@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
package/src/machine/state.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface Enrollment {
|
|
|
20
20
|
approvedRevision: string;
|
|
21
21
|
appliedRevision?: string;
|
|
22
22
|
previousRevision?: string;
|
|
23
|
+
/** The revision each worker last converged to, by fleet id. A worker
|
|
24
|
+
* keeps its progress when the fleet as a whole is not ready.
|
|
25
|
+
*/
|
|
26
|
+
workers?: Record<string, string>;
|
|
27
|
+
/** Tombstones: when each retired worker left the fleet, by fleet id. */
|
|
28
|
+
retired?: Record<string, string>;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export const isRevision = (value: unknown): value is string =>
|
|
@@ -156,6 +162,30 @@ export async function ensurePrivateRoot(root: string): Promise<void> {
|
|
|
156
162
|
}
|
|
157
163
|
}
|
|
158
164
|
|
|
165
|
+
function validWorkers(value: unknown): boolean {
|
|
166
|
+
return (
|
|
167
|
+
value === undefined ||
|
|
168
|
+
(record(value) &&
|
|
169
|
+
Object.entries(value).every(
|
|
170
|
+
([id, revision]) =>
|
|
171
|
+
/^[a-z][a-z0-9-]{0,62}$/.test(id) && isRevision(revision)
|
|
172
|
+
))
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validRetired(value: unknown): boolean {
|
|
177
|
+
return (
|
|
178
|
+
value === undefined ||
|
|
179
|
+
(record(value) &&
|
|
180
|
+
Object.entries(value).every(
|
|
181
|
+
([id, at]) =>
|
|
182
|
+
/^[a-z][a-z0-9-]{0,62}$/.test(id) &&
|
|
183
|
+
typeof at === 'string' &&
|
|
184
|
+
!Number.isNaN(Date.parse(at))
|
|
185
|
+
))
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
159
189
|
/** A valid enrollment record, or null. */
|
|
160
190
|
export function parseEnrollment(data: unknown): Enrollment | null {
|
|
161
191
|
if (!record(data) || data['version'] !== 1) return null;
|
|
@@ -167,7 +197,9 @@ export function parseEnrollment(data: unknown): Enrollment | null {
|
|
|
167
197
|
return isRevision(data['approvedRevision']) &&
|
|
168
198
|
['appliedRevision', 'previousRevision'].every(
|
|
169
199
|
(key) => data[key] === undefined || isRevision(data[key])
|
|
170
|
-
)
|
|
200
|
+
) &&
|
|
201
|
+
validWorkers(data['workers']) &&
|
|
202
|
+
validRetired(data['retired'])
|
|
171
203
|
? (data as unknown as Enrollment)
|
|
172
204
|
: null;
|
|
173
205
|
}
|
package/src/manager-sync.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ManagerReport {
|
|
|
37
37
|
ok: boolean;
|
|
38
38
|
applied: boolean;
|
|
39
39
|
desiredRevision: string | null;
|
|
40
|
+
/** The worker that proved a revision new to the fleet first, when one did. */
|
|
41
|
+
canary?: string;
|
|
40
42
|
checks: Check[];
|
|
41
43
|
nodes: ManagerNode[];
|
|
42
44
|
candidates: number;
|
|
@@ -196,12 +198,10 @@ async function applyNodes(
|
|
|
196
198
|
deploy: DeployNode
|
|
197
199
|
): Promise<void> {
|
|
198
200
|
const workers = report.nodes.filter((node) => node.role !== 'manager');
|
|
199
|
-
// A revision new to the fleet is proven on one worker first. When the run
|
|
200
|
-
// selects only later workers, the first selected one proves it instead:
|
|
201
|
-
// narrowing a run never skips the canary.
|
|
202
201
|
const canary = input.canary
|
|
203
|
-
? (workers
|
|
202
|
+
? chooseCanary(workers, input.canary, input.nodes)
|
|
204
203
|
: undefined;
|
|
204
|
+
if (canary) report.canary = canary.node;
|
|
205
205
|
let blocked = report.checks.some(blocking);
|
|
206
206
|
const step = async (result: ManagerNode) => {
|
|
207
207
|
const deployable = mayDeploy(result, input.nodes);
|
|
@@ -219,6 +219,26 @@ async function applyNodes(
|
|
|
219
219
|
await Promise.all(workers.filter((node) => node !== canary).map(step));
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
/** A revision new to the fleet is proven on one worker first: the preferred
|
|
223
|
+
* one, or when it cannot be reached (powered off, being rebuilt, offline)
|
|
224
|
+
* the next worker in declared order that can. An unreachable worker is
|
|
225
|
+
* reported as such and never blocks the others; only a canary that was
|
|
226
|
+
* deployed and failed stops promotion. When the run selects only later
|
|
227
|
+
* workers, the first selected one proves it instead: narrowing a run never
|
|
228
|
+
* skips the canary.
|
|
229
|
+
*/
|
|
230
|
+
function chooseCanary(
|
|
231
|
+
workers: ManagerNode[],
|
|
232
|
+
preferred: string,
|
|
233
|
+
nodes: FleetNode[]
|
|
234
|
+
): ManagerNode | undefined {
|
|
235
|
+
const first = workers.find((node) => node.node === preferred);
|
|
236
|
+
const order = first
|
|
237
|
+
? [first, ...workers.filter((node) => node !== first)]
|
|
238
|
+
: workers;
|
|
239
|
+
return order.find((node) => mayDeploy(node, nodes));
|
|
240
|
+
}
|
|
241
|
+
|
|
222
242
|
/** One node's rollout step; true when its remote operation ran. */
|
|
223
243
|
async function rollOut(
|
|
224
244
|
result: ManagerNode,
|
package/src/secrets.ts
CHANGED
|
@@ -26,6 +26,9 @@ export const OPTIONAL_SECRET_NAMES = [
|
|
|
26
26
|
// Where fleet-watch notifies (notify.targets).
|
|
27
27
|
'notify-webhook-url',
|
|
28
28
|
'notify-ntfy-url',
|
|
29
|
+
// A short-lived, single-use, tagged Tailscale auth key a new worker joins
|
|
30
|
+
// the tailnet with (fleet-node-seed).
|
|
31
|
+
'tailnet-auth-key',
|
|
29
32
|
] as const;
|
|
30
33
|
export type SecretName =
|
|
31
34
|
(typeof SECRET_NAMES)[number] | (typeof OPTIONAL_SECRET_NAMES)[number];
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { fleet } from './fleet-config';
|
|
2
|
+
import { resolveVaultConfig, type VaultConfig } from './vault-config';
|
|
3
|
+
import type { Check, Runner } from './types';
|
|
4
|
+
|
|
5
|
+
/** Each worker's own Vault token is kept in a document of its own, beside
|
|
6
|
+
* the fleet's shared one: `<project>/workers/<id>`, field `token`, with the
|
|
7
|
+
* token's `accessor` so the manager can revoke it without holding it. A
|
|
8
|
+
* policy that grants one worker its document grants it nothing of another's.
|
|
9
|
+
*/
|
|
10
|
+
export interface WorkerTokenLocation {
|
|
11
|
+
mount: string;
|
|
12
|
+
path: string;
|
|
13
|
+
field: 'token';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function workerTokenLocation(
|
|
17
|
+
node: string,
|
|
18
|
+
vault: VaultConfig = resolveVaultConfig()
|
|
19
|
+
): WorkerTokenLocation {
|
|
20
|
+
return {
|
|
21
|
+
mount: vault.mount,
|
|
22
|
+
path: `${vault.project}/workers/${node}`,
|
|
23
|
+
field: 'token',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The Vault policy each worker token is issued with. */
|
|
28
|
+
export const workerPolicyName = (node: string): string =>
|
|
29
|
+
`fleet-worker-${node}`;
|
|
30
|
+
|
|
31
|
+
/** The least a worker needs: the fleet's shared document (the proxy key and
|
|
32
|
+
* what the providers read) and its own token document. Nothing grants a
|
|
33
|
+
* worker the documents of the others, so one worker's token cannot read
|
|
34
|
+
* another's. Token renewal and lookup come from Vault's default policy.
|
|
35
|
+
*/
|
|
36
|
+
export function workerVaultPolicy(
|
|
37
|
+
node: string,
|
|
38
|
+
vault: VaultConfig = resolveVaultConfig(),
|
|
39
|
+
shared: string = sharedDocument()
|
|
40
|
+
): string {
|
|
41
|
+
const own = workerTokenLocation(node, vault);
|
|
42
|
+
return [
|
|
43
|
+
`# dev-fleet: the policy of the worker ${node}. Written by dev-fleet; do not edit.`,
|
|
44
|
+
`path "${vault.mount}/data/${shared}" {`,
|
|
45
|
+
' capabilities = ["read"]',
|
|
46
|
+
'}',
|
|
47
|
+
`path "${own.mount}/data/${own.path}" {`,
|
|
48
|
+
' capabilities = ["read"]',
|
|
49
|
+
'}',
|
|
50
|
+
'',
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sharedDocument(): string {
|
|
55
|
+
const secrets = fleet.secrets;
|
|
56
|
+
return secrets.provider === 'vault'
|
|
57
|
+
? secrets.locations['proxy-client-key'].path
|
|
58
|
+
: '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const TIMEOUT = { timeoutMs: 20_000 };
|
|
62
|
+
|
|
63
|
+
/** Issues a worker its own periodic, orphan token under its own policy and
|
|
64
|
+
* keeps it in its own document. The token reaches `vault kv put` on standard
|
|
65
|
+
* input, never as an argument. Run on the manager, logged in to Vault.
|
|
66
|
+
*/
|
|
67
|
+
export async function issueWorkerToken(
|
|
68
|
+
node: string,
|
|
69
|
+
run: Runner,
|
|
70
|
+
vault: VaultConfig = resolveVaultConfig()
|
|
71
|
+
): Promise<Check> {
|
|
72
|
+
const address = `-address=${vault.addr}`;
|
|
73
|
+
const policy = await run(
|
|
74
|
+
['vault', 'policy', 'write', address, workerPolicyName(node), '-'],
|
|
75
|
+
{ ...TIMEOUT, input: workerVaultPolicy(node, vault) }
|
|
76
|
+
);
|
|
77
|
+
if (!policy.ok) return credentialFailure('VAULT_WORKER_POLICY_FAILED');
|
|
78
|
+
const created = await run(
|
|
79
|
+
[
|
|
80
|
+
'vault',
|
|
81
|
+
'token',
|
|
82
|
+
'create',
|
|
83
|
+
address,
|
|
84
|
+
`-policy=${workerPolicyName(node)}`,
|
|
85
|
+
'-orphan',
|
|
86
|
+
'-period=768h',
|
|
87
|
+
`-display-name=${workerPolicyName(node)}`,
|
|
88
|
+
'-format=json',
|
|
89
|
+
],
|
|
90
|
+
TIMEOUT
|
|
91
|
+
);
|
|
92
|
+
const issued = created.ok ? parseIssued(created.stdout) : null;
|
|
93
|
+
if (!issued) return credentialFailure('VAULT_WORKER_TOKEN_NOT_ISSUED');
|
|
94
|
+
const location = workerTokenLocation(node, vault);
|
|
95
|
+
const stored = await run(
|
|
96
|
+
[
|
|
97
|
+
'vault',
|
|
98
|
+
'kv',
|
|
99
|
+
'put',
|
|
100
|
+
address,
|
|
101
|
+
`-mount=${location.mount}`,
|
|
102
|
+
location.path,
|
|
103
|
+
'token=-',
|
|
104
|
+
`accessor=${issued.accessor}`,
|
|
105
|
+
],
|
|
106
|
+
{ ...TIMEOUT, input: issued.token }
|
|
107
|
+
);
|
|
108
|
+
if (!stored.ok) return credentialFailure('VAULT_WORKER_TOKEN_NOT_STORED');
|
|
109
|
+
return {
|
|
110
|
+
id: 'auth:worker-token',
|
|
111
|
+
status: 'pass',
|
|
112
|
+
summary: `${node} has a token of its own, readable by nothing but itself and the manager.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseIssued(
|
|
117
|
+
stdout: string
|
|
118
|
+
): { token: string; accessor: string } | null {
|
|
119
|
+
try {
|
|
120
|
+
const auth = (JSON.parse(stdout) as { auth?: Record<string, unknown> })
|
|
121
|
+
.auth;
|
|
122
|
+
const token = auth?.['client_token'];
|
|
123
|
+
const accessor = auth?.['accessor'];
|
|
124
|
+
return typeof token === 'string' &&
|
|
125
|
+
typeof accessor === 'string' &&
|
|
126
|
+
/^[\x21-\x7e]{16,4096}$/.test(token) &&
|
|
127
|
+
/^[A-Za-z0-9._-]{8,256}$/.test(accessor)
|
|
128
|
+
? { token, accessor }
|
|
129
|
+
: null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Revokes a worker's token by its accessor (the manager never holds the
|
|
136
|
+
* token to do so), then removes its document and its policy. A worker that
|
|
137
|
+
* has none already is revoked: running it twice changes nothing.
|
|
138
|
+
*/
|
|
139
|
+
export async function revokeWorkerToken(
|
|
140
|
+
node: string,
|
|
141
|
+
run: Runner,
|
|
142
|
+
vault: VaultConfig = resolveVaultConfig()
|
|
143
|
+
): Promise<Check> {
|
|
144
|
+
const address = `-address=${vault.addr}`;
|
|
145
|
+
const location = workerTokenLocation(node, vault);
|
|
146
|
+
const read = await run(
|
|
147
|
+
[
|
|
148
|
+
'vault',
|
|
149
|
+
'kv',
|
|
150
|
+
'get',
|
|
151
|
+
address,
|
|
152
|
+
`-mount=${location.mount}`,
|
|
153
|
+
'-field=accessor',
|
|
154
|
+
location.path,
|
|
155
|
+
],
|
|
156
|
+
TIMEOUT
|
|
157
|
+
);
|
|
158
|
+
const accessor = read.ok ? read.stdout.trim() : '';
|
|
159
|
+
if (accessor) {
|
|
160
|
+
if (!/^[A-Za-z0-9._-]{8,256}$/.test(accessor))
|
|
161
|
+
return credentialFailure('VAULT_WORKER_TOKEN_NOT_REVOKED');
|
|
162
|
+
const revoked = await run(
|
|
163
|
+
['vault', 'token', 'revoke', address, '-accessor', accessor],
|
|
164
|
+
TIMEOUT
|
|
165
|
+
);
|
|
166
|
+
if (!revoked.ok) return credentialFailure('VAULT_WORKER_TOKEN_NOT_REVOKED');
|
|
167
|
+
}
|
|
168
|
+
await run(
|
|
169
|
+
[
|
|
170
|
+
'vault',
|
|
171
|
+
'kv',
|
|
172
|
+
'metadata',
|
|
173
|
+
'delete',
|
|
174
|
+
address,
|
|
175
|
+
`-mount=${location.mount}`,
|
|
176
|
+
location.path,
|
|
177
|
+
],
|
|
178
|
+
TIMEOUT
|
|
179
|
+
);
|
|
180
|
+
await run(
|
|
181
|
+
['vault', 'policy', 'delete', address, workerPolicyName(node)],
|
|
182
|
+
TIMEOUT
|
|
183
|
+
);
|
|
184
|
+
return {
|
|
185
|
+
id: 'auth:worker-token',
|
|
186
|
+
status: 'pass',
|
|
187
|
+
summary: `${node}'s token is revoked and its document and policy removed.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function credentialFailure(summary: string): Check {
|
|
192
|
+
return {
|
|
193
|
+
id: 'auth:worker-token',
|
|
194
|
+
status: 'fail',
|
|
195
|
+
summary,
|
|
196
|
+
remedy: {
|
|
197
|
+
action:
|
|
198
|
+
'Log the manager in to Vault with a token that may write policies, issue tokens and write the fleet mount, then run the command again.',
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -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
|
);
|