mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 04:12:10 +00:00
Prune catalog snapshots and defer recipes requiring newer profile keys
This commit is contained in:
+16
-1
@@ -9,7 +9,12 @@ a connection failure. Selecting it during a refresh does not start a second work
|
|||||||
Manual refresh bypasses the conditional check and verifies the complete manifest,
|
Manual refresh bypasses the conditional check and verifies the complete manifest,
|
||||||
allowing it to repair damaged cached files even when the upstream revision has not
|
allowing it to repair damaged cached files even when the upstream revision has not
|
||||||
changed. A repaired snapshot gets a separate immutable directory so existing
|
changed. A repaired snapshot gets a separate immutable directory so existing
|
||||||
readers retain their original snapshot until they return to the menu.
|
readers retain their original snapshot until they return to the menu. Each
|
||||||
|
Catalog reader holds an OS file-lock lease. Every worker run, including unchanged
|
||||||
|
checks and failed downloads, prunes snapshots that are neither a channel's active
|
||||||
|
snapshot nor leased by an open reader. Reader exit or a crash releases its lease
|
||||||
|
automatically. Snapshot selection, publication and pruning share a short local
|
||||||
|
lock; opening a menu never waits for the worker's network lock.
|
||||||
|
|
||||||
The worker checks the latest commit affecting `oci/catalog` in the official
|
The worker checks the latest commit affecting `oci/catalog` in the official
|
||||||
MacRimi/ProxMenux repository. GitHub's ETag avoids repeated transfers. When the
|
MacRimi/ProxMenux repository. GitHub's ETag avoids repeated transfers. When the
|
||||||
@@ -46,6 +51,16 @@ acknowledged only after the menu displays it. Exiting during a download therefor
|
|||||||
preserves its news for the next visit. The new-app count remains visible throughout
|
preserves its news for the next visit. The new-app count remains visible throughout
|
||||||
the current visit and becomes zero on the next visit after acknowledgement.
|
the current visit and becomes zero on the next visit after acknowledgement.
|
||||||
|
|
||||||
|
Downloaded recipes are also checked against the top-level `installer_profile`
|
||||||
|
keys used by the engine's bundled apps, curated recipes and overlays. A new app
|
||||||
|
using an unknown key is silently excluded; an existing app retains its previous
|
||||||
|
compatible, fully composed recipe and its matching settings. Hidden entries do
|
||||||
|
not contribute to the available/new counters, and retaining an old recipe does
|
||||||
|
not count it as an update. When a ProxMenux update adds support for that key, the
|
||||||
|
next background check re-evaluates the catalog even at the same remote revision.
|
||||||
|
This is a conservative key-presence gate, not validation of new values or nested
|
||||||
|
options of an already known key. Changes in meaning still require `engine_api`.
|
||||||
|
|
||||||
Catalog maintainers must increase `catalog/compatibility.json`'s `engine_api` when
|
Catalog maintainers must increase `catalog/compatibility.json`'s `engine_api` when
|
||||||
recipes require incompatible runtime capabilities. The consumer currently
|
recipes require incompatible runtime capabilities. The consumer currently
|
||||||
supports API 1. Changes to the template schema also require a ProxMenux engine
|
supports API 1. Changes to the template schema also require a ProxMenux engine
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import weakref
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -59,8 +60,10 @@ class Catalog:
|
|||||||
def __init__(self, root: Path, source: GitHubSource | None = None, *,
|
def __init__(self, root: Path, source: GitHubSource | None = None, *,
|
||||||
catalog_dir: Path | None = None) -> None:
|
catalog_dir: Path | None = None) -> None:
|
||||||
self.root = root
|
self.root = root
|
||||||
from .catalog_refresh import current_catalog
|
from .catalog_refresh import pin_catalog
|
||||||
self.catalog_dir = catalog_dir if catalog_dir is not None else current_catalog(root)
|
self.catalog_dir, lease = pin_catalog(root, catalog_dir)
|
||||||
|
if lease is not None:
|
||||||
|
self._snapshot_lease = weakref.finalize(self, lease.close)
|
||||||
self._include_local = catalog_dir is None and self.catalog_dir != root / "catalog"
|
self._include_local = catalog_dir is None and self.catalog_dir != root / "catalog"
|
||||||
self.local_app_ids: set[str] = set()
|
self.local_app_ids: set[str] = set()
|
||||||
self.apps_dir = self.catalog_dir / "apps"
|
self.apps_dir = self.catalog_dir / "apps"
|
||||||
@@ -825,6 +828,9 @@ class Catalog:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise ConversionError(f"No template is available for '{app_id}'")
|
raise ConversionError(f"No template is available for '{app_id}'")
|
||||||
template = json.loads(path.read_text(encoding="utf-8"))
|
template = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if item.get("_retained_recipe"):
|
||||||
|
self.validate(template, required=False)
|
||||||
|
return template
|
||||||
self._apply_overlay(item["id"], template)
|
self._apply_overlay(item["id"], template)
|
||||||
self._apply_category(item["id"], template["catalog_ui"])
|
self._apply_category(item["id"], template["catalog_ui"])
|
||||||
self._apply_volume_policy(item["id"], template["container_contract"],
|
self._apply_volume_policy(item["id"], template["container_contract"],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ never downloaded. An installed engine always retains its bundled fallback.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from contextlib import contextmanager
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import fcntl
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -95,6 +96,113 @@ def schema_hash(root):
|
|||||||
return hashlib.sha256((Path(root) / "schemas/oci-template.schema.json").read_bytes()).hexdigest()
|
return hashlib.sha256((Path(root) / "schemas/oci-template.schema.json").read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_hash(root):
|
||||||
|
# Cheap menu-time engine-update detection; no scan of the bundled recipes.
|
||||||
|
return hashlib.sha256((Path(root) / "catalog/index.json").read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def snapshot_lock(root):
|
||||||
|
cache = cache_root(root)
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
with (cache / "snapshots.lock").open("a") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def pin_catalog(root, directory=None):
|
||||||
|
"""Select and lease atomically against pruning; no network/worker wait."""
|
||||||
|
explicit = directory
|
||||||
|
directory = Path(directory) if directory is not None else current_catalog(root)
|
||||||
|
if (directory.parent != cache_root(root) / "snapshots"
|
||||||
|
or not re.fullmatch(r"[0-9a-f]{40}(?:-[0-9a-f]{12})?", directory.name)):
|
||||||
|
return directory, None
|
||||||
|
with snapshot_lock(root):
|
||||||
|
directory = Path(explicit) if explicit is not None else current_catalog(root)
|
||||||
|
snapshots = cache_root(root) / "snapshots"
|
||||||
|
if directory.parent != snapshots or not directory.is_dir():
|
||||||
|
return directory, None
|
||||||
|
leases = cache_root(root) / "leases"
|
||||||
|
leases.mkdir(exist_ok=True)
|
||||||
|
lease = (leases / directory.name).open("a")
|
||||||
|
fcntl.flock(lease, fcntl.LOCK_SH)
|
||||||
|
return directory, lease
|
||||||
|
|
||||||
|
|
||||||
|
def prune_snapshots(root):
|
||||||
|
"""Retain channel pointers and live readers; locks survive PID reuse/crashes."""
|
||||||
|
cache = cache_root(root)
|
||||||
|
with snapshot_lock(root):
|
||||||
|
keep = {read_json(p).get("snapshot", read_json(p).get("revision"))
|
||||||
|
for p in cache.glob("*-current.json")}
|
||||||
|
leases = cache / "leases"
|
||||||
|
leases.mkdir(exist_ok=True)
|
||||||
|
for directory in (cache / "snapshots").glob("*"):
|
||||||
|
if (not SHA.fullmatch(directory.name.split("-")[0])
|
||||||
|
or directory.name in keep or directory.is_symlink() or not directory.is_dir()):
|
||||||
|
continue
|
||||||
|
lease_path = leases / directory.name
|
||||||
|
with lease_path.open("a") as lease:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
continue
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
lease_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def supported_profile_keys(root):
|
||||||
|
keys = set()
|
||||||
|
for folder in ("apps", "curated", "overlays"):
|
||||||
|
for path in (Path(root) / "catalog" / folder).glob("*.json"):
|
||||||
|
value = json.loads(path.read_text())
|
||||||
|
keys.update(value.get("proxmox", {}).get("installer_profile", {}))
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def filter_compatible_recipes(root, directory, previous):
|
||||||
|
"""Silently defer new engine capabilities, retaining compatible old apps."""
|
||||||
|
from .catalog import Catalog
|
||||||
|
supported = supported_profile_keys(root)
|
||||||
|
candidate = Catalog(Path(root), catalog_dir=directory)
|
||||||
|
old = Catalog(Path(root), catalog_dir=previous)
|
||||||
|
old_items = {item["id"]: item for item in old.load_index()["applications"]}
|
||||||
|
index = json.loads((directory / "index.json").read_text())
|
||||||
|
accepted = []
|
||||||
|
old_fingerprints = None
|
||||||
|
for item in index["applications"]:
|
||||||
|
if item.get("hidden") or not item.get("automatic_install_candidate"):
|
||||||
|
accepted.append(item)
|
||||||
|
continue
|
||||||
|
template = candidate._compose_item(item)
|
||||||
|
profile = template.get("proxmox", {}).get("installer_profile", {})
|
||||||
|
if set(profile) <= supported:
|
||||||
|
accepted.append(item)
|
||||||
|
continue
|
||||||
|
fallback = old_items.get(item["id"])
|
||||||
|
if fallback is None:
|
||||||
|
continue
|
||||||
|
template = old._compose_item(fallback)
|
||||||
|
if not set(template.get("proxmox", {}).get("installer_profile", {})) <= supported:
|
||||||
|
continue
|
||||||
|
# Materialize the previously composed contract: its old overlay/volume
|
||||||
|
# policy must not be combined with a newly downloaded incompatible one.
|
||||||
|
app_id = item["id"]
|
||||||
|
for folder in ("apps", "curated"):
|
||||||
|
path = directory / folder / f"{app_id}.json"
|
||||||
|
path.parent.mkdir(exist_ok=True)
|
||||||
|
write_json(path, template)
|
||||||
|
(directory / "overlays" / f"{app_id}.json").unlink(missing_ok=True)
|
||||||
|
if old_fingerprints is None:
|
||||||
|
old_fingerprints = visible_recipes(old)
|
||||||
|
fallback = dict(fallback, _retained_recipe_hash=old_fingerprints.get(app_id), template_family="curated-profile",
|
||||||
|
curated_path=f"catalog/curated/{app_id}.json",
|
||||||
|
_retained_recipe=True)
|
||||||
|
accepted.append(fallback)
|
||||||
|
index["applications"] = accepted
|
||||||
|
write_json(directory / "index.json", index)
|
||||||
|
|
||||||
|
|
||||||
def current_catalog(root, channel=None):
|
def current_catalog(root, channel=None):
|
||||||
root = Path(root)
|
root = Path(root)
|
||||||
channel = channel or channel_for(root)
|
channel = channel or channel_for(root)
|
||||||
@@ -102,14 +210,15 @@ def current_catalog(root, channel=None):
|
|||||||
revision = value.get("revision", "")
|
revision = value.get("revision", "")
|
||||||
if (isinstance(revision, str) and SHA.fullmatch(revision)
|
if (isinstance(revision, str) and SHA.fullmatch(revision)
|
||||||
and value.get("schema_hash") == schema_hash(root)
|
and value.get("schema_hash") == schema_hash(root)
|
||||||
and value.get("engine_api") == CATALOG_API):
|
and value.get("engine_api") == CATALOG_API
|
||||||
|
and value.get("bundle_hash") == bundle_hash(root)):
|
||||||
snapshot = value.get("snapshot", revision)
|
snapshot = value.get("snapshot", revision)
|
||||||
if not isinstance(snapshot, str) or not re.fullmatch(revision + r"(?:-[0-9a-f]{12})?", snapshot):
|
if not isinstance(snapshot, str) or not re.fullmatch(revision + r"(?:-[0-9a-f]{12})?", snapshot):
|
||||||
return root / "catalog"
|
return root / "catalog"
|
||||||
path = cache_root(root) / "snapshots" / snapshot
|
path = cache_root(root) / "snapshots" / snapshot
|
||||||
index = read_json(path / "index.json")
|
index = read_json(path / "index.json")
|
||||||
if (index.get("kind") == "proxmenux.oci-catalog-index"
|
if (index.get("kind") == "proxmenux.oci-catalog-index"
|
||||||
and isinstance(index.get("applications"), list) and index["applications"]):
|
and isinstance(index.get("applications"), list)):
|
||||||
return path
|
return path
|
||||||
return root / "catalog"
|
return root / "catalog"
|
||||||
|
|
||||||
@@ -146,7 +255,7 @@ def catalog_path(name):
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def validate_snapshot(root, directory):
|
def validate_snapshot(root, directory, *, allow_empty=False):
|
||||||
from .catalog import Catalog
|
from .catalog import Catalog
|
||||||
from jsonschema import Draft202012Validator
|
from jsonschema import Draft202012Validator
|
||||||
compatibility = (json.loads((directory / "compatibility.json").read_text())
|
compatibility = (json.loads((directory / "compatibility.json").read_text())
|
||||||
@@ -171,7 +280,7 @@ def validate_snapshot(root, directory):
|
|||||||
if not curated.startswith("catalog/"):
|
if not curated.startswith("catalog/"):
|
||||||
raise ValueError("Invalid curated recipe path")
|
raise ValueError("Invalid curated recipe path")
|
||||||
catalog_path(curated.removeprefix("catalog/"))
|
catalog_path(curated.removeprefix("catalog/"))
|
||||||
if not ids:
|
if not ids and not allow_empty:
|
||||||
raise ValueError("The remote catalog is empty")
|
raise ValueError("The remote catalog is empty")
|
||||||
catalog = Catalog(Path(root), catalog_dir=directory)
|
catalog = Catalog(Path(root), catalog_dir=directory)
|
||||||
for item in catalog.load_index()["applications"]:
|
for item in catalog.load_index()["applications"]:
|
||||||
@@ -187,6 +296,8 @@ def synchronize(root, channel, fetch=request, force=False):
|
|||||||
pointer = cache / f"{channel}-current.json"
|
pointer = cache / f"{channel}-current.json"
|
||||||
previous = read_json(pointer)
|
previous = read_json(pointer)
|
||||||
active = current_catalog(root, channel)
|
active = current_catalog(root, channel)
|
||||||
|
capabilities = hashlib.sha256(json.dumps(sorted(supported_profile_keys(root))).encode()).hexdigest()
|
||||||
|
force = force or previous.get("profile_capabilities") != capabilities
|
||||||
etag = previous.get("etag") if active != root / "catalog" and not force else None
|
etag = previous.get("etag") if active != root / "catalog" and not force else None
|
||||||
url = f"https://api.github.com/repos/{REPOSITORY}/commits?sha={channel}&path=oci/catalog&per_page=1"
|
url = f"https://api.github.com/repos/{REPOSITORY}/commits?sha={channel}&path=oci/catalog&per_page=1"
|
||||||
body, etag = fetch(url, etag)
|
body, etag = fetch(url, etag)
|
||||||
@@ -255,21 +366,26 @@ def synchronize(root, channel, fetch=request, force=False):
|
|||||||
finally:
|
finally:
|
||||||
pool.shutdown(wait=True, cancel_futures=True)
|
pool.shutdown(wait=True, cancel_futures=True)
|
||||||
validate_snapshot(root, staging)
|
validate_snapshot(root, staging)
|
||||||
|
filter_compatible_recipes(root, staging, active)
|
||||||
|
validate_snapshot(root, staging, allow_empty=True)
|
||||||
destination = (active if previous.get("revision") == revision and active != root / "catalog"
|
destination = (active if previous.get("revision") == revision and active != root / "catalog"
|
||||||
else snapshots / revision)
|
else snapshots / revision)
|
||||||
if destination.exists():
|
if destination.exists():
|
||||||
|
staged_files = {str(p.relative_to(staging)) for p in staging.rglob("*.json")}
|
||||||
intact = all((destination / relative).is_file()
|
intact = all((destination / relative).is_file()
|
||||||
and not (destination / relative).is_symlink()
|
and not (destination / relative).is_symlink()
|
||||||
and git_blob((destination / relative).read_bytes()) == entry["sha"]
|
and (destination / relative).read_bytes() == (staging / relative).read_bytes()
|
||||||
for relative, entry in entries.items())
|
for relative in staged_files)
|
||||||
existing = {str(p.relative_to(destination)) for p in destination.rglob("*.json")}
|
existing = {str(p.relative_to(destination)) for p in destination.rglob("*.json")}
|
||||||
if not intact or existing != set(entries):
|
if not intact or existing != staged_files:
|
||||||
# Repair without removing a snapshot another wizard may still hold.
|
# Repair without removing a snapshot another wizard may still hold.
|
||||||
destination = snapshots / (revision + "-" + uuid.uuid4().hex[:12])
|
destination = snapshots / (revision + "-" + uuid.uuid4().hex[:12])
|
||||||
if not destination.exists():
|
with snapshot_lock(root):
|
||||||
os.replace(staging, destination)
|
if not destination.exists():
|
||||||
write_json(pointer, {"revision": revision, "snapshot": destination.name, "etag": etag,
|
os.replace(staging, destination)
|
||||||
"schema_hash": schema_hash(root), "engine_api": CATALOG_API})
|
write_json(pointer, {"revision": revision, "snapshot": destination.name, "etag": etag,
|
||||||
|
"schema_hash": schema_hash(root), "engine_api": CATALOG_API,
|
||||||
|
"bundle_hash": bundle_hash(root), "profile_capabilities": capabilities})
|
||||||
return destination != active
|
return destination != active
|
||||||
|
|
||||||
|
|
||||||
@@ -286,6 +402,8 @@ def worker(root, channel, force=False):
|
|||||||
write_json(cache / f"{channel}-result.json", {"ok": True, "changed": changed})
|
write_json(cache / f"{channel}-result.json", {"ok": True, "changed": changed})
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
write_json(cache / f"{channel}-result.json", {"ok": False, "error": str(error)[:400]})
|
write_json(cache / f"{channel}-result.json", {"ok": False, "error": str(error)[:400]})
|
||||||
|
finally:
|
||||||
|
prune_snapshots(root)
|
||||||
|
|
||||||
|
|
||||||
def visible_recipes(catalog):
|
def visible_recipes(catalog):
|
||||||
@@ -295,6 +413,9 @@ def visible_recipes(catalog):
|
|||||||
for item in catalog.load_index()["applications"]:
|
for item in catalog.load_index()["applications"]:
|
||||||
if item.get("hidden") or not item.get("automatic_install_candidate"):
|
if item.get("hidden") or not item.get("automatic_install_candidate"):
|
||||||
continue
|
continue
|
||||||
|
if item.get("_retained_recipe") and item.get("_retained_recipe_hash"):
|
||||||
|
result[item["id"]] = item["_retained_recipe_hash"]
|
||||||
|
continue
|
||||||
directory = catalog.root / "catalog" if item["id"] in catalog.local_app_ids else catalog.catalog_dir
|
directory = catalog.root / "catalog" if item["id"] in catalog.local_app_ids else catalog.catalog_dir
|
||||||
if item.get("template_family") == "curated-profile":
|
if item.get("template_family") == "curated-profile":
|
||||||
path = directory / item["curated_path"].removeprefix("catalog/")
|
path = directory / item["curated_path"].removeprefix("catalog/")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Catalog delivery is atomic, offline-safe, and never acknowledges unseen apps."""
|
"""Catalog delivery is atomic, offline-safe, and never acknowledges unseen apps."""
|
||||||
import json
|
import json
|
||||||
|
import gc
|
||||||
import errno
|
import errno
|
||||||
import http.server
|
import http.server
|
||||||
import itertools
|
import itertools
|
||||||
@@ -74,6 +75,155 @@ class CatalogRefreshTests(unittest.TestCase):
|
|||||||
index["applications"].append(item)
|
index["applications"].append(item)
|
||||||
self.files["index.json"] = json.dumps(index).encode()
|
self.files["index.json"] = json.dumps(index).encode()
|
||||||
|
|
||||||
|
def future_profile(self, app_id):
|
||||||
|
index = json.loads(self.files["index.json"])
|
||||||
|
item = next(x for x in index["applications"] if x["id"] == app_id)
|
||||||
|
path = item["curated_path"].removeprefix("catalog/")
|
||||||
|
recipe = json.loads(self.files[path])
|
||||||
|
recipe.setdefault("proxmox", {}).setdefault("installer_profile", {})["future_engine_key"] = True
|
||||||
|
self.files[path] = json.dumps(recipe).encode()
|
||||||
|
|
||||||
|
def test_new_unknown_profile_key_is_silent_and_not_counted(self):
|
||||||
|
self.sync()
|
||||||
|
baseline = Catalog(self.root)
|
||||||
|
status = refresh.CatalogRefresh(self.root, baseline)
|
||||||
|
self.revision = "b" * 40
|
||||||
|
self.add_app()
|
||||||
|
self.future_profile("sample-added")
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
catalog = Catalog(self.root)
|
||||||
|
self.assertNotIn("sample-added", refresh.visible_recipes(catalog))
|
||||||
|
self.assertEqual(set(refresh.visible_recipes(catalog)), set(status.recipes))
|
||||||
|
status.recipes = refresh.visible_recipes(catalog)
|
||||||
|
self.assertIn("New applications since your last visit: 0", status.describe())
|
||||||
|
|
||||||
|
def test_incompatible_existing_app_retains_complete_previous_contract(self):
|
||||||
|
self.sync()
|
||||||
|
old = Catalog(self.root).compose(self.item["id"])
|
||||||
|
old_visible = refresh.visible_recipes(Catalog(self.root))
|
||||||
|
self.revision = "b" * 40
|
||||||
|
self.future_profile(self.item["id"])
|
||||||
|
# A new policy must not mutate the retained contract.
|
||||||
|
policy = json.loads(self.files["volume-policy.json"])
|
||||||
|
policy["shared_paths"] = [v["container_path"] for v in old["container_contract"].get("volumes", [])]
|
||||||
|
self.files["volume-policy.json"] = json.dumps(policy).encode()
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(Catalog(self.root).compose(self.item["id"]), old)
|
||||||
|
self.assertEqual(refresh.visible_recipes(Catalog(self.root)), old_visible)
|
||||||
|
# A later incompatible revision still retains the same original contract.
|
||||||
|
self.revision = "c" * 40
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(Catalog(self.root).compose(self.item["id"]), old)
|
||||||
|
|
||||||
|
def test_engine_capability_update_rechecks_same_revision_without_etag(self):
|
||||||
|
self.add_app()
|
||||||
|
self.future_profile("sample-added")
|
||||||
|
self.sync()
|
||||||
|
self.assertNotIn("sample-added", refresh.visible_recipes(Catalog(self.root)))
|
||||||
|
bundled = self.root / "catalog" / self.item["curated_path"].removeprefix("catalog/")
|
||||||
|
value = json.loads(bundled.read_text())
|
||||||
|
value.setdefault("proxmox", {}).setdefault("installer_profile", {})["future_engine_key"] = True
|
||||||
|
bundled.write_text(json.dumps(value))
|
||||||
|
self.requests.clear()
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
self.assertIsNone(self.requests[0][1])
|
||||||
|
self.assertIn("sample-added", refresh.visible_recipes(Catalog(self.root)))
|
||||||
|
|
||||||
|
def test_known_profile_key_accepts_new_values(self):
|
||||||
|
self.add_app()
|
||||||
|
path = "curated/sample-added.json"
|
||||||
|
value = json.loads(self.files[path])
|
||||||
|
self.assertIn("completion_notes", refresh.supported_profile_keys(self.root))
|
||||||
|
value["proxmox"]["installer_profile"]["completion_notes"].append("A new supported setting")
|
||||||
|
self.files[path] = json.dumps(value).encode()
|
||||||
|
self.sync()
|
||||||
|
self.assertIn("sample-added", refresh.visible_recipes(Catalog(self.root)))
|
||||||
|
|
||||||
|
def test_all_incompatible_apps_can_produce_empty_visible_catalog(self):
|
||||||
|
self.future_profile(self.item["id"])
|
||||||
|
# No compatible old recipe exists on this host.
|
||||||
|
path = self.root / "catalog" / self.item["curated_path"].removeprefix("catalog/")
|
||||||
|
value = json.loads(path.read_text())
|
||||||
|
value["proxmox"]["installer_profile"].pop("future_engine_key", None)
|
||||||
|
# Use a new ID instead of retaining the bundled application.
|
||||||
|
self.add_app()
|
||||||
|
self.future_profile("sample-added")
|
||||||
|
index = json.loads(self.files["index.json"])
|
||||||
|
index["applications"] = [x for x in index["applications"] if x["id"] == "sample-added"]
|
||||||
|
self.files["index.json"] = json.dumps(index).encode()
|
||||||
|
self.sync()
|
||||||
|
self.assertNotEqual(refresh.current_catalog(self.root), self.root / "catalog")
|
||||||
|
self.assertEqual(refresh.visible_recipes(Catalog(self.root)), {})
|
||||||
|
|
||||||
|
def test_pruning_preserves_active_and_leased_snapshots_then_reclaims_reader(self):
|
||||||
|
self.sync()
|
||||||
|
old = Catalog(self.root)
|
||||||
|
old_directory = old.catalog_dir
|
||||||
|
self.revision = "b" * 40
|
||||||
|
self.add_app()
|
||||||
|
self.sync()
|
||||||
|
refresh.prune_snapshots(self.root)
|
||||||
|
self.assertTrue(old_directory.exists())
|
||||||
|
self.assertEqual(old.compose(self.item["id"])["id"], self.item["template_id"])
|
||||||
|
del old
|
||||||
|
gc.collect()
|
||||||
|
refresh.prune_snapshots(self.root)
|
||||||
|
self.assertFalse(old_directory.exists())
|
||||||
|
self.assertTrue(refresh.current_catalog(self.root).exists())
|
||||||
|
|
||||||
|
def test_pruning_preserves_both_channels_and_unlinks_orphan_lease(self):
|
||||||
|
self.sync()
|
||||||
|
main = refresh.current_catalog(self.root, "main")
|
||||||
|
self.revision = "b" * 40
|
||||||
|
refresh.synchronize(self.root, "develop", self.fetch)
|
||||||
|
orphan = refresh.cache_root(self.root) / "snapshots" / ("c" * 40)
|
||||||
|
orphan.mkdir()
|
||||||
|
refresh.prune_snapshots(self.root)
|
||||||
|
self.assertTrue(main.exists())
|
||||||
|
self.assertTrue(refresh.current_catalog(self.root, "develop").exists())
|
||||||
|
self.assertFalse(orphan.exists())
|
||||||
|
self.assertFalse((refresh.cache_root(self.root) / "leases" / orphan.name).exists())
|
||||||
|
|
||||||
|
def test_worker_prunes_even_when_no_change_or_network_failure(self):
|
||||||
|
self.sync()
|
||||||
|
for error in (None, OSError("offline")):
|
||||||
|
orphan = refresh.cache_root(self.root) / "snapshots" / ("c" * 40)
|
||||||
|
orphan.mkdir()
|
||||||
|
with patch.object(refresh, "synchronize", side_effect=error, return_value=False):
|
||||||
|
refresh.worker(self.root, "main")
|
||||||
|
self.assertFalse(orphan.exists())
|
||||||
|
|
||||||
|
def test_reader_process_exit_releases_lease_without_pid_cleanup(self):
|
||||||
|
import subprocess
|
||||||
|
self.sync()
|
||||||
|
directory = refresh.current_catalog(self.root)
|
||||||
|
code = ("import fcntl,sys,time; "
|
||||||
|
"f=open(sys.argv[1], 'a'); fcntl.flock(f,fcntl.LOCK_SH); "
|
||||||
|
"print('ready',flush=True); time.sleep(30)")
|
||||||
|
leases = refresh.cache_root(self.root) / "leases"
|
||||||
|
leases.mkdir(exist_ok=True)
|
||||||
|
child = subprocess.Popen([sys.executable, "-c", code, str(leases / directory.name)],
|
||||||
|
stdout=subprocess.PIPE, text=True)
|
||||||
|
self.addCleanup(child.stdout.close)
|
||||||
|
self.addCleanup(lambda: child.poll() is None and child.kill())
|
||||||
|
self.assertEqual(child.stdout.readline().strip(), "ready")
|
||||||
|
self.revision = "b" * 40
|
||||||
|
self.sync()
|
||||||
|
refresh.prune_snapshots(self.root)
|
||||||
|
self.assertTrue(directory.exists())
|
||||||
|
child.kill()
|
||||||
|
child.wait(timeout=3)
|
||||||
|
refresh.prune_snapshots(self.root)
|
||||||
|
self.assertFalse(directory.exists())
|
||||||
|
|
||||||
|
def test_menu_pin_does_not_wait_for_network_worker_lock(self):
|
||||||
|
self.sync()
|
||||||
|
lock = (refresh.cache_root(self.root) / "refresh.lock").open("a")
|
||||||
|
self.addCleanup(lock.close)
|
||||||
|
refresh.fcntl.flock(lock, refresh.fcntl.LOCK_EX)
|
||||||
|
catalog = Catalog(self.root)
|
||||||
|
self.assertEqual(catalog.compose(self.item["id"])["id"], self.item["template_id"])
|
||||||
|
|
||||||
def test_complete_catalog_is_selected_atomically_without_redownloading_unchanged_files(self):
|
def test_complete_catalog_is_selected_atomically_without_redownloading_unchanged_files(self):
|
||||||
self.assertTrue(self.sync())
|
self.assertTrue(self.sync())
|
||||||
directory = refresh.current_catalog(self.root, "main")
|
directory = refresh.current_catalog(self.root, "main")
|
||||||
|
|||||||
Reference in New Issue
Block a user