@crvouga/dev-fleet 0.3.0 → 0.4.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 +22 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/policy.json +11 -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/ubuntu/baseline.yml +2 -0
- package/ansible/ubuntu/disk-gc.py +25 -3
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +50 -1
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- 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/declared.md +2 -0
- package/docs/machine-setup.md +22 -12
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/ubuntu-sync.md +7 -6
- 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 +522 -0
- package/schema/fleet.schema.json +178 -1
- 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 +3 -1
- 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 +142 -5
- package/src/git-identity.ts +160 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +12 -2
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +16 -5
- package/src/notify.ts +239 -0
- package/src/remote-bootstrap.ts +87 -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 +32 -0
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
|
@@ -8,6 +8,7 @@ import { publicViewerIdentity, fleetAccount } from './hub-access';
|
|
|
8
8
|
import { ensurePublicViewer, type HubLogin } from './hub-viewer';
|
|
9
9
|
import { verifyHubBackups } from './hub-backup';
|
|
10
10
|
import type { FleetDefinition } from '../definition';
|
|
11
|
+
import { secretVia } from '../fleet-config';
|
|
11
12
|
import { hostId, hubUrl } from '../topology';
|
|
12
13
|
import { publicHubUrl, privateHub } from './beszel';
|
|
13
14
|
import type { Check, Runner } from '../types';
|
|
@@ -56,6 +57,9 @@ export interface ManagerHubOptions {
|
|
|
56
57
|
receipts?: string;
|
|
57
58
|
/** Ensures the read-only AUTO_LOGIN user; defaults to the loopback hub API. */
|
|
58
59
|
viewer?: (login: HubLogin | null) => Promise<Check>;
|
|
60
|
+
/** The declared backups for the hub's playbook; defaults to the fleet
|
|
61
|
+
* configuration and its secrets. */
|
|
62
|
+
backupStorage?: () => Promise<Record<string, string | number | boolean>>;
|
|
59
63
|
/** Reads the hub's backup settings; defaults to the loopback hub API. */
|
|
60
64
|
backups?: (login: HubLogin | null) => Promise<Check>;
|
|
61
65
|
}
|
|
@@ -78,13 +82,19 @@ export async function convergeManagerHub(
|
|
|
78
82
|
const paths = managerHubPaths(options.home);
|
|
79
83
|
const tunnel = await tunnelToken(run);
|
|
80
84
|
const account = await (options.account ?? fleetAccount)();
|
|
85
|
+
const backups = await (
|
|
86
|
+
options.backupStorage ?? (() => backupVariables(definition, run))
|
|
87
|
+
)();
|
|
81
88
|
const monitoring = resolve(root, 'ansible/monitoring');
|
|
82
89
|
const result = await runAnsibleWithReceipt(
|
|
83
90
|
run,
|
|
84
91
|
'hub-darwin.yml',
|
|
85
92
|
{
|
|
86
93
|
cwd: monitoring,
|
|
87
|
-
input: JSON.stringify(
|
|
94
|
+
input: JSON.stringify({
|
|
95
|
+
...hubVariables(hub, paths, tunnel, account),
|
|
96
|
+
...backups,
|
|
97
|
+
}),
|
|
88
98
|
timeoutMs: 300_000,
|
|
89
99
|
},
|
|
90
100
|
{
|
|
@@ -127,7 +137,13 @@ export async function convergeManagerHub(
|
|
|
127
137
|
},
|
|
128
138
|
publicEndpointCheck(tunnel !== null),
|
|
129
139
|
await ensureViewer(options, account),
|
|
130
|
-
await (
|
|
140
|
+
await (
|
|
141
|
+
options.backups ??
|
|
142
|
+
((login) =>
|
|
143
|
+
verifyHubBackups(login, fetch, LOCAL_HUB, {
|
|
144
|
+
declared: definition.services.monitoring.backups,
|
|
145
|
+
}))
|
|
146
|
+
)(account),
|
|
131
147
|
];
|
|
132
148
|
}
|
|
133
149
|
|
|
@@ -153,6 +169,32 @@ function hubVariables(
|
|
|
153
169
|
};
|
|
154
170
|
}
|
|
155
171
|
|
|
172
|
+
/** The declared backup schedule and storage, with the storage's keys from
|
|
173
|
+
* the fleet's secrets; nothing when no backups are declared or a key is
|
|
174
|
+
* missing (beszel:backup then says so).
|
|
175
|
+
*/
|
|
176
|
+
export async function backupVariables(
|
|
177
|
+
definition: FleetDefinition,
|
|
178
|
+
run: Runner
|
|
179
|
+
): Promise<Record<string, string | number | boolean>> {
|
|
180
|
+
const declared = definition.services.monitoring.backups;
|
|
181
|
+
if (!declared) return {};
|
|
182
|
+
const secret = secretVia(run);
|
|
183
|
+
const accessKey = await secret('monitoring-backup-access-key');
|
|
184
|
+
const secretKey = await secret('monitoring-backup-secret-key');
|
|
185
|
+
if (!accessKey || !secretKey) return {};
|
|
186
|
+
return {
|
|
187
|
+
beszel_backup_schedule: declared.schedule,
|
|
188
|
+
beszel_backup_keep: declared.keep,
|
|
189
|
+
beszel_backup_bucket: declared.storage.bucket,
|
|
190
|
+
beszel_backup_endpoint: declared.storage.endpoint,
|
|
191
|
+
beszel_backup_region: declared.storage.region,
|
|
192
|
+
beszel_backup_force_path_style: declared.storage.forcePathStyle ?? false,
|
|
193
|
+
beszel_backup_access_key: accessKey,
|
|
194
|
+
beszel_backup_secret_key: secretKey,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
156
198
|
function ensureViewer(
|
|
157
199
|
options: ManagerHubOptions,
|
|
158
200
|
account: HubLogin | null
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
import { userInfo } from 'node:os';
|
|
23
23
|
import { resolve } from 'node:path';
|
|
24
24
|
import { auditUbuntu } from './platform/ubuntu-audit';
|
|
25
|
+
import { convergeGitIdentity } from './git-identity';
|
|
26
|
+
import { workerSelection } from '../bootstrap/worker-tools';
|
|
25
27
|
import {
|
|
26
28
|
applyAgentProfiles,
|
|
27
29
|
attempt,
|
|
@@ -59,7 +61,7 @@ export async function convergeLinux(
|
|
|
59
61
|
await applyTailscale(root, run),
|
|
60
62
|
]))
|
|
61
63
|
);
|
|
62
|
-
const { baseline, verified } = await baselineStep(root, run);
|
|
64
|
+
const { baseline, verified } = await baselineStep(root, run, node);
|
|
63
65
|
checks.push(...baseline, ...verified);
|
|
64
66
|
checks.push(...(await t3AfterBaseline(root, { node, baseline, run })));
|
|
65
67
|
checks.push(
|
|
@@ -67,6 +69,11 @@ export async function convergeLinux(
|
|
|
67
69
|
configureWorkstation(root, run)
|
|
68
70
|
))
|
|
69
71
|
);
|
|
72
|
+
checks.push(
|
|
73
|
+
...(await attempt('git:configuration', () =>
|
|
74
|
+
convergeGitIdentity(root, fleet.git, run)
|
|
75
|
+
))
|
|
76
|
+
);
|
|
70
77
|
checks.push(
|
|
71
78
|
...(await attempt('configuration:apply', () =>
|
|
72
79
|
applyAgentProfiles(root, node, revision, run)
|
|
@@ -110,7 +117,8 @@ export async function convergeLinux(
|
|
|
110
117
|
*/
|
|
111
118
|
async function baselineStep(
|
|
112
119
|
root: string,
|
|
113
|
-
run: Runner
|
|
120
|
+
run: Runner,
|
|
121
|
+
node: string
|
|
114
122
|
): Promise<{ baseline: Check[]; verified: Check[] }> {
|
|
115
123
|
const measure = async () => [
|
|
116
124
|
...(await attempt('ubuntu:boot-policy', () =>
|
|
@@ -122,14 +130,14 @@ async function baselineStep(
|
|
|
122
130
|
];
|
|
123
131
|
let cached = false;
|
|
124
132
|
let baseline = await attempt('ubuntu:baseline', async () => {
|
|
125
|
-
const applied = await applyBaseline(root, run, false);
|
|
133
|
+
const applied = await applyBaseline(root, run, false, node);
|
|
126
134
|
cached = applied.cached;
|
|
127
135
|
return applied.checks;
|
|
128
136
|
});
|
|
129
137
|
let verified = await measure();
|
|
130
138
|
if (cached && verified.some((check) => check.status === 'fail')) {
|
|
131
139
|
baseline = await attempt('ubuntu:baseline', async () => {
|
|
132
|
-
return (await applyBaseline(root, run, true)).checks;
|
|
140
|
+
return (await applyBaseline(root, run, true, node)).checks;
|
|
133
141
|
});
|
|
134
142
|
verified = await measure();
|
|
135
143
|
}
|
|
@@ -139,7 +147,8 @@ async function baselineStep(
|
|
|
139
147
|
async function applyBaseline(
|
|
140
148
|
root: string,
|
|
141
149
|
run: Runner,
|
|
142
|
-
force: boolean
|
|
150
|
+
force: boolean,
|
|
151
|
+
node: string
|
|
143
152
|
): Promise<{ cached: boolean; checks: Check[] }> {
|
|
144
153
|
const user = userInfo();
|
|
145
154
|
const ubuntu = resolve(root, 'ansible/ubuntu');
|
|
@@ -154,6 +163,8 @@ async function applyBaseline(
|
|
|
154
163
|
development_user: user.username,
|
|
155
164
|
development_uid: user.uid,
|
|
156
165
|
development_home: user.homedir,
|
|
166
|
+
// The web profile brings headless Chromium (tasks/browser.yml).
|
|
167
|
+
worker_tool_profiles: workerSelection(fleet, node).profiles,
|
|
157
168
|
}),
|
|
158
169
|
},
|
|
159
170
|
{
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { record } from './config';
|
|
2
|
+
import type { SecretName, SecretsProvider } from './secrets';
|
|
3
|
+
import type { Check, NodeReport } from './types';
|
|
4
|
+
|
|
5
|
+
/** Where fleet-watch sends a notification. The address of each target is a
|
|
6
|
+
* secret, read by purpose: a webhook or ntfy URL often carries its own token.
|
|
7
|
+
*/
|
|
8
|
+
export const NOTIFY_TARGETS = {
|
|
9
|
+
webhook: 'notify-webhook-url',
|
|
10
|
+
ntfy: 'notify-ntfy-url',
|
|
11
|
+
} as const satisfies Record<string, SecretName>;
|
|
12
|
+
export type NotifyTarget = keyof typeof NOTIFY_TARGETS;
|
|
13
|
+
|
|
14
|
+
export interface NotifyConfig {
|
|
15
|
+
targets: NotifyTarget[];
|
|
16
|
+
/** Notify again about a machine that stays unhealthy this often; 0 never. */
|
|
17
|
+
repeatHours: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_REPEAT_HOURS = 24;
|
|
21
|
+
|
|
22
|
+
/** The `notify` block of a fleet configuration, or null when it is invalid. */
|
|
23
|
+
export function parseNotify(value: unknown): NotifyConfig | null {
|
|
24
|
+
if (!record(value)) return null;
|
|
25
|
+
if (
|
|
26
|
+
Object.keys(value).some((key) => !['targets', 'repeatHours'].includes(key))
|
|
27
|
+
)
|
|
28
|
+
return null;
|
|
29
|
+
const { targets, repeatHours = DEFAULT_REPEAT_HOURS } = value;
|
|
30
|
+
if (
|
|
31
|
+
!Array.isArray(targets) ||
|
|
32
|
+
targets.length === 0 ||
|
|
33
|
+
new Set(targets).size !== targets.length ||
|
|
34
|
+
!targets.every(
|
|
35
|
+
(target) =>
|
|
36
|
+
typeof target === 'string' && Object.hasOwn(NOTIFY_TARGETS, target)
|
|
37
|
+
) ||
|
|
38
|
+
typeof repeatHours !== 'number' ||
|
|
39
|
+
!Number.isInteger(repeatHours) ||
|
|
40
|
+
repeatHours < 0 ||
|
|
41
|
+
repeatHours > 720
|
|
42
|
+
)
|
|
43
|
+
return null;
|
|
44
|
+
return { targets: targets as NotifyTarget[], repeatHours };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the watcher remembers of each machine between two runs. */
|
|
48
|
+
export interface WatchMemory {
|
|
49
|
+
version: 1;
|
|
50
|
+
/** When the watcher last ran: the heartbeat an off-machine check reads. */
|
|
51
|
+
heartbeatAt: string;
|
|
52
|
+
nodes: Record<
|
|
53
|
+
string,
|
|
54
|
+
{ state: 'ready' | 'degraded'; since: string; notifiedAt: string | null }
|
|
55
|
+
>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One machine as fleet-status saw it: the IDs of its failing checks. */
|
|
59
|
+
export interface Observation {
|
|
60
|
+
node: string;
|
|
61
|
+
failing: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Notification {
|
|
65
|
+
event: 'degraded' | 'recovered' | 'still-degraded' | 'test';
|
|
66
|
+
node: string;
|
|
67
|
+
checks: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Only what a report concluded: the required checks that failed. */
|
|
71
|
+
export function observe(nodes: NodeReport[]): Observation[] {
|
|
72
|
+
return nodes.map((node) => ({
|
|
73
|
+
node: node.node,
|
|
74
|
+
failing: node.checks
|
|
75
|
+
.filter((check) => check.status === 'fail' && check.required !== false)
|
|
76
|
+
.map((check) => check.id),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Notifications on transitions only (ready to degraded and back), and a
|
|
81
|
+
* reminder for a machine that stays degraded longer than the repeat interval.
|
|
82
|
+
* A machine never seen before counts as ready.
|
|
83
|
+
*/
|
|
84
|
+
export function transitions(
|
|
85
|
+
previous: WatchMemory | null,
|
|
86
|
+
observed: Observation[],
|
|
87
|
+
now: Date,
|
|
88
|
+
repeatHours: number
|
|
89
|
+
): { notifications: Notification[]; memory: WatchMemory } {
|
|
90
|
+
const at = now.toISOString();
|
|
91
|
+
const notifications: Notification[] = [];
|
|
92
|
+
const nodes: WatchMemory['nodes'] = {};
|
|
93
|
+
for (const { node, failing } of observed) {
|
|
94
|
+
const before = previous?.nodes[node];
|
|
95
|
+
const state = failing.length ? 'degraded' : 'ready';
|
|
96
|
+
const checks = failing.filter(safe);
|
|
97
|
+
if (state === (before?.state ?? 'ready')) {
|
|
98
|
+
const due = state === 'degraded' && repeatDue(before, now, repeatHours);
|
|
99
|
+
if (due) notifications.push({ event: 'still-degraded', node, checks });
|
|
100
|
+
nodes[node] = before
|
|
101
|
+
? { ...before, ...(due ? { notifiedAt: at } : {}) }
|
|
102
|
+
: { state, since: at, notifiedAt: null };
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
notifications.push({
|
|
106
|
+
event: state === 'degraded' ? 'degraded' : 'recovered',
|
|
107
|
+
node,
|
|
108
|
+
checks,
|
|
109
|
+
});
|
|
110
|
+
nodes[node] = { state, since: at, notifiedAt: at };
|
|
111
|
+
}
|
|
112
|
+
return { notifications, memory: { version: 1, heartbeatAt: at, nodes } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function repeatDue(
|
|
116
|
+
before: WatchMemory['nodes'][string] | undefined,
|
|
117
|
+
now: Date,
|
|
118
|
+
repeatHours: number
|
|
119
|
+
): boolean {
|
|
120
|
+
if (repeatHours <= 0 || !before?.notifiedAt) return false;
|
|
121
|
+
return (
|
|
122
|
+
now.getTime() - Date.parse(before.notifiedAt) >= repeatHours * 3_600_000
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The memory stored last time, or null when there is none to trust. */
|
|
127
|
+
export function parseMemory(text: string | null): WatchMemory | null {
|
|
128
|
+
if (!text) return null;
|
|
129
|
+
try {
|
|
130
|
+
const value: unknown = JSON.parse(text);
|
|
131
|
+
if (
|
|
132
|
+
!record(value) ||
|
|
133
|
+
value['version'] !== 1 ||
|
|
134
|
+
typeof value['heartbeatAt'] !== 'string' ||
|
|
135
|
+
!record(value['nodes'])
|
|
136
|
+
)
|
|
137
|
+
return null;
|
|
138
|
+
return value as unknown as WatchMemory;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Node and check IDs are the only words a notification carries. */
|
|
145
|
+
const safe = (id: string): boolean => /^[A-Za-z0-9:._@-]{1,128}$/.test(id);
|
|
146
|
+
|
|
147
|
+
const WORDS: Record<Notification['event'], string> = {
|
|
148
|
+
degraded: 'is degraded',
|
|
149
|
+
recovered: 'is ready again',
|
|
150
|
+
'still-degraded': 'is still degraded',
|
|
151
|
+
test: 'sent a test notification',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export function notificationText(notification: Notification): string {
|
|
155
|
+
const node = safe(notification.node) ? notification.node : 'a machine';
|
|
156
|
+
const checks = notification.checks.filter(safe);
|
|
157
|
+
return `dev-fleet: ${node} ${WORDS[notification.event]}${
|
|
158
|
+
checks.length ? `. Failing checks: ${checks.join(', ')}` : ''
|
|
159
|
+
}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Sends one notification to every target. The result names the target and
|
|
163
|
+
* whether it took the notification: never its address or its answer.
|
|
164
|
+
*/
|
|
165
|
+
export async function deliver(
|
|
166
|
+
config: NotifyConfig,
|
|
167
|
+
notification: Notification,
|
|
168
|
+
secrets: SecretsProvider,
|
|
169
|
+
send: typeof fetch = fetch
|
|
170
|
+
): Promise<Check[]> {
|
|
171
|
+
const text = notificationText(notification);
|
|
172
|
+
const checks: Check[] = [];
|
|
173
|
+
for (const target of config.targets) {
|
|
174
|
+
const id = `notify:${target}`;
|
|
175
|
+
const url = await secrets(NOTIFY_TARGETS[target]);
|
|
176
|
+
if (!url || !/^https?:\/\//.test(url)) {
|
|
177
|
+
checks.push({
|
|
178
|
+
id,
|
|
179
|
+
status: 'fail',
|
|
180
|
+
summary: 'NOTIFY_TARGET_UNAVAILABLE',
|
|
181
|
+
remedy: {
|
|
182
|
+
action: `Store the address of the ${target} target as the secret "${NOTIFY_TARGETS[target]}" with the secret provider fleet.json names.`,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const ok = await send(url, request(target, notification, text))
|
|
188
|
+
.then((response) => response.ok)
|
|
189
|
+
.catch(() => false);
|
|
190
|
+
checks.push(
|
|
191
|
+
ok
|
|
192
|
+
? {
|
|
193
|
+
id,
|
|
194
|
+
status: 'pass',
|
|
195
|
+
summary: `The ${target} target took the notification.`,
|
|
196
|
+
}
|
|
197
|
+
: {
|
|
198
|
+
id,
|
|
199
|
+
status: 'fail',
|
|
200
|
+
summary: 'NOTIFY_DELIVERY_FAILED',
|
|
201
|
+
remedy: {
|
|
202
|
+
action: `Check that the ${target} target answers, then run devfleet fleet-watch --test.`,
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return checks;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function request(
|
|
211
|
+
target: NotifyTarget,
|
|
212
|
+
notification: Notification,
|
|
213
|
+
text: string
|
|
214
|
+
): RequestInit {
|
|
215
|
+
const signal = AbortSignal.timeout(15_000);
|
|
216
|
+
if (target === 'ntfy')
|
|
217
|
+
return {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
body: text,
|
|
220
|
+
headers: {
|
|
221
|
+
Title: `dev-fleet: ${notification.event}`,
|
|
222
|
+
Tags:
|
|
223
|
+
notification.event === 'recovered' ? 'white_check_mark' : 'warning',
|
|
224
|
+
},
|
|
225
|
+
signal,
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
body: JSON.stringify({
|
|
231
|
+
source: 'dev-fleet',
|
|
232
|
+
event: notification.event,
|
|
233
|
+
node: safe(notification.node) ? notification.node : null,
|
|
234
|
+
checks: notification.checks.filter(safe),
|
|
235
|
+
text,
|
|
236
|
+
}),
|
|
237
|
+
signal,
|
|
238
|
+
};
|
|
239
|
+
}
|
package/src/remote-bootstrap.ts
CHANGED
|
@@ -4,7 +4,22 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { prepareSource } from './machine/source';
|
|
5
5
|
import { isRevision } from './machine/state';
|
|
6
6
|
import { sshArgs, shellQuote, sshFailure } from './ssh';
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
FleetNode,
|
|
9
|
+
NodeReport,
|
|
10
|
+
Runner,
|
|
11
|
+
Check,
|
|
12
|
+
CommandResult,
|
|
13
|
+
} from './types';
|
|
14
|
+
import {
|
|
15
|
+
followOperation,
|
|
16
|
+
realClock,
|
|
17
|
+
retrying,
|
|
18
|
+
RETRY_PAUSES_MS,
|
|
19
|
+
transient,
|
|
20
|
+
type Clock,
|
|
21
|
+
type Follow,
|
|
22
|
+
} from './remote-operation';
|
|
8
23
|
import { record } from './config';
|
|
9
24
|
import { installedDefinition, type FleetDefinition } from './definition';
|
|
10
25
|
import { cleanCheck, validCheck } from './protocol';
|
|
@@ -94,9 +109,11 @@ async function bundleRevision(
|
|
|
94
109
|
export async function bootstrapRemote(
|
|
95
110
|
node: FleetNode,
|
|
96
111
|
payload: BootstrapPayload,
|
|
97
|
-
run: Runner
|
|
112
|
+
run: Runner,
|
|
113
|
+
follow: Follow = {}
|
|
98
114
|
): Promise<NodeReport> {
|
|
99
|
-
const
|
|
115
|
+
const clock = follow.clock ?? realClock;
|
|
116
|
+
const cached = await cachedRevision(node, payload.revision, run, clock);
|
|
100
117
|
if (typeof cached !== 'boolean') return { node: node.id, checks: [cached] };
|
|
101
118
|
const transfer = cached
|
|
102
119
|
? { mode: 'cached', bundle: '' }
|
|
@@ -113,27 +130,68 @@ export async function bootstrapRemote(
|
|
|
113
130
|
]
|
|
114
131
|
.map(shellQuote)
|
|
115
132
|
.join(' ');
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
133
|
+
const checks = await bootstrapSessions(
|
|
134
|
+
node,
|
|
135
|
+
payload,
|
|
136
|
+
{ command, input: bootstrapInput(payload.config, transfer.bundle) },
|
|
137
|
+
{ run, follow: { ...follow, clock } }
|
|
138
|
+
);
|
|
139
|
+
return { node: node.id, checks };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Opens the bootstrap session, and again after a lost one unless the
|
|
143
|
+
* worker's own operation can be followed to its end instead.
|
|
144
|
+
*/
|
|
145
|
+
async function bootstrapSessions(
|
|
146
|
+
node: FleetNode,
|
|
147
|
+
payload: BootstrapPayload,
|
|
148
|
+
session: { command: string; input: string },
|
|
149
|
+
context: { run: Runner; follow: Follow & { clock: Clock } }
|
|
150
|
+
): Promise<Check[]> {
|
|
151
|
+
const { run, follow } = context;
|
|
152
|
+
let result: CommandResult | undefined;
|
|
153
|
+
for (const pause of [0, ...RETRY_PAUSES_MS]) {
|
|
154
|
+
if (result) {
|
|
155
|
+
// The session was lost, not the operation: the worker keeps running
|
|
156
|
+
// it, so the manager follows it rather than starting another.
|
|
157
|
+
await follow.clock.pause(pause);
|
|
158
|
+
const followed = await followOperation(
|
|
159
|
+
node,
|
|
160
|
+
payload.revision,
|
|
161
|
+
run,
|
|
162
|
+
follow
|
|
163
|
+
);
|
|
164
|
+
if (followed) return followed;
|
|
165
|
+
}
|
|
166
|
+
result = await run([...sshArgs(node), session.command], {
|
|
167
|
+
input: session.input,
|
|
168
|
+
timeoutMs: 5_400_000,
|
|
169
|
+
});
|
|
170
|
+
const evidence = parseEvidence(result);
|
|
171
|
+
if (evidence && locked(evidence))
|
|
172
|
+
return (
|
|
173
|
+
(await followOperation(node, payload.revision, run, follow)) ?? evidence
|
|
174
|
+
);
|
|
175
|
+
if (evidence) return evidence;
|
|
176
|
+
if (result.reason !== 'exit' || !transient(result)) break;
|
|
177
|
+
}
|
|
178
|
+
if (!result!.ok) return [sshFailure(result!)];
|
|
179
|
+
return [
|
|
180
|
+
{
|
|
181
|
+
id: 'bootstrap:protocol',
|
|
182
|
+
status: 'fail',
|
|
183
|
+
summary: 'REMOTE_BOOTSTRAP_EVIDENCE_INVALID',
|
|
184
|
+
remedy: {
|
|
185
|
+
action:
|
|
186
|
+
'Inspect the source-owned remote operation and lock. Do not assume a manager disconnect stopped installation.',
|
|
134
187
|
},
|
|
135
|
-
|
|
136
|
-
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Another operation holds the worker: its lock was the first thing tried. */
|
|
193
|
+
function locked(checks: Check[]): boolean {
|
|
194
|
+
return checks.some((check) => check.summary === 'FLEET_REMOTE_LOCKED');
|
|
137
195
|
}
|
|
138
196
|
|
|
139
197
|
/** What the bootstrap reads on stdin: one line with the fleet configuration
|
|
@@ -151,10 +209,14 @@ export function bootstrapInput(
|
|
|
151
209
|
async function cachedRevision(
|
|
152
210
|
node: FleetNode,
|
|
153
211
|
revision: string,
|
|
154
|
-
run: Runner
|
|
212
|
+
run: Runner,
|
|
213
|
+
clock: Clock
|
|
155
214
|
): Promise<boolean | Check> {
|
|
156
215
|
const command = `d="$HOME/.local/share/personal/fleet/revisions/${revision}"; test ! -L "$d" && test -d "$d/.git" && test "$(git -C "$d" rev-parse HEAD 2>/dev/null)" = '${revision}' && test "$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" = "$d" && clean=$(git -C "$d" status --porcelain 2>/dev/null) && test -z "$clean" && printf ready`;
|
|
157
|
-
const result = await
|
|
216
|
+
const result = await retrying(
|
|
217
|
+
() => run([...sshArgs(node), command], { timeoutMs: 20_000 }),
|
|
218
|
+
clock
|
|
219
|
+
);
|
|
158
220
|
if (!result.ok) {
|
|
159
221
|
const failure = sshFailure(result);
|
|
160
222
|
if (result.reason !== 'exit' || failure.id !== 'ssh:probe') return failure;
|