fix: harden stack restart migration
This commit is contained in:
@@ -51,6 +51,9 @@ systemctl --user status ejclaw # Check status
|
||||
journalctl --user -u ejclaw -f # Follow logs
|
||||
```
|
||||
|
||||
`npm run restart:stack` is an external deploy/operator entrypoint. Do not invoke it from inside a managed EJClaw service process.
|
||||
If you pulled this slice onto an existing install, run `npm run setup -- --step service` once so `ejclaw-stack-restart.service` is installed.
|
||||
|
||||
Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/EJClaw/dist/`
|
||||
|
||||
## Service Stack Architecture
|
||||
|
||||
@@ -117,6 +117,9 @@ journalctl --user -u ejclaw-codex -f
|
||||
journalctl --user -u ejclaw-review -f
|
||||
```
|
||||
|
||||
Use `npm run restart:stack` from an external shell or deploy wrapper, not from inside one of the managed EJClaw services.
|
||||
On existing installs, run `npm run setup -- --step service` once after pulling this slice so the `ejclaw-stack-restart.service` unit is installed.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user