fix: harden stack restart migration

This commit is contained in:
Eyejoker
2026-03-29 07:33:13 +09:00
parent ca578d1627
commit 3dd41f749e
6 changed files with 259 additions and 6 deletions

View File

@@ -4,7 +4,10 @@ import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { restartStackServices } from './restart-stack.js';
import {
STACK_RESTART_UNIT_NAME,
restartStackServices,
} from './restart-stack.js';
describe('restartStackServices', () => {
const tempRoots: string[] = [];
@@ -15,7 +18,7 @@ describe('restartStackServices', () => {
}
});
it('restarts and verifies the configured three-service stack', () => {
it('dispatches stack restart through the oneshot unit by default', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
@@ -27,6 +30,33 @@ describe('restartStackServices', () => {
execFileSyncImpl,
runningAsRoot: false,
serviceManager: 'systemd',
serviceId: null,
});
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
1,
'systemctl',
['--user', 'start', '--wait', STACK_RESTART_UNIT_NAME],
{ stdio: 'ignore' },
);
expect(execFileSyncImpl).toHaveBeenCalledTimes(1);
});
it('restarts and verifies the configured three-service stack in direct mode', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n');
const execFileSyncImpl = vi.fn();
const services = restartStackServices(tempRoot, {
direct: true,
execFileSyncImpl,
runningAsRoot: false,
serviceManager: 'systemd',
serviceId: null,
});
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
@@ -66,4 +96,89 @@ describe('restartStackServices', () => {
}),
).toThrow('restart:stack only supports Linux systemd services in this repo');
});
it('falls back to direct restart when the oneshot unit is not installed for an external caller', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n');
const execFileSyncImpl = vi
.fn()
.mockImplementationOnce(() => {
const error = new Error('unit missing');
Object.assign(error, {
stderr: 'Unit ejclaw-stack-restart.service not found.',
});
throw error;
});
const services = restartStackServices(tempRoot, {
execFileSyncImpl,
runningAsRoot: false,
serviceManager: 'systemd',
serviceId: null,
});
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
1,
'systemctl',
['--user', 'start', '--wait', STACK_RESTART_UNIT_NAME],
{ stdio: 'ignore' },
);
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
2,
'systemctl',
['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'],
{ stdio: 'ignore' },
);
});
it('rejects direct fallback when the unit is missing for a managed service caller', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
const execFileSyncImpl = vi.fn().mockImplementation(() => {
const error = new Error('unit missing');
Object.assign(error, {
stderr: 'Unit ejclaw-stack-restart.service not found.',
});
throw error;
});
expect(() =>
restartStackServices(tempRoot, {
execFileSyncImpl,
runningAsRoot: false,
serviceManager: 'systemd',
serviceId: 'codex-main',
}),
).toThrow('Run `npm run setup -- --step service`');
});
it('does not hide a general start failure behind direct fallback', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
const execFileSyncImpl = vi.fn().mockImplementation(() => {
const error = new Error('job failed');
Object.assign(error, {
stderr: 'Job for ejclaw-stack-restart.service failed because the control process exited with error code.',
});
throw error;
});
expect(() =>
restartStackServices(tempRoot, {
execFileSyncImpl,
runningAsRoot: false,
serviceManager: 'systemd',
serviceId: null,
}),
).toThrow('job failed');
expect(execFileSyncImpl).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,14 +5,21 @@ import { getServiceManager, isRoot } from './platform.js';
import { getConfiguredServiceNames } from './service-defs.js';
type ServiceManager = ReturnType<typeof getServiceManager>;
export const STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service';
const UNIT_NOT_FOUND_PATTERN =
/(Unit .* not found|Could not find the requested service|not-found)/i;
const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE =
'Stack restart unit is not installed yet. Run `npm run setup -- --step service` from an external shell before retrying from a managed EJClaw service.';
interface RestartStackDeps {
execFileSyncImpl?: typeof execFileSync;
runningAsRoot?: boolean;
serviceManager?: ServiceManager;
direct?: boolean;
serviceId?: string | null;
}
export function restartStackServices(
function restartStackServicesDirect(
projectRoot: string,
deps: RestartStackDeps = {},
): string[] {
@@ -44,9 +51,73 @@ export function restartStackServices(
return services;
}
export async function run(_args: string[]): Promise<void> {
const services = restartStackServices(process.cwd());
console.log(`Restarted and verified: ${services.join(', ')}`);
export function restartStackServices(
projectRoot: string,
deps: RestartStackDeps = {},
): string[] {
const serviceManager = deps.serviceManager ?? getServiceManager();
if (serviceManager !== 'systemd') {
throw new Error(
'restart:stack only supports Linux systemd services in this repo',
);
}
const services = getConfiguredServiceNames(projectRoot);
const execImpl = deps.execFileSyncImpl ?? execFileSync;
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user'];
if (deps.direct) {
return restartStackServicesDirect(projectRoot, deps);
}
try {
execImpl(
'systemctl',
[...systemctlArgs, 'start', '--wait', STACK_RESTART_UNIT_NAME],
{
stdio: 'ignore',
},
);
} catch (error) {
const stderr =
error && typeof error === 'object' && 'stderr' in error
? String((error as { stderr?: unknown }).stderr || '')
: '';
const stdout =
error && typeof error === 'object' && 'stdout' in error
? String((error as { stdout?: unknown }).stdout || '')
: '';
const message =
error instanceof Error ? error.message : `${stdout}\n${stderr}`.trim();
const combined = `${message}\n${stdout}\n${stderr}`;
const unitMissing = UNIT_NOT_FOUND_PATTERN.test(combined);
const callerServiceId =
deps.serviceId === undefined ? process.env.SERVICE_ID : deps.serviceId;
const managedServiceCaller = Boolean(callerServiceId);
if (!unitMissing) {
throw error;
}
if (managedServiceCaller) {
throw new Error(MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE);
}
return restartStackServicesDirect(projectRoot, deps);
}
return services;
}
export async function run(args: string[]): Promise<void> {
const direct = args.includes('--direct');
const services = restartStackServices(process.cwd(), { direct });
if (direct) {
console.log(`Restarted and verified: ${services.join(', ')}`);
return;
}
console.log(
`Restarted and verified via ${STACK_RESTART_UNIT_NAME}: ${services.join(', ')}`,
);
}
if (process.argv[1]) {

View File

@@ -3,6 +3,7 @@ import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { buildStackRestartSystemdUnit } from './service.js';
import { getServiceDefs } from './service-defs.js';
/**
@@ -202,4 +203,18 @@ describe('service definitions', () => {
'ejclaw-review',
]);
});
it('generates a oneshot stack restart unit', () => {
const unit = buildStackRestartSystemdUnit(
'/srv/ejclaw',
'/usr/bin/node',
'/home/user',
);
expect(unit).toContain('Description=EJClaw Stack Restart Orchestrator');
expect(unit).toContain('Type=oneshot');
expect(unit).toContain(
'ExecStart=/bin/bash /srv/ejclaw/scripts/restart-ejclaw-stack.sh --direct',
);
});
});

View File

@@ -19,6 +19,7 @@ import {
getServiceManager,
isRoot,
} from './platform.js';
import { STACK_RESTART_UNIT_NAME } from './restart-stack.js';
import { getServiceDefs, type ServiceDef } from './service-defs.js';
import { emitStatus } from './status.js';
@@ -256,6 +257,7 @@ function setupSystemdAll(
for (const def of serviceDefs) {
setupSystemdUnit(def, projectRoot, nodePath, homeDir, runningAsRoot);
}
setupSystemdStackRestartUnit(projectRoot, nodePath, homeDir, runningAsRoot);
// Reload daemon once after all units are written
try {
@@ -366,6 +368,50 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
logger.info({ unitPath, service: def.name }, 'Wrote systemd unit');
}
export function buildStackRestartSystemdUnit(
projectRoot: string,
nodePath: string,
homeDir: string,
): string {
const envLines = [
`Environment=HOME=${homeDir}`,
`Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin`,
];
return `[Unit]
Description=EJClaw Stack Restart Orchestrator
After=network.target
[Service]
Type=oneshot
WorkingDirectory=${projectRoot}
${envLines.join('\n')}
ExecStart=/bin/bash ${projectRoot}/scripts/restart-ejclaw-stack.sh --direct
`;
}
function setupSystemdStackRestartUnit(
projectRoot: string,
nodePath: string,
homeDir: string,
runningAsRoot: boolean,
): void {
const unitPath = getUnitPath(
STACK_RESTART_UNIT_NAME.replace(/\.service$/, ''),
homeDir,
runningAsRoot,
);
fs.mkdirSync(path.dirname(unitPath), { recursive: true });
fs.writeFileSync(
unitPath,
buildStackRestartSystemdUnit(projectRoot, nodePath, homeDir),
);
logger.info(
{ unitPath, service: STACK_RESTART_UNIT_NAME },
'Wrote stack restart systemd unit',
);
}
/* ------------------------------------------------------------------ */
/* nohup fallback (WSL / no systemd) */
/* ------------------------------------------------------------------ */