0.1 update, make the stop container code more robust and fix some bugs, update the about screen for version 0.1
This commit is contained in:
parent
91fa50370a
commit
e8e93d7b21
17 changed files with 449 additions and 43 deletions
|
|
@ -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()
|
||||
|
|
|
|||
42
DEVLOG.md
Normal file
42
DEVLOG.md
Normal file
|
|
@ -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.
|
||||
58
README.md
58
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<ContainerInfo> getContainerList() const;
|
||||
std::vector<std::pair<std::string, std::string>> getImageList() const;
|
||||
bool checkConnection(...);
|
||||
ListResult<ContainerInfo> getContainerList() const;
|
||||
ListResult<ImageInfo> 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
|
||||
|
|
|
|||
26
compile.sh
26
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!"
|
||||
|
|
|
|||
85
main.cpp
85
main.cpp
|
|
@ -1,4 +1,5 @@
|
|||
#include "src/docker_manager.hpp"
|
||||
#include "src/operation_state.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
|
|
@ -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<std::string> 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<std::thread> 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(const std::string&, const std::string&)>);
|
||||
void PromptImageSelection(const std::string&, std::function<void(const std::string&, const std::string&)>);
|
||||
void RunDeferredStatusAction(const std::string&, std::function<std::string()>);
|
||||
void BeginBusyOperation(const std::string&, const std::string&, std::function<std::string()>);
|
||||
void BeginStopOperation(const std::string& id);
|
||||
void RefreshState(const std::string& message = "Refreshing Docker state...");
|
||||
void ApplyRefreshResults(DockerManager::ListResult<DockerManager::ContainerInfo> containers,
|
||||
DockerManager::ListResult<DockerManager::ImageInfo> images);
|
||||
|
|
@ -155,16 +160,49 @@ void TuxDockApp::PromptContainerSelection(const std::string& title, std::functio
|
|||
void TuxDockApp::PromptImageSelection(const std::string& title, std::function<void(const std::string&, const std::string&)> callback) { if (images_.empty()) { RefreshState("No cached images. Refreshing..."); return; } std::vector<std::string> 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<int>(images_.size())) return SetStatus("Please choose a valid image."); const auto& image = images_[static_cast<std::size_t>(selected)]; callback(image.first, image.second); }); }
|
||||
|
||||
void TuxDockApp::RunDeferredStatusAction(const std::string& wait, std::function<std::string()> action) {
|
||||
SetStatus(wait);
|
||||
BeginBusyOperation("Working", wait, std::move(action));
|
||||
}
|
||||
|
||||
void TuxDockApp::BeginBusyOperation(const std::string& title,
|
||||
const std::string& message,
|
||||
std::function<std::string()> 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<void()>& 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<RunContainerContext>(); context->image = tag; PromptPortCountAndRun(context); }); }
|
||||
void TuxDockApp::PromptPortCountAndRun(const std::shared_ptr<RunContainerContext>& 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<RunContainerContext>& 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)) {
|
||||
|
|
|
|||
|
|
@ -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<std::size_t>(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include "process_runner.hpp"
|
||||
#include "container_parser.hpp"
|
||||
#include "stop_waiter.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {});
|
||||
|
|
|
|||
15
src/operation_state.cpp
Normal file
15
src/operation_state.cpp
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#include "operation_state.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
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);
|
||||
}
|
||||
20
src/operation_state.hpp
Normal file
20
src/operation_state.hpp
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
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_;
|
||||
};
|
||||
14
src/stop_waiter.cpp
Normal file
14
src/stop_waiter.cpp
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#include "stop_waiter.hpp"
|
||||
|
||||
#include <thread>
|
||||
|
||||
StopProbe waitForStopped(const std::function<StopProbe()>& 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();
|
||||
}
|
||||
10
src/stop_waiter.hpp
Normal file
10
src/stop_waiter.hpp
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
|
||||
enum class StopProbe { Stopped, Running, Unknown };
|
||||
|
||||
StopProbe waitForStopped(const std::function<StopProbe()>& probe,
|
||||
std::chrono::milliseconds timeout = std::chrono::milliseconds(2000),
|
||||
std::chrono::milliseconds interval = std::chrono::milliseconds(100));
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
19
tests/test_operation_state.cpp
Normal file
19
tests/test_operation_state.cpp
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#include "operation_state.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
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";
|
||||
}
|
||||
22
tests/test_stop_sequence.cpp
Normal file
22
tests/test_stop_sequence.cpp
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#include "stop_waiter.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
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";
|
||||
}
|
||||
33
tests/test_stop_waiter.cpp
Normal file
33
tests/test_stop_waiter.cpp
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#include "stop_waiter.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
|
||||
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";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue