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
|
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/`
|
Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/EJClaw/dist/`
|
||||||
|
|
||||||
## Service Stack Architecture
|
## Service Stack Architecture
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ journalctl --user -u ejclaw-codex -f
|
|||||||
journalctl --user -u ejclaw-review -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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import path from 'path';
|
|||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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', () => {
|
describe('restartStackServices', () => {
|
||||||
const tempRoots: string[] = [];
|
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-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||||
tempRoots.push(tempRoot);
|
tempRoots.push(tempRoot);
|
||||||
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
|
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
|
||||||
@@ -27,6 +30,33 @@ describe('restartStackServices', () => {
|
|||||||
execFileSyncImpl,
|
execFileSyncImpl,
|
||||||
runningAsRoot: false,
|
runningAsRoot: false,
|
||||||
serviceManager: 'systemd',
|
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']);
|
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');
|
).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';
|
import { getConfiguredServiceNames } from './service-defs.js';
|
||||||
|
|
||||||
type ServiceManager = ReturnType<typeof getServiceManager>;
|
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 {
|
interface RestartStackDeps {
|
||||||
execFileSyncImpl?: typeof execFileSync;
|
execFileSyncImpl?: typeof execFileSync;
|
||||||
runningAsRoot?: boolean;
|
runningAsRoot?: boolean;
|
||||||
serviceManager?: ServiceManager;
|
serviceManager?: ServiceManager;
|
||||||
|
direct?: boolean;
|
||||||
|
serviceId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restartStackServices(
|
function restartStackServicesDirect(
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
deps: RestartStackDeps = {},
|
deps: RestartStackDeps = {},
|
||||||
): string[] {
|
): string[] {
|
||||||
@@ -44,9 +51,73 @@ export function restartStackServices(
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run(_args: string[]): Promise<void> {
|
export function restartStackServices(
|
||||||
const services = restartStackServices(process.cwd());
|
projectRoot: string,
|
||||||
console.log(`Restarted and verified: ${services.join(', ')}`);
|
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]) {
|
if (process.argv[1]) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from 'os';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildStackRestartSystemdUnit } from './service.js';
|
||||||
import { getServiceDefs } from './service-defs.js';
|
import { getServiceDefs } from './service-defs.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,4 +203,18 @@ describe('service definitions', () => {
|
|||||||
'ejclaw-review',
|
'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,
|
getServiceManager,
|
||||||
isRoot,
|
isRoot,
|
||||||
} from './platform.js';
|
} from './platform.js';
|
||||||
|
import { STACK_RESTART_UNIT_NAME } from './restart-stack.js';
|
||||||
import { getServiceDefs, type ServiceDef } from './service-defs.js';
|
import { getServiceDefs, type ServiceDef } from './service-defs.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
|
|
||||||
@@ -256,6 +257,7 @@ function setupSystemdAll(
|
|||||||
for (const def of serviceDefs) {
|
for (const def of serviceDefs) {
|
||||||
setupSystemdUnit(def, projectRoot, nodePath, homeDir, runningAsRoot);
|
setupSystemdUnit(def, projectRoot, nodePath, homeDir, runningAsRoot);
|
||||||
}
|
}
|
||||||
|
setupSystemdStackRestartUnit(projectRoot, nodePath, homeDir, runningAsRoot);
|
||||||
|
|
||||||
// Reload daemon once after all units are written
|
// Reload daemon once after all units are written
|
||||||
try {
|
try {
|
||||||
@@ -366,6 +368,50 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
|||||||
logger.info({ unitPath, service: def.name }, 'Wrote systemd unit');
|
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) */
|
/* nohup fallback (WSL / no systemd) */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
Reference in New Issue
Block a user