Integrate Gate B acceptance bundle
This commit is contained in:
parent
158d21ca04
commit
c5c6ec1453
@ -22,13 +22,13 @@ This summary explains what each milestone delivers and where it currently stands
|
||||
| 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.** The verified Ansible handoff now covers commit-bound installation, boot services, Nginx validation, listener restrictions and public HTTPS; privileged installation, firewall review, controlled reboot and supervised persistence evidence remain open. |
|
||||
| 11 | Backups, monitoring, and recovery | Scheduled encrypted backups, verified off-host transfer, Zabbix monitoring, restore, and rollback rehearsal. | **In progress.** The Ansible operations handoff now installs validated systemd units, public-key-only backup support, transfer retry and restricted Zabbix status; secret provisioning, durable-log confirmation, manual backup, timed restore, rollback and independent evidence remain open. |
|
||||
| 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. |
|
||||
| 12 | Google mailbox acceptance | End-to-end Gmail consent, import, recovery, reviewed sending, reconciliation, and revocation evidence. | **In progress.** The runbook, record validator and release-bound Gate B bundle integration 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. |
|
||||
| 15 | Knowledge, AI, and FAQ activation | Supervised knowledge-quality, AI-draft, FAQ test-mode, staff-training, and stop-control acceptance. | **Implemented; acceptance required.** Evaluation tooling and cross-release bundle validation exist; the supervised evaluation, staff training 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. |
|
||||
| 18 | Pilot, capacity, and release approval | Capacity proof, incident exercise, five-business-day hotel pilot, findings closure, and Gate B approval. | **In progress.** The integrated bundle validator now enforces one archive, release record, image set, environment, capacity report, pilot record and approval decision; live prerequisites, incident rehearsal, five-day 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
|
||||
@ -62,13 +62,13 @@ This summary explains what each milestone delivers and where it currently stands
|
||||
| 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 Ansible handoff verifies the source on both controller and host, enables Docker/Nginx at boot, installs and validates the reviewed proxy, rejects exposed API/MongoDB listeners, and requires trusted public HTTPS before selecting the release. Run it on the provisioned Debian host, review the firewall, complete the confirmation-gated persistence drill and controlled reboot, and retain independent evidence. |
|
||||
| 11 | Backups, monitoring, and recovery | A | In progress | Encrypted backup and isolated restore tooling includes opt-in systemd scheduling, checksum-verified rsync transfer, restricted Zabbix status, guarded local retention and a release-bound acceptance validator. The Ansible operations playbook now verifies the selected release and private-file modes, imports only the recovery public key, validates and installs the units, enables transfer/monitoring, leaves backup scheduling off until manual acceptance, and fetches non-sensitive evidence. Provision secrets and durable logs, configure central alerts/retention, run the manual backup plus timed restore and rollback drills, and retain independent approval. |
|
||||
| 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. |
|
||||
| 12 | Google mailbox and reviewed-reply acceptance | B | In progress | The synthetic-data provider runbook, exact scenario set and restricted-record validator are implemented and wired into the Gate B bundle validator. 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. |
|
||||
| 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. The integrated Gate B bundle rejects release or environment mismatches. 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.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. |
|
||||
| 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 validators plus an integrated bundle check that binds every prerequisite to one release and verifies the retained capacity/pilot checksums. Retain the exact package and Ansible evidence, complete the live Gate A/B exercises, resolve or contain findings, and retain separate hotel-owner and technical approval. Gate C remains dependent on milestones 13 and 14. |
|
||||
|
||||
## Delivery sequence
|
||||
|
||||
|
||||
220
deploy/gate_b_bundle.py
Normal file
220
deploy/gate_b_bundle.py
Normal file
@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the complete release-bound GuestOps Gate B acceptance bundle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
DEPLOY = Path(__file__).resolve().parent
|
||||
if str(DEPLOY) not in sys.path:
|
||||
sys.path.insert(0, str(DEPLOY))
|
||||
|
||||
import automation_acceptance
|
||||
import backup_restore_acceptance
|
||||
import debian_acceptance
|
||||
import desktop_acceptance
|
||||
import google_acceptance
|
||||
import identity_privacy_acceptance
|
||||
import incident_exercise
|
||||
import pilot_approval
|
||||
import pilot_run
|
||||
import verify_release
|
||||
import verify_source_package
|
||||
|
||||
|
||||
VERSION = "0.2.1"
|
||||
SHA256 = re.compile(r"[0-9a-f]{64}")
|
||||
RECORD_KEYS = {
|
||||
"debian-host", "persistence", "backup-restore", "google-mailbox",
|
||||
"automation", "identity-privacy", "inbox-usability", "capacity",
|
||||
"incident-support", "pilot-findings", "pilot-approval",
|
||||
}
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def load_json(path: Path) -> object:
|
||||
require(path.is_file() and not path.is_symlink(), f"Required bundle file is missing or is a symlink: {path.name}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def release_binding(record: object, name: str, commit: str, release_sha: str) -> None:
|
||||
require(isinstance(record, dict), f"{name} must be a JSON object.")
|
||||
require(record.get("releaseCommit") == commit, f"{name} uses a different releaseCommit.")
|
||||
require(record.get("releaseRecordSha256") == release_sha,
|
||||
f"{name} uses a different releaseRecordSha256.")
|
||||
|
||||
|
||||
def environment_origin(record: object, name: str) -> str:
|
||||
require(isinstance(record, dict), f"{name} must be a JSON object.")
|
||||
value = str(record.get("environment", "")).rstrip("/")
|
||||
parsed = urlparse(value)
|
||||
require(parsed.scheme == "https" and parsed.hostname and parsed.path in ("", "/"),
|
||||
f"{name} has no valid HTTPS environment.")
|
||||
return value
|
||||
|
||||
|
||||
def validate_capacity(report: object, commit: str, release_sha: str) -> None:
|
||||
require(isinstance(report, dict), "capacity must be a JSON object.")
|
||||
require(report.get("schemaVersion") == 1 and report.get("kind") == "guestops-read-only-capacity",
|
||||
"capacity has an unsupported schema or kind.")
|
||||
require(report.get("releaseCommit") == commit, "capacity uses a different releaseCommit.")
|
||||
require(report.get("releaseRecordSha256") == release_sha,
|
||||
"capacity uses a different releaseRecordSha256.")
|
||||
require(report.get("paths") == ["/health/ready", "/api/hotel", "/api/conversations/page"],
|
||||
"capacity must use the exact read-only path set.")
|
||||
for field in ("concurrency", "requests", "successes", "failures"):
|
||||
require(isinstance(report.get(field), int) and not isinstance(report.get(field), bool),
|
||||
f"capacity.{field} must be an integer.")
|
||||
require(1 <= report["concurrency"] <= 20 and report["requests"] > 0,
|
||||
"capacity has invalid concurrency or request count.")
|
||||
require(report["successes"] + report["failures"] == report["requests"],
|
||||
"capacity success and failure counts do not match requests.")
|
||||
expected_error = round(report["failures"] / report["requests"], 6)
|
||||
require(report.get("errorRate") == expected_error, "capacity.errorRate does not match its counts.")
|
||||
latency = report.get("latencyMs")
|
||||
require(isinstance(latency, dict) and set(latency) == {"median", "p95", "maximum"}
|
||||
and all(isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0
|
||||
for value in latency.values()), "capacity.latencyMs is invalid.")
|
||||
require(latency["median"] <= latency["p95"] <= latency["maximum"],
|
||||
"capacity latency percentiles are out of order.")
|
||||
|
||||
|
||||
def validate_bundle(
|
||||
source_archive: Path,
|
||||
source_record_path: Path,
|
||||
release_record_path: Path,
|
||||
records: dict[str, tuple[Path, object]],
|
||||
host_metrics_path: Path,
|
||||
) -> dict[str, object]:
|
||||
require(set(records) == RECORD_KEYS, "Gate B bundle requires the exact record set.")
|
||||
release_record = load_json(release_record_path)
|
||||
require(isinstance(release_record, dict), "release-record must be a JSON object.")
|
||||
commit = str(release_record.get("commit", ""))
|
||||
version = str(release_record.get("version", ""))
|
||||
require(version == VERSION, f"Gate B bundle version must be {VERSION}.")
|
||||
release_result = verify_release.validate(source_archive, release_record_path, commit, version)
|
||||
source_result = verify_source_package.validate(source_archive, source_record_path, commit, version)
|
||||
release_sha = str(release_result["releaseRecord"]["sha256"])
|
||||
require(source_result["artifact"]["sha256"] == release_result["archive"]["sha256"],
|
||||
"Source and release records identify different archives.")
|
||||
|
||||
values = {name: value for name, (_, value) in records.items()}
|
||||
for name, record in values.items():
|
||||
release_binding(record, name, commit, release_sha)
|
||||
|
||||
debian_acceptance.validate_pair(values["debian-host"], values["persistence"], commit, release_sha)
|
||||
backup_restore_acceptance.validate(values["backup-restore"], commit, release_sha)
|
||||
google_acceptance.validate(values["google-mailbox"])
|
||||
automation_acceptance.validate(values["automation"])
|
||||
identity_privacy_acceptance.validate(values["identity-privacy"])
|
||||
desktop_acceptance.validate(values["inbox-usability"])
|
||||
validate_capacity(values["capacity"], commit, release_sha)
|
||||
incident_exercise.validate(values["incident-support"])
|
||||
pilot_run.validate(values["pilot-findings"])
|
||||
pilot_approval.validate(values["pilot-approval"])
|
||||
|
||||
environment_names = {
|
||||
"debian-host", "persistence", "backup-restore", "google-mailbox",
|
||||
"automation", "identity-privacy", "inbox-usability", "incident-support",
|
||||
"pilot-findings",
|
||||
}
|
||||
origins = {environment_origin(values[name], name) for name in environment_names}
|
||||
require(len(origins) == 1, "Gate B records use different environments.")
|
||||
origin = next(iter(origins))
|
||||
require(values["capacity"].get("originHost") == urlparse(origin).hostname,
|
||||
"capacity originHost does not match the accepted environment.")
|
||||
|
||||
archive_sha = str(release_result["archive"]["sha256"])
|
||||
for name in ("debian-host", "persistence", "backup-restore"):
|
||||
require(values[name].get("archiveSha256") == archive_sha,
|
||||
f"{name} uses a different archiveSha256.")
|
||||
approved_images = release_record["images"]
|
||||
for name in ("debian-host", "persistence", "backup-restore"):
|
||||
images = values[name].get("images", {})
|
||||
require(images.get("api") == approved_images["api"] and images.get("worker") == approved_images["worker"],
|
||||
f"{name} uses different API or worker image identities.")
|
||||
|
||||
approval_capacity = values["pilot-approval"]["capacity"]
|
||||
capacity = values["capacity"]
|
||||
require(approval_capacity["reportSha256"] == digest(records["capacity"][0]),
|
||||
"Pilot approval capacity report checksum does not match the retained report.")
|
||||
require(approval_capacity["hostMetricsSha256"] == digest(host_metrics_path),
|
||||
"Pilot approval host metrics checksum does not match the retained file.")
|
||||
require(approval_capacity["observedConcurrency"] == capacity["concurrency"],
|
||||
"Pilot approval concurrency does not match the capacity report.")
|
||||
require(approval_capacity["observedP95Ms"] == capacity["latencyMs"]["p95"],
|
||||
"Pilot approval p95 does not match the capacity report.")
|
||||
require(approval_capacity["observedErrorRate"] == capacity["errorRate"],
|
||||
"Pilot approval error rate does not match the capacity report.")
|
||||
require(values["pilot-approval"]["pilot"]["recordSha256"] == digest(records["pilot-findings"][0]),
|
||||
"Pilot approval checksum does not match the retained pilot run record.")
|
||||
|
||||
summary_records = {name: digest(path) for name, (path, _) in sorted(records.items())}
|
||||
return {
|
||||
"archiveSha256": archive_sha,
|
||||
"environmentHost": urlparse(origin).hostname,
|
||||
"releaseCommit": commit,
|
||||
"releaseRecordSha256": release_sha,
|
||||
"records": summary_records,
|
||||
"schemaVersion": 1,
|
||||
"sourceRecordSha256": source_result["sourceRecord"]["sha256"],
|
||||
"validated": True,
|
||||
"version": version,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source-archive", required=True, type=Path)
|
||||
parser.add_argument("--source-record", required=True, type=Path)
|
||||
parser.add_argument("--release-record", required=True, type=Path)
|
||||
parser.add_argument("--host-metrics", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
for name in sorted(RECORD_KEYS):
|
||||
parser.add_argument("--" + name, required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
require(args.host_metrics.is_file() and not args.host_metrics.is_symlink(),
|
||||
"Host metrics file is missing or is a symlink.")
|
||||
require(not args.output.exists() and args.output.parent.is_dir(),
|
||||
"Output must be a new file in an existing restricted directory.")
|
||||
records = {}
|
||||
for name in RECORD_KEYS:
|
||||
path = getattr(args, name.replace("-", "_"))
|
||||
records[name] = (path, load_json(path))
|
||||
summary = validate_bundle(
|
||||
args.source_archive,
|
||||
args.source_record,
|
||||
args.release_record,
|
||||
records,
|
||||
args.host_metrics,
|
||||
)
|
||||
descriptor = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
||||
output.write(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
||||
print("Gate B bundle is structurally complete, consistently release-bound and approved. "
|
||||
"This validates records and checksums, not the underlying restricted evidence.")
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Gate B bundle rejected: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -62,6 +62,32 @@ python3 deploy/pilot_approval.py /secure/acceptance/pilot-approval.json
|
||||
|
||||
The validator requires the exact Gate B evidence set, or that set plus independently accepted PMS and payment-provider evidence for Gate C. It also verifies that observed concurrency meets the pre-agreed target and that p95 latency and error rate remain within their pre-agreed bounds. Structural validation does not inspect evidence or authorize rollout by itself.
|
||||
|
||||
## Integrated Gate B bundle validation
|
||||
|
||||
After every individual record passes independent review, validate the complete bundle together. This prevents records from different commits, release records, archives, image builds or sandbox environments from being combined into one approval. It also binds the final decision to the retained capacity report, host metrics and five-day pilot record by checksum.
|
||||
|
||||
```sh
|
||||
python3 deploy/gate_b_bundle.py \
|
||||
--source-archive /secure/releases/GuestOps-0.2.1-COMMIT.tar.gz \
|
||||
--source-record /secure/releases/GuestOps-0.2.1-COMMIT.source.json \
|
||||
--release-record /secure/releases/release-record.json \
|
||||
--host-metrics /secure/acceptance/capacity-host-metrics.json \
|
||||
--debian-host /secure/acceptance/debian-host.json \
|
||||
--persistence /secure/acceptance/persistence.json \
|
||||
--backup-restore /secure/acceptance/backup-restore.json \
|
||||
--google-mailbox /secure/acceptance/google-mailbox.json \
|
||||
--automation /secure/acceptance/automation.json \
|
||||
--identity-privacy /secure/acceptance/identity-privacy.json \
|
||||
--inbox-usability /secure/acceptance/inbox-usability.json \
|
||||
--capacity /secure/acceptance/capacity.json \
|
||||
--incident-support /secure/acceptance/incident-support.json \
|
||||
--pilot-findings /secure/acceptance/pilot-run.json \
|
||||
--pilot-approval /secure/acceptance/pilot-approval.json \
|
||||
--output /secure/acceptance/gate-b-bundle-summary.json
|
||||
```
|
||||
|
||||
The output contains only release identity and file checksums, uses mode `0600`, and refuses to overwrite an existing summary. Retain it with the individual validator outputs. A passing bundle proves structural consistency, not that screenshots, provider activity, approvals, findings or other referenced evidence are genuine.
|
||||
|
||||
Complete the [incident and rollback exercise](incident-exercise.md) before marking `incident-support` as passed. Its record must use the same release identifiers and target gate as this decision. Reference the retained exercise record and validator output; do not substitute a local automated-test result for the supervised exercise.
|
||||
|
||||
Complete the [desktop-parity acceptance exercise](desktop-acceptance.md) before marking `inbox-usability` as passed. Bind it to the same release identifiers and retain its independently reviewed record outside the repository.
|
||||
|
||||
141
tests/test_gate_b_bundle.py
Normal file
141
tests/test_gate_b_bundle.py
Normal file
@ -0,0 +1,141 @@
|
||||
import contextlib
|
||||
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("gate_b_bundle", ROOT / "deploy" / "gate_b_bundle.py")
|
||||
bundle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bundle)
|
||||
|
||||
|
||||
COMMIT = "a" * 40
|
||||
API = {"reference": f"guestops-api:{COMMIT}", "id": "sha256:" + "b" * 64}
|
||||
WORKER = {"reference": f"guestops-worker:{COMMIT}", "id": "sha256:" + "c" * 64}
|
||||
ORIGIN = "https://sandbox-guestops.futuresens.co.uk"
|
||||
|
||||
|
||||
class GateBBundleTests(unittest.TestCase):
|
||||
def fixture(self, directory: str):
|
||||
root = Path(directory)
|
||||
archive = root / "GuestOps-0.2.1-aaaaaaaaaaaa.tar.gz"
|
||||
source = root / "GuestOps-0.2.1-aaaaaaaaaaaa.source.json"
|
||||
release = root / "release-record.json"
|
||||
metrics = root / "host-metrics.json"
|
||||
archive.write_bytes(b"approved source")
|
||||
artifact = {
|
||||
"name": archive.name,
|
||||
"size": archive.stat().st_size,
|
||||
"sha256": hashlib.sha256(archive.read_bytes()).hexdigest(),
|
||||
}
|
||||
source.write_text(json.dumps({"schemaVersion": 1, "version": "0.2.1", "commit": COMMIT, "artifact": artifact}), encoding="utf-8")
|
||||
release.write_text(json.dumps({
|
||||
"schemaVersion": 1, "version": "0.2.1", "commit": COMMIT,
|
||||
"artifact": artifact, "images": {"api": API, "worker": WORKER},
|
||||
}), encoding="utf-8")
|
||||
release_sha = hashlib.sha256(release.read_bytes()).hexdigest()
|
||||
metrics.write_bytes(b'{"cpu":40,"memory":35}')
|
||||
|
||||
values = {}
|
||||
paths = {}
|
||||
for name in bundle.RECORD_KEYS:
|
||||
value = {"releaseCommit": COMMIT, "releaseRecordSha256": release_sha}
|
||||
if name in {
|
||||
"debian-host", "persistence", "backup-restore", "google-mailbox",
|
||||
"automation", "identity-privacy", "inbox-usability", "incident-support",
|
||||
"pilot-findings",
|
||||
}:
|
||||
value["environment"] = ORIGIN
|
||||
if name in {"debian-host", "persistence", "backup-restore"}:
|
||||
value["archiveSha256"] = artifact["sha256"]
|
||||
value["images"] = {"api": API, "worker": WORKER}
|
||||
values[name] = value
|
||||
|
||||
values["capacity"].update({
|
||||
"schemaVersion": 1, "kind": "guestops-read-only-capacity",
|
||||
"originHost": "sandbox-guestops.futuresens.co.uk",
|
||||
"paths": ["/health/ready", "/api/hotel", "/api/conversations/page"],
|
||||
"concurrency": 10, "requests": 100, "successes": 100, "failures": 0,
|
||||
"errorRate": 0.0, "latencyMs": {"median": 100, "p95": 250, "maximum": 300},
|
||||
})
|
||||
pilot_path = root / "pilot-findings.json"
|
||||
pilot_path.write_text(json.dumps(values["pilot-findings"]), encoding="utf-8")
|
||||
paths["pilot-findings"] = pilot_path
|
||||
capacity_path = root / "capacity.json"
|
||||
capacity_path.write_text(json.dumps(values["capacity"]), encoding="utf-8")
|
||||
paths["capacity"] = capacity_path
|
||||
values["pilot-approval"].update({
|
||||
"capacity": {
|
||||
"reportSha256": hashlib.sha256(capacity_path.read_bytes()).hexdigest(),
|
||||
"hostMetricsSha256": hashlib.sha256(metrics.read_bytes()).hexdigest(),
|
||||
"observedConcurrency": 10, "observedP95Ms": 250, "observedErrorRate": 0.0,
|
||||
},
|
||||
"pilot": {"recordSha256": hashlib.sha256(pilot_path.read_bytes()).hexdigest()},
|
||||
})
|
||||
for name in bundle.RECORD_KEYS - paths.keys():
|
||||
path = root / f"{name}.json"
|
||||
path.write_text(json.dumps(values[name]), encoding="utf-8")
|
||||
paths[name] = path
|
||||
records = {name: (paths[name], values[name]) for name in bundle.RECORD_KEYS}
|
||||
return archive, source, release, metrics, records
|
||||
|
||||
@contextlib.contextmanager
|
||||
def mocked_individual_validators(self):
|
||||
patches = [
|
||||
patch.object(bundle.debian_acceptance, "validate_pair"),
|
||||
patch.object(bundle.backup_restore_acceptance, "validate"),
|
||||
patch.object(bundle.google_acceptance, "validate"),
|
||||
patch.object(bundle.automation_acceptance, "validate"),
|
||||
patch.object(bundle.identity_privacy_acceptance, "validate"),
|
||||
patch.object(bundle.desktop_acceptance, "validate"),
|
||||
patch.object(bundle.incident_exercise, "validate"),
|
||||
patch.object(bundle.pilot_run, "validate"),
|
||||
patch.object(bundle.pilot_approval, "validate"),
|
||||
]
|
||||
with contextlib.ExitStack() as stack:
|
||||
for item in patches:
|
||||
stack.enter_context(item)
|
||||
yield
|
||||
|
||||
def test_complete_bundle_is_bound_to_one_release(self):
|
||||
with tempfile.TemporaryDirectory() as directory, self.mocked_individual_validators():
|
||||
archive, source, release, metrics, records = self.fixture(directory)
|
||||
result = bundle.validate_bundle(archive, source, release, records, metrics)
|
||||
self.assertTrue(result["validated"])
|
||||
self.assertEqual(result["releaseCommit"], COMMIT)
|
||||
self.assertEqual(set(result["records"]), bundle.RECORD_KEYS)
|
||||
|
||||
def test_different_environment_or_checksum_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory, self.mocked_individual_validators():
|
||||
archive, source, release, metrics, records = self.fixture(directory)
|
||||
records["google-mailbox"][1]["environment"] = "https://other.example.invalid"
|
||||
with self.assertRaisesRegex(ValueError, "different environments"):
|
||||
bundle.validate_bundle(archive, source, release, records, metrics)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, self.mocked_individual_validators():
|
||||
archive, source, release, metrics, records = self.fixture(directory)
|
||||
records["pilot-approval"][1]["capacity"]["reportSha256"] = "0" * 64
|
||||
with self.assertRaisesRegex(ValueError, "capacity report checksum"):
|
||||
bundle.validate_bundle(archive, source, release, records, metrics)
|
||||
|
||||
def test_capacity_counts_and_latency_are_checked(self):
|
||||
report = {
|
||||
"schemaVersion": 1, "kind": "guestops-read-only-capacity",
|
||||
"releaseCommit": COMMIT, "releaseRecordSha256": "d" * 64,
|
||||
"paths": ["/health/ready", "/api/hotel", "/api/conversations/page"],
|
||||
"concurrency": 10, "requests": 10, "successes": 9, "failures": 1,
|
||||
"errorRate": 0.1, "latencyMs": {"median": 10, "p95": 20, "maximum": 30},
|
||||
}
|
||||
bundle.validate_capacity(report, COMMIT, "d" * 64)
|
||||
report["failures"] = 2
|
||||
with self.assertRaisesRegex(ValueError, "do not match requests"):
|
||||
bundle.validate_capacity(report, COMMIT, "d" * 64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user