6624fd3ab5
ComposeService::user/group (compose_file.{h,cpp}) parse a "user[:group]"
key, split on the first ':' the same way an image's own declared USER is
split (oci_image.cpp). start_compose_services() passes both straight
through to run_mounted_container()'s existing user/group parameters, which
already fall back to the image's own declared user when unset -- the same
default -r/--run itself has when --user isn't given.
Verified end to end on the real target machine (root, via the scoped doas
rule), checked via `ps -eo pid,ppid,uid,cmd` (not -x/--exec, see below):
the actual sandboxed command runs as the resolved uid/gid, matching plain
-r --user's own already-working behavior.
Also recorded in TODO.md: verifying this surfaced a real but unrelated
bug in resolve_namespace_pid() (sandbox_process.cpp), which -x/--exec's
own default-identity resolution uses -- it stops at bwrap's own pid-1
namespace supervisor instead of walking one level deeper to the real
(correctly priv-dropped) target, so `-x/--exec <pid> -- id` with no
explicit --user misreports root for a session that's actually running as
a non-root user the whole time. Not a regression from this change and not
fixed here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
640 lines
30 KiB
C++
640 lines
30 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.
|
|
|
|
// [unit]: exercises every syntax form load_compose_file() (compose_file.h)
|
|
// is meant to support, plus its validation errors, against small
|
|
// hand-written compose YAML snippets -- deliberately not the checked-in
|
|
// test-compose/compose.yaml (that file exists to pin down which subset of
|
|
// Compose this project supports and to drive later, higher-level
|
|
// integration tests once an orchestrator exists; its content is expected to
|
|
// keep changing as more of that gets built, so pinning per-field unit
|
|
// assertions to it would be brittle).
|
|
|
|
#include <fstream>
|
|
#include <string>
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include "compose_file.h"
|
|
#include "fixtures.h"
|
|
|
|
namespace {
|
|
|
|
std::filesystem::path write_compose(const std::filesystem::path& dir, const std::string& content) {
|
|
auto path = dir / "compose.yaml";
|
|
std::ofstream out(path);
|
|
out << content;
|
|
return path;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("compose file: minimal service parses", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
auto path = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n");
|
|
|
|
auto compose = load_compose_file(path);
|
|
REQUIRE(compose.has_value());
|
|
REQUIRE(compose->services.size() == 1);
|
|
CHECK(compose->services[0].name == "web");
|
|
CHECK(compose->services[0].image == "busybox:latest");
|
|
CHECK_FALSE(compose->services[0].container_name.has_value());
|
|
CHECK(compose->services[0].command.empty());
|
|
}
|
|
|
|
TEST_CASE("compose file: missing/empty services section is an error", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
CHECK_FALSE(load_compose_file(write_compose(scratch.path(), "networks:\n n1: {}\n")).has_value());
|
|
CHECK_FALSE(load_compose_file(write_compose(scratch.path(), "services: {}\n")).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: a service with no image is an error", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
auto path = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" command: [\"true\"]\n");
|
|
CHECK_FALSE(load_compose_file(path).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: duplicate service name is an error", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
// A duplicate YAML mapping key -- libyaml keeps both pairs rather than
|
|
// deduplicating, so this reaches load_compose_file()'s own explicit
|
|
// duplicate-name check.
|
|
auto path = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" web:\n"
|
|
" image: busybox:1.36\n");
|
|
CHECK_FALSE(load_compose_file(path).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: container_name -- optional, and duplicates across services are an error", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto ok = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" container_name: web1\n");
|
|
auto loaded = load_compose_file(ok);
|
|
REQUIRE(loaded.has_value());
|
|
REQUIRE(loaded->services[0].container_name.has_value());
|
|
CHECK(*loaded->services[0].container_name == "web1");
|
|
|
|
auto colliding = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" container_name: shared\n"
|
|
" worker:\n"
|
|
" image: busybox:latest\n"
|
|
" container_name: shared\n");
|
|
CHECK_FALSE(load_compose_file(colliding).has_value());
|
|
|
|
// A service's own explicit container_name colliding with a *different*
|
|
// service's implicit identity (its own name, since it has no
|
|
// container_name of its own) -- also an error, not just two explicit
|
|
// container_names matching each other.
|
|
auto colliding_with_name = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" worker:\n"
|
|
" image: busybox:latest\n"
|
|
" container_name: web\n");
|
|
CHECK_FALSE(load_compose_file(colliding_with_name).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: user -- optional, with and without a group, split the same way as an image's own USER",
|
|
"[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto unset = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n");
|
|
auto loaded_unset = load_compose_file(unset);
|
|
REQUIRE(loaded_unset.has_value());
|
|
CHECK_FALSE(loaded_unset->services[0].user.has_value());
|
|
CHECK_FALSE(loaded_unset->services[0].group.has_value());
|
|
|
|
auto user_only = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" user: \"1000\"\n");
|
|
auto loaded_user_only = load_compose_file(user_only);
|
|
REQUIRE(loaded_user_only.has_value());
|
|
REQUIRE(loaded_user_only->services[0].user.has_value());
|
|
CHECK(*loaded_user_only->services[0].user == "1000");
|
|
CHECK_FALSE(loaded_user_only->services[0].group.has_value());
|
|
|
|
auto user_and_group = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" user: \"1000:1000\"\n");
|
|
auto loaded_both = load_compose_file(user_and_group);
|
|
REQUIRE(loaded_both.has_value());
|
|
REQUIRE(loaded_both->services[0].user.has_value());
|
|
CHECK(*loaded_both->services[0].user == "1000");
|
|
REQUIRE(loaded_both->services[0].group.has_value());
|
|
CHECK(*loaded_both->services[0].group == "1000");
|
|
|
|
auto empty_user = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" user: \"\"\n");
|
|
CHECK_FALSE(load_compose_file(empty_user).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: command -- list form used as-is, scalar form wrapped in sh -c", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto list_form = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" command: [\"sh\", \"/scripts/run.sh\"]\n");
|
|
auto loaded_list = load_compose_file(list_form);
|
|
REQUIRE(loaded_list.has_value());
|
|
CHECK(loaded_list->services[0].command == std::vector<std::string>{"sh", "/scripts/run.sh"});
|
|
|
|
auto scalar_form = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" command: \"echo hi\"\n");
|
|
auto loaded_scalar = load_compose_file(scalar_form);
|
|
REQUIRE(loaded_scalar.has_value());
|
|
CHECK(loaded_scalar->services[0].command == std::vector<std::string>{"sh", "-c", "echo hi"});
|
|
}
|
|
|
|
TEST_CASE("compose file: environment -- list and mapping forms, env_file resolved absolute and ordered first",
|
|
"[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto list_form = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" env_file:\n"
|
|
" - ./web.env\n"
|
|
" environment:\n"
|
|
" - FOO=bar\n"
|
|
" - BAZ=qux\n");
|
|
auto loaded_list = load_compose_file(list_form);
|
|
REQUIRE(loaded_list.has_value());
|
|
const auto& specs_list = loaded_list->services[0].environment_specs;
|
|
REQUIRE(specs_list.size() == 3);
|
|
CHECK(specs_list[0].is_file);
|
|
CHECK(specs_list[0].value == (scratch.path() / "web.env").lexically_normal().string());
|
|
CHECK_FALSE(specs_list[1].is_file);
|
|
CHECK(specs_list[1].value == "FOO=bar");
|
|
CHECK(specs_list[2].value == "BAZ=qux");
|
|
|
|
auto mapping_form = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" env_file: ./web.env\n"
|
|
" environment:\n"
|
|
" FOO: bar\n");
|
|
auto loaded_mapping = load_compose_file(mapping_form);
|
|
REQUIRE(loaded_mapping.has_value());
|
|
const auto& specs_mapping = loaded_mapping->services[0].environment_specs;
|
|
REQUIRE(specs_mapping.size() == 2);
|
|
CHECK(specs_mapping[0].is_file);
|
|
CHECK_FALSE(specs_mapping[1].is_file);
|
|
CHECK(specs_mapping[1].value == "FOO=bar");
|
|
|
|
// A null/omitted mapping value ("host environment passthrough" in real
|
|
// Compose) is indistinguishable from an explicit empty string through
|
|
// libyaml's own document API -- accepted as a literal empty value
|
|
// rather than erroring or actually inheriting anything (see
|
|
// compose_file.h's own doc comment on ComposeService::environment_specs).
|
|
auto null_value = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" environment:\n"
|
|
" FOO:\n");
|
|
auto loaded_null = load_compose_file(null_value);
|
|
REQUIRE(loaded_null.has_value());
|
|
REQUIRE(loaded_null->services[0].environment_specs.size() == 1);
|
|
CHECK(loaded_null->services[0].environment_specs[0].value == "FOO=");
|
|
}
|
|
|
|
TEST_CASE("compose file: depends_on -- long and short forms, condition handling, cross-validation", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto long_form = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" worker:\n"
|
|
" image: busybox:latest\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on:\n"
|
|
" worker:\n"
|
|
" condition: service_healthy\n");
|
|
auto loaded_long = load_compose_file(long_form);
|
|
REQUIRE(loaded_long.has_value());
|
|
// Services are collected in the same order they appear in the YAML.
|
|
REQUIRE(loaded_long->services.size() == 2);
|
|
CHECK(loaded_long->services[1].name == "web");
|
|
CHECK(loaded_long->services[1].depends_on == std::vector<std::string>{"worker"});
|
|
|
|
auto short_form = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" worker:\n"
|
|
" image: busybox:latest\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"worker\"]\n");
|
|
CHECK(load_compose_file(short_form).has_value());
|
|
|
|
auto bad_condition = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" worker:\n"
|
|
" image: busybox:latest\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on:\n"
|
|
" worker:\n"
|
|
" condition: service_completed_successfully\n");
|
|
CHECK_FALSE(load_compose_file(bad_condition).has_value());
|
|
|
|
auto self_dependency = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"web\"]\n");
|
|
CHECK_FALSE(load_compose_file(self_dependency).has_value());
|
|
|
|
auto undeclared = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"ghost\"]\n");
|
|
CHECK_FALSE(load_compose_file(undeclared).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: depends_on cycles are rejected, acyclic graphs are not", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
// A 2-service direct cycle (a -> b -> a) -- distinct from the
|
|
// already-tested direct self-reference (a -> a).
|
|
auto two_cycle = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" a:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"b\"]\n"
|
|
" b:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"a\"]\n");
|
|
CHECK_FALSE(load_compose_file(two_cycle).has_value());
|
|
|
|
// A longer, 3-service cycle (a -> b -> c -> a).
|
|
auto three_cycle = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" a:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"b\"]\n"
|
|
" b:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"c\"]\n"
|
|
" c:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"a\"]\n");
|
|
CHECK_FALSE(load_compose_file(three_cycle).has_value());
|
|
|
|
// A valid, acyclic multi-service graph (a diamond: d depends on both b
|
|
// and c, both of which depend on a) must still load successfully.
|
|
auto acyclic = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" a:\n"
|
|
" image: busybox:latest\n"
|
|
" b:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"a\"]\n"
|
|
" c:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"a\"]\n"
|
|
" d:\n"
|
|
" image: busybox:latest\n"
|
|
" depends_on: [\"b\", \"c\"]\n");
|
|
CHECK(load_compose_file(acyclic).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: stop_grace_period parses durations, rejects malformed ones", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto simple = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" stop_grace_period: 20s\n");
|
|
auto loaded_simple = load_compose_file(simple);
|
|
REQUIRE(loaded_simple.has_value());
|
|
REQUIRE(loaded_simple->services[0].stop_grace_period_seconds.has_value());
|
|
CHECK(*loaded_simple->services[0].stop_grace_period_seconds == 20);
|
|
|
|
auto combined = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" stop_grace_period: 1m30s\n");
|
|
auto loaded_combined = load_compose_file(combined);
|
|
REQUIRE(loaded_combined.has_value());
|
|
CHECK(*loaded_combined->services[0].stop_grace_period_seconds == 90);
|
|
|
|
auto malformed = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" stop_grace_period: not-a-duration\n");
|
|
CHECK_FALSE(load_compose_file(malformed).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: service networks -- list and mapping forms, undeclared reference is an error", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto list_form = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" networks:\n"
|
|
" - net1\n"
|
|
"networks:\n"
|
|
" net1: {}\n");
|
|
auto loaded_list = load_compose_file(list_form);
|
|
REQUIRE(loaded_list.has_value());
|
|
CHECK(loaded_list->services[0].networks == std::vector<std::string>{"net1"});
|
|
|
|
auto mapping_form = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" networks:\n"
|
|
" net1:\n"
|
|
" aliases: [\"alt-name\"]\n"
|
|
"networks:\n"
|
|
" net1: {}\n");
|
|
auto loaded_mapping = load_compose_file(mapping_form);
|
|
REQUIRE(loaded_mapping.has_value());
|
|
CHECK(loaded_mapping->services[0].networks == std::vector<std::string>{"net1"});
|
|
|
|
auto undeclared = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" networks:\n"
|
|
" - ghost\n");
|
|
CHECK_FALSE(load_compose_file(undeclared).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: top-level networks -- internal/external, duplicate name is an error", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto path = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
"networks:\n"
|
|
" net-intern:\n"
|
|
" internal: true\n"
|
|
" net-extern:\n"
|
|
" internal: false\n"
|
|
" net-preexisting:\n"
|
|
" external: true\n");
|
|
auto loaded = load_compose_file(path);
|
|
REQUIRE(loaded.has_value());
|
|
REQUIRE(loaded->networks.size() == 3);
|
|
|
|
auto find_network = [&](const std::string& name) -> const ComposeNetwork& {
|
|
for (const auto& n : loaded->networks) {
|
|
if (n.name == name) {
|
|
return n;
|
|
}
|
|
}
|
|
FAIL("network not found: " + name);
|
|
throw std::runtime_error("unreachable");
|
|
};
|
|
CHECK(find_network("net-intern").mode == ComposeNetworkMode::managed);
|
|
CHECK(find_network("net-intern").internal == true);
|
|
CHECK(find_network("net-extern").mode == ComposeNetworkMode::managed);
|
|
CHECK(find_network("net-extern").internal == false);
|
|
CHECK(find_network("net-preexisting").mode == ComposeNetworkMode::external);
|
|
|
|
auto duplicate = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
"networks:\n"
|
|
" net1: {}\n"
|
|
" net1:\n"
|
|
" internal: true\n");
|
|
CHECK_FALSE(load_compose_file(duplicate).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: ports -- list and scalar forms reuse parse_port_forward_spec()", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto list_form = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" ports:\n"
|
|
" - \"18080:80\"\n");
|
|
auto loaded_list = load_compose_file(list_form);
|
|
REQUIRE(loaded_list.has_value());
|
|
REQUIRE(loaded_list->services[0].ports.size() == 1);
|
|
CHECK_FALSE(loaded_list->services[0].ports[0].network.has_value());
|
|
CHECK(loaded_list->services[0].ports[0].host_port == 18080);
|
|
CHECK(loaded_list->services[0].ports[0].container_port == 80);
|
|
|
|
auto scalar_form = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" ports: \"18080:80\"\n");
|
|
CHECK(load_compose_file(scalar_form).has_value());
|
|
|
|
auto bad = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" ports:\n"
|
|
" - \"not-a-port\"\n");
|
|
CHECK_FALSE(load_compose_file(bad).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: duplicate host-port/protocol across (or within) services is an error", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto across_services = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" ports:\n"
|
|
" - \"8080:80\"\n"
|
|
" web2:\n"
|
|
" image: busybox:latest\n"
|
|
" ports:\n"
|
|
" - \"8080:81\"\n");
|
|
CHECK_FALSE(load_compose_file(across_services).has_value());
|
|
|
|
auto within_one_service = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" ports:\n"
|
|
" - \"8080:80\"\n"
|
|
" - \"8080:81\"\n");
|
|
CHECK_FALSE(load_compose_file(within_one_service).has_value());
|
|
|
|
// Same host port, different protocols -- must still succeed, matching
|
|
// port_forward.h's own existing "same port pair once per protocol"
|
|
// precedent (e.g. a DNS-like service forwarding both tcp and udp).
|
|
auto different_protocols = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" ports:\n"
|
|
" - \"53:53/tcp\"\n"
|
|
" - \"53:53/udp\"\n");
|
|
CHECK(load_compose_file(different_protocols).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: service volumes -- bind mounts (absolute-resolved, ro), named volume references",
|
|
"[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto path = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" volumes:\n"
|
|
" - ./scripts:/scripts:ro\n"
|
|
" - applog:/var/log\n"
|
|
"volumes:\n"
|
|
" applog:\n");
|
|
auto loaded = load_compose_file(path);
|
|
REQUIRE(loaded.has_value());
|
|
REQUIRE(loaded->services[0].volumes.size() == 2);
|
|
|
|
const auto& bind = loaded->services[0].volumes[0];
|
|
CHECK_FALSE(bind.is_named_volume);
|
|
CHECK(bind.source == (scratch.path() / "scripts").lexically_normal().string());
|
|
CHECK(bind.target == "/scripts");
|
|
CHECK(bind.read_only);
|
|
|
|
const auto& named = loaded->services[0].volumes[1];
|
|
CHECK(named.is_named_volume);
|
|
CHECK(named.source == "applog");
|
|
CHECK(named.target == "/var/log");
|
|
CHECK_FALSE(named.read_only);
|
|
|
|
auto undeclared_volume = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" volumes:\n"
|
|
" - ghostvol:/data\n");
|
|
CHECK_FALSE(load_compose_file(undeclared_volume).has_value());
|
|
|
|
auto relative_target = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" volumes:\n"
|
|
" - ./scripts:relative/path\n");
|
|
CHECK_FALSE(load_compose_file(relative_target).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: duplicate top-level volume name is an error", "[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
// A duplicate YAML mapping key under volumes: -- same shape as the
|
|
// already-tested duplicate service name (libyaml keeps both pairs
|
|
// rather than deduplicating).
|
|
auto path = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
"volumes:\n"
|
|
" applog:\n"
|
|
" applog:\n");
|
|
CHECK_FALSE(load_compose_file(path).has_value());
|
|
}
|
|
|
|
TEST_CASE("compose file: validate_compose_external_state -- env_file existence, external network existence",
|
|
"[unit]") {
|
|
ScratchXdgDirs scratch;
|
|
|
|
auto path = write_compose(scratch.path(),
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" env_file: ./web.env\n"
|
|
"networks:\n"
|
|
" net-managed:\n"
|
|
" internal: false\n"
|
|
" net-preexisting:\n"
|
|
" external: true\n");
|
|
auto loaded = load_compose_file(path);
|
|
REQUIRE(loaded.has_value());
|
|
|
|
// env_file doesn't exist yet, and the external network isn't declared
|
|
// anywhere in persistent_config -- both must fail, one at a time
|
|
// (fail-fast, same convention load_compose_file()'s own cross-validation
|
|
// already uses).
|
|
AppConfig empty_persistent;
|
|
CHECK_FALSE(validate_compose_external_state(*loaded, empty_persistent));
|
|
|
|
// Create the env_file -- still fails, since the external network still
|
|
// isn't declared.
|
|
{
|
|
std::ofstream env_file(scratch.path() / "web.env");
|
|
env_file << "FOO=bar\n";
|
|
}
|
|
CHECK_FALSE(validate_compose_external_state(*loaded, empty_persistent));
|
|
|
|
// Declare the external network -- now both checks pass. A managed
|
|
// (non-external) network, net-managed, is never checked against
|
|
// persistent_config at all, so its absence there doesn't matter.
|
|
AppConfig with_network;
|
|
with_network.networks.push_back({"net-preexisting", NetworkKind::intern, "10.169.10.0/24", false, "", true});
|
|
CHECK(validate_compose_external_state(*loaded, with_network));
|
|
|
|
// A directory in place of the env_file still fails -- not a regular file.
|
|
auto dir_env_file = write_compose(scratch.path(), "services:\n"
|
|
" web:\n"
|
|
" image: busybox:latest\n"
|
|
" env_file: ./a-directory\n");
|
|
auto loaded_dir = load_compose_file(dir_env_file);
|
|
REQUIRE(loaded_dir.has_value());
|
|
std::filesystem::create_directory(scratch.path() / "a-directory");
|
|
CHECK_FALSE(validate_compose_external_state(*loaded_dir, AppConfig{}));
|
|
}
|