Files
slocker-lite/tests/integration/test_compose_orchestrator.cpp
ceamac ac4194c96b Surface -u/-d's captured stdout via INFO() on test failure
CapturedStdout swallows compose_up_command()/compose_down_command()'s own
fmt::print()/spdlog output (spdlog's default sink is stdout) so it doesn't
pollute Catch2's console reporting -- but that also meant a failure had no
diagnostic trail at all. Both captures are now kept and attached via
INFO(), which Catch2 only actually prints alongside a failing assertion in
the same scope, staying silent on a normal passing run.

Also softens the port-forward reply REQUIRE to CHECK: a flaky reply on one
run must never skip the -d/--down cleanup below it, which is exactly what
a REQUIRE there would do -- found while investigating a real failure on
the actual target device (managed networks not torn down by -d), where
losing that diagnostic trail (and, if this were interactive, the -d skip
compounding into a real leaked uplink relay) would have made root-causing
much harder.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-07 13:34:07 +00:00

347 lines
14 KiB
C++

// 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;
std::string up_output;
{
CapturedStdout capture;
up_result = dispatch_command(up_args, "/nonexistent/unused-config.yaml", up_config);
up_output = capture.contents();
}
// Only surfaced if something below actually fails -- compose_up_command()'s
// own fmt::print()/spdlog output (spdlog's default sink is stdout, same
// as slocker-lite's own plain status lines) would otherwise be silently
// discarded by CapturedStdout, leaving no way to diagnose *why* a step
// failed on a run this test itself can't rerun interactively.
INFO("up output:\n" << up_output);
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();
}));
CHECK(reply.has_value());
// CHECK, not REQUIRE: a flaky port-forward reply on this specific
// run must never skip the -d/--down cleanup below -- an extern
// network's uplink relay left running would otherwise leak real
// host state (and, if this were run interactively rather than with
// output redirected to a file, hang the invoking shell -- see
// reference_device_ssh_access.md).
if (reply) {
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;
std::string down_output;
{
CapturedStdout capture;
down_result = dispatch_command(down_args, "/nonexistent/unused-config.yaml", down_config);
down_output = capture.contents();
}
INFO("down output:\n" << down_output);
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"));
}