mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-25 20:47:18 +09:00
Hardening plan §8. A stdlib-only, import-safe, unit-tested Python package that
ports the stabilized bash install logic into testable orchestration. Per the
plan's migration path this is ADDITIVE — install.sh stays canonical and this is
NOT wired into it yet; it is the validated foundation a future thin-bootstrap
install.sh can call.
backend/serverkit_installer/:
- _proc.py — sudo-aware subprocess helpers, mirroring app/utils/system.py
WITHOUT importing app (the bootstrap runs before the venv
exists, so it must not pull in Flask).
- distro.py — OS family mapping (a faithful port of install.sh's
os_family_from, rhel-before-fedora) + container/WSL/systemd
predicates (mirroring scripts/lib/env.sh).
- deps.py — loads scripts/deps/manifest.yaml (its primary consumer) and
resolves base/python/node/docker/pkg-manager per family.
- firewall.py — detect + pure open/close command builders (port of
scripts/lib/firewall.sh).
- service.py — init detect + pure start/stop/enable/reload builders (port of
scripts/lib/init.sh).
- main.py — composes a READ-ONLY install "plan" + `python -m
serverkit_installer plan|detect` CLI. No host mutation.
Tests: backend/tests/test_serverkit_installer.py — 46 pure-logic cases
(family mapping vs os_family_from, real-manifest resolution for all six
families, firewall/service command builders, env predicates, composed plan).
46 passed; py_compile + pyflakes clean. The CLI proves the manifest is genuinely
consumed (Debian -> apt + python3.11 + base packages), not dead config.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
110 lines
3.9 KiB
Python
110 lines
3.9 KiB
Python
"""Installer bootstrap orchestration (additive foundation).
|
|
|
|
The canonical installer is still ``install.sh``. This module composes the
|
|
detection + manifest + firewall + service pieces into a single "plan" — what an
|
|
install *would* do on the detected distro — and prints it. It performs no host
|
|
mutation on its own (the ``plan`` command is read-only), so it is safe to run
|
|
anywhere, including in CI and on a dev box.
|
|
|
|
Usage:
|
|
python -m serverkit_installer plan
|
|
python -m serverkit_installer detect --os-release /etc/os-release
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from typing import Optional
|
|
|
|
from . import deps, distro, firewall, service
|
|
|
|
PANEL_PORTS = ["80/tcp", "443/tcp"]
|
|
BACKEND_SERVICE = "serverkit"
|
|
|
|
|
|
def build_plan(
|
|
*,
|
|
os_release_path: str = "/etc/os-release",
|
|
manifest_path: Optional[str] = None,
|
|
firewall_backend: Optional[str] = None,
|
|
init_system: Optional[str] = None,
|
|
) -> dict:
|
|
"""Return a read-only description of the install actions for this host.
|
|
|
|
Every field is derived without mutating the system. ``firewall_backend`` and
|
|
``init_system`` can be forced (used by tests and dry-run previews).
|
|
"""
|
|
box = distro.detect(os_release_path)
|
|
|
|
plan: dict = {
|
|
"distro": {
|
|
"id": box.id,
|
|
"id_like": box.id_like,
|
|
"pretty_name": box.pretty_name,
|
|
"family": box.family,
|
|
"supported": box.supported,
|
|
},
|
|
"environment": {
|
|
"container": distro.is_container(),
|
|
"wsl": distro.is_wsl(),
|
|
"systemd": distro.has_systemd(),
|
|
},
|
|
}
|
|
|
|
# Packages from the manifest (best-effort: an unknown family or unreadable
|
|
# manifest just yields empty lists rather than blowing up the preview).
|
|
try:
|
|
manifest = deps.load_manifest(manifest_path)
|
|
plan["packages"] = {
|
|
"package_manager": deps.package_manager(manifest, box.family),
|
|
"base": deps.base_packages(manifest, box.family),
|
|
"python": deps.python_spec(manifest, box.family),
|
|
"node": deps.node_spec(manifest, box.family),
|
|
"docker": deps.docker_spec(manifest, box.family),
|
|
}
|
|
except (RuntimeError, OSError) as exc:
|
|
plan["packages"] = {"error": str(exc)}
|
|
|
|
fw_backend = firewall.detect(firewall_backend)
|
|
plan["firewall"] = {
|
|
"backend": fw_backend,
|
|
"open": [" ".join(c) for c in firewall.open_commands(fw_backend, PANEL_PORTS)],
|
|
}
|
|
|
|
init = service.detect(init_system)
|
|
plan["service"] = {
|
|
"init": init,
|
|
"enable": (lambda c: " ".join(c) if c else None)(service.enable_command(init, BACKEND_SERVICE)),
|
|
"start": (lambda c: " ".join(c) if c else None)(service.start_command(init, BACKEND_SERVICE)),
|
|
}
|
|
return plan
|
|
|
|
|
|
def main(argv: Optional[list] = None) -> int:
|
|
parser = argparse.ArgumentParser(prog="serverkit_installer", description=__doc__)
|
|
parser.add_argument("command", nargs="?", default="plan", choices=["plan", "detect"])
|
|
parser.add_argument("--os-release", default="/etc/os-release")
|
|
parser.add_argument("--manifest", default=None)
|
|
parser.add_argument("--firewall", default=None, help="force a firewall backend")
|
|
parser.add_argument("--init", default=None, help="force an init system")
|
|
parser.add_argument("--json", action="store_true", help="emit JSON")
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.command == "detect":
|
|
box = distro.detect(args.os_release)
|
|
if args.json:
|
|
print(json.dumps(box.__dict__))
|
|
else:
|
|
print(f"{box.pretty_name or box.id or 'unknown'} -> family={box.family} "
|
|
f"(supported={box.supported})")
|
|
return 0
|
|
|
|
plan = build_plan(
|
|
os_release_path=args.os_release,
|
|
manifest_path=args.manifest,
|
|
firewall_backend=args.firewall,
|
|
init_system=args.init,
|
|
)
|
|
print(json.dumps(plan, indent=2))
|
|
return 0
|