@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.
Files changed (110) hide show
  1. package/README.md +237 -37
  2. package/agents/setup.md +42 -12
  3. package/ansible/macos/manager.yml +7 -7
  4. package/ansible/macos/policy.json +2 -10
  5. package/ansible/monitoring/README.md +86 -25
  6. package/ansible/monitoring/agent.yml +40 -2
  7. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  8. package/ansible/monitoring/lock.json +5 -0
  9. package/ansible/pyproject.toml +12 -0
  10. package/ansible/ubuntu/baseline.yml +1 -1
  11. package/ansible/ubuntu/disk-gc.py +336 -0
  12. package/ansible/ubuntu/policy.json +12 -3
  13. package/ansible/ubuntu/tailscale.yml +8 -5
  14. package/ansible/ubuntu/tasks/resources.yml +61 -1
  15. package/ansible/uv.lock +190 -0
  16. package/bootstrap/bootstrap.sh +25 -18
  17. package/bootstrap/doctor.ts +46 -1
  18. package/bootstrap/fleet-manager-run.sh +42 -15
  19. package/bootstrap/fleet-remote-bootstrap.sh +85 -12
  20. package/bootstrap/global-config.ts +64 -6
  21. package/bootstrap/install-launcher.sh +3 -0
  22. package/bootstrap/npm.ts +10 -2
  23. package/bootstrap/release-version.ts +157 -0
  24. package/docs/consumer-setup.md +408 -0
  25. package/docs/declared.md +89 -0
  26. package/docs/machine-setup.md +399 -0
  27. package/docs/operations.md +276 -0
  28. package/docs/releasing.md +103 -0
  29. package/docs/state.md +254 -0
  30. package/docs/topology.md +67 -0
  31. package/docs/ubuntu-sync.md +351 -0
  32. package/docs/worker-contract.md +67 -0
  33. package/examples/fleet.json +5 -0
  34. package/llms.txt +35 -0
  35. package/mise.lock +29 -0
  36. package/mise.toml +12 -1
  37. package/package.json +11 -2
  38. package/schema/fleet.schema.json +71 -15
  39. package/src/agents/fleet-verify.ts +9 -0
  40. package/src/agents/native-verify.ts +167 -22
  41. package/src/agents/provider-errors.ts +9 -2
  42. package/src/agents/registry.ts +7 -1
  43. package/src/cli/agent-guide.ts +39 -5
  44. package/src/cli/app.ts +11 -4
  45. package/src/cli/cli.ts +7 -0
  46. package/src/cli/command-index.ts +7 -0
  47. package/src/cli/fleet-state.ts +499 -0
  48. package/src/cli/fleet-sync-checkout.ts +56 -17
  49. package/src/cli/fleet-sync-enrollment.ts +19 -23
  50. package/src/cli/fleet-sync-forward.ts +1 -1
  51. package/src/cli/fleet-sync-manager.ts +64 -8
  52. package/src/cli/fleet-sync-monitoring.ts +5 -5
  53. package/src/cli/fleet-sync-output.ts +4 -1
  54. package/src/cli/fleet-sync-remote.ts +11 -5
  55. package/src/cli/fleet-sync-state.ts +86 -0
  56. package/src/cli/fleet-sync.ts +123 -57
  57. package/src/cli/fleet.ts +4 -2
  58. package/src/cli/managed-health.ts +4 -4
  59. package/src/cli/node-sync.ts +7 -0
  60. package/src/cli/program.ts +20 -4
  61. package/src/cli/project.ts +51 -0
  62. package/src/cli/register-fleet.ts +51 -1
  63. package/src/cli/registry.ts +28 -1
  64. package/src/cli/roster.ts +46 -30
  65. package/src/cli/settings.ts +15 -12
  66. package/src/cli/source-operations.ts +27 -3
  67. package/src/cli/tailnet.ts +2 -2
  68. package/src/definition.ts +31 -1
  69. package/src/diagnostics.ts +1 -1
  70. package/src/fleet-config.ts +105 -19
  71. package/src/machine/source.ts +3 -3
  72. package/src/machine/state.ts +15 -27
  73. package/src/manager-sync.ts +8 -3
  74. package/src/monitoring/agent-evidence.ts +146 -0
  75. package/src/monitoring/apply.ts +15 -3
  76. package/src/monitoring/beszel.ts +11 -4
  77. package/src/monitoring/enroll.ts +2 -1
  78. package/src/monitoring/hub-access.ts +4 -3
  79. package/src/monitoring/hub-backup.ts +121 -0
  80. package/src/monitoring/hub-viewer.ts +8 -8
  81. package/src/monitoring/hub.ts +3 -3
  82. package/src/monitoring/manager-hub.ts +30 -14
  83. package/src/node-converge-linux.ts +2 -0
  84. package/src/package-info.ts +45 -0
  85. package/src/platform/ansible.ts +19 -15
  86. package/src/platform/resource-verify.ts +8 -4
  87. package/src/platform/ubuntu-audit.ts +5 -2
  88. package/src/remote-bootstrap.ts +17 -19
  89. package/src/repair-catalog.ts +1 -1
  90. package/src/runner.ts +1 -1
  91. package/src/state/bucket.ts +208 -0
  92. package/src/state/check.ts +74 -0
  93. package/src/state/connection.ts +92 -0
  94. package/src/state/enrollment.ts +45 -0
  95. package/src/state/legacy.ts +89 -0
  96. package/src/state/litestream.ts +143 -0
  97. package/src/state/operator.ts +20 -0
  98. package/src/state/paths.ts +40 -0
  99. package/src/state/roster.ts +62 -0
  100. package/src/state/store.ts +169 -0
  101. package/src/state/sync.ts +352 -0
  102. package/src/t3-endpoint.ts +64 -8
  103. package/src/t3-linked-check.ts +23 -1
  104. package/src/toolchain-checks.ts +25 -0
  105. package/src/types.ts +4 -0
  106. package/src/worker-access-checks.ts +56 -3
  107. package/src/worker-host-checks.ts +5 -0
  108. package/src/workstation-checks.ts +1 -1
  109. package/src/index.ts +0 -6
  110. 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
