mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(runner): restart when hub identity changes (#303)
* fix(runner): restart when hub identity changes * fix(runner): fail closed on missing identity
This commit is contained in:
@@ -35,6 +35,9 @@ export interface RunnerLocallyPersistedState {
|
||||
startTime: string;
|
||||
startedWithCliVersion: string;
|
||||
startedWithCliMtimeMs?: number;
|
||||
startedWithApiUrl?: string;
|
||||
startedWithMachineId?: string;
|
||||
startedWithCliApiTokenHash?: string;
|
||||
lastHeartbeat?: string;
|
||||
runnerLogPath?: string;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
*/
|
||||
|
||||
import { logger } from '@/ui/logger';
|
||||
import { clearRunnerState, readRunnerState } from '@/persistence';
|
||||
import { clearRunnerState, readRunnerState, readSettings } from '@/persistence';
|
||||
import { Metadata } from '@/api/types';
|
||||
import packageJson from '../../package.json';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { isBunCompiled, projectPath } from '@/projectPath';
|
||||
import { isProcessAlive, killProcess } from '@/utils/process';
|
||||
import { configuration } from '@/configuration';
|
||||
import { hashRunnerCliApiToken, isRunnerStateCompatibleWithIdentity } from './runnerIdentity';
|
||||
|
||||
export function getInstalledCliMtimeMs(): number | undefined {
|
||||
if (isBunCompiled()) {
|
||||
@@ -171,17 +173,44 @@ export async function isRunnerRunningCurrentlyInstalledHappyVersion(): Promise<b
|
||||
logger.debug('[RUNNER CONTROL] No runner state found, returning false');
|
||||
return false;
|
||||
}
|
||||
|
||||
const settings = await readSettings();
|
||||
const currentApiUrl = process.env.HAPI_API_URL
|
||||
|| settings.apiUrl
|
||||
|| settings.serverUrl
|
||||
|| configuration.apiUrl;
|
||||
const currentCliApiToken = process.env.CLI_API_TOKEN
|
||||
|| settings.cliApiToken
|
||||
|| configuration.cliApiToken;
|
||||
const currentMachineId = settings.machineId;
|
||||
|
||||
try {
|
||||
const currentCliMtimeMs = getInstalledCliMtimeMs();
|
||||
if (typeof currentCliMtimeMs === 'number' && typeof state.startedWithCliMtimeMs === 'number') {
|
||||
logger.debug(`[RUNNER CONTROL] Current CLI mtime: ${currentCliMtimeMs}, Runner started with mtime: ${state.startedWithCliMtimeMs}`);
|
||||
return currentCliMtimeMs === state.startedWithCliMtimeMs;
|
||||
if (currentCliMtimeMs !== state.startedWithCliMtimeMs) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const currentCliVersion = packageJson.version;
|
||||
logger.debug(`[RUNNER CONTROL] Current CLI version: ${currentCliVersion}, Runner started with version: ${state.startedWithCliVersion}`);
|
||||
if (currentCliVersion !== state.startedWithCliVersion) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const currentCliVersion = packageJson.version;
|
||||
logger.debug(`[RUNNER CONTROL] Current CLI version: ${currentCliVersion}, Runner started with version: ${state.startedWithCliVersion}`);
|
||||
return currentCliVersion === state.startedWithCliVersion;
|
||||
const currentIdentityMatches = isRunnerStateCompatibleWithIdentity(state, {
|
||||
apiUrl: currentApiUrl,
|
||||
machineId: currentMachineId,
|
||||
cliApiTokenHash: hashRunnerCliApiToken(currentCliApiToken)
|
||||
});
|
||||
logger.debug(`[RUNNER CONTROL] Runner identity match: ${currentIdentityMatches}`, {
|
||||
currentApiUrl,
|
||||
currentMachineId,
|
||||
runnerStartedWithApiUrl: state.startedWithApiUrl,
|
||||
runnerStartedWithMachineId: state.startedWithMachineId
|
||||
});
|
||||
return currentIdentityMatches;
|
||||
|
||||
// PREVIOUS IMPLEMENTATION - Keeping this commented in case we need it
|
||||
// Kirill does not understand how the upgrade of npm packages happen and whether
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RunnerState, Metadata } from '@/api/types';
|
||||
import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/rpcTypes';
|
||||
import { logger } from '@/ui/logger';
|
||||
import { authAndSetupMachineIfNeeded } from '@/ui/auth';
|
||||
import { configuration } from '@/configuration';
|
||||
import packageJson from '../../package.json';
|
||||
import { getEnvironmentInfo } from '@/ui/doctor';
|
||||
import { spawnHappyCLI } from '@/utils/spawnHappyCLI';
|
||||
@@ -20,6 +21,7 @@ import { startRunnerControlServer } from './controlServer';
|
||||
import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree';
|
||||
import { join } from 'path';
|
||||
import { buildMachineMetadata } from '@/agent/sessionFactory';
|
||||
import { hashRunnerCliApiToken } from './runnerIdentity';
|
||||
|
||||
export async function startRunner(): Promise<void> {
|
||||
// We don't have cleanup function at the time of server construction
|
||||
@@ -629,6 +631,9 @@ export async function startRunner(): Promise<void> {
|
||||
startTime: new Date().toLocaleString(),
|
||||
startedWithCliVersion: packageJson.version,
|
||||
startedWithCliMtimeMs,
|
||||
startedWithApiUrl: configuration.apiUrl,
|
||||
startedWithMachineId: machineId,
|
||||
startedWithCliApiTokenHash: hashRunnerCliApiToken(configuration.cliApiToken),
|
||||
runnerLogPath: logger.logFilePath
|
||||
};
|
||||
writeRunnerState(fileState);
|
||||
@@ -788,6 +793,9 @@ export async function startRunner(): Promise<void> {
|
||||
startTime: fileState.startTime,
|
||||
startedWithCliVersion: packageJson.version,
|
||||
startedWithCliMtimeMs,
|
||||
startedWithApiUrl: fileState.startedWithApiUrl,
|
||||
startedWithMachineId: fileState.startedWithMachineId,
|
||||
startedWithCliApiTokenHash: fileState.startedWithCliApiTokenHash,
|
||||
lastHeartbeat: new Date().toLocaleString(),
|
||||
runnerLogPath: fileState.runnerLogPath
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hashRunnerCliApiToken, isRunnerStateCompatibleWithIdentity } from './runnerIdentity'
|
||||
|
||||
describe('runnerIdentity', () => {
|
||||
it('matches when api url, machine id, token hash all same', () => {
|
||||
const tokenHash = hashRunnerCliApiToken('secret-token')
|
||||
|
||||
expect(isRunnerStateCompatibleWithIdentity(
|
||||
{
|
||||
startedWithApiUrl: 'http://example.com',
|
||||
startedWithMachineId: 'machine-123',
|
||||
startedWithCliApiTokenHash: tokenHash
|
||||
},
|
||||
{
|
||||
apiUrl: 'http://example.com',
|
||||
machineId: 'machine-123',
|
||||
cliApiTokenHash: tokenHash
|
||||
}
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects reused runner when api url changed', () => {
|
||||
expect(isRunnerStateCompatibleWithIdentity(
|
||||
{
|
||||
startedWithApiUrl: 'http://old-hub',
|
||||
startedWithMachineId: 'machine-123',
|
||||
startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token')
|
||||
},
|
||||
{
|
||||
apiUrl: 'http://new-hub',
|
||||
machineId: 'machine-123',
|
||||
cliApiTokenHash: hashRunnerCliApiToken('secret-token')
|
||||
}
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects reused runner when token changed', () => {
|
||||
expect(isRunnerStateCompatibleWithIdentity(
|
||||
{
|
||||
startedWithApiUrl: 'http://example.com',
|
||||
startedWithMachineId: 'machine-123',
|
||||
startedWithCliApiTokenHash: hashRunnerCliApiToken('old-token')
|
||||
},
|
||||
{
|
||||
apiUrl: 'http://example.com',
|
||||
machineId: 'machine-123',
|
||||
cliApiTokenHash: hashRunnerCliApiToken('new-token')
|
||||
}
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects reused runner when current machine id is missing', () => {
|
||||
expect(isRunnerStateCompatibleWithIdentity(
|
||||
{
|
||||
startedWithApiUrl: 'http://example.com',
|
||||
startedWithMachineId: 'machine-123',
|
||||
startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token')
|
||||
},
|
||||
{
|
||||
apiUrl: 'http://example.com',
|
||||
cliApiTokenHash: hashRunnerCliApiToken('secret-token')
|
||||
}
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects reused runner when current token hash is missing', () => {
|
||||
expect(isRunnerStateCompatibleWithIdentity(
|
||||
{
|
||||
startedWithApiUrl: 'http://example.com',
|
||||
startedWithMachineId: 'machine-123',
|
||||
startedWithCliApiTokenHash: hashRunnerCliApiToken('secret-token')
|
||||
},
|
||||
{
|
||||
apiUrl: 'http://example.com',
|
||||
machineId: 'machine-123'
|
||||
}
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects old runner state missing connection identity', () => {
|
||||
expect(isRunnerStateCompatibleWithIdentity(
|
||||
{},
|
||||
{
|
||||
apiUrl: 'http://example.com',
|
||||
machineId: 'machine-123',
|
||||
cliApiTokenHash: hashRunnerCliApiToken('secret-token')
|
||||
}
|
||||
)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { RunnerLocallyPersistedState } from '@/persistence'
|
||||
|
||||
export type RunnerConnectionIdentity = {
|
||||
apiUrl: string
|
||||
machineId?: string
|
||||
cliApiTokenHash?: string
|
||||
}
|
||||
|
||||
export function hashRunnerCliApiToken(token: string | null | undefined): string | undefined {
|
||||
const trimmed = token?.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
return createHash('sha256').update(trimmed).digest('hex')
|
||||
}
|
||||
|
||||
export function isRunnerStateCompatibleWithIdentity(
|
||||
state: Pick<
|
||||
RunnerLocallyPersistedState,
|
||||
'startedWithApiUrl' | 'startedWithMachineId' | 'startedWithCliApiTokenHash'
|
||||
>,
|
||||
current: RunnerConnectionIdentity
|
||||
): boolean {
|
||||
if (!state.startedWithApiUrl || state.startedWithApiUrl !== current.apiUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!current.machineId || state.startedWithMachineId !== current.machineId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!current.cliApiTokenHash || state.startedWithCliApiTokenHash !== current.cliApiTokenHash) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user