@crvouga/dev-fleet 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +357 -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 +158 -0
- package/agents/t3.json +40 -0
- package/ansible/macos/manager.yml +30 -0
- package/ansible/macos/policy.json +9 -0
- package/ansible/monitoring/README.md +215 -0
- package/ansible/monitoring/agent-darwin.yml +198 -0
- package/ansible/monitoring/agent.yml +172 -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/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +16 -0
- package/ansible/net/remote-probe.sh +13 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/authorize-sudo.sh +51 -0
- package/ansible/ubuntu/baseline.yml +89 -0
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/disk-guard.py +104 -0
- package/ansible/ubuntu/policy.json +52 -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 +78 -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 +312 -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/ansible/uv.lock +190 -0
- package/bin/devfleet.mjs +36 -0
- package/bootstrap/bootstrap.ps1 +50 -0
- package/bootstrap/bootstrap.sh +80 -0
- package/bootstrap/devfleet +7 -0
- package/bootstrap/docker-desktop.sh +33 -0
- package/bootstrap/doctor.ts +234 -0
- package/bootstrap/fleet-manager-run.sh +103 -0
- package/bootstrap/fleet-remote-bootstrap.sh +258 -0
- package/bootstrap/global-config.ts +171 -0
- package/bootstrap/install-launcher.sh +10 -0
- package/bootstrap/native-aws.ts +86 -0
- package/bootstrap/native-t3.ts +154 -0
- package/bootstrap/npm.ts +358 -0
- package/bootstrap/release-version.ts +157 -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/docs/consumer-setup.md +408 -0
- package/docs/declared.md +89 -0
- package/docs/machine-setup.md +399 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +67 -0
- package/docs/ubuntu-sync.md +351 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +100 -0
- package/examples/roster.json +13 -0
- package/llms.txt +35 -0
- package/mise.lock +1521 -0
- package/mise.toml +110 -0
- package/package.json +74 -4
- package/schema/fleet.schema.json +420 -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 +165 -0
- package/src/agents/launch.ts +290 -0
- package/src/agents/native-verify.ts +371 -0
- package/src/agents/provider-errors.ts +57 -0
- package/src/agents/registry.ts +61 -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 +129 -0
- package/src/cli/app.ts +122 -0
- package/src/cli/cli.ts +28 -0
- package/src/cli/command-index.ts +57 -0
- package/src/cli/contracts.ts +25 -0
- package/src/cli/fleet-reference.ts +17 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-checkout.ts +130 -0
- package/src/cli/fleet-sync-enrollment.ts +140 -0
- package/src/cli/fleet-sync-forward.ts +134 -0
- package/src/cli/fleet-sync-manager.ts +249 -0
- package/src/cli/fleet-sync-monitoring.ts +185 -0
- package/src/cli/fleet-sync-output.ts +317 -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 +242 -0
- package/src/cli/fleet-sync-report.ts +26 -0
- package/src/cli/fleet-sync-schedule.ts +138 -0
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync-vault.ts +166 -0
- package/src/cli/fleet-sync.ts +375 -0
- package/src/cli/fleet.ts +165 -0
- package/src/cli/managed-health.ts +219 -0
- package/src/cli/node-deployment.ts +34 -0
- package/src/cli/node-sync.ts +121 -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 +185 -0
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +296 -0
- package/src/cli/registry.ts +185 -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 +105 -0
- package/src/cli/search.ts +35 -0
- package/src/cli/settings.ts +67 -0
- package/src/cli/source-operations.ts +102 -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 +290 -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 +174 -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 +173 -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 +405 -0
- package/src/manager.ts +62 -0
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +183 -0
- package/src/monitoring/beszel.ts +198 -0
- package/src/monitoring/enroll.ts +111 -0
- package/src/monitoring/hub-access.ts +114 -0
- package/src/monitoring/hub-backup.ts +121 -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 +248 -0
- package/src/monitoring/remote.ts +65 -0
- package/src/network-definition.ts +83 -0
- package/src/node-converge-linux.ts +374 -0
- package/src/node-converge-shared.ts +83 -0
- package/src/node-converge.ts +40 -0
- package/src/onboard.ts +35 -0
- package/src/package-info.ts +45 -0
- package/src/platform/ansible-receipt.ts +128 -0
- package/src/platform/ansible.ts +66 -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 +132 -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 +66 -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 +210 -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/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/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 +104 -0
- package/src/t3-linked-check.ts +123 -0
- package/src/tailnet-health.ts +231 -0
- package/src/tailnet-matching.ts +37 -0
- package/src/tailscale.ts +141 -0
- package/src/toolchain-checks.ts +108 -0
- package/src/topology.ts +24 -0
- package/src/transport.ts +122 -0
- package/src/types.ts +99 -0
- package/src/vault-config.ts +38 -0
- package/src/worker-access-checks.ts +168 -0
- package/src/worker-capacity.ts +34 -0
- package/src/worker-host-checks.ts +181 -0
- package/src/worker-policy.ts +64 -0
- package/src/worker-readiness.ts +64 -0
- package/src/workstation-checks.ts +29 -0
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
import { parseLocation, type BucketLocation } from './state/bucket';
|
|
8
|
+
|
|
9
|
+
export interface MachineDefinition {
|
|
10
|
+
id: string;
|
|
11
|
+
ssh: string;
|
|
12
|
+
os: 'linux' | 'macOS' | 'windows';
|
|
13
|
+
network?: {
|
|
14
|
+
tailscaleName: string;
|
|
15
|
+
user: string;
|
|
16
|
+
sshPort: number;
|
|
17
|
+
t3Port: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** The singleton manager: the human's machine. It runs fleet-sync and hosts
|
|
21
|
+
* every singleton service; it is never converged over SSH. */
|
|
22
|
+
export interface ManagerDefinition {
|
|
23
|
+
id: string;
|
|
24
|
+
os: 'macOS';
|
|
25
|
+
network: { tailscaleName: string; user: string };
|
|
26
|
+
neverSleepOnPower: true;
|
|
27
|
+
}
|
|
28
|
+
export interface FleetDefinition {
|
|
29
|
+
version: 3;
|
|
30
|
+
/** The Git repository machines take dev-fleet's code from. */
|
|
31
|
+
source: { repository: string };
|
|
32
|
+
/** Where this fleet keeps its secrets. */
|
|
33
|
+
secrets: SecretsConfig;
|
|
34
|
+
/** The bucket that holds the fleet state. Its credentials are never part
|
|
35
|
+
* of the configuration: they come from the environment of the command.
|
|
36
|
+
*/
|
|
37
|
+
state?: BucketLocation;
|
|
38
|
+
manager: ManagerDefinition;
|
|
39
|
+
/** Stateless, replaceable Linux boxes; clients of the manager's services. */
|
|
40
|
+
workers: MachineDefinition[];
|
|
41
|
+
services: {
|
|
42
|
+
/** Public HTTPS origin of the CLI proxy every node uses. */
|
|
43
|
+
proxy: { endpoint: string };
|
|
44
|
+
monitoring: {
|
|
45
|
+
hubPort: 8443;
|
|
46
|
+
/** Public dashboard origin. */
|
|
47
|
+
publicUrl: string;
|
|
48
|
+
/** Beszel account the manager enrolls nodes with. */
|
|
49
|
+
managerAccount: string;
|
|
50
|
+
/** Read-only Beszel account visitors are signed in as. */
|
|
51
|
+
viewerAccount: string;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
worker?: WorkerPolicy;
|
|
55
|
+
ubuntu: { release: '26.04'; architectures: ['amd64', 'arm64'] };
|
|
56
|
+
}
|
|
57
|
+
const ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
58
|
+
const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
|
|
59
|
+
function fail(): never {
|
|
60
|
+
throw new FleetUsageError(
|
|
61
|
+
'Invalid Fleet definition. Check version 3, one macOS manager, unique Linux worker IDs, singleton services and network metadata in fleet.json.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
function keys(value: Record<string, unknown>, allowed: string[]): boolean {
|
|
65
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
66
|
+
}
|
|
67
|
+
function network(value: unknown): NonNullable<MachineDefinition['network']> {
|
|
68
|
+
if (
|
|
69
|
+
!record(value) ||
|
|
70
|
+
!keys(value, ['tailscaleName', 'user', 'sshPort', 't3Port'])
|
|
71
|
+
)
|
|
72
|
+
return fail();
|
|
73
|
+
if (
|
|
74
|
+
typeof value['tailscaleName'] !== 'string' ||
|
|
75
|
+
!HOST.test(value['tailscaleName'])
|
|
76
|
+
)
|
|
77
|
+
return fail();
|
|
78
|
+
if (
|
|
79
|
+
typeof value['user'] !== 'string' ||
|
|
80
|
+
!/^[a-z_][a-z0-9_-]{0,31}$/.test(value['user'])
|
|
81
|
+
)
|
|
82
|
+
return fail();
|
|
83
|
+
for (const port of ['sshPort', 't3Port'])
|
|
84
|
+
if (!validPort(value[port])) return fail();
|
|
85
|
+
return value as NonNullable<MachineDefinition['network']>;
|
|
86
|
+
}
|
|
87
|
+
function validPort(value: unknown): boolean {
|
|
88
|
+
return (
|
|
89
|
+
Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 65535
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
function worker(value: unknown): MachineDefinition {
|
|
93
|
+
if (!record(value) || !keys(value, ['id', 'ssh', 'os', 'network']))
|
|
94
|
+
return fail();
|
|
95
|
+
if (typeof value['id'] !== 'string' || !ID.test(value['id'])) return fail();
|
|
96
|
+
if (typeof value['ssh'] !== 'string' || !HOST.test(value['ssh']))
|
|
97
|
+
return fail();
|
|
98
|
+
// Workers are the Linux boxes; any other platform is not a worker.
|
|
99
|
+
if (value['os'] !== 'linux') return fail();
|
|
100
|
+
return {
|
|
101
|
+
id: value['id'],
|
|
102
|
+
ssh: value['ssh'],
|
|
103
|
+
os: 'linux',
|
|
104
|
+
...(value['network'] ? { network: network(value['network']) } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function manager(value: unknown): ManagerDefinition {
|
|
108
|
+
if (
|
|
109
|
+
!record(value) ||
|
|
110
|
+
!keys(value, ['id', 'os', 'network', 'neverSleepOnPower']) ||
|
|
111
|
+
typeof value['id'] !== 'string' ||
|
|
112
|
+
!ID.test(value['id']) ||
|
|
113
|
+
value['os'] !== 'macOS' ||
|
|
114
|
+
value['neverSleepOnPower'] !== true
|
|
115
|
+
)
|
|
116
|
+
return fail();
|
|
117
|
+
return {
|
|
118
|
+
id: value['id'],
|
|
119
|
+
os: 'macOS',
|
|
120
|
+
network: managerNetwork(value['network']),
|
|
121
|
+
neverSleepOnPower: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function managerNetwork(value: unknown): ManagerDefinition['network'] {
|
|
125
|
+
if (
|
|
126
|
+
!record(value) ||
|
|
127
|
+
!keys(value, ['tailscaleName', 'user']) ||
|
|
128
|
+
typeof value['tailscaleName'] !== 'string' ||
|
|
129
|
+
!HOST.test(value['tailscaleName']) ||
|
|
130
|
+
typeof value['user'] !== 'string' ||
|
|
131
|
+
!/^[a-z_][a-z0-9_.-]{0,31}$/.test(value['user'])
|
|
132
|
+
)
|
|
133
|
+
return fail();
|
|
134
|
+
return { tailscaleName: value['tailscaleName'], user: value['user'] };
|
|
135
|
+
}
|
|
136
|
+
export function parseDefinition(value: unknown): FleetDefinition {
|
|
137
|
+
if (
|
|
138
|
+
!record(value) ||
|
|
139
|
+
value['version'] !== 3 ||
|
|
140
|
+
!keys(value, [
|
|
141
|
+
'$schema',
|
|
142
|
+
'version',
|
|
143
|
+
'source',
|
|
144
|
+
'secrets',
|
|
145
|
+
'state',
|
|
146
|
+
'manager',
|
|
147
|
+
'workers',
|
|
148
|
+
'services',
|
|
149
|
+
'ubuntu',
|
|
150
|
+
'worker',
|
|
151
|
+
])
|
|
152
|
+
)
|
|
153
|
+
return fail();
|
|
154
|
+
if (!Array.isArray(value['workers']) || value['workers'].length > 64)
|
|
155
|
+
return fail();
|
|
156
|
+
const lead = manager(value['manager']);
|
|
157
|
+
const workers = value['workers'].map(worker);
|
|
158
|
+
const ids = [lead.id, ...workers.map((entry) => entry.id)];
|
|
159
|
+
if (new Set(ids).size !== ids.length) return fail();
|
|
160
|
+
return {
|
|
161
|
+
version: 3,
|
|
162
|
+
source: source(value['source']),
|
|
163
|
+
secrets: secrets(value['secrets']),
|
|
164
|
+
...(value['state'] === undefined ? {} : { state: state(value['state']) }),
|
|
165
|
+
manager: lead,
|
|
166
|
+
workers,
|
|
167
|
+
services: services(value['services']),
|
|
168
|
+
ubuntu: baseline(value['ubuntu']),
|
|
169
|
+
...(value['worker'] === undefined
|
|
170
|
+
? {}
|
|
171
|
+
: { worker: parseWorkerPolicy(value['worker']) }),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** Singleton services name no host: they always run on the manager. */
|
|
175
|
+
function services(value: unknown): FleetDefinition['services'] {
|
|
176
|
+
if (!record(value) || !keys(value, ['proxy', 'monitoring'])) return fail();
|
|
177
|
+
const { proxy, monitoring } = value;
|
|
178
|
+
if (
|
|
179
|
+
!record(proxy) ||
|
|
180
|
+
!keys(proxy, ['endpoint']) ||
|
|
181
|
+
!origin(proxy['endpoint'])
|
|
182
|
+
)
|
|
183
|
+
return fail();
|
|
184
|
+
return {
|
|
185
|
+
proxy: { endpoint: proxy['endpoint'] },
|
|
186
|
+
monitoring: monitoringService(monitoring),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function monitoringService(
|
|
190
|
+
value: unknown
|
|
191
|
+
): FleetDefinition['services']['monitoring'] {
|
|
192
|
+
const fields = ['hubPort', 'publicUrl', 'managerAccount', 'viewerAccount'];
|
|
193
|
+
if (!record(value) || !keys(value, fields) || value['hubPort'] !== 8443)
|
|
194
|
+
return fail();
|
|
195
|
+
const { publicUrl, managerAccount, viewerAccount } = value;
|
|
196
|
+
if (!origin(publicUrl) || !account(managerAccount) || !account(viewerAccount))
|
|
197
|
+
return fail();
|
|
198
|
+
return { hubPort: 8443, publicUrl, managerAccount, viewerAccount };
|
|
199
|
+
}
|
|
200
|
+
const origin = (value: unknown): value is string =>
|
|
201
|
+
typeof value === 'string' && /^https:\/\/[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
202
|
+
const account = (value: unknown): value is string =>
|
|
203
|
+
typeof value === 'string' &&
|
|
204
|
+
/^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
205
|
+
function secrets(value: unknown): SecretsConfig {
|
|
206
|
+
return parseSecretsConfig(value) ?? fail();
|
|
207
|
+
}
|
|
208
|
+
/** Says what is wrong with the block itself: a bucket has several settings. */
|
|
209
|
+
function state(value: unknown): BucketLocation {
|
|
210
|
+
const bucket = parseLocation(value);
|
|
211
|
+
if (bucket.kind === 'directory') return { directory: bucket.path };
|
|
212
|
+
const { bucket: name, endpoint, region } = bucket;
|
|
213
|
+
return {
|
|
214
|
+
bucket: name,
|
|
215
|
+
...(endpoint ? { endpoint } : {}),
|
|
216
|
+
...(region ? { region } : {}),
|
|
217
|
+
prefix: bucket.prefix,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function source(value: unknown): FleetDefinition['source'] {
|
|
221
|
+
if (
|
|
222
|
+
!record(value) ||
|
|
223
|
+
!keys(value, ['repository']) ||
|
|
224
|
+
typeof value['repository'] !== 'string' ||
|
|
225
|
+
!/^https:\/\/[A-Za-z0-9.-]+\/[A-Za-z0-9._/-]+\.git$/.test(
|
|
226
|
+
value['repository']
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
return fail();
|
|
230
|
+
return { repository: value['repository'] };
|
|
231
|
+
}
|
|
232
|
+
function baseline(ubuntu: unknown): FleetDefinition['ubuntu'] {
|
|
233
|
+
if (
|
|
234
|
+
!record(ubuntu) ||
|
|
235
|
+
!keys(ubuntu, ['release', 'architectures']) ||
|
|
236
|
+
ubuntu['release'] !== '26.04' ||
|
|
237
|
+
JSON.stringify(ubuntu['architectures']) !== '["amd64","arm64"]'
|
|
238
|
+
)
|
|
239
|
+
return fail();
|
|
240
|
+
return ubuntu as FleetDefinition['ubuntu'];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The configuration as every machine keeps it at
|
|
244
|
+
* ~/.config/dev-fleet/fleet.json: without the bucket. Only the devfleet CLI
|
|
245
|
+
* on the manager reaches the bucket, and a launcher that still runs the
|
|
246
|
+
* revision from before the block existed must go on accepting the file while
|
|
247
|
+
* a sync is under way.
|
|
248
|
+
*/
|
|
249
|
+
export function installedDefinition(definition: FleetDefinition): string {
|
|
250
|
+
const { state: _bucket, ...machine } = definition;
|
|
251
|
+
return JSON.stringify(machine, null, 2) + '\n';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function loadDefinition(path: string): FleetDefinition {
|
|
255
|
+
try {
|
|
256
|
+
return parseDefinition(JSON.parse(readFileSync(path, 'utf8')));
|
|
257
|
+
} catch {
|
|
258
|
+
return fail();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** Local v1 roster supplies paths/requirements only for explicitly enrolled workers. */
|
|
262
|
+
export function enrolledRoster(
|
|
263
|
+
definition: FleetDefinition,
|
|
264
|
+
overrides: unknown
|
|
265
|
+
): FleetConfig {
|
|
266
|
+
const roster = parseConfig(overrides);
|
|
267
|
+
for (const node of roster.nodes) {
|
|
268
|
+
const declared = definition.workers.find((entry) => entry.id === node.id);
|
|
269
|
+
if (!declared)
|
|
270
|
+
throw new FleetUsageError(
|
|
271
|
+
'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.'
|
|
272
|
+
);
|
|
273
|
+
if (declared.ssh !== node.ssh)
|
|
274
|
+
throw new FleetUsageError(
|
|
275
|
+
'Fleet SSH assignment conflicts with the local roster. Review the migration; discovery cannot overwrite explicit assignments.'
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
...roster,
|
|
280
|
+
nodes: roster.nodes.map((node) => ({
|
|
281
|
+
...node,
|
|
282
|
+
required: [
|
|
283
|
+
...new Set([
|
|
284
|
+
...node.required,
|
|
285
|
+
...(definition.worker?.requiredCapabilities ?? []),
|
|
286
|
+
]),
|
|
287
|
+
],
|
|
288
|
+
})),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Check } from './types';
|
|
2
|
+
|
|
3
|
+
export interface Diagnostic {
|
|
4
|
+
node: string;
|
|
5
|
+
phase: string;
|
|
6
|
+
checkId: string;
|
|
7
|
+
status: Check['status'];
|
|
8
|
+
code: string;
|
|
9
|
+
summary: string;
|
|
10
|
+
expected: string;
|
|
11
|
+
observed: string;
|
|
12
|
+
scope: 'manager' | 'node' | 'unverified';
|
|
13
|
+
durationMs: number | null;
|
|
14
|
+
retryable: boolean;
|
|
15
|
+
actionRequired: boolean;
|
|
16
|
+
remedy: { owner: string; action: string };
|
|
17
|
+
}
|
|
18
|
+
const EXPECTED: Record<string, string> = {
|
|
19
|
+
ssh: 'Fresh noninteractive SSH session with the enrolled host key and designated user.',
|
|
20
|
+
tailscale:
|
|
21
|
+
'The enrolled device identity is connected and reachable through the intended tailnet.',
|
|
22
|
+
ubuntu:
|
|
23
|
+
'Observed Ubuntu configuration matches the reviewed baseline without a distribution upgrade.',
|
|
24
|
+
t3: 'Pinned native T3 service and the specifically named runtime acceptance check pass.',
|
|
25
|
+
provider:
|
|
26
|
+
'The declared gateway model passes authenticated inference, completed streaming and a bounded tool round trip.',
|
|
27
|
+
proxy:
|
|
28
|
+
'One declared gateway owner preserves runtime identities and serves authorized clients.',
|
|
29
|
+
beszel:
|
|
30
|
+
'Pinned monitoring artifacts, canonical registration and fresh authenticated metrics are independently verified.',
|
|
31
|
+
rollout: 'Required prerequisites and canary gates pass before promotion.',
|
|
32
|
+
manager:
|
|
33
|
+
'Manager enrollment, approval and exclusive operation ownership are valid.',
|
|
34
|
+
source:
|
|
35
|
+
'The approved full commit and packaged canonical definition match verified source.',
|
|
36
|
+
configuration:
|
|
37
|
+
'The existing Workstation owner applies and verifies the selected source configuration.',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Derived only from already-redacted checks. No raw subprocess errors, journal
|
|
41
|
+
* contents, auth payloads or environment fields enter the diagnostic contract.
|
|
42
|
+
*/
|
|
43
|
+
export function diagnostic(node: string, check: Check): Diagnostic {
|
|
44
|
+
const phase = check.id.split(':')[0]!;
|
|
45
|
+
const failed = check.status !== 'pass';
|
|
46
|
+
const code = /^[A-Z][A-Z0-9_]{2,100}$/.test(check.summary)
|
|
47
|
+
? check.summary
|
|
48
|
+
: `${check.id.replaceAll(/[^a-z0-9]/g, '_').toUpperCase()}_${failed ? 'UNVERIFIED' : 'VERIFIED'}`;
|
|
49
|
+
return {
|
|
50
|
+
node,
|
|
51
|
+
phase,
|
|
52
|
+
checkId: check.id,
|
|
53
|
+
status: check.status,
|
|
54
|
+
code,
|
|
55
|
+
summary: check.summary,
|
|
56
|
+
expected:
|
|
57
|
+
EXPECTED[phase] ??
|
|
58
|
+
'The named required check has independent verification evidence.',
|
|
59
|
+
observed: check.summary,
|
|
60
|
+
scope: scope(check.id),
|
|
61
|
+
durationMs: check.phaseDurationMs ?? null,
|
|
62
|
+
retryable: failed && /OFFLINE|UNREACHABLE|METRICS_STALE|TIMEOUT/.test(code),
|
|
63
|
+
actionRequired: failed,
|
|
64
|
+
remedy: {
|
|
65
|
+
owner: owner(phase),
|
|
66
|
+
action:
|
|
67
|
+
check.remedy?.action ??
|
|
68
|
+
'Inspect this named check in the Fleet runbook; preserve runtime identity and repair persistent configuration in source.',
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function scope(id: string): Diagnostic['scope'] {
|
|
73
|
+
if (
|
|
74
|
+
/^(t3:manager-(endpoint|origin)|ssh:|manager:|rollout:|source:|tailscale:(reachability|identity|enrolled-identity))/.test(
|
|
75
|
+
id
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return 'manager';
|
|
79
|
+
if (
|
|
80
|
+
/^(provider:|proxy:|configuration:|ubuntu:baseline|t3:(service|policy)|tailscale:configuration)/.test(
|
|
81
|
+
id
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return 'node';
|
|
85
|
+
if (/^beszel:(version|registration|connection|freshness|api)/.test(id))
|
|
86
|
+
return 'manager';
|
|
87
|
+
return 'unverified';
|
|
88
|
+
}
|
|
89
|
+
function owner(phase: string): string {
|
|
90
|
+
if (phase === 'proxy') return 'cliproxy';
|
|
91
|
+
return 'dev-fleet';
|
|
92
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type CheckSeverity = 'pass' | 'warn' | 'fail';
|
|
2
|
+
|
|
3
|
+
/** One human-readable health finding, with an optional fix to run. */
|
|
4
|
+
export type DoctorCheck = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
severity: CheckSeverity;
|
|
8
|
+
detail: string;
|
|
9
|
+
fix?: string | undefined;
|
|
10
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class FleetUsageError extends Error {}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { record } from './config';
|
|
4
|
+
import {
|
|
5
|
+
fileHash,
|
|
6
|
+
manifestHash,
|
|
7
|
+
sidecarHash,
|
|
8
|
+
INPUT_FILES,
|
|
9
|
+
FINGERPRINT_FILES,
|
|
10
|
+
} from './input-hashes';
|
|
11
|
+
import type { Fingerprint, Runner, Check } from './types';
|
|
12
|
+
|
|
13
|
+
const RECEIPT = 'node_modules/.fleet-sync.json';
|
|
14
|
+
|
|
15
|
+
export async function fingerprint(
|
|
16
|
+
root: string,
|
|
17
|
+
run: Runner
|
|
18
|
+
): Promise<Fingerprint> {
|
|
19
|
+
const files: Fingerprint['files'] = {};
|
|
20
|
+
for (const name of INPUT_FILES)
|
|
21
|
+
files[name] = await fileHash(join(root, name));
|
|
22
|
+
files['workspace-manifests'] = await manifestHash(root);
|
|
23
|
+
files['mise-sidecars'] = await sidecarHash(root);
|
|
24
|
+
const head = await run(['git', 'rev-parse', 'HEAD'], { cwd: root });
|
|
25
|
+
return {
|
|
26
|
+
head:
|
|
27
|
+
head.ok && /^[a-f0-9]{40,64}\s*$/.test(head.stdout)
|
|
28
|
+
? head.stdout.trim()
|
|
29
|
+
: null,
|
|
30
|
+
files,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function dependencyCheck(
|
|
35
|
+
root: string,
|
|
36
|
+
current: Fingerprint
|
|
37
|
+
): Promise<Check> {
|
|
38
|
+
const remedy = {
|
|
39
|
+
action:
|
|
40
|
+
'Review devfleet fleet-repair --repair workspace, then explicitly apply a frozen toolchain/dependency sync.',
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
const receipt: unknown = JSON.parse(
|
|
44
|
+
await readFile(join(root, RECEIPT), 'utf8')
|
|
45
|
+
);
|
|
46
|
+
const matches =
|
|
47
|
+
record(receipt) &&
|
|
48
|
+
receipt['version'] === 1 &&
|
|
49
|
+
JSON.stringify(receipt['files']) === JSON.stringify(current.files);
|
|
50
|
+
return {
|
|
51
|
+
id: 'dependencies',
|
|
52
|
+
status: matches ? 'pass' : 'fail',
|
|
53
|
+
summary: matches
|
|
54
|
+
? 'Last successful frozen sync matches input hashes; not a full integrity audit.'
|
|
55
|
+
: 'Dependency sync receipt is stale or invalid.',
|
|
56
|
+
...(matches ? {} : { remedy }),
|
|
57
|
+
};
|
|
58
|
+
} catch {
|
|
59
|
+
return {
|
|
60
|
+
id: 'dependencies',
|
|
61
|
+
status: 'warn',
|
|
62
|
+
summary: 'No frozen-sync receipt; installed dependencies are unverified.',
|
|
63
|
+
remedy,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function writeReceipt(
|
|
69
|
+
root: string,
|
|
70
|
+
current: Fingerprint
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
await mkdir(join(root, 'node_modules'), { recursive: true });
|
|
73
|
+
await writeFile(
|
|
74
|
+
join(root, RECEIPT),
|
|
75
|
+
JSON.stringify({ version: 1, files: current.files }),
|
|
76
|
+
{ mode: 0o600 }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function compareFingerprints(
|
|
81
|
+
expected: Fingerprint,
|
|
82
|
+
actual: Fingerprint
|
|
83
|
+
): Check[] {
|
|
84
|
+
return [
|
|
85
|
+
compare('revision', expected.head, actual.head),
|
|
86
|
+
...FINGERPRINT_FILES.map((name) =>
|
|
87
|
+
compare(
|
|
88
|
+
`sync:${name}`,
|
|
89
|
+
expected.files[name] ?? null,
|
|
90
|
+
actual.files[name] ?? null
|
|
91
|
+
)
|
|
92
|
+
),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function compare(
|
|
97
|
+
id: string,
|
|
98
|
+
expected: string | null,
|
|
99
|
+
actual: string | null
|
|
100
|
+
): Check {
|
|
101
|
+
const known = expected !== null && actual !== null;
|
|
102
|
+
const matches = known && expected === actual;
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
status: matches ? 'pass' : 'fail',
|
|
106
|
+
summary: matches
|
|
107
|
+
? 'Matches the manager checkout.'
|
|
108
|
+
: known
|
|
109
|
+
? 'Differs from the manager checkout.'
|
|
110
|
+
: 'Cannot verify; reference or node input is missing.',
|
|
111
|
+
...(matches
|
|
112
|
+
? {}
|
|
113
|
+
: {
|
|
114
|
+
remedy: {
|
|
115
|
+
action:
|
|
116
|
+
'Reconcile branches/lockfiles; review devfleet fleet-sync for the approved revision. Never reset a working tree.',
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
installedDefinition,
|
|
13
|
+
parseDefinition,
|
|
14
|
+
type FleetDefinition,
|
|
15
|
+
} from './definition';
|
|
16
|
+
import { FleetUsageError } from './errors';
|
|
17
|
+
import { pinnedPath } from './agents/toolchain';
|
|
18
|
+
import type { Runner } from './types';
|
|
19
|
+
import {
|
|
20
|
+
secretsProvider,
|
|
21
|
+
type SecretCommand,
|
|
22
|
+
type SecretsProvider,
|
|
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
|
+
}
|
|
46
|
+
|
|
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.
|
|
72
|
+
*/
|
|
73
|
+
export function configPath(env = process.env, home = homedir()): string {
|
|
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;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Installs the configuration this process runs with as the machine's copy,
|
|
83
|
+
* so launchers and scheduled jobs (which get no environment) use the same one.
|
|
84
|
+
* Returns whether the file changed.
|
|
85
|
+
*/
|
|
86
|
+
export function installConfig(home = homedir()): boolean {
|
|
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);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function load(): FleetDefinition {
|
|
103
|
+
const path = configPath();
|
|
104
|
+
if (!existsSync(path))
|
|
105
|
+
throw new FleetUsageError(
|
|
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}.`
|
|
107
|
+
);
|
|
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;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** This fleet's own configuration: its machines, endpoints, accounts and source
|
|
134
|
+
* repository. Fleet code never names any of them; it reads them here, and this
|
|
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.
|
|
137
|
+
*/
|
|
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
|
+
});
|
|
145
|
+
|
|
146
|
+
/** Reads one of this fleet's secrets for this node. Tools run from the pinned
|
|
147
|
+
* toolchain, whatever PATH the caller (T3, launchd, systemd) started with.
|
|
148
|
+
*/
|
|
149
|
+
export const secret: SecretsProvider = (name) =>
|
|
150
|
+
secretsProvider(fleet.secrets, { run: pinned })(name);
|
|
151
|
+
|
|
152
|
+
/** The same, through an injected runner (fleet-sync phases and their tests). */
|
|
153
|
+
export const secretVia = (run: Runner): SecretsProvider =>
|
|
154
|
+
secretsProvider(fleet.secrets, {
|
|
155
|
+
run: async (argv) => {
|
|
156
|
+
const result = await run(argv, { timeoutMs: 20_000 });
|
|
157
|
+
return result.ok ? result.stdout : null;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const pinned: SecretCommand = (argv) =>
|
|
162
|
+
new Promise((resolve) => {
|
|
163
|
+
const child = spawn(argv[0]!, argv.slice(1), {
|
|
164
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
165
|
+
timeout: 20_000,
|
|
166
|
+
env: { ...process.env, PATH: pinnedPath() },
|
|
167
|
+
});
|
|
168
|
+
let output = '';
|
|
169
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
170
|
+
if (output.length < 65_536) output += chunk.toString('utf8');
|
|
171
|
+
});
|
|
172
|
+
child.on('error', () => resolve(null));
|
|
173
|
+
child.on('close', (code) => resolve(code === 0 ? output : null));
|
|
174
|
+
});
|