Python control-panel package driving the full provisioning flow from a site's site.yaml (docs/05): - provision: ZFS web dataset + ownership, per-site DB + least-priv user, generated .env encrypted to secrets.enc.yaml (SOPS/age), render the profile templates + persist site.yaml, per-customer chrooted SFTP account, docker compose up. - deprovision (gated: data destroyed only with --purge, after a final backup), backup (ZFS snapshot + mariadb-dump), restore (rollback + import), render (preview), list. Design: one command/file runner with a real --dry-run (prints every action, redacts secrets); idempotent steps; Config + Site validation mirroring docs/03; passwords never logged. Modules: cli, config, naming, context, render, runner, zfs, database, secrets, sftp, compose, provision, backup. Plus pyproject (heleosctl entry point), config.example.yaml, an example site, and a README. Tests: 22 pure-logic unit tests (naming, config validation, template render across all profiles + db on/off) — all passing. Full provision and deprovision verified end-to-end in --dry-run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
66 lines
2.6 KiB
Python
66 lines
2.6 KiB
Python
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from heleos import context, render
|
|
from heleos.config import Config, Site
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
TEMPLATES = REPO_ROOT / "site-templates" / "profiles"
|
|
|
|
|
|
def _cfg():
|
|
return Config(templates_dir=TEMPLATES, registry="reg.example.com/heleos")
|
|
|
|
|
|
def test_context_resolves_images_and_paths():
|
|
cfg = _cfg()
|
|
s = Site.from_dict({"customer": "acme", "site": "shop", "profile": "custom-php",
|
|
"domains": ["shop.acme.com"], "database": True})
|
|
ctx = context.build(cfg, s)
|
|
assert ctx["slug"] == "acme-shop"
|
|
assert ctx["slug_underscored"] == "acme_shop"
|
|
assert ctx["webroot"] == "/tank/customers/acme/shop/web"
|
|
assert ctx["php_image"] == "reg.example.com/heleos/php-fpm:8.3"
|
|
assert ctx["nginx_image"] == "reg.example.com/heleos/nginx:latest"
|
|
|
|
|
|
def test_custom_php_renders_valid_compose_with_db():
|
|
cfg = _cfg()
|
|
s = Site.from_dict({"customer": "acme", "site": "shop", "profile": "custom-php",
|
|
"domains": ["shop.acme.com", "www.shop.acme.com"], "database": True})
|
|
out = render.render(cfg, s)
|
|
doc = yaml.safe_load(out["docker-compose.yml"])
|
|
assert doc["name"] == "acme-shop"
|
|
assert set(doc["services"]) == {"nginx", "fpm"}
|
|
# fpm reaches the DB, so it is on the platform network.
|
|
assert "platform" in doc["services"]["fpm"]["networks"]
|
|
labels = doc["services"]["nginx"]["labels"]
|
|
rule = [l for l in labels if ".rule=" in l][0]
|
|
assert "Host(`shop.acme.com`) || Host(`www.shop.acme.com`)" in rule
|
|
|
|
|
|
def test_custom_php_without_db_has_no_platform_network():
|
|
cfg = _cfg()
|
|
s = Site.from_dict({"customer": "acme", "site": "api", "profile": "custom-php",
|
|
"domains": ["api.acme.com"], "database": False})
|
|
doc = yaml.safe_load(render.render(cfg, s)["docker-compose.yml"])
|
|
assert doc["services"]["fpm"]["networks"] == ["site"]
|
|
assert "platform" not in doc.get("networks", {})
|
|
|
|
|
|
def test_static_profile_is_nginx_only():
|
|
cfg = _cfg()
|
|
s = Site.from_dict({"customer": "acme", "site": "www", "profile": "static",
|
|
"domains": ["acme.com"]})
|
|
doc = yaml.safe_load(render.render(cfg, s)["docker-compose.yml"])
|
|
assert list(doc["services"]) == ["nginx"]
|
|
|
|
|
|
def test_redirect_profile_renders_redirect_conf():
|
|
cfg = _cfg()
|
|
s = Site.from_dict({"customer": "acme", "site": "old", "profile": "redirect",
|
|
"domains": ["old.acme.com"], "redirect_to": "https://acme.com",
|
|
"redirect_code": 302})
|
|
out = render.render(cfg, s)
|
|
assert "return 302 https://acme.com$request_uri;" in out["nginx-redirect.conf"]
|