diff --git a/runner/harness/lifecycle.py b/runner/harness/lifecycle.py index 1be9462..5a82eaf 100644 --- a/runner/harness/lifecycle.py +++ b/runner/harness/lifecycle.py @@ -6,6 +6,7 @@ next run. Callers wrap deploy()/teardown() in try/finally (or a pytest finalizer from __future__ import annotations import datetime +import os import re import ssl import subprocess @@ -63,12 +64,33 @@ def _stack_age_seconds(stack: str) -> float | None: return oldest +def _recipe_extra_env(recipe: str, domain: str) -> dict[str, str]: + """Per-recipe extra .env keys, applied at every deploy (install + upgrade's old_app) so a recipe + with multi-domain / config needs is enrolled with NO shared-harness change (D5/M6.5). A recipe + declares `EXTRA_ENV` in tests//recipe_meta.py as either a dict or a callable + `EXTRA_ENV(domain) -> dict` (callable form lets it derive values from the per-run domain, e.g. + cryptpad's SANDBOX_DOMAIN). Returns {} if none.""" + path = os.path.join(os.path.dirname(__file__), "..", "..", "tests", recipe, "recipe_meta.py") + if not os.path.exists(path): + return {} + ns: dict = {} + with open(path) as fh: + exec(compile(fh.read(), path, "exec"), ns) # noqa: S102 (trusted, in-repo) + ee = ns.get("EXTRA_ENV") + if callable(ee): + ee = ee(domain) + return {str(k): str(v) for k, v in (ee or {}).items()} + + def deploy_app(recipe: str, domain: str, version: str | None = None, secrets: bool = True) -> None: """Create + configure + deploy an app. Forces LETS_ENCRYPT_ENV='' so traefik serves the - wildcard cert via the file provider and NEVER attempts ACME (adversary finding A1).""" + wildcard cert via the file provider and NEVER attempts ACME (adversary finding A1). Applies any + per-recipe EXTRA_ENV (recipe_meta.py) before deploy.""" abra.app_config_remove(domain) # clear any stale .env from a prior crashed run abra.app_new(recipe, domain, version=version, secrets=secrets) abra.env_set(domain, "LETS_ENCRYPT_ENV", "") + for k, v in _recipe_extra_env(recipe, domain).items(): + abra.env_set(domain, k, v) if secrets: abra.secret_generate(domain) abra.deploy(domain) diff --git a/tests/cryptpad/recipe_meta.py b/tests/cryptpad/recipe_meta.py new file mode 100644 index 0000000..bc1099d --- /dev/null +++ b/tests/cryptpad/recipe_meta.py @@ -0,0 +1,15 @@ +# Per-recipe harness config for cryptpad (recipe #3 — stateful, no external DB; data on disk +# volumes). Enrolling needs NO shared-harness change (D5): the SANDBOX_DOMAIN quirk is handled by +# the generic EXTRA_ENV mechanism in lifecycle.deploy_app. +HEALTH_PATH = "/" +HEALTH_OK = (200, 301, 302) +DEPLOY_TIMEOUT = 600 +HTTP_TIMEOUT = 600 + + +def EXTRA_ENV(domain): + """cryptpad needs a SANDBOX_DOMAIN distinct from the main DOMAIN (it serves user content from a + separate origin; the web router routes both). Derive a sibling subdomain under the same wildcard + (covered by the wildcard cert, so no cert work).""" + label, _, rest = domain.partition(".") + return {"SANDBOX_DOMAIN": f"{label}-sb.{rest}"} diff --git a/tests/cryptpad/test_backup.py b/tests/cryptpad/test_backup.py new file mode 100644 index 0000000..41eaf28 --- /dev/null +++ b/tests/cryptpad/test_backup.py @@ -0,0 +1,32 @@ +"""cryptpad — backup/restore stage (D2): write a marker into the backed-up cryptpad_data volume, +backup, mutate, restore, assert the restored state matches the pre-mutation (backed-up) state. + +The cryptpad `app` service is labelled `backupbot.backup=true`, so its volumes (incl. cryptpad_data) +are backed up. Marker is checked via `exec_in_app` (data isn't HTTP-served).""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import lifecycle # noqa: E402 + +MARKER = "/cryptpad/data/ci-marker.txt" + + +def test_backup_mutate_restore(deployed, meta): + domain = deployed + + # 1) establish original state in the backed-up volume, then back it up + lifecycle.exec_in_app(domain, ["sh", "-c", f"echo original > {MARKER}"]) + assert lifecycle.exec_in_app(domain, ["cat", MARKER]).strip() == "original" + lifecycle.backup_app(domain) + + # 2) mutate state (diverge from the backup) + lifecycle.exec_in_app(domain, ["sh", "-c", f"echo mutated > {MARKER}"]) + assert lifecycle.exec_in_app(domain, ["cat", MARKER]).strip() == "mutated" + + # 3) restore -> state returns to the backed-up "original" + lifecycle.restore_app(domain) + lifecycle.wait_healthy(domain, ok_codes=tuple(meta["HEALTH_OK"]), path=meta["HEALTH_PATH"], + deploy_timeout=meta["DEPLOY_TIMEOUT"], http_timeout=meta["HTTP_TIMEOUT"]) + assert lifecycle.exec_in_app(domain, ["cat", MARKER]).strip() == "original", \ + "restore did not return the pre-mutation state" diff --git a/tests/cryptpad/test_install.py b/tests/cryptpad/test_install.py new file mode 100644 index 0000000..41a322d --- /dev/null +++ b/tests/cryptpad/test_install.py @@ -0,0 +1,30 @@ +"""cryptpad — install stage (recipe #3, stateful/no-DB). D2 install + D3 Playwright.""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import lifecycle # noqa: E402 + + +def test_http_reachable(deployed_app): + """cryptpad answers over real HTTPS through the gateway (nginx -> cryptpad app).""" + status = lifecycle.http_get(deployed_app, "/") + assert status in (200, 301, 302), f"expected 2xx/3xx from {deployed_app}, got {status}" + + +def test_playwright_loads_cryptpad(deployed_app): + """A real browser loads the live cryptpad landing page and sees its served app.""" + from playwright.sync_api import sync_playwright + + url = f"https://{deployed_app}/" + with sync_playwright() as p: + browser = p.chromium.launch(args=["--no-sandbox"]) + try: + ctx = browser.new_context(ignore_https_errors=True) + page = ctx.new_page() + resp = page.goto(url, wait_until="load", timeout=60000) + assert resp is not None and resp.status in (200, 304), f"page status {resp and resp.status}" + body = page.content().lower() + assert "cryptpad" in body or " {MARKER}"]) + assert lifecycle.exec_in_app(domain, ["cat", MARKER]).strip() == "upgrade-survives" + + # upgrade previous -> current/$REF + lifecycle.upgrade_app(domain, version=os.environ.get("VERSION") or None) + lifecycle.wait_healthy(domain, ok_codes=tuple(meta["HEALTH_OK"]), path=meta["HEALTH_PATH"], + deploy_timeout=meta["DEPLOY_TIMEOUT"], http_timeout=meta["HTTP_TIMEOUT"]) + + # app healthy and the data written before the upgrade is still there + assert lifecycle.http_get(domain, "/") in (200, 301, 302) + assert lifecycle.exec_in_app(domain, ["cat", MARKER]).strip() == "upgrade-survives", \ + "data did not survive the upgrade"