diff --git a/oci/catalog/apps/filebrowser-quantum.json b/oci/catalog/apps/filebrowser-quantum.json index e8c88aab..e22d2981 100644 --- a/oci/catalog/apps/filebrowser-quantum.json +++ b/oci/catalog/apps/filebrowser-quantum.json @@ -304,7 +304,7 @@ "mode": "0644", "owner": "mapped-application-user", "only_if_missing": true, - "content": "server:\n port: 80\n cacheDir: /home/filebrowser/data/tmp\n sources:\n - path: /folder\n config:\n defaultEnabled: true\nauth:\n adminUsername: admin\n" + "content": "http:\n port: 80\nserver:\n cacheDir: /home/filebrowser/data/tmp\n sources:\n - path: /folder\n config:\n defaultEnabled: true\nauth:\n adminUsername: admin\n" } ], "startup_healthcheck": { diff --git a/oci/catalog/curated/filebrowser-quantum.json b/oci/catalog/curated/filebrowser-quantum.json index 6fc1c187..f75a34ff 100644 --- a/oci/catalog/curated/filebrowser-quantum.json +++ b/oci/catalog/curated/filebrowser-quantum.json @@ -304,7 +304,7 @@ "mode": "0644", "owner": "mapped-application-user", "only_if_missing": true, - "content": "server:\n port: 80\n cacheDir: /home/filebrowser/data/tmp\n sources:\n - path: /folder\n config:\n defaultEnabled: true\nauth:\n adminUsername: admin\n" + "content": "http:\n port: 80\nserver:\n cacheDir: /home/filebrowser/data/tmp\n sources:\n - path: /folder\n config:\n defaultEnabled: true\nauth:\n adminUsername: admin\n" } ], "startup_healthcheck": { diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 3b2e11c6..53c23b5a 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -330,6 +330,8 @@ INSTALL_COMPLETE=0 PRESERVE_FAILED_CT=0 VOLUME_SEED_STAGE_ROOT="" VOLUME_SEED_TARGETS=() +# The volumes that have just been filled with what the image has at their path. +VOLUME_SEEDED_NOW=() VOLUME_SEED_STAGES=() VOLUME_SEED_MOUNT_TYPES=() CANONICAL_INPUT_DIR="" @@ -553,12 +555,23 @@ apply_volume_seeds() { || { oci_log "Could not seed the persistent volume from the image: $target"; failed=1; break; } rm -rf -- "$stage" \ || { oci_log "Could not remove the temporary image data: $target"; failed=1; break; } + VOLUME_SEEDED_NOW+=("$target") oci_log "Image data copied into the new persistent volume: $target" done cleanup_volume_seed_staging || { oci_log "Could not remove the temporary image-volume staging directory"; failed=1; } (( failed == 0 )) } +# Whether a path sits in a volume that has just been filled from the image. +# What is there is the copy the image ships, not a file the user kept. +filled_from_the_image() { + local path=$1 target + for target in "${VOLUME_SEEDED_NOW[@]}"; do + [[ $path == "$target"/* ]] && return 0 + done + return 1 +} + prepare_file_mount_target() { local requested_target=$1 local rootfs="/var/lib/lxc/${VMID}/rootfs" @@ -1162,8 +1175,13 @@ apply_installer_profile() { rm -f "$merged_file" fi if [[ $(jq -r '.only_if_missing // false' <<<"$item") == true && -e $destination ]]; then - oci_log "Keeping the existing persistent file: $path" - continue + if ! filled_from_the_image "$path"; then + oci_log "Keeping the existing persistent file: $path" + continue + fi + # The file of the recipe is the starting configuration: it takes the + # place of the one the image ships, never of one the user already had. + oci_log "The generated file replaces the one of the image: $path" fi install -D -m "$mode" /dev/null "$destination" || { failed=1; break; } jq -er '.content' <<<"$item" >"$destination" || { failed=1; break; } diff --git a/oci/tests/test_generated_file_over_image_copy.py b/oci/tests/test_generated_file_over_image_copy.py new file mode 100644 index 00000000..b63da17f --- /dev/null +++ b/oci/tests/test_generated_file_over_image_copy.py @@ -0,0 +1,73 @@ +"""A new volume starts with what the image has at its path, and a recipe may +write its own starting file in it. The file of the recipe wins over the copy of +the image; a file the user already had, in a reused volume or a host +directory, is kept.""" + +from pathlib import Path +import re +import shutil +import subprocess +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from proxmenux_oci.catalog import Catalog + +INSTALLER = (ROOT / "remote/install_oci.sh").read_text() + + +def function(name): + found = re.search(rf"^{name}\(\) \{{\n.*?^\}}\n", INSTALLER, re.S | re.M) + assert found, name + return found.group(0) + + +class GeneratedFileTests(unittest.TestCase): + def filled(self, path, *volumes): + script = f'VOLUME_SEEDED_NOW=({" ".join(volumes)})\n{function("filled_from_the_image")}\nfilled_from_the_image "$1" && echo yes || echo no' + done = subprocess.run(["bash", "-c", script, "bash", path], capture_output=True, text=True, check=False) + self.assertEqual(done.stderr, "") + return done.stdout.strip() + + @unittest.skipUnless(shutil.which("bash"), "bash is needed") + def test_a_file_in_a_volume_just_filled_is_the_copy_of_the_image(self): + self.assertEqual(self.filled("/home/filebrowser/data/config.yaml", "/home/filebrowser/data"), "yes") + self.assertEqual(self.filled("/config/app/settings.yml", "/data", "/config"), "yes") + + @unittest.skipUnless(shutil.which("bash"), "bash is needed") + def test_a_file_anywhere_else_is_not(self): + self.assertEqual(self.filled("/home/filebrowser/data/config.yaml"), "no") + self.assertEqual(self.filled("/home/filebrowser/database/config.yaml", "/home/filebrowser/data"), "no") + self.assertEqual(self.filled("/home/filebrowser/data", "/home/filebrowser/data"), "no") + + def test_only_a_volume_that_was_filled_is_recorded(self): + body = function("apply_volume_seeds") + copied = body.index('cp -a -- "${stage}/." "${destination}/"') + self.assertLess(copied, body.index('VOLUME_SEEDED_NOW+=("$target")')) + # A host directory that already holds data is kept and not recorded. + self.assertLess(body.index("Keeping existing host data without seeding it"), copied) + + def test_the_recipe_file_is_written_over_the_copy_of_the_image_only(self): + step = INSTALLER[INSTALLER.index("generated_count=$(jq"):INSTALLER.index("generated_created=$((generated_created + 1))")] + self.assertLess(step.index("apply_volume_seeds"), step.index("only_if_missing")) + kept = step.index('if ! filled_from_the_image "$path"; then') + self.assertLess(kept, step.index("Keeping the existing persistent file")) + self.assertLess(step.index("Keeping the existing persistent file"), step.index("The generated file replaces the one of the image")) + + def test_filebrowser_quantum_serves_the_folder_it_mounts(self): + template = Catalog(ROOT).compose("filebrowser-quantum") + volumes = [item["container_path"] for item in template["container_contract"]["volumes"]] + generated = template["proxmox"]["installer_profile"]["generated_files"][0] + served = re.search(r"sources:\s*\n\s*- path: (\S+)", generated["content"]).group(1) + self.assertIn(served, volumes) + self.assertTrue(any(generated["container_path"].startswith(volume + "/") for volume in volumes)) + self.assertTrue(generated["only_if_missing"]) + # FileBrowser Quantum 2 reads the port under http, not under server. + self.assertRegex(generated["content"], r"(?m)^http:\n port: 80$") + self.assertNotRegex(generated["content"], r"(?m)^server:\n port:") + + +if __name__ == "__main__": + unittest.main()