@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,155 @@
|
|
|
1
|
+
import type { DaemonClient } from './client/daemon-client';
|
|
2
|
+
import type { RestartFailedRow } from './parse-restart-result';
|
|
3
|
+
import type { StoredRow } from './read-stored-rows';
|
|
4
|
+
import { readStoredRows } from './read-stored-rows';
|
|
5
|
+
import { isRecord } from './shared/report';
|
|
6
|
+
|
|
7
|
+
interface FleetVerdict {
|
|
8
|
+
// The rows the daemon stored, exited ones included.
|
|
9
|
+
readonly total: number;
|
|
10
|
+
readonly failed: readonly RestartFailedRow[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The rows that are not restored yet, and whether waiting longer can still
|
|
14
|
+
// change that.
|
|
15
|
+
interface FailedRows {
|
|
16
|
+
readonly failed: readonly RestartFailedRow[];
|
|
17
|
+
readonly pending: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const UNSIZED_READ_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Restores the stored fleet on a daemon and waits for every stored row to
|
|
24
|
+
* show up. `snapshot` holds the rows read from the old daemon before it
|
|
25
|
+
* stopped, with their original exit status; without one, the new daemon's
|
|
26
|
+
* rows are read instead. The call joins a restore the daemon already started by itself. A
|
|
27
|
+
* row that is not exited must be listed with a live terminal before the deadline
|
|
28
|
+
* passes (`timeoutSeconds` when set, else the rows that are not exited times the restore boot cap, plus 30 s), and an exited row must be listed. A stored row that is not listed
|
|
29
|
+
* failed to restore, and a listed row that is still without a live terminal
|
|
30
|
+
* at the deadline failed to revive. The deadline counts from the call, and
|
|
31
|
+
* every request to the daemon is held to it.
|
|
32
|
+
*/
|
|
33
|
+
export async function verifyRestoredFleet(
|
|
34
|
+
client: Pick<DaemonClient, 'sendRequest'>,
|
|
35
|
+
timeoutSeconds: number | null,
|
|
36
|
+
snapshot: readonly StoredRow[] | null,
|
|
37
|
+
): Promise<FleetVerdict> {
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
|
|
40
|
+
// Without a snapshot the rows come from the new daemon, and its count is
|
|
41
|
+
// not known until they are read, so that read gets the fixed allowance.
|
|
42
|
+
const read =
|
|
43
|
+
snapshot === null
|
|
44
|
+
? await sendBounded(
|
|
45
|
+
() => readStoredRows(client),
|
|
46
|
+
startedAt + (timeoutSeconds === null ? UNSIZED_READ_MS : timeoutSeconds * 1000),
|
|
47
|
+
)
|
|
48
|
+
: null;
|
|
49
|
+
|
|
50
|
+
const stored = snapshot ?? read ?? [];
|
|
51
|
+
const deadline = startedAt + pickDeadlineMs(stored, timeoutSeconds);
|
|
52
|
+
|
|
53
|
+
const restored = await tryRestore(client, deadline);
|
|
54
|
+
let found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline);
|
|
55
|
+
|
|
56
|
+
while (found.pending && Date.now() < deadline) {
|
|
57
|
+
await Bun.sleep(250);
|
|
58
|
+
|
|
59
|
+
found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { total: stored.length, failed: found.failed };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Runs a request and rejects once the deadline passes, so a daemon that
|
|
67
|
+
* stops answering cannot hold the restart, and its lock, open.
|
|
68
|
+
*/
|
|
69
|
+
async function sendBounded<T>(send: () => Promise<T>, deadline: number): Promise<T> {
|
|
70
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
71
|
+
|
|
72
|
+
const expired = new Promise<never>((_resolve, reject) => {
|
|
73
|
+
timer = setTimeout(
|
|
74
|
+
() => {
|
|
75
|
+
reject(new Error('the new daemon stopped answering before the restore deadline'));
|
|
76
|
+
},
|
|
77
|
+
Math.max(0, deadline - Date.now()),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
return await Promise.race([send(), expired]);
|
|
83
|
+
} finally {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pickDeadlineMs(stored: readonly StoredRow[], timeoutSeconds: number | null): number {
|
|
89
|
+
if (timeoutSeconds !== null) {
|
|
90
|
+
return timeoutSeconds * 1000;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const cap = Number(process.env['ATC_RESTORE_BOOT_TIMEOUT_MS']);
|
|
94
|
+
const bootMs = Number.isFinite(cap) && cap >= 0 ? cap : 15_000;
|
|
95
|
+
|
|
96
|
+
return stored.filter((row) => !row.exited).length * bootMs + 30_000;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function tryRestore(
|
|
100
|
+
client: Pick<DaemonClient, 'sendRequest'>,
|
|
101
|
+
deadline: number,
|
|
102
|
+
): Promise<boolean> {
|
|
103
|
+
try {
|
|
104
|
+
await sendBounded(() => client.sendRequest('fleet.restore', { cols: 80, rows: 24 }), deadline);
|
|
105
|
+
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function collectFailedRows(
|
|
113
|
+
client: Pick<DaemonClient, 'sendRequest'>,
|
|
114
|
+
stored: readonly StoredRow[],
|
|
115
|
+
restored: boolean,
|
|
116
|
+
): Promise<FailedRows> {
|
|
117
|
+
const listed = await client.sendRequest('session.list');
|
|
118
|
+
|
|
119
|
+
const sessions = Array.isArray(listed['sessions']) ? listed['sessions'].filter(isRecord) : [];
|
|
120
|
+
|
|
121
|
+
const byID = new Map(sessions.map((session) => [String(session['id']), session]));
|
|
122
|
+
|
|
123
|
+
const byAgentID = new Map(
|
|
124
|
+
sessions
|
|
125
|
+
.filter((session) => typeof session['agentSessionID'] === 'string')
|
|
126
|
+
.map((session) => [String(session['agentSessionID']), session]),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const failed: RestartFailedRow[] = [];
|
|
130
|
+
let pending = false;
|
|
131
|
+
|
|
132
|
+
for (const row of stored) {
|
|
133
|
+
const session =
|
|
134
|
+
byID.get(row.id) ??
|
|
135
|
+
(row.agentSessionID === null ? undefined : byAgentID.get(row.agentSessionID));
|
|
136
|
+
|
|
137
|
+
if (session === undefined) {
|
|
138
|
+
failed.push({ name: row.name, id: row.id, reason: 'not listed after the restore' });
|
|
139
|
+
|
|
140
|
+
// A finished restore lists every row it registers, so a row still
|
|
141
|
+
// missing never appears; after a failed restore it may yet.
|
|
142
|
+
pending ||= !restored;
|
|
143
|
+
} else if (!row.exited && !(session['alive'] === true && session['kind'] === 'pty')) {
|
|
144
|
+
failed.push({
|
|
145
|
+
name: row.name,
|
|
146
|
+
id: row.id,
|
|
147
|
+
reason: `listed in state ${String(session['state'])} without a terminal: ${String(session['lastMsg'])}`,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
pending = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { failed, pending };
|
|
155
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { DaemonClient } from './client/daemon-client';
|
|
2
|
+
import { DaemonError } from './protocol/daemon-error';
|
|
3
|
+
import { daemonRecordFile } from './shared/config';
|
|
4
|
+
import { findDaemonRecord } from './shared/find-daemon-record';
|
|
5
|
+
import { getBuild } from './shared/get-build';
|
|
6
|
+
import { isProcessAlive } from './shared/is-process-alive';
|
|
7
|
+
|
|
8
|
+
type ReplacementOutcome =
|
|
9
|
+
| {
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly client: DaemonClient;
|
|
12
|
+
readonly pid: number;
|
|
13
|
+
readonly build: string;
|
|
14
|
+
readonly listenPort: number | null;
|
|
15
|
+
}
|
|
16
|
+
| { readonly ok: false; readonly reason: string };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Waits up to `waitMs` for a daemon to answer whose pid differs from the one
|
|
20
|
+
* that was stopped and whose record in the state directory matches it. With
|
|
21
|
+
* `expectedBuild`, the answering daemon must run that build: another client
|
|
22
|
+
* that won the start with a different build is a failure.
|
|
23
|
+
*/
|
|
24
|
+
export async function waitForReplacement(
|
|
25
|
+
oldPID: number | null,
|
|
26
|
+
expectedBuild: string | null,
|
|
27
|
+
waitMs: number,
|
|
28
|
+
): Promise<ReplacementOutcome> {
|
|
29
|
+
const deadline = Date.now() + waitMs;
|
|
30
|
+
let refusal: string | null = null;
|
|
31
|
+
|
|
32
|
+
while (Date.now() < deadline) {
|
|
33
|
+
const record = findDaemonRecord(daemonRecordFile);
|
|
34
|
+
|
|
35
|
+
if (record !== null && record.pid !== oldPID && isProcessAlive(record.pid)) {
|
|
36
|
+
const attempt = await tryHello(record.socketPath, deadline);
|
|
37
|
+
|
|
38
|
+
if (attempt.kind === 'ok') {
|
|
39
|
+
if (expectedBuild !== null && attempt.build !== expectedBuild) {
|
|
40
|
+
attempt.client.stop();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: `the daemon that came up (pid ${record.pid}) runs build ${attempt.build}, not ${expectedBuild}; another client started it first`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
client: attempt.client,
|
|
51
|
+
pid: record.pid,
|
|
52
|
+
build: attempt.build,
|
|
53
|
+
listenPort: record.listenPort,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (attempt.kind === 'refused') {
|
|
58
|
+
refusal = attempt.message;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await Bun.sleep(100);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason:
|
|
68
|
+
refusal === null
|
|
69
|
+
? `no replacement daemon answered within ${Math.round(waitMs / 1000)} s`
|
|
70
|
+
: `the replacement daemon refused the handshake: ${refusal}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Hello =
|
|
75
|
+
| { readonly kind: 'ok'; readonly client: DaemonClient; readonly build: string }
|
|
76
|
+
| { readonly kind: 'refused'; readonly message: string }
|
|
77
|
+
| { readonly kind: 'none' };
|
|
78
|
+
|
|
79
|
+
async function tryHello(socketPath: string, deadline: number): Promise<Hello> {
|
|
80
|
+
let client: DaemonClient;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
client = await DaemonClient.open(socketPath);
|
|
84
|
+
} catch {
|
|
85
|
+
return { kind: 'none' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A daemon that accepts the socket and never answers must not outlast the
|
|
89
|
+
// startup deadline: closing the client rejects the handshake.
|
|
90
|
+
const expiry = setTimeout(
|
|
91
|
+
() => {
|
|
92
|
+
client.stop();
|
|
93
|
+
},
|
|
94
|
+
Math.max(0, deadline - Date.now()),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const hello = await client.sendHello(getBuild());
|
|
99
|
+
|
|
100
|
+
const build = hello['daemon'];
|
|
101
|
+
|
|
102
|
+
return { kind: 'ok', client, build: typeof build === 'string' ? build : 'unknown' };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
client.stop();
|
|
105
|
+
|
|
106
|
+
return error instanceof DaemonError && error.code === 'protocol_mismatch'
|
|
107
|
+
? { kind: 'refused', message: error.message }
|
|
108
|
+
: { kind: 'none' };
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(expiry);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { RestartResult } from './parse-restart-result';
|
|
4
|
+
import { restartsDir } from './shared/config';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Writes the result of a finished restart to `last.json` in the restarts
|
|
8
|
+
* directory, through a rename so a reader never sees half a record.
|
|
9
|
+
*/
|
|
10
|
+
export function writeRestartResult(result: RestartResult): void {
|
|
11
|
+
mkdirSync(restartsDir, { recursive: true });
|
|
12
|
+
|
|
13
|
+
const staging = join(restartsDir, `last.${process.pid}.tmp`);
|
|
14
|
+
|
|
15
|
+
writeFileSync(staging, `${JSON.stringify(result)}\n`);
|
|
16
|
+
renameSync(staging, join(restartsDir, 'last.json'));
|
|
17
|
+
}
|
|
File without changes
|
|
File without changes
|