From 17ff13ccbe274e831d5d9327740cd6d506ea8c1c Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Mon, 20 Jul 2026 10:45:01 +0530 Subject: [PATCH] fix(install): migrate deployments off the retired chopratejas image repo (#2427) ## Description Fixes #2426. Persistent Docker deployments store their image in the deployment manifest. The image org moved from the personal `ghcr.io/chopratejas/headroom` repo to the project org `ghcr.io/headroomlabs-ai/headroom`, and the personal repo is frozen at 0.27.0. Because the manifest image is only ever read back verbatim (`build_runtime_command`, `docker run`, status output), a deployment created before the move keeps pulling 0.27.0 forever, several minor versions behind the CLI, with no drift signal to the user. Two related gaps: - `headroom/install/state.py` reads the recorded image straight back with no migration, so an old manifest is stuck on the dead repo. - `headroom/cli/install.py` `deploy --image` still defaulted to `ghcr.io/chopratejas/headroom:latest`, so brand new deploys through that command also pinned the retired repo (the `install-apply` default was already correct). ## Fix - Rewrite the retired repo to the org repo when a manifest is loaded, in both `load_manifest` and `list_manifests`, preserving whatever tag was recorded. The rewrite is surgical: it only matches the exact retired `ghcr.io/chopratejas/headroom` repo and leaves already-current images and any third-party image untouched. The migrated value persists on the next apply/save. - Change the `deploy --image` default to `ghcr.io/headroomlabs-ai/headroom:latest` so it matches `install-apply`. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/install/state.py`: add `_migrate_deprecated_image` and apply it in `load_manifest` and `list_manifests` before constructing the manifest. - `headroom/cli/install.py`: `deploy --image` default now points at the org repo. - `tests/test_install/test_state.py`: new tests covering load and list migrating the retired repo (tag preserved) and leaving current/third-party images untouched. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uvx ruff@0.15.17 check headroom/install/state.py headroom/cli/install.py tests/test_install/test_state.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/install/state.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`. - Exact command / steps: wrote a manifest.json pinning `ghcr.io/chopratejas/headroom:latest` (and `:0.27.0`) under a temp home, then called the real `load_manifest` and `list_manifests`. - Observed result: both returned a manifest with `image == ghcr.io/headroomlabs-ai/headroom:latest` (tag preserved on the `0.27.0` case too); an already-current image and a third-party image passed through unchanged. Ran against the actual module. - Not tested: a live `docker run` against the migrated image. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable --- headroom/cli/install.py | 2 +- headroom/install/state.py | 22 ++++++++++++ tests/test_install/test_state.py | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/headroom/cli/install.py b/headroom/cli/install.py index 0c0b6bae5..fd61c2bea 100644 --- a/headroom/cli/install.py +++ b/headroom/cli/install.py @@ -608,7 +608,7 @@ def install_apply( ) @click.option( "--image", - default="ghcr.io/chopratejas/headroom:latest", + default="ghcr.io/headroomlabs-ai/headroom:latest", show_default=True, help="Docker image to use when Docker is selected.", ) diff --git a/headroom/install/state.py b/headroom/install/state.py index 9480f4b5d..16f66a897 100644 --- a/headroom/install/state.py +++ b/headroom/install/state.py @@ -9,6 +9,7 @@ import shutil import tempfile from dataclasses import asdict from pathlib import Path +from typing import Any from .models import ArtifactRecord, DeploymentManifest, ManagedMutation, iso_utc_now from .paths import deploy_root, manifest_path, profile_root @@ -60,6 +61,23 @@ def save_manifest(manifest: DeploymentManifest) -> None: logger.warning("Cannot save deployment manifest: %s — continuing without persistence", e) +# The Docker image org moved from a personal repo to the project org. The old +# ``ghcr.io/chopratejas/headroom`` repo is frozen at 0.27.0, so a manifest that +# still pins it silently runs ~5 minor versions behind the CLI with no drift +# signal (#2426). Rewrite it to the org repo on load, preserving the tag. +_DEPRECATED_IMAGE_REPO = "ghcr.io/chopratejas/headroom" +_CURRENT_IMAGE_REPO = "ghcr.io/headroomlabs-ai/headroom" + + +def _migrate_deprecated_image(image: Any) -> Any: + """Rewrite the retired ``chopratejas`` Docker repo to the org repo (#2426).""" + if isinstance(image, str) and image.startswith(_DEPRECATED_IMAGE_REPO): + migrated = _CURRENT_IMAGE_REPO + image[len(_DEPRECATED_IMAGE_REPO) :] + logger.info("Migrating deployment image from retired repo %s to %s", image, migrated) + return migrated + return image + + def load_manifest(profile: str = "default") -> DeploymentManifest | None: """Load a deployment manifest when present.""" @@ -74,6 +92,8 @@ def load_manifest(profile: str = "default") -> DeploymentManifest | None: payload = json.loads(path.read_text(encoding="utf-8")) payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])] payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])] + if "image" in payload: + payload["image"] = _migrate_deprecated_image(payload["image"]) return DeploymentManifest(**payload) except (json.JSONDecodeError, ValueError, TypeError, OSError) as e: raise ManifestError(f"deployment profile '{profile}' is corrupt ({path}): {e}") from e @@ -94,6 +114,8 @@ def list_manifests() -> list[DeploymentManifest]: ManagedMutation(**item) for item in payload.get("mutations", []) ] payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])] + if "image" in payload: + payload["image"] = _migrate_deprecated_image(payload["image"]) manifests.append(DeploymentManifest(**payload)) except (OSError, ValueError, TypeError): continue diff --git a/tests/test_install/test_state.py b/tests/test_install/test_state.py index a053a4d8f..4622e3f04 100644 --- a/tests/test_install/test_state.py +++ b/tests/test_install/test_state.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -85,6 +86,63 @@ def test_list_manifests_ignores_invalid_payloads(monkeypatch, tmp_path: Path) -> assert [manifest.profile for manifest in manifests] == ["default"] +def _write_manifest_with_image(profile_dir: Path, image: str) -> None: + profile_dir.mkdir(parents=True, exist_ok=True) + payload = { + "profile": profile_dir.name, + "preset": "persistent-docker", + "runtime_kind": "docker", + "supervisor_kind": "none", + "scope": "user", + "provider_mode": "manual", + "targets": ["claude"], + "port": 8787, + "host": "127.0.0.1", + "backend": "anthropic", + "image": image, + } + (profile_dir / "manifest.json").write_text(json.dumps(payload), encoding="utf-8") + + +def test_load_manifest_migrates_retired_image_repo(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(Path, "home", lambda: tmp_path) + profile_dir = tmp_path / ".headroom" / "deploy" / "default" + # A manifest written before the org move still pins the retired personal + # repo, which is frozen at 0.27.0. Loading it must rewrite the repo while + # preserving the tag, so the deployment tracks the current image (#2426). + _write_manifest_with_image(profile_dir, "ghcr.io/chopratejas/headroom:latest") + + loaded = load_manifest("default") + + assert loaded is not None + assert loaded.image == "ghcr.io/headroomlabs-ai/headroom:latest" + + +def test_load_manifest_leaves_unrelated_image_untouched(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(Path, "home", lambda: tmp_path) + profile_dir = tmp_path / ".headroom" / "deploy" / "default" + _write_manifest_with_image(profile_dir, "ghcr.io/headroomlabs-ai/headroom:0.31.0") + + loaded = load_manifest("default") + + assert loaded is not None + # An already-current image, and any third-party image, must pass through + # unchanged so the migration only ever rewrites the one retired repo. + assert loaded.image == "ghcr.io/headroomlabs-ai/headroom:0.31.0" + + +def test_list_manifests_migrates_retired_image_repo(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(Path, "home", lambda: tmp_path) + _write_manifest_with_image( + tmp_path / ".headroom" / "deploy" / "default", + "ghcr.io/chopratejas/headroom:0.27.0", + ) + + manifests = list_manifests() + + assert [m.image for m in manifests] == ["ghcr.io/headroomlabs-ai/headroom:0.27.0"] + + def test_delete_manifest_removes_profile_root(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) manifest = _manifest()