From a6130aa69daeab40743ceae605d6c75a0f8d0dd2 Mon Sep 17 00:00:00 2001 From: Viorel Munteanu Date: Mon, 7 Sep 2026 09:20:46 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz --- CLAUDE.md | 118 ++++++ meson.build | 3 +- src/compose_file.cpp | 681 +++++++++++++++++++++++++++++++ src/compose_file.h | 162 ++++++++ src/config_file.cpp | 21 +- src/yaml_util.cpp | 47 +++ src/yaml_util.h | 39 ++ tests/unit/test_compose_file.cpp | 433 ++++++++++++++++++++ 8 files changed, 1483 insertions(+), 21 deletions(-) create mode 100644 src/compose_file.cpp create mode 100644 src/compose_file.h create mode 100644 src/yaml_util.cpp create mode 100644 src/yaml_util.h create mode 100644 tests/unit/test_compose_file.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 1dfd506..7877a6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2466,6 +2466,124 @@ Source layout (all under `src/`): `/etc/gitea`/`/var/lib/gitea` volumes under a real rootless mount: the resulting host directories' mode/ownership matched the image's own declared values in both cases, and no mounts/layers were left behind afterward. +- `yaml_util.{h,cpp}` — `scalar_value()`/`find_in_mapping()` pulled out of + `config_file.cpp`'s own former `.cpp`-local pair (config.yaml's own shape + is purely nested scalar mappings, so it never needed more) once + `compose_file.cpp` (below) needed the exact same two, plus a new + `sequence_items()` (every item of a `YAML_SEQUENCE_NODE`, in order) that + Compose's own list-valued keys need and config.yaml's shape never did. + Both `config_file.cpp` and `compose_file.cpp` depend on this now, so the + two never drift into two slightly-different copies of the same libyaml + document-traversal boilerplate. +- `compose_file.{h,cpp}` — `load_compose_file()` parses and validates a + Docker/Podman Compose YAML file (`docker-compose.yaml`/`compose.yaml`), + extracting only the subset of fields slocker-lite supports and silently + ignoring everything else (`build`, `deploy`, `restart`, `healthcheck`, + and any other unrecognized top-level section) — the same forward- + compatible "unknown keys ignored" policy `config_file.cpp` already uses + for `config.yaml`. Unlike that policy, though, a *supported* key with an + unrecognized or malformed value (a missing `image`, a bad + `stop_grace_period`, an undeclared network/volume reference, a duplicate + name, an unresolvable/self-referential `depends_on`, an unsupported + `depends_on` `condition`, an unparseable `ports`/`volumes` entry) is a + hard parse error, not a silent skip — a Compose file is user-authored + input describing an actual deployment, not a version-spanning settings + file, so a mistake in it should surface clearly. No dedicated C++ library + for parsing/validating Compose files exists (confirmed by research before + starting this); the alternative — pulling in the official + `compose-spec.json` JSON Schema plus a schema-validator library — was + rejected in favor of hand-writing the parser against the already-present + `libyaml` dependency, matching this project's own "only extract/support + the fields actually consumed" precedent (`oci_image.cpp`'s + `OciImageConfig`) rather than validating against the full upstream spec. + `ComposeService` captures `image`/`container_name`/`command` (both + Compose's list form and its scalar shell-string form, the latter wrapped + as `{"sh", "-c", }`)/`environment`+`env_file` (both list and + mapping forms for `environment`, scalar-or-list for `env_file`; folded + into a single ordered `std::vector` — `env_spec.h`'s own struct, + reused as-is — with every `env_file` entry first and every `environment` + entry after, regardless of which key came first in the YAML, so handing + this straight to `resolve_env_specs()` later reproduces real Compose's + "environment always overrides env_file" precedence for free via that + function's own existing "later wins" mechanism)/`depends_on` (both the + short list form and the long `condition:` mapping form; only + `service_started`/`service_healthy` are accepted, the latter folded into + "started" — no real healthcheck support exists yet — any other value, or + a self-referential or undeclared-service reference, is a hard error; + `restart`/`required` sub-keys are silently ignored, not implemented)/ + `stop_grace_period` (a hand-rolled Go-style duration parser -- + `parse_duration_seconds()`, `.cpp`-local -- accepting `h`/`m`/`s`/`ms` + units, optionally combined like `"1m30s"`, rounded to whole seconds; no + unit or an unrecognized one is a parse error)/`networks` (list or + per-network mapping form, the latter's nested fields like `aliases` + ignored; every name validated against the file's own top-level + `networks:`)/`ports` (list or single-scalar form; each entry reuses + `port_forward.h`'s own `parse_port_forward_spec()` directly rather than a + second parser, since Compose's own `":[/proto]"` syntax + is a strict subset of what that function already accepts — a + network-qualified `-p` prefix is never present in a Compose ports entry, + so `PortForwardSpec::network` always comes back unset here, letting a + later `-p` resolution pick the service's own sole `extern` network the + same way an ordinary CLI `-p` with no prefix already does; known gap, not + fixed: a host-IP-prefixed entry like `"127.0.0.1:8080:80"`, valid real + Compose syntax, has the same three-colon-separated-field shape as a + network-qualified spec, so it's misread as if `"127.0.0.1"` were a + network name instead of rejected outright, only failing later at + network-resolution time with a confusing error)/`volumes` (short + `"SRC:DST[:MODE]"` string form only, `MODE` exactly `ro`/`rw`; whether + `SRC` is a bind-mount host path or a named-volume reference is decided + the same way `volume_mount.h`'s own `-v` spec parsing already does -- + `is_valid_volume_name()`, reused directly: no `/` means a name, checked + against the compose file's own top-level `volumes:`; anything else is a + path, resolved to an absolute, lexically-normalized one relative to the + compose file's own directory, ready for `resolve_volume_mount()` the same + way a plain `-v ` spec already is; the parsed + `read_only` flag isn't enforced anywhere yet since + `resolve_volume_mount()`/`build_bwrap_args()` only ever bind writable — + captured here rather than silently lost, for when read-only bind support + exists). `ComposeNetwork`'s `external: true` (real Compose syntax, means + "must already exist, not managed by Compose") maps to + `ComposeNetworkMode::external` — checked against the real + `persistent.yaml` at "up" time, not by this parser, which only records + that the network must already exist; otherwise `internal: true`/`false` + (default `false`, matching real Compose's own default) directly selects + this project's own `intern`/`extern` `NetworkKind` for a + (not-yet-implemented) orchestrator to create. `ComposeVolume` records + only a declared name for now — Compose auto-provisions a host directory + for a named volume with no further fields, which this parser doesn't do + (an orchestrator concern, not yet implemented); it exists here only so a + service's own named-volume references can be validated against it. + Cross-references (`depends_on`/`networks`/named-volume `volumes`) are + validated in a second pass after every service/network/volume has been + parsed, so declaration order in the YAML never matters. **Scope decisions + confirmed with the user before implementation**: for a key with multiple + real Compose syntax forms, best-effort support both forms rather than + only whichever one a first example happened to use (matching the + `environment`/`command`/`depends_on`/`env_file`/`networks`/`ports` + handling above); this pass is scoped to the parser module plus unit tests + only (`tests/unit/test_compose_file.cpp`, `[unit]`, exercising every + supported form and validation error against small hand-written YAML + snippets, not the checked-in `test-compose/compose.yaml` skeleton — see + below) — no `Mode`/CLI flag, `commands.cpp` dispatch case, or actual + container orchestration exists yet. A `test-compose/compose.yaml` (with + matching `worker/`/`server/` script directories) was hand-drafted + interactively at the repo root first, specifically to pin down which + Compose fields/forms this project would commit to supporting before any + parser code was written — two busybox services (a `test-worker` that + `nc -lk`-listens and echoes a fixed reply, and a `test-server` that joins + both an `intern` and an `extern` network plus a third pre-created + `external: true` network, port-forwards from the `extern` side, and + relays the worker's own reply) exercising `container_name`, + `environment`/`env_file`, `depends_on` (`condition: service_started` — + real `docker compose` itself requires an actual healthcheck for + `service_healthy`, confirmed by hand against real Docker before commit), + `stop_grace_period`, bind-mounted script directories, and one named + volume (`/var/log`, for persistent logging) alongside the bind mounts — + verified against a real Docker installation before being committed. That + file is reserved for later, higher-level integration tests once an + orchestrator exists, not for this parser's own unit tests, since its + content is expected to keep changing as more of the orchestrator gets + built on top of it. Errors are logged via `spdlog::error`; every external command is also traced at debug level in `run_process()`/`run_process_foreground()` (`src/process.cpp`) — visible via diff --git a/meson.build b/meson.build index 0bf0de4..d0333a8 100644 --- a/meson.build +++ b/meson.build @@ -27,6 +27,7 @@ if get_option('enable_tests') 'tests/unit/test_env_spec.cpp', 'tests/unit/test_network_subnet.cpp', 'tests/unit/test_cli_args.cpp', + 'tests/unit/test_compose_file.cpp', 'tests/support/fixtures.cpp', 'tests/integration/test_config_bwrap_chain.cpp', 'tests/integration/test_rootless_run.cpp', @@ -44,7 +45,7 @@ slocker_lite = executable('slocker-lite', 'src/sandbox_process.cpp', 'src/session_cgroup.cpp', 'src/kill_session.cpp', 'src/network_subnet.cpp', 'src/persistent_netns.cpp', 'src/network_bridge.cpp', 'src/network_join.cpp', 'src/port_forward.cpp', 'src/network_tap_relay.cpp', - 'src/network_dns.cpp'] + test_sources, + 'src/network_dns.cpp', 'src/yaml_util.cpp', 'src/compose_file.cpp'] + test_sources, include_directories : include_directories('.', 'src', 'tests/support'), dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep], install : true) diff --git a/src/compose_file.cpp b/src/compose_file.cpp new file mode 100644 index 0000000..d503e98 --- /dev/null +++ b/src/compose_file.cpp @@ -0,0 +1,681 @@ +// 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. + +#include "compose_file.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "config_file.h" +#include "network_subnet.h" +#include "volume_mount.h" +#include "yaml_util.h" + +namespace { + +// Opens and parses `path` into a fresh yaml_document_t. Unlike +// config_file.cpp's own parse_yaml_file(), a missing file is a hard error +// here, not "treat as empty" -- a compose file is an explicit path the +// caller named, not a maybe-not-created-yet settings file. +std::optional open_and_parse_yaml(const std::filesystem::path& path) { + FILE* file = std::fopen(path.c_str(), "r"); + if (!file) { + spdlog::error("compose file: cannot open {}", path.string()); + return std::nullopt; + } + + yaml_parser_t parser; + yaml_parser_initialize(&parser); + yaml_parser_set_input_file(&parser, file); + + yaml_document_t document; + bool loaded = yaml_parser_load(&parser, &document) != 0; + yaml_parser_delete(&parser); + std::fclose(file); + + if (!loaded) { + spdlog::error("compose file: failed to parse {}", path.string()); + return std::nullopt; + } + return document; +} + +// load_compose_file() below has many early-error returns (one per failed +// validation step) -- this guarantees yaml_document_delete() runs on every +// one of them instead of needing a manual call before each `return +// std::nullopt`, which config_file.cpp's own simpler, single-return-point +// loaders never needed. +struct YamlDocumentGuard { + yaml_document_t& document; + ~YamlDocumentGuard() { yaml_document_delete(&document); } +}; + +// Collects a list of plain scalar strings from `node`: a YAML_SEQUENCE_NODE +// of scalars, a single YAML_SCALAR_NODE (a one-element list), or -- only +// when `allow_mapping_keys` is true -- a YAML_MAPPING_NODE, whose keys (not +// values) become the list, in their own order. Used for env_file's +// scalar-or-list forms and, with `allow_mapping_keys`, a service's own +// networks: list-or-per-network-mapping forms. nullopt (logging `context`) +// for any other shape, a non-scalar sequence item, or a non-scalar mapping +// key. +std::optional> collect_string_list(yaml_document_t& document, const yaml_node_t& node, + bool allow_mapping_keys, const std::string& context) { + std::vector result; + if (node.type == YAML_SCALAR_NODE) { + result.emplace_back(scalar_value(node)); + return result; + } + if (node.type == YAML_SEQUENCE_NODE) { + for (const yaml_node_t* item : sequence_items(document, node)) { + if (item->type != YAML_SCALAR_NODE) { + spdlog::error("compose file: {} must be a list of plain strings", context); + return std::nullopt; + } + result.emplace_back(scalar_value(*item)); + } + return result; + } + if (allow_mapping_keys && node.type == YAML_MAPPING_NODE) { + for (auto* pair = node.data.mapping.pairs.start; pair < node.data.mapping.pairs.top; ++pair) { + yaml_node_t* key_node = yaml_document_get_node(&document, pair->key); + if (!key_node || key_node->type != YAML_SCALAR_NODE) { + spdlog::error("compose file: {} must have plain string keys", context); + return std::nullopt; + } + result.emplace_back(scalar_value(*key_node)); + } + return result; + } + spdlog::error("compose file: unsupported form for {}", context); + return std::nullopt; +} + +// Turns a service's `command:` into an argv list -- Compose's own list form +// is used as-is; its scalar shell-string form is wrapped as {"sh", "-c", +// }, matching Compose's "run through the image's shell" semantics +// for that form. +std::optional> parse_command(yaml_document_t& document, const yaml_node_t& node, + const std::string& service_name) { + if (node.type == YAML_SCALAR_NODE) { + return std::vector{"sh", "-c", std::string(scalar_value(node))}; + } + if (node.type == YAML_SEQUENCE_NODE) { + std::vector result; + for (const yaml_node_t* item : sequence_items(document, node)) { + if (item->type != YAML_SCALAR_NODE) { + spdlog::error("compose file: service '{}' command list entries must be plain strings", service_name); + return std::nullopt; + } + result.emplace_back(scalar_value(*item)); + } + return result; + } + spdlog::error("compose file: service '{}' command must be a list or a plain string", service_name); + return std::nullopt; +} + +// Turns a service's `environment:` into a flat "KEY=VALUE" list -- both the +// list form (entries already "KEY=VALUE") and the mapping form +// ({KEY: VALUE}, reassembled into the same shape) are accepted; a mapping +// value that isn't a plain scalar (an omitted/null value, meaning "inherit +// from the host environment" in real Compose) isn't supported and is a +// parse error, to avoid silently guessing at "the host environment" as a +// concept that doesn't cleanly apply here. +std::optional> parse_environment_entries(yaml_document_t& document, const yaml_node_t& node, + const std::string& service_name) { + std::vector result; + if (node.type == YAML_SEQUENCE_NODE) { + for (const yaml_node_t* item : sequence_items(document, node)) { + if (item->type != YAML_SCALAR_NODE) { + spdlog::error("compose file: service '{}' environment entries must be plain strings", service_name); + return std::nullopt; + } + std::string entry(scalar_value(*item)); + auto eq = entry.find('='); + if (eq == std::string::npos || eq == 0) { + spdlog::error("compose file: service '{}' environment entry '{}' is not KEY=VALUE", service_name, + entry); + return std::nullopt; + } + result.push_back(std::move(entry)); + } + return result; + } + if (node.type == YAML_MAPPING_NODE) { + for (auto* pair = node.data.mapping.pairs.start; pair < node.data.mapping.pairs.top; ++pair) { + yaml_node_t* key_node = yaml_document_get_node(&document, pair->key); + yaml_node_t* value_node = yaml_document_get_node(&document, pair->value); + if (!key_node || key_node->type != YAML_SCALAR_NODE || scalar_value(*key_node).empty()) { + spdlog::error("compose file: service '{}' environment mapping has a bad key", service_name); + return std::nullopt; + } + // libyaml's own document API doesn't perform implicit-tag + // resolution -- a bare `FOO:` (YAML null, meaning "inherit from + // the host" in real Compose) comes back as a plain + // YAML_SCALAR_NODE tagged "str" with empty content, indistinguishable + // here from an explicit `FOO: ""`. Rather than reject both (which + // would also break the legitimate "set to an empty string" case), + // both are accepted as a literal empty value -- "inherit from the + // host" isn't supported either way, it just silently becomes + // "FOO=" instead of erroring. + if (!value_node || value_node->type != YAML_SCALAR_NODE) { + spdlog::error("compose file: service '{}' environment key '{}' needs a plain string value", + service_name, scalar_value(*key_node)); + return std::nullopt; + } + result.push_back(fmt::format("{}={}", scalar_value(*key_node), scalar_value(*value_node))); + } + return result; + } + spdlog::error("compose file: service '{}' environment must be a list or mapping", service_name); + return std::nullopt; +} + +// Turns a service's `depends_on:` into a flat list of depended-on service +// names -- both the short list form (condition implicitly service_started) +// and the long mapping form (condition: service_started/service_healthy, +// the latter folded into "started" for now; restart/required sub-keys, if +// present, are silently ignored -- not implemented) are accepted. Existence +// of the referenced service, and self-dependency, are checked later, once +// every service has been parsed (load_compose_file()'s own cross-validation +// pass). +std::optional> parse_depends_on(yaml_document_t& document, const yaml_node_t& node, + const std::string& service_name) { + std::vector result; + if (node.type == YAML_SEQUENCE_NODE) { + for (const yaml_node_t* item : sequence_items(document, node)) { + if (item->type != YAML_SCALAR_NODE || scalar_value(*item).empty()) { + spdlog::error("compose file: service '{}' depends_on list entries must be plain service names", + service_name); + return std::nullopt; + } + result.emplace_back(scalar_value(*item)); + } + return result; + } + if (node.type == YAML_MAPPING_NODE) { + for (auto* pair = node.data.mapping.pairs.start; pair < node.data.mapping.pairs.top; ++pair) { + yaml_node_t* key_node = yaml_document_get_node(&document, pair->key); + yaml_node_t* value_node = yaml_document_get_node(&document, pair->value); + if (!key_node || key_node->type != YAML_SCALAR_NODE || scalar_value(*key_node).empty()) { + spdlog::error("compose file: service '{}' depends_on has a bad entry name", service_name); + return std::nullopt; + } + std::string depended_on(scalar_value(*key_node)); + if (value_node && value_node->type == YAML_MAPPING_NODE) { + if (const yaml_node_t* condition = find_in_mapping(document, *value_node, "condition")) { + if (condition->type != YAML_SCALAR_NODE) { + spdlog::error("compose file: service '{}' depends_on '{}' has a bad condition", service_name, + depended_on); + return std::nullopt; + } + std::string_view value = scalar_value(*condition); + if (value != "service_started" && value != "service_healthy") { + spdlog::error( + "compose file: service '{}' depends_on '{}' has unsupported condition '{}' (only " + "service_started/service_healthy are supported -- service_healthy is treated " + "identically to service_started for now, no real healthcheck support yet)", + service_name, depended_on, value); + return std::nullopt; + } + } + // restart/required are ignored if present -- not implemented. + } else if (value_node && value_node->type == YAML_SEQUENCE_NODE) { + spdlog::error("compose file: service '{}' depends_on '{}' must be a mapping", service_name, + depended_on); + return std::nullopt; + } + // A scalar/null value_node (e.g. a bare `worker:` with nothing + // after it) means "no condition given" -> defaults to + // service_started, same as real Compose. + result.push_back(std::move(depended_on)); + } + return result; + } + spdlog::error("compose file: service '{}' depends_on must be a list or mapping", service_name); + return std::nullopt; +} + +// Parses a Go-style duration string ("20s", "1m30s", "1.5h", ...) into +// whole seconds, rounded to the nearest one -- h/m/s/ms units, optionally +// combined; anything else (no unit at all, an unsupported unit like "us"/ +// "ns", non-numeric text) is nullopt. +std::optional parse_duration_seconds(std::string_view text) { + if (text.empty()) { + return std::nullopt; + } + double total_seconds = 0.0; + size_t pos = 0; + while (pos < text.size()) { + std::string number_text; + while (pos < text.size() && (std::isdigit(static_cast(text[pos])) || text[pos] == '.')) { + number_text += text[pos++]; + } + if (number_text.empty()) { + return std::nullopt; + } + char* end = nullptr; + double value = std::strtod(number_text.c_str(), &end); + if (!end || *end != '\0') { + return std::nullopt; + } + + size_t unit_start = pos; + while (pos < text.size() && std::isalpha(static_cast(text[pos]))) { + ++pos; + } + std::string unit = std::string(text.substr(unit_start, pos - unit_start)); + if (unit == "h") { + total_seconds += value * 3600.0; + } else if (unit == "m") { + total_seconds += value * 60.0; + } else if (unit == "s") { + total_seconds += value; + } else if (unit == "ms") { + total_seconds += value / 1000.0; + } else { + return std::nullopt; + } + } + return static_cast(total_seconds + 0.5); +} + +std::vector split_colon(std::string_view text) { + std::vector parts; + size_t start = 0; + while (true) { + size_t colon = text.find(':', start); + parts.emplace_back( + text.substr(start, colon == std::string_view::npos ? std::string_view::npos : colon - start)); + if (colon == std::string_view::npos) { + break; + } + start = colon + 1; + } + return parts; +} + +// Parses one service `volumes:` entry ("SRC:DST" or "SRC:DST:MODE", MODE +// exactly "ro" or "rw" -- Compose's long mapping-per-entry form isn't +// supported). Whether `source` is a bind-mount host path or a named-volume +// reference is decided the same way volume_mount.h's own -v spec parsing +// already does: is_valid_volume_name() (no '/') means a name, anything else +// a path -- a bind-mount path is resolved to absolute (relative to +// `base_dir`, the compose file's own directory) here; a named-volume name +// is left as-is, validated against the compose file's own top-level +// `volumes:` later. +std::optional parse_volume_entry(std::string_view text, const std::filesystem::path& base_dir, + const std::string& service_name) { + auto parts = split_colon(text); + if (parts.size() != 2 && parts.size() != 3) { + spdlog::error("compose file: service '{}' volume entry '{}' must be SRC:DST or SRC:DST:MODE", service_name, + text); + return std::nullopt; + } + + bool read_only = false; + if (parts.size() == 3) { + if (parts[2] == "ro") { + read_only = true; + } else if (parts[2] != "rw") { + spdlog::error( + "compose file: service '{}' volume entry '{}' has unsupported mount option '{}' (only ro/rw)", + service_name, text, parts[2]); + return std::nullopt; + } + } + if (parts[0].empty() || parts[1].empty()) { + spdlog::error("compose file: service '{}' volume entry '{}' has an empty source or target", service_name, + text); + return std::nullopt; + } + if (parts[1][0] != '/') { + spdlog::error("compose file: service '{}' volume entry '{}' target must be an absolute container path", + service_name, text); + return std::nullopt; + } + + ComposeVolumeMount mount; + mount.target = parts[1]; + mount.read_only = read_only; + if (is_valid_volume_name(parts[0])) { + mount.is_named_volume = true; + mount.source = parts[0]; + } else { + mount.is_named_volume = false; + mount.source = (base_dir / parts[0]).lexically_normal().string(); + } + return mount; +} + +} // namespace + +std::optional load_compose_file(const std::filesystem::path& path) { + auto parsed = open_and_parse_yaml(path); + if (!parsed) { + return std::nullopt; + } + yaml_document_t& document = *parsed; + YamlDocumentGuard guard{document}; + + yaml_node_t* root = yaml_document_get_root_node(&document); + if (!root || root->type != YAML_MAPPING_NODE) { + spdlog::error("compose file {}: expected a top-level mapping", path.string()); + return std::nullopt; + } + + const yaml_node_t* services_node = find_in_mapping(document, *root, "services"); + if (!services_node || services_node->type != YAML_MAPPING_NODE || + services_node->data.mapping.pairs.start == services_node->data.mapping.pairs.top) { + spdlog::error("compose file {}: no services declared", path.string()); + return std::nullopt; + } + + std::filesystem::path base_dir = std::filesystem::absolute(path).parent_path(); + + ComposeFile result; + + for (auto* pair = services_node->data.mapping.pairs.start; pair < services_node->data.mapping.pairs.top; + ++pair) { + yaml_node_t* key_node = yaml_document_get_node(&document, pair->key); + yaml_node_t* value_node = yaml_document_get_node(&document, pair->value); + if (!key_node || key_node->type != YAML_SCALAR_NODE || scalar_value(*key_node).empty()) { + spdlog::error("compose file {}: services has a bad entry name", path.string()); + return std::nullopt; + } + std::string service_name(scalar_value(*key_node)); + if (std::any_of(result.services.begin(), result.services.end(), + [&](const ComposeService& s) { return s.name == service_name; })) { + spdlog::error("compose file {}: duplicate service '{}'", path.string(), service_name); + return std::nullopt; + } + if (!value_node || value_node->type != YAML_MAPPING_NODE) { + spdlog::error("compose file {}: service '{}' must be a mapping", path.string(), service_name); + return std::nullopt; + } + + ComposeService service; + service.name = service_name; + + const yaml_node_t* image_node = find_in_mapping(document, *value_node, "image"); + if (!image_node || image_node->type != YAML_SCALAR_NODE || scalar_value(*image_node).empty()) { + spdlog::error("compose file {}: service '{}' has no image (build: is not supported)", path.string(), + service_name); + return std::nullopt; + } + service.image = std::string(scalar_value(*image_node)); + + if (const yaml_node_t* container_name_node = find_in_mapping(document, *value_node, "container_name")) { + if (container_name_node->type != YAML_SCALAR_NODE || scalar_value(*container_name_node).empty()) { + spdlog::error("compose file {}: service '{}' container_name must be a non-empty string", + path.string(), service_name); + return std::nullopt; + } + service.container_name = std::string(scalar_value(*container_name_node)); + } + + if (const yaml_node_t* command_node = find_in_mapping(document, *value_node, "command")) { + auto command = parse_command(document, *command_node, service_name); + if (!command) { + return std::nullopt; + } + service.command = std::move(*command); + } + + if (const yaml_node_t* env_file_node = find_in_mapping(document, *value_node, "env_file")) { + auto files = + collect_string_list(document, *env_file_node, false, fmt::format("service '{}' env_file", service_name)); + if (!files) { + return std::nullopt; + } + for (const auto& file : *files) { + std::string resolved = (base_dir / file).lexically_normal().string(); + service.environment_specs.push_back(EnvSpec{true, resolved}); + } + } + if (const yaml_node_t* environment_node = find_in_mapping(document, *value_node, "environment")) { + auto entries = parse_environment_entries(document, *environment_node, service_name); + if (!entries) { + return std::nullopt; + } + for (auto& entry : *entries) { + service.environment_specs.push_back(EnvSpec{false, std::move(entry)}); + } + } + + if (const yaml_node_t* depends_on_node = find_in_mapping(document, *value_node, "depends_on")) { + auto depends = parse_depends_on(document, *depends_on_node, service_name); + if (!depends) { + return std::nullopt; + } + service.depends_on = std::move(*depends); + } + + if (const yaml_node_t* grace_node = find_in_mapping(document, *value_node, "stop_grace_period")) { + if (grace_node->type != YAML_SCALAR_NODE) { + spdlog::error("compose file {}: service '{}' stop_grace_period must be a plain duration string", + path.string(), service_name); + return std::nullopt; + } + auto seconds = parse_duration_seconds(scalar_value(*grace_node)); + if (!seconds) { + spdlog::error("compose file {}: service '{}' has an unparseable stop_grace_period '{}'", + path.string(), service_name, scalar_value(*grace_node)); + return std::nullopt; + } + service.stop_grace_period_seconds = seconds; + } + + if (const yaml_node_t* networks_node = find_in_mapping(document, *value_node, "networks")) { + auto names = + collect_string_list(document, *networks_node, true, fmt::format("service '{}' networks", service_name)); + if (!names) { + return std::nullopt; + } + service.networks = std::move(*names); + } + + if (const yaml_node_t* ports_node = find_in_mapping(document, *value_node, "ports")) { + auto raw_ports = + collect_string_list(document, *ports_node, false, fmt::format("service '{}' ports", service_name)); + if (!raw_ports) { + return std::nullopt; + } + for (const auto& raw : *raw_ports) { + auto spec = parse_port_forward_spec(raw); + if (!spec) { + spdlog::error("compose file {}: service '{}' has an unparseable ports entry '{}'", path.string(), + service_name, raw); + return std::nullopt; + } + service.ports.push_back(*spec); + } + } + + if (const yaml_node_t* volumes_node = find_in_mapping(document, *value_node, "volumes")) { + if (volumes_node->type != YAML_SEQUENCE_NODE) { + spdlog::error("compose file {}: service '{}' volumes must be a list", path.string(), service_name); + return std::nullopt; + } + for (const yaml_node_t* item : sequence_items(document, *volumes_node)) { + if (item->type != YAML_SCALAR_NODE) { + spdlog::error("compose file {}: service '{}' volumes entries must be plain strings", + path.string(), service_name); + return std::nullopt; + } + auto mount = parse_volume_entry(scalar_value(*item), base_dir, service_name); + if (!mount) { + return std::nullopt; + } + service.volumes.push_back(std::move(*mount)); + } + } + + result.services.push_back(std::move(service)); + } + + for (size_t i = 0; i < result.services.size(); ++i) { + if (!result.services[i].container_name) { + continue; + } + for (size_t j = i + 1; j < result.services.size(); ++j) { + if (result.services[j].container_name && + *result.services[j].container_name == *result.services[i].container_name) { + spdlog::error("compose file {}: services '{}' and '{}' both use container_name '{}'", path.string(), + result.services[i].name, result.services[j].name, *result.services[i].container_name); + return std::nullopt; + } + } + } + + if (const yaml_node_t* networks_node = find_in_mapping(document, *root, "networks")) { + if (networks_node->type != YAML_MAPPING_NODE) { + spdlog::error("compose file {}: top-level networks must be a mapping", path.string()); + return std::nullopt; + } + for (auto* pair = networks_node->data.mapping.pairs.start; pair < networks_node->data.mapping.pairs.top; + ++pair) { + yaml_node_t* key_node = yaml_document_get_node(&document, pair->key); + yaml_node_t* value_node = yaml_document_get_node(&document, pair->value); + if (!key_node || key_node->type != YAML_SCALAR_NODE || scalar_value(*key_node).empty()) { + spdlog::error("compose file {}: networks has a bad entry name", path.string()); + return std::nullopt; + } + std::string network_name(scalar_value(*key_node)); + if (!is_valid_network_name(network_name)) { + spdlog::error("compose file {}: network '{}' is not a valid name", path.string(), network_name); + return std::nullopt; + } + if (std::any_of(result.networks.begin(), result.networks.end(), + [&](const ComposeNetwork& n) { return n.name == network_name; })) { + spdlog::error("compose file {}: duplicate network '{}'", path.string(), network_name); + return std::nullopt; + } + + ComposeNetwork network; + network.name = network_name; + bool external = false; + if (value_node && value_node->type == YAML_MAPPING_NODE) { + if (const yaml_node_t* external_node = find_in_mapping(document, *value_node, "external")) { + if (external_node->type != YAML_SCALAR_NODE) { + spdlog::error("compose file {}: network '{}' external must be a plain boolean", path.string(), + network_name); + return std::nullopt; + } + auto parsed = parse_bool_flag(scalar_value(*external_node)); + if (!parsed) { + spdlog::error("compose file {}: network '{}' has an unrecognized external value '{}'", + path.string(), network_name, scalar_value(*external_node)); + return std::nullopt; + } + external = *parsed; + } + if (!external) { + if (const yaml_node_t* internal_node = find_in_mapping(document, *value_node, "internal")) { + if (internal_node->type != YAML_SCALAR_NODE) { + spdlog::error("compose file {}: network '{}' internal must be a plain boolean", + path.string(), network_name); + return std::nullopt; + } + auto parsed = parse_bool_flag(scalar_value(*internal_node)); + if (!parsed) { + spdlog::error("compose file {}: network '{}' has an unrecognized internal value '{}'", + path.string(), network_name, scalar_value(*internal_node)); + return std::nullopt; + } + network.internal = *parsed; + } + } + } else if (value_node && value_node->type != YAML_SCALAR_NODE) { + spdlog::error("compose file {}: network '{}' must be a mapping", path.string(), network_name); + return std::nullopt; + } + network.mode = external ? ComposeNetworkMode::external : ComposeNetworkMode::managed; + result.networks.push_back(std::move(network)); + } + } + + if (const yaml_node_t* volumes_node = find_in_mapping(document, *root, "volumes")) { + if (volumes_node->type != YAML_MAPPING_NODE) { + spdlog::error("compose file {}: top-level volumes must be a mapping", path.string()); + return std::nullopt; + } + for (auto* pair = volumes_node->data.mapping.pairs.start; pair < volumes_node->data.mapping.pairs.top; + ++pair) { + yaml_node_t* key_node = yaml_document_get_node(&document, pair->key); + if (!key_node || key_node->type != YAML_SCALAR_NODE || scalar_value(*key_node).empty()) { + spdlog::error("compose file {}: volumes has a bad entry name", path.string()); + return std::nullopt; + } + std::string volume_name(scalar_value(*key_node)); + if (!is_valid_volume_name(volume_name)) { + spdlog::error("compose file {}: volume '{}' is not a valid name", path.string(), volume_name); + return std::nullopt; + } + if (std::any_of(result.volumes.begin(), result.volumes.end(), + [&](const ComposeVolume& v) { return v.name == volume_name; })) { + spdlog::error("compose file {}: duplicate volume '{}'", path.string(), volume_name); + return std::nullopt; + } + result.volumes.push_back({volume_name}); + } + } + + for (const auto& service : result.services) { + for (const auto& dep : service.depends_on) { + if (dep == service.name) { + spdlog::error("compose file {}: service '{}' cannot depend on itself", path.string(), service.name); + return std::nullopt; + } + if (std::none_of(result.services.begin(), result.services.end(), + [&](const ComposeService& s) { return s.name == dep; })) { + spdlog::error("compose file {}: service '{}' depends_on undeclared service '{}'", path.string(), + service.name, dep); + return std::nullopt; + } + } + for (const auto& network_name : service.networks) { + if (std::none_of(result.networks.begin(), result.networks.end(), + [&](const ComposeNetwork& n) { return n.name == network_name; })) { + spdlog::error("compose file {}: service '{}' references undeclared network '{}'", path.string(), + service.name, network_name); + return std::nullopt; + } + } + for (const auto& mount : service.volumes) { + if (mount.is_named_volume && + std::none_of(result.volumes.begin(), result.volumes.end(), + [&](const ComposeVolume& v) { return v.name == mount.source; })) { + spdlog::error("compose file {}: service '{}' references undeclared volume '{}'", path.string(), + service.name, mount.source); + return std::nullopt; + } + } + } + + return result; +} diff --git a/src/compose_file.h b/src/compose_file.h new file mode 100644 index 0000000..053fcca --- /dev/null +++ b/src/compose_file.h @@ -0,0 +1,162 @@ +// 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. + +#pragma once + +#include +#include +#include +#include + +#include "env_spec.h" +#include "port_forward.h" + +// One bind-mount or named-volume reference under a service's own `volumes:` +// list (short string syntax only, "SRC:DST[:MODE]" -- Compose's long +// mapping-per-entry form isn't supported). `is_named_volume` mirrors +// volume_mount.h's own name/path distinction (is_valid_volume_name(): a +// source containing '/' is a host path, one without is a name): when true, +// `source` is the bare name and must match one declared under the compose +// file's own top-level `volumes:` section; when false, `source` is already +// resolved to an absolute, lexically-normalized path (relative sources are +// resolved against the compose file's own directory), ready to hand to +// resolve_volume_mount() (volume_mount.h) the same way a plain +// `-v ` spec already is. +struct ComposeVolumeMount { + std::string source; + std::string target; + bool is_named_volume = false; + // Parsed from a trailing ":ro" ("rw" is also accepted, as a no-op -- + // it's already the default). Not yet enforced anywhere: + // resolve_volume_mount()/build_bwrap_args() only ever bind writable, so + // this is captured here rather than silently lost, for when read-only + // bind support exists. + bool read_only = false; +}; + +struct ComposeService { + std::string name; // the mapping key under `services` + std::string image; + std::optional container_name; + // Always normalized to an argv list: Compose's own list form is used + // as-is; its scalar shell-string form is wrapped as {"sh", "-c", + // }, matching Compose's "run through the image's shell" + // semantics for that form. + std::vector command; + // env_file entries first (in their own list order), then environment + // entries (list or mapping form, in their own order) -- environment + // always overrides env_file for the same key regardless of which + // appears first in the YAML, matching real Compose precedence; handing + // this straight to resolve_env_specs() (env_spec.h) reproduces that for + // free, via the same "later wins" mechanism it already implements for + // --env/--env-file. env_file paths are already resolved to absolute + // (relative to the compose file's own directory). A mapping-form + // environment entry with no value (`FOO:`, real Compose's "inherit from + // the host" syntax) is indistinguishable from an explicit `FOO: ""` + // through libyaml's own document API, so both are accepted as a literal + // empty value rather than either being rejected or actually inheriting + // anything. + std::vector environment_specs; + // Names of services that must be started before this one. Both the long + // mapping form (`depends_on: : {condition: ...}`) and the short + // list form (`depends_on: [, ...]`, condition implicitly + // service_started) are accepted; a long-form entry's `restart`/ + // `required` sub-keys, if present, are ignored -- not implemented. + // `condition: service_started` and `condition: service_healthy` are + // both accepted and treated identically (no real healthcheck support + // yet, so "healthy" can't mean anything more than "started" today); any + // other condition value is a parse error. Every name here is validated + // to reference a real, distinct (non-self-referential) service declared + // under `services`. + std::vector depends_on; + // Parsed from a Go-style duration string (e.g. "20s", "1m30s", "1.5h" + // -- h/m/s/ms units, optionally combined), rounded to whole seconds. + std::optional stop_grace_period_seconds; + // Names referencing the compose file's own top-level `networks:` + // section (list or per-network mapping form; a mapping form's nested + // per-network fields, e.g. `aliases`, are ignored). Every name is + // validated to be declared there. + std::vector networks; + // Parsed via port_forward.h's own parse_port_forward_spec() (list or + // single-scalar form) -- Compose's own ":[/proto]" + // syntax is a strict subset of that function's, so this reuses it + // directly rather than a second parser. A `network:` prefix is never + // present in a Compose ports entry, so `PortForwardSpec::network` is + // always unset here -- a later `-p` resolution then picks this + // service's own sole `extern` network, exactly like an ordinary CLI + // `-p` with no prefix already does. Known gap: a host-IP-prefixed entry + // (e.g. "127.0.0.1:8080:80", valid real Compose syntax) is not + // rejected at parse time -- it has the same 3-colon-separated-field + // shape as a network-qualified `-p` spec, so it's misread as if + // "127.0.0.1" were a network name instead, only failing later, at + // network-resolution time, with a confusing "unknown network" error. + // Not worth special-casing for a form nothing here actually uses yet. + std::vector ports; + std::vector volumes; +}; + +// A compose-file-level network reference (the top-level `networks:` section). +enum class ComposeNetworkMode { + // Created by the (not yet implemented) orchestrator as a slocker-lite + // -n/--network of the kind `internal` selects: true maps to `intern`, + // false (the default, matching Compose's own default) maps to `extern`. + managed, + // `external: true` -- must already exist as a real slocker-lite network, + // created by hand ahead of time; its kind/subnet are whatever they + // already are on the host and are not recorded here at all -- checked + // against the real persistent.yaml at "up" time, not by this parser. + external, +}; + +struct ComposeNetwork { + std::string name; + ComposeNetworkMode mode = ComposeNetworkMode::managed; + bool internal = false; // only meaningful when mode == managed +}; + +// A compose-file-level named volume (the top-level `volumes:` section). Just +// its declared name for now -- Compose auto-provisions a host directory for +// one with no further fields, which this parser doesn't do (an orchestrator +// concern, not yet implemented); recorded here only so a service's own +// `volumes:` references can be validated against it. +struct ComposeVolume { + std::string name; +}; + +struct ComposeFile { + std::vector services; + std::vector networks; + std::vector volumes; +}; + +// Loads and validates `path` as a Compose file, extracting only the subset +// of fields slocker-lite supports -- see this header's own struct comments +// for exactly which keys/forms of each. Every other key (`build`, `deploy`, +// `restart`, `healthcheck`, ...), and any unrecognized top-level section, is +// silently ignored, matching config_file.h's own forward-compatible +// "unknown keys ignored" policy -- but a *supported* key with an +// unrecognized or malformed value (a missing `image`, a bad +// `stop_grace_period` duration, an undeclared network/volume reference, a +// duplicate service/network/volume name, a self-referential or +// unresolvable `depends_on`, an unsupported `depends_on` `condition` value, +// an unparseable `ports`/`volumes` entry) is a hard parse error: a Compose +// file is user-authored input describing an actual deployment, not a +// version-spanning settings file, so a mistake here should surface clearly +// rather than be silently skipped the way config_file.cpp treats a +// malformed config.yaml entry. Logs a specific error and returns nullopt +// for any of the above, for unreadable/malformed YAML, for a missing file, +// or for a missing/empty `services` section. +std::optional load_compose_file(const std::filesystem::path& path); diff --git a/src/config_file.cpp b/src/config_file.cpp index 0108d04..bcdfc21 100644 --- a/src/config_file.cpp +++ b/src/config_file.cpp @@ -29,13 +29,10 @@ #include #include "pid_file.h" +#include "yaml_util.h" namespace { -std::string_view scalar_value(const yaml_node_t& node) { - return {reinterpret_cast(node.data.scalar.value), node.data.scalar.length}; -} - // Pairs a boolean global.* YAML key with the AppConfig field it fills, so // load_config_file()/write_config_file() can share one list (per group) // instead of repeating each group's keys twice. @@ -60,22 +57,6 @@ constexpr std::array network_default_keys = {{ {"with-ipv6", &AppConfig::with_ipv6}, }}; -// Finds `key` in a YAML_MAPPING_NODE and returns its value node, or nullptr if -// `node` isn't a mapping or has no such (scalar) key. -const yaml_node_t* find_in_mapping(yaml_document_t& document, const yaml_node_t& node, - std::string_view key) { - if (node.type != YAML_MAPPING_NODE) { - return nullptr; - } - for (auto* pair = node.data.mapping.pairs.start; pair < node.data.mapping.pairs.top; ++pair) { - yaml_node_t* key_node = yaml_document_get_node(&document, pair->key); - if (key_node && key_node->type == YAML_SCALAR_NODE && scalar_value(*key_node) == key) { - return yaml_document_get_node(&document, pair->value); - } - } - return nullptr; -} - int add_scalar(yaml_document_t& document, std::string_view value) { return yaml_document_add_scalar(&document, reinterpret_cast(const_cast(YAML_STR_TAG)), reinterpret_cast(value.data()), diff --git a/src/yaml_util.cpp b/src/yaml_util.cpp new file mode 100644 index 0000000..9392dfa --- /dev/null +++ b/src/yaml_util.cpp @@ -0,0 +1,47 @@ +// 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. + +#include "yaml_util.h" + +std::string_view scalar_value(const yaml_node_t& node) { + return {reinterpret_cast(node.data.scalar.value), node.data.scalar.length}; +} + +const yaml_node_t* find_in_mapping(yaml_document_t& document, const yaml_node_t& node, std::string_view key) { + if (node.type != YAML_MAPPING_NODE) { + return nullptr; + } + for (auto* pair = node.data.mapping.pairs.start; pair < node.data.mapping.pairs.top; ++pair) { + yaml_node_t* key_node = yaml_document_get_node(&document, pair->key); + if (key_node && key_node->type == YAML_SCALAR_NODE && scalar_value(*key_node) == key) { + return yaml_document_get_node(&document, pair->value); + } + } + return nullptr; +} + +std::vector sequence_items(yaml_document_t& document, const yaml_node_t& node) { + std::vector items; + if (node.type != YAML_SEQUENCE_NODE) { + return items; + } + for (auto* item = node.data.sequence.items.start; item < node.data.sequence.items.top; ++item) { + if (yaml_node_t* n = yaml_document_get_node(&document, *item)) { + items.push_back(n); + } + } + return items; +} diff --git a/src/yaml_util.h b/src/yaml_util.h new file mode 100644 index 0000000..96ebde8 --- /dev/null +++ b/src/yaml_util.h @@ -0,0 +1,39 @@ +// 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. + +#pragma once + +#include +#include + +#include + +// Shared libyaml document-traversal helpers -- originally config_file.cpp's +// own .cpp-local pair, pulled out here once compose_file.cpp needed the +// exact same two, plus a sequence-iteration helper neither config.yaml's +// own (purely scalar-mapping) shape ever needed before. + +// The scalar node's own text -- borrows its lifetime from `node` (i.e. from +// the yaml_document_t it belongs to). +std::string_view scalar_value(const yaml_node_t& node); + +// Finds `key` in a YAML_MAPPING_NODE and returns its value node, or nullptr +// if `node` isn't a mapping or has no such (scalar) key. +const yaml_node_t* find_in_mapping(yaml_document_t& document, const yaml_node_t& node, std::string_view key); + +// Every item of a YAML_SEQUENCE_NODE, in order -- empty if `node` isn't a +// sequence. +std::vector sequence_items(yaml_document_t& document, const yaml_node_t& node); diff --git a/tests/unit/test_compose_file.cpp b/tests/unit/test_compose_file.cpp new file mode 100644 index 0000000..1553ff9 --- /dev/null +++ b/tests/unit/test_compose_file.cpp @@ -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 +#include + +#include + +#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{"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{"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{"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{"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{"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()); +}