Compare commits

...

5 commits

25 changed files with 1629 additions and 1245 deletions

View file

@ -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()

42
DEVLOG.md Normal file
View 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.

View file

@ -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<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(...);
@ -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
---

View file

@ -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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/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 "<TR><TD>%s</TD><TD>%s</TD><TD>%s</TD><TD>%s sec</TD></TR>\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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g' "$tui_output" >"$escaped_tui"
{
printf '%s\n' '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">'
printf '%s\n' '<HTML><HEAD><TITLE>Tux-Dock Test Report</TITLE></HEAD><BODY>'
printf '%s\n' '<H1>Tux-Dock Test Report</H1>'
printf '<P>Generated: %s</P>\n' "$(date)"
printf '%s\n' '<H2>Summary</H2><TABLE BORDER="1"><TR><TH>Metric</TH><TH>Value</TH><TH>Graph</TH></TR>'
printf '<TR><TD>Total tests</TD><TD>%s</TD><TD><TABLE BORDER="0"><TR><TD BGCOLOR="blue" WIDTH="160">&nbsp;</TD></TR></TABLE></TD></TR>\n' "$total"
printf '<TR><TD>Passed</TD><TD>%s</TD><TD><TABLE BORDER="0"><TR><TD BGCOLOR="green" WIDTH="160">&nbsp;</TD></TR></TABLE></TD></TR>\n' "$passed"
printf '<TR><TD>Failed</TD><TD>%s</TD><TD><TABLE BORDER="0"><TR><TD BGCOLOR="red" WIDTH="%s">&nbsp;</TD></TR></TABLE></TD></TR>\n' "$failed" "$([ "$failed" -gt 0 ] 2>/dev/null && printf 160 || printf 1)"
printf '<TR><TD>Duration</TD><TD>%s seconds</TD><TD>&nbsp;</TD></TR></TABLE>\n' "$duration"
printf '%s\n' '<H2>Individual Tests</H2><TABLE BORDER="1"><TR><TH>#</TH><TH>Test</TH><TH>Status</TH><TH>Duration</TH></TR>'
cat "$test_rows"
printf '%s\n' '</TABLE>'
printf '%s\n' '<H2>Exact Test Run</H2><PRE>'
printf '%s\n' "$command_line"
printf '%s\n' '</PRE><H2>Test Output</H2><PRE>'
cat "$escaped_output"
printf '%s\n' '</PRE><H2>Docker Integration Raw Output</H2><PRE>'
cat "$escaped_raw"
printf '%s\n' '</PRE><H2>TUI Transcript</H2><PRE>'
cat "$escaped_tui"
printf '%s\n' '</PRE></BODY></HTML>'
} >"$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!"

1465
main.cpp

File diff suppressed because it is too large Load diff

63
src/container_parser.cpp Normal file
View file

@ -0,0 +1,63 @@
#include "container_parser.hpp"
#include <nlohmann/json.hpp>
#include <sstream>
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{};
}
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<DockerManager::ContainerInfo>
parseContainerList(const std::string& body) {
DockerManager::ListResult<DockerManager::ContainerInfo> 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<std::string>();
}
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;
}

9
src/container_parser.hpp Normal file
View file

@ -0,0 +1,9 @@
#pragma once
#include "docker_manager.hpp"
#include <string>
#include <vector>
DockerManager::ListResult<DockerManager::ContainerInfo>
parseContainerList(const std::string& body);

View file

@ -0,0 +1,107 @@
#include "docker_engine_client.hpp"
#include "http_response_parser.hpp"
#include <cerrno>
#include <cstring>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <sstream>
#include <utility>
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<sockaddr*>(&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<std::size_t>(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<std::size_t>(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;
}

View file

@ -0,0 +1,25 @@
#pragma once
#include <string>
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_;
};

206
src/docker_manager.cpp Normal file
View file

@ -0,0 +1,206 @@
#include "docker_manager.hpp"
#include "process_runner.hpp"
#include "container_parser.hpp"
#include "stop_waiter.hpp"
#include <filesystem>
#include <fstream>
#include <sstream>
#include <nlohmann/json.hpp>
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<std::string>& 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::ContainerInfo> DockerManager::getContainerList() const {
ListResult<ContainerInfo> 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::ImageInfo> DockerManager::getImageList() const {
ListResult<ImageInfo> 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<std::string>{});
if (id.empty()) continue;
if (tags.empty()) result.items.emplace_back(id, "<untagged>");
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<std::string>& ports,
std::string& message) const {
if (image.empty()) {
message = "Please choose an image first.";
return false;
}
std::vector<std::string> 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;
}

55
src/docker_manager.hpp Normal file
View file

@ -0,0 +1,55 @@
#pragma once
#include "docker_engine_client.hpp"
#include <string>
#include <utility>
#include <vector>
class DockerManager {
public:
struct ContainerInfo {
std::string id;
std::string name;
std::string status;
std::string ports;
bool running = false;
};
using ImageInfo = std::pair<std::string, std::string>;
template <typename T>
struct ListResult {
std::vector<T> items;
std::string error;
bool ok() const { return error.empty(); }
};
bool checkConnection(std::string& error) const;
ListResult<ContainerInfo> getContainerList() const;
ListResult<ImageInfo> getImageList() const;
bool pullImage(const std::string& image, std::string& message) const;
bool runContainerInteractive(const std::string& image,
const std::vector<std::string>& 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<std::string>& args,
std::string& message,
bool inherit_stdio = false);
};

View file

@ -0,0 +1,142 @@
#include "http_response_parser.hpp"
#include <algorithm>
#include <cctype>
#include <sstream>
namespace {
std::string lower(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
return static_cast<char>(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;
}
}

View file

@ -0,0 +1,14 @@
#pragma once
#include <string>
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 = {});

15
src/operation_state.cpp Normal file
View 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
View 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_;
};

122
src/process_runner.cpp Normal file
View file

@ -0,0 +1,122 @@
#include "process_runner.hpp"
#include <cerrno>
#include <cstring>
#include <sys/wait.h>
#include <unistd.h>
#include <array>
#include <poll.h>
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<std::string>& 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<char*> argv;
argv.reserve(args.size() + 1);
for (const auto& arg : args) argv.push_back(const_cast<char*>(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<char, 4096> 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<nfds_t>(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<std::size_t>(count));
else result.stderr_text.append(buffer.data(), static_cast<std::size_t>(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;
}

26
src/process_runner.hpp Normal file
View file

@ -0,0 +1,26 @@
#pragma once
#include <string>
#include <vector>
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<std::string>& args,
const ProcessOptions& options = {});
};

14
src/stop_waiter.cpp Normal file
View 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
View 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));

View file

@ -0,0 +1,27 @@
#include "container_parser.hpp"
#include <cassert>
#include <iostream>
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";
}

View file

@ -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 '<tests>'"
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"

View file

@ -0,0 +1,65 @@
#include "http_response_parser.hpp"
#include <cassert>
#include <iostream>
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";
}

View 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";
}

View 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";
}

View 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";
}