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
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:
@ -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)
|
||||
|
||||
15
tests/cryptpad/recipe_meta.py
Normal file
15
tests/cryptpad/recipe_meta.py
Normal 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}"}
|
||||
32
tests/cryptpad/test_backup.py
Normal file
32
tests/cryptpad/test_backup.py
Normal 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"
|
||||
30
tests/cryptpad/test_install.py
Normal file
30
tests/cryptpad/test_install.py
Normal 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()
|
||||
44
tests/cryptpad/test_upgrade.py
Normal file
44
tests/cryptpad/test_upgrade.py
Normal 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"
|
||||
Reference in New Issue
Block a user