Files
ceamac 8b33afe03b Implement -d/--down, taking only an optional compose file name
Splits -u/-d's previously-shared CLI parsing: -u/--up keeps its two-token
(required images directory + optional compose file) shape, while -d/--down
becomes its own no_argument option with a single manually-peeked optional
trailing token -- no images directory needed at all, since stopping a
stack doesn't mount or resolve any image.

stop_compose_services() (compose_orchestrator.{h,cpp}) reads the state
file a previous -u/--up wrote for the same compose path, stops every
recorded session via kill_session() (the same graceful mechanism --kill
already uses), then removes the state file. If the compose file still
exists and parses, each service's own stop_grace_period_seconds (parsed
since compose_file.cpp's first commit but unused until now) is honored as
that service's own grace period instead of kill_session()'s 10s default,
falling back to it otherwise -- the state file alone already has
everything strictly required.

Known scope limitation: doesn't tear down the compose file's own managed
networks/volumes, matching real `docker compose down`'s own default.

Verified manually end to end: -u followed by a bare -d correctly finds and
stops both services via the recorded state file, removes it, and leaves no
processes behind; -d against a compose file with nothing recorded exits
cleanly (not an error).

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

313 lines
13 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.
// [unit] tests for parse_args() (cli_args.h) against synthetic argv's --
// pure argument parsing, no mounting/running/side effects. Each call goes
// through the real getopt_long()-based parser exactly as main() does.
#include <string>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "cli_args.h"
namespace {
// Builds a synthetic argv (argv[0] is always "slocker-lite", matching this
// project's own real binary name -- parse_args() only ever uses it for
// print_usage()'s own diagnostic text) and calls parse_args(). The
// std::vector<std::string> is kept alive by the caller for as long as the
// returned char* vector is used, since parse_args() doesn't copy argv's
// own strings out (ParsedArgs::command/test_args are the only things it
// keeps, and those *are* copies).
struct ParseResult {
std::optional<int> exit_code;
ParsedArgs args;
};
ParseResult run_parse(const std::vector<std::string>& args) {
std::vector<std::string> owned = {"slocker-lite"};
owned.insert(owned.end(), args.begin(), args.end());
std::vector<char*> argv;
argv.reserve(owned.size());
for (auto& a : owned) {
argv.push_back(a.data());
}
ParseResult result;
result.exit_code = parse_args(static_cast<int>(argv.size()), argv.data(), result.args);
return result;
}
} // namespace
TEST_CASE("parse_args: --mount <image.tar> selects Mode::mount", "[unit]") {
auto result = run_parse({"--mount", "image.tar"});
REQUIRE_FALSE(result.exit_code.has_value());
CHECK(result.args.mode == Mode::mount);
CHECK(result.args.mode_arg == "image.tar");
}
TEST_CASE("parse_args: -m is no longer a recognized short option", "[unit]") {
auto result = run_parse({"-m", "image.tar"});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 1);
}
TEST_CASE("parse_args: -h returns exit code 0 immediately", "[unit]") {
auto result = run_parse({"-h"});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 0);
}
TEST_CASE("parse_args: -V returns exit code 0 immediately", "[unit]") {
auto result = run_parse({"-V"});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 0);
}
TEST_CASE("parse_args: no mode at all is a parse error", "[unit]") {
auto result = run_parse({});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 1);
}
TEST_CASE("parse_args: two actions on the same command line is a parse error", "[unit]") {
auto result = run_parse({"--mount", "a.tar", "-l", "dir"});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 1);
}
TEST_CASE("parse_args: --group without --user is a parse error", "[unit]") {
auto result = run_parse({"-r", "a.tar", "--group", "mygroup", "--", "true"});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 1);
}
TEST_CASE("parse_args: -r captures the trailing command after --", "[unit]") {
auto result = run_parse({"-r", "a.tar", "--", "sh", "-c", "echo hi"});
REQUIRE_FALSE(result.exit_code.has_value());
CHECK(result.args.mode == Mode::run);
REQUIRE(result.args.command.size() == 3);
CHECK(result.args.command[0] == "sh");
CHECK(result.args.command[1] == "-c");
CHECK(result.args.command[2] == "echo hi");
}
TEST_CASE("parse_args: -x <pid> -- <command> selects Mode::exec with a parsed pid", "[unit]") {
auto result = run_parse({"-x", "1234", "--", "ls", "-la"});
REQUIRE_FALSE(result.exit_code.has_value());
CHECK(result.args.mode == Mode::exec);
REQUIRE(result.args.exec_pid.has_value());
CHECK(*result.args.exec_pid == 1234);
REQUIRE(result.args.command.size() == 2);
CHECK(result.args.command[0] == "ls");
}
TEST_CASE("parse_args: -x with a non-numeric pid is a parse error", "[unit]") {
auto result = run_parse({"-x", "notapid", "--", "ls"});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 1);
}
TEST_CASE("parse_args: -x with no trailing command is a parse error", "[unit]") {
auto result = run_parse({"-x", "1234"});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 1);
}
TEST_CASE("parse_args: leftover positional args are rejected for a mode that doesn't take one", "[unit]") {
auto result = run_parse({"-l", "dir", "extra"});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 1);
}
TEST_CASE("parse_args: bare -t has an empty test_args and no leftover-args error", "[unit]") {
auto result = run_parse({"-t"});
REQUIRE_FALSE(result.exit_code.has_value());
CHECK(result.args.mode == Mode::test);
CHECK(result.args.test_args.empty());
}
TEST_CASE("parse_args: -t -- <catch options> captures everything after -- verbatim", "[unit]") {
auto result = run_parse({"-t", "--", "[unit]", "--reporter=compact"});
REQUIRE_FALSE(result.exit_code.has_value());
CHECK(result.args.mode == Mode::test);
REQUIRE(result.args.test_args.size() == 2);
CHECK(result.args.test_args[0] == "[unit]");
CHECK(result.args.test_args[1] == "--reporter=compact");
}
TEST_CASE("parse_args: -t <tag-expression> works without a '--' since it doesn't look like an option", "[unit]") {
auto result = run_parse({"-t", "[unit]"});
REQUIRE_FALSE(result.exit_code.has_value());
CHECK(result.args.mode == Mode::test);
REQUIRE(result.args.test_args.size() == 1);
CHECK(result.args.test_args[0] == "[unit]");
}
TEST_CASE("parse_args: repeated -n accumulates network_specs in order", "[unit]") {
auto result = run_parse({"-r", "a.tar", "-n", "net1", "-n", "net2", "--", "true"});
REQUIRE_FALSE(result.exit_code.has_value());
REQUIRE(result.args.network_specs.size() == 2);
CHECK(result.args.network_specs[0] == "net1");
CHECK(result.args.network_specs[1] == "net2");
}
TEST_CASE("parse_args: -v with -r accumulates volume_specs as (spec, container-path) pairs", "[unit]") {
auto result = run_parse({"-r", "a.tar", "-v", "myvol", "/data", "--", "true"});
REQUIRE_FALSE(result.exit_code.has_value());
REQUIRE(result.args.volume_specs.size() == 1);
CHECK(result.args.volume_specs[0].first == "myvol");
CHECK(result.args.volume_specs[0].second == "/data");
}
TEST_CASE("parse_args: --with-ipv6/--with-veth accept true/false and default to unset", "[unit]") {
auto plain = run_parse({"-n", "net1", "--extern"});
REQUIRE_FALSE(plain.exit_code.has_value());
CHECK_FALSE(plain.args.network_with_ipv6_flag.has_value());
CHECK_FALSE(plain.args.network_with_veth_flag.has_value());
auto disabled = run_parse({"-n", "net1", "--extern", "--with-ipv6=false", "--with-veth=false"});
REQUIRE_FALSE(disabled.exit_code.has_value());
REQUIRE(disabled.args.network_with_ipv6_flag.has_value());
CHECK(*disabled.args.network_with_ipv6_flag == false);
REQUIRE(disabled.args.network_with_veth_flag.has_value());
CHECK(*disabled.args.network_with_veth_flag == false);
// Same accepted forms as the config file itself (parse_bool_flag(),
// config_file.h) -- not just a literal "true"/"false".
auto on = run_parse({"-n", "net1", "--intern", "--with-ipv6=on", "--with-veth=1"});
REQUIRE_FALSE(on.exit_code.has_value());
REQUIRE(on.args.network_with_ipv6_flag.has_value());
CHECK(*on.args.network_with_ipv6_flag == true);
REQUIRE(on.args.network_with_veth_flag.has_value());
CHECK(*on.args.network_with_veth_flag == true);
}
TEST_CASE("parse_args: --with-ipv6 with an unrecognized value is a parse error", "[unit]") {
auto result = run_parse({"-n", "net1", "--extern", "--with-ipv6=maybe"});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 1);
}
TEST_CASE("parse_args: --subnet6 and --with-ipv6=false can't be used together", "[unit]") {
auto result = run_parse({"-n", "net1", "--extern", "--subnet6", "fdf0::/64", "--with-ipv6=false"});
REQUIRE(result.exit_code.has_value());
CHECK(*result.exit_code == 1);
}
TEST_CASE("parse_args: --kill requires a numeric pid", "[unit]") {
auto ok = run_parse({"--kill", "5678"});
REQUIRE_FALSE(ok.exit_code.has_value());
CHECK(ok.args.mode == Mode::kill);
REQUIRE(ok.args.kill_pid.has_value());
CHECK(*ok.args.kill_pid == 5678);
auto bad = run_parse({"--kill", "notapid"});
REQUIRE(bad.exit_code.has_value());
CHECK(*bad.exit_code == 1);
}
TEST_CASE("parse_args: -c/--config-file populates config_file_flag, unset by default", "[unit]") {
auto without = run_parse({"--list-volumes"});
REQUIRE_FALSE(without.exit_code.has_value());
CHECK_FALSE(without.args.config_file_flag.has_value());
auto with_short = run_parse({"-c", "/tmp/alt.yaml", "--list-volumes"});
REQUIRE_FALSE(with_short.exit_code.has_value());
REQUIRE(with_short.args.config_file_flag.has_value());
CHECK(*with_short.args.config_file_flag == "/tmp/alt.yaml");
auto with_long = run_parse({"--config-file", "/tmp/alt2.yaml", "-w"});
REQUIRE_FALSE(with_long.exit_code.has_value());
REQUIRE(with_long.args.config_file_flag.has_value());
CHECK(*with_long.args.config_file_flag == "/tmp/alt2.yaml");
}
TEST_CASE("parse_args: --log-level sets log_level_flag_given", "[unit]") {
auto without = run_parse({"--list-volumes"});
REQUIRE_FALSE(without.exit_code.has_value());
CHECK_FALSE(without.args.log_level_flag_given);
auto with_level = run_parse({"--log-level", "debug", "--list-volumes"});
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 takes a single optional compose-file-name, no images directory", "[unit]") {
auto default_name = run_parse({"--down"});
REQUIRE_FALSE(default_name.exit_code.has_value());
CHECK(default_name.args.mode == Mode::compose_down);
CHECK_FALSE(default_name.args.compose_images_directory.has_value());
CHECK(default_name.args.compose_file_name == "compose.yaml");
auto explicit_name = run_parse({"-d", "my-stack.yaml"});
REQUIRE_FALSE(explicit_name.exit_code.has_value());
CHECK(explicit_name.args.mode == Mode::compose_down);
CHECK_FALSE(explicit_name.args.compose_images_directory.has_value());
CHECK(explicit_name.args.compose_file_name == "my-stack.yaml");
}
TEST_CASE("parse_args: -d doesn't swallow a following flag as compose_file_name", "[unit]") {
auto result = run_parse({"-d", "--log-level", "debug"});
REQUIRE_FALSE(result.exit_code.has_value());
CHECK(result.args.mode == Mode::compose_down);
CHECK(result.args.compose_file_name == "compose.yaml");
CHECK(result.args.log_level_flag_given);
}
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);
}
TEST_CASE("parse_args: --list-containers selects Mode::list_containers", "[unit]") {
auto result = run_parse({"--list-containers"});
REQUIRE_FALSE(result.exit_code.has_value());
CHECK(result.args.mode == Mode::list_containers);
}