@zgeoff/atc 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +4 -3
- package/src/build-own-daemon-process.ts +16 -0
- package/src/build-restart-failure.ts +24 -0
- package/src/cli.ts +96 -0
- package/src/client/boot-daemon.ts +5 -32
- package/src/collect-restart-env.ts +18 -0
- package/src/collect-restart-plan.ts +157 -0
- package/src/daemon/daemon.ts +7 -3
- package/src/daemon-process.ts +12 -0
- package/src/find-restart-unit.ts +45 -0
- package/src/format-restart-preflight.ts +74 -0
- package/src/format-restart-report.ts +45 -0
- package/src/is-daemon-command-line.ts +33 -0
- package/src/is-daemon-process.ts +33 -0
- package/src/parse-daemon-flags.ts +33 -0
- package/src/parse-mismatch-build.ts +19 -0
- package/src/parse-restart-result.ts +65 -0
- package/src/parse-systemd-unit.ts +25 -0
- package/src/read-daemon-process.ts +50 -0
- package/src/read-fleet-snapshot.ts +37 -0
- package/src/read-restart-result.ts +17 -0
- package/src/read-stored-fleet-file.ts +74 -0
- package/src/read-stored-rows.ts +36 -0
- package/src/remove-restart-result.ts +11 -0
- package/src/restart-daemon.ts +149 -0
- package/src/restart-plan.ts +33 -0
- package/src/run-daemon-restart-worker.ts +77 -0
- package/src/run-daemon-restart.ts +228 -0
- package/src/run-systemctl.ts +33 -0
- package/src/shared/build-atc-command.ts +13 -0
- package/src/shared/config.ts +5 -0
- package/src/shared/find-pid-file-pid.ts +15 -0
- package/src/shared/get-build.ts +4 -3
- package/src/shared/is-process-alive.ts +21 -0
- package/src/shared/spawn-atc-detached.ts +34 -0
- package/src/start-replacement-daemon.ts +31 -0
- package/src/stop-daemon-process.ts +46 -0
- package/src/store/state-store.ts +2 -2
- package/src/test-utils/can-bind-addresses.ts +19 -0
- package/src/test-utils/create-migrated-state-db.ts +38 -0
- package/src/test-utils/env-originals.ts +6 -0
- package/src/test-utils/fixture-dir-provider.ts +131 -0
- package/src/test-utils/fixture-imp-port.ts +1692 -0
- package/src/test-utils/get-agent-entry.ts +17 -0
- package/src/test-utils/get-gateway-config.ts +18 -0
- package/src/test-utils/isolate-home.ts +60 -0
- package/src/test-utils/read-json-record.ts +20 -0
- package/src/test-utils/remove-env-overrides.ts +18 -0
- package/src/test-utils/run-legacy-daemon.ts +38 -0
- package/src/test-utils/run-listener-daemon.ts +51 -0
- package/src/test-utils/run-mcp-authorization.ts +118 -0
- package/src/test-utils/run-source-daemon.ts +110 -0
- package/src/test-utils/setup-fake-systemd.ts +125 -0
- package/src/test-utils/setup-mcp-http.ts +126 -0
- package/src/test-utils/setup-temp-dir.ts +33 -0
- package/src/test-utils/spawn-named-session.ts +27 -0
- package/src/test-utils/start-cut-proxy.ts +175 -0
- package/src/test-utils/start-git-http-server.ts +102 -0
- package/src/test-utils/start-legacy-daemon.ts +111 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +60 -0
- package/src/test-utils/update-env.ts +19 -0
- package/src/test-utils/wait-for.ts +38 -0
- package/src/verify-restored-fleet.ts +155 -0
- package/src/wait-for-replacement.ts +112 -0
- package/src/write-restart-result.ts +17 -0
- /package/src/{daemon → shared}/claim-daemon-lock.ts +0 -0
- /package/src/{daemon → shared}/load-listener-tokens.ts +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a command line starts the atc daemon: the atc entry (the `atc`
|
|
5
|
+
* shim or a compiled `atc` binary as the program, or the CLI source file as
|
|
6
|
+
* the runtime's script) directly followed by the `daemon` subcommand, and
|
|
7
|
+
* then nothing, a flag, or `serve`. Any other program that merely passes a
|
|
8
|
+
* `daemon` argument is not the daemon.
|
|
9
|
+
*/
|
|
10
|
+
export function isDaemonCommandLine(args: readonly string[]): boolean {
|
|
11
|
+
const at = args.indexOf('daemon');
|
|
12
|
+
|
|
13
|
+
if (at !== 1 && at !== 2) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const entry = args[at - 1] ?? '';
|
|
18
|
+
const next = args[at + 1];
|
|
19
|
+
|
|
20
|
+
return isATCEntry(entry, at) && (next === undefined || next === 'serve' || next.startsWith('--'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The program itself when it sits first, or the script a runtime loads when
|
|
24
|
+
// it sits second.
|
|
25
|
+
function isATCEntry(entry: string, at: number): boolean {
|
|
26
|
+
const name = basename(entry);
|
|
27
|
+
|
|
28
|
+
if (at === 1 && /^atc(?:-[a-z0-9]+)*$/.test(name)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return at === 2 && (entry.endsWith('/src/cli.ts') || entry.endsWith('/bin/atc'));
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { userInfo } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isDaemonCommandLine } from './is-daemon-command-line';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether the process with this pid runs `atc daemon` on this state
|
|
8
|
+
* directory: its command line starts the daemon, and the home in its
|
|
9
|
+
* environment puts its state directory at `stateDir`. A daemon of another
|
|
10
|
+
* checkout or another test home is not this one. A pid that cannot be read
|
|
11
|
+
* is not verified, so it reads as false: a recorded pid may have been reused
|
|
12
|
+
* by an unrelated process since the daemon died.
|
|
13
|
+
*/
|
|
14
|
+
export function isDaemonProcess(pid: number, stateDir: string): boolean {
|
|
15
|
+
let cmdline: string;
|
|
16
|
+
let environ: string;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
20
|
+
environ = readFileSync(`/proc/${pid}/environ`, 'utf8');
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!isDaemonCommandLine(cmdline.split('\0').filter((arg) => arg !== ''))) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const homeVar = environ.split('\0').find((entry) => entry.startsWith('HOME='));
|
|
30
|
+
const home = homeVar === undefined || homeVar === 'HOME=' ? userInfo().homedir : homeVar.slice(5);
|
|
31
|
+
|
|
32
|
+
return join(home, '.local', 'state', 'atc') === stateDir;
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface DaemonFlags {
|
|
2
|
+
readonly listen: string | null;
|
|
3
|
+
readonly tokenFile: string | null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `--listen` and `--token-file` values on a daemon's command line, read
|
|
8
|
+
* from the NUL-separated text of `/proc/<pid>/cmdline`. Both the
|
|
9
|
+
* `--flag value` and `--flag=value` forms count; a flag the line lacks reads
|
|
10
|
+
* as null.
|
|
11
|
+
*/
|
|
12
|
+
export function parseDaemonFlags(cmdline: string): DaemonFlags {
|
|
13
|
+
const args = cmdline.split('\0').filter((arg) => arg !== '');
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
listen: findFlagValue(args, '--listen'),
|
|
17
|
+
tokenFile: findFlagValue(args, '--token-file'),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function findFlagValue(args: readonly string[], flag: string): string | null {
|
|
22
|
+
for (const [i, arg] of args.entries()) {
|
|
23
|
+
if (arg === flag) {
|
|
24
|
+
return args[i + 1] ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (arg.startsWith(`${flag}=`)) {
|
|
28
|
+
return arg.slice(flag.length + 1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface MismatchBuild {
|
|
2
|
+
readonly build: string;
|
|
3
|
+
readonly protocol: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The daemon's build and protocol version in its `protocol_mismatch`
|
|
8
|
+
* refusal text (`daemon <build> speaks v<N>`), or null when the text does
|
|
9
|
+
* not match that wording.
|
|
10
|
+
*/
|
|
11
|
+
export function parseMismatchBuild(text: string): MismatchBuild | null {
|
|
12
|
+
const match = /\bdaemon (?<build>\S+) speaks v(?<protocol>\d+)/.exec(text);
|
|
13
|
+
|
|
14
|
+
if (match === null) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { build: match.groups?.['build'] ?? '', protocol: Number(match.groups?.['protocol']) };
|
|
19
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export interface RestartFailedRow {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly reason: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface RestartInterruptedRow {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly id: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a finished restart reports: the daemon now serving, how much of the
|
|
16
|
+
* fleet came back, the rows that did not, and the sessions the restart
|
|
17
|
+
* interrupted. `code` is the exit code of the restart, and `error` holds the
|
|
18
|
+
* reason the restart stopped before it could count the fleet.
|
|
19
|
+
*/
|
|
20
|
+
export interface RestartResult {
|
|
21
|
+
readonly runID: string;
|
|
22
|
+
readonly code: number;
|
|
23
|
+
readonly pid: number | null;
|
|
24
|
+
readonly build: string | null;
|
|
25
|
+
readonly listenPort: number | null;
|
|
26
|
+
readonly restored: number;
|
|
27
|
+
readonly total: number;
|
|
28
|
+
readonly failed: readonly RestartFailedRow[];
|
|
29
|
+
readonly interrupted: readonly RestartInterruptedRow[];
|
|
30
|
+
readonly error: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FAILED_ROW_SCHEMA = z.object({ name: z.string(), id: z.string(), reason: z.string() });
|
|
34
|
+
const INTERRUPTED_ROW_SCHEMA = z.object({ name: z.string(), id: z.string() });
|
|
35
|
+
|
|
36
|
+
const RESULT_SCHEMA = z.object({
|
|
37
|
+
runID: z.string(),
|
|
38
|
+
code: z.number().int(),
|
|
39
|
+
pid: z.number().int().nullable(),
|
|
40
|
+
build: z.string().nullable(),
|
|
41
|
+
listenPort: z.number().int().nullable(),
|
|
42
|
+
restored: z.number().int(),
|
|
43
|
+
total: z.number().int(),
|
|
44
|
+
failed: z.array(FAILED_ROW_SCHEMA),
|
|
45
|
+
interrupted: z.array(INTERRUPTED_ROW_SCHEMA),
|
|
46
|
+
error: z.string().nullable(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads a restart result from a line of JSON, or null when the line holds
|
|
51
|
+
* anything else.
|
|
52
|
+
*/
|
|
53
|
+
export function parseRestartResult(line: string): RestartResult | null {
|
|
54
|
+
let raw: unknown;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(line);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parsed = RESULT_SCHEMA.safeParse(raw);
|
|
63
|
+
|
|
64
|
+
return parsed.success ? parsed.data : null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name of the user service a process runs in, read from its
|
|
3
|
+
* `/proc/<pid>/cgroup` text: the first `<name>.service` below
|
|
4
|
+
* `user@<uid>.service`. A process in a login session, a scope, or a system
|
|
5
|
+
* service reads as null, since a user manager cannot restart it.
|
|
6
|
+
*/
|
|
7
|
+
export function parseSystemdUnit(cgroupText: string, uid: number): string | null {
|
|
8
|
+
for (const line of cgroupText.split('\n')) {
|
|
9
|
+
const path = line.slice(line.indexOf(':', line.indexOf(':') + 1) + 1);
|
|
10
|
+
const segments = path.split('/').filter((segment) => segment !== '');
|
|
11
|
+
const manager = segments.indexOf(`user@${uid}.service`);
|
|
12
|
+
|
|
13
|
+
if (manager === -1) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const unit = segments.slice(manager + 1).find((segment) => segment.endsWith('.service'));
|
|
18
|
+
|
|
19
|
+
if (unit !== undefined) {
|
|
20
|
+
return unit;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync, readlinkSync, statSync } from 'node:fs';
|
|
2
|
+
import { buildOwnDaemonProcess } from './build-own-daemon-process';
|
|
3
|
+
import { collectRestartEnv } from './collect-restart-env';
|
|
4
|
+
import type { DaemonProcess } from './daemon-process';
|
|
5
|
+
import { parseDaemonFlags } from './parse-daemon-flags';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads what a running daemon was started with from `/proc/<pid>`: its
|
|
9
|
+
* environment, its working directory, and the listener flags on its command
|
|
10
|
+
* line. Where `/proc` has no entry for it, as on macOS, the environment is
|
|
11
|
+
* this process's own and the flags are empty.
|
|
12
|
+
*/
|
|
13
|
+
export function readDaemonProcess(pid: number): DaemonProcess {
|
|
14
|
+
let environ: string;
|
|
15
|
+
let cmdline: string;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
environ = readFileSync(`/proc/${pid}/environ`, 'utf8');
|
|
19
|
+
cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
20
|
+
} catch {
|
|
21
|
+
return buildOwnDaemonProcess();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const env: Record<string, string> = {};
|
|
25
|
+
|
|
26
|
+
for (const entry of environ.split('\0')) {
|
|
27
|
+
const eq = entry.indexOf('=');
|
|
28
|
+
|
|
29
|
+
if (eq > 0) {
|
|
30
|
+
env[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
env: collectRestartEnv(env),
|
|
36
|
+
cwd: readCwd(pid),
|
|
37
|
+
flags: parseDaemonFlags(cmdline),
|
|
38
|
+
fromProc: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readCwd(pid: number): string | null {
|
|
43
|
+
try {
|
|
44
|
+
const cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
45
|
+
|
|
46
|
+
return statSync(cwd).isDirectory() ? cwd : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { DaemonClient } from './client/daemon-client';
|
|
2
|
+
import { readStoredRows } from './read-stored-rows';
|
|
3
|
+
import type { StoredRow } from './read-stored-rows';
|
|
4
|
+
import { getBuild } from './shared/get-build';
|
|
5
|
+
|
|
6
|
+
const SNAPSHOT_DEADLINE_MS = 3000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reads the stored fleet rows from the daemon at this socket, or null when
|
|
10
|
+
* it does not answer in time. The restart takes this before it stops the
|
|
11
|
+
* daemon, so the rows keep their original exit status.
|
|
12
|
+
*/
|
|
13
|
+
export async function readFleetSnapshot(socketPath: string): Promise<StoredRow[] | null> {
|
|
14
|
+
let client: DaemonClient;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
client = await DaemonClient.open(socketPath);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const expiry = setTimeout(() => {
|
|
23
|
+
client.stop();
|
|
24
|
+
}, SNAPSHOT_DEADLINE_MS);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await client.sendHello(getBuild());
|
|
28
|
+
|
|
29
|
+
return await readStoredRows(client);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(expiry);
|
|
34
|
+
|
|
35
|
+
client.stop();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseRestartResult } from './parse-restart-result';
|
|
4
|
+
import type { RestartResult } from './parse-restart-result';
|
|
5
|
+
import { restartsDir } from './shared/config';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The result of the last finished restart, or null when none was written or
|
|
9
|
+
* the file holds anything else.
|
|
10
|
+
*/
|
|
11
|
+
export function readRestartResult(): RestartResult | null {
|
|
12
|
+
try {
|
|
13
|
+
return parseRestartResult(readFileSync(join(restartsDir, 'last.json'), 'utf8').trim());
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import type { StoredRow } from './read-stored-rows';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads the stored fleet rows straight from the state database, read-only,
|
|
6
|
+
* for a daemon whose protocol this build cannot speak. Each column is read
|
|
7
|
+
* under the name the file's schema gives it, and one the schema lacks reads
|
|
8
|
+
* as its default, so an older daemon's database reads without a migration. On a schema that records
|
|
9
|
+
* session ownership, only the rows this state directory's daemon owns are
|
|
10
|
+
* read, as the daemon's own fleet list reads them. Returns null when the
|
|
11
|
+
* file is missing or cannot be read.
|
|
12
|
+
*/
|
|
13
|
+
export function readStoredFleetFile(dbPath: string): StoredRow[] | null {
|
|
14
|
+
let db: Database;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
db = new Database(dbPath, { readonly: true });
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const columns = new Set(
|
|
24
|
+
db
|
|
25
|
+
.query<{ name: string }, []>('PRAGMA table_info(fleet)')
|
|
26
|
+
.all()
|
|
27
|
+
.map((column) => column.name),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// Before the store keyed rows by atc session id, a row held only the
|
|
31
|
+
// agent's session id, under one of two names; the new daemon assigns
|
|
32
|
+
// the atc id as it migrates, so such a row is matched by the agent's id.
|
|
33
|
+
const agentColumn = ['agent_session_id', 'claude_id'].find((name) => columns.has(name));
|
|
34
|
+
|
|
35
|
+
if (!columns.has('session_id') && agentColumn === undefined) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const id = columns.has('session_id') ? 'fleet.session_id' : `fleet.${agentColumn}`;
|
|
40
|
+
const agentID = agentColumn === undefined ? 'NULL' : `fleet.${agentColumn}`;
|
|
41
|
+
const exited = columns.has('exited') ? 'fleet.exited' : '0';
|
|
42
|
+
|
|
43
|
+
const hasOwners =
|
|
44
|
+
db
|
|
45
|
+
.query<{ name: string }, []>(
|
|
46
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_owner'",
|
|
47
|
+
)
|
|
48
|
+
.get() !== null;
|
|
49
|
+
|
|
50
|
+
const owned = hasOwners
|
|
51
|
+
? " WHERE fleet.session_id IN (SELECT session_id FROM session_owner WHERE daemon_id = (SELECT value FROM prefs WHERE key = 'daemon_id'))"
|
|
52
|
+
: '';
|
|
53
|
+
|
|
54
|
+
const rows = db
|
|
55
|
+
.query<
|
|
56
|
+
{ id: string; agent_session_id: string | null; name: string | null; exited: number | null },
|
|
57
|
+
[]
|
|
58
|
+
>(
|
|
59
|
+
`SELECT ${id} AS id, ${agentID} AS agent_session_id, fleet.name, ${exited} AS exited FROM fleet${owned} ORDER BY fleet.rowid`,
|
|
60
|
+
)
|
|
61
|
+
.all();
|
|
62
|
+
|
|
63
|
+
return rows.map((row) => ({
|
|
64
|
+
id: row.id,
|
|
65
|
+
name: row.name ?? row.id,
|
|
66
|
+
exited: row.exited === 1,
|
|
67
|
+
agentSessionID: row.agent_session_id,
|
|
68
|
+
}));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
} finally {
|
|
72
|
+
db.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DaemonClient } from './client/daemon-client';
|
|
2
|
+
import { isRecord } from './shared/report';
|
|
3
|
+
|
|
4
|
+
export interface StoredRow {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly exited: boolean;
|
|
8
|
+
|
|
9
|
+
// The agent's own session id, which matches the row when its atc id is
|
|
10
|
+
// assigned only as the new daemon migrates the store; null when unknown.
|
|
11
|
+
readonly agentSessionID: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reads the fleet rows a daemon has stored, exited ones included.
|
|
16
|
+
*/
|
|
17
|
+
export async function readStoredRows(
|
|
18
|
+
client: Pick<DaemonClient, 'sendRequest'>,
|
|
19
|
+
): Promise<StoredRow[]> {
|
|
20
|
+
const listed = await client.sendRequest('fleet.list');
|
|
21
|
+
|
|
22
|
+
const fleet = listed['fleet'];
|
|
23
|
+
|
|
24
|
+
if (!Array.isArray(fleet)) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return fleet
|
|
29
|
+
.filter((entry) => isRecord(entry))
|
|
30
|
+
.map((entry) => ({
|
|
31
|
+
id: String(entry['sessionID']),
|
|
32
|
+
name: String(entry['name']),
|
|
33
|
+
exited: entry['exited'] === true,
|
|
34
|
+
agentSessionID: typeof entry['agentSessionID'] === 'string' ? entry['agentSessionID'] : null,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { restartsDir } from './shared/config';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Removes the result of the last finished restart, so a reader finds a
|
|
7
|
+
* result only from a restart that ran after this call.
|
|
8
|
+
*/
|
|
9
|
+
export function removeRestartResult(): void {
|
|
10
|
+
rmSync(join(restartsDir, 'last.json'), { force: true });
|
|
11
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { buildOwnDaemonProcess } from './build-own-daemon-process';
|
|
3
|
+
import { buildRestartFailure } from './build-restart-failure';
|
|
4
|
+
import { collectRestartPlan } from './collect-restart-plan';
|
|
5
|
+
import { formatRestartPreflight } from './format-restart-preflight';
|
|
6
|
+
import type { RestartResult } from './parse-restart-result';
|
|
7
|
+
import { readDaemonProcess } from './read-daemon-process';
|
|
8
|
+
import { readFleetSnapshot } from './read-fleet-snapshot';
|
|
9
|
+
import { readStoredFleetFile } from './read-stored-fleet-file';
|
|
10
|
+
import { runSystemctl } from './run-systemctl';
|
|
11
|
+
import { dbFile } from './shared/config';
|
|
12
|
+
import { getBuild } from './shared/get-build';
|
|
13
|
+
import { loadListenerTokens } from './shared/load-listener-tokens';
|
|
14
|
+
import { startReplacementDaemon } from './start-replacement-daemon';
|
|
15
|
+
import { stopDaemonProcess } from './stop-daemon-process';
|
|
16
|
+
import { verifyRestoredFleet } from './verify-restored-fleet';
|
|
17
|
+
import { waitForReplacement } from './wait-for-replacement';
|
|
18
|
+
|
|
19
|
+
export interface RestartOptions {
|
|
20
|
+
readonly runID: string;
|
|
21
|
+
readonly callerSession: string | null;
|
|
22
|
+
readonly listen: string | null;
|
|
23
|
+
readonly tokenFile: string | null;
|
|
24
|
+
readonly timeoutSeconds: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const START_WAIT_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stops the running daemon, starts exactly one in its place, restores the
|
|
31
|
+
* stored fleet on it, and reports what came back. The preflight prints
|
|
32
|
+
* before anything stops. A failure on the way is a result, never a throw.
|
|
33
|
+
*/
|
|
34
|
+
export async function restartDaemon(options: RestartOptions): Promise<RestartResult> {
|
|
35
|
+
const plan = await collectRestartPlan(options.callerSession);
|
|
36
|
+
|
|
37
|
+
for (const line of formatRestartPreflight(plan)) {
|
|
38
|
+
console.log(line);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const interrupted = (plan.sessions ?? [])
|
|
42
|
+
.filter((session) => session.state === 'running')
|
|
43
|
+
.map((session) => ({ name: session.name, id: session.id }));
|
|
44
|
+
|
|
45
|
+
// Taken while the old daemon still runs, so a row that the restore marks
|
|
46
|
+
// exited is not mistaken for one that was archived before. A daemon this
|
|
47
|
+
// build cannot speak to is read from its database instead.
|
|
48
|
+
const snapshot =
|
|
49
|
+
(plan.answer?.kind === 'ok' && plan.socketPath !== null
|
|
50
|
+
? await readFleetSnapshot(plan.socketPath)
|
|
51
|
+
: null) ?? readStoredFleetFile(dbFile);
|
|
52
|
+
|
|
53
|
+
if (snapshot === null) {
|
|
54
|
+
console.log(
|
|
55
|
+
'cannot read the stored fleet before the stop: the report checks the rows the new daemon lists, and a row it marks exited counts as restored',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const buildFailure = (error: string): RestartResult =>
|
|
60
|
+
buildRestartFailure(options.runID, error, interrupted);
|
|
61
|
+
|
|
62
|
+
let expectedBuild: string | null = null;
|
|
63
|
+
|
|
64
|
+
if (plan.replacement.kind === 'unit') {
|
|
65
|
+
if (options.listen !== null || options.tokenFile !== null) {
|
|
66
|
+
console.log('--listen and --token-file are ignored: the unit decides how the daemon starts');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const restarted = await runSystemctl(['restart', plan.replacement.unit]);
|
|
70
|
+
|
|
71
|
+
if (restarted.code !== 0) {
|
|
72
|
+
return buildFailure(`systemctl restart ${plan.replacement.unit} failed: ${restarted.stderr}`);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
expectedBuild = getBuild();
|
|
76
|
+
|
|
77
|
+
const old = plan.pid === null ? buildOwnDaemonProcess() : readDaemonProcess(plan.pid);
|
|
78
|
+
|
|
79
|
+
if (plan.pid !== null && !old.fromProc) {
|
|
80
|
+
console.log(
|
|
81
|
+
'cannot read /proc for the daemon: the replacement starts with this restart’s environment and without the old listener flags',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const listen = options.listen ?? old.flags.listen;
|
|
86
|
+
const tokenFile = options.tokenFile ?? old.flags.tokenFile;
|
|
87
|
+
|
|
88
|
+
if ((listen === null) !== (tokenFile === null)) {
|
|
89
|
+
return buildFailure('--listen and --token-file go together; the daemon was left running');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (listen !== null && tokenFile !== null) {
|
|
93
|
+
const loaded = loadListenerTokens(resolve(old.cwd ?? process.cwd(), tokenFile));
|
|
94
|
+
|
|
95
|
+
if (!loaded.ok) {
|
|
96
|
+
return buildFailure(`${loaded.reason}; the daemon was left running`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (plan.pid !== null) {
|
|
101
|
+
const stopped = await stopDaemonProcess(plan.pid);
|
|
102
|
+
|
|
103
|
+
const stoppedLine =
|
|
104
|
+
stopped === 'killed'
|
|
105
|
+
? `the daemon (pid ${plan.pid}) ignored SIGTERM for 10 s, so it was killed with SIGKILL`
|
|
106
|
+
: `stopped the daemon (pid ${plan.pid})`;
|
|
107
|
+
|
|
108
|
+
console.log(stoppedLine);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
startReplacementDaemon(old, { listen: options.listen, tokenFile: options.tokenFile });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const replacement = await waitForReplacement(plan.pid, expectedBuild, START_WAIT_MS);
|
|
115
|
+
|
|
116
|
+
if (!replacement.ok) {
|
|
117
|
+
return buildFailure(replacement.reason);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
console.log(`daemon pid ${replacement.pid} is up (${replacement.build}); restoring the fleet`);
|
|
122
|
+
|
|
123
|
+
const verdict = await verifyRestoredFleet(replacement.client, options.timeoutSeconds, snapshot);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
runID: options.runID,
|
|
127
|
+
code: verdict.failed.length === 0 ? 0 : 1,
|
|
128
|
+
pid: replacement.pid,
|
|
129
|
+
build: replacement.build,
|
|
130
|
+
listenPort: replacement.listenPort,
|
|
131
|
+
restored: verdict.total - verdict.failed.length,
|
|
132
|
+
total: verdict.total,
|
|
133
|
+
failed: [...verdict.failed],
|
|
134
|
+
interrupted,
|
|
135
|
+
error: null,
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
...buildFailure(`the fleet could not be verified: ${reason}`),
|
|
142
|
+
pid: replacement.pid,
|
|
143
|
+
build: replacement.build,
|
|
144
|
+
listenPort: replacement.listenPort,
|
|
145
|
+
};
|
|
146
|
+
} finally {
|
|
147
|
+
replacement.client.stop();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// What a restart is about to stop and what it starts in its place.
|
|
2
|
+
export interface RestartPlan {
|
|
3
|
+
// The pid of the daemon to stop, or null when no live pid is recorded.
|
|
4
|
+
readonly pid: number | null;
|
|
5
|
+
|
|
6
|
+
// The socket that answered the handshake, or null when none did.
|
|
7
|
+
readonly socketPath: string | null;
|
|
8
|
+
|
|
9
|
+
// How the daemon answered this build's handshake.
|
|
10
|
+
readonly answer: DaemonAnswer | null;
|
|
11
|
+
|
|
12
|
+
// The session list the daemon returned, or null when it could not be read.
|
|
13
|
+
readonly sessions: readonly PlanSession[] | null;
|
|
14
|
+
|
|
15
|
+
// The session the restart was run from, when it runs inside one.
|
|
16
|
+
readonly callerSession: string | null;
|
|
17
|
+
|
|
18
|
+
readonly replacement: ReplacementPlan;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type DaemonAnswer =
|
|
22
|
+
| { readonly kind: 'ok'; readonly build: string; readonly protocol: number }
|
|
23
|
+
| { readonly kind: 'refused'; readonly message: string };
|
|
24
|
+
|
|
25
|
+
export interface PlanSession {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly state: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ReplacementPlan =
|
|
32
|
+
| { readonly kind: 'unit'; readonly unit: string; readonly execStart: string | null }
|
|
33
|
+
| { readonly kind: 'plain'; readonly build: string; readonly command: string };
|