@crvouga/dev-fleet 0.7.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 +28 -3
- package/ansible/macos/manager-privilege.yml +37 -0
- package/ansible/macos/manager.yml +13 -5
- package/ansible/macos/worker-host-key.yml +33 -0
- package/bootstrap/worker-seed.yaml +61 -0
- package/docs/authentication.md +2 -0
- package/docs/consumer-setup.md +32 -31
- package/docs/declared.md +24 -24
- package/docs/manager-setup.md +27 -0
- package/docs/runbooks.md +15 -1
- package/docs/topology.md +15 -6
- package/docs/ubuntu-sync.md +21 -15
- package/package.json +2 -2
- package/schema/check-ids.json +32 -0
- package/schema/fleet.schema.json +22 -2
- package/src/check-owners.ts +1 -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-sync-forward.ts +2 -0
- package/src/cli/fleet-sync-manager.ts +50 -5
- package/src/cli/fleet-sync-remote.ts +21 -6
- package/src/cli/fleet-sync-state.ts +12 -3
- package/src/cli/fleet-sync-vault.ts +75 -26
- package/src/cli/fleet-sync.ts +22 -2
- package/src/cli/fleet-validate.ts +239 -0
- package/src/cli/register-fleet.ts +136 -0
- package/src/cli/registry.ts +33 -0
- package/src/definition.ts +444 -237
- package/src/machine/state.ts +33 -1
- package/src/manager-sync.ts +24 -4
- package/src/secrets.ts +3 -0
- 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,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
|
+
}
|