diff --git a/src/cli_args.cpp b/src/cli_args.cpp index 909dac3..7ad30cb 100644 --- a/src/cli_args.cpp +++ b/src/cli_args.cpp @@ -82,7 +82,7 @@ constexpr int umount = 280; constexpr int cleanup = 281; } // namespace options -constexpr std::array long_options = {{ +constexpr std::array long_options = {{ {"help", no_argument, nullptr, 'h'}, {"version", no_argument, nullptr, 'V'}, {"test", no_argument, nullptr, 't'}, @@ -122,6 +122,8 @@ constexpr std::array long_options = {{ {"delete-network-full", required_argument, nullptr, options::delete_network_full}, {"port-forward", required_argument, nullptr, 'p'}, {"no-dns", no_argument, nullptr, options::no_dns}, + {"up", required_argument, nullptr, 'u'}, + {"down", required_argument, nullptr, 'd'}, {nullptr, 0, nullptr, 0}, }}; @@ -148,6 +150,8 @@ void print_usage(const char* prog) { " {0} --delete-network-full \n" " {0} --list-processes\n" " {0} --clean-processes\n" + " {0} -u|--up []\n" + " {0} -d|--down []\n" " {0} [-c|--config-file ] -w|--write-config\n" " {0} -t|--test [-- ]\n" " {0} -h|--help\n" @@ -297,6 +301,17 @@ void print_usage(const char* prog) { " --clean-processes remove stale pid files (see --list-processes)\n" " left behind by sessions that are no longer\n" " running\n" + " -u, --up []\n" + " load and validate a Compose file (default\n" + " \"compose.yaml\", resolved relative to the\n" + " current directory) against \n" + " (where its services' images are expected to\n" + " live) -- orchestration itself (actually\n" + " starting the services) isn't implemented yet\n" + " -d, --down []\n" + " same file/directory resolution as -u/--up --\n" + " orchestration itself (actually stopping the\n" + " services) isn't implemented yet\n" " -w, --write-config write a complete *global* config file (creating\n" " it, and its parent directory, if missing),\n" " filling in every global option's current or\n" @@ -393,7 +408,7 @@ std::optional parse_args(int argc, char* argv[], ParsedArgs& out) { optind = 0; opterr = 0; int opt; - while ((opt = getopt_long(argc, argv, ":hVtc:r:l:v:i:x:Dwn:p:", long_options.data(), nullptr)) != -1) { + while ((opt = getopt_long(argc, argv, ":hVtc:r:l:v:i:x:Dwn:p:u:d:", long_options.data(), nullptr)) != -1) { switch (opt) { case 'h': print_usage(argv[0]); @@ -502,6 +517,30 @@ std::optional parse_args(int argc, char* argv[], ParsedArgs& out) { ++optind; break; } + case 'u': + case 'd': { + // -u/--up and -d/--down both take the OCI images directory + // (optarg, required) plus an optional second token naming the + // compose YAML file to load, resolved relative to the cwd -- + // same manual second-token consumption -v/--volume already + // uses above, except this second token is optional: only + // consumed when present and not itself the next flag (the + // same "looks like -x" guard -v's own required second token + // uses to detect it was omitted). + Mode requested = (opt == 'u') ? Mode::compose_up : Mode::compose_down; + if (out.mode != Mode::none && out.mode != requested) { + spdlog::error("multiple actions specified"); + print_usage(argv[0]); + return 1; + } + out.mode = requested; + out.compose_images_directory = optarg; + if (optind < argc && !(argv[optind][0] == '-' && argv[optind][1] != '\0')) { + out.compose_file_name = argv[optind]; + ++optind; + } + break; + } case 'n': // -n/--network takes a single token (the name), always via // getopt's own required_argument -- repeatable, so it's diff --git a/src/cli_args.h b/src/cli_args.h index eff1946..5f16a2a 100644 --- a/src/cli_args.h +++ b/src/cli_args.h @@ -47,7 +47,9 @@ enum class Mode { network, list_networks, delete_network, - delete_network_full + delete_network_full, + compose_up, + compose_down }; // Everything parse_args() extracts from argv, ready to hand to @@ -120,6 +122,17 @@ struct ParsedArgs { // (possibly -c-sourced) global config's own log-level -- see // apply_log_level()'s own doc comment below for why this matters. bool log_level_flag_given = false; + // -u/--up [] and -d/--down + // []: `compose_images_directory` + // (optarg) is required; the second, optional token names the compose + // YAML file to load -- resolved relative to the current working + // directory, same as any other plain file argument on this CLI (e.g. + // -r/--run ), *not* relative to the images directory. + // Defaults to "compose.yaml" when omitted. Consumed the same manual + // second-token technique -v/--volume already uses (cli_args.cpp), just + // with the second token optional rather than required. + std::optional compose_images_directory; + std::string compose_file_name = "compose.yaml"; }; // Validates and applies a log-level name (trace/debug/info/warn/error/critical/off) diff --git a/src/commands.cpp b/src/commands.cpp index 09e3e17..1800107 100644 --- a/src/commands.cpp +++ b/src/commands.cpp @@ -33,6 +33,7 @@ #include #include "bwrap.h" +#include "compose_file.h" #include "containers_storage.h" #include "daemonize.h" #include "env_spec.h" @@ -576,6 +577,60 @@ int write_config_command(const std::filesystem::path& config_path, const AppConf return 0; } +// Shared by compose_up_command()/compose_down_command() below: both need +// the exact same resolve-images-directory + load_compose_file() + +// validate_compose_external_state() step before doing anything +// mode-specific -- which, for now, is nothing at all; see each caller's own +// "not implemented yet" message. `compose_file_name` is resolved relative +// to the current working directory (cli_args.h's own doc comment on +// ParsedArgs::compose_file_name), independent of `images_directory`. +std::optional load_and_validate_compose(const std::string& images_directory, + const std::string& compose_file_name, AppConfig& config) { + if (!std::filesystem::is_directory(images_directory)) { + spdlog::error("images directory does not exist or isn't a directory: {}", images_directory); + return std::nullopt; + } + + auto compose_path = std::filesystem::absolute(compose_file_name); + auto compose = load_compose_file(compose_path); + if (!compose) { + return std::nullopt; + } + if (!validate_compose_external_state(*compose, config)) { + return std::nullopt; + } + + fmt::print("compose file {} parsed and validated: {} service(s), {} network(s), {} volume(s)\n", + compose_path.string(), compose->services.size(), compose->networks.size(), compose->volumes.size()); + fmt::print("images directory: {}\n", images_directory); + return compose; +} + +// Stub for -u/--up: confirms the CLI wiring and the existing parser/ +// validator (compose_file.h) work end to end against a real compose file, +// but doesn't actually start anything yet -- see CLAUDE.md's own "Mutable +// global state and multi-container support" note for the planned +// one-forked-process-per-service orchestration model this will eventually +// drive. +int compose_up_command(const std::string& images_directory, const std::string& compose_file_name, + AppConfig& config) { + if (!load_and_validate_compose(images_directory, compose_file_name, config)) { + return 1; + } + spdlog::warn("compose orchestration (-u/--up) isn't implemented yet -- nothing was actually started"); + return 0; +} + +// Stub for -d/--down -- same idea as compose_up_command() above. +int compose_down_command(const std::string& images_directory, const std::string& compose_file_name, + AppConfig& config) { + if (!load_and_validate_compose(images_directory, compose_file_name, config)) { + return 1; + } + spdlog::warn("compose orchestration (-d/--down) isn't implemented yet -- nothing was actually stopped"); + return 0; +} + int run_container(const std::filesystem::path& image_tar, const std::vector& requested_command, bool use_nsenter, const std::optional& user, const std::optional& group, @@ -913,6 +968,10 @@ int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config return delete_network_command(args.mode_arg, config, false); case Mode::delete_network_full: return delete_network_command(args.mode_arg, config, true); + case Mode::compose_up: + return compose_up_command(*args.compose_images_directory, args.compose_file_name, config); + case Mode::compose_down: + return compose_down_command(*args.compose_images_directory, args.compose_file_name, config); case Mode::run: { // As root, containers-storage mount doesn't need to reexec into a private // user namespace to gain privilege, so the mount is already directly diff --git a/tests/unit/test_cli_args.cpp b/tests/unit/test_cli_args.cpp index fa799d2..31fe900 100644 --- a/tests/unit/test_cli_args.cpp +++ b/tests/unit/test_cli_args.cpp @@ -249,3 +249,48 @@ TEST_CASE("parse_args: --log-level sets log_level_flag_given", "[unit]") { REQUIRE_FALSE(with_level.exit_code.has_value()); CHECK(with_level.args.log_level_flag_given); } + +TEST_CASE("parse_args: -u/--up defaults compose_file_name to compose.yaml when omitted", "[unit]") { + auto result = run_parse({"-u", "/var/lib/images"}); + REQUIRE_FALSE(result.exit_code.has_value()); + CHECK(result.args.mode == Mode::compose_up); + REQUIRE(result.args.compose_images_directory.has_value()); + CHECK(*result.args.compose_images_directory == "/var/lib/images"); + CHECK(result.args.compose_file_name == "compose.yaml"); +} + +TEST_CASE("parse_args: -u accepts an explicit second token as compose_file_name", "[unit]") { + auto result = run_parse({"-u", "/var/lib/images", "my-stack.yaml"}); + REQUIRE_FALSE(result.exit_code.has_value()); + CHECK(result.args.mode == Mode::compose_up); + CHECK(*result.args.compose_images_directory == "/var/lib/images"); + CHECK(result.args.compose_file_name == "my-stack.yaml"); +} + +TEST_CASE("parse_args: -u doesn't swallow a following flag as compose_file_name", "[unit]") { + auto result = run_parse({"-u", "/var/lib/images", "--log-level", "debug"}); + REQUIRE_FALSE(result.exit_code.has_value()); + CHECK(result.args.mode == Mode::compose_up); + CHECK(*result.args.compose_images_directory == "/var/lib/images"); + // The default, since "--log-level" looks like a flag, not a filename. + CHECK(result.args.compose_file_name == "compose.yaml"); + CHECK(result.args.log_level_flag_given); +} + +TEST_CASE("parse_args: -d/--down works the same way as -u/--up", "[unit]") { + auto default_name = run_parse({"--down", "/var/lib/images"}); + REQUIRE_FALSE(default_name.exit_code.has_value()); + CHECK(default_name.args.mode == Mode::compose_down); + CHECK(default_name.args.compose_file_name == "compose.yaml"); + + auto explicit_name = run_parse({"-d", "/var/lib/images", "my-stack.yaml"}); + REQUIRE_FALSE(explicit_name.exit_code.has_value()); + CHECK(explicit_name.args.mode == Mode::compose_down); + CHECK(explicit_name.args.compose_file_name == "my-stack.yaml"); +} + +TEST_CASE("parse_args: -u and -d together on the same command line is a parse error", "[unit]") { + auto result = run_parse({"-u", "/var/lib/images", "-d", "/var/lib/images"}); + REQUIRE(result.exit_code.has_value()); + CHECK(*result.exit_code == 1); +}