diff --git a/CLAUDE.md b/CLAUDE.md index 1043abf..631daad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2673,17 +2673,46 @@ Source layout (all under `src/`): up for compose services yet (`ComposeService::ports` is parsed but unused here) — every service runs as whatever user its own image declares, same as `-r/--run`'s own default. - 5. `compose_state_file_path()`/`record_compose_services()` — write one - line per started service (`" "`) - to `xdg_state_dir()/"compose"/sanitize_for_filename( "`), to + `xdg_state_dir()/"compose"/sanitize_for_filename()`, the same state-file-naming pattern `port_forward.h`'s/`network_tap_relay.h`'s own crash-orphan records - already use, so a future `-d/--down` implementation can find exactly - which sessions a given `-u/--up` started. Overwrites any previous - run's own record for the same file — a known, expected limitation - until `-d/--down` itself exists to keep the two in sync (there's no - way yet to tell which of an older run's services are still actually - running versus already stopped by hand). + already use — read back by both a future `-d/--down` implementation + and `--list-containers` (`list_compose_containers()`, below). + Overwrites any previous run's own record for the same file — a known, + expected limitation until `-d/--down` itself exists to keep the two + in sync (there's no way yet to tell which of an older run's services + are still actually running versus already stopped by hand). + + **`--list-containers`** (`list_compose_containers()`, `list_containers_command()` + in `commands.cpp`) — scans every compose state file under + `xdg_state_dir()/"compose"/` and reports one row per recorded service: + compose file path, service name, container name, pid, and status. + `running` is a real, live check — each recorded pid is cross-referenced + against `list_sessions()` (`pid_file.h`, the same advisory-`flock()` + liveness test every other list/clean command in this project already + uses), not merely "this line exists in the state file" (which only ever + reflects the *most recent* `-u/--up`, per `record_compose_services()`'s + own doc comment) — a compose-started session's own pid file is created + exactly the same way any other session's is (`run_bwrap()`'s own + `on_start` callback, reached identically via `run_mounted_container()`), + so it's already present in `list_sessions()`'s own result with no + special-casing needed. `commands.cpp`'s own `pad_column()` (new, + `.cpp`-local) factors out the tab-alignment scheme every other list + command in this file already duplicates inline per-column, since this + one needed it across four independent columns rather than two or three. + **Verified manually**: `--list-containers` against a real running + 2-service compose stack correctly showed both as `running` with their + real compose path/service/container/pid; killing one and re-running + correctly flipped just that one row to `exited` while the other stayed + `running` — confirming the status reflects real liveness, not just + presence in the state file. **`commands.cpp` exports needed for all of the above** (each a pure refactor out of its own former anonymous-namespace scope, no behavior @@ -2774,7 +2803,7 @@ Build directory is `buildDir/` (already configured). `--user`, `--group`, `--hostname`, `--env`, `--env-file`, `-v/--volume`, `--list-volumes`, `--delete-volume`, `--delete-volume-full`, `-n/--network`, `--extern`, `--intern`, `--subnet`, `--with-ipv6`, `--subnet6`, `--with-veth`, `--list-networks`, `--delete-network`, `-p/--port-forward`, `--no-dns`, `--list-processes`, `--clean-processes`, - `-u/--up`, `-d/--down`, + `-u/--up`, `-d/--down`, `--list-containers`, `-c/--config-file`, `-w/--write-config`, `-t/--test [-- ]`, `--log-level`, `-h/--help`, `-V/--version`) - Run tests: `meson test -C buildDir` (the `[unit]` + safe `[integration]` categories diff --git a/src/cli_args.cpp b/src/cli_args.cpp index 7ad30cb..f6130de 100644 --- a/src/cli_args.cpp +++ b/src/cli_args.cpp @@ -80,9 +80,10 @@ constexpr int no_dns = 278; constexpr int mount = 279; constexpr int umount = 280; constexpr int cleanup = 281; +constexpr int list_containers = 282; } // 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'}, @@ -124,6 +125,7 @@ constexpr std::array long_options = {{ {"no-dns", no_argument, nullptr, options::no_dns}, {"up", required_argument, nullptr, 'u'}, {"down", required_argument, nullptr, 'd'}, + {"list-containers", no_argument, nullptr, options::list_containers}, {nullptr, 0, nullptr, 0}, }}; @@ -150,6 +152,7 @@ void print_usage(const char* prog) { " {0} --delete-network-full \n" " {0} --list-processes\n" " {0} --clean-processes\n" + " {0} --list-containers\n" " {0} -u|--up []\n" " {0} -d|--down []\n" " {0} [-c|--config-file ] -w|--write-config\n" @@ -312,6 +315,10 @@ void print_usage(const char* prog) { " same file/directory resolution as -u/--up --\n" " orchestration itself (actually stopping the\n" " services) isn't implemented yet\n" + " --list-containers list containers started by -u/--up, across every\n" + " compose file that's been run at least once --\n" + " compose file, service name, container name, pid,\n" + " and status (running or exited)\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" @@ -433,6 +440,7 @@ std::optional parse_args(int argc, char* argv[], ParsedArgs& out) { case options::list_networks: case options::delete_network: case options::delete_network_full: + case options::list_containers: case options::kill: { Mode requested; switch (opt) { @@ -487,6 +495,9 @@ std::optional parse_args(int argc, char* argv[], ParsedArgs& out) { case options::delete_network_full: requested = Mode::delete_network_full; break; + case options::list_containers: + requested = Mode::list_containers; + break; default: requested = Mode::clean_processes; break; diff --git a/src/cli_args.h b/src/cli_args.h index 5f16a2a..05d4ae1 100644 --- a/src/cli_args.h +++ b/src/cli_args.h @@ -49,7 +49,8 @@ enum class Mode { delete_network, delete_network_full, compose_up, - compose_down + compose_down, + list_containers }; // Everything parse_args() extracts from argv, ready to hand to diff --git a/src/commands.cpp b/src/commands.cpp index 7d236b8..7a0ee51 100644 --- a/src/commands.cpp +++ b/src/commands.cpp @@ -77,6 +77,18 @@ std::optional read_file_if_exists(const std::filesystem::path& path // stop past the longest entry in that column, however long that is. constexpr size_t tab_width = 8; +// The same tab-alignment scheme as above, factored into a helper here +// specifically because list_containers_command() (below) needs it across +// four independent columns -- repeating the inline three-line pattern that +// many times read worse than the other list commands' own two-column +// duplication of it already does. +std::string pad_column(const std::string& value, size_t column_max_len) { + size_t target_tabs = column_max_len / tab_width + 1; + size_t tabs_used = value.size() / tab_width; + size_t tabs_needed = target_tabs > tabs_used ? target_tabs - tabs_used : 1; + return value + std::string(tabs_needed, '\t'); +} + bool check_required_dependencies() { bool all_found = true; for (auto name : required_tools) { @@ -344,6 +356,42 @@ int list_processes_command() { return 0; } +// --list-containers: list_compose_containers() (compose_orchestrator.h) +// already does all the real work (scanning every compose state file, +// cross-referencing each recorded pid against list_sessions() for a real +// liveness check) -- this just formats the result, four independently +// tab-aligned columns (pad_column() above), same scheme as every other +// list command in this file. +int list_containers_command() { + auto containers = list_compose_containers(); + + std::vector compose_paths; + std::vector services; + std::vector names; + std::vector pids; + size_t max_compose_len = 0; + size_t max_service_len = 0; + size_t max_name_len = 0; + size_t max_pid_len = 0; + for (const auto& container : containers) { + compose_paths.push_back(container.compose_path.string()); + services.push_back(container.service_name); + names.push_back(container.container_name); + pids.push_back(fmt::format("{}", container.pid)); + max_compose_len = std::max(max_compose_len, compose_paths.back().size()); + max_service_len = std::max(max_service_len, services.back().size()); + max_name_len = std::max(max_name_len, names.back().size()); + max_pid_len = std::max(max_pid_len, pids.back().size()); + } + + for (size_t i = 0; i < containers.size(); ++i) { + fmt::print("{}{}{}{}{}\n", pad_column(compose_paths[i], max_compose_len), + pad_column(services[i], max_service_len), pad_column(names[i], max_name_len), + pad_column(pids[i], max_pid_len), containers[i].running ? "running" : "exited"); + } + return 0; +} + int clean_processes_command() { for (const auto& session : clean_stale_sessions()) { fmt::print("removed stale pid file for '{}' (pid {})\n", session.container_name, session.pid); @@ -1008,6 +1056,8 @@ int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config return list_processes_command(); case Mode::clean_processes: return clean_processes_command(); + case Mode::list_containers: + return list_containers_command(); case Mode::test: return run_self_tests(args.test_args, config); case Mode::exec: diff --git a/src/compose_orchestrator.cpp b/src/compose_orchestrator.cpp index c8165bd..5d693a8 100644 --- a/src/compose_orchestrator.cpp +++ b/src/compose_orchestrator.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -300,8 +301,61 @@ bool record_compose_services(const std::filesystem::path& compose_path, spdlog::warn("failed to open {} for writing", path.string()); return false; } + // The compose file's own real path is written first, as a header line + // -- the state file's own *name* only encodes a sanitized (lossy) + // version of it (sanitize_for_filename(), used by + // compose_state_file_path() above), so --list-containers + // (list_compose_containers()) needs the real path recorded somewhere + // it can read it back exactly. + out << std::filesystem::absolute(compose_path).lexically_normal().string() << '\n'; for (const auto& service : started) { out << service.service_name << ' ' << service.container_name << ' ' << service.pid << '\n'; } return true; } + +std::vector list_compose_containers() { + std::vector result; + + auto compose_dir = xdg_state_dir() / "compose"; + std::error_code ec; + if (!std::filesystem::is_directory(compose_dir, ec)) { + return result; + } + + auto sessions = list_sessions(); + auto is_running = [&](pid_t pid) { + return std::any_of(sessions.begin(), sessions.end(), + [&](const SessionInfo& session) { return session.pid == pid && session.running; }); + }; + + for (const auto& entry : std::filesystem::directory_iterator(compose_dir, ec)) { + if (ec || !entry.is_regular_file()) { + continue; + } + + std::ifstream in(entry.path()); + if (!in) { + continue; + } + + std::string compose_path; + if (!std::getline(in, compose_path) || compose_path.empty()) { + continue; + } + + std::string line; + while (std::getline(in, line)) { + std::istringstream fields(line); + std::string service_name; + std::string container_name; + pid_t pid = -1; + if (!(fields >> service_name >> container_name >> pid)) { + continue; + } + result.push_back({compose_path, service_name, container_name, pid, is_running(pid)}); + } + } + + return result; +} diff --git a/src/compose_orchestrator.h b/src/compose_orchestrator.h index 4f0db0c..cdb6c62 100644 --- a/src/compose_orchestrator.h +++ b/src/compose_orchestrator.h @@ -172,20 +172,54 @@ std::vector start_compose_services(const ComposeFile& com // per-something state file under xdg_state_dir(). std::filesystem::path compose_state_file_path(const std::filesystem::path& compose_path); -// Step 5: records every entry in `started` (one line each: " -// ") to compose_state_file_path(compose_path), -// creating its parent directory if needed -- so a future -d/--down can -// read it back to find exactly which sessions a given -u/--up started, -// the same "state file names what a related --clean-processes/-d sweep -// needs" pattern port_forward.h's/network_tap_relay.h's own crash-orphan -// records already use. Overwrites whatever was there before, deliberately -// not merged with an older run's own leftover state -- there's no way yet -// (before -d/--down itself exists) to know which of a previous run's -// services are still actually running versus already stopped by hand, so -// this always reflects only the most recent -u/--up; a known, expected -// limitation until -d/--down exists to keep the two in sync. A no-op -// (true, nothing to do) if `started` is empty. Best-effort, like every -// other state-file write in this project: logs a warning and returns -// false on failure, never fatal to -u/--up itself. +// Step 5: records every entry in `started` to compose_state_file_path(compose_path), +// creating its parent directory if needed -- so a future -d/--down (and +// --list-containers, see list_compose_containers() below) can read it back +// to find exactly which sessions a given -u/--up started, the same "state +// file names what a related sweep/listing needs" pattern port_forward.h's/ +// network_tap_relay.h's own crash-orphan records already use. The file's +// first line is the compose file's own real, absolute path -- needed +// because compose_state_file_path()'s own *filename* only encodes a +// sanitized (lossy) version of it, so this is the only place the exact +// path is recoverable from; every line after that is one started service, +// " ". Overwrites whatever was there +// before, deliberately not merged with an older run's own leftover state +// -- there's no way yet (before -d/--down itself exists) to know which of +// a previous run's services are still actually running versus already +// stopped by hand, so this always reflects only the most recent -u/--up; a +// known, expected limitation until -d/--down exists to keep the two in +// sync. A no-op (true, nothing to do) if `started` is empty. Best-effort, +// like every other state-file write in this project: logs a warning and +// returns false on failure, never fatal to -u/--up itself. bool record_compose_services(const std::filesystem::path& compose_path, const std::vector& started); + +// One entry read back from a compose state file for --list-containers. +struct ComposeContainerInfo { + std::filesystem::path compose_path; + std::string service_name; + std::string container_name; + pid_t pid; + bool running; +}; + +// Implements --list-containers: scans every compose state file under +// xdg_state_dir()/"compose"/ (one per compose file that's had -u/--up run +// against it at least once) and reports one ComposeContainerInfo per +// recorded service. `running` is determined by cross-referencing +// `container_name`'s own pid against list_sessions() (pid_file.h) -- the +// same real, live liveness check (an advisory flock() on the session's own +// pid file) every other list/clean command in this project already uses, +// not merely "this line exists in the compose state file" (which reflects +// only the most recent -u/--up, per record_compose_services()'s own doc +// comment above, and says nothing about whether that session is still +// actually running). A compose-started session's own pid file is created +// exactly the same way any other session's is (run_bwrap()'s own on_start +// callback, reached identically via run_mounted_container()), so it's +// already present in list_sessions()'s own result with no special-casing +// needed here. A state file that can't be read, has no path header line, +// or a line that doesn't parse, is skipped rather than treated as an +// error -- the same forward-compatible/best-effort convention +// list_sessions() itself already uses for a malformed pid file. An empty +// or missing compose/ directory yields an empty result, not an error. +std::vector list_compose_containers(); diff --git a/tests/unit/test_cli_args.cpp b/tests/unit/test_cli_args.cpp index 31fe900..99f1ab6 100644 --- a/tests/unit/test_cli_args.cpp +++ b/tests/unit/test_cli_args.cpp @@ -294,3 +294,9 @@ TEST_CASE("parse_args: -u and -d together on the same command line is a parse er 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); +}