@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,208 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { dirname } from 'node:path';
|
|
12
|
+
import { record } from '../config';
|
|
13
|
+
import { FleetUsageError } from '../errors';
|
|
14
|
+
|
|
15
|
+
/** Where the fleet's state is kept for good. An S3-compatible bucket (AWS S3,
|
|
16
|
+
* Cloudflare R2, Backblaze B2, MinIO) is the usual one; a directory on an
|
|
17
|
+
* attached disk or a mounted share works the same way.
|
|
18
|
+
*/
|
|
19
|
+
export type Bucket =
|
|
20
|
+
| {
|
|
21
|
+
kind: 's3';
|
|
22
|
+
bucket: string;
|
|
23
|
+
/** Omitted for AWS S3 itself. */
|
|
24
|
+
endpoint?: string;
|
|
25
|
+
region?: string;
|
|
26
|
+
/** Key prefix, so one bucket can hold more than this fleet's state. */
|
|
27
|
+
prefix: string;
|
|
28
|
+
/** Omitted to use the standard AWS environment or profile. */
|
|
29
|
+
accessKeyId?: string;
|
|
30
|
+
secretAccessKey?: string;
|
|
31
|
+
}
|
|
32
|
+
| { kind: 'directory'; path: string };
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_PREFIX = 'dev-fleet';
|
|
35
|
+
|
|
36
|
+
const text = (value: unknown, pattern: RegExp): value is string =>
|
|
37
|
+
typeof value === 'string' && pattern.test(value);
|
|
38
|
+
|
|
39
|
+
const ENDPOINT =
|
|
40
|
+
/^(https:\/\/[A-Za-z0-9.-]{1,253}(:\d{1,5})?|http:\/\/(127\.0\.0\.1|localhost)(:\d{1,5})?)$/;
|
|
41
|
+
const KEY = /^[\x21-\x7e]{1,512}$/;
|
|
42
|
+
|
|
43
|
+
function invalid(reason: string): never {
|
|
44
|
+
throw new FleetUsageError(`Invalid bucket settings: ${reason}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function directory(value: Record<string, unknown>): Bucket {
|
|
48
|
+
if (Object.keys(value).some((key) => !['version', 'directory'].includes(key)))
|
|
49
|
+
invalid('a directory takes no other setting.');
|
|
50
|
+
const path = value['directory'];
|
|
51
|
+
if (!text(path, /^\/[^\x00-\x1f\x7f]{1,4000}$/))
|
|
52
|
+
invalid('directory must be an absolute path.');
|
|
53
|
+
return { kind: 'directory', path };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function prefix(value: unknown): string {
|
|
57
|
+
if (value === undefined) return DEFAULT_PREFIX;
|
|
58
|
+
if (
|
|
59
|
+
!text(value, /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/) ||
|
|
60
|
+
value.includes('..') ||
|
|
61
|
+
value.endsWith('/')
|
|
62
|
+
)
|
|
63
|
+
invalid('prefix must be a plain key prefix such as dev-fleet.');
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function credentials(
|
|
68
|
+
value: Record<string, unknown>
|
|
69
|
+
): { accessKeyId: string; secretAccessKey: string } | Record<string, never> {
|
|
70
|
+
const { accessKeyId, secretAccessKey } = value;
|
|
71
|
+
if (accessKeyId === undefined && secretAccessKey === undefined) return {};
|
|
72
|
+
if (!text(accessKeyId, KEY) || !text(secretAccessKey, KEY))
|
|
73
|
+
invalid('accessKeyId and secretAccessKey must be given together.');
|
|
74
|
+
return { accessKeyId, secretAccessKey };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const S3_SETTINGS = [
|
|
78
|
+
'version',
|
|
79
|
+
'bucket',
|
|
80
|
+
'endpoint',
|
|
81
|
+
'region',
|
|
82
|
+
'prefix',
|
|
83
|
+
'accessKeyId',
|
|
84
|
+
'secretAccessKey',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
function s3(value: Record<string, unknown>): Bucket {
|
|
88
|
+
if (Object.keys(value).some((key) => !S3_SETTINGS.includes(key)))
|
|
89
|
+
invalid('unknown setting.');
|
|
90
|
+
const { bucket, endpoint, region } = value;
|
|
91
|
+
if (!text(bucket, /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/))
|
|
92
|
+
invalid('bucket must be a bucket name.');
|
|
93
|
+
if (endpoint !== undefined && !text(endpoint, ENDPOINT))
|
|
94
|
+
invalid('endpoint must be an https:// origin without a path.');
|
|
95
|
+
if (region !== undefined && !text(region, /^[a-z0-9-]{1,32}$/))
|
|
96
|
+
invalid('region must be a region name such as auto or us-east-1.');
|
|
97
|
+
return {
|
|
98
|
+
kind: 's3',
|
|
99
|
+
bucket,
|
|
100
|
+
...(endpoint ? { endpoint } : {}),
|
|
101
|
+
...(region ? { region } : {}),
|
|
102
|
+
prefix: prefix(value['prefix']),
|
|
103
|
+
...credentials(value),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The contents of bucket.json, or the same fields from `fleet-state --init`. */
|
|
108
|
+
export function parseBucket(value: unknown): Bucket {
|
|
109
|
+
if (!record(value)) return invalid('expected a JSON object.');
|
|
110
|
+
if (value['version'] !== undefined && value['version'] !== 1)
|
|
111
|
+
invalid('unsupported version.');
|
|
112
|
+
return value['directory'] !== undefined ? directory(value) : s3(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The environment a devfleet command takes the bucket's credentials from. */
|
|
116
|
+
export const ACCESS_KEY_ID_ENV = 'DEVFLEET_BUCKET_ACCESS_KEY_ID';
|
|
117
|
+
export const SECRET_ACCESS_KEY_ENV = 'DEVFLEET_BUCKET_SECRET_ACCESS_KEY';
|
|
118
|
+
|
|
119
|
+
/** Where a fleet configuration says its state is kept: a bucket's location
|
|
120
|
+
* without credentials, which never belong in a file kept in Git.
|
|
121
|
+
*/
|
|
122
|
+
export type BucketLocation =
|
|
123
|
+
| { bucket: string; endpoint?: string; region?: string; prefix?: string }
|
|
124
|
+
| { directory: string };
|
|
125
|
+
|
|
126
|
+
const LOCATION = ['bucket', 'endpoint', 'region', 'prefix', 'directory'];
|
|
127
|
+
|
|
128
|
+
/** The `state` block of a fleet configuration. */
|
|
129
|
+
export function parseLocation(value: unknown): Bucket {
|
|
130
|
+
if (!record(value)) return invalid('expected a JSON object.');
|
|
131
|
+
if ('accessKeyId' in value || 'secretAccessKey' in value)
|
|
132
|
+
invalid(
|
|
133
|
+
`credentials do not belong in the fleet configuration. Set ${ACCESS_KEY_ID_ENV} and ${SECRET_ACCESS_KEY_ENV} instead.`
|
|
134
|
+
);
|
|
135
|
+
if (Object.keys(value).some((key) => !LOCATION.includes(key)))
|
|
136
|
+
invalid('unknown setting.');
|
|
137
|
+
return parseBucket(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type S3Bucket = Extract<Bucket, { kind: 's3' }>;
|
|
141
|
+
|
|
142
|
+
/** The same bucket, reached with these credentials instead of its own. */
|
|
143
|
+
export function withCredentials(
|
|
144
|
+
bucket: S3Bucket,
|
|
145
|
+
given: { accessKeyId?: string; secretAccessKey?: string }
|
|
146
|
+
): S3Bucket {
|
|
147
|
+
const { accessKeyId: _id, secretAccessKey: _secret, ...place } = bucket;
|
|
148
|
+
return { ...place, ...credentials(given) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Null when this machine has no bucket. The file holds credentials, so one
|
|
152
|
+
* that other users can read is refused rather than used.
|
|
153
|
+
*/
|
|
154
|
+
export function readBucket(path: string): Bucket | null {
|
|
155
|
+
if (!existsSync(path)) return null;
|
|
156
|
+
if (process.platform !== 'win32' && (statSync(path).mode & 0o077) !== 0)
|
|
157
|
+
throw new FleetUsageError(
|
|
158
|
+
`${path} holds bucket credentials and must be private. Run chmod 600 on it.`
|
|
159
|
+
);
|
|
160
|
+
let value: unknown;
|
|
161
|
+
try {
|
|
162
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
163
|
+
} catch {
|
|
164
|
+
return invalid('the file is not JSON.');
|
|
165
|
+
}
|
|
166
|
+
return parseBucket(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function writeBucket(path: string, bucket: Bucket): void {
|
|
170
|
+
const { kind, ...settings } = bucket;
|
|
171
|
+
const body =
|
|
172
|
+
kind === 'directory'
|
|
173
|
+
? { version: 1, directory: bucket.path }
|
|
174
|
+
: { version: 1, ...settings };
|
|
175
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
176
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
177
|
+
try {
|
|
178
|
+
writeFileSync(temporary, JSON.stringify(body, null, 2) + '\n', {
|
|
179
|
+
mode: 0o600,
|
|
180
|
+
flag: 'wx',
|
|
181
|
+
});
|
|
182
|
+
renameSync(temporary, path);
|
|
183
|
+
} finally {
|
|
184
|
+
rmSync(temporary, { force: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** What a report may say about the bucket: never a credential. */
|
|
189
|
+
export function describeBucket(bucket: Bucket | null): Record<string, unknown> {
|
|
190
|
+
if (!bucket) return { configured: false };
|
|
191
|
+
if (bucket.kind === 'directory')
|
|
192
|
+
return { configured: true, kind: 'directory', directory: bucket.path };
|
|
193
|
+
return {
|
|
194
|
+
configured: true,
|
|
195
|
+
kind: 's3',
|
|
196
|
+
bucket: bucket.bucket,
|
|
197
|
+
...(bucket.endpoint ? { endpoint: bucket.endpoint } : {}),
|
|
198
|
+
...(bucket.region ? { region: bucket.region } : {}),
|
|
199
|
+
prefix: bucket.prefix,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The same target replicates the same database; a different one starts over. */
|
|
204
|
+
export function bucketIdentity(bucket: Bucket): string {
|
|
205
|
+
return bucket.kind === 'directory'
|
|
206
|
+
? `directory:${bucket.path}`
|
|
207
|
+
: `s3:${bucket.endpoint ?? 'aws'}/${bucket.bucket}/${bucket.prefix}`;
|
|
208
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Check, Status } from '../types';
|
|
2
|
+
import { DIVERGED_REMEDY, type Standing } from './sync';
|
|
3
|
+
|
|
4
|
+
const INIT =
|
|
5
|
+
'Name a bucket under "state" in fleet.json and pass its credentials in DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY (see docs/state.md); without a fleet.json, devfleet fleet-state --init --bucket NAME --endpoint https://HOST. Until then a lost manager loses the roster, the approved revision and the configuration last applied.';
|
|
6
|
+
|
|
7
|
+
const FINDINGS: Record<
|
|
8
|
+
Standing,
|
|
9
|
+
{ status: Status; summary: string; action?: string }
|
|
10
|
+
> = {
|
|
11
|
+
'in-sync': {
|
|
12
|
+
status: 'pass',
|
|
13
|
+
summary: 'The bucket holds the fleet state this machine works from.',
|
|
14
|
+
},
|
|
15
|
+
'local-only': {
|
|
16
|
+
status: 'warn',
|
|
17
|
+
summary:
|
|
18
|
+
'No bucket is configured: this machine holds the only copy of the fleet state.',
|
|
19
|
+
action: INIT,
|
|
20
|
+
},
|
|
21
|
+
empty: {
|
|
22
|
+
status: 'warn',
|
|
23
|
+
summary: 'Neither this machine nor the bucket holds any fleet state yet.',
|
|
24
|
+
action:
|
|
25
|
+
'Run devfleet fleet-sync from the project that holds your fleet.json: it records the configuration, the roster and the approved revision. devfleet fleet-state --example prints a fleet.json to start from.',
|
|
26
|
+
},
|
|
27
|
+
unpublished: {
|
|
28
|
+
status: 'warn',
|
|
29
|
+
summary:
|
|
30
|
+
'The bucket holds no fleet state yet; this machine has the only copy.',
|
|
31
|
+
action: 'Upload it: devfleet fleet-state --push.',
|
|
32
|
+
},
|
|
33
|
+
'local-ahead': {
|
|
34
|
+
status: 'warn',
|
|
35
|
+
summary: 'This machine has fleet state the bucket has not received.',
|
|
36
|
+
action: 'Upload it: devfleet fleet-state --push.',
|
|
37
|
+
},
|
|
38
|
+
'bucket-ahead': {
|
|
39
|
+
status: 'warn',
|
|
40
|
+
summary:
|
|
41
|
+
'The bucket holds a newer fleet state than this machine; another manager changed it.',
|
|
42
|
+
action:
|
|
43
|
+
'The next fleet-sync takes it automatically; devfleet fleet-state --restore takes it now.',
|
|
44
|
+
},
|
|
45
|
+
unreachable: {
|
|
46
|
+
status: 'fail',
|
|
47
|
+
summary:
|
|
48
|
+
'The bucket did not answer, so the fleet state could not be compared or uploaded.',
|
|
49
|
+
action:
|
|
50
|
+
'Check the network, the bucket named under "state" in fleet.json (or saved in bucket.json) and its credentials: DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY for this command, or the ones devfleet fleet-state --init saved on this machine. Then run devfleet fleet-state --push, which also installs the pinned Litestream when it is missing (that needs mise, which the first fleet-sync on a machine installs). Details are withheld to protect credentials.',
|
|
51
|
+
},
|
|
52
|
+
diverged: {
|
|
53
|
+
status: 'fail',
|
|
54
|
+
summary: 'This machine and the bucket hold different fleet states.',
|
|
55
|
+
action: DIVERGED_REMEDY,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Whether the bucket holds this machine's fleet state. `advisory` reports a
|
|
60
|
+
* problem as a warning: for a run that succeeded in everything else.
|
|
61
|
+
*/
|
|
62
|
+
export function replicaCheck(
|
|
63
|
+
standing: Standing,
|
|
64
|
+
options: { advisory?: boolean } = {}
|
|
65
|
+
): Check {
|
|
66
|
+
const finding = FINDINGS[standing];
|
|
67
|
+
return {
|
|
68
|
+
id: 'state:replica',
|
|
69
|
+
status:
|
|
70
|
+
options.advisory && finding.status === 'fail' ? 'warn' : finding.status,
|
|
71
|
+
summary: finding.summary,
|
|
72
|
+
...(finding.action ? { remedy: { action: finding.action } } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { FleetUsageError } from '../errors';
|
|
3
|
+
import { declaredState } from '../fleet-config';
|
|
4
|
+
import {
|
|
5
|
+
ACCESS_KEY_ID_ENV,
|
|
6
|
+
SECRET_ACCESS_KEY_ENV,
|
|
7
|
+
bucketIdentity,
|
|
8
|
+
parseLocation,
|
|
9
|
+
readBucket,
|
|
10
|
+
withCredentials,
|
|
11
|
+
type Bucket,
|
|
12
|
+
} from './bucket';
|
|
13
|
+
import { bucketPath } from './paths';
|
|
14
|
+
|
|
15
|
+
/** How a devfleet command reaches the bucket, and where each part came from.
|
|
16
|
+
* Never a credential itself.
|
|
17
|
+
*/
|
|
18
|
+
export interface Connection {
|
|
19
|
+
bucket: Bucket | null;
|
|
20
|
+
/** `configuration`: the `state` block of the fleet configuration.
|
|
21
|
+
* `saved`: this machine's bucket.json.
|
|
22
|
+
*/
|
|
23
|
+
location: 'configuration' | 'saved' | null;
|
|
24
|
+
/** `environment`: the two `DEVFLEET_BUCKET_*` variables. `saved`: this
|
|
25
|
+
* machine's bucket.json. `ambient`: none given, so the standard AWS
|
|
26
|
+
* environment or profile.
|
|
27
|
+
*/
|
|
28
|
+
credentials: 'environment' | 'saved' | 'ambient' | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The credentials passed to this command, or null. */
|
|
32
|
+
export function environmentCredentials(
|
|
33
|
+
env: NodeJS.ProcessEnv
|
|
34
|
+
): { accessKeyId: string; secretAccessKey: string } | null {
|
|
35
|
+
const accessKeyId = env[ACCESS_KEY_ID_ENV];
|
|
36
|
+
const secretAccessKey = env[SECRET_ACCESS_KEY_ENV];
|
|
37
|
+
if (!accessKeyId && !secretAccessKey) return null;
|
|
38
|
+
if (!accessKeyId || !secretAccessKey)
|
|
39
|
+
throw new FleetUsageError(
|
|
40
|
+
`Set both ${ACCESS_KEY_ID_ENV} and ${SECRET_ACCESS_KEY_ENV}, or neither. Credentials are never taken from arguments.`
|
|
41
|
+
);
|
|
42
|
+
return { accessKeyId, secretAccessKey };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The configuration says where the bucket is; credentials are passed to the
|
|
46
|
+
* command. A machine that runs unattended (the schedule, a hand-off from a
|
|
47
|
+
* worker) gets no environment, so `fleet-state --init` may save both there:
|
|
48
|
+
* a saved location is used when the configuration names none, and saved
|
|
49
|
+
* credentials only for the bucket they were saved for.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveConnection(
|
|
52
|
+
declared: Bucket | null,
|
|
53
|
+
saved: Bucket | null,
|
|
54
|
+
env: NodeJS.ProcessEnv
|
|
55
|
+
): Connection {
|
|
56
|
+
const target = declared ?? saved;
|
|
57
|
+
if (!target) return { bucket: null, location: null, credentials: null };
|
|
58
|
+
const location = declared ? 'configuration' : 'saved';
|
|
59
|
+
if (target.kind === 'directory')
|
|
60
|
+
return { bucket: target, location, credentials: null };
|
|
61
|
+
const given = environmentCredentials(env);
|
|
62
|
+
if (given)
|
|
63
|
+
return {
|
|
64
|
+
bucket: withCredentials(target, given),
|
|
65
|
+
location,
|
|
66
|
+
credentials: 'environment',
|
|
67
|
+
};
|
|
68
|
+
if (
|
|
69
|
+
saved?.kind === 's3' &&
|
|
70
|
+
saved.accessKeyId &&
|
|
71
|
+
bucketIdentity(saved) === bucketIdentity(target)
|
|
72
|
+
)
|
|
73
|
+
return {
|
|
74
|
+
bucket: withCredentials(target, saved),
|
|
75
|
+
location,
|
|
76
|
+
credentials: 'saved',
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
bucket: withCredentials(target, {}),
|
|
80
|
+
location,
|
|
81
|
+
credentials: 'ambient',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function connection(env = process.env, home = homedir()): Connection {
|
|
86
|
+
const declared = declaredState(env, home);
|
|
87
|
+
return resolveConnection(
|
|
88
|
+
declared ? parseLocation(declared) : null,
|
|
89
|
+
readBucket(bucketPath(env, home)),
|
|
90
|
+
env
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { FleetUsageError } from '../errors';
|
|
3
|
+
import {
|
|
4
|
+
parseEnrollment,
|
|
5
|
+
readEnrollment,
|
|
6
|
+
type Enrollment,
|
|
7
|
+
} from '../machine/state';
|
|
8
|
+
import { adoptEarlier } from './legacy';
|
|
9
|
+
import { databasePath, stateRoot } from './paths';
|
|
10
|
+
import { readDocument, writeDocument } from './store';
|
|
11
|
+
|
|
12
|
+
/** The manager's approved and applied revision, from the fleet state. A
|
|
13
|
+
* machine that has no state database yet reads the file that held it before
|
|
14
|
+
* (`enrollment.json` in `earlier`); its first approval moves it.
|
|
15
|
+
*/
|
|
16
|
+
export async function loadEnrollment(
|
|
17
|
+
earlier: string | null,
|
|
18
|
+
env = process.env
|
|
19
|
+
): Promise<Enrollment | null> {
|
|
20
|
+
const database = databasePath(stateRoot(env));
|
|
21
|
+
if (earlier && !existsSync(database)) return readEnrollment(earlier);
|
|
22
|
+
const stored = readDocument(database, 'enrollment');
|
|
23
|
+
if (stored === null) return null;
|
|
24
|
+
let value: unknown;
|
|
25
|
+
try {
|
|
26
|
+
value = JSON.parse(stored);
|
|
27
|
+
} catch {
|
|
28
|
+
value = null;
|
|
29
|
+
}
|
|
30
|
+
const enrollment = parseEnrollment(value);
|
|
31
|
+
if (!enrollment)
|
|
32
|
+
throw new FleetUsageError(
|
|
33
|
+
'Invalid enrollment in the fleet state. Restore it from the bucket with devfleet fleet-state --restore; do not enroll a replacement using another machine’s identity.'
|
|
34
|
+
);
|
|
35
|
+
return enrollment;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function storeEnrollment(state: Enrollment, env = process.env): void {
|
|
39
|
+
adoptEarlier(env);
|
|
40
|
+
writeDocument(
|
|
41
|
+
databasePath(stateRoot(env)),
|
|
42
|
+
'enrollment',
|
|
43
|
+
JSON.stringify(state) + '\n'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parseConfig } from '../config';
|
|
5
|
+
import { parseDefinition } from '../definition';
|
|
6
|
+
import { parseEnrollment } from '../machine/state';
|
|
7
|
+
import { databasePath, definitionPath, legacyHome, stateRoot } from './paths';
|
|
8
|
+
import { DOCUMENTS, writeDocument, type DocumentName } from './store';
|
|
9
|
+
|
|
10
|
+
/** Where each document was kept before the fleet state existed. A machine
|
|
11
|
+
* that has no state database yet still reads them; its first change moves
|
|
12
|
+
* all of them into the state, and nothing reads or writes them afterwards.
|
|
13
|
+
*/
|
|
14
|
+
const LEGACY: Record<DocumentName, string> = {
|
|
15
|
+
// The copy fleet-sync installs; it stays the launchers' copy afterwards.
|
|
16
|
+
definition: '.config/dev-fleet/fleet.json',
|
|
17
|
+
roster: '.config/personal/fleet.json',
|
|
18
|
+
enrollment: '.local/share/personal/fleet/manager/enrollment.json',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function legacyPath(
|
|
22
|
+
name: DocumentName,
|
|
23
|
+
env = process.env,
|
|
24
|
+
home = homedir()
|
|
25
|
+
): string | null {
|
|
26
|
+
const earlier = legacyHome(env, home);
|
|
27
|
+
return earlier ? join(earlier, LEGACY[name]) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The earlier file's text, or null: when there is no such file, and once
|
|
31
|
+
* this machine has a state database, which then holds everything.
|
|
32
|
+
*/
|
|
33
|
+
export function readLegacy(
|
|
34
|
+
name: DocumentName,
|
|
35
|
+
env = process.env,
|
|
36
|
+
home = homedir()
|
|
37
|
+
): string | null {
|
|
38
|
+
const path = legacyPath(name, env, home);
|
|
39
|
+
if (!path || existsSync(databasePath(stateRoot(env, home)))) return null;
|
|
40
|
+
try {
|
|
41
|
+
const info = lstatSync(path);
|
|
42
|
+
if (!info.isFile() || info.size > 1024 * 1024) return null;
|
|
43
|
+
return readFileSync(path, 'utf8');
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether a document's text is what its readers accept. */
|
|
50
|
+
const VALID: Record<DocumentName, (value: unknown) => boolean> = {
|
|
51
|
+
definition: (value) => Boolean(parseDefinition(value)),
|
|
52
|
+
roster: (value) => Boolean(parseConfig(value)),
|
|
53
|
+
enrollment: (value) => parseEnrollment(value) !== null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function valid(name: DocumentName, text: string): boolean {
|
|
57
|
+
try {
|
|
58
|
+
return VALID[name](JSON.parse(text));
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Run before the first change on a machine. A state database that does not
|
|
65
|
+
* exist yet starts with what the machine kept in files, so creating it never
|
|
66
|
+
* hides a document the machine still depends on. Returns what was moved.
|
|
67
|
+
*/
|
|
68
|
+
export function adoptEarlier(
|
|
69
|
+
env = process.env,
|
|
70
|
+
home = homedir()
|
|
71
|
+
): DocumentName[] {
|
|
72
|
+
const root = stateRoot(env, home);
|
|
73
|
+
if (existsSync(databasePath(root))) return [];
|
|
74
|
+
const named = env['DEVFLEET_CONFIG'];
|
|
75
|
+
const earlier = DOCUMENTS.map((name) => ({
|
|
76
|
+
name,
|
|
77
|
+
text: readLegacy(name, env, home),
|
|
78
|
+
}));
|
|
79
|
+
const adopted: DocumentName[] = [];
|
|
80
|
+
for (const { name, text } of earlier) {
|
|
81
|
+
// A configuration kept in a file of your own stays there.
|
|
82
|
+
if (name === 'definition' && named && named !== definitionPath(root))
|
|
83
|
+
continue;
|
|
84
|
+
if (text === null || !valid(name, text)) continue;
|
|
85
|
+
writeDocument(databasePath(root), name, text);
|
|
86
|
+
adopted.push(name);
|
|
87
|
+
}
|
|
88
|
+
return adopted;
|
|
89
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { stringify } from 'yaml';
|
|
4
|
+
import { pinnedPath } from '../agents/toolchain';
|
|
5
|
+
import { PACKAGE_ROOT } from '../package-info';
|
|
6
|
+
import type { Runner } from '../types';
|
|
7
|
+
import type { Bucket } from './bucket';
|
|
8
|
+
|
|
9
|
+
/** Where this code runs from: its mise.toml pins Litestream. */
|
|
10
|
+
const SOURCE = PACKAGE_ROOT;
|
|
11
|
+
|
|
12
|
+
export interface Replication {
|
|
13
|
+
database: string;
|
|
14
|
+
bucket: Bucket;
|
|
15
|
+
run: Runner;
|
|
16
|
+
/** False for a read-only command: it does not install the binary. */
|
|
17
|
+
install?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const located = new WeakMap<Runner, Promise<string | null>>();
|
|
21
|
+
|
|
22
|
+
/** The pinned Litestream binary. It is a lazy mise pin, installed the first
|
|
23
|
+
* time the manager needs it; workers never do.
|
|
24
|
+
*/
|
|
25
|
+
function binary(run: Runner, install: boolean): Promise<string | null> {
|
|
26
|
+
// An installed package is a directory nobody ran `mise trust` in. Its pins
|
|
27
|
+
// are trusted for these commands only; nothing is recorded on the machine.
|
|
28
|
+
const options = {
|
|
29
|
+
cwd: SOURCE,
|
|
30
|
+
env: { PATH: pinnedPath(), MISE_TRUSTED_CONFIG_PATHS: SOURCE },
|
|
31
|
+
};
|
|
32
|
+
const locate = async (): Promise<string | null> => {
|
|
33
|
+
const which = await run(['mise', 'which', 'litestream'], options);
|
|
34
|
+
const path = which.stdout.trim();
|
|
35
|
+
return which.ok && path.startsWith('/') ? path : null;
|
|
36
|
+
};
|
|
37
|
+
const known = located.get(run);
|
|
38
|
+
if (known) return known;
|
|
39
|
+
const found = locate().then(async (path) => {
|
|
40
|
+
if (path || !install) return path;
|
|
41
|
+
await run(['mise', 'install', 'litestream'], {
|
|
42
|
+
...options,
|
|
43
|
+
timeoutMs: 180_000,
|
|
44
|
+
});
|
|
45
|
+
return locate();
|
|
46
|
+
});
|
|
47
|
+
// A missing binary is looked for again: a later command may install it.
|
|
48
|
+
void found.then((path) => {
|
|
49
|
+
if (!path) located.delete(run);
|
|
50
|
+
});
|
|
51
|
+
located.set(run, found);
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function replica(database: string, bucket: Bucket): Record<string, unknown> {
|
|
56
|
+
if (bucket.kind === 'directory')
|
|
57
|
+
return { type: 'file', path: join(bucket.path, basename(database)) };
|
|
58
|
+
return {
|
|
59
|
+
type: 's3',
|
|
60
|
+
bucket: bucket.bucket,
|
|
61
|
+
path: `${bucket.prefix}/${basename(database)}`,
|
|
62
|
+
...(bucket.region ? { region: bucket.region } : {}),
|
|
63
|
+
// Every S3-compatible service answers path-style requests.
|
|
64
|
+
...(bucket.endpoint
|
|
65
|
+
? { endpoint: bucket.endpoint, 'force-path-style': true }
|
|
66
|
+
: {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The Litestream configuration for one database. It never holds a
|
|
71
|
+
* credential: those reach Litestream through its environment.
|
|
72
|
+
*/
|
|
73
|
+
export function litestreamConfig(database: string, bucket: Bucket): string {
|
|
74
|
+
return stringify({
|
|
75
|
+
logging: { level: 'error' },
|
|
76
|
+
// Each change uploads a full snapshot (the database is a few kilobytes),
|
|
77
|
+
// so a restore reads one object however long the history is.
|
|
78
|
+
snapshot: { interval: '24h', retention: '720h' },
|
|
79
|
+
dbs: [{ path: database, replica: replica(database, bucket) }],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function litestreamEnvironment(bucket: Bucket): Record<string, string> {
|
|
84
|
+
return bucket.kind === 's3' && bucket.accessKeyId && bucket.secretAccessKey
|
|
85
|
+
? {
|
|
86
|
+
// The standard names: they replace any keys in the operator's shell,
|
|
87
|
+
// which Litestream would otherwise prefer.
|
|
88
|
+
AWS_ACCESS_KEY_ID: bucket.accessKeyId,
|
|
89
|
+
AWS_SECRET_ACCESS_KEY: bucket.secretAccessKey,
|
|
90
|
+
// These keys are not a session's: a token left in the shell must
|
|
91
|
+
// not be sent with them.
|
|
92
|
+
AWS_SESSION_TOKEN: '',
|
|
93
|
+
}
|
|
94
|
+
: {};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Litestream is run once per change rather than as a daemon: a devfleet
|
|
98
|
+
* command that changed the state has not finished until the bucket has it,
|
|
99
|
+
* and nothing has to be kept running on the manager.
|
|
100
|
+
*/
|
|
101
|
+
async function invoke(
|
|
102
|
+
{ database, bucket, run, install }: Replication,
|
|
103
|
+
command: string,
|
|
104
|
+
args: string[]
|
|
105
|
+
): Promise<boolean> {
|
|
106
|
+
const litestream = await binary(run, install !== false);
|
|
107
|
+
if (!litestream) return false;
|
|
108
|
+
const config = join(dirname(database), 'litestream.yml');
|
|
109
|
+
mkdirSync(dirname(database), { recursive: true, mode: 0o700 });
|
|
110
|
+
writeFileSync(config, litestreamConfig(database, bucket), { mode: 0o600 });
|
|
111
|
+
const result = await run(
|
|
112
|
+
[litestream, command, '-no-expand-env', '-config', config, ...args],
|
|
113
|
+
// A bucket that does not answer must not hold a fleet-sync up for long.
|
|
114
|
+
{ timeoutMs: 60_000, env: litestreamEnvironment(bucket) }
|
|
115
|
+
);
|
|
116
|
+
return result.ok;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Uploads everything the bucket does not have yet. */
|
|
120
|
+
export async function replicate(target: Replication): Promise<boolean> {
|
|
121
|
+
return invoke(target, 'replicate', [
|
|
122
|
+
'-once',
|
|
123
|
+
'-force-snapshot',
|
|
124
|
+
'-enforce-retention',
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Downloads the bucket's latest version to `output`, which must not exist.
|
|
129
|
+
* `absent` means the bucket is reachable and holds no state yet.
|
|
130
|
+
*/
|
|
131
|
+
export async function restore(
|
|
132
|
+
target: Replication,
|
|
133
|
+
output: string
|
|
134
|
+
): Promise<'restored' | 'absent' | 'failed'> {
|
|
135
|
+
const ran = await invoke(target, 'restore', [
|
|
136
|
+
'-if-replica-exists',
|
|
137
|
+
'-o',
|
|
138
|
+
output,
|
|
139
|
+
target.database,
|
|
140
|
+
]);
|
|
141
|
+
if (!ran) return 'failed';
|
|
142
|
+
return existsSync(output) ? 'restored' : 'absent';
|
|
143
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Reads the `definition` document from the fleet state under `root`. */
|
|
2
|
+
type DefinitionReader = (root: string) => string | null;
|
|
3
|
+
|
|
4
|
+
let reader: DefinitionReader | null = null;
|
|
5
|
+
|
|
6
|
+
/** Called by the devfleet CLI. Only it reads the fleet's state directly, so
|
|
7
|
+
* a change to the configuration reaches the manager's launchers and
|
|
8
|
+
* scheduled jobs the way it reaches every worker: as the copy fleet-sync
|
|
9
|
+
* installs. The reader is handed in rather than imported because launchers
|
|
10
|
+
* and the worker probe load the configuration under Node, which cannot open
|
|
11
|
+
* the state database. Null undoes it (tests).
|
|
12
|
+
*/
|
|
13
|
+
export function actAsOperator(read: DefinitionReader | null): void {
|
|
14
|
+
reader = read;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const isOperator = (): boolean => reader !== null;
|
|
18
|
+
|
|
19
|
+
export const stateDefinition = (root: string): string | null =>
|
|
20
|
+
reader?.(root) ?? null;
|