This commit is contained in:
parent
92f875621d
commit
2e8cf269fb
@ -42,7 +42,7 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i
|
||||
| 15 | Knowledge, AI, and FAQ activation | B | In progress | Owners can run a bounded no-send batch evaluation against current FAQ rules and approved knowledge, with false-positive/negative results and documented zero-error activation thresholds and stop conditions. Curate hotel-specific cases, evaluate AI suggestions separately, train staff, name monitoring/rollback owners, and retain staged-activation evidence. |
|
||||
| 16 | Identity, preferences, and privacy | B/C | In progress | Login throttling now uses the client address only after one-hop processing from the explicitly trusted reverse proxy. Finish privacy/retention decisions, preference coverage, identity acceptance and audit review. |
|
||||
| 17 | Inbox usability and desktop parity | B | In progress | The inbox now uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during navigation, conversation selection, filtering and search changes. Inbox, activity, mailbox-health and FAQ-history timestamps use the saved hotel timezone; finish the remaining secondary screens and agreed desktop-parity acceptance. |
|
||||
| 18 | Pilot, capacity, and release approval | B/C | Planned | Run the supervised pilot, exercise support and incident procedures, validate capacity, resolve pilot findings, and capture explicit go/no-go approval for wider rollout. |
|
||||
| 18 | Pilot, capacity, and release approval | B/C | In progress | A bounded read-only sandbox capacity probe and release-bound pilot exit checklist are implemented. Agree targets, run the probe with host monitoring, complete the supervised pilot and incident exercises, resolve or explicitly contain findings, and retain separate hotel-owner and technical go/no-go decisions. |
|
||||
|
||||
## Delivery sequence
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ The reviewed candidate is now promoted into the local `main` history. It is not
|
||||
- FAQ automation controls, team invitations, password recovery, and stronger Google connection recovery.
|
||||
- No-send FAQ batch evaluation with false-positive and false-negative reporting before activation.
|
||||
- Stable tenant-scoped inbox pagination beyond the former 500-message view, stronger unsaved-draft navigation guards, and hotel-timezone inbox timestamps.
|
||||
- A bounded read-only sandbox capacity probe and release-bound supervised-pilot approval checklist.
|
||||
- Backup, restore, opt-in systemd scheduling, deployment, persistence-drill, diagnostic, release-evidence, Google acceptance-record validation and rollback tooling.
|
||||
|
||||
These capabilities still require their separately documented provider, host and operational acceptance. Google, PMS and payment-provider acceptance is not established by local automated tests.
|
||||
|
||||
128
deploy/capacity_probe.py
Normal file
128
deploy/capacity_probe.py
Normal file
@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a bounded, read-only capacity probe against an approved GuestOps sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import datetime as dt
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import statistics
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
PATHS = ("/health/ready", "/api/hotel", "/api/conversations/page")
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def percentile(values: list[float], fraction: float) -> float:
|
||||
ordered = sorted(values)
|
||||
position = max(0, min(len(ordered) - 1, int(len(ordered) * fraction + 0.999999) - 1))
|
||||
return ordered[position]
|
||||
|
||||
|
||||
def summarize(results: list[tuple[bool, float]], concurrency: int) -> dict[str, object]:
|
||||
require(len(results) > 0, "At least one probe result is required.")
|
||||
latencies = [latency for _, latency in results]
|
||||
successes = sum(1 for success, _ in results if success)
|
||||
return {
|
||||
"concurrency": concurrency,
|
||||
"requests": len(results),
|
||||
"successes": successes,
|
||||
"failures": len(results) - successes,
|
||||
"errorRate": round((len(results) - successes) / len(results), 6),
|
||||
"latencyMs": {
|
||||
"median": round(statistics.median(latencies), 2),
|
||||
"p95": round(percentile(latencies, 0.95), 2),
|
||||
"maximum": round(max(latencies), 2),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def login(origin: str, email: str, password: str) -> str:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
with opener.open(origin + "/api/session", timeout=15) as response:
|
||||
csrf = json.load(response)["csrfToken"]
|
||||
body = json.dumps({"email": email, "password": password}).encode()
|
||||
request = urllib.request.Request(origin + "/api/auth/login", data=body, method="POST", headers={"Content-Type": "application/json", "X-CSRF-TOKEN": csrf})
|
||||
with opener.open(request, timeout=15) as response:
|
||||
require(response.status == 200, "Sandbox login failed.")
|
||||
cookies = "; ".join(f"{cookie.name}={cookie.value}" for cookie in jar)
|
||||
require("guestops.session=" in cookies, "Sandbox did not issue a GuestOps session cookie.")
|
||||
return cookies
|
||||
|
||||
|
||||
def request_once(origin: str, cookie: str, number: int) -> tuple[bool, float]:
|
||||
path = PATHS[number % len(PATHS)]
|
||||
request = urllib.request.Request(origin + path, headers={"Cookie": cookie, "Accept": "application/json"})
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
success = response.status == 200
|
||||
response.read(1024) # Bound local processing; never retain response or guest content.
|
||||
except (OSError, urllib.error.HTTPError):
|
||||
success = False
|
||||
return success, (time.perf_counter() - started) * 1000
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--origin", required=True)
|
||||
parser.add_argument("--requests", type=int, default=100)
|
||||
parser.add_argument("--concurrency", type=int, default=5)
|
||||
parser.add_argument("--release-commit", required=True)
|
||||
parser.add_argument("--release-record-sha256", required=True)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--confirm-sandbox", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
parsed = urllib.parse.urlparse(args.origin)
|
||||
require(args.confirm_sandbox, "Use --confirm-sandbox after confirming the target and maintenance window.")
|
||||
require(parsed.scheme == "https" and parsed.hostname and parsed.path in ("", "/") and not parsed.query and not parsed.fragment and not parsed.username,
|
||||
"Origin must be an HTTPS origin without credentials, path, query or fragment.")
|
||||
require(parsed.hostname != "localhost" and not parsed.hostname.startswith("127."), "Use the deployed HTTPS sandbox, not a development server.")
|
||||
require(1 <= args.concurrency <= 20 and 1 <= args.requests <= 2000, "Probe bounds are 1–20 concurrent workers and 1–2000 requests.")
|
||||
require(re.fullmatch(r"[0-9a-f]{40}", args.release_commit) is not None, "Use a full lowercase release commit SHA.")
|
||||
require(re.fullmatch(r"[0-9a-f]{64}", args.release_record_sha256) is not None, "Use the release-record SHA-256.")
|
||||
require(not args.output.exists() and args.output.parent.is_dir(), "Output must be a new file in an existing restricted directory.")
|
||||
email = os.environ.get("CAPACITY_EMAIL", "")
|
||||
password = os.environ.get("CAPACITY_PASSWORD", "")
|
||||
require(email and password, "Set CAPACITY_EMAIL and CAPACITY_PASSWORD for a dedicated sandbox staff account.")
|
||||
|
||||
cookie = login(args.origin.rstrip("/"), email, password)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as pool:
|
||||
results = list(pool.map(lambda number: request_once(args.origin.rstrip("/"), cookie, number), range(args.requests)))
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"kind": "guestops-read-only-capacity",
|
||||
"recordedAt": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"originHost": parsed.hostname,
|
||||
"releaseCommit": args.release_commit,
|
||||
"releaseRecordSha256": args.release_record_sha256,
|
||||
"paths": list(PATHS),
|
||||
**summarize(results, args.concurrency),
|
||||
}
|
||||
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(report, indent=2, sort_keys=True) + "\n")
|
||||
print(f"Capacity probe completed: {report['successes']}/{report['requests']} successful; p95 {report['latencyMs']['p95']} ms. Review against the approved target before release.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Capacity probe stopped: {error}", file=__import__("sys").stderr)
|
||||
raise SystemExit(1)
|
||||
40
docs/pilot-release.md
Normal file
40
docs/pilot-release.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Supervised pilot, capacity and release approval
|
||||
|
||||
Milestone 18 is an evidence exercise against the exact approved release, not a feature toggle. Use synthetic data for capacity work and a separately approved, tightly supervised hotel cohort for the pilot. Keep provider writes and FAQ live mode disabled until their individual acceptance records are approved.
|
||||
|
||||
## Read-only capacity probe
|
||||
|
||||
The capacity probe logs in once with a dedicated sandbox staff account and sends bounded concurrent GET requests to readiness, hotel settings and cursor-paginated inbox endpoints. It never calls provider integrations, creates records, edits drafts or retains response bodies. Run it only during an approved sandbox window and monitor CPU, memory, MongoDB latency, disk, Nginx and application errors independently.
|
||||
|
||||
```sh
|
||||
read -r -p 'Capacity account email: ' CAPACITY_EMAIL
|
||||
read -r -s -p 'Capacity account password: ' CAPACITY_PASSWORD
|
||||
export CAPACITY_EMAIL CAPACITY_PASSWORD
|
||||
python3 deploy/capacity_probe.py \
|
||||
--origin https://sandbox-guestops.futuresens.co.uk \
|
||||
--requests 500 --concurrency 10 \
|
||||
--release-commit FULL_40_CHARACTER_SHA \
|
||||
--release-record-sha256 RELEASE_RECORD_SHA256 \
|
||||
--output /secure/acceptance/capacity.json \
|
||||
--confirm-sandbox
|
||||
unset CAPACITY_EMAIL CAPACITY_PASSWORD
|
||||
```
|
||||
|
||||
Agree the concurrency, latency, error-rate and resource-headroom targets before running the probe. The generated result reports observations, not a pass/fail claim. Repeat after a warm-up, investigate every error, and retain host metrics with the report. Do not point the probe at a live hotel or increase its built-in bounds to simulate a denial of service.
|
||||
|
||||
## Pilot exit record
|
||||
|
||||
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;
|
||||
- Debian preflight, HTTPS, persistence and controlled reboot;
|
||||
- encrypted off-host backup and timed isolated restore;
|
||||
- Google mailbox and reviewed-send acceptance;
|
||||
- knowledge, AI and FAQ test-mode evaluation;
|
||||
- identity, privacy, retention and audit review;
|
||||
- inbox pagination, draft protection, timezone and desktop-parity acceptance;
|
||||
- capacity targets, observed host headroom and expected pilot workload;
|
||||
- support, rollback, provider-reconciliation and incident exercises;
|
||||
- every pilot usability or security finding.
|
||||
|
||||
Approval requires separate named decisions from the hotel pilot owner and technical release owner. Gate C additionally requires independently accepted PMS and payment-provider evidence. A conditional approval must identify the containment, owner, expiry and rollback trigger; an unresolved finding is not silently converted into acceptance. Retain the signed decision with the release rather than committing guest, credential or incident data to this repository.
|
||||
26
tests/test_capacity_probe.py
Normal file
26
tests/test_capacity_probe.py
Normal file
@ -0,0 +1,26 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("capacity_probe", Path(__file__).resolve().parents[1] / "deploy" / "capacity_probe.py")
|
||||
probe = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(probe)
|
||||
|
||||
|
||||
class CapacityProbeTests(unittest.TestCase):
|
||||
def test_summary_reports_failures_and_nearest_rank_latency(self):
|
||||
report = probe.summarize([(True, 10), (True, 20), (False, 30), (True, 40), (True, 100)], 2)
|
||||
self.assertEqual(report["requests"], 5)
|
||||
self.assertEqual(report["successes"], 4)
|
||||
self.assertEqual(report["failures"], 1)
|
||||
self.assertEqual(report["errorRate"], 0.2)
|
||||
self.assertEqual(report["latencyMs"], {"median": 30, "p95": 100, "maximum": 100})
|
||||
|
||||
def test_summary_rejects_empty_results(self):
|
||||
with self.assertRaisesRegex(ValueError, "At least one"):
|
||||
probe.summarize([], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user