diff --git a/CMakeLists.txt b/CMakeLists.txt index bee3ac9..7de9776 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,8 @@ add_executable(tux-dock 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 ) @@ -66,4 +68,28 @@ if(BUILD_TESTING) 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) 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 01a6212..5e3ebe4 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,12 +39,14 @@ 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 ``` Prefer a prebuilt binary? CI artifacts are published at: @@ -72,7 +78,7 @@ Current TUI actions: ## 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 { @@ -85,9 +91,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(...); @@ -106,11 +112,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 @@ -119,24 +125,44 @@ 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 diff --git a/compile.sh b/compile.sh index f5a6b58..ae041fa 100755 --- a/compile.sh +++ b/compile.sh @@ -1,3 +1,27 @@ #!/bin/sh -cmake -S . -B build && cmake --build build -j && echo "tux-dock successfully compiled!" +set -eu + +if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ "$1" != "--no-test" ]; }; then + printf '%s\n' "Usage: $0 [--no-test]" >&2 + exit 2 +fi + +run_tests=1 +if [ "$#" -eq 1 ]; then + run_tests=0 +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 + ctest --test-dir build --output-on-failure +fi + +printf '%s\n' "tux-dock successfully compiled!" diff --git a/main.cpp b/main.cpp index b2d2268..189219b 100644 --- a/main.cpp +++ b/main.cpp @@ -1,4 +1,5 @@ #include "src/docker_manager.hpp" +#include "src/operation_state.hpp" #include #include @@ -21,7 +22,7 @@ public: int Run(); private: - enum class ModalMode { None, Input, Confirm, Select, Message }; + 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; }; struct DockerfileContext { std::string base_image; std::string script_path; std::string output_file; std::string image_name; }; @@ -46,6 +47,8 @@ private: ftxui::ScreenInteractive* screen_ = nullptr; std::thread refresh_thread_; 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); @@ -68,6 +71,8 @@ private: 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 BeginStopOperation(const std::string& id); void RefreshState(const std::string& message = "Refreshing Docker state..."); void ApplyRefreshResults(DockerManager::ListResult containers, DockerManager::ListResult images); @@ -155,16 +160,49 @@ void TuxDockApp::PromptContainerSelection(const std::string& title, std::functio 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) { - SetStatus(wait); + BeginBusyOperation("Working", wait, std::move(action)); +} + +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_; + 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] { SetStatus(message); RefreshState(); }); + active->Post([this, message] { + operation_state_.complete(message); + modal_mode_ = ModalMode::None; + OpenMessage("Operation complete", message); + RefreshState(); + }); active->PostEvent(ftxui::Event::Custom); } }); } +void TuxDockApp::BeginStopOperation(const std::string& id) { + operation_state_.begin("Stopping container", "Stopping and refreshing state..."); + modal_mode_ = ModalMode::Busy; + auto* active = screen_; + action_threads_.emplace_back([this, active, id] { + std::string message; + 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); + 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(); } @@ -183,18 +221,18 @@ void TuxDockApp::ActionListImages() { } OpenListMessage("Images", FormatImageList(images_)); } -void TuxDockApp::ActionPullImage() { OpenInput("Pull Docker Image", "Enter image name:", [this](bool ok, const std::string& image) { if (!ok) return; RunDeferredStatusAction("Please wait, pulling image...", [this, image] { std::string m; docker_.pullImage(image, m); return m; }); }); } +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&) { RunDeferredStatusAction("Starting container...", [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) RunDeferredStatusAction("Deleting image...", [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&) { RunDeferredStatusAction("Stopping container...", [this, id] { std::string m; docker_.stopContainer(id, m); return m; }); }); } -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) RunDeferredStatusAction("Removing container...", [this, id] { std::string m; docker_.removeContainer(id, m); return m; }); }); }); } +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; RunDeferredStatusAction("Running command...", [this, id, command] { std::string m; docker_.execDetachedCommand(id, command, m); return m; }); }); }); } -void TuxDockApp::ActionSpinUpMySQL() { OpenInput("MySQL Setup", "Enter port mapping:", [this](bool ok, const std::string& port) { if (!ok || !IsValidPortMapping(port)) return SetStatus("Use host:container format."); OpenInput("MySQL Setup", "Enter root password:", [this, port](bool ok2, const std::string& password) { if (!ok2) return; OpenInput("MySQL Setup", "Enter version tag:", [this, port, password](bool ok3, const std::string& version) { if (!ok3) return; RunDeferredStatusAction("Launching MySQL...", [this, port, password, version] { std::string m; docker_.spinUpMySQL(port, password, version, m); return m; }); }); }, true); }); } +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::ActionSpinUpMySQL() { OpenInput("MySQL Setup", "Enter port mapping:", [this](bool ok, const std::string& port) { if (!ok || !IsValidPortMapping(port)) return SetStatus("Use host:container format."); OpenInput("MySQL Setup", "Enter root password:", [this, port](bool ok2, const std::string& password) { if (!ok2) return; OpenInput("MySQL Setup", "Enter version tag:", [this, port, password](bool ok3, const std::string& version) { if (!ok3) return; BeginBusyOperation("Launching MySQL", "Please wait...", [this, port, password, version] { std::string m; docker_.spinUpMySQL(port, password, version, m); return m; }); }); }, true); }); } void TuxDockApp::ActionCreateDockerfile() { OpenInput("Dockerfile Builder", "Enter base image:", [this](bool ok, const std::string& base) { if (!ok) return; @@ -204,7 +242,7 @@ void TuxDockApp::ActionCreateDockerfile() { if (!ok3) return; OpenInput("Dockerfile Builder", "Enter image name (empty skips build):", [this, base, script, output](bool ok4, const std::string& image) { if (!ok4) return; - RunDeferredStatusAction("Creating Dockerfile...", [this, base, script, output, image] { + BeginBusyOperation("Building image", "Creating Dockerfile and running build...", [this, base, script, output, image] { std::string m; docker_.createDockerfile(base, script, output, image, m); return m; @@ -214,11 +252,19 @@ void TuxDockApp::ActionCreateDockerfile() { }); }); } -void TuxDockApp::ActionAbout() { SetStatus("Tux-Dock 022526-dev\nCreated by markmental"); } +void TuxDockApp::ActionAbout() { OpenMessage("About Tux-Dock", "Tux-Dock 0.1-beta | Created by markmental"); } 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: ActionCreateDockerfile(); break; case 13: ActionAbout(); break; case 14: 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::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; } +bool TuxDockApp::OnEvent(ftxui::Event event) { + if (modal_mode_ == ModalMode::Busy) 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; @@ -232,17 +278,26 @@ ftxui::Element TuxDockApp::RenderModal() const { } else if (modal_mode_ == ModalMode::Select) { 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 = (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(Elements{body, separator(), footer})) | + 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 lines; std::stringstream s(status_); std::string line; while (std::getline(s, line)) lines.push_back(line.empty() ? text(" ") : text(line)); auto base = hbox(Elements{window(text("Actions"), vbox(Elements{menu_component_->Render() | frame | vscroll_indicator, separator(), text("Up/Down: navigate Enter: select") | dim})) | size(WIDTH, GREATER_THAN, 48) | flex, separator(), window(text("Status"), vbox(Elements{vbox(std::move(lines)) | yflex | frame | vscroll_indicator, separator(), text("Engine API cache") | dim})) | size(WIDTH, GREATER_THAN, 48) | flex}) | border; return modal_mode_ == ModalMode::None ? base : dbox({base, RenderModal() | clear_under | center}); } +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)) { diff --git a/src/docker_engine_client.cpp b/src/docker_engine_client.cpp index bf8826a..85a3bca 100644 --- a/src/docker_engine_client.cpp +++ b/src/docker_engine_client.cpp @@ -60,14 +60,14 @@ EngineResponse DockerEngineClient::request(const std::string& method, setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); - std::ostringstream request; - request << method << " " << path << " HTTP/1.1\r\n" + 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 = request.str(); + 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); @@ -82,18 +82,26 @@ EngineResponse DockerEngineClient::request(const std::string& method, std::string raw; char buffer[8192]; ssize_t count = 0; - while ((count = read(fd, buffer, sizeof(buffer))) > 0) { + while (!httpResponseComplete(raw, method) && (count = read(fd, buffer, sizeof(buffer))) > 0) { raw.append(buffer, static_cast(count)); } close(fd); if (count < 0) { - response.error = std::strerror(errno); - return response; + 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_manager.cpp b/src/docker_manager.cpp index eb783fa..b38b547 100644 --- a/src/docker_manager.cpp +++ b/src/docker_manager.cpp @@ -2,6 +2,7 @@ #include "process_runner.hpp" #include "container_parser.hpp" +#include "stop_waiter.hpp" #include #include @@ -133,9 +134,43 @@ bool DockerManager::deleteImage(const std::string& id, std::string& message) con } bool DockerManager::stopContainer(const std::string& id, std::string& message) const { - const EngineResponse response = engine_.request("POST", "/containers/" + id + "/stop"); - if (!response.ok()) { - message = apiError(response, "Could not stop that container."); + 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."; diff --git a/src/http_response_parser.cpp b/src/http_response_parser.cpp index ce93bab..e2f7eaf 100644 --- a/src/http_response_parser.cpp +++ b/src/http_response_parser.cpp @@ -70,6 +70,9 @@ ParsedHttpResponse parseHttpResponse(const std::string& raw) { 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; @@ -90,7 +93,9 @@ ParsedHttpResponse parseHttpResponse(const std::string& raw) { } const std::string payload = raw.substr(header_end + 4); - if (chunked) { + 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) { @@ -102,7 +107,36 @@ ParsedHttpResponse parseHttpResponse(const std::string& raw) { 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 index 1d7af0b..3e8113e 100644 --- a/src/http_response_parser.hpp +++ b/src/http_response_parser.hpp @@ -7,7 +7,8 @@ struct ParsedHttpResponse { std::string body; std::string error; - bool ok() const { return status_code >= 200 && status_code < 300 && error.empty(); } + 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/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_http_response_parser.cpp b/tests/test_http_response_parser.cpp index 33a5ee2..5cc78de 100644 --- a/tests/test_http_response_parser.cpp +++ b/tests/test_http_response_parser.cpp @@ -34,10 +34,32 @@ void testErrorResponse() { 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"; +}