aman/packaging/portable/portable_installer.py
Thales Maciel a3368056ff
Some checks are pending
ci / test-and-build (push) Waiting to run
Ship the portable X11 bundle lifecycle
Implement milestone 2 around a portable X11 release bundle instead of\nkeeping distro packages as the only end-user path.\n\nAdd make/package scripts plus a portable installer helper that builds the\ntarball, creates a user-scoped venv install, manages the user service, handles\nupgrade rollback, and supports uninstall with optional purge.\n\nFlip the end-user docs to the portable bundle, add a dedicated install guide\nand validation matrix, and leave the roadmap milestone open only for the\nremaining manual distro validation evidence.\n\nValidation: python3 -m py_compile src/*.py packaging/portable/portable_installer.py tests/test_portable_bundle.py; PYTHONPATH=src python3 -m unittest tests.test_portable_bundle; PYTHONPATH=src python3 -m unittest tests.test_aman_cli tests.test_diagnostics tests.test_portable_bundle; PYTHONPATH=src python3 -m unittest discover -s tests -p 'test_*.py'
2026-03-12 15:01:26 -03:00

578 lines
20 KiB
Python
Executable file

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import textwrap
import time
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
APP_NAME = "aman"
INSTALL_KIND = "portable"
SERVICE_NAME = "aman"
MANAGED_MARKER = "# managed by aman portable installer"
SUPPORTED_PYTHON_TAGS = ("cp310", "cp311", "cp312")
DEFAULT_ARCHITECTURE = "x86_64"
DEFAULT_SMOKE_CHECK_CODE = textwrap.dedent(
"""
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3, Gtk
import Xlib
import sounddevice
"""
).strip()
DEFAULT_RUNTIME_DEPENDENCY_HINT = (
"Install the documented GTK, AppIndicator, PyGObject, python-xlib, and "
"PortAudio runtime dependencies for your distro, then rerun install.sh."
)
class PortableInstallError(RuntimeError):
pass
@dataclass
class InstallPaths:
home: Path
share_root: Path
current_link: Path
state_path: Path
bin_dir: Path
shim_path: Path
systemd_dir: Path
service_path: Path
config_dir: Path
cache_dir: Path
@classmethod
def detect(cls) -> "InstallPaths":
home = Path.home()
share_root = home / ".local" / "share" / APP_NAME
return cls(
home=home,
share_root=share_root,
current_link=share_root / "current",
state_path=share_root / "install-state.json",
bin_dir=home / ".local" / "bin",
shim_path=home / ".local" / "bin" / APP_NAME,
systemd_dir=home / ".config" / "systemd" / "user",
service_path=home / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service",
config_dir=home / ".config" / APP_NAME,
cache_dir=home / ".cache" / APP_NAME,
)
def as_serializable(self) -> dict[str, str]:
return {
"share_root": str(self.share_root),
"current_link": str(self.current_link),
"state_path": str(self.state_path),
"shim_path": str(self.shim_path),
"service_path": str(self.service_path),
"config_dir": str(self.config_dir),
"cache_dir": str(self.cache_dir),
}
@dataclass
class Manifest:
app_name: str
version: str
architecture: str
supported_python_tags: list[str]
wheelhouse_dirs: list[str]
managed_paths: dict[str, str]
smoke_check_code: str
runtime_dependency_hint: str
bundle_format_version: int = 1
@classmethod
def default(cls, version: str) -> "Manifest":
return cls(
app_name=APP_NAME,
version=version,
architecture=DEFAULT_ARCHITECTURE,
supported_python_tags=list(SUPPORTED_PYTHON_TAGS),
wheelhouse_dirs=[
"wheelhouse/common",
"wheelhouse/cp310",
"wheelhouse/cp311",
"wheelhouse/cp312",
],
managed_paths={
"install_root": "~/.local/share/aman",
"current_link": "~/.local/share/aman/current",
"shim": "~/.local/bin/aman",
"service": "~/.config/systemd/user/aman.service",
"state": "~/.local/share/aman/install-state.json",
},
smoke_check_code=DEFAULT_SMOKE_CHECK_CODE,
runtime_dependency_hint=DEFAULT_RUNTIME_DEPENDENCY_HINT,
)
@dataclass
class InstallState:
app_name: str
install_kind: str
version: str
installed_at: str
service_mode: str
architecture: str
supported_python_tags: list[str]
paths: dict[str, str]
def _portable_tag() -> str:
test_override = os.environ.get("AMAN_PORTABLE_TEST_PYTHON_TAG", "").strip()
if test_override:
return test_override
return f"cp{sys.version_info.major}{sys.version_info.minor}"
def _load_manifest(bundle_dir: Path) -> Manifest:
manifest_path = bundle_dir / "manifest.json"
try:
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise PortableInstallError(f"missing manifest: {manifest_path}") from exc
except json.JSONDecodeError as exc:
raise PortableInstallError(f"invalid manifest JSON: {manifest_path}") from exc
try:
return Manifest(**payload)
except TypeError as exc:
raise PortableInstallError(f"invalid manifest shape: {manifest_path}") from exc
def _load_state(state_path: Path) -> InstallState | None:
if not state_path.exists():
return None
try:
payload = json.loads(state_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise PortableInstallError(f"invalid install state JSON: {state_path}") from exc
try:
return InstallState(**payload)
except TypeError as exc:
raise PortableInstallError(f"invalid install state shape: {state_path}") from exc
def _atomic_write(path: Path, content: str, *, mode: int = 0o644) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.tmp-",
delete=False,
) as handle:
handle.write(content)
tmp_path = Path(handle.name)
os.chmod(tmp_path, mode)
os.replace(tmp_path, path)
def _atomic_symlink(target: Path, link_path: Path) -> None:
link_path.parent.mkdir(parents=True, exist_ok=True)
tmp_link = link_path.parent / f".{link_path.name}.tmp-{os.getpid()}"
try:
if tmp_link.exists() or tmp_link.is_symlink():
tmp_link.unlink()
os.symlink(str(target), tmp_link)
os.replace(tmp_link, link_path)
finally:
if tmp_link.exists() or tmp_link.is_symlink():
tmp_link.unlink()
def _read_text_if_exists(path: Path) -> str | None:
if not path.exists():
return None
return path.read_text(encoding="utf-8")
def _current_target(current_link: Path) -> Path | None:
if current_link.is_symlink():
target = os.readlink(current_link)
target_path = Path(target)
if not target_path.is_absolute():
target_path = current_link.parent / target_path
return target_path
if current_link.exists():
return current_link
return None
def _is_managed_text(content: str | None) -> bool:
return bool(content and MANAGED_MARKER in content)
def _run(
args: list[str],
*,
check: bool = True,
capture_output: bool = False,
) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
args,
check=check,
text=True,
capture_output=capture_output,
)
except subprocess.CalledProcessError as exc:
details = exc.stderr.strip() or exc.stdout.strip() or str(exc)
raise PortableInstallError(details) from exc
def _run_systemctl(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
return _run(["systemctl", "--user", *args], check=check, capture_output=True)
def _supported_tag_or_raise(manifest: Manifest) -> str:
if sys.implementation.name != "cpython":
raise PortableInstallError("portable installer requires CPython 3.10, 3.11, or 3.12")
tag = _portable_tag()
if tag not in manifest.supported_python_tags:
version = f"{sys.version_info.major}.{sys.version_info.minor}"
raise PortableInstallError(
f"unsupported python3 version {version}; supported versions are CPython 3.10, 3.11, and 3.12"
)
return tag
def _check_preflight(manifest: Manifest, paths: InstallPaths) -> InstallState | None:
_supported_tag_or_raise(manifest)
if shutil.which("systemctl") is None:
raise PortableInstallError("systemctl is required for the supported user service lifecycle")
try:
import venv as _venv # noqa: F401
except Exception as exc: # pragma: no cover - import failure is environment dependent
raise PortableInstallError("python3 venv support is required for the portable installer") from exc
state = _load_state(paths.state_path)
if state is not None:
if state.app_name != APP_NAME or state.install_kind != INSTALL_KIND:
raise PortableInstallError(f"unexpected install state in {paths.state_path}")
shim_text = _read_text_if_exists(paths.shim_path)
if shim_text is not None and (state is None or not _is_managed_text(shim_text)):
raise PortableInstallError(
f"refusing to overwrite unmanaged shim at {paths.shim_path}; remove it first"
)
service_text = _read_text_if_exists(paths.service_path)
if service_text is not None and (state is None or not _is_managed_text(service_text)):
raise PortableInstallError(
f"refusing to overwrite unmanaged service file at {paths.service_path}; remove it first"
)
detected_aman = shutil.which(APP_NAME)
if detected_aman:
expected_paths = {str(paths.shim_path)}
current_target = _current_target(paths.current_link)
if current_target is not None:
expected_paths.add(str(current_target / "venv" / "bin" / APP_NAME))
if detected_aman not in expected_paths:
raise PortableInstallError(
"detected another Aman install in PATH at "
f"{detected_aman}; remove that install before using the portable bundle"
)
return state
def _require_bundle_file(path: Path, description: str) -> Path:
if not path.exists():
raise PortableInstallError(f"missing {description}: {path}")
return path
def _aman_wheel(common_wheelhouse: Path) -> Path:
wheels = sorted(common_wheelhouse.glob(f"{APP_NAME}-*.whl"))
if not wheels:
raise PortableInstallError(f"no Aman wheel found in {common_wheelhouse}")
return wheels[-1]
def _render_wrapper(paths: InstallPaths) -> str:
exec_path = paths.current_link / "venv" / "bin" / APP_NAME
return textwrap.dedent(
f"""\
#!/usr/bin/env bash
set -euo pipefail
{MANAGED_MARKER}
exec "{exec_path}" "$@"
"""
)
def _render_service(template_text: str, paths: InstallPaths) -> str:
exec_start = (
f"{paths.current_link / 'venv' / 'bin' / APP_NAME} "
f"run --config {paths.home / '.config' / APP_NAME / 'config.json'}"
)
return template_text.replace("__EXEC_START__", exec_start)
def _write_state(paths: InstallPaths, manifest: Manifest, version_dir: Path) -> None:
state = InstallState(
app_name=APP_NAME,
install_kind=INSTALL_KIND,
version=manifest.version,
installed_at=datetime.now(timezone.utc).isoformat(),
service_mode="systemd-user",
architecture=manifest.architecture,
supported_python_tags=list(manifest.supported_python_tags),
paths={
**paths.as_serializable(),
"version_dir": str(version_dir),
},
)
_atomic_write(paths.state_path, json.dumps(asdict(state), indent=2, sort_keys=True) + "\n")
def _copy_bundle_support_files(bundle_dir: Path, stage_dir: Path) -> None:
for name in ("manifest.json", "install.sh", "uninstall.sh", "portable_installer.py"):
src = _require_bundle_file(bundle_dir / name, name)
dst = stage_dir / name
shutil.copy2(src, dst)
if dst.suffix in {".sh", ".py"}:
os.chmod(dst, 0o755)
src_service_dir = _require_bundle_file(bundle_dir / "systemd", "systemd directory")
dst_service_dir = stage_dir / "systemd"
if dst_service_dir.exists():
shutil.rmtree(dst_service_dir)
shutil.copytree(src_service_dir, dst_service_dir)
def _run_pip_install(bundle_dir: Path, stage_dir: Path, python_tag: str) -> None:
common_dir = _require_bundle_file(bundle_dir / "wheelhouse" / "common", "common wheelhouse")
version_dir = _require_bundle_file(bundle_dir / "wheelhouse" / python_tag, f"{python_tag} wheelhouse")
aman_wheel = _aman_wheel(common_dir)
venv_dir = stage_dir / "venv"
_run([sys.executable, "-m", "venv", "--system-site-packages", str(venv_dir)])
_run(
[
str(venv_dir / "bin" / "python"),
"-m",
"pip",
"install",
"--no-index",
"--find-links",
str(common_dir),
"--find-links",
str(version_dir),
str(aman_wheel),
]
)
def _run_smoke_check(stage_dir: Path, manifest: Manifest) -> None:
venv_python = stage_dir / "venv" / "bin" / "python"
try:
_run([str(venv_python), "-c", manifest.smoke_check_code], capture_output=True)
except PortableInstallError as exc:
raise PortableInstallError(
f"runtime dependency smoke check failed: {exc}\n{manifest.runtime_dependency_hint}"
) from exc
def _remove_path(path: Path) -> None:
if path.is_symlink() or path.is_file():
path.unlink(missing_ok=True)
return
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
def _rollback_install(
*,
paths: InstallPaths,
manifest: Manifest,
old_state_text: str | None,
old_service_text: str | None,
old_shim_text: str | None,
old_current_target: Path | None,
new_version_dir: Path,
backup_dir: Path | None,
) -> None:
_remove_path(new_version_dir)
if backup_dir is not None and backup_dir.exists():
os.replace(backup_dir, new_version_dir)
if old_current_target is not None:
_atomic_symlink(old_current_target, paths.current_link)
else:
_remove_path(paths.current_link)
if old_shim_text is not None:
_atomic_write(paths.shim_path, old_shim_text, mode=0o755)
else:
_remove_path(paths.shim_path)
if old_service_text is not None:
_atomic_write(paths.service_path, old_service_text)
else:
_remove_path(paths.service_path)
if old_state_text is not None:
_atomic_write(paths.state_path, old_state_text)
else:
_remove_path(paths.state_path)
_run_systemctl(["daemon-reload"], check=False)
if old_current_target is not None and old_service_text is not None:
_run_systemctl(["enable", "--now", SERVICE_NAME], check=False)
def _prune_versions(paths: InstallPaths, keep_version: str) -> None:
for entry in paths.share_root.iterdir():
if entry.name in {"current", "install-state.json"}:
continue
if entry.is_dir() and entry.name != keep_version:
shutil.rmtree(entry, ignore_errors=True)
def install_bundle(bundle_dir: Path) -> int:
manifest = _load_manifest(bundle_dir)
paths = InstallPaths.detect()
previous_state = _check_preflight(manifest, paths)
python_tag = _supported_tag_or_raise(manifest)
paths.share_root.mkdir(parents=True, exist_ok=True)
stage_dir = paths.share_root / f".staging-{manifest.version}-{os.getpid()}"
version_dir = paths.share_root / manifest.version
backup_dir: Path | None = None
old_state_text = _read_text_if_exists(paths.state_path)
old_service_text = _read_text_if_exists(paths.service_path)
old_shim_text = _read_text_if_exists(paths.shim_path)
old_current_target = _current_target(paths.current_link)
service_template_path = _require_bundle_file(
bundle_dir / "systemd" / f"{SERVICE_NAME}.service.in",
"service template",
)
service_template = service_template_path.read_text(encoding="utf-8")
cutover_done = False
if previous_state is not None:
_run_systemctl(["stop", SERVICE_NAME], check=False)
_remove_path(stage_dir)
stage_dir.mkdir(parents=True, exist_ok=True)
try:
_run_pip_install(bundle_dir, stage_dir, python_tag)
_copy_bundle_support_files(bundle_dir, stage_dir)
_run_smoke_check(stage_dir, manifest)
if version_dir.exists():
backup_dir = paths.share_root / f".rollback-{manifest.version}-{int(time.time())}"
_remove_path(backup_dir)
os.replace(version_dir, backup_dir)
os.replace(stage_dir, version_dir)
_atomic_symlink(version_dir, paths.current_link)
_atomic_write(paths.shim_path, _render_wrapper(paths), mode=0o755)
_atomic_write(paths.service_path, _render_service(service_template, paths))
_write_state(paths, manifest, version_dir)
cutover_done = True
_run_systemctl(["daemon-reload"])
_run_systemctl(["enable", "--now", SERVICE_NAME])
except Exception:
_remove_path(stage_dir)
if cutover_done or backup_dir is not None:
_rollback_install(
paths=paths,
manifest=manifest,
old_state_text=old_state_text,
old_service_text=old_service_text,
old_shim_text=old_shim_text,
old_current_target=old_current_target,
new_version_dir=version_dir,
backup_dir=backup_dir,
)
else:
_remove_path(stage_dir)
raise
if backup_dir is not None:
_remove_path(backup_dir)
_prune_versions(paths, manifest.version)
print(f"installed {APP_NAME} {manifest.version} in {version_dir}")
return 0
def uninstall_bundle(bundle_dir: Path, *, purge: bool) -> int:
_ = bundle_dir
paths = InstallPaths.detect()
state = _load_state(paths.state_path)
if state is None:
raise PortableInstallError(f"no portable install state found at {paths.state_path}")
if state.app_name != APP_NAME or state.install_kind != INSTALL_KIND:
raise PortableInstallError(f"unexpected install state in {paths.state_path}")
shim_text = _read_text_if_exists(paths.shim_path)
if shim_text is not None and not _is_managed_text(shim_text):
raise PortableInstallError(f"refusing to remove unmanaged shim at {paths.shim_path}")
service_text = _read_text_if_exists(paths.service_path)
if service_text is not None and not _is_managed_text(service_text):
raise PortableInstallError(f"refusing to remove unmanaged service at {paths.service_path}")
_run_systemctl(["disable", "--now", SERVICE_NAME], check=False)
_remove_path(paths.service_path)
_run_systemctl(["daemon-reload"], check=False)
_remove_path(paths.shim_path)
_remove_path(paths.share_root)
if purge:
_remove_path(paths.config_dir)
_remove_path(paths.cache_dir)
print(f"uninstalled {APP_NAME} portable bundle")
return 0
def write_manifest(version: str, output_path: Path) -> int:
manifest = Manifest.default(version)
_atomic_write(output_path, json.dumps(asdict(manifest), indent=2, sort_keys=True) + "\n")
return 0
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Aman portable bundle helper")
subparsers = parser.add_subparsers(dest="command", required=True)
install_parser = subparsers.add_parser("install", help="Install or upgrade the portable bundle")
install_parser.add_argument("--bundle-dir", default=str(Path.cwd()))
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall the portable bundle")
uninstall_parser.add_argument("--bundle-dir", default=str(Path.cwd()))
uninstall_parser.add_argument("--purge", action="store_true", help="Remove config and cache too")
manifest_parser = subparsers.add_parser("write-manifest", help="Write the portable bundle manifest")
manifest_parser.add_argument("--version", required=True)
manifest_parser.add_argument("--output", required=True)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv or sys.argv[1:])
try:
if args.command == "install":
return install_bundle(Path(args.bundle_dir).resolve())
if args.command == "uninstall":
return uninstall_bundle(Path(args.bundle_dir).resolve(), purge=args.purge)
if args.command == "write-manifest":
return write_manifest(args.version, Path(args.output).resolve())
except PortableInstallError as exc:
print(str(exc), file=sys.stderr)
return 1
return 1
if __name__ == "__main__":
raise SystemExit(main())