@crvouga/dev-fleet 0.2.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 +50 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/policy.json +11 -1
- package/ansible/macos/t3-policy.yml +49 -0
- 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 +51 -1
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +4 -4
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +30 -2
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/authentication.md +90 -0
- package/docs/declared.md +5 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +26 -15
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +35 -28
- package/examples/fleet.json +19 -1
- package/llms.txt +3 -0
- package/mise.toml +10 -1
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +244 -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/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +128 -2
- package/src/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +31 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +23 -14
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +126 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +86 -0
- package/src/cli/registry.ts +26 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +226 -7
- package/src/git-identity.ts +160 -0
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +13 -17
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +44 -12
- package/src/notify.ts +239 -0
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-entry.ts +15 -1
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/service-auth.ts +15 -5
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-linked-check.ts +1 -1
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/transport.ts +3 -2
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +68 -13
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
package/src/cli/fleet-sync.ts
CHANGED
|
@@ -20,13 +20,22 @@ import {
|
|
|
20
20
|
} from '../machine/state';
|
|
21
21
|
import { loadEnrollment } from '../state/enrollment';
|
|
22
22
|
import { legacyHome } from '../state/paths';
|
|
23
|
+
import {
|
|
24
|
+
appendRun,
|
|
25
|
+
summarizeRun,
|
|
26
|
+
type SyncRun,
|
|
27
|
+
type Trigger,
|
|
28
|
+
} from '../state/history';
|
|
29
|
+
import { capScheduleLogs } from './schedule-logs';
|
|
23
30
|
import {
|
|
24
31
|
DIVERGED_REMEDY,
|
|
32
|
+
mutateState,
|
|
25
33
|
refresh,
|
|
26
34
|
stateContext,
|
|
27
35
|
type Standing,
|
|
28
36
|
} from '../state/sync';
|
|
29
37
|
import { runRecord } from './fleet-sync-state';
|
|
38
|
+
import { planSources, planText, syncPlan } from './fleet-sync-plan';
|
|
30
39
|
import { runCommand } from '../runner';
|
|
31
40
|
import type { Runner } from '../types';
|
|
32
41
|
import type { Settings } from './settings';
|
|
@@ -36,6 +45,7 @@ import { FleetLockError } from '../machine/lock-error';
|
|
|
36
45
|
import { selected } from './fleet-sync-remote';
|
|
37
46
|
import { managerIdentity } from './fleet-sync-manager';
|
|
38
47
|
import { managerRunScript, rehome } from './fleet-sync-checkout';
|
|
48
|
+
import { syncAccounts } from './fleet-sync-accounts';
|
|
39
49
|
import { packageInfo } from '../package-info';
|
|
40
50
|
import {
|
|
41
51
|
forwardingDisabled,
|
|
@@ -53,6 +63,8 @@ export interface FleetSyncOptions {
|
|
|
53
63
|
node?: string[];
|
|
54
64
|
all?: boolean;
|
|
55
65
|
schedule?: ScheduleAction;
|
|
66
|
+
/** `accounts`: only the account-derived configuration, on every node. */
|
|
67
|
+
only?: string;
|
|
56
68
|
/** Internal: this revision already converged the whole fleet, so no canary
|
|
57
69
|
* has to prove it first and every worker converges at once.
|
|
58
70
|
*/
|
|
@@ -80,11 +92,44 @@ export async function fleetSync(
|
|
|
80
92
|
if (!(await onManager(run)))
|
|
81
93
|
return offManager(options, settings, run, forward);
|
|
82
94
|
if (options.schedule === 'status') return scheduleStatus(settings, run);
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
if (options.only === 'accounts') return onlyAccounts(options, settings, run);
|
|
96
|
+
const sync = () =>
|
|
97
|
+
managerSync(options, settings, {
|
|
98
|
+
run,
|
|
99
|
+
root: machineRoot(),
|
|
100
|
+
restart: sync,
|
|
101
|
+
});
|
|
102
|
+
if (options.dryRun || options.schedule) return sync();
|
|
103
|
+
const trigger = runTrigger();
|
|
104
|
+
if (trigger === 'schedule') capScheduleLogs(join(machineRoot(), 'logs'));
|
|
105
|
+
const startedAt = new Date();
|
|
106
|
+
const outcome = await sync();
|
|
107
|
+
await recordRun(
|
|
108
|
+
summarizeRun(
|
|
109
|
+
outcome.data,
|
|
110
|
+
{ startedAt, finishedAt: new Date() },
|
|
111
|
+
trigger,
|
|
112
|
+
outcome.exitCode
|
|
113
|
+
),
|
|
114
|
+
run
|
|
115
|
+
);
|
|
116
|
+
return outcome;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Runs the manager's schedule starts say so in their environment. */
|
|
120
|
+
function runTrigger(env = process.env): Trigger {
|
|
121
|
+
return env['DEVFLEET_TRIGGER'] === 'schedule' ? 'schedule' : 'operator';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Appends the run to the history in the fleet state and uploads it. A
|
|
125
|
+
* history that cannot be written never changes the run's result.
|
|
126
|
+
*/
|
|
127
|
+
async function recordRun(entry: SyncRun, run: Runner): Promise<void> {
|
|
128
|
+
try {
|
|
129
|
+
await mutateState(stateContext(run), () => appendRun(entry));
|
|
130
|
+
} catch {
|
|
131
|
+
// Reported by the next fleet-state or fleet-sync run.
|
|
132
|
+
}
|
|
88
133
|
}
|
|
89
134
|
|
|
90
135
|
export interface ManagerSync {
|
|
@@ -128,9 +173,9 @@ export async function managerSync(
|
|
|
128
173
|
revision
|
|
129
174
|
);
|
|
130
175
|
if (rehomed !== 'here') return rehomed;
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
//
|
|
176
|
+
// The bucket is the source of truth: a run, and a preview, starts from its
|
|
177
|
+
// version of the configuration, the roster and the approval, not from a
|
|
178
|
+
// stale local copy. Refreshing never uploads.
|
|
134
179
|
const fresh = await refresh(stateContext(run));
|
|
135
180
|
if (fresh.standing === 'diverged')
|
|
136
181
|
return actionRequired('FLEET_STATE_DIVERGED', DIVERGED_REMEDY, revision);
|
|
@@ -140,6 +185,13 @@ export async function managerSync(
|
|
|
140
185
|
reloadFleet();
|
|
141
186
|
return restart();
|
|
142
187
|
}
|
|
188
|
+
if (options.dryRun)
|
|
189
|
+
return preview({ ...options, revision }, settings, {
|
|
190
|
+
run,
|
|
191
|
+
root,
|
|
192
|
+
applied: state?.appliedRevision ?? null,
|
|
193
|
+
standing: fresh.standing,
|
|
194
|
+
});
|
|
143
195
|
return authorizeAndApply(root, state, options, {
|
|
144
196
|
settings,
|
|
145
197
|
run,
|
|
@@ -148,6 +200,40 @@ export async function managerSync(
|
|
|
148
200
|
});
|
|
149
201
|
}
|
|
150
202
|
|
|
203
|
+
/** The preview: the read-only report of every machine, and the plan of what
|
|
204
|
+
* a sync would change on them.
|
|
205
|
+
*/
|
|
206
|
+
async function preview(
|
|
207
|
+
options: FleetSyncOptions & { revision: string },
|
|
208
|
+
settings: Settings,
|
|
209
|
+
context: {
|
|
210
|
+
run: Runner;
|
|
211
|
+
root: string;
|
|
212
|
+
applied: string | null;
|
|
213
|
+
standing: Standing;
|
|
214
|
+
}
|
|
215
|
+
): Promise<CommandOutcome> {
|
|
216
|
+
const outcome = await selected(options, settings, context.run);
|
|
217
|
+
const report = outcome.data as { nodes?: { node?: unknown }[] };
|
|
218
|
+
const plan = await syncPlan(
|
|
219
|
+
{
|
|
220
|
+
from: context.applied,
|
|
221
|
+
to: options.revision,
|
|
222
|
+
state: context.standing,
|
|
223
|
+
nodes: (report.nodes ?? [])
|
|
224
|
+
.map((node) => node.node)
|
|
225
|
+
.filter((node): node is string => typeof node === 'string'),
|
|
226
|
+
sources: planSources(settings.workspace, context.root),
|
|
227
|
+
},
|
|
228
|
+
context.run
|
|
229
|
+
);
|
|
230
|
+
return {
|
|
231
|
+
...outcome,
|
|
232
|
+
data: { ...(outcome.data as object), plan },
|
|
233
|
+
text: [outcome.text, planText(plan)].filter(Boolean).join('\n'),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
151
237
|
interface ApplyContext {
|
|
152
238
|
settings: Settings;
|
|
153
239
|
run: Runner;
|
|
@@ -289,11 +375,43 @@ function validate(options: FleetSyncOptions): void {
|
|
|
289
375
|
throw new FleetUsageError(
|
|
290
376
|
'--revision requires an exact lowercase 40-character commit SHA.'
|
|
291
377
|
);
|
|
378
|
+
validateOnly(options);
|
|
292
379
|
if (options.dryRun && (options.apply || options.yes))
|
|
293
380
|
throw new FleetUsageError(
|
|
294
381
|
'--dry-run cannot be combined with apply confirmation.'
|
|
295
382
|
);
|
|
296
383
|
}
|
|
384
|
+
function validateOnly(options: FleetSyncOptions): void {
|
|
385
|
+
if (options.only !== undefined && options.only !== 'accounts')
|
|
386
|
+
throw new FleetUsageError('--only takes one scope: accounts.');
|
|
387
|
+
if (options.only && (options.dryRun || options.schedule))
|
|
388
|
+
throw new FleetUsageError(
|
|
389
|
+
'--only accounts applies at once; it takes neither --dry-run nor --schedule.'
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** `--only accounts`: workers keep the revision they run; only their
|
|
394
|
+
* accounts change.
|
|
395
|
+
*/
|
|
396
|
+
async function onlyAccounts(
|
|
397
|
+
options: FleetSyncOptions,
|
|
398
|
+
settings: Settings,
|
|
399
|
+
run: Runner
|
|
400
|
+
): Promise<CommandOutcome> {
|
|
401
|
+
const root = machineRoot();
|
|
402
|
+
await ensurePrivateRoot(root);
|
|
403
|
+
const state = await loadEnrollment(legacyHome() ? root : null);
|
|
404
|
+
const revision =
|
|
405
|
+
state?.approvedRevision ??
|
|
406
|
+
(await selectedRevision(options.revision, undefined, settings, run));
|
|
407
|
+
if (!isRevision(revision))
|
|
408
|
+
return actionRequired(
|
|
409
|
+
'FLEET_REVISION_REQUIRED',
|
|
410
|
+
'No revision is applied yet. Run a full fleet-sync first.'
|
|
411
|
+
);
|
|
412
|
+
return syncAccounts(options, settings, { revision, run });
|
|
413
|
+
}
|
|
414
|
+
|
|
297
415
|
function interactive(options: FleetSyncOptions): boolean {
|
|
298
416
|
return (
|
|
299
417
|
![
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fleet, secret } from '../fleet-config';
|
|
4
|
+
import {
|
|
5
|
+
deliver,
|
|
6
|
+
observe,
|
|
7
|
+
parseMemory,
|
|
8
|
+
transitions,
|
|
9
|
+
type Notification,
|
|
10
|
+
type NotifyConfig,
|
|
11
|
+
} from '../notify';
|
|
12
|
+
import { connection } from '../state/connection';
|
|
13
|
+
import type { Bucket } from '../state/bucket';
|
|
14
|
+
import type { SecretsProvider } from '../secrets';
|
|
15
|
+
import type { Check, NodeReport } from '../types';
|
|
16
|
+
import type { CommandOutcome } from './contracts';
|
|
17
|
+
|
|
18
|
+
export interface FleetWatchOptions {
|
|
19
|
+
test?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What the watcher remembers between runs, kept in the bucket beside the
|
|
23
|
+
* fleet state. Its time is the heartbeat an off-machine check reads.
|
|
24
|
+
*/
|
|
25
|
+
export interface WatchStore {
|
|
26
|
+
read: () => Promise<string | null>;
|
|
27
|
+
write: (text: string) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FleetWatchServices {
|
|
31
|
+
notify: NotifyConfig | undefined;
|
|
32
|
+
/** The machines as `fleet-status --workers` sees them now. */
|
|
33
|
+
status: () => Promise<NodeReport[]>;
|
|
34
|
+
store: WatchStore | null;
|
|
35
|
+
secrets: SecretsProvider;
|
|
36
|
+
send: typeof fetch;
|
|
37
|
+
now: () => Date;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Compares the fleet's health with the previous run and notifies on
|
|
41
|
+
* transitions only. `--test` sends one test notification and nothing else.
|
|
42
|
+
*/
|
|
43
|
+
export async function fleetWatch(
|
|
44
|
+
options: FleetWatchOptions,
|
|
45
|
+
services: FleetWatchServices
|
|
46
|
+
): Promise<CommandOutcome> {
|
|
47
|
+
const { notify } = services;
|
|
48
|
+
if (options.test) {
|
|
49
|
+
const checks = notify
|
|
50
|
+
? await deliver(
|
|
51
|
+
notify,
|
|
52
|
+
{ event: 'test', node: 'fleet-watch', checks: [] },
|
|
53
|
+
services.secrets,
|
|
54
|
+
services.send
|
|
55
|
+
)
|
|
56
|
+
: [unconfigured('fail')];
|
|
57
|
+
return outcome(checks, [
|
|
58
|
+
{ event: 'test', node: 'fleet-watch', checks: [] },
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
const previous = services.store
|
|
62
|
+
? parseMemory(await services.store.read().catch(() => null))
|
|
63
|
+
: null;
|
|
64
|
+
const observed = observe(await services.status());
|
|
65
|
+
const { notifications, memory } = transitions(
|
|
66
|
+
previous,
|
|
67
|
+
observed,
|
|
68
|
+
services.now(),
|
|
69
|
+
notify?.repeatHours ?? 0
|
|
70
|
+
);
|
|
71
|
+
const checks: Check[] = [];
|
|
72
|
+
for (const notification of notifications)
|
|
73
|
+
if (notify)
|
|
74
|
+
checks.push(
|
|
75
|
+
...(await deliver(
|
|
76
|
+
notify,
|
|
77
|
+
notification,
|
|
78
|
+
services.secrets,
|
|
79
|
+
services.send
|
|
80
|
+
))
|
|
81
|
+
);
|
|
82
|
+
if (!notify) checks.push(unconfigured('warn'));
|
|
83
|
+
checks.push(await heartbeat(services.store, JSON.stringify(memory)));
|
|
84
|
+
return outcome(checks, notifications);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function unconfigured(status: 'warn' | 'fail'): Check {
|
|
88
|
+
return {
|
|
89
|
+
id: 'notify:configured',
|
|
90
|
+
status,
|
|
91
|
+
summary: 'NOTIFY_NOT_CONFIGURED',
|
|
92
|
+
remedy: {
|
|
93
|
+
action:
|
|
94
|
+
'Add a "notify" block to fleet.json with its targets, and store each target address as its secret (notify-webhook-url, notify-ntfy-url).',
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function heartbeat(
|
|
100
|
+
store: WatchStore | null,
|
|
101
|
+
text: string
|
|
102
|
+
): Promise<Check> {
|
|
103
|
+
const id = 'watch:heartbeat';
|
|
104
|
+
if (!store)
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
status: 'warn',
|
|
108
|
+
summary: 'WATCH_HEARTBEAT_NO_BUCKET',
|
|
109
|
+
remedy: {
|
|
110
|
+
action:
|
|
111
|
+
'Name the bucket under "state" in fleet.json: the heartbeat and what the watcher remembers are kept there.',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
return store.write(text).then(
|
|
115
|
+
() => ({
|
|
116
|
+
id,
|
|
117
|
+
status: 'pass' as const,
|
|
118
|
+
summary: 'The heartbeat and the health of each machine were stored.',
|
|
119
|
+
}),
|
|
120
|
+
() => ({
|
|
121
|
+
id,
|
|
122
|
+
status: 'fail' as const,
|
|
123
|
+
summary: 'WATCH_HEARTBEAT_UNWRITTEN',
|
|
124
|
+
remedy: {
|
|
125
|
+
action:
|
|
126
|
+
'Check the bucket credentials this run was given (devfleet fleet-state shows the connection).',
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function outcome(
|
|
133
|
+
checks: Check[],
|
|
134
|
+
notifications: Notification[]
|
|
135
|
+
): CommandOutcome {
|
|
136
|
+
const ok = !checks.some((check) => check.status === 'fail');
|
|
137
|
+
const lines = [
|
|
138
|
+
...notifications.map(
|
|
139
|
+
(entry) =>
|
|
140
|
+
`${entry.event} ${entry.node}${entry.checks.length ? `: ${entry.checks.join(', ')}` : ''}`
|
|
141
|
+
),
|
|
142
|
+
...checks.map((check) => `${check.status} ${check.id} ${check.summary}`),
|
|
143
|
+
];
|
|
144
|
+
return {
|
|
145
|
+
exitCode: ok ? 0 : 1,
|
|
146
|
+
data: { version: 1, command: 'fleet-watch', ok, notifications, checks },
|
|
147
|
+
text: lines.join('\n') || 'Nothing changed.',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The watcher's object in the bucket, beside the fleet state. */
|
|
152
|
+
export const WATCH_OBJECT = 'watch.json';
|
|
153
|
+
|
|
154
|
+
export function bucketStore(bucket: Bucket | null): WatchStore | null {
|
|
155
|
+
if (!bucket) return null;
|
|
156
|
+
if (bucket.kind === 'directory') {
|
|
157
|
+
const path = join(bucket.path, WATCH_OBJECT);
|
|
158
|
+
return {
|
|
159
|
+
read: () => readFile(path, 'utf8').catch(() => null),
|
|
160
|
+
write: async (text) => {
|
|
161
|
+
await mkdir(dirname(path), { recursive: true });
|
|
162
|
+
await writeFile(path, text, { mode: 0o600 });
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const client = new Bun.S3Client({
|
|
167
|
+
bucket: bucket.bucket,
|
|
168
|
+
...(bucket.endpoint ? { endpoint: bucket.endpoint } : {}),
|
|
169
|
+
...(bucket.region ? { region: bucket.region } : {}),
|
|
170
|
+
...(bucket.accessKeyId && bucket.secretAccessKey
|
|
171
|
+
? {
|
|
172
|
+
accessKeyId: bucket.accessKeyId,
|
|
173
|
+
secretAccessKey: bucket.secretAccessKey,
|
|
174
|
+
}
|
|
175
|
+
: {}),
|
|
176
|
+
});
|
|
177
|
+
const file = client.file(`${bucket.prefix}/${WATCH_OBJECT}`);
|
|
178
|
+
return {
|
|
179
|
+
read: async () => ((await file.exists()) ? file.text() : null),
|
|
180
|
+
write: async (text) => {
|
|
181
|
+
await file.write(text, { type: 'application/json' });
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The services of a real run, on the manager. */
|
|
187
|
+
export function watchServices(
|
|
188
|
+
status: () => Promise<NodeReport[]>
|
|
189
|
+
): FleetWatchServices {
|
|
190
|
+
return {
|
|
191
|
+
notify: fleet.notify,
|
|
192
|
+
status,
|
|
193
|
+
store: bucketStore(connection().bucket),
|
|
194
|
+
secrets: secret,
|
|
195
|
+
send: fetch,
|
|
196
|
+
now: () => new Date(),
|
|
197
|
+
};
|
|
198
|
+
}
|
package/src/cli/fleet.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { addNode, nodeFromOptions, readRoster } from './roster';
|
|
|
14
14
|
import { executeTailnet } from './tailnet';
|
|
15
15
|
import { probeSource } from './probe-source';
|
|
16
16
|
import { executeRepairCommand } from './repair';
|
|
17
|
+
import { scheduleCheck } from '../schedule-check';
|
|
18
|
+
import { readHistory } from '../state/history';
|
|
17
19
|
|
|
18
20
|
export interface FleetServices {
|
|
19
21
|
run: Runner;
|
|
@@ -125,6 +127,8 @@ async function diagnose(
|
|
|
125
127
|
services.run
|
|
126
128
|
);
|
|
127
129
|
if (!options.local && settings.missingRoster) reports.push(missingRoster());
|
|
130
|
+
const schedule = scheduleCheck(readHistory(), new Date());
|
|
131
|
+
if (schedule) reports[0]?.checks.push(schedule);
|
|
128
132
|
const report = {
|
|
129
133
|
...makeReport('doctor', reports),
|
|
130
134
|
coverage: {
|
|
@@ -7,6 +7,10 @@ import { executeFleet } from './fleet';
|
|
|
7
7
|
import { fleetSync, type FleetSyncOptions } from './fleet-sync';
|
|
8
8
|
import { syncNode, type NodeSyncOptions } from './node-sync';
|
|
9
9
|
import { fleetState, type FleetStateOptions } from './fleet-state';
|
|
10
|
+
import { fleetAccounts, type FleetAccountsOptions } from './fleet-accounts';
|
|
11
|
+
import { fleetReport, type FleetReportOptions } from './fleet-report';
|
|
12
|
+
import { fleetWatch, watchServices } from './fleet-watch';
|
|
13
|
+
import type { NodeReport } from '../types';
|
|
10
14
|
|
|
11
15
|
type Complete = (outcome: CommandOutcome) => void;
|
|
12
16
|
|
|
@@ -198,6 +202,10 @@ function registerSync({ program, defaults, complete }: Registration) {
|
|
|
198
202
|
'--schedule <action>',
|
|
199
203
|
'Manager-only schedule: enable, pause, resume, disable, status'
|
|
200
204
|
)
|
|
205
|
+
.option(
|
|
206
|
+
'--only <scope>',
|
|
207
|
+
'accounts: only the account-derived configuration, on every node at once, without a full sync'
|
|
208
|
+
)
|
|
201
209
|
.action(async (_options, command: Command) => {
|
|
202
210
|
const options = command.optsWithGlobals<
|
|
203
211
|
FleetSyncOptions & Partial<Settings>
|
|
@@ -277,6 +285,52 @@ function registerState({ program, complete }: Registration) {
|
|
|
277
285
|
});
|
|
278
286
|
}
|
|
279
287
|
|
|
288
|
+
function registerReport({ program, complete }: Registration) {
|
|
289
|
+
program
|
|
290
|
+
.command('fleet-report')
|
|
291
|
+
.description(
|
|
292
|
+
'Report how reliably fleet-sync ran: success rate, duration, failing checks and time not ready'
|
|
293
|
+
)
|
|
294
|
+
.option('--since <duration>', 'How far back: 30d (default), 12h or 90m')
|
|
295
|
+
.action((_options, command: Command) => {
|
|
296
|
+
complete(fleetReport(command.optsWithGlobals<FleetReportOptions>()));
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function registerWatch({ program, defaults, complete }: Registration) {
|
|
301
|
+
program
|
|
302
|
+
.command('fleet-watch')
|
|
303
|
+
.description(
|
|
304
|
+
"Check the workers and notify the fleet's targets when one becomes unhealthy or recovers"
|
|
305
|
+
)
|
|
306
|
+
.option('--test', 'Send one test notification to every target and stop')
|
|
307
|
+
.addHelpText(
|
|
308
|
+
'after',
|
|
309
|
+
'\nRuns fleet-status --workers, compares it with the previous run kept in the bucket and notifies only on a change (and again every repeatHours while a worker stays unhealthy). Targets come from "notify" in fleet.json; their addresses are secrets. Every run stores a heartbeat in the bucket for an off-machine check.'
|
|
310
|
+
)
|
|
311
|
+
.action(async (_options, command: Command) => {
|
|
312
|
+
const options = command.optsWithGlobals<
|
|
313
|
+
CliOptions & { test?: boolean }
|
|
314
|
+
>();
|
|
315
|
+
const settings = await withSettings(defaults, options);
|
|
316
|
+
const status = async (): Promise<NodeReport[]> => {
|
|
317
|
+
const report = await executeFleet(
|
|
318
|
+
'health',
|
|
319
|
+
{ ...options, workers: true, managed: true },
|
|
320
|
+
settings
|
|
321
|
+
);
|
|
322
|
+
const data = report.data as { nodes?: NodeReport[] };
|
|
323
|
+
return data.nodes ?? [];
|
|
324
|
+
};
|
|
325
|
+
complete(
|
|
326
|
+
await fleetWatch(
|
|
327
|
+
{ ...(options.test ? { test: true } : {}) },
|
|
328
|
+
watchServices(status)
|
|
329
|
+
)
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
280
334
|
export function registerFleet(
|
|
281
335
|
program: Command,
|
|
282
336
|
defaults: Settings,
|
|
@@ -291,6 +345,38 @@ export function registerFleet(
|
|
|
291
345
|
);
|
|
292
346
|
registerSync(registration);
|
|
293
347
|
registerState(registration);
|
|
348
|
+
registerReport(registration);
|
|
349
|
+
registerWatch(registration);
|
|
294
350
|
registerRepair(registration);
|
|
295
351
|
registerNodes(registration);
|
|
352
|
+
registerAccounts(registration);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function registerAccounts({ program, defaults, complete }: Registration) {
|
|
356
|
+
program
|
|
357
|
+
.command('fleet-accounts [args...]')
|
|
358
|
+
.description(
|
|
359
|
+
'List the provider accounts signed in on the CLI proxy and their state; add, sign in again or remove one'
|
|
360
|
+
)
|
|
361
|
+
.option('--diff', 'Also compare what this machine offers with the roster')
|
|
362
|
+
.option('--add', 'Sign a new account in: --add PROVIDER')
|
|
363
|
+
.option('--relogin', 'Sign one account in again: --relogin PROVIDER INDEX')
|
|
364
|
+
.option('--remove', 'Remove one account: --remove PROVIDER INDEX')
|
|
365
|
+
.option(
|
|
366
|
+
'--remove-native-logins',
|
|
367
|
+
'Remove provider logins agent CLIs keep outside the proxy on this machine; needs --yes'
|
|
368
|
+
)
|
|
369
|
+
.option('--yes', 'Confirm --remove-native-logins')
|
|
370
|
+
.action(async (args: string[], _options, command: Command) => {
|
|
371
|
+
const options = command.optsWithGlobals<
|
|
372
|
+
FleetAccountsOptions & CliOptions
|
|
373
|
+
>();
|
|
374
|
+
complete(
|
|
375
|
+
await fleetAccounts(
|
|
376
|
+
options,
|
|
377
|
+
args,
|
|
378
|
+
await withSettings(defaults, options)
|
|
379
|
+
)
|
|
380
|
+
);
|
|
381
|
+
});
|
|
296
382
|
}
|
package/src/cli/registry.ts
CHANGED
|
@@ -50,6 +50,13 @@ export const COMMANDS: CommandSpec[] = [
|
|
|
50
50
|
'Read-only canonical worker readiness, dependencies, services and authentication; lists every remaining gap.',
|
|
51
51
|
['workers', 'readiness', 'health', 'canonical'],
|
|
52
52
|
],
|
|
53
|
+
[
|
|
54
|
+
'fleet-watch',
|
|
55
|
+
'Notify when a worker becomes unhealthy or recovers',
|
|
56
|
+
'Runs fleet-status --workers, compares it with the previous run kept in the bucket and notifies the targets fleet.json names under "notify" on a change only. Stores a heartbeat for an off-machine check. --test sends one test notification.',
|
|
57
|
+
['notify', 'alert', 'webhook', 'ntfy', 'heartbeat', 'watch', 'monitor'],
|
|
58
|
+
true,
|
|
59
|
+
],
|
|
53
60
|
[
|
|
54
61
|
'fleet-discover',
|
|
55
62
|
'Discover Tailscale computers',
|
|
@@ -90,6 +97,12 @@ export const COMMANDS: CommandSpec[] = [
|
|
|
90
97
|
['state', 'bucket', 'restore', 'recover', 'disposable'],
|
|
91
98
|
true,
|
|
92
99
|
],
|
|
100
|
+
[
|
|
101
|
+
'fleet-report',
|
|
102
|
+
'Report how reliably fleet-sync runs',
|
|
103
|
+
'Read-only: success rate, mean duration, the most frequent failing check IDs and how long each machine was not ready, from the history of fleet-sync runs kept in the fleet state. --since 30d (default), 12h or 90m.',
|
|
104
|
+
['history', 'reliability', 'report', 'failures', 'sync'],
|
|
105
|
+
],
|
|
93
106
|
[
|
|
94
107
|
'fleet-node-sync',
|
|
95
108
|
'Sync this node',
|
|
@@ -135,6 +148,19 @@ export const COMMANDS: CommandSpec[] = [
|
|
|
135
148
|
['onboarding', 'register', 'new', 'setup', 'add'],
|
|
136
149
|
true,
|
|
137
150
|
],
|
|
151
|
+
[
|
|
152
|
+
'fleet-accounts',
|
|
153
|
+
'List provider accounts and their state',
|
|
154
|
+
'Read-only: the provider accounts signed in on the CLI proxy (provider, index, label, state, when a cooldown clears), the roster version and the proxy:account:<provider> checks. --diff also compares what this machine offers with the roster.',
|
|
155
|
+
['accounts', 'providers', 'login', 'quota', 'cooldown', 'roster'],
|
|
156
|
+
],
|
|
157
|
+
[
|
|
158
|
+
'fleet-accounts --add',
|
|
159
|
+
'Add, sign in again or remove a provider account',
|
|
160
|
+
'--add PROVIDER, --relogin PROVIDER INDEX or --remove PROVIDER INDEX changes one account on the CLI proxy (signing in is the only manual step), then propagates the accounts to every node with fleet-sync --only accounts.',
|
|
161
|
+
['accounts', 'login', 'relogin', 'remove', 'add', 'sign in'],
|
|
162
|
+
true,
|
|
163
|
+
],
|
|
138
164
|
]),
|
|
139
165
|
...group('T3', [
|
|
140
166
|
[
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** The most a scheduled job's log may hold; a longer one keeps its newest
|
|
5
|
+
* quarter.
|
|
6
|
+
*/
|
|
7
|
+
export const SCHEDULE_LOG_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Caps the schedule's own logs (`<label>.out.log`, `<label>.err.log`) at the
|
|
10
|
+
* start of every scheduled run. launchd and systemd append to them, so the
|
|
11
|
+
* newest lines are what is kept. Nothing else in the directory is touched.
|
|
12
|
+
*/
|
|
13
|
+
export function capScheduleLogs(
|
|
14
|
+
directory: string,
|
|
15
|
+
limit = SCHEDULE_LOG_BYTES
|
|
16
|
+
): void {
|
|
17
|
+
let names: string[];
|
|
18
|
+
try {
|
|
19
|
+
names = readdirSync(directory);
|
|
20
|
+
} catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
if (!/^[a-z0-9-]+\.(out|err)\.log$/.test(name)) continue;
|
|
25
|
+
const path = join(directory, name);
|
|
26
|
+
try {
|
|
27
|
+
const stat = statSync(path);
|
|
28
|
+
if (!stat.isFile() || stat.size <= limit) continue;
|
|
29
|
+
const kept = readFileSync(path).subarray(stat.size - limit / 4);
|
|
30
|
+
const start = kept.indexOf(10) + 1;
|
|
31
|
+
writeFileSync(path, kept.subarray(start), { mode: 0o600 });
|
|
32
|
+
} catch {
|
|
33
|
+
// A log that cannot be capped is capped by the next run.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import policy from '../ansible/ubuntu/policy.json';
|
|
2
|
+
import type { HealthSample } from './machine-health';
|
|
3
|
+
import type { Check } from './types';
|
|
4
|
+
|
|
5
|
+
const { warnOffsetSeconds, failOffsetSeconds } = policy.clock;
|
|
6
|
+
|
|
7
|
+
/** A skewed clock breaks TLS, token validation, apt signatures and Vault
|
|
8
|
+
* logins with errors that never mention time. The clock passes when it is
|
|
9
|
+
* synchronised to a time source and, where a reference time was measured,
|
|
10
|
+
* within the offset floors of `policy.json`.
|
|
11
|
+
*
|
|
12
|
+
* `offsetSeconds` is the difference to the reference (the manager's clock),
|
|
13
|
+
* when one was measured.
|
|
14
|
+
*/
|
|
15
|
+
export function clockCheck(
|
|
16
|
+
id: string,
|
|
17
|
+
sample: Pick<HealthSample, 'clockSynced' | 'timeService'>,
|
|
18
|
+
offsetSeconds?: number
|
|
19
|
+
): Check {
|
|
20
|
+
const offset =
|
|
21
|
+
offsetSeconds === undefined ? undefined : Math.abs(offsetSeconds);
|
|
22
|
+
const measured =
|
|
23
|
+
offset === undefined ? '' : `, ${offset.toFixed(1)} s from the manager`;
|
|
24
|
+
const remedy = clockRemedy(sample.timeService);
|
|
25
|
+
if (sample.clockSynced === undefined && offset === undefined)
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
status: 'skip',
|
|
29
|
+
summary: 'CLOCK_UNOBSERVED: the clock synchronisation could not be read.',
|
|
30
|
+
};
|
|
31
|
+
if (offset !== undefined && offset > failOffsetSeconds)
|
|
32
|
+
return { id, status: 'fail', summary: `CLOCK_SKEWED${measured}`, remedy };
|
|
33
|
+
if (sample.clockSynced === 0 && sample.timeService === 0)
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
status: 'fail',
|
|
37
|
+
summary: `CLOCK_NOT_SYNCHRONISED: no time service is enabled${measured}`,
|
|
38
|
+
remedy,
|
|
39
|
+
};
|
|
40
|
+
if (sample.clockSynced === 0)
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
status: 'warn',
|
|
44
|
+
summary: `CLOCK_NOT_SYNCHRONISED${measured}`,
|
|
45
|
+
remedy,
|
|
46
|
+
};
|
|
47
|
+
if (offset !== undefined && offset > warnOffsetSeconds)
|
|
48
|
+
return { id, status: 'warn', summary: `CLOCK_DRIFTING${measured}`, remedy };
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
status: 'pass',
|
|
52
|
+
summary: `The clock is synchronised to a time source${measured}.`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clockRemedy(timeService: number | undefined): { action: string } {
|
|
57
|
+
return {
|
|
58
|
+
action:
|
|
59
|
+
timeService === 0
|
|
60
|
+
? 'Enable the time service: apply the approved fleet-sync revision, which keeps chrony or systemd-timesyncd enabled on workers (timedatectl set-ntp true is the same setting).'
|
|
61
|
+
: 'Check that the time service reaches a time server (timedatectl timesync-status) and that UDP port 123 is not blocked.',
|
|
62
|
+
};
|
|
63
|
+
}
|