@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.
Files changed (129) hide show
  1. package/README.md +50 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/manager.yml +3 -0
  4. package/ansible/macos/policy.json +11 -1
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/compose.yml +10 -0
  7. package/ansible/monitoring/container-watchdog.py +118 -0
  8. package/ansible/monitoring/hub-darwin.yml +146 -0
  9. package/ansible/net/remote-probe.sh +5 -0
  10. package/ansible/ubuntu/baseline.yml +2 -0
  11. package/ansible/ubuntu/disk-gc.py +25 -3
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +51 -1
  14. package/ansible/ubuntu/t3-desktop.yml +19 -0
  15. package/ansible/ubuntu/t3-health.sh +93 -22
  16. package/ansible/ubuntu/t3-idle.py +42 -0
  17. package/ansible/ubuntu/t3-policy.yml +4 -11
  18. package/ansible/ubuntu/tasks/browser.yml +56 -0
  19. package/ansible/ubuntu/tasks/docker.yml +52 -0
  20. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  21. package/ansible/ubuntu/tasks/resources.yml +2 -2
  22. package/ansible/ubuntu/tasks/services.yml +13 -0
  23. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  24. package/ansible/ubuntu/wip-guard.py +121 -0
  25. package/bootstrap/doctor.ts +32 -6
  26. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  27. package/bootstrap/native-t3.ts +22 -11
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +4 -4
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/accounts.py +216 -0
  33. package/cliproxy/scripts/checkpoint.py +30 -2
  34. package/cliproxy/scripts/cli.py +1 -1
  35. package/cliproxy/scripts/clientkeys.py +99 -0
  36. package/cliproxy/scripts/lease.py +80 -0
  37. package/cliproxy/scripts/ops +137 -25
  38. package/cliproxy/scripts/restore-verify +15 -0
  39. package/docs/accounts.md +154 -0
  40. package/docs/authentication.md +90 -0
  41. package/docs/declared.md +5 -1
  42. package/docs/durability.md +26 -0
  43. package/docs/machine-setup.md +26 -15
  44. package/docs/operations.md +87 -31
  45. package/docs/runbooks.md +380 -0
  46. package/docs/state.md +13 -7
  47. package/docs/topology.md +2 -0
  48. package/docs/ubuntu-sync.md +35 -28
  49. package/examples/fleet.json +19 -1
  50. package/llms.txt +3 -0
  51. package/mise.toml +10 -1
  52. package/package.json +2 -2
  53. package/schema/check-ids.json +522 -0
  54. package/schema/fleet.schema.json +244 -1
  55. package/schema/output/check.schema.json +23 -0
  56. package/schema/output/error.schema.json +14 -0
  57. package/schema/output/fleet-report.schema.json +79 -0
  58. package/schema/output/fleet-sync.schema.json +112 -0
  59. package/schema/output/report.schema.json +38 -0
  60. package/src/accounts/checks.ts +220 -0
  61. package/src/accounts/context.ts +76 -0
  62. package/src/accounts/propagate.ts +62 -0
  63. package/src/accounts/roster.ts +85 -0
  64. package/src/accounts/selection.ts +67 -0
  65. package/src/accounts/sessions.ts +120 -0
  66. package/src/agents/account-relay.ts +176 -26
  67. package/src/agents/launch.ts +84 -5
  68. package/src/agents/native-verify.ts +128 -2
  69. package/src/auth-checks.ts +3 -0
  70. package/src/cli/agent-guide.ts +5 -3
  71. package/src/cli/fleet-accounts.ts +253 -0
  72. package/src/cli/fleet-report.ts +126 -0
  73. package/src/cli/fleet-sync-accounts.ts +58 -0
  74. package/src/cli/fleet-sync-manager.ts +31 -4
  75. package/src/cli/fleet-sync-output.ts +3 -2
  76. package/src/cli/fleet-sync-plan.ts +200 -0
  77. package/src/cli/fleet-sync-remote.ts +23 -14
  78. package/src/cli/fleet-sync-schedule.ts +142 -35
  79. package/src/cli/fleet-sync.ts +126 -8
  80. package/src/cli/fleet-watch.ts +198 -0
  81. package/src/cli/fleet.ts +4 -0
  82. package/src/cli/register-fleet.ts +86 -0
  83. package/src/cli/registry.ts +26 -0
  84. package/src/cli/schedule-logs.ts +36 -0
  85. package/src/clock.ts +63 -0
  86. package/src/definition.ts +226 -7
  87. package/src/git-identity.ts +160 -0
  88. package/src/ide/definition.ts +86 -0
  89. package/src/ide/ide.ts +43 -0
  90. package/src/ide/index.ts +5 -0
  91. package/src/ide/orchestrate.ts +50 -0
  92. package/src/ide/t3.ts +111 -0
  93. package/src/machine-health.ts +51 -4
  94. package/src/manager-health.ts +205 -0
  95. package/src/manager-sync.ts +13 -17
  96. package/src/monitoring/hub-backup.ts +63 -17
  97. package/src/monitoring/manager-hub.ts +44 -2
  98. package/src/node-converge-linux.ts +44 -12
  99. package/src/notify.ts +239 -0
  100. package/src/platform/darwin.ts +0 -13
  101. package/src/platform/fallback.ts +0 -8
  102. package/src/platform/linux.ts +0 -10
  103. package/src/platform/t3-policy.ts +33 -6
  104. package/src/platform/t3-service.ts +104 -0
  105. package/src/platform/types.ts +0 -4
  106. package/src/platform/windows.ts +0 -14
  107. package/src/remote-bootstrap.ts +87 -25
  108. package/src/remote-entry.ts +15 -1
  109. package/src/remote-operation.ts +213 -0
  110. package/src/schedule-check.ts +40 -0
  111. package/src/secrets.ts +26 -8
  112. package/src/service-auth.ts +15 -5
  113. package/src/ssh.ts +10 -2
  114. package/src/state/history.ts +172 -0
  115. package/src/state/store.ts +11 -4
  116. package/src/t3/accounts.ts +7 -1
  117. package/src/t3/models.ts +133 -0
  118. package/src/t3/pairing.ts +31 -0
  119. package/src/t3/profile.ts +203 -0
  120. package/src/t3/version.ts +119 -0
  121. package/src/t3-linked-check.ts +1 -1
  122. package/src/tailscale.ts +51 -0
  123. package/src/toolchain-checks.ts +8 -3
  124. package/src/transport.ts +3 -2
  125. package/src/worker-capability-checks.ts +167 -0
  126. package/src/worker-capacity.ts +19 -7
  127. package/src/worker-host-checks.ts +68 -13
  128. package/src/worker-policy.ts +33 -0
  129. package/src/worker-readiness.ts +10 -1
@@ -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 managerSync(options, settings, {
84
- run,
85
- root: machineRoot(),
86
- restart: () => fleetSync(options, settings, run, forward),
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
- if (options.dryRun) return selected({ ...options, revision }, settings, run);
132
- // The bucket is the source of truth: a run starts from its version of the
133
- // configuration, the roster and the approval, not from a stale local copy.
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
  }
@@ -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
+ }