@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
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { fleet as definition } from '../fleet-config';
|
|
3
3
|
import { sshArgs, shellQuote } from '../ssh';
|
|
4
4
|
import { resolveVaultConfig, usesVault } from '../vault-config';
|
|
5
|
+
import { workerTokenLocation } from '../worker-credentials';
|
|
5
6
|
import type { Check, FleetNode, Runner } from '../types';
|
|
6
7
|
|
|
7
8
|
const TOOL_PATH =
|
|
@@ -15,7 +16,11 @@ const terminal: TerminalRunner = (args) =>
|
|
|
15
16
|
|
|
16
17
|
type VaultState = 'authenticated' | 'unauthenticated' | 'unavailable';
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/** The field of the fleet's shared document that held a worker's token
|
|
20
|
+
* before each worker had a document of its own (src/worker-credentials.ts).
|
|
21
|
+
* Read only when the worker's own document holds none, and reported: every
|
|
22
|
+
* worker that can read the shared document can read that field.
|
|
23
|
+
*/
|
|
19
24
|
export function workerTokenField(node: string): string {
|
|
20
25
|
return `FLEET_WORKER_TOKEN_${node.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
21
26
|
}
|
|
@@ -49,7 +54,13 @@ export async function authorizeManager(
|
|
|
49
54
|
*/
|
|
50
55
|
export async function authorizeVault(
|
|
51
56
|
node: FleetNode,
|
|
52
|
-
options: {
|
|
57
|
+
options: {
|
|
58
|
+
interactive: boolean;
|
|
59
|
+
/** Opt-in: the fleet's shared Vault user, typed on the worker's own
|
|
60
|
+
* terminal, when no token of its own was issued.
|
|
61
|
+
*/
|
|
62
|
+
passwordLogin?: boolean;
|
|
63
|
+
},
|
|
53
64
|
run: Runner,
|
|
54
65
|
attach: TerminalRunner = terminal
|
|
55
66
|
): Promise<Check[]> {
|
|
@@ -59,8 +70,10 @@ export async function authorizeVault(
|
|
|
59
70
|
if (before === 'authenticated') return [pass(node)];
|
|
60
71
|
// Fresh node: the locked toolchain (including vault) is not installed yet.
|
|
61
72
|
if (before === 'unavailable') return [];
|
|
62
|
-
|
|
63
|
-
if (
|
|
73
|
+
const delivered = await deliverWorkerToken(node, run);
|
|
74
|
+
if (delivered === 'own') return [pass(node)];
|
|
75
|
+
if (delivered === 'shared') return [pass(node), shared(node)];
|
|
76
|
+
if (!options.interactive || !options.passwordLogin) return [required(node)];
|
|
64
77
|
process.stderr.write(
|
|
65
78
|
`[${node.id}] No issued worker token found. Enter the Vault password to log this machine in.\n`
|
|
66
79
|
);
|
|
@@ -72,30 +85,54 @@ export async function authorizeVault(
|
|
|
72
85
|
];
|
|
73
86
|
}
|
|
74
87
|
|
|
88
|
+
/** Delivers the worker's token, from its own document or else from the
|
|
89
|
+
* field it used to have in the shared one; says which, or that none was.
|
|
90
|
+
*/
|
|
75
91
|
async function deliverWorkerToken(
|
|
76
92
|
node: FleetNode,
|
|
77
93
|
run: Runner
|
|
78
|
-
): Promise<
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
'
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
): Promise<'own' | 'shared' | null> {
|
|
95
|
+
const vault = resolveVaultConfig();
|
|
96
|
+
const own = workerTokenLocation(node.id, vault);
|
|
97
|
+
const sources = [
|
|
98
|
+
{ from: 'own' as const, ...own },
|
|
99
|
+
{
|
|
100
|
+
from: 'shared' as const,
|
|
101
|
+
mount: vault.mount,
|
|
102
|
+
path: `${vault.project}/${vault.config}`,
|
|
103
|
+
field: workerTokenField(node.id),
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
for (const source of sources) {
|
|
107
|
+
const secret = await run(
|
|
108
|
+
[
|
|
109
|
+
'vault',
|
|
110
|
+
'kv',
|
|
111
|
+
'get',
|
|
112
|
+
`-address=${vault.addr}`,
|
|
113
|
+
`-mount=${source.mount}`,
|
|
114
|
+
`-field=${source.field}`,
|
|
115
|
+
source.path,
|
|
116
|
+
],
|
|
117
|
+
{ timeoutMs: 20_000 }
|
|
118
|
+
);
|
|
119
|
+
const token = secret.ok ? secret.stdout.trim() : '';
|
|
120
|
+
if (!/^[\x21-\x7e]{16,4096}$/.test(token)) continue;
|
|
121
|
+
const delivered = await run(
|
|
122
|
+
remote(node, [
|
|
123
|
+
'vault',
|
|
124
|
+
'login',
|
|
125
|
+
`-address=${vault.addr}`,
|
|
126
|
+
'-no-print',
|
|
127
|
+
'-',
|
|
128
|
+
]),
|
|
129
|
+
{ input: token + '\n', timeoutMs: 30_000 }
|
|
130
|
+
);
|
|
131
|
+
return delivered.ok && (await vaultState(node, run)) === 'authenticated'
|
|
132
|
+
? source.from
|
|
133
|
+
: null;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
99
136
|
}
|
|
100
137
|
|
|
101
138
|
async function vaultState(node: FleetNode, run: Runner): Promise<VaultState> {
|
|
@@ -153,6 +190,18 @@ function pass(node: FleetNode): Check {
|
|
|
153
190
|
};
|
|
154
191
|
}
|
|
155
192
|
|
|
193
|
+
function shared(node: FleetNode): Check {
|
|
194
|
+
const own = workerTokenLocation(node.id);
|
|
195
|
+
return {
|
|
196
|
+
id: 'auth:vault-isolation',
|
|
197
|
+
status: 'warn',
|
|
198
|
+
summary: 'VAULT_WORKER_TOKEN_SHARED',
|
|
199
|
+
remedy: {
|
|
200
|
+
action: `${node.id}'s token was read from the fleet's shared document, which every worker can read. Issue it a token of its own with devfleet fleet-node-replace --id ${node.id} --apply --yes (kept at ${own.mount}/${own.path}), then remove ${workerTokenField(node.id)} from the shared document.`,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
156
205
|
function required(node: FleetNode): Check {
|
|
157
206
|
const { addr } = resolveVaultConfig();
|
|
158
207
|
return {
|
|
@@ -160,7 +209,7 @@ function required(node: FleetNode): Check {
|
|
|
160
209
|
status: 'fail',
|
|
161
210
|
summary: 'VAULT_LOGIN_REQUIRED',
|
|
162
211
|
remedy: {
|
|
163
|
-
action: `
|
|
212
|
+
action: `Issue ${node.id} a token of its own with devfleet fleet-node-replace --id ${node.id} --apply --yes. Opt-in fallback: devfleet fleet-sync --vault-password-login in a terminal, or on ${node.id}: vault login -address=${addr} -method=userpass username=${definition.worker!.vaultUser}. Proxy providers in T3 stay unavailable until then.`,
|
|
164
213
|
},
|
|
165
214
|
};
|
|
166
215
|
}
|
package/src/cli/fleet-sync.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ManagerReport } from '../manager-sync';
|
|
1
2
|
import { localCheckoutRevision } from '../machine/local-source';
|
|
2
3
|
import {
|
|
3
4
|
definitionSource,
|
|
@@ -31,6 +32,8 @@ import {
|
|
|
31
32
|
DIVERGED_REMEDY,
|
|
32
33
|
mutateState,
|
|
33
34
|
refresh,
|
|
35
|
+
leaseConflict,
|
|
36
|
+
leaseHeldMessage,
|
|
34
37
|
stateContext,
|
|
35
38
|
type Standing,
|
|
36
39
|
} from '../state/sync';
|
|
@@ -61,6 +64,14 @@ export interface FleetSyncOptions {
|
|
|
61
64
|
json?: boolean;
|
|
62
65
|
nonInteractive?: boolean;
|
|
63
66
|
node?: string[];
|
|
67
|
+
/** The worker to prove a revision new to the fleet on first, when it is
|
|
68
|
+
* reachable; overrides `canary` in fleet.json.
|
|
69
|
+
*/
|
|
70
|
+
canary?: string;
|
|
71
|
+
/** Opt-in: a worker without a token of its own may be logged in with the
|
|
72
|
+
* fleet's shared Vault user, typed on its own terminal.
|
|
73
|
+
*/
|
|
74
|
+
vaultPasswordLogin?: boolean;
|
|
64
75
|
all?: boolean;
|
|
65
76
|
schedule?: ScheduleAction;
|
|
66
77
|
/** `accounts`: only the account-derived configuration, on every node. */
|
|
@@ -72,6 +83,8 @@ export interface FleetSyncOptions {
|
|
|
72
83
|
/** Internal: set for interactive terminal runs (sudo enrollment prompts). */
|
|
73
84
|
interactiveAuthorization?: boolean;
|
|
74
85
|
expectedPlanFingerprint?: string;
|
|
86
|
+
/** Take the manager lease from another manager before it expired. */
|
|
87
|
+
takeOver?: boolean;
|
|
75
88
|
}
|
|
76
89
|
export function machineRoot(): string {
|
|
77
90
|
const root = join(homedir(), '.local/share/personal/fleet/manager');
|
|
@@ -140,6 +153,31 @@ export interface ManagerSync {
|
|
|
140
153
|
restart: () => Promise<CommandOutcome>;
|
|
141
154
|
}
|
|
142
155
|
|
|
156
|
+
/** Why the fleet state stops this run before any machine or the state is
|
|
157
|
+
* changed, if it does.
|
|
158
|
+
*/
|
|
159
|
+
function stateRefusal(
|
|
160
|
+
standing: Standing,
|
|
161
|
+
options: FleetSyncOptions,
|
|
162
|
+
run: Runner,
|
|
163
|
+
revision: string
|
|
164
|
+
): CommandOutcome | null {
|
|
165
|
+
if (standing === 'diverged')
|
|
166
|
+
return actionRequired('FLEET_STATE_DIVERGED', DIVERGED_REMEDY, revision);
|
|
167
|
+
// One manager at a time: another one's lease stops this run.
|
|
168
|
+
const held = leaseConflict({
|
|
169
|
+
...stateContext(run),
|
|
170
|
+
takeOver: options.takeOver === true,
|
|
171
|
+
});
|
|
172
|
+
return held
|
|
173
|
+
? actionRequired(
|
|
174
|
+
'FLEET_MANAGER_LEASE_HELD',
|
|
175
|
+
leaseHeldMessage(held),
|
|
176
|
+
revision
|
|
177
|
+
)
|
|
178
|
+
: null;
|
|
179
|
+
}
|
|
180
|
+
|
|
143
181
|
/** fleet-sync on the machine already known to be the manager. */
|
|
144
182
|
export async function managerSync(
|
|
145
183
|
options: FleetSyncOptions,
|
|
@@ -177,8 +215,8 @@ export async function managerSync(
|
|
|
177
215
|
// version of the configuration, the roster and the approval, not from a
|
|
178
216
|
// stale local copy. Refreshing never uploads.
|
|
179
217
|
const fresh = await refresh(stateContext(run));
|
|
180
|
-
|
|
181
|
-
|
|
218
|
+
const refused = stateRefusal(fresh.standing, options, run, revision);
|
|
219
|
+
if (refused) return refused;
|
|
182
220
|
if (fresh.replaced) {
|
|
183
221
|
// Everything read so far came from the stale copy, the manager's own
|
|
184
222
|
// identity included.
|
|
@@ -274,7 +312,7 @@ function applyLocked(
|
|
|
274
312
|
): Promise<CommandOutcome> {
|
|
275
313
|
const { settings, run, revision } = context;
|
|
276
314
|
const record = runRecord(state, revision, {
|
|
277
|
-
context: stateContext(run),
|
|
315
|
+
context: { ...stateContext(run), takeOver: request.takeOver === true },
|
|
278
316
|
standing: context.standing,
|
|
279
317
|
...(definitionSource().kind === 'file'
|
|
280
318
|
? { definition: JSON.stringify(canonicalDefinition, null, 2) + '\n' }
|
|
@@ -287,7 +325,7 @@ function applyLocked(
|
|
|
287
325
|
{ ...request, rolledOut: state?.appliedRevision === revision },
|
|
288
326
|
settings,
|
|
289
327
|
run,
|
|
290
|
-
(report) => record.settle(report.ok)
|
|
328
|
+
(report) => record.settle(report.ok, appliedRevisions(report))
|
|
291
329
|
);
|
|
292
330
|
} finally {
|
|
293
331
|
await record.abandon();
|
|
@@ -327,7 +365,7 @@ async function offManager(
|
|
|
327
365
|
if (forwardingDisabled())
|
|
328
366
|
return actionRequired(
|
|
329
367
|
'FLEET_NOT_MANAGER',
|
|
330
|
-
`fleet-sync runs only on the manager (${definition.manager.id}); nothing was changed. Run it there, or from a worker that can SSH to the manager.`,
|
|
368
|
+
`fleet-sync runs only on the manager (${definition.manager.id}), which is always a macOS machine; nothing was changed. Run it there, or from a worker that can SSH to the manager.`,
|
|
331
369
|
revision ?? null
|
|
332
370
|
);
|
|
333
371
|
const runScript = revision
|
|
@@ -491,3 +529,14 @@ async function selectedRevision(
|
|
|
491
529
|
approved
|
|
492
530
|
);
|
|
493
531
|
}
|
|
532
|
+
|
|
533
|
+
/** The revision each worker holds after this run, for the fleet state: a
|
|
534
|
+
* worker that converged keeps its progress even when the fleet is not ready.
|
|
535
|
+
*/
|
|
536
|
+
function appliedRevisions(report: ManagerReport): Record<string, string> {
|
|
537
|
+
return Object.fromEntries(
|
|
538
|
+
report.nodes
|
|
539
|
+
.filter((node) => node.role !== 'manager' && node.appliedRevision)
|
|
540
|
+
.map((node) => [node.node, node.appliedRevision!])
|
|
541
|
+
);
|
|
542
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
DefinitionError,
|
|
6
|
+
parseDefinition,
|
|
7
|
+
type DefinitionProblem,
|
|
8
|
+
type FleetDefinition,
|
|
9
|
+
} from '../definition';
|
|
10
|
+
import { FleetUsageError } from '../errors';
|
|
11
|
+
import { PACKAGE_ROOT } from '../package-info';
|
|
12
|
+
import { ansibleCommand } from '../platform/ansible';
|
|
13
|
+
import { runCommand } from '../runner';
|
|
14
|
+
import type { Check, Runner } from '../types';
|
|
15
|
+
import type { CommandOutcome } from './contracts';
|
|
16
|
+
|
|
17
|
+
export interface FleetValidateOptions {
|
|
18
|
+
file?: string;
|
|
19
|
+
ansible?: boolean;
|
|
20
|
+
json?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FleetValidateServices {
|
|
24
|
+
run: Runner;
|
|
25
|
+
env: NodeJS.ProcessEnv;
|
|
26
|
+
platform: NodeJS.Platform;
|
|
27
|
+
/** The package's Ansible playbooks, relative to `root`. */
|
|
28
|
+
root: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SERVICES: FleetValidateServices = {
|
|
32
|
+
run: runCommand,
|
|
33
|
+
env: process.env,
|
|
34
|
+
platform: process.platform,
|
|
35
|
+
root: PACKAGE_ROOT,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The playbooks a definition must be able to drive, in the package. */
|
|
39
|
+
const PLAYBOOK_DIRECTORIES = ['ansible/ubuntu', 'ansible/macos'];
|
|
40
|
+
|
|
41
|
+
/** The Ansible inventory a definition describes: the manager, and every
|
|
42
|
+
* worker in the `workers` group in the order it is declared. Host names are
|
|
43
|
+
* the fleet ids; a machine is reached as fleet-sync reaches it.
|
|
44
|
+
*/
|
|
45
|
+
export function fleetInventory(definition: FleetDefinition): unknown {
|
|
46
|
+
const workers = Object.fromEntries(
|
|
47
|
+
definition.workers.map((worker) => [
|
|
48
|
+
worker.id,
|
|
49
|
+
{
|
|
50
|
+
ansible_host: worker.ssh,
|
|
51
|
+
...(worker.network
|
|
52
|
+
? {
|
|
53
|
+
ansible_user: worker.network.user,
|
|
54
|
+
ansible_port: worker.network.sshPort,
|
|
55
|
+
}
|
|
56
|
+
: {}),
|
|
57
|
+
},
|
|
58
|
+
])
|
|
59
|
+
);
|
|
60
|
+
return {
|
|
61
|
+
all: {
|
|
62
|
+
children: {
|
|
63
|
+
manager: {
|
|
64
|
+
hosts: {
|
|
65
|
+
[definition.manager.id]: {
|
|
66
|
+
ansible_connection: 'local',
|
|
67
|
+
ansible_user: definition.manager.network.user,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
workers: { hosts: workers },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Reads and judges one fleet.json. Nothing is contacted: with `--ansible`
|
|
78
|
+
* the pinned Ansible renders the inventory and checks the playbooks' syntax
|
|
79
|
+
* on this machine.
|
|
80
|
+
*/
|
|
81
|
+
export async function fleetValidate(
|
|
82
|
+
options: FleetValidateOptions,
|
|
83
|
+
services: FleetValidateServices = SERVICES
|
|
84
|
+
): Promise<CommandOutcome> {
|
|
85
|
+
const path = options.file ?? services.env['DEVFLEET_CONFIG'];
|
|
86
|
+
if (!path)
|
|
87
|
+
throw new FleetUsageError(
|
|
88
|
+
'No fleet configuration to validate. Run devfleet from the project that holds your fleet.json, or pass --file PATH.'
|
|
89
|
+
);
|
|
90
|
+
let definition: FleetDefinition;
|
|
91
|
+
try {
|
|
92
|
+
definition = parseDefinition(await readJson(path));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error instanceof DefinitionError) return invalid(path, error.problem);
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
const checks: Check[] = [
|
|
98
|
+
{
|
|
99
|
+
id: 'definition:schema',
|
|
100
|
+
status: 'pass',
|
|
101
|
+
summary: `${path} is a valid fleet definition: a manager and ${definition.workers.length} worker(s).`,
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
if (options.ansible)
|
|
105
|
+
checks.push(...(await ansibleChecks(definition, services)));
|
|
106
|
+
const ok = checks.every((check) => check.status !== 'fail');
|
|
107
|
+
return {
|
|
108
|
+
exitCode: ok ? 0 : 1,
|
|
109
|
+
data: { version: 1, command: 'fleet-validate', ok, path, checks },
|
|
110
|
+
text: checks
|
|
111
|
+
.map(
|
|
112
|
+
(check) => `${check.status.toUpperCase()} ${check.id}: ${check.summary}`
|
|
113
|
+
)
|
|
114
|
+
.join('\n'),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readJson(path: string): Promise<unknown> {
|
|
119
|
+
let text: string;
|
|
120
|
+
try {
|
|
121
|
+
text = await readFile(path, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
throw new DefinitionError({
|
|
124
|
+
code: 'DEFINITION_UNREADABLE',
|
|
125
|
+
path: '$',
|
|
126
|
+
expected: 'a readable file',
|
|
127
|
+
actual: 'no file',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(text) as unknown;
|
|
132
|
+
} catch {
|
|
133
|
+
throw new DefinitionError({
|
|
134
|
+
code: 'DEFINITION_UNREADABLE',
|
|
135
|
+
path: '$',
|
|
136
|
+
expected: 'JSON',
|
|
137
|
+
actual: 'text that is not JSON',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function invalid(path: string, problem: DefinitionProblem): CommandOutcome {
|
|
143
|
+
const data = {
|
|
144
|
+
version: 1,
|
|
145
|
+
command: 'fleet-validate',
|
|
146
|
+
ok: false,
|
|
147
|
+
path,
|
|
148
|
+
problem,
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
exitCode: 1,
|
|
152
|
+
data,
|
|
153
|
+
text: `${problem.code} at ${problem.path}: expected ${problem.expected}, found ${problem.actual}.`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Renders the inventory, lists it with `ansible-inventory` and checks the
|
|
158
|
+
* syntax of every playbook against it.
|
|
159
|
+
*/
|
|
160
|
+
async function ansibleChecks(
|
|
161
|
+
definition: FleetDefinition,
|
|
162
|
+
services: FleetValidateServices
|
|
163
|
+
): Promise<Check[]> {
|
|
164
|
+
const directory = await mkdtemp(join(tmpdir(), 'devfleet-validate-'));
|
|
165
|
+
const inventory = join(directory, 'inventory.json');
|
|
166
|
+
try {
|
|
167
|
+
await writeFile(inventory, JSON.stringify(fleetInventory(definition)), {
|
|
168
|
+
mode: 0o600,
|
|
169
|
+
});
|
|
170
|
+
const playbook = ansibleCommand(services.platform, services.root);
|
|
171
|
+
const tool = (name: string) => [...playbook.slice(0, -1), name];
|
|
172
|
+
const listed = await services.run(
|
|
173
|
+
[...tool('ansible-inventory'), '-i', inventory, '--list'],
|
|
174
|
+
{ timeoutMs: 120_000 }
|
|
175
|
+
);
|
|
176
|
+
const checks: Check[] = [
|
|
177
|
+
listed.ok
|
|
178
|
+
? {
|
|
179
|
+
id: 'definition:inventory',
|
|
180
|
+
status: 'pass',
|
|
181
|
+
summary: 'Ansible reads the inventory the definition describes.',
|
|
182
|
+
}
|
|
183
|
+
: {
|
|
184
|
+
id: 'definition:inventory',
|
|
185
|
+
status: 'fail',
|
|
186
|
+
summary: 'DEFINITION_INVENTORY_REJECTED',
|
|
187
|
+
remedy: {
|
|
188
|
+
action:
|
|
189
|
+
'Run devfleet fleet-validate --ansible on the manager after fleet-sync installed the pinned Ansible; report the failure as an issue if it persists.',
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
const failed: string[] = [];
|
|
194
|
+
for (const file of await playbooks(services.root)) {
|
|
195
|
+
const result = await services.run(
|
|
196
|
+
[
|
|
197
|
+
...playbook,
|
|
198
|
+
'-i',
|
|
199
|
+
inventory,
|
|
200
|
+
'--syntax-check',
|
|
201
|
+
join(services.root, file),
|
|
202
|
+
],
|
|
203
|
+
{ timeoutMs: 120_000 }
|
|
204
|
+
);
|
|
205
|
+
if (!result.ok) failed.push(file);
|
|
206
|
+
}
|
|
207
|
+
checks.push(
|
|
208
|
+
failed.length
|
|
209
|
+
? {
|
|
210
|
+
id: 'definition:playbooks',
|
|
211
|
+
status: 'fail',
|
|
212
|
+
summary: `DEFINITION_PLAYBOOK_SYNTAX: ${failed.join(', ')}`,
|
|
213
|
+
remedy: {
|
|
214
|
+
action:
|
|
215
|
+
'The installed dev-fleet package carries a playbook Ansible cannot parse; report it as an issue.',
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
: {
|
|
219
|
+
id: 'definition:playbooks',
|
|
220
|
+
status: 'pass',
|
|
221
|
+
summary: 'Every playbook passes ansible-playbook --syntax-check.',
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
return checks;
|
|
225
|
+
} finally {
|
|
226
|
+
await rm(directory, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Every top-level playbook the fleet applies, sorted. */
|
|
231
|
+
async function playbooks(root: string): Promise<string[]> {
|
|
232
|
+
const found: string[] = [];
|
|
233
|
+
for (const directory of PLAYBOOK_DIRECTORIES) {
|
|
234
|
+
const entries = await readdir(join(root, directory)).catch(() => []);
|
|
235
|
+
for (const entry of entries)
|
|
236
|
+
if (entry.endsWith('.yml')) found.push(join(directory, entry));
|
|
237
|
+
}
|
|
238
|
+
return found.sort();
|
|
239
|
+
}
|