fix(oci): prepare managed volumes across stack installs

This commit is contained in:
VAIO73
2026-09-29 19:44:42 +02:00
parent eb7cc548fa
commit e18ff82eea
635 changed files with 9804 additions and 400 deletions
@@ -0,0 +1,373 @@
"""Unit tests for the declarative volume_preparations application inside
install_generic_stack.py's attach_mounts() (Solution A, approved
2026-09-29): the stack path now runs volume_preparations itself, after
attach_mounts() has physically attached the real mount — instead of
relying on install_oci.sh's apply_installer_profile(), whose
only_when_mount_type check can never match for a stack service because
create_service() invokes install_oci.sh per-service with an
always-empty DEPLOYMENT_FILE.mounts.
Corrected after review found two bugs in the first implementation:
1. resolve_volume_owner() originally computed ONE value shared by both
owner_strategy values, so mapped-root incorrectly picked up
PUID/PGID/volume_owner (it must always be 100000:100000 or 0:0,
independent of the container's application user). It now takes the
owner_strategy as an explicit argument and returns a genuinely
different value per strategy, mirroring install_oci.sh's
HOST_ROOT_UID/GID (mapped-root, fixed) vs. HOST_BIND_UID/GID
(mapped-application-user, PUID/PGID + offset).
2. apply_volume_preparation() no longer chowns directly — it only
resolves and returns the declared owner; attach_mounts() applies it
AFTER the seed copy and AFTER its own always-run fallback chown
(which preserves image ownership for mounts with no declared
preparation), so the declared owner is never silently overwritten.
These tests exercise apply_volume_preparation()/resolve_volume_owner()
directly against a real filesystem fixture (a temp directory standing
in for the already-mounted container_path) — they do NOT touch pct,
LXC, or any real container; attach_mounts() itself (which does call
pct) is exercised only at the metadata/selection level by the existing
test_stack_volume_preparations.py tests (build_stack() output) and, for
the post-attach ordering fix, by a source-order guard test below — not
re-tested here at the pct/LXC level.
"""
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
sys.path.insert(0, str(ROOT / "src"))
import install_generic_stack as igs # noqa: E402
def make_service(*, unprivileged=True, environment=None, volume_owner=None,
volume_preparations=None):
return {
'deployment': {
'security': {'unprivileged': unprivileged},
'environment': environment or [],
},
'template': {
'proxmox': {
'installer_profile': {
**({'volume_owner': volume_owner} if volume_owner else {}),
'volume_preparations': volume_preparations or [],
}
}
},
}
class ResolveVolumeOwnerTests(unittest.TestCase):
def test_mapped_root_unprivileged_is_100000(self):
service = make_service(unprivileged=True)
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-root'), (100000, 100000))
def test_mapped_root_privileged_is_0(self):
service = make_service(unprivileged=False)
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-root'), (0, 0))
def test_mapped_root_ignores_puid_pgid(self):
"""The bug this guards against: mapped-root with PUID/PGID=1000
must still resolve to the fixed root mapping (100000:100000 for
unprivileged), NOT the application-user offset (101000:101000)."""
service = make_service(unprivileged=True, environment=[
{'name': 'PUID', 'value': '1000'},
{'name': 'PGID', 'value': '1000'},
])
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-root'), (100000, 100000))
def test_mapped_root_ignores_volume_owner_override(self):
service = make_service(unprivileged=True, volume_owner={'uid': 5000, 'gid': 6000})
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-root'), (100000, 100000))
def test_mapped_application_user_default_is_offset_zero(self):
service = make_service(unprivileged=True)
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user'), (100000, 100000))
def test_mapped_application_user_privileged_default_is_root(self):
service = make_service(unprivileged=False)
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user'), (0, 0))
def test_mapped_application_user_with_puid_pgid_unprivileged(self):
"""The bug this guards against, other direction: mapped-root and
mapped-application-user with the SAME PUID/PGID=1000 must give
DIFFERENT results — this one gets the application offset."""
service = make_service(unprivileged=True, environment=[
{'name': 'PUID', 'value': '1000'},
{'name': 'PGID', 'value': '1000'},
])
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user'), (101000, 101000))
def test_mapped_application_user_with_puid_pgid_privileged(self):
service = make_service(unprivileged=False, environment=[
{'name': 'PUID', 'value': '1000'},
{'name': 'PGID', 'value': '1000'},
])
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user'), (1000, 1000))
def test_mapped_application_user_last_matching_environment_name_wins(self):
service = make_service(unprivileged=False, environment=[
{'name': 'PUID', 'value': '1000'},
{'name': 'UID', 'value': '2000'},
{'name': 'PGID', 'value': '1000'},
{'name': 'GROUP_ID', 'value': '3000'},
])
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user'), (2000, 3000))
def test_mapped_application_user_invalid_environment_value_is_safely_zero(self):
service = make_service(unprivileged=False, environment=[
{'name': 'PUID', 'value': 'not-a-number'},
{'name': 'PGID', 'value': '-5'},
])
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user'), (0, 0))
def test_volume_owner_overrides_only_mapped_application_user(self):
"""volume_owner must change only the application-user strategy,
never mapped-root — even when both are resolved for the same
service."""
service = make_service(unprivileged=False, environment=[
{'name': 'PUID', 'value': '1000'},
{'name': 'PGID', 'value': '1000'},
], volume_owner={'uid': 5000, 'gid': 6000})
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user'), (5000, 6000))
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-root'), (0, 0))
def test_invalid_volume_owner_override_is_safely_zero(self):
service = make_service(unprivileged=False, environment=[
{'name': 'PUID', 'value': '1000'},
], volume_owner={'uid': 'bogus'})
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user')[0], 0)
def test_unknown_owner_strategy_fails_clearly(self):
service = make_service(unprivileged=True)
with self.assertRaises(RuntimeError):
igs.resolve_volume_owner(service, 'some-future-strategy')
class ApplyVolumePreparationTests(unittest.TestCase):
def _target(self, tmp, with_lost_found=True):
target = Path(tmp) / 'target'
target.mkdir()
if with_lost_found:
(target / 'lost+found').mkdir()
return target
def test_managed_volume_preparation_removes_lost_found_and_resolves_owner(self):
"""apply_volume_preparation() no longer chowns directly — it
resolves and returns the declared owner for the caller to apply
after the seed copy (see ApplyVolumePreparationOrderingTests)."""
with tempfile.TemporaryDirectory() as tmp:
target = self._target(tmp)
service = make_service(unprivileged=True, volume_preparations=[{
'container_path': '/app/.blinko',
'remove_lost_found': True,
'owner_strategy': 'mapped-root',
'only_when_mount_type': 'managed-volume',
}])
mount = {'container_path': '/app/.blinko', 'type': 'managed-volume'}
applied, owner = igs.apply_volume_preparation(service, mount, target)
self.assertTrue(applied)
self.assertEqual(owner, (100000, 100000))
self.assertFalse((target / 'lost+found').exists())
def test_host_bind_mount_with_managed_volume_only_rule_is_not_applied(self):
"""A declared preparation scoped to only_when_mount_type:
managed-volume must not run against a host-bind mount, even if
the container_path matches."""
with tempfile.TemporaryDirectory() as tmp:
target = self._target(tmp)
lost_found_existed_before = (target / 'lost+found').exists()
service = make_service(unprivileged=True, volume_preparations=[{
'container_path': '/shared/data',
'remove_lost_found': True,
'owner_strategy': 'mapped-root',
'only_when_mount_type': 'managed-volume',
}])
mount = {'container_path': '/shared/data', 'type': 'host-bind'}
applied, owner = igs.apply_volume_preparation(service, mount, target)
self.assertFalse(applied)
self.assertIsNone(owner)
self.assertEqual((target / 'lost+found').exists(), lost_found_existed_before)
def test_final_normalized_postgres_path_is_applied(self):
with tempfile.TemporaryDirectory() as tmp:
target = self._target(tmp)
service = make_service(unprivileged=True, volume_preparations=[{
'container_path': '/var/lib/postgresql',
'remove_lost_found': True,
'owner_strategy': 'mapped-root',
'only_when_mount_type': 'managed-volume',
}])
mount = {'container_path': '/var/lib/postgresql', 'type': 'managed-volume'}
applied, owner = igs.apply_volume_preparation(service, mount, target)
self.assertTrue(applied)
self.assertEqual(owner, (100000, 100000))
self.assertFalse((target / 'lost+found').exists())
def test_pre_rename_path_does_not_match_final_mount(self):
with tempfile.TemporaryDirectory() as tmp:
target = self._target(tmp)
service = make_service(unprivileged=True, volume_preparations=[{
'container_path': '/var/lib/postgresql/data',
'remove_lost_found': True,
'owner_strategy': 'mapped-root',
'only_when_mount_type': 'managed-volume',
}])
mount = {'container_path': '/var/lib/postgresql', 'type': 'managed-volume'}
applied, owner = igs.apply_volume_preparation(service, mount, target)
self.assertFalse(applied)
self.assertIsNone(owner)
self.assertTrue((target / 'lost+found').exists())
def test_no_declared_preparation_leaves_caller_to_fall_back(self):
with tempfile.TemporaryDirectory() as tmp:
target = self._target(tmp)
service = make_service(unprivileged=True, volume_preparations=[])
mount = {'container_path': '/app/.blinko', 'type': 'managed-volume'}
applied, owner = igs.apply_volume_preparation(service, mount, target)
self.assertFalse(applied)
self.assertIsNone(owner)
self.assertTrue((target / 'lost+found').exists())
def test_unknown_owner_strategy_fails_clearly(self):
with tempfile.TemporaryDirectory() as tmp:
target = self._target(tmp)
service = make_service(unprivileged=True, volume_preparations=[{
'container_path': '/app/.blinko',
'remove_lost_found': True,
'owner_strategy': 'some-future-strategy',
'only_when_mount_type': 'managed-volume',
}])
mount = {'container_path': '/app/.blinko', 'type': 'managed-volume'}
with self.assertRaises(RuntimeError):
igs.apply_volume_preparation(service, mount, target)
def test_missing_owner_strategy_fails_clearly(self):
with tempfile.TemporaryDirectory() as tmp:
target = self._target(tmp)
service = make_service(unprivileged=True, volume_preparations=[{
'container_path': '/app/.blinko',
'remove_lost_found': True,
'only_when_mount_type': 'managed-volume',
}])
mount = {'container_path': '/app/.blinko', 'type': 'managed-volume'}
with self.assertRaises(RuntimeError):
igs.apply_volume_preparation(service, mount, target)
def test_mapped_application_user_is_resolved_distinctly_from_mapped_root(self):
"""Integration-level guard for bug #1: the two owner_strategy
values applied to the SAME service (same PUID/PGID) must return
different owners through apply_volume_preparation() itself, not
just through resolve_volume_owner() in isolation."""
with tempfile.TemporaryDirectory() as tmp:
service = make_service(unprivileged=True, environment=[
{'name': 'PUID', 'value': '1000'},
{'name': 'PGID', 'value': '1000'},
], volume_preparations=[
{
'container_path': '/data/root-owned',
'owner_strategy': 'mapped-root',
'only_when_mount_type': 'managed-volume',
},
{
'container_path': '/data/app-owned',
'owner_strategy': 'mapped-application-user',
'only_when_mount_type': 'managed-volume',
},
])
target_root = self._target(tmp, with_lost_found=False)
target_app = Path(tmp) / 'target-app'
target_app.mkdir()
_, owner_root = igs.apply_volume_preparation(
service, {'container_path': '/data/root-owned', 'type': 'managed-volume'}, target_root)
_, owner_app = igs.apply_volume_preparation(
service, {'container_path': '/data/app-owned', 'type': 'managed-volume'}, target_app)
self.assertEqual(owner_root, (100000, 100000))
self.assertEqual(owner_app, (101000, 101000))
self.assertNotEqual(owner_root, owner_app)
class AttachMountsOrderingTests(unittest.TestCase):
"""Guards bug #2 at the source level: attach_mounts() must apply a
declared owner_strategy's chown AFTER the seed cp -a and AFTER its
own fallback os.chown(target, *owner[:2]) — otherwise that always-run
fallback (which exists to preserve image ownership for mounts with
no declared preparation) silently overwrites the declared result.
This does not execute attach_mounts() (it calls pct/LXC); it proves
the source order directly, which is what determines runtime
behaviour here."""
def setUp(self):
self.source = (ROOT / "remote" / "install_generic_stack.py").read_text(encoding="utf-8")
start = self.source.index("def attach_mounts(")
end = self.source.index("\ndef ", start + 1)
self.body = self.source[start:end]
def test_apply_volume_preparation_called_before_seed_copy(self):
self.assertLess(
self.body.index("apply_volume_preparation("),
self.body.index("run('cp','-a',str(seed)"),
)
def test_fallback_chown_runs_before_declared_owner_chown(self):
fallback_index = self.body.index("os.chown(target,*owner[:2])")
declared_index = self.body.index("os.chown(target, *declared_owner)")
self.assertLess(fallback_index, declared_index,
"the declared owner_strategy chown must run AFTER the fallback chown, "
"so it is the mount's final ownership and not silently overwritten")
def test_declared_owner_chown_is_conditional_on_applied(self):
# Must only run when a preparation actually matched — guards
# against unconditionally chowning to a stale/None owner.
declared_index = self.body.index("os.chown(target, *declared_owner)")
guard_index = self.body.rindex("if applied:", 0, declared_index)
self.assertGreater(declared_index, guard_index)
class InstallOciShStackManagedTests(unittest.TestCase):
"""install_oci.sh's own volume_preparations loop must be skipped
only for stack_managed: true services, and must leave generated_files,
self_signed_tls, and every other apply_installer_profile() step
untouched — this is a source-text guard (no bash execution here; a
real stack install run is exercised separately, outside this unit
test file)."""
def setUp(self):
self.source = (ROOT / "remote" / "install_oci.sh").read_text(encoding="utf-8")
def test_volume_preparations_loop_is_gated_on_stack_managed(self):
self.assertIn(
'if (( failed == 0 )) && [[ $(jq -r \'.stack_managed // false\' "$DEPLOYMENT_FILE") != true ]]; then',
self.source,
)
def test_generated_files_loop_is_not_gated_on_stack_managed(self):
marker = "done < <(jq -r '.proxmox.installer_profile.generated_files[]? | @base64' \"$TEMPLATE_FILE\")"
self.assertIn(marker, self.source)
gate_index = self.source.index(
'if (( failed == 0 )) && [[ $(jq -r \'.stack_managed // false\' "$DEPLOYMENT_FILE") != true ]]; then'
)
self.assertLess(self.source.index(marker), gate_index,
"generated_files loop must run before, and outside, the new stack_managed gate")
def test_self_signed_tls_block_is_not_gated_on_stack_managed(self):
marker = "tls_count == 1"
self.assertIn(marker, self.source)
gate_index = self.source.index(
'if (( failed == 0 )) && [[ $(jq -r \'.stack_managed // false\' "$DEPLOYMENT_FILE") != true ]]; then'
)
self.assertGreater(self.source.index(marker), gate_index,
"self_signed_tls must remain its own block after the volume_preparations gate, unaffected by it")
def test_pre_start_repairs_and_volume_seeds_are_not_gated(self):
self.assertIn("pre_start_repairs", self.source)
self.assertIn("apply_volume_seeds", self.source)
if __name__ == "__main__":
unittest.main()
+140
View File
@@ -0,0 +1,140 @@
"""Regression test for volume_preparations survival through the generic
multi-LXC stack pipeline (apply_stack_support + service_template).
install_generic_stack.py::create_service() writes each service's own
service['template'] to a per-service template.json and runs install_oci.sh
against that file independently — the parent stack template's
installer_profile is never read at install time. So the fix must land in
each service's own template, not just the parent's top-level
installer_profile (that alone would be a cosmetic data match with no real
effect). This is the regression guard for the stack volume_preparations
loss first found via read-only audit (2026-09-29): apply_stack_support()
replaced installer_profile wholesale and silently dropped
volume_preparations for every stack app (Blinko, Kimai, Linkwarden, Monica
Official, Petio, Qui, RomM, Teable).
"""
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.stack import build_stack, DefaultsUI
class StackVolumePreparationsTests(unittest.TestCase):
def test_stack_service_receives_only_its_own_volume_preparation(self):
"""Blinko (2-service stack: blinko + blinko-postgres). The real
build_stack() output — what create_service() actually writes to
each service's template.json — must carry the preparation for
/app/.blinko into the blinko service's own template, scoped to
only_when_mount_type: managed-volume, and must NOT leak it (or any
other service's paths) into blinko-postgres's template, which has
no volume_preparations entry of its own in the source catalog."""
template = Catalog(ROOT).compose("blinko")
result = build_stack(template, DefaultsUI(), mode="simple")
services_by_name = {s["name"]: s for s in result["services"]}
self.assertEqual(set(services_by_name), {"blinko", "blinko-postgres"})
blinko_preps = services_by_name["blinko"]["template"]["proxmox"] \
.get("installer_profile", {}).get("volume_preparations", [])
self.assertEqual(len(blinko_preps), 1)
self.assertEqual(blinko_preps[0]["container_path"], "/app/.blinko")
self.assertEqual(blinko_preps[0]["only_when_mount_type"], "managed-volume")
postgres_preps = services_by_name["blinko-postgres"]["template"]["proxmox"] \
.get("installer_profile", {}).get("volume_preparations", [])
self.assertEqual(postgres_preps, [])
def test_synthetic_dependency_volume_preparation_reaches_only_that_service(self):
"""Blinko's postgres dependency service (blinko-postgres, image
postgres:latest) has its own Compose-declared mount,
/var/lib/postgresql/data, which build_stack() normalizes to
/var/lib/postgresql for PostgreSQL 18+ (see the 'Official
PostgreSQL 18+' comment in build_stack()) — this mount is NOT one
of the DEPENDENCY_VOLUMES fallback entries, it comes straight from
the source Compose file. A synthetic volume_preparations entry for
the final, normalized mount target (/var/lib/postgresql) is
injected on the parent template to verify it reaches only the
postgres service's own template — not blinko's, and not lost. This
test is only about correct metadata transfer through
service_template()/build_stack(); it makes no claim about whether
preparing a database data directory this way is runtime-appropriate."""
template = Catalog(ROOT).compose("blinko")
template["proxmox"]["installer_profile"].setdefault("volume_preparations", [])
template["proxmox"]["installer_profile"]["volume_preparations"].append({
"container_path": "/var/lib/postgresql",
"remove_lost_found": True,
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume",
})
result = build_stack(template, DefaultsUI(), mode="simple")
services_by_name = {s["name"]: s for s in result["services"]}
postgres_preps = services_by_name["blinko-postgres"]["template"]["proxmox"] \
.get("installer_profile", {}).get("volume_preparations", [])
self.assertEqual(
[p["container_path"] for p in postgres_preps],
["/var/lib/postgresql"],
"The synthetic preparation for the final, normalized postgres "
"mount target must reach the postgres service's own template.",
)
self.assertEqual(
postgres_preps[0]["container_path"],
services_by_name["blinko-postgres"]["template"]["container_contract"]["volumes"][0]["container_path"],
"The preparation's container_path must match the service's own "
"final mount path exactly, not an intermediate/pre-rename form.",
)
blinko_preps = services_by_name["blinko"]["template"]["proxmox"] \
.get("installer_profile", {}).get("volume_preparations", [])
self.assertEqual(
[p["container_path"] for p in blinko_preps],
["/app/.blinko"],
"The synthetic postgres-only preparation must not leak into "
"blinko's own template.",
)
def test_synthetic_dependency_volume_preparation_keyed_to_the_pre_rename_path_is_also_normalized(self):
"""The same postgres mount, but the synthetic preparation is keyed
to the pre-rename Compose path (/var/lib/postgresql/data) instead
of the final one. It must still reach blinko-postgres's own
template, remapped to the same final container_path
(/var/lib/postgresql) that build_stack() actually mounts — a
preparation entry whose path no longer matches any real mount is
dead configuration at install time. This test is only about
correct metadata transfer; it makes no claim about runtime
appropriateness."""
template = Catalog(ROOT).compose("blinko")
template["proxmox"]["installer_profile"].setdefault("volume_preparations", [])
template["proxmox"]["installer_profile"]["volume_preparations"].append({
"container_path": "/var/lib/postgresql/data",
"remove_lost_found": True,
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume",
})
result = build_stack(template, DefaultsUI(), mode="simple")
services_by_name = {s["name"]: s for s in result["services"]}
postgres_preps = services_by_name["blinko-postgres"]["template"]["proxmox"] \
.get("installer_profile", {}).get("volume_preparations", [])
self.assertEqual(
[p["container_path"] for p in postgres_preps],
["/var/lib/postgresql"],
"A preparation keyed to the pre-rename Compose path must be "
"remapped to the same final path build_stack() actually mounts, "
"not silently dropped and not left pointing at the stale path.",
)
self.assertEqual(
postgres_preps[0]["container_path"],
services_by_name["blinko-postgres"]["template"]["container_contract"]["volumes"][0]["container_path"],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,80 @@
"""Regression tests for volume_preparations coverage across generated and
curated-profile catalog entries.
Covers the original OCI lost+found fix: a fresh ext4 managed volume carries
a lost+found directory, which stops images that take ownership of their own
data directories. volume_preparations (remove_lost_found +
only_when_mount_type: managed-volume) must be present for every persistent
mount, for both regular generated apps/*.json entries and curated-profile
entries (curated/*.json) — the curated-profile gap (Jellyfin Official first
surfaced it) is exactly what this suite guards against regressing.
"""
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from proxmenux_oci.catalog import Catalog
class VolumePreparationsCoverageTests(unittest.TestCase):
def _preparations_by_path(self, template):
preparations = template["proxmox"]["installer_profile"].get("volume_preparations", [])
return {item["container_path"]: item for item in preparations}
def test_generated_app_volume_preparations_match_persistent_mounts(self):
"""Regular generated app (Uptime Kuma, apps/uptimekuma.json): every
declared volume has a matching volume_preparations entry, and every
entry is scoped to only_when_mount_type: managed-volume."""
template = Catalog(ROOT).compose("uptimekuma")
volume_paths = {v["container_path"] for v in template["container_contract"].get("volumes", [])}
preparations = self._preparations_by_path(template)
self.assertTrue(volume_paths, "fixture app must declare at least one volume")
self.assertEqual(set(preparations.keys()), volume_paths)
for path, entry in preparations.items():
self.assertTrue(entry.get("remove_lost_found"))
self.assertEqual(entry.get("only_when_mount_type"), "managed-volume")
def test_curated_profile_app_volume_preparations_match_persistent_mounts(self):
"""Curated-profile app (Jellyfin Official, curated/jellyfin-official.json,
3 mounts: /config, /cache, /media): same coverage requirement as
generated apps. This is the regression guard for the gap first found
via live testing — the original fix only touched apps/*.json and
missed curated/*.json entirely."""
template = Catalog(ROOT).compose("jellyfin-official")
volume_paths = {v["container_path"] for v in template["container_contract"].get("volumes", [])}
preparations = self._preparations_by_path(template)
self.assertEqual(volume_paths, {"/config", "/cache", "/media"})
self.assertEqual(set(preparations.keys()), volume_paths)
for path, entry in preparations.items():
self.assertTrue(entry.get("remove_lost_found"))
self.assertEqual(entry.get("only_when_mount_type"), "managed-volume")
def test_overlay_app_volume_preparations_match_the_overlay_remapped_mount(self):
"""PocketBase (apps/pocketbase.json + overlays/pocketbase.json): the
overlay remaps the container_path from /pb_data to
/pocketbase/pb_data. volume_preparations must follow the overlay's
remapped path, not the pre-overlay source path — this is the
regression guard for the overlay/mount desync first found via
read-only audit (2026-09-29)."""
template = Catalog(ROOT).compose("pocketbase")
volume_paths = {v["container_path"] for v in template["container_contract"].get("volumes", [])}
preparations = self._preparations_by_path(template)
self.assertEqual(volume_paths, {"/pocketbase/pb_data"})
self.assertEqual(set(preparations.keys()), volume_paths)
for path, entry in preparations.items():
self.assertTrue(entry.get("remove_lost_found"))
self.assertEqual(entry.get("only_when_mount_type"), "managed-volume")
if __name__ == "__main__":
unittest.main()