@crvouga/dev-fleet 0.3.0 → 0.5.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 +25 -17
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/policy.json +12 -1
- package/ansible/monitoring/compose.yml +10 -0
- package/ansible/monitoring/container-watchdog.py +118 -0
- package/ansible/monitoring/hub-darwin.yml +146 -0
- package/ansible/net/remote-probe.sh +5 -0
- package/ansible/platforms.json +111 -0
- package/ansible/ubuntu/authorize-sudo.sh +7 -16
- package/ansible/ubuntu/baseline.yml +6 -28
- package/ansible/ubuntu/disk-gc.py +211 -7
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +68 -4
- package/ansible/ubuntu/t3-desktop.yml +2 -2
- package/ansible/ubuntu/tailscale.yml +20 -9
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/dpkg.yml +46 -0
- package/ansible/ubuntu/tasks/install.yml +38 -0
- package/ansible/ubuntu/tasks/packages.yml +4 -55
- package/ansible/ubuntu/tasks/release.yml +25 -0
- package/ansible/ubuntu/tasks/resources.yml +7 -6
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/snapshot.yml +50 -0
- package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +49 -45
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +1 -1
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/checkpoint.py +1 -1
- package/docs/authentication.md +90 -0
- package/docs/consumer-setup.md +32 -30
- package/docs/declared.md +6 -5
- package/docs/machine-setup.md +22 -12
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +377 -0
- package/docs/state.md +13 -7
- package/docs/support-matrix.md +36 -0
- package/docs/ubuntu-sync.md +16 -13
- package/docs/worker-contract.md +28 -1
- package/examples/fleet.json +19 -1
- package/llms.txt +2 -0
- package/mise.toml +7 -0
- package/package.json +2 -2
- package/schema/check-ids.json +526 -0
- package/schema/fleet.schema.json +185 -4
- package/schema/output/check.schema.json +23 -0
- package/schema/output/error.schema.json +14 -0
- package/schema/output/fleet-report.schema.json +79 -0
- package/schema/output/fleet-sync.schema.json +112 -0
- package/schema/output/report.schema.json +38 -0
- package/src/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-manager.ts +11 -0
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +4 -2
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +90 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +51 -0
- package/src/cli/registry.ts +13 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +160 -12
- package/src/git-identity.ts +160 -0
- package/src/machine/platform.ts +48 -28
- package/src/machine/support-matrix.ts +76 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +27 -9
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/network-definition.ts +1 -1
- package/src/node-converge-linux.ts +16 -5
- package/src/node-converge.ts +1 -1
- package/src/notify.ts +239 -0
- package/src/platform/debian-version.ts +62 -0
- package/src/platform/ubuntu-audit.ts +11 -4
- package/src/platform/ubuntu-release.ts +41 -0
- package/src/remote-bootstrap.ts +89 -25
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +68 -10
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { record } from '../config';
|
|
2
|
+
import { databasePath, stateRoot } from './paths';
|
|
3
|
+
import { readDocument, writeDocument } from './store';
|
|
4
|
+
|
|
5
|
+
/** Who started a fleet-sync run: a person (or an agent) at the manager, or
|
|
6
|
+
* the manager's own schedule.
|
|
7
|
+
*/
|
|
8
|
+
export type Trigger = 'operator' | 'schedule';
|
|
9
|
+
|
|
10
|
+
/** One fleet-sync run, as the history keeps it: codes and node names only,
|
|
11
|
+
* never a remedy, a path or anything a subprocess printed.
|
|
12
|
+
*/
|
|
13
|
+
export interface SyncRun {
|
|
14
|
+
startedAt: string;
|
|
15
|
+
finishedAt: string;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
trigger: Trigger;
|
|
18
|
+
revision: string | null;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
exitCode: number;
|
|
21
|
+
/** The IDs of the checks that failed, once each. */
|
|
22
|
+
failing: string[];
|
|
23
|
+
/** The machines that were not ready when the run ended. */
|
|
24
|
+
notReady: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The schedule the manager was last told to keep. */
|
|
28
|
+
export interface ScheduleRecord {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
intervalSeconds: number;
|
|
31
|
+
changedAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface History {
|
|
35
|
+
version: 1;
|
|
36
|
+
schedule?: ScheduleRecord;
|
|
37
|
+
runs: SyncRun[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The history is capped twice: by age and by count, so the document stays
|
|
41
|
+
* far below the fleet state's limit on one document.
|
|
42
|
+
*/
|
|
43
|
+
export const HISTORY_DAYS = 90;
|
|
44
|
+
export const HISTORY_RUNS = 500;
|
|
45
|
+
|
|
46
|
+
const DAY_MS = 86_400_000;
|
|
47
|
+
|
|
48
|
+
function strings(value: unknown): value is string[] {
|
|
49
|
+
return Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function syncRun(value: unknown): value is SyncRun {
|
|
53
|
+
return (
|
|
54
|
+
record(value) &&
|
|
55
|
+
typeof value['startedAt'] === 'string' &&
|
|
56
|
+
typeof value['finishedAt'] === 'string' &&
|
|
57
|
+
typeof value['durationMs'] === 'number' &&
|
|
58
|
+
(value['trigger'] === 'operator' || value['trigger'] === 'schedule') &&
|
|
59
|
+
(value['revision'] === null || typeof value['revision'] === 'string') &&
|
|
60
|
+
typeof value['ok'] === 'boolean' &&
|
|
61
|
+
typeof value['exitCode'] === 'number' &&
|
|
62
|
+
strings(value['failing']) &&
|
|
63
|
+
strings(value['notReady'])
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scheduleRecord(value: unknown): value is ScheduleRecord {
|
|
68
|
+
return (
|
|
69
|
+
record(value) &&
|
|
70
|
+
typeof value['enabled'] === 'boolean' &&
|
|
71
|
+
typeof value['intervalSeconds'] === 'number' &&
|
|
72
|
+
typeof value['changedAt'] === 'string'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A document that cannot be read is an empty history: it only ever
|
|
77
|
+
* describes the past, and the next run starts it again.
|
|
78
|
+
*/
|
|
79
|
+
export function parseHistory(text: string | null): History {
|
|
80
|
+
let value: unknown = null;
|
|
81
|
+
try {
|
|
82
|
+
value = text === null ? null : JSON.parse(text);
|
|
83
|
+
} catch {
|
|
84
|
+
value = null;
|
|
85
|
+
}
|
|
86
|
+
if (!record(value) || value['version'] !== 1) return { version: 1, runs: [] };
|
|
87
|
+
return {
|
|
88
|
+
version: 1,
|
|
89
|
+
...(scheduleRecord(value['schedule'])
|
|
90
|
+
? { schedule: value['schedule'] }
|
|
91
|
+
: {}),
|
|
92
|
+
runs: Array.isArray(value['runs']) ? value['runs'].filter(syncRun) : [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readHistory(env = process.env): History {
|
|
97
|
+
return parseHistory(readDocument(databasePath(stateRoot(env)), 'history'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeHistory(history: History, env: NodeJS.ProcessEnv): void {
|
|
101
|
+
writeDocument(
|
|
102
|
+
databasePath(stateRoot(env)),
|
|
103
|
+
'history',
|
|
104
|
+
JSON.stringify(history) + '\n'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Keeps the runs of the last HISTORY_DAYS days, and at most HISTORY_RUNS. */
|
|
109
|
+
export function capped(runs: SyncRun[], now: Date): SyncRun[] {
|
|
110
|
+
const oldest = now.getTime() - HISTORY_DAYS * DAY_MS;
|
|
111
|
+
return runs
|
|
112
|
+
.filter((run) => Date.parse(run.finishedAt) >= oldest)
|
|
113
|
+
.slice(-HISTORY_RUNS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function appendRun(
|
|
117
|
+
run: SyncRun,
|
|
118
|
+
env = process.env,
|
|
119
|
+
now = new Date()
|
|
120
|
+
): History {
|
|
121
|
+
const history = readHistory(env);
|
|
122
|
+
const next = { ...history, runs: capped([...history.runs, run], now) };
|
|
123
|
+
writeHistory(next, env);
|
|
124
|
+
return next;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function recordSchedule(
|
|
128
|
+
schedule: ScheduleRecord,
|
|
129
|
+
env = process.env
|
|
130
|
+
): void {
|
|
131
|
+
writeHistory({ ...readHistory(env), schedule }, env);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What a fleet-sync report says about one run. */
|
|
135
|
+
export function summarizeRun(
|
|
136
|
+
data: unknown,
|
|
137
|
+
timing: { startedAt: Date; finishedAt: Date },
|
|
138
|
+
trigger: Trigger,
|
|
139
|
+
exitCode: number
|
|
140
|
+
): SyncRun {
|
|
141
|
+
const report = record(data) ? data : {};
|
|
142
|
+
const checks = (value: unknown): Record<string, unknown>[] =>
|
|
143
|
+
Array.isArray(value) ? value.filter(record) : [];
|
|
144
|
+
const nodes = checks(report['nodes']);
|
|
145
|
+
const failing = new Set<string>();
|
|
146
|
+
for (const check of [
|
|
147
|
+
...checks(report['checks']),
|
|
148
|
+
...nodes.flatMap((node) => checks(node['checks'])),
|
|
149
|
+
])
|
|
150
|
+
if (check['status'] === 'fail' && typeof check['id'] === 'string')
|
|
151
|
+
failing.add(check['id']);
|
|
152
|
+
const notReady = nodes
|
|
153
|
+
.filter((node) =>
|
|
154
|
+
checks(node['checks']).some((check) => check['status'] === 'fail')
|
|
155
|
+
)
|
|
156
|
+
.map((node) => node['node'])
|
|
157
|
+
.filter((node): node is string => typeof node === 'string');
|
|
158
|
+
return {
|
|
159
|
+
startedAt: timing.startedAt.toISOString(),
|
|
160
|
+
finishedAt: timing.finishedAt.toISOString(),
|
|
161
|
+
durationMs: timing.finishedAt.getTime() - timing.startedAt.getTime(),
|
|
162
|
+
trigger,
|
|
163
|
+
revision:
|
|
164
|
+
typeof report['desiredRevision'] === 'string'
|
|
165
|
+
? report['desiredRevision']
|
|
166
|
+
: null,
|
|
167
|
+
ok: exitCode === 0 && report['ok'] !== false,
|
|
168
|
+
exitCode,
|
|
169
|
+
failing: [...failing].sort(),
|
|
170
|
+
notReady: [...new Set(notReady)].sort(),
|
|
171
|
+
};
|
|
172
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -9,7 +9,14 @@ import { FleetUsageError } from '../errors';
|
|
|
9
9
|
* connection to each worker, `enrollment` the approved and applied revision.
|
|
10
10
|
*/
|
|
11
11
|
export const DOCUMENTS = ['definition', 'roster', 'enrollment'] as const;
|
|
12
|
+
/** Kept when there is something to keep; a fleet without them is complete:
|
|
13
|
+
* `history` is the record of recent fleet-sync runs and of the schedule.
|
|
14
|
+
*/
|
|
15
|
+
export const OPTIONAL_DOCUMENTS = ['history'] as const;
|
|
12
16
|
export type DocumentName = (typeof DOCUMENTS)[number];
|
|
17
|
+
/** Every document the fleet state may hold. */
|
|
18
|
+
export type StoredName = DocumentName | (typeof OPTIONAL_DOCUMENTS)[number];
|
|
19
|
+
const KNOWN: readonly string[] = [...DOCUMENTS, ...OPTIONAL_DOCUMENTS];
|
|
13
20
|
|
|
14
21
|
/** One version of the whole database. Every change replaces it, so two copies
|
|
15
22
|
* hold the same state exactly when their heads are equal.
|
|
@@ -21,7 +28,7 @@ export interface Head {
|
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
export interface DocumentInfo {
|
|
24
|
-
name:
|
|
31
|
+
name: StoredName;
|
|
25
32
|
bytes: number;
|
|
26
33
|
updatedAt: string;
|
|
27
34
|
}
|
|
@@ -100,7 +107,7 @@ function tables(db: Database): boolean {
|
|
|
100
107
|
);
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
export function readDocument(file: string, name:
|
|
110
|
+
export function readDocument(file: string, name: StoredName): string | null {
|
|
104
111
|
return reading(file, (db) => {
|
|
105
112
|
if (!tables(db)) return null;
|
|
106
113
|
const row = db
|
|
@@ -119,7 +126,7 @@ export function listDocuments(file: string): DocumentInfo[] {
|
|
|
119
126
|
'SELECT name, length(CAST(body AS BLOB)) AS bytes, updated_at AS updatedAt FROM documents ORDER BY name'
|
|
120
127
|
)
|
|
121
128
|
.all() as DocumentInfo[];
|
|
122
|
-
return rows.filter((row) =>
|
|
129
|
+
return rows.filter((row) => KNOWN.includes(row.name));
|
|
123
130
|
}) ?? []
|
|
124
131
|
);
|
|
125
132
|
}
|
|
@@ -138,7 +145,7 @@ export function readHead(file: string): Head | null {
|
|
|
138
145
|
*/
|
|
139
146
|
export function writeDocument(
|
|
140
147
|
file: string,
|
|
141
|
-
name:
|
|
148
|
+
name: StoredName,
|
|
142
149
|
body: string
|
|
143
150
|
): Head {
|
|
144
151
|
if (Buffer.byteLength(body) > MAX_DOCUMENT_BYTES)
|
package/src/tailscale.ts
CHANGED
|
@@ -10,6 +10,9 @@ export interface TailnetComputer {
|
|
|
10
10
|
ips: string[];
|
|
11
11
|
online: boolean;
|
|
12
12
|
self: boolean;
|
|
13
|
+
/** When the node key expires, in milliseconds since the epoch; absent when
|
|
14
|
+
* it never does (tagged nodes, or expiry disabled in the admin console). */
|
|
15
|
+
keyExpiry?: number;
|
|
13
16
|
}
|
|
14
17
|
export interface Tailnet {
|
|
15
18
|
computers: TailnetComputer[];
|
|
@@ -103,6 +106,7 @@ function parseComputer(value: unknown, self: boolean): TailnetComputer {
|
|
|
103
106
|
OS: os,
|
|
104
107
|
TailscaleIPs: ips,
|
|
105
108
|
Online: online,
|
|
109
|
+
KeyExpiry: keyExpiry,
|
|
106
110
|
} = value;
|
|
107
111
|
if (
|
|
108
112
|
![id, name, os].every(
|
|
@@ -129,6 +133,53 @@ function parseComputer(value: unknown, self: boolean): TailnetComputer {
|
|
|
129
133
|
ips: ips as string[],
|
|
130
134
|
online,
|
|
131
135
|
self,
|
|
136
|
+
...expiry(keyExpiry),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function expiry(value: unknown): { keyExpiry?: number } {
|
|
141
|
+
if (typeof value !== 'string') return {};
|
|
142
|
+
const time = Date.parse(value);
|
|
143
|
+
// Tailscale writes the zero time for a key that never expires.
|
|
144
|
+
return Number.isFinite(time) && time > 0 ? { keyExpiry: time } : {};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const DAY_MS = 86_400_000;
|
|
148
|
+
const KEY_EXPIRY = { warnDays: 14, failDays: 3 };
|
|
149
|
+
|
|
150
|
+
/** A node drops off the tailnet on the day its key expires. The check warns
|
|
151
|
+
* two weeks ahead and fails three days ahead, like a T3 link that expires.
|
|
152
|
+
*/
|
|
153
|
+
export function keyExpiryCheck(
|
|
154
|
+
computer: TailnetComputer | undefined,
|
|
155
|
+
now = Date.now()
|
|
156
|
+
): Check {
|
|
157
|
+
const id = 'tailscale:key-expiry';
|
|
158
|
+
if (!computer)
|
|
159
|
+
return { id, status: 'skip', summary: 'TAILSCALE_KEY_EXPIRY_UNOBSERVED' };
|
|
160
|
+
if (computer.keyExpiry === undefined)
|
|
161
|
+
return {
|
|
162
|
+
id,
|
|
163
|
+
status: 'pass',
|
|
164
|
+
summary: 'The Tailscale node key of this machine does not expire.',
|
|
165
|
+
};
|
|
166
|
+
const days = Math.floor((computer.keyExpiry - now) / DAY_MS);
|
|
167
|
+
const remedy = {
|
|
168
|
+
action: computer.self
|
|
169
|
+
? 'Renew the node key of the manager before it expires: tailscale up --force-reauth on the manager, or disable key expiry for it in the Tailscale admin console.'
|
|
170
|
+
: 'Disable key expiry for the worker in the Tailscale admin console, or tag it (tagged nodes do not expire). After an expiry, re-join it as docs/operations.md describes (Tailscale key expiry).',
|
|
171
|
+
};
|
|
172
|
+
if (days < 0)
|
|
173
|
+
return { id, status: 'fail', summary: 'TAILSCALE_KEY_EXPIRED', remedy };
|
|
174
|
+
const summary = `TAILSCALE_KEY_EXPIRES_SOON in ${days} days`;
|
|
175
|
+
if (days <= KEY_EXPIRY.failDays)
|
|
176
|
+
return { id, status: 'fail', summary, remedy };
|
|
177
|
+
if (days <= KEY_EXPIRY.warnDays)
|
|
178
|
+
return { id, status: 'warn', summary, remedy };
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
status: 'pass',
|
|
182
|
+
summary: `The Tailscale node key expires in ${days} days.`,
|
|
132
183
|
};
|
|
133
184
|
}
|
|
134
185
|
|
package/src/toolchain-checks.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
selectionArguments,
|
|
4
|
+
type ToolSelection,
|
|
5
|
+
} from '../bootstrap/worker-tools';
|
|
2
6
|
import { record } from './config';
|
|
3
7
|
import type { Check, Runner } from './types';
|
|
4
8
|
|
|
5
9
|
export async function toolchainChecks(
|
|
6
10
|
root: string,
|
|
7
11
|
run: Runner,
|
|
8
|
-
worker
|
|
12
|
+
/** A worker's own tool selection; absent on the manager. */
|
|
13
|
+
worker?: ToolSelection
|
|
9
14
|
): Promise<Check[]> {
|
|
10
15
|
const result = await run(doctorArguments(root, worker), {
|
|
11
16
|
cwd: root,
|
|
@@ -97,12 +102,12 @@ function toolCheck(value: unknown): Check | null {
|
|
|
97
102
|
};
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
function doctorArguments(root: string, worker?:
|
|
105
|
+
function doctorArguments(root: string, worker?: ToolSelection): string[] {
|
|
101
106
|
return [
|
|
102
107
|
'bun',
|
|
103
108
|
join(root, 'bootstrap/doctor.ts'),
|
|
104
109
|
'--json',
|
|
105
110
|
'--docker',
|
|
106
|
-
...(worker ? ['--worker'] : []),
|
|
111
|
+
...(worker ? ['--worker', ...selectionArguments(worker)] : []),
|
|
107
112
|
];
|
|
108
113
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import policy from '../ansible/ubuntu/policy.json';
|
|
2
|
+
import type { Check, CommandResult, Runner } from './types';
|
|
3
|
+
|
|
4
|
+
/** What an agent does on a worker, tried the way it does it: start a
|
|
5
|
+
* container, build an image, launch a headless browser. Each check runs as
|
|
6
|
+
* the worker's user, in its session, and reports a code and a remedy, never
|
|
7
|
+
* what a command printed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function failure(id: string, summary: string, action: string): Check {
|
|
11
|
+
return { id, status: 'fail', summary, remedy: { action } };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Docker's own words for the causes an agent meets most. */
|
|
15
|
+
function dockerFailure(result: CommandResult): {
|
|
16
|
+
code: string;
|
|
17
|
+
action: string;
|
|
18
|
+
} {
|
|
19
|
+
const said = `${result.stdout}\n${result.stderr}`;
|
|
20
|
+
if (result.reason === 'timeout')
|
|
21
|
+
return {
|
|
22
|
+
code: 'DOCKER_RUN_TIMEOUT',
|
|
23
|
+
action:
|
|
24
|
+
'A container did not start and exit in time. Inspect the engine (systemctl status docker) and its disk; rerun fleet-sync.',
|
|
25
|
+
};
|
|
26
|
+
if (/permission denied[^\n]*docker\.sock/i.test(said))
|
|
27
|
+
return {
|
|
28
|
+
code: 'DOCKER_SOCKET_ACCESS_DENIED',
|
|
29
|
+
action:
|
|
30
|
+
"This session cannot use the Docker socket. fleet-sync adds the worker's user to the docker group; a session started before that keeps its old groups. Rerun fleet-sync, then restart the session (log out and in, or restart the T3 service).",
|
|
31
|
+
};
|
|
32
|
+
if (/cannot connect to the docker daemon/i.test(said))
|
|
33
|
+
return {
|
|
34
|
+
code: 'DOCKER_DAEMON_UNREACHABLE',
|
|
35
|
+
action:
|
|
36
|
+
'The Docker engine is not running. Rerun fleet-sync, which starts and enables docker.service.',
|
|
37
|
+
};
|
|
38
|
+
if (/no such image|pull access denied|not found/i.test(said))
|
|
39
|
+
return {
|
|
40
|
+
code: 'DOCKER_SMOKE_IMAGE_MISSING',
|
|
41
|
+
action:
|
|
42
|
+
'The pinned smoke-test image is not cached on this worker. Rerun fleet-sync, which pulls it once so this check works offline.',
|
|
43
|
+
};
|
|
44
|
+
if (/no space left on device/i.test(said))
|
|
45
|
+
return {
|
|
46
|
+
code: 'DOCKER_DISK_FULL',
|
|
47
|
+
action:
|
|
48
|
+
'The image store is full. Free space under /var/lib/docker (the disk GC prunes old images) and rerun fleet-sync.',
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
code: 'DOCKER_RUN_FAILED',
|
|
52
|
+
action:
|
|
53
|
+
'Docker answers but cannot run a container here. Inspect cgroups and the engine log (journalctl -u docker), then rerun fleet-sync.',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Runs the pinned image offline, then builds a one-line image from it. */
|
|
58
|
+
export async function dockerRunCheck(run: Runner): Promise<Check> {
|
|
59
|
+
const id = 'worker:docker-run';
|
|
60
|
+
const { image, timeoutSeconds } = policy.dockerSmokeTest;
|
|
61
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
62
|
+
const started = await run(
|
|
63
|
+
[
|
|
64
|
+
'docker',
|
|
65
|
+
'run',
|
|
66
|
+
'--rm',
|
|
67
|
+
'--network',
|
|
68
|
+
'none',
|
|
69
|
+
'--pull',
|
|
70
|
+
'never',
|
|
71
|
+
image,
|
|
72
|
+
'true',
|
|
73
|
+
],
|
|
74
|
+
{ timeoutMs }
|
|
75
|
+
);
|
|
76
|
+
if (!started.ok) {
|
|
77
|
+
const { code, action } = dockerFailure(started);
|
|
78
|
+
return failure(id, code, action);
|
|
79
|
+
}
|
|
80
|
+
const built = await run(
|
|
81
|
+
['docker', 'build', '--quiet', '--pull=false', '--network', 'none', '-'],
|
|
82
|
+
{ input: `FROM ${image}\nRUN true\n`, timeoutMs }
|
|
83
|
+
);
|
|
84
|
+
if (!built.ok) {
|
|
85
|
+
const { code, action } = dockerFailure(built);
|
|
86
|
+
return failure(
|
|
87
|
+
id,
|
|
88
|
+
code === 'DOCKER_RUN_FAILED' ? 'DOCKER_BUILD_FAILED' : code,
|
|
89
|
+
code === 'DOCKER_RUN_FAILED'
|
|
90
|
+
? 'A container runs but an image does not build. Inspect Buildx (docker buildx ls) and the image store, then rerun fleet-sync.'
|
|
91
|
+
: action
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
status: 'pass',
|
|
97
|
+
summary:
|
|
98
|
+
'A container of the pinned image ran and a one-line image built, offline, in this session.',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Where `ansible/ubuntu/tasks/browser.yml` unpacks the pinned build. */
|
|
103
|
+
export function headlessShell(arch: string = process.arch): string {
|
|
104
|
+
const { version, amd64, arm64 } = policy.browser.headlessShell;
|
|
105
|
+
const build = arch === 'arm64' ? arm64 : amd64;
|
|
106
|
+
const directory = build.archive.split('/').pop()!;
|
|
107
|
+
return `/opt/personal-fleet/browser/${version}/${directory}/chrome-headless-shell`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A worker with the web profile launches the pinned headless Chromium,
|
|
111
|
+
* loads a blank page and exits, as a browser test would.
|
|
112
|
+
*/
|
|
113
|
+
export async function browserCheck(
|
|
114
|
+
run: Runner,
|
|
115
|
+
web: boolean,
|
|
116
|
+
arch: string = process.arch
|
|
117
|
+
): Promise<Check> {
|
|
118
|
+
const id = 'worker:browser';
|
|
119
|
+
if (!web)
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
status: 'skip',
|
|
123
|
+
summary:
|
|
124
|
+
'This worker does not select the web tool profile, so no browser is installed for tests.',
|
|
125
|
+
};
|
|
126
|
+
const launched = await run(
|
|
127
|
+
[
|
|
128
|
+
headlessShell(arch),
|
|
129
|
+
'--headless',
|
|
130
|
+
'--no-sandbox',
|
|
131
|
+
'--disable-gpu',
|
|
132
|
+
'--dump-dom',
|
|
133
|
+
'about:blank',
|
|
134
|
+
],
|
|
135
|
+
{ timeoutMs: policy.browser.launchTimeoutSeconds * 1000 }
|
|
136
|
+
);
|
|
137
|
+
if (launched.ok && /<html/i.test(launched.stdout))
|
|
138
|
+
return {
|
|
139
|
+
id,
|
|
140
|
+
status: 'pass',
|
|
141
|
+
summary: `Headless Chromium ${policy.browser.headlessShell.version} launched, loaded a blank page and exited.`,
|
|
142
|
+
};
|
|
143
|
+
const said = `${launched.stdout}\n${launched.stderr}`;
|
|
144
|
+
if (launched.reason === 'missing')
|
|
145
|
+
return failure(
|
|
146
|
+
id,
|
|
147
|
+
'BROWSER_NOT_INSTALLED',
|
|
148
|
+
'The pinned headless Chromium is not installed. Rerun fleet-sync, which installs it on workers with the web profile.'
|
|
149
|
+
);
|
|
150
|
+
if (/error while loading shared libraries/i.test(said))
|
|
151
|
+
return failure(
|
|
152
|
+
id,
|
|
153
|
+
'BROWSER_LIBRARY_MISSING',
|
|
154
|
+
'A system library headless Chromium loads is missing. Rerun fleet-sync, which installs the pinned libraries of the web profile.'
|
|
155
|
+
);
|
|
156
|
+
if (launched.reason === 'timeout')
|
|
157
|
+
return failure(
|
|
158
|
+
id,
|
|
159
|
+
'BROWSER_LAUNCH_TIMEOUT',
|
|
160
|
+
'Headless Chromium did not exit in time. Check the worker for memory pressure, then rerun fleet-sync.'
|
|
161
|
+
);
|
|
162
|
+
return failure(
|
|
163
|
+
id,
|
|
164
|
+
'BROWSER_LAUNCH_FAILED',
|
|
165
|
+
'Headless Chromium did not load a blank page. Run it by hand with --dump-dom about:blank to see why, then rerun fleet-sync.'
|
|
166
|
+
);
|
|
167
|
+
}
|
package/src/worker-capacity.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { clockCheck } from './clock';
|
|
2
3
|
import { healthChecks, parseProbe } from './machine-health';
|
|
3
4
|
import type { Check, Runner } from './types';
|
|
4
5
|
|
|
5
6
|
export async function workerCapacity(
|
|
6
7
|
root: string,
|
|
7
8
|
node: string,
|
|
8
|
-
run: Runner
|
|
9
|
+
run: Runner,
|
|
10
|
+
/** The manager's clock in seconds since the epoch, when it sent one. */
|
|
11
|
+
reference?: number
|
|
9
12
|
): Promise<Check[]> {
|
|
10
13
|
const result = await run(['sh', join(root, 'ansible/net/remote-probe.sh')], {
|
|
11
14
|
timeoutMs: 10_000,
|
|
@@ -25,10 +28,19 @@ export async function workerCapacity(
|
|
|
25
28
|
summary: 'Required CPU, memory and disk evidence is unavailable.',
|
|
26
29
|
},
|
|
27
30
|
];
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
return [
|
|
32
|
+
...healthChecks(sample, node).map((check) => ({
|
|
33
|
+
id: `worker:capacity:${check.id}`,
|
|
34
|
+
status: check.severity,
|
|
35
|
+
summary: check.detail,
|
|
36
|
+
...(check.fix ? { remedy: { action: check.fix } } : {}),
|
|
37
|
+
})),
|
|
38
|
+
clockCheck(
|
|
39
|
+
'worker:clock',
|
|
40
|
+
sample,
|
|
41
|
+
reference === undefined || sample.epoch === undefined
|
|
42
|
+
? undefined
|
|
43
|
+
: sample.epoch - reference
|
|
44
|
+
),
|
|
45
|
+
];
|
|
34
46
|
}
|
|
@@ -5,6 +5,8 @@ import { verifyResourceSafety } from './platform/resource-verify';
|
|
|
5
5
|
import policy from '../ansible/ubuntu/policy.json';
|
|
6
6
|
import { userInfo } from 'node:os';
|
|
7
7
|
import { T3_HEALTH_SCRIPT, runningVersionCheck } from './platform/t3-service';
|
|
8
|
+
import { compareDebianVersions } from './platform/debian-version';
|
|
9
|
+
import { packagePins, ubuntuRelease } from './platform/ubuntu-release';
|
|
8
10
|
import type { Check, Runner } from './types';
|
|
9
11
|
|
|
10
12
|
function check(id: string, ok: boolean, summary: string): Check {
|
|
@@ -22,6 +24,34 @@ function check(id: string, ok: boolean, summary: string): Check {
|
|
|
22
24
|
: {}),
|
|
23
25
|
};
|
|
24
26
|
}
|
|
27
|
+
/** A pin is a minimum (docs/worker-contract.md): Ubuntu's security updates
|
|
28
|
+
* may move a package past it, which is reported but never undone. */
|
|
29
|
+
export function pinnedPackage(
|
|
30
|
+
name: string,
|
|
31
|
+
pinned: string,
|
|
32
|
+
installed: string | undefined
|
|
33
|
+
): Check {
|
|
34
|
+
const order =
|
|
35
|
+
installed === undefined ? -1 : compareDebianVersions(installed, pinned);
|
|
36
|
+
if (order <= 0)
|
|
37
|
+
return check(
|
|
38
|
+
`package:${name}`,
|
|
39
|
+
order === 0,
|
|
40
|
+
order === 0
|
|
41
|
+
? 'Installed at the version the policy pins.'
|
|
42
|
+
: `PACKAGE_BELOW_PIN ${name} ${installed ?? 'missing'}, pinned ${pinned}`
|
|
43
|
+
);
|
|
44
|
+
return {
|
|
45
|
+
id: `worker:package:${name}`,
|
|
46
|
+
status: 'warn',
|
|
47
|
+
summary: `PACKAGE_NEWER_THAN_PIN ${name} ${installed}, pinned ${pinned}`,
|
|
48
|
+
remedy: {
|
|
49
|
+
action:
|
|
50
|
+
'An unattended security update moved this package past its pin, and it is kept. Raise the pin in the Ubuntu policy when the snapshot moves.',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
25
55
|
/** Observed unit state for a failed service check, e.g.
|
|
26
56
|
* `SERVICE_NOT_INSTALLED beszel-agent.service (inactive)`. */
|
|
27
57
|
export function serviceState(name: string, show: string): string {
|
|
@@ -56,17 +86,13 @@ export async function workerHostChecks(
|
|
|
56
86
|
audit.passwordlessSudo,
|
|
57
87
|
'Designated worker user has noninteractive sudo.'
|
|
58
88
|
),
|
|
59
|
-
...Object.entries(
|
|
60
|
-
|
|
61
|
-
`package:${name}`,
|
|
62
|
-
audit.packages[name] === version,
|
|
63
|
-
'Exact declarative Ubuntu package version.'
|
|
64
|
-
)
|
|
89
|
+
...Object.entries(packagePins(ubuntuRelease(audit.release))).map(
|
|
90
|
+
([name, version]) => pinnedPackage(name, version, audit.packages[name])
|
|
65
91
|
),
|
|
66
|
-
|
|
67
|
-
'
|
|
68
|
-
|
|
69
|
-
|
|
92
|
+
pinnedPackage(
|
|
93
|
+
'tailscale',
|
|
94
|
+
policy.tailscale.version,
|
|
95
|
+
audit.packages['tailscale']
|
|
70
96
|
),
|
|
71
97
|
];
|
|
72
98
|
for (const name of policy.alwaysOnServices) {
|
|
@@ -95,6 +121,7 @@ export async function workerHostChecks(
|
|
|
95
121
|
)
|
|
96
122
|
);
|
|
97
123
|
}
|
|
124
|
+
checks.push(await rebootPendingCheck(audit.rebootPending, run));
|
|
98
125
|
checks.push(...(await recoveryChecks(run)));
|
|
99
126
|
checks.push(...(await verifyUbuntuBoot(user, run)));
|
|
100
127
|
checks.push(...(await verifyResourceSafety(userInfo().homedir, run)));
|
|
@@ -202,3 +229,34 @@ async function t3HealthCheck(run: Runner): Promise<Check> {
|
|
|
202
229
|
'T3 local API answers with its environment identity within 10 seconds.'
|
|
203
230
|
);
|
|
204
231
|
}
|
|
232
|
+
|
|
233
|
+
const DAY_SECONDS = 86_400;
|
|
234
|
+
|
|
235
|
+
/** Kernel and library updates apply only after a reboot. A pending reboot
|
|
236
|
+
* warns with its age, and fails once it is older than `policy.json` allows.
|
|
237
|
+
*/
|
|
238
|
+
export async function rebootPendingCheck(
|
|
239
|
+
pending: boolean,
|
|
240
|
+
run: Runner,
|
|
241
|
+
now = Date.now()
|
|
242
|
+
): Promise<Check> {
|
|
243
|
+
const id = 'worker:reboot-pending';
|
|
244
|
+
if (!pending) return { id, status: 'pass', summary: 'No reboot is pending.' };
|
|
245
|
+
const since = await run(['stat', '-c', '%Y', '/var/run/reboot-required']);
|
|
246
|
+
const seconds = Number(since.stdout.trim());
|
|
247
|
+
const days =
|
|
248
|
+
since.ok && /^\d+$/.test(since.stdout.trim())
|
|
249
|
+
? Math.max(0, Math.floor((now / 1000 - seconds) / DAY_SECONDS))
|
|
250
|
+
: null;
|
|
251
|
+
const age = days === null ? '' : ` for ${days} days`;
|
|
252
|
+
return {
|
|
253
|
+
id,
|
|
254
|
+
status:
|
|
255
|
+
days !== null && days > policy.rebootPendingFailDays ? 'fail' : 'warn',
|
|
256
|
+
summary: `REBOOT_PENDING${age}: installed updates apply only after a reboot.`,
|
|
257
|
+
remedy: {
|
|
258
|
+
action:
|
|
259
|
+
'Reboot the worker when no agent turn is running (sudo systemctl reboot on the worker), then confirm it returns to ready with devfleet fleet-status --workers.',
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|