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:
2026-09-07 09:20:46 +00:00
parent 9f7af0e45a
commit a6130aa69d
8 changed files with 1483 additions and 21 deletions
+433
View File
@@ -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());
}