Files
slocker-lite/tests/integration/test_config_bwrap_chain.cpp
ceamac cbec986e78 Split config.yaml into global+persistent files; add -c/--config-file
config.yaml now holds only the global section (log-level, unshare-*,
with-veth, with-ipv6); a new persistent.yaml holds volumes/networks.
load_config_file()/write_config_file() are replaced by
load_global_config()/load_persistent_config()/write_global_config()/
write_persistent_config(), each touching only their own file.

-c/--config-file <path> lets one invocation use an alternate file for the
global section only -- persistent.yaml is always the one fixed path,
regardless of -c, so an experiment can never affect real volumes/networks
(a -c file's own volumes/networks, if any, are simply never read either).
A -c path that doesn't exist is a hard error, unlike the default path's
existing missing-file leniency.

migrate_legacy_config_if_needed() moves volumes/networks out of an
old-format config.yaml into persistent.yaml on first run after upgrading,
always against the fixed default paths regardless of -c. A name collision
aborts the migration for that run (touching neither file) rather than
risking data loss.

Required reordering main() to parse CLI args before loading config (so
-c's value is known first) -- ParsedArgs::log_level_flag_given tracks
whether --log-level was already given so the config file's own log-level
doesn't clobber it despite the reversed call order.
2026-09-05 09:32:36 +00:00

244 lines
10 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] (no net, no root): chains config_file.h's read/write with
// bwrap.h's argv assembly -- write a config file, load it back, resolve a
// NamespaceConfig from it the same way commands.cpp's run_container() does,
// and confirm build_bwrap_args()'s resulting argv actually reflects it.
// Neither step mounts/runs anything or needs any privilege -- build_bwrap_args()
// is pure argv assembly, given a `root` that's just a string here, never
// actually accessed.
#include <algorithm>
#include <fstream>
#include <string>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "bwrap.h"
#include "config_file.h"
#include "fixtures.h"
namespace {
bool contains(const std::vector<std::string>& argv, const std::string& flag) {
return std::find(argv.begin(), argv.end(), flag) != argv.end();
}
} // namespace
TEST_CASE("config file -> NamespaceConfig -> bwrap argv: disabled namespaces are never requested", "[integration]") {
ScratchXdgDirs scratch;
auto config_path = scratch.path() / "config.yaml";
AppConfig written;
written.unshare_net = false;
written.unshare_uts = false;
REQUIRE(write_global_config(config_path, written));
auto loaded = load_global_config(config_path);
REQUIRE(loaded.has_value());
CHECK(loaded->unshare_net == std::optional<bool>(false));
CHECK(loaded->unshare_uts == std::optional<bool>(false));
// Same resolution run_container() (commands.cpp) itself does: each
// unshare-* key defaults to enabled when unset.
NamespaceConfig namespace_config{
loaded->unshare_user.value_or(true), loaded->unshare_ipc.value_or(true),
loaded->unshare_pid.value_or(true), loaded->unshare_net.value_or(true),
loaded->unshare_uts.value_or(true), loaded->unshare_cgroup.value_or(true),
};
CHECK(namespace_config.net == false);
CHECK(namespace_config.uts == false);
CHECK(namespace_config.user == true);
auto argv = build_bwrap_args("/fake/root", {"/bin/sh"}, {}, std::nullopt, std::nullopt, namespace_config, false);
CHECK_FALSE(contains(argv, "--unshare-net"));
CHECK_FALSE(contains(argv, "--unshare-uts"));
CHECK(contains(argv, "--bind"));
CHECK(contains(argv, "/fake/root"));
CHECK(contains(argv, "/bin/sh"));
}
TEST_CASE("config file -> NamespaceConfig -> bwrap argv: default (unset) config matches real kernel support",
"[integration]") {
ScratchXdgDirs scratch;
auto config_path = scratch.path() / "config.yaml";
// Nothing set -- write_global_config()/load_global_config() round-trip
// an otherwise-empty AppConfig, so every unshare-* key comes back unset.
REQUIRE(write_global_config(config_path, AppConfig{}));
auto loaded = load_global_config(config_path);
REQUIRE(loaded.has_value());
CHECK_FALSE(loaded->unshare_net.has_value());
NamespaceConfig namespace_config{
loaded->unshare_user.value_or(true), loaded->unshare_ipc.value_or(true),
loaded->unshare_pid.value_or(true), loaded->unshare_net.value_or(true),
loaded->unshare_uts.value_or(true), loaded->unshare_cgroup.value_or(true),
};
auto argv = build_bwrap_args("/fake/root", {"true"}, {}, std::nullopt, std::nullopt, namespace_config, false);
// With every policy gate open, the only thing left restricting which
// --unshare-xxx flags actually appear is real kernel support -- so the
// resulting argv should exactly match detect_bwrap_unshare_args()'s
// own live probe of this host.
for (const auto& flag : detect_bwrap_unshare_args()) {
CHECK(contains(argv, flag));
}
}
TEST_CASE("config file -> AppConfig: global.with-veth/with-ipv6 round-trip", "[integration]") {
ScratchXdgDirs scratch;
auto config_path = scratch.path() / "config.yaml";
AppConfig written;
written.with_veth = false;
written.with_ipv6 = false;
REQUIRE(write_global_config(config_path, written));
auto loaded = load_global_config(config_path);
REQUIRE(loaded.has_value());
CHECK(loaded->with_veth == std::optional<bool>(false));
CHECK(loaded->with_ipv6 == std::optional<bool>(false));
// Same "unset means enabled" convention as the six unshare-* keys --
// create_network_command()'s own resolution (commands.cpp) is
// `args.network_with_*_flag.value_or(config.with_*.value_or(true))`.
auto loaded_empty = load_global_config(scratch.path() / "nonexistent.yaml");
REQUIRE(loaded_empty.has_value());
CHECK_FALSE(loaded_empty->with_veth.has_value());
CHECK_FALSE(loaded_empty->with_ipv6.has_value());
}
TEST_CASE("persistent file -> AppConfig: volumes/networks round-trip, global section ignored",
"[integration]") {
ScratchXdgDirs scratch;
auto persistent_path = scratch.path() / "persistent.yaml";
AppConfig written;
written.log_level = "debug"; // global-only field -- must never reach persistent.yaml
written.volumes.push_back({"myvol", "/home/user/myvol"});
written.networks.push_back({"mynet", NetworkKind::extern_, "10.168.0.0/24", true, "fdf0::/64", true});
REQUIRE(write_persistent_config(persistent_path, written));
auto loaded = load_persistent_config(persistent_path);
REQUIRE(loaded.has_value());
REQUIRE(loaded->volumes.size() == 1);
CHECK(loaded->volumes[0].name == "myvol");
CHECK(loaded->volumes[0].directory == "/home/user/myvol");
REQUIRE(loaded->networks.size() == 1);
CHECK(loaded->networks[0].name == "mynet");
CHECK(loaded->networks[0].subnet == "10.168.0.0/24");
// write_persistent_config() never writes a "global" mapping at all, so
// there's nothing for load_persistent_config() to (deliberately) ignore
// here -- confirmed via load_global_config() against the same file
// instead, below.
CHECK_FALSE(loaded->log_level.has_value());
auto global_view = load_global_config(persistent_path);
REQUIRE(global_view.has_value());
CHECK_FALSE(global_view->log_level.has_value());
}
TEST_CASE("migrate_legacy_config_if_needed: moves volumes/networks out of an old-format config.yaml",
"[integration]") {
ScratchXdgDirs scratch;
// Hand-write an old-format single-file config.yaml -- global + volumes +
// networks all combined, exactly the pre-split shape -- directly at the
// real default config_file_path() (ScratchXdgDirs already points
// XDG_CONFIG_HOME here for this test's lifetime, so this doesn't touch
// the real developer's own config).
auto config_path = config_file_path();
std::filesystem::create_directories(config_path.parent_path());
{
std::ofstream legacy(config_path);
legacy << "global:\n"
" log-level: debug\n"
"volumes:\n"
" myvol: /home/user/myvol\n"
"networks:\n"
" mynet:\n"
" kind: extern\n"
" subnet: 10.168.0.0/24\n"
" ipv6: true\n"
" subnet6: fdf0::/64\n"
" veth: true\n";
}
REQUIRE(migrate_legacy_config_if_needed());
auto persistent = load_persistent_config(persistent_file_path());
REQUIRE(persistent.has_value());
REQUIRE(persistent->volumes.size() == 1);
CHECK(persistent->volumes[0].name == "myvol");
REQUIRE(persistent->networks.size() == 1);
CHECK(persistent->networks[0].name == "mynet");
// config.yaml itself is rewritten global-only -- the legacy
// volumes/networks are gone from it, but the global section survives.
auto remaining_global = load_global_config(config_path);
REQUIRE(remaining_global.has_value());
CHECK(remaining_global->log_level == std::optional<std::string>("debug"));
auto remaining_persistent_view = load_persistent_config(config_path);
REQUIRE(remaining_persistent_view.has_value());
CHECK(remaining_persistent_view->volumes.empty());
CHECK(remaining_persistent_view->networks.empty());
// A second run is a clean no-op -- nothing left to migrate.
REQUIRE(migrate_legacy_config_if_needed());
auto persistent_again = load_persistent_config(persistent_file_path());
REQUIRE(persistent_again.has_value());
CHECK(persistent_again->volumes.size() == 1);
CHECK(persistent_again->networks.size() == 1);
}
TEST_CASE("migrate_legacy_config_if_needed: a name collision aborts the migration, touching neither file",
"[integration]") {
ScratchXdgDirs scratch;
AppConfig existing_persistent;
existing_persistent.volumes.push_back({"myvol", "/already/here"});
REQUIRE(write_persistent_config(persistent_file_path(), existing_persistent));
AppConfig legacy;
legacy.volumes.push_back({"myvol", "/legacy/path"});
REQUIRE(write_global_config(config_file_path(), AppConfig{})); // an existing global section
// write_persistent_config() targets persistent_file_path(), not
// config_file_path() -- to plant "legacy" volumes/networks directly in
// config.yaml the way an old-format file would have them, write there
// explicitly instead.
REQUIRE(write_persistent_config(config_file_path(), legacy));
REQUIRE(migrate_legacy_config_if_needed()); // false only on a genuine I/O error -- a collision just warns
// Neither file was touched: the legacy copy is still in config.yaml,
// and persistent.yaml's own pre-existing entry is unchanged.
auto still_legacy = load_persistent_config(config_file_path());
REQUIRE(still_legacy.has_value());
REQUIRE(still_legacy->volumes.size() == 1);
CHECK(still_legacy->volumes[0].directory == "/legacy/path");
auto still_persistent = load_persistent_config(persistent_file_path());
REQUIRE(still_persistent.has_value());
REQUIRE(still_persistent->volumes.size() == 1);
CHECK(still_persistent->volumes[0].directory == "/already/here");
}