ac4194c96b
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
347 lines
14 KiB
C++
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"));
|
|
}
|