diff --git a/CMakeLists.txt b/CMakeLists.txt index 2b8b1ec..bf82d92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,11 +20,79 @@ FetchContent_Declare( FetchContent_MakeAvailable(ftxui) -add_executable(tux-dock main.cpp) +include(FetchContent) + +FetchContent_Declare( + json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +FetchContent_MakeAvailable(json) + +add_executable(tux-dock + main.cpp + src/process_runner.cpp + src/docker_engine_client.cpp + src/http_response_parser.cpp + src/container_parser.cpp + src/operation_state.cpp + src/stop_waiter.cpp + src/docker_manager.cpp +) target_link_libraries(tux-dock PRIVATE ftxui::screen ftxui::dom ftxui::component + nlohmann_json::nlohmann_json ) + +target_compile_options(tux-dock PRIVATE -Wall -Wextra -Wpedantic) + +include(CTest) +if(BUILD_TESTING) + add_executable(tux-dock-http-tests + tests/test_http_response_parser.cpp + src/http_response_parser.cpp + ) + target_include_directories(tux-dock-http-tests PRIVATE src) + target_compile_options(tux-dock-http-tests PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME tux-dock-http-tests COMMAND tux-dock-http-tests) + + add_executable(tux-dock-container-tests + tests/test_container_parser.cpp + src/container_parser.cpp + ) + target_include_directories(tux-dock-container-tests PRIVATE src) + target_link_libraries(tux-dock-container-tests PRIVATE nlohmann_json::nlohmann_json) + target_compile_options(tux-dock-container-tests PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME tux-dock-container-tests COMMAND tux-dock-container-tests) + + add_executable(tux-dock-operation-tests + tests/test_operation_state.cpp + src/operation_state.cpp + ) + target_include_directories(tux-dock-operation-tests PRIVATE src) + target_compile_options(tux-dock-operation-tests PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME tux-dock-operation-tests COMMAND tux-dock-operation-tests) + + add_executable(tux-dock-stop-tests + tests/test_stop_waiter.cpp + src/stop_waiter.cpp + ) + target_include_directories(tux-dock-stop-tests PRIVATE src) + target_compile_options(tux-dock-stop-tests PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME tux-dock-stop-tests COMMAND tux-dock-stop-tests) + + add_executable(tux-dock-stop-sequence-tests + tests/test_stop_sequence.cpp + src/stop_waiter.cpp + ) + target_include_directories(tux-dock-stop-sequence-tests PRIVATE src) + target_compile_options(tux-dock-stop-sequence-tests PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME tux-dock-stop-sequence-tests COMMAND tux-dock-stop-sequence-tests) + + add_test(NAME tux-dock-docker-integration-tests + COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_docker_integration.sh) +endif() diff --git a/DEVLOG.md b/DEVLOG.md new file mode 100644 index 0000000..edb17c6 --- /dev/null +++ b/DEVLOG.md @@ -0,0 +1,42 @@ +# Tux-Dock Development Log + +## 0.1-beta + +This release establishes the first beta-quality Docker integration and TUI workflow. + +### Docker integration + +- Added direct Docker Engine API access through `/var/run/docker.sock`. +- Added startup Docker preflight using `GET /_ping` before launching the TUI. +- Added synchronous initial container and image loading. +- Added asynchronous cache refreshes after mutations and interactive sessions. +- Preserved cached state when a refresh fails. +- Added structured container/image JSON mapping with exited-container support. + +### Process execution + +- Replaced `system()` and `popen()` with a `fork`/`exec` process runner. +- Added captured stdout/stderr and inherited terminal I/O modes. +- Kept direct CLI execution for interactive terminal handoffs. + +### Reliability + +- Added HTTP response parsing for Content-Length, chunked transfer, and bodyless responses. +- Added robust handling for Docker `204` and idempotent `304` responses. +- Added tri-state stop polling: stopped, running, and unknown. +- Added bounded stop retries after transport timeouts. +- Stop completion now refreshes cached state before showing the result modal. + +### TUI + +- Replaced the persistent status panel with a centered actions panel. +- Added busy-operation modals with spinner feedback. +- Blocked input during long-running Docker operations. +- Added structured, scrollable container and image list dialogs. + +### Tests + +- Added HTTP response parser tests. +- Added container JSON mapping tests. +- Added operation-state tests. +- Added stop polling and timeout-sequence regression tests. diff --git a/README.md b/README.md index 53f1158..051c28e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ### A lightweight C++ Docker TUI Tux-Dock is a modern **C++17** Docker terminal frontend built with **FTXUI**. -It gives you a guided, keyboard-first TUI for common Docker operations like pulling images, running containers, inspecting IPs, and managing images/containers without memorizing long CLI flags. +It gives you a guided, keyboard-first TUI for common Docker operations without memorizing long CLI flags. --- @@ -10,12 +10,16 @@ It gives you a guided, keyboard-first TUI for common Docker operations like pull - Interactive Docker workflows through a single-screen TUI with modal steps. - Picker-based selection (arrow keys + Enter) for containers/images instead of numeric menus. -- High-level status panel with responsive wait states for slower operations. +- Busy-operation modals with a spinner and input blocking while Docker work completes. - Rich container display with state and forwarded ports. - Interactive shell handoff with clean terminal clear before/after shell transitions. - Image operations: pull/list/delete with curated quick picks and custom image support. - Script-to-image workflow: generate Dockerfile from bash script, then optionally build. - MySQL quick start flow with version/password/port prompts. +- Docker Engine API access through `/var/run/docker.sock` for structured list and lifecycle operations. +- Direct `fork`/`exec` process execution for CLI-backed streaming and interactive commands. +- Persistent container listings that retain exited containers. +- Robust stop handling with state polling, timeout retry, and idempotent stop responses. - About screen in-app with project/version/repository info. --- @@ -35,16 +39,23 @@ It gives you a guided, keyboard-first TUI for common Docker operations like pull git clone https://mentalnet.xyz/forgejo/markmental/tuxdock.git cd tuxdock -# Configure & build (FTXUI is fetched automatically) -cmake -S . -B build -cmake --build build -j +# Configure, build, and test (FTXUI and nlohmann/json are fetched automatically) +./compile.sh # Run it (requires Docker permissions) sudo ./build/tux-dock + +# Build without running tests +./compile.sh --no-test + +# Run tests and serve an HTML 3.2 report on port 8095 +./compile.sh --web-test-view + +# Override the report server port +./compile.sh --web-test-view 9000 ``` -Prefer a prebuilt binary? CI artifacts are published at: -https://mentalnet.xyz/forgejo/markmental/tuxdock/actions +The web report is generated under `/tmp` and includes exact CTest output, test summaries, Docker integration output, and a text rendition of the TUI flow. It requires `nc` or `netcat`; press `Ctrl-C` to stop the server. The default port is `8095`; pass a port after `--web-test-view` to override it. `--web-test-view --no-test` is invalid. Normal test runs include a Docker integration test using `debian:forky`, so Docker must be available. --- @@ -63,17 +74,14 @@ Current TUI actions: 9. Remove Container 10. Attach Shell to Running Container 11. Run Detached Command in Container -12. Spin Up MySQL Container -13. Get Container IP Address -14. Create Dockerfile & Build Image from Bash Script -15. About Tux-Dock -16. Exit +12. About Tux-Dock +13. Exit --- ## Design Overview -Tux-Dock is now structured around **two main classes**: +Tux-Dock is organized around the TUI, Docker manager, Engine API client, and direct process runner: ```cpp class DockerManager { @@ -86,9 +94,9 @@ public: bool running; }; - // Docker command/data layer - std::vector getContainerList() const; - std::vector> getImageList() const; + bool checkConnection(...); + ListResult getContainerList() const; + ListResult getImageList() const; bool pullImage(...); bool runContainerInteractive(...); bool startInteractive(...); @@ -98,9 +106,6 @@ public: bool deleteImage(...); bool execShell(...); bool execDetachedCommand(...); - bool spinUpMySQL(...); - bool showContainerIP(...); - bool createDockerfile(...); }; class TuxDockApp { @@ -108,11 +113,11 @@ public: void Run(); private: - // TUI orchestration layer + // TUI orchestration layer with modal busy states void OpenInput(...); void OpenSelect(...); void OpenConfirm(...); - void RunDeferredStatusAction(...); + void BeginBusyOperation(...); void RunWithRestoredIO(...); void ExecuteSelectedAction(); // action handlers bridge UI -> DockerManager @@ -121,27 +126,47 @@ private: ### Responsibilities +- `DockerEngineClient` + - Talks directly to Docker over the Unix socket. + - Parses HTTP responses, including chunked and bodyless responses. + +- `ProcessRunner` + - Executes direct argument vectors using `fork` and `exec`. + - Supports captured output and inherited terminal I/O. + - `DockerManager` - - Executes Docker commands. - - Escapes shell arguments and returns success/failure + user-facing messages. - - Collects container/image data used by the UI. + - Maps Engine API JSON into application data. + - Performs lifecycle operations and robust stop-state confirmation. + - Preserves cached state when refreshes fail. - `TuxDockApp` - Renders the FTXUI interface. - Manages modal flows (input/select/confirm/message). - - Handles status updates, deferred actions, and interactive shell transitions. + - Handles modal flows, busy operations, blocked input, and interactive shell transitions. - Coordinates end-to-end user flows by calling `DockerManager` methods. This split keeps Docker behavior isolated while making UI behavior easier to extend. --- +## Testing + +```bash +cmake -S . -B build -DBUILD_TESTING=ON +cmake --build build -j +ctest --test-dir build --output-on-failure +``` + +`compile.sh` runs these tests by default. Pass `--no-test` to skip them. + +--- + ## About / Version -- Version: `022526-dev` +- Version: `0.1-beta` - Created by: `markmental` - GitHub: https://github.com/MARKMENTAL/tuxdock -- Forgejo: https://mentalnet.xyz/forgejo/markmental/tuxdock +- Forgejo: https://mentalnet.xyz/forgejo-v2/markmental/tuxdock --- diff --git a/compile.sh b/compile.sh index f5a6b58..29ab3a7 100755 --- a/compile.sh +++ b/compile.sh @@ -1,3 +1,149 @@ #!/bin/sh -cmake -S . -B build && cmake --build build -j && echo "tux-dock successfully compiled!" +set -eu + +run_tests=1 +web_view=0 +web_port=8095 + +while [ "$#" -gt 0 ]; do + argument=$1 + shift + case "$argument" in + --no-test) run_tests=0 ;; + --web-test-view) + web_view=1 + if [ "$#" -gt 0 ] && printf '%s' "$1" | grep -Eq '^[0-9]+$'; then + web_port=$1 + shift + fi + ;; + *) + printf '%s\n' "Usage: $0 [--no-test] [--web-test-view [port]]" >&2 + exit 2 + ;; + esac +done + +if [ "$web_port" -lt 1 ] || [ "$web_port" -gt 65535 ]; then + printf '%s\n' "Web test view port must be between 1 and 65535" >&2 + exit 2 +fi + +if [ "$web_view" -eq 1 ] && [ "$run_tests" -eq 0 ]; then + printf '%s\n' "--web-test-view cannot be combined with --no-test" >&2 + exit 2 +fi + +if [ "$run_tests" -eq 1 ]; then + cmake -S . -B build -DBUILD_TESTING=ON +else + cmake -S . -B build -DBUILD_TESTING=OFF +fi + +cmake --build build -j + +if [ "$run_tests" -eq 1 ]; then + report_dir=$(mktemp -d /tmp/tux-dock-test.XXXXXX) + test_output="$report_dir/ctest-output.txt" + test_status=0 + ctest --test-dir build --output-on-failure >"$test_output" 2>&1 || test_status=$? + + if [ "$web_view" -eq 1 ]; then + report="$report_dir/report.html" + escaped_output=$(mktemp "$report_dir/output.XXXXXX") + sed 's/&/\&/g; s//\>/g' "$test_output" >"$escaped_output" + total=$(awk '/tests passed, [0-9]+ tests failed out of [0-9]+/ { print $(NF); exit }' "$test_output" 2>/dev/null || true) + failed=$(awk '/tests passed, [0-9]+ tests failed out of [0-9]+/ { print $4; exit }' "$test_output" 2>/dev/null || true) + total=${total:-unknown} + passed=${passed:-0} + failed=${failed:-0} + if [ "$total" != unknown ]; then + passed=$((total - failed)) + fi + duration=$(awk '/Total Test time/ { print $(NF - 1); exit }' "$test_output" 2>/dev/null || true) + duration=${duration:-unknown} + command_line='ctest --test-dir build --output-on-failure' + test_rows=$(mktemp "$report_dir/test-rows.XXXXXX") + awk ' + match($0, /^[[:space:]]*([0-9]+)\/([0-9]+) Test #[0-9]+: ([^ ]+)[[:space:]]+\.+[[:space:]]+(Passed|Failed|Skipped)[[:space:]]+([0-9.]+) sec/, m) { + printf "%s%s%s%s sec\n", m[1], m[3], m[4], m[5] + } + ' "$test_output" >"$test_rows" + integration_output=$(mktemp "$report_dir/integration-output.XXXXXX") + test_log="build/Testing/Temporary/LastTest.log" + if [ -f "$test_log" ]; then + awk ' + /^[0-9]+\/[0-9]+ Testing: tux-dock-docker-integration-tests$/ { capture=1 } + capture { print } + /^Test Passed\.$/ && capture { end=1 } + end && /time elapsed:/ { print; exit } + ' "$test_log" >"$integration_output" + else + printf '%s\n' 'Docker integration transcript unavailable: LastTest.log was not found.' >"$integration_output" + fi + escaped_integration=$(mktemp "$report_dir/integration-escaped.XXXXXX") + sed 's/&/\&/g; s//\>/g' "$integration_output" >"$escaped_integration" + raw_output=$(mktemp "$report_dir/raw-output.XXXXXX") + awk '/\[TEST OUTPUT BEGIN\]/{capture=1} capture{print} /\[TEST RESULT\]/{capture=0}' "$integration_output" >"$raw_output" + escaped_raw=$(mktemp "$report_dir/raw-escaped.XXXXXX") + sed 's/&/\&/g; s//\>/g' "$raw_output" >"$escaped_raw" + tui_output=$(mktemp "$report_dir/tui-output.XXXXXX") + awk '!/\[TEST OUTPUT BEGIN\]/ && !/\[TEST OUTPUT END\]/ && !/\[TEST RESULT\]/ { print }' "$integration_output" >"$tui_output" + escaped_tui=$(mktemp "$report_dir/tui-escaped.XXXXXX") + sed 's/&/\&/g; s//\>/g' "$tui_output" >"$escaped_tui" + { + printf '%s\n' '' + printf '%s\n' 'Tux-Dock Test Report' + printf '%s\n' '

Tux-Dock Test Report

' + printf '

Generated: %s

\n' "$(date)" + printf '%s\n' '

Summary

' + printf '\n' "$total" + printf '\n' "$passed" + printf '\n' "$failed" "$([ "$failed" -gt 0 ] 2>/dev/null && printf 160 || printf 1)" + printf '
MetricValueGraph
Total tests%s
 
Passed%s
 
Failed%s
 
Duration%s seconds 
\n' "$duration" + printf '%s\n' '

Individual Tests

' + cat "$test_rows" + printf '%s\n' '
#TestStatusDuration
' + printf '%s\n' '

Exact Test Run

'
+            printf '%s\n' "$command_line"
+            printf '%s\n' '

Test Output

'
+            cat "$escaped_output"
+            printf '%s\n' '

Docker Integration Raw Output

'
+            cat "$escaped_raw"
+            printf '%s\n' '

TUI Transcript

'
+            cat "$escaped_tui"
+            printf '%s\n' '
' + } >"$report" + + response="$report_dir/http-response.txt" + report_size=$(wc -c <"$report" | tr -d ' ') + { + printf 'HTTP/1.1 200 OK\r\n' + printf 'Content-Type: text/html; charset=utf-8\r\n' + printf 'Content-Length: %s\r\n' "$report_size" + printf 'Connection: close\r\n' + printf '\r\n' + cat "$report" + } >"$response" + + if command -v nc >/dev/null 2>&1; then + nc_command=nc + elif command -v netcat >/dev/null 2>&1; then + nc_command=netcat + else + printf '%s\n' "Web report generated at $report, but nc/netcat is unavailable." >&2 + exit "$test_status" + fi + + printf '%s\n' "Test report: http://0.0.0.0:$web_port/" + printf '%s\n' "Report file: $report" + printf '%s\n' "Press Ctrl-C to stop the server." + while :; do + "$nc_command" -l -N -s 0.0.0.0 -p "$web_port" <"$response" + done + fi + exit "$test_status" +fi + +printf '%s\n' "tux-dock successfully compiled!" diff --git a/main.cpp b/main.cpp index 6e9bc8e..8910290 100644 --- a/main.cpp +++ b/main.cpp @@ -1,7 +1,9 @@ -#include +#include "src/docker_manager.hpp" +#include "src/operation_state.hpp" + #include -#include -#include +#include +#include #include #include #include @@ -17,1296 +19,327 @@ #include #include -class DockerManager { -public: - struct ContainerInfo { - std::string id; - std::string name; - std::string status; - std::string ports; - bool running = false; - }; - - std::vector getContainerList() const; - std::vector> getImageList() const; - - bool pullImage(const std::string& image, std::string& message) const; - bool runContainerInteractive(const std::string& image, - const std::vector& ports, - std::string& message) const; - bool startInteractive(const std::string& containerId, std::string& message) const; - bool startDetached(const std::string& containerId, std::string& message) const; - bool deleteImage(const std::string& imageId, std::string& message) const; - bool stopContainer(const std::string& containerId, std::string& message) const; - bool removeContainer(const std::string& containerId, std::string& message) const; - bool execShell(const std::string& containerId, std::string& message) const; - bool execDetachedCommand(const std::string& containerId, - const std::string& command, - std::string& message) const; - bool spinUpMySQL(const std::string& port, - const std::string& password, - const std::string& version, - std::string& message) const; - bool showContainerIP(const std::string& containerId, std::string& message) const; - bool createDockerfile(const std::string& baseImage, - const std::string& bashScriptPath, - std::string outputFile, - const std::string& imageName, - std::string& message) const; - -private: - static bool runCommand(const std::string& cmd, bool quiet = true); - static std::string shellEscape(const std::string& value); -}; - -bool DockerManager::runCommand(const std::string& cmd, bool quiet) { - std::string command = cmd; - if (quiet) { - command += " > /dev/null 2>&1"; - } - const int code = std::system(command.c_str()); - return code == 0; -} - -std::string DockerManager::shellEscape(const std::string& value) { - std::string escaped = "'"; - for (char c : value) { - if (c == '\'') { - escaped += "'\\''"; - } else { - escaped += c; - } - } - escaped += "'"; - return escaped; -} - -std::vector> DockerManager::getImageList() const { - std::vector> images; - std::array buffer{}; - std::string result; - FILE* pipe = popen("docker images --format '{{.ID}} {{.Repository}}:{{.Tag}}'", "r"); - if (!pipe) { - return images; - } - while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { - result = buffer.data(); - std::stringstream ss(result); - std::string id; - std::string repoTag; - ss >> id >> repoTag; - if (!id.empty() && !repoTag.empty()) { - images.emplace_back(id, repoTag); - } - } - pclose(pipe); - return images; -} - -std::vector DockerManager::getContainerList() const { - std::vector containers; - std::array buffer{}; - std::string result; - - FILE* pipe = popen("docker ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}'", "r"); - if (!pipe) { - return containers; - } - - while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { - result = buffer.data(); - while (!result.empty() && (result.back() == '\n' || result.back() == '\r')) { - result.pop_back(); - } - - std::stringstream ss(result); - ContainerInfo info; - std::getline(ss, info.id, '\t'); - std::getline(ss, info.name, '\t'); - std::getline(ss, info.status, '\t'); - std::getline(ss, info.ports); - - if (!info.id.empty() && !info.name.empty()) { - info.running = info.status.rfind("Up", 0) == 0; - containers.push_back(info); - } - } - pclose(pipe); - return containers; -} - -bool DockerManager::pullImage(const std::string& image, std::string& message) const { - if (image.empty()) { - message = "Please provide an image name."; - return false; - } - const bool ok = runCommand("docker pull " + shellEscape(image)); - message = ok ? "Image pulled successfully." : "Could not pull that image."; - return ok; -} - -bool DockerManager::runContainerInteractive(const std::string& image, - const std::vector& ports, - std::string& message) const { - if (image.empty()) { - message = "Please choose an image first."; - return false; - } - - std::string cmd = "docker run -it "; - for (const auto& port : ports) { - cmd += "-p " + shellEscape(port) + " "; - } - cmd += shellEscape(image) + " /bin/sh"; - - const bool ok = runCommand(cmd, false); - message = ok ? "Interactive container session finished." : "Could not start that container session."; - return ok; -} - -bool DockerManager::startInteractive(const std::string& containerId, std::string& message) const { - const bool ok = runCommand("docker start -ai " + shellEscape(containerId), false); - message = ok ? "Interactive container session finished." : "Could not start that container interactively."; - return ok; -} - -bool DockerManager::startDetached(const std::string& containerId, std::string& message) const { - const bool ok = runCommand("docker start " + shellEscape(containerId)); - message = ok ? "Container started in detached mode." : "Could not start that container."; - return ok; -} - -bool DockerManager::deleteImage(const std::string& imageId, std::string& message) const { - const bool ok = runCommand("docker rmi " + shellEscape(imageId)); - message = ok ? "Image deleted." : "Could not delete that image. It may still be in use."; - return ok; -} - -bool DockerManager::stopContainer(const std::string& containerId, std::string& message) const { - const bool ok = runCommand("docker stop " + shellEscape(containerId)); - message = ok ? "Container stopped." : "Could not stop that container."; - return ok; -} - -bool DockerManager::removeContainer(const std::string& containerId, std::string& message) const { - const bool ok = runCommand("docker rm " + shellEscape(containerId)); - message = ok ? "Container removed." : "Could not remove that container."; - return ok; -} - -bool DockerManager::execShell(const std::string& containerId, std::string& message) const { - const bool ok = runCommand("docker exec -it " + shellEscape(containerId) + " /bin/sh", false); - message = ok ? "Shell session finished." : "Could not open a shell in that container."; - return ok; -} - -bool DockerManager::execDetachedCommand(const std::string& containerId, - const std::string& command, - std::string& message) const { - if (command.empty()) { - message = "Please provide a command to run."; - return false; - } - - const std::string cmd = "docker exec -d " + shellEscape(containerId) + " /bin/sh -c " + - shellEscape(command); - const bool ok = runCommand(cmd); - message = ok ? "Command dispatched in detached mode." : "Could not run that command."; - return ok; -} - -bool DockerManager::spinUpMySQL(const std::string& port, - const std::string& password, - const std::string& version, - std::string& message) const { - const std::string cmd = "docker run -p " + shellEscape(port) + - " --name mysql-container -e MYSQL_ROOT_PASSWORD=" + - shellEscape(password) + " -d " + shellEscape("mysql:" + version); - const bool ok = runCommand(cmd); - message = ok ? "MySQL container launched." : "Could not launch MySQL. Check the version and port mapping."; - return ok; -} - -bool DockerManager::showContainerIP(const std::string& containerId, std::string& message) const { - const std::string command = "docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' " + - shellEscape(containerId); - - std::array buffer{}; - std::string ip; - FILE* pipe = popen(command.c_str(), "r"); - if (!pipe) { - message = "Could not inspect that container."; - return false; - } - - if (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { - ip = buffer.data(); - } - pclose(pipe); - - while (!ip.empty() && (ip.back() == '\n' || ip.back() == '\r' || ip.back() == ' ' || ip.back() == '\t')) { - ip.pop_back(); - } - - if (ip.empty()) { - message = "No IP address found. The container may be stopped."; - return false; - } - - message = "Container IP address: " + ip; - return true; -} - -bool DockerManager::createDockerfile(const std::string& baseImage, - const std::string& bashScriptPath, - std::string outputFile, - const std::string& imageName, - std::string& message) const { - if (baseImage.empty() || bashScriptPath.empty()) { - message = "Base image and script path are required."; - return false; - } - - if (!std::filesystem::exists(bashScriptPath)) { - message = "Could not find that script file."; - return false; - } - - if (outputFile.empty()) { - outputFile = "Dockerfile"; - } - - std::ifstream scriptFile(bashScriptPath); - std::ofstream dockerfile(outputFile); - - if (!scriptFile.is_open() || !dockerfile.is_open()) { - message = "Could not open one of the files."; - return false; - } - - dockerfile << "FROM " << baseImage << "\n"; - dockerfile << "WORKDIR /app\n\n"; - dockerfile << "# Auto-generated by Tux-Dock\n"; - - std::string line; - while (std::getline(scriptFile, line)) { - if (line.empty()) { - continue; - } - - if (line.rfind("#", 0) == 0 || line.rfind("#!", 0) == 0) { - continue; - } - - dockerfile << "RUN " << line << "\n"; - } - - dockerfile << "\nCMD [\"/bin/bash\"]\n"; - dockerfile.close(); - scriptFile.close(); - - if (imageName.empty()) { - message = "Dockerfile created. Build skipped because no image name was provided."; - return true; - } - - const bool ok = runCommand("docker build -t " + shellEscape(imageName) + " -f " + - shellEscape(outputFile) + " ."); - message = ok ? "Dockerfile created and image build completed." : "Dockerfile created, but the image build failed."; - return ok; -} - class TuxDockApp { public: - void Run(); + int Run(); private: - enum class ModalMode { None, Input, Confirm, Select, Message }; - - struct RunContainerContext { - std::string image; - int port_count = 0; - std::vector ports; - }; - - struct MySqlContext { - std::string port; - std::string password; - std::string version; - }; - - struct DockerfileContext { - std::string base_image; - std::string script_path; - std::string output_file; - std::string image_name; - }; + enum class ModalMode { None, Input, Confirm, Select, Message, Busy }; + struct RunContainerContext { std::string image; int port_count = 0; std::vector ports; }; + struct MySqlContext { std::string port; std::string password; std::string version; }; DockerManager docker_; - - std::vector menu_entries_ = { - "Pull Docker Image", - "Run/Create Interactive Container", - "List All Containers", - "List All Images", - "Start Container Interactively (boot new session)", - "Start Detached Container Session", - "Delete Docker Image", - "Stop Container", - "Remove Container", - "Attach Shell to Running Container", - "Run Detached Command in Container", - "Spin Up MySQL Container", - "Get Container IP Address", - "Create Dockerfile & Build Image from Bash Script", - "About Tux-Dock", - "Exit", - }; - + std::vector containers_; + std::vector images_; + std::vector menu_entries_ = {"Pull Docker Image", "Run/Create Interactive Container", "List All Containers", "List All Images", "Start Container Interactively (boot new session)", "Start Detached Container Session", "Delete Docker Image", "Stop Container", "Remove Container", "Attach Shell to Running Container", "Run Detached Command in Container", "About Tux-Dock", "Exit"}; int menu_selected_ = 0; std::string status_ = "Ready. Select an action and press Enter."; - ModalMode modal_mode_ = ModalMode::None; - std::string modal_title_; - std::string modal_text_; - std::string modal_input_; + std::string modal_title_, modal_text_, modal_input_; + ftxui::Element modal_content_; std::vector modal_select_entries_; int modal_select_index_ = 0; - ftxui::Component menu_component_ = ftxui::Menu(&menu_entries_, &menu_selected_); ftxui::Component input_component_ = ftxui::Input(&modal_input_, "Type here"); ftxui::Component select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); - std::function input_callback_; std::function confirm_callback_; std::function select_callback_; - ftxui::ScreenInteractive* screen_ = nullptr; + std::thread refresh_thread_; + std::thread spinner_thread_; + std::atomic spinner_stop_{true}; + std::vector action_threads_; + OperationState operation_state_; + std::size_t spinner_frame_ = 0; static bool IsDigits(const std::string& value); static std::string ShortId(const std::string& id); static bool IsValidPortMapping(const std::string& mapping); - - std::string FormatContainerList(const std::vector& containers) const; - std::string FormatImageList(const std::vector>& images) const; - - void SetStatus(const std::string& message); - void OpenInput(const std::string& title, - const std::string& text, - std::function callback, - bool secret = false); - void OpenConfirm(const std::string& title, const std::string& text, std::function callback); - void OpenSelect(const std::string& title, - const std::string& text, - std::vector options, - std::function callback); + ftxui::Element FormatContainerList(const std::vector& containers) const; + ftxui::Element FormatImageList(const std::vector& images) const; + void SetStatus(const std::string& message) { status_ = message; } + void OpenInput(const std::string&, const std::string&, std::function, bool secret = false); + void OpenConfirm(const std::string&, const std::string&, std::function); + void OpenSelect(const std::string&, const std::string&, std::vector, std::function); void OpenMessage(const std::string& title, const std::string& text); - void ResolveInput(bool confirmed); - void ResolveConfirm(bool confirmed); - void ResolveSelect(bool confirmed); - void CloseMessage(); - + void OpenListMessage(const std::string& title, ftxui::Element content); + void ResolveInput(bool); void ResolveConfirm(bool); void ResolveSelect(bool); void CloseMessage(); void ExecuteSelectedAction(); - void ActionPullImage(); - void ActionRunContainer(); - void ActionListContainers(); - void ActionListImages(); - void ActionStartInteractive(); - void ActionStartDetached(); - void ActionDeleteImage(); - void ActionStopContainer(); - void ActionRemoveContainer(); - void ActionExecShell(); - void ActionExecDetachedCommand(); - void ActionSpinUpMySQL(); - void ActionShowContainerIP(); - void ActionCreateDockerfile(); - void ActionAbout(); - void PromptPortCountAndRun(const std::shared_ptr& context); - void PromptNextPort(const std::shared_ptr& context, int index); - - void PromptContainerSelection(const std::string& title, - std::function callback); - void PromptImageSelection(const std::string& title, - std::function callback); - void RunDeferredStatusAction(const std::string& wait_message, - std::function action); + void ActionPullImage(); void ActionRunContainer(); void ActionListContainers(); void ActionListImages(); + void ActionStartInteractive(); void ActionStartDetached(); void ActionDeleteImage(); void ActionStopContainer(); + void ActionRemoveContainer(); void ActionExecShell(); void ActionExecDetachedCommand(); void ActionAbout(); + void PromptPortCountAndRun(const std::shared_ptr&); void PromptNextPort(const std::shared_ptr&, int); + void PromptContainerSelection(const std::string&, std::function); + void PromptImageSelection(const std::string&, std::function); + void RunDeferredStatusAction(const std::string&, std::function); + void BeginBusyOperation(const std::string&, const std::string&, std::function); + void StartSpinner(); + void StopSpinner(); + void BeginStopOperation(const std::string& id); + void RefreshState(const std::string& message = "Refreshing Docker state..."); + void ApplyRefreshResults(DockerManager::ListResult containers, + DockerManager::ListResult images); static void ClearTerminal(); - void RunWithRestoredIO(const std::function& action, - bool clear_before = false, - bool clear_after = false); - - bool OnEvent(ftxui::Event event); - ftxui::Element Render() const; - ftxui::Element RenderModal() const; + void RunWithRestoredIO(const std::function&, bool clear_before = false, bool clear_after = false); + bool OnEvent(ftxui::Event); ftxui::Element Render() const; ftxui::Element RenderModal() const; }; -bool TuxDockApp::IsDigits(const std::string& value) { - if (value.empty()) { - return false; +bool TuxDockApp::IsDigits(const std::string& value) { if (value.empty()) return false; for (unsigned char c : value) if (!std::isdigit(c)) return false; return true; } +std::string TuxDockApp::ShortId(const std::string& id) { return id.size() <= 12 ? id : id.substr(0, 12); } +bool TuxDockApp::IsValidPortMapping(const std::string& mapping) { const auto p = mapping.find(':'); return p != std::string::npos && IsDigits(mapping.substr(0, p)) && IsDigits(mapping.substr(p + 1)); } +ftxui::Element TuxDockApp::FormatContainerList(const std::vector& items) const { + using namespace ftxui; + Elements rows; + for (std::size_t i = 0; i < items.size(); ++i) { + const auto& container = items[i]; + rows.push_back(vbox(Elements{ + text("[" + std::to_string(i + 1) + "] " + container.name) | bold, + text((container.running ? "RUNNING" : "STOPPED") + std::string(" id ") + ShortId(container.id)) | dim, + text("ports: " + (container.ports.empty() ? std::string("none") : container.ports)) | dim, + })); + if (i + 1 < items.size()) rows.push_back(separator()); } - for (unsigned char c : value) { - if (!std::isdigit(c)) { - return false; + return vbox(std::move(rows)); +} + +ftxui::Element TuxDockApp::FormatImageList(const std::vector& items) const { + using namespace ftxui; + Elements rows; + for (std::size_t i = 0; i < items.size(); ++i) { + const auto& image = items[i]; + rows.push_back(vbox(Elements{ + text("[" + std::to_string(i + 1) + "] " + image.second) | bold, + text("id " + ShortId(image.first)) | dim, + })); + if (i + 1 < items.size()) rows.push_back(separator()); + } + return vbox(std::move(rows)); +} + +void TuxDockApp::RefreshState(const std::string& message) { + SetStatus(message); + if (refresh_thread_.joinable()) refresh_thread_.join(); + auto* active = screen_; + refresh_thread_ = std::thread([this, active] { + auto containers = docker_.getContainerList(); + auto images = docker_.getImageList(); + if (active == nullptr || ftxui::ScreenInteractive::Active() != active) return; + active->Post([this, containers = std::move(containers), images = std::move(images)]() mutable { + ApplyRefreshResults(std::move(containers), std::move(images)); + }); + active->PostEvent(ftxui::Event::Custom); + }); +} + +void TuxDockApp::ApplyRefreshResults( + DockerManager::ListResult containers, + DockerManager::ListResult images) { + std::string error; + if (containers.ok()) { + containers_ = std::move(containers.items); + } else { + error = "Containers: " + containers.error; + } + if (images.ok()) { + images_ = std::move(images.items); + } else { + if (!error.empty()) error += "\n"; + error += "Images: " + images.error; + } + SetStatus(error.empty() ? "Docker state refreshed." : "Refresh failed; cached state preserved.\n" + error); +} + +void TuxDockApp::OpenInput(const std::string& title, const std::string& text, std::function callback, bool secret) { modal_mode_ = ModalMode::Input; modal_title_ = title; modal_text_ = text; modal_input_.clear(); ftxui::InputOption option; option.password = secret; input_component_ = ftxui::Input(&modal_input_, "Type here", option); input_callback_ = std::move(callback); } +void TuxDockApp::OpenConfirm(const std::string& title, const std::string& text, std::function callback) { modal_mode_ = ModalMode::Confirm; modal_title_ = title; modal_text_ = text; confirm_callback_ = std::move(callback); } +void TuxDockApp::OpenSelect(const std::string& title, const std::string& text, std::vector options, std::function callback) { modal_mode_ = ModalMode::Select; modal_title_ = title; modal_text_ = text; modal_select_entries_ = std::move(options); modal_select_index_ = 0; select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); select_callback_ = std::move(callback); } +void TuxDockApp::OpenMessage(const std::string& title, const std::string& text) { modal_mode_ = ModalMode::Message; modal_title_ = title; modal_text_ = text; modal_content_ = {}; } +void TuxDockApp::OpenListMessage(const std::string& title, ftxui::Element content) { modal_mode_ = ModalMode::Message; modal_title_ = title; modal_text_.clear(); modal_content_ = std::move(content); } +void TuxDockApp::ResolveInput(bool confirmed) { auto callback = std::move(input_callback_); const auto value = modal_input_; modal_mode_ = ModalMode::None; input_callback_ = {}; if (callback) callback(confirmed, value); } +void TuxDockApp::ResolveConfirm(bool confirmed) { auto callback = std::move(confirm_callback_); modal_mode_ = ModalMode::None; confirm_callback_ = {}; if (callback) callback(confirmed); } +void TuxDockApp::ResolveSelect(bool confirmed) { auto callback = std::move(select_callback_); const int selected = modal_select_index_; modal_mode_ = ModalMode::None; select_callback_ = {}; if (callback) callback(confirmed, selected); } +void TuxDockApp::CloseMessage() { modal_mode_ = ModalMode::None; modal_content_ = {}; } + +void TuxDockApp::PromptContainerSelection(const std::string& title, std::function callback) { if (containers_.empty()) { RefreshState("No cached containers. Refreshing..."); return; } std::vector options; for (const auto& c : containers_) options.push_back(c.name + " (" + ShortId(c.id) + ") [" + (c.running ? "running" : "stopped") + "]"); OpenSelect(title, "Select a container with arrows and press Enter.", std::move(options), [this, callback = std::move(callback)](bool ok, int selected) { if (!ok) return SetStatus("Action cancelled."); if (selected < 0 || selected >= static_cast(containers_.size())) return SetStatus("Please choose a valid container."); const auto& c = containers_[static_cast(selected)]; callback(c.id, c.name); }); } +void TuxDockApp::PromptImageSelection(const std::string& title, std::function callback) { if (images_.empty()) { RefreshState("No cached images. Refreshing..."); return; } std::vector options; for (const auto& image : images_) options.push_back(image.second + " (" + ShortId(image.first) + ")"); OpenSelect(title, "Select an image with arrows and press Enter.", std::move(options), [this, callback = std::move(callback)](bool ok, int selected) { if (!ok) return SetStatus("Action cancelled."); if (selected < 0 || selected >= static_cast(images_.size())) return SetStatus("Please choose a valid image."); const auto& image = images_[static_cast(selected)]; callback(image.first, image.second); }); } + +void TuxDockApp::RunDeferredStatusAction(const std::string& wait, std::function action) { + BeginBusyOperation("Working", wait, std::move(action)); +} + +void TuxDockApp::StartSpinner() { + StopSpinner(); + spinner_stop_ = false; + auto* active = screen_; + spinner_thread_ = std::thread([this, active] { + while (active && !spinner_stop_.load() && ftxui::ScreenInteractive::Active() == active) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + if (spinner_stop_.load() || ftxui::ScreenInteractive::Active() != active) break; + active->RequestAnimationFrame(); + active->Post([this] { if (!spinner_stop_.load()) ++spinner_frame_; }); } - } - return true; + }); } -std::string TuxDockApp::ShortId(const std::string& id) { - if (id.size() <= 12) { - return id; - } - return id.substr(0, 12); +void TuxDockApp::StopSpinner() { + spinner_stop_ = true; + if (spinner_thread_.joinable()) spinner_thread_.join(); } -bool TuxDockApp::IsValidPortMapping(const std::string& mapping) { - const std::size_t separator = mapping.find(':'); - if (separator == std::string::npos) { - return false; - } - const std::string host = mapping.substr(0, separator); - const std::string container = mapping.substr(separator + 1); - return IsDigits(host) && IsDigits(container); -} - -std::string TuxDockApp::FormatContainerList( - const std::vector& containers) const { - std::stringstream ss; - for (std::size_t i = 0; i < containers.size(); ++i) { - const auto& container = containers[i]; - const std::string state = container.running ? "running" : "stopped"; - const std::string ports = container.ports.empty() ? "none" : container.ports; - ss << i + 1 << ". " << container.name << " (" << ShortId(container.id) << ")" - << " [" << state << "]" - << " ports: " << ports; - if (i + 1 < containers.size()) { - ss << "\n"; +void TuxDockApp::BeginBusyOperation(const std::string& title, + const std::string& message, + std::function action) { + operation_state_.begin(title, message); + modal_mode_ = ModalMode::Busy; + spinner_frame_ = 0; + auto* active = screen_; + StartSpinner(); + if (active) active->PostEvent(ftxui::Event::Custom); + action_threads_.emplace_back([this, active, action = std::move(action)]() mutable { + const auto message = action(); + if (active && ftxui::ScreenInteractive::Active() == active) { + active->Post([this, message] { + operation_state_.complete(message); + StopSpinner(); + modal_mode_ = ModalMode::None; + OpenMessage("Operation complete", message); + RefreshState(); + }); + active->PostEvent(ftxui::Event::Custom); } - } - return ss.str(); + }); } - -std::string TuxDockApp::FormatImageList(const std::vector>& images) const { - std::stringstream ss; - for (std::size_t i = 0; i < images.size(); ++i) { - ss << i + 1 << ". " << images[i].second << " (" << ShortId(images[i].first) << ")"; - if (i + 1 < images.size()) { - ss << "\n"; - } - } - return ss.str(); -} - -void TuxDockApp::SetStatus(const std::string& message) { - status_ = message; -} - -void TuxDockApp::OpenInput(const std::string& title, - const std::string& text, - std::function callback, - bool secret) { - modal_mode_ = ModalMode::Input; - modal_title_ = title; - modal_text_ = text; - modal_input_.clear(); - ftxui::InputOption input_option; - input_option.password = secret; - input_component_ = ftxui::Input(&modal_input_, "Type here", input_option); - input_callback_ = std::move(callback); -} - -void TuxDockApp::OpenConfirm(const std::string& title, - const std::string& text, - std::function callback) { - modal_mode_ = ModalMode::Confirm; - modal_title_ = title; - modal_text_ = text; - confirm_callback_ = std::move(callback); -} - -void TuxDockApp::OpenSelect(const std::string& title, - const std::string& text, - std::vector options, - std::function callback) { - modal_mode_ = ModalMode::Select; - modal_title_ = title; - modal_text_ = text; - modal_select_entries_ = std::move(options); - modal_select_index_ = 0; - select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); - select_callback_ = std::move(callback); -} - -void TuxDockApp::OpenMessage(const std::string& title, const std::string& text) { - modal_mode_ = ModalMode::Message; - modal_title_ = title; - modal_text_ = text; -} - -void TuxDockApp::ResolveInput(bool confirmed) { - const std::string value = modal_input_; - auto callback = std::move(input_callback_); - - modal_mode_ = ModalMode::None; - modal_title_.clear(); - modal_text_.clear(); - modal_input_.clear(); - input_component_ = ftxui::Input(&modal_input_, "Type here"); - input_callback_ = {}; - - if (callback) { - callback(confirmed, value); - } -} - -void TuxDockApp::ResolveConfirm(bool confirmed) { - auto callback = std::move(confirm_callback_); - - modal_mode_ = ModalMode::None; - modal_title_.clear(); - modal_text_.clear(); - confirm_callback_ = {}; - - if (callback) { - callback(confirmed); - } -} - -void TuxDockApp::ResolveSelect(bool confirmed) { - const int selected = modal_select_index_; - auto callback = std::move(select_callback_); - - modal_mode_ = ModalMode::None; - modal_title_.clear(); - modal_text_.clear(); - modal_select_entries_.clear(); - modal_select_index_ = 0; - select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); - select_callback_ = {}; - - if (callback) { - callback(confirmed, selected); - } -} - -void TuxDockApp::CloseMessage() { - modal_mode_ = ModalMode::None; - modal_title_.clear(); - modal_text_.clear(); -} - -void TuxDockApp::PromptContainerSelection( - const std::string& title, - std::function callback) { - auto containers = docker_.getContainerList(); - if (containers.empty()) { - SetStatus("No containers available."); - return; - } - - std::vector options; - options.reserve(containers.size()); - for (const auto& container : containers) { - const std::string state = container.running ? "running" : "stopped"; - const std::string ports = container.ports.empty() ? "none" : container.ports; - options.push_back(container.name + " (" + ShortId(container.id) + ") [" + state + "] ports: " + ports); - } - - OpenSelect(title, - "Select a container with arrows and press Enter.", - std::move(options), - [this, containers, callback = std::move(callback)](bool confirmed, int selected) mutable { - if (!confirmed) { - SetStatus("Action cancelled."); - return; - } - if (selected < 0 || selected >= static_cast(containers.size())) { - SetStatus("Please choose a valid container."); - return; - } - const auto& chosen = containers[static_cast(selected)]; - callback(chosen.id, chosen.name); - }); -} - -void TuxDockApp::PromptImageSelection(const std::string& title, - std::function callback) { - auto images = docker_.getImageList(); - if (images.empty()) { - SetStatus("No images found."); - return; - } - - std::vector options; - options.reserve(images.size()); - for (const auto& image : images) { - options.push_back(image.second + " (" + ShortId(image.first) + ")"); - } - - OpenSelect(title, - "Select an image with arrows and press Enter.", - std::move(options), - [this, images, callback = std::move(callback)](bool confirmed, int selected) mutable { - if (!confirmed) { - SetStatus("Action cancelled."); - return; - } - if (selected < 0 || selected >= static_cast(images.size())) { - SetStatus("Please choose a valid image."); - return; - } - const auto& chosen = images[static_cast(selected)]; - callback(chosen.first, chosen.second); - }); -} - -void TuxDockApp::ClearTerminal() { - std::cout << "\x1b[2J\x1b[H" << std::flush; -} - -void TuxDockApp::RunWithRestoredIO(const std::function& action, - bool clear_before, - bool clear_after) { - if (screen_ != nullptr) { - screen_->WithRestoredIO([&] { - if (clear_before) { - ClearTerminal(); - } - action(); - if (clear_after) { - ClearTerminal(); - } - })(); - return; - } - if (clear_before) { - ClearTerminal(); - } - action(); - if (clear_after) { - ClearTerminal(); - } -} - -void TuxDockApp::RunDeferredStatusAction(const std::string& wait_message, - std::function action) { - SetStatus(wait_message); - - if (screen_ == nullptr) { - SetStatus(action()); - return; - } - - ftxui::ScreenInteractive* active_screen = screen_; - active_screen->PostEvent(ftxui::Event::Custom); - - std::thread([this, active_screen, action = std::move(action)]() mutable { - const std::string final_message = action(); - if (ftxui::ScreenInteractive::Active() != active_screen) { - return; - } - active_screen->Post([this, final_message] { SetStatus(final_message); }); - active_screen->PostEvent(ftxui::Event::Custom); - }).detach(); -} - -void TuxDockApp::ActionPullImage() { - const std::vector quick_images = { - "debian:stable", - "ubuntu:noble", - "rockylinux:9.3", - "alpine:latest", - }; - - std::vector options = quick_images; - options.push_back("Custom image..."); - - OpenSelect("Pull Docker Image", - "Select an image with arrows, then press Enter.", - std::move(options), - [this, quick_images](bool confirmed, int selected) { - if (!confirmed) { - SetStatus("Image pull cancelled."); - return; - } - - if (selected < 0 || selected > static_cast(quick_images.size())) { - SetStatus("Please select a valid image option."); - return; - } - - if (selected < static_cast(quick_images.size())) { - const std::string image = quick_images[static_cast(selected)]; - RunDeferredStatusAction("Please wait, pulling image...", [this, image] { - std::string message; - docker_.pullImage(image, message); - return message; - }); - return; - } - - OpenInput("Pull Docker Image", - "Enter custom Docker image name:", - [this](bool custom_confirmed, const std::string& image) { - if (!custom_confirmed) { - SetStatus("Image pull cancelled."); - return; - } - if (image.empty()) { - SetStatus("Image name cannot be empty."); - ActionPullImage(); - return; - } - RunDeferredStatusAction("Please wait, pulling image...", [this, image] { - std::string message; - docker_.pullImage(image, message); - return message; - }); - }); - }); -} - -void TuxDockApp::ActionRunContainer() { - auto images = docker_.getImageList(); - std::vector options; - options.reserve(images.size() + 1); - for (const auto& image : images) { - options.push_back(image.second + " (" + ShortId(image.first) + ")"); - } - options.push_back("Custom image..."); - - OpenSelect("Run Interactive Container", - "Select an image with arrows, then press Enter.", - std::move(options), - [this, images](bool confirmed, int selected) { - if (!confirmed) { - SetStatus("Container run cancelled."); - return; - } - - if (selected < 0 || selected > static_cast(images.size())) { - SetStatus("Please select a valid image option."); - return; - } - - auto context = std::make_shared(); - if (selected == static_cast(images.size())) { - OpenInput("Run Interactive Container", - "Enter custom image name:", - [this, context](bool custom_confirmed, const std::string& image_name) { - if (!custom_confirmed) { - SetStatus("Container run cancelled."); - return; - } - if (image_name.empty()) { - SetStatus("Image name cannot be empty."); - ActionRunContainer(); - return; - } - context->image = image_name; - PromptPortCountAndRun(context); - }); - return; - } - - context->image = images[static_cast(selected)].second; - PromptPortCountAndRun(context); - }); -} - -void TuxDockApp::PromptPortCountAndRun(const std::shared_ptr& context) { - OpenInput("Port Mappings", - "How many port mappings? (0 for none)", - [this, context](bool confirmed, const std::string& count_value) { - if (!confirmed) { - SetStatus("Container run cancelled."); - return; - } - if (!IsDigits(count_value)) { - SetStatus("Please enter a valid number."); - PromptPortCountAndRun(context); - return; - } - - context->port_count = std::stoi(count_value); - context->ports.clear(); - if (context->port_count < 0) { - SetStatus("Port mappings cannot be negative."); - PromptPortCountAndRun(context); - return; - } - - PromptNextPort(context, 0); - }); -} - -void TuxDockApp::PromptNextPort(const std::shared_ptr& context, int index) { - if (index >= context->port_count) { +void TuxDockApp::BeginStopOperation(const std::string& id) { + operation_state_.begin("Stopping container", "Stopping and refreshing state..."); + modal_mode_ = ModalMode::Busy; + auto* active = screen_; + spinner_frame_ = 0; + StartSpinner(); + action_threads_.emplace_back([this, active, id] { std::string message; - SetStatus("Starting interactive container..."); - RunWithRestoredIO([this, context, &message] { - docker_.runContainerInteractive(context->image, context->ports, message); - }, true, true); - SetStatus(message); - return; - } - - OpenInput("Port Mapping", - "Enter mapping #" + std::to_string(index + 1) + " (example: 8080:80)", - [this, context, index](bool confirmed, const std::string& mapping) { - if (!confirmed) { - SetStatus("Container run cancelled."); - return; - } - if (!IsValidPortMapping(mapping)) { - SetStatus("Use host:container format, for example 8080:80."); - PromptNextPort(context, index); - return; - } - - context->ports.push_back(mapping); - PromptNextPort(context, index + 1); - }); + const bool stopped = docker_.stopContainer(id, message); + auto containers = docker_.getContainerList(); + auto images = docker_.getImageList(); + if (!active || ftxui::ScreenInteractive::Active() != active) return; + active->Post([this, stopped, message, containers = std::move(containers), images = std::move(images)]() mutable { + ApplyRefreshResults(std::move(containers), std::move(images)); + operation_state_.complete(message); + StopSpinner(); + modal_mode_ = ModalMode::None; + OpenMessage(stopped ? "Container stopped" : "Stop failed", message); + }); + active->PostEvent(ftxui::Event::Custom); + }); } +void TuxDockApp::ClearTerminal() { std::cout << "\x1b[2J\x1b[H" << std::flush; } +void TuxDockApp::RunWithRestoredIO(const std::function& action, bool before, bool after) { if (screen_) screen_->WithRestoredIO([&] { if (before) ClearTerminal(); action(); if (after) ClearTerminal(); })(); else action(); } void TuxDockApp::ActionListContainers() { - auto containers = docker_.getContainerList(); - if (containers.empty()) { - SetStatus("No containers available."); + if (containers_.empty()) { + OpenMessage("Containers", "No containers found."); return; } - OpenMessage("Containers", FormatContainerList(containers)); - SetStatus("Container list opened."); + OpenListMessage("Containers", FormatContainerList(containers_)); } void TuxDockApp::ActionListImages() { - auto images = docker_.getImageList(); - if (images.empty()) { - SetStatus("No images found."); + if (images_.empty()) { + OpenMessage("Images", "No images found."); return; } - OpenMessage("Images", FormatImageList(images)); - SetStatus("Image list opened."); + OpenListMessage("Images", FormatImageList(images_)); } +void TuxDockApp::ActionPullImage() { OpenInput("Pull Docker Image", "Enter image name:", [this](bool ok, const std::string& image) { if (!ok) return; BeginBusyOperation("Pulling image", "Please wait...", [this, image] { std::string m; docker_.pullImage(image, m); return m; }); }); } +void TuxDockApp::ActionRunContainer() { PromptImageSelection("Run Interactive Container", [this](const std::string&, const std::string& tag) { auto context = std::make_shared(); context->image = tag; PromptPortCountAndRun(context); }); } +void TuxDockApp::PromptPortCountAndRun(const std::shared_ptr& c) { OpenInput("Port Mappings", "How many port mappings? (0 for none)", [this, c](bool ok, const std::string& value) { if (!ok) return; if (!IsDigits(value)) return SetStatus("Please enter a valid number."); c->port_count = std::stoi(value); PromptNextPort(c, 0); }); } +void TuxDockApp::PromptNextPort(const std::shared_ptr& c, int index) { if (index >= c->port_count) { std::string m; RunWithRestoredIO([this, c, &m] { docker_.runContainerInteractive(c->image, c->ports, m); }, true, true); SetStatus(m); RefreshState(); return; } OpenInput("Port Mapping", "Enter mapping #" + std::to_string(index + 1), [this, c, index](bool ok, const std::string& value) { if (!ok) return; if (!IsValidPortMapping(value)) return SetStatus("Use host:container format."); c->ports.push_back(value); PromptNextPort(c, index + 1); }); } +void TuxDockApp::ActionStartInteractive() { PromptContainerSelection("Start Interactively", [this](const std::string& id, const std::string&) { std::string m; RunWithRestoredIO([this, &id, &m] { docker_.startInteractive(id, m); }, true, true); SetStatus(m); RefreshState(); }); } +void TuxDockApp::ActionStartDetached() { PromptContainerSelection("Start Detached", [this](const std::string& id, const std::string&) { BeginBusyOperation("Starting container", "Please wait...", [this, id] { std::string m; docker_.startDetached(id, m); return m; }); }); } +void TuxDockApp::ActionDeleteImage() { PromptImageSelection("Delete Image", [this](const std::string& id, const std::string& tag) { OpenConfirm("Delete Image", "Delete image " + tag + "?", [this, id](bool ok) { if (ok) BeginBusyOperation("Deleting image", "Please wait...", [this, id] { std::string m; docker_.deleteImage(id, m); return m; }); }); }); } +void TuxDockApp::ActionStopContainer() { PromptContainerSelection("Stop Container", [this](const std::string& id, const std::string&) { BeginStopOperation(id); }); } +void TuxDockApp::ActionRemoveContainer() { PromptContainerSelection("Remove Container", [this](const std::string& id, const std::string& name) { OpenConfirm("Remove Container", "Remove container " + name + "?", [this, id](bool ok) { if (ok) BeginBusyOperation("Removing container", "Please wait...", [this, id] { std::string m; docker_.removeContainer(id, m); return m; }); }); }); } +void TuxDockApp::ActionExecShell() { PromptContainerSelection("Open Shell", [this](const std::string& id, const std::string&) { std::string m; RunWithRestoredIO([this, &id, &m] { docker_.execShell(id, m); }, true, true); SetStatus(m); }); } +void TuxDockApp::ActionExecDetachedCommand() { PromptContainerSelection("Run Detached Command", [this](const std::string& id, const std::string& name) { OpenInput("Detached Command", "Enter command to run in " + name + ":", [this, id](bool ok, const std::string& command) { if (!ok) return; BeginBusyOperation("Running command", "Please wait...", [this, id, command] { std::string m; docker_.execDetachedCommand(id, command, m); return m; }); }); }); } +void TuxDockApp::ActionAbout() { OpenMessage("About Tux-Dock", "Tux-Dock 0.1-beta | Created by markmental"); } -void TuxDockApp::ActionStartInteractive() { - PromptContainerSelection("Start Interactively", [this](const std::string& id, const std::string&) { - std::string message; - SetStatus("Starting interactive session..."); - RunWithRestoredIO([this, &id, &message] { docker_.startInteractive(id, message); }, true, true); - SetStatus(message); - }); -} - -void TuxDockApp::ActionStartDetached() { - PromptContainerSelection("Start Detached", [this](const std::string& id, const std::string&) { - std::string message; - SetStatus("Starting container in detached mode..."); - RunWithRestoredIO([this, &id, &message] { docker_.startDetached(id, message); }); - SetStatus(message); - }); -} - -void TuxDockApp::ActionDeleteImage() { - PromptImageSelection("Delete Image", [this](const std::string& id, const std::string& tag) { - OpenConfirm("Delete Image", - "Delete image " + tag + "?", - [this, id](bool confirmed) { - if (!confirmed) { - SetStatus("Image deletion cancelled."); - return; - } - RunDeferredStatusAction("Please wait, deleting image...", [this, id] { - std::string message; - docker_.deleteImage(id, message); - return message; - }); - }); - }); -} - -void TuxDockApp::ActionStopContainer() { - PromptContainerSelection("Stop Container", [this](const std::string& id, const std::string&) { - RunDeferredStatusAction("Please wait, stopping container...", [this, id] { - std::string message; - docker_.stopContainer(id, message); - return message; - }); - }); -} - -void TuxDockApp::ActionRemoveContainer() { - PromptContainerSelection("Remove Container", [this](const std::string& id, const std::string& name) { - OpenConfirm("Remove Container", - "Remove container " + name + "?", - [this, id](bool confirmed) { - if (!confirmed) { - SetStatus("Container removal cancelled."); - return; - } - RunDeferredStatusAction("Please wait, removing container...", [this, id] { - std::string message; - docker_.removeContainer(id, message); - return message; - }); - }); - }); -} - -void TuxDockApp::ActionExecShell() { - PromptContainerSelection("Open Shell", [this](const std::string& id, const std::string&) { - std::string message; - SetStatus("Opening shell session..."); - RunWithRestoredIO([this, &id, &message] { docker_.execShell(id, message); }, true, true); - SetStatus(message); - }); -} - -void TuxDockApp::ActionExecDetachedCommand() { - PromptContainerSelection("Run Detached Command", - [this](const std::string& id, const std::string& name) { - OpenInput("Detached Command", - "Enter command to run in " + name + ":", - [this, id](bool confirmed, const std::string& command) { - if (!confirmed) { - SetStatus("Detached command cancelled."); - return; - } - std::string message; - SetStatus("Running detached command..."); - RunWithRestoredIO([this, &id, &command, &message] { - docker_.execDetachedCommand(id, command, message); - }); - SetStatus(message); - }); - }); -} - -void TuxDockApp::ActionSpinUpMySQL() { - auto context = std::make_shared(); - - OpenInput("MySQL Setup", - "Enter port mapping (example: 3306:3306)", - [this, context](bool confirmed, const std::string& port) { - if (!confirmed) { - SetStatus("MySQL setup cancelled."); - return; - } - if (!IsValidPortMapping(port)) { - SetStatus("Use host:container format, for example 3306:3306."); - return; - } - context->port = port; - - OpenInput("MySQL Setup", - "Enter MySQL root password:", - [this, context](bool pwd_confirmed, const std::string& password) { - if (!pwd_confirmed) { - SetStatus("MySQL setup cancelled."); - return; - } - if (password.empty()) { - SetStatus("Password cannot be empty."); - return; - } - context->password = password; - - OpenInput("MySQL Setup", - "Enter MySQL version tag (example: 8)", - [this, context](bool ver_confirmed, const std::string& version) { - if (!ver_confirmed) { - SetStatus("MySQL setup cancelled."); - return; - } - if (version.empty()) { - SetStatus("Version tag cannot be empty."); - return; - } - context->version = version; - - std::string message; - SetStatus("Launching MySQL container..."); - RunWithRestoredIO([this, context, &message] { - docker_.spinUpMySQL(context->port, - context->password, - context->version, - message); - }); - SetStatus(message); - }); - }, - true); - }); -} - -void TuxDockApp::ActionShowContainerIP() { - PromptContainerSelection("Container IP", [this](const std::string& id, const std::string&) { - std::string message; - SetStatus("Looking up container IP..."); - RunWithRestoredIO([this, &id, &message] { docker_.showContainerIP(id, message); }); - SetStatus(message); - }); -} - -void TuxDockApp::ActionCreateDockerfile() { - auto context = std::make_shared(); - - OpenInput("Dockerfile Builder", - "Enter base image (example: ubuntu:22.04)", - [this, context](bool base_confirmed, const std::string& base) { - if (!base_confirmed) { - SetStatus("Dockerfile workflow cancelled."); - return; - } - if (base.empty()) { - SetStatus("Base image cannot be empty."); - return; - } - context->base_image = base; - - OpenInput("Dockerfile Builder", - "Enter bash script path:", - [this, context](bool script_confirmed, const std::string& script_path) { - if (!script_confirmed) { - SetStatus("Dockerfile workflow cancelled."); - return; - } - if (script_path.empty()) { - SetStatus("Script path cannot be empty."); - return; - } - context->script_path = script_path; - - OpenInput("Dockerfile Builder", - "Enter output Dockerfile name (leave empty for Dockerfile)", - [this, context](bool output_confirmed, const std::string& output_file) { - if (!output_confirmed) { - SetStatus("Dockerfile workflow cancelled."); - return; - } - context->output_file = output_file; - - OpenInput("Dockerfile Builder", - "Enter image name to build (leave empty to skip build)", - [this, context](bool image_confirmed, - const std::string& image_name) { - if (!image_confirmed) { - SetStatus("Dockerfile workflow cancelled."); - return; - } - context->image_name = image_name; - - std::string message; - SetStatus("Creating Dockerfile and running build..."); - RunWithRestoredIO([this, context, &message] { - docker_.createDockerfile(context->base_image, - context->script_path, - context->output_file, - context->image_name, - message); - }); - SetStatus(message); - }); - }); - }); - }); -} - -void TuxDockApp::ActionAbout() { - SetStatus("Tux-Dock 022526-dev\n" - "Created by markmental\n\n" - "GitHub:\n" - "https://github.com/MARKMENTAL/tuxdock\n\n" - "Forgejo:\n" - "https://mentalnet.xyz/forgejo/markmental/tuxdock"); -} - -void TuxDockApp::ExecuteSelectedAction() { - switch (menu_selected_) { - case 0: - ActionPullImage(); - break; - case 1: - ActionRunContainer(); - break; - case 2: - ActionListContainers(); - break; - case 3: - ActionListImages(); - break; - case 4: - ActionStartInteractive(); - break; - case 5: - ActionStartDetached(); - break; - case 6: - ActionDeleteImage(); - break; - case 7: - ActionStopContainer(); - break; - case 8: - ActionRemoveContainer(); - break; - case 9: - ActionExecShell(); - break; - case 10: - ActionExecDetachedCommand(); - break; - case 11: - ActionSpinUpMySQL(); - break; - case 12: - ActionShowContainerIP(); - break; - case 13: - ActionCreateDockerfile(); - break; - case 14: - ActionAbout(); - break; - case 15: - SetStatus("Exiting Tux-Dock."); - if (screen_ != nullptr) { - screen_->ExitLoopClosure()(); - } - break; - default: - SetStatus("Please choose a valid menu option."); - break; - } -} +void TuxDockApp::ExecuteSelectedAction() { switch (menu_selected_) { case 0: ActionPullImage(); break; case 1: ActionRunContainer(); break; case 2: ActionListContainers(); break; case 3: ActionListImages(); break; case 4: ActionStartInteractive(); break; case 5: ActionStartDetached(); break; case 6: ActionDeleteImage(); break; case 7: ActionStopContainer(); break; case 8: ActionRemoveContainer(); break; case 9: ActionExecShell(); break; case 10: ActionExecDetachedCommand(); break; case 11: ActionAbout(); break; case 12: if (screen_) screen_->ExitLoopClosure()(); break; default: break; } } bool TuxDockApp::OnEvent(ftxui::Event event) { - if (modal_mode_ == ModalMode::Input) { - if (event == ftxui::Event::Return) { - ResolveInput(true); - return true; - } - if (event == ftxui::Event::Escape) { - ResolveInput(false); - return true; - } - return input_component_->OnEvent(event); - } - - if (modal_mode_ == ModalMode::Confirm) { - if (event == ftxui::Event::Character("y") || event == ftxui::Event::Character("Y") || - event == ftxui::Event::Return) { - ResolveConfirm(true); - return true; - } - if (event == ftxui::Event::Character("n") || event == ftxui::Event::Character("N") || - event == ftxui::Event::Escape) { - ResolveConfirm(false); - return true; - } + if (modal_mode_ == ModalMode::Busy) { + if (event == ftxui::Event::Custom) ++spinner_frame_; return true; } - - if (modal_mode_ == ModalMode::Select) { - if (event == ftxui::Event::Return) { - ResolveSelect(true); - return true; - } - if (event == ftxui::Event::Escape) { - ResolveSelect(false); - return true; - } - return select_component_->OnEvent(event); - } - - if (modal_mode_ == ModalMode::Message) { - if (event == ftxui::Event::Return || event == ftxui::Event::Escape) { - CloseMessage(); - return true; - } - return true; - } - - if (event == ftxui::Event::Return) { - ExecuteSelectedAction(); - return true; - } - + if (modal_mode_ == ModalMode::Input) { if (event == ftxui::Event::Return) { ResolveInput(true); return true; } if (event == ftxui::Event::Escape) { ResolveInput(false); return true; } return input_component_->OnEvent(event); } + if (modal_mode_ == ModalMode::Confirm) { if (event == ftxui::Event::Return || event == ftxui::Event::Character("y")) { ResolveConfirm(true); return true; } if (event == ftxui::Event::Escape || event == ftxui::Event::Character("n")) { ResolveConfirm(false); return true; } return true; } + if (modal_mode_ == ModalMode::Select) { if (event == ftxui::Event::Return) { ResolveSelect(true); return true; } if (event == ftxui::Event::Escape) { ResolveSelect(false); return true; } return select_component_->OnEvent(event); } + if (modal_mode_ == ModalMode::Message) { if (event == ftxui::Event::Return || event == ftxui::Event::Escape) { CloseMessage(); return true; } return true; } + if (event == ftxui::Event::Return) { ExecuteSelectedAction(); return true; } return false; } - ftxui::Element TuxDockApp::RenderModal() const { using namespace ftxui; - Element body; Element footer; - if (modal_mode_ == ModalMode::Input) { - body = vbox(ftxui::Elements{paragraph(modal_text_), separator(), input_component_->Render() | border}); + body = vbox(Elements{paragraph(modal_text_), separator(), input_component_->Render() | border}); footer = text("Enter: confirm Esc: cancel") | dim; } else if (modal_mode_ == ModalMode::Confirm) { body = paragraph(modal_text_); footer = text("Y/Enter: confirm N/Esc: cancel") | dim; } else if (modal_mode_ == ModalMode::Select) { - body = vbox(ftxui::Elements{paragraph(modal_text_), separator(), select_component_->Render() | frame | vscroll_indicator}); + body = vbox(Elements{paragraph(modal_text_), separator(), select_component_->Render() | frame | vscroll_indicator}); footer = text("Up/Down: choose Enter: confirm Esc: cancel") | dim; + } else if (modal_mode_ == ModalMode::Busy) { + static const std::string spinner = "|/-\\"; + body = vbox(Elements{ + text(operation_state_.message()), + separator(), + text(std::string(" ") + spinner[spinner_frame_ % spinner.size()]) | bold, + }); + footer = text("Please wait; input is disabled") | dim; } else { - body = paragraph(modal_text_) | vscroll_indicator | frame | size(HEIGHT, LESS_THAN, 14); + body = (modal_content_ ? std::move(modal_content_) : paragraph(modal_text_)) | + frame | vscroll_indicator | + size(HEIGHT, LESS_THAN, 18) | size(WIDTH, LESS_THAN, 96); footer = text("Enter/Esc: close") | dim; } - - return window(text(modal_title_), vbox(ftxui::Elements{body, separator(), footer})) | - size(WIDTH, GREATER_THAN, 70) | size(HEIGHT, GREATER_THAN, 12) | center; + const std::string title = modal_mode_ == ModalMode::Busy ? operation_state_.title() : modal_title_; + return window(text(title), vbox(Elements{body, separator(), footer})) | + size(WIDTH, LESS_THAN, 96) | size(WIDTH, GREATER_THAN, 42) | + size(HEIGHT, LESS_THAN, 24) | size(HEIGHT, GREATER_THAN, 10) | center; } - -ftxui::Element TuxDockApp::Render() const { - using namespace ftxui; - - Elements status_lines; - { - std::stringstream ss(status_); - std::string line; - while (std::getline(ss, line)) { - status_lines.push_back(line.empty() ? text(" ") : text(line)); - } - if (status_lines.empty()) { - status_lines.push_back(text(" ")); - } +ftxui::Element TuxDockApp::Render() const { using namespace ftxui; auto base = window(text("Actions"), vbox(Elements{menu_component_->Render() | frame | vscroll_indicator, separator(), text("Up/Down: navigate Enter: select") | dim})) | size(WIDTH, GREATER_THAN, 56) | size(WIDTH, LESS_THAN, 90) | center; return modal_mode_ == ModalMode::None ? base : dbox({base, RenderModal() | clear_under | center}); } +int TuxDockApp::Run() { + std::string connection_error; + if (!docker_.checkConnection(connection_error)) { + std::cerr << "Unable to connect to Docker Engine.\n" + << "Socket: /var/run/docker.sock\n" + << "Reason: " << connection_error << "\n\n" + << "Ensure Docker is running and your user can access the Docker socket.\n"; + return 1; } - auto actions_panel = window(text("Actions"), - vbox(ftxui::Elements{menu_component_->Render() | frame | vscroll_indicator, - separator(), - text("Up/Down: navigate Enter: select") | dim})) | - size(WIDTH, GREATER_THAN, 48) | flex; - - auto status_panel = window(text("Status"), vbox(ftxui::Elements{vbox(std::move(status_lines)) | yflex | frame | vscroll_indicator, - separator(), - text("High-level updates only") | dim})) | - size(WIDTH, GREATER_THAN, 48) | flex; - - Element base = hbox(ftxui::Elements{actions_panel, separator(), status_panel}) | border; - - if (modal_mode_ == ModalMode::None) { - return base; + const auto initial_containers = docker_.getContainerList(); + const auto initial_images = docker_.getImageList(); + if (initial_containers.ok()) containers_ = initial_containers.items; + if (initial_images.ok()) images_ = initial_images.items; + if (!initial_containers.ok() || !initial_images.ok()) { + SetStatus("Docker connected, but initial state could not be fully loaded."); + } else if (containers_.empty() && images_.empty()) { + SetStatus("Docker connected. No containers or images found."); + } else { + SetStatus("Docker connected. State loaded."); } - return dbox({base, RenderModal() | clear_under | center}); -} - -void TuxDockApp::Run() { auto root = ftxui::Renderer(menu_component_, [this] { return Render(); }); - auto app = ftxui::CatchEvent(root, [this](ftxui::Event event) { return OnEvent(event); }); - + auto app = ftxui::CatchEvent(root, [this](ftxui::Event e) { return OnEvent(e); }); auto screen = ftxui::ScreenInteractive::TerminalOutput(); screen_ = &screen; screen.Loop(app); screen_ = nullptr; -} - -int main() { - TuxDockApp app; - app.Run(); + if (refresh_thread_.joinable()) refresh_thread_.join(); + if (spinner_thread_.joinable()) spinner_thread_.join(); + for (auto& thread : action_threads_) if (thread.joinable()) thread.join(); return 0; } +int main() { TuxDockApp app; return app.Run(); } diff --git a/src/container_parser.cpp b/src/container_parser.cpp new file mode 100644 index 0000000..8fc5b04 --- /dev/null +++ b/src/container_parser.cpp @@ -0,0 +1,63 @@ +#include "container_parser.hpp" + +#include + +#include + +using json = nlohmann::json; + +namespace { + +std::string optionalString(const json& object, const char* key) { + const auto it = object.find(key); + return it != object.end() && it->is_string() ? it->get() : std::string{}; +} + +std::string portsFor(const json& item) { + const auto it = item.find("Ports"); + if (it == item.end() || !it->is_array()) return {}; + std::ostringstream result; + bool first = true; + for (const auto& port : *it) { + if (!port.is_object()) continue; + const auto public_it = port.find("PublicPort"); + const auto private_it = port.find("PrivatePort"); + if (public_it == port.end() || private_it == port.end() || + !public_it->is_number() || !private_it->is_number()) continue; + if (!first) result << ", "; + result << *public_it << ":" << *private_it; + first = false; + } + return result.str(); +} + +} // namespace + +DockerManager::ListResult +parseContainerList(const std::string& body) { + DockerManager::ListResult result; + try { + const auto parsed = json::parse(body); + if (!parsed.is_array()) { + result.error = "Container response is not an array."; + return result; + } + for (const auto& item : parsed) { + if (!item.is_object()) continue; + DockerManager::ContainerInfo info; + info.id = optionalString(item, "Id"); + const auto names = item.find("Names"); + if (names != item.end() && names->is_array() && !names->empty() && names->front().is_string()) { + info.name = names->front().get(); + } + if (!info.name.empty() && info.name.front() == '/') info.name.erase(0, 1); + info.status = optionalString(item, "Status"); + info.running = optionalString(item, "State") == "running"; + info.ports = portsFor(item); + if (!info.id.empty() && !info.name.empty()) result.items.push_back(std::move(info)); + } + } catch (const json::exception& error) { + result.error = std::string("Could not parse container data: ") + error.what(); + } + return result; +} diff --git a/src/container_parser.hpp b/src/container_parser.hpp new file mode 100644 index 0000000..4dd325e --- /dev/null +++ b/src/container_parser.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include "docker_manager.hpp" + +#include +#include + +DockerManager::ListResult +parseContainerList(const std::string& body); diff --git a/src/docker_engine_client.cpp b/src/docker_engine_client.cpp new file mode 100644 index 0000000..85a3bca --- /dev/null +++ b/src/docker_engine_client.cpp @@ -0,0 +1,107 @@ +#include "docker_engine_client.hpp" + +#include "http_response_parser.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +DockerEngineClient::DockerEngineClient(std::string socket_path) + : socket_path_(std::move(socket_path)) {} + +bool DockerEngineClient::checkConnection(std::string& error) const { + const EngineResponse response = request("GET", "/_ping"); + if (!response.error.empty()) { + error = response.error; + return false; + } + if (response.status_code < 200 || response.status_code >= 300) { + error = "Docker Engine returned HTTP status " + std::to_string(response.status_code) + "."; + return false; + } + if (response.body != "OK" && response.body != "OK\n" && response.body != "OK\r\n") { + error = "Docker Engine returned an unexpected /_ping response."; + return false; + } + return true; +} + +EngineResponse DockerEngineClient::request(const std::string& method, + const std::string& path, + const std::string& body) const { + EngineResponse response; + const int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd == -1) { + response.error = std::strerror(errno); + return response; + } + + sockaddr_un address{}; + address.sun_family = AF_UNIX; + if (socket_path_.size() >= sizeof(address.sun_path)) { + response.error = "Docker socket path is too long."; + close(fd); + return response; + } + std::strncpy(address.sun_path, socket_path_.c_str(), sizeof(address.sun_path) - 1); + if (connect(fd, reinterpret_cast(&address), sizeof(address)) != 0) { + response.error = std::strerror(errno); + close(fd); + return response; + } + + timeval timeout{}; + timeout.tv_sec = 10; + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + + std::ostringstream wire_request; + wire_request << method << " " << path << " HTTP/1.1\r\n" + << "Host: docker\r\n" + << "Connection: close\r\n" + << "Content-Type: application/json\r\n" + << "Content-Length: " << body.size() << "\r\n\r\n" + << body; + const std::string wire = wire_request.str(); + std::size_t sent = 0; + while (sent < wire.size()) { + const ssize_t count = write(fd, wire.data() + sent, wire.size() - sent); + if (count <= 0) { + response.error = std::strerror(errno); + close(fd); + return response; + } + sent += static_cast(count); + } + + std::string raw; + char buffer[8192]; + ssize_t count = 0; + while (!httpResponseComplete(raw, method) && (count = read(fd, buffer, sizeof(buffer))) > 0) { + raw.append(buffer, static_cast(count)); + } + close(fd); + if (count < 0) { + if (errno == EINTR) return request(method, path, body); + if (errno != EAGAIN && errno != EWOULDBLOCK) { + response.error = std::strerror(errno); + return response; + } + if (!httpResponseComplete(raw, method)) { + response.error = "Docker Engine response timed out."; + return response; + } + } + + const auto parsed = parseHttpResponse(raw); + response.status_code = parsed.status_code; + response.body = parsed.body; + response.error = parsed.error; + if (response.status_code == 304 && response.error.empty()) response.error.clear(); + return response; +} diff --git a/src/docker_engine_client.hpp b/src/docker_engine_client.hpp new file mode 100644 index 0000000..234d456 --- /dev/null +++ b/src/docker_engine_client.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +struct EngineResponse { + int status_code = 0; + std::string body; + std::string error; + + bool ok() const { return status_code >= 200 && status_code < 300 && error.empty(); } +}; + +class DockerEngineClient { +public: + explicit DockerEngineClient(std::string socket_path = "/var/run/docker.sock"); + + bool checkConnection(std::string& error) const; + + EngineResponse request(const std::string& method, + const std::string& path, + const std::string& body = {}) const; + +private: + std::string socket_path_; +}; diff --git a/src/docker_manager.cpp b/src/docker_manager.cpp new file mode 100644 index 0000000..18ffffa --- /dev/null +++ b/src/docker_manager.cpp @@ -0,0 +1,206 @@ +#include "docker_manager.hpp" + +#include "process_runner.hpp" +#include "container_parser.hpp" +#include "stop_waiter.hpp" + +#include +#include +#include + +#include + +using json = nlohmann::json; + +namespace { + +std::string apiError(const EngineResponse& response, const std::string& fallback) { + if (!response.error.empty()) return response.error; + return fallback; +} + +} // namespace + +std::string DockerManager::processError(const std::string& fallback, + const std::string& stderr_text) { + return stderr_text.empty() ? fallback : stderr_text; +} + +bool DockerManager::runProcess(const std::vector& args, + std::string& message, + bool inherit_stdio) { + ProcessOptions options; + options.inherit_stdio = inherit_stdio; + options.capture_stdout = !inherit_stdio; + options.capture_stderr = !inherit_stdio; + const ProcessResult result = ProcessRunner::run(args, options); + if (!result.ok()) { + message = processError("Docker command failed.", result.stderr_text); + return false; + } + return true; +} + +bool DockerManager::checkConnection(std::string& error) const { + return engine_.checkConnection(error); +} + +DockerManager::ListResult DockerManager::getContainerList() const { + ListResult result; + const EngineResponse response = engine_.request("GET", "/containers/json?all=true"); + if (!response.ok()) { + result.error = apiError(response, "Could not list containers."); + return result; + } + + return parseContainerList(response.body); +} + +DockerManager::ListResult DockerManager::getImageList() const { + ListResult result; + const EngineResponse response = engine_.request("GET", "/images/json"); + if (!response.ok()) { + result.error = apiError(response, "Could not list images."); + return result; + } + + try { + for (const auto& item : json::parse(response.body)) { + const std::string id = item.value("Id", ""); + const auto tags = item.value("RepoTags", std::vector{}); + if (id.empty()) continue; + if (tags.empty()) result.items.emplace_back(id, ""); + else for (const auto& tag : tags) result.items.emplace_back(id, tag); + } + } catch (const json::exception& error) { + result.items.clear(); + result.error = std::string("Could not parse image data: ") + error.what(); + } + return result; +} + +bool DockerManager::pullImage(const std::string& image, std::string& message) const { + if (image.empty()) { + message = "Please provide an image name."; + return false; + } + const bool ok = runProcess({"docker", "pull", image}, message); + if (ok) message = "Image pulled successfully."; + return ok; +} + +bool DockerManager::runContainerInteractive(const std::string& image, + const std::vector& ports, + std::string& message) const { + if (image.empty()) { + message = "Please choose an image first."; + return false; + } + std::vector args{"docker", "run", "-it"}; + for (const auto& port : ports) { + args.push_back("-p"); + args.push_back(port); + } + args.insert(args.end(), {image, "/bin/sh"}); + const bool ok = runProcess(args, message, true); + if (ok) message = "Interactive container session finished."; + return ok; +} + +bool DockerManager::startInteractive(const std::string& id, std::string& message) const { + const bool ok = runProcess({"docker", "start", "-ai", id}, message, true); + if (ok) message = "Interactive container session finished."; + return ok; +} + +bool DockerManager::startDetached(const std::string& id, std::string& message) const { + const EngineResponse response = engine_.request("POST", "/containers/" + id + "/start"); + if (!response.ok()) { + message = apiError(response, "Could not start that container."); + return false; + } + message = "Container started in detached mode."; + return true; +} + +bool DockerManager::deleteImage(const std::string& id, std::string& message) const { + const EngineResponse response = engine_.request("DELETE", "/images/" + id); + if (!response.ok()) { + message = apiError(response, "Could not delete that image."); + return false; + } + message = "Image deleted."; + return true; +} + +bool DockerManager::stopContainer(const std::string& id, std::string& message) const { + const auto stop_request = [this, &id] { return engine_.request("POST", "/containers/" + id + "/stop"); }; + const auto acceptable = [](const EngineResponse& response) { + return response.status_code == 204 || response.status_code == 304 || response.status_code == 404 || response.ok(); + }; + const auto probe = [this, &id] { + const EngineResponse state = engine_.request("GET", "/containers/" + id + "/json"); + if (!state.ok()) return StopProbe::Unknown; + try { + const bool running = nlohmann::json::parse(state.body) + .value("State", nlohmann::json::object()) + .value("Running", true); + return running ? StopProbe::Running : StopProbe::Stopped; + } catch (const nlohmann::json::exception&) { + return StopProbe::Unknown; + } + }; + + const EngineResponse first = stop_request(); + const bool first_timed_out = first.error == "Docker Engine response timed out."; + if (!acceptable(first) && !first_timed_out) { + message = apiError(first, "Could not stop that container."); + return false; + } + + StopProbe state = first_timed_out ? StopProbe::Unknown : waitForStopped(probe); + if (state != StopProbe::Stopped) { + const EngineResponse retry = stop_request(); + const bool retry_timed_out = retry.error == "Docker Engine response timed out."; + if (!acceptable(retry) && !retry_timed_out) { + message = apiError(retry, "Could not confirm that container stopped."); + return false; + } + state = waitForStopped(probe); + } + + if (state != StopProbe::Stopped) { + message = "Stop requested, but container state could not be confirmed."; + return false; + } + message = "Container stopped."; + return true; +} + +bool DockerManager::removeContainer(const std::string& id, std::string& message) const { + const EngineResponse response = engine_.request("DELETE", "/containers/" + id); + if (!response.ok()) { + message = apiError(response, "Could not remove that container."); + return false; + } + message = "Container removed."; + return true; +} + +bool DockerManager::execShell(const std::string& id, std::string& message) const { + const bool ok = runProcess({"docker", "exec", "-it", id, "/bin/sh"}, message, true); + if (ok) message = "Shell session finished."; + return ok; +} + +bool DockerManager::execDetachedCommand(const std::string& id, + const std::string& command, + std::string& message) const { + if (command.empty()) { + message = "Please provide a command to run."; + return false; + } + const bool ok = runProcess({"docker", "exec", "-d", id, "/bin/sh", "-c", command}, message); + if (ok) message = "Command dispatched in detached mode."; + return ok; +} diff --git a/src/docker_manager.hpp b/src/docker_manager.hpp new file mode 100644 index 0000000..9ba7def --- /dev/null +++ b/src/docker_manager.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "docker_engine_client.hpp" + +#include +#include +#include + +class DockerManager { +public: + struct ContainerInfo { + std::string id; + std::string name; + std::string status; + std::string ports; + bool running = false; + }; + + using ImageInfo = std::pair; + + template + struct ListResult { + std::vector items; + std::string error; + + bool ok() const { return error.empty(); } + }; + + bool checkConnection(std::string& error) const; + + ListResult getContainerList() const; + ListResult getImageList() const; + + bool pullImage(const std::string& image, std::string& message) const; + bool runContainerInteractive(const std::string& image, + const std::vector& ports, + std::string& message) const; + bool startInteractive(const std::string& containerId, std::string& message) const; + bool startDetached(const std::string& containerId, std::string& message) const; + bool deleteImage(const std::string& imageId, std::string& message) const; + bool stopContainer(const std::string& containerId, std::string& message) const; + bool removeContainer(const std::string& containerId, std::string& message) const; + bool execShell(const std::string& containerId, std::string& message) const; + bool execDetachedCommand(const std::string& containerId, + const std::string& command, + std::string& message) const; +private: + DockerEngineClient engine_; + + static std::string processError(const std::string& fallback, + const std::string& stderr_text); + static bool runProcess(const std::vector& args, + std::string& message, + bool inherit_stdio = false); +}; diff --git a/src/http_response_parser.cpp b/src/http_response_parser.cpp new file mode 100644 index 0000000..e2f7eaf --- /dev/null +++ b/src/http_response_parser.cpp @@ -0,0 +1,142 @@ +#include "http_response_parser.hpp" + +#include +#include +#include + +namespace { + +std::string lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::string trim(std::string value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); +} + +bool decodeChunked(const std::string& input, std::string& body, std::string& error) { + std::size_t offset = 0; + while (offset < input.size()) { + const auto line_end = input.find("\r\n", offset); + if (line_end == std::string::npos) { + error = "Truncated chunk size."; + return false; + } + const std::string size_text = trim(input.substr(offset, line_end - offset)); + const auto semicolon = size_text.find(';'); + const std::string size_value = size_text.substr(0, semicolon); + std::size_t chunk_size = 0; + try { + chunk_size = std::stoull(size_value, nullptr, 16); + } catch (...) { + error = "Invalid chunk size."; + return false; + } + offset = line_end + 2; + if (chunk_size == 0) return true; + if (offset + chunk_size + 2 > input.size() || input.substr(offset + chunk_size, 2) != "\r\n") { + error = "Truncated chunk data."; + return false; + } + body.append(input, offset, chunk_size); + offset += chunk_size + 2; + } + error = "Missing terminating chunk."; + return false; +} + +} // namespace + +ParsedHttpResponse parseHttpResponse(const std::string& raw) { + ParsedHttpResponse response; + const auto header_end = raw.find("\r\n\r\n"); + if (header_end == std::string::npos) { + response.error = "Invalid response from Docker Engine: missing headers."; + return response; + } + + const auto status_end = raw.find("\r\n"); + std::istringstream status_line(raw.substr(0, status_end)); + std::string version; + status_line >> version >> response.status_code; + if (response.status_code == 0) { + response.error = "Invalid HTTP status from Docker Engine."; + return response; + } + + const bool bodyless = (response.status_code >= 100 && response.status_code < 200) || + response.status_code == 204 || response.status_code == 304; + + std::size_t content_length = std::string::npos; + bool chunked = false; + std::size_t line_start = status_end + 2; + while (line_start < header_end) { + const auto line_end = raw.find("\r\n", line_start); + if (line_end == std::string::npos || line_end > header_end) break; + const auto separator = raw.find(':', line_start); + if (separator != std::string::npos && separator < line_end) { + const auto name = lower(trim(raw.substr(line_start, separator - line_start))); + const auto value = lower(trim(raw.substr(separator + 1, line_end - separator - 1))); + if (name == "content-length") { + try { content_length = std::stoull(value); } catch (...) { response.error = "Invalid Content-Length."; return response; } + } else if (name == "transfer-encoding" && value.find("chunked") != std::string::npos) { + chunked = true; + } + } + line_start = line_end + 2; + } + + const std::string payload = raw.substr(header_end + 4); + if (bodyless) { + response.body.clear(); + } else if (chunked) { + if (!decodeChunked(payload, response.body, response.error)) return response; + } else if (content_length != std::string::npos) { + if (payload.size() < content_length) { + response.error = "Truncated HTTP response body."; + return response; + } + response.body = payload.substr(0, content_length); + } else { + response.body = payload; + } + if (response.status_code < 200 || response.status_code >= 300) { + if (response.status_code == 304) return response; + response.error = response.body.empty() ? "Docker Engine request failed." : response.body; + } + return response; +} + +bool httpResponseComplete(const std::string& raw, const std::string& method) { + const auto header_end = raw.find("\r\n\r\n"); + if (header_end == std::string::npos) return false; + const auto status_end = raw.find("\r\n"); + if (status_end == std::string::npos || status_end > header_end) return false; + std::istringstream status_line(raw.substr(0, status_end)); + std::string version; + int status_code = 0; + status_line >> version >> status_code; + if (method == "HEAD" || (status_code >= 100 && status_code < 200) || status_code == 204 || status_code == 304) return true; + const std::string headers = raw.substr(status_end + 2, header_end - status_end - 2); + const auto transfer = headers.find("Transfer-Encoding:"); + if (transfer != std::string::npos && lower(headers.substr(transfer)).find("chunked") != std::string::npos) { + return raw.size() >= header_end + 7 && raw.find("\r\n0\r\n", header_end + 4) != std::string::npos; + } + const auto length = lower(headers).find("content-length:"); + if (length == std::string::npos) return false; + const auto line_end = headers.find("\r\n", length); + const auto value_start = headers.find(':', length); + if (value_start == std::string::npos) return false; + try { + const auto expected = std::stoull(trim(headers.substr(value_start + 1, line_end - value_start - 1))); + return raw.size() >= header_end + 4 + expected; + } catch (...) { + return false; + } +} diff --git a/src/http_response_parser.hpp b/src/http_response_parser.hpp new file mode 100644 index 0000000..3e8113e --- /dev/null +++ b/src/http_response_parser.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +struct ParsedHttpResponse { + int status_code = 0; + std::string body; + std::string error; + + bool ok() const { return ((status_code >= 200 && status_code < 300) || status_code == 304) && error.empty(); } +}; + +ParsedHttpResponse parseHttpResponse(const std::string& raw); +bool httpResponseComplete(const std::string& raw, const std::string& method = {}); diff --git a/src/operation_state.cpp b/src/operation_state.cpp new file mode 100644 index 0000000..eb4b7c5 --- /dev/null +++ b/src/operation_state.cpp @@ -0,0 +1,15 @@ +#include "operation_state.hpp" + +#include + +void OperationState::begin(std::string title, std::string message) { + busy_ = true; + title_ = std::move(title); + message_ = std::move(message); + result_.clear(); +} + +void OperationState::complete(std::string result) { + busy_ = false; + result_ = std::move(result); +} diff --git a/src/operation_state.hpp b/src/operation_state.hpp new file mode 100644 index 0000000..6c80ae1 --- /dev/null +++ b/src/operation_state.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +class OperationState { +public: + void begin(std::string title, std::string message); + void complete(std::string result); + + bool busy() const { return busy_; } + const std::string& title() const { return title_; } + const std::string& message() const { return message_; } + const std::string& result() const { return result_; } + +private: + bool busy_ = false; + std::string title_; + std::string message_; + std::string result_; +}; diff --git a/src/process_runner.cpp b/src/process_runner.cpp new file mode 100644 index 0000000..41fd2bd --- /dev/null +++ b/src/process_runner.cpp @@ -0,0 +1,122 @@ +#include "process_runner.hpp" + +#include +#include +#include +#include + +#include +#include +namespace { + +struct Pipe { + int read = -1; + int write = -1; + + ~Pipe() { + if (read != -1) close(read); + if (write != -1) close(write); + } +}; + +} // namespace + +ProcessResult ProcessRunner::run(const std::vector& args, + const ProcessOptions& options) { + ProcessResult result; + if (args.empty()) { + result.stderr_text = "No executable specified."; + return result; + } + + Pipe stdout_pipe; + Pipe stderr_pipe; + if (!options.inherit_stdio && options.capture_stdout) { + int fds[2]; + if (pipe(fds) != 0) { + result.stderr_text = std::strerror(errno); + return result; + } + stdout_pipe.read = fds[0]; + stdout_pipe.write = fds[1]; + } + if (!options.inherit_stdio && options.capture_stderr) { + int fds[2]; + if (pipe(fds) != 0) { + result.stderr_text = std::strerror(errno); + return result; + } + stderr_pipe.read = fds[0]; + stderr_pipe.write = fds[1]; + } + + pid_t pid = fork(); + if (pid == -1) { + result.stderr_text = std::strerror(errno); + return result; + } + + if (pid == 0) { + if (!options.inherit_stdio && options.capture_stdout) { + if (dup2(stdout_pipe.write, STDOUT_FILENO) == -1) _exit(127); + } + if (!options.inherit_stdio && options.capture_stderr) { + if (dup2(stderr_pipe.write, STDERR_FILENO) == -1) _exit(127); + } + if (stdout_pipe.read != -1) close(stdout_pipe.read); + if (stdout_pipe.write != -1) close(stdout_pipe.write); + if (stderr_pipe.read != -1) close(stderr_pipe.read); + if (stderr_pipe.write != -1) close(stderr_pipe.write); + + std::vector argv; + argv.reserve(args.size() + 1); + for (const auto& arg : args) argv.push_back(const_cast(arg.c_str())); + argv.push_back(nullptr); + execvp(argv[0], argv.data()); + _exit(127); + } + + if (stdout_pipe.write != -1) close(stdout_pipe.write); + stdout_pipe.write = -1; + if (stderr_pipe.write != -1) close(stderr_pipe.write); + stderr_pipe.write = -1; + std::array buffer{}; + pollfd poll_fds[2]{}; + int active = 0; + if (stdout_pipe.read != -1) poll_fds[active++] = {stdout_pipe.read, POLLIN, 0}; + if (stderr_pipe.read != -1) poll_fds[active++] = {stderr_pipe.read, POLLIN, 0}; + while (active > 0) { + if (poll(poll_fds, static_cast(active), -1) == -1) { + if (errno == EINTR) continue; + break; + } + for (int i = 0; i < active;) { + if ((poll_fds[i].revents & (POLLIN | POLLHUP)) == 0) { + ++i; + continue; + } + const ssize_t count = read(poll_fds[i].fd, buffer.data(), buffer.size()); + if (count > 0) { + if (poll_fds[i].fd == stdout_pipe.read) result.stdout_text.append(buffer.data(), static_cast(count)); + else result.stderr_text.append(buffer.data(), static_cast(count)); + ++i; + } else { + close(poll_fds[i].fd); + poll_fds[i] = poll_fds[--active]; + } + } + } + + int status = 0; + if (waitpid(pid, &status, 0) == -1) { + result.stderr_text = std::strerror(errno); + return result; + } + if (WIFEXITED(status)) { + result.exit_code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + result.signaled = true; + result.signal = WTERMSIG(status); + } + return result; +} diff --git a/src/process_runner.hpp b/src/process_runner.hpp new file mode 100644 index 0000000..fece031 --- /dev/null +++ b/src/process_runner.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +struct ProcessResult { + int exit_code = -1; + bool signaled = false; + int signal = 0; + std::string stdout_text; + std::string stderr_text; + + bool ok() const { return !signaled && exit_code == 0; } +}; + +struct ProcessOptions { + bool capture_stdout = true; + bool capture_stderr = true; + bool inherit_stdio = false; +}; + +class ProcessRunner { +public: + static ProcessResult run(const std::vector& args, + const ProcessOptions& options = {}); +}; diff --git a/src/stop_waiter.cpp b/src/stop_waiter.cpp new file mode 100644 index 0000000..798dc02 --- /dev/null +++ b/src/stop_waiter.cpp @@ -0,0 +1,14 @@ +#include "stop_waiter.hpp" + +#include + +StopProbe waitForStopped(const std::function& probe, + std::chrono::milliseconds timeout, + std::chrono::milliseconds interval) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (probe() == StopProbe::Stopped) return StopProbe::Stopped; + std::this_thread::sleep_for(interval); + } + return probe(); +} diff --git a/src/stop_waiter.hpp b/src/stop_waiter.hpp new file mode 100644 index 0000000..060fc5e --- /dev/null +++ b/src/stop_waiter.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +enum class StopProbe { Stopped, Running, Unknown }; + +StopProbe waitForStopped(const std::function& probe, + std::chrono::milliseconds timeout = std::chrono::milliseconds(2000), + std::chrono::milliseconds interval = std::chrono::milliseconds(100)); diff --git a/tests/test_container_parser.cpp b/tests/test_container_parser.cpp new file mode 100644 index 0000000..79d0ff9 --- /dev/null +++ b/tests/test_container_parser.cpp @@ -0,0 +1,27 @@ +#include "container_parser.hpp" + +#include +#include + +int main() { + const auto result = parseContainerList(R"([ + {"Id":"running-id","Names":["/web"],"State":"running","Status":"Up 2 minutes","Ports":[]}, + {"Id":"exited-id","Names":["/old"],"State":"exited","Status":"Exited (0) 1 minute ago","Ports":[{"PrivatePort":80,"Type":"tcp"}]}, + {"Id":"odd-id","Names":["/odd"],"State":"exited","Ports":"unexpected"} + ])"); + assert(result.ok()); + assert(result.items.size() == 3); + assert(result.items[0].running); + assert(!result.items[1].running); + assert(result.items[1].name == "old"); + assert(result.items[1].ports.empty()); + assert(result.items[2].name == "odd"); + + const auto empty = parseContainerList("[]"); + assert(empty.ok()); + assert(empty.items.empty()); + + const auto invalid = parseContainerList("not-json"); + assert(!invalid.ok()); + std::cout << "Container parser tests passed\n"; +} diff --git a/tests/test_docker_integration.sh b/tests/test_docker_integration.sh new file mode 100755 index 0000000..60a2aba --- /dev/null +++ b/tests/test_docker_integration.sh @@ -0,0 +1,76 @@ +#!/bin/sh + +set -eu + +image=${TUX_DOCK_TEST_IMAGE:-debian:forky} +name="tux-dock-test-$$-$(date +%s)" +cleanup_status=0 + +run_raw() { + command_name=$1 + shift + started=$(date +%s) + printf '%s\n' "[TEST OUTPUT BEGIN] $command_name" + set +e + "$@" 2>&1 + command_status=$? + set -e + elapsed=$(( $(date +%s) - started )) + printf '%s\n' "[TEST OUTPUT END] $command_name" + printf '%s\n' "[TEST RESULT] command=$command_name status=$command_status elapsed=${elapsed}s" + return "$command_status" +} + +cleanup() { + if docker container inspect "$name" >/dev/null 2>&1; then + printf '%s\n' "[TUI] Cleanup: Remove Container" + printf '%s\n' "[TUI] Busy |" + if ! docker rm -f "$name"; then + cleanup_status=1 + fi + printf '%s\n' "[TUI] Complete: Container removed" + fi +} +trap cleanup EXIT INT TERM + +printf '%s\n' "[TUI] Actions > Run/Create Interactive Container" +printf '%s\n' "[TUI] Busy |" +printf '%s\n' "[TUI] docker run --name $name $image sh -c ''" + +if ! docker image inspect "$image" >/dev/null 2>&1; then + printf '%s\n' "[TUI] Pull Docker Image: $image" + run_raw "docker pull $image" docker pull "$image" +else + printf '%s\n' "[TUI] Image available: $image" +fi + +run_in_container() { + command_name=$1 + shift + printf '%s\n' "[TUI] Run command: $command_name" + printf '%s\n' "[TUI] Busy |" + run_raw "$command_name" docker exec "$name" "$@" + printf '%s\n' "[TUI] Complete: $command_name succeeded" +} + +run_raw "docker run -d --name $name $image sleep 300" docker run -d --name "$name" "$image" sleep 300 +printf '%s\n' "[TUI] Complete: Container created" +run_in_container "ls /" ls / +run_in_container "cat /etc/os-release" cat /etc/os-release +run_in_container "apt-get update" apt-get update +run_in_container "bash --version" bash --version + +printf '%s\n' "[TUI] Remove Container: $name" +run_raw "docker rm -f $name" docker rm -f "$name" +printf '%s\n' "[TUI] Complete: Container removed" + +printf '%s\n' "[TUI] Verify removal: docker container inspect $name" +if run_raw "docker container inspect $name" docker container inspect "$name"; then + printf '%s\n' "Container still exists after removal" >&2 + exit 1 +else + printf '%s\n' "[TUI] Complete: Removal verified" +fi + +trap - EXIT INT TERM +exit "$cleanup_status" diff --git a/tests/test_http_response_parser.cpp b/tests/test_http_response_parser.cpp new file mode 100644 index 0000000..5cc78de --- /dev/null +++ b/tests/test_http_response_parser.cpp @@ -0,0 +1,65 @@ +#include "http_response_parser.hpp" + +#include +#include + +void testContentLength() { + const auto response = parseHttpResponse( + "HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\n[1,2]\n"); + if (!response.ok()) std::cerr << response.error << " status=" << response.status_code << " body=" << response.body << "\n"; + assert(response.ok()); + assert(response.body == "[1,2]\n"); +} + +void testChunked() { + const auto response = parseHttpResponse( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"); + assert(response.ok()); + assert(response.body == "hello world"); +} + +void testTruncatedBody() { + const auto response = parseHttpResponse( + "HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort"); + assert(!response.ok()); + assert(response.error == "Truncated HTTP response body."); +} + +void testErrorResponse() { + const auto response = parseHttpResponse( + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 3\r\n\r\nbad"); + assert(!response.ok()); + assert(response.status_code == 500); + assert(response.error == "bad"); +} + +void testCompleteResponseDoesNotNeedPeerClose() { + const std::string raw = "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n"; + assert(httpResponseComplete(raw)); + const auto response = parseHttpResponse(raw); + assert(response.ok()); + assert(response.status_code == 204); +} + +void testBodylessStatuses() { + for (const int status : {204, 304}) { + const std::string raw = "HTTP/1.1 " + std::to_string(status) + " Status\r\n\r\n"; + assert(httpResponseComplete(raw)); + const auto parsed = parseHttpResponse(raw); + if (!parsed.ok()) std::cerr << "status=" << status << " error=" << parsed.error << "\n"; + assert(parsed.ok()); + } + const std::string head = "HTTP/1.1 200 OK\r\n\r\n"; + assert(httpResponseComplete(head, "HEAD")); +} + +int main() { + testContentLength(); + testChunked(); + testTruncatedBody(); + testErrorResponse(); + testCompleteResponseDoesNotNeedPeerClose(); + testBodylessStatuses(); + std::cout << "HTTP response parser tests passed\n"; +} diff --git a/tests/test_operation_state.cpp b/tests/test_operation_state.cpp new file mode 100644 index 0000000..5de0872 --- /dev/null +++ b/tests/test_operation_state.cpp @@ -0,0 +1,19 @@ +#include "operation_state.hpp" + +#include +#include + +int main() { + OperationState operation; + assert(!operation.busy()); + + operation.begin("Stopping container", "Please wait..."); + assert(operation.busy()); + assert(operation.title() == "Stopping container"); + assert(operation.message() == "Please wait..."); + + operation.complete("Container stopped."); + assert(!operation.busy()); + assert(operation.result() == "Container stopped."); + std::cout << "Operation state tests passed\n"; +} diff --git a/tests/test_stop_sequence.cpp b/tests/test_stop_sequence.cpp new file mode 100644 index 0000000..d897e11 --- /dev/null +++ b/tests/test_stop_sequence.cpp @@ -0,0 +1,22 @@ +#include "stop_waiter.hpp" + +#include +#include + +int main() { + int stop_requests = 0; + int probes = 0; + const auto first = [&] { + ++stop_requests; + return stop_requests == 1 ? StopProbe::Unknown : StopProbe::Stopped; + }; + const auto state = waitForStopped([&] { + ++probes; + return probes < 2 ? StopProbe::Unknown : StopProbe::Stopped; + }, std::chrono::milliseconds(10), std::chrono::milliseconds(1)); + assert(state == StopProbe::Stopped); + assert(first() == StopProbe::Unknown); + assert(first() == StopProbe::Stopped); + assert(stop_requests == 2); + std::cout << "Stop sequence tests passed\n"; +} diff --git a/tests/test_stop_waiter.cpp b/tests/test_stop_waiter.cpp new file mode 100644 index 0000000..a5dbe33 --- /dev/null +++ b/tests/test_stop_waiter.cpp @@ -0,0 +1,33 @@ +#include "stop_waiter.hpp" + +#include +#include +#include + +int main() { + int calls = 0; + const auto stopped = waitForStopped([&] { + return ++calls < 3 ? StopProbe::Running : StopProbe::Stopped; + }, + std::chrono::milliseconds(100), + std::chrono::milliseconds(1)); + assert(stopped == StopProbe::Stopped); + + const auto timed_out = waitForStopped([] { return StopProbe::Unknown; }, + std::chrono::milliseconds(5), + std::chrono::milliseconds(1)); + assert(timed_out == StopProbe::Unknown); + int transient_calls = 0; + const auto transient = waitForStopped([&] { + ++transient_calls; + if (transient_calls < 3) return StopProbe::Unknown; + return StopProbe::Stopped; + }, std::chrono::milliseconds(100), std::chrono::milliseconds(1)); + assert(transient == StopProbe::Stopped); + // A failed refresh must not be interpreted as a stopped state. + const auto preserved = waitForStopped([] { return StopProbe::Unknown; }, + std::chrono::milliseconds(3), + std::chrono::milliseconds(1)); + assert(preserved == StopProbe::Unknown); + std::cout << "Stop waiter tests passed\n"; +}