diff --git a/CLAUDE.md b/CLAUDE.md index 2aadb8f..41c0334 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -551,6 +551,45 @@ Source layout (all under `src/`): expected shutdown `SIGTERM` via the inherited handler and report a spurious failure) — fixed the same way, resetting `SIGTERM` to `SIG_DFL` right before forking the straggler. + - `tests/integration/test_compose_orchestrator.cpp` (`[integration][root][net]`) + — the full `-u/--up` → `-d/--down` lifecycle against the real, checked-in + `test-compose/compose.yaml` skeleton (not a scratch-written snippet — + the whole point is testing this project's own hand-maintained fixture), + matching the user's own explicit test plan: create `test-preexisting-net` + if it isn't already there, `-u`, confirm both services' `environment`/ + `env_file` values via their own log files, confirm `test-server`'s + `-p 18080:80` actually relays `test-worker1`'s reply over a real TCP + connection, `-d`, confirm both managed networks were torn down while + `test-preexisting-net` (external) and the managed volume both survived, + then explicitly delete the volume too so a later run can exercise its + creation again from scratch. **Deliberately does not use `ScratchXdgDirs`** + (every other `[root][net]` test in this suite does) — `test-preexisting-net` + is `external: true`, meaning real Compose's own convention already + treats it as the user's own responsibility to set up once and keep + reusing, not something to create-and-tear-down per test run; every + *managed* resource this test's own `-u/--up` creates is already + "test-"-prefixed by `compose_project_name()`, and this test's own + `-d/--down` plus final volume deletion leave no managed residue behind + regardless. A daemonized service's own `-u/--up` `dispatch_command()` + call can return before the sandboxed script has produced any output at + all (`report_daemon_started()` fires the instant `bwrap`'s own pid is + known, not once the script itself has run — `daemonize.h`/`bwrap.cpp`), + so both the log-content and the port-forward checks poll (plain + `nanosleep()`-based, matching `kill_session.cpp`'s own style) rather + than asserting immediately. The port-forward check discovers the + host's own real global IPv4 address (`ip -4 -o addr show scope global`, + the same technique `network_bridge.cpp`'s own uplink code already uses + to discover real routing state rather than hardcoding it) instead of + connecting via `127.0.0.1` — a documented, known limitation of this + project's own port forwarding (NAT hairpinning, `port_forward.h`) means + a loopback connection never actually reaches the container regardless + of whether forwarding itself works. **Verified end to end on this dev + machine (root, via the scoped `doas` rule)**, twice in a row to confirm + the volume-deletion step genuinely makes the whole lifecycle repeatable: + 27 assertions passed both times, and `--list-containers`/ + `--list-networks`/`--list-volumes`/`ps` all confirmed clean afterward + (only the pre-existing, unrelated `test-preexisting-net`/other real + volumes remained, untouched). - `tests/support/fixtures.{h,cpp}` — `g_test_app_config` (a plain `AppConfig` global, default-constructed) is set once by `run_self_tests()` (`self_test.cpp`, see above) from the current `-t` run's own effective, diff --git a/meson.build b/meson.build index 05f3fdd..3f250fb 100644 --- a/meson.build +++ b/meson.build @@ -34,6 +34,7 @@ if get_option('enable_tests') 'tests/integration/test_root_networking.cpp', 'tests/integration/test_session_cleanup.cpp', 'tests/integration/test_network_join_scenarios.cpp', + 'tests/integration/test_compose_orchestrator.cpp', ] endif diff --git a/tests/integration/test_compose_orchestrator.cpp b/tests/integration/test_compose_orchestrator.cpp new file mode 100644 index 0000000..2a484a1 --- /dev/null +++ b/tests/integration/test_compose_orchestrator.cpp @@ -0,0 +1,327 @@ +// 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")); +}