// 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. // [integration][root][net]: exercises the full -u/--up -> -d/--down // lifecycle against the real, checked-in test-compose/compose.yaml // skeleton and its worker/server scripts -- not a scratch-written // snippet, since the whole point is to test this project's own // hand-maintained "does the orchestrator actually work end to end" // fixture. Needs root (real bridges/namespaces/iptables/port-forwarding) // and a real busybox fixture (find_busybox_fixture()) -- SKIPs cleanly on // either missing. // // Deliberately does NOT use ScratchXdgDirs (unlike every other // [root][net] test in this suite): test-compose/compose.yaml's own // `test-preexisting-net` is an `external: true` network, meaning it's // explicitly the *user's own* responsibility to create ahead of time -- // real Compose's own convention for that field -- so this test creates it // once against the real, persistent config (if it isn't already there) // and leaves it standing afterward, exactly like a real user would set it // up once and keep reusing it, rather than tearing it down and recreating // it on every run. Every *managed* resource this test's own -u/--up run // creates (test-compose_test-intern-net, test-compose_test-extern-net, // test-compose_test-server-log) is already "test-"-prefixed by // compose_project_name(), matching this project's own established test- // naming convention, so there's no realistic collision risk with a real // (non-test) invocation either way -- and this test's own -d/--down plus // an explicit final volume deletion leave no managed residue behind. #include #include #include #include #include #include #include #include #include #include #include #include #include "cli_args.h" #include "commands.h" #include "compose_orchestrator.h" #include "config_file.h" #include "fixtures.h" #include "pid_file.h" #include "process.h" namespace { // Mirrors exactly what a real CLI invocation would see: main()'s own merge // of the effective global config (g_test_app_config, fixtures.h) with // persistent.yaml's own current volumes/networks, freshly reloaded from // disk -- same pattern test_network_join_scenarios.cpp's own reload_config() // already uses, needed since create_network_command()/compose_up_command() // etc. always read/write persistent_file_path() directly. AppConfig reload_config() { AppConfig config = g_test_app_config; if (auto persistent = load_persistent_config(persistent_file_path())) { config.volumes = persistent->volumes; config.networks = persistent->networks; } return config; } bool has_network(const AppConfig& config, const std::string& name) { return std::any_of(config.networks.begin(), config.networks.end(), [&](const NetworkEntry& n) { return n.name == name; }); } bool has_volume(const AppConfig& config, const std::string& name) { return std::any_of(config.volumes.begin(), config.volumes.end(), [&](const VolumeEntry& v) { return v.name == name; }); } // Creates the real, persistent "test-preexisting-net" (--intern, an // explicit test-range subnet) if it doesn't already exist -- see this // file's own top comment for why this one network is deliberately not // scratch-isolated or torn down at the end. bool ensure_test_preexisting_network() { AppConfig config = reload_config(); if (has_network(config, "test-preexisting-net")) { return true; } ParsedArgs args; args.mode = Mode::network; args.network_specs = {"test-preexisting-net"}; args.network_intern_flag = true; args.network_with_ipv6_flag = false; args.network_subnet_flag = "10.169.20.0/24"; CapturedStdout capture; return dispatch_command(args, "/nonexistent/unused-config.yaml", config) == 0; } // Best-effort delete of a managed volume by name, via the real // --delete-volume-full CLI path -- used at the end of this test to remove // the volume -u/--up created, so a later run can exercise its creation // again from scratch. void delete_test_volume(const std::string& name) { ParsedArgs args; args.mode = Mode::delete_volume_full; args.mode_arg = name; AppConfig config = reload_config(); CapturedStdout capture; dispatch_command(args, "/nonexistent/unused-config.yaml", config); } // Plain nanosleep()-based bounded poll, matching this project's existing // direct-POSIX style elsewhere (kill_session.cpp's own poll_until()) rather // than /. template bool wait_until(int timeout_ms, int interval_ms, Predicate predicate) { for (int waited = 0; waited < timeout_ms; waited += interval_ms) { if (predicate()) { return true; } struct timespec ts { interval_ms / 1000, (interval_ms % 1000) * 1000000L }; nanosleep(&ts, nullptr); } return predicate(); } // Whether `path`'s current content contains `needle` -- used to poll a // daemonized service's own log file for its startup echo lines, since // -u/--up's own dispatch_command() call can return before the sandboxed // script has actually started running: report_daemon_started() fires the // instant bwrap's own pid is known, not once the sandboxed command itself // has produced any output (daemonize.h/bwrap.cpp). bool log_contains(const std::filesystem::path& path, const std::string& needle) { std::ifstream in(path); if (!in) { return false; } std::ostringstream contents; contents << in.rdbuf(); return contents.str().find(needle) != std::string::npos; } // Discovers the host's own real, externally-reachable IPv4 address -- // needed because connecting to a -p-forwarded port via 127.0.0.1 doesn't // work (a documented, known limitation of this project's own port // forwarding -- NAT hairpinning, see port_forward.h's own doc comment): the // DNAT rewrite leaves the packet's source address as 127.0.0.1, which the // container's own kernel then drops as a martian source arriving on a // non-loopback interface. Parses `ip -4 -o addr show scope global`'s own // output (the same technique this project's own uplink code, // network_bridge.cpp, already uses to discover real routing state rather // than hardcoding it) and returns the first address found. std::optional host_global_ipv4() { auto result = run_process({"ip", "-4", "-o", "addr", "show", "scope", "global"}); if (result.exit_code != 0) { return std::nullopt; } std::istringstream lines(result.stdout_output); std::string line; while (std::getline(lines, line)) { std::istringstream fields(line); std::string index; std::string ifname; std::string family; std::string addr_with_prefix; if (!(fields >> index >> ifname >> family >> addr_with_prefix)) { continue; } auto slash = addr_with_prefix.find('/'); if (slash != std::string::npos) { return addr_with_prefix.substr(0, slash); } } return std::nullopt; } // Connects to host:port, reads until the peer closes the connection (or // `timeout_ms` elapses with nothing received), and returns whatever was // read -- a small hand-rolled TCP client, matching this project's own // precedent of writing minimal raw network code directly rather than // pulling in a library for it (self_test.cpp's own DNS query helpers). // nullopt if the connection itself couldn't even be established. std::optional tcp_request(const std::string& host, int port, int timeout_ms) { int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { return std::nullopt; } struct timeval tv { timeout_ms / 1000, (timeout_ms % 1000) * 1000 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); struct sockaddr_in addr {}; addr.sin_family = AF_INET; addr.sin_port = htons(static_cast(port)); if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) { close(fd); return std::nullopt; } if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { close(fd); return std::nullopt; } std::string result; char buf[256]; ssize_t n; while ((n = read(fd, buf, sizeof(buf))) > 0) { result.append(buf, static_cast(n)); } close(fd); return result; } } // namespace TEST_CASE("compose lifecycle: test-compose/compose.yaml up, verify, down", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } auto busybox = find_busybox_fixture(); if (!busybox) { SKIP("no busybox fixture (see tests/setup-tests.py)"); } auto compose_path = std::filesystem::absolute(std::filesystem::path("test-compose") / "compose.yaml").lexically_normal(); if (!std::filesystem::exists(compose_path)) { SKIP("test-compose/compose.yaml not found relative to cwd"); } auto images_dir = busybox->parent_path(); REQUIRE(ensure_test_preexisting_network()); ParsedArgs up_args; up_args.mode = Mode::compose_up; up_args.compose_images_directory = images_dir.string(); up_args.compose_file_name = compose_path.string(); AppConfig up_config = reload_config(); int up_result = 1; { CapturedStdout capture; up_result = dispatch_command(up_args, "/nonexistent/unused-config.yaml", up_config); } CHECK(up_result == 0); auto containers = list_compose_containers(); auto find_container = [&](const std::string& service_name) -> const ComposeContainerInfo* { for (const auto& container : containers) { if (container.compose_path == compose_path && container.service_name == service_name) { return &container; } } return nullptr; }; const ComposeContainerInfo* worker = find_container("test-worker"); const ComposeContainerInfo* server = find_container("test-server"); CHECK(worker != nullptr); CHECK(server != nullptr); // Both env-var sources (environment: and env_file:) should be visible, // printed by each service's own listen.sh at startup. if (worker) { auto log_path = session_log_file_path(worker->container_name, worker->pid); CHECK(wait_until(10000, 200, [&] { return log_contains(log_path, "WORKER_GREETING=Hello from environment"); })); CHECK(log_contains(log_path, "WORKER_ENV_FILE_VAR=set-from-worker-env-file")); } if (server) { auto log_path = session_log_file_path(server->container_name, server->pid); CHECK(wait_until(10000, 200, [&] { return log_contains(log_path, "SERVER_ROLE=proxy"); })); CHECK(log_contains(log_path, "SERVER_ENV_FILE_VAR=set-from-server-env-file")); } // test-server's own -p 18080:80 should relay to test-worker1's own // "Hello from test-worker" reply. auto host_ip = host_global_ipv4(); CHECK(host_ip.has_value()); if (host_ip) { std::optional reply; CHECK(wait_until(15000, 500, [&] { reply = tcp_request(*host_ip, 18080, 2000); return reply.has_value() && !reply->empty(); })); REQUIRE(reply.has_value()); CHECK(reply->find("Hello from test-worker") != std::string::npos); } ParsedArgs down_args; down_args.mode = Mode::compose_down; down_args.compose_file_name = compose_path.string(); AppConfig down_config = reload_config(); int down_result = 1; { CapturedStdout capture; down_result = dispatch_command(down_args, "/nonexistent/unused-config.yaml", down_config); } CHECK(down_result == 0); AppConfig after_down = reload_config(); CHECK_FALSE(has_network(after_down, "test-compose_test-intern-net")); CHECK_FALSE(has_network(after_down, "test-compose_test-extern-net")); CHECK(has_network(after_down, "test-preexisting-net")); // external -- never touched CHECK(has_volume(after_down, "test-compose_test-server-log")); // volumes always persist // Remove the volume too, so a later run of this same test (or a manual // -u/--up) can exercise its creation again from scratch. delete_test_volume("test-compose_test-server-log"); AppConfig final_config = reload_config(); CHECK_FALSE(has_volume(final_config, "test-compose_test-server-log")); }