@ouro.bot/cli 0.1.0-alpha.99 → 0.1.0-alpha.991
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/LICENSE +21 -0
- package/README.md +171 -23
- package/RepairGuide.ouro/agent.json +5 -0
- package/RepairGuide.ouro/psyche/IDENTITY.md +19 -0
- package/RepairGuide.ouro/psyche/SOUL.md +55 -0
- package/RepairGuide.ouro/skills/diagnose-broken-remote.md +63 -0
- package/RepairGuide.ouro/skills/diagnose-stacked-typed-issues.md +35 -0
- package/RepairGuide.ouro/skills/diagnose-sync-blocked.md +54 -0
- package/RepairGuide.ouro/skills/diagnose-vault-expired.md +60 -0
- package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/agent.json +4 -2
- package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/SOUL.md +2 -2
- package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-serpent.md +1 -1
- package/assets/bluebubbles-host +264 -0
- package/assets/sanctuary-host-launcher.sh +16 -0
- package/changelog.json +6257 -16
- package/deploy/unraid/Dockerfile +71 -0
- package/deploy/unraid/INSTALL.md +64 -0
- package/deploy/unraid/README.txt +3032 -0
- package/deploy/unraid/audit-container-spec.sh +4 -0
- package/deploy/unraid/container-runtime.json +1 -0
- package/deploy/unraid/credit-gateway.md +95 -0
- package/deploy/unraid/docker-man-template-transaction.mjs +727 -0
- package/deploy/unraid/docker-man-template-xml.cjs +105 -0
- package/deploy/unraid/github_known_hosts +3 -0
- package/deploy/unraid/install.sh +274 -0
- package/deploy/unraid/migrate-sanctuary-bundle.mjs +63 -0
- package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
- package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
- package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
- package/deploy/unraid/ouro-events/install-usenet-guard.sh +435 -0
- package/deploy/unraid/ouro-events/usenet-health.sh +176 -0
- package/deploy/unraid/ouro-sync-ssh +98 -0
- package/deploy/unraid/ouro-sync.sudoers +3 -0
- package/deploy/unraid/sanctuary-acceptance-adapter.sh +43 -0
- package/deploy/unraid/sanctuary-acceptance-contract.json +198 -0
- package/deploy/unraid/sanctuary-acceptance-harness.sh +23 -0
- package/deploy/unraid/sanctuary-authority-installation.json +36 -0
- package/deploy/unraid/sanctuary-authority-service.sh +30 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +1523 -0
- package/deploy/unraid/sanctuary-deployment-target.mjs +710 -0
- package/deploy/unraid/sanctuary-replay-gate.mjs +2467 -0
- package/deploy/unraid/sanctuary-safe-rename.py +24 -0
- package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +2088 -0
- package/deploy/unraid/sanctuary-unit16-run.sh +570 -0
- package/deploy/unraid/sanctuary-unit18-target-audit.sh +27 -0
- package/deploy/unraid/sanctuary.ouro/agent.json +67 -0
- package/deploy/unraid/sanctuary.ouro/arc/README.md +3 -0
- package/deploy/unraid/sanctuary.ouro/awaiting/house-care-sweep.md +17 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +5 -0
- package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +8 -0
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +2255 -0
- package/deploy/unraid/sanctuary.ouro/product/PRODUCT.md +13 -0
- package/deploy/unraid/sanctuary.ouro/provider-readiness.json +11 -0
- package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +5 -0
- package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +7 -0
- package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +7 -0
- package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +15 -0
- package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +3 -0
- package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +234 -0
- package/deploy/unraid/sanctuary.xml +30 -0
- package/dist/a2a/card.js +57 -0
- package/dist/a2a/client.js +290 -0
- package/dist/a2a/config.js +50 -0
- package/dist/a2a/delegated-command-grants.js +93 -0
- package/dist/a2a/delegated-command.js +197 -0
- package/dist/a2a/delegation-stores.js +56 -0
- package/dist/a2a/did-resolution.js +205 -0
- package/dist/a2a/escalation-grants.js +84 -0
- package/dist/a2a/escalation-wiring.js +60 -0
- package/dist/a2a/identity.js +258 -0
- package/dist/a2a/inbound-share.js +131 -0
- package/dist/a2a/mission-result-wire.js +265 -0
- package/dist/a2a/onboarding.js +133 -0
- package/dist/a2a/operator-trust.js +399 -0
- package/dist/a2a/outbox-client.js +59 -0
- package/dist/a2a/outbox-store.js +188 -0
- package/dist/a2a/outbox-wire.js +106 -0
- package/dist/a2a/pin-store.js +167 -0
- package/dist/a2a/refusals.js +53 -0
- package/dist/a2a/relay-client.js +106 -0
- package/dist/a2a/relay-config-vault.js +64 -0
- package/dist/a2a/relay-contacts.js +120 -0
- package/dist/a2a/relay-inbox.js +360 -0
- package/dist/a2a/relay-retirements.js +89 -0
- package/dist/a2a/replay-grant-holders.js +33 -0
- package/dist/a2a/replay-harness.js +188 -0
- package/dist/a2a/resolution-proof.js +53 -0
- package/dist/a2a/sealed-chat.js +114 -0
- package/dist/a2a/seen-ledger.js +214 -0
- package/dist/a2a/server.js +808 -0
- package/dist/a2a/task-store.js +69 -0
- package/dist/a2a/transport.js +100 -0
- package/dist/a2a/trust-commands.js +203 -0
- package/dist/a2a/trusted-files.js +185 -0
- package/dist/a2a/types.js +3 -0
- package/dist/arc/attention-types.js +8 -0
- package/dist/arc/cares.js +360 -0
- package/dist/arc/episodes.js +118 -0
- package/dist/arc/evolution.js +490 -0
- package/dist/arc/flight-recorder.js +1034 -0
- package/dist/arc/intentions.js +134 -0
- package/dist/arc/json-store.js +117 -0
- package/dist/arc/obligations.js +386 -0
- package/dist/arc/packets.js +336 -0
- package/dist/arc/presence.js +185 -0
- package/dist/arc/task-lifecycle.js +57 -0
- package/dist/commerce/store.js +755 -0
- package/dist/commerce/types.js +3 -0
- package/dist/gateway/keys.js +117 -0
- package/dist/gateway/server.js +429 -0
- package/dist/heart/active-work.js +798 -40
- package/dist/heart/agent-entry.js +252 -3
- package/dist/heart/approval-files.js +113 -0
- package/dist/heart/approval-store.js +960 -0
- package/dist/heart/attachments/image-normalize.js +201 -0
- package/dist/heart/attachments/materialize.js +97 -0
- package/dist/heart/attachments/originals.js +88 -0
- package/dist/heart/attachments/render.js +29 -0
- package/dist/heart/attachments/sources/bluebubbles.js +156 -0
- package/dist/heart/attachments/sources/cli-local-file.js +78 -0
- package/dist/heart/attachments/sources/index.js +18 -0
- package/dist/heart/attachments/sources/telegram.js +71 -0
- package/dist/heart/attachments/store.js +132 -0
- package/dist/heart/attachments/types.js +93 -0
- package/dist/heart/auth/auth-flow.js +511 -0
- package/dist/heart/autonomy-budget.js +478 -0
- package/dist/heart/awaiting/a2a-await-delivery.js +109 -0
- package/dist/heart/awaiting/await-alert.js +152 -0
- package/dist/heart/awaiting/await-expiry.js +143 -0
- package/dist/heart/awaiting/await-loader.js +91 -0
- package/dist/heart/awaiting/await-parser.js +152 -0
- package/dist/heart/awaiting/await-runtime-state.js +130 -0
- package/dist/heart/awaiting/await-scheduler.js +391 -0
- package/dist/heart/background-operations.js +281 -0
- package/dist/heart/bluebubbles-host-account.js +78 -0
- package/dist/heart/bridges/manager.js +137 -17
- package/dist/heart/bridges/store.js +14 -2
- package/dist/heart/bundle-state.js +168 -0
- package/dist/heart/commitments.js +44 -18
- package/dist/heart/config-registry.js +331 -0
- package/dist/heart/config.js +179 -131
- package/dist/heart/context-loss-gauntlet.js +387 -0
- package/dist/heart/context-loss-sentinel.js +1204 -0
- package/dist/heart/core.js +2378 -586
- package/dist/heart/cross-chat-delivery.js +9 -22
- package/dist/heart/daemon/agent-config-check.js +474 -0
- package/dist/heart/daemon/agent-discovery.js +287 -3
- package/dist/heart/daemon/agent-home.js +355 -0
- package/dist/heart/daemon/agent-service.js +542 -0
- package/dist/heart/daemon/agentic-repair.js +547 -0
- package/dist/heart/daemon/await-private-wake.js +59 -0
- package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
- package/dist/heart/daemon/bluebubbles-health-diagnostics.js +122 -0
- package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
- package/dist/heart/daemon/bluebubbles-host.js +273 -0
- package/dist/heart/daemon/boot-sync-probe.js +223 -0
- package/dist/heart/daemon/cadence.js +180 -0
- package/dist/heart/daemon/cli-arg-redaction.js +26 -0
- package/dist/heart/daemon/cli-defaults.js +881 -0
- package/dist/heart/daemon/cli-desk.js +331 -0
- package/dist/heart/daemon/cli-exec.js +9339 -0
- package/dist/heart/daemon/cli-help.js +766 -0
- package/dist/heart/daemon/cli-parse.js +3343 -0
- package/dist/heart/daemon/cli-render-doctor.js +57 -0
- package/dist/heart/daemon/cli-render.js +819 -0
- package/dist/heart/daemon/cli-types.js +8 -0
- package/dist/heart/daemon/connect-bay.js +323 -0
- package/dist/heart/daemon/container-credential-bootstrap.js +273 -0
- package/dist/heart/daemon/container-healthcheck.js +112 -0
- package/dist/heart/daemon/container-runtime.js +97 -0
- package/dist/heart/daemon/container-spec-auditor-main.js +187 -0
- package/dist/heart/daemon/container-spec-auditor.js +257 -0
- package/dist/heart/daemon/daemon-bootstrap-startup.js +115 -0
- package/dist/heart/daemon/daemon-cli.js +29 -2373
- package/dist/heart/daemon/daemon-entry.js +975 -11
- package/dist/heart/daemon/daemon-health.js +186 -0
- package/dist/heart/daemon/daemon-rollup.js +57 -0
- package/dist/heart/daemon/daemon-runtime-sync.js +88 -13
- package/dist/heart/daemon/daemon-tombstone.js +236 -0
- package/dist/heart/daemon/daemon.js +2110 -104
- package/dist/heart/daemon/desk-admin.js +420 -0
- package/dist/heart/daemon/dns-workflow.js +394 -0
- package/dist/heart/daemon/doctor-types.js +8 -0
- package/dist/heart/daemon/doctor.js +1766 -0
- package/dist/heart/daemon/freshness.js +345 -0
- package/dist/heart/daemon/habit-private-wake.js +61 -0
- package/dist/heart/daemon/health-monitor.js +154 -11
- package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
- package/dist/heart/daemon/hooks/bundle-meta.js +135 -1
- package/dist/heart/daemon/http-health-probe.js +80 -0
- package/dist/heart/daemon/human-command-screens.js +234 -0
- package/dist/heart/daemon/human-readiness.js +114 -0
- package/dist/heart/daemon/inner-status.js +78 -0
- package/dist/heart/daemon/interactive-repair.js +394 -0
- package/dist/heart/daemon/launchd.js +37 -8
- package/dist/heart/daemon/log-tailer.js +79 -10
- package/dist/heart/daemon/logs-prune.js +132 -0
- package/dist/heart/daemon/mcp-canary.js +524 -0
- package/dist/heart/daemon/message-router.js +65 -5
- package/dist/heart/daemon/migrate-to-desk.js +848 -0
- package/dist/heart/daemon/os-cron-deps.js +135 -0
- package/dist/heart/daemon/os-cron.js +22 -14
- package/dist/heart/daemon/ouro-bot-entry.js +6 -3
- package/dist/heart/daemon/ouro-entry.js +9 -3
- package/dist/heart/daemon/plugin-cli.js +432 -0
- package/dist/heart/daemon/process-manager.js +760 -61
- package/dist/heart/daemon/provider-discovery.js +137 -0
- package/dist/heart/daemon/provider-ping-progress.js +83 -0
- package/dist/heart/daemon/prunable-bundle.js +144 -0
- package/dist/heart/daemon/pulse.js +511 -0
- package/dist/heart/daemon/readiness-repair.js +365 -0
- package/dist/heart/daemon/run-hooks.js +2 -0
- package/dist/heart/daemon/runtime-logging.js +47 -14
- package/dist/heart/daemon/runtime-metadata.js +2 -30
- package/dist/heart/daemon/runtime-mode.js +13 -2
- package/dist/heart/daemon/safe-mode.js +161 -0
- package/dist/heart/daemon/sanctuary-acceptance-adapter.js +2662 -0
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1832 -0
- package/dist/heart/daemon/sanctuary-acceptance-marker.js +267 -0
- package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +956 -0
- package/dist/heart/daemon/sanctuary-authority-codec.js +86 -0
- package/dist/heart/daemon/sanctuary-authority-epoch.js +291 -0
- package/dist/heart/daemon/sanctuary-authority-installation.js +161 -0
- package/dist/heart/daemon/sanctuary-authority-ledger.js +241 -0
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +1332 -0
- package/dist/heart/daemon/sanctuary-authority-vault-migration.js +79 -0
- package/dist/heart/daemon/sanctuary-bundle-migration.js +974 -0
- package/dist/heart/daemon/sanctuary-host-authority.js +1008 -0
- package/dist/heart/daemon/sanctuary-host-detached-supervisor.js +493 -0
- package/dist/heart/daemon/sanctuary-host-executor.js +670 -0
- package/dist/heart/daemon/sanctuary-host-linux-kernel.js +334 -0
- package/dist/heart/daemon/sanctuary-host-supervisor-entry.js +207 -0
- package/dist/heart/daemon/sanctuary-host-supervisor.js +95 -0
- package/dist/heart/daemon/sanctuary-package-management.js +113 -0
- package/dist/heart/daemon/sanctuary-scheduler-liveness.js +227 -0
- package/dist/heart/daemon/sanctuary-scheduler-origin.js +132 -0
- package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +475 -0
- package/dist/heart/daemon/sanctuary-telegram-authority-gateway.js +585 -0
- package/dist/heart/daemon/sanctuary-telegram-authority-service.js +676 -0
- package/dist/heart/daemon/sense-manager.js +786 -48
- package/dist/heart/daemon/session-id-resolver.js +131 -0
- package/dist/heart/daemon/skill-management-installer.js +1 -1
- package/dist/heart/daemon/socket-client.js +180 -12
- package/dist/heart/daemon/stale-bundle-prune.js +113 -0
- package/dist/heart/daemon/startup-tui.js +339 -0
- package/dist/heart/daemon/supercronic-supervisor.js +282 -0
- package/dist/heart/daemon/task-scheduler.js +117 -39
- package/dist/heart/daemon/terminal-ui.js +503 -0
- package/dist/heart/daemon/thoughts.js +138 -54
- package/dist/heart/daemon/up-progress.js +366 -0
- package/dist/heart/daemon/vault-items.js +56 -0
- package/dist/heart/delegation.js +1 -4
- package/dist/heart/external-events/router.js +1531 -0
- package/dist/heart/failure-reports.js +336 -0
- package/dist/heart/file-lock.js +109 -0
- package/dist/heart/frontend-approval-runtime.js +506 -0
- package/dist/heart/frontend-journal.js +215 -0
- package/dist/heart/frontend-session-service.js +271 -0
- package/dist/heart/frontend-socket-client.js +173 -0
- package/dist/heart/frontend-socket.js +434 -0
- package/dist/heart/frontend-turn-errors.js +20 -0
- package/dist/heart/habits/habit-cancel.js +810 -0
- package/dist/heart/habits/habit-lifecycle.js +1327 -0
- package/dist/heart/habits/habit-migration.js +194 -0
- package/dist/heart/habits/habit-parser.js +278 -0
- package/dist/heart/habits/habit-runtime-state.js +93 -0
- package/dist/heart/habits/habit-scheduler.js +653 -0
- package/dist/heart/habits/habit-session-summary.js +318 -0
- package/dist/heart/habits/habit-session.js +636 -0
- package/dist/heart/{daemon → hatch}/hatch-flow.js +55 -62
- package/dist/heart/{daemon → hatch}/hatch-specialist.js +6 -8
- package/dist/heart/{daemon → hatch}/specialist-prompt.js +12 -9
- package/dist/heart/{daemon → hatch}/specialist-tools.js +53 -18
- package/dist/heart/identity.js +239 -59
- package/dist/heart/kept-notes.js +289 -0
- package/dist/heart/kicks.js +1 -1
- package/dist/heart/machine-identity.js +175 -0
- package/dist/heart/mail-import-discovery.js +356 -0
- package/dist/heart/mailbox/mailbox-http-hooks.js +99 -0
- package/dist/heart/mailbox/mailbox-http-response.js +7 -0
- package/dist/heart/mailbox/mailbox-http-routes.js +406 -0
- package/dist/heart/mailbox/mailbox-http-static.js +103 -0
- package/dist/heart/mailbox/mailbox-http-transport.js +272 -0
- package/dist/heart/mailbox/mailbox-http.js +99 -0
- package/dist/heart/mailbox/mailbox-read.js +41 -0
- package/dist/heart/mailbox/mailbox-supervision.js +131 -0
- package/dist/heart/mailbox/mailbox-types.js +27 -0
- package/dist/heart/mailbox/mailbox-view.js +197 -0
- package/dist/heart/mailbox/readers/agent-machine.js +428 -0
- package/dist/heart/mailbox/readers/continuity-readers.js +329 -0
- package/dist/heart/mailbox/readers/mail.js +375 -0
- package/dist/heart/mailbox/readers/runtime-readers.js +823 -0
- package/dist/heart/mailbox/readers/sessions.js +231 -0
- package/dist/heart/mailbox/readers/shared.js +111 -0
- package/dist/heart/mcp/mcp-server.js +760 -0
- package/dist/heart/migrate-config.js +100 -0
- package/dist/heart/model-capabilities.js +83 -16
- package/dist/heart/orientation-frame.js +376 -0
- package/dist/heart/platform.js +81 -0
- package/dist/heart/primitives.js +1 -1
- package/dist/heart/private-runtime/decision-renderer.js +158 -0
- package/dist/heart/private-runtime/index.js +18 -0
- package/dist/heart/private-runtime/ledger.js +221 -0
- package/dist/heart/private-runtime/policy.js +260 -0
- package/dist/heart/private-runtime/types.js +2 -0
- package/dist/heart/progress-story.js +1 -1
- package/dist/heart/provider-attempt.js +137 -0
- package/dist/heart/provider-binding-resolver.js +273 -0
- package/dist/heart/provider-credentials.js +449 -0
- package/dist/heart/provider-failover.js +311 -0
- package/dist/heart/provider-models.js +96 -0
- package/dist/heart/provider-ping.js +266 -0
- package/dist/heart/provider-readiness-cache.js +119 -0
- package/dist/heart/provider-visibility.js +188 -0
- package/dist/heart/providers/anthropic-token.js +131 -0
- package/dist/heart/providers/anthropic.js +248 -67
- package/dist/heart/providers/azure.js +23 -11
- package/dist/heart/providers/error-classification.js +129 -0
- package/dist/heart/providers/github-copilot.js +38 -39
- package/dist/heart/providers/minimax-vlm.js +189 -0
- package/dist/heart/providers/minimax.js +26 -8
- package/dist/heart/providers/openai-codex-token.js +349 -0
- package/dist/heart/providers/openai-codex.js +55 -40
- package/dist/heart/providers/openai-compatible.js +177 -0
- package/dist/heart/reach-channel.js +85 -0
- package/dist/heart/reply-style.js +52 -0
- package/dist/heart/run-ledger.js +247 -0
- package/dist/heart/runtime-capability-check.js +170 -0
- package/dist/heart/runtime-credentials.js +608 -0
- package/dist/heart/runtime-cwd.js +87 -0
- package/dist/heart/sense-truth.js +24 -4
- package/dist/heart/session-activity.js +94 -24
- package/dist/heart/session-events.js +1521 -0
- package/dist/heart/session-playback-cli-main.js +5 -0
- package/dist/heart/session-playback-cli.js +36 -0
- package/dist/heart/session-playback.js +231 -0
- package/dist/heart/session-redaction-repair-cli-main.js +31 -0
- package/dist/heart/session-redaction-repair.js +552 -0
- package/dist/heart/session-stats-cli-main.js +5 -0
- package/dist/heart/session-stats.js +183 -0
- package/dist/heart/session-transcript.js +133 -0
- package/dist/heart/shelf-claims.js +78 -0
- package/dist/heart/shepherd/custody-types.js +2 -0
- package/dist/heart/shepherd/mandates.js +352 -0
- package/dist/heart/shepherd/operation-commands.js +19 -0
- package/dist/heart/shepherd/operations.js +135 -0
- package/dist/heart/shepherd/supervision.js +114 -0
- package/dist/heart/shepherd/types.js +2 -0
- package/dist/heart/shepherd-config.js +123 -0
- package/dist/heart/source-grounding.js +313 -0
- package/dist/heart/start-of-turn-packet.js +372 -0
- package/dist/heart/steward-policy.js +656 -0
- package/dist/heart/streaming.js +631 -207
- package/dist/heart/structured-output.js +196 -0
- package/dist/heart/sync-classification.js +176 -0
- package/dist/heart/sync-health.js +152 -0
- package/dist/heart/sync-lock.js +431 -0
- package/dist/heart/sync.js +1579 -0
- package/dist/heart/target-resolution.js +11 -7
- package/dist/heart/tempo.js +93 -0
- package/dist/heart/temporal-view.js +41 -0
- package/dist/heart/timeouts.js +101 -0
- package/dist/heart/tool-activity-callbacks.js +59 -0
- package/dist/heart/tool-approval.js +382 -0
- package/dist/heart/tool-description.js +155 -0
- package/dist/heart/tool-friction.js +55 -0
- package/dist/heart/tool-loop.js +206 -0
- package/dist/heart/tool-output-budget.js +41 -0
- package/dist/heart/turn-context.js +496 -0
- package/dist/heart/turn-execution-lease.js +30 -0
- package/dist/heart/{daemon → versioning}/ouro-bot-global-installer.js +6 -5
- package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +3 -2
- package/dist/heart/versioning/ouro-path-installer.js +432 -0
- package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
- package/dist/heart/versioning/ouro-version-manager.js +432 -0
- package/dist/heart/versioning/release-sources.js +173 -0
- package/dist/heart/versioning/self-update-defaults.js +294 -0
- package/dist/heart/versioning/self-update.js +196 -0
- package/dist/heart/{daemon → versioning}/staged-restart.js +40 -8
- package/dist/heart/{daemon → versioning}/update-checker.js +6 -1
- package/dist/heart/versioning/update-hooks.js +154 -0
- package/dist/heart/versioning/update-state.js +126 -0
- package/dist/heart/versioning/updater-launchd.js +150 -0
- package/dist/heart/versioning/version-intent.js +114 -0
- package/dist/heart/work-card.js +380 -0
- package/dist/mailbox-ui/assets/index-Dbska1tZ.js +1 -0
- package/dist/mailbox-ui/assets/index-DsiKaPyH.css +1 -0
- package/dist/mailbox-ui/assets/vendor-CcN1XpQ9.js +61 -0
- package/dist/mailbox-ui/index.html +16 -0
- package/dist/mailroom/attention.js +167 -0
- package/dist/mailroom/autonomy.js +209 -0
- package/dist/mailroom/blob-store.js +889 -0
- package/dist/mailroom/body-cache.js +61 -0
- package/dist/mailroom/cache-sync-cli.js +58 -0
- package/dist/mailroom/core.js +803 -0
- package/dist/mailroom/entry.js +160 -0
- package/dist/mailroom/file-store.js +568 -0
- package/dist/mailroom/hosted-cache-sync.js +384 -0
- package/dist/mailroom/mbox-import.js +393 -0
- package/dist/mailroom/migration.js +164 -0
- package/dist/mailroom/outbound.js +380 -0
- package/dist/mailroom/policy.js +263 -0
- package/dist/mailroom/reader.js +292 -0
- package/dist/mailroom/search-cache.js +513 -0
- package/dist/mailroom/search-relevance.js +319 -0
- package/dist/mailroom/smtp-ingress.js +176 -0
- package/dist/mailroom/source-state.js +176 -0
- package/dist/mailroom/thread.js +109 -0
- package/dist/mailroom/travel-extract.js +89 -0
- package/dist/mind/bundle-manifest.js +25 -2
- package/dist/mind/context.js +328 -139
- package/dist/mind/desk-section.js +362 -0
- package/dist/mind/diary-integrity.js +60 -0
- package/dist/mind/{memory.js → diary.js} +68 -77
- package/dist/mind/embedding-provider.js +60 -0
- package/dist/mind/file-state.js +179 -0
- package/dist/mind/friend-link-codes.js +209 -0
- package/dist/mind/friend-merge.js +336 -0
- package/dist/mind/house-names.js +43 -0
- package/dist/mind/{associative-recall.js → note-search.js} +47 -58
- package/dist/mind/obligation-steering.js +199 -3
- package/dist/mind/outbound-record.js +168 -0
- package/dist/mind/pending.js +36 -10
- package/dist/mind/prompt-budget.js +479 -0
- package/dist/mind/prompt-refresh.js +3 -2
- package/dist/mind/prompt.js +1222 -203
- package/dist/mind/provenance-trust.js +26 -0
- package/dist/mind/record-paths.js +312 -0
- package/dist/mind/scrutiny.js +173 -0
- package/dist/mind/session-transaction.js +622 -0
- package/dist/nerves/cli-logging.js +7 -1
- package/dist/nerves/coverage/audit-rules.js +15 -6
- package/dist/nerves/coverage/audit.js +28 -2
- package/dist/nerves/coverage/cli.js +1 -1
- package/dist/nerves/coverage/contract.js +5 -5
- package/dist/nerves/coverage/file-completeness.js +143 -5
- package/dist/nerves/coverage/run-artifacts.js +55 -35
- package/dist/nerves/coverage/source-scanner.js +4 -4
- package/dist/nerves/event-buffer.js +111 -0
- package/dist/nerves/index.js +293 -9
- package/dist/nerves/observation.js +20 -0
- package/dist/nerves/redact.js +79 -0
- package/dist/nerves/review/cli-main.js +5 -0
- package/dist/nerves/review/cli.js +156 -0
- package/dist/nerves/review/core.js +159 -0
- package/dist/nerves/runtime.js +17 -1
- package/dist/repertoire/ado-client.js +15 -56
- package/dist/repertoire/ado-semantic.js +16 -10
- package/dist/repertoire/api-client.js +97 -0
- package/dist/repertoire/bitwarden-store.js +1199 -0
- package/dist/repertoire/bundle-templates.js +71 -0
- package/dist/repertoire/bw-installer.js +231 -0
- package/dist/repertoire/coding/codex-jsonl.js +64 -0
- package/dist/repertoire/coding/context-pack.js +331 -0
- package/dist/repertoire/coding/feedback.js +146 -34
- package/dist/repertoire/coding/index.js +15 -3
- package/dist/repertoire/coding/manager.js +159 -10
- package/dist/repertoire/coding/spawner.js +40 -11
- package/dist/repertoire/coding/tools.js +182 -18
- package/dist/repertoire/coding/workbench-client.js +272 -0
- package/dist/repertoire/coding/workbench-manager.js +346 -0
- package/dist/repertoire/commerce-errors.js +109 -0
- package/dist/repertoire/commerce-self-test.js +156 -0
- package/dist/repertoire/credential-access.js +183 -0
- package/dist/repertoire/desk/classifier.js +362 -0
- package/dist/repertoire/duffel-client.js +185 -0
- package/dist/repertoire/github-client.js +14 -55
- package/dist/repertoire/graph-client.js +11 -52
- package/dist/repertoire/guardrails.js +1480 -22
- package/dist/repertoire/house-sweep.js +471 -0
- package/dist/repertoire/mcp-client.js +58 -5
- package/dist/repertoire/mcp-manager.js +427 -139
- package/dist/repertoire/mcp-tools.js +132 -0
- package/dist/repertoire/mcp-write-guard.js +46 -0
- package/dist/repertoire/plugin-mcp.js +175 -0
- package/dist/repertoire/plugins.js +253 -0
- package/dist/repertoire/relationship-authorization.js +322 -0
- package/dist/repertoire/sanctuary-container-policy.js +50 -0
- package/dist/repertoire/shell-sessions.js +134 -0
- package/dist/repertoire/skills.js +48 -4
- package/dist/repertoire/stripe-client.js +131 -0
- package/dist/repertoire/tool-arguments.js +182 -0
- package/dist/repertoire/tool-results.js +29 -0
- package/dist/repertoire/tools-a2a.js +742 -0
- package/dist/repertoire/tools-attachments.js +322 -0
- package/dist/repertoire/tools-awaiting.js +897 -0
- package/dist/repertoire/tools-base.js +83 -1168
- package/dist/repertoire/tools-bluebubbles.js +2 -0
- package/dist/repertoire/tools-bridge.js +144 -0
- package/dist/repertoire/tools-bundle.js +917 -0
- package/dist/repertoire/tools-commerce.js +253 -0
- package/dist/repertoire/tools-config.js +186 -0
- package/dist/repertoire/tools-continuity.js +588 -0
- package/dist/repertoire/tools-credential.js +383 -0
- package/dist/repertoire/tools-escalation.js +84 -0
- package/dist/repertoire/tools-evolution.js +527 -0
- package/dist/repertoire/tools-files.js +362 -0
- package/dist/repertoire/tools-flight.js +290 -0
- package/dist/repertoire/tools-flow.js +119 -0
- package/dist/repertoire/tools-friend-link.js +57 -0
- package/dist/repertoire/tools-github.js +3 -8
- package/dist/repertoire/tools-habits.js +103 -0
- package/dist/repertoire/tools-house-care.js +174 -0
- package/dist/repertoire/tools-mail.js +1828 -0
- package/dist/repertoire/tools-notes.js +501 -0
- package/dist/repertoire/tools-obligations.js +182 -0
- package/dist/repertoire/tools-orientation.js +31 -0
- package/dist/repertoire/tools-record.js +482 -0
- package/dist/repertoire/tools-rsvp.js +139 -0
- package/dist/repertoire/tools-runtime.js +238 -0
- package/dist/repertoire/tools-sanctuary-host.js +202 -0
- package/dist/repertoire/tools-session.js +1141 -0
- package/dist/repertoire/tools-shell.js +154 -0
- package/dist/repertoire/tools-shepherd-guidance.js +171 -0
- package/dist/repertoire/tools-shepherd.js +325 -0
- package/dist/repertoire/tools-steward-policy.js +147 -0
- package/dist/repertoire/tools-stripe.js +224 -0
- package/dist/repertoire/tools-surface.js +369 -0
- package/dist/repertoire/tools-teams.js +50 -39
- package/dist/repertoire/tools-telegram-contacts.js +37 -0
- package/dist/repertoire/tools-travel.js +125 -0
- package/dist/repertoire/tools-trip.js +982 -0
- package/dist/repertoire/tools-unraid.js +556 -0
- package/dist/repertoire/tools-user-profile.js +146 -0
- package/dist/repertoire/tools-vault.js +40 -0
- package/dist/repertoire/tools-voice-local.js +198 -0
- package/dist/repertoire/tools-voice.js +145 -0
- package/dist/repertoire/tools.js +523 -156
- package/dist/repertoire/travel-api-client.js +360 -0
- package/dist/repertoire/unraid-client.js +155 -0
- package/dist/repertoire/unraid-restart.js +339 -0
- package/dist/repertoire/user-profile.js +131 -0
- package/dist/repertoire/vault-setup.js +246 -0
- package/dist/repertoire/vault-unlock.js +686 -0
- package/dist/rsvp/aisleplanner-client.js +354 -0
- package/dist/rsvp/cli.js +936 -0
- package/dist/rsvp/config.js +327 -0
- package/dist/rsvp/cutover.js +453 -0
- package/dist/rsvp/diagnostics.js +232 -0
- package/dist/rsvp/diff-renderer.js +176 -0
- package/dist/rsvp/habit-policy.js +110 -0
- package/dist/rsvp/habit-stage.js +123 -0
- package/dist/rsvp/incident-bundle.js +97 -0
- package/dist/rsvp/migration.js +204 -0
- package/dist/rsvp/native-habit-runner.js +630 -0
- package/dist/rsvp/outbound-state.js +628 -0
- package/dist/rsvp/query.js +118 -0
- package/dist/rsvp/replay.js +233 -0
- package/dist/rsvp/snapshot.js +232 -0
- package/dist/rsvp/spend-ledger.js +175 -0
- package/dist/scripts/claude-code-hook.js +41 -0
- package/dist/scripts/claude-code-stop-hook.js +47 -0
- package/dist/senses/a2a-entry.js +140 -0
- package/dist/senses/acp-server.js +386 -0
- package/dist/senses/attention-queue.js +186 -0
- package/dist/senses/await-turn-message.js +65 -0
- package/dist/senses/bluebubbles/active-turns.js +216 -0
- package/dist/senses/bluebubbles/attachment-cache.js +53 -0
- package/dist/senses/bluebubbles/attachment-download.js +137 -0
- package/dist/senses/{bluebubbles-client.js → bluebubbles/client.js} +393 -43
- package/dist/senses/bluebubbles/context-packet.js +207 -0
- package/dist/senses/bluebubbles/context-smoke.js +144 -0
- package/dist/senses/bluebubbles/entry.js +90 -0
- package/dist/senses/bluebubbles/host-account-verifier.js +88 -0
- package/dist/senses/{bluebubbles-inbound-log.js → bluebubbles/inbound-log.js} +21 -3
- package/dist/senses/bluebubbles/index.js +4054 -0
- package/dist/senses/bluebubbles/latest-turn.js +311 -0
- package/dist/senses/{bluebubbles-media.js → bluebubbles/media.js} +121 -71
- package/dist/senses/{bluebubbles-model.js → bluebubbles/model.js} +159 -28
- package/dist/senses/{bluebubbles-mutation-log.js → bluebubbles/mutation-log.js} +24 -13
- package/dist/senses/bluebubbles/outbound-state.js +335 -0
- package/dist/senses/bluebubbles/processed-log.js +133 -0
- package/dist/senses/bluebubbles/reaction-policy.js +47 -0
- package/dist/senses/bluebubbles/replay.js +284 -0
- package/dist/senses/{bluebubbles-runtime-state.js → bluebubbles/runtime-state.js} +30 -2
- package/dist/senses/bluebubbles/semantic-receipts.js +1986 -0
- package/dist/senses/{bluebubbles-session-cleanup.js → bluebubbles/session-cleanup.js} +1 -1
- package/dist/senses/bluebubbles/webhook-registration.js +257 -0
- package/dist/senses/bluebubbles-meta-guard.js +39 -0
- package/dist/senses/cli/bracketed-paste.js +82 -0
- package/dist/senses/cli/image-paste.js +287 -0
- package/dist/senses/cli/image-ref-navigation.js +75 -0
- package/dist/senses/cli/ink-app.js +156 -0
- package/dist/senses/cli/inline-diff.js +64 -0
- package/dist/senses/cli/input-keys.js +174 -0
- package/dist/senses/cli/kill-ring.js +86 -0
- package/dist/senses/cli/message-list.js +51 -0
- package/dist/senses/cli/ouro-tui.js +607 -0
- package/dist/senses/cli/spinner-imperative.js +135 -0
- package/dist/senses/cli/spinner.js +101 -0
- package/dist/senses/cli/status-line.js +60 -0
- package/dist/senses/cli/streaming-markdown.js +526 -0
- package/dist/senses/cli/tool-display.js +85 -0
- package/dist/senses/cli/tool-render.js +85 -0
- package/dist/senses/cli/tui-store.js +241 -0
- package/dist/senses/cli/virtual-list.js +35 -0
- package/dist/senses/cli-entry.js +60 -8
- package/dist/senses/cli.js +570 -309
- package/dist/senses/commands.js +66 -3
- package/dist/senses/context-packet-ledger.js +371 -0
- package/dist/senses/context-packets.js +154 -0
- package/dist/senses/habit-lifecycle-guard.js +24 -0
- package/dist/senses/habit-turn-message.js +160 -0
- package/dist/senses/ingress-evidence.js +2 -0
- package/dist/senses/mail-entry.js +66 -0
- package/dist/senses/mail.js +524 -0
- package/dist/senses/pipeline.js +1192 -204
- package/dist/senses/private-runtime-worker.js +857 -0
- package/dist/senses/private-runtime.js +1622 -0
- package/dist/senses/proactive-content-guard.js +51 -0
- package/dist/senses/root-host-approval-port.js +345 -0
- package/dist/senses/root-host-approval-runtime.js +393 -0
- package/dist/senses/sanctuary-authority-resident.js +102 -0
- package/dist/senses/sanctuary-download-credit-presentation.js +31 -0
- package/dist/senses/sanctuary-full-visibility-contract.js +302 -0
- package/dist/senses/sanctuary-grounding.js +118 -0
- package/dist/senses/sanctuary-health-acceptance-probe-entry.js +12 -0
- package/dist/senses/sanctuary-health-acceptance-probe.js +786 -0
- package/dist/senses/sanctuary-health-runner.js +106 -0
- package/dist/senses/sanctuary-health.js +442 -0
- package/dist/senses/sanctuary-house.js +97 -0
- package/dist/senses/sanctuary-install-state-contract.js +169 -0
- package/dist/senses/sanctuary-interactive-control.js +410 -0
- package/dist/senses/sanctuary-media-catalog-contract.js +268 -0
- package/dist/senses/sanctuary-media-optimization.js +517 -0
- package/dist/senses/sanctuary-runtime.js +390 -0
- package/dist/senses/sanctuary-sab.js +85 -0
- package/dist/senses/sanctuary-storage-optimization-contract.js +32 -0
- package/dist/senses/shared-turn.js +832 -0
- package/dist/senses/shepherd/agent-presentation.js +88 -0
- package/dist/senses/shepherd/attention-page.js +166 -0
- package/dist/senses/shepherd/attention-sidebar.js +76 -0
- package/dist/senses/shepherd/attention.js +295 -0
- package/dist/senses/shepherd/checkin-config.js +76 -0
- package/dist/senses/shepherd/checkin-store.js +386 -0
- package/dist/senses/shepherd/client.js +196 -0
- package/dist/senses/shepherd/cmux.js +483 -0
- package/dist/senses/shepherd/composer.js +336 -0
- package/dist/senses/shepherd/copilot-app.js +648 -0
- package/dist/senses/shepherd/feedback-reply.js +108 -0
- package/dist/senses/shepherd/feedback-source.js +20 -0
- package/dist/senses/shepherd/feedback-store.js +331 -0
- package/dist/senses/shepherd/feedback-submit.js +182 -0
- package/dist/senses/shepherd/feedback-worker.js +208 -0
- package/dist/senses/shepherd/guidance.js +273 -0
- package/dist/senses/shepherd/herdr.js +427 -0
- package/dist/senses/shepherd/host.js +17 -0
- package/dist/senses/shepherd/human-decision-guard.js +29 -0
- package/dist/senses/shepherd/judge-evaluation-command.js +183 -0
- package/dist/senses/shepherd/judge-evaluation-entry.js +13 -0
- package/dist/senses/shepherd/judge-evaluation.js +155 -0
- package/dist/senses/shepherd/judge.js +210 -0
- package/dist/senses/shepherd/notice-outbox.js +262 -0
- package/dist/senses/shepherd/observation-publisher.js +320 -0
- package/dist/senses/shepherd/operations-defaults.js +86 -0
- package/dist/senses/shepherd/operations.js +159 -0
- package/dist/senses/shepherd/owner-notice.js +489 -0
- package/dist/senses/shepherd/prompt-state.js +60 -0
- package/dist/senses/shepherd/recovery.js +543 -0
- package/dist/senses/shepherd/redact.js +66 -0
- package/dist/senses/shepherd/returns.js +79 -0
- package/dist/senses/shepherd/sense.js +849 -0
- package/dist/senses/shepherd/shepherd-judge-corpus-v1.json +170 -0
- package/dist/senses/shepherd/surface.js +123 -0
- package/dist/senses/shepherd/target-binding.js +13 -0
- package/dist/senses/shepherd/terminal-process.js +147 -0
- package/dist/senses/shepherd-entry.js +98 -0
- package/dist/senses/surface-tool.js +109 -0
- package/dist/senses/teams-entry.js +68 -8
- package/dist/senses/teams-image.js +119 -0
- package/dist/senses/teams.js +998 -337
- package/dist/senses/telegram-admission.js +718 -0
- package/dist/senses/telegram-approval-runtime.js +793 -0
- package/dist/senses/telegram-attachments.js +186 -0
- package/dist/senses/telegram-audit-ledger.js +220 -0
- package/dist/senses/telegram-authority-transport.js +231 -0
- package/dist/senses/telegram-client.js +1755 -0
- package/dist/senses/telegram-effect-adapter.js +904 -0
- package/dist/senses/telegram-entry.js +83 -0
- package/dist/senses/telegram-handoff.js +154 -0
- package/dist/senses/telegram.js +2474 -0
- package/dist/senses/trust-gate.js +104 -9
- package/dist/senses/voice/audio-playback.js +237 -0
- package/dist/senses/voice/audio-routing.js +119 -0
- package/dist/senses/voice/call-auth.js +250 -0
- package/dist/senses/voice/elevenlabs.js +202 -0
- package/dist/senses/voice/floor-control.js +431 -0
- package/dist/senses/voice/floor-controller.js +115 -0
- package/dist/senses/voice/golden-path.js +116 -0
- package/dist/senses/voice/index.js +29 -0
- package/dist/senses/voice/local-audio-control.js +344 -0
- package/dist/senses/voice/local-audio-devices.js +516 -0
- package/dist/senses/voice/local-audio-disclosure.js +174 -0
- package/dist/senses/voice/local-audio-identity.js +103 -0
- package/dist/senses/voice/local-audio-join.js +325 -0
- package/dist/senses/voice/local-audio-mute.js +199 -0
- package/dist/senses/voice/local-audio-notify.js +124 -0
- package/dist/senses/voice/local-audio-prompts.js +116 -0
- package/dist/senses/voice/local-audio-transport.js +427 -0
- package/dist/senses/voice/local-audio.js +281 -0
- package/dist/senses/voice/local-media-stream.js +145 -0
- package/dist/senses/voice/meeting.js +113 -0
- package/dist/senses/voice/outbound.js +190 -0
- package/dist/senses/voice/phone.js +33 -0
- package/dist/senses/voice/playback.js +139 -0
- package/dist/senses/voice/process-guards.js +51 -0
- package/dist/senses/voice/realtime-eval.js +496 -0
- package/dist/senses/voice/realtime-tool-schema.js +195 -0
- package/dist/senses/voice/realtime-trace.js +531 -0
- package/dist/senses/voice/transcript.js +70 -0
- package/dist/senses/voice/turn.js +191 -0
- package/dist/senses/voice/twilio-phone-runtime.js +836 -0
- package/dist/senses/voice/twilio-phone.js +5454 -0
- package/dist/senses/voice/types.js +2 -0
- package/dist/senses/voice/whisper.js +161 -0
- package/dist/senses/voice-entry.js +83 -0
- package/dist/senses/voice-realtime-eval-command.js +99 -0
- package/dist/senses/voice-realtime-eval-entry.js +21 -0
- package/dist/senses/voice-twilio-entry.js +87 -0
- package/dist/trips/core.js +138 -0
- package/dist/trips/store.js +265 -0
- package/dist/util/frontmatter.js +69 -0
- package/npm-shrinkwrap.json +8342 -0
- package/package.json +76 -13
- package/skills/agent-commerce.md +113 -0
- package/skills/browser-navigation.md +117 -0
- package/skills/commerce-setup-guide.md +116 -0
- package/skills/commerce-setup.md +84 -0
- package/skills/configure-dev-tools.md +99 -0
- package/skills/travel-planning.md +138 -0
- package/dist/heart/daemon/auth-flow.js +0 -430
- package/dist/heart/daemon/ouro-path-installer.js +0 -260
- package/dist/heart/daemon/ouro-version-manager.js +0 -171
- package/dist/heart/daemon/update-hooks.js +0 -138
- package/dist/heart/obligations.js +0 -191
- package/dist/heart/safe-workspace.js +0 -381
- package/dist/heart/session-recall.js +0 -116
- package/dist/mind/friends/channel.js +0 -84
- package/dist/mind/friends/group-context.js +0 -144
- package/dist/mind/friends/resolver.js +0 -84
- package/dist/mind/friends/store-file.js +0 -190
- package/dist/mind/friends/store.js +0 -4
- package/dist/mind/friends/tokens.js +0 -26
- package/dist/mind/friends/trust-explanation.js +0 -74
- package/dist/mind/friends/types.js +0 -29
- package/dist/repertoire/tasks/board.js +0 -134
- package/dist/repertoire/tasks/index.js +0 -224
- package/dist/repertoire/tasks/lifecycle.js +0 -80
- package/dist/repertoire/tasks/middleware.js +0 -65
- package/dist/repertoire/tasks/parser.js +0 -173
- package/dist/repertoire/tasks/scanner.js +0 -132
- package/dist/repertoire/tasks/transitions.js +0 -144
- package/dist/senses/bluebubbles-entry.js +0 -13
- package/dist/senses/bluebubbles.js +0 -1181
- package/dist/senses/debug-activity.js +0 -154
- package/dist/senses/inner-dialog-worker.js +0 -90
- package/dist/senses/inner-dialog.js +0 -525
- package/subagents/README.md +0 -7
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
- /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
- /package/dist/{repertoire/tasks/types.js → heart/attachments/sources/adapter.js} +0 -0
- /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
- /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
- /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
- /package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +0 -0
|
@@ -0,0 +1,1039 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Moves the Butler's bundle from the hourly host backup onto bundle sync. Runs as root on the Unraid host.
|
|
3
|
+
#
|
|
4
|
+
# sanctuary-sync-cutover.sh [--dry-run] read the before-state, print it and save it to bundle-backup/cutover-before-<time>.txt (the default)
|
|
5
|
+
# sanctuary-sync-cutover.sh --apply cut over; any failure rolls back to that saved before-state
|
|
6
|
+
# sanctuary-sync-cutover.sh --rollback put the hourly backup back, from the before-state the apply recorded
|
|
7
|
+
# sanctuary-sync-cutover.sh --verify-live after --apply: the sync path reaches GitHub, the key stays unreadable, a stalled fetch ends
|
|
8
|
+
#
|
|
9
|
+
# --apply and --rollback run detached (a new session, all output to bundle-backup/cutover-<time>.log). This process only
|
|
10
|
+
# follows that log and exits with the detached run's status, so a dropped ssh session or a closed terminal never stops a
|
|
11
|
+
# cutover or a rollback halfway. Once a rollback starts it ignores INT, TERM and HUP, runs every step, and when a step fails
|
|
12
|
+
# it writes bundle-backup/cutover-ROLLBACK-INCOMPLETE and says whether the Butler is running. --rollback can be run again.
|
|
13
|
+
#
|
|
14
|
+
# Every git call names its repository and work tree (`git --git-dir=... --work-tree=...`), so git never discovers the
|
|
15
|
+
# repository and never refuses it for dubious ownership after the move or the chown. Root's git calls also run no hooks,
|
|
16
|
+
# no fsmonitor and no global excludes file, and `git status` takes no optional locks, so root never rewrites the index
|
|
17
|
+
# behind the Butler.
|
|
18
|
+
#
|
|
19
|
+
# From step 13 the Butler owns .git, so its config, hooks and info/attributes may name any command (a clean filter, an
|
|
20
|
+
# [include], an ext:: URL). Root runs no git on it after that: --verify-live runs git only inside the container as the
|
|
21
|
+
# Butler, an apply refuses while .git is in the bundle, and --rollback first moves .git back, hands it to root and puts
|
|
22
|
+
# back config, hooks/ and info/attributes from the root-owned snapshot the apply took before step 1. It refuses a repo.git
|
|
23
|
+
# that holds any symbolic link, which could carry root's writes onto the host. Only on a restored repo.git that holds no
|
|
24
|
+
# links does root run git, and only then does the rollback bring back the hourly backup.sh cron, which runs git as root
|
|
25
|
+
# with hooks on. Otherwise the rollback ends in ROLLBACK-INCOMPLETE with the cron still off.
|
|
26
|
+
set -Eeuo pipefail
|
|
27
|
+
umask 022
|
|
28
|
+
|
|
29
|
+
# The bundle's GitHub repository. It is renamed to butler.ouro at R4.
|
|
30
|
+
BUNDLE_REPO_OWNER=arimendelow
|
|
31
|
+
BUNDLE_REPO_NAME=sanctuary.ouro
|
|
32
|
+
BUNDLE_REMOTE="git@github.com:${BUNDLE_REPO_OWNER}/${BUNDLE_REPO_NAME}.git"
|
|
33
|
+
BUNDLE_BRANCH=main
|
|
34
|
+
AGENT_NAME=sanctuary
|
|
35
|
+
# backup.sh's commit identity and the fetch refspec the moved repository must have.
|
|
36
|
+
BACKUP_COMMIT_NAME="Sanctuary backup"
|
|
37
|
+
BACKUP_COMMIT_EMAIL=16390116+arimendelow@users.noreply.github.com
|
|
38
|
+
DEFAULT_FETCH='+refs/heads/*:refs/remotes/origin/*'
|
|
39
|
+
PSYCHE_FILES="ASPIRATIONS.md IDENTITY.md LORE.md SOUL.md TACIT.md"
|
|
40
|
+
|
|
41
|
+
CONTAINER=ouro-butler
|
|
42
|
+
CONTAINER_BUNDLE=/home/ouro/AgentBundles/sanctuary.ouro
|
|
43
|
+
CLI_ENTRY=/opt/ouro/dist/heart/daemon/ouro-entry.js
|
|
44
|
+
GATE_ENTRY=/opt/ouro/deploy/unraid/sanctuary-replay-gate.mjs
|
|
45
|
+
SYNC_SSH_WRAPPER=/usr/local/libexec/ouro-sync-ssh
|
|
46
|
+
CONTAINER_KEY=/run/ouro-sync/deploy_key
|
|
47
|
+
# The image's ouro-sync user, which alone reads the key and runs sync's ssh. The stall probe drops its port-22 traffic.
|
|
48
|
+
SYNC_USER=ouro-sync
|
|
49
|
+
SYNC_CONTAINER_UID=10002
|
|
50
|
+
PROBE_COMMENT=sanctuary-sync-cutover-probe
|
|
51
|
+
AUDIT_ICON=https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png
|
|
52
|
+
# git status and git add -A --dry-run on the live bundle must each stay under this (M5).
|
|
53
|
+
TIMING_LIMIT_MS=300
|
|
54
|
+
# A stalled fetch must end on its own within this (the wrapper's ssh timeouts are about 20 s).
|
|
55
|
+
STALL_LIMIT_S=25
|
|
56
|
+
|
|
57
|
+
# Tests point every host path into a temp tree. Nothing else reads this.
|
|
58
|
+
TEST_ROOT=${OURO_CUTOVER_ROOT:-}
|
|
59
|
+
APPDATA=$TEST_ROOT/mnt/user/appdata/ouro-butler
|
|
60
|
+
BUNDLE=$APPDATA/agent/sanctuary.ouro
|
|
61
|
+
BACKUP=$APPDATA/bundle-backup
|
|
62
|
+
BACKUP_GIT=$BACKUP/repo.git
|
|
63
|
+
BACKUP_KEY=$BACKUP/deploy_key
|
|
64
|
+
KNOWN_HOSTS=$BACKUP/known_hosts
|
|
65
|
+
LOCK_FILE=$BACKUP/lock
|
|
66
|
+
CUTOVER_LOCK=$BACKUP/cutover.lock
|
|
67
|
+
MARKER=$BACKUP/cutover-done
|
|
68
|
+
APPLYING=$BACKUP/cutover-applying
|
|
69
|
+
INCOMPLETE=$BACKUP/cutover-ROLLBACK-INCOMPLETE
|
|
70
|
+
CRON_DISABLED=$BACKUP/sanctuary-bundle-backup.cron.disabled
|
|
71
|
+
# What of repo.git can make git run a command, saved as root before step 1 and put back first on rollback (C1).
|
|
72
|
+
GIT_SNAPSHOT_PREFIX=$BACKUP/cutover-git-snapshot
|
|
73
|
+
SYNC_DIR=$APPDATA/sync
|
|
74
|
+
SYNC_KEY=$SYNC_DIR/deploy_key
|
|
75
|
+
CRON_FILE=$TEST_ROOT/boot/config/plugins/dynamix/sanctuary-bundle-backup.cron
|
|
76
|
+
CRON_TABLE=$TEST_ROOT/etc/cron.d/root
|
|
77
|
+
HOST_NODE=$TEST_ROOT/usr/local/bin/node
|
|
78
|
+
|
|
79
|
+
# The Butler's uid (owns .git), ouro-sync's uid (owns the key) and root. A test that is not root owns all three.
|
|
80
|
+
BUTLER_OWNER=10001:10001
|
|
81
|
+
SYNC_OWNER=10002:10002
|
|
82
|
+
ROOT_OWNER=0:0
|
|
83
|
+
if [ -n "$TEST_ROOT" ] && [ -n "${OURO_CUTOVER_OWNER:-}" ]; then
|
|
84
|
+
BUTLER_OWNER=$OURO_CUTOVER_OWNER
|
|
85
|
+
SYNC_OWNER=$OURO_CUTOVER_OWNER
|
|
86
|
+
ROOT_OWNER=$OURO_CUTOVER_OWNER
|
|
87
|
+
fi
|
|
88
|
+
if [ -n "$TEST_ROOT" ]; then STALL_LIMIT_S=${OURO_CUTOVER_STALL_LIMIT_S:-$STALL_LIMIT_S}; fi
|
|
89
|
+
BUTLER_UID=${BUTLER_OWNER%%:*}
|
|
90
|
+
SYNC_UID=${SYNC_OWNER%%:*}
|
|
91
|
+
SYNC_GID=${SYNC_OWNER##*:}
|
|
92
|
+
|
|
93
|
+
# Secret-looking names. The gate's SECRET_PATH is the same pattern; a test pins the two together.
|
|
94
|
+
SECRET_NAME_PATTERN='credential|secret|token|\.key$|\.pem$|(^|/)\.env($|\.)'
|
|
95
|
+
|
|
96
|
+
say() { printf '%s\n' "$*"; }
|
|
97
|
+
die() { printf 'cutover: %s\n' "$*" >&2; exit 1; }
|
|
98
|
+
|
|
99
|
+
# Git on the backup repository (before step 5) and on the bundle's own .git (from step 5). No hooks, no fsmonitor, and no
|
|
100
|
+
# global excludes file, so root's view of what is ignored is the repository's own (M5).
|
|
101
|
+
GIT_SAFE="-c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.excludesFile=/dev/null"
|
|
102
|
+
# shellcheck disable=SC2086
|
|
103
|
+
backup_git() { git $GIT_SAFE --git-dir="$BACKUP_GIT" --work-tree="$BUNDLE" "$@"; }
|
|
104
|
+
# shellcheck disable=SC2086
|
|
105
|
+
bundle_git() { git $GIT_SAFE --git-dir="$BUNDLE/.git" --work-tree="$BUNDLE" "$@"; }
|
|
106
|
+
# backup.sh's ssh, with the host key it already accepted into bundle-backup/known_hosts now required (I2).
|
|
107
|
+
BACKUP_SSH="ssh -i $BACKUP_KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KNOWN_HOSTS -o BatchMode=yes"
|
|
108
|
+
|
|
109
|
+
# A config value, or "(unset)" when git says it is unset (exit 1). Any other failure is a failure (M9).
|
|
110
|
+
config_value() {
|
|
111
|
+
local gitfn=$1 key=$2 value rc=0
|
|
112
|
+
value=$("$gitfn" config --get "$key") || rc=$?
|
|
113
|
+
case "$rc" in
|
|
114
|
+
0) printf '%s' "$value" ;;
|
|
115
|
+
1) printf '(unset)' ;;
|
|
116
|
+
*) die "git config --get $key failed (exit $rc)" ;;
|
|
117
|
+
esac
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Every value of a multi-valued key, joined with " ; ", or "(unset)".
|
|
121
|
+
config_all() {
|
|
122
|
+
local gitfn=$1 key=$2 value rc=0
|
|
123
|
+
value=$("$gitfn" config --get-all "$key") || rc=$?
|
|
124
|
+
case "$rc" in
|
|
125
|
+
0) printf '%s' "$value" | awk 'NR > 1 { printf " ; " } { printf "%s", $0 }' ;;
|
|
126
|
+
1) printf '(unset)' ;;
|
|
127
|
+
*) die "git config --get-all $key failed (exit $rc)" ;;
|
|
128
|
+
esac
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Sets a config value, or unsets it for "(unset)".
|
|
132
|
+
restore_config() {
|
|
133
|
+
local gitfn=$1 key=$2 value=$3
|
|
134
|
+
if [ "$value" = "(unset)" ]; then
|
|
135
|
+
"$gitfn" config --unset-all "$key" || [ $? -eq 5 ]
|
|
136
|
+
else
|
|
137
|
+
"$gitfn" config "$key" "$value"
|
|
138
|
+
fi
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# One "key: value" line from a file.
|
|
142
|
+
file_value() {
|
|
143
|
+
awk -v k="$2: " 'index($0, k) == 1 { print substr($0, length(k) + 1); found = 1; exit } END { exit found ? 0 : 1 }' "$1"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# One saved value from the before-state.
|
|
147
|
+
before_value() {
|
|
148
|
+
file_value "$BEFORE" "$1" || die "$(basename "$BEFORE") has no \"$1\" line"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
container_image() {
|
|
152
|
+
local image
|
|
153
|
+
image=$(docker inspect --format '{{.Image}}' "$CONTAINER")
|
|
154
|
+
case "$image" in
|
|
155
|
+
sha256:*) ;;
|
|
156
|
+
*) die "the container's image id is not a sha256 id: $image" ;;
|
|
157
|
+
esac
|
|
158
|
+
[ "${#image}" -eq 71 ] || die "the container's image id is not a sha256 id: $image"
|
|
159
|
+
printf '%s' "$image"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# "true", "false", or "unknown" when docker cannot say.
|
|
163
|
+
container_running() {
|
|
164
|
+
docker inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || printf 'unknown'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# The exclude list from the release's own code, into $1.
|
|
168
|
+
print_exclude() {
|
|
169
|
+
local image=$1 out=$2
|
|
170
|
+
docker run --rm --pull=never --network none --entrypoint node "$image" "$CLI_ENTRY" sanctuary git-exclude --print >"$out.raw"
|
|
171
|
+
# One entry per line and one final newline, exactly as the boot writes it.
|
|
172
|
+
awk 'NF { print }' "$out.raw" >"$out"
|
|
173
|
+
rm -f -- "$out.raw"
|
|
174
|
+
grep -qx 'state/' "$out" || die "the release's exclude list has no state/ entry"
|
|
175
|
+
grep -qx '\*\*/\*\.key' "$out" || die "the release's exclude list has no **/*.key entry"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Milliseconds for git status and git add -A --dry-run. Arguments: the git dir, the work tree and the new exclude list, all
|
|
179
|
+
# of which may be read-only. Both run on a private copy of the index (git add --dry-run still takes index.lock), and status
|
|
180
|
+
# takes no optional lock, so nothing under the repository is written (I1). No single quotes below: a test runs this text.
|
|
181
|
+
# shellcheck disable=SC2016
|
|
182
|
+
MEASURE_GIT_SCRIPT='
|
|
183
|
+
repo=$1 w=$2 exclude=$3
|
|
184
|
+
tmp=$(mktemp -d) || exit 1
|
|
185
|
+
cleanup() { rm -rf "$tmp"; }
|
|
186
|
+
trap cleanup EXIT
|
|
187
|
+
if [ -f "$repo/index" ]; then cp "$repo/index" "$tmp/index" || exit 1; fi
|
|
188
|
+
g() { GIT_OPTIONAL_LOCKS=0 GIT_INDEX_FILE="$tmp/index" git -c safe.directory="*" -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.excludesFile="$exclude" --git-dir="$repo" --work-tree="$w" "$@"; }
|
|
189
|
+
a=$(date +%s%N); g status --porcelain >/dev/null || exit 1
|
|
190
|
+
b=$(date +%s%N); g add -A --dry-run >/dev/null || exit 1
|
|
191
|
+
c=$(date +%s%N); echo "$(( (b - a) / 1000000 )) $(( (c - b) / 1000000 ))"
|
|
192
|
+
'
|
|
193
|
+
|
|
194
|
+
# The timings inside the release image as the Butler's uid, with the repository and the live bundle mounted read-only.
|
|
195
|
+
measure_git() {
|
|
196
|
+
local image=$1 exclude=$2
|
|
197
|
+
docker run --rm --pull=never --network none --user 10001:10001 --entrypoint sh \
|
|
198
|
+
--mount "type=bind,src=$BACKUP_GIT,dst=/cutover/repo.git,readonly" \
|
|
199
|
+
--mount "type=bind,src=$BUNDLE,dst=$CONTAINER_BUNDLE,readonly" \
|
|
200
|
+
--mount "type=bind,src=$exclude,dst=/cutover/exclude,readonly" \
|
|
201
|
+
"$image" -c "$MEASURE_GIT_SCRIPT" sh /cutover/repo.git "$CONTAINER_BUNDLE" /cutover/exclude
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Paths in the tracked list ($1, one per line) that look like secrets.
|
|
205
|
+
secret_paths() { grep -Ei "$SECRET_NAME_PATTERN" "$1" || true; }
|
|
206
|
+
|
|
207
|
+
# The paths in a `git status --porcelain --untracked-files=all` file that sync must never commit, from the replay gate's own
|
|
208
|
+
# scan inside the release image, so step 10 and the gate cannot drift (I3).
|
|
209
|
+
status_problems() {
|
|
210
|
+
local image=$1 status=$2
|
|
211
|
+
docker run --rm -i --pull=never --network none --entrypoint node "$image" "$GATE_ENTRY" status-scan <"$status"
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# The owner ("uid:gid") and mode ("755") of a path, read without following a link.
|
|
215
|
+
owner_and_mode() {
|
|
216
|
+
"$HOST_NODE" -e '
|
|
217
|
+
const s = require("node:fs").lstatSync(process.argv[1]);
|
|
218
|
+
process.stdout.write(s.uid + ":" + s.gid + " " + (s.mode & 0o7777).toString(8));
|
|
219
|
+
' "$1"
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# agent.json's "sync" value as JSON, or "(absent)".
|
|
223
|
+
agent_sync_value() {
|
|
224
|
+
"$HOST_NODE" -e '
|
|
225
|
+
const fs = require("node:fs");
|
|
226
|
+
const fd = fs.openSync(process.argv[1], fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
227
|
+
if (!fs.fstatSync(fd).isFile()) throw new Error(process.argv[1] + " is not a regular file");
|
|
228
|
+
const agent = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
229
|
+
process.stdout.write(Object.prototype.hasOwnProperty.call(agent, "sync") ? JSON.stringify(agent.sync) : "(absent)");
|
|
230
|
+
' "$BUNDLE/agent.json"
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# Edits agent.json's "sync" through a temp file and a rename, keeping its owner and mode. "enable" turns sync on for the
|
|
234
|
+
# branch; "restore <json|(absent)>" puts back exactly what the before-state recorded.
|
|
235
|
+
edit_agent_sync() {
|
|
236
|
+
"$HOST_NODE" -e '
|
|
237
|
+
const fs = require("node:fs"), path = require("node:path");
|
|
238
|
+
const [file, action, value, branch] = process.argv.slice(1);
|
|
239
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
240
|
+
const stat = fs.fstatSync(fd);
|
|
241
|
+
if (!stat.isFile()) throw new Error(file + " is not a regular file");
|
|
242
|
+
const agent = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
243
|
+
fs.closeSync(fd);
|
|
244
|
+
if (action === "enable") {
|
|
245
|
+
const sync = agent.sync && typeof agent.sync === "object" ? agent.sync : {};
|
|
246
|
+
agent.sync = { ...sync, enabled: true, branch: sync.branch ?? branch };
|
|
247
|
+
} else if (value === "(absent)") {
|
|
248
|
+
delete agent.sync;
|
|
249
|
+
} else {
|
|
250
|
+
agent.sync = JSON.parse(value);
|
|
251
|
+
}
|
|
252
|
+
const temp = path.join(path.dirname(file), ".agent.json.cutover-" + process.pid);
|
|
253
|
+
const out = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
254
|
+
try {
|
|
255
|
+
fs.writeFileSync(out, JSON.stringify(agent, null, 2) + "\n");
|
|
256
|
+
fs.fchownSync(out, stat.uid, stat.gid);
|
|
257
|
+
fs.fchmodSync(out, stat.mode & 0o7777);
|
|
258
|
+
fs.fsyncSync(out);
|
|
259
|
+
} finally { fs.closeSync(out); }
|
|
260
|
+
fs.renameSync(temp, file);
|
|
261
|
+
' "$BUNDLE/agent.json" "$1" "${2:-}" "$BUNDLE_BRANCH"
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# Runs `docker exec "$@"`, its stdout in $EXEC_OUT (default $WORK/exec.out) and its status in $EXEC_STATUS. Dies when the
|
|
265
|
+
# check itself could not run (exit 125, 126 or 127, or no such container), so that is never read as the result (M3).
|
|
266
|
+
exec_check() {
|
|
267
|
+
EXEC_STATUS=0
|
|
268
|
+
docker exec "$@" >"${EXEC_OUT:-$WORK/exec.out}" 2>"$WORK/exec.err" || EXEC_STATUS=$?
|
|
269
|
+
if { [ "$EXEC_STATUS" -ge 125 ] && [ "$EXEC_STATUS" -le 127 ]; } || grep -qiE 'no such container|is not running' "$WORK/exec.err"; then
|
|
270
|
+
die "the check could not run: docker exec $* exited $EXEC_STATUS: $(head -c 300 "$WORK/exec.err" | tr '\n' ' ')"
|
|
271
|
+
fi
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
# The check that the Butler's uid cannot read the key: `cat` and `test -r` must both exit 1.
|
|
275
|
+
assert_key_unreadable() {
|
|
276
|
+
EXEC_OUT=/dev/null exec_check -u 10001 "$CONTAINER" cat "$CONTAINER_KEY"
|
|
277
|
+
case "$EXEC_STATUS" in
|
|
278
|
+
0) die "the Butler's uid can read $CONTAINER_KEY" ;;
|
|
279
|
+
1) ;;
|
|
280
|
+
*) die "cat $CONTAINER_KEY as the Butler's uid exited $EXEC_STATUS, not 1" ;;
|
|
281
|
+
esac
|
|
282
|
+
exec_check -u 10001 "$CONTAINER" test -r "$CONTAINER_KEY"
|
|
283
|
+
case "$EXEC_STATUS" in
|
|
284
|
+
0) die "the Butler's uid can read $CONTAINER_KEY" ;;
|
|
285
|
+
1) ;;
|
|
286
|
+
*) die "test -r $CONTAINER_KEY as the Butler's uid exited $EXEC_STATUS, not 1" ;;
|
|
287
|
+
esac
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
# ---------- dry run ----------
|
|
291
|
+
|
|
292
|
+
# Refuses a state the cutover cannot start from. Changes nothing.
|
|
293
|
+
refuse_unless_ready() {
|
|
294
|
+
[ -d "$BACKUP_GIT" ] || die "$BACKUP_GIT is missing"
|
|
295
|
+
[ -d "$BUNDLE" ] || die "$BUNDLE is missing"
|
|
296
|
+
[ ! -e "$INCOMPLETE" ] || die "a rollback did not finish ($INCOMPLETE); run --rollback again"
|
|
297
|
+
[ ! -e "$APPLYING" ] || die "an apply is running or was interrupted ($APPLYING); run --rollback"
|
|
298
|
+
[ ! -e "$BUNDLE/.git" ] || die "$BUNDLE/.git already exists"
|
|
299
|
+
[ ! -e "$MARKER" ] || die "the cutover is already done ($MARKER)"
|
|
300
|
+
[ -f "$CRON_FILE" ] || die "the backup cron file $CRON_FILE is missing"
|
|
301
|
+
[ -f "$BACKUP_KEY" ] || die "the backup deploy key $BACKUP_KEY is missing"
|
|
302
|
+
[ -f "$KNOWN_HOSTS" ] || die "$KNOWN_HOSTS is missing; backup.sh's pushes fill it, and the cutover's ssh requires it"
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# Reads the before-state, prints it and saves it to a new timestamped file, never overwriting one. Changes nothing else.
|
|
306
|
+
# Sets BEFORE, and leaves the exclude list in $WORK/exclude.
|
|
307
|
+
preflight() {
|
|
308
|
+
refuse_unless_ready
|
|
309
|
+
local image branch upstream remote merge stash tracked timings status_ms add_ms excluded secrets out
|
|
310
|
+
local origin_url fetch bare worktree sshcmd ucache manyfiles agent_sync repo_stat repo_owner repo_mode key_state
|
|
311
|
+
image=$(container_image)
|
|
312
|
+
print_exclude "$image" "$WORK/exclude"
|
|
313
|
+
chmod 0644 "$WORK/exclude"
|
|
314
|
+
timings=$(measure_git "$image" "$WORK/exclude")
|
|
315
|
+
read -r status_ms add_ms <<<"$timings"
|
|
316
|
+
case "$status_ms:$add_ms" in
|
|
317
|
+
*[!0-9:]* | :* | *:) die "the git timing inside the image printed \"$timings\"" ;;
|
|
318
|
+
esac
|
|
319
|
+
branch=$(backup_git symbolic-ref --short HEAD) || die "the backup repository's HEAD is not on a branch"
|
|
320
|
+
remote=$(config_value backup_git "branch.$branch.remote")
|
|
321
|
+
merge=$(config_value backup_git "branch.$branch.merge")
|
|
322
|
+
upstream=$(backup_git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) || upstream="(none)"
|
|
323
|
+
stash=$(backup_git rev-list --walk-reflogs --count refs/stash 2>/dev/null) || stash=0
|
|
324
|
+
origin_url=$(config_value backup_git remote.origin.url)
|
|
325
|
+
fetch=$(config_all backup_git remote.origin.fetch)
|
|
326
|
+
bare=$(config_value backup_git core.bare)
|
|
327
|
+
worktree=$(config_value backup_git core.worktree)
|
|
328
|
+
sshcmd=$(config_value backup_git core.sshCommand)
|
|
329
|
+
ucache=$(config_value backup_git core.untrackedCache)
|
|
330
|
+
manyfiles=$(config_value backup_git feature.manyFiles)
|
|
331
|
+
agent_sync=$(agent_sync_value)
|
|
332
|
+
repo_stat=$(owner_and_mode "$BACKUP_GIT")
|
|
333
|
+
repo_owner=${repo_stat% *}
|
|
334
|
+
repo_mode=${repo_stat#* }
|
|
335
|
+
if [ -e "$SYNC_KEY" ]; then key_state=present; else key_state=absent; fi
|
|
336
|
+
tracked=$WORK/tracked
|
|
337
|
+
backup_git ls-files >"$tracked"
|
|
338
|
+
excluded=$(backup_git ls-files -ci --exclude-per-directory=.gitignore --exclude-from="$WORK/exclude")
|
|
339
|
+
secrets=$(secret_paths "$tracked")
|
|
340
|
+
out=$WORK/before
|
|
341
|
+
{
|
|
342
|
+
say "# Bundle sync cutover: the before-state, read $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
343
|
+
say "origin.url: $origin_url"
|
|
344
|
+
say "target.url: $BUNDLE_REMOTE"
|
|
345
|
+
say "remote.origin.fetch: $fetch"
|
|
346
|
+
say "image: $image"
|
|
347
|
+
say "branch: $branch"
|
|
348
|
+
say "upstream: $upstream"
|
|
349
|
+
say "branch.remote: $remote"
|
|
350
|
+
say "branch.merge: $merge"
|
|
351
|
+
say "core.bare: $bare"
|
|
352
|
+
say "core.worktree: $worktree"
|
|
353
|
+
say "core.sshCommand: $sshcmd"
|
|
354
|
+
say "core.untrackedCache: $ucache"
|
|
355
|
+
say "feature.manyFiles: $manyfiles"
|
|
356
|
+
say "repo.git owner: $repo_owner"
|
|
357
|
+
say "repo.git mode: $repo_mode"
|
|
358
|
+
say "agent.json sync: $agent_sync"
|
|
359
|
+
say "sync key: $key_state"
|
|
360
|
+
say "known_hosts: $KNOWN_HOSTS"
|
|
361
|
+
say "stash count: $stash"
|
|
362
|
+
say "git status ms: $status_ms"
|
|
363
|
+
say "git add -A --dry-run ms: $add_ms"
|
|
364
|
+
say "tracked files: $(wc -l <"$tracked" | tr -d ' ')"
|
|
365
|
+
say "tracked paths the new exclude matches: $(printf '%s' "$excluded" | grep -c . || true)"
|
|
366
|
+
[ -z "$excluded" ] || printf '%s\n' "$excluded" | sed 's/^/ /'
|
|
367
|
+
say "secret-named tracked paths: $(printf '%s' "$secrets" | grep -c . || true)"
|
|
368
|
+
[ -z "$secrets" ] || printf '%s\n' "$secrets" | sed 's/^/ /'
|
|
369
|
+
say "cron file: $CRON_FILE"
|
|
370
|
+
sed 's/^/ /' "$CRON_FILE"
|
|
371
|
+
if [ -f "$BACKUP_GIT/info/exclude" ]; then
|
|
372
|
+
say "info/exclude:"
|
|
373
|
+
sed 's/^/ | /' "$BACKUP_GIT/info/exclude"
|
|
374
|
+
say "info/exclude.base64: $(base64 <"$BACKUP_GIT/info/exclude" | tr -d '\n')"
|
|
375
|
+
else
|
|
376
|
+
say "info/exclude: (absent)"
|
|
377
|
+
say "info/exclude.base64: (absent)"
|
|
378
|
+
fi
|
|
379
|
+
} >"$out"
|
|
380
|
+
cat "$out"
|
|
381
|
+
# A new file each time; noclobber refuses to replace one that exists (M10).
|
|
382
|
+
BEFORE=$BACKUP/cutover-before-$(date -u +%Y%m%dT%H%M%SZ)-$$.txt
|
|
383
|
+
(set -C; cat "$out" >"$BEFORE") || die "could not write $BEFORE"
|
|
384
|
+
local problems=0
|
|
385
|
+
if [ "$status_ms" -gt "$TIMING_LIMIT_MS" ] || [ "$add_ms" -gt "$TIMING_LIMIT_MS" ]; then
|
|
386
|
+
say "FAIL: git status took $status_ms ms and git add -A --dry-run $add_ms ms; each must be at most $TIMING_LIMIT_MS ms" >&2
|
|
387
|
+
problems=1
|
|
388
|
+
fi
|
|
389
|
+
if [ -n "$secrets" ]; then
|
|
390
|
+
say "FAIL: tracked paths look like secrets; untrack them and rotate what they hold before the cutover" >&2
|
|
391
|
+
problems=1
|
|
392
|
+
fi
|
|
393
|
+
[ "$problems" -eq 0 ] || die "the dry run found problems; nothing was changed"
|
|
394
|
+
say "dry run ok; saved $BEFORE"
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
# ---------- rollback ----------
|
|
398
|
+
|
|
399
|
+
ROLLBACK_FAILED=""
|
|
400
|
+
|
|
401
|
+
# Runs one rollback step and records its result. A failed step never stops the steps after it.
|
|
402
|
+
rollback_step() {
|
|
403
|
+
local name=$1
|
|
404
|
+
shift
|
|
405
|
+
if "$@"; then
|
|
406
|
+
say "rollback: $name: ok"
|
|
407
|
+
else
|
|
408
|
+
say "rollback: $name: FAILED"
|
|
409
|
+
ROLLBACK_FAILED="${ROLLBACK_FAILED:+$ROLLBACK_FAILED, }$name"
|
|
410
|
+
fi
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
# Undoes the cutover from the before-state in $BEFORE. Every step reads the state it finds, so a rollback can run again
|
|
414
|
+
# after one that was cut short. $1 is the step the apply reached (15 for --rollback): the container is stopped only when
|
|
415
|
+
# the apply started it, and started again only when the apply stopped it.
|
|
416
|
+
rollback_from() {
|
|
417
|
+
local reached=$1 running
|
|
418
|
+
# Nothing interrupts a rollback: a second Ctrl-C, a TERM or a lost terminal is ignored, here and in every child (C1).
|
|
419
|
+
trap '' INT TERM HUP PIPE
|
|
420
|
+
set +e
|
|
421
|
+
ROLLBACK_FAILED=""
|
|
422
|
+
# Read now: the marker goes, and the INCOMPLETE record is rewritten, before the rollback ends.
|
|
423
|
+
ROLLBACK_SNAPSHOT=$(recorded_value "git snapshot") || ROLLBACK_SNAPSHOT=""
|
|
424
|
+
ROLLBACK_SNAPSHOT_HOOKS=$(recorded_value "git snapshot hooks") || ROLLBACK_SNAPSHOT_HOOKS=""
|
|
425
|
+
ROLLBACK_SNAPSHOT_ATTRIBUTES=$(recorded_value "git snapshot attributes") || ROLLBACK_SNAPSHOT_ATTRIBUTES=""
|
|
426
|
+
say "rolling back from step $reached, from $BEFORE"
|
|
427
|
+
if [ "$reached" -ge 15 ]; then rollback_step "stop the Butler" docker stop "$CONTAINER"; fi
|
|
428
|
+
rollback_step "restore agent.json's sync value" restore_agent_sync
|
|
429
|
+
rollback_step "move .git back to repo.git" move_git_back
|
|
430
|
+
# Before any git: root owns repo.git again and nothing the Butler wrote into it can run (C1).
|
|
431
|
+
rollback_step "give repo.git back to root and put back its config, hooks and attributes" secure_backup_git
|
|
432
|
+
rollback_step "clear a stale index.lock" clear_stale_index_lock
|
|
433
|
+
rollback_step "restore repo.git's config, branch and exclude" git_step restore_backup_repo
|
|
434
|
+
rollback_step "drop an apply commit GitHub never got" git_step drop_unpushed_commit
|
|
435
|
+
rollback_step "restore repo.git's owner and mode" restore_repo_owner
|
|
436
|
+
rollback_step "remove the sync key the apply installed" remove_installed_key
|
|
437
|
+
# The hourly backup runs git as root, so it comes back only on a repo.git secure_backup_git put back.
|
|
438
|
+
rollback_step "restore the backup cron" git_step restore_cron
|
|
439
|
+
rollback_step "remove the cutover marker" rm -f -- "$MARKER"
|
|
440
|
+
rollback_step "check the hourly backup can push" git_step check_backup_can_push
|
|
441
|
+
if [ "$reached" -ge 1 ]; then
|
|
442
|
+
rollback_step "start the Butler" docker start "$CONTAINER"
|
|
443
|
+
rollback_step "check the Butler is running" butler_is_running
|
|
444
|
+
fi
|
|
445
|
+
running=$(container_running)
|
|
446
|
+
case "$running" in
|
|
447
|
+
true) running="running" ;;
|
|
448
|
+
false) running="STOPPED" ;;
|
|
449
|
+
*) running="in an unknown state (docker inspect failed)" ;;
|
|
450
|
+
esac
|
|
451
|
+
if [ -n "$ROLLBACK_FAILED" ]; then
|
|
452
|
+
{
|
|
453
|
+
say "before: $BEFORE"
|
|
454
|
+
[ -z "$ROLLBACK_SNAPSHOT" ] || say "git snapshot: $ROLLBACK_SNAPSHOT"
|
|
455
|
+
[ -z "$ROLLBACK_SNAPSHOT_HOOKS" ] || say "git snapshot hooks: $ROLLBACK_SNAPSHOT_HOOKS"
|
|
456
|
+
[ -z "$ROLLBACK_SNAPSHOT_ATTRIBUTES" ] || say "git snapshot attributes: $ROLLBACK_SNAPSHOT_ATTRIBUTES"
|
|
457
|
+
say "failed: $ROLLBACK_FAILED"
|
|
458
|
+
say "at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
459
|
+
say "butler: $running"
|
|
460
|
+
} >"$INCOMPLETE" || say "could not write $INCOMPLETE"
|
|
461
|
+
say "ROLLBACK INCOMPLETE: these steps failed: $ROLLBACK_FAILED. The Butler is $running. See $INCOMPLETE; fix the cause, then run --rollback again."
|
|
462
|
+
return 1
|
|
463
|
+
fi
|
|
464
|
+
rm -f -- "$INCOMPLETE" "$APPLYING"
|
|
465
|
+
say "rolled back: the hourly backup owns the bundle again; commits already pushed stay on GitHub. The Butler is $running."
|
|
466
|
+
[ "$reached" -lt 1 ] || [ "$running" = running ]
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
butler_is_running() { [ "$(container_running)" = true ]; }
|
|
470
|
+
|
|
471
|
+
# Before step 1, while root still owns repo.git: a root-owned 0600 tar of what in it can make git run a command, which
|
|
472
|
+
# the Butler can change once it owns .git: config, hooks/ and info/attributes. Sets SNAPSHOT and SNAPSHOT_LINES, the lines
|
|
473
|
+
# the apply records with its state so a rollback finds the snapshot.
|
|
474
|
+
take_git_snapshot() {
|
|
475
|
+
local hooks=absent attributes=absent
|
|
476
|
+
local -a members=(config)
|
|
477
|
+
[ ! -L "$BACKUP_GIT" ] && [ -d "$BACKUP_GIT" ] || die "$BACKUP_GIT is not a real directory"
|
|
478
|
+
[ ! -L "$BACKUP_GIT/config" ] && [ -f "$BACKUP_GIT/config" ] || die "$BACKUP_GIT/config is not a regular file"
|
|
479
|
+
if [ -d "$BACKUP_GIT/hooks" ] && [ ! -L "$BACKUP_GIT/hooks" ]; then hooks=present; members+=(hooks); fi
|
|
480
|
+
if [ -f "$BACKUP_GIT/info/attributes" ] && [ ! -L "$BACKUP_GIT/info/attributes" ]; then attributes=present; members+=(info/attributes); fi
|
|
481
|
+
SNAPSHOT=$GIT_SNAPSHOT_PREFIX-$(date -u +%Y%m%dT%H%M%SZ)-$$.tar
|
|
482
|
+
(set -C; umask 077; : >"$SNAPSHOT") || die "could not create $SNAPSHOT"
|
|
483
|
+
tar -C "$BACKUP_GIT" -cf "$SNAPSHOT" -- "${members[@]}" || die "could not save $BACKUP_GIT's config and hooks to $SNAPSHOT"
|
|
484
|
+
if ! { chown "$ROOT_OWNER" "$SNAPSHOT" && chmod 0600 "$SNAPSHOT"; }; then die "could not make $SNAPSHOT root-only"; fi
|
|
485
|
+
SNAPSHOT_LINES=$(printf 'git snapshot: %s\ngit snapshot hooks: %s\ngit snapshot attributes: %s' "$SNAPSHOT" "$hooks" "$attributes")
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
# A value the apply recorded: in the in-progress file, the unfinished rollback's file, or the marker.
|
|
489
|
+
recorded_value() {
|
|
490
|
+
local file value
|
|
491
|
+
for file in "$APPLYING" "$INCOMPLETE" "$MARKER"; do
|
|
492
|
+
if [ -f "$file" ] && value=$(file_value "$file" "$1"); then printf '%s' "$value"; return 0; fi
|
|
493
|
+
done
|
|
494
|
+
return 1
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
# Moves a path aside as <path>.disabled-<time>-<pid>, never deleting it.
|
|
498
|
+
disable_path() {
|
|
499
|
+
local target
|
|
500
|
+
target="$1.disabled-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
|
501
|
+
mv -- "$1" "$target" && say "moved aside $1 (now $target)"
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
# The first thing a rollback does to repo.git, before any git runs on it: root owns it again, config is the snapshot's
|
|
505
|
+
# byte for byte (so no [include], filter, alias, sshCommand, insteadOf, hooksPath, fsmonitor or extension the Butler
|
|
506
|
+
# added survives), config.worktree and commondir are gone, hooks/ holds exactly the snapshot's hooks (anything else moved
|
|
507
|
+
# aside, never deleted), and info/attributes is the snapshot's or absent. GIT_READY says whether git may run on it.
|
|
508
|
+
GIT_READY=0
|
|
509
|
+
secure_backup_git() {
|
|
510
|
+
local snapshot hooks attributes owner mode tmp entry name links rc=0
|
|
511
|
+
GIT_READY=0
|
|
512
|
+
snapshot=$ROLLBACK_SNAPSHOT hooks=$ROLLBACK_SNAPSHOT_HOOKS attributes=$ROLLBACK_SNAPSHOT_ATTRIBUTES
|
|
513
|
+
[ -n "$snapshot" ] && [ -n "$hooks" ] && [ -n "$attributes" ] || { say "no git snapshot is recorded, so no git runs on $BACKUP_GIT"; return 1; }
|
|
514
|
+
[ -f "$snapshot" ] && [ ! -L "$snapshot" ] || { say "the git snapshot $snapshot is missing, so no git runs on $BACKUP_GIT"; return 1; }
|
|
515
|
+
[ -d "$BACKUP_GIT" ] && [ ! -L "$BACKUP_GIT" ] || { say "$BACKUP_GIT is not a real directory"; return 1; }
|
|
516
|
+
owner=$(before_value "repo.git owner") || return 1
|
|
517
|
+
mode=$(before_value "repo.git mode") || return 1
|
|
518
|
+
chown -R -h "$owner" "$BACKUP_GIT" && chmod "$mode" "$BACKUP_GIT" || return 1
|
|
519
|
+
# A real repo.git holds no symbolic links. One the Butler planted (objects/ab -> /etc, logs/HEAD -> a host file) would
|
|
520
|
+
# have root's git write through it, and a link in objects/ means the object store is no longer one root can trust (N1).
|
|
521
|
+
links=$(find "$BACKUP_GIT" -type l -print) || { say "could not look for symbolic links in $BACKUP_GIT, so no git runs on it"; return 1; }
|
|
522
|
+
if [ -n "$links" ]; then
|
|
523
|
+
say "$BACKUP_GIT holds a symbolic link, ${links%%$'\n'*}: move it aside (a real repo.git holds no links), then run --rollback again"
|
|
524
|
+
return 1
|
|
525
|
+
fi
|
|
526
|
+
tmp=$(umask 077; mktemp -d "$BACKUP/.cutover-git-restore.XXXXXX") || return 1
|
|
527
|
+
# In a subshell, so the work directory is removed whichever step fails.
|
|
528
|
+
(
|
|
529
|
+
tar -C "$tmp" -xf "$snapshot" || exit 1
|
|
530
|
+
# Each copy goes to a fresh name first (rm never follows a link the Butler may have planted there), then over the old file.
|
|
531
|
+
rm -f -- "$BACKUP_GIT/.config.cutover-restore" && cp -p -- "$tmp/config" "$BACKUP_GIT/.config.cutover-restore" && mv -f -- "$BACKUP_GIT/.config.cutover-restore" "$BACKUP_GIT/config" || exit 1
|
|
532
|
+
rm -f -- "$BACKUP_GIT/config.worktree" || exit 1
|
|
533
|
+
if [ -e "$BACKUP_GIT/commondir" ] || [ -L "$BACKUP_GIT/commondir" ]; then disable_path "$BACKUP_GIT/commondir" || exit 1; fi
|
|
534
|
+
if [ -L "$BACKUP_GIT/hooks" ] || { [ -e "$BACKUP_GIT/hooks" ] && [ ! -d "$BACKUP_GIT/hooks" ]; }; then disable_path "$BACKUP_GIT/hooks" || exit 1; fi
|
|
535
|
+
if [ "$hooks" = present ] || [ -d "$BACKUP_GIT/hooks" ]; then
|
|
536
|
+
mkdir -p -- "$BACKUP_GIT/hooks" || exit 1
|
|
537
|
+
for entry in "$BACKUP_GIT/hooks"/* "$BACKUP_GIT/hooks"/.[!.]* "$BACKUP_GIT/hooks"/..?*; do
|
|
538
|
+
[ -e "$entry" ] || [ -L "$entry" ] || continue
|
|
539
|
+
name=${entry##*/}
|
|
540
|
+
case "$name" in *.disabled-*) continue ;; esac
|
|
541
|
+
if [ -f "$tmp/hooks/$name" ] && [ ! -L "$tmp/hooks/$name" ] && [ -f "$entry" ] && [ ! -L "$entry" ] && cmp -s -- "$entry" "$tmp/hooks/$name"; then continue; fi
|
|
542
|
+
disable_path "$entry" || exit 1
|
|
543
|
+
done
|
|
544
|
+
for entry in "$tmp/hooks"/* "$tmp/hooks"/.[!.]* "$tmp/hooks"/..?*; do
|
|
545
|
+
[ -f "$entry" ] && [ ! -L "$entry" ] || continue
|
|
546
|
+
name=${entry##*/}
|
|
547
|
+
rm -f -- "$BACKUP_GIT/hooks/.$name.cutover-restore" && cp -p -- "$entry" "$BACKUP_GIT/hooks/.$name.cutover-restore" && mv -f -- "$BACKUP_GIT/hooks/.$name.cutover-restore" "$BACKUP_GIT/hooks/$name" || exit 1
|
|
548
|
+
done
|
|
549
|
+
fi
|
|
550
|
+
if [ -L "$BACKUP_GIT/info" ] || { [ -e "$BACKUP_GIT/info" ] && [ ! -d "$BACKUP_GIT/info" ]; }; then disable_path "$BACKUP_GIT/info" || exit 1; fi
|
|
551
|
+
if [ "$attributes" = present ]; then
|
|
552
|
+
mkdir -p -- "$BACKUP_GIT/info" || exit 1
|
|
553
|
+
rm -f -- "$BACKUP_GIT/info/.attributes.cutover-restore" && cp -p -- "$tmp/info/attributes" "$BACKUP_GIT/info/.attributes.cutover-restore" && mv -f -- "$BACKUP_GIT/info/.attributes.cutover-restore" "$BACKUP_GIT/info/attributes" || exit 1
|
|
554
|
+
elif [ -e "$BACKUP_GIT/info/attributes" ] || [ -L "$BACKUP_GIT/info/attributes" ]; then
|
|
555
|
+
rm -f -- "$BACKUP_GIT/info/attributes" || exit 1
|
|
556
|
+
fi
|
|
557
|
+
chown -R -h "$owner" "$BACKUP_GIT" && chmod "$mode" "$BACKUP_GIT"
|
|
558
|
+
) || rc=1
|
|
559
|
+
rm -rf -- "$tmp"
|
|
560
|
+
[ "$rc" -eq 0 ] || return 1
|
|
561
|
+
GIT_READY=1
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
# A rollback step that runs git on repo.git: only once secure_backup_git has put it back.
|
|
565
|
+
git_step() {
|
|
566
|
+
if [ "$GIT_READY" -ne 1 ]; then say "skipped: repo.git's config and hooks were not put back, so no git runs on it"; return 1; fi
|
|
567
|
+
"$@"
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
restore_agent_sync() {
|
|
571
|
+
local value
|
|
572
|
+
value=$(file_value "$BEFORE" "agent.json sync") || { say "the before-state has no agent.json sync line"; return 1; }
|
|
573
|
+
[ "$(agent_sync_value)" = "$value" ] || edit_agent_sync restore "$value"
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
move_git_back() {
|
|
577
|
+
if [ -e "$BUNDLE/.git" ] && [ -e "$BACKUP_GIT" ]; then say "both $BUNDLE/.git and $BACKUP_GIT exist"; return 1; fi
|
|
578
|
+
if [ -e "$BUNDLE/.git" ]; then mv -- "$BUNDLE/.git" "$BACKUP_GIT"; fi
|
|
579
|
+
[ -d "$BACKUP_GIT" ]
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
# A git that a stop killed mid-write leaves index.lock behind, and every hourly backup would then fail unseen. It is removed
|
|
583
|
+
# only when no git runs on the host or in the container (M16).
|
|
584
|
+
clear_stale_index_lock() {
|
|
585
|
+
[ -e "$BACKUP_GIT/index.lock" ] || return 0
|
|
586
|
+
if pgrep -x git >/dev/null; then say "a git process is running on the host; $BACKUP_GIT/index.lock is left in place"; return 1; fi
|
|
587
|
+
if [ "$(container_running)" != false ]; then
|
|
588
|
+
local rc=0
|
|
589
|
+
docker exec "$CONTAINER" pgrep -x git >/dev/null 2>&1 || rc=$?
|
|
590
|
+
if [ "$rc" -ne 1 ]; then say "git may be running in $CONTAINER (pgrep exited $rc); $BACKUP_GIT/index.lock is left in place"; return 1; fi
|
|
591
|
+
fi
|
|
592
|
+
rm -f -- "$BACKUP_GIT/index.lock" && say "removed a stale $BACKUP_GIT/index.lock (no git was running)"
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
# Puts back the backup repository's config, branch, HEAD and exclude from the before-state.
|
|
596
|
+
restore_backup_repo() {
|
|
597
|
+
local branch key value encoded
|
|
598
|
+
[ -d "$BACKUP_GIT" ] || return 1
|
|
599
|
+
branch=$(before_value branch) || return 1
|
|
600
|
+
if [ "$branch" != "$BUNDLE_BRANCH" ] && backup_git rev-parse --verify -q "refs/heads/$BUNDLE_BRANCH" >/dev/null \
|
|
601
|
+
&& [ "$(backup_git symbolic-ref --short HEAD)" = "$BUNDLE_BRANCH" ]; then
|
|
602
|
+
backup_git branch -M "$BUNDLE_BRANCH" "$branch" || return 1
|
|
603
|
+
fi
|
|
604
|
+
value=$(before_value origin.url) || return 1
|
|
605
|
+
restore_config backup_git remote.origin.url "$value" || return 1
|
|
606
|
+
value=$(before_value branch.remote) || return 1
|
|
607
|
+
restore_config backup_git "branch.$branch.remote" "$value" || return 1
|
|
608
|
+
value=$(before_value branch.merge) || return 1
|
|
609
|
+
restore_config backup_git "branch.$branch.merge" "$value" || return 1
|
|
610
|
+
for key in core.bare core.worktree core.sshCommand core.untrackedCache feature.manyFiles; do
|
|
611
|
+
value=$(before_value "$key") || return 1
|
|
612
|
+
restore_config backup_git "$key" "$value" || return 1
|
|
613
|
+
done
|
|
614
|
+
encoded=$(before_value info/exclude.base64) || return 1
|
|
615
|
+
if [ "$encoded" = "(absent)" ]; then
|
|
616
|
+
rm -f -- "$BACKUP_GIT/info/exclude"
|
|
617
|
+
else
|
|
618
|
+
mkdir -p -- "$BACKUP_GIT/info" || return 1
|
|
619
|
+
rm -f -- "$BACKUP_GIT/info/exclude.restore" || return 1
|
|
620
|
+
printf '%s' "$encoded" | base64 -d >"$BACKUP_GIT/info/exclude.restore" || return 1
|
|
621
|
+
mv -f -- "$BACKUP_GIT/info/exclude.restore" "$BACKUP_GIT/info/exclude"
|
|
622
|
+
fi
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
# A commit the apply made after step 4 that GitHub never got (step 9 stopped before its push) is dropped from the branch,
|
|
626
|
+
# so the hourly backup never pushes it. Only GitHub's own main decides: the branch moves back to the step-4 head only when
|
|
627
|
+
# GitHub's main is that head. A push can reach GitHub and still fail on the host, and the owner can push meanwhile, so
|
|
628
|
+
# local refs are never trusted for this. The work tree is not touched.
|
|
629
|
+
drop_unpushed_commit() {
|
|
630
|
+
local head current listed github
|
|
631
|
+
head=$(file_value "$APPLYING" head 2>/dev/null) || return 0
|
|
632
|
+
[ -d "$BACKUP_GIT" ] || return 1
|
|
633
|
+
current=$(backup_git rev-parse HEAD) || return 1
|
|
634
|
+
[ "$current" != "$head" ] || return 0
|
|
635
|
+
listed=$(GIT_SSH_COMMAND=$BACKUP_SSH backup_git ls-remote origin "refs/heads/$BUNDLE_BRANCH") || {
|
|
636
|
+
say "could not read GitHub's $BUNDLE_BRANCH (ls-remote failed), so the branch was left at $current; check GitHub, then run --rollback again"
|
|
637
|
+
return 1
|
|
638
|
+
}
|
|
639
|
+
github=${listed%%[[:space:]]*}
|
|
640
|
+
if [ "$github" = "$head" ]; then
|
|
641
|
+
backup_git reset -q --mixed "$head" || return 1
|
|
642
|
+
say "reset the branch to $head, dropping a commit GitHub never got"
|
|
643
|
+
else
|
|
644
|
+
say "kept the branch: GitHub already has ${github:-(no $BUNDLE_BRANCH)}"
|
|
645
|
+
fi
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
# The hourly backup's push hides its failures, so the rollback proves now that the next one can land.
|
|
649
|
+
check_backup_can_push() {
|
|
650
|
+
local out
|
|
651
|
+
[ -d "$BACKUP_GIT" ] || return 1
|
|
652
|
+
if out=$(GIT_SSH_COMMAND=$BACKUP_SSH backup_git push --dry-run origin "HEAD:$BUNDLE_BRANCH" 2>&1); then return 0; fi
|
|
653
|
+
if printf '%s' "$out" | grep -qiE 'rejected|non-fast-forward|fetch first'; then
|
|
654
|
+
say "the hourly backup can't push; GitHub has commits the host doesn't. Merge GitHub's $BUNDLE_BRANCH into repo.git's branch, then run --rollback again"
|
|
655
|
+
else
|
|
656
|
+
say "the hourly backup can't push: $(printf '%s' "$out" | tail -3 | tr '\n' ' ')"
|
|
657
|
+
fi
|
|
658
|
+
return 1
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
# repo.git's owner (recursively) and mode, as the before-state recorded them (M8).
|
|
662
|
+
restore_repo_owner() {
|
|
663
|
+
local owner mode
|
|
664
|
+
[ -d "$BACKUP_GIT" ] || return 1
|
|
665
|
+
owner=$(before_value "repo.git owner") || return 1
|
|
666
|
+
mode=$(before_value "repo.git mode") || return 1
|
|
667
|
+
chown -R -h "$owner" "$BACKUP_GIT" && chmod "$mode" "$BACKUP_GIT"
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
remove_installed_key() {
|
|
671
|
+
local state
|
|
672
|
+
state=$(before_value "sync key") || return 1
|
|
673
|
+
[ "$state" = present ] || rm -f -- "$SYNC_KEY" "$SYNC_DIR/.deploy_key.cutover"
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
restore_cron() {
|
|
677
|
+
if [ ! -e "$CRON_FILE" ] && [ -f "$CRON_DISABLED" ]; then mv -- "$CRON_DISABLED" "$CRON_FILE" || return 1; fi
|
|
678
|
+
[ -f "$CRON_FILE" ] || { say "$CRON_FILE is missing"; return 1; }
|
|
679
|
+
update_cron || return 1
|
|
680
|
+
grep -q 'backup\.sh' "$CRON_TABLE" || { say "$CRON_TABLE has no backup.sh entry after update_cron"; return 1; }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
# Whether nothing of a cutover is on disk: no marker, no .git in the bundle, no apply in progress, no unfinished rollback
|
|
684
|
+
# and no disabled cron file.
|
|
685
|
+
nothing_to_roll_back() {
|
|
686
|
+
[ ! -e "$MARKER" ] && [ ! -e "$BUNDLE/.git" ] && [ ! -e "$APPLYING" ] && [ ! -e "$INCOMPLETE" ] && [ ! -e "$CRON_DISABLED" ]
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
# The before-state the apply recorded: in the in-progress file, the unfinished rollback's file, or the marker.
|
|
690
|
+
recorded_before() {
|
|
691
|
+
local file value
|
|
692
|
+
for file in "$APPLYING" "$INCOMPLETE" "$MARKER"; do
|
|
693
|
+
if [ -f "$file" ] && value=$(file_value "$file" before) && [ -f "$value" ]; then printf '%s' "$value"; return 0; fi
|
|
694
|
+
done
|
|
695
|
+
return 1
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
rollback() {
|
|
699
|
+
if nothing_to_roll_back; then say "nothing to roll back: no cutover marker and no .git in the bundle"; return 0; fi
|
|
700
|
+
# A running apply holds this lock and rolls itself back on failure; a second writer would race it.
|
|
701
|
+
exec 8>>"$CUTOVER_LOCK"
|
|
702
|
+
if ! flock -n 8; then
|
|
703
|
+
die "an apply is running (log: $(file_value "$APPLYING" log 2>/dev/null || printf 'unknown')); it rolls itself back on failure. Run --rollback after it ends"
|
|
704
|
+
fi
|
|
705
|
+
BEFORE=$(recorded_before) || die "no recorded before-state: none of $APPLYING, $INCOMPLETE or $MARKER names one"
|
|
706
|
+
exec 9>>"$LOCK_FILE"
|
|
707
|
+
flock -w 900 9 || die "the backup lock stayed busy for 15 minutes"
|
|
708
|
+
rollback_from 15
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
# ---------- apply ----------
|
|
712
|
+
|
|
713
|
+
STEP=0
|
|
714
|
+
APPLIED=0
|
|
715
|
+
IPTABLES_RULE=""
|
|
716
|
+
BEFORE=""
|
|
717
|
+
DETACHED_LOG=${OURO_CUTOVER_DETACHED:-}
|
|
718
|
+
|
|
719
|
+
# The one EXIT trap: an apply that did not finish rolls back from the step it reached; the iptables rule and the work
|
|
720
|
+
# directory never outlive the script; a detached run records its final status for the process following its log.
|
|
721
|
+
on_exit() {
|
|
722
|
+
local status=$?
|
|
723
|
+
trap '' INT TERM HUP PIPE
|
|
724
|
+
# A failure inside a redirected command (`f >/dev/null`) runs this trap under that redirection: write to the script's own output.
|
|
725
|
+
exec 1>&5 2>&6
|
|
726
|
+
if [ "$MODE" = apply ] && [ "$APPLIED" -ne 1 ] && [ "$STEP" -gt 0 ]; then
|
|
727
|
+
say "cutover failed at step $STEP (exit $status); rolling back"
|
|
728
|
+
rollback_from "$STEP" || true
|
|
729
|
+
status=1
|
|
730
|
+
fi
|
|
731
|
+
remove_drop_rule || status=1
|
|
732
|
+
[ -z "${WORK:-}" ] || rm -rf -- "$WORK"
|
|
733
|
+
if [ -n "$DETACHED_LOG" ]; then
|
|
734
|
+
printf '%s\n' "$status" >"$DETACHED_LOG.status.tmp" && mv -f -- "$DETACHED_LOG.status.tmp" "$DETACHED_LOG.status"
|
|
735
|
+
fi
|
|
736
|
+
exit "$status"
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
assert_owned_by_butler() {
|
|
740
|
+
local strays
|
|
741
|
+
strays=$(find "$BUNDLE/.git" ! -uid "$BUTLER_UID" -print | head -5)
|
|
742
|
+
[ -z "$strays" ] || die ".git still has paths not owned by $BUTLER_UID: $strays"
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
# The key's mode, owner and type, read without opening it.
|
|
746
|
+
assert_sync_key() {
|
|
747
|
+
"$HOST_NODE" -e '
|
|
748
|
+
const fs = require("node:fs");
|
|
749
|
+
const [dir, key, uid, gid, rootUid] = process.argv.slice(1);
|
|
750
|
+
const d = fs.lstatSync(dir), k = fs.lstatSync(key);
|
|
751
|
+
const fail = (m) => { process.stderr.write("cutover: " + m + "\n"); process.exit(1); };
|
|
752
|
+
if (!d.isDirectory() || (d.mode & 0o7777) !== 0o755 || d.uid !== Number(rootUid)) fail(dir + " must be a root-owned 0755 directory");
|
|
753
|
+
if (!k.isFile() || (k.mode & 0o7777) !== 0o400 || k.uid !== Number(uid) || k.gid !== Number(gid)) fail(key + " must be a regular file, mode 0400, owner " + uid + ":" + gid);
|
|
754
|
+
' "$SYNC_DIR" "$SYNC_KEY" "$SYNC_UID" "$SYNC_GID" "${ROOT_OWNER%%:*}"
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
# Every psyche file is still tracked (M5).
|
|
758
|
+
assert_psyche_tracked() {
|
|
759
|
+
local name
|
|
760
|
+
for name in $PSYCHE_FILES; do
|
|
761
|
+
bundle_git ls-files --error-unmatch -- "psyche/$name" >/dev/null 2>&1 || die "psyche/$name would no longer be tracked; nothing was pushed"
|
|
762
|
+
done
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
# The effective audit of the started container, with sync on, so a missing /run/ouro-sync mount fails the apply (13b).
|
|
766
|
+
audit_started_container() {
|
|
767
|
+
local image=$1 reference
|
|
768
|
+
reference=$(docker inspect --format '{{.Config.Image}}' "$CONTAINER")
|
|
769
|
+
(umask 077; docker inspect "$CONTAINER" >"$WORK/container.json"; docker image inspect "$image" >"$WORK/image.json")
|
|
770
|
+
docker run --rm --pull=never --network=none \
|
|
771
|
+
--user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges \
|
|
772
|
+
--entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
|
|
773
|
+
--mount "type=bind,src=$WORK/container.json,dst=/audit/container.json,readonly" \
|
|
774
|
+
--mount "type=bind,src=$WORK/image.json,dst=/audit/image.json,readonly" \
|
|
775
|
+
"$image" --inspect /audit/container.json --image-inspect /audit/image.json --expected-image "$image" \
|
|
776
|
+
--mount-contract canonical-gateway --expected-image-reference "$reference" --expected-icon "$AUDIT_ICON" --sync-enabled true
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
apply() {
|
|
780
|
+
preflight
|
|
781
|
+
local image head remote_head untrack status dirty fetch
|
|
782
|
+
image=$(before_value image)
|
|
783
|
+
# The moved repository must fetch every branch into refs/remotes/origin/, so a push moves the tracking ref (I4).
|
|
784
|
+
fetch=$(before_value remote.origin.fetch)
|
|
785
|
+
[ "$fetch" = "$DEFAULT_FETCH" ] || die "repo.git's remote.origin.fetch is \"$fetch\", not \"$DEFAULT_FETCH\"; nothing was changed"
|
|
786
|
+
exec 8>>"$CUTOVER_LOCK"
|
|
787
|
+
flock -n 8 || die "another cutover run holds $CUTOVER_LOCK; nothing was changed"
|
|
788
|
+
|
|
789
|
+
take_git_snapshot
|
|
790
|
+
STEP=1; say "1. stopping $CONTAINER"
|
|
791
|
+
printf 'before: %s\nlog: %s\nstarted: %s\n%s\n' "$BEFORE" "${DETACHED_LOG:-(none)}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SNAPSHOT_LINES" >"$APPLYING.tmp"
|
|
792
|
+
mv -f -- "$APPLYING.tmp" "$APPLYING"
|
|
793
|
+
docker stop "$CONTAINER" >/dev/null
|
|
794
|
+
|
|
795
|
+
STEP=2; say "2. disabling the hourly backup cron"
|
|
796
|
+
mv -- "$CRON_FILE" "$CRON_DISABLED"
|
|
797
|
+
update_cron
|
|
798
|
+
if grep -q 'backup\.sh' "$CRON_TABLE"; then die "$CRON_TABLE still runs backup.sh"; fi
|
|
799
|
+
|
|
800
|
+
STEP=3; say "3. taking $LOCK_FILE"
|
|
801
|
+
exec 9>>"$LOCK_FILE"
|
|
802
|
+
flock -w 900 9 || die "the backup lock stayed busy for 15 minutes"
|
|
803
|
+
|
|
804
|
+
STEP=4; say "4. the last backup commit and push, as backup.sh makes them"
|
|
805
|
+
backup_git add -A
|
|
806
|
+
if ! backup_git diff --cached --quiet; then
|
|
807
|
+
backup_git -c "user.name=$BACKUP_COMMIT_NAME" -c "user.email=$BACKUP_COMMIT_EMAIL" commit -q -m "backup $(date -u +%Y-%m-%dT%H:%MZ)"
|
|
808
|
+
fi
|
|
809
|
+
GIT_SSH_COMMAND=$BACKUP_SSH backup_git push -q origin "HEAD:$BUNDLE_BRANCH"
|
|
810
|
+
printf 'head: %s\n' "$(backup_git rev-parse HEAD)" >>"$APPLYING"
|
|
811
|
+
|
|
812
|
+
STEP=5; say "5. moving the repository into the bundle"
|
|
813
|
+
mv -- "$BACKUP_GIT" "$BUNDLE/.git"
|
|
814
|
+
restore_config bundle_git core.bare "(unset)"
|
|
815
|
+
restore_config bundle_git core.worktree "(unset)"
|
|
816
|
+
restore_config bundle_git core.sshCommand "(unset)"
|
|
817
|
+
|
|
818
|
+
STEP=6; say "6. origin is $BUNDLE_REMOTE"
|
|
819
|
+
bundle_git remote set-url origin "$BUNDLE_REMOTE"
|
|
820
|
+
|
|
821
|
+
STEP=7; say "7. fetching origin $BUNDLE_BRANCH and tracking it"
|
|
822
|
+
GIT_SSH_COMMAND=$BACKUP_SSH bundle_git fetch -q origin "+refs/heads/$BUNDLE_BRANCH:refs/remotes/origin/$BUNDLE_BRANCH"
|
|
823
|
+
bundle_git branch -M "$BUNDLE_BRANCH"
|
|
824
|
+
bundle_git branch -q -u "origin/$BUNDLE_BRANCH"
|
|
825
|
+
head=$(bundle_git rev-parse HEAD)
|
|
826
|
+
remote_head=$(bundle_git rev-parse "refs/remotes/origin/$BUNDLE_BRANCH")
|
|
827
|
+
[ "$head" = "$remote_head" ] || die "HEAD $head is not origin/$BUNDLE_BRANCH $remote_head"
|
|
828
|
+
|
|
829
|
+
STEP=8; say "8. writing the release's exclude list"
|
|
830
|
+
mkdir -p -- "$BUNDLE/.git/info"
|
|
831
|
+
cp -- "$WORK/exclude" "$BUNDLE/.git/info/exclude.cutover"
|
|
832
|
+
mv -f -- "$BUNDLE/.git/info/exclude.cutover" "$BUNDLE/.git/info/exclude"
|
|
833
|
+
|
|
834
|
+
STEP=9; say "9. untracking excluded files"
|
|
835
|
+
untrack=$WORK/untrack
|
|
836
|
+
bundle_git ls-files -ci --exclude-standard -z >"$untrack"
|
|
837
|
+
if [ -s "$untrack" ]; then
|
|
838
|
+
bundle_git --literal-pathspecs rm --cached --quiet --pathspec-from-file="$untrack" --pathspec-file-nul
|
|
839
|
+
bundle_git -c "user.name=$AGENT_NAME" -c "user.email=$AGENT_NAME@ouro.local" commit -q -m "sync: stop tracking package-managed and excluded files"
|
|
840
|
+
assert_psyche_tracked
|
|
841
|
+
GIT_SSH_COMMAND=$BACKUP_SSH bundle_git push -q origin "HEAD:$BUNDLE_BRANCH"
|
|
842
|
+
fi
|
|
843
|
+
assert_psyche_tracked
|
|
844
|
+
bundle_git config core.untrackedCache true
|
|
845
|
+
bundle_git config feature.manyFiles true
|
|
846
|
+
|
|
847
|
+
STEP=10; say "10. checking nothing excluded is tracked"
|
|
848
|
+
[ -z "$(bundle_git ls-files -ci --exclude-standard)" ] || die "excluded files are still tracked"
|
|
849
|
+
status=$WORK/status
|
|
850
|
+
GIT_OPTIONAL_LOCKS=0 bundle_git status --porcelain --untracked-files=all >"$status"
|
|
851
|
+
dirty=$(status_problems "$image" "$status")
|
|
852
|
+
[ -z "$dirty" ] || die "git status shows package-managed or secret paths: $(printf '%s' "$dirty" | tr '\n' ' ')"
|
|
853
|
+
|
|
854
|
+
STEP=11; say "11. installing the sync key for ouro-sync"
|
|
855
|
+
[ ! -L "$SYNC_DIR" ] || die "$SYNC_DIR is a symlink"
|
|
856
|
+
mkdir -p -- "$SYNC_DIR"
|
|
857
|
+
chown -h "$ROOT_OWNER" "$SYNC_DIR"
|
|
858
|
+
chmod 0755 "$SYNC_DIR"
|
|
859
|
+
rm -f -- "$SYNC_DIR/.deploy_key.cutover"
|
|
860
|
+
# The copy is born 0600, so it is never readable to anyone else, even before the chmod (M6).
|
|
861
|
+
(umask 077; cp -- "$BACKUP_KEY" "$SYNC_DIR/.deploy_key.cutover")
|
|
862
|
+
chown "$SYNC_OWNER" "$SYNC_DIR/.deploy_key.cutover"
|
|
863
|
+
chmod 0400 "$SYNC_DIR/.deploy_key.cutover"
|
|
864
|
+
mv -f -- "$SYNC_DIR/.deploy_key.cutover" "$SYNC_KEY"
|
|
865
|
+
assert_sync_key
|
|
866
|
+
|
|
867
|
+
STEP=12; say "12. turning sync on in agent.json"
|
|
868
|
+
edit_agent_sync enable
|
|
869
|
+
|
|
870
|
+
STEP=13; say "13. handing .git to the Butler"
|
|
871
|
+
chown -R -h "$BUTLER_OWNER" "$BUNDLE/.git"
|
|
872
|
+
assert_owned_by_butler
|
|
873
|
+
|
|
874
|
+
STEP=14; say "14. writing $MARKER"
|
|
875
|
+
printf 'before: %s\n%s\ndone: %s\n' "$BEFORE" "$SNAPSHOT_LINES" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$MARKER"
|
|
876
|
+
|
|
877
|
+
STEP=15; say "15. starting $CONTAINER"
|
|
878
|
+
docker start "$CONTAINER" >/dev/null
|
|
879
|
+
say "13b. auditing the started container with sync on"
|
|
880
|
+
audit_started_container "$image" >/dev/null
|
|
881
|
+
exec_check -u 10001 "$CONTAINER" test -r "$CONTAINER_KEY"
|
|
882
|
+
case "$EXEC_STATUS" in
|
|
883
|
+
0) die "the Butler's uid can read $CONTAINER_KEY" ;;
|
|
884
|
+
1) ;;
|
|
885
|
+
*) die "test -r $CONTAINER_KEY as the Butler's uid exited $EXEC_STATUS, not 1" ;;
|
|
886
|
+
esac
|
|
887
|
+
|
|
888
|
+
rm -f -- "$APPLYING"
|
|
889
|
+
APPLIED=1
|
|
890
|
+
say "cutover done. Run --verify-live next."
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
# ---------- verify live ----------
|
|
894
|
+
|
|
895
|
+
# Removes the stall probe's rule and proves it is gone (`iptables -C` must then fail).
|
|
896
|
+
remove_drop_rule() {
|
|
897
|
+
[ -n "$IPTABLES_RULE" ] || return 0
|
|
898
|
+
local rule=$IPTABLES_RULE
|
|
899
|
+
IPTABLES_RULE=""
|
|
900
|
+
# shellcheck disable=SC2086
|
|
901
|
+
iptables -D $rule || say "verify: could not remove the iptables rule: iptables -D $rule" >&2
|
|
902
|
+
# shellcheck disable=SC2086
|
|
903
|
+
if iptables -C $rule 2>/dev/null; then
|
|
904
|
+
say "verify: THE IPTABLES RULE IS STILL IN PLACE; remove it by hand: iptables -D $rule" >&2
|
|
905
|
+
return 1
|
|
906
|
+
fi
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
butler_sync() { docker exec -u 10001 "$CONTAINER" timeout 60 node "$CLI_ENTRY" sync --agent "$AGENT_NAME"; }
|
|
910
|
+
|
|
911
|
+
verify_live() {
|
|
912
|
+
[ -e "$MARKER" ] || die "the cutover is not done ($MARKER is missing)"
|
|
913
|
+
local heads uid start elapsed status out leftover
|
|
914
|
+
trap 'trap "" INT TERM HUP; exit 130' INT TERM
|
|
915
|
+
trap 'trap "" INT TERM HUP; exit 129' HUP
|
|
916
|
+
|
|
917
|
+
say "the sync path reaches GitHub as ouro-sync"
|
|
918
|
+
heads=$(docker exec -u 10001 -e "GIT_SSH_COMMAND=sudo -n -u ouro-sync $SYNC_SSH_WRAPPER" -e GIT_SSH_VARIANT=ssh "$CONTAINER" \
|
|
919
|
+
git ls-remote "$BUNDLE_REMOTE" "refs/heads/$BUNDLE_BRANCH")
|
|
920
|
+
[ -n "$heads" ] || die "git ls-remote through $SYNC_SSH_WRAPPER returned no $BUNDLE_BRANCH"
|
|
921
|
+
|
|
922
|
+
say "the Butler's uid cannot read the key"
|
|
923
|
+
assert_key_unreadable
|
|
924
|
+
|
|
925
|
+
say "a stalled fetch ends on its own"
|
|
926
|
+
# The container shares the host's network, so the rule drops port 22 for the sync user's uid alone (M4).
|
|
927
|
+
exec_check "$CONTAINER" id -u "$SYNC_USER"
|
|
928
|
+
uid=$(tr -d '[:space:]' <"$WORK/exec.out")
|
|
929
|
+
[ "$EXEC_STATUS" -eq 0 ] && [ "$uid" = "$SYNC_CONTAINER_UID" ] || die "the image's $SYNC_USER uid is \"$uid\", not $SYNC_CONTAINER_UID"
|
|
930
|
+
IPTABLES_RULE="OUTPUT -m owner --uid-owner $uid -p tcp --dport 22 -m comment --comment $PROBE_COMMENT -j DROP"
|
|
931
|
+
# shellcheck disable=SC2086
|
|
932
|
+
iptables -I $IPTABLES_RULE
|
|
933
|
+
start=$(date +%s)
|
|
934
|
+
status=0
|
|
935
|
+
out=$(butler_sync 2>&1) || status=$?
|
|
936
|
+
elapsed=$(( $(date +%s) - start ))
|
|
937
|
+
remove_drop_rule || die "the stall probe's iptables rule could not be removed"
|
|
938
|
+
printf '%s\n' "$out"
|
|
939
|
+
[ "$status" -ne 0 ] || die "ouro sync succeeded while TCP 22 was dropped, so the rule did not stall it"
|
|
940
|
+
if printf '%s' "$out" | grep -qi 'paused'; then die "ouro sync was paused, so the stall proves nothing; remove state/sync-paused and run again"; fi
|
|
941
|
+
# The stall must end the way a stall ends: a timeout or a failed connect, not some other fast failure (M2).
|
|
942
|
+
printf '%s' "$out" | grep -Eqi 'timed out|connect to host' || die "the stalled ouro sync failed without a timeout or a connect failure (exit $status)"
|
|
943
|
+
[ "$elapsed" -le "$STALL_LIMIT_S" ] || die "the stalled ouro sync took ${elapsed}s; it must end within ${STALL_LIMIT_S}s"
|
|
944
|
+
exec_check "$CONTAINER" ps -u "$uid" -o pid=
|
|
945
|
+
leftover=$(tr -d '[:space:]' <"$WORK/exec.out")
|
|
946
|
+
case "$EXEC_STATUS" in
|
|
947
|
+
0) die "ouro-sync processes outlived the stalled sync: $leftover" ;;
|
|
948
|
+
1) [ -z "$leftover" ] || die "ouro-sync processes outlived the stalled sync: $leftover" ;;
|
|
949
|
+
*) die "ps -u $uid exited $EXEC_STATUS, not 0 or 1" ;;
|
|
950
|
+
esac
|
|
951
|
+
say "the stalled sync ended in ${elapsed}s with nothing left running"
|
|
952
|
+
|
|
953
|
+
say "ouro sync works again"
|
|
954
|
+
butler_sync
|
|
955
|
+
say "verify-live ok"
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
# ---------- detaching ----------
|
|
959
|
+
|
|
960
|
+
# Starts this script again for $1 in a new session with all output in a new log, then follows that log and exits with
|
|
961
|
+
# the detached run's status. Losing this process (Ctrl-C, a closed terminal, a dropped ssh session) never stops the run.
|
|
962
|
+
run_detached() {
|
|
963
|
+
local mode=$1 log
|
|
964
|
+
[ -d "$BACKUP" ] || die "$BACKUP is missing"
|
|
965
|
+
log=$BACKUP/cutover-$(date -u +%Y%m%dT%H%M%SZ)-$$.log
|
|
966
|
+
(set -C; umask 077; : >"$log") || die "could not create $log"
|
|
967
|
+
OURO_CUTOVER_DETACHED=$log setsid -f bash "$0" "$mode" </dev/null >>"$log" 2>&1 || die "could not start the detached run"
|
|
968
|
+
say "running detached; its log is $log"
|
|
969
|
+
follow_log "$log"
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
follow_log() {
|
|
973
|
+
local log=$1 printed=0 size pid="" waited=0
|
|
974
|
+
trap 'say ""; say "stopped following. The run continues detached; its log is $log"; exit 130' INT TERM HUP
|
|
975
|
+
while :; do
|
|
976
|
+
size=$(wc -c <"$log" | tr -d ' ')
|
|
977
|
+
if [ "$size" -gt "$printed" ]; then
|
|
978
|
+
tail -c +"$((printed + 1))" "$log" | head -c "$((size - printed))" || true
|
|
979
|
+
printed=$size
|
|
980
|
+
fi
|
|
981
|
+
if [ -f "$log.status" ]; then
|
|
982
|
+
size=$(wc -c <"$log" | tr -d ' ')
|
|
983
|
+
[ "$size" -le "$printed" ] || tail -c +"$((printed + 1))" "$log"
|
|
984
|
+
exit "$(cat "$log.status")"
|
|
985
|
+
fi
|
|
986
|
+
[ -n "$pid" ] || pid=$(cat "$log.pid" 2>/dev/null || true)
|
|
987
|
+
if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null && [ ! -f "$log.status" ]; then
|
|
988
|
+
tail -c +"$((printed + 1))" "$log"
|
|
989
|
+
say "THE DETACHED RUN (pid $pid) ENDED WITHOUT A STATUS. Read $log; if the Butler is not back, run --rollback."
|
|
990
|
+
exit 1
|
|
991
|
+
fi
|
|
992
|
+
if [ -z "$pid" ]; then
|
|
993
|
+
waited=$((waited + 1))
|
|
994
|
+
[ "$waited" -lt 100 ] || { say "the detached run never started; see $log"; exit 1; }
|
|
995
|
+
fi
|
|
996
|
+
sleep 0.2
|
|
997
|
+
done
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
# ---------- main ----------
|
|
1001
|
+
|
|
1002
|
+
MODE=dry-run
|
|
1003
|
+
MODE_ARG=${1:-}
|
|
1004
|
+
case "$MODE_ARG" in
|
|
1005
|
+
"" | --dry-run) MODE=dry-run ;;
|
|
1006
|
+
--apply) MODE=apply ;;
|
|
1007
|
+
--rollback) MODE=rollback ;;
|
|
1008
|
+
--verify-live) MODE=verify-live ;;
|
|
1009
|
+
*) die "usage: sanctuary-sync-cutover.sh [--dry-run|--apply|--rollback|--verify-live]" ;;
|
|
1010
|
+
esac
|
|
1011
|
+
[ "$#" -le 1 ] || die "usage: sanctuary-sync-cutover.sh [--dry-run|--apply|--rollback|--verify-live]"
|
|
1012
|
+
if [ -z "$TEST_ROOT" ] && [ "$(id -u)" -ne 0 ]; then die "run this as root on the host"; fi
|
|
1013
|
+
|
|
1014
|
+
if [ -z "$DETACHED_LOG" ] && { [ "$MODE" = apply ] || [ "$MODE" = rollback ]; }; then
|
|
1015
|
+
# The detached run starts this file again, so it must be one (not `bash -s` reading stdin).
|
|
1016
|
+
{ [ -f "$0" ] && [ -r "$0" ]; } || die "run this script from a file, not stdin"
|
|
1017
|
+
if [ "$MODE" = rollback ] && nothing_to_roll_back; then say "nothing to roll back: no cutover marker and no .git in the bundle"; exit 0; fi
|
|
1018
|
+
run_detached "$MODE_ARG"
|
|
1019
|
+
fi
|
|
1020
|
+
|
|
1021
|
+
if [ -n "$DETACHED_LOG" ]; then
|
|
1022
|
+
# A detached run outlives its terminal: HUP is ignored, as under nohup. INT and TERM end an apply into its rollback.
|
|
1023
|
+
trap '' HUP
|
|
1024
|
+
printf '%s\n' "$$" >"$DETACHED_LOG.pid"
|
|
1025
|
+
fi
|
|
1026
|
+
exec 5>&1 6>&2
|
|
1027
|
+
WORK=$(mktemp -d "${TMPDIR:-/tmp}/sanctuary-sync-cutover.XXXXXX")
|
|
1028
|
+
trap on_exit EXIT
|
|
1029
|
+
trap 'trap "" INT TERM HUP; exit 130' INT TERM
|
|
1030
|
+
|
|
1031
|
+
case "$MODE" in
|
|
1032
|
+
dry-run) preflight ;;
|
|
1033
|
+
apply) apply ;;
|
|
1034
|
+
rollback)
|
|
1035
|
+
trap '' INT TERM HUP PIPE
|
|
1036
|
+
rollback
|
|
1037
|
+
;;
|
|
1038
|
+
verify-live) verify_live ;;
|
|
1039
|
+
esac
|