Add a Compose YAML parser for the subset of fields slocker-lite supports
load_compose_file() (src/compose_file.{h,cpp}) parses and validates
services/networks/volumes -- unrecognized keys are silently ignored, but a
malformed value for a supported key is a hard parse error, since a Compose
file describes an actual deployment rather than being a version-spanning
settings file. Confirmed no dedicated C++ library for this exists, so it's
hand-written against the already-present libyaml dependency rather than
pulling in the official JSON Schema plus a validator library.
scalar_value()/find_in_mapping() move out of config_file.cpp's own
.cpp-local pair into a new shared src/yaml_util.{h,cpp} (plus a new
sequence_items(), for Compose's list-valued keys) so both files share one
YAML-traversal implementation instead of drifting copies.
tests/unit/test_compose_file.cpp covers every supported field/form and
validation error against small hand-written snippets -- the checked-in
test-compose/compose.yaml skeleton is reserved for later integration tests
once an orchestrator exists, not these unit tests.
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,433 @@
|
||||
// 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());
|
||||
}
|
||||
|
||||
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: 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: 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());
|
||||
}
|
||||
Reference in New Issue
Block a user