@crvouga/dev-fleet 0.7.0 → 0.9.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 +88 -7
- 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 +83 -0
- package/ansible/macos/manager.yml +164 -9
- 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/macos/worker-host-key.yml +33 -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 +65 -0
- 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 +21 -11
- package/docs/consumer-setup.md +74 -58
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +34 -0
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +112 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +82 -12
- package/docs/ubuntu-sync.md +98 -42
- 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 +1991 -162
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +349 -10
- 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 +62 -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 +108 -0
- package/src/cli/fleet-node-lifecycle.ts +704 -0
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +167 -0
- 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 +6 -0
- package/src/cli/fleet-sync-manager.ts +123 -8
- 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 +172 -15
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +31 -10
- package/src/cli/fleet-sync-vault.ts +152 -26
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +172 -21
- package/src/cli/fleet-validate.ts +277 -0
- 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 +251 -4
- package/src/cli/registry.ts +47 -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 +762 -232
- 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 +70 -16
- 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 +89 -8
- 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 +7 -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-capacity.ts +31 -0
- package/src/worker-credentials.ts +201 -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 +224 -0
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +38 -36
- 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 +143 -0
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import type { FleetDefinition, MachineDefinition } from './definition';
|
|
11
|
+
import { stateRoot } from './state/paths';
|
|
12
|
+
|
|
13
|
+
/** How the manager reaches a worker, written once: every direct SSH session
|
|
14
|
+
* (src/ssh.ts) and every Ansible run over SSH (fleet-validate's inventory,
|
|
15
|
+
* with ansible/ansible.cfg) takes its options from here, so the two cannot
|
|
16
|
+
* drift. Host keys are checked strictly and never learned on first use. A
|
|
17
|
+
* few attempts and keepalives let a short network blip pass without ending
|
|
18
|
+
* a long operation.
|
|
19
|
+
*/
|
|
20
|
+
export const SSH_TRANSPORT: readonly (readonly [string, string])[] = [
|
|
21
|
+
['BatchMode', 'yes'],
|
|
22
|
+
['StrictHostKeyChecking', 'yes'],
|
|
23
|
+
['UpdateHostKeys', 'no'],
|
|
24
|
+
['ConnectTimeout', '10'],
|
|
25
|
+
['ConnectionAttempts', '3'],
|
|
26
|
+
['ServerAliveInterval', '15'],
|
|
27
|
+
['ServerAliveCountMax', '4'],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** A direct session also forwards nothing and shares no connection. Ansible
|
|
31
|
+
* multiplexes its own (ControlPersist in ansible/ansible.cfg).
|
|
32
|
+
*/
|
|
33
|
+
export const SSH_SESSION: readonly (readonly [string, string])[] = [
|
|
34
|
+
['ClearAllForwardings', 'yes'],
|
|
35
|
+
['PermitLocalCommand', 'no'],
|
|
36
|
+
['ControlMaster', 'no'],
|
|
37
|
+
['ControlPath', 'none'],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** The name a worker's host key is pinned under, whatever its address. */
|
|
41
|
+
export const hostKeyAlias = (id: string): string => `devfleet-${id}`;
|
|
42
|
+
|
|
43
|
+
/** The fleet-owned known_hosts file: a cache rendered from the pinned keys
|
|
44
|
+
* (declared in a worker record, or in the fleet state). The operator's own
|
|
45
|
+
* ~/.ssh files are neither read for it nor changed.
|
|
46
|
+
*/
|
|
47
|
+
export function knownHostsPath(env = process.env, home = homedir()): string {
|
|
48
|
+
return join(dirname(stateRoot(env, home)), 'ssh', 'known_hosts');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A worker without an SSH alias is reached from its declared network
|
|
52
|
+
* block: its tailnet name, user and port.
|
|
53
|
+
*/
|
|
54
|
+
export function declaredConnection(
|
|
55
|
+
worker: MachineDefinition | undefined
|
|
56
|
+
):
|
|
57
|
+
| (MachineDefinition & { network: NonNullable<MachineDefinition['network']> })
|
|
58
|
+
| null {
|
|
59
|
+
return worker && worker.ssh === undefined && worker.network
|
|
60
|
+
? (worker as MachineDefinition & {
|
|
61
|
+
network: NonNullable<MachineDefinition['network']>;
|
|
62
|
+
})
|
|
63
|
+
: null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Host keys pinned in the fleet state, by worker ID. Only the devfleet CLI
|
|
67
|
+
* reads the state (it registers the reader); launchers and the worker probe
|
|
68
|
+
* run under Node, which cannot open the state database.
|
|
69
|
+
*/
|
|
70
|
+
type PinReader = () => Record<string, string>;
|
|
71
|
+
let pinReader: PinReader | null = null;
|
|
72
|
+
export function readPinsWith(reader: PinReader | null): void {
|
|
73
|
+
pinReader = reader;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The key each worker reached from declared data is pinned to: the one its
|
|
77
|
+
* record or fleet.json declares, else the one the fleet state pins.
|
|
78
|
+
*/
|
|
79
|
+
export function pinnedKeys(
|
|
80
|
+
definition: FleetDefinition,
|
|
81
|
+
pins: Record<string, string> = pinReader?.() ?? {}
|
|
82
|
+
): Record<string, string> {
|
|
83
|
+
return Object.fromEntries(
|
|
84
|
+
definition.workers.flatMap((worker) => {
|
|
85
|
+
const declared = declaredConnection(worker);
|
|
86
|
+
const key = declared?.network.hostKey ?? pins[worker.id];
|
|
87
|
+
return declared && key ? [[worker.id, key]] : [];
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function knownHostsText(keys: Record<string, string>): string {
|
|
93
|
+
return [
|
|
94
|
+
'# Managed by dev-fleet: rendered from the fleet state and worker records. Do not edit.',
|
|
95
|
+
...Object.entries(keys)
|
|
96
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
97
|
+
.map(([id, key]) => `${hostKeyAlias(id)} ${key}`),
|
|
98
|
+
'',
|
|
99
|
+
].join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Writes the fleet-owned known_hosts when it differs; returns its path. */
|
|
103
|
+
export function renderKnownHosts(
|
|
104
|
+
definition: FleetDefinition,
|
|
105
|
+
env = process.env,
|
|
106
|
+
pins?: Record<string, string>
|
|
107
|
+
): string {
|
|
108
|
+
const path = knownHostsPath(env);
|
|
109
|
+
const text = knownHostsText(pinnedKeys(definition, pins));
|
|
110
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === text) return path;
|
|
111
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
112
|
+
writeFileSync(`${path}.new`, text, { mode: 0o600 });
|
|
113
|
+
renameSync(`${path}.new`, path);
|
|
114
|
+
return path;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const option = ([name, value]: readonly [string, string]): string =>
|
|
118
|
+
`${name}=${value}`;
|
|
119
|
+
|
|
120
|
+
/** The options that reach one worker from declared data. */
|
|
121
|
+
export function connectionOptions(
|
|
122
|
+
worker: MachineDefinition & {
|
|
123
|
+
network: NonNullable<MachineDefinition['network']>;
|
|
124
|
+
},
|
|
125
|
+
knownHosts: string
|
|
126
|
+
): [string, string][] {
|
|
127
|
+
return [
|
|
128
|
+
['HostKeyAlias', hostKeyAlias(worker.id)],
|
|
129
|
+
['UserKnownHostsFile', knownHosts],
|
|
130
|
+
['GlobalKnownHostsFile', '/dev/null'],
|
|
131
|
+
['User', worker.network.user],
|
|
132
|
+
['Port', String(worker.network.sshPort)],
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `-o NAME=VALUE` for each option, as ssh takes them. */
|
|
137
|
+
export function sshOptionArgs(
|
|
138
|
+
options: readonly (readonly [string, string])[]
|
|
139
|
+
): string[] {
|
|
140
|
+
return options.flatMap((entry) => ['-o', option(entry)]);
|
|
141
|
+
}
|
package/src/ssh.ts
CHANGED
|
@@ -1,40 +1,79 @@
|
|
|
1
1
|
import type { CommandResult, FleetNode, Check } from './types';
|
|
2
|
+
import { fleet } from './fleet-config';
|
|
3
|
+
import {
|
|
4
|
+
connectionOptions,
|
|
5
|
+
declaredConnection,
|
|
6
|
+
renderKnownHosts,
|
|
7
|
+
SSH_SESSION,
|
|
8
|
+
SSH_TRANSPORT,
|
|
9
|
+
sshOptionArgs,
|
|
10
|
+
} from './ssh-connection';
|
|
2
11
|
|
|
3
12
|
export function shellQuote(value: string): string {
|
|
4
13
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
5
14
|
}
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
/** The worker fleet.json declares for a roster entry that is reached from
|
|
17
|
+
* declared data, if it is one. No configuration (a fresh machine) is none.
|
|
18
|
+
*/
|
|
19
|
+
function declaredWorker(node: Pick<FleetNode, 'id' | 'ssh'>) {
|
|
20
|
+
try {
|
|
21
|
+
const worker = declaredConnection(
|
|
22
|
+
fleet.workers.find((entry) => entry.id === node.id)
|
|
23
|
+
);
|
|
24
|
+
return worker && worker.network.tailscaleName === node.ssh ? worker : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One SSH session to a node: unattended, strict about the host key,
|
|
31
|
+
* forwarding nothing. A worker without an alias is reached from its declared
|
|
32
|
+
* user, port and tailnet name, against the fleet-owned known_hosts.
|
|
33
|
+
*/
|
|
34
|
+
export function sshArgs(node: Pick<FleetNode, 'id' | 'ssh'>): string[] {
|
|
35
|
+
const declared = declaredWorker(node);
|
|
8
36
|
return [
|
|
9
37
|
'ssh',
|
|
10
38
|
'-T',
|
|
11
39
|
'-a',
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'UpdateHostKeys=no',
|
|
18
|
-
'-o',
|
|
19
|
-
'ConnectTimeout=8',
|
|
20
|
-
'-o',
|
|
21
|
-
'ConnectionAttempts=1',
|
|
22
|
-
'-o',
|
|
23
|
-
'ServerAliveInterval=15',
|
|
24
|
-
'-o',
|
|
25
|
-
'ServerAliveCountMax=4',
|
|
26
|
-
'-o',
|
|
27
|
-
'ClearAllForwardings=yes',
|
|
28
|
-
'-o',
|
|
29
|
-
'PermitLocalCommand=no',
|
|
30
|
-
'-o',
|
|
31
|
-
'ControlMaster=no',
|
|
32
|
-
'-o',
|
|
33
|
-
'ControlPath=none',
|
|
40
|
+
...sshOptionArgs(SSH_TRANSPORT),
|
|
41
|
+
...(declared
|
|
42
|
+
? sshOptionArgs(connectionOptions(declared, renderKnownHosts(fleet)))
|
|
43
|
+
: []),
|
|
44
|
+
...sshOptionArgs(SSH_SESSION),
|
|
34
45
|
node.ssh,
|
|
35
46
|
];
|
|
36
47
|
}
|
|
37
48
|
|
|
49
|
+
/** How a package playbook that addresses a worker over SSH itself
|
|
50
|
+
* (ansible/ubuntu/worker-account.yml, ansible/ubuntu/reboot.yml) reaches it:
|
|
51
|
+
* the options every direct session takes, as one `ansible_ssh_common_args`
|
|
52
|
+
* value, and the account a worker reached from declared data logs in as. The
|
|
53
|
+
* play names the login itself (root, or the declared account), so the
|
|
54
|
+
* declared user is not among the options.
|
|
55
|
+
*/
|
|
56
|
+
export function playbookConnection(node: Pick<FleetNode, 'id' | 'ssh'>): {
|
|
57
|
+
options: string;
|
|
58
|
+
user: string | null;
|
|
59
|
+
} {
|
|
60
|
+
const declared = declaredWorker(node);
|
|
61
|
+
const options = [
|
|
62
|
+
...SSH_TRANSPORT,
|
|
63
|
+
...(declared
|
|
64
|
+
? connectionOptions(declared, renderKnownHosts(fleet)).filter(
|
|
65
|
+
([name]) => name !== 'User'
|
|
66
|
+
)
|
|
67
|
+
: []),
|
|
68
|
+
];
|
|
69
|
+
return {
|
|
70
|
+
options: sshOptionArgs(options)
|
|
71
|
+
.map((arg) => (arg === '-o' ? arg : shellQuote(arg)))
|
|
72
|
+
.join(' '),
|
|
73
|
+
user: declared?.network.user ?? null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
export function remoteCommand(node: FleetNode, request: object): string[] {
|
|
39
78
|
const encoded = Buffer.from(JSON.stringify(request)).toString('base64');
|
|
40
79
|
const command = `cd -- ${shellQuote(node.workspace)} && export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" && exec ${shellQuote(node.runtime ?? 'node')} --input-type=module - ${shellQuote(encoded)}`;
|
|
@@ -48,7 +87,7 @@ const FAILURES = [
|
|
|
48
87
|
id: 'ssh:host-key',
|
|
49
88
|
summary: 'SSH host key is unknown or changed.',
|
|
50
89
|
action:
|
|
51
|
-
'
|
|
90
|
+
'The worker presented a host key other than the pinned one. Verify its fingerprint out of band, then pin the new key: devfleet fleet-node-replace --id ID --host-key "TYPE BASE64" --apply --yes, or change hostKey in its worker record. A worker reached through your own SSH alias is checked against your known_hosts instead. Never disable host-key checking.',
|
|
52
91
|
},
|
|
53
92
|
{
|
|
54
93
|
pattern:
|
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
|
+
}
|