Compare commits
5 commits
6635446438
...
47c6d988cc
| Author | SHA1 | Date | |
|---|---|---|---|
| 47c6d988cc | |||
| 0282d333c2 | |||
| e8e93d7b21 | |||
| 91fa50370a | |||
| 802550f6e7 |
25 changed files with 1629 additions and 1245 deletions
|
|
@ -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
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.
|
||||
79
README.md
79
README.md
|
|
@ -2,7 +2,7 @@
|
|||
### A lightweight C++ Docker TUI
|
||||
|
||||
Tux-Dock is a modern **C++17** Docker terminal frontend built with **FTXUI**.
|
||||
It gives you a guided, keyboard-first TUI for common Docker operations like pulling images, running containers, inspecting IPs, and managing images/containers without memorizing long CLI flags.
|
||||
It gives you a guided, keyboard-first TUI for common Docker operations without memorizing long CLI flags.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -10,12 +10,16 @@ It gives you a guided, keyboard-first TUI for common Docker operations like pull
|
|||
|
||||
- Interactive Docker workflows through a single-screen TUI with modal steps.
|
||||
- Picker-based selection (arrow keys + Enter) for containers/images instead of numeric menus.
|
||||
- High-level status panel with responsive wait states for slower operations.
|
||||
- Busy-operation modals with a spinner and input blocking while Docker work completes.
|
||||
- Rich container display with state and forwarded ports.
|
||||
- Interactive shell handoff with clean terminal clear before/after shell transitions.
|
||||
- Image operations: pull/list/delete with curated quick picks and custom image support.
|
||||
- Script-to-image workflow: generate Dockerfile from bash script, then optionally build.
|
||||
- MySQL quick start flow with version/password/port prompts.
|
||||
- Docker Engine API access through `/var/run/docker.sock` for structured list and lifecycle operations.
|
||||
- Direct `fork`/`exec` process execution for CLI-backed streaming and interactive commands.
|
||||
- Persistent container listings that retain exited containers.
|
||||
- Robust stop handling with state polling, timeout retry, and idempotent stop responses.
|
||||
- About screen in-app with project/version/repository info.
|
||||
|
||||
---
|
||||
|
|
@ -35,16 +39,23 @@ It gives you a guided, keyboard-first TUI for common Docker operations like pull
|
|||
git clone https://mentalnet.xyz/forgejo/markmental/tuxdock.git
|
||||
cd tuxdock
|
||||
|
||||
# Configure & build (FTXUI is fetched automatically)
|
||||
cmake -S . -B build
|
||||
cmake --build build -j
|
||||
# Configure, build, and test (FTXUI and nlohmann/json are fetched automatically)
|
||||
./compile.sh
|
||||
|
||||
# Run it (requires Docker permissions)
|
||||
sudo ./build/tux-dock
|
||||
|
||||
# Build without running tests
|
||||
./compile.sh --no-test
|
||||
|
||||
# Run tests and serve an HTML 3.2 report on port 8095
|
||||
./compile.sh --web-test-view
|
||||
|
||||
# Override the report server port
|
||||
./compile.sh --web-test-view 9000
|
||||
```
|
||||
|
||||
Prefer a prebuilt binary? CI artifacts are published at:
|
||||
https://mentalnet.xyz/forgejo/markmental/tuxdock/actions
|
||||
The web report is generated under `/tmp` and includes exact CTest output, test summaries, Docker integration output, and a text rendition of the TUI flow. It requires `nc` or `netcat`; press `Ctrl-C` to stop the server. The default port is `8095`; pass a port after `--web-test-view` to override it. `--web-test-view --no-test` is invalid. Normal test runs include a Docker integration test using `debian:forky`, so Docker must be available.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -63,17 +74,14 @@ Current TUI actions:
|
|||
9. Remove Container
|
||||
10. Attach Shell to Running Container
|
||||
11. Run Detached Command in Container
|
||||
12. Spin Up MySQL Container
|
||||
13. Get Container IP Address
|
||||
14. Create Dockerfile & Build Image from Bash Script
|
||||
15. About Tux-Dock
|
||||
16. Exit
|
||||
12. About Tux-Dock
|
||||
13. Exit
|
||||
|
||||
---
|
||||
|
||||
## Design Overview
|
||||
|
||||
Tux-Dock is now structured around **two main classes**:
|
||||
Tux-Dock is organized around the TUI, Docker manager, Engine API client, and direct process runner:
|
||||
|
||||
```cpp
|
||||
class DockerManager {
|
||||
|
|
@ -86,9 +94,9 @@ public:
|
|||
bool running;
|
||||
};
|
||||
|
||||
// Docker command/data layer
|
||||
std::vector<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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
148
compile.sh
148
compile.sh
|
|
@ -1,3 +1,149 @@
|
|||
#!/bin/sh
|
||||
|
||||
cmake -S . -B build && cmake --build build -j && echo "tux-dock successfully compiled!"
|
||||
set -eu
|
||||
|
||||
run_tests=1
|
||||
web_view=0
|
||||
web_port=8095
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
argument=$1
|
||||
shift
|
||||
case "$argument" in
|
||||
--no-test) run_tests=0 ;;
|
||||
--web-test-view)
|
||||
web_view=1
|
||||
if [ "$#" -gt 0 ] && printf '%s' "$1" | grep -Eq '^[0-9]+$'; then
|
||||
web_port=$1
|
||||
shift
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "Usage: $0 [--no-test] [--web-test-view [port]]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$web_port" -lt 1 ] || [ "$web_port" -gt 65535 ]; then
|
||||
printf '%s\n' "Web test view port must be between 1 and 65535" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$web_view" -eq 1 ] && [ "$run_tests" -eq 0 ]; then
|
||||
printf '%s\n' "--web-test-view cannot be combined with --no-test" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$run_tests" -eq 1 ]; then
|
||||
cmake -S . -B build -DBUILD_TESTING=ON
|
||||
else
|
||||
cmake -S . -B build -DBUILD_TESTING=OFF
|
||||
fi
|
||||
|
||||
cmake --build build -j
|
||||
|
||||
if [ "$run_tests" -eq 1 ]; then
|
||||
report_dir=$(mktemp -d /tmp/tux-dock-test.XXXXXX)
|
||||
test_output="$report_dir/ctest-output.txt"
|
||||
test_status=0
|
||||
ctest --test-dir build --output-on-failure >"$test_output" 2>&1 || test_status=$?
|
||||
|
||||
if [ "$web_view" -eq 1 ]; then
|
||||
report="$report_dir/report.html"
|
||||
escaped_output=$(mktemp "$report_dir/output.XXXXXX")
|
||||
sed 's/&/\&/g; s/</\</g; s/>/\>/g' "$test_output" >"$escaped_output"
|
||||
total=$(awk '/tests passed, [0-9]+ tests failed out of [0-9]+/ { print $(NF); exit }' "$test_output" 2>/dev/null || true)
|
||||
failed=$(awk '/tests passed, [0-9]+ tests failed out of [0-9]+/ { print $4; exit }' "$test_output" 2>/dev/null || true)
|
||||
total=${total:-unknown}
|
||||
passed=${passed:-0}
|
||||
failed=${failed:-0}
|
||||
if [ "$total" != unknown ]; then
|
||||
passed=$((total - failed))
|
||||
fi
|
||||
duration=$(awk '/Total Test time/ { print $(NF - 1); exit }' "$test_output" 2>/dev/null || true)
|
||||
duration=${duration:-unknown}
|
||||
command_line='ctest --test-dir build --output-on-failure'
|
||||
test_rows=$(mktemp "$report_dir/test-rows.XXXXXX")
|
||||
awk '
|
||||
match($0, /^[[:space:]]*([0-9]+)\/([0-9]+) Test #[0-9]+: ([^ ]+)[[:space:]]+\.+[[:space:]]+(Passed|Failed|Skipped)[[:space:]]+([0-9.]+) sec/, m) {
|
||||
printf "<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/&/\&/g; s/</\</g; s/>/\>/g' "$integration_output" >"$escaped_integration"
|
||||
raw_output=$(mktemp "$report_dir/raw-output.XXXXXX")
|
||||
awk '/\[TEST OUTPUT BEGIN\]/{capture=1} capture{print} /\[TEST RESULT\]/{capture=0}' "$integration_output" >"$raw_output"
|
||||
escaped_raw=$(mktemp "$report_dir/raw-escaped.XXXXXX")
|
||||
sed 's/&/\&/g; s/</\</g; s/>/\>/g' "$raw_output" >"$escaped_raw"
|
||||
tui_output=$(mktemp "$report_dir/tui-output.XXXXXX")
|
||||
awk '!/\[TEST OUTPUT BEGIN\]/ && !/\[TEST OUTPUT END\]/ && !/\[TEST RESULT\]/ { print }' "$integration_output" >"$tui_output"
|
||||
escaped_tui=$(mktemp "$report_dir/tui-escaped.XXXXXX")
|
||||
sed 's/&/\&/g; s/</\</g; s/>/\>/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"> </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"> </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"> </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> </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!"
|
||||
|
|
|
|||
63
src/container_parser.cpp
Normal file
63
src/container_parser.cpp
Normal 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
9
src/container_parser.hpp
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#pragma once
|
||||
|
||||
#include "docker_manager.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
DockerManager::ListResult<DockerManager::ContainerInfo>
|
||||
parseContainerList(const std::string& body);
|
||||
107
src/docker_engine_client.cpp
Normal file
107
src/docker_engine_client.cpp
Normal 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;
|
||||
}
|
||||
25
src/docker_engine_client.hpp
Normal file
25
src/docker_engine_client.hpp
Normal 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
206
src/docker_manager.cpp
Normal 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
55
src/docker_manager.hpp
Normal 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);
|
||||
};
|
||||
142
src/http_response_parser.cpp
Normal file
142
src/http_response_parser.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
14
src/http_response_parser.hpp
Normal file
14
src/http_response_parser.hpp
Normal 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
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_;
|
||||
};
|
||||
122
src/process_runner.cpp
Normal file
122
src/process_runner.cpp
Normal 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
26
src/process_runner.hpp
Normal 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
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));
|
||||
27
tests/test_container_parser.cpp
Normal file
27
tests/test_container_parser.cpp
Normal 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";
|
||||
}
|
||||
76
tests/test_docker_integration.sh
Executable file
76
tests/test_docker_integration.sh
Executable 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"
|
||||
65
tests/test_http_response_parser.cpp
Normal file
65
tests/test_http_response_parser.cpp
Normal 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";
|
||||
}
|
||||
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