@crvouga/dev-fleet 0.1.0 → 0.2.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 +237 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +7 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/monitoring/README.md +86 -25
- package/ansible/monitoring/agent.yml +40 -2
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +5 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/baseline.yml +1 -1
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/policy.json +12 -3
- package/ansible/ubuntu/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/uv.lock +190 -0
- package/bootstrap/bootstrap.sh +25 -18
- package/bootstrap/doctor.ts +46 -1
- package/bootstrap/fleet-manager-run.sh +42 -15
- package/bootstrap/fleet-remote-bootstrap.sh +85 -12
- package/bootstrap/global-config.ts +64 -6
- package/bootstrap/install-launcher.sh +3 -0
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +89 -0
- package/docs/machine-setup.md +399 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +67 -0
- package/docs/ubuntu-sync.md +351 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +35 -0
- package/mise.lock +29 -0
- package/mise.toml +12 -1
- package/package.json +11 -2
- package/schema/fleet.schema.json +71 -15
- package/src/agents/fleet-verify.ts +9 -0
- package/src/agents/native-verify.ts +167 -22
- package/src/agents/provider-errors.ts +9 -2
- package/src/agents/registry.ts +7 -1
- package/src/cli/agent-guide.ts +39 -5
- package/src/cli/app.ts +11 -4
- package/src/cli/cli.ts +7 -0
- package/src/cli/command-index.ts +7 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-checkout.ts +56 -17
- package/src/cli/fleet-sync-enrollment.ts +19 -23
- package/src/cli/fleet-sync-forward.ts +1 -1
- package/src/cli/fleet-sync-manager.ts +64 -8
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +4 -1
- package/src/cli/fleet-sync-remote.ts +11 -5
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +123 -57
- package/src/cli/fleet.ts +4 -2
- package/src/cli/managed-health.ts +4 -4
- package/src/cli/node-sync.ts +7 -0
- package/src/cli/program.ts +20 -4
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +51 -1
- package/src/cli/registry.ts +28 -1
- package/src/cli/roster.ts +46 -30
- package/src/cli/settings.ts +15 -12
- package/src/cli/source-operations.ts +27 -3
- package/src/cli/tailnet.ts +2 -2
- package/src/definition.ts +31 -1
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- package/src/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +8 -3
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +15 -3
- package/src/monitoring/beszel.ts +11 -4
- package/src/monitoring/enroll.ts +2 -1
- package/src/monitoring/hub-access.ts +4 -3
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +8 -8
- package/src/monitoring/hub.ts +3 -3
- package/src/monitoring/manager-hub.ts +30 -14
- package/src/node-converge-linux.ts +2 -0
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/resource-verify.ts +8 -4
- package/src/platform/ubuntu-audit.ts +5 -2
- package/src/remote-bootstrap.ts +17 -19
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- package/src/t3-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +23 -1
- package/src/toolchain-checks.ts +25 -0
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +5 -0
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
|
@@ -6,7 +6,7 @@ import { enrolledRoster } from '../definition';
|
|
|
6
6
|
import type { Tailnet } from '../tailscale';
|
|
7
7
|
import type { FleetNode } from '../types';
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
publicHubUrl,
|
|
10
10
|
verifyBeszel,
|
|
11
11
|
type BeszelRequest,
|
|
12
12
|
} from '../monitoring/beszel';
|
|
@@ -122,7 +122,7 @@ async function hubReachable(
|
|
|
122
122
|
*/
|
|
123
123
|
async function publicEndpoint(fetcher: typeof fetch): Promise<Check> {
|
|
124
124
|
try {
|
|
125
|
-
const response = await fetcher(`${
|
|
125
|
+
const response = await fetcher(`${publicHubUrl()}/api/health`, {
|
|
126
126
|
redirect: 'error',
|
|
127
127
|
signal: AbortSignal.timeout(10_000),
|
|
128
128
|
});
|
|
@@ -130,7 +130,7 @@ async function publicEndpoint(fetcher: typeof fetch): Promise<Check> {
|
|
|
130
130
|
return {
|
|
131
131
|
id: 'beszel:public-endpoint',
|
|
132
132
|
status: 'pass',
|
|
133
|
-
summary: `Dashboard answers publicly at ${
|
|
133
|
+
summary: `Dashboard answers publicly at ${publicHubUrl()}.`,
|
|
134
134
|
};
|
|
135
135
|
} catch {
|
|
136
136
|
/* Connection details are not evidence; fall through. */
|
|
@@ -140,7 +140,7 @@ async function publicEndpoint(fetcher: typeof fetch): Promise<Check> {
|
|
|
140
140
|
status: 'warn',
|
|
141
141
|
summary: 'BESZEL_PUBLIC_ENDPOINT_OFFLINE',
|
|
142
142
|
remedy: {
|
|
143
|
-
action: `${
|
|
143
|
+
action: `${publicHubUrl()} did not answer. Create the Cloudflare tunnel and store its token in the fleet's secrets, then run fleet-sync on the manager so the hub starts its Cloudflare connector. The private dashboard is unaffected.`,
|
|
144
144
|
},
|
|
145
145
|
};
|
|
146
146
|
}
|
package/src/cli/node-sync.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { configureWorkspace } from '../configure';
|
|
3
3
|
import { runCommand } from '../runner';
|
|
4
4
|
import { FleetUsageError } from '../errors';
|
|
5
|
+
import { packageInfo } from '../package-info';
|
|
5
6
|
import type { Runner } from '../types';
|
|
6
7
|
import type { Settings } from './settings';
|
|
7
8
|
import type { CommandOutcome } from './contracts';
|
|
@@ -24,6 +25,12 @@ export async function syncNode(
|
|
|
24
25
|
'Codex proxy node sync requires macOS/Linux/WSL. Use dev-fleet commands for native Windows configuration.'
|
|
25
26
|
);
|
|
26
27
|
const node = nodeId(options.id);
|
|
28
|
+
// An installed package is not a checkout, and the Git repository around it
|
|
29
|
+
// is the operator's own project: its HEAD is not a dev-fleet revision.
|
|
30
|
+
if (packageInfo().revision)
|
|
31
|
+
throw new FleetUsageError(
|
|
32
|
+
'fleet-node-sync works on a checkout of dev-fleet. Run fleet-sync instead: it converges this machine from a checkout it owns.'
|
|
33
|
+
);
|
|
27
34
|
const head = await run(['git', 'rev-parse', '--verify', 'HEAD'], {
|
|
28
35
|
cwd: settings.workspace,
|
|
29
36
|
});
|
package/src/cli/program.ts
CHANGED
|
@@ -13,6 +13,14 @@ export interface Release {
|
|
|
13
13
|
version: string;
|
|
14
14
|
/** ISO timestamp recorded when this distribution was built; null for source runs. */
|
|
15
15
|
releasedAt: string | null;
|
|
16
|
+
/** The commit a published package was built from: what fleet-sync run from
|
|
17
|
+
* it converges every machine to. Absent for source runs.
|
|
18
|
+
*/
|
|
19
|
+
revision?: string | null;
|
|
20
|
+
/** The issue tracker of the package: where its users report a defect or
|
|
21
|
+
* ask for a feature.
|
|
22
|
+
*/
|
|
23
|
+
issues?: string | null;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
export const GLOBAL_OPTIONS = [
|
|
@@ -23,7 +31,8 @@ export const GLOBAL_OPTIONS = [
|
|
|
23
31
|
{ flag: '--non-interactive', description: 'Never open interactive prompts' },
|
|
24
32
|
{
|
|
25
33
|
flag: '--workspace <path>',
|
|
26
|
-
description:
|
|
34
|
+
description:
|
|
35
|
+
'A dev-fleet checkout to run from instead of this package (development); does not clone or change it',
|
|
27
36
|
},
|
|
28
37
|
{ flag: '--config <path>', description: 'Local, secret-free fleet roster' },
|
|
29
38
|
];
|
|
@@ -34,8 +43,10 @@ export const SOURCE_VERSION = '0.0.0-development';
|
|
|
34
43
|
export function releaseLabel(release: Release): string {
|
|
35
44
|
if (release.releasedAt)
|
|
36
45
|
return `${release.version} (released ${release.releasedAt})`;
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
if (release.version === SOURCE_VERSION)
|
|
47
|
+
return `${release.version} (unreleased source build)`;
|
|
48
|
+
return release.revision
|
|
49
|
+
? `${release.version} (commit ${release.revision})`
|
|
39
50
|
: release.version;
|
|
40
51
|
}
|
|
41
52
|
|
|
@@ -63,9 +74,14 @@ function createProgram(
|
|
|
63
74
|
'',
|
|
64
75
|
`Release: ${releaseLabel(release)}`,
|
|
65
76
|
'Groups: fleet-* developer computers · t3-* T3 provider instances · cliproxy the CLI proxy service.',
|
|
66
|
-
'Apply on invocation (--dry-run previews): fleet-node-sync, t3-apply, t3-cleanup. fleet-sync
|
|
77
|
+
'Apply on invocation (--dry-run previews): fleet-node-sync, t3-apply, t3-cleanup. fleet-sync applies on invocation too; --apply --yes is still accepted.',
|
|
67
78
|
'Preview until --apply --yes: fleet-repair, fleet-node-add. --json does not make a command read-only.',
|
|
68
79
|
'Agents: devfleet commands --json. Menu: devfleet --interactive.',
|
|
80
|
+
...(release.issues
|
|
81
|
+
? [
|
|
82
|
+
`Bugs and feature requests: ${release.issues}. Report them there; never patch the installed package.`,
|
|
83
|
+
]
|
|
84
|
+
: []),
|
|
69
85
|
].join('\n')
|
|
70
86
|
);
|
|
71
87
|
return program;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { record } from '../config';
|
|
4
|
+
|
|
5
|
+
/** The file a project keeps its fleet configuration in. */
|
|
6
|
+
export const PROJECT_CONFIG = 'fleet.json';
|
|
7
|
+
|
|
8
|
+
/** Another tool's fleet.json is not this one: a fleet configuration is a JSON
|
|
9
|
+
* object that names a manager. Whether it is a valid one is said later, with
|
|
10
|
+
* the file's path, by whatever reads it.
|
|
11
|
+
*/
|
|
12
|
+
function fleetConfiguration(path: string): boolean {
|
|
13
|
+
try {
|
|
14
|
+
const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
15
|
+
return record(value) && record(value['manager']);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The fleet.json of the project a command is run from: the closest one in
|
|
22
|
+
* the working directory or above it, or null.
|
|
23
|
+
*/
|
|
24
|
+
export function projectConfig(cwd: string): string | null {
|
|
25
|
+
let current = resolve(cwd);
|
|
26
|
+
while (true) {
|
|
27
|
+
const candidate = join(current, PROJECT_CONFIG);
|
|
28
|
+
if (fleetConfiguration(candidate)) return candidate;
|
|
29
|
+
const parent = dirname(current);
|
|
30
|
+
if (parent === current) return null;
|
|
31
|
+
current = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A fleet is a project of your own: a fleet.json in Git, and the devfleet
|
|
36
|
+
* CLI run from inside it. This names that file for the whole command, and
|
|
37
|
+
* for everything the command starts in another directory (fleet-sync re-runs
|
|
38
|
+
* itself from a checkout of the revision). `DEVFLEET_CONFIG` names a file
|
|
39
|
+
* elsewhere and wins. Only the CLI does this: launchers and scheduled jobs
|
|
40
|
+
* run in directories that belong to other projects and read the copy
|
|
41
|
+
* fleet-sync installed.
|
|
42
|
+
*/
|
|
43
|
+
export function useProjectConfig(
|
|
44
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
45
|
+
cwd = process.cwd()
|
|
46
|
+
): string | null {
|
|
47
|
+
const named = env['DEVFLEET_CONFIG'];
|
|
48
|
+
const path = named ? resolve(cwd, named) : projectConfig(cwd);
|
|
49
|
+
if (path) env['DEVFLEET_CONFIG'] = path;
|
|
50
|
+
return path;
|
|
51
|
+
}
|
|
@@ -6,6 +6,7 @@ import { resolveSettings } from './settings';
|
|
|
6
6
|
import { executeFleet } from './fleet';
|
|
7
7
|
import { fleetSync, type FleetSyncOptions } from './fleet-sync';
|
|
8
8
|
import { syncNode, type NodeSyncOptions } from './node-sync';
|
|
9
|
+
import { fleetState, type FleetStateOptions } from './fleet-state';
|
|
9
10
|
|
|
10
11
|
type Complete = (outcome: CommandOutcome) => void;
|
|
11
12
|
|
|
@@ -172,7 +173,7 @@ function registerSync({ program, defaults, complete }: Registration) {
|
|
|
172
173
|
)
|
|
173
174
|
.option(
|
|
174
175
|
'--revision <sha>',
|
|
175
|
-
'
|
|
176
|
+
'Converge to an exact source commit; defaults to the commit the dev-fleet package you run was published from'
|
|
176
177
|
)
|
|
177
178
|
.option(
|
|
178
179
|
'--node <id>',
|
|
@@ -228,6 +229,54 @@ function registerSync({ program, defaults, complete }: Registration) {
|
|
|
228
229
|
});
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
function registerState({ program, complete }: Registration) {
|
|
233
|
+
program
|
|
234
|
+
.command('fleet-state')
|
|
235
|
+
.description(
|
|
236
|
+
'Show where the fleet state is kept and whether the bucket holds it; flags connect, upload and restore'
|
|
237
|
+
)
|
|
238
|
+
.option(
|
|
239
|
+
'--init',
|
|
240
|
+
'Connect the bucket: restore the fleet state from it, or seed it from this machine, and save its credentials for runs this machine starts by itself'
|
|
241
|
+
)
|
|
242
|
+
.option(
|
|
243
|
+
'--bucket <name>',
|
|
244
|
+
'With --init, when no fleet.json names the bucket: S3-compatible bucket name'
|
|
245
|
+
)
|
|
246
|
+
.option(
|
|
247
|
+
'--endpoint <url>',
|
|
248
|
+
'With --init: https origin of the S3 service; omit for AWS S3'
|
|
249
|
+
)
|
|
250
|
+
.option('--region <name>', 'With --init: bucket region, such as auto')
|
|
251
|
+
.option('--prefix <prefix>', 'With --init: key prefix; default dev-fleet')
|
|
252
|
+
.option(
|
|
253
|
+
'--directory <path>',
|
|
254
|
+
'With --init: replicate to a directory instead of a bucket'
|
|
255
|
+
)
|
|
256
|
+
.option(
|
|
257
|
+
'--import <path>',
|
|
258
|
+
'Store a fleet configuration without running fleet-sync'
|
|
259
|
+
)
|
|
260
|
+
.option(
|
|
261
|
+
'--export',
|
|
262
|
+
'Print the stored fleet configuration: the one fleet-sync last applied'
|
|
263
|
+
)
|
|
264
|
+
.option('--example', 'Print an example fleet.json to start a project from')
|
|
265
|
+
.option('--push', 'Upload this machine’s changes to the bucket')
|
|
266
|
+
.option(
|
|
267
|
+
'--restore',
|
|
268
|
+
'Replace this machine’s copy with the bucket’s version'
|
|
269
|
+
)
|
|
270
|
+
.option('--yes', 'With --restore: discard changes the bucket does not hold')
|
|
271
|
+
.addHelpText(
|
|
272
|
+
'after',
|
|
273
|
+
'\nRead-only without a flag. The bucket is the one named under "state" in fleet.json. Every command reads its credentials from DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY; --init also asks on the terminal. They are never taken from arguments.'
|
|
274
|
+
)
|
|
275
|
+
.action(async (_options, command: Command) => {
|
|
276
|
+
complete(await fleetState(command.optsWithGlobals<FleetStateOptions>()));
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
231
280
|
export function registerFleet(
|
|
232
281
|
program: Command,
|
|
233
282
|
defaults: Settings,
|
|
@@ -241,6 +290,7 @@ export function registerFleet(
|
|
|
241
290
|
'List computers visible through Tailscale, including offline devices'
|
|
242
291
|
);
|
|
243
292
|
registerSync(registration);
|
|
293
|
+
registerState(registration);
|
|
244
294
|
registerRepair(registration);
|
|
245
295
|
registerNodes(registration);
|
|
246
296
|
}
|
package/src/cli/registry.ts
CHANGED
|
@@ -59,10 +59,37 @@ export const COMMANDS: CommandSpec[] = [
|
|
|
59
59
|
[
|
|
60
60
|
'fleet-sync',
|
|
61
61
|
'Synchronize the enrolled Ubuntu fleet',
|
|
62
|
-
'Manager push to all enrolled Ubuntu nodes at one approved revision over verified SSH
|
|
62
|
+
'Manager push to all enrolled Ubuntu nodes at one approved revision over verified SSH: the commit the dev-fleet package was published from, with the fleet.json of the project it is run from. Interactive confirmation or --apply --yes. Readiness is verified separately.',
|
|
63
63
|
['ubuntu', 'enrollment', 'converge', 'sync'],
|
|
64
64
|
true,
|
|
65
65
|
],
|
|
66
|
+
[
|
|
67
|
+
'fleet-state',
|
|
68
|
+
'Check the fleet state and its bucket',
|
|
69
|
+
'Read-only: where the fleet configuration, the roster and the approved revision are kept on this manager, and whether the bucket holds the same version. --example prints a fleet.json to start a project from; --export prints the configuration fleet-sync last applied; --import PATH stores one.',
|
|
70
|
+
['state', 'bucket', 's3', 'backup', 'litestream', 'configuration'],
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
'fleet-state --init',
|
|
74
|
+
'Connect the bucket and restore or seed the fleet state',
|
|
75
|
+
'Restore the fleet state from the bucket fleet.json names under "state" (or the one given with --bucket/--endpoint or --directory), or seed an empty bucket from this machine, and save the bucket and its credentials on this machine for runs it starts by itself. Credentials come from the environment or a terminal prompt.',
|
|
76
|
+
['state', 'bucket', 's3', 'setup', 'restore', 'new manager'],
|
|
77
|
+
true,
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
'fleet-state --push',
|
|
81
|
+
'Upload fleet state changes to the bucket',
|
|
82
|
+
'Upload changes this manager made while the bucket was unreachable. Refuses when the bucket has changed since.',
|
|
83
|
+
['state', 'bucket', 'upload', 'replicate'],
|
|
84
|
+
true,
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
'fleet-state --restore',
|
|
88
|
+
'Replace the local fleet state with the bucket’s',
|
|
89
|
+
'Discard this manager’s copy of the fleet state and download the bucket’s version. Needs --yes when this machine has changes the bucket does not hold.',
|
|
90
|
+
['state', 'bucket', 'restore', 'recover', 'disposable'],
|
|
91
|
+
true,
|
|
92
|
+
],
|
|
66
93
|
[
|
|
67
94
|
'fleet-node-sync',
|
|
68
95
|
'Sync this node',
|
package/src/cli/roster.ts
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdir, open, unlink } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import { parseConfig, record } from '../config';
|
|
3
|
+
import { parseConfig } from '../config';
|
|
5
4
|
import { FleetUsageError } from '../errors';
|
|
6
5
|
import type { FleetConfig, FleetNode } from '../types';
|
|
7
6
|
import { capabilities } from './options';
|
|
8
7
|
import type { CliOptions } from './options';
|
|
8
|
+
import { STATE_ROSTER, readRosterText, writeRosterText } from '../state/roster';
|
|
9
|
+
import {
|
|
10
|
+
mutateState,
|
|
11
|
+
stateContext,
|
|
12
|
+
type Standing,
|
|
13
|
+
type StateContext,
|
|
14
|
+
} from '../state/sync';
|
|
9
15
|
|
|
10
16
|
export async function readRoster(path: string): Promise<FleetConfig | null> {
|
|
11
17
|
try {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
throw new FleetUsageError(
|
|
15
|
-
'Roster must be a regular file smaller than 1 MiB; symlinks are not supported.'
|
|
16
|
-
);
|
|
17
|
-
return parseConfig(JSON.parse(await readFile(path, 'utf8')) as unknown);
|
|
18
|
+
const text = await readRosterText(path);
|
|
19
|
+
return text === null ? null : parseConfig(JSON.parse(text) as unknown);
|
|
18
20
|
} catch (error) {
|
|
19
|
-
if (record(error) && error['code'] === 'ENOENT') return null;
|
|
20
21
|
if (error instanceof FleetUsageError) throw error;
|
|
21
22
|
throw new FleetUsageError(
|
|
22
23
|
'Cannot read a valid roster. Use fleet-nodes --schema or choose --config PATH.'
|
|
@@ -24,6 +25,21 @@ export async function readRoster(path: string): Promise<FleetConfig | null> {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** The roster a fleet-sync needs: a missing one is an error, not an empty fleet. */
|
|
29
|
+
export async function loadRoster(path: string): Promise<FleetConfig> {
|
|
30
|
+
let value: unknown;
|
|
31
|
+
try {
|
|
32
|
+
const text = await readRosterText(path);
|
|
33
|
+
if (text === null) throw new Error();
|
|
34
|
+
value = JSON.parse(text);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new FleetUsageError(
|
|
37
|
+
'Cannot read fleet config. Run devfleet fleet-node-add or use --config PATH.'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return parseConfig(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
export function nodeFromOptions(options: CliOptions): FleetNode {
|
|
28
44
|
if (!options.id || !options.ssh || !options.path)
|
|
29
45
|
throw new FleetUsageError(
|
|
@@ -46,18 +62,32 @@ function appendNode(
|
|
|
46
62
|
return parseConfig({ version: 1, nodes: [...(existing?.nodes ?? []), node] });
|
|
47
63
|
}
|
|
48
64
|
|
|
65
|
+
export interface AddedNode {
|
|
66
|
+
roster: FleetConfig;
|
|
67
|
+
/** How the fleet state stands with the bucket; absent for a roster file. */
|
|
68
|
+
standing?: Standing;
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
export async function addNode(
|
|
50
72
|
path: string,
|
|
51
73
|
node: FleetNode,
|
|
52
|
-
apply: boolean
|
|
53
|
-
|
|
54
|
-
|
|
74
|
+
apply: boolean,
|
|
75
|
+
context: () => StateContext = stateContext
|
|
76
|
+
): Promise<AddedNode> {
|
|
77
|
+
const save = async (): Promise<FleetConfig> => {
|
|
78
|
+
const roster = appendNode(await readRoster(path), node);
|
|
79
|
+
await writeRosterText(path, `${JSON.stringify(roster, null, 2)}\n`);
|
|
80
|
+
return roster;
|
|
81
|
+
};
|
|
82
|
+
if (!apply) return { roster: appendNode(await readRoster(path), node) };
|
|
83
|
+
if (path === STATE_ROSTER) {
|
|
84
|
+
const { value, standing } = await mutateState(context(), save);
|
|
85
|
+
return { roster: value, standing };
|
|
86
|
+
}
|
|
55
87
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
56
88
|
const lock = await acquireLock(path);
|
|
57
89
|
try {
|
|
58
|
-
|
|
59
|
-
await atomicWrite(path, roster);
|
|
60
|
-
return roster;
|
|
90
|
+
return { roster: await save() };
|
|
61
91
|
} finally {
|
|
62
92
|
await lock.close();
|
|
63
93
|
await unlink(`${path}.lock`);
|
|
@@ -73,17 +103,3 @@ async function acquireLock(path: string) {
|
|
|
73
103
|
);
|
|
74
104
|
}
|
|
75
105
|
}
|
|
76
|
-
|
|
77
|
-
async function atomicWrite(path: string, roster: FleetConfig): Promise<void> {
|
|
78
|
-
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
79
|
-
const handle = await open(temporary, 'wx', 0o600);
|
|
80
|
-
try {
|
|
81
|
-
await handle.writeFile(`${JSON.stringify(roster, null, 2)}\n`);
|
|
82
|
-
await handle.sync();
|
|
83
|
-
await handle.close();
|
|
84
|
-
await rename(temporary, path);
|
|
85
|
-
} finally {
|
|
86
|
-
await handle.close();
|
|
87
|
-
await unlink(temporary).catch(() => undefined);
|
|
88
|
-
}
|
|
89
|
-
}
|
package/src/cli/settings.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { access } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { PACKAGE_ROOT, packageInfo } from '../package-info';
|
|
4
|
+
import { STATE_ROSTER } from '../state/roster';
|
|
5
5
|
|
|
6
6
|
export interface Settings {
|
|
7
7
|
workspace: string;
|
|
8
|
+
/** A roster file, or `state:roster` for the roster in the fleet state. */
|
|
8
9
|
config: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
@@ -30,16 +31,17 @@ async function findUp(start: string, name: string): Promise<string | null> {
|
|
|
30
31
|
/** A dev-fleet checkout, as opposed to any other project that has a mise.toml. */
|
|
31
32
|
const CHECKOUT_MARKER = 'bootstrap/fleet-manager-run.sh';
|
|
32
33
|
|
|
33
|
-
/** Where
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
/** Where the CLI runs from. A published package is its own workspace, whatever
|
|
35
|
+
* project it is run in; code run from a checkout uses the dev-fleet checkout
|
|
36
|
+
* the command is started in (development).
|
|
37
|
+
*/
|
|
37
38
|
async function findCheckout(start: string): Promise<string> {
|
|
39
|
+
if (packageInfo().revision) return PACKAGE_ROOT;
|
|
38
40
|
let current = resolve(start);
|
|
39
41
|
while (true) {
|
|
40
42
|
if (await exists(join(current, CHECKOUT_MARKER))) return current;
|
|
41
43
|
const parent = dirname(current);
|
|
42
|
-
if (parent === current) return
|
|
44
|
+
if (parent === current) return PACKAGE_ROOT;
|
|
43
45
|
current = parent;
|
|
44
46
|
}
|
|
45
47
|
}
|
|
@@ -54,11 +56,12 @@ export async function resolveSettings(
|
|
|
54
56
|
env['PERSONAL_WORKSPACE'] ??
|
|
55
57
|
(await findCheckout(cwd))
|
|
56
58
|
);
|
|
57
|
-
|
|
59
|
+
// A roster file is named explicitly; otherwise it is kept in the fleet state.
|
|
60
|
+
const named =
|
|
58
61
|
overrides.config ??
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
env['PERSONAL_FLEET_CONFIG'] ??
|
|
63
|
+
(await findUp(cwd, '.fleet.local.json'));
|
|
64
|
+
const config =
|
|
65
|
+
!named || named === STATE_ROSTER ? STATE_ROSTER : resolve(named);
|
|
63
66
|
return { workspace, config };
|
|
64
67
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
import type { CommandOutcome } from './contracts';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
4
6
|
import { join } from 'node:path';
|
|
5
7
|
import type { Settings } from './settings';
|
|
6
8
|
|
|
@@ -30,23 +32,45 @@ export function sourceOperation(args: string[]): string | undefined {
|
|
|
30
32
|
: undefined;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/** These owners install from Git (a service keeps the commit it runs), so
|
|
36
|
+
* they run from a checkout: the one this CLI is in, or, for an installed
|
|
37
|
+
* package, the fleet-owned checkout of the revision fleet-sync applied on
|
|
38
|
+
* this machine. Null before the first fleet-sync.
|
|
39
|
+
*/
|
|
40
|
+
export function ownerCheckout(
|
|
41
|
+
workspace: string,
|
|
42
|
+
home = homedir()
|
|
43
|
+
): string | null {
|
|
44
|
+
if (existsSync(join(workspace, '.git'))) return workspace;
|
|
45
|
+
const applied = join(home, '.local/share/personal/fleet/current');
|
|
46
|
+
return existsSync(join(applied, '.git')) ? applied : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CHECKOUT_REQUIRED =
|
|
50
|
+
'This command runs from the revision fleet-sync applied on this machine, and none is applied yet. Run devfleet fleet-sync first.\n';
|
|
51
|
+
|
|
33
52
|
export async function forwardSourceOperation(
|
|
34
53
|
args: string[],
|
|
35
54
|
settings: Settings
|
|
36
55
|
): Promise<number> {
|
|
37
56
|
const operation = SOURCE_OPERATIONS[args[0]!]!;
|
|
38
57
|
const [runtime, ...ownerArgs] = operation.command;
|
|
58
|
+
const checkout = ownerCheckout(settings.workspace);
|
|
59
|
+
if (!checkout) {
|
|
60
|
+
process.stderr.write(CHECKOUT_REQUIRED);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
39
63
|
const resolved = ownerArgs.map((arg) =>
|
|
40
|
-
arg.includes('/') ? join(
|
|
64
|
+
arg.includes('/') ? join(checkout, arg) : arg
|
|
41
65
|
);
|
|
42
66
|
return new Promise((resolve) => {
|
|
43
67
|
const child = spawn(runtime!, [...resolved, ...args.slice(1)], {
|
|
44
|
-
cwd:
|
|
68
|
+
cwd: checkout,
|
|
45
69
|
stdio: 'inherit',
|
|
46
70
|
});
|
|
47
71
|
child.on('error', () => {
|
|
48
72
|
process.stderr.write(
|
|
49
|
-
'This command requires
|
|
73
|
+
'This command requires python3 and the toolchain fleet-sync installs.\n'
|
|
50
74
|
);
|
|
51
75
|
resolve(1);
|
|
52
76
|
});
|
package/src/cli/tailnet.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { discoverTailnet } from '../tailscale';
|
|
|
9
9
|
import { tailnetHealth } from '../tailnet-health';
|
|
10
10
|
import { makeReport, renderReport, reportExitCode } from '../report';
|
|
11
11
|
import { readRoster } from './roster';
|
|
12
|
-
import {
|
|
12
|
+
import { publicHubUrl } from '../monitoring/beszel';
|
|
13
13
|
import { stableUrls } from './fleet-sync-output';
|
|
14
14
|
import { timeout } from './options';
|
|
15
15
|
import type { FleetServices } from './fleet';
|
|
@@ -89,7 +89,7 @@ export async function executeTailnet(
|
|
|
89
89
|
|
|
90
90
|
/** Shown to people: the stable public name, never the tailnet address. */
|
|
91
91
|
function publicDashboard(privateHub: string | null): string | null {
|
|
92
|
-
return privateHub ?
|
|
92
|
+
return privateHub ? publicHubUrl() : null;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
async function healthContext(
|
package/src/definition.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { FleetUsageError } from './errors';
|
|
|
4
4
|
import { parseConfig, record } from './config';
|
|
5
5
|
import type { FleetConfig } from './types';
|
|
6
6
|
import { parseSecretsConfig, type SecretsConfig } from './secrets';
|
|
7
|
+
import { parseLocation, type BucketLocation } from './state/bucket';
|
|
7
8
|
|
|
8
9
|
export interface MachineDefinition {
|
|
9
10
|
id: string;
|
|
@@ -26,10 +27,14 @@ export interface ManagerDefinition {
|
|
|
26
27
|
}
|
|
27
28
|
export interface FleetDefinition {
|
|
28
29
|
version: 3;
|
|
29
|
-
/** The Git repository
|
|
30
|
+
/** The Git repository machines take dev-fleet's code from. */
|
|
30
31
|
source: { repository: string };
|
|
31
32
|
/** Where this fleet keeps its secrets. */
|
|
32
33
|
secrets: SecretsConfig;
|
|
34
|
+
/** The bucket that holds the fleet state. Its credentials are never part
|
|
35
|
+
* of the configuration: they come from the environment of the command.
|
|
36
|
+
*/
|
|
37
|
+
state?: BucketLocation;
|
|
33
38
|
manager: ManagerDefinition;
|
|
34
39
|
/** Stateless, replaceable Linux boxes; clients of the manager's services. */
|
|
35
40
|
workers: MachineDefinition[];
|
|
@@ -137,6 +142,7 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
137
142
|
'version',
|
|
138
143
|
'source',
|
|
139
144
|
'secrets',
|
|
145
|
+
'state',
|
|
140
146
|
'manager',
|
|
141
147
|
'workers',
|
|
142
148
|
'services',
|
|
@@ -155,6 +161,7 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
155
161
|
version: 3,
|
|
156
162
|
source: source(value['source']),
|
|
157
163
|
secrets: secrets(value['secrets']),
|
|
164
|
+
...(value['state'] === undefined ? {} : { state: state(value['state']) }),
|
|
158
165
|
manager: lead,
|
|
159
166
|
workers,
|
|
160
167
|
services: services(value['services']),
|
|
@@ -198,6 +205,18 @@ const account = (value: unknown): value is string =>
|
|
|
198
205
|
function secrets(value: unknown): SecretsConfig {
|
|
199
206
|
return parseSecretsConfig(value) ?? fail();
|
|
200
207
|
}
|
|
208
|
+
/** Says what is wrong with the block itself: a bucket has several settings. */
|
|
209
|
+
function state(value: unknown): BucketLocation {
|
|
210
|
+
const bucket = parseLocation(value);
|
|
211
|
+
if (bucket.kind === 'directory') return { directory: bucket.path };
|
|
212
|
+
const { bucket: name, endpoint, region } = bucket;
|
|
213
|
+
return {
|
|
214
|
+
bucket: name,
|
|
215
|
+
...(endpoint ? { endpoint } : {}),
|
|
216
|
+
...(region ? { region } : {}),
|
|
217
|
+
prefix: bucket.prefix,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
201
220
|
function source(value: unknown): FleetDefinition['source'] {
|
|
202
221
|
if (
|
|
203
222
|
!record(value) ||
|
|
@@ -221,6 +240,17 @@ function baseline(ubuntu: unknown): FleetDefinition['ubuntu'] {
|
|
|
221
240
|
return ubuntu as FleetDefinition['ubuntu'];
|
|
222
241
|
}
|
|
223
242
|
|
|
243
|
+
/** The configuration as every machine keeps it at
|
|
244
|
+
* ~/.config/dev-fleet/fleet.json: without the bucket. Only the devfleet CLI
|
|
245
|
+
* on the manager reaches the bucket, and a launcher that still runs the
|
|
246
|
+
* revision from before the block existed must go on accepting the file while
|
|
247
|
+
* a sync is under way.
|
|
248
|
+
*/
|
|
249
|
+
export function installedDefinition(definition: FleetDefinition): string {
|
|
250
|
+
const { state: _bucket, ...machine } = definition;
|
|
251
|
+
return JSON.stringify(machine, null, 2) + '\n';
|
|
252
|
+
}
|
|
253
|
+
|
|
224
254
|
export function loadDefinition(path: string): FleetDefinition {
|
|
225
255
|
try {
|
|
226
256
|
return parseDefinition(JSON.parse(readFileSync(path, 'utf8')));
|
package/src/diagnostics.ts
CHANGED
|
@@ -71,7 +71,7 @@ export function diagnostic(node: string, check: Check): Diagnostic {
|
|
|
71
71
|
}
|
|
72
72
|
function scope(id: string): Diagnostic['scope'] {
|
|
73
73
|
if (
|
|
74
|
-
/^(t3:manager-endpoint|ssh:|manager:|rollout:|source:|tailscale:(reachability|identity|enrolled-identity))/.test(
|
|
74
|
+
/^(t3:manager-(endpoint|origin)|ssh:|manager:|rollout:|source:|tailscale:(reachability|identity|enrolled-identity))/.test(
|
|
75
75
|
id
|
|
76
76
|
)
|
|
77
77
|
)
|