@crvouga/dev-fleet 0.7.0 → 0.9.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 +88 -7
- package/agents/audit.md +18 -18
- package/ansible/agents/agents.yml +134 -0
- package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
- package/ansible/agents/templates/claude.settings.json.j2 +3 -0
- package/ansible/agents/templates/codex.config.toml.j2 +11 -0
- package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
- package/ansible/agents/templates/grok.config.toml.j2 +19 -0
- package/ansible/agents/templates/opencode.json.j2 +24 -0
- package/ansible/agents/templates/receipt.json.j2 +10 -0
- package/ansible/ansible.cfg +14 -0
- package/ansible/github/git.yml +82 -0
- package/ansible/macos/manager-privilege.yml +83 -0
- package/ansible/macos/manager.yml +164 -9
- package/ansible/macos/policy.json +13 -0
- package/ansible/macos/t3-policy.yml +5 -3
- package/ansible/macos/vault-renew.yml +85 -0
- package/ansible/macos/worker-host-key.yml +33 -0
- package/ansible/monitoring/agent-darwin.yml +3 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/monitoring/hub-darwin.yml +3 -0
- package/ansible/monitoring/hub-route-darwin.yml +1 -0
- package/ansible/net/remote-probe.sh +4 -1
- package/ansible/platforms.json +2 -2
- package/ansible/pyproject.toml +5 -0
- package/ansible/ubuntu/baseline.yml +39 -13
- package/ansible/ubuntu/disk-gc.py +6 -2
- package/ansible/ubuntu/firewall.nft.j2 +50 -0
- package/ansible/ubuntu/policy.json +125 -6
- package/ansible/ubuntu/reboot.yml +89 -0
- package/ansible/ubuntu/recovery.yml +13 -3
- package/ansible/ubuntu/t3/policy.json +3 -0
- package/ansible/ubuntu/t3-desktop.yml +23 -2
- package/ansible/ubuntu/t3-headless.yml +128 -0
- package/ansible/ubuntu/t3-health.sh +24 -10
- package/ansible/ubuntu/t3-idle.py +37 -2
- package/ansible/ubuntu/t3-policy.yml +4 -1
- package/ansible/ubuntu/t3-route.yml +1 -0
- package/ansible/ubuntu/tailscale.yml +54 -8
- package/ansible/ubuntu/tasks/browser.yml +3 -0
- package/ansible/ubuntu/tasks/containers-off.yml +43 -0
- package/ansible/ubuntu/tasks/docker.yml +53 -6
- package/ansible/ubuntu/tasks/dpkg.yml +4 -0
- package/ansible/ubuntu/tasks/features.yml +22 -0
- package/ansible/ubuntu/tasks/firewall.yml +172 -0
- package/ansible/ubuntu/tasks/install.yml +2 -1
- package/ansible/ubuntu/tasks/packages.yml +4 -2
- package/ansible/ubuntu/tasks/release.yml +6 -0
- package/ansible/ubuntu/tasks/resources.yml +6 -4
- package/ansible/ubuntu/tasks/services.yml +2 -2
- package/ansible/ubuntu/tasks/sizing.yml +49 -0
- package/ansible/ubuntu/tasks/snapshot.yml +14 -3
- package/ansible/ubuntu/tasks/ssh.yml +61 -4
- package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
- package/ansible/ubuntu/tasks/user.yml +1 -0
- package/ansible/ubuntu/worker-account.yml +117 -0
- package/ansible/uv.lock +265 -0
- package/bin/devfleet.mjs +42 -4
- package/bootstrap/fleet-remote-bootstrap.sh +91 -25
- package/bootstrap/global-config.ts +29 -3
- package/bootstrap/package-smoke.ts +127 -0
- package/bootstrap/pins.ts +189 -0
- package/bootstrap/release-notes.ts +88 -0
- package/bootstrap/release-version.ts +41 -7
- package/bootstrap/worker-features.ts +114 -0
- package/bootstrap/worker-seed.yaml +65 -0
- package/bootstrap/worker-tools.ts +39 -9
- package/cliproxy/README.md +1 -1
- package/cliproxy/docs/health-workflow.example.yml +20 -143
- package/docs/authentication.md +21 -11
- package/docs/consumer-setup.md +74 -58
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +34 -0
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +112 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +82 -12
- package/docs/ubuntu-sync.md +98 -42
- package/docs/worker-contract.md +122 -19
- package/docs/worker-requirements.md +107 -0
- package/examples/fleet.json +1 -0
- package/examples/workers-from-infrastructure/main.tf +132 -0
- package/examples/workers-from-infrastructure/worker-records.json +14 -0
- package/llms.txt +3 -1
- package/mise.lock +29 -0
- package/mise.toml +5 -3
- package/package.json +2 -2
- package/schema/check-ids.json +1991 -162
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +349 -10
- package/schema/output/check.schema.json +1 -1
- package/schema/output/fleet-state-definition.schema.json +14 -0
- package/schema/output/fleet-sync-schedule.schema.json +26 -0
- package/schema/output/fleet-sync.schema.json +18 -0
- package/schema/output/report.schema.json +3 -2
- package/schema/worker-record.schema.json +76 -0
- package/src/agents/converge.ts +140 -24
- package/src/agents/files.ts +2 -32
- package/src/agents/native-verify.ts +34 -3
- package/src/agents/secrets.ts +39 -4
- package/src/check-owners.ts +62 -2
- package/src/check-registry.ts +630 -0
- package/src/cli/agent-guide.ts +3 -3
- package/src/cli/cli.ts +4 -0
- package/src/cli/fleet-manifest.ts +108 -0
- package/src/cli/fleet-node-lifecycle.ts +704 -0
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +167 -0
- package/src/cli/fleet-state.ts +2 -1
- package/src/cli/fleet-sync-checkout.ts +7 -0
- package/src/cli/fleet-sync-enrollment.ts +193 -32
- package/src/cli/fleet-sync-forward.ts +6 -0
- package/src/cli/fleet-sync-manager.ts +123 -8
- package/src/cli/fleet-sync-output.ts +31 -11
- package/src/cli/fleet-sync-plan.ts +65 -9
- package/src/cli/fleet-sync-records.ts +113 -0
- package/src/cli/fleet-sync-remote.ts +172 -15
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +31 -10
- package/src/cli/fleet-sync-vault.ts +152 -26
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +172 -21
- package/src/cli/fleet-validate.ts +277 -0
- package/src/cli/fleet-watch.ts +7 -1
- package/src/cli/fleet.ts +87 -5
- package/src/cli/managed-health.ts +25 -1
- package/src/cli/register-fleet.ts +251 -4
- package/src/cli/registry.ts +47 -0
- package/src/cli/tailnet.ts +43 -10
- package/src/cli/ui-data.ts +3 -1
- package/src/cli/worker-usage-report.ts +63 -0
- package/src/clock.ts +42 -0
- package/src/definition.ts +762 -232
- package/src/diagnostics.ts +5 -2
- package/src/exposure.ts +102 -0
- package/src/fleet-config.ts +3 -1
- package/src/git-identity.ts +76 -3
- package/src/ide/ide.ts +5 -0
- package/src/ide/t3.ts +12 -2
- package/src/input-hashes.ts +6 -0
- package/src/machine/machine-class.ts +59 -0
- package/src/machine/platform.ts +90 -11
- package/src/machine/state.ts +70 -16
- package/src/machine/support-matrix.ts +11 -9
- package/src/machine-health.ts +28 -5
- package/src/manager-health.ts +148 -2
- package/src/manager-sync.ts +89 -8
- package/src/monitoring/beszel.ts +8 -8
- package/src/monitoring/enroll.ts +8 -1
- package/src/monitoring/hub-access.ts +10 -4
- package/src/monitoring/systems.ts +138 -0
- package/src/network-definition.ts +13 -4
- package/src/node-converge-linux.ts +136 -27
- package/src/node-converge.ts +13 -3
- package/src/platform/ansible-receipt.ts +17 -2
- package/src/platform/ansible.ts +40 -4
- package/src/platform/manager-host.ts +25 -8
- package/src/platform/resource-verify.ts +18 -7
- package/src/platform/t3-policy.ts +24 -10
- package/src/platform/t3-service.ts +109 -34
- package/src/platform/ubuntu-release.ts +24 -0
- package/src/platform/ubuntu-verify.ts +31 -3
- package/src/platform/ubuntu-worker.ts +58 -0
- package/src/platform/worker-platform.ts +72 -0
- package/src/protocol.ts +1 -1
- package/src/public-reachability.ts +186 -0
- package/src/remote-bootstrap.ts +50 -12
- package/src/remote-operation.ts +18 -4
- package/src/repair-plan.ts +23 -12
- package/src/report.ts +2 -1
- package/src/schedule-check.ts +21 -2
- package/src/secrets.ts +7 -0
- package/src/service-auth.ts +55 -48
- package/src/ssh-connection.ts +141 -0
- package/src/ssh.ts +63 -24
- package/src/state/store.ts +4 -2
- package/src/state/sync.ts +35 -6
- package/src/state/workers.ts +140 -0
- package/src/t3/pairing.ts +59 -0
- package/src/t3/peer-link.ts +333 -0
- package/src/t3/version.ts +1 -1
- package/src/tailnet-cleanup.ts +185 -0
- package/src/tailnet-control.ts +346 -0
- package/src/tailscale.ts +1 -1
- package/src/toolchain-checks.ts +11 -1
- package/src/types.ts +4 -2
- package/src/worker-account.ts +63 -0
- package/src/worker-activity.ts +303 -0
- package/src/worker-capacity.ts +31 -0
- package/src/worker-credentials.ts +201 -0
- package/src/worker-exposure.ts +388 -0
- package/src/worker-features.ts +201 -0
- package/src/worker-host-checks.ts +71 -20
- package/src/worker-manifest.ts +224 -0
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +38 -36
- package/src/worker-readiness.ts +15 -3
- package/src/worker-reboot.ts +129 -0
- package/src/worker-records.ts +305 -0
- package/src/worker-seed.ts +143 -0
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "dev-fleet worker records, version 1",
|
|
4
|
+
"description": "Workers as the tool that created them describes them, without naming the tool or the provider. fleet.json names a file or a command that returns this document under workerRecords; dev-fleet only reads it, never creates a machine and never calls a provider. Each record is a declared worker reached from its data: its host key is pinned as given. A record needs a tailnetName or an address. A worker whose record disappears (a file that is gone holds no record) is retired by the next fleet-sync.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["version", "workers"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": { "type": "string" },
|
|
10
|
+
"version": { "const": 1 },
|
|
11
|
+
"workers": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"maxItems": 64,
|
|
14
|
+
"items": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": false,
|
|
17
|
+
"required": ["id", "user"],
|
|
18
|
+
"properties": {
|
|
19
|
+
"id": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"pattern": "^[a-z][a-z0-9-]{0,62}$",
|
|
22
|
+
"description": "The worker's fleet ID. No other worker, inline or from a record, may have it."
|
|
23
|
+
},
|
|
24
|
+
"tailnetName": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$",
|
|
27
|
+
"description": "The tailnet name the machine joins under, without the trailing dot."
|
|
28
|
+
},
|
|
29
|
+
"address": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$",
|
|
32
|
+
"description": "A name or address SSH reaches the machine at, when it has no tailnet name."
|
|
33
|
+
},
|
|
34
|
+
"user": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"pattern": "^[a-z_][a-z0-9_-]{0,31}$",
|
|
37
|
+
"description": "The non-root account with passwordless sudo the manager signs in as."
|
|
38
|
+
},
|
|
39
|
+
"port": {
|
|
40
|
+
"type": "integer",
|
|
41
|
+
"minimum": 1,
|
|
42
|
+
"maximum": 65535,
|
|
43
|
+
"description": "The port sshd listens on. Default 22."
|
|
44
|
+
},
|
|
45
|
+
"hostKey": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"pattern": "^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}$",
|
|
48
|
+
"description": "The machine's SSH host public key, \"TYPE BASE64\": generated before the machine exists (devfleet fleet-node-seed prints it) or read from the machine by the tool that created it. It is pinned, never learned on first use. Without it the key the fleet state pins is used (devfleet fleet-node-seed --apply --yes), and until there is one every session is refused."
|
|
49
|
+
},
|
|
50
|
+
"t3Port": {
|
|
51
|
+
"type": "integer",
|
|
52
|
+
"minimum": 1,
|
|
53
|
+
"maximum": 65535,
|
|
54
|
+
"description": "The port the worker's T3 server listens on. Default 3773."
|
|
55
|
+
},
|
|
56
|
+
"labels": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"additionalProperties": { "type": "string" },
|
|
59
|
+
"description": "Free-form labels; only displayed."
|
|
60
|
+
},
|
|
61
|
+
"billing": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"additionalProperties": false,
|
|
64
|
+
"required": ["hourlyRate"],
|
|
65
|
+
"properties": {
|
|
66
|
+
"hourlyRate": { "type": "number", "minimum": 0 },
|
|
67
|
+
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
|
|
68
|
+
"note": { "type": "string" }
|
|
69
|
+
},
|
|
70
|
+
"description": "What the machine costs while it runs, as you declare it; fleet-status sums it for idle workers."
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/agents/converge.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { AgentConfigurationError } from './errors';
|
|
2
2
|
import { mkdir, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { digest, registry, type Selection } from './registry';
|
|
5
6
|
import { render } from './render';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
safeParents,
|
|
12
|
-
} from './files';
|
|
7
|
+
import { readOwned, fileState, safeParents } from './files';
|
|
8
|
+
import { runAnsible } from '../platform/ansible';
|
|
9
|
+
import { PACKAGE_ROOT } from '../package-info';
|
|
10
|
+
import { runCommand } from '../runner';
|
|
11
|
+
import type { CommandResult, Runner } from '../types';
|
|
13
12
|
|
|
14
13
|
export type Receipt = {
|
|
15
14
|
version: 1;
|
|
@@ -19,6 +18,58 @@ export type Receipt = {
|
|
|
19
18
|
};
|
|
20
19
|
export type Context = { root: string; revision: string; selection: Selection };
|
|
21
20
|
|
|
21
|
+
/** What `ansible/agents/agents.yml` is given to write one profile. TypeScript
|
|
22
|
+
* decides here, beforehand, what is backed up and what each file must hash
|
|
23
|
+
* to; the play renders the files from its templates and asserts them.
|
|
24
|
+
*/
|
|
25
|
+
export interface ProfileVariables {
|
|
26
|
+
agent_root: string;
|
|
27
|
+
agent_node: string;
|
|
28
|
+
agent_tool: string;
|
|
29
|
+
agent_profile: string;
|
|
30
|
+
agent_model: string;
|
|
31
|
+
/** Empty for a direct profile: it never names the proxy. */
|
|
32
|
+
agent_proxy_endpoint: string;
|
|
33
|
+
/** The model of every proxy route, in the registry's order. */
|
|
34
|
+
agent_proxy_models: string[];
|
|
35
|
+
/** The SHA-256 of each file as src/agents/render.ts specifies it. */
|
|
36
|
+
agent_files: Record<string, string>;
|
|
37
|
+
/** What the receipt records beside those digests. */
|
|
38
|
+
agent_revision: string;
|
|
39
|
+
agent_fingerprint: string;
|
|
40
|
+
/** Files to copy into ROOT/backups before they are replaced. */
|
|
41
|
+
agent_backups: { source: string; dest: string }[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Runs the agents play with these variables. */
|
|
45
|
+
export type ProfilePlay = (
|
|
46
|
+
variables: ProfileVariables
|
|
47
|
+
) => Promise<CommandResult>;
|
|
48
|
+
|
|
49
|
+
/** The package's agents play, with the Ansible this machine runs. `scratch`
|
|
50
|
+
* is where its variables file is kept while it runs (the fleet's private
|
|
51
|
+
* run directory by default).
|
|
52
|
+
*/
|
|
53
|
+
export function agentsPlay(
|
|
54
|
+
run: Runner = runCommand,
|
|
55
|
+
options: { check?: boolean; scratch?: string } = {}
|
|
56
|
+
): ProfilePlay {
|
|
57
|
+
const directory = join(PACKAGE_ROOT, 'ansible', 'agents');
|
|
58
|
+
return (variables) =>
|
|
59
|
+
runAnsible(
|
|
60
|
+
run,
|
|
61
|
+
join(directory, 'agents.yml'),
|
|
62
|
+
{
|
|
63
|
+
cwd: directory,
|
|
64
|
+
input: JSON.stringify(variables),
|
|
65
|
+
// Inside the two minutes the sync gives `agents apply`.
|
|
66
|
+
timeoutMs: 90_000,
|
|
67
|
+
...(options.check ? { check: true } : {}),
|
|
68
|
+
},
|
|
69
|
+
options.scratch
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
22
73
|
export async function plan(context: Context) {
|
|
23
74
|
const { root, selection } = context;
|
|
24
75
|
const directory = join(
|
|
@@ -46,7 +97,16 @@ export async function plan(context: Context) {
|
|
|
46
97
|
return { directory, receiptPath, previous, files, fingerprint };
|
|
47
98
|
}
|
|
48
99
|
|
|
49
|
-
|
|
100
|
+
/** Applies one profile. A file someone edited is refused unless `adopt`;
|
|
101
|
+
* then the agents play writes the profile, and what it wrote is measured
|
|
102
|
+
* against the specification before this reports success. With nothing to
|
|
103
|
+
* change, the play is not run.
|
|
104
|
+
*/
|
|
105
|
+
export async function convergeAgents(
|
|
106
|
+
context: Context,
|
|
107
|
+
adopt = false,
|
|
108
|
+
play: ProfilePlay = agentsPlay()
|
|
109
|
+
) {
|
|
50
110
|
await safeParents(context.root);
|
|
51
111
|
await mkdir(context.root, { recursive: true, mode: 0o700 });
|
|
52
112
|
const lock = join(context.root, '.apply-lock');
|
|
@@ -63,23 +123,25 @@ export async function convergeAgents(context: Context, adopt = false) {
|
|
|
63
123
|
throw new AgentConfigurationError(
|
|
64
124
|
'Unmanaged or modified configuration. Review locally, then use --adopt to back up and replace only this profile.'
|
|
65
125
|
);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
126
|
+
const { receipt, encoded } = receiptFor(result, context.revision);
|
|
127
|
+
const changing = result.files.filter((file) => file.state !== 'unchanged');
|
|
128
|
+
if (changing.length || (await readOwned(result.receiptPath)) !== encoded) {
|
|
129
|
+
const applied = await play(
|
|
130
|
+
profileVariables(context, receipt, {
|
|
131
|
+
backups: changing
|
|
132
|
+
.filter((file) => file.state !== 'create')
|
|
133
|
+
.map((file) => ({
|
|
134
|
+
source: file.path,
|
|
135
|
+
dest: join(context.root, 'backups', randomUUID()),
|
|
136
|
+
})),
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
if (!applied.ok)
|
|
140
|
+
throw new AgentConfigurationError(
|
|
141
|
+
'The agents play (ansible/agents/agents.yml) failed for this profile; no receipt was advanced and its output is not reported. Inspect the profile with agents preview, then apply again.'
|
|
142
|
+
);
|
|
143
|
+
await measure(context, encoded);
|
|
71
144
|
}
|
|
72
|
-
const receipt: Receipt = {
|
|
73
|
-
version: 1,
|
|
74
|
-
revision: context.revision,
|
|
75
|
-
fingerprint: result.fingerprint,
|
|
76
|
-
files: Object.fromEntries(
|
|
77
|
-
result.files.map((file) => [file.name, digest(file.content)])
|
|
78
|
-
),
|
|
79
|
-
};
|
|
80
|
-
const encoded = JSON.stringify(receipt, null, 2) + '\n';
|
|
81
|
-
if ((await readOwned(result.receiptPath)) !== encoded)
|
|
82
|
-
await atomicWrite(result.receiptPath, encoded);
|
|
83
145
|
return {
|
|
84
146
|
...publicPlan(result, context.revision),
|
|
85
147
|
appliedRevision: context.revision,
|
|
@@ -90,6 +152,60 @@ export async function convergeAgents(context: Context, adopt = false) {
|
|
|
90
152
|
}
|
|
91
153
|
}
|
|
92
154
|
|
|
155
|
+
/** The receipt an apply of this plan records, and its exact text. */
|
|
156
|
+
export function receiptFor(
|
|
157
|
+
result: Awaited<ReturnType<typeof plan>>,
|
|
158
|
+
revision: string
|
|
159
|
+
): { receipt: Receipt; encoded: string } {
|
|
160
|
+
const receipt: Receipt = {
|
|
161
|
+
version: 1,
|
|
162
|
+
revision,
|
|
163
|
+
fingerprint: result.fingerprint,
|
|
164
|
+
files: Object.fromEntries(
|
|
165
|
+
result.files.map((file) => [file.name, digest(file.content)])
|
|
166
|
+
),
|
|
167
|
+
};
|
|
168
|
+
return { receipt, encoded: JSON.stringify(receipt, null, 2) + '\n' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function profileVariables(
|
|
172
|
+
context: Context,
|
|
173
|
+
receipt: Receipt,
|
|
174
|
+
options: { backups: { source: string; dest: string }[] } = { backups: [] }
|
|
175
|
+
): ProfileVariables {
|
|
176
|
+
const { selection } = context;
|
|
177
|
+
return {
|
|
178
|
+
agent_root: context.root,
|
|
179
|
+
agent_node: selection.node,
|
|
180
|
+
agent_tool: selection.tool,
|
|
181
|
+
agent_profile: selection.profile,
|
|
182
|
+
agent_model: selection.model,
|
|
183
|
+
agent_proxy_endpoint:
|
|
184
|
+
selection.profile === 'proxy' ? registry.proxy.endpoint : '',
|
|
185
|
+
agent_proxy_models: Object.values(registry.proxy.routes).map(
|
|
186
|
+
(route) => route.model
|
|
187
|
+
),
|
|
188
|
+
agent_files: receipt.files,
|
|
189
|
+
agent_revision: receipt.revision,
|
|
190
|
+
agent_fingerprint: receipt.fingerprint,
|
|
191
|
+
agent_backups: options.backups,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** After the play: every file is what was specified, byte for byte, and the
|
|
196
|
+
* receipt is the one this apply decided on.
|
|
197
|
+
*/
|
|
198
|
+
async function measure(context: Context, encoded: string): Promise<void> {
|
|
199
|
+
const after = await plan(context);
|
|
200
|
+
if (
|
|
201
|
+
after.files.some((file) => file.state !== 'unchanged') ||
|
|
202
|
+
(await readOwned(after.receiptPath)) !== encoded
|
|
203
|
+
)
|
|
204
|
+
throw new AgentConfigurationError(
|
|
205
|
+
'After the agents play, the profile on disk is not what dev-fleet specifies. Inspect it with agents preview before applying again.'
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
93
209
|
export function publicPlan(
|
|
94
210
|
result: Awaited<ReturnType<typeof plan>>,
|
|
95
211
|
revision: string
|
package/src/agents/files.ts
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
import { AgentConfigurationError } from './errors';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
mkdir,
|
|
5
|
-
readFile,
|
|
6
|
-
rename,
|
|
7
|
-
rm,
|
|
8
|
-
writeFile,
|
|
9
|
-
copyFile,
|
|
10
|
-
chmod,
|
|
11
|
-
} from 'node:fs/promises';
|
|
12
|
-
import { dirname, join, parse } from 'node:path';
|
|
13
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, parse } from 'node:path';
|
|
14
4
|
import { digest } from './registry';
|
|
15
5
|
|
|
16
6
|
export async function safeParents(path: string): Promise<void> {
|
|
@@ -42,26 +32,6 @@ export async function readOwned(path: string): Promise<string | null> {
|
|
|
42
32
|
}
|
|
43
33
|
}
|
|
44
34
|
|
|
45
|
-
export async function atomicWrite(path: string, value: string): Promise<void> {
|
|
46
|
-
await safeParents(path);
|
|
47
|
-
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
48
|
-
const temp = `${path}.${randomUUID()}.tmp`;
|
|
49
|
-
try {
|
|
50
|
-
await writeFile(temp, value, { flag: 'wx', mode: 0o600 });
|
|
51
|
-
await rename(temp, path);
|
|
52
|
-
} finally {
|
|
53
|
-
await rm(temp, { force: true });
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export async function backup(path: string, root: string): Promise<void> {
|
|
58
|
-
const dir = join(root, 'backups');
|
|
59
|
-
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
60
|
-
const target = join(dir, randomUUID());
|
|
61
|
-
await copyFile(path, target);
|
|
62
|
-
await chmod(target, 0o600);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
35
|
export function fileState(
|
|
66
36
|
current: string | null,
|
|
67
37
|
desired: string,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
2
|
+
import { homedir, tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { currentPlatform } from '../platform';
|
|
5
|
+
import { agentRoot, render } from './render';
|
|
6
|
+
import { select } from './registry';
|
|
4
7
|
import type { Check, Runner } from '../types';
|
|
5
8
|
import { pinnedPath } from './toolchain';
|
|
6
9
|
import { registry } from './registry';
|
|
@@ -110,9 +113,13 @@ export async function verifyNativeAgents(
|
|
|
110
113
|
* account change or key rotation re-verifies at once.
|
|
111
114
|
*/
|
|
112
115
|
accounts?: AccountContext;
|
|
116
|
+
/** Where the agent profiles are rendered; this user's by default. */
|
|
117
|
+
profiles?: string;
|
|
113
118
|
} = {}
|
|
114
119
|
): Promise<Check[]> {
|
|
115
120
|
const { outOfQuota = new Set<string>(), cache = {} } = options;
|
|
121
|
+
const profiles =
|
|
122
|
+
options.profiles ?? agentRoot(homedir(), currentPlatform().name);
|
|
116
123
|
const pins = parsePins(await readFile(join(workspace, 'mise.toml'), 'utf8'));
|
|
117
124
|
const environment = options.t3Service
|
|
118
125
|
? await serviceLaunch(run)
|
|
@@ -136,7 +143,8 @@ export async function verifyNativeAgents(
|
|
|
136
143
|
registry,
|
|
137
144
|
await sourceKey(launcherSources(workspace, agent)),
|
|
138
145
|
options.accounts?.rosterVersion ?? '',
|
|
139
|
-
options.accounts?.keyVersion ?? ''
|
|
146
|
+
options.accounts?.keyVersion ?? '',
|
|
147
|
+
await configurationKey(profiles, node, agent.binary)
|
|
140
148
|
),
|
|
141
149
|
() =>
|
|
142
150
|
sessionCheck(agent, { node, workspace, cwd, launch }, run),
|
|
@@ -157,6 +165,7 @@ export async function verifyNativeAgents(
|
|
|
157
165
|
launch,
|
|
158
166
|
cache,
|
|
159
167
|
run,
|
|
168
|
+
profiles,
|
|
160
169
|
})
|
|
161
170
|
)
|
|
162
171
|
: [];
|
|
@@ -207,6 +216,7 @@ async function accountSession(
|
|
|
207
216
|
launch: Launch;
|
|
208
217
|
cache: VerifyCache;
|
|
209
218
|
run: Runner;
|
|
219
|
+
profiles: string;
|
|
210
220
|
}
|
|
211
221
|
): Promise<'ok' | 'cooling-down' | 'failed'> {
|
|
212
222
|
const spec = ACCOUNT_LAUNCH[instance.tool];
|
|
@@ -218,7 +228,8 @@ async function accountSession(
|
|
|
218
228
|
instance,
|
|
219
229
|
spec,
|
|
220
230
|
accounts.rosterVersion,
|
|
221
|
-
accounts.keyVersion
|
|
231
|
+
accounts.keyVersion,
|
|
232
|
+
await configurationKey(context.profiles, context.node, instance.tool)
|
|
222
233
|
),
|
|
223
234
|
async () => {
|
|
224
235
|
const agents = join(context.workspace, 'agents');
|
|
@@ -484,6 +495,26 @@ function sessionRemedy(agent: Agent): string {
|
|
|
484
495
|
}
|
|
485
496
|
|
|
486
497
|
/** The launcher and profile code a session depends on. */
|
|
498
|
+
/** Digest of the agent's proxy profile as it is on this machine: the files
|
|
499
|
+
* its session reads. A profile rendered again, edited or removed changes it,
|
|
500
|
+
* so the session is proven again at once. Empty for a tool with no profile.
|
|
501
|
+
*/
|
|
502
|
+
export async function configurationKey(
|
|
503
|
+
profiles: string,
|
|
504
|
+
node: string,
|
|
505
|
+
tool: string
|
|
506
|
+
): Promise<string> {
|
|
507
|
+
try {
|
|
508
|
+
const selection = select(node, tool, 'proxy');
|
|
509
|
+
const directory = join(profiles, node, tool, 'proxy');
|
|
510
|
+
return await sourceKey(
|
|
511
|
+
Object.keys(render(selection)).map((name) => join(directory, name))
|
|
512
|
+
);
|
|
513
|
+
} catch {
|
|
514
|
+
return '';
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
487
518
|
function launcherSources(workspace: string, agent: Agent): string[] {
|
|
488
519
|
const agents = join(workspace, 'agents');
|
|
489
520
|
const lib = join(workspace, 'src/agents');
|
package/src/agents/secrets.ts
CHANGED
|
@@ -1,13 +1,48 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
1
4
|
import { AgentConfigurationError } from './errors';
|
|
2
5
|
import { secret } from '../fleet-config';
|
|
3
6
|
|
|
7
|
+
/** Where a node keeps the last proxy client key its secrets provider gave it,
|
|
8
|
+
* readable by its user alone. Disposable: the next successful read rewrites it.
|
|
9
|
+
*/
|
|
10
|
+
export const lastProxyKeyPath = (home = homedir()): string =>
|
|
11
|
+
join(home, '.cache/dev-fleet/proxy-client-key');
|
|
12
|
+
|
|
4
13
|
/** The key every node presents to the CLI proxy, from this fleet's secrets
|
|
5
|
-
* provider with this node's own access.
|
|
14
|
+
* provider with this node's own access. The provider stays the source: a key
|
|
15
|
+
* rotated there is used at the next launch. When the provider cannot answer
|
|
16
|
+
* (Vault unreachable, or this node's session expired) the last key it gave is
|
|
17
|
+
* used instead, so an outage of the secrets provider never takes every agent
|
|
18
|
+
* down with it; the node's own Vault checks still fail until it is repaired.
|
|
6
19
|
*/
|
|
7
|
-
export async function proxyKey(
|
|
8
|
-
|
|
9
|
-
|
|
20
|
+
export async function proxyKey(
|
|
21
|
+
options: {
|
|
22
|
+
read?: () => Promise<string | null>;
|
|
23
|
+
path?: string;
|
|
24
|
+
} = {}
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
const read = options.read ?? (() => secret('proxy-client-key'));
|
|
27
|
+
const path = options.path ?? lastProxyKeyPath();
|
|
28
|
+
const value = await read().catch(() => null);
|
|
29
|
+
if (value) {
|
|
30
|
+
await remember(path, value).catch(() => undefined);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
const last = (await readFile(path, 'utf8').catch(() => '')).trim();
|
|
34
|
+
if (last) return last;
|
|
10
35
|
throw new AgentConfigurationError(
|
|
11
36
|
"Required secret unavailable: proxy-client-key. Restore this node's access to the fleet's secrets provider (see `secrets` in the fleet configuration)."
|
|
12
37
|
);
|
|
13
38
|
}
|
|
39
|
+
|
|
40
|
+
async function remember(path: string, value: string): Promise<void> {
|
|
41
|
+
if ((await readFile(path, 'utf8').catch(() => '')).trim() === value) return;
|
|
42
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
43
|
+
await chmod(dirname(path), 0o700);
|
|
44
|
+
// Written aside and renamed, so a launch never reads half a key.
|
|
45
|
+
const staged = `${path}.${process.pid}`;
|
|
46
|
+
await writeFile(staged, `${value}\n`, { mode: 0o600 });
|
|
47
|
+
await rename(staged, path);
|
|
48
|
+
}
|
package/src/check-owners.ts
CHANGED
|
@@ -19,6 +19,9 @@ const DECLARATION =
|
|
|
19
19
|
const HARDWARE =
|
|
20
20
|
'It is a property of the hardware, the firmware or the operating system release, which no command changes.';
|
|
21
21
|
|
|
22
|
+
const PARKING =
|
|
23
|
+
'Whether a worker runs is the operator’s choice: park it with fleet-node-park or resume it with fleet-node-resume.';
|
|
24
|
+
|
|
22
25
|
export const CHECK_OWNERS: CheckOwner[] = [
|
|
23
26
|
// Toolchain and workstation: automatic.
|
|
24
27
|
{ pattern: 'tool:*', repair: 'workspace' },
|
|
@@ -68,12 +71,46 @@ export const CHECK_OWNERS: CheckOwner[] = [
|
|
|
68
71
|
'Re-converge the worker with fleet-sync: it applies the whole worker policy at the approved revision.',
|
|
69
72
|
},
|
|
70
73
|
{ pattern: 'worker:power-loss-recovery', manual: HARDWARE },
|
|
74
|
+
{
|
|
75
|
+
pattern: 'worker:account',
|
|
76
|
+
manual:
|
|
77
|
+
'fleet-sync creates the declared account over a root login; when it cannot, the account is created by the operator on the machine, or declared in fleet.json.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
pattern: 'worker:reboot',
|
|
81
|
+
manual:
|
|
82
|
+
'A reboot ends running agent turns, so it waits for them: rerun fleet-sync --maintenance-reboot once they end, or with --force.',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
pattern: 'tailscale:enrolment-key',
|
|
86
|
+
manual:
|
|
87
|
+
'The tailnet-admin credential and the tag it may use are the operator’s, kept in the secrets provider and the control plane.',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
pattern: 'tailscale:key-expiry-policy',
|
|
91
|
+
manual:
|
|
92
|
+
'The tailnet-admin credential and the tag it may use are the operator’s, kept in the secrets provider and the control plane.',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
pattern: 'tailscale:device',
|
|
96
|
+
manual:
|
|
97
|
+
'Removing a device needs the tailnet-admin credential and a declared control plane, both the operator’s; without them a person removes it in the admin console.',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
pattern: 'tailscale:leftover',
|
|
101
|
+
manual:
|
|
102
|
+
'Whether a device tagged as a worker belongs to the fleet is the operator’s choice: declare it, or remove it from the tailnet.',
|
|
103
|
+
},
|
|
104
|
+
// Whether a worker runs is the operator's choice (fleet-node-park).
|
|
105
|
+
{ pattern: 'worker:idle', manual: PARKING },
|
|
106
|
+
{ pattern: 'worker:park*', manual: PARKING },
|
|
107
|
+
{ pattern: 'worker:auto-park', manual: PARKING },
|
|
71
108
|
{ pattern: 'worker:github-principal', manual: IDENTITY },
|
|
72
109
|
{ pattern: 'worker:vault-renewal', manual: IDENTITY },
|
|
73
110
|
{
|
|
74
111
|
pattern: 'manager:*',
|
|
75
112
|
manual:
|
|
76
|
-
'The manager converges itself in fleet-sync;
|
|
113
|
+
'The manager converges itself in fleet-sync; what needs an administrator is granted once with ansible-playbook -e user=$(id -un) -K ansible/macos/manager-privilege.yml.',
|
|
77
114
|
},
|
|
78
115
|
{ pattern: 'manager:identity', manual: DECLARATION },
|
|
79
116
|
{
|
|
@@ -104,7 +141,7 @@ export const CHECK_OWNERS: CheckOwner[] = [
|
|
|
104
141
|
{
|
|
105
142
|
pattern: 't3:*',
|
|
106
143
|
manual:
|
|
107
|
-
'T3 pairing and its private route are approved by a person; fleet-sync applies the rest.',
|
|
144
|
+
'A worker’s first T3 pairing and its private route are approved by a person; a replaced worker is paired by fleet-node-replace or the next fleet-sync, and fleet-sync applies the rest.',
|
|
108
145
|
},
|
|
109
146
|
{ pattern: 'vscode:*', manual: IDENTITY },
|
|
110
147
|
{
|
|
@@ -130,6 +167,7 @@ export const CHECK_OWNERS: CheckOwner[] = [
|
|
|
130
167
|
{ pattern: 'github:rate-limit', manual: EVIDENCE },
|
|
131
168
|
{ pattern: 'github:workflow', manual: EVIDENCE },
|
|
132
169
|
{ pattern: 'fleet:*', manual: DECLARATION },
|
|
170
|
+
{ pattern: 'definition:*', manual: DECLARATION },
|
|
133
171
|
{ pattern: 'enrollment:*', manual: DECLARATION },
|
|
134
172
|
{ pattern: 'notify:*', manual: DECLARATION },
|
|
135
173
|
{
|
|
@@ -143,11 +181,33 @@ export const CHECK_OWNERS: CheckOwner[] = [
|
|
|
143
181
|
'The roster reaches every node with devfleet fleet-sync --only accounts, run from the manager.',
|
|
144
182
|
},
|
|
145
183
|
{ pattern: 'accounts:native-login', manual: IDENTITY },
|
|
184
|
+
{ pattern: 'accounts:verification', manual: EVIDENCE },
|
|
146
185
|
{ pattern: 'configuration:*', manual: EVIDENCE },
|
|
147
186
|
{ pattern: 'source:*', manual: EVIDENCE },
|
|
148
187
|
{ pattern: 'bootstrap:*', manual: EVIDENCE },
|
|
149
188
|
{ pattern: 'rollout:*', manual: EVIDENCE },
|
|
150
189
|
{ pattern: 'repair:*', manual: EVIDENCE },
|
|
190
|
+
// A roster computer's checkout compared with the manager's.
|
|
191
|
+
{
|
|
192
|
+
pattern: 'revision',
|
|
193
|
+
manual:
|
|
194
|
+
'The checkout of that computer differs from the manager’s: reconcile its branch, or converge it with fleet-sync; dev-fleet never resets a working tree.',
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
pattern: 'sync:*',
|
|
198
|
+
manual:
|
|
199
|
+
'The checkout of that computer differs from the manager’s: reconcile its lockfiles and pins, or converge it with fleet-sync; dev-fleet never resets a working tree.',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
pattern: 'restore:*',
|
|
203
|
+
manual:
|
|
204
|
+
'fleet-restore rebuilds a replacement manager from the bucket; what it needs first (the project’s fleet.json, the bucket’s credentials, Git, Docker Desktop, a tailnet sign-in) is the operator’s: follow the remedy, then run fleet-restore again.',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
pattern: 'watch:*',
|
|
208
|
+
manual:
|
|
209
|
+
'The heartbeat is kept in the bucket fleet.json names: name it, or restore access to it, then run fleet-watch again.',
|
|
210
|
+
},
|
|
151
211
|
];
|
|
152
212
|
|
|
153
213
|
const matches = (pattern: string, id: string): boolean =>
|