M6.5: enroll cryptpad (recipe #3, stateful/no-DB) + generic per-recipe EXTRA_ENV
All checks were successful
continuous-integration/drone/push Build is passing

Adds a shared-harness EXTRA_ENV mechanism (recipe_meta.py dict or domain-callable),
applied in deploy_app at every deploy path — no per-recipe harness surgery (D5).
cryptpad uses it for its required distinct SANDBOX_DOMAIN. Tests assert data
survival via a marker file in the backed-up cryptpad_data volume (exec_in_app,
since cryptpad data isn't HTTP-served).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 04:41:44 +01:00
parent 2ade2914c1
commit ebb4c0cbca
5 changed files with 144 additions and 1 deletions

View File

@ -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>/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)

View File

@ -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}"}

View File

@ -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"

View File

@ -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 "<html" in body, "no cryptpad content served"
finally:
browser.close()

View File

@ -0,0 +1,44 @@
"""cryptpad — upgrade stage (D2): deploy the previous published version, write a data marker into a
persistent volume, upgrade to current/$REF, assert the app stays healthy and the data survives.
cryptpad data isn't HTTP-served as a static file (it's an encrypted datastore), so the marker is
written into the cryptpad_data volume and read back via `exec_in_app` (docker exec), not HTTP."""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import lifecycle # noqa: E402
MARKER = "/cryptpad/data/ci-marker.txt"
@pytest.fixture
def old_app(recipe, app_domain, meta, request):
prev = lifecycle.previous_version(recipe)
if not prev:
pytest.skip(f"{recipe}: no previous published version to upgrade from")
lifecycle.janitor()
request.addfinalizer(lambda: lifecycle.teardown_app(app_domain))
lifecycle.deploy_app(recipe, app_domain, version=prev)
lifecycle.wait_healthy(app_domain, ok_codes=tuple(meta["HEALTH_OK"]), path=meta["HEALTH_PATH"],
deploy_timeout=meta["DEPLOY_TIMEOUT"], http_timeout=meta["HTTP_TIMEOUT"])
return app_domain, prev
def test_upgrade_preserves_data(old_app, meta):
domain, prev = old_app
# write a data marker into the persistent cryptpad_data volume
lifecycle.exec_in_app(domain, ["sh", "-c", f"echo upgrade-survives > {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"