4ffc68a00e
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
860 lines
40 KiB
C++
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;
|
|
}
|