Ship the portable X11 bundle lifecycle
Some checks are pending
ci / test-and-build (push) Waiting to run
Some checks are pending
ci / test-and-build (push) Waiting to run
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'
This commit is contained in:
parent
511fab683a
commit
a3368056ff
15 changed files with 1372 additions and 45 deletions
358
tests/test_portable_bundle.py
Normal file
358
tests/test_portable_bundle.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PORTABLE_DIR = ROOT / "packaging" / "portable"
|
||||
if str(PORTABLE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(PORTABLE_DIR))
|
||||
|
||||
import portable_installer as portable
|
||||
|
||||
|
||||
def _project_version() -> str:
|
||||
text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
||||
match = re.search(r'(?m)^version\s*=\s*"([^"]+)"\s*$', text)
|
||||
if not match:
|
||||
raise RuntimeError("project version not found")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _write_file(path: Path, content: str, *, mode: int | None = None) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
if mode is not None:
|
||||
path.chmod(mode)
|
||||
|
||||
|
||||
def _build_fake_wheel(root: Path, version: str) -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
wheel_path = root / f"aman-{version}-py3-none-any.whl"
|
||||
dist_info = f"aman-{version}.dist-info"
|
||||
module_code = f'VERSION = "{version}"\n\ndef main():\n print(VERSION)\n return 0\n'
|
||||
with zipfile.ZipFile(wheel_path, "w") as archive:
|
||||
archive.writestr("portable_test_app.py", module_code)
|
||||
archive.writestr(
|
||||
f"{dist_info}/METADATA",
|
||||
"\n".join(
|
||||
[
|
||||
"Metadata-Version: 2.1",
|
||||
"Name: aman",
|
||||
f"Version: {version}",
|
||||
"Summary: portable bundle test wheel",
|
||||
"",
|
||||
]
|
||||
),
|
||||
)
|
||||
archive.writestr(
|
||||
f"{dist_info}/WHEEL",
|
||||
"\n".join(
|
||||
[
|
||||
"Wheel-Version: 1.0",
|
||||
"Generator: test_portable_bundle",
|
||||
"Root-Is-Purelib: true",
|
||||
"Tag: py3-none-any",
|
||||
"",
|
||||
]
|
||||
),
|
||||
)
|
||||
archive.writestr(
|
||||
f"{dist_info}/entry_points.txt",
|
||||
"[console_scripts]\naman=portable_test_app:main\n",
|
||||
)
|
||||
archive.writestr(f"{dist_info}/RECORD", "")
|
||||
return wheel_path
|
||||
|
||||
|
||||
def _bundle_dir(root: Path, version: str) -> Path:
|
||||
bundle_dir = root / f"bundle-{version}"
|
||||
(bundle_dir / "wheelhouse" / "common").mkdir(parents=True, exist_ok=True)
|
||||
for tag in portable.SUPPORTED_PYTHON_TAGS:
|
||||
(bundle_dir / "wheelhouse" / tag).mkdir(parents=True, exist_ok=True)
|
||||
(bundle_dir / "systemd").mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(PORTABLE_DIR / "install.sh", bundle_dir / "install.sh")
|
||||
shutil.copy2(PORTABLE_DIR / "uninstall.sh", bundle_dir / "uninstall.sh")
|
||||
shutil.copy2(PORTABLE_DIR / "portable_installer.py", bundle_dir / "portable_installer.py")
|
||||
shutil.copy2(PORTABLE_DIR / "systemd" / "aman.service.in", bundle_dir / "systemd" / "aman.service.in")
|
||||
portable.write_manifest(version, bundle_dir / "manifest.json")
|
||||
payload = json.loads((bundle_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
payload["smoke_check_code"] = "import portable_test_app"
|
||||
(bundle_dir / "manifest.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
shutil.copy2(_build_fake_wheel(root / "wheelhouse", version), bundle_dir / "wheelhouse" / "common")
|
||||
for name in ("install.sh", "uninstall.sh", "portable_installer.py"):
|
||||
(bundle_dir / name).chmod(0o755)
|
||||
return bundle_dir
|
||||
|
||||
|
||||
def _systemctl_env(home: Path, *, extra_path: list[Path] | None = None, fail_match: str | None = None) -> tuple[dict[str, str], Path]:
|
||||
fake_bin = home / "test-bin"
|
||||
fake_bin.mkdir(parents=True, exist_ok=True)
|
||||
log_path = home / "systemctl.log"
|
||||
script_path = fake_bin / "systemctl"
|
||||
_write_file(
|
||||
script_path,
|
||||
"\n".join(
|
||||
[
|
||||
"#!/usr/bin/env python3",
|
||||
"import os",
|
||||
"import sys",
|
||||
"from pathlib import Path",
|
||||
"log_path = Path(os.environ['SYSTEMCTL_LOG'])",
|
||||
"log_path.parent.mkdir(parents=True, exist_ok=True)",
|
||||
"command = ' '.join(sys.argv[1:])",
|
||||
"with log_path.open('a', encoding='utf-8') as handle:",
|
||||
" handle.write(command + '\\n')",
|
||||
"fail_match = os.environ.get('SYSTEMCTL_FAIL_MATCH', '')",
|
||||
"if fail_match and fail_match in command:",
|
||||
" print(f'forced failure: {command}', file=sys.stderr)",
|
||||
" raise SystemExit(1)",
|
||||
"raise SystemExit(0)",
|
||||
"",
|
||||
]
|
||||
),
|
||||
mode=0o755,
|
||||
)
|
||||
search_path = [
|
||||
str(home / ".local" / "bin"),
|
||||
*(str(path) for path in (extra_path or [])),
|
||||
str(fake_bin),
|
||||
os.environ["PATH"],
|
||||
]
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home)
|
||||
env["PATH"] = os.pathsep.join(search_path)
|
||||
env["SYSTEMCTL_LOG"] = str(log_path)
|
||||
env["AMAN_PORTABLE_TEST_PYTHON_TAG"] = "cp311"
|
||||
if fail_match:
|
||||
env["SYSTEMCTL_FAIL_MATCH"] = fail_match
|
||||
else:
|
||||
env.pop("SYSTEMCTL_FAIL_MATCH", None)
|
||||
return env, log_path
|
||||
|
||||
|
||||
def _run_script(bundle_dir: Path, script_name: str, env: dict[str, str], *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["bash", str(bundle_dir / script_name), *args],
|
||||
cwd=bundle_dir,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def _manifest_with_supported_tags(bundle_dir: Path, tags: list[str]) -> None:
|
||||
manifest_path = bundle_dir / "manifest.json"
|
||||
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
payload["supported_python_tags"] = tags
|
||||
manifest_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _installed_version(home: Path) -> str:
|
||||
installed_python = home / ".local" / "share" / "aman" / "current" / "venv" / "bin" / "python"
|
||||
result = subprocess.run(
|
||||
[str(installed_python), "-c", "import portable_test_app; print(portable_test_app.VERSION)"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
class PortableBundleTests(unittest.TestCase):
|
||||
def test_package_portable_builds_bundle_and_checksum(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
dist_dir = tmp_path / "dist"
|
||||
build_dir = tmp_path / "build"
|
||||
test_wheelhouse = tmp_path / "wheelhouse"
|
||||
for tag in portable.SUPPORTED_PYTHON_TAGS:
|
||||
target_dir = test_wheelhouse / tag
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
_write_file(target_dir / f"{tag}-placeholder.whl", "placeholder\n")
|
||||
env = os.environ.copy()
|
||||
env["DIST_DIR"] = str(dist_dir)
|
||||
env["BUILD_DIR"] = str(build_dir)
|
||||
env["AMAN_PORTABLE_TEST_WHEELHOUSE_ROOT"] = str(test_wheelhouse)
|
||||
env["UV_CACHE_DIR"] = str(tmp_path / ".uv-cache")
|
||||
env["PIP_CACHE_DIR"] = str(tmp_path / ".pip-cache")
|
||||
|
||||
subprocess.run(
|
||||
["bash", "./scripts/package_portable.sh"],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
version = _project_version()
|
||||
tarball = dist_dir / f"aman-x11-linux-{version}.tar.gz"
|
||||
checksum = dist_dir / f"aman-x11-linux-{version}.tar.gz.sha256"
|
||||
self.assertTrue(tarball.exists())
|
||||
self.assertTrue(checksum.exists())
|
||||
with tarfile.open(tarball, "r:gz") as archive:
|
||||
names = set(archive.getnames())
|
||||
prefix = f"aman-x11-linux-{version}"
|
||||
self.assertIn(f"{prefix}/install.sh", names)
|
||||
self.assertIn(f"{prefix}/uninstall.sh", names)
|
||||
self.assertIn(f"{prefix}/portable_installer.py", names)
|
||||
self.assertIn(f"{prefix}/manifest.json", names)
|
||||
self.assertIn(f"{prefix}/wheelhouse/common", names)
|
||||
self.assertIn(f"{prefix}/wheelhouse/cp310", names)
|
||||
self.assertIn(f"{prefix}/wheelhouse/cp311", names)
|
||||
self.assertIn(f"{prefix}/wheelhouse/cp312", names)
|
||||
self.assertIn(f"{prefix}/systemd/aman.service.in", names)
|
||||
|
||||
def test_fresh_install_creates_managed_paths_and_starts_service(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
home = tmp_path / "home"
|
||||
bundle_dir = _bundle_dir(tmp_path, "0.1.0")
|
||||
env, log_path = _systemctl_env(home)
|
||||
|
||||
result = _run_script(bundle_dir, "install.sh", env)
|
||||
|
||||
self.assertIn("installed aman 0.1.0", result.stdout)
|
||||
current_link = home / ".local" / "share" / "aman" / "current"
|
||||
self.assertTrue(current_link.is_symlink())
|
||||
self.assertEqual(current_link.resolve().name, "0.1.0")
|
||||
self.assertEqual(_installed_version(home), "0.1.0")
|
||||
shim_path = home / ".local" / "bin" / "aman"
|
||||
service_path = home / ".config" / "systemd" / "user" / "aman.service"
|
||||
state_path = home / ".local" / "share" / "aman" / "install-state.json"
|
||||
self.assertIn(portable.MANAGED_MARKER, shim_path.read_text(encoding="utf-8"))
|
||||
service_text = service_path.read_text(encoding="utf-8")
|
||||
self.assertIn(portable.MANAGED_MARKER, service_text)
|
||||
self.assertIn(str(current_link / "venv" / "bin" / "aman"), service_text)
|
||||
payload = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["version"], "0.1.0")
|
||||
commands = log_path.read_text(encoding="utf-8")
|
||||
self.assertIn("--user daemon-reload", commands)
|
||||
self.assertIn("--user enable --now aman", commands)
|
||||
|
||||
def test_upgrade_preserves_config_and_cache_and_prunes_old_version(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
home = tmp_path / "home"
|
||||
env, _log_path = _systemctl_env(home)
|
||||
bundle_v1 = _bundle_dir(tmp_path / "v1", "0.1.0")
|
||||
bundle_v2 = _bundle_dir(tmp_path / "v2", "0.2.0")
|
||||
|
||||
_run_script(bundle_v1, "install.sh", env)
|
||||
config_path = home / ".config" / "aman" / "config.json"
|
||||
cache_path = home / ".cache" / "aman" / "models" / "cached.bin"
|
||||
_write_file(config_path, '{"config_version": 1}\n')
|
||||
_write_file(cache_path, "cache\n")
|
||||
|
||||
_run_script(bundle_v2, "install.sh", env)
|
||||
|
||||
current_link = home / ".local" / "share" / "aman" / "current"
|
||||
self.assertEqual(current_link.resolve().name, "0.2.0")
|
||||
self.assertEqual(_installed_version(home), "0.2.0")
|
||||
self.assertFalse((home / ".local" / "share" / "aman" / "0.1.0").exists())
|
||||
self.assertTrue(config_path.exists())
|
||||
self.assertTrue(cache_path.exists())
|
||||
|
||||
def test_unmanaged_shim_conflict_fails_before_mutation(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
home = tmp_path / "home"
|
||||
bundle_dir = _bundle_dir(tmp_path, "0.1.0")
|
||||
env, _log_path = _systemctl_env(home)
|
||||
_write_file(home / ".local" / "bin" / "aman", "#!/usr/bin/env bash\necho nope\n", mode=0o755)
|
||||
|
||||
result = _run_script(bundle_dir, "install.sh", env, check=False)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("unmanaged shim", result.stderr)
|
||||
self.assertFalse((home / ".local" / "share" / "aman" / "install-state.json").exists())
|
||||
|
||||
def test_manifest_supported_tag_mismatch_fails_before_mutation(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
home = tmp_path / "home"
|
||||
bundle_dir = _bundle_dir(tmp_path, "0.1.0")
|
||||
_manifest_with_supported_tags(bundle_dir, ["cp399"])
|
||||
env, _log_path = _systemctl_env(home)
|
||||
|
||||
result = _run_script(bundle_dir, "install.sh", env, check=False)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("unsupported python3 version", result.stderr)
|
||||
self.assertFalse((home / ".local" / "share" / "aman").exists())
|
||||
|
||||
def test_uninstall_preserves_config_and_cache_by_default(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
home = tmp_path / "home"
|
||||
bundle_dir = _bundle_dir(tmp_path, "0.1.0")
|
||||
env, log_path = _systemctl_env(home)
|
||||
|
||||
_run_script(bundle_dir, "install.sh", env)
|
||||
_write_file(home / ".config" / "aman" / "config.json", '{"config_version": 1}\n')
|
||||
_write_file(home / ".cache" / "aman" / "models" / "cached.bin", "cache\n")
|
||||
|
||||
result = _run_script(bundle_dir, "uninstall.sh", env)
|
||||
|
||||
self.assertIn("uninstalled aman portable bundle", result.stdout)
|
||||
self.assertFalse((home / ".local" / "share" / "aman").exists())
|
||||
self.assertFalse((home / ".local" / "bin" / "aman").exists())
|
||||
self.assertFalse((home / ".config" / "systemd" / "user" / "aman.service").exists())
|
||||
self.assertTrue((home / ".config" / "aman" / "config.json").exists())
|
||||
self.assertTrue((home / ".cache" / "aman" / "models" / "cached.bin").exists())
|
||||
commands = log_path.read_text(encoding="utf-8")
|
||||
self.assertIn("--user disable --now aman", commands)
|
||||
|
||||
def test_uninstall_purge_removes_config_and_cache(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
home = tmp_path / "home"
|
||||
bundle_dir = _bundle_dir(tmp_path, "0.1.0")
|
||||
env, _log_path = _systemctl_env(home)
|
||||
|
||||
_run_script(bundle_dir, "install.sh", env)
|
||||
_write_file(home / ".config" / "aman" / "config.json", '{"config_version": 1}\n')
|
||||
_write_file(home / ".cache" / "aman" / "models" / "cached.bin", "cache\n")
|
||||
|
||||
_run_script(bundle_dir, "uninstall.sh", env, "--purge")
|
||||
|
||||
self.assertFalse((home / ".config" / "aman").exists())
|
||||
self.assertFalse((home / ".cache" / "aman").exists())
|
||||
|
||||
def test_upgrade_rolls_back_when_service_restart_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
home = tmp_path / "home"
|
||||
bundle_v1 = _bundle_dir(tmp_path / "v1", "0.1.0")
|
||||
bundle_v2 = _bundle_dir(tmp_path / "v2", "0.2.0")
|
||||
good_env, _ = _systemctl_env(home)
|
||||
failing_env, _ = _systemctl_env(home, fail_match="enable --now aman")
|
||||
|
||||
_run_script(bundle_v1, "install.sh", good_env)
|
||||
result = _run_script(bundle_v2, "install.sh", failing_env, check=False)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("forced failure", result.stderr)
|
||||
self.assertEqual((home / ".local" / "share" / "aman" / "current").resolve().name, "0.1.0")
|
||||
self.assertEqual(_installed_version(home), "0.1.0")
|
||||
self.assertFalse((home / ".local" / "share" / "aman" / "0.2.0").exists())
|
||||
payload = json.loads(
|
||||
(home / ".local" / "share" / "aman" / "install-state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(payload["version"], "0.1.0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue