platform/control-panel/src/heleos/database.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

62 lines
2.1 KiB
Python

"""Shared-MariaDB operations: create/drop per-site database + least-priv user.
SQL is piped over stdin to ``docker exec -i mariadb mariadb`` so the root
password never appears in the process list; the command itself is logged redacted.
"""
from __future__ import annotations
import os
from pathlib import Path
from . import naming
from .config import Config, Site
from .errors import CommandError
from .runner import Runner
def root_password(cfg: Config) -> str:
val = os.environ.get("MARIADB_ROOT_PASSWORD")
if val:
return val
p = Path(cfg.mariadb_env_file)
if p.is_file():
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith("MARIADB_ROOT_PASSWORD="):
return line.split("=", 1)[1].strip()
raise CommandError(
"MARIADB_ROOT_PASSWORD not set (env var or mariadb .env at "
f"{cfg.mariadb_env_file})"
)
def _exec_sql(runner: Runner, cfg: Config, sql: str) -> None:
pw = "<redacted>" if runner.dry_run else root_password(cfg)
runner.run(
["docker", "exec", "-i", cfg.mariadb_container, "mariadb", "-uroot", f"-p{pw}"],
input=sql, secret=True,
)
def create_db_user(runner: Runner, cfg: Config, site: Site, password: str) -> None:
db = naming.database_name(site.customer, site.site)
user = naming.database_user(site.customer, site.site)
sql = (
f"CREATE DATABASE IF NOT EXISTS `{db}` "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n"
f"CREATE USER IF NOT EXISTS '{user}'@'%' IDENTIFIED BY '{password}';\n"
f"ALTER USER '{user}'@'%' IDENTIFIED BY '{password}';\n"
f"GRANT ALL PRIVILEGES ON `{db}`.* TO '{user}'@'%';\n"
"FLUSH PRIVILEGES;\n"
)
_exec_sql(runner, cfg, sql)
def drop_db_user(runner: Runner, cfg: Config, site: Site) -> None:
db = naming.database_name(site.customer, site.site)
user = naming.database_user(site.customer, site.site)
sql = (
f"DROP USER IF EXISTS '{user}'@'%';\n"
f"DROP DATABASE IF EXISTS `{db}`;\n"
"FLUSH PRIVILEGES;\n"
)
_exec_sql(runner, cfg, sql)