// Copyright (C) 2026 Viorel Munteanu // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation; either version 2 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. // [unit]: exercises every syntax form load_compose_file() (compose_file.h) // is meant to support, plus its validation errors, against small // hand-written compose YAML snippets -- deliberately not the checked-in // test-compose/compose.yaml (that file exists to pin down which subset of // Compose this project supports and to drive later, higher-level // integration tests once an orchestrator exists; its content is expected to // keep changing as more of that gets built, so pinning per-field unit // assertions to it would be brittle). #include #include #include #include "compose_file.h" #include "fixtures.h" namespace { std::filesystem::path write_compose(const std::filesystem::path& dir, const std::string& content) { auto path = dir / "compose.yaml"; std::ofstream out(path); out << content; return path; } } // namespace TEST_CASE("compose file: minimal service parses", "[unit]") { ScratchXdgDirs scratch; auto path = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n"); auto compose = load_compose_file(path); REQUIRE(compose.has_value()); REQUIRE(compose->services.size() == 1); CHECK(compose->services[0].name == "web"); CHECK(compose->services[0].image == "busybox:latest"); CHECK_FALSE(compose->services[0].container_name.has_value()); CHECK(compose->services[0].command.empty()); } TEST_CASE("compose file: missing/empty services section is an error", "[unit]") { ScratchXdgDirs scratch; CHECK_FALSE(load_compose_file(write_compose(scratch.path(), "networks:\n n1: {}\n")).has_value()); CHECK_FALSE(load_compose_file(write_compose(scratch.path(), "services: {}\n")).has_value()); } TEST_CASE("compose file: a service with no image is an error", "[unit]") { ScratchXdgDirs scratch; auto path = write_compose(scratch.path(), "services:\n" " web:\n" " command: [\"true\"]\n"); CHECK_FALSE(load_compose_file(path).has_value()); } TEST_CASE("compose file: duplicate service name is an error", "[unit]") { ScratchXdgDirs scratch; // A duplicate YAML mapping key -- libyaml keeps both pairs rather than // deduplicating, so this reaches load_compose_file()'s own explicit // duplicate-name check. auto path = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " web:\n" " image: busybox:1.36\n"); CHECK_FALSE(load_compose_file(path).has_value()); } TEST_CASE("compose file: container_name -- optional, and duplicates across services are an error", "[unit]") { ScratchXdgDirs scratch; auto ok = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " container_name: web1\n"); auto loaded = load_compose_file(ok); REQUIRE(loaded.has_value()); REQUIRE(loaded->services[0].container_name.has_value()); CHECK(*loaded->services[0].container_name == "web1"); auto colliding = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " container_name: shared\n" " worker:\n" " image: busybox:latest\n" " container_name: shared\n"); CHECK_FALSE(load_compose_file(colliding).has_value()); // A service's own explicit container_name colliding with a *different* // service's implicit identity (its own name, since it has no // container_name of its own) -- also an error, not just two explicit // container_names matching each other. auto colliding_with_name = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " worker:\n" " image: busybox:latest\n" " container_name: web\n"); CHECK_FALSE(load_compose_file(colliding_with_name).has_value()); } TEST_CASE("compose file: user -- optional, with and without a group, split the same way as an image's own USER", "[unit]") { ScratchXdgDirs scratch; auto unset = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n"); auto loaded_unset = load_compose_file(unset); REQUIRE(loaded_unset.has_value()); CHECK_FALSE(loaded_unset->services[0].user.has_value()); CHECK_FALSE(loaded_unset->services[0].group.has_value()); auto user_only = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " user: \"1000\"\n"); auto loaded_user_only = load_compose_file(user_only); REQUIRE(loaded_user_only.has_value()); REQUIRE(loaded_user_only->services[0].user.has_value()); CHECK(*loaded_user_only->services[0].user == "1000"); CHECK_FALSE(loaded_user_only->services[0].group.has_value()); auto user_and_group = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " user: \"1000:1000\"\n"); auto loaded_both = load_compose_file(user_and_group); REQUIRE(loaded_both.has_value()); REQUIRE(loaded_both->services[0].user.has_value()); CHECK(*loaded_both->services[0].user == "1000"); REQUIRE(loaded_both->services[0].group.has_value()); CHECK(*loaded_both->services[0].group == "1000"); auto empty_user = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " user: \"\"\n"); CHECK_FALSE(load_compose_file(empty_user).has_value()); } TEST_CASE("compose file: command -- list form used as-is, scalar form wrapped in sh -c", "[unit]") { ScratchXdgDirs scratch; auto list_form = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " command: [\"sh\", \"/scripts/run.sh\"]\n"); auto loaded_list = load_compose_file(list_form); REQUIRE(loaded_list.has_value()); CHECK(loaded_list->services[0].command == std::vector{"sh", "/scripts/run.sh"}); auto scalar_form = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " command: \"echo hi\"\n"); auto loaded_scalar = load_compose_file(scalar_form); REQUIRE(loaded_scalar.has_value()); CHECK(loaded_scalar->services[0].command == std::vector{"sh", "-c", "echo hi"}); } TEST_CASE("compose file: environment -- list and mapping forms, env_file resolved absolute and ordered first", "[unit]") { ScratchXdgDirs scratch; auto list_form = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " env_file:\n" " - ./web.env\n" " environment:\n" " - FOO=bar\n" " - BAZ=qux\n"); auto loaded_list = load_compose_file(list_form); REQUIRE(loaded_list.has_value()); const auto& specs_list = loaded_list->services[0].environment_specs; REQUIRE(specs_list.size() == 3); CHECK(specs_list[0].is_file); CHECK(specs_list[0].value == (scratch.path() / "web.env").lexically_normal().string()); CHECK_FALSE(specs_list[1].is_file); CHECK(specs_list[1].value == "FOO=bar"); CHECK(specs_list[2].value == "BAZ=qux"); auto mapping_form = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " env_file: ./web.env\n" " environment:\n" " FOO: bar\n"); auto loaded_mapping = load_compose_file(mapping_form); REQUIRE(loaded_mapping.has_value()); const auto& specs_mapping = loaded_mapping->services[0].environment_specs; REQUIRE(specs_mapping.size() == 2); CHECK(specs_mapping[0].is_file); CHECK_FALSE(specs_mapping[1].is_file); CHECK(specs_mapping[1].value == "FOO=bar"); // A null/omitted mapping value ("host environment passthrough" in real // Compose) is indistinguishable from an explicit empty string through // libyaml's own document API -- accepted as a literal empty value // rather than erroring or actually inheriting anything (see // compose_file.h's own doc comment on ComposeService::environment_specs). auto null_value = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " environment:\n" " FOO:\n"); auto loaded_null = load_compose_file(null_value); REQUIRE(loaded_null.has_value()); REQUIRE(loaded_null->services[0].environment_specs.size() == 1); CHECK(loaded_null->services[0].environment_specs[0].value == "FOO="); } TEST_CASE("compose file: depends_on -- long and short forms, condition handling, cross-validation", "[unit]") { ScratchXdgDirs scratch; auto long_form = write_compose(scratch.path(), "services:\n" " worker:\n" " image: busybox:latest\n" " web:\n" " image: busybox:latest\n" " depends_on:\n" " worker:\n" " condition: service_healthy\n"); auto loaded_long = load_compose_file(long_form); REQUIRE(loaded_long.has_value()); // Services are collected in the same order they appear in the YAML. REQUIRE(loaded_long->services.size() == 2); CHECK(loaded_long->services[1].name == "web"); CHECK(loaded_long->services[1].depends_on == std::vector{"worker"}); auto short_form = write_compose(scratch.path(), "services:\n" " worker:\n" " image: busybox:latest\n" " web:\n" " image: busybox:latest\n" " depends_on: [\"worker\"]\n"); CHECK(load_compose_file(short_form).has_value()); auto bad_condition = write_compose(scratch.path(), "services:\n" " worker:\n" " image: busybox:latest\n" " web:\n" " image: busybox:latest\n" " depends_on:\n" " worker:\n" " condition: service_completed_successfully\n"); CHECK_FALSE(load_compose_file(bad_condition).has_value()); auto self_dependency = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " depends_on: [\"web\"]\n"); CHECK_FALSE(load_compose_file(self_dependency).has_value()); auto undeclared = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " depends_on: [\"ghost\"]\n"); CHECK_FALSE(load_compose_file(undeclared).has_value()); } TEST_CASE("compose file: depends_on cycles are rejected, acyclic graphs are not", "[unit]") { ScratchXdgDirs scratch; // A 2-service direct cycle (a -> b -> a) -- distinct from the // already-tested direct self-reference (a -> a). auto two_cycle = write_compose(scratch.path(), "services:\n" " a:\n" " image: busybox:latest\n" " depends_on: [\"b\"]\n" " b:\n" " image: busybox:latest\n" " depends_on: [\"a\"]\n"); CHECK_FALSE(load_compose_file(two_cycle).has_value()); // A longer, 3-service cycle (a -> b -> c -> a). auto three_cycle = write_compose(scratch.path(), "services:\n" " a:\n" " image: busybox:latest\n" " depends_on: [\"b\"]\n" " b:\n" " image: busybox:latest\n" " depends_on: [\"c\"]\n" " c:\n" " image: busybox:latest\n" " depends_on: [\"a\"]\n"); CHECK_FALSE(load_compose_file(three_cycle).has_value()); // A valid, acyclic multi-service graph (a diamond: d depends on both b // and c, both of which depend on a) must still load successfully. auto acyclic = write_compose(scratch.path(), "services:\n" " a:\n" " image: busybox:latest\n" " b:\n" " image: busybox:latest\n" " depends_on: [\"a\"]\n" " c:\n" " image: busybox:latest\n" " depends_on: [\"a\"]\n" " d:\n" " image: busybox:latest\n" " depends_on: [\"b\", \"c\"]\n"); CHECK(load_compose_file(acyclic).has_value()); } TEST_CASE("compose file: stop_grace_period parses durations, rejects malformed ones", "[unit]") { ScratchXdgDirs scratch; auto simple = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " stop_grace_period: 20s\n"); auto loaded_simple = load_compose_file(simple); REQUIRE(loaded_simple.has_value()); REQUIRE(loaded_simple->services[0].stop_grace_period_seconds.has_value()); CHECK(*loaded_simple->services[0].stop_grace_period_seconds == 20); auto combined = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " stop_grace_period: 1m30s\n"); auto loaded_combined = load_compose_file(combined); REQUIRE(loaded_combined.has_value()); CHECK(*loaded_combined->services[0].stop_grace_period_seconds == 90); auto malformed = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " stop_grace_period: not-a-duration\n"); CHECK_FALSE(load_compose_file(malformed).has_value()); } TEST_CASE("compose file: service networks -- list and mapping forms, undeclared reference is an error", "[unit]") { ScratchXdgDirs scratch; auto list_form = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " networks:\n" " - net1\n" "networks:\n" " net1: {}\n"); auto loaded_list = load_compose_file(list_form); REQUIRE(loaded_list.has_value()); CHECK(loaded_list->services[0].networks == std::vector{"net1"}); auto mapping_form = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " networks:\n" " net1:\n" " aliases: [\"alt-name\"]\n" "networks:\n" " net1: {}\n"); auto loaded_mapping = load_compose_file(mapping_form); REQUIRE(loaded_mapping.has_value()); CHECK(loaded_mapping->services[0].networks == std::vector{"net1"}); auto undeclared = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " networks:\n" " - ghost\n"); CHECK_FALSE(load_compose_file(undeclared).has_value()); } TEST_CASE("compose file: top-level networks -- internal/external, duplicate name is an error", "[unit]") { ScratchXdgDirs scratch; auto path = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" "networks:\n" " net-intern:\n" " internal: true\n" " net-extern:\n" " internal: false\n" " net-preexisting:\n" " external: true\n"); auto loaded = load_compose_file(path); REQUIRE(loaded.has_value()); REQUIRE(loaded->networks.size() == 3); auto find_network = [&](const std::string& name) -> const ComposeNetwork& { for (const auto& n : loaded->networks) { if (n.name == name) { return n; } } FAIL("network not found: " + name); throw std::runtime_error("unreachable"); }; CHECK(find_network("net-intern").mode == ComposeNetworkMode::managed); CHECK(find_network("net-intern").internal == true); CHECK(find_network("net-extern").mode == ComposeNetworkMode::managed); CHECK(find_network("net-extern").internal == false); CHECK(find_network("net-preexisting").mode == ComposeNetworkMode::external); auto duplicate = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" "networks:\n" " net1: {}\n" " net1:\n" " internal: true\n"); CHECK_FALSE(load_compose_file(duplicate).has_value()); } TEST_CASE("compose file: ports -- list and scalar forms reuse parse_port_forward_spec()", "[unit]") { ScratchXdgDirs scratch; auto list_form = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " ports:\n" " - \"18080:80\"\n"); auto loaded_list = load_compose_file(list_form); REQUIRE(loaded_list.has_value()); REQUIRE(loaded_list->services[0].ports.size() == 1); CHECK_FALSE(loaded_list->services[0].ports[0].network.has_value()); CHECK(loaded_list->services[0].ports[0].host_port == 18080); CHECK(loaded_list->services[0].ports[0].container_port == 80); auto scalar_form = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " ports: \"18080:80\"\n"); CHECK(load_compose_file(scalar_form).has_value()); auto bad = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " ports:\n" " - \"not-a-port\"\n"); CHECK_FALSE(load_compose_file(bad).has_value()); } TEST_CASE("compose file: duplicate host-port/protocol across (or within) services is an error", "[unit]") { ScratchXdgDirs scratch; auto across_services = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " ports:\n" " - \"8080:80\"\n" " web2:\n" " image: busybox:latest\n" " ports:\n" " - \"8080:81\"\n"); CHECK_FALSE(load_compose_file(across_services).has_value()); auto within_one_service = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " ports:\n" " - \"8080:80\"\n" " - \"8080:81\"\n"); CHECK_FALSE(load_compose_file(within_one_service).has_value()); // Same host port, different protocols -- must still succeed, matching // port_forward.h's own existing "same port pair once per protocol" // precedent (e.g. a DNS-like service forwarding both tcp and udp). auto different_protocols = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " ports:\n" " - \"53:53/tcp\"\n" " - \"53:53/udp\"\n"); CHECK(load_compose_file(different_protocols).has_value()); } TEST_CASE("compose file: service volumes -- bind mounts (absolute-resolved, ro), named volume references", "[unit]") { ScratchXdgDirs scratch; auto path = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " volumes:\n" " - ./scripts:/scripts:ro\n" " - applog:/var/log\n" "volumes:\n" " applog:\n"); auto loaded = load_compose_file(path); REQUIRE(loaded.has_value()); REQUIRE(loaded->services[0].volumes.size() == 2); const auto& bind = loaded->services[0].volumes[0]; CHECK_FALSE(bind.is_named_volume); CHECK(bind.source == (scratch.path() / "scripts").lexically_normal().string()); CHECK(bind.target == "/scripts"); CHECK(bind.read_only); const auto& named = loaded->services[0].volumes[1]; CHECK(named.is_named_volume); CHECK(named.source == "applog"); CHECK(named.target == "/var/log"); CHECK_FALSE(named.read_only); auto undeclared_volume = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " volumes:\n" " - ghostvol:/data\n"); CHECK_FALSE(load_compose_file(undeclared_volume).has_value()); auto relative_target = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " volumes:\n" " - ./scripts:relative/path\n"); CHECK_FALSE(load_compose_file(relative_target).has_value()); } TEST_CASE("compose file: duplicate top-level volume name is an error", "[unit]") { ScratchXdgDirs scratch; // A duplicate YAML mapping key under volumes: -- same shape as the // already-tested duplicate service name (libyaml keeps both pairs // rather than deduplicating). auto path = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" "volumes:\n" " applog:\n" " applog:\n"); CHECK_FALSE(load_compose_file(path).has_value()); } TEST_CASE("compose file: validate_compose_external_state -- env_file existence, external network existence", "[unit]") { ScratchXdgDirs scratch; auto path = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " env_file: ./web.env\n" "networks:\n" " net-managed:\n" " internal: false\n" " net-preexisting:\n" " external: true\n"); auto loaded = load_compose_file(path); REQUIRE(loaded.has_value()); // env_file doesn't exist yet, and the external network isn't declared // anywhere in persistent_config -- both must fail, one at a time // (fail-fast, same convention load_compose_file()'s own cross-validation // already uses). AppConfig empty_persistent; CHECK_FALSE(validate_compose_external_state(*loaded, empty_persistent)); // Create the env_file -- still fails, since the external network still // isn't declared. { std::ofstream env_file(scratch.path() / "web.env"); env_file << "FOO=bar\n"; } CHECK_FALSE(validate_compose_external_state(*loaded, empty_persistent)); // Declare the external network -- now both checks pass. A managed // (non-external) network, net-managed, is never checked against // persistent_config at all, so its absence there doesn't matter. AppConfig with_network; with_network.networks.push_back({"net-preexisting", NetworkKind::intern, "10.169.10.0/24", false, "", true}); CHECK(validate_compose_external_state(*loaded, with_network)); // A directory in place of the env_file still fails -- not a regular file. auto dir_env_file = write_compose(scratch.path(), "services:\n" " web:\n" " image: busybox:latest\n" " env_file: ./a-directory\n"); auto loaded_dir = load_compose_file(dir_env_file); REQUIRE(loaded_dir.has_value()); std::filesystem::create_directory(scratch.path() / "a-directory"); CHECK_FALSE(validate_compose_external_state(*loaded_dir, AppConfig{})); }