@crvouga/dev-fleet 0.1.0 → 0.3.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 +265 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +10 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/macos/t3-policy.yml +49 -0
- 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 +13 -3
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- 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/native-t3.ts +22 -11
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- package/cliproxy/README.md +3 -3
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +29 -1
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +91 -0
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +400 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +69 -0
- package/docs/ubuntu-sync.md +357 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +36 -0
- package/mise.lock +29 -0
- package/mise.toml +15 -2
- package/package.json +11 -2
- package/schema/fleet.schema.json +137 -15
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/fleet-verify.ts +9 -0
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +293 -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-accounts.ts +253 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-accounts.ts +58 -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 +84 -12
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +7 -3
- package/src/cli/fleet-sync-remote.ts +31 -18
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +159 -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 +86 -1
- package/src/cli/registry.ts +41 -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 +116 -4
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +9 -18
- 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 +30 -7
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/resource-verify.ts +8 -4
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/ubuntu-audit.ts +5 -2
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +17 -19
- package/src/remote-entry.ts +15 -1
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/service-auth.ts +15 -5
- 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/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +24 -2
- package/src/toolchain-checks.ts +25 -0
- package/src/transport.ts +3 -2
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +41 -13
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
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,13 @@ 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';
|
|
8
|
+
import {
|
|
9
|
+
parseIdes,
|
|
10
|
+
parseT3,
|
|
11
|
+
type IdeId,
|
|
12
|
+
type T3Definition,
|
|
13
|
+
} from './ide/definition';
|
|
7
14
|
|
|
8
15
|
export interface MachineDefinition {
|
|
9
16
|
id: string;
|
|
@@ -26,16 +33,20 @@ export interface ManagerDefinition {
|
|
|
26
33
|
}
|
|
27
34
|
export interface FleetDefinition {
|
|
28
35
|
version: 3;
|
|
29
|
-
/** The Git repository
|
|
36
|
+
/** The Git repository machines take dev-fleet's code from. */
|
|
30
37
|
source: { repository: string };
|
|
31
38
|
/** Where this fleet keeps its secrets. */
|
|
32
39
|
secrets: SecretsConfig;
|
|
40
|
+
/** The bucket that holds the fleet state. Its credentials are never part
|
|
41
|
+
* of the configuration: they come from the environment of the command.
|
|
42
|
+
*/
|
|
43
|
+
state?: BucketLocation;
|
|
33
44
|
manager: ManagerDefinition;
|
|
34
45
|
/** Stateless, replaceable Linux boxes; clients of the manager's services. */
|
|
35
46
|
workers: MachineDefinition[];
|
|
36
47
|
services: {
|
|
37
48
|
/** Public HTTPS origin of the CLI proxy every node uses. */
|
|
38
|
-
proxy: { endpoint: string };
|
|
49
|
+
proxy: { endpoint: string; accounts?: AccountPolicy };
|
|
39
50
|
monitoring: {
|
|
40
51
|
hubPort: 8443;
|
|
41
52
|
/** Public dashboard origin. */
|
|
@@ -47,8 +58,28 @@ export interface FleetDefinition {
|
|
|
47
58
|
};
|
|
48
59
|
};
|
|
49
60
|
worker?: WorkerPolicy;
|
|
61
|
+
/** The IDEs every machine runs; `["t3"]` when not named (idesOf). */
|
|
62
|
+
ides?: IdeId[];
|
|
63
|
+
/** What every machine's T3 holds beyond its provider instances. */
|
|
64
|
+
t3?: T3Definition;
|
|
50
65
|
ubuntu: { release: '26.04'; architectures: ['amd64', 'arm64'] };
|
|
51
66
|
}
|
|
67
|
+
/** How provider accounts are watched and chosen (src/accounts/). */
|
|
68
|
+
export interface AccountPolicy {
|
|
69
|
+
/** Fewer usable accounts of a provider than this is a warning; none fails. */
|
|
70
|
+
minimumUsable?: number;
|
|
71
|
+
/** Per provider (`claude`, `codex`, `antigravity`): which account an
|
|
72
|
+
* unpinned request goes to first. Accounts cooling down are skipped.
|
|
73
|
+
*/
|
|
74
|
+
selection?: Record<string, AccountSelection>;
|
|
75
|
+
}
|
|
76
|
+
export interface AccountSelection {
|
|
77
|
+
strategy: 'fill-first' | 'round-robin' | 'least-recently-limited';
|
|
78
|
+
/** Account labels (sign-in emails) first, in this order; the others
|
|
79
|
+
* follow in the order of their routing prefix.
|
|
80
|
+
*/
|
|
81
|
+
order?: string[];
|
|
82
|
+
}
|
|
52
83
|
const ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
53
84
|
const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
|
|
54
85
|
function fail(): never {
|
|
@@ -137,11 +168,14 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
137
168
|
'version',
|
|
138
169
|
'source',
|
|
139
170
|
'secrets',
|
|
171
|
+
'state',
|
|
140
172
|
'manager',
|
|
141
173
|
'workers',
|
|
142
174
|
'services',
|
|
143
175
|
'ubuntu',
|
|
144
176
|
'worker',
|
|
177
|
+
'ides',
|
|
178
|
+
't3',
|
|
145
179
|
])
|
|
146
180
|
)
|
|
147
181
|
return fail();
|
|
@@ -155,6 +189,7 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
155
189
|
version: 3,
|
|
156
190
|
source: source(value['source']),
|
|
157
191
|
secrets: secrets(value['secrets']),
|
|
192
|
+
...(value['state'] === undefined ? {} : { state: state(value['state']) }),
|
|
158
193
|
manager: lead,
|
|
159
194
|
workers,
|
|
160
195
|
services: services(value['services']),
|
|
@@ -162,6 +197,9 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
162
197
|
...(value['worker'] === undefined
|
|
163
198
|
? {}
|
|
164
199
|
: { worker: parseWorkerPolicy(value['worker']) }),
|
|
200
|
+
// Kept only when named: an earlier revision must still read the copy.
|
|
201
|
+
...(value['ides'] === undefined ? {} : { ides: parseIdes(value['ides']) }),
|
|
202
|
+
...(value['t3'] === undefined ? {} : { t3: parseT3(value['t3']) }),
|
|
165
203
|
};
|
|
166
204
|
}
|
|
167
205
|
/** Singleton services name no host: they always run on the manager. */
|
|
@@ -170,15 +208,66 @@ function services(value: unknown): FleetDefinition['services'] {
|
|
|
170
208
|
const { proxy, monitoring } = value;
|
|
171
209
|
if (
|
|
172
210
|
!record(proxy) ||
|
|
173
|
-
!keys(proxy, ['endpoint']) ||
|
|
211
|
+
!keys(proxy, ['endpoint', 'accounts']) ||
|
|
174
212
|
!origin(proxy['endpoint'])
|
|
175
213
|
)
|
|
176
214
|
return fail();
|
|
177
215
|
return {
|
|
178
|
-
proxy: {
|
|
216
|
+
proxy: {
|
|
217
|
+
endpoint: proxy['endpoint'],
|
|
218
|
+
...(proxy['accounts'] === undefined
|
|
219
|
+
? {}
|
|
220
|
+
: { accounts: accountPolicy(proxy['accounts']) }),
|
|
221
|
+
},
|
|
179
222
|
monitoring: monitoringService(monitoring),
|
|
180
223
|
};
|
|
181
224
|
}
|
|
225
|
+
const STRATEGIES = ['fill-first', 'round-robin', 'least-recently-limited'];
|
|
226
|
+
function accountPolicy(value: unknown): AccountPolicy {
|
|
227
|
+
if (!record(value) || !keys(value, ['minimumUsable', 'selection']))
|
|
228
|
+
return fail();
|
|
229
|
+
const { minimumUsable, selection } = value;
|
|
230
|
+
if (
|
|
231
|
+
minimumUsable !== undefined &&
|
|
232
|
+
!(Number.isInteger(minimumUsable) && Number(minimumUsable) >= 1)
|
|
233
|
+
)
|
|
234
|
+
return fail();
|
|
235
|
+
return {
|
|
236
|
+
...(minimumUsable === undefined
|
|
237
|
+
? {}
|
|
238
|
+
: { minimumUsable: minimumUsable as number }),
|
|
239
|
+
...(selection === undefined
|
|
240
|
+
? {}
|
|
241
|
+
: { selection: selectionPolicies(selection) }),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function selectionPolicies(value: unknown): Record<string, AccountSelection> {
|
|
245
|
+
if (!record(value)) return fail();
|
|
246
|
+
const providers: Record<string, AccountSelection> = {};
|
|
247
|
+
for (const [provider, entry] of Object.entries(value)) {
|
|
248
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider)) return fail();
|
|
249
|
+
providers[provider] = accountSelection(entry);
|
|
250
|
+
}
|
|
251
|
+
return providers;
|
|
252
|
+
}
|
|
253
|
+
function accountSelection(entry: unknown): AccountSelection {
|
|
254
|
+
if (
|
|
255
|
+
!record(entry) ||
|
|
256
|
+
!keys(entry, ['strategy', 'order']) ||
|
|
257
|
+
!STRATEGIES.includes(String(entry['strategy']))
|
|
258
|
+
)
|
|
259
|
+
return fail();
|
|
260
|
+
const { order } = entry;
|
|
261
|
+
if (
|
|
262
|
+
order !== undefined &&
|
|
263
|
+
!(Array.isArray(order) && order.every((label) => account(label)))
|
|
264
|
+
)
|
|
265
|
+
return fail();
|
|
266
|
+
return {
|
|
267
|
+
strategy: entry['strategy'] as AccountSelection['strategy'],
|
|
268
|
+
...(order ? { order: order as string[] } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
182
271
|
function monitoringService(
|
|
183
272
|
value: unknown
|
|
184
273
|
): FleetDefinition['services']['monitoring'] {
|
|
@@ -198,6 +287,18 @@ const account = (value: unknown): value is string =>
|
|
|
198
287
|
function secrets(value: unknown): SecretsConfig {
|
|
199
288
|
return parseSecretsConfig(value) ?? fail();
|
|
200
289
|
}
|
|
290
|
+
/** Says what is wrong with the block itself: a bucket has several settings. */
|
|
291
|
+
function state(value: unknown): BucketLocation {
|
|
292
|
+
const bucket = parseLocation(value);
|
|
293
|
+
if (bucket.kind === 'directory') return { directory: bucket.path };
|
|
294
|
+
const { bucket: name, endpoint, region } = bucket;
|
|
295
|
+
return {
|
|
296
|
+
bucket: name,
|
|
297
|
+
...(endpoint ? { endpoint } : {}),
|
|
298
|
+
...(region ? { region } : {}),
|
|
299
|
+
prefix: bucket.prefix,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
201
302
|
function source(value: unknown): FleetDefinition['source'] {
|
|
202
303
|
if (
|
|
203
304
|
!record(value) ||
|
|
@@ -221,6 +322,17 @@ function baseline(ubuntu: unknown): FleetDefinition['ubuntu'] {
|
|
|
221
322
|
return ubuntu as FleetDefinition['ubuntu'];
|
|
222
323
|
}
|
|
223
324
|
|
|
325
|
+
/** The configuration as every machine keeps it at
|
|
326
|
+
* ~/.config/dev-fleet/fleet.json: without the bucket. Only the devfleet CLI
|
|
327
|
+
* on the manager reaches the bucket, and a launcher that still runs the
|
|
328
|
+
* revision from before the block existed must go on accepting the file while
|
|
329
|
+
* a sync is under way.
|
|
330
|
+
*/
|
|
331
|
+
export function installedDefinition(definition: FleetDefinition): string {
|
|
332
|
+
const { state: _bucket, ...machine } = definition;
|
|
333
|
+
return JSON.stringify(machine, null, 2) + '\n';
|
|
334
|
+
}
|
|
335
|
+
|
|
224
336
|
export function loadDefinition(path: string): FleetDefinition {
|
|
225
337
|
try {
|
|
226
338
|
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
|
)
|
package/src/fleet-config.ts
CHANGED
|
@@ -7,8 +7,12 @@ import {
|
|
|
7
7
|
writeFileSync,
|
|
8
8
|
} from 'node:fs';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
import {
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
installedDefinition,
|
|
13
|
+
parseDefinition,
|
|
14
|
+
type FleetDefinition,
|
|
15
|
+
} from './definition';
|
|
12
16
|
import { FleetUsageError } from './errors';
|
|
13
17
|
import { pinnedPath } from './agents/toolchain';
|
|
14
18
|
import type { Runner } from './types';
|
|
@@ -17,14 +21,62 @@ import {
|
|
|
17
21
|
type SecretCommand,
|
|
18
22
|
type SecretsProvider,
|
|
19
23
|
} from './secrets';
|
|
24
|
+
import { isOperator, stateDefinition } from './state/operator';
|
|
25
|
+
import type { BucketLocation } from './state/bucket';
|
|
26
|
+
import { definitionPath, stateRoot } from './state/paths';
|
|
27
|
+
|
|
28
|
+
/** Where every machine keeps the fleet configuration fleet-sync approved.
|
|
29
|
+
* Launchers and scheduled jobs get no environment and read it from here.
|
|
30
|
+
*/
|
|
31
|
+
const installedPath = (home: string): string =>
|
|
32
|
+
join(home, '.config/dev-fleet/fleet.json');
|
|
33
|
+
|
|
34
|
+
/** Where this process reads the fleet configuration from, in this order:
|
|
35
|
+
* - `file`: the file `DEVFLEET_CONFIG` names. The devfleet CLI sets it to the
|
|
36
|
+
* `fleet.json` of the project it is run from (src/cli/project.ts);
|
|
37
|
+
* - `state`: the `definition` document in the manager's fleet state, the
|
|
38
|
+
* configuration fleet-sync last applied, which only the devfleet CLI
|
|
39
|
+
* reads. `path` is its copy as a file;
|
|
40
|
+
* - `installed`: the copy fleet-sync installed on this machine.
|
|
41
|
+
*/
|
|
42
|
+
export interface DefinitionSource {
|
|
43
|
+
kind: 'file' | 'state' | 'installed';
|
|
44
|
+
path: string;
|
|
45
|
+
}
|
|
20
46
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
47
|
+
export function definitionSource(
|
|
48
|
+
env = process.env,
|
|
49
|
+
home = homedir()
|
|
50
|
+
): DefinitionSource {
|
|
51
|
+
const root = stateRoot(env, home);
|
|
52
|
+
const copy = definitionPath(root);
|
|
53
|
+
const named = env['DEVFLEET_CONFIG'];
|
|
54
|
+
// A devfleet command names the state's copy for the commands it starts.
|
|
55
|
+
const inherited = named === copy && isOperator();
|
|
56
|
+
if (named && !inherited) return { kind: 'file', path: named };
|
|
57
|
+
if (stateDefinition(root) !== null) return { kind: 'state', path: copy };
|
|
58
|
+
return { kind: 'installed', path: installedPath(home) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writePrivate(path: string, content: string): boolean {
|
|
62
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === content) return false;
|
|
63
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
64
|
+
writeFileSync(`${path}.new`, content, { mode: 0o600 });
|
|
65
|
+
renameSync(`${path}.new`, path);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The file that holds the configuration this process runs with, for the
|
|
70
|
+
* subprocesses that take a path. Every command this process starts reads the
|
|
71
|
+
* same one.
|
|
25
72
|
*/
|
|
26
73
|
export function configPath(env = process.env, home = homedir()): string {
|
|
27
|
-
|
|
74
|
+
const source = definitionSource(env, home);
|
|
75
|
+
if (source.kind !== 'state') return source.path;
|
|
76
|
+
const body = stateDefinition(stateRoot(env, home));
|
|
77
|
+
if (body !== null) writePrivate(source.path, body);
|
|
78
|
+
env['DEVFLEET_CONFIG'] = source.path;
|
|
79
|
+
return source.path;
|
|
28
80
|
}
|
|
29
81
|
|
|
30
82
|
/** Installs the configuration this process runs with as the machine's copy,
|
|
@@ -32,30 +84,64 @@ export function configPath(env = process.env, home = homedir()): string {
|
|
|
32
84
|
* Returns whether the file changed.
|
|
33
85
|
*/
|
|
34
86
|
export function installConfig(home = homedir()): boolean {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
87
|
+
return writePrivate(installedPath(home), installedDefinition(fleet));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function read(path: string): FleetDefinition {
|
|
91
|
+
let value: unknown;
|
|
92
|
+
try {
|
|
93
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
94
|
+
} catch {
|
|
95
|
+
throw new FleetUsageError(
|
|
96
|
+
`The fleet configuration at ${path} is not readable JSON.`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return parseDefinition(value);
|
|
43
100
|
}
|
|
44
101
|
|
|
45
102
|
function load(): FleetDefinition {
|
|
46
103
|
const path = configPath();
|
|
47
104
|
if (!existsSync(path))
|
|
48
105
|
throw new FleetUsageError(
|
|
49
|
-
`No fleet configuration
|
|
106
|
+
`No fleet configuration. Run devfleet from the project that holds your fleet.json (devfleet fleet-state --example prints one to start from), or name the file with DEVFLEET_CONFIG. A manager whose bucket already holds one restores it with devfleet fleet-state --init. Looked at ${path}.`
|
|
50
107
|
);
|
|
51
|
-
return
|
|
108
|
+
return read(path);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Where the configuration says the fleet state is kept, read from the file
|
|
112
|
+
* each time: null when it names no bucket, or when this machine has no
|
|
113
|
+
* configuration yet.
|
|
114
|
+
*/
|
|
115
|
+
export function declaredState(
|
|
116
|
+
env = process.env,
|
|
117
|
+
home = homedir()
|
|
118
|
+
): BucketLocation | null {
|
|
119
|
+
const path = configPath(env, home);
|
|
120
|
+
return existsSync(path) ? (read(path).state ?? null) : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let loaded: FleetDefinition | undefined;
|
|
124
|
+
const current = (): FleetDefinition => (loaded ??= load());
|
|
125
|
+
|
|
126
|
+
/** Forgets the configuration read so far; the next use reads it again. For
|
|
127
|
+
* a command that has just replaced the fleet state under itself.
|
|
128
|
+
*/
|
|
129
|
+
export function reloadFleet(): void {
|
|
130
|
+
loaded = undefined;
|
|
52
131
|
}
|
|
53
132
|
|
|
54
133
|
/** This fleet's own configuration: its machines, endpoints, accounts and source
|
|
55
134
|
* repository. Fleet code never names any of them; it reads them here, and this
|
|
56
|
-
* is the only module that loads the file.
|
|
135
|
+
* is the only module that loads the file. It is read on first use, so a
|
|
136
|
+
* command that needs no configuration runs on a machine that has none yet.
|
|
57
137
|
*/
|
|
58
|
-
export const fleet =
|
|
138
|
+
export const fleet: FleetDefinition = new Proxy({} as FleetDefinition, {
|
|
139
|
+
get: (_, key) => Reflect.get(current(), key),
|
|
140
|
+
has: (_, key) => Reflect.has(current(), key),
|
|
141
|
+
ownKeys: () => Reflect.ownKeys(current()),
|
|
142
|
+
getOwnPropertyDescriptor: (_, key) =>
|
|
143
|
+
Reflect.getOwnPropertyDescriptor(current(), key),
|
|
144
|
+
});
|
|
59
145
|
|
|
60
146
|
/** Reads one of this fleet's secrets for this node. Tools run from the pinned
|
|
61
147
|
* toolchain, whatever PATH the caller (T3, launchd, systemd) started with.
|