Compare commits
6 Commits
a3ef408de6
...
864100b813
| Author | SHA1 | Date | |
|---|---|---|---|
| 864100b813 | |||
| 9971a63635 | |||
| 9c29bfcd86 | |||
| 8588b058c5 | |||
|
|
d7e19bd9da | ||
|
|
a00fad5553 |
@ -11,7 +11,7 @@ AI_MODEL=
|
||||
# Optional private host-side JSON file binding internal hotel IDs to OHIP credentials.
|
||||
# Default example has no connections. Never commit the real configuration.
|
||||
PMS_CONFIG_FILE_HOST=./deploy/pms.example.json
|
||||
# CI produces image archives. Set these to the loaded, reviewed commit tags.
|
||||
# Ansible builds the reviewed source package. Set these to its full-commit image tags.
|
||||
GUESTOPS_API_IMAGE=guestops-api:local
|
||||
GUESTOPS_WORKER_IMAGE=guestops-worker:local
|
||||
|
||||
|
||||
98
.github/workflows/web.yml
vendored
98
.github/workflows/web.yml
vendored
@ -1,98 +0,0 @@
|
||||
name: Build and verify web migration
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'codex/**']
|
||||
tags: ['[0-9]+.[0-9]+.[0-9]+']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:8.0
|
||||
ports: ['27017:27017']
|
||||
options: >-
|
||||
--health-cmd "mongosh --quiet --eval 'db.adminCommand({ping:1}).ok'"
|
||||
--health-interval 10s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with: { dotnet-version: '10.0.x' }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '22', cache: npm, cache-dependency-path: web/package-lock.json }
|
||||
- name: Build services
|
||||
run: dotnet build src/GuestOps.Worker/GuestOps.Worker.csproj -c Release
|
||||
- name: Verify backup validation and failure recovery
|
||||
run: python3 -m unittest discover -s tests -p 'test_*.py'
|
||||
- name: Build interface
|
||||
working-directory: web
|
||||
run: npm ci && npm run build
|
||||
- name: Start isolated preview API
|
||||
run: |
|
||||
ASPNETCORE_ENVIRONMENT=Development Preview=true dotnet src/GuestOps.Api/bin/Release/net10.0/GuestOps.Api.dll --urls http://127.0.0.1:5180 > /tmp/guestops-api.log 2>&1 &
|
||||
for i in $(seq 1 30); do curl -fsS http://127.0.0.1:5180/health && exit 0; sleep 1; done
|
||||
cat /tmp/guestops-api.log
|
||||
exit 1
|
||||
- name: Verify MongoDB and HTTP boundaries
|
||||
env:
|
||||
MONGO_TEST_URI: mongodb://127.0.0.1:27017
|
||||
TEST_API_URL: http://127.0.0.1:5180
|
||||
run: dotnet run --project tests/GuestOps.Tests/GuestOps.Tests.csproj -c Release
|
||||
- name: Build Linux images
|
||||
run: |
|
||||
docker build --target api -t guestops-api:${{ github.sha }} .
|
||||
docker build --target worker -t guestops-worker:${{ github.sha }} .
|
||||
- name: Package reviewed images
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
docker save guestops-api:${{ github.sha }} guestops-worker:${{ github.sha }} | gzip -n > guestops-images.tar.gz
|
||||
python3 deploy/release_record.py \
|
||||
--artifact guestops-images.tar.gz \
|
||||
--commit '${{ github.sha }}' \
|
||||
--api-image 'guestops-api:${{ github.sha }}' \
|
||||
--api-id "$(docker image inspect --format '{{.Id}}' 'guestops-api:${{ github.sha }}')" \
|
||||
--worker-image 'guestops-worker:${{ github.sha }}' \
|
||||
--worker-id "$(docker image inspect --format '{{.Id}}' 'guestops-worker:${{ github.sha }}')" \
|
||||
--output release-record.json
|
||||
sha256sum --check <(python3 -c "import json; r=json.load(open('release-record.json')); print(r['artifact']['sha256'] + ' ' + r['artifact']['name'])")
|
||||
- name: Smoke test production containers and restart persistence
|
||||
env:
|
||||
GUESTOPS_API_IMAGE: guestops-api:${{ github.sha }}
|
||||
GUESTOPS_WORKER_IMAGE: guestops-worker:${{ github.sha }}
|
||||
BOOTSTRAP_EMAIL: ci-owner@example.invalid
|
||||
BOOTSTRAP_HOTEL: CI test hotel
|
||||
run: |
|
||||
export MONGO_ROOT_PASSWORD=$(openssl rand -hex 32)
|
||||
export MONGO_APP_PASSWORD=$(openssl rand -hex 32)
|
||||
export BOOTSTRAP_PASSWORD=$(openssl rand -hex 24)
|
||||
trap 'docker compose down --volumes' EXIT
|
||||
docker compose config --quiet
|
||||
docker compose up -d --no-build
|
||||
curl --retry 30 --retry-delay 2 --retry-all-errors --fail http://127.0.0.1:8080/health
|
||||
docker compose run --rm --no-deps -e BOOTSTRAP_EMAIL -e BOOTSTRAP_HOTEL -e BOOTSTRAP_PASSWORD api --bootstrap
|
||||
python3 tests/production_smoke.py
|
||||
docker compose restart api worker
|
||||
curl --retry 30 --retry-delay 2 --retry-all-errors --fail http://127.0.0.1:8080/health
|
||||
python3 tests/production_smoke.py --read
|
||||
install -m 600 /dev/null .env
|
||||
python3 deploy/ops.py preflight --offline
|
||||
mkdir -m 700 .guestops-test-keyring
|
||||
export GNUPGHOME="$PWD/.guestops-test-keyring"
|
||||
gpg --batch --pinentry-mode loopback --passphrase '' --quick-generate-key 'GuestOps CI <ci@example.invalid>' rsa2048 encr 1d
|
||||
BACKUP_RECIPIENT=$(gpg --batch --with-colons --list-keys | awk -F: '$1=="fpr" {print $10; exit}')
|
||||
backup_dir=$(mktemp -d)
|
||||
python3 deploy/ops.py backup --recipient "$BACKUP_RECIPIENT" --output "$backup_dir/fixture.tar.gpg" --confirm-maintenance
|
||||
python3 deploy/ops.py restore-drill "$backup_dir/fixture.tar.gpg" --api-image "$GUESTOPS_API_IMAGE"
|
||||
curl --retry 30 --retry-delay 2 --retry-all-errors --fail http://127.0.0.1:8080/health/ready
|
||||
python3 tests/production_smoke.py --read
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: guestops-linux-${{ github.run_number }}
|
||||
path: |
|
||||
guestops-images.tar.gz
|
||||
release-record.json
|
||||
retention-days: 90
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -17,6 +17,7 @@
|
||||
**/payments.local.json
|
||||
.guestops-maintenance.lock
|
||||
.guestops-test-keyring/
|
||||
.guestops-release/
|
||||
guestops-backup-*/
|
||||
guestops-restore-*/
|
||||
*.tar.gpg
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>0.2.0</Version>
|
||||
<Version>0.2.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
|
||||
@ -1,10 +1,36 @@
|
||||
# GuestOps Milestone Report
|
||||
|
||||
Version: **0.2.0 release candidate**
|
||||
Last updated: **29 September 2026**
|
||||
Version: **0.2.1 release candidate**
|
||||
Last updated: **30 September 2026**
|
||||
|
||||
This is the working delivery tracker for GuestOps Web. Update a milestone when its state changes and link the pull request, release artifact, test run, or acceptance record that proves the change.
|
||||
|
||||
## Milestones at a glance
|
||||
|
||||
This summary explains what each milestone delivers and where it currently stands. The release-gate and delivery tables below contain the detailed evidence and exit conditions.
|
||||
|
||||
| # | Milestone | What it delivers | Current position |
|
||||
| ---: | --- | --- | --- |
|
||||
| 1 | Web foundation | The React application, ASP.NET Core API, tenant-isolated MongoDB storage, preview mode, and container foundation. | **Implemented.** The application foundation and automated tests are on `main`. |
|
||||
| 2 | AI suggestions and reviewed Gmail sending | AI-assisted reply drafts and staff-reviewed Gmail delivery with safety and duplicate-send controls. | **Implemented; acceptance required.** Real Gmail threading, revocation, uncertain-send, and staff-review scenarios still need live evidence. |
|
||||
| 3 | OHIP PMS workflow | Internal proposal, approval, and execution controls for PMS operations. | **Implemented; acceptance required.** The workflow exists, but provider-contract and sandbox acceptance remain outstanding. |
|
||||
| 4 | NMI payment workflow | Internal payment proposal, approval, status, expiry, and reconciliation controls. | **Implemented; acceptance required.** The workflow exists, but real payment-provider sandbox acceptance remains outstanding. |
|
||||
| 5 | FAQ automation | Knowledge-based FAQ drafting, test mode, approval controls, and guarded live automation. | **Implemented; acceptance required.** Live mode remains disabled pending quality, false-positive, monitoring, and rollback acceptance. |
|
||||
| 6 | Team onboarding and account recovery | Staff invitations, password setup/reset, access disable/restore, and administrator recovery. | **Implemented; acceptance required.** Deployed link delivery, expiry, recovery, and administrator procedures still need operational evidence. |
|
||||
| 7 | Google connection recovery | OAuth reconnect, checkpoint recovery, grant revocation handling, and worker restart safety. | **Implemented; acceptance required.** Dedicated Google-account and worker-restart exercises have not yet been accepted. |
|
||||
| 8 | Operational readiness tooling | Release verification, diagnostics, encrypted backup, restore, preflight, and persistence tools. | **Implemented; acceptance required.** The tools must still be run against the exact release on the Debian host. |
|
||||
| 9 | Gitea and reproducible releases | A checksummed source package, Ansible-controlled installation, recorded image identities, retained artifacts, and approval tagging. | **In progress.** The runner-free deterministic packager is implemented and the Gitea Action is removed; the release-line identity must be confirmed, then the package, Ansible installation evidence, and approval record must be retained. |
|
||||
| 10 | Debian deployment and persistence | Secure Debian/Compose deployment, HTTPS, persistent database and key volumes, and reboot/recreation proof. | **In progress.** Acceptance tooling is ready, but Docker, correct HTTPS/network exposure, exact artifacts, privileged installation, and the supervised drills remain open. |
|
||||
| 11 | Backups, monitoring, and recovery | Scheduled encrypted backups, verified off-host transfer, Zabbix monitoring, restore, and rollback rehearsal. | **In progress.** Repository tooling is ready; installation and timed operational evidence are blocked until Milestones 9 and 10 pass. |
|
||||
| 12 | Google mailbox acceptance | End-to-end Gmail consent, import, recovery, reviewed sending, reconciliation, and revocation evidence. | **In progress.** The runbook and validator exist; the live synthetic-data exercise and independent review remain outstanding. |
|
||||
| 13 | Rezlynx/Guestline adapter | The real PMS provider adapter, mappings, idempotency, reconciliation, and ambiguous-write handling. | **Planned.** Provider contract and sandbox access are still required before implementation and acceptance. |
|
||||
| 14 | Payment links and status | The real payment-provider integration, webhooks, expiry, replay protection, and reconciliation. | **Planned.** The provider path and sandbox acceptance plan still need to be confirmed and completed. |
|
||||
| 15 | Knowledge, AI, and FAQ activation | Supervised knowledge-quality, AI-draft, FAQ test-mode, staff-training, and stop-control acceptance. | **Implemented; acceptance required.** The evaluation tooling exists; the supervised evaluation and independent approval remain outstanding. |
|
||||
| 16 | Identity, preferences, and privacy | Account/session controls, hotel preferences, privacy inventory, retention decisions, and audit review. | **Implemented; acceptance required.** Legal and operational decisions, identity checks, and independent review remain outstanding. |
|
||||
| 17 | Inbox usability and desktop parity | Stable pagination, protected unsaved drafts, hotel-timezone display, and desktop workflow parity. | **Implemented; acceptance required.** Automated checks pass; the supervised desktop exercise and independent approval remain outstanding. |
|
||||
| 18 | Pilot, capacity, and release approval | Capacity proof, incident exercise, five-business-day hotel pilot, findings closure, and Gate B approval. | **In progress.** Validators and targets exist; Gate A/B prerequisites, capacity evidence, incident rehearsal, pilot, and named approvals remain open. |
|
||||
| 19 | Account security and self-service | TOTP MFA, recovery codes, transactional email, granular roles, preferences, and security notifications. | **Implemented on the development branch; acceptance required.** Keep it separate until `0.2.1` is approved and tagged, then review, merge, and version it as `0.3.0`. |
|
||||
|
||||
## Status key
|
||||
|
||||
- **Implemented** — present on `main` and supported by code or automated-test evidence.
|
||||
@ -33,16 +59,16 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i
|
||||
| 6 | Team onboarding and account recovery | B | Implemented / acceptance required | Invitation, password reset, and recovery flows are promoted to local `main`; verify deployed links, mail delivery, token expiry, and administrator recovery procedures. |
|
||||
| 7 | Google connection recovery | B | Implemented / acceptance required | Connection epochs, checkpoint recovery, and revocation handling are promoted to local `main`; complete real Google acceptance and worker-restart exercises. |
|
||||
| 8 | Operational readiness tooling | A | Implemented / acceptance required | Backup, restore, release, and diagnostic tooling is promoted to local `main`; execute it on the actual Debian host and retain evidence. |
|
||||
| 9 | Gitea and reproducible releases | A | In progress | The `0.2.0` candidate is versioned on `main`. CI records the full commit, matched application version, archive checksum and immutable image IDs, and the rollback procedure is documented. Retain the successful default-branch evidence off-host and create the immutable approval tag only after Gate B approval; the existing `0.1.0` tag remains attached to the foundation release. |
|
||||
| 9 | Gitea and reproducible releases | A | In progress | `deploy/package_source.py` now packages only an explicit committed ref, verifies matched application versions, produces deterministic gzip output and a SHA-256 source record, and refuses overwrite. Hand that package to a version-selected Ansible playbook following the CMS/CMSFront pattern. Ansible must verify and install it, build commit-tagged images, record their immutable IDs, and deploy without a Gitea runner. Retain the package/install evidence off-host and resolve the release-line/tag identity before approval; the existing `0.1.0` tag remains attached to the foundation release. |
|
||||
| 10 | Debian deployment and persistence | A | In progress | Compose uses separate named database and shared key volumes, private host configuration, loopback-only API access and bounded logs. The confirmation-gated persistence drill verifies restart and container-recreation behaviour. Run it on the provisioned Debian host, complete HTTPS and controlled-reboot acceptance, and retain the evidence. |
|
||||
| 11 | Backups, monitoring, and recovery | A | In progress | Encrypted backup and isolated restore tooling now includes opt-in systemd scheduling without command-line secrets. Install and test it on Debian, configure monitored off-host transfer and durable logs, name alert/retention owners, and retain evidence from a timed restore and recovery drill. |
|
||||
| 11 | Backups, monitoring, and recovery | A | In progress | Encrypted backup and isolated restore tooling now includes opt-in systemd scheduling, checksum-verified rsync transfer, a restricted Zabbix status boundary, guarded local retention and a release-bound acceptance validator. Install and test it on Debian, configure the restricted store and alerts, name operational/review owners, and retain independently reviewed evidence from the timed restore and rollback drill. |
|
||||
| 12 | Google mailbox and reviewed-reply acceptance | B | In progress | The synthetic-data provider runbook, exact scenario set and restricted-record validator are implemented. Complete every scenario against the accepted Debian release and dedicated Google sandbox accounts, independently review the evidence, and retain the validated record. |
|
||||
| 13 | Rezlynx/Guestline adapter | C | Planned | Obtain the provider contract and sandbox, implement the adapter and mapping, and accept idempotency, stale-data, ambiguous-write, and reconciliation paths. |
|
||||
| 14 | Payment links and status | C | Planned | Select/confirm the payment-provider path, complete sandbox and webhook acceptance, and prove expiry, replay protection, reconciliation, and support recovery. |
|
||||
| 15 | Knowledge, AI, and FAQ activation | B | Implemented / acceptance required | Owners can run a bounded no-send batch evaluation, and a release-bound acceptance record enforces positive/negative coverage, zero FAQ errors, separate AI review, staff training, stop-control evidence and named monitoring/rollback owners. Complete the supervised evaluation and retain independent approval. |
|
||||
| 16 | Identity, preferences, and privacy | B/C | Implemented / acceptance required | Login throttling trusts the client address only after one-hop processing by the configured proxy. A release-bound review now covers owner-controlled preferences, account/session controls, data inventory, retention/deletion/legal-hold ownership, provider decisions, audit evidence and known identity limitations. Complete the legal/operational decisions and independently approve the record. |
|
||||
| 17 | Inbox usability and desktop parity | B | Implemented / acceptance required | The inbox uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during route/history navigation, reload, conversation selection, filtering and search. Operational timestamps use the saved hotel timezone, and a release-bound desktop-parity acceptance record is implemented. The implementation and preview HTTP suite pass; run the supervised exercise against the approved release and retain independent approval. |
|
||||
| 18 | Pilot, capacity, and release approval | B/C | In progress | The `0.2.0` Gate B candidate has bounded capacity, five-business-day pilot, incident and final-decision record validators with agreed targets. Push and retain CI evidence, complete Gate A and Gate B prerequisites, run the probe and supervised exercises, resolve or contain findings, and retain separate hotel-owner and technical approval. Gate C remains dependent on milestones 13 and 14. |
|
||||
| 18 | Pilot, capacity, and release approval | B/C | In progress | The `0.2.1` Gate B candidate has bounded capacity, five-business-day pilot, incident and final-decision record validators with agreed targets. Retain the exact source-package and Ansible-install evidence, complete Gate A and Gate B prerequisites, run the probe and supervised exercises, resolve or contain findings, and retain separate hotel-owner and technical approval. Gate C remains dependent on milestones 13 and 14. |
|
||||
|
||||
## Delivery sequence
|
||||
|
||||
@ -54,8 +80,10 @@ Milestones 13 (Guestline/Rezlynx) and 14 (payments) can progress as parallel pro
|
||||
|
||||
## Next actions
|
||||
|
||||
- [ ] Push the local release-candidate promotion to the intended default branch and retain its successful CI evidence.
|
||||
- [ ] Retain successful `0.2.0` default-branch CI evidence, then create and archive the immutable approval tag only after Gate B approval.
|
||||
- [ ] Merge the Action removal and release-process update to the intended default branch; that resulting commit becomes the new package candidate.
|
||||
- [x] Preserve the published `0.2.0` tag unchanged and use the clean `0.2.1` candidate line from `main`, excluding Milestone 19 application code.
|
||||
- [ ] Create and checksum the approved source archive with `deploy/package_source.py`, add/select it in the GuestOps Ansible playbook, and retain the package and installation evidence.
|
||||
- [ ] Create and archive the immutable `0.2.1` approval tag only after Gate B approval.
|
||||
- [ ] Deploy to the target Debian environment with persistent MongoDB and data-protection keys.
|
||||
- [ ] Run and record backup, restore, restart, monitoring, and rollback exercises.
|
||||
- [ ] Complete real Google mailbox acceptance without using production guest data.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
A Linux-hosted hotel email workspace, developed separately from the Windows GuestOps application. **This migration now includes AI draft generation, staff-approved Gmail sending, reviewed OHIP reservation updates, NMI hosted invoices, controlled FAQ auto-replies and team onboarding. It is not yet a production-complete replacement.**
|
||||
|
||||
Current release-candidate version: **0.2.0**
|
||||
Current release-candidate version: **0.2.1**
|
||||
|
||||
Project progress is tracked in the [milestone report](MILESTONES.md). User-visible changes and release limitations are recorded in the [release notes](RELEASE_NOTES.md).
|
||||
|
||||
@ -59,6 +59,8 @@ For MongoDB-backed operation, disable Preview and set `Mongo__ConnectionString`,
|
||||
|
||||
Operational tooling includes an owner-only Workspace health page and Linux deployment preflight, encrypted backup and isolated restore drill. See [operations and recovery](docs/operations.md) before the hotel pilot.
|
||||
|
||||
Release packaging is runner-free. `deploy/package_source.py` creates a deterministic archive and checksum record from one explicit committed Git ref for handoff to the Futuresens Ansible deployment. It never packages uncommitted working-tree files.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
@ -66,7 +68,7 @@ dotnet run --project tests/GuestOps.Tests
|
||||
cd web && npm ci && npm run build
|
||||
```
|
||||
|
||||
Set `MONGO_TEST_URI` to an isolated MongoDB server and `TEST_API_URL=http://127.0.0.1:5180` with a preview API running to enable database and HTTP integration checks. The suite creates and drops only its own randomly named `guestops_test_*` database. CI runs both integrations and builds both Linux images.
|
||||
Set `MONGO_TEST_URI` to an isolated MongoDB server and `TEST_API_URL=http://127.0.0.1:5180` with a preview API running to enable database and HTTP integration checks. The suite creates and drops only its own randomly named `guestops_test_*` database. Run these checks before creating a versioned source package. GuestOps does not require a Gitea Actions runner; deployment follows the existing Futuresens source-package and Ansible process described in the [deployment guide](docs/deployment.md).
|
||||
|
||||
See [controlled FAQ automation](docs/auto-replies.md), [NMI payment setup and recovery](docs/payments.md), [OHIP reservation setup and recovery](docs/pms.md), [AI drafts and reply delivery setup](docs/replies.md), [migration status](docs/migration.md) and [deployment guide](docs/deployment.md).
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# GuestOps Release Notes
|
||||
|
||||
## 0.2.0 — Gate B release candidate
|
||||
## 0.2.1 — Gate B release candidate
|
||||
|
||||
This candidate freezes the implemented Gate B scope for controlled acceptance. It is not yet approved for live hotel operations and does not become a release until the exact commit is pushed, CI and operational evidence are retained, the supervised pilot is approved and the immutable `0.2.0` tag is created.
|
||||
This candidate freezes the implemented Gate B scope for controlled acceptance. It is not yet approved for live hotel operations and does not become a release until the exact commit is pushed, a checksummed source package and operational evidence are retained, the supervised pilot is approved and the immutable `0.2.1` tag is created.
|
||||
|
||||
### Promoted scope
|
||||
|
||||
@ -19,13 +19,17 @@ These capabilities still require their separately documented provider, host and
|
||||
|
||||
### Known limitations and launch conditions
|
||||
|
||||
- Gate A still requires a successful default-branch CI run, durable off-host release archive, target-Debian deployment, persistent storage/key validation, monitoring, and a successful restore/rollback exercise.
|
||||
- Gate A still requires a checksummed source package from the exact default-branch commit, Ansible installation on the target Debian host, persistent storage/key validation, monitoring, and a successful restore/rollback exercise.
|
||||
- Gate B still requires real Google acceptance and supervised staff testing, including desktop-parity acceptance of pagination, draft protection, proxy-aware login throttling and saved-hotel-timezone rendering.
|
||||
- Gate C still requires the Rezlynx/Guestline adapter and independently accepted PMS/payment workflows, plus privacy, identity, capacity, and release approvals.
|
||||
- FAQ live mode and all external write actions must remain disabled until their corresponding acceptance gate has passed.
|
||||
|
||||
See [MILESTONES.md](MILESTONES.md) for the gate assessment, delivery sequence, and remaining work.
|
||||
|
||||
## 0.2.0 — Superseded candidate identifier
|
||||
|
||||
The published `0.2.0` tag is retained unchanged for auditability but is not the approved deployment candidate. It identifies a development-line commit that is not on the clean Gate B release branch. Do not package or deploy it; `0.2.1` supersedes it.
|
||||
|
||||
## 0.1.0 — 29 September 2026
|
||||
|
||||
The `0.1.0` tag identifies the initial GuestOps Web foundation. It is not approved for live hotel operations.
|
||||
@ -41,4 +45,4 @@ The `0.1.0` tag identifies the initial GuestOps Web foundation. It is not approv
|
||||
|
||||
### Versioning
|
||||
|
||||
The foundation remains tagged `0.1.0`. The .NET projects and frontend package now share candidate version `0.2.0`; create that immutable tag only after the exact commit, checksummed artifacts and Gate B acceptance evidence have been approved.
|
||||
The foundation remains tagged `0.1.0`. The .NET projects and frontend package now share candidate version `0.2.1`; create that immutable tag only after the exact commit, checksummed artifacts and Gate B acceptance evidence have been approved.
|
||||
|
||||
81
deploy/backup-restore-acceptance.example.json
Normal file
81
deploy/backup-restore-acceptance.example.json
Normal file
@ -0,0 +1,81 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"system": "guestops-backup-recovery",
|
||||
"evidenceId": "backup-restore",
|
||||
"dataClassification": "synthetic-only",
|
||||
"releaseVersion": "0.2.1",
|
||||
"releaseCommit": "0000000000000000000000000000000000000000",
|
||||
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"archiveSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"environment": "https://sandbox-guestops.futuresens.co.uk",
|
||||
"hostIdentifier": "guestops-sandbox-01",
|
||||
"images": {
|
||||
"api": {"reference": "guestops-api:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"},
|
||||
"worker": {"reference": "guestops-worker:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"},
|
||||
"mongo": {"reference": "mongo:8.0", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
},
|
||||
"owners": {
|
||||
"backup": "REPLACE",
|
||||
"monitoring": "REPLACE",
|
||||
"recoveryOperator": "REPLACE",
|
||||
"technicalEscalation": "REPLACE",
|
||||
"retention": "REPLACE",
|
||||
"independentReviewer": "REPLACE"
|
||||
},
|
||||
"startedAt": "2026-10-01T09:00:00Z",
|
||||
"endedAt": "2026-10-01T13:00:00Z",
|
||||
"reviewedAt": "2026-10-01T14:00:00Z",
|
||||
"recoveryObjectives": {
|
||||
"targetRpoHours": 24,
|
||||
"observedRpoHours": 25,
|
||||
"targetRtoMinutes": 240,
|
||||
"observedRtoMinutes": 241
|
||||
},
|
||||
"retention": {
|
||||
"localVerifiedDays": 7,
|
||||
"offHostDaily": 35,
|
||||
"offHostMonthly": 12,
|
||||
"legalHoldOverrideTested": false
|
||||
},
|
||||
"backup": {
|
||||
"createdAt": "2026-10-01T08:00:00Z",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"transferredSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"privateKeyPresentOnHost": false
|
||||
},
|
||||
"rollback": {
|
||||
"previousReleaseCommit": "1111111111111111111111111111111111111111",
|
||||
"previousArchiveSha256": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"previousImages": {
|
||||
"api": {"reference": "guestops-api:1111111111111111111111111111111111111111", "id": "sha256:1111111111111111111111111111111111111111111111111111111111111111"},
|
||||
"worker": {"reference": "guestops-worker:1111111111111111111111111111111111111111", "id": "sha256:1111111111111111111111111111111111111111111111111111111111111111"}
|
||||
},
|
||||
"persistentVolumesReplaced": false,
|
||||
"restoredReleaseCommit": "0000000000000000000000000000000000000000",
|
||||
"unresolvedOperations": 1,
|
||||
"finalControls": {
|
||||
"googleSending": "disabled",
|
||||
"faqLiveMode": "disabled",
|
||||
"pmsWrites": "disabled",
|
||||
"paymentCreation": "disabled"
|
||||
}
|
||||
},
|
||||
"monitoringState": "not-configured",
|
||||
"unresolvedCriticalFindings": 1,
|
||||
"scenarios": [
|
||||
{"id": "alert-escalation", "status": "not-run", "evidence": []},
|
||||
{"id": "atomic-off-host-transfer", "status": "not-run", "evidence": []},
|
||||
{"id": "controlled-return-to-service", "status": "not-run", "evidence": []},
|
||||
{"id": "data-protection-recovery", "status": "not-run", "evidence": []},
|
||||
{"id": "database-inventory", "status": "not-run", "evidence": []},
|
||||
{"id": "durable-secret-free-logs", "status": "not-run", "evidence": []},
|
||||
{"id": "encrypted-manual-backup", "status": "not-run", "evidence": []},
|
||||
{"id": "image-rollback", "status": "not-run", "evidence": []},
|
||||
{"id": "isolated-restore", "status": "not-run", "evidence": []},
|
||||
{"id": "monitoring-coverage", "status": "not-run", "evidence": []},
|
||||
{"id": "off-host-checksum", "status": "not-run", "evidence": []},
|
||||
{"id": "production-service-recovery", "status": "not-run", "evidence": []},
|
||||
{"id": "retention-and-legal-hold", "status": "not-run", "evidence": []},
|
||||
{"id": "scheduled-backup", "status": "not-run", "evidence": []}
|
||||
]
|
||||
}
|
||||
220
deploy/backup_restore_acceptance.py
Normal file
220
deploy/backup_restore_acceptance.py
Normal file
@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a restricted GuestOps backup, monitoring and recovery record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
VERSION = "0.2.1"
|
||||
SCENARIOS = {
|
||||
"encrypted-manual-backup",
|
||||
"scheduled-backup",
|
||||
"production-service-recovery",
|
||||
"atomic-off-host-transfer",
|
||||
"off-host-checksum",
|
||||
"retention-and-legal-hold",
|
||||
"monitoring-coverage",
|
||||
"alert-escalation",
|
||||
"durable-secret-free-logs",
|
||||
"isolated-restore",
|
||||
"data-protection-recovery",
|
||||
"database-inventory",
|
||||
"image-rollback",
|
||||
"controlled-return-to-service",
|
||||
}
|
||||
SHA256 = re.compile(r"[0-9a-f]{64}")
|
||||
GIT_SHA = re.compile(r"[0-9a-f]{40}")
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def timestamp(value: object, field: str) -> dt.datetime:
|
||||
require(isinstance(value, str) and value.endswith("Z"),
|
||||
f"{field} must be a UTC timestamp ending in Z.")
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{field} is not a valid timestamp.") from error
|
||||
require(parsed.tzinfo == dt.timezone.utc, f"{field} must be UTC.")
|
||||
return parsed
|
||||
|
||||
|
||||
def safe_name(value: object, field: str) -> str:
|
||||
name = str(value or "").strip()
|
||||
require(2 <= len(name) <= 120 and "@" not in name and "/" not in name and "\\" not in name,
|
||||
f"{field} requires a name without an email address or path.")
|
||||
return name
|
||||
|
||||
|
||||
def validate(record: object, expected_commit: str, expected_release_sha256: str) -> None:
|
||||
require(isinstance(record, dict), "Acceptance record must be a JSON object.")
|
||||
require(record.get("schemaVersion") == 1, "Unsupported backup/recovery schema.")
|
||||
require(record.get("system") == "guestops-backup-recovery",
|
||||
"system must be guestops-backup-recovery.")
|
||||
require(record.get("evidenceId") == "backup-restore",
|
||||
"evidenceId must be backup-restore.")
|
||||
require(record.get("dataClassification") == "synthetic-only",
|
||||
"Recovery acceptance must use synthetic data only.")
|
||||
require(record.get("releaseVersion") == VERSION, f"releaseVersion must be {VERSION}.")
|
||||
require(GIT_SHA.fullmatch(str(expected_commit)) is not None,
|
||||
"Expected release commit must be a full lowercase Git SHA.")
|
||||
require(SHA256.fullmatch(str(expected_release_sha256)) is not None,
|
||||
"Expected release-record checksum must be a lowercase SHA-256 digest.")
|
||||
require(record.get("releaseCommit") == expected_commit,
|
||||
"releaseCommit does not match the approved candidate.")
|
||||
require(record.get("releaseRecordSha256") == expected_release_sha256,
|
||||
"releaseRecordSha256 does not match the retained release record.")
|
||||
require(SHA256.fullmatch(str(record.get("archiveSha256", ""))) is not None,
|
||||
"archiveSha256 must be a lowercase SHA-256 digest.")
|
||||
|
||||
origin = urlparse(str(record.get("environment", "")))
|
||||
require(origin.scheme == "https" and origin.hostname and origin.path in ("", "/")
|
||||
and not origin.query and not origin.fragment and origin.username is None
|
||||
and origin.password is None,
|
||||
"environment must be an HTTPS origin without credentials, path, query or fragment.")
|
||||
safe_name(record.get("hostIdentifier"), "hostIdentifier")
|
||||
|
||||
images = record.get("images")
|
||||
require(isinstance(images, dict) and set(images) == {"api", "worker", "mongo"},
|
||||
"images must contain exactly api, worker and mongo.")
|
||||
for name in ("api", "worker"):
|
||||
image = images[name]
|
||||
require(isinstance(image, dict) and set(image) == {"reference", "id"},
|
||||
f"images.{name} must contain exactly reference and id.")
|
||||
require(image["reference"] == f"guestops-{name}:{expected_commit}",
|
||||
f"images.{name}.reference must use the full approved commit.")
|
||||
require(re.fullmatch(r"sha256:[0-9a-f]{64}", str(image["id"])) is not None,
|
||||
f"images.{name}.id must be immutable.")
|
||||
mongo = images["mongo"]
|
||||
require(isinstance(mongo, dict) and set(mongo) == {"reference", "id"}
|
||||
and mongo["reference"] == "mongo:8.0"
|
||||
and re.fullmatch(r"sha256:[0-9a-f]{64}", str(mongo["id"])) is not None,
|
||||
"images.mongo must identify the immutable mongo:8.0 image.")
|
||||
|
||||
owners = record.get("owners")
|
||||
owner_keys = {"backup", "monitoring", "recoveryOperator", "technicalEscalation",
|
||||
"retention", "independentReviewer"}
|
||||
require(isinstance(owners, dict) and set(owners) == owner_keys,
|
||||
"owners must contain the exact operational and review roles.")
|
||||
names = {key: safe_name(value, f"owners.{key}") for key, value in owners.items()}
|
||||
reviewer = names["independentReviewer"].casefold()
|
||||
require(reviewer not in {names[key].casefold() for key in owner_keys - {"independentReviewer"}},
|
||||
"independentReviewer must be different from every operational owner.")
|
||||
|
||||
started = timestamp(record.get("startedAt"), "startedAt")
|
||||
ended = timestamp(record.get("endedAt"), "endedAt")
|
||||
reviewed = timestamp(record.get("reviewedAt"), "reviewedAt")
|
||||
require(started <= ended <= reviewed, "Acceptance timestamps are out of order.")
|
||||
|
||||
recovery = record.get("recoveryObjectives")
|
||||
require(isinstance(recovery, dict) and set(recovery) == {
|
||||
"targetRpoHours", "observedRpoHours", "targetRtoMinutes", "observedRtoMinutes",
|
||||
}, "recoveryObjectives must contain exact target and observed RPO/RTO values.")
|
||||
for field in recovery:
|
||||
require(isinstance(recovery[field], (int, float)) and not isinstance(recovery[field], bool)
|
||||
and recovery[field] >= 0, f"recoveryObjectives.{field} must be non-negative.")
|
||||
require(recovery["targetRpoHours"] == 24 and recovery["observedRpoHours"] <= 24,
|
||||
"Observed RPO must meet the approved 24-hour target.")
|
||||
require(recovery["targetRtoMinutes"] == 240 and recovery["observedRtoMinutes"] <= 240,
|
||||
"Observed RTO must meet the approved four-hour target.")
|
||||
|
||||
retention = record.get("retention")
|
||||
require(retention == {
|
||||
"localVerifiedDays": 7,
|
||||
"offHostDaily": 35,
|
||||
"offHostMonthly": 12,
|
||||
"legalHoldOverrideTested": True,
|
||||
}, "Retention must record seven local days, 35 daily and 12 monthly off-host copies, and legal-hold testing.")
|
||||
|
||||
backup = record.get("backup")
|
||||
require(isinstance(backup, dict) and set(backup) == {
|
||||
"createdAt", "sha256", "transferredSha256", "privateKeyPresentOnHost",
|
||||
}, "backup must contain exact creation, checksum, transfer and private-key fields.")
|
||||
created = timestamp(backup["createdAt"], "backup.createdAt")
|
||||
require(created <= started, "The accepted backup must exist when the timed exercise starts.")
|
||||
require(abs(recovery["observedRpoHours"] - (started - created).total_seconds() / 3600) < 0.01,
|
||||
"Observed RPO must match the backup and exercise timestamps.")
|
||||
require(abs(recovery["observedRtoMinutes"] - (ended - started).total_seconds() / 60) < 0.01,
|
||||
"Observed RTO must match the exercise timestamps.")
|
||||
require(SHA256.fullmatch(str(backup["sha256"])) is not None
|
||||
and backup["transferredSha256"] == backup["sha256"],
|
||||
"Local and transferred backup checksums must match.")
|
||||
require(backup["privateKeyPresentOnHost"] is False,
|
||||
"The recovery private key must not be present on the Debian host.")
|
||||
|
||||
rollback = record.get("rollback")
|
||||
require(isinstance(rollback, dict) and set(rollback) == {
|
||||
"previousReleaseCommit", "previousArchiveSha256", "previousImages",
|
||||
"persistentVolumesReplaced", "restoredReleaseCommit", "unresolvedOperations", "finalControls",
|
||||
}, "rollback must contain the exact rehearsal and final-state fields.")
|
||||
require(GIT_SHA.fullmatch(str(rollback["previousReleaseCommit"])) is not None
|
||||
and rollback["previousReleaseCommit"] != expected_commit,
|
||||
"Rollback must use a different retained previous release.")
|
||||
require(SHA256.fullmatch(str(rollback["previousArchiveSha256"])) is not None,
|
||||
"Rollback requires the previous archive checksum.")
|
||||
previous_images = rollback["previousImages"]
|
||||
require(isinstance(previous_images, dict) and set(previous_images) == {"api", "worker"},
|
||||
"Rollback requires exact previous API and worker images.")
|
||||
for name in ("api", "worker"):
|
||||
image = previous_images[name]
|
||||
require(isinstance(image, dict) and set(image) == {"reference", "id"}
|
||||
and image["reference"] == f"guestops-{name}:{rollback['previousReleaseCommit']}"
|
||||
and re.fullmatch(r"sha256:[0-9a-f]{64}", str(image["id"])) is not None,
|
||||
f"rollback.previousImages.{name} must use the retained previous release identity.")
|
||||
require(rollback["persistentVolumesReplaced"] is False,
|
||||
"Image rollback must not replace persistent volumes.")
|
||||
require(rollback["restoredReleaseCommit"] == expected_commit,
|
||||
"The exercise must finish on the approved candidate.")
|
||||
require(rollback["unresolvedOperations"] == 0,
|
||||
"The exercise must finish without unresolved operations.")
|
||||
require(rollback["finalControls"] == {
|
||||
"googleSending": "disabled", "faqLiveMode": "disabled",
|
||||
"pmsWrites": "disabled", "paymentCreation": "disabled",
|
||||
}, "The exercise must finish with all unaccepted external writes disabled.")
|
||||
|
||||
scenarios = record.get("scenarios")
|
||||
require(isinstance(scenarios, list), "scenarios must be a list.")
|
||||
ids = [item.get("id") for item in scenarios if isinstance(item, dict)]
|
||||
require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == SCENARIOS,
|
||||
"Acceptance record requires the exact backup/recovery scenario set.")
|
||||
for item in scenarios:
|
||||
scenario_id = item["id"]
|
||||
require(item.get("status") == "pass", f"Scenario {scenario_id} has not passed.")
|
||||
evidence = item.get("evidence")
|
||||
require(isinstance(evidence, list) and 1 <= len(evidence) <= 10 and all(
|
||||
isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value
|
||||
and "\\" not in value and not value.startswith("/") for value in evidence
|
||||
), f"Scenario {scenario_id} requires safe opaque evidence references.")
|
||||
require(record.get("monitoringState") == "healthy",
|
||||
"Monitoring must be healthy at acceptance completion.")
|
||||
require(record.get("unresolvedCriticalFindings") == 0,
|
||||
"Acceptance cannot pass with unresolved critical findings.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("record", type=Path)
|
||||
parser.add_argument("--expected-commit", required=True)
|
||||
parser.add_argument("--expected-release-record-sha256", required=True)
|
||||
args = parser.parse_args()
|
||||
validate(json.loads(args.record.read_text(encoding="utf-8")),
|
||||
args.expected_commit, args.expected_release_record_sha256)
|
||||
print("Backup, monitoring and recovery acceptance record is structurally complete and passed. "
|
||||
"This validates the record, not its restricted evidence.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Backup/recovery acceptance record rejected: {error}", file=__import__("sys").stderr)
|
||||
raise SystemExit(1)
|
||||
238
deploy/backup_transfer.py
Normal file
238
deploy/backup_transfer.py
Normal file
@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Atomically transfer encrypted GuestOps backups to restricted storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
BACKUP_NAME = re.compile(r"guestops-[0-9]{8}T[0-9]{6}Z\.tar\.gpg")
|
||||
SAFE_HOST = re.compile(r"[A-Za-z0-9.-]{1,253}")
|
||||
SAFE_USER = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,31}")
|
||||
SAFE_REMOTE_PATH = re.compile(r"/[A-Za-z0-9._/-]{1,500}")
|
||||
SHA256 = re.compile(r"[0-9a-f]{64}")
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def private_directory(value: str) -> Path:
|
||||
requested = Path(value)
|
||||
require(requested.is_absolute() and not requested.is_symlink(),
|
||||
"BACKUP_DIRECTORY must be an absolute, non-symlink path.")
|
||||
directory = requested.resolve()
|
||||
require(directory.is_dir(), "BACKUP_DIRECTORY must exist.")
|
||||
require(stat.S_IMODE(directory.stat().st_mode) & 0o077 == 0,
|
||||
"BACKUP_DIRECTORY must not be accessible to group or other users.")
|
||||
return directory
|
||||
|
||||
|
||||
def regular_file(value: str, field: str, *, private: bool) -> Path:
|
||||
requested = Path(value)
|
||||
require(requested.is_absolute() and not requested.is_symlink(),
|
||||
f"{field} must be an absolute, non-symlink path.")
|
||||
path = requested.resolve()
|
||||
require(path.is_file(), f"{field} must be an existing regular file.")
|
||||
if private:
|
||||
require(stat.S_IMODE(path.stat().st_mode) & 0o077 == 0,
|
||||
f"{field} must not be accessible to group or other users.")
|
||||
return path
|
||||
|
||||
|
||||
def configuration(environment: dict[str, str]) -> dict[str, object]:
|
||||
directory = private_directory(environment.get("BACKUP_DIRECTORY", ""))
|
||||
host = environment.get("BACKUP_REMOTE_HOST", "")
|
||||
user = environment.get("BACKUP_REMOTE_USER", "")
|
||||
remote = environment.get("BACKUP_REMOTE_DIRECTORY", "")
|
||||
require(SAFE_HOST.fullmatch(host) is not None, "BACKUP_REMOTE_HOST is invalid.")
|
||||
require(SAFE_USER.fullmatch(user) is not None, "BACKUP_REMOTE_USER is invalid.")
|
||||
require(SAFE_REMOTE_PATH.fullmatch(remote) is not None and "//" not in remote
|
||||
and "/../" not in remote + "/" and not remote.endswith("/.."),
|
||||
"BACKUP_REMOTE_DIRECTORY must be a safe absolute path.")
|
||||
identity = regular_file(environment.get("BACKUP_SSH_IDENTITY", ""),
|
||||
"BACKUP_SSH_IDENTITY", private=True)
|
||||
known_hosts = regular_file(environment.get("BACKUP_SSH_KNOWN_HOSTS", ""),
|
||||
"BACKUP_SSH_KNOWN_HOSTS", private=False)
|
||||
require(shutil.which("ssh") is not None and shutil.which("rsync") is not None,
|
||||
"ssh and rsync are required.")
|
||||
return {
|
||||
"directory": directory,
|
||||
"host": host,
|
||||
"user": user,
|
||||
"remote": remote.rstrip("/"),
|
||||
"identity": identity,
|
||||
"known_hosts": known_hosts,
|
||||
}
|
||||
|
||||
|
||||
def ssh_base(config: dict[str, object]) -> list[str]:
|
||||
return [
|
||||
"ssh", "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=15",
|
||||
"-o", f"UserKnownHostsFile={config['known_hosts']}",
|
||||
"-i", str(config["identity"]), f"{config['user']}@{config['host']}",
|
||||
]
|
||||
|
||||
|
||||
def run(args: list[str], *, environment: dict[str, str] | None = None) -> bytes:
|
||||
result = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=900, env=environment)
|
||||
require(result.returncode == 0,
|
||||
f"{Path(args[0]).name} step failed; review the restricted operator logs.")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def remote_digest(config: dict[str, object], remote_path: str) -> str | None:
|
||||
command = f"if test -f {remote_path} && test ! -L {remote_path}; then sha256sum -- {remote_path}; fi"
|
||||
output = run([*ssh_base(config), command]).decode("utf-8", "strict").strip()
|
||||
if not output:
|
||||
return None
|
||||
value = output.split()[0]
|
||||
require(SHA256.fullmatch(value) is not None, "Remote checksum response was invalid.")
|
||||
return value
|
||||
|
||||
|
||||
def marker_path(backup: Path) -> Path:
|
||||
return backup.with_name(backup.name + ".transferred.json")
|
||||
|
||||
|
||||
def write_marker(backup: Path, checksum: str) -> None:
|
||||
marker = marker_path(backup)
|
||||
payload = json.dumps({
|
||||
"schemaVersion": 1,
|
||||
"backup": backup.name,
|
||||
"sha256": checksum,
|
||||
"verifiedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}, sort_keys=True) + "\n"
|
||||
fd, temporary = tempfile.mkstemp(prefix=marker.name + ".", dir=marker.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, marker)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def transfer_one(config: dict[str, object], backup: Path) -> None:
|
||||
require(backup.is_file() and not backup.is_symlink()
|
||||
and BACKUP_NAME.fullmatch(backup.name) is not None,
|
||||
"Refusing to transfer an unexpected backup path.")
|
||||
checksum = digest(backup)
|
||||
remote_final = f"{config['remote']}/{backup.name}"
|
||||
existing = remote_digest(config, remote_final)
|
||||
if existing is not None:
|
||||
require(existing == checksum, "A remote backup with this name has a different checksum.")
|
||||
write_marker(backup, checksum)
|
||||
return
|
||||
|
||||
remote_partial = f"{config['remote']}/.{backup.name}.partial-{uuid.uuid4().hex}"
|
||||
rsh = shlex.join([
|
||||
"ssh", "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=15",
|
||||
"-o", f"UserKnownHostsFile={config['known_hosts']}",
|
||||
"-i", str(config["identity"]),
|
||||
])
|
||||
rsync_environment = os.environ.copy()
|
||||
rsync_environment["RSYNC_RSH"] = rsh
|
||||
try:
|
||||
run(["rsync", "--archive", "--chmod=F600", "--protect-args", "--",
|
||||
str(backup), f"{config['user']}@{config['host']}:{remote_partial}"],
|
||||
environment=rsync_environment)
|
||||
require(remote_digest(config, remote_partial) == checksum,
|
||||
"Transferred backup checksum does not match the local file.")
|
||||
command = (f"test ! -e {remote_final} && mv -T -- {remote_partial} {remote_final} "
|
||||
f"&& chmod 600 -- {remote_final}")
|
||||
run([*ssh_base(config), command])
|
||||
require(remote_digest(config, remote_final) == checksum,
|
||||
"Final remote backup checksum does not match the local file.")
|
||||
write_marker(backup, checksum)
|
||||
except Exception:
|
||||
# The name contains a fresh random suffix and is the only remote object this run may remove.
|
||||
subprocess.run([*ssh_base(config), f"rm -f -- {remote_partial}"], stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
|
||||
raise
|
||||
|
||||
|
||||
def transfer_pending(config: dict[str, object]) -> int:
|
||||
directory = config["directory"]
|
||||
backups = sorted(path for path in directory.iterdir()
|
||||
if path.is_file() and not path.is_symlink()
|
||||
and BACKUP_NAME.fullmatch(path.name) is not None)
|
||||
for backup in backups:
|
||||
marker = marker_path(backup)
|
||||
if marker.is_file() and not marker.is_symlink():
|
||||
try:
|
||||
recorded = json.loads(marker.read_text(encoding="utf-8"))
|
||||
if recorded.get("sha256") == digest(backup):
|
||||
continue
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
transfer_one(config, backup)
|
||||
return len(backups)
|
||||
|
||||
|
||||
def prune_verified(config: dict[str, object], retention_days: int, now: float | None = None) -> int:
|
||||
require(1 <= retention_days <= 365, "Local retention must be between 1 and 365 days.")
|
||||
threshold = (time.time() if now is None else now) - retention_days * 86400
|
||||
removed = 0
|
||||
for backup in config["directory"].iterdir():
|
||||
if not backup.is_file() or backup.is_symlink() or BACKUP_NAME.fullmatch(backup.name) is None:
|
||||
continue
|
||||
marker = marker_path(backup)
|
||||
if backup.stat().st_mtime >= threshold or not marker.is_file() or marker.is_symlink():
|
||||
continue
|
||||
try:
|
||||
recorded = json.loads(marker.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
if recorded.get("backup") != backup.name or recorded.get("sha256") != digest(backup):
|
||||
continue
|
||||
backup.unlink()
|
||||
marker.unlink()
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--prune-verified", action="store_true")
|
||||
parser.add_argument("--retention-days", type=int, default=7)
|
||||
args = parser.parse_args()
|
||||
config = configuration(dict(os.environ))
|
||||
observed = transfer_pending(config)
|
||||
removed = prune_verified(config, args.retention_days) if args.prune_verified else 0
|
||||
print(f"Backup transfer completed: {observed} encrypted backup(s) inspected; "
|
||||
f"{removed} verified local backup(s) expired.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError, json.JSONDecodeError) as error:
|
||||
print(f"Backup transfer failed: {error}", file=__import__("sys").stderr)
|
||||
raise SystemExit(1)
|
||||
45
deploy/debian-host-acceptance.example.json
Normal file
45
deploy/debian-host-acceptance.example.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"system": "guestops-debian-host",
|
||||
"evidenceId": "debian-host",
|
||||
"releaseVersion": "0.2.1",
|
||||
"releaseCommit": "0000000000000000000000000000000000000000",
|
||||
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"archiveSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"environment": "https://sandbox-guestops.futuresens.co.uk",
|
||||
"hostIdentifier": "guestops-sandbox-01",
|
||||
"images": {
|
||||
"api": {"reference": "guestops-api:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"},
|
||||
"worker": {"reference": "guestops-worker:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
},
|
||||
"hostFacts": {
|
||||
"debianMajor": 12,
|
||||
"cpuCores": 4,
|
||||
"memoryBytes": 8140382208,
|
||||
"freeDiskBytes": 14275686400,
|
||||
"publicTcpPorts": []
|
||||
},
|
||||
"featureControls": {
|
||||
"googleSending": "disabled",
|
||||
"faqLiveMode": "disabled",
|
||||
"pmsWrites": "disabled",
|
||||
"paymentCreation": "disabled"
|
||||
},
|
||||
"operator": "REPLACE",
|
||||
"reviewedBy": "REPLACE",
|
||||
"startedAt": "2026-09-30T09:00:00Z",
|
||||
"endedAt": "2026-09-30T10:00:00Z",
|
||||
"reviewedAt": "2026-09-30T11:00:00Z",
|
||||
"unresolvedCriticalFindings": 1,
|
||||
"scenarios": [
|
||||
{"id": "boot-services", "status": "not-run", "evidence": []},
|
||||
{"id": "controlled-reboot", "status": "not-run", "evidence": []},
|
||||
{"id": "durable-log-retrieval", "status": "not-run", "evidence": []},
|
||||
{"id": "host-baseline", "status": "not-run", "evidence": []},
|
||||
{"id": "https-and-redirect", "status": "not-run", "evidence": []},
|
||||
{"id": "network-exposure", "status": "not-run", "evidence": []},
|
||||
{"id": "proxy-trust", "status": "not-run", "evidence": []},
|
||||
{"id": "secret-free-logs", "status": "not-run", "evidence": []},
|
||||
{"id": "workspace-health", "status": "not-run", "evidence": []}
|
||||
]
|
||||
}
|
||||
206
deploy/debian_acceptance.py
Normal file
206
deploy/debian_acceptance.py
Normal file
@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate restricted GuestOps Debian-host and persistence acceptance records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
VERSION = "0.2.1"
|
||||
SYSTEMS = {
|
||||
"guestops-debian-host": {
|
||||
"evidenceId": "debian-host",
|
||||
"scenarios": {
|
||||
"host-baseline",
|
||||
"network-exposure",
|
||||
"https-and-redirect",
|
||||
"proxy-trust",
|
||||
"boot-services",
|
||||
"controlled-reboot",
|
||||
"workspace-health",
|
||||
"durable-log-retrieval",
|
||||
"secret-free-logs",
|
||||
},
|
||||
},
|
||||
"guestops-persistence": {
|
||||
"evidenceId": "persistence",
|
||||
"scenarios": {
|
||||
"separate-volume-layout",
|
||||
"service-restart",
|
||||
"container-recreation",
|
||||
"database-inventory",
|
||||
"data-protection-key",
|
||||
"image-identity",
|
||||
"post-reboot-persistence",
|
||||
},
|
||||
},
|
||||
}
|
||||
SHA256 = re.compile(r"[0-9a-f]{64}")
|
||||
GIT_SHA = re.compile(r"[0-9a-f]{40}")
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def timestamp(value: object, field: str) -> dt.datetime:
|
||||
require(isinstance(value, str) and value.endswith("Z"),
|
||||
f"{field} must be a UTC timestamp ending in Z.")
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{field} is not a valid timestamp.") from error
|
||||
require(parsed.tzinfo == dt.timezone.utc, f"{field} must be UTC.")
|
||||
return parsed
|
||||
|
||||
|
||||
def safe_text(value: object, field: str, minimum: int = 2, maximum: int = 160) -> str:
|
||||
text = str(value or "").strip()
|
||||
require(minimum <= len(text) <= maximum and "@" not in text and "\\" not in text,
|
||||
f"{field} must be safe text without an email address or local path.")
|
||||
return text
|
||||
|
||||
|
||||
def validate_common(record: object, expected_commit: str, expected_release_sha256: str) -> str:
|
||||
require(isinstance(record, dict), "Acceptance record must be a JSON object.")
|
||||
require(record.get("schemaVersion") == 1, "Unsupported Debian acceptance schema.")
|
||||
system = record.get("system")
|
||||
require(system in SYSTEMS, "Unknown Debian acceptance record system.")
|
||||
require(record.get("evidenceId") == SYSTEMS[system]["evidenceId"],
|
||||
f"{system} has the wrong evidenceId.")
|
||||
require(record.get("releaseVersion") == VERSION,
|
||||
f"releaseVersion must be {VERSION}.")
|
||||
require(GIT_SHA.fullmatch(str(expected_commit)) is not None,
|
||||
"Expected release commit must be a full lowercase Git SHA.")
|
||||
require(SHA256.fullmatch(str(expected_release_sha256)) is not None,
|
||||
"Expected release-record checksum must be a lowercase SHA-256 digest.")
|
||||
require(record.get("releaseCommit") == expected_commit,
|
||||
"releaseCommit does not match the approved candidate.")
|
||||
require(record.get("releaseRecordSha256") == expected_release_sha256,
|
||||
"releaseRecordSha256 does not match the retained release record.")
|
||||
require(SHA256.fullmatch(str(record.get("archiveSha256", ""))) is not None,
|
||||
"archiveSha256 must be a lowercase SHA-256 digest.")
|
||||
|
||||
origin = urlparse(str(record.get("environment", "")))
|
||||
require(origin.scheme == "https" and origin.hostname and origin.path in ("", "/")
|
||||
and not origin.query and not origin.fragment and origin.username is None
|
||||
and origin.password is None,
|
||||
"environment must be an HTTPS origin without credentials, path, query or fragment.")
|
||||
host = safe_text(record.get("hostIdentifier"), "hostIdentifier")
|
||||
|
||||
images = record.get("images")
|
||||
require(isinstance(images, dict) and set(images) == {"api", "worker"},
|
||||
"images must contain exactly api and worker.")
|
||||
for name in ("api", "worker"):
|
||||
image = images[name]
|
||||
require(isinstance(image, dict) and set(image) == {"reference", "id"},
|
||||
f"images.{name} must contain exactly reference and id.")
|
||||
require(image["reference"] == f"guestops-{name}:{expected_commit}",
|
||||
f"images.{name}.reference must use the full approved commit.")
|
||||
require(re.fullmatch(r"sha256:[0-9a-f]{64}", str(image["id"])) is not None,
|
||||
f"images.{name}.id must be an immutable image ID.")
|
||||
|
||||
operator = safe_text(record.get("operator"), "operator")
|
||||
reviewer = safe_text(record.get("reviewedBy"), "reviewedBy")
|
||||
require(operator.casefold() != reviewer.casefold(),
|
||||
"operator and reviewedBy must be different people.")
|
||||
started = timestamp(record.get("startedAt"), "startedAt")
|
||||
ended = timestamp(record.get("endedAt"), "endedAt")
|
||||
reviewed = timestamp(record.get("reviewedAt"), "reviewedAt")
|
||||
require(started <= ended <= reviewed, "Acceptance timestamps are out of order.")
|
||||
|
||||
scenarios = record.get("scenarios")
|
||||
require(isinstance(scenarios, list), "scenarios must be a list.")
|
||||
ids = [item.get("id") for item in scenarios if isinstance(item, dict)]
|
||||
required = SYSTEMS[system]["scenarios"]
|
||||
require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == required,
|
||||
f"{system} requires its exact acceptance scenario set.")
|
||||
for item in scenarios:
|
||||
scenario_id = item["id"]
|
||||
require(item.get("status") == "pass", f"Scenario {scenario_id} has not passed.")
|
||||
evidence = item.get("evidence")
|
||||
require(isinstance(evidence, list) and 1 <= len(evidence) <= 10 and all(
|
||||
isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value
|
||||
and "\\" not in value and not value.startswith("/") for value in evidence
|
||||
), f"Scenario {scenario_id} requires safe opaque evidence references.")
|
||||
|
||||
require(record.get("unresolvedCriticalFindings") == 0,
|
||||
"Acceptance cannot pass with unresolved critical findings.")
|
||||
return host
|
||||
|
||||
|
||||
def validate_host(record: object, expected_commit: str, expected_release_sha256: str) -> str:
|
||||
host = validate_common(record, expected_commit, expected_release_sha256)
|
||||
require(record.get("system") == "guestops-debian-host",
|
||||
"First record must be guestops-debian-host.")
|
||||
facts = record.get("hostFacts")
|
||||
require(isinstance(facts, dict) and set(facts) == {
|
||||
"debianMajor", "cpuCores", "memoryBytes", "freeDiskBytes", "publicTcpPorts",
|
||||
}, "hostFacts must contain the exact reviewed host facts.")
|
||||
require(isinstance(facts["debianMajor"], int) and facts["debianMajor"] >= 12,
|
||||
"Debian 12 or newer is required.")
|
||||
require(isinstance(facts["cpuCores"], int) and facts["cpuCores"] >= 4,
|
||||
"At least four CPU cores are required.")
|
||||
require(isinstance(facts["memoryBytes"], int) and facts["memoryBytes"] >= 7_500_000_000,
|
||||
"At least 7.5 GB of memory is required.")
|
||||
require(isinstance(facts["freeDiskBytes"], int) and facts["freeDiskBytes"] >= 8 * 1024**3,
|
||||
"At least 8 GiB of free disk space is required.")
|
||||
require(facts["publicTcpPorts"] == [80, 443],
|
||||
"Only TCP ports 80 and 443 may be public.")
|
||||
require(record.get("featureControls") == {
|
||||
"googleSending": "disabled",
|
||||
"faqLiveMode": "disabled",
|
||||
"pmsWrites": "disabled",
|
||||
"paymentCreation": "disabled",
|
||||
}, "Unaccepted external writes and FAQ live mode must remain disabled.")
|
||||
return host
|
||||
|
||||
|
||||
def validate_persistence(record: object, expected_commit: str, expected_release_sha256: str) -> str:
|
||||
host = validate_common(record, expected_commit, expected_release_sha256)
|
||||
require(record.get("system") == "guestops-persistence",
|
||||
"Second record must be guestops-persistence.")
|
||||
require(record.get("drillCommand") == "python3 deploy/ops.py persistence-drill --confirm-restart",
|
||||
"drillCommand must identify the confirmation-gated persistence drill.")
|
||||
return host
|
||||
|
||||
|
||||
def validate_pair(host_record: object, persistence_record: object,
|
||||
expected_commit: str, expected_release_sha256: str) -> None:
|
||||
host = validate_host(host_record, expected_commit, expected_release_sha256)
|
||||
persistence_host = validate_persistence(
|
||||
persistence_record, expected_commit, expected_release_sha256)
|
||||
require(host == persistence_host, "Both records must identify the same host.")
|
||||
for field in ("environment", "releaseVersion", "releaseCommit", "releaseRecordSha256",
|
||||
"archiveSha256", "images"):
|
||||
require(host_record.get(field) == persistence_record.get(field),
|
||||
f"Both records must use the same {field}.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("host_record", type=Path)
|
||||
parser.add_argument("persistence_record", type=Path)
|
||||
parser.add_argument("--expected-commit", required=True)
|
||||
parser.add_argument("--expected-release-record-sha256", required=True)
|
||||
args = parser.parse_args()
|
||||
host_record = json.loads(args.host_record.read_text(encoding="utf-8"))
|
||||
persistence_record = json.loads(args.persistence_record.read_text(encoding="utf-8"))
|
||||
validate_pair(host_record, persistence_record,
|
||||
args.expected_commit, args.expected_release_record_sha256)
|
||||
print("Debian-host and persistence acceptance records are structurally complete and passed. "
|
||||
"This validates the records, not their restricted evidence.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Debian acceptance records rejected: {error}", file=__import__("sys").stderr)
|
||||
raise SystemExit(1)
|
||||
189
deploy/monitor_status.py
Normal file
189
deploy/monitor_status.py
Normal file
@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write non-sensitive GuestOps host status for a read-only monitoring agent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
BACKUP_NAME = re.compile(r"guestops-[0-9]{8}T[0-9]{6}Z\.tar\.gpg")
|
||||
MARKER_NAME = re.compile(r"guestops-[0-9]{8}T[0-9]{6}Z\.tar\.gpg\.transferred\.json")
|
||||
AUTH = ('const c=new Mongo("mongodb://127.0.0.1");'
|
||||
'c.getDB("admin").auth(process.env.MONGO_INITDB_ROOT_USERNAME,'
|
||||
'process.env.MONGO_INITDB_ROOT_PASSWORD);')
|
||||
HEARTBEAT = ('const x=c.getDB("guestops").workerheartbeat.findOne({_id:"worker"});'
|
||||
'print(JSON.stringify(x&&x.At?x.At:null));')
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def run(args: list[str], root: Path, timeout: int = 30) -> bytes:
|
||||
result = subprocess.run(args, cwd=root, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
||||
require(result.returncode == 0, f"{Path(args[0]).name} probe failed.")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def utc_now() -> dt.datetime:
|
||||
return dt.datetime.now(dt.timezone.utc)
|
||||
|
||||
|
||||
def age_seconds(path: Path, pattern: re.Pattern[str], now: dt.datetime) -> int | None:
|
||||
if not path.is_dir() or path.is_symlink():
|
||||
return None
|
||||
times = [item.stat().st_mtime for item in path.iterdir()
|
||||
if item.is_file() and not item.is_symlink() and pattern.fullmatch(item.name)]
|
||||
return max(0, int(now.timestamp() - max(times))) if times else None
|
||||
|
||||
|
||||
def https_status(origin: str, now: dt.datetime) -> dict[str, object]:
|
||||
parsed = urlparse(origin)
|
||||
require(parsed.scheme == "https" and parsed.hostname and parsed.port in (None, 443)
|
||||
and parsed.path in ("", "/") and not parsed.query and not parsed.fragment
|
||||
and parsed.username is None and parsed.password is None,
|
||||
"GUESTOPS_ORIGIN must be an HTTPS origin without credentials or a path.")
|
||||
context = ssl.create_default_context()
|
||||
with socket.create_connection((parsed.hostname, 443), timeout=10) as connection:
|
||||
with context.wrap_socket(connection, server_hostname=parsed.hostname) as secured:
|
||||
certificate = secured.getpeercert()
|
||||
expires = dt.datetime.strptime(certificate["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(
|
||||
tzinfo=dt.timezone.utc)
|
||||
with urllib.request.urlopen(origin.rstrip("/") + "/health/ready", timeout=15,
|
||||
context=context) as response:
|
||||
ready = response.status == 200 and json.load(response) == {"status": "ready"}
|
||||
return {"ready": ready, "certificateDaysRemaining": max(0, int((expires - now).total_seconds() // 86400))}
|
||||
|
||||
|
||||
def parse_compose(value: bytes) -> dict[str, dict[str, str]]:
|
||||
text = value.decode("utf-8", "strict").strip()
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
rows = parsed if isinstance(parsed, list) else [parsed]
|
||||
except json.JSONDecodeError:
|
||||
rows = [json.loads(line) for line in text.splitlines() if line.strip()]
|
||||
result = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
service = str(row.get("Service", ""))
|
||||
if service in {"api", "worker", "mongo"}:
|
||||
result[service] = {
|
||||
"state": str(row.get("State", "unknown")).lower(),
|
||||
"health": str(row.get("Health", "none") or "none").lower(),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def worker_heartbeat_age(root: Path, now: dt.datetime) -> int | None:
|
||||
output = run(["docker", "compose", "exec", "-T", "mongo", "mongosh", "--quiet",
|
||||
"--nodb", "--eval", AUTH + HEARTBEAT], root).decode("utf-8", "strict").strip()
|
||||
value = json.loads(output)
|
||||
if value is None:
|
||||
return None
|
||||
require(isinstance(value, str), "Worker heartbeat response was invalid.")
|
||||
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
require(parsed.tzinfo is not None, "Worker heartbeat must contain a timezone.")
|
||||
return max(0, int((now - parsed.astimezone(dt.timezone.utc)).total_seconds()))
|
||||
|
||||
|
||||
def build_status(root: Path, backup_directory: Path, origin: str,
|
||||
now: dt.datetime | None = None) -> tuple[dict[str, object], list[str]]:
|
||||
moment = now or utc_now()
|
||||
errors: list[str] = []
|
||||
try:
|
||||
https = https_status(origin, moment)
|
||||
except Exception:
|
||||
https = {"ready": False, "certificateDaysRemaining": None}
|
||||
errors.append("https-probe-failed")
|
||||
try:
|
||||
containers = parse_compose(run(["docker", "compose", "ps", "--format", "json"], root))
|
||||
if set(containers) != {"api", "worker", "mongo"}:
|
||||
errors.append("container-set-incomplete")
|
||||
except Exception:
|
||||
containers = {}
|
||||
errors.append("container-probe-failed")
|
||||
try:
|
||||
heartbeat_age = worker_heartbeat_age(root, moment)
|
||||
if heartbeat_age is None:
|
||||
errors.append("worker-heartbeat-missing")
|
||||
except Exception:
|
||||
heartbeat_age = None
|
||||
errors.append("worker-heartbeat-probe-failed")
|
||||
disk = shutil.disk_usage(root)
|
||||
status = {
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": moment.isoformat().replace("+00:00", "Z"),
|
||||
"https": https,
|
||||
"containers": containers,
|
||||
"workerHeartbeatAgeSeconds": heartbeat_age,
|
||||
"backupAgeSeconds": age_seconds(backup_directory, BACKUP_NAME, moment),
|
||||
"verifiedTransferAgeSeconds": age_seconds(backup_directory, MARKER_NAME, moment),
|
||||
"diskFreePercent": round(disk.free * 100 / disk.total, 2),
|
||||
"persistentJournal": Path("/var/log/journal").is_dir(),
|
||||
"errors": sorted(set(errors)),
|
||||
}
|
||||
return status, errors
|
||||
|
||||
|
||||
def write_status(path: Path, status: dict[str, object]) -> None:
|
||||
require(path.is_absolute() and not path.is_symlink(),
|
||||
"Status output must be an absolute, non-symlink path.")
|
||||
parent = path.parent.resolve()
|
||||
require(parent.is_dir() and not path.parent.is_symlink(), "Status output directory must exist.")
|
||||
fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o644)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
json.dump(status, stream, sort_keys=True, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--backup-directory", type=Path, default=Path("/var/backups/guestops"))
|
||||
parser.add_argument("--origin", default="https://sandbox-guestops.futuresens.co.uk")
|
||||
parser.add_argument("--output", type=Path, default=Path("/run/guestops-monitor/status.json"))
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
require(root.is_dir(), "GuestOps root must exist.")
|
||||
backup_directory = args.backup_directory.resolve()
|
||||
status, errors = build_status(root, backup_directory, args.origin)
|
||||
write_status(args.output, status)
|
||||
print("GuestOps monitoring status updated." if not errors
|
||||
else "GuestOps monitoring status updated with failed probes.")
|
||||
raise SystemExit(1 if errors else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError, json.JSONDecodeError) as error:
|
||||
print(f"GuestOps monitoring probe failed: {error}", file=__import__("sys").stderr)
|
||||
raise SystemExit(1)
|
||||
124
deploy/package_source.py
Normal file
124
deploy/package_source.py
Normal file
@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a deterministic GuestOps source package from one committed Git tree."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
SEMVER = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+")
|
||||
FULL_SHA = re.compile(r"[0-9a-f]{40}")
|
||||
|
||||
|
||||
def git(repository: Path, *arguments: str, binary: bool = False) -> bytes | str:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repository), *arguments],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=not binary,
|
||||
)
|
||||
return result.stdout if binary else result.stdout.strip()
|
||||
|
||||
|
||||
def committed_text(repository: Path, commit: str, path: str) -> str:
|
||||
return str(git(repository, "show", f"{commit}:{path}"))
|
||||
|
||||
|
||||
def versions(repository: Path, commit: str) -> tuple[str, str]:
|
||||
props = ET.fromstring(committed_text(repository, commit, "Directory.Build.props"))
|
||||
version_node = props.find(".//Version")
|
||||
if version_node is None or not version_node.text:
|
||||
raise ValueError("The committed Directory.Build.props has no Version element.")
|
||||
dotnet_version = version_node.text.strip()
|
||||
package = json.loads(committed_text(repository, commit, "web/package.json"))
|
||||
web_version = str(package.get("version", ""))
|
||||
return dotnet_version, web_version
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def build_package(repository: Path, ref: str, output_directory: Path) -> tuple[Path, Path, dict[str, object]]:
|
||||
repository = repository.resolve()
|
||||
output_directory = output_directory.resolve()
|
||||
if not (repository / ".git").exists():
|
||||
raise ValueError("--repository must be a Git working tree.")
|
||||
|
||||
commit = str(git(repository, "rev-parse", "--verify", f"{ref}^{{commit}}")).lower()
|
||||
if FULL_SHA.fullmatch(commit) is None:
|
||||
raise ValueError("The selected ref did not resolve to a full Git commit SHA.")
|
||||
|
||||
dotnet_version, web_version = versions(repository, commit)
|
||||
if dotnet_version != web_version:
|
||||
raise ValueError(
|
||||
f"Committed release versions differ: .NET={dotnet_version}, web={web_version}."
|
||||
)
|
||||
if SEMVER.fullmatch(dotnet_version) is None:
|
||||
raise ValueError("The committed version must use MAJOR.MINOR.PATCH.")
|
||||
|
||||
prefix = f"GuestOps-{dotnet_version}/"
|
||||
tar_bytes = bytes(
|
||||
git(
|
||||
repository,
|
||||
"archive",
|
||||
"--format=tar",
|
||||
f"--prefix={prefix}",
|
||||
commit,
|
||||
binary=True,
|
||||
)
|
||||
)
|
||||
compressed = io.BytesIO()
|
||||
with gzip.GzipFile(fileobj=compressed, mode="wb", filename="", mtime=0) as stream:
|
||||
stream.write(tar_bytes)
|
||||
archive_bytes = compressed.getvalue()
|
||||
|
||||
stem = f"GuestOps-{dotnet_version}-{commit[:12]}"
|
||||
archive = output_directory / f"{stem}.tar.gz"
|
||||
record_path = output_directory / f"{stem}.source.json"
|
||||
if archive.exists() or record_path.exists():
|
||||
raise ValueError("The output package or source record already exists; releases are not overwritten.")
|
||||
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
archive.write_bytes(archive_bytes)
|
||||
record: dict[str, object] = {
|
||||
"artifact": {
|
||||
"name": archive.name,
|
||||
"sha256": sha256_bytes(archive_bytes),
|
||||
"size": len(archive_bytes),
|
||||
},
|
||||
"commit": commit,
|
||||
"schemaVersion": 1,
|
||||
"version": dotnet_version,
|
||||
}
|
||||
record_path.write_text(
|
||||
json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
return archive, record_path, record
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--ref", required=True, help="Exact tag, branch, or full commit to package.")
|
||||
parser.add_argument("--repository", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--output-directory", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
archive, record, details = build_package(args.repository, args.ref, args.output_directory)
|
||||
except (OSError, ValueError, ET.ParseError, json.JSONDecodeError, subprocess.SubprocessError) as error:
|
||||
print(f"Source packaging failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(json.dumps({"archive": str(archive), "record": str(record), **details}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
31
deploy/persistence-acceptance.example.json
Normal file
31
deploy/persistence-acceptance.example.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"system": "guestops-persistence",
|
||||
"evidenceId": "persistence",
|
||||
"releaseVersion": "0.2.1",
|
||||
"releaseCommit": "0000000000000000000000000000000000000000",
|
||||
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"archiveSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"environment": "https://sandbox-guestops.futuresens.co.uk",
|
||||
"hostIdentifier": "guestops-sandbox-01",
|
||||
"images": {
|
||||
"api": {"reference": "guestops-api:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"},
|
||||
"worker": {"reference": "guestops-worker:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
},
|
||||
"drillCommand": "python3 deploy/ops.py persistence-drill --confirm-restart",
|
||||
"operator": "REPLACE",
|
||||
"reviewedBy": "REPLACE",
|
||||
"startedAt": "2026-09-30T09:00:00Z",
|
||||
"endedAt": "2026-09-30T10:00:00Z",
|
||||
"reviewedAt": "2026-09-30T11:00:00Z",
|
||||
"unresolvedCriticalFindings": 1,
|
||||
"scenarios": [
|
||||
{"id": "container-recreation", "status": "not-run", "evidence": []},
|
||||
{"id": "data-protection-key", "status": "not-run", "evidence": []},
|
||||
{"id": "database-inventory", "status": "not-run", "evidence": []},
|
||||
{"id": "image-identity", "status": "not-run", "evidence": []},
|
||||
{"id": "post-reboot-persistence", "status": "not-run", "evidence": []},
|
||||
{"id": "separate-volume-layout", "status": "not-run", "evidence": []},
|
||||
{"id": "service-restart", "status": "not-run", "evidence": []}
|
||||
]
|
||||
}
|
||||
@ -11,7 +11,7 @@ import re
|
||||
|
||||
|
||||
GATE_B = {
|
||||
"release-ci", "debian-host", "persistence", "backup-restore", "google-mailbox",
|
||||
"release-package", "debian-host", "persistence", "backup-restore", "google-mailbox",
|
||||
"automation", "identity-privacy", "inbox-usability", "capacity",
|
||||
"incident-support", "pilot-findings",
|
||||
}
|
||||
@ -34,7 +34,7 @@ def timestamp(value: object, field: str) -> dt.datetime:
|
||||
|
||||
def validate(record: object) -> None:
|
||||
require(isinstance(record, dict), "Approval record must be a JSON object.")
|
||||
require(record.get("schemaVersion") == 2, "Unsupported approval schema; Gate B 0.2.0 requires schemaVersion 2.")
|
||||
require(record.get("schemaVersion") == 2, "Unsupported approval schema; Gate B 0.2.1 requires schemaVersion 2.")
|
||||
gate = record.get("targetGate")
|
||||
require(gate in ("B", "C"), "targetGate must be B or C.")
|
||||
require(record.get("decision") == "approved", "Only an explicit approved decision passes validation.")
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create deterministic evidence for a reviewed GuestOps image archive."""
|
||||
"""Bind a reviewed GuestOps source package to its installed image identities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
6
deploy/systemd/backup-transfer.env.example
Normal file
6
deploy/systemd/backup-transfer.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
BACKUP_DIRECTORY=/var/backups/guestops
|
||||
BACKUP_REMOTE_HOST=restricted-store.example.invalid
|
||||
BACKUP_REMOTE_USER=guestops_upload
|
||||
BACKUP_REMOTE_DIRECTORY=/restricted/guestops/backups
|
||||
BACKUP_SSH_IDENTITY=/etc/guestops/backup-transfer.key
|
||||
BACKUP_SSH_KNOWN_HOSTS=/etc/guestops/backup-known-hosts
|
||||
2
deploy/systemd/backup.env.example
Normal file
2
deploy/systemd/backup.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
BACKUP_RECIPIENT=0123456789ABCDEF0123456789ABCDEF01234567
|
||||
BACKUP_DIRECTORY=/var/backups/guestops
|
||||
19
deploy/systemd/guestops-backup-transfer.service
Normal file
19
deploy/systemd/guestops-backup-transfer.service
Normal file
@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Transfer GuestOps encrypted backups to restricted storage
|
||||
Wants=network-online.target
|
||||
After=network-online.target guestops-backup.service
|
||||
ConditionPathIsDirectory=/srv/guestops
|
||||
ConditionPathIsDirectory=/var/backups/guestops
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/srv/guestops
|
||||
EnvironmentFile=/etc/guestops/backup-transfer.env
|
||||
UMask=0077
|
||||
ExecStart=/usr/bin/python3 /srv/guestops/deploy/backup_transfer.py --prune-verified --retention-days 7
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/var/backups/guestops
|
||||
TimeoutStartSec=30min
|
||||
11
deploy/systemd/guestops-backup-transfer.timer
Normal file
11
deploy/systemd/guestops-backup-transfer.timer
Normal file
@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Retry GuestOps off-host backup transfer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=10min
|
||||
OnUnitActiveSec=15min
|
||||
Persistent=true
|
||||
Unit=guestops-backup-transfer.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
21
deploy/systemd/guestops-monitor-status.service
Normal file
21
deploy/systemd/guestops-monitor-status.service
Normal file
@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Write non-sensitive GuestOps monitoring status
|
||||
Requires=docker.service
|
||||
After=docker.service network-online.target
|
||||
ConditionPathIsDirectory=/srv/guestops
|
||||
ConditionPathIsDirectory=/var/backups/guestops
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/srv/guestops
|
||||
UMask=0022
|
||||
ExecStart=/usr/bin/python3 /srv/guestops/deploy/monitor_status.py
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
RuntimeDirectory=guestops-monitor
|
||||
RuntimeDirectoryMode=0755
|
||||
RuntimeDirectoryPreserve=yes
|
||||
ReadWritePaths=/run/guestops-monitor
|
||||
TimeoutStartSec=2min
|
||||
11
deploy/systemd/guestops-monitor-status.timer
Normal file
11
deploy/systemd/guestops-monitor-status.timer
Normal file
@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Refresh GuestOps monitoring status each minute
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=1min
|
||||
Persistent=true
|
||||
Unit=guestops-monitor-status.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
2
deploy/systemd/zabbix-agent-guestops.conf.example
Normal file
2
deploy/systemd/zabbix-agent-guestops.conf.example
Normal file
@ -0,0 +1,2 @@
|
||||
# The privileged systemd probe owns Docker/database access. Zabbix reads only this aggregate JSON.
|
||||
UserParameter=guestops.status,cat /run/guestops-monitor/status.json
|
||||
118
deploy/verify_release.py
Normal file
118
deploy/verify_release.py
Normal file
@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a GuestOps source package and its immutable release record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
SHA = re.compile(r"[0-9a-f]{40}")
|
||||
DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def loaded_image_id(reference: str) -> str:
|
||||
result = subprocess.run(
|
||||
["docker", "image", "inspect", "--format", "{{.Id}}", reference],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def validate(
|
||||
archive: Path,
|
||||
record_path: Path,
|
||||
expected_commit: str,
|
||||
expected_version: str,
|
||||
verify_loaded_images: bool = False,
|
||||
) -> dict[str, object]:
|
||||
require(archive.is_file(), "Release archive does not exist.")
|
||||
require(record_path.is_file(), "Release record does not exist.")
|
||||
require(SHA.fullmatch(expected_commit) is not None, "Expected commit must be a full lowercase Git SHA.")
|
||||
require(re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", expected_version) is not None,
|
||||
"Expected version must use MAJOR.MINOR.PATCH.")
|
||||
|
||||
record = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
require(isinstance(record, dict), "Release record must be a JSON object.")
|
||||
require(record.get("schemaVersion") == 1, "Unsupported release-record schema.")
|
||||
require(record.get("commit") == expected_commit, "Release-record commit does not match the approved candidate.")
|
||||
require(record.get("version") == expected_version, "Release-record version does not match the approved version.")
|
||||
|
||||
artifact = record.get("artifact")
|
||||
require(isinstance(artifact, dict), "Release record has no artifact object.")
|
||||
require(artifact.get("name") == archive.name, "Release archive filename does not match the record.")
|
||||
require(artifact.get("size") == archive.stat().st_size, "Release archive size does not match the record.")
|
||||
archive_sha = sha256(archive)
|
||||
require(artifact.get("sha256") == archive_sha, "Release archive SHA-256 does not match the record.")
|
||||
|
||||
images = record.get("images")
|
||||
require(isinstance(images, dict) and set(images) == {"api", "worker"},
|
||||
"Release record must contain exactly API and worker images.")
|
||||
expected_references = {
|
||||
"api": f"guestops-api:{expected_commit}",
|
||||
"worker": f"guestops-worker:{expected_commit}",
|
||||
}
|
||||
verified_images: dict[str, dict[str, str]] = {}
|
||||
for name, reference in expected_references.items():
|
||||
image = images.get(name)
|
||||
require(isinstance(image, dict), f"Release record has no {name} image object.")
|
||||
require(image.get("reference") == reference, f"{name} image reference is not bound to the full candidate SHA.")
|
||||
image_id = str(image.get("id", ""))
|
||||
require(DIGEST.fullmatch(image_id) is not None, f"{name} image ID is not an immutable SHA-256 digest.")
|
||||
if verify_loaded_images:
|
||||
require(loaded_image_id(reference) == image_id, f"Loaded {name} image ID does not match the release record.")
|
||||
verified_images[name] = {"reference": reference, "id": image_id}
|
||||
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"verified": True,
|
||||
"commit": expected_commit,
|
||||
"version": expected_version,
|
||||
"archive": {"name": archive.name, "size": archive.stat().st_size, "sha256": archive_sha},
|
||||
"releaseRecord": {"name": record_path.name, "sha256": sha256(record_path)},
|
||||
"images": verified_images,
|
||||
"loadedImageIdsVerified": verify_loaded_images,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--archive", required=True, type=Path)
|
||||
parser.add_argument("--record", required=True, type=Path)
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--verify-loaded-images", action="store_true")
|
||||
parser.add_argument("--output", type=Path, help="Optional path for the non-sensitive verification summary.")
|
||||
args = parser.parse_args()
|
||||
result = validate(args.archive, args.record, args.commit, args.version, args.verify_loaded_images)
|
||||
rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
|
||||
if args.output:
|
||||
args.output.write_text(rendered, encoding="utf-8")
|
||||
print(rendered, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
|
||||
print(f"Release verification failed: {error}", file=__import__("sys").stderr)
|
||||
raise SystemExit(1)
|
||||
@ -8,19 +8,34 @@ Check the existing Nginx, Docker, MongoDB and firewall configuration before inst
|
||||
|
||||
Install Docker Engine/Compose and Nginx using their official Debian instructions. Keep SSH access unchanged. Only HTTPS/HTTP for this hostname need public access; port 8080 is loopback-only and MongoDB has no published port.
|
||||
|
||||
Clone the private repository using an authorized GitHub account. Place the checkout in a dedicated application directory. Copy `.env.example` to `.env`, set permissions to 600, and fill two different MongoDB passwords generated with `openssl rand -hex 32`. Use hex values so they are safe in the MongoDB URI. Store real values only on the server or in its secret-management system.
|
||||
Install the versioned source package through the Futuresens Ansible repository. Do not clone GuestOps from the target server and do not place Gitea credentials on it. The playbook should extract the package into a dedicated application directory. Copy `.env.example` to `.env`, set permissions to 600, and fill two different MongoDB passwords generated with `openssl rand -hex 32`. Use hex values so they are safe in the MongoDB URI. Store real values only on the server or in its secret-management system.
|
||||
|
||||
The MongoDB initialization script runs only on a new volume. Changing `.env` later does not rotate existing database users. Rotate those credentials through MongoDB administration and update application configuration together.
|
||||
|
||||
## 2. Load reviewed application images
|
||||
## 2. Package and install with Ansible
|
||||
|
||||
CI builds API and worker images and packages them in a `guestops-linux-*` artifact. Download the successful artifact for the desired commit and transfer it to the sandbox through your normal authorized deployment process.
|
||||
GuestOps follows the CMS/CMSFront deployment pattern: Gitea stores the application source, a specific committed version is compressed, and a version-selected Ansible playbook installs it. No Gitea Actions runner is required.
|
||||
|
||||
On the trusted packaging machine, fetch the current remote refs, run the automated checks, select the full commit SHA, and create the archive from that committed tree rather than from a working directory:
|
||||
|
||||
```sh
|
||||
docker load -i guestops-images.tar.gz
|
||||
git fetch origin --prune --tags
|
||||
git rev-parse origin/main
|
||||
python3 deploy/package_source.py \
|
||||
--ref FULL_40_CHARACTER_SHA \
|
||||
--output-directory /secure/release-staging
|
||||
```
|
||||
|
||||
Set `GUESTOPS_API_IMAGE=guestops-api:<commit-sha>` and `GUESTOPS_WORKER_IMAGE=guestops-worker:<commit-sha>` in `.env` using that exact build's SHA. Then run:
|
||||
The packaging command resolves the ref to a full commit, reads both application versions from that committed tree, requires them to match, creates deterministic gzip output, and writes a `.source.json` record containing the version, commit, filename, size and SHA-256. Existing package files are never overwritten. Independently confirm that the selected commit is the intended release line before transferring it.
|
||||
|
||||
Store the archive and source record under a versioned GuestOps files directory in the private Ansible repository. The GuestOps playbook and environment variables should select that version, verify the archive against the source record, extract it into the application directory, preserve the private `.env` and provider configuration, and build images tagged with the full source commit:
|
||||
|
||||
```sh
|
||||
docker build --target api -t guestops-api:FULL_40_CHARACTER_SHA .
|
||||
docker build --target worker -t guestops-worker:FULL_40_CHARACTER_SHA .
|
||||
```
|
||||
|
||||
Record the resulting immutable image IDs and bind them to the source archive with `deploy/release_record.py`. Retain the source archive, checksum, release record, its checksum, build output, commit and Ansible run result in the restricted release store. Set `GUESTOPS_API_IMAGE=guestops-api:FULL_40_CHARACTER_SHA` and `GUESTOPS_WORKER_IMAGE=guestops-worker:FULL_40_CHARACTER_SHA` in `.env`. Then the playbook runs:
|
||||
|
||||
```sh
|
||||
docker compose config --quiet
|
||||
@ -28,7 +43,7 @@ docker compose up -d --no-build
|
||||
curl --fail http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
Do not run `docker compose config` without `--quiet` in shared logs: expanded configuration contains secrets. Local image builds are available with Compose for development, but CI builds avoid consuming sandbox resources.
|
||||
Do not run `docker compose config` without `--quiet` in shared logs: expanded configuration contains secrets. Do not store `.env`, provider credentials, host inventory secrets, or restricted evidence in either application repository. Ansible must stop on a checksum, commit, version, build, or health-check mismatch.
|
||||
|
||||
## 3. Provision the first hotel owner
|
||||
|
||||
@ -86,4 +101,16 @@ The drill restarts MongoDB, API and worker, then force-recreates the stateless a
|
||||
|
||||
Record the host, operator, start/end time, release record checksum, resolved image IDs, preflight output and drill result in the deployment acceptance record. Also verify Docker starts at boot and perform a controlled Debian reboot before Gate A approval. After reboot, run the online preflight and inspect the Workspace health page; do not infer worker health solely from API readiness.
|
||||
|
||||
Keep the `debian-host` and `persistence` records in the restricted evidence store. Start from `deploy/debian-host-acceptance.example.json` and `deploy/persistence-acceptance.example.json`; the examples deliberately fail until every supervised scenario has passed. Bind both records to the expected release identifiers and validate them together:
|
||||
|
||||
```sh
|
||||
python3 deploy/debian_acceptance.py \
|
||||
/secure/acceptance/debian-host.json \
|
||||
/secure/acceptance/persistence.json \
|
||||
--expected-commit FULL_40_CHARACTER_SHA \
|
||||
--expected-release-record-sha256 RELEASE_RECORD_SHA256
|
||||
```
|
||||
|
||||
The validator requires matching archive and image identities, separate operator and reviewer names, the approved host capacity, only ports 80 and 443 recorded as publicly reachable, disabled unaccepted external writes, exact passed scenario sets and no unresolved critical findings. It validates record structure, not the restricted evidence itself. Retain the records, validator output and their checksums outside Git.
|
||||
|
||||
The supplied Docker `json-file` logs are size-capped to protect the small pilot disk, but container recreation removes that container's local log history. Before host acceptance, route GuestOps and Nginx logs to the site's durable restricted logging system, or use a reviewed Docker logging override backed by persistent systemd journal storage. Prove that operators can retrieve pre-recreation logs without exposing request credentials or OAuth callback query strings. Central retention and alerting are completed under milestone 11.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Gate B automation, identity and privacy acceptance
|
||||
|
||||
Run these reviews against the exact `0.2.0` candidate on the accepted HTTPS sandbox. Keep guest data, staff addresses, provider agreements, screenshots and raw reports in the restricted evidence store. Repository records contain opaque references only.
|
||||
Run these reviews against the exact `0.2.1` candidate on the accepted HTTPS sandbox. Keep guest data, staff addresses, provider agreements, screenshots and raw reports in the restricted evidence store. Repository records contain opaque references only.
|
||||
|
||||
## Knowledge, AI and FAQ automation
|
||||
|
||||
|
||||
@ -4,21 +4,49 @@ The owner-only **Workspace health** page reports database reachability, the work
|
||||
|
||||
## Release evidence and rollback
|
||||
|
||||
Every non-pull-request CI build packages the API and worker images under the full Git commit SHA. The accompanying `release-record.json` binds the archive checksum, application version, commit, image references and immutable Docker image IDs. Retain both files together in restricted off-host release storage; the CI artifact is a transfer mechanism, not the permanent archive.
|
||||
Create the release source archive from an exact committed Gitea tree with `deploy/package_source.py`, then verify its generated source record before Ansible installs it. The tool packages only the selected commit, produces deterministic gzip output, rejects version disagreement, and refuses to overwrite an existing release. Ansible builds the API and worker images under the full Git commit SHA. The post-build `release-record.json` binds the source-archive checksum, application version, commit, image references and immutable Docker image IDs. Retain the archive, source record, release record and Ansible result together in restricted off-host release storage.
|
||||
|
||||
Before deployment, verify the archive against its record without loading it:
|
||||
```sh
|
||||
git fetch origin --prune --tags
|
||||
python3 deploy/package_source.py \
|
||||
--ref FULL_40_CHARACTER_SHA \
|
||||
--output-directory /secure/release-staging
|
||||
```
|
||||
|
||||
Before deployment, verify the archive against its source record:
|
||||
|
||||
```sh
|
||||
python3 - <<'PY'
|
||||
import hashlib, json, pathlib
|
||||
r = json.load(open('release-record.json', encoding='utf-8'))
|
||||
r = json.load(open('GuestOps-0.2.1-COMMIT.source.json', encoding='utf-8'))
|
||||
p = pathlib.Path(r['artifact']['name'])
|
||||
assert hashlib.sha256(p.read_bytes()).hexdigest() == r['artifact']['sha256']
|
||||
print(r['commit'], r['version'], r['images'])
|
||||
print(r['commit'], r['version'], r['artifact'])
|
||||
PY
|
||||
```
|
||||
|
||||
Load the archive, verify each loaded image ID matches the record, set `GUESTOPS_API_IMAGE` and `GUESTOPS_WORKER_IMAGE` to the recorded full-SHA references, and run the deployment preflight. Record the CI run, commit, checksum and operator in the change ticket. A release tag is an approval marker; do not move or reuse an existing tag. The application and web versions must match before the record can be created.
|
||||
After Ansible has built the commit-tagged images, the repository verifier performs the same checks strictly, calculates the release-record checksum used by later acceptance records, and compares the record with the installed Docker images:
|
||||
|
||||
```sh
|
||||
python3 deploy/verify_release.py \
|
||||
--archive GuestOps-0.2.1.tar.gz \
|
||||
--record release-record.json \
|
||||
--commit FULL_40_CHARACTER_SHA \
|
||||
--version 0.2.1 \
|
||||
--output release-verification.json
|
||||
|
||||
python3 deploy/verify_release.py \
|
||||
--archive GuestOps-0.2.1.tar.gz \
|
||||
--record release-record.json \
|
||||
--commit FULL_40_CHARACTER_SHA \
|
||||
--version 0.2.1 \
|
||||
--verify-loaded-images \
|
||||
--output loaded-image-verification.json
|
||||
```
|
||||
|
||||
Retain both verification summaries with the untouched source archive, its checksum, the release record, its SHA-256, and the exact Ansible run metadata. A package from an uncommitted working tree, a mismatched checksum, or a failed Ansible run is not release evidence. Do not silently replace an approved package or rebuild under the same release identity.
|
||||
|
||||
Verify each installed image ID matches the record, set `GUESTOPS_API_IMAGE` and `GUESTOPS_WORKER_IMAGE` to the recorded full-SHA references, and run the deployment preflight. Record the Ansible run, commit, archive checksum and operator in the change ticket. A release tag is an approval marker; do not move or reuse an existing tag. The application and web versions must match before the package is accepted.
|
||||
|
||||
For rollback, first disable worker-driven external writes and reconcile any sending, payment or PMS operation that may have completed since the prior release. Confirm the previous release archive and record are retained, verify its checksum and image IDs, take an encrypted backup, then select the previous recorded image references in `.env` and recreate only the API and worker. Do not roll back MongoDB or the key volume merely to change application images. Run the online preflight, readiness check and read-only smoke test before re-enabling the worker or provider writes. If a release introduced an incompatible data change, follow its release-specific recovery plan rather than starting an older image against newer data.
|
||||
|
||||
@ -91,6 +119,43 @@ systemctl list-timers guestops-backup.timer
|
||||
|
||||
The timer deliberately causes the same brief maintenance interruption as a manual backup. `Persistent=true` runs a missed event after downtime, so choose and communicate the maintenance window. A successful unit only stages an encrypted file locally. Configure an independently monitored off-host transfer, verify the destination checksum, alert on both unit and transfer failure, and test the alert route. Do not add automatic deletion until retention, legal hold and recovery requirements have named owners.
|
||||
|
||||
### Restricted off-host transfer
|
||||
|
||||
The optional transfer service uses rsync over pinned-host SSH. Create a dedicated upload-only account at the restricted store, disable interactive login, agent/port forwarding and access outside the GuestOps backup directory, and keep its private key only in `/etc/guestops` with mode 600. Pin the reviewed server host key; never use `StrictHostKeyChecking=no`.
|
||||
|
||||
Start from `deploy/systemd/backup-transfer.env.example` and store the completed file as `/etc/guestops/backup-transfer.env` with mode 600. The local and remote directories must already exist and remain private. Install and verify the service and its 15-minute retry timer:
|
||||
|
||||
```sh
|
||||
sudo install -m 600 deploy/systemd/backup-transfer.env.example /etc/guestops/backup-transfer.env
|
||||
sudoedit /etc/guestops/backup-transfer.env
|
||||
sudo install -m 644 deploy/systemd/guestops-backup-transfer.service /etc/systemd/system/
|
||||
sudo install -m 644 deploy/systemd/guestops-backup-transfer.timer /etc/systemd/system/
|
||||
sudo systemd-analyze verify /etc/systemd/system/guestops-backup-transfer.service /etc/systemd/system/guestops-backup-transfer.timer
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start guestops-backup-transfer.service
|
||||
sudo systemctl enable --now guestops-backup-transfer.timer
|
||||
```
|
||||
|
||||
Only files named `guestops-YYYYMMDDTHHMMSSZ.tar.gpg` are eligible. The transfer writes a unique remote partial file, compares the remote and local SHA-256 values, atomically publishes a previously unused final name, verifies it again, and then writes a non-sensitive local marker. An existing remote name is accepted only when its checksum matches. Failed or mismatched transfers are never marked. The service removes local backups older than seven days only when the marker still matches the local checksum; untransferred or changed files are never pruned.
|
||||
|
||||
The restricted store is the durable copy. Configure its independently reviewed retention policy for 35 daily and 12 monthly recovery points. Legal hold must override expiry. The host tool does not delete remote data or enforce remote retention.
|
||||
|
||||
### Zabbix status boundary
|
||||
|
||||
Do not give the Zabbix agent access to Docker, MongoDB credentials or the application owner session. A root-owned systemd probe reads those local sources and atomically publishes aggregate status under `/run/guestops-monitor/status.json`; the agent reads that file only.
|
||||
|
||||
```sh
|
||||
sudo install -m 644 deploy/systemd/guestops-monitor-status.service /etc/systemd/system/
|
||||
sudo install -m 644 deploy/systemd/guestops-monitor-status.timer /etc/systemd/system/
|
||||
sudo install -m 644 deploy/systemd/zabbix-agent-guestops.conf.example /etc/zabbix/zabbix_agentd.d/guestops.conf
|
||||
sudo systemd-analyze verify /etc/systemd/system/guestops-monitor-status.service /etc/systemd/system/guestops-monitor-status.timer
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now guestops-monitor-status.timer
|
||||
sudo systemctl restart zabbix-agent
|
||||
```
|
||||
|
||||
Create dependent Zabbix items from `guestops.status` for HTTPS readiness, certificate days remaining, container state/health, worker heartbeat age, backup age, verified-transfer age, free-disk percentage, persistent journal availability and probe error categories. Alert when the heartbeat is older than three minutes; backup or transfer is older than 30 hours; free disk falls below 25% (warning) or 15% (critical); the certificate has fewer than 30 days (warning) or 14 days (critical); any required container is absent/unhealthy; or the probe/timers fail. Route alerts to the monitoring owner and escalate unacknowledged critical events to the technical owner after 15 minutes. Exercise every trigger and its recovery notification with synthetic conditions.
|
||||
|
||||
Temporary plaintext files are held in private directories and removed on normal completion or exceptions. Process termination or power loss can leave temporary data, stopped services or TTL expiry disabled. After an interrupted run, inspect the dedicated project and remove only its identified abandoned temporary directory after securing any recovery material. Restore the recorded TTL setting (normally true) and restart the services:
|
||||
|
||||
```sh
|
||||
@ -123,5 +188,21 @@ Restore into new isolated MongoDB and key volumes; preserve the damaged original
|
||||
|
||||
**A restored database can predate emails, invoices and PMS changes that providers already completed.** Review pending, sending and uncertain records against provider evidence before enabling any worker, including automatic FAQ rules. Do not replay an older approval merely because the restored record says it is pending. Reconcile external effects, validate account sessions and mailbox authorization, and explicitly approve the cutover only after these checks. Rotate credentials if compromise prompted the recovery. Keep the old deployment stopped when enabling the replacement.
|
||||
|
||||
CI exercises a synthetic encrypted backup and isolated restore drill, including actual key decryption and database comparison. A successful CI drill is separate from the required rehearsal on the Debian server with its actual deployment configuration.
|
||||
Run the synthetic encrypted backup and isolated restore drill in a controlled test environment, including actual key decryption and database comparison. This automated check is separate from the required rehearsal on the Debian server with its actual deployment configuration.
|
||||
|
||||
## Milestone 11 acceptance record
|
||||
|
||||
Keep raw backups, restored data, remote paths, host keys, monitoring recipients, screenshots and logs outside Git. Copy `deploy/backup-restore-acceptance.example.json` to the restricted evidence store and replace every placeholder only after completing the supervised exercises. The example deliberately fails.
|
||||
|
||||
The accepted record uses a 24-hour RPO, four-hour RTO, seven-day verified local staging window, 35 daily and 12 monthly off-host recovery points, and the opaque evidence ID `backup-restore`. Bind it to the same release identifiers as the Debian-host and persistence records:
|
||||
|
||||
```sh
|
||||
python3 deploy/backup_restore_acceptance.py \
|
||||
/secure/acceptance/backup-restore.json \
|
||||
--expected-commit FULL_40_CHARACTER_SHA \
|
||||
--expected-release-record-sha256 RELEASE_RECORD_SHA256
|
||||
sha256sum /secure/acceptance/backup-restore.json
|
||||
```
|
||||
|
||||
The validator requires matching local/remote backup checksums, exact immutable image identities, a timed isolated restore, tested retention/legal hold and alert escalation, an image rollback that preserves persistent volumes, return to the approved candidate with external writes disabled, separate independent review, healthy monitoring and no unresolved critical findings. Structural validation does not inspect the restricted evidence or authorize a release by itself.
|
||||
|
||||
|
||||
@ -46,6 +46,6 @@ Only unsubmitted proposals can be cancelled in GuestOps. Invoice closure, refund
|
||||
|
||||
## Acceptance before live use
|
||||
|
||||
Automated tests use an in-process fake HTTP handler and never contact NMI. They cover tenant isolation, amount/currency/identity mismatches, partial status, concurrent approvals, lost create responses, pagination and merchant changes. CI checks the production configuration mount and disabled defaults.
|
||||
Automated tests use an in-process fake HTTP handler and never contact NMI. They cover tenant isolation, amount/currency/identity mismatches, partial status, concurrent approvals, lost create responses, pagination and merchant changes. Run the production-configuration and disabled-default checks before packaging and again during the Ansible deployment verification.
|
||||
|
||||
Use a dedicated sandbox merchant and recipient to validate authentication, supported currency, exact request/response fields, preservation of `order_details.order_id`, customer invoice email and hosted checkout, partial/full payments and interrupted-request recovery. Live acceptance remains pending. Planet, direct payment links in GuestOps replies, automatic booking after payment, automated expiry/closure and background polling are follow-on work.
|
||||
|
||||
@ -22,7 +22,7 @@ python3 deploy/capacity_probe.py \
|
||||
unset CAPACITY_EMAIL CAPACITY_PASSWORD
|
||||
```
|
||||
|
||||
For the `0.2.0` Gate B candidate, the approved targets are concurrency 10, p95 latency at or below 500 ms, error rate at or below 1%, and at least 25% CPU and memory headroom on the documented four-core, 7.6 GiB host. The generated result reports HTTP observations, not a pass/fail claim. Repeat after a warm-up, investigate every error, and retain independently captured host metrics with the report. Hash both retained files for the approval record. Do not point the probe at a live hotel or increase its built-in bounds to simulate a denial of service.
|
||||
For the `0.2.1` Gate B candidate, the approved targets are concurrency 10, p95 latency at or below 500 ms, error rate at or below 1%, and at least 25% CPU and memory headroom on the documented four-core, 7.6 GiB host. The generated result reports HTTP observations, not a pass/fail claim. Repeat after a warm-up, investigate every error, and retain independently captured host metrics with the report. Hash both retained files for the approval record. Do not point the probe at a live hotel or increase its built-in bounds to simulate a denial of service.
|
||||
|
||||
## Five-business-day supervised pilot
|
||||
|
||||
@ -41,7 +41,7 @@ The example deliberately fails while daily reviews are `not-run`. A structurally
|
||||
|
||||
The go/no-go record must bind all evidence to the same release commit and release-record checksum. Record named owners, dates, evidence locations, findings and explicit dispositions for:
|
||||
|
||||
- default-branch CI and immutable release archive;
|
||||
- exact-commit source package, checksum, recorded image IDs, and successful Ansible installation;
|
||||
- Debian preflight, HTTPS, persistence and controlled reboot;
|
||||
- encrypted off-host backup and timed isolated restore;
|
||||
- Google mailbox and reviewed-send acceptance;
|
||||
|
||||
@ -34,6 +34,6 @@ Current limitations: messages imported before reply headers were stored must be
|
||||
|
||||
## Verification
|
||||
|
||||
The test suite covers competing workers, send identity/thread headers, invalid recipients, header injection, uncertain outcomes, restart recovery, pre-send token failure, disabling hotel sending, cross-hotel access, Gmail reconciliation mismatches, AI source isolation, invalid citations, escalations and incomplete responses. CI also runs production container login/restart-persistence smoke checks. Live acceptance must additionally verify Google consent, actual threading, grant revocation, a representative AI draft evaluation set and provider error behaviour with the configured accounts.
|
||||
The test suite covers competing workers, send identity/thread headers, invalid recipients, header injection, uncertain outcomes, restart recovery, pre-send token failure, disabling hotel sending, cross-hotel access, Gmail reconciliation mismatches, AI source isolation, invalid citations, escalations and incomplete responses. Run the production-container login and restart-persistence smoke checks before packaging and during Ansible deployment verification. Live acceptance must additionally verify Google consent, actual threading, grant revocation, a representative AI draft evaluation set and provider error behaviour with the configured accounts.
|
||||
|
||||
Implementation references: [OpenAI Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs), [Gmail sending](https://developers.google.com/workspace/gmail/api/guides/sending), [Gmail threads](https://developers.google.com/workspace/gmail/api/guides/threads).
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Exercise disposable CI containers through the host's trusted proxy address.
|
||||
"""Exercise disposable deployment containers through the host's trusted proxy address.
|
||||
|
||||
The forwarded HTTPS header simulates Nginx TLS termination; this is never run
|
||||
against an existing hotel database. Cookie values and credentials are not logged.
|
||||
@ -66,12 +66,12 @@ team = request("/api/team")
|
||||
assert all("passwordHash" not in member and "securityStamp" not in member and "accountLinkHash" not in member for member in team)
|
||||
if "--read" in sys.argv:
|
||||
assert hotel["signature"] == "Persisted across container restart"
|
||||
invited = next(member for member in team if member["email"] == "ci-staff@example.invalid")
|
||||
invited = next(member for member in team if member["email"] == "deployment-staff@example.invalid")
|
||||
assert invited["pending"] and not invited["active"] and invited["linkPurpose"] == "Invite"
|
||||
else:
|
||||
hotel["signature"] = "Persisted across container restart"
|
||||
request("/api/hotel", "PUT", hotel)
|
||||
invite = request("/api/team/invite", "POST", {"name": "CI Staff", "email": "ci-staff@example.invalid"})
|
||||
invite = request("/api/team/invite", "POST", {"name": "Deployment Staff", "email": "deployment-staff@example.invalid"})
|
||||
assert invite["link"].startswith("https://sandbox-guestops.futuresens.co.uk/account#token=")
|
||||
request("/api/auth/logout", "POST")
|
||||
request("/api/hotel", expected=401)
|
||||
|
||||
129
tests/test_backup_restore_acceptance.py
Normal file
129
tests/test_backup_restore_acceptance.py
Normal file
@ -0,0 +1,129 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"backup_restore_acceptance",
|
||||
Path(__file__).resolve().parents[1] / "deploy" / "backup_restore_acceptance.py")
|
||||
acceptance = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(acceptance)
|
||||
|
||||
COMMIT = "a" * 40
|
||||
RELEASE_SHA = "b" * 64
|
||||
|
||||
|
||||
def valid_record():
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"system": "guestops-backup-recovery",
|
||||
"evidenceId": "backup-restore",
|
||||
"dataClassification": "synthetic-only",
|
||||
"releaseVersion": "0.2.1",
|
||||
"releaseCommit": COMMIT,
|
||||
"releaseRecordSha256": RELEASE_SHA,
|
||||
"archiveSha256": "c" * 64,
|
||||
"environment": "https://sandbox-guestops.futuresens.co.uk",
|
||||
"hostIdentifier": "guestops-sandbox-01",
|
||||
"images": {
|
||||
"api": {"reference": f"guestops-api:{COMMIT}", "id": "sha256:" + "d" * 64},
|
||||
"worker": {"reference": f"guestops-worker:{COMMIT}", "id": "sha256:" + "e" * 64},
|
||||
"mongo": {"reference": "mongo:8.0", "id": "sha256:" + "f" * 64},
|
||||
},
|
||||
"owners": {
|
||||
"backup": "Backup owner",
|
||||
"monitoring": "Monitoring owner",
|
||||
"recoveryOperator": "Recovery operator",
|
||||
"technicalEscalation": "Technical owner",
|
||||
"retention": "Retention owner",
|
||||
"independentReviewer": "Independent reviewer",
|
||||
},
|
||||
"startedAt": "2026-10-01T09:00:00Z",
|
||||
"endedAt": "2026-10-01T12:00:00Z",
|
||||
"reviewedAt": "2026-10-01T13:00:00Z",
|
||||
"recoveryObjectives": {
|
||||
"targetRpoHours": 24, "observedRpoHours": 1,
|
||||
"targetRtoMinutes": 240, "observedRtoMinutes": 180,
|
||||
},
|
||||
"retention": {
|
||||
"localVerifiedDays": 7, "offHostDaily": 35,
|
||||
"offHostMonthly": 12, "legalHoldOverrideTested": True,
|
||||
},
|
||||
"backup": {
|
||||
"createdAt": "2026-10-01T08:00:00Z",
|
||||
"sha256": "1" * 64,
|
||||
"transferredSha256": "1" * 64,
|
||||
"privateKeyPresentOnHost": False,
|
||||
},
|
||||
"rollback": {
|
||||
"previousReleaseCommit": "2" * 40,
|
||||
"previousArchiveSha256": "3" * 64,
|
||||
"previousImages": {
|
||||
"api": {"reference": "guestops-api:" + "2" * 40, "id": "sha256:" + "4" * 64},
|
||||
"worker": {"reference": "guestops-worker:" + "2" * 40, "id": "sha256:" + "5" * 64},
|
||||
},
|
||||
"persistentVolumesReplaced": False,
|
||||
"restoredReleaseCommit": COMMIT,
|
||||
"unresolvedOperations": 0,
|
||||
"finalControls": {
|
||||
"googleSending": "disabled", "faqLiveMode": "disabled",
|
||||
"pmsWrites": "disabled", "paymentCreation": "disabled",
|
||||
},
|
||||
},
|
||||
"monitoringState": "healthy",
|
||||
"unresolvedCriticalFindings": 0,
|
||||
"scenarios": [
|
||||
{"id": scenario, "status": "pass", "evidence": [f"restricted-{index}"]}
|
||||
for index, scenario in enumerate(sorted(acceptance.SCENARIOS), 1)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class BackupRestoreAcceptanceTests(unittest.TestCase):
|
||||
def test_complete_record_passes(self):
|
||||
acceptance.validate(valid_record(), COMMIT, RELEASE_SHA)
|
||||
|
||||
def test_release_identity_and_images_are_bound(self):
|
||||
record = valid_record(); record["releaseCommit"] = "9" * 40
|
||||
with self.assertRaisesRegex(ValueError, "approved candidate"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
record = valid_record(); record["images"]["api"]["reference"] = "guestops-api:latest"
|
||||
with self.assertRaisesRegex(ValueError, "full approved commit"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
record = valid_record(); record["rollback"]["previousImages"]["worker"]["id"] = "mutable"
|
||||
with self.assertRaisesRegex(ValueError, "retained previous release identity"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
|
||||
def test_recovery_objectives_and_checksums_must_pass(self):
|
||||
record = valid_record(); record["recoveryObjectives"]["observedRtoMinutes"] = 241
|
||||
with self.assertRaisesRegex(ValueError, "four-hour"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
record = valid_record(); record["backup"]["transferredSha256"] = "9" * 64
|
||||
with self.assertRaisesRegex(ValueError, "checksums must match"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
record = valid_record(); record["recoveryObjectives"]["observedRpoHours"] = 2
|
||||
with self.assertRaisesRegex(ValueError, "match the backup"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
|
||||
def test_independent_review_retention_and_final_state_are_required(self):
|
||||
record = valid_record(); record["owners"]["independentReviewer"] = record["owners"]["backup"]
|
||||
with self.assertRaisesRegex(ValueError, "different"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
record = valid_record(); record["retention"]["legalHoldOverrideTested"] = False
|
||||
with self.assertRaisesRegex(ValueError, "Retention"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
record = valid_record(); record["rollback"]["persistentVolumesReplaced"] = True
|
||||
with self.assertRaisesRegex(ValueError, "must not replace"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
|
||||
def test_exact_passed_scenarios_and_monitoring_are_required(self):
|
||||
record = valid_record(); record["scenarios"].pop()
|
||||
with self.assertRaisesRegex(ValueError, "exact backup/recovery scenario"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
record = valid_record(); record["monitoringState"] = "degraded"
|
||||
with self.assertRaisesRegex(ValueError, "Monitoring must be healthy"):
|
||||
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
81
tests/test_backup_transfer.py
Normal file
81
tests/test_backup_transfer.py
Normal file
@ -0,0 +1,81 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"backup_transfer", Path(__file__).resolve().parents[1] / "deploy" / "backup_transfer.py")
|
||||
transfer = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(transfer)
|
||||
|
||||
|
||||
class BackupTransferTests(unittest.TestCase):
|
||||
def backup(self, directory: Path, name="guestops-20261001T021700Z.tar.gpg") -> Path:
|
||||
path = directory / name
|
||||
path.write_bytes(b"encrypted-backup")
|
||||
return path
|
||||
|
||||
def config(self, directory: Path):
|
||||
return {
|
||||
"directory": directory, "host": "store.example.invalid", "user": "guestops_upload",
|
||||
"remote": "/restricted/guestops", "identity": Path("/safe/key"),
|
||||
"known_hosts": Path("/safe/known_hosts"),
|
||||
}
|
||||
|
||||
def test_existing_matching_remote_is_marked_without_upload(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
backup = self.backup(Path(folder)); checksum = transfer.digest(backup)
|
||||
with patch.object(transfer, "remote_digest", return_value=checksum), \
|
||||
patch.object(transfer, "run") as run:
|
||||
transfer.transfer_one(self.config(Path(folder)), backup)
|
||||
run.assert_not_called()
|
||||
marker = json.loads(transfer.marker_path(backup).read_text(encoding="utf-8"))
|
||||
self.assertEqual(marker["sha256"], checksum)
|
||||
|
||||
def test_existing_mismatched_remote_is_never_overwritten(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
backup = self.backup(Path(folder))
|
||||
with patch.object(transfer, "remote_digest", return_value="9" * 64), \
|
||||
patch.object(transfer, "run") as run:
|
||||
with self.assertRaisesRegex(RuntimeError, "different checksum"):
|
||||
transfer.transfer_one(self.config(Path(folder)), backup)
|
||||
run.assert_not_called()
|
||||
self.assertFalse(transfer.marker_path(backup).exists())
|
||||
|
||||
def test_new_remote_is_uploaded_verified_and_marked(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
backup = self.backup(Path(folder)); checksum = transfer.digest(backup)
|
||||
with patch.object(transfer, "remote_digest", side_effect=[None, checksum, checksum]), \
|
||||
patch.object(transfer, "run", return_value=b"") as run, \
|
||||
patch.object(transfer.subprocess, "run"):
|
||||
transfer.transfer_one(self.config(Path(folder)), backup)
|
||||
self.assertTrue(any(call.args[0][0] == "rsync" for call in run.call_args_list))
|
||||
self.assertTrue(transfer.marker_path(backup).exists())
|
||||
|
||||
def test_prune_removes_only_old_checksum_verified_backups(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
directory = Path(folder)
|
||||
verified = self.backup(directory)
|
||||
checksum = transfer.digest(verified)
|
||||
transfer.write_marker(verified, checksum)
|
||||
unverified = self.backup(directory, "guestops-20261002T021700Z.tar.gpg")
|
||||
old = time.time() - 8 * 86400
|
||||
os.utime(verified, (old, old)); os.utime(unverified, (old, old))
|
||||
removed = transfer.prune_verified(self.config(directory), 7, now=time.time())
|
||||
self.assertEqual(removed, 1)
|
||||
self.assertFalse(verified.exists())
|
||||
self.assertTrue(unverified.exists())
|
||||
|
||||
def test_retention_bounds_are_enforced(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
with self.assertRaisesRegex(RuntimeError, "between 1 and 365"):
|
||||
transfer.prune_verified(self.config(Path(folder)), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
113
tests/test_debian_acceptance.py
Normal file
113
tests/test_debian_acceptance.py
Normal file
@ -0,0 +1,113 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"debian_acceptance", Path(__file__).resolve().parents[1] / "deploy" / "debian_acceptance.py")
|
||||
acceptance = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(acceptance)
|
||||
|
||||
COMMIT = "a" * 40
|
||||
RELEASE_SHA = "b" * 64
|
||||
|
||||
|
||||
def common(system):
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"system": system,
|
||||
"evidenceId": acceptance.SYSTEMS[system]["evidenceId"],
|
||||
"releaseVersion": "0.2.1",
|
||||
"releaseCommit": COMMIT,
|
||||
"releaseRecordSha256": RELEASE_SHA,
|
||||
"archiveSha256": "c" * 64,
|
||||
"environment": "https://sandbox-guestops.futuresens.co.uk",
|
||||
"hostIdentifier": "guestops-sandbox-01",
|
||||
"images": {
|
||||
"api": {"reference": f"guestops-api:{COMMIT}", "id": "sha256:" + "d" * 64},
|
||||
"worker": {"reference": f"guestops-worker:{COMMIT}", "id": "sha256:" + "e" * 64},
|
||||
},
|
||||
"operator": "Deployment operator",
|
||||
"reviewedBy": "Independent reviewer",
|
||||
"startedAt": "2026-09-30T09:00:00Z",
|
||||
"endedAt": "2026-09-30T10:00:00Z",
|
||||
"reviewedAt": "2026-09-30T11:00:00Z",
|
||||
"unresolvedCriticalFindings": 0,
|
||||
"scenarios": [
|
||||
{"id": scenario, "status": "pass", "evidence": [f"restricted-{index}"]}
|
||||
for index, scenario in enumerate(sorted(acceptance.SYSTEMS[system]["scenarios"]), 1)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def valid_records():
|
||||
host = common("guestops-debian-host")
|
||||
host["hostFacts"] = {
|
||||
"debianMajor": 12,
|
||||
"cpuCores": 4,
|
||||
"memoryBytes": 8_140_382_208,
|
||||
"freeDiskBytes": 14_275_686_400,
|
||||
"publicTcpPorts": [80, 443],
|
||||
}
|
||||
host["featureControls"] = {
|
||||
"googleSending": "disabled",
|
||||
"faqLiveMode": "disabled",
|
||||
"pmsWrites": "disabled",
|
||||
"paymentCreation": "disabled",
|
||||
}
|
||||
persistence = common("guestops-persistence")
|
||||
persistence["drillCommand"] = "python3 deploy/ops.py persistence-drill --confirm-restart"
|
||||
return host, persistence
|
||||
|
||||
|
||||
class DebianAcceptanceTests(unittest.TestCase):
|
||||
def test_complete_matching_records_pass(self):
|
||||
acceptance.validate_pair(*valid_records(), COMMIT, RELEASE_SHA)
|
||||
|
||||
def test_expected_release_identity_is_required(self):
|
||||
host, persistence = valid_records()
|
||||
host["releaseCommit"] = "f" * 40
|
||||
with self.assertRaisesRegex(ValueError, "approved candidate"):
|
||||
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
|
||||
host, persistence = valid_records()
|
||||
persistence["releaseRecordSha256"] = "f" * 64
|
||||
with self.assertRaisesRegex(ValueError, "retained release record"):
|
||||
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
|
||||
|
||||
def test_exact_images_and_cross_record_identity_are_required(self):
|
||||
host, persistence = valid_records()
|
||||
host["images"]["api"]["reference"] = "guestops-api:latest"
|
||||
with self.assertRaisesRegex(ValueError, "full approved commit"):
|
||||
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
|
||||
host, persistence = valid_records()
|
||||
persistence["archiveSha256"] = "f" * 64
|
||||
with self.assertRaisesRegex(ValueError, "same archiveSha256"):
|
||||
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
|
||||
|
||||
def test_host_capacity_ports_and_disabled_controls_are_required(self):
|
||||
host, persistence = valid_records()
|
||||
host["hostFacts"]["publicTcpPorts"] = [80, 443, 8080]
|
||||
with self.assertRaisesRegex(ValueError, "Only TCP ports"):
|
||||
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
|
||||
host, persistence = valid_records()
|
||||
host["featureControls"]["googleSending"] = "enabled"
|
||||
with self.assertRaisesRegex(ValueError, "must remain disabled"):
|
||||
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
|
||||
|
||||
def test_independent_review_scenarios_and_findings_are_required(self):
|
||||
host, persistence = valid_records()
|
||||
host["reviewedBy"] = host["operator"]
|
||||
with self.assertRaisesRegex(ValueError, "different people"):
|
||||
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
|
||||
host, persistence = valid_records()
|
||||
persistence["scenarios"][0]["status"] = "not-run"
|
||||
with self.assertRaisesRegex(ValueError, "has not passed"):
|
||||
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
|
||||
host, persistence = valid_records()
|
||||
host["unresolvedCriticalFindings"] = 1
|
||||
with self.assertRaisesRegex(ValueError, "critical findings"):
|
||||
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
75
tests/test_monitor_status.py
Normal file
75
tests/test_monitor_status.py
Normal file
@ -0,0 +1,75 @@
|
||||
import datetime as dt
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"monitor_status", Path(__file__).resolve().parents[1] / "deploy" / "monitor_status.py")
|
||||
monitor = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(monitor)
|
||||
|
||||
|
||||
class MonitorStatusTests(unittest.TestCase):
|
||||
def test_compose_json_is_reduced_to_safe_state(self):
|
||||
value = json.dumps([
|
||||
{"Service": "api", "State": "running", "Health": "healthy", "Publishers": "secret"},
|
||||
{"Service": "worker", "State": "running", "Health": ""},
|
||||
{"Service": "mongo", "State": "running", "Health": "healthy"},
|
||||
]).encode()
|
||||
self.assertEqual(monitor.parse_compose(value)["api"], {"state": "running", "health": "healthy"})
|
||||
self.assertNotIn("Publishers", monitor.parse_compose(value)["api"])
|
||||
|
||||
def test_age_ignores_symlinks_and_unexpected_files(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
directory = Path(folder)
|
||||
backup = directory / "guestops-20261001T021700Z.tar.gpg"
|
||||
backup.write_bytes(b"fixture")
|
||||
moment = dt.datetime(2026, 10, 1, 3, 17, tzinfo=dt.timezone.utc)
|
||||
os.utime(backup, (moment.timestamp() - 3600, moment.timestamp() - 3600))
|
||||
(directory / "secret.txt").write_text("ignored", encoding="utf-8")
|
||||
self.assertEqual(monitor.age_seconds(directory, monitor.BACKUP_NAME, moment), 3600)
|
||||
|
||||
def test_build_status_contains_only_aggregate_health(self):
|
||||
now = dt.datetime(2026, 10, 1, 12, tzinfo=dt.timezone.utc)
|
||||
compose = json.dumps([
|
||||
{"Service": name, "State": "running", "Health": "healthy"}
|
||||
for name in ("api", "worker", "mongo")
|
||||
]).encode()
|
||||
usage = type("Usage", (), {"total": 1000, "used": 500, "free": 500})()
|
||||
with tempfile.TemporaryDirectory() as folder, \
|
||||
patch.object(monitor, "https_status", return_value={"ready": True, "certificateDaysRemaining": 60}), \
|
||||
patch.object(monitor, "run", return_value=compose), \
|
||||
patch.object(monitor, "worker_heartbeat_age", return_value=30), \
|
||||
patch.object(monitor.shutil, "disk_usage", return_value=usage):
|
||||
status, errors = monitor.build_status(Path(folder), Path(folder), "https://example.invalid", now)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(status["workerHeartbeatAgeSeconds"], 30)
|
||||
self.assertEqual(status["diskFreePercent"], 50)
|
||||
self.assertNotIn("credentials", json.dumps(status).lower())
|
||||
|
||||
def test_failed_probes_write_categories_not_exception_details(self):
|
||||
now = dt.datetime(2026, 10, 1, 12, tzinfo=dt.timezone.utc)
|
||||
usage = type("Usage", (), {"total": 1000, "used": 500, "free": 500})()
|
||||
with tempfile.TemporaryDirectory() as folder, \
|
||||
patch.object(monitor, "https_status", side_effect=RuntimeError("secret value")), \
|
||||
patch.object(monitor, "run", side_effect=RuntimeError("secret value")), \
|
||||
patch.object(monitor, "worker_heartbeat_age", side_effect=RuntimeError("secret value")), \
|
||||
patch.object(monitor.shutil, "disk_usage", return_value=usage):
|
||||
status, errors = monitor.build_status(Path(folder), Path(folder), "https://example.invalid", now)
|
||||
self.assertGreaterEqual(len(errors), 3)
|
||||
self.assertNotIn("secret value", json.dumps(status))
|
||||
|
||||
def test_status_output_is_atomic_json(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / "status.json"
|
||||
monitor.write_status(path, {"schemaVersion": 1, "errors": []})
|
||||
self.assertEqual(json.loads(path.read_text(encoding="utf-8"))["schemaVersion"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
88
tests/test_package_source.py
Normal file
88
tests/test_package_source.py
Normal file
@ -0,0 +1,88 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location("package_source", ROOT / "deploy" / "package_source.py")
|
||||
package_source = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(package_source)
|
||||
|
||||
|
||||
def run(repository: Path, *arguments: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repository), *arguments],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
class PackageSourceTests(unittest.TestCase):
|
||||
def repository(self, root: Path, dotnet_version: str = "1.2.3", web_version: str = "1.2.3") -> tuple[Path, str]:
|
||||
repository = root / "repository"
|
||||
(repository / "web").mkdir(parents=True)
|
||||
run(repository, "init")
|
||||
run(repository, "config", "user.name", "GuestOps Test")
|
||||
run(repository, "config", "user.email", "guestops@example.invalid")
|
||||
run(repository, "config", "core.autocrlf", "false")
|
||||
(repository / "Directory.Build.props").write_text(
|
||||
f"<Project><PropertyGroup><Version>{dotnet_version}</Version></PropertyGroup></Project>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repository / "web" / "package.json").write_text(
|
||||
json.dumps({"version": web_version}) + "\n", encoding="utf-8"
|
||||
)
|
||||
(repository / "application.txt").write_text("committed application\n", encoding="utf-8")
|
||||
run(repository, "add", ".")
|
||||
run(repository, "commit", "-m", "fixture")
|
||||
return repository, run(repository, "rev-parse", "HEAD")
|
||||
|
||||
def test_packages_only_the_selected_commit_and_records_identity(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
repository, commit = self.repository(root)
|
||||
(repository / "application.txt").write_text("uncommitted change\n", encoding="utf-8")
|
||||
|
||||
archive, record_path, record = package_source.build_package(repository, commit, root / "output")
|
||||
|
||||
self.assertEqual(record["commit"], commit)
|
||||
self.assertEqual(record["version"], "1.2.3")
|
||||
self.assertEqual(record["artifact"]["sha256"], hashlib.sha256(archive.read_bytes()).hexdigest())
|
||||
self.assertEqual(json.loads(record_path.read_text(encoding="utf-8")), record)
|
||||
with tarfile.open(archive, "r:gz") as package:
|
||||
member = package.extractfile("GuestOps-1.2.3/application.txt")
|
||||
self.assertIsNotNone(member)
|
||||
self.assertEqual(member.read().decode("utf-8").strip(), "committed application")
|
||||
|
||||
def test_same_commit_produces_identical_package(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
repository, commit = self.repository(root)
|
||||
first, _, _ = package_source.build_package(repository, commit, root / "first")
|
||||
second, _, _ = package_source.build_package(repository, commit, root / "second")
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
|
||||
def test_rejects_mismatched_versions_and_existing_output(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
repository, commit = self.repository(root, web_version="1.2.4")
|
||||
with self.assertRaisesRegex(ValueError, "versions differ"):
|
||||
package_source.build_package(repository, commit, root / "output")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
repository, commit = self.repository(root)
|
||||
package_source.build_package(repository, commit, root / "output")
|
||||
with self.assertRaisesRegex(ValueError, "not overwritten"):
|
||||
package_source.build_package(repository, commit, root / "output")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
class ReleaseRecordTests(unittest.TestCase):
|
||||
def test_writes_versions_checksum_and_immutable_image_ids(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
artifact = Path(directory) / "guestops-images.tar.gz"
|
||||
artifact = Path(directory) / "GuestOps-0.2.1.tar.gz"
|
||||
output = Path(directory) / "release-record.json"
|
||||
artifact.write_bytes(b"reviewed image archive")
|
||||
|
||||
@ -40,7 +40,7 @@ class ReleaseRecordTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
record = json.loads(output.read_text(encoding="utf-8"))
|
||||
self.assertEqual(record["version"], "0.2.0")
|
||||
self.assertEqual(record["version"], "0.2.1")
|
||||
self.assertEqual(record["commit"], "a" * 40)
|
||||
self.assertEqual(record["images"]["api"]["id"], "sha256:api")
|
||||
self.assertEqual(
|
||||
|
||||
87
tests/test_verify_release.py
Normal file
87
tests/test_verify_release.py
Normal file
@ -0,0 +1,87 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location("verify_release", ROOT / "deploy" / "verify_release.py")
|
||||
verify_release = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(verify_release)
|
||||
|
||||
|
||||
COMMIT = "a" * 40
|
||||
API_ID = "sha256:" + "b" * 64
|
||||
WORKER_ID = "sha256:" + "c" * 64
|
||||
|
||||
|
||||
class VerifyReleaseTests(unittest.TestCase):
|
||||
def fixture(self, directory: str):
|
||||
root = Path(directory)
|
||||
archive = root / "GuestOps-0.2.1.tar.gz"
|
||||
record = root / "release-record.json"
|
||||
archive.write_bytes(b"reviewed image archive")
|
||||
release = {
|
||||
"schemaVersion": 1,
|
||||
"version": "0.2.1",
|
||||
"commit": COMMIT,
|
||||
"artifact": {
|
||||
"name": archive.name,
|
||||
"size": archive.stat().st_size,
|
||||
"sha256": hashlib.sha256(archive.read_bytes()).hexdigest(),
|
||||
},
|
||||
"images": {
|
||||
"api": {"reference": f"guestops-api:{COMMIT}", "id": API_ID},
|
||||
"worker": {"reference": f"guestops-worker:{COMMIT}", "id": WORKER_ID},
|
||||
},
|
||||
}
|
||||
record.write_text(json.dumps(release), encoding="utf-8")
|
||||
return archive, record, release
|
||||
|
||||
def test_verifies_candidate_archive_record_and_record_checksum(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
archive, record, _ = self.fixture(directory)
|
||||
result = verify_release.validate(archive, record, COMMIT, "0.2.1")
|
||||
self.assertTrue(result["verified"])
|
||||
self.assertEqual(result["archive"]["sha256"], hashlib.sha256(archive.read_bytes()).hexdigest())
|
||||
self.assertEqual(result["releaseRecord"]["sha256"], hashlib.sha256(record.read_bytes()).hexdigest())
|
||||
self.assertFalse(result["loadedImageIdsVerified"])
|
||||
|
||||
def test_rejects_changed_archive(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
archive, record, _ = self.fixture(directory)
|
||||
archive.write_bytes(b"changed")
|
||||
with self.assertRaisesRegex(ValueError, "size does not match"):
|
||||
verify_release.validate(archive, record, COMMIT, "0.2.1")
|
||||
|
||||
def test_rejects_wrong_commit_version_reference_and_mutable_id(self):
|
||||
cases = [
|
||||
(lambda value: value.update(commit="d" * 40), "commit does not match"),
|
||||
(lambda value: value.update(version="0.3.0"), "version does not match"),
|
||||
(lambda value: value["images"]["api"].update(reference="guestops-api:latest"), "full candidate SHA"),
|
||||
(lambda value: value["images"]["worker"].update(id="worker-image"), "immutable SHA-256"),
|
||||
]
|
||||
for mutate, message in cases:
|
||||
with self.subTest(message=message), tempfile.TemporaryDirectory() as directory:
|
||||
archive, record, release = self.fixture(directory)
|
||||
mutate(release)
|
||||
record.write_text(json.dumps(release), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
verify_release.validate(archive, record, COMMIT, "0.2.1")
|
||||
|
||||
def test_loaded_image_ids_must_match(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
archive, record, _ = self.fixture(directory)
|
||||
with patch.object(verify_release, "loaded_image_id", side_effect=[API_ID, WORKER_ID]):
|
||||
result = verify_release.validate(archive, record, COMMIT, "0.2.1", True)
|
||||
self.assertTrue(result["loadedImageIdsVerified"])
|
||||
with patch.object(verify_release, "loaded_image_id", return_value="sha256:" + "d" * 64):
|
||||
with self.assertRaisesRegex(ValueError, "Loaded api image ID"):
|
||||
verify_release.validate(archive, record, COMMIT, "0.2.1", True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "guestops-web",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "guestops-web",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "19.2.8",
|
||||
|
||||
@ -1 +1 @@
|
||||
{"name":"guestops-web","private":true,"version":"0.2.0","type":"module","scripts":{"dev":"vite --host 127.0.0.1","build":"tsc -b && vite build","check":"tsc -b"},"dependencies":{"react":"19.2.8","react-dom":"19.2.8","lucide-react":"^0.577.0"},"devDependencies":{"@types/react":"^19.2.0","@types/react-dom":"^19.2.0","@vitejs/plugin-react":"6.1.1","typescript":"~5.9.3","vite":"8.2.2"}}
|
||||
{"name":"guestops-web","private":true,"version":"0.2.1","type":"module","scripts":{"dev":"vite --host 127.0.0.1","build":"tsc -b && vite build","check":"tsc -b"},"dependencies":{"react":"19.2.8","react-dom":"19.2.8","lucide-react":"^0.577.0"},"devDependencies":{"@types/react":"^19.2.0","@types/react-dom":"^19.2.0","@vitejs/plugin-react":"6.1.1","typescript":"~5.9.3","vite":"8.2.2"}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user