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>
84 lines
3.1 KiB
Python
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
|