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
This commit is contained in:
@@ -23,6 +23,7 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
#include <system_error>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -822,3 +823,37 @@ std::optional<ComposeFile> load_compose_file(const std::filesystem::path& path)
|
|||||||
|
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "config_file.h"
|
||||||
#include "env_spec.h"
|
#include "env_spec.h"
|
||||||
#include "port_forward.h"
|
#include "port_forward.h"
|
||||||
|
|
||||||
@@ -160,3 +161,25 @@ struct ComposeFile {
|
|||||||
// for any of the above, for unreadable/malformed YAML, for a missing file,
|
// for any of the above, for unreadable/malformed YAML, for a missing file,
|
||||||
// or for a missing/empty `services` section.
|
// or for a missing/empty `services` section.
|
||||||
std::optional<ComposeFile> load_compose_file(const std::filesystem::path& path);
|
std::optional<ComposeFile> 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);
|
||||||
|
|||||||
@@ -528,3 +528,53 @@ TEST_CASE("compose file: service volumes -- bind mounts (absolute-resolved, ro),
|
|||||||
" - ./scripts:relative/path\n");
|
" - ./scripts:relative/path\n");
|
||||||
CHECK_FALSE(load_compose_file(relative_target).has_value());
|
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{}));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user