@crvouga/dev-fleet 0.6.0 → 0.8.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 (56) hide show
  1. package/README.md +32 -13
  2. package/ansible/macos/manager-privilege.yml +37 -0
  3. package/ansible/macos/manager.yml +51 -6
  4. package/ansible/macos/policy.json +6 -1
  5. package/ansible/macos/worker-host-key.yml +33 -0
  6. package/ansible/monitoring/agent.yml +2 -0
  7. package/ansible/ubuntu/baseline.yml +6 -0
  8. package/ansible/ubuntu/recovery.yml +2 -0
  9. package/ansible/ubuntu/tailscale.yml +2 -0
  10. package/bootstrap/worker-seed.yaml +61 -0
  11. package/docs/authentication.md +2 -0
  12. package/docs/consumer-setup.md +34 -35
  13. package/docs/declared.md +24 -24
  14. package/docs/manager-recovery.md +78 -0
  15. package/docs/manager-setup.md +27 -0
  16. package/docs/operations.md +8 -0
  17. package/docs/runbooks.md +15 -1
  18. package/docs/state.md +38 -6
  19. package/docs/topology.md +19 -11
  20. package/docs/ubuntu-sync.md +21 -15
  21. package/package.json +2 -2
  22. package/schema/check-ids.json +36 -0
  23. package/schema/fleet.schema.json +22 -2
  24. package/src/check-owners.ts +164 -0
  25. package/src/cli/fleet-manifest.ts +102 -0
  26. package/src/cli/fleet-node-lifecycle.ts +455 -0
  27. package/src/cli/fleet-node-seed.ts +151 -0
  28. package/src/cli/fleet-restore.ts +205 -0
  29. package/src/cli/fleet-state.ts +57 -5
  30. package/src/cli/fleet-sync-forward.ts +2 -0
  31. package/src/cli/fleet-sync-manager.ts +101 -18
  32. package/src/cli/fleet-sync-remote.ts +21 -6
  33. package/src/cli/fleet-sync-state.ts +30 -11
  34. package/src/cli/fleet-sync-vault.ts +75 -26
  35. package/src/cli/fleet-sync.ts +54 -5
  36. package/src/cli/fleet-validate.ts +239 -0
  37. package/src/cli/register-fleet.ts +167 -0
  38. package/src/cli/registry.ts +47 -1
  39. package/src/definition.ts +444 -237
  40. package/src/machine/state.ts +33 -1
  41. package/src/manager-sync.ts +24 -4
  42. package/src/platform/manager-host.ts +184 -0
  43. package/src/repair-catalog.ts +64 -0
  44. package/src/repair-plan.ts +25 -13
  45. package/src/secrets.ts +3 -0
  46. package/src/state/check.ts +7 -0
  47. package/src/state/holder.ts +50 -0
  48. package/src/state/litestream.ts +6 -3
  49. package/src/state/migrations/0001-documents.sql +13 -0
  50. package/src/state/migrations/0002-lease.sql +7 -0
  51. package/src/state/store.ts +125 -26
  52. package/src/state/sync.ts +131 -10
  53. package/src/worker-credentials.ts +201 -0
  54. package/src/worker-manifest.ts +201 -0
  55. package/src/worker-policy.ts +21 -35
  56. package/src/worker-seed.ts +138 -0
@@ -0,0 +1,201 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { PACKAGE_ROOT } from './package-info';
5
+ import { shellQuote, sshArgs, sshFailure } from './ssh';
6
+ import type { Check, FleetNode, Runner } from './types';
7
+
8
+ /** What a worker is made of, measured: every installed package and its
9
+ * version, the tools mise has active, and whether each always-on service is
10
+ * enabled and running. Two workers built from the same revision have the same
11
+ * manifest, so its digest proves a rebuild reproduces a machine, and its
12
+ * difference from a fresh build shows what a long-lived worker gained.
13
+ */
14
+ export interface WorkerManifest {
15
+ version: 1;
16
+ packages: Record<string, string>;
17
+ tools: Record<string, string>;
18
+ units: Record<string, string>;
19
+ }
20
+
21
+ /** The policy a worker's manifest is measured against. */
22
+ interface UbuntuPolicy {
23
+ packages: Record<string, string>;
24
+ alwaysOnServices: string[];
25
+ }
26
+
27
+ export async function ubuntuPolicy(root = PACKAGE_ROOT): Promise<UbuntuPolicy> {
28
+ return JSON.parse(
29
+ await readFile(join(root, 'ansible/ubuntu/policy.json'), 'utf8')
30
+ ) as UbuntuPolicy;
31
+ }
32
+
33
+ /** A read-only POSIX probe: it lists, and changes nothing. */
34
+ export function manifestProbe(services: string[]): string {
35
+ const units = services.map(shellQuote).join(' ');
36
+ return [
37
+ 'export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH"',
38
+ 'echo @packages',
39
+ "dpkg-query -W -f='${binary:Package}=${Version}\\n' 2>/dev/null",
40
+ 'echo @tools',
41
+ "mise ls --current --json 2>/dev/null | tr -d '\\n'; echo",
42
+ 'echo @units',
43
+ `for unit in ${units}; do printf '%s=%s/%s\\n' "$unit" "$(systemctl is-enabled "$unit" 2>/dev/null)" "$(systemctl is-active "$unit" 2>/dev/null)"; done`,
44
+ ].join('; ');
45
+ }
46
+
47
+ const sorted = (entries: [string, string][]): Record<string, string> =>
48
+ Object.fromEntries(entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
49
+
50
+ function pairs(lines: string[]): [string, string][] {
51
+ return lines
52
+ .map((line) => line.trim())
53
+ .filter((line) => line.includes('='))
54
+ .map((line) => {
55
+ const at = line.indexOf('=');
56
+ return [line.slice(0, at), line.slice(at + 1)] as [string, string];
57
+ });
58
+ }
59
+
60
+ function tools(line: string | undefined): Record<string, string> {
61
+ try {
62
+ const listed = JSON.parse(line ?? '{}') as Record<
63
+ string,
64
+ { version?: string }[]
65
+ >;
66
+ return sorted(
67
+ Object.entries(listed).map(([tool, versions]) => [
68
+ tool,
69
+ versions
70
+ .map((entry) => entry.version ?? '')
71
+ .sort()
72
+ .join(','),
73
+ ])
74
+ );
75
+ } catch {
76
+ return {};
77
+ }
78
+ }
79
+
80
+ /** The manifest the probe printed. Order never matters: everything is sorted. */
81
+ export function parseManifest(stdout: string): WorkerManifest {
82
+ const sections: Record<string, string[]> = {};
83
+ let current = '';
84
+ for (const line of stdout.split('\n')) {
85
+ if (/^@[a-z]+$/.test(line.trim())) {
86
+ current = line.trim().slice(1);
87
+ sections[current] = [];
88
+ } else if (current) sections[current]!.push(line);
89
+ }
90
+ return {
91
+ version: 1,
92
+ packages: sorted(pairs(sections['packages'] ?? [])),
93
+ tools: tools(sections['tools']?.find((line) => line.trim())),
94
+ units: sorted(pairs(sections['units'] ?? [])),
95
+ };
96
+ }
97
+
98
+ /** The same manifest always has the same digest. */
99
+ export function manifestDigest(manifest: WorkerManifest): string {
100
+ return createHash('sha256').update(JSON.stringify(manifest)).digest('hex');
101
+ }
102
+
103
+ export interface ManifestDifference {
104
+ section: 'packages' | 'tools' | 'units';
105
+ name: string;
106
+ expected: string | null;
107
+ actual: string | null;
108
+ }
109
+
110
+ /** What `live` has that `reference` does not, lacks, or has otherwise. */
111
+ export function diffManifests(
112
+ reference: WorkerManifest,
113
+ live: WorkerManifest
114
+ ): ManifestDifference[] {
115
+ const differences: ManifestDifference[] = [];
116
+ for (const section of ['packages', 'tools', 'units'] as const) {
117
+ const names = new Set([
118
+ ...Object.keys(reference[section]),
119
+ ...Object.keys(live[section]),
120
+ ]);
121
+ for (const name of [...names].sort()) {
122
+ const expected = reference[section][name] ?? null;
123
+ const actual = live[section][name] ?? null;
124
+ if (expected !== actual)
125
+ differences.push({ section, name, expected, actual });
126
+ }
127
+ }
128
+ return differences;
129
+ }
130
+
131
+ /** Where a manifest departs from what the policy declares: a pinned package
132
+ * at another version or missing, an always-on service not enabled and active.
133
+ */
134
+ export function declaredDifferences(
135
+ manifest: WorkerManifest,
136
+ policy: UbuntuPolicy
137
+ ): ManifestDifference[] {
138
+ const differences: ManifestDifference[] = [];
139
+ for (const [name, version] of Object.entries(policy.packages)) {
140
+ const actual = manifest.packages[name] ?? null;
141
+ if (actual !== version)
142
+ differences.push({
143
+ section: 'packages',
144
+ name,
145
+ expected: version,
146
+ actual,
147
+ });
148
+ }
149
+ for (const unit of policy.alwaysOnServices) {
150
+ const actual = manifest.units[unit] ?? null;
151
+ if (actual !== 'enabled/active')
152
+ differences.push({
153
+ section: 'units',
154
+ name: unit,
155
+ expected: 'enabled/active',
156
+ actual,
157
+ });
158
+ }
159
+ return differences;
160
+ }
161
+
162
+ /** `worker:manifest`: the worker is what a fresh build of the same revision
163
+ * is (when a reference is given) and what the policy declares.
164
+ */
165
+ export function manifestCheck(differences: ManifestDifference[]): Check {
166
+ if (!differences.length)
167
+ return {
168
+ id: 'worker:manifest',
169
+ status: 'pass',
170
+ summary: 'The worker matches the declared pins and the fresh build.',
171
+ };
172
+ const shown = differences
173
+ .slice(0, 20)
174
+ .map(
175
+ (difference) =>
176
+ `${difference.section}:${difference.name} ${difference.expected ?? 'absent'} -> ${difference.actual ?? 'absent'}`
177
+ );
178
+ return {
179
+ id: 'worker:manifest',
180
+ status: 'fail',
181
+ summary: `WORKER_MANIFEST_DRIFT: ${differences.length} difference(s): ${shown.join('; ')}`,
182
+ remedy: {
183
+ action:
184
+ 'Something was installed, removed or changed outside fleet-sync. Rebuild the worker (fleet-node-replace) or declare the change in dev-fleet.',
185
+ },
186
+ };
187
+ }
188
+
189
+ /** Measures one worker's manifest over the verified SSH connection. */
190
+ export async function measureManifest(
191
+ node: FleetNode,
192
+ run: Runner,
193
+ policy: UbuntuPolicy
194
+ ): Promise<{ manifest: WorkerManifest } | { check: Check }> {
195
+ const result = await run(
196
+ [...sshArgs(node), manifestProbe(policy.alwaysOnServices)],
197
+ { timeoutMs: 60_000 }
198
+ );
199
+ if (!result.ok) return { check: sshFailure(result) };
200
+ return { manifest: parseManifest(result.stdout) };
201
+ }
@@ -34,33 +34,27 @@ function validCapabilities(value: unknown): boolean {
34
34
  REQUIRED.every((item) => value.includes(item))
35
35
  );
36
36
  }
37
- function validPolicy(value: Record<string, unknown>): boolean {
37
+ const FIELDS: Record<string, (value: unknown) => boolean> = {
38
+ requiredCapabilities: validCapabilities,
39
+ githubLogin: (value) =>
40
+ typeof value === 'string' && /^[a-zA-Z0-9-]{1,39}$/.test(value),
41
+ vaultUser: (value) =>
42
+ typeof value === 'string' && /^[a-zA-Z0-9_.-]{1,64}$/.test(value),
43
+ toolchain: (value) => value === 'mise.lock',
44
+ agentProfile: (value) => value === 'proxy',
45
+ unattendedSudo: (value) => value === true,
46
+ persistentT3: (value) => value === true,
47
+ neverSleep: (value) => value === true,
48
+ toolProfiles: (value) => value === undefined || toolProfiles(value) !== null,
49
+ tools: (value) => value === undefined || toolNames(value),
50
+ };
51
+ /** The first field of a worker policy that breaks its rule, or null. */
52
+ export function policyFault(value: Record<string, unknown>): string | null {
53
+ const unknown = Object.keys(value).find((key) => !(key in FIELDS));
54
+ if (unknown !== undefined) return unknown;
38
55
  return (
39
- Object.keys(value).every((key) =>
40
- [
41
- 'requiredCapabilities',
42
- 'githubLogin',
43
- 'vaultUser',
44
- 'toolchain',
45
- 'agentProfile',
46
- 'unattendedSudo',
47
- 'persistentT3',
48
- 'neverSleep',
49
- 'toolProfiles',
50
- 'tools',
51
- ].includes(key)
52
- ) &&
53
- validTools(value) &&
54
- validCapabilities(value['requiredCapabilities']) &&
55
- typeof value['githubLogin'] === 'string' &&
56
- /^[a-zA-Z0-9-]{1,39}$/.test(value['githubLogin']) &&
57
- typeof value['vaultUser'] === 'string' &&
58
- /^[a-zA-Z0-9_.-]{1,64}$/.test(value['vaultUser']) &&
59
- value['toolchain'] === 'mise.lock' &&
60
- value['agentProfile'] === 'proxy' &&
61
- ['unattendedSudo', 'persistentT3', 'neverSleep'].every(
62
- (key) => value[key] === true
63
- )
56
+ Object.entries(FIELDS).find(([key, valid]) => !valid(value[key]))?.[0] ??
57
+ null
64
58
  );
65
59
  }
66
60
  /** A list of known tool profiles, each once; null when it is not one. */
@@ -71,14 +65,6 @@ export function toolProfiles(value: unknown): ToolProfile[] | null {
71
65
  ? (value as ToolProfile[])
72
66
  : null;
73
67
  }
74
- /** The optional tool profiles and single tools every worker adds. */
75
- function validTools(value: Record<string, unknown>): boolean {
76
- return (
77
- (value['toolProfiles'] === undefined ||
78
- toolProfiles(value['toolProfiles']) !== null) &&
79
- (value['tools'] === undefined || toolNames(value['tools']))
80
- );
81
- }
82
68
  function toolNames(value: unknown): boolean {
83
69
  return (
84
70
  Array.isArray(value) &&
@@ -89,7 +75,7 @@ function toolNames(value: unknown): boolean {
89
75
  );
90
76
  }
91
77
  export function parseWorkerPolicy(value: unknown): WorkerPolicy {
92
- if (!record(value) || !validPolicy(value))
78
+ if (!record(value) || policyFault(value) !== null)
93
79
  throw new FleetUsageError(
94
80
  'Invalid mandatory worker policy. Workers require the locked toolchain, common services, unattended sudo, persistent T3 and no sleep.'
95
81
  );
@@ -0,0 +1,138 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { parse, stringify } from 'yaml';
5
+ import type { MachineDefinition } from './definition';
6
+ import { FleetUsageError } from './errors';
7
+ import { PACKAGE_ROOT } from './package-info';
8
+ import type { Runner } from './types';
9
+
10
+ /** What a new worker's first boot needs, and nothing that outlives it: the
11
+ * worker account with the manager's public key, the privilege rule
12
+ * fleet-sync expects, a host key generated here so the manager pins it before
13
+ * the machine ever answers (no trust on first use), and a short-lived,
14
+ * single-use Tailscale auth key that is deleted once the machine has joined.
15
+ */
16
+ export interface SeedInput {
17
+ worker: MachineDefinition;
18
+ /** The manager's SSH public key: one line of a `.pub` file. */
19
+ managerKey: string;
20
+ /** From the secrets provider, by purpose (`tailnet-auth-key`). */
21
+ authKey: string;
22
+ /** Generated with ssh-keygen for this seed. */
23
+ hostKey: { private: string; public: string };
24
+ /** `tailscale` in ansible/ubuntu/policy.json: version and key digest. */
25
+ tailscale: { version: string; keySha256: string };
26
+ /** The codename of the fleet's Ubuntu release, from the Ubuntu policy. */
27
+ codename: string;
28
+ }
29
+
30
+ const PUBLIC_KEY =
31
+ /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}( [^\n]*)?$/;
32
+
33
+ /** The user data of one worker: bootstrap/worker-seed.yaml with every
34
+ * `${NAME}` filled. Values replace whole placeholders inside parsed strings,
35
+ * so none of them can change the document's structure.
36
+ */
37
+ export async function seedUserData(
38
+ input: SeedInput,
39
+ root = PACKAGE_ROOT
40
+ ): Promise<Record<string, unknown>> {
41
+ const { worker } = input;
42
+ const user = worker.network?.user;
43
+ if (!user)
44
+ throw new FleetUsageError(
45
+ `${worker.id} declares no network block; the seed needs its user and Tailscale name.`
46
+ );
47
+ if (!PUBLIC_KEY.test(input.managerKey.trim()))
48
+ throw new FleetUsageError(
49
+ 'The manager public key is not one OpenSSH public key line.'
50
+ );
51
+ if (!/^tskey-auth-[A-Za-z0-9-]{8,200}$/.test(input.authKey))
52
+ throw new FleetUsageError(
53
+ 'The tailnet-auth-key secret is not a Tailscale auth key (tskey-auth-…). Use a short-lived, single-use, tagged key.'
54
+ );
55
+ const values: Record<string, string> = {
56
+ HOSTNAME: worker.network!.tailscaleName.split('.')[0]!,
57
+ USER: user,
58
+ MANAGER_KEY: input.managerKey.trim(),
59
+ HOST_KEY_PRIVATE: input.hostKey.private,
60
+ HOST_KEY_PUBLIC: input.hostKey.public,
61
+ AUTH_KEY: input.authKey,
62
+ TAILSCALE_VERSION: input.tailscale.version,
63
+ TAILSCALE_KEY_SHA256: input.tailscale.keySha256,
64
+ CODENAME: input.codename,
65
+ };
66
+ const template = parse(
67
+ await readFile(join(root, 'bootstrap/worker-seed.yaml'), 'utf8')
68
+ ) as unknown;
69
+ return fill(template, values) as Record<string, unknown>;
70
+ }
71
+
72
+ function fill(value: unknown, values: Record<string, string>): unknown {
73
+ if (typeof value === 'string')
74
+ return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name: string) => {
75
+ const found = values[name];
76
+ if (found === undefined)
77
+ throw new Error(`The seed template names an unknown value ${name}.`);
78
+ return found;
79
+ });
80
+ if (Array.isArray(value)) return value.map((item) => fill(item, values));
81
+ if (value && typeof value === 'object')
82
+ return Object.fromEntries(
83
+ Object.entries(value).map(([key, item]) => [key, fill(item, values)])
84
+ );
85
+ return value;
86
+ }
87
+
88
+ /** Cloud-init user data, or an Ubuntu autoinstall file that carries it. */
89
+ export function renderSeed(
90
+ userData: Record<string, unknown>,
91
+ autoinstall: boolean
92
+ ): string {
93
+ const document = autoinstall
94
+ ? {
95
+ autoinstall: {
96
+ version: 1,
97
+ ssh: { 'install-server': true, 'allow-pw': false },
98
+ 'user-data': userData,
99
+ },
100
+ }
101
+ : userData;
102
+ return `#cloud-config\n${stringify(document, { lineWidth: 0 })}`;
103
+ }
104
+
105
+ /** The version and key digest the worker baseline pins for Tailscale. */
106
+ export async function tailscalePins(
107
+ root = PACKAGE_ROOT
108
+ ): Promise<SeedInput['tailscale']> {
109
+ const policy = JSON.parse(
110
+ await readFile(join(root, 'ansible/ubuntu/policy.json'), 'utf8')
111
+ ) as { tailscale: SeedInput['tailscale'] };
112
+ return policy.tailscale;
113
+ }
114
+
115
+ /** A new ed25519 host key, generated in a private directory that is removed
116
+ * afterwards.
117
+ */
118
+ export async function generateHostKey(
119
+ id: string,
120
+ run: Runner
121
+ ): Promise<SeedInput['hostKey']> {
122
+ const directory = await mkdtemp(join(tmpdir(), 'devfleet-seed-'));
123
+ try {
124
+ const path = join(directory, 'host');
125
+ const made = await run(
126
+ ['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', id, '-f', path],
127
+ { timeoutMs: 20_000 }
128
+ );
129
+ if (!made.ok)
130
+ throw new FleetUsageError('ssh-keygen could not make a host key.');
131
+ return {
132
+ private: await readFile(path, 'utf8'),
133
+ public: (await readFile(`${path}.pub`, 'utf8')).trim(),
134
+ };
135
+ } finally {
136
+ await rm(directory, { recursive: true, force: true });
137
+ }
138
+ }