From 4ffc68a00e4dcbfe90b87c32a153d1a706e20c6e Mon Sep 17 00:00:00 2001 From: Viorel Munteanu Date: Mon, 7 Sep 2026 09:39:12 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz --- src/compose_file.cpp | 35 ++++++++++++++++++++++ src/compose_file.h | 23 +++++++++++++++ tests/unit/test_compose_file.cpp | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/src/compose_file.cpp b/src/compose_file.cpp index 634c5ce..be0a98b 100644 --- a/src/compose_file.cpp +++ b/src/compose_file.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -822,3 +823,37 @@ std::optional load_compose_file(const std::filesystem::path& path) 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; +} diff --git a/src/compose_file.h b/src/compose_file.h index 053fcca..5e0a75b 100644 --- a/src/compose_file.h +++ b/src/compose_file.h @@ -21,6 +21,7 @@ #include #include +#include "config_file.h" #include "env_spec.h" #include "port_forward.h" @@ -160,3 +161,25 @@ struct ComposeFile { // 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); + +// Checks `compose` against real, live host state that load_compose_file() +// itself never touches (it's pure YAML validation, with no dependency on +// anything outside the file being parsed) -- namely: every `env_file` entry +// (`ComposeService::environment_specs` where `is_file` is true) must exist +// as a readable regular file, and every network with `mode == +// ComposeNetworkMode::external` must already exist in `persistent_config` +// (the real, already-loaded `persistent.yaml` -- see `load_persistent_config()`, +// `config_file.h`), i.e. have a matching `NetworkEntry::name`. Deliberately +// a separate function from load_compose_file(), not folded into it: this +// needs real host state (a real `AppConfig`, a real filesystem) that a pure +// parse of the YAML itself never should, matching this project's existing +// separation of pure parsing (`config_file.cpp`'s own load functions) from +// checks/actions against live host state (`network_bridge.cpp`'s +// provisioning). Stops and returns false at the first problem found, +// logging a specific error -- same fail-fast convention +// load_compose_file()'s own cross-validation already uses, rather than +// collecting every problem into one report. Bind-mount host directories +// are deliberately not checked here: `resolve_volume_mount()` (`volume_mount.h`) +// already auto-creates a missing one, so requiring it to pre-exist would +// only be stricter than the runtime behavior it's meant to sanity-check. +bool validate_compose_external_state(const ComposeFile& compose, const AppConfig& persistent_config); diff --git a/tests/unit/test_compose_file.cpp b/tests/unit/test_compose_file.cpp index c9ed6f4..40e8ede 100644 --- a/tests/unit/test_compose_file.cpp +++ b/tests/unit/test_compose_file.cpp @@ -528,3 +528,53 @@ TEST_CASE("compose file: service volumes -- bind mounts (absolute-resolved, ro), " - ./scripts:relative/path\n"); CHECK_FALSE(load_compose_file(relative_target).has_value()); } + +TEST_CASE("compose file: validate_compose_external_state -- env_file existence, external network existence", + "[unit]") { + ScratchXdgDirs scratch; + + auto path = write_compose(scratch.path(), + "services:\n" + " web:\n" + " image: busybox:latest\n" + " env_file: ./web.env\n" + "networks:\n" + " net-managed:\n" + " internal: false\n" + " net-preexisting:\n" + " external: true\n"); + auto loaded = load_compose_file(path); + REQUIRE(loaded.has_value()); + + // env_file doesn't exist yet, and the external network isn't declared + // anywhere in persistent_config -- both must fail, one at a time + // (fail-fast, same convention load_compose_file()'s own cross-validation + // already uses). + AppConfig empty_persistent; + CHECK_FALSE(validate_compose_external_state(*loaded, empty_persistent)); + + // Create the env_file -- still fails, since the external network still + // isn't declared. + { + std::ofstream env_file(scratch.path() / "web.env"); + env_file << "FOO=bar\n"; + } + CHECK_FALSE(validate_compose_external_state(*loaded, empty_persistent)); + + // Declare the external network -- now both checks pass. A managed + // (non-external) network, net-managed, is never checked against + // persistent_config at all, so its absence there doesn't matter. + AppConfig with_network; + with_network.networks.push_back({"net-preexisting", NetworkKind::intern, "10.169.10.0/24", false, "", true}); + CHECK(validate_compose_external_state(*loaded, with_network)); + + // A directory in place of the env_file still fails -- not a regular file. + auto dir_env_file = write_compose(scratch.path(), "services:\n" + " web:\n" + " image: busybox:latest\n" + " env_file: ./a-directory\n"); + auto loaded_dir = load_compose_file(dir_env_file); + REQUIRE(loaded_dir.has_value()); + std::filesystem::create_directory(scratch.path() / "a-directory"); + CHECK_FALSE(validate_compose_external_state(*loaded_dir, AppConfig{})); +}