@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.
- package/README.md +32 -13
- package/ansible/macos/manager-privilege.yml +37 -0
- package/ansible/macos/manager.yml +51 -6
- package/ansible/macos/policy.json +6 -1
- package/ansible/macos/worker-host-key.yml +33 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/ubuntu/baseline.yml +6 -0
- package/ansible/ubuntu/recovery.yml +2 -0
- package/ansible/ubuntu/tailscale.yml +2 -0
- package/bootstrap/worker-seed.yaml +61 -0
- package/docs/authentication.md +2 -0
- package/docs/consumer-setup.md +34 -35
- package/docs/declared.md +24 -24
- package/docs/manager-recovery.md +78 -0
- package/docs/manager-setup.md +27 -0
- package/docs/operations.md +8 -0
- package/docs/runbooks.md +15 -1
- package/docs/state.md +38 -6
- package/docs/topology.md +19 -11
- package/docs/ubuntu-sync.md +21 -15
- package/package.json +2 -2
- package/schema/check-ids.json +36 -0
- package/schema/fleet.schema.json +22 -2
- package/src/check-owners.ts +164 -0
- package/src/cli/fleet-manifest.ts +102 -0
- package/src/cli/fleet-node-lifecycle.ts +455 -0
- package/src/cli/fleet-node-seed.ts +151 -0
- package/src/cli/fleet-restore.ts +205 -0
- package/src/cli/fleet-state.ts +57 -5
- package/src/cli/fleet-sync-forward.ts +2 -0
- package/src/cli/fleet-sync-manager.ts +101 -18
- package/src/cli/fleet-sync-remote.ts +21 -6
- package/src/cli/fleet-sync-state.ts +30 -11
- package/src/cli/fleet-sync-vault.ts +75 -26
- package/src/cli/fleet-sync.ts +54 -5
- package/src/cli/fleet-validate.ts +239 -0
- package/src/cli/register-fleet.ts +167 -0
- package/src/cli/registry.ts +47 -1
- package/src/definition.ts +444 -237
- package/src/machine/state.ts +33 -1
- package/src/manager-sync.ts +24 -4
- package/src/platform/manager-host.ts +184 -0
- package/src/repair-catalog.ts +64 -0
- package/src/repair-plan.ts +25 -13
- package/src/secrets.ts +3 -0
- package/src/state/check.ts +7 -0
- package/src/state/holder.ts +50 -0
- package/src/state/litestream.ts +6 -3
- package/src/state/migrations/0001-documents.sql +13 -0
- package/src/state/migrations/0002-lease.sql +7 -0
- package/src/state/store.ts +125 -26
- package/src/state/sync.ts +131 -10
- package/src/worker-credentials.ts +201 -0
- package/src/worker-manifest.ts +201 -0
- package/src/worker-policy.ts +21 -35
- 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
|
+
}
|
package/src/worker-policy.ts
CHANGED
|
@@ -34,33 +34,27 @@ function validCapabilities(value: unknown): boolean {
|
|
|
34
34
|
REQUIRED.every((item) => value.includes(item))
|
|
35
35
|
);
|
|
36
36
|
}
|
|
37
|
-
|
|
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.
|
|
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) ||
|
|
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
|
+
}
|