- PUBLIC_HUB_URL,
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(`${PUBLIC_HUB_URL}/api/health`, {
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 ${PUBLIC_HUB_URL}.`,
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: `${PUBLIC_HUB_URL} 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.`,
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
  }
@@ -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
  });
@@ -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: 'Existing manager checkout; does not clone or change it',
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
- return release.version === SOURCE_VERSION
38
- ? `${release.version} (unreleased source build)`
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 confirms interactively or with --apply --yes.',
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
- 'Approve an exact source commit; defaults to published release or current canonical checkout'
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
  }
@@ -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; interactive confirmation or --apply --yes. Readiness is verified separately.',
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 { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
1
+ import { mkdir, open, unlink } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { randomUUID } from 'node:crypto';
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 info = await lstat(path);
13
- if (!info.isFile() || info.size > 1024 * 1024)
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
- ): Promise<FleetConfig> {
54
- if (!apply) return appendNode(await readRoster(path), node);
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
- const roster = appendNode(await readRoster(path), node);
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
- }
@@ -1,10 +1,11 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { fileURLToPath } from 'node:url';
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 this CLI's own files live: the package root, also when run by npx. */
34
- const packageRoot = (): string =>
35
- fileURLToPath(new URL('../..', import.meta.url));
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 packageRoot();
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
- const config = resolve(
59
+ // A roster file is named explicitly; otherwise it is kept in the fleet state.
60
+ const named =
58
61
  overrides.config ??
59
- env['PERSONAL_FLEET_CONFIG'] ??
60
- (await findUp(cwd, '.fleet.local.json')) ??
61
- join(homedir(), '.config/personal/fleet.json')
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(settings.workspace, arg) : arg
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: settings.workspace,
68
+ cwd: checkout,
45
69
  stdio: 'inherit',
46
70
  });
47
71
  child.on('error', () => {
48
72
  process.stderr.write(
49
- 'This command requires the dev-fleet checkout and its toolchain.\n'
73
+ 'This command requires python3 and the toolchain fleet-sync installs.\n'
50
74
  );
51
75
  resolve(1);
52
76
  });
@@ -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 { PUBLIC_HUB_URL } from '../monitoring/beszel';
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 ? PUBLIC_HUB_URL : null;
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 that holds this fleet's configuration and pins. */
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')));
@@ -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
  )