@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,226 @@
|
|
|
1
|
+
"""Consistent, secret-minimal checkpoints. Runtime state is never executable code."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
STATE_PATHS = (
|
|
14
|
+
"runtime/config.yaml",
|
|
15
|
+
"runtime/auths",
|
|
16
|
+
"runtime/static/management.html",
|
|
17
|
+
"tailscale",
|
|
18
|
+
"endpoint.json",
|
|
19
|
+
"public-endpoint-enabled",
|
|
20
|
+
"repository.json",
|
|
21
|
+
"status/gc-success.json",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def regular_tree(path):
|
|
26
|
+
"""Reject links/devices before copying or publishing recovered state."""
|
|
27
|
+
mode = path.lstat().st_mode
|
|
28
|
+
if stat.S_ISDIR(mode):
|
|
29
|
+
for child in path.iterdir():
|
|
30
|
+
regular_tree(child)
|
|
31
|
+
elif not stat.S_ISREG(mode):
|
|
32
|
+
raise ValueError("Checkpoint must contain only regular files/directories")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def hashes(root):
|
|
36
|
+
regular_tree(root)
|
|
37
|
+
return {
|
|
38
|
+
str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
|
|
39
|
+
for path in sorted(root.rglob("*"))
|
|
40
|
+
if path.is_file() and path != root / "CHECKPOINT.json"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Upstream writes these beside credentials; they are never checkpointed.
|
|
45
|
+
AUTH_TRANSIENT = ("*.lock", "logs")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate(root):
|
|
49
|
+
regular_tree(root)
|
|
50
|
+
manifest = json.loads((root / "CHECKPOINT.json").read_text())
|
|
51
|
+
if manifest.get("version") != 1 or manifest.get("files") != hashes(root):
|
|
52
|
+
raise ValueError("Checkpoint incomplete or changed")
|
|
53
|
+
state = root / "state"
|
|
54
|
+
for path in root.rglob("*"):
|
|
55
|
+
if not path.is_file():
|
|
56
|
+
continue
|
|
57
|
+
name = str(path.relative_to(root))
|
|
58
|
+
if name in ("CHECKPOINT.json", "INSTALLATION.json"):
|
|
59
|
+
continue
|
|
60
|
+
if not any(
|
|
61
|
+
name == "state/" + allowed or name.startswith("state/" + allowed + "/")
|
|
62
|
+
for allowed in STATE_PATHS
|
|
63
|
+
):
|
|
64
|
+
raise ValueError("Unexpected checkpoint file")
|
|
65
|
+
if not (state / "runtime/static/management.html").is_file():
|
|
66
|
+
raise ValueError("Dashboard missing")
|
|
67
|
+
if not (state / "runtime/auths").is_dir():
|
|
68
|
+
raise ValueError("Provider cache directory missing")
|
|
69
|
+
for path in (state / "runtime/auths").iterdir():
|
|
70
|
+
if path.name == "kv.db" and path.is_file() and path.stat().st_size > 0:
|
|
71
|
+
continue # Opaque upstream bbolt state, captured while the process is stopped.
|
|
72
|
+
# .json: provider credential; .cds: upstream per-account cooldown
|
|
73
|
+
# records (JSON object, no credential), kept so limits survive moves.
|
|
74
|
+
if path.suffix not in (".json", ".cds") or not path.is_file():
|
|
75
|
+
raise ValueError("Unexpected provider file")
|
|
76
|
+
if not isinstance(json.loads(path.read_text()), dict):
|
|
77
|
+
raise ValueError("Provider credential must be an object")
|
|
78
|
+
subprocess.run(
|
|
79
|
+
[
|
|
80
|
+
str(pathlib.Path(__file__).parent / "validate-config"),
|
|
81
|
+
str(state / "runtime/config.yaml"),
|
|
82
|
+
],
|
|
83
|
+
check=True,
|
|
84
|
+
capture_output=True,
|
|
85
|
+
)
|
|
86
|
+
return manifest
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def stamps(root):
|
|
90
|
+
"""Size and modification time of every state file whose copy must be whole:
|
|
91
|
+
the runtime tree and the Tailscale identity. Changing logs are not state."""
|
|
92
|
+
found = {}
|
|
93
|
+
for name in ("runtime", "tailscale/tailscaled.state"):
|
|
94
|
+
source = root / "state" / name
|
|
95
|
+
paths = [source] if source.is_file() else source.rglob("*") if source.is_dir() else []
|
|
96
|
+
for path in paths:
|
|
97
|
+
if path.is_file() and not any(
|
|
98
|
+
fnmatch.fnmatch(part, pattern)
|
|
99
|
+
for part in path.relative_to(root).parts
|
|
100
|
+
for pattern in AUTH_TRANSIENT
|
|
101
|
+
):
|
|
102
|
+
status = path.stat()
|
|
103
|
+
found[str(path)] = (status.st_size, status.st_mtime_ns)
|
|
104
|
+
return found
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def copy_state(root, target):
|
|
108
|
+
target.mkdir(mode=0o700)
|
|
109
|
+
(target / "state").mkdir(mode=0o700)
|
|
110
|
+
for name in STATE_PATHS:
|
|
111
|
+
source = root / "state" / name
|
|
112
|
+
if not source.exists() and not source.is_symlink():
|
|
113
|
+
continue
|
|
114
|
+
regular_tree(source)
|
|
115
|
+
destination = target / "state" / name
|
|
116
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
if source.is_dir():
|
|
118
|
+
# Process-local locks and upstream error logs (request
|
|
119
|
+
# content) are never restored.
|
|
120
|
+
ignore = (
|
|
121
|
+
shutil.ignore_patterns(*AUTH_TRANSIENT)
|
|
122
|
+
if name == "runtime/auths"
|
|
123
|
+
else None
|
|
124
|
+
)
|
|
125
|
+
shutil.copytree(source, destination, ignore=ignore)
|
|
126
|
+
else:
|
|
127
|
+
shutil.copyfile(source, destination)
|
|
128
|
+
shutil.copyfile(root / "INSTALLATION.json", target / "INSTALLATION.json")
|
|
129
|
+
(target / "CHECKPOINT.json").write_text(
|
|
130
|
+
json.dumps({"version": 1, "files": hashes(target)}) + "\n"
|
|
131
|
+
)
|
|
132
|
+
validate(target)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def capture(root, target, attempts=6, sleep=time.sleep):
|
|
136
|
+
"""Copies the state of the running stack without ever stopping it: agents
|
|
137
|
+
use the proxy around the clock, and a stopped connector fails every one of
|
|
138
|
+
their requests. A copy is consistent when no state file changed while it
|
|
139
|
+
was taken (the proxy writes a file, including its bbolt store, in one
|
|
140
|
+
synced step); otherwise it is discarded and taken again. A stack that
|
|
141
|
+
never holds still fails the backup, which the monitor reports."""
|
|
142
|
+
for attempt in range(attempts):
|
|
143
|
+
before = stamps(root)
|
|
144
|
+
try:
|
|
145
|
+
copy_state(root, target)
|
|
146
|
+
if stamps(root) == before:
|
|
147
|
+
return
|
|
148
|
+
except (OSError, ValueError):
|
|
149
|
+
# A file replaced mid-copy; only the last attempt's error counts.
|
|
150
|
+
if attempt + 1 == attempts:
|
|
151
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
152
|
+
raise
|
|
153
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
154
|
+
sleep(2)
|
|
155
|
+
raise ValueError("State kept changing during capture")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def locate(restored):
|
|
159
|
+
"""Restic retains the source absolute path; never assume a source hostname/path."""
|
|
160
|
+
candidates = list(restored.rglob("CHECKPOINT.json"))
|
|
161
|
+
if len(candidates) != 1:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"Expected exactly one modern checkpoint; use manual legacy recovery"
|
|
164
|
+
)
|
|
165
|
+
root = candidates[0].parent
|
|
166
|
+
validate(root)
|
|
167
|
+
return root
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def hydrate(checkpoint, state):
|
|
171
|
+
"""Populate an empty cache only. A crash marker prevents partial-cache startup."""
|
|
172
|
+
validate(checkpoint)
|
|
173
|
+
names = (
|
|
174
|
+
"runtime",
|
|
175
|
+
"tailscale",
|
|
176
|
+
"endpoint.json",
|
|
177
|
+
"public-endpoint-enabled",
|
|
178
|
+
"repository.json",
|
|
179
|
+
)
|
|
180
|
+
repository = state / "repository.json"
|
|
181
|
+
if repository.exists() or repository.is_symlink():
|
|
182
|
+
regular_tree(repository)
|
|
183
|
+
saved = checkpoint / "state/repository.json"
|
|
184
|
+
if not saved.is_file() or json.loads(repository.read_text()) != json.loads(
|
|
185
|
+
saved.read_text()
|
|
186
|
+
):
|
|
187
|
+
raise ValueError("Existing repository identity differs from checkpoint")
|
|
188
|
+
names = tuple(name for name in names if name != "repository.json")
|
|
189
|
+
if any((state / name).exists() or (state / name).is_symlink() for name in names):
|
|
190
|
+
raise ValueError(
|
|
191
|
+
"Recovery requires an empty cache; preserve or remove existing cache explicitly"
|
|
192
|
+
)
|
|
193
|
+
marker = state / "recovery-incomplete"
|
|
194
|
+
if marker.exists():
|
|
195
|
+
raise ValueError("Interrupted recovery requires inspection")
|
|
196
|
+
marker.write_text("Recovery in progress; do not start services.\n")
|
|
197
|
+
created = []
|
|
198
|
+
try:
|
|
199
|
+
for name in names:
|
|
200
|
+
source = checkpoint / "state" / name
|
|
201
|
+
if not source.exists():
|
|
202
|
+
continue
|
|
203
|
+
destination = state / name
|
|
204
|
+
created.append(destination)
|
|
205
|
+
if source.is_dir():
|
|
206
|
+
shutil.copytree(source, destination)
|
|
207
|
+
else:
|
|
208
|
+
shutil.copyfile(source, destination)
|
|
209
|
+
paths = (
|
|
210
|
+
[destination, *destination.rglob("*")]
|
|
211
|
+
if destination.is_dir()
|
|
212
|
+
else [destination]
|
|
213
|
+
)
|
|
214
|
+
for path in paths:
|
|
215
|
+
path.chmod(0o700 if path.is_dir() else 0o600)
|
|
216
|
+
if os.geteuid() == 0:
|
|
217
|
+
os.chown(path, 1000, 1000)
|
|
218
|
+
marker.unlink()
|
|
219
|
+
except BaseException:
|
|
220
|
+
for destination in reversed(created):
|
|
221
|
+
if destination.is_dir():
|
|
222
|
+
shutil.rmtree(destination)
|
|
223
|
+
else:
|
|
224
|
+
destination.unlink(missing_ok=True)
|
|
225
|
+
marker.unlink()
|
|
226
|
+
raise
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Repository entry point for installing and operating the local service."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
sys.dont_write_bytecode = True
|
|
7
|
+
|
|
8
|
+
import pathlib as _pathlib
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
11
|
+
import cliproxy_host
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import pathlib
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
source = pathlib.Path(__file__).resolve().parent
|
|
20
|
+
root = cliproxy_host.default_root().resolve()
|
|
21
|
+
args = sys.argv[1:]
|
|
22
|
+
if not args or args[0] in ["help", "--help", "-h"]:
|
|
23
|
+
print(
|
|
24
|
+
"Usage: devfleet cliproxy COMMAND\nCommands: install [--revision REV|--rollback|--dry-run|--prepare-only], audit, public-endpoint [enable|start|disable|verify], status, start, stop, restart, health, models, logs, validate, apply FILE, add-account PROVIDER, inference PROTOCOL MODEL, model-health [--profile liveness|readiness|light|nightly|manual] [--suite core|cursor|all] [--target live|mock], model-suite (same flags; defaults to nightly), backup, gc, snapshots, check, check-full, restore SNAPSHOT EMPTY_DIRECTORY, restore-cache --previous-node-stopped, vault-login, recover, recover-node NEW_DIRECTORY [--vault-token-file FILE], verify-recovery, portable-login, activate, drain"
|
|
25
|
+
)
|
|
26
|
+
sys.exit(0)
|
|
27
|
+
command, *rest = args
|
|
28
|
+
if command == "install":
|
|
29
|
+
invocation = [sys.executable, str(source / "install.py"), *rest]
|
|
30
|
+
# The legacy Linux host installs system units; the macOS manager is per-user.
|
|
31
|
+
linux = cliproxy_host.system() == "linux"
|
|
32
|
+
if linux and os.geteuid() != 0 and "--dry-run" not in rest:
|
|
33
|
+
invocation = ["sudo", *invocation]
|
|
34
|
+
elif command in ["model-health", "model-suite"]:
|
|
35
|
+
invocation = [sys.executable, "-B", str(source / "model_health.py"), command, *rest]
|
|
36
|
+
elif command == "recover-node":
|
|
37
|
+
invocation = [sys.executable, "-B", str(root / "scripts/recover_node.py"), *rest]
|
|
38
|
+
elif command == "public-endpoint":
|
|
39
|
+
invocation = [str(root / "scripts/public-endpoint"), *rest]
|
|
40
|
+
elif command == "audit":
|
|
41
|
+
invocation = [sys.executable, str(root / "scripts/audit.py"), *rest]
|
|
42
|
+
elif command in [
|
|
43
|
+
"backup",
|
|
44
|
+
"gc",
|
|
45
|
+
"snapshots",
|
|
46
|
+
"check",
|
|
47
|
+
"check-full",
|
|
48
|
+
"restore",
|
|
49
|
+
"restore-cache",
|
|
50
|
+
]:
|
|
51
|
+
invocation = [str(root / "scripts/backup"), command, *rest]
|
|
52
|
+
elif command in ["vault-login", "recover", "verify-recovery"]:
|
|
53
|
+
invocation = [
|
|
54
|
+
str(
|
|
55
|
+
root
|
|
56
|
+
/ "scripts"
|
|
57
|
+
/ ("vault-backup-login" if command == "vault-login" else "vault-recovery")
|
|
58
|
+
)
|
|
59
|
+
]
|
|
60
|
+
if command != "vault-login":
|
|
61
|
+
invocation.append("recover" if command == "recover" else "verify")
|
|
62
|
+
else:
|
|
63
|
+
invocation = [str(root / "scripts/ops"), command, *rest]
|
|
64
|
+
raise SystemExit(subprocess.call(invocation))
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Explicit host dispatch for the singleton proxy.
|
|
2
|
+
|
|
3
|
+
The manager (macOS) hosts the proxy with per-user launchd agents and Docker
|
|
4
|
+
Desktop; no sudo. Linux systemd hosting is legacy and kept for fencing and
|
|
5
|
+
decommissioning the previous owner. Everything here is pure or takes injected
|
|
6
|
+
commands so it can be tested offline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
sys.dont_write_bytecode = True
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import re
|
|
16
|
+
import socket
|
|
17
|
+
import subprocess
|
|
18
|
+
|
|
19
|
+
LINUX_ROOT = pathlib.Path("/opt/cliproxy-fleet")
|
|
20
|
+
# The tracked LaunchAgents reference this path relative to $HOME.
|
|
21
|
+
MACOS_ROOT = "Library/Application Support/cliproxy-fleet"
|
|
22
|
+
LINUX_IDS = (1000, 1000)
|
|
23
|
+
# Base PATH of the tracked LaunchAgents. Each agent puts the install's
|
|
24
|
+
# tools/bin (pinned python3 + PyYAML venv, pinned restic) in front of it.
|
|
25
|
+
LAUNCHD_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Docker.app/Contents/Resources/bin"
|
|
26
|
+
LAUNCHD_TOOLS = "$HOME/Library/Application Support/cliproxy-fleet/tools/bin"
|
|
27
|
+
DOCKER_DESKTOP_BIN = "/Applications/Docker.app/Contents/Resources/bin"
|
|
28
|
+
DOCKER_UNAVAILABLE = 69 # EX_UNAVAILABLE
|
|
29
|
+
|
|
30
|
+
SYSTEMD_SERVICE = "cliproxy-fleet.service"
|
|
31
|
+
SYSTEMD_SCHEDULED = [
|
|
32
|
+
"cliproxy-monitor.timer",
|
|
33
|
+
"cliproxy-vault-renew.timer",
|
|
34
|
+
"cliproxy-backup.timer",
|
|
35
|
+
"cliproxy-backup-check.timer",
|
|
36
|
+
"cliproxy-gc.timer",
|
|
37
|
+
]
|
|
38
|
+
LAUNCHD_SERVICE = "dev.devfleet.cliproxy-fleet"
|
|
39
|
+
LAUNCHD_SCHEDULED = [
|
|
40
|
+
"dev.devfleet.cliproxy-monitor",
|
|
41
|
+
"dev.devfleet.cliproxy-vault-renew",
|
|
42
|
+
"dev.devfleet.cliproxy-backup",
|
|
43
|
+
"dev.devfleet.cliproxy-backup-check",
|
|
44
|
+
"dev.devfleet.cliproxy-gc",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def system(platform=None):
|
|
49
|
+
platform = platform or sys.platform
|
|
50
|
+
if platform == "darwin":
|
|
51
|
+
return "macos"
|
|
52
|
+
if platform.startswith("linux"):
|
|
53
|
+
return "linux"
|
|
54
|
+
raise ValueError("Unsupported proxy host platform")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def default_root(environ=None, platform=None, home=None):
|
|
58
|
+
"""CLIPROXY_ROOT wins; otherwise the platform's installation root."""
|
|
59
|
+
environ = os.environ if environ is None else environ
|
|
60
|
+
if environ.get("CLIPROXY_ROOT"):
|
|
61
|
+
return pathlib.Path(environ["CLIPROXY_ROOT"])
|
|
62
|
+
if system(platform) == "macos":
|
|
63
|
+
return pathlib.Path(home or pathlib.Path.home()) / MACOS_ROOT
|
|
64
|
+
return LINUX_ROOT
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def runtime_ids(platform=None):
|
|
68
|
+
"""Container/state owner: the installing user on macOS, UID/GID 1000 on Linux."""
|
|
69
|
+
if system(platform) == "macos":
|
|
70
|
+
return os.getuid(), os.getgid()
|
|
71
|
+
return LINUX_IDS
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def runtime_home(platform=None):
|
|
75
|
+
if system(platform) == "macos":
|
|
76
|
+
return pathlib.Path.home()
|
|
77
|
+
import pwd
|
|
78
|
+
|
|
79
|
+
return pathlib.Path(pwd.getpwuid(LINUX_IDS[0]).pw_dir)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def normalize_node(name):
|
|
83
|
+
name = name.strip().lower()
|
|
84
|
+
name = name.removesuffix(".local")
|
|
85
|
+
return re.sub(r"[^a-z0-9-]", "-", name)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def local_node_id(platform=None, output=subprocess.check_output):
|
|
89
|
+
"""macOS LocalHostName (scutil) or the Linux hostname, normalized."""
|
|
90
|
+
if system(platform) == "macos":
|
|
91
|
+
name = output(["scutil", "--get", "LocalHostName"], text=True, timeout=10)
|
|
92
|
+
else:
|
|
93
|
+
name = socket.gethostname()
|
|
94
|
+
return normalize_node(name)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _executable(path):
|
|
98
|
+
return os.path.isfile(path) and os.access(path, os.X_OK)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _reachable(path):
|
|
102
|
+
connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
103
|
+
connection.settimeout(2)
|
|
104
|
+
try:
|
|
105
|
+
connection.connect(path)
|
|
106
|
+
return True
|
|
107
|
+
except OSError:
|
|
108
|
+
return False
|
|
109
|
+
finally:
|
|
110
|
+
connection.close()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class DockerUnavailable(Exception):
|
|
114
|
+
def __init__(self, code, message):
|
|
115
|
+
super().__init__(message)
|
|
116
|
+
self.code = code
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def docker_invocation(
|
|
120
|
+
args,
|
|
121
|
+
wrapper,
|
|
122
|
+
platform=None,
|
|
123
|
+
environ=None,
|
|
124
|
+
home=None,
|
|
125
|
+
executable=_executable,
|
|
126
|
+
reachable=_reachable,
|
|
127
|
+
):
|
|
128
|
+
"""Return (argv, environment) for the real Docker CLI behind the tracked wrapper."""
|
|
129
|
+
environ = dict(os.environ if environ is None else environ)
|
|
130
|
+
if system(platform) == "linux":
|
|
131
|
+
if os.access("/var/run/docker.sock", os.R_OK | os.W_OK):
|
|
132
|
+
return ["/usr/bin/docker", *args], environ
|
|
133
|
+
return ["sudo", "-n", "/usr/bin/docker", *args], environ
|
|
134
|
+
home = pathlib.Path(home or pathlib.Path.home())
|
|
135
|
+
own = os.path.realpath(wrapper)
|
|
136
|
+
candidates = [
|
|
137
|
+
os.path.join(directory, "docker")
|
|
138
|
+
for directory in environ.get("PATH", "").split(os.pathsep)
|
|
139
|
+
if directory
|
|
140
|
+
] + [
|
|
141
|
+
str(home / ".docker/bin/docker"),
|
|
142
|
+
"/usr/local/bin/docker",
|
|
143
|
+
"/opt/homebrew/bin/docker",
|
|
144
|
+
DOCKER_DESKTOP_BIN + "/docker",
|
|
145
|
+
str(home / ".local/share/mise/shims/docker"),
|
|
146
|
+
]
|
|
147
|
+
binary = next(
|
|
148
|
+
(
|
|
149
|
+
path
|
|
150
|
+
for path in candidates
|
|
151
|
+
if executable(path) and os.path.realpath(path) != own
|
|
152
|
+
),
|
|
153
|
+
None,
|
|
154
|
+
)
|
|
155
|
+
if binary is None:
|
|
156
|
+
raise DockerUnavailable(
|
|
157
|
+
"DOCKER_CLI_MISSING",
|
|
158
|
+
"DOCKER_CLI_MISSING: install Docker Desktop (it provides the docker CLI).",
|
|
159
|
+
)
|
|
160
|
+
host = environ.get("DOCKER_HOST", "")
|
|
161
|
+
if host and not host.startswith("unix://"):
|
|
162
|
+
socket_path = None # Remote engines are the operator's explicit choice.
|
|
163
|
+
elif host:
|
|
164
|
+
socket_path = host.removeprefix("unix://")
|
|
165
|
+
if not reachable(socket_path):
|
|
166
|
+
socket_path = ""
|
|
167
|
+
else:
|
|
168
|
+
socket_path = next(
|
|
169
|
+
(
|
|
170
|
+
path
|
|
171
|
+
for path in [str(home / ".docker/run/docker.sock"), "/var/run/docker.sock"]
|
|
172
|
+
if reachable(path)
|
|
173
|
+
),
|
|
174
|
+
"",
|
|
175
|
+
)
|
|
176
|
+
if socket_path and not environ.get("DOCKER_CONTEXT"):
|
|
177
|
+
environ["DOCKER_HOST"] = "unix://" + socket_path
|
|
178
|
+
if socket_path == "":
|
|
179
|
+
raise DockerUnavailable(
|
|
180
|
+
"DOCKER_DESKTOP_NOT_RUNNING",
|
|
181
|
+
"DOCKER_DESKTOP_NOT_RUNNING: start Docker Desktop and enable "
|
|
182
|
+
"'Start Docker Desktop when you sign in'; nothing was changed.",
|
|
183
|
+
)
|
|
184
|
+
# Credential helpers (docker-credential-desktop) live beside the CLI.
|
|
185
|
+
environ["PATH"] = os.pathsep.join(
|
|
186
|
+
[os.path.dirname(binary), DOCKER_DESKTOP_BIN, environ.get("PATH", "")]
|
|
187
|
+
)
|
|
188
|
+
return [binary, *args], environ
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Systemd:
|
|
192
|
+
"""Legacy Linux owner backend: fencing/decommission of the previous host."""
|
|
193
|
+
|
|
194
|
+
name = "systemd"
|
|
195
|
+
service = SYSTEMD_SERVICE
|
|
196
|
+
scheduled = SYSTEMD_SCHEDULED
|
|
197
|
+
recovery_gated = set(SYSTEMD_SCHEDULED[2:])
|
|
198
|
+
units = [SYSTEMD_SERVICE, *SYSTEMD_SCHEDULED]
|
|
199
|
+
unit_directory = pathlib.Path("/etc/systemd/system")
|
|
200
|
+
source_directory = "systemd"
|
|
201
|
+
|
|
202
|
+
def state(self, unit, run):
|
|
203
|
+
return (
|
|
204
|
+
run(["systemctl", "is-active", "--quiet", unit]),
|
|
205
|
+
run(["systemctl", "is-enabled", "--quiet", unit]),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def enable(self, unit):
|
|
209
|
+
return [["sudo", "-n", "systemctl", "enable", "--now", unit]]
|
|
210
|
+
|
|
211
|
+
def install(self, python, scripts, revision):
|
|
212
|
+
return ["sudo", "-n", python, "-B", str(scripts / "install.py"), "--revision", revision]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Launchd:
|
|
216
|
+
"""Manager owner backend: per-user LaunchAgents in gui/$UID, no root."""
|
|
217
|
+
|
|
218
|
+
name = "launchd"
|
|
219
|
+
service = LAUNCHD_SERVICE
|
|
220
|
+
scheduled = LAUNCHD_SCHEDULED
|
|
221
|
+
recovery_gated = set(LAUNCHD_SCHEDULED[2:])
|
|
222
|
+
# Load scheduled agents first: service-start's audit expects them loaded.
|
|
223
|
+
units = [*LAUNCHD_SCHEDULED, LAUNCHD_SERVICE]
|
|
224
|
+
source_directory = "launchd"
|
|
225
|
+
|
|
226
|
+
def __init__(self, uid=None, home=None):
|
|
227
|
+
self.uid = os.getuid() if uid is None else uid
|
|
228
|
+
self.home = pathlib.Path(home or pathlib.Path.home())
|
|
229
|
+
self.domain = "gui/" + str(self.uid)
|
|
230
|
+
self.unit_directory = self.home / "Library/LaunchAgents"
|
|
231
|
+
|
|
232
|
+
def plist(self, label):
|
|
233
|
+
return self.unit_directory / (label + ".plist")
|
|
234
|
+
|
|
235
|
+
def loaded(self, label, run):
|
|
236
|
+
return run(["launchctl", "print", self.domain + "/" + label])
|
|
237
|
+
|
|
238
|
+
def state(self, unit, run):
|
|
239
|
+
loaded = self.loaded(unit, run)
|
|
240
|
+
return loaded, loaded and self.plist(unit).is_symlink()
|
|
241
|
+
|
|
242
|
+
def enable(self, unit):
|
|
243
|
+
# A prior `launchctl disable` persists; clear it before loading.
|
|
244
|
+
return [
|
|
245
|
+
["launchctl", "enable", self.domain + "/" + unit],
|
|
246
|
+
["launchctl", "bootstrap", self.domain, str(self.plist(unit))],
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
def bootout(self, unit):
|
|
250
|
+
return ["launchctl", "bootout", self.domain + "/" + unit]
|
|
251
|
+
|
|
252
|
+
def install(self, python, scripts, revision):
|
|
253
|
+
return [python, "-B", str(scripts / "install.py"), "--revision", revision]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def backend(platform=None):
|
|
257
|
+
return Launchd() if system(platform) == "macos" else Systemd()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Read-only cloud recovery helpers; no dependency on the running workstation."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
import sys as _sys, pathlib as _p
|
|
13
|
+
_sys.path.insert(0, str(_p.Path(__file__).resolve().parent))
|
|
14
|
+
import fleet_config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def shared_url():
|
|
18
|
+
"""The shared Vault document holding the proxy's keys and backup credentials."""
|
|
19
|
+
vault = fleet_config.vault()
|
|
20
|
+
return vault["address"] + "/v1/" + vault["shared"]
|
|
21
|
+
|
|
22
|
+
FIELDS = (
|
|
23
|
+
"S3_ENDPOINT",
|
|
24
|
+
"S3_BUCKET",
|
|
25
|
+
"S3_REGION",
|
|
26
|
+
"S3_ACCESS_KEY_ID",
|
|
27
|
+
"S3_SECRET_ACCESS_KEY",
|
|
28
|
+
"CLIPROXY_FLEET_RESTIC_PASSWORD",
|
|
29
|
+
"CLIPROXY_FLEET_CLIENT_API_KEY",
|
|
30
|
+
"CLIPROXY_FLEET_MANAGEMENT_KEY",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
35
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
36
|
+
return None # Never forward a Vault token or API key to a redirect destination.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def request(url, headers=None):
|
|
40
|
+
if not url.startswith("https://"):
|
|
41
|
+
raise ValueError("HTTPS required")
|
|
42
|
+
headers = {"User-Agent": "cliproxy-fleet/1.0", **(headers or {})}
|
|
43
|
+
try:
|
|
44
|
+
with urllib.request.build_opener(NoRedirect).open(
|
|
45
|
+
urllib.request.Request(url, headers=headers), timeout=30
|
|
46
|
+
) as response:
|
|
47
|
+
return response.status, response.read()
|
|
48
|
+
except urllib.error.HTTPError as error:
|
|
49
|
+
return error.code, b""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def settings(token):
|
|
53
|
+
status, body = request(
|
|
54
|
+
shared_url(), {"X-Vault-Token": token}
|
|
55
|
+
)
|
|
56
|
+
if status != 200:
|
|
57
|
+
raise ValueError("Vault read failed")
|
|
58
|
+
data = json.loads(body)["data"]["data"]
|
|
59
|
+
if any(
|
|
60
|
+
not isinstance(data.get(name), str) or not data[name].strip() for name in FIELDS
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("Cloud recovery contract is incomplete")
|
|
63
|
+
return {name: data[name] for name in FIELDS}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def restic_environment(data):
|
|
67
|
+
if not data["S3_ENDPOINT"].startswith("https://"):
|
|
68
|
+
raise ValueError("Storage endpoint must use HTTPS")
|
|
69
|
+
# Do not inherit competing Restic destinations, passwords, AWS profiles or session tokens.
|
|
70
|
+
return {
|
|
71
|
+
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
72
|
+
"RESTIC_REPOSITORY": "s3:"
|
|
73
|
+
+ data["S3_ENDPOINT"].rstrip("/")
|
|
74
|
+
+ "/"
|
|
75
|
+
+ data["S3_BUCKET"]
|
|
76
|
+
+ "/cliproxy-fleet/restic",
|
|
77
|
+
"RESTIC_PASSWORD": data["CLIPROXY_FLEET_RESTIC_PASSWORD"],
|
|
78
|
+
"AWS_ACCESS_KEY_ID": data["S3_ACCESS_KEY_ID"],
|
|
79
|
+
"AWS_SECRET_ACCESS_KEY": data["S3_SECRET_ACCESS_KEY"],
|
|
80
|
+
"AWS_DEFAULT_REGION": data["S3_REGION"],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def client(data):
|
|
85
|
+
environment = restic_environment(data)
|
|
86
|
+
|
|
87
|
+
def run(*args):
|
|
88
|
+
if not args or args[0] not in ("snapshots", "dump", "restore"):
|
|
89
|
+
raise ValueError("Only read-only cloud operations are permitted")
|
|
90
|
+
return subprocess.run(
|
|
91
|
+
["restic", "-o", "s3.bucket-lookup=path", "--no-cache", "--no-lock", *args],
|
|
92
|
+
env=environment,
|
|
93
|
+
check=True,
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=180,
|
|
97
|
+
).stdout
|
|
98
|
+
|
|
99
|
+
return run
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def latest(restic):
|
|
103
|
+
snapshots = json.loads(
|
|
104
|
+
restic(
|
|
105
|
+
"snapshots",
|
|
106
|
+
"--json",
|
|
107
|
+
"--host",
|
|
108
|
+
"cliproxy-primary",
|
|
109
|
+
"--tag",
|
|
110
|
+
"cliproxy-checkpoint-v1",
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
if not snapshots:
|
|
114
|
+
raise ValueError("No cloud checkpoint")
|
|
115
|
+
snapshot = max(snapshots, key=lambda value: timestamp(value["time"]))
|
|
116
|
+
if not re.fullmatch(r"[0-9a-f]{64}", snapshot["id"]):
|
|
117
|
+
raise ValueError("Invalid checkpoint identifier")
|
|
118
|
+
paths = snapshot["paths"]
|
|
119
|
+
if (
|
|
120
|
+
len(paths) != 1
|
|
121
|
+
or not pathlib.PurePosixPath(paths[0]).is_absolute()
|
|
122
|
+
or ".." in pathlib.PurePosixPath(paths[0]).parts
|
|
123
|
+
):
|
|
124
|
+
raise ValueError("Invalid checkpoint path")
|
|
125
|
+
return snapshot
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def timestamp(value):
|
|
129
|
+
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
130
|
+
if parsed.tzinfo is None:
|
|
131
|
+
raise ValueError("Timestamp must include timezone")
|
|
132
|
+
return parsed
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def fresh(value, seconds, now=None):
|
|
136
|
+
now = now or datetime.datetime.now(datetime.timezone.utc)
|
|
137
|
+
age = (now - timestamp(value)).total_seconds()
|
|
138
|
+
if not -300 <= age <= seconds:
|
|
139
|
+
raise ValueError("Cloud status is stale or future-dated")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def client_key(token):
|
|
143
|
+
"""Read only the client API key; independent of the backup fields in settings()."""
|
|
144
|
+
status, body = request(shared_url(), {"X-Vault-Token": token})
|
|
145
|
+
if status != 200:
|
|
146
|
+
raise ValueError("Vault read failed")
|
|
147
|
+
value = json.loads(body)["data"]["data"].get("CLIPROXY_FLEET_CLIENT_API_KEY")
|
|
148
|
+
if not isinstance(value, str) or not value.strip():
|
|
149
|
+
raise ValueError("Vault has no client API key")
|
|
150
|
+
return value.strip()
|