Add a full -u/-d integration test against test-compose/compose.yaml
tests/integration/test_compose_orchestrator.cpp ([integration][root][net]) exercises the real, checked-in test-compose/compose.yaml skeleton end to end: creates test-preexisting-net if needed, -u, confirms both services' environment/env_file values via their own log files, confirms the server's port-forward actually relays the worker's reply over a real TCP connection, -d, confirms managed networks were torn down while the external network and the managed volume both survived, then deletes the volume too so a later run can exercise its creation again. Deliberately doesn't use ScratchXdgDirs (unlike every other [root][net] test in this suite): test-preexisting-net is external: true, meaning it's the user's own responsibility to set up once and keep reusing, matching real Compose's own convention for that field. A daemonized service's own dispatch_command() call can return before the sandboxed script has produced any output, so log-content and port-forward checks poll rather than asserting immediately; the port-forward check connects via the host's own real global IPv4 (discovered via `ip`, not hardcoded) instead of 127.0.0.1, since loopback never reaches the container regardless of whether forwarding itself works (a documented NAT-hairpinning limitation). Verified end to end on this dev machine (root, via the scoped doas rule), twice in a row: 27 assertions passed both times, and --list-containers/--list-networks/--list-volumes/ps all confirmed clean afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
@@ -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 <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#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 <chrono>/<thread>.
|
||||
template <typename Predicate>
|
||||
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<std::string> 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<std::string> 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<uint16_t>(port));
|
||||
if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) {
|
||||
close(fd);
|
||||
return std::nullopt;
|
||||
}
|
||||
if (connect(fd, reinterpret_cast<struct sockaddr*>(&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<size_t>(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<std::string> 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"));
|
||||
}
|
||||
Reference in New Issue
Block a user