@crvouga/dev-fleet 0.0.0-stage → 0.1.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/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
- package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
- package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
- package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
- package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
- package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/agents/antigravity-proxy.ts +3 -0
- package/agents/audit.md +89 -0
- package/agents/claude-proxy +5 -0
- package/agents/claude-proxy.ts +3 -0
- package/agents/codex-proxy.ts +3 -0
- package/agents/grok-proxy.ts +3 -0
- package/agents/managed-proxy.ts +55 -0
- package/agents/opencode-proxy.ts +3 -0
- package/agents/registry.json +209 -0
- package/agents/registry.schema.json +149 -0
- package/agents/setup.md +128 -0
- package/agents/t3.json +40 -0
- package/ansible/macos/manager.yml +30 -0
- package/ansible/macos/policy.json +17 -0
- package/ansible/monitoring/README.md +154 -0
- package/ansible/monitoring/agent-darwin.yml +198 -0
- package/ansible/monitoring/agent.yml +134 -0
- package/ansible/monitoring/beszel-agent.service +42 -0
- package/ansible/monitoring/compose.yml +74 -0
- package/ansible/monitoring/hub-darwin.yml +102 -0
- package/ansible/monitoring/lock.json +11 -0
- package/ansible/net/remote-probe.sh +13 -0
- package/ansible/ubuntu/authorize-sudo.sh +51 -0
- package/ansible/ubuntu/baseline.yml +89 -0
- package/ansible/ubuntu/disk-guard.py +104 -0
- package/ansible/ubuntu/policy.json +43 -0
- package/ansible/ubuntu/recovery.yml +15 -0
- package/ansible/ubuntu/t3/policy.json +7 -0
- package/ansible/ubuntu/t3-desktop.yml +220 -0
- package/ansible/ubuntu/t3-health.sh +33 -0
- package/ansible/ubuntu/t3-policy.yml +76 -0
- package/ansible/ubuntu/t3-route.yml +28 -0
- package/ansible/ubuntu/tailscale.yml +75 -0
- package/ansible/ubuntu/tasks/credentials.yml +47 -0
- package/ansible/ubuntu/tasks/docker.yml +31 -0
- package/ansible/ubuntu/tasks/packages.yml +56 -0
- package/ansible/ubuntu/tasks/power.yml +51 -0
- package/ansible/ubuntu/tasks/recovery.yml +39 -0
- package/ansible/ubuntu/tasks/resources.yml +252 -0
- package/ansible/ubuntu/tasks/services.yml +31 -0
- package/ansible/ubuntu/tasks/ssh.yml +71 -0
- package/ansible/ubuntu/tasks/user.yml +120 -0
- package/bin/devfleet.mjs +36 -0
- package/bootstrap/bootstrap.ps1 +50 -0
- package/bootstrap/bootstrap.sh +73 -0
- package/bootstrap/devfleet +7 -0
- package/bootstrap/docker-desktop.sh +33 -0
- package/bootstrap/doctor.ts +189 -0
- package/bootstrap/fleet-manager-run.sh +76 -0
- package/bootstrap/fleet-remote-bootstrap.sh +185 -0
- package/bootstrap/global-config.ts +113 -0
- package/bootstrap/install-launcher.sh +7 -0
- package/bootstrap/native-aws.ts +86 -0
- package/bootstrap/native-t3.ts +154 -0
- package/bootstrap/npm.ts +350 -0
- package/bootstrap/setup-status.ts +51 -0
- package/bootstrap/setup.ts +88 -0
- package/bootstrap/shell-path.ts +105 -0
- package/bootstrap/verify-t3-archive.py +49 -0
- package/bootstrap/worker-auth.ts +65 -0
- package/bootstrap/worker-github.ts +47 -0
- package/bootstrap/worker-tools.ts +24 -0
- package/cliproxy/AGENTS.md +11 -0
- package/cliproxy/README.md +269 -0
- package/cliproxy/clients/bootstrap.py +143 -0
- package/cliproxy/clients/launcher.py +57 -0
- package/cliproxy/compose.yaml +90 -0
- package/cliproxy/config.template.yaml +70 -0
- package/cliproxy/docs/cloud-adoption.md +16 -0
- package/cliproxy/docs/deployment.png +0 -0
- package/cliproxy/docs/deployment.svg +109 -0
- package/cliproxy/docs/health-workflow.example.yml +185 -0
- package/cliproxy/docs/migration.md +131 -0
- package/cliproxy/docs/model-health-report.schema.json +364 -0
- package/cliproxy/docs/model-health.md +393 -0
- package/cliproxy/image.lock.json +5 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
- package/cliproxy/models.json +1618 -0
- package/cliproxy/requirements.lock +78 -0
- package/cliproxy/scripts/accounts.py +38 -0
- package/cliproxy/scripts/audit.py +123 -0
- package/cliproxy/scripts/backup +234 -0
- package/cliproxy/scripts/check.py +34 -0
- package/cliproxy/scripts/checkpoint.py +226 -0
- package/cliproxy/scripts/cli.py +64 -0
- package/cliproxy/scripts/cliproxy_host.py +257 -0
- package/cliproxy/scripts/cloud_state.py +150 -0
- package/cliproxy/scripts/container-health +6 -0
- package/cliproxy/scripts/docker +24 -0
- package/cliproxy/scripts/external-health.py +79 -0
- package/cliproxy/scripts/fleet_config.py +43 -0
- package/cliproxy/scripts/fleet_sync.py +274 -0
- package/cliproxy/scripts/initialize +84 -0
- package/cliproxy/scripts/install-dependencies +48 -0
- package/cliproxy/scripts/install.py +406 -0
- package/cliproxy/scripts/model_health.py +16 -0
- package/cliproxy/scripts/modelhealth/__init__.py +5 -0
- package/cliproxy/scripts/modelhealth/checks.py +429 -0
- package/cliproxy/scripts/modelhealth/cli.py +117 -0
- package/cliproxy/scripts/modelhealth/config.py +186 -0
- package/cliproxy/scripts/modelhealth/emulator.py +536 -0
- package/cliproxy/scripts/modelhealth/expect.py +88 -0
- package/cliproxy/scripts/modelhealth/findings.py +183 -0
- package/cliproxy/scripts/modelhealth/protocol.py +330 -0
- package/cliproxy/scripts/modelhealth/redact.py +28 -0
- package/cliproxy/scripts/modelhealth/report.py +150 -0
- package/cliproxy/scripts/modelhealth/runner.py +390 -0
- package/cliproxy/scripts/modelhealth/scenario.py +131 -0
- package/cliproxy/scripts/modelhealth/schema.py +63 -0
- package/cliproxy/scripts/modelhealth/simulate.py +111 -0
- package/cliproxy/scripts/modelhealth/transport.py +213 -0
- package/cliproxy/scripts/monitor +68 -0
- package/cliproxy/scripts/ops +527 -0
- package/cliproxy/scripts/public-endpoint +127 -0
- package/cliproxy/scripts/recover_node.py +177 -0
- package/cliproxy/scripts/restore-verify +60 -0
- package/cliproxy/scripts/retention.py +144 -0
- package/cliproxy/scripts/service-start +7 -0
- package/cliproxy/scripts/validate-config +56 -0
- package/cliproxy/scripts/vault-access.py +57 -0
- package/cliproxy/scripts/vault-backup-login +55 -0
- package/cliproxy/scripts/vault-recovery +56 -0
- package/cliproxy/scripts/vault-renew +30 -0
- package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
- package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
- package/cliproxy/systemd/cliproxy-backup.service +13 -0
- package/cliproxy/systemd/cliproxy-backup.timer +8 -0
- package/cliproxy/systemd/cliproxy-fleet.service +17 -0
- package/cliproxy/systemd/cliproxy-gc.service +13 -0
- package/cliproxy/systemd/cliproxy-gc.timer +8 -0
- package/cliproxy/systemd/cliproxy-monitor.service +8 -0
- package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
- package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
- package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
- package/cliproxy/vendor/LICENSE +22 -0
- package/cliproxy/vendor/config.upstream.yaml +1349 -0
- package/examples/fleet.json +95 -0
- package/examples/roster.json +13 -0
- package/mise.lock +1492 -0
- package/mise.toml +99 -0
- package/package.json +65 -4
- package/schema/fleet.schema.json +364 -0
- package/schema/roster.schema.json +77 -0
- package/src/agents/account-relay.ts +111 -0
- package/src/agents/accounts.ts +85 -0
- package/src/agents/audit.ts +77 -0
- package/src/agents/cli.ts +13 -0
- package/src/agents/command.ts +204 -0
- package/src/agents/converge.ts +123 -0
- package/src/agents/doctor.ts +107 -0
- package/src/agents/errors.ts +2 -0
- package/src/agents/files.ts +73 -0
- package/src/agents/fleet-verify.ts +156 -0
- package/src/agents/launch.ts +290 -0
- package/src/agents/native-verify.ts +226 -0
- package/src/agents/provider-errors.ts +50 -0
- package/src/agents/registry.ts +55 -0
- package/src/agents/render.ts +110 -0
- package/src/agents/round-trip.ts +192 -0
- package/src/agents/secrets.ts +13 -0
- package/src/agents/toolchain.ts +34 -0
- package/src/agents/verify-cache.ts +103 -0
- package/src/agents/verify.ts +127 -0
- package/src/auth-checks.ts +163 -0
- package/src/cli/agent-guide.ts +95 -0
- package/src/cli/app.ts +115 -0
- package/src/cli/cli.ts +21 -0
- package/src/cli/command-index.ts +50 -0
- package/src/cli/contracts.ts +25 -0
- package/src/cli/fleet-reference.ts +17 -0
- package/src/cli/fleet-sync-checkout.ts +91 -0
- package/src/cli/fleet-sync-enrollment.ts +144 -0
- package/src/cli/fleet-sync-forward.ts +134 -0
- package/src/cli/fleet-sync-manager.ts +193 -0
- package/src/cli/fleet-sync-monitoring.ts +185 -0
- package/src/cli/fleet-sync-output.ts +314 -0
- package/src/cli/fleet-sync-privilege.ts +27 -0
- package/src/cli/fleet-sync-progress.ts +109 -0
- package/src/cli/fleet-sync-remote.ts +236 -0
- package/src/cli/fleet-sync-report.ts +26 -0
- package/src/cli/fleet-sync-schedule.ts +138 -0
- package/src/cli/fleet-sync-vault.ts +166 -0
- package/src/cli/fleet-sync.ts +309 -0
- package/src/cli/fleet.ts +163 -0
- package/src/cli/managed-health.ts +219 -0
- package/src/cli/node-deployment.ts +34 -0
- package/src/cli/node-sync.ts +114 -0
- package/src/cli/node-t3.ts +31 -0
- package/src/cli/options.ts +141 -0
- package/src/cli/probe-source.ts +7 -0
- package/src/cli/program.ts +169 -0
- package/src/cli/register-fleet.ts +246 -0
- package/src/cli/registry.ts +158 -0
- package/src/cli/repair-apply.ts +67 -0
- package/src/cli/repair-guard.ts +11 -0
- package/src/cli/repair-inspect.ts +150 -0
- package/src/cli/repair-script.ts +76 -0
- package/src/cli/repair.ts +209 -0
- package/src/cli/roster.ts +89 -0
- package/src/cli/search.ts +35 -0
- package/src/cli/settings.ts +64 -0
- package/src/cli/source-operations.ts +78 -0
- package/src/cli/t3/command.ts +90 -0
- package/src/cli/t3/register.ts +55 -0
- package/src/cli/tailnet.ts +127 -0
- package/src/cli/ui-actions.ts +133 -0
- package/src/cli/ui-controller.ts +113 -0
- package/src/cli/ui-data.ts +106 -0
- package/src/cli/ui-execute.ts +163 -0
- package/src/cli/ui-repairs.ts +34 -0
- package/src/cli/ui-reports.ts +211 -0
- package/src/cli/ui-targets.ts +100 -0
- package/src/cli/ui-terminal.ts +109 -0
- package/src/cli/ui-types.ts +49 -0
- package/src/cli/ui-wizard.ts +101 -0
- package/src/cli/ui.ts +9 -0
- package/src/cli/worker-health-output.ts +37 -0
- package/src/config.ts +186 -0
- package/src/configure.ts +255 -0
- package/src/definition.ts +260 -0
- package/src/diagnostics.ts +92 -0
- package/src/doctor-check.ts +10 -0
- package/src/errors.ts +1 -0
- package/src/fingerprint.ts +120 -0
- package/src/fleet-config.ts +88 -0
- package/src/index.ts +6 -0
- package/src/input-hashes.ts +73 -0
- package/src/machine/local-source.ts +52 -0
- package/src/machine/lock-error.ts +2 -0
- package/src/machine/platform.ts +44 -0
- package/src/machine/source.ts +68 -0
- package/src/machine/state.ts +185 -0
- package/src/machine-health.ts +95 -0
- package/src/managed-source.ts +47 -0
- package/src/manager-failure.ts +48 -0
- package/src/manager-sync.ts +400 -0
- package/src/manager.ts +62 -0
- package/src/monitoring/apply.ts +171 -0
- package/src/monitoring/beszel.ts +191 -0
- package/src/monitoring/enroll.ts +110 -0
- package/src/monitoring/hub-access.ts +113 -0
- package/src/monitoring/hub-viewer.ts +142 -0
- package/src/monitoring/hub.ts +93 -0
- package/src/monitoring/manager-agent.ts +73 -0
- package/src/monitoring/manager-hub.ts +232 -0
- package/src/monitoring/remote.ts +65 -0
- package/src/network-definition.ts +83 -0
- package/src/node-converge-linux.ts +372 -0
- package/src/node-converge-shared.ts +83 -0
- package/src/node-converge.ts +40 -0
- package/src/onboard.ts +35 -0
- package/src/platform/ansible-receipt.ts +128 -0
- package/src/platform/ansible.ts +62 -0
- package/src/platform/darwin.ts +203 -0
- package/src/platform/detect.ts +11 -0
- package/src/platform/fallback.ts +110 -0
- package/src/platform/index.ts +38 -0
- package/src/platform/launchd.ts +98 -0
- package/src/platform/linux.ts +139 -0
- package/src/platform/ps.ts +45 -0
- package/src/platform/resource-verify.ts +128 -0
- package/src/platform/systemd-user.ts +95 -0
- package/src/platform/t3-policy.ts +137 -0
- package/src/platform/t3-route.ts +68 -0
- package/src/platform/t3-service.ts +75 -0
- package/src/platform/types.ts +123 -0
- package/src/platform/ubuntu-audit.ts +63 -0
- package/src/platform/ubuntu-verify.ts +105 -0
- package/src/platform/windows.ts +148 -0
- package/src/power-recovery.ts +55 -0
- package/src/probe.ts +51 -0
- package/src/protocol.ts +119 -0
- package/src/proxy-converge.ts +85 -0
- package/src/remote-bootstrap.ts +212 -0
- package/src/remote-entry.ts +89 -0
- package/src/remote-lock.ts +12 -0
- package/src/repair-catalog.ts +206 -0
- package/src/repair-plan.ts +78 -0
- package/src/repair-runner.ts +84 -0
- package/src/report.ts +71 -0
- package/src/runner.ts +104 -0
- package/src/secrets.ts +169 -0
- package/src/service-auth.ts +115 -0
- package/src/ssh.ts +135 -0
- package/src/sync.ts +104 -0
- package/src/t3/accounts.ts +81 -0
- package/src/t3/desired.ts +165 -0
- package/src/t3/files.ts +81 -0
- package/src/t3/names-check.ts +53 -0
- package/src/t3/names.ts +155 -0
- package/src/t3/plan.ts +82 -0
- package/src/t3/providers.ts +74 -0
- package/src/t3/reconcile.ts +123 -0
- package/src/t3-endpoint.ts +48 -0
- package/src/t3-linked-check.ts +101 -0
- package/src/tailnet-health.ts +231 -0
- package/src/tailnet-matching.ts +37 -0
- package/src/tailscale.ts +141 -0
- package/src/test-fixtures.ts +36 -0
- package/src/toolchain-checks.ts +83 -0
- package/src/topology.ts +24 -0
- package/src/transport.ts +122 -0
- package/src/types.ts +95 -0
- package/src/vault-config.ts +38 -0
- package/src/worker-access-checks.ts +115 -0
- package/src/worker-capacity.ts +34 -0
- package/src/worker-host-checks.ts +176 -0
- package/src/worker-policy.ts +64 -0
- package/src/worker-readiness.ts +64 -0
- package/src/workstation-checks.ts +29 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import type { Capability, FleetConfig, FleetNode } from './types';
|
|
3
|
+
import { FleetUsageError } from './errors';
|
|
4
|
+
|
|
5
|
+
export const CAPABILITIES: Capability[] = [
|
|
6
|
+
'github',
|
|
7
|
+
'vault',
|
|
8
|
+
't3',
|
|
9
|
+
'vscode',
|
|
10
|
+
'toolchain',
|
|
11
|
+
'codex',
|
|
12
|
+
'claude',
|
|
13
|
+
'services',
|
|
14
|
+
'workstation',
|
|
15
|
+
'worker',
|
|
16
|
+
];
|
|
17
|
+
export const DEFAULT_CONFIG = '.fleet.local.json';
|
|
18
|
+
|
|
19
|
+
export function record(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fields(value: Record<string, unknown>, allowed: string[]): void {
|
|
24
|
+
if (Object.keys(value).some((key) => !allowed.includes(key))) {
|
|
25
|
+
throw new FleetUsageError(
|
|
26
|
+
'Unknown configuration field. Run fleet-nodes --schema.'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function absolutePath(value: unknown): value is string {
|
|
32
|
+
return (
|
|
33
|
+
typeof value === 'string' &&
|
|
34
|
+
value.startsWith('/') &&
|
|
35
|
+
value.length <= 4096 &&
|
|
36
|
+
!/[\x00-\x1f\x7f]/.test(value)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseNode(value: unknown): FleetNode {
|
|
41
|
+
if (!record(value)) throw new FleetUsageError('Each node must be an object.');
|
|
42
|
+
fields(value, [
|
|
43
|
+
'id',
|
|
44
|
+
'ssh',
|
|
45
|
+
'workspace',
|
|
46
|
+
'runtime',
|
|
47
|
+
'required',
|
|
48
|
+
'tailscaleId',
|
|
49
|
+
't3EnvironmentId',
|
|
50
|
+
'bootstrapViaSsh',
|
|
51
|
+
]);
|
|
52
|
+
if (
|
|
53
|
+
value['t3EnvironmentId'] !== undefined &&
|
|
54
|
+
(typeof value['t3EnvironmentId'] !== 'string' ||
|
|
55
|
+
!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(
|
|
56
|
+
value['t3EnvironmentId']
|
|
57
|
+
))
|
|
58
|
+
)
|
|
59
|
+
throw new FleetUsageError('Invalid enrolled T3 environment identity.');
|
|
60
|
+
const names = nodeNames(value);
|
|
61
|
+
if (!absolutePath(value['workspace']))
|
|
62
|
+
throw new FleetUsageError('workspace must be an absolute POSIX path.');
|
|
63
|
+
if (value['runtime'] !== undefined && !absolutePath(value['runtime'])) {
|
|
64
|
+
throw new FleetUsageError(
|
|
65
|
+
'runtime must be an absolute POSIX path to Node or Bun.'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...names,
|
|
70
|
+
...enrollmentOptions(value),
|
|
71
|
+
...(typeof value['t3EnvironmentId'] === 'string'
|
|
72
|
+
? { t3EnvironmentId: value['t3EnvironmentId'] }
|
|
73
|
+
: {}),
|
|
74
|
+
workspace: value['workspace'],
|
|
75
|
+
...(typeof value['runtime'] === 'string'
|
|
76
|
+
? { runtime: value['runtime'] }
|
|
77
|
+
: {}),
|
|
78
|
+
required: requiredCapabilities(value['required']),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function enrollmentOptions(
|
|
83
|
+
value: Record<string, unknown>
|
|
84
|
+
): Pick<FleetNode, 'tailscaleId' | 'bootstrapViaSsh'> {
|
|
85
|
+
if (
|
|
86
|
+
value['bootstrapViaSsh'] !== undefined &&
|
|
87
|
+
typeof value['bootstrapViaSsh'] !== 'boolean'
|
|
88
|
+
)
|
|
89
|
+
throw new FleetUsageError(
|
|
90
|
+
'bootstrapViaSsh must explicitly authorize an existing trusted SSH bootstrap route.'
|
|
91
|
+
);
|
|
92
|
+
if (
|
|
93
|
+
value['tailscaleId'] !== undefined &&
|
|
94
|
+
(typeof value['tailscaleId'] !== 'string' ||
|
|
95
|
+
!/^[a-zA-Z0-9:_-]{1,128}$/.test(value['tailscaleId']))
|
|
96
|
+
)
|
|
97
|
+
throw new FleetUsageError(
|
|
98
|
+
'tailscaleId must be the explicitly enrolled stable Tailscale device ID.'
|
|
99
|
+
);
|
|
100
|
+
return {
|
|
101
|
+
...(value['bootstrapViaSsh'] === true ? { bootstrapViaSsh: true } : {}),
|
|
102
|
+
...(typeof value['tailscaleId'] === 'string'
|
|
103
|
+
? { tailscaleId: value['tailscaleId'] }
|
|
104
|
+
: {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function nodeNames(
|
|
109
|
+
value: Record<string, unknown>
|
|
110
|
+
): Pick<FleetNode, 'id' | 'ssh'> {
|
|
111
|
+
if (
|
|
112
|
+
typeof value['id'] !== 'string' ||
|
|
113
|
+
!/^[a-z][a-z0-9-]{0,62}$/.test(value['id'])
|
|
114
|
+
) {
|
|
115
|
+
throw new FleetUsageError(
|
|
116
|
+
'Node IDs must be lowercase names (1–63 characters).'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
typeof value['ssh'] !== 'string' ||
|
|
121
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/.test(value['ssh'])
|
|
122
|
+
) {
|
|
123
|
+
throw new FleetUsageError(
|
|
124
|
+
'ssh must be a plain SSH alias or hostname, not options or user@host.'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return { id: value['id'], ssh: value['ssh'] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function requiredCapabilities(value: unknown): Capability[] {
|
|
131
|
+
const required =
|
|
132
|
+
value === undefined ? ['github', 'vault', 't3', 'toolchain'] : value;
|
|
133
|
+
if (
|
|
134
|
+
!Array.isArray(required) ||
|
|
135
|
+
required.some((item) => !CAPABILITIES.includes(item as Capability))
|
|
136
|
+
) {
|
|
137
|
+
throw new FleetUsageError(
|
|
138
|
+
'required must contain only capability names from fleet-nodes --schema.'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (new Set(required).size !== required.length)
|
|
142
|
+
throw new FleetUsageError('required capabilities must be unique.');
|
|
143
|
+
return required as Capability[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function parseConfig(value: unknown): FleetConfig {
|
|
147
|
+
if (!record(value))
|
|
148
|
+
throw new FleetUsageError('Fleet configuration must be an object.');
|
|
149
|
+
fields(value, ['$schema', 'version', 'nodes']);
|
|
150
|
+
if (value['$schema'] !== undefined && typeof value['$schema'] !== 'string')
|
|
151
|
+
throw new FleetUsageError('$schema must be a string.');
|
|
152
|
+
if (value['version'] !== 1 || !Array.isArray(value['nodes'])) {
|
|
153
|
+
throw new FleetUsageError('Expected version 1 and a nodes array.');
|
|
154
|
+
}
|
|
155
|
+
if (value['nodes'].length < 1 || value['nodes'].length > 64) {
|
|
156
|
+
throw new FleetUsageError('Configure between 1 and 64 developer nodes.');
|
|
157
|
+
}
|
|
158
|
+
const nodes = value['nodes'].map(parseNode);
|
|
159
|
+
if (new Set(nodes.map((node) => node.id)).size !== nodes.length) {
|
|
160
|
+
throw new FleetUsageError('Node IDs must be unique.');
|
|
161
|
+
}
|
|
162
|
+
return { version: 1, nodes };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function loadConfig(path: string): Promise<FleetConfig> {
|
|
166
|
+
let value: unknown;
|
|
167
|
+
try {
|
|
168
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
169
|
+
} catch {
|
|
170
|
+
throw new FleetUsageError(
|
|
171
|
+
'Cannot read fleet config. Run devfleet fleet-node-add or use --config PATH.'
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return parseConfig(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function selectNodes(config: FleetConfig, ids: string[]): FleetNode[] {
|
|
178
|
+
if (ids.some((id) => !config.nodes.some((node) => node.id === id))) {
|
|
179
|
+
throw new FleetUsageError(
|
|
180
|
+
'Unknown node selection. Run fleet-nodes --json.'
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return ids.length === 0
|
|
184
|
+
? config.nodes
|
|
185
|
+
: config.nodes.filter((node) => ids.includes(node.id));
|
|
186
|
+
}
|
package/src/configure.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { mkdir, writeFile, rename, readFile } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { record } from './config';
|
|
5
|
+
import type { Check, Runner } from './types';
|
|
6
|
+
|
|
7
|
+
export type ConfigurationRequest = {
|
|
8
|
+
revision: string;
|
|
9
|
+
tool: string;
|
|
10
|
+
profile: string;
|
|
11
|
+
node: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function validConfiguration(
|
|
15
|
+
value: unknown
|
|
16
|
+
): value is ConfigurationRequest {
|
|
17
|
+
return (
|
|
18
|
+
record(value) &&
|
|
19
|
+
typeof value['revision'] === 'string' &&
|
|
20
|
+
/^[a-f0-9]{40}$/.test(value['revision']) &&
|
|
21
|
+
['codex', 'claude', 'opencode', 'gemini', 'grok', 'antigravity'].includes(
|
|
22
|
+
String(value['tool'])
|
|
23
|
+
) &&
|
|
24
|
+
['direct', 'proxy'].includes(String(value['profile'])) &&
|
|
25
|
+
typeof value['node'] === 'string' &&
|
|
26
|
+
/^[a-z0-9][a-z0-9-]{0,62}$/.test(value['node'])
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A pinned deployment worktree leaves the developer's branch and dirty files intact. */
|
|
31
|
+
export async function configureWorkspace(
|
|
32
|
+
root: string,
|
|
33
|
+
request: ConfigurationRequest,
|
|
34
|
+
run: Runner,
|
|
35
|
+
options: { additionalTools: string[]; bootstrappedDeployment: boolean } = {
|
|
36
|
+
additionalTools: [],
|
|
37
|
+
bootstrappedDeployment: false,
|
|
38
|
+
}
|
|
39
|
+
): Promise<Check[]> {
|
|
40
|
+
if (
|
|
41
|
+
options.additionalTools.some(
|
|
42
|
+
(tool) => !validConfiguration({ ...request, tool })
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
return failure('Invalid additional profile.');
|
|
46
|
+
if (!validConfiguration(request))
|
|
47
|
+
return failure('Invalid reviewed revision or configuration selection.');
|
|
48
|
+
const directory = deploymentDirectory(
|
|
49
|
+
root,
|
|
50
|
+
request.revision,
|
|
51
|
+
options.bootstrappedDeployment
|
|
52
|
+
);
|
|
53
|
+
const head = await run(
|
|
54
|
+
['git', 'rev-parse', '--verify', `${request.revision}^{commit}`],
|
|
55
|
+
{ cwd: root }
|
|
56
|
+
);
|
|
57
|
+
if (!head.ok || head.stdout.trim() !== request.revision)
|
|
58
|
+
return failure(
|
|
59
|
+
'Reviewed commit is unavailable locally. Fetch it explicitly and retry.'
|
|
60
|
+
);
|
|
61
|
+
const prepared = await prepareRevision(
|
|
62
|
+
root,
|
|
63
|
+
directory,
|
|
64
|
+
request.revision,
|
|
65
|
+
run
|
|
66
|
+
);
|
|
67
|
+
if (prepared.length) return prepared;
|
|
68
|
+
if (!options.bootstrappedDeployment) {
|
|
69
|
+
const setup = await setupRevision(directory, run);
|
|
70
|
+
if (setup.length) return setup;
|
|
71
|
+
}
|
|
72
|
+
const checks: Check[] = [];
|
|
73
|
+
for (const tool of [request.tool, ...options.additionalTools]) {
|
|
74
|
+
checks.push(
|
|
75
|
+
...(await applyProfile(root, directory, { ...request, tool }, run))
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return checks;
|
|
79
|
+
}
|
|
80
|
+
async function setupRevision(directory: string, run: Runner): Promise<Check[]> {
|
|
81
|
+
const setup = await run(
|
|
82
|
+
[
|
|
83
|
+
'mise',
|
|
84
|
+
'exec',
|
|
85
|
+
'--locked',
|
|
86
|
+
'bun',
|
|
87
|
+
'--',
|
|
88
|
+
'bun',
|
|
89
|
+
'bootstrap/setup.ts',
|
|
90
|
+
'--non-interactive',
|
|
91
|
+
],
|
|
92
|
+
{ cwd: directory, timeoutMs: 1_200_000 }
|
|
93
|
+
);
|
|
94
|
+
if (!setup.ok)
|
|
95
|
+
return failure(
|
|
96
|
+
'Existing setup owner failed. Partial tool/dependency installation is possible; no configuration receipt was advanced.'
|
|
97
|
+
);
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function applyProfile(
|
|
102
|
+
root: string,
|
|
103
|
+
directory: string,
|
|
104
|
+
request: ConfigurationRequest,
|
|
105
|
+
run: Runner
|
|
106
|
+
): Promise<Check[]> {
|
|
107
|
+
const applied = await applyOwner(directory, request, run);
|
|
108
|
+
if (!applied.ok)
|
|
109
|
+
return failure(
|
|
110
|
+
'Agent profile configuration failed. Inspect preview on this node; no rollout receipt was advanced.'
|
|
111
|
+
);
|
|
112
|
+
const receipt = decodeReceipt(applied.stdout, request.revision);
|
|
113
|
+
if (!receipt)
|
|
114
|
+
return failure(
|
|
115
|
+
'The agent profile owner returned an invalid receipt; rollout remains unverified.'
|
|
116
|
+
);
|
|
117
|
+
await recordConfiguration(root, request, receipt);
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
id: 'configuration:apply',
|
|
121
|
+
status: 'pass',
|
|
122
|
+
summary: `agent profile applied at revision ${request.revision}; configuration fingerprint ${receipt}. Authentication and T3 end-to-end remain separate gates.`,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function decodeReceipt(output: string, revision: string): string | null {
|
|
128
|
+
try {
|
|
129
|
+
const data: unknown = JSON.parse(output);
|
|
130
|
+
return record(data) &&
|
|
131
|
+
data['ok'] === true &&
|
|
132
|
+
data['revision'] === revision &&
|
|
133
|
+
typeof data['fingerprint'] === 'string' &&
|
|
134
|
+
/^[a-f0-9]{64}$/.test(data['fingerprint'])
|
|
135
|
+
? data['fingerprint']
|
|
136
|
+
: null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function failure(summary: string): Check[] {
|
|
143
|
+
return [{ id: 'configuration:apply', status: 'fail', summary }];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function prepareRevision(
|
|
147
|
+
root: string,
|
|
148
|
+
directory: string,
|
|
149
|
+
revision: string,
|
|
150
|
+
run: Runner
|
|
151
|
+
): Promise<Check[]> {
|
|
152
|
+
if (root === directory) return verifyRevision(directory, revision, run);
|
|
153
|
+
await mkdir(join(root, 'node_modules', '.fleet-revisions'), {
|
|
154
|
+
recursive: true,
|
|
155
|
+
});
|
|
156
|
+
let deployed = await run(['git', 'rev-parse', 'HEAD'], { cwd: directory });
|
|
157
|
+
if (!deployed.ok) {
|
|
158
|
+
const created = await run(
|
|
159
|
+
['git', 'worktree', 'add', '--detach', directory, revision],
|
|
160
|
+
{ cwd: root, timeoutMs: 30_000 }
|
|
161
|
+
);
|
|
162
|
+
if (!created.ok)
|
|
163
|
+
return failure(
|
|
164
|
+
'Cannot create pinned deployment worktree. Existing data was preserved.'
|
|
165
|
+
);
|
|
166
|
+
deployed = await run(['git', 'rev-parse', 'HEAD'], { cwd: directory });
|
|
167
|
+
}
|
|
168
|
+
return verifyRevision(directory, revision, run);
|
|
169
|
+
}
|
|
170
|
+
async function verifyRevision(
|
|
171
|
+
directory: string,
|
|
172
|
+
revision: string,
|
|
173
|
+
run: Runner
|
|
174
|
+
): Promise<Check[]> {
|
|
175
|
+
const deployed = await run(['git', 'rev-parse', 'HEAD'], { cwd: directory });
|
|
176
|
+
const clean = await run(['git', 'status', '--porcelain'], { cwd: directory });
|
|
177
|
+
if (
|
|
178
|
+
!deployed.ok ||
|
|
179
|
+
deployed.stdout.trim() !== revision ||
|
|
180
|
+
!clean.ok ||
|
|
181
|
+
clean.stdout.trim()
|
|
182
|
+
)
|
|
183
|
+
return failure(
|
|
184
|
+
'Pinned deployment checkout differs or is dirty. Inspect it; fleet never resets it.'
|
|
185
|
+
);
|
|
186
|
+
const top = await run(['git', 'rev-parse', '--show-toplevel'], {
|
|
187
|
+
cwd: directory,
|
|
188
|
+
});
|
|
189
|
+
if (!top.ok || top.stdout.trim() !== directory)
|
|
190
|
+
return failure('Deployment path is not its own Git worktree.');
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function recordConfiguration(
|
|
195
|
+
root: string,
|
|
196
|
+
request: ConfigurationRequest,
|
|
197
|
+
receipt: string
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
const receipts = join(root, 'node_modules', '.fleet-configuration');
|
|
200
|
+
await mkdir(receipts, { recursive: true, mode: 0o700 });
|
|
201
|
+
const path = join(
|
|
202
|
+
receipts,
|
|
203
|
+
`${request.node}-${request.tool}-${request.profile}.json`
|
|
204
|
+
);
|
|
205
|
+
const contents =
|
|
206
|
+
JSON.stringify({ version: 1, ...request, fingerprint: receipt }) + '\n';
|
|
207
|
+
try {
|
|
208
|
+
if ((await readFile(path, 'utf8')) === contents) return;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
211
|
+
}
|
|
212
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
213
|
+
await writeFile(temp, contents, { flag: 'wx', mode: 0o600 });
|
|
214
|
+
await rename(temp, path);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function applyOwner(
|
|
218
|
+
directory: string,
|
|
219
|
+
request: ConfigurationRequest,
|
|
220
|
+
run: Runner
|
|
221
|
+
) {
|
|
222
|
+
return run(
|
|
223
|
+
[
|
|
224
|
+
'mise',
|
|
225
|
+
'exec',
|
|
226
|
+
'--locked',
|
|
227
|
+
'bun',
|
|
228
|
+
'--',
|
|
229
|
+
'bun',
|
|
230
|
+
'src/agents/cli.ts',
|
|
231
|
+
'--json',
|
|
232
|
+
'--yes',
|
|
233
|
+
'--non-interactive',
|
|
234
|
+
'agents',
|
|
235
|
+
'apply',
|
|
236
|
+
'--node',
|
|
237
|
+
request.node,
|
|
238
|
+
'--tool',
|
|
239
|
+
request.tool,
|
|
240
|
+
'--profile',
|
|
241
|
+
request.profile,
|
|
242
|
+
],
|
|
243
|
+
{ cwd: directory, timeoutMs: 120_000 }
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function deploymentDirectory(
|
|
248
|
+
root: string,
|
|
249
|
+
revision: string,
|
|
250
|
+
bootstrapped: boolean
|
|
251
|
+
): string {
|
|
252
|
+
return bootstrapped
|
|
253
|
+
? root
|
|
254
|
+
: join(root, 'node_modules', '.fleet-revisions', revision);
|
|
255
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { parseWorkerPolicy, type WorkerPolicy } from './worker-policy';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { FleetUsageError } from './errors';
|
|
4
|
+
import { parseConfig, record } from './config';
|
|
5
|
+
import type { FleetConfig } from './types';
|
|
6
|
+
import { parseSecretsConfig, type SecretsConfig } from './secrets';
|
|
7
|
+
|
|
8
|
+
export interface MachineDefinition {
|
|
9
|
+
id: string;
|
|
10
|
+
ssh: string;
|
|
11
|
+
os: 'linux' | 'macOS' | 'windows';
|
|
12
|
+
network?: {
|
|
13
|
+
tailscaleName: string;
|
|
14
|
+
user: string;
|
|
15
|
+
sshPort: number;
|
|
16
|
+
t3Port: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** The singleton manager: the human's machine. It runs fleet-sync and hosts
|
|
20
|
+
* every singleton service; it is never converged over SSH. */
|
|
21
|
+
export interface ManagerDefinition {
|
|
22
|
+
id: string;
|
|
23
|
+
os: 'macOS';
|
|
24
|
+
network: { tailscaleName: string; user: string };
|
|
25
|
+
neverSleepOnPower: true;
|
|
26
|
+
}
|
|
27
|
+
export interface FleetDefinition {
|
|
28
|
+
version: 3;
|
|
29
|
+
/** The Git repository that holds this fleet's configuration and pins. */
|
|
30
|
+
source: { repository: string };
|
|
31
|
+
/** Where this fleet keeps its secrets. */
|
|
32
|
+
secrets: SecretsConfig;
|
|
33
|
+
manager: ManagerDefinition;
|
|
34
|
+
/** Stateless, replaceable Linux boxes; clients of the manager's services. */
|
|
35
|
+
workers: MachineDefinition[];
|
|
36
|
+
services: {
|
|
37
|
+
/** Public HTTPS origin of the CLI proxy every node uses. */
|
|
38
|
+
proxy: { endpoint: string };
|
|
39
|
+
monitoring: {
|
|
40
|
+
hubPort: 8443;
|
|
41
|
+
/** Public dashboard origin. */
|
|
42
|
+
publicUrl: string;
|
|
43
|
+
/** Beszel account the manager enrolls nodes with. */
|
|
44
|
+
managerAccount: string;
|
|
45
|
+
/** Read-only Beszel account visitors are signed in as. */
|
|
46
|
+
viewerAccount: string;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
worker?: WorkerPolicy;
|
|
50
|
+
ubuntu: { release: '26.04'; architectures: ['amd64', 'arm64'] };
|
|
51
|
+
}
|
|
52
|
+
const ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
53
|
+
const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
|
|
54
|
+
function fail(): never {
|
|
55
|
+
throw new FleetUsageError(
|
|
56
|
+
'Invalid Fleet definition. Check version 3, one macOS manager, unique Linux worker IDs, singleton services and network metadata in fleet.json.'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
function keys(value: Record<string, unknown>, allowed: string[]): boolean {
|
|
60
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
61
|
+
}
|
|
62
|
+
function network(value: unknown): NonNullable<MachineDefinition['network']> {
|
|
63
|
+
if (
|
|
64
|
+
!record(value) ||
|
|
65
|
+
!keys(value, ['tailscaleName', 'user', 'sshPort', 't3Port'])
|
|
66
|
+
)
|
|
67
|
+
return fail();
|
|
68
|
+
if (
|
|
69
|
+
typeof value['tailscaleName'] !== 'string' ||
|
|
70
|
+
!HOST.test(value['tailscaleName'])
|
|
71
|
+
)
|
|
72
|
+
return fail();
|
|
73
|
+
if (
|
|
74
|
+
typeof value['user'] !== 'string' ||
|
|
75
|
+
!/^[a-z_][a-z0-9_-]{0,31}$/.test(value['user'])
|
|
76
|
+
)
|
|
77
|
+
return fail();
|
|
78
|
+
for (const port of ['sshPort', 't3Port'])
|
|
79
|
+
if (!validPort(value[port])) return fail();
|
|
80
|
+
return value as NonNullable<MachineDefinition['network']>;
|
|
81
|
+
}
|
|
82
|
+
function validPort(value: unknown): boolean {
|
|
83
|
+
return (
|
|
84
|
+
Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 65535
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
function worker(value: unknown): MachineDefinition {
|
|
88
|
+
if (!record(value) || !keys(value, ['id', 'ssh', 'os', 'network']))
|
|
89
|
+
return fail();
|
|
90
|
+
if (typeof value['id'] !== 'string' || !ID.test(value['id'])) return fail();
|
|
91
|
+
if (typeof value['ssh'] !== 'string' || !HOST.test(value['ssh']))
|
|
92
|
+
return fail();
|
|
93
|
+
// Workers are the Linux boxes; any other platform is not a worker.
|
|
94
|
+
if (value['os'] !== 'linux') return fail();
|
|
95
|
+
return {
|
|
96
|
+
id: value['id'],
|
|
97
|
+
ssh: value['ssh'],
|
|
98
|
+
os: 'linux',
|
|
99
|
+
...(value['network'] ? { network: network(value['network']) } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function manager(value: unknown): ManagerDefinition {
|
|
103
|
+
if (
|
|
104
|
+
!record(value) ||
|
|
105
|
+
!keys(value, ['id', 'os', 'network', 'neverSleepOnPower']) ||
|
|
106
|
+
typeof value['id'] !== 'string' ||
|
|
107
|
+
!ID.test(value['id']) ||
|
|
108
|
+
value['os'] !== 'macOS' ||
|
|
109
|
+
value['neverSleepOnPower'] !== true
|
|
110
|
+
)
|
|
111
|
+
return fail();
|
|
112
|
+
return {
|
|
113
|
+
id: value['id'],
|
|
114
|
+
os: 'macOS',
|
|
115
|
+
network: managerNetwork(value['network']),
|
|
116
|
+
neverSleepOnPower: true,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function managerNetwork(value: unknown): ManagerDefinition['network'] {
|
|
120
|
+
if (
|
|
121
|
+
!record(value) ||
|
|
122
|
+
!keys(value, ['tailscaleName', 'user']) ||
|
|
123
|
+
typeof value['tailscaleName'] !== 'string' ||
|
|
124
|
+
!HOST.test(value['tailscaleName']) ||
|
|
125
|
+
typeof value['user'] !== 'string' ||
|
|
126
|
+
!/^[a-z_][a-z0-9_.-]{0,31}$/.test(value['user'])
|
|
127
|
+
)
|
|
128
|
+
return fail();
|
|
129
|
+
return { tailscaleName: value['tailscaleName'], user: value['user'] };
|
|
130
|
+
}
|
|
131
|
+
export function parseDefinition(value: unknown): FleetDefinition {
|
|
132
|
+
if (
|
|
133
|
+
!record(value) ||
|
|
134
|
+
value['version'] !== 3 ||
|
|
135
|
+
!keys(value, [
|
|
136
|
+
'$schema',
|
|
137
|
+
'version',
|
|
138
|
+
'source',
|
|
139
|
+
'secrets',
|
|
140
|
+
'manager',
|
|
141
|
+
'workers',
|
|
142
|
+
'services',
|
|
143
|
+
'ubuntu',
|
|
144
|
+
'worker',
|
|
145
|
+
])
|
|
146
|
+
)
|
|
147
|
+
return fail();
|
|
148
|
+
if (!Array.isArray(value['workers']) || value['workers'].length > 64)
|
|
149
|
+
return fail();
|
|
150
|
+
const lead = manager(value['manager']);
|
|
151
|
+
const workers = value['workers'].map(worker);
|
|
152
|
+
const ids = [lead.id, ...workers.map((entry) => entry.id)];
|
|
153
|
+
if (new Set(ids).size !== ids.length) return fail();
|
|
154
|
+
return {
|
|
155
|
+
version: 3,
|
|
156
|
+
source: source(value['source']),
|
|
157
|
+
secrets: secrets(value['secrets']),
|
|
158
|
+
manager: lead,
|
|
159
|
+
workers,
|
|
160
|
+
services: services(value['services']),
|
|
161
|
+
ubuntu: baseline(value['ubuntu']),
|
|
162
|
+
...(value['worker'] === undefined
|
|
163
|
+
? {}
|
|
164
|
+
: { worker: parseWorkerPolicy(value['worker']) }),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Singleton services name no host: they always run on the manager. */
|
|
168
|
+
function services(value: unknown): FleetDefinition['services'] {
|
|
169
|
+
if (!record(value) || !keys(value, ['proxy', 'monitoring'])) return fail();
|
|
170
|
+
const { proxy, monitoring } = value;
|
|
171
|
+
if (
|
|
172
|
+
!record(proxy) ||
|
|
173
|
+
!keys(proxy, ['endpoint']) ||
|
|
174
|
+
!origin(proxy['endpoint'])
|
|
175
|
+
)
|
|
176
|
+
return fail();
|
|
177
|
+
return {
|
|
178
|
+
proxy: { endpoint: proxy['endpoint'] },
|
|
179
|
+
monitoring: monitoringService(monitoring),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function monitoringService(
|
|
183
|
+
value: unknown
|
|
184
|
+
): FleetDefinition['services']['monitoring'] {
|
|
185
|
+
const fields = ['hubPort', 'publicUrl', 'managerAccount', 'viewerAccount'];
|
|
186
|
+
if (!record(value) || !keys(value, fields) || value['hubPort'] !== 8443)
|
|
187
|
+
return fail();
|
|
188
|
+
const { publicUrl, managerAccount, viewerAccount } = value;
|
|
189
|
+
if (!origin(publicUrl) || !account(managerAccount) || !account(viewerAccount))
|
|
190
|
+
return fail();
|
|
191
|
+
return { hubPort: 8443, publicUrl, managerAccount, viewerAccount };
|
|
192
|
+
}
|
|
193
|
+
const origin = (value: unknown): value is string =>
|
|
194
|
+
typeof value === 'string' && /^https:\/\/[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
195
|
+
const account = (value: unknown): value is string =>
|
|
196
|
+
typeof value === 'string' &&
|
|
197
|
+
/^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
198
|
+
function secrets(value: unknown): SecretsConfig {
|
|
199
|
+
return parseSecretsConfig(value) ?? fail();
|
|
200
|
+
}
|
|
201
|
+
function source(value: unknown): FleetDefinition['source'] {
|
|
202
|
+
if (
|
|
203
|
+
!record(value) ||
|
|
204
|
+
!keys(value, ['repository']) ||
|
|
205
|
+
typeof value['repository'] !== 'string' ||
|
|
206
|
+
!/^https:\/\/[A-Za-z0-9.-]+\/[A-Za-z0-9._/-]+\.git$/.test(
|
|
207
|
+
value['repository']
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return fail();
|
|
211
|
+
return { repository: value['repository'] };
|
|
212
|
+
}
|
|
213
|
+
function baseline(ubuntu: unknown): FleetDefinition['ubuntu'] {
|
|
214
|
+
if (
|
|
215
|
+
!record(ubuntu) ||
|
|
216
|
+
!keys(ubuntu, ['release', 'architectures']) ||
|
|
217
|
+
ubuntu['release'] !== '26.04' ||
|
|
218
|
+
JSON.stringify(ubuntu['architectures']) !== '["amd64","arm64"]'
|
|
219
|
+
)
|
|
220
|
+
return fail();
|
|
221
|
+
return ubuntu as FleetDefinition['ubuntu'];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function loadDefinition(path: string): FleetDefinition {
|
|
225
|
+
try {
|
|
226
|
+
return parseDefinition(JSON.parse(readFileSync(path, 'utf8')));
|
|
227
|
+
} catch {
|
|
228
|
+
return fail();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Local v1 roster supplies paths/requirements only for explicitly enrolled workers. */
|
|
232
|
+
export function enrolledRoster(
|
|
233
|
+
definition: FleetDefinition,
|
|
234
|
+
overrides: unknown
|
|
235
|
+
): FleetConfig {
|
|
236
|
+
const roster = parseConfig(overrides);
|
|
237
|
+
for (const node of roster.nodes) {
|
|
238
|
+
const declared = definition.workers.find((entry) => entry.id === node.id);
|
|
239
|
+
if (!declared)
|
|
240
|
+
throw new FleetUsageError(
|
|
241
|
+
'Roster contains a node that is not a declared worker. Add the worker to fleet.json in Git before rollout; the manager is never a roster node.'
|
|
242
|
+
);
|
|
243
|
+
if (declared.ssh !== node.ssh)
|
|
244
|
+
throw new FleetUsageError(
|
|
245
|
+
'Fleet SSH assignment conflicts with the local roster. Review the migration; discovery cannot overwrite explicit assignments.'
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
...roster,
|
|
250
|
+
nodes: roster.nodes.map((node) => ({
|
|
251
|
+
...node,
|
|
252
|
+
required: [
|
|
253
|
+
...new Set([
|
|
254
|
+
...node.required,
|
|
255
|
+
...(definition.worker?.requiredCapabilities ?? []),
|
|
256
|
+
]),
|
|
257
|
+
],
|
|
258
|
+
})),
|
|
259
|
+
};
|
|
260
|
+
}
|