Files
slocker-lite/src/compose_file.cpp
T
ceamac 4ffc68a00e Add validate_compose_external_state() for env_file/external-network checks
Separate from load_compose_file() (which stays pure YAML validation with no
host-state dependency): checks every env_file exists as a readable regular
file, and every network marked external: true already exists in the real
persistent.yaml. Fail-fast, same convention as load_compose_file()'s own
cross-validation. Bind-mount host directories are deliberately not checked
here, since resolve_volume_mount() already auto-creates a missing one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-07 09:39:12 +00:00

860 lines
40 KiB
C++

// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#include "compose_file.h"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
#include <vector>
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#include <yaml.h>
#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<yaml_document_t> 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<std::vector<std::string>> collect_string_list(yaml_document_t& document, const yaml_node_t& node,
bool allow_mapping_keys, const std::string& context) {
std::vector<std::string> 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",
// <string>}, matching Compose's "run through the image's shell" semantics
// for that form.
std::optional<std::vector<std::string>> 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<std::string>{"sh", "-c", std::string(scalar_value(node))};
}
if (node.type == YAML_SEQUENCE_NODE) {
std::vector<std::string> 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<std::vector<std::string>> parse_environment_entries(yaml_document_t& document, const yaml_node_t& node,
const std::string& service_name) {
std::vector<std::string> 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<std::vector<std::string>> parse_depends_on(yaml_document_t& document, const yaml_node_t& node,
const std::string& service_name) {
std::vector<std::string> 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<int> 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<unsigned char>(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<unsigned char>(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<int>(total_seconds + 0.5);
}
std::vector<std::string> split_colon(std::string_view text) {
std::vector<std::string> 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<ComposeVolumeMount> 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;
}
enum class DfsColor { white, gray, black };
DfsColor& color_of(std::vector<std::pair<std::string, DfsColor>>& colors, const std::string& name) {
for (auto& entry : colors) {
if (entry.first == name) {
return entry.second;
}
}
static DfsColor unreachable = DfsColor::black; // every name is seeded into `colors` up front -- never reached
return unreachable;
}
const ComposeService* find_service(const std::vector<ComposeService>& services, const std::string& name) {
for (const auto& service : services) {
if (service.name == name) {
return &service;
}
}
return nullptr;
}
// Recursive step of find_dependency_cycle()'s three-color DFS: returns the
// cycle path if the subtree rooted at `name` contains one (a depends_on
// edge reaching back to a `gray` -- currently-on-the-stack -- node), else
// nullopt. `stack` mirrors the recursion path so the actual cycle can be
// read off it once a back-edge is found, rather than just reporting "a
// cycle exists somewhere."
std::optional<std::vector<std::string>> dfs_find_cycle(const std::vector<ComposeService>& services,
std::vector<std::pair<std::string, DfsColor>>& colors,
std::vector<std::string>& stack, const std::string& name) {
color_of(colors, name) = DfsColor::gray;
stack.push_back(name);
const ComposeService* service = find_service(services, name);
for (const auto& dep : service->depends_on) {
DfsColor dep_color = color_of(colors, dep);
if (dep_color == DfsColor::gray) {
auto start = std::find(stack.begin(), stack.end(), dep);
std::vector<std::string> cycle(start, stack.end());
cycle.push_back(dep);
return cycle;
}
if (dep_color == DfsColor::white) {
if (auto found = dfs_find_cycle(services, colors, stack, dep)) {
return found;
}
}
}
stack.pop_back();
color_of(colors, name) = DfsColor::black;
return std::nullopt;
}
// Finds a depends_on cycle among `services` (A -> B -> ... -> A), if any --
// not just the direct self-reference load_compose_file() already rejects
// per-edge before this ever runs, but any longer one, which would make a
// future orchestrator's own startup ordering impossible. Assumes every
// depends_on name already refers to a real, declared service (the
// per-edge existence check that runs first guarantees this). Returns the
// actual cycle path (e.g. {"a", "b", "c", "a"}) if one exists.
std::optional<std::vector<std::string>> find_dependency_cycle(const std::vector<ComposeService>& services) {
std::vector<std::pair<std::string, DfsColor>> colors;
colors.reserve(services.size());
for (const auto& service : services) {
colors.emplace_back(service.name, DfsColor::white);
}
std::vector<std::string> stack;
for (const auto& service : services) {
if (color_of(colors, service.name) == DfsColor::white) {
if (auto found = dfs_find_cycle(services, colors, stack, service.name)) {
return found;
}
}
}
return std::nullopt;
}
} // namespace
std::optional<ComposeFile> 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;
}
const std::string& container_name = *result.services[i].container_name;
for (size_t j = 0; j < result.services.size(); ++j) {
if (j == i) {
continue;
}
// Two services both setting the same explicit container_name --
// checked with j > i so the pair is only reported once, not
// once as (i, j) and again as (j, i).
if (j > i && result.services[j].container_name && *result.services[j].container_name == container_name) {
spdlog::error("compose file {}: services '{}' and '{}' both use container_name '{}'", path.string(),
result.services[i].name, result.services[j].name, container_name);
return std::nullopt;
}
// A service's own explicit container_name colliding with
// *another* service's implicit identity (its own name, when it
// has no container_name of its own) -- inherently one-directional
// (service[i]'s container_name vs. service[j]'s name), so no
// j > i guard is needed here the way the symmetric check above
// needs one.
if (result.services[j].name == container_name) {
spdlog::error(
"compose file {}: service '{}' has container_name '{}', colliding with service '{}'s own name",
path.string(), result.services[i].name, container_name, result.services[j].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;
}
}
}
// Not just the direct self-reference already rejected above -- a longer
// cycle (A -> B -> C -> A) would make a future orchestrator's own
// startup ordering impossible.
if (auto cycle = find_dependency_cycle(result.services)) {
std::string cycle_text;
for (size_t i = 0; i < cycle->size(); ++i) {
if (i > 0) {
cycle_text += " -> ";
}
cycle_text += (*cycle)[i];
}
spdlog::error("compose file {}: depends_on cycle: {}", path.string(), cycle_text);
return std::nullopt;
}
// A (host_port, protocol) pair names one real, host-wide socket -- two
// services (or two entries within the same one) both publishing it
// would only ever leave one of them actually reachable, even though
// both DNAT rules would get added by a future orchestrator.
struct PublishedPort {
int host_port;
PortForwardProtocol protocol;
std::string service_name;
};
std::vector<PublishedPort> published_ports;
for (const auto& service : result.services) {
for (const auto& port : service.ports) {
auto colliding =
std::find_if(published_ports.begin(), published_ports.end(), [&](const PublishedPort& p) {
return p.host_port == port.host_port && p.protocol == port.protocol;
});
if (colliding != published_ports.end()) {
const char* proto = port.protocol == PortForwardProtocol::udp ? "udp" : "tcp";
if (colliding->service_name == service.name) {
spdlog::error("compose file {}: service '{}' publishes host port {}/{} twice", path.string(),
service.name, port.host_port, proto);
} else {
spdlog::error("compose file {}: services '{}' and '{}' both publish host port {}/{}",
path.string(), colliding->service_name, service.name, port.host_port, proto);
}
return std::nullopt;
}
published_ports.push_back({port.host_port, port.protocol, service.name});
}
}
return result;
}
bool validate_compose_external_state(const ComposeFile& compose, const AppConfig& persistent_config) {
for (const auto& service : compose.services) {
for (const auto& spec : service.environment_specs) {
if (!spec.is_file) {
continue;
}
std::error_code ec;
bool is_regular_file = std::filesystem::is_regular_file(spec.value, ec);
if (ec || !is_regular_file) {
spdlog::error("compose file: service '{}' env_file '{}' does not exist (or isn't a regular file)",
service.name, spec.value);
return false;
}
}
}
for (const auto& network : compose.networks) {
if (network.mode != ComposeNetworkMode::external) {
continue;
}
bool exists = std::any_of(persistent_config.networks.begin(), persistent_config.networks.end(),
[&](const NetworkEntry& entry) { return entry.name == network.name; });
if (!exists) {
spdlog::error(
"compose file: network '{}' is marked external but no such network exists yet -- create it first "
"with -n/--network --intern or --extern",
network.name);
return false;
}
}
return true;
}