@crvouga/dev-fleet 0.8.0 → 0.9.1
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 +65 -9
- package/agents/audit.md +18 -18
- package/ansible/agents/agents.yml +134 -0
- package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
- package/ansible/agents/templates/claude.settings.json.j2 +3 -0
- package/ansible/agents/templates/codex.config.toml.j2 +11 -0
- package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
- package/ansible/agents/templates/grok.config.toml.j2 +19 -0
- package/ansible/agents/templates/opencode.json.j2 +24 -0
- package/ansible/agents/templates/receipt.json.j2 +10 -0
- package/ansible/ansible.cfg +14 -0
- package/ansible/github/git.yml +82 -0
- package/ansible/macos/manager-privilege.yml +55 -9
- package/ansible/macos/manager.yml +154 -7
- package/ansible/macos/policy.json +13 -0
- package/ansible/macos/t3-policy.yml +5 -3
- package/ansible/macos/vault-renew.yml +85 -0
- package/ansible/monitoring/agent-darwin.yml +3 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/monitoring/hub-darwin.yml +3 -0
- package/ansible/monitoring/hub-route-darwin.yml +1 -0
- package/ansible/net/remote-probe.sh +4 -1
- package/ansible/platforms.json +2 -2
- package/ansible/pyproject.toml +5 -0
- package/ansible/ubuntu/baseline.yml +39 -13
- package/ansible/ubuntu/disk-gc.py +6 -2
- package/ansible/ubuntu/firewall.nft.j2 +50 -0
- package/ansible/ubuntu/policy.json +125 -6
- package/ansible/ubuntu/reboot.yml +89 -0
- package/ansible/ubuntu/recovery.yml +13 -3
- package/ansible/ubuntu/t3/policy.json +3 -0
- package/ansible/ubuntu/t3-desktop.yml +23 -2
- package/ansible/ubuntu/t3-headless.yml +128 -0
- package/ansible/ubuntu/t3-health.sh +24 -10
- package/ansible/ubuntu/t3-idle.py +37 -2
- package/ansible/ubuntu/t3-policy.yml +4 -1
- package/ansible/ubuntu/t3-route.yml +1 -0
- package/ansible/ubuntu/tailscale.yml +54 -8
- package/ansible/ubuntu/tasks/browser.yml +3 -0
- package/ansible/ubuntu/tasks/containers-off.yml +43 -0
- package/ansible/ubuntu/tasks/docker.yml +53 -6
- package/ansible/ubuntu/tasks/dpkg.yml +4 -0
- package/ansible/ubuntu/tasks/features.yml +22 -0
- package/ansible/ubuntu/tasks/firewall.yml +172 -0
- package/ansible/ubuntu/tasks/install.yml +2 -1
- package/ansible/ubuntu/tasks/packages.yml +4 -2
- package/ansible/ubuntu/tasks/release.yml +6 -0
- package/ansible/ubuntu/tasks/resources.yml +6 -4
- package/ansible/ubuntu/tasks/services.yml +2 -2
- package/ansible/ubuntu/tasks/sizing.yml +49 -0
- package/ansible/ubuntu/tasks/snapshot.yml +14 -3
- package/ansible/ubuntu/tasks/ssh.yml +61 -4
- package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
- package/ansible/ubuntu/tasks/user.yml +1 -0
- package/ansible/ubuntu/worker-account.yml +117 -0
- package/ansible/uv.lock +265 -0
- package/bin/devfleet.mjs +42 -4
- package/bootstrap/fleet-remote-bootstrap.sh +91 -25
- package/bootstrap/global-config.ts +29 -3
- package/bootstrap/package-smoke.ts +127 -0
- package/bootstrap/pins.ts +189 -0
- package/bootstrap/release-notes.ts +88 -0
- package/bootstrap/release-version.ts +41 -7
- package/bootstrap/worker-features.ts +114 -0
- package/bootstrap/worker-seed.yaml +7 -3
- package/bootstrap/worker-tools.ts +39 -9
- package/cliproxy/README.md +1 -1
- package/cliproxy/docs/health-workflow.example.yml +20 -143
- package/docs/authentication.md +19 -11
- package/docs/consumer-setup.md +42 -27
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +10 -3
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +98 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +67 -6
- package/docs/ubuntu-sync.md +78 -28
- package/docs/worker-contract.md +122 -19
- package/docs/worker-requirements.md +107 -0
- package/examples/fleet.json +1 -0
- package/examples/workers-from-infrastructure/main.tf +132 -0
- package/examples/workers-from-infrastructure/worker-records.json +14 -0
- package/llms.txt +3 -1
- package/mise.lock +29 -0
- package/mise.toml +5 -3
- package/package.json +2 -2
- package/schema/check-ids.json +1967 -170
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +327 -8
- package/schema/output/check.schema.json +1 -1
- package/schema/output/fleet-state-definition.schema.json +14 -0
- package/schema/output/fleet-sync-schedule.schema.json +26 -0
- package/schema/output/fleet-sync.schema.json +18 -0
- package/schema/output/report.schema.json +3 -2
- package/schema/worker-record.schema.json +76 -0
- package/src/agents/converge.ts +140 -24
- package/src/agents/files.ts +2 -32
- package/src/agents/native-verify.ts +34 -3
- package/src/agents/secrets.ts +39 -4
- package/src/check-owners.ts +61 -2
- package/src/check-registry.ts +630 -0
- package/src/cli/agent-guide.ts +3 -3
- package/src/cli/cli.ts +4 -0
- package/src/cli/fleet-manifest.ts +9 -3
- package/src/cli/fleet-node-lifecycle.ts +313 -64
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +51 -35
- package/src/cli/fleet-state.ts +2 -1
- package/src/cli/fleet-sync-checkout.ts +7 -0
- package/src/cli/fleet-sync-enrollment.ts +193 -32
- package/src/cli/fleet-sync-forward.ts +4 -0
- package/src/cli/fleet-sync-manager.ts +103 -33
- package/src/cli/fleet-sync-output.ts +31 -11
- package/src/cli/fleet-sync-plan.ts +65 -9
- package/src/cli/fleet-sync-records.ts +113 -0
- package/src/cli/fleet-sync-remote.ts +151 -9
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +28 -16
- package/src/cli/fleet-sync-vault.ts +77 -0
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +150 -19
- package/src/cli/fleet-validate.ts +59 -21
- package/src/cli/fleet-watch.ts +7 -1
- package/src/cli/fleet.ts +87 -5
- package/src/cli/managed-health.ts +25 -1
- package/src/cli/register-fleet.ts +115 -4
- package/src/cli/registry.ts +14 -0
- package/src/cli/tailnet.ts +43 -10
- package/src/cli/ui-data.ts +3 -1
- package/src/cli/worker-usage-report.ts +63 -0
- package/src/clock.ts +42 -0
- package/src/definition.ts +332 -9
- package/src/diagnostics.ts +5 -2
- package/src/exposure.ts +102 -0
- package/src/fleet-config.ts +3 -1
- package/src/git-identity.ts +76 -3
- package/src/ide/ide.ts +5 -0
- package/src/ide/t3.ts +12 -2
- package/src/input-hashes.ts +6 -0
- package/src/machine/machine-class.ts +59 -0
- package/src/machine/platform.ts +90 -11
- package/src/machine/state.ts +37 -15
- package/src/machine/support-matrix.ts +11 -9
- package/src/machine-health.ts +28 -5
- package/src/manager-health.ts +148 -2
- package/src/manager-sync.ts +65 -4
- package/src/monitoring/beszel.ts +8 -8
- package/src/monitoring/enroll.ts +8 -1
- package/src/monitoring/hub-access.ts +10 -4
- package/src/monitoring/systems.ts +138 -0
- package/src/network-definition.ts +13 -4
- package/src/node-converge-linux.ts +136 -27
- package/src/node-converge.ts +13 -3
- package/src/platform/ansible-receipt.ts +17 -2
- package/src/platform/ansible.ts +40 -4
- package/src/platform/manager-host.ts +25 -8
- package/src/platform/resource-verify.ts +18 -7
- package/src/platform/t3-policy.ts +24 -10
- package/src/platform/t3-service.ts +109 -34
- package/src/platform/ubuntu-release.ts +24 -0
- package/src/platform/ubuntu-verify.ts +31 -3
- package/src/platform/ubuntu-worker.ts +58 -0
- package/src/platform/worker-platform.ts +72 -0
- package/src/protocol.ts +1 -1
- package/src/public-reachability.ts +186 -0
- package/src/remote-bootstrap.ts +50 -12
- package/src/remote-operation.ts +18 -4
- package/src/repair-plan.ts +23 -12
- package/src/report.ts +2 -1
- package/src/schedule-check.ts +21 -2
- package/src/secrets.ts +4 -0
- package/src/service-auth.ts +55 -48
- package/src/ssh-connection.ts +141 -0
- package/src/ssh.ts +63 -24
- package/src/state/store.ts +4 -2
- package/src/state/sync.ts +35 -6
- package/src/state/workers.ts +140 -0
- package/src/t3/pairing.ts +59 -0
- package/src/t3/peer-link.ts +333 -0
- package/src/t3/version.ts +1 -1
- package/src/tailnet-cleanup.ts +185 -0
- package/src/tailnet-control.ts +346 -0
- package/src/tailscale.ts +1 -1
- package/src/toolchain-checks.ts +11 -1
- package/src/types.ts +4 -2
- package/src/worker-account.ts +63 -0
- package/src/worker-activity.ts +303 -0
- package/src/worker-capability-checks.ts +55 -16
- package/src/worker-capacity.ts +31 -0
- package/src/worker-exposure.ts +388 -0
- package/src/worker-features.ts +201 -0
- package/src/worker-host-checks.ts +71 -20
- package/src/worker-manifest.ts +27 -4
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +17 -1
- package/src/worker-readiness.ts +15 -3
- package/src/worker-reboot.ts +129 -0
- package/src/worker-records.ts +305 -0
- package/src/worker-seed.ts +16 -11
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
package/src/state/store.ts
CHANGED
|
@@ -17,9 +17,11 @@ import { FleetUsageError } from '../errors';
|
|
|
17
17
|
*/
|
|
18
18
|
export const DOCUMENTS = ['definition', 'roster', 'enrollment'] as const;
|
|
19
19
|
/** Kept when there is something to keep; a fleet without them is complete:
|
|
20
|
-
* `history` is the record of recent fleet-sync runs and of the schedule
|
|
20
|
+
* `history` is the record of recent fleet-sync runs and of the schedule;
|
|
21
|
+
* `workers` what the manager knows of each worker beyond fleet.json: its
|
|
22
|
+
* pinned SSH host key and whether it is parked (src/state/workers.ts).
|
|
21
23
|
*/
|
|
22
|
-
export const OPTIONAL_DOCUMENTS = ['history'] as const;
|
|
24
|
+
export const OPTIONAL_DOCUMENTS = ['history', 'workers'] as const;
|
|
23
25
|
export type DocumentName = (typeof DOCUMENTS)[number];
|
|
24
26
|
/** Every document the fleet state may hold. */
|
|
25
27
|
export type StoredName = DocumentName | (typeof OPTIONAL_DOCUMENTS)[number];
|
package/src/state/sync.ts
CHANGED
|
@@ -349,9 +349,14 @@ export function pull(context: StateContext): Promise<Standing> {
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
/** Replaces this copy with the version the bucket held at `timestamp`, then
|
|
352
|
-
* uploads it as the bucket's current version
|
|
353
|
-
* whose latest version is damaged. `unreachable`
|
|
354
|
-
* readable version at that time.
|
|
352
|
+
* uploads it as the bucket's current version, which takes the manager lease:
|
|
353
|
+
* the way back from a replica whose latest version is damaged. `unreachable`
|
|
354
|
+
* when the bucket holds no readable version at that time.
|
|
355
|
+
*
|
|
356
|
+
* Refused, before anything changes, while another manager holds the lease in
|
|
357
|
+
* the bucket's current version, unless told to take it over. When that
|
|
358
|
+
* version cannot be read the lease of this machine's copy, the last one it
|
|
359
|
+
* saw, decides instead.
|
|
355
360
|
*/
|
|
356
361
|
export function pullAt(
|
|
357
362
|
context: StateContext,
|
|
@@ -367,6 +372,17 @@ export function pullAt(
|
|
|
367
372
|
'No bucket is configured, so there is no earlier version to restore.'
|
|
368
373
|
);
|
|
369
374
|
return locked(context.root, async () => {
|
|
375
|
+
const database = databasePath(context.root);
|
|
376
|
+
const current = await fetchRemote({ ...context, bucket });
|
|
377
|
+
try {
|
|
378
|
+
const held = leaseIn(
|
|
379
|
+
context,
|
|
380
|
+
current.status === 'present' ? current.file : database
|
|
381
|
+
);
|
|
382
|
+
if (held) throw new LeaseHeldError(leaseHeldMessage(held));
|
|
383
|
+
} finally {
|
|
384
|
+
if (current.status === 'present') discard(current.file);
|
|
385
|
+
}
|
|
370
386
|
const remote = await fetchRemote(
|
|
371
387
|
{ ...context, bucket },
|
|
372
388
|
true,
|
|
@@ -374,10 +390,17 @@ export function pullAt(
|
|
|
374
390
|
);
|
|
375
391
|
if (remote.status !== 'present')
|
|
376
392
|
return remote.status === 'absent' ? 'empty' : remote.status;
|
|
377
|
-
const database = databasePath(context.root);
|
|
378
393
|
discard(database);
|
|
379
394
|
renameSync(remote.file, database);
|
|
380
395
|
chmodSync(database, 0o600);
|
|
396
|
+
// The earlier version names whoever held the lease then.
|
|
397
|
+
if (context.holder) {
|
|
398
|
+
const now = (context.now ?? (() => new Date()))();
|
|
399
|
+
writeLease(database, {
|
|
400
|
+
holder: context.holder,
|
|
401
|
+
expiresAt: new Date(now.getTime() + LEASE_MS).toISOString(),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
381
404
|
return upload({ ...context, bucket }, true);
|
|
382
405
|
});
|
|
383
406
|
}
|
|
@@ -408,11 +431,17 @@ export class LeaseHeldError extends FleetUsageError {}
|
|
|
408
431
|
* and this machine was not told to take it over. Reads local files only: call
|
|
409
432
|
* it on the bucket's version, after a refresh.
|
|
410
433
|
*/
|
|
411
|
-
export function leaseConflict(
|
|
434
|
+
export function leaseConflict(context: StateContext, now?: Date): Lease | null {
|
|
435
|
+
return leaseIn(context, databasePath(context.root), now);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The lease another manager holds over the database at `file`. */
|
|
439
|
+
function leaseIn(
|
|
412
440
|
context: StateContext,
|
|
441
|
+
file: string,
|
|
413
442
|
now: Date = (context.now ?? (() => new Date()))()
|
|
414
443
|
): Lease | null {
|
|
415
|
-
const lease = readLease(
|
|
444
|
+
const lease = readLease(file);
|
|
416
445
|
if (!context.holder || !lease || lease.holder === context.holder) return null;
|
|
417
446
|
if (Date.parse(lease.expiresAt) <= now.getTime() || context.takeOver)
|
|
418
447
|
return null;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { record } from '../config';
|
|
2
|
+
import { HOST_KEY } from '../definition';
|
|
3
|
+
import { databasePath, stateRoot } from './paths';
|
|
4
|
+
import { readDocument, writeDocument } from './store';
|
|
5
|
+
import { mutateState, type StateContext, type Standing } from './sync';
|
|
6
|
+
|
|
7
|
+
/** What the manager knows of one worker that fleet.json does not say. */
|
|
8
|
+
export interface WorkerState {
|
|
9
|
+
/** The pinned SSH host key, "TYPE BASE64", and when it was pinned. */
|
|
10
|
+
hostKey?: string;
|
|
11
|
+
hostKeyPinnedAt?: string;
|
|
12
|
+
/** When the worker was parked: stopped or destroyed on purpose. Absent,
|
|
13
|
+
* the worker is active.
|
|
14
|
+
*/
|
|
15
|
+
parkedAt?: string;
|
|
16
|
+
/** `records`: the worker was enrolled from a worker record, so it is
|
|
17
|
+
* retired by itself once its record disappears.
|
|
18
|
+
*/
|
|
19
|
+
origin?: 'records';
|
|
20
|
+
/** When fleet-node-replace put a new machine in the worker's place: the
|
|
21
|
+
* manager pairs its T3 with the new machine's T3 environment as soon as
|
|
22
|
+
* it runs, then forgets this. A worker enrolled for the first time is
|
|
23
|
+
* paired by a person.
|
|
24
|
+
*/
|
|
25
|
+
t3PairingRequestedAt?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The `workers` document of the fleet state. */
|
|
29
|
+
export interface WorkerStates {
|
|
30
|
+
version: 1;
|
|
31
|
+
workers: Record<string, WorkerState>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
35
|
+
const isTime = (value: unknown): value is string =>
|
|
36
|
+
typeof value === 'string' && !Number.isNaN(Date.parse(value));
|
|
37
|
+
|
|
38
|
+
const RULES: Record<keyof WorkerState, (value: unknown) => boolean> = {
|
|
39
|
+
hostKey: (value) => typeof value === 'string' && HOST_KEY.test(value),
|
|
40
|
+
hostKeyPinnedAt: isTime,
|
|
41
|
+
parkedAt: isTime,
|
|
42
|
+
origin: (value) => value === 'records',
|
|
43
|
+
t3PairingRequestedAt: isTime,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function workerState(value: unknown): WorkerState | null {
|
|
47
|
+
if (!record(value)) return null;
|
|
48
|
+
const broken = Object.entries(RULES).some(
|
|
49
|
+
([field, valid]) => value[field] !== undefined && !valid(value[field])
|
|
50
|
+
);
|
|
51
|
+
if (broken) return null;
|
|
52
|
+
const { hostKey, hostKeyPinnedAt, parkedAt, origin, t3PairingRequestedAt } =
|
|
53
|
+
value;
|
|
54
|
+
return {
|
|
55
|
+
...(typeof hostKey === 'string' ? { hostKey } : {}),
|
|
56
|
+
...(isTime(hostKeyPinnedAt) ? { hostKeyPinnedAt } : {}),
|
|
57
|
+
...(isTime(parkedAt) ? { parkedAt } : {}),
|
|
58
|
+
...(origin === 'records' ? { origin } : {}),
|
|
59
|
+
...(isTime(t3PairingRequestedAt) ? { t3PairingRequestedAt } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The document, or an empty one. An entry that cannot be read is dropped:
|
|
64
|
+
* an unreadable pin pins nothing, so the worker is refused, never trusted.
|
|
65
|
+
*/
|
|
66
|
+
export function parseWorkerStates(text: string | null): WorkerStates {
|
|
67
|
+
let value: unknown = null;
|
|
68
|
+
try {
|
|
69
|
+
value = text === null ? null : JSON.parse(text);
|
|
70
|
+
} catch {
|
|
71
|
+
value = null;
|
|
72
|
+
}
|
|
73
|
+
const workers: Record<string, WorkerState> = {};
|
|
74
|
+
if (record(value) && record(value['workers']))
|
|
75
|
+
for (const [id, entry] of Object.entries(value['workers'])) {
|
|
76
|
+
const parsed = workerState(entry);
|
|
77
|
+
if (ID.test(id) && parsed) workers[id] = parsed;
|
|
78
|
+
}
|
|
79
|
+
return { version: 1, workers };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readWorkerStates(env = process.env): WorkerStates {
|
|
83
|
+
return parseWorkerStates(
|
|
84
|
+
readDocument(databasePath(stateRoot(env)), 'workers')
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The host keys pinned in the fleet state, by worker ID. */
|
|
89
|
+
export function readPins(env = process.env): Record<string, string> {
|
|
90
|
+
return Object.fromEntries(
|
|
91
|
+
Object.entries(readWorkerStates(env).workers).flatMap(([id, entry]) =>
|
|
92
|
+
entry.hostKey ? [[id, entry.hostKey]] : []
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** When each parked worker was parked, by worker ID. */
|
|
98
|
+
export function readParked(env = process.env): Record<string, string> {
|
|
99
|
+
return Object.fromEntries(
|
|
100
|
+
Object.entries(readWorkerStates(env).workers).flatMap(([id, entry]) =>
|
|
101
|
+
entry.parkedAt ? [[id, entry.parkedAt]] : []
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeWorkerStates(states: WorkerStates, env = process.env): void {
|
|
107
|
+
const workers = Object.fromEntries(
|
|
108
|
+
Object.entries(states.workers)
|
|
109
|
+
.filter(([, entry]) => Object.keys(entry).length > 0)
|
|
110
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
111
|
+
);
|
|
112
|
+
writeDocument(
|
|
113
|
+
databasePath(stateRoot(env)),
|
|
114
|
+
'workers',
|
|
115
|
+
`${JSON.stringify({ version: 1, workers }, null, 2)}\n`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Changes the entries of some workers on top of the bucket's version and
|
|
120
|
+
* uploads the result. `change` returns the new entry of each worker it
|
|
121
|
+
* changes, or null to forget the worker.
|
|
122
|
+
*/
|
|
123
|
+
export async function changeWorkers(
|
|
124
|
+
context: StateContext,
|
|
125
|
+
change: (
|
|
126
|
+
current: Record<string, WorkerState>
|
|
127
|
+
) => Record<string, WorkerState | null>
|
|
128
|
+
): Promise<{ workers: Record<string, WorkerState>; standing: Standing }> {
|
|
129
|
+
const { value, standing } = await mutateState(context, () => {
|
|
130
|
+
const states = readWorkerStates();
|
|
131
|
+
const updates = change(states.workers);
|
|
132
|
+
const workers = { ...states.workers };
|
|
133
|
+
for (const [id, entry] of Object.entries(updates))
|
|
134
|
+
if (entry === null) delete workers[id];
|
|
135
|
+
else workers[id] = entry;
|
|
136
|
+
writeWorkerStates({ version: 1, workers });
|
|
137
|
+
return workers;
|
|
138
|
+
});
|
|
139
|
+
return { workers: value, standing };
|
|
140
|
+
}
|
package/src/t3/pairing.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { parseConfig } from '../config';
|
|
2
2
|
import { STATE_ROSTER, readRosterText, writeRosterText } from '../state/roster';
|
|
3
3
|
import { mutateState, stateContext, type StateContext } from '../state/sync';
|
|
4
|
+
import { changeWorkers, readWorkerStates } from '../state/workers';
|
|
5
|
+
import type { Check, FleetNode, Runner } from '../types';
|
|
6
|
+
import { pairWorker, pairingCheck, type ManagerT3 } from './peer-link';
|
|
4
7
|
|
|
5
8
|
/** Records the T3 environment a worker runs in the roster, the first time a
|
|
6
9
|
* verified link from the manager reaches it. From then on the link check
|
|
@@ -29,3 +32,59 @@ export async function rememberEnvironment(
|
|
|
29
32
|
if (path !== STATE_ROSTER) return save();
|
|
30
33
|
return (await mutateState(context(), save)).value;
|
|
31
34
|
}
|
|
35
|
+
|
|
36
|
+
/** What pairing a worker needs: where the roster is, how commands run, the
|
|
37
|
+
* manager's T3 and the fleet state. */
|
|
38
|
+
export interface PairingServices {
|
|
39
|
+
roster: string;
|
|
40
|
+
run: Runner;
|
|
41
|
+
manager: ManagerT3;
|
|
42
|
+
context: () => StateContext;
|
|
43
|
+
now: () => Date;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pairs the manager's T3 with the environment a worker runs now, without a
|
|
47
|
+
* person (src/t3/peer-link.ts), and records it in the roster. Until it is
|
|
48
|
+
* paired, the fleet state keeps the request, so the next fleet-sync pairs
|
|
49
|
+
* it once the worker's T3 runs; once paired, the request is forgotten.
|
|
50
|
+
*/
|
|
51
|
+
export async function pairAndRecord(
|
|
52
|
+
node: FleetNode,
|
|
53
|
+
services: PairingServices
|
|
54
|
+
): Promise<Check> {
|
|
55
|
+
const pairing = await pairWorker(node, services.run, services.manager);
|
|
56
|
+
if ('paired' in pairing)
|
|
57
|
+
await rememberEnvironment(
|
|
58
|
+
services.roster,
|
|
59
|
+
node.id,
|
|
60
|
+
pairing.paired,
|
|
61
|
+
services.context
|
|
62
|
+
);
|
|
63
|
+
const requested = services.now().toISOString();
|
|
64
|
+
await changeWorkers(services.context(), (current) => {
|
|
65
|
+
const { t3PairingRequestedAt: earlier, ...rest } = current[node.id] ?? {};
|
|
66
|
+
return {
|
|
67
|
+
[node.id]:
|
|
68
|
+
'paired' in pairing
|
|
69
|
+
? rest
|
|
70
|
+
: { ...rest, t3PairingRequestedAt: earlier ?? requested },
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
return pairingCheck(node.id, pairing);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** fleet-sync's part: pairs a worker only when fleet-node-replace asked for
|
|
77
|
+
* it. A worker enrolled for the first time is paired by a person.
|
|
78
|
+
*/
|
|
79
|
+
export async function pairIfRequested(
|
|
80
|
+
node: FleetNode,
|
|
81
|
+
services: Omit<PairingServices, 'context' | 'now'> &
|
|
82
|
+
Partial<Pick<PairingServices, 'context'>>
|
|
83
|
+
): Promise<Check | undefined> {
|
|
84
|
+
if (!readWorkerStates().workers[node.id]?.t3PairingRequestedAt) return;
|
|
85
|
+
return pairAndRecord(node, {
|
|
86
|
+
context: () => stateContext(services.run),
|
|
87
|
+
...services,
|
|
88
|
+
now: () => new Date(),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { record } from '../config';
|
|
5
|
+
import { sshArgs } from '../ssh';
|
|
6
|
+
import { observedIdentity } from '../t3-linked-check';
|
|
7
|
+
import { discoverTailnet } from '../tailscale';
|
|
8
|
+
import type { Check, FleetNode, Runner } from '../types';
|
|
9
|
+
|
|
10
|
+
/** Pairing the manager's T3 with a worker's T3 environment, with no person.
|
|
11
|
+
*
|
|
12
|
+
* A pairing code is a one-time secret, so it never enters a command line,
|
|
13
|
+
* a report or a log. The worker mints it (`t3 auth pairing create --json`
|
|
14
|
+
* over the SSH session the manager already has), and its stdout is read in
|
|
15
|
+
* memory. The manager's T3 receives it in the body of one request to its
|
|
16
|
+
* own server on loopback: the `peerLinks.link` call of its WebSocket API,
|
|
17
|
+
* which the T3 Settings (Connections) use, authorized by a session issued
|
|
18
|
+
* for this one call and revoked after it. `t3 environment link` takes the
|
|
19
|
+
* code only as `--pairing-code`, so it is not used.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** What the manager's T3 is asked to do: link the environment at `url`. */
|
|
23
|
+
export interface LinkRequest {
|
|
24
|
+
url: string;
|
|
25
|
+
pairingCode: string;
|
|
26
|
+
/** The environment the worker answered as over SSH: T3 refuses an
|
|
27
|
+
* address that answers as another one. */
|
|
28
|
+
expectedEnvironmentId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type LinkOutcome =
|
|
32
|
+
| { environmentId: string }
|
|
33
|
+
/** Why T3 refused, as its own reason (`pairing_rejected`, `unreachable`,
|
|
34
|
+
* `disabled`, …), or `unavailable` when the manager's T3 did not answer. */
|
|
35
|
+
| { refused: string };
|
|
36
|
+
|
|
37
|
+
/** The manager's own T3. */
|
|
38
|
+
export interface ManagerT3 {
|
|
39
|
+
link(request: LinkRequest): Promise<LinkOutcome>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The two requests to the manager's T3 server, injected by scenarios. */
|
|
43
|
+
export interface T3Transport {
|
|
44
|
+
/** POSTs to the server with the session as a bearer token; the JSON body. */
|
|
45
|
+
post(url: string, bearer: string): Promise<unknown>;
|
|
46
|
+
/** Sends one message on a WebSocket and answers with the first message
|
|
47
|
+
* that ends request `id`. */
|
|
48
|
+
call(url: string, message: object, id: string): Promise<unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The access the manager's agents get on the worker: T3's default. */
|
|
52
|
+
const ACCESS = 'approval-required';
|
|
53
|
+
/** Scopes of the session that makes the call, and how long it lives at most. */
|
|
54
|
+
const SESSION = [
|
|
55
|
+
'--ttl',
|
|
56
|
+
'2m',
|
|
57
|
+
'--scope',
|
|
58
|
+
'access:write',
|
|
59
|
+
'--label',
|
|
60
|
+
'dev-fleet pairing',
|
|
61
|
+
];
|
|
62
|
+
/** The WebSocket protocol of the pinned T3 server. */
|
|
63
|
+
const ORCHESTRATION_PROTOCOL = '2';
|
|
64
|
+
const CODE = /^[A-Za-z0-9_-]{6,128}$/;
|
|
65
|
+
const ENVIRONMENT =
|
|
66
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
67
|
+
|
|
68
|
+
/** The manager's T3, reached through its local server. */
|
|
69
|
+
export function managerT3(
|
|
70
|
+
run: Runner,
|
|
71
|
+
options: {
|
|
72
|
+
home?: string;
|
|
73
|
+
transport?: T3Transport;
|
|
74
|
+
read?: (path: string) => Promise<string>;
|
|
75
|
+
} = {}
|
|
76
|
+
): ManagerT3 {
|
|
77
|
+
const home = resolve(
|
|
78
|
+
options.home ?? process.env['T3CODE_HOME'] ?? join(homedir(), '.t3')
|
|
79
|
+
);
|
|
80
|
+
const transport = options.transport ?? webTransport;
|
|
81
|
+
const read = options.read ?? ((path: string) => readFile(path, 'utf8'));
|
|
82
|
+
return {
|
|
83
|
+
async link(request) {
|
|
84
|
+
const origin = await loopbackOrigin(read, home);
|
|
85
|
+
if (!origin) return { refused: 'unavailable' };
|
|
86
|
+
const issued = await run(
|
|
87
|
+
['t3', 'auth', 'session', 'issue', '--json', ...SESSION],
|
|
88
|
+
{ timeoutMs: 30_000 }
|
|
89
|
+
);
|
|
90
|
+
const session = parseSession(issued.ok ? issued.stdout : '');
|
|
91
|
+
if (!session) return { refused: 'unavailable' };
|
|
92
|
+
try {
|
|
93
|
+
const ticket = await transport.post(
|
|
94
|
+
`${origin}/api/auth/websocket-ticket/rpc`,
|
|
95
|
+
session.token
|
|
96
|
+
);
|
|
97
|
+
if (!record(ticket) || typeof ticket['ticket'] !== 'string')
|
|
98
|
+
return { refused: 'unavailable' };
|
|
99
|
+
const socket = new URL('/ws', origin);
|
|
100
|
+
socket.protocol = socket.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
101
|
+
socket.searchParams.set(
|
|
102
|
+
'orchestrationProtocol',
|
|
103
|
+
ORCHESTRATION_PROTOCOL
|
|
104
|
+
);
|
|
105
|
+
socket.searchParams.set('wsTicket', ticket['ticket']);
|
|
106
|
+
const reply = await transport.call(
|
|
107
|
+
socket.href,
|
|
108
|
+
{
|
|
109
|
+
_tag: 'Request',
|
|
110
|
+
id: '1',
|
|
111
|
+
tag: 'peerLinks.link',
|
|
112
|
+
payload: {
|
|
113
|
+
url: request.url,
|
|
114
|
+
pairingCode: request.pairingCode,
|
|
115
|
+
access: ACCESS,
|
|
116
|
+
expectedEnvironmentId: request.expectedEnvironmentId,
|
|
117
|
+
},
|
|
118
|
+
headers: [],
|
|
119
|
+
},
|
|
120
|
+
'1'
|
|
121
|
+
);
|
|
122
|
+
return outcomeOf(reply);
|
|
123
|
+
} catch {
|
|
124
|
+
return { refused: 'unavailable' };
|
|
125
|
+
} finally {
|
|
126
|
+
await run(['t3', 'auth', 'session', 'revoke', session.id], {
|
|
127
|
+
timeoutMs: 30_000,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The origin the manager's T3 server records for itself, when it is on
|
|
135
|
+
* this machine: a code is never sent anywhere else. */
|
|
136
|
+
async function loopbackOrigin(
|
|
137
|
+
read: (path: string) => Promise<string>,
|
|
138
|
+
home: string
|
|
139
|
+
): Promise<string | undefined> {
|
|
140
|
+
try {
|
|
141
|
+
const runtime: unknown = JSON.parse(
|
|
142
|
+
await read(join(home, 'userdata/server-runtime.json'))
|
|
143
|
+
);
|
|
144
|
+
if (!record(runtime) || typeof runtime['origin'] !== 'string') return;
|
|
145
|
+
const origin = new URL(runtime['origin']);
|
|
146
|
+
return origin.protocol === 'http:' &&
|
|
147
|
+
['127.0.0.1', 'localhost', '[::1]'].includes(origin.hostname)
|
|
148
|
+
? origin.origin
|
|
149
|
+
: undefined;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseSession(text: string): { id: string; token: string } | null {
|
|
156
|
+
try {
|
|
157
|
+
const value: unknown = JSON.parse(text);
|
|
158
|
+
return record(value) &&
|
|
159
|
+
typeof value['sessionId'] === 'string' &&
|
|
160
|
+
/^[A-Za-z0-9_-]{1,128}$/.test(value['sessionId']) &&
|
|
161
|
+
typeof value['token'] === 'string' &&
|
|
162
|
+
value['token'].length > 0
|
|
163
|
+
? { id: value['sessionId'], token: value['token'] }
|
|
164
|
+
: null;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The end of the call: the link T3 made, or why it refused. */
|
|
171
|
+
function outcomeOf(reply: unknown): LinkOutcome {
|
|
172
|
+
if (!record(reply) || reply['_tag'] !== 'Exit' || !record(reply['exit']))
|
|
173
|
+
return { refused: 'unavailable' };
|
|
174
|
+
const exit = reply['exit'];
|
|
175
|
+
if (exit['_tag'] === 'Success' && record(exit['value'])) {
|
|
176
|
+
const id = exit['value']['environmentId'];
|
|
177
|
+
return typeof id === 'string' && ENVIRONMENT.test(id)
|
|
178
|
+
? { environmentId: id }
|
|
179
|
+
: { refused: 'unavailable' };
|
|
180
|
+
}
|
|
181
|
+
return { refused: refusal(exit['cause']) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** T3's own reason for a failed call: a word, never its message. */
|
|
185
|
+
function refusal(cause: unknown): string {
|
|
186
|
+
const first: unknown = Array.isArray(cause) ? cause[0] : undefined;
|
|
187
|
+
const error = record(first) ? first['error'] : undefined;
|
|
188
|
+
const reason = record(error) ? error['reason'] : undefined;
|
|
189
|
+
return typeof reason === 'string' && /^[a-z_]{1,40}$/.test(reason)
|
|
190
|
+
? reason
|
|
191
|
+
: 'unavailable';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const webTransport: T3Transport = {
|
|
195
|
+
async post(url, bearer) {
|
|
196
|
+
const response = await fetch(url, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
199
|
+
signal: AbortSignal.timeout(10_000),
|
|
200
|
+
});
|
|
201
|
+
return response.ok ? ((await response.json()) as unknown) : null;
|
|
202
|
+
},
|
|
203
|
+
call(url, message, id) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const socket = new WebSocket(url);
|
|
206
|
+
const timer = setTimeout(() => {
|
|
207
|
+
socket.close();
|
|
208
|
+
reject(new Error('timeout'));
|
|
209
|
+
}, 60_000);
|
|
210
|
+
const end = (value: unknown, error?: Error) => {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
socket.close();
|
|
213
|
+
if (error) reject(error);
|
|
214
|
+
else resolve(value);
|
|
215
|
+
};
|
|
216
|
+
socket.onopen = () => socket.send(JSON.stringify(message));
|
|
217
|
+
socket.onmessage = (event) => {
|
|
218
|
+
try {
|
|
219
|
+
const reply: unknown = JSON.parse(String(event.data));
|
|
220
|
+
if (
|
|
221
|
+
record(reply) &&
|
|
222
|
+
reply['_tag'] === 'Exit' &&
|
|
223
|
+
reply['requestId'] === id
|
|
224
|
+
)
|
|
225
|
+
end(reply);
|
|
226
|
+
} catch {
|
|
227
|
+
// Not the reply; keep waiting.
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
socket.onerror = () => end(null, new Error('socket'));
|
|
231
|
+
socket.onclose = () => end(null, new Error('closed'));
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Run on the worker, as its development user, over SSH: mints a pairing
|
|
237
|
+
* code for the manager with the worker's own T3 (the CLI on PATH, or the
|
|
238
|
+
* pinned one the toolchain installed) and prints it as JSON on stdout. */
|
|
239
|
+
export const MINT_PAIRING_CODE =
|
|
240
|
+
't3="$(command -v t3 || ls -d "$HOME"/.local/share/personal/tools/t3-*/t3 2>/dev/null | sort -V | tail -n 1)" && ' +
|
|
241
|
+
'exec "$t3" auth pairing create --json --ttl 5m --label "dev-fleet manager"';
|
|
242
|
+
|
|
243
|
+
/** Mints a one-time pairing code on the worker. The code stays in memory. */
|
|
244
|
+
async function mintPairingCode(
|
|
245
|
+
node: FleetNode,
|
|
246
|
+
run: Runner
|
|
247
|
+
): Promise<string | undefined> {
|
|
248
|
+
const minted = await run([...sshArgs(node), MINT_PAIRING_CODE], {
|
|
249
|
+
timeoutMs: 30_000,
|
|
250
|
+
});
|
|
251
|
+
if (!minted.ok) return undefined;
|
|
252
|
+
try {
|
|
253
|
+
const value: unknown = JSON.parse(minted.stdout);
|
|
254
|
+
return record(value) &&
|
|
255
|
+
typeof value['credential'] === 'string' &&
|
|
256
|
+
CODE.test(value['credential'])
|
|
257
|
+
? value['credential']
|
|
258
|
+
: undefined;
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export type Pairing =
|
|
265
|
+
| { paired: string }
|
|
266
|
+
/** The worker's T3 does not answer yet: nothing was minted. */
|
|
267
|
+
| { waiting: true }
|
|
268
|
+
| { failed: string };
|
|
269
|
+
|
|
270
|
+
/** Pairs the manager's T3 with the T3 environment `node` runs now: the
|
|
271
|
+
* environment the worker answers as over SSH, at its private HTTPS address
|
|
272
|
+
* on the tailnet. */
|
|
273
|
+
export async function pairWorker(
|
|
274
|
+
node: FleetNode,
|
|
275
|
+
run: Runner,
|
|
276
|
+
manager: ManagerT3
|
|
277
|
+
): Promise<Pairing> {
|
|
278
|
+
const identity = await observedIdentity(node, run).catch(() => undefined);
|
|
279
|
+
if (!identity) return { waiting: true };
|
|
280
|
+
const tailnet = await discoverTailnet(run);
|
|
281
|
+
const peer = tailnet.computers.find(
|
|
282
|
+
(computer) => computer.id === node.tailscaleId && computer.online
|
|
283
|
+
);
|
|
284
|
+
if (!peer || !/^[a-z0-9.-]+\.ts\.net$/.test(peer.dns))
|
|
285
|
+
return { failed: 'unreachable' };
|
|
286
|
+
const pairingCode = await mintPairingCode(node, run);
|
|
287
|
+
if (!pairingCode) return { failed: 'mint' };
|
|
288
|
+
const linked = await manager.link({
|
|
289
|
+
url: `https://${peer.dns}`,
|
|
290
|
+
pairingCode,
|
|
291
|
+
expectedEnvironmentId: identity,
|
|
292
|
+
});
|
|
293
|
+
if ('refused' in linked) return { failed: linked.refused };
|
|
294
|
+
return linked.environmentId === identity
|
|
295
|
+
? { paired: identity }
|
|
296
|
+
: { failed: 'identity' };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** What each refusal asks a person to do. */
|
|
300
|
+
function remedy(id: string, reason: string): string {
|
|
301
|
+
switch (reason) {
|
|
302
|
+
case 'mint':
|
|
303
|
+
return `The T3 of ${id} did not mint a pairing code. Run devfleet fleet-sync --node ${id} so it runs the pinned T3, then run the command again.`;
|
|
304
|
+
case 'disabled':
|
|
305
|
+
return "Linked environments is off in the manager's T3. Turn it on in its Settings (Labs), or stage the fleet's T3 policy, which turns it on, with ansible/macos/manager-privilege.yml as an administrator; then run the command again.";
|
|
306
|
+
case 'unavailable':
|
|
307
|
+
return "The manager's T3 did not answer on this machine. Open the T3 app (or start its service), then run the command again.";
|
|
308
|
+
default:
|
|
309
|
+
return `The T3 of ${id} refused the link (${reason}). Check that it runs the T3 policy fleet-sync stages (Linked environments on) and answers at its private address, then run devfleet fleet-sync --node ${id}.`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The `t3:link` check of a pairing. */
|
|
314
|
+
export function pairingCheck(id: string, pairing: Pairing): Check {
|
|
315
|
+
if ('paired' in pairing)
|
|
316
|
+
return {
|
|
317
|
+
id: 't3:link',
|
|
318
|
+
status: 'pass',
|
|
319
|
+
summary: `The manager's T3 is paired with the T3 environment ${pairing.paired} of ${id}.`,
|
|
320
|
+
};
|
|
321
|
+
if ('waiting' in pairing)
|
|
322
|
+
return {
|
|
323
|
+
id: 't3:link',
|
|
324
|
+
status: 'skip',
|
|
325
|
+
summary: `The T3 of ${id} does not answer yet; devfleet fleet-sync --node ${id} pairs the manager with it once it runs.`,
|
|
326
|
+
};
|
|
327
|
+
return {
|
|
328
|
+
id: 't3:link',
|
|
329
|
+
status: 'fail',
|
|
330
|
+
summary: 'T3_LINK_PAIRING_FAILED',
|
|
331
|
+
remedy: { action: remedy(id, pairing.failed) },
|
|
332
|
+
};
|
|
333
|
+
}
|
package/src/t3/version.ts
CHANGED
|
@@ -62,7 +62,7 @@ export async function managerVersion(
|
|
|
62
62
|
running,
|
|
63
63
|
await pinnedT3Version(workspace),
|
|
64
64
|
'the manager',
|
|
65
|
-
'Install that release of T3 Code on the manager. With the fleet policy staged (ansible-playbook -K ansible/macos/manager.yml) it no longer updates itself.'
|
|
65
|
+
'Install that release of T3 Code on the manager. With the fleet policy staged (ansible-playbook -e user=$(id -un) -K ansible/macos/manager-privilege.yml) it no longer updates itself.'
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|