platform/control-panel/src/heleos/provision.py
Bart Van Geyt fe4cbbe36a Phase 4: provisioning CLI (heleosctl)
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>
2026-07-07 17:18:36 +02:00

84 lines
3.1 KiB
Python

"""Provisioning orchestrator: ties ZFS, DB, secrets, render, SFTP and compose
into the provision / deprovision flows described in docs/05-provisioning-workflow.md.
"""
from __future__ import annotations
from pathlib import Path
import yaml
from . import backup, compose, database, render, secrets, sftp, zfs
from .config import Config, Site
from .runner import Runner
SITE_FIELDS = ("customer", "site", "profile", "domains", "php_version",
"database", "redirect_to", "redirect_code", "resources")
def deployment_dir(cfg: Config, site: Site) -> Path:
return cfg.deployments_dir / site.customer / site.site
def _site_to_dict(site: Site) -> dict:
return {f: getattr(site, f) for f in SITE_FIELDS}
def provision(cfg: Config, site: Site, runner: Runner) -> dict:
"""Provision a site end-to-end. Idempotent where the underlying steps are."""
deploy_dir = deployment_dir(cfg, site)
summary: dict = {"slug": site.slug, "deploy_dir": str(deploy_dir)}
# 1. Storage
zfs.create_web(runner, cfg, site)
sftp.set_web_ownership(runner, cfg, site)
# 2. Database + secrets (only for DB profiles)
if site.database:
db_password = secrets.generate_password()
database.create_db_user(runner, cfg, site, db_password)
secrets.write_env(runner, deploy_dir, {secrets.db_env_key(site): db_password})
secrets.encrypt_env(runner, cfg, deploy_dir)
# 3. Render compose + nginx config + persist site.yaml (source of truth)
for name, content in render.render(cfg, site).items():
runner.write_file(deploy_dir / name, content)
runner.write_file(deploy_dir / "site.yaml",
yaml.safe_dump(_site_to_dict(site), sort_keys=False))
# 4. SFTP (per customer; password only set when the account is first created)
sftp_password = secrets.generate_password()
created = sftp.ensure_customer_account(runner, cfg, site.customer, sftp_password)
summary["sftp_user"] = f"sftp_{site.customer}"
summary["sftp_password"] = sftp_password if created else None
# 5. Deploy
compose.up(runner, deploy_dir)
return summary
def deprovision(cfg: Config, site: Site, runner: Runner, *,
purge: bool = False, final_backup: bool = True) -> dict:
"""Tear down a site. Data is destroyed only with ``purge=True`` and always
after a final backup.
"""
deploy_dir = deployment_dir(cfg, site)
summary: dict = {"slug": site.slug, "purged": purge}
compose.down(runner, deploy_dir)
if final_backup:
summary["final_backup"] = backup.backup_site(runner, cfg, site)
if site.database:
database.drop_db_user(runner, cfg, site)
if purge:
# Destroy the site dataset (its /web child included) but keep the customer
# dataset + SFTP account — other sites may still belong to this customer.
zfs.destroy(runner, f"{cfg.pool}/customers/{site.customer}/{site.site}")
runner.run(["rm", "-rf", str(deploy_dir)])
summary["data_destroyed"] = True
else:
summary["data_destroyed"] = False
summary["note"] = "config/containers removed; data retained. Re-run with purge to delete."
return summary