From 91fa50370a06e7e1bd54737d79f329ff9a83031d Mon Sep 17 00:00:00 2001 From: mrkmntal Date: Fri, 14 Aug 2026 13:37:22 -0400 Subject: [PATCH] Add unit testing, stabilize the container and image listing --- CMakeLists.txt | 22 ++++++ main.cpp | 53 ++++++++++++-- src/container_parser.cpp | 63 ++++++++++++++++ src/container_parser.hpp | 9 +++ src/docker_engine_client.cpp | 40 ++++++----- src/docker_engine_client.hpp | 2 + src/docker_manager.cpp | 70 +++++++----------- src/docker_manager.hpp | 14 +++- src/http_response_parser.cpp | 108 ++++++++++++++++++++++++++++ src/http_response_parser.hpp | 13 ++++ tests/test_container_parser.cpp | 27 +++++++ tests/test_http_response_parser.cpp | 43 +++++++++++ 12 files changed, 396 insertions(+), 68 deletions(-) create mode 100644 src/container_parser.cpp create mode 100644 src/container_parser.hpp create mode 100644 src/http_response_parser.cpp create mode 100644 src/http_response_parser.hpp create mode 100644 tests/test_container_parser.cpp create mode 100644 tests/test_http_response_parser.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 239d97e..bee3ac9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,8 @@ 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/docker_manager.cpp ) @@ -45,3 +47,23 @@ target_link_libraries(tux-dock ) 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) +endif() diff --git a/main.cpp b/main.cpp index 6c4af52..b2d2268 100644 --- a/main.cpp +++ b/main.cpp @@ -18,7 +18,7 @@ class TuxDockApp { public: - void Run(); + int Run(); private: enum class ModalMode { None, Input, Confirm, Select, Message }; @@ -69,6 +69,8 @@ private: void PromptImageSelection(const std::string&, std::function); void RunDeferredStatusAction(const std::string&, std::function); void RefreshState(const std::string& message = "Refreshing Docker state..."); + void ApplyRefreshResults(DockerManager::ListResult containers, + DockerManager::ListResult images); static void ClearTerminal(); void RunWithRestoredIO(const std::function&, bool clear_before = false, bool clear_after = false); bool OnEvent(ftxui::Event); ftxui::Element Render() const; ftxui::Element RenderModal() const; @@ -114,11 +116,31 @@ void TuxDockApp::RefreshState(const std::string& message) { auto containers = docker_.getContainerList(); auto images = docker_.getImageList(); if (active == nullptr || ftxui::ScreenInteractive::Active() != active) return; - active->Post([this, containers = std::move(containers), images = std::move(images)]() mutable { containers_ = std::move(containers); images_ = std::move(images); SetStatus("Docker state refreshed."); }); + active->Post([this, containers = std::move(containers), images = std::move(images)]() mutable { + ApplyRefreshResults(std::move(containers), std::move(images)); + }); active->PostEvent(ftxui::Event::Custom); }); } +void TuxDockApp::ApplyRefreshResults( + DockerManager::ListResult containers, + DockerManager::ListResult images) { + std::string error; + if (containers.ok()) { + containers_ = std::move(containers.items); + } else { + error = "Containers: " + containers.error; + } + if (images.ok()) { + images_ = std::move(images.items); + } else { + if (!error.empty()) error += "\n"; + error += "Images: " + images.error; + } + SetStatus(error.empty() ? "Docker state refreshed." : "Refresh failed; cached state preserved.\n" + error); +} + void TuxDockApp::OpenInput(const std::string& title, const std::string& text, std::function callback, bool secret) { modal_mode_ = ModalMode::Input; modal_title_ = title; modal_text_ = text; modal_input_.clear(); ftxui::InputOption option; option.password = secret; input_component_ = ftxui::Input(&modal_input_, "Type here", option); input_callback_ = std::move(callback); } void TuxDockApp::OpenConfirm(const std::string& title, const std::string& text, std::function callback) { modal_mode_ = ModalMode::Confirm; modal_title_ = title; modal_text_ = text; confirm_callback_ = std::move(callback); } void TuxDockApp::OpenSelect(const std::string& title, const std::string& text, std::vector options, std::function callback) { modal_mode_ = ModalMode::Select; modal_title_ = title; modal_text_ = text; modal_select_entries_ = std::move(options); modal_select_index_ = 0; select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); select_callback_ = std::move(callback); } @@ -221,8 +243,28 @@ ftxui::Element TuxDockApp::RenderModal() const { size(HEIGHT, LESS_THAN, 24) | size(HEIGHT, GREATER_THAN, 10) | center; } ftxui::Element TuxDockApp::Render() const { using namespace ftxui; Elements lines; std::stringstream s(status_); std::string line; while (std::getline(s, line)) lines.push_back(line.empty() ? text(" ") : text(line)); auto base = hbox(Elements{window(text("Actions"), vbox(Elements{menu_component_->Render() | frame | vscroll_indicator, separator(), text("Up/Down: navigate Enter: select") | dim})) | size(WIDTH, GREATER_THAN, 48) | flex, separator(), window(text("Status"), vbox(Elements{vbox(std::move(lines)) | yflex | frame | vscroll_indicator, separator(), text("Engine API cache") | dim})) | size(WIDTH, GREATER_THAN, 48) | flex}) | border; return modal_mode_ == ModalMode::None ? base : dbox({base, RenderModal() | clear_under | center}); } -void TuxDockApp::Run() { - RefreshState("Connecting to Docker..."); +int TuxDockApp::Run() { + std::string connection_error; + if (!docker_.checkConnection(connection_error)) { + std::cerr << "Unable to connect to Docker Engine.\n" + << "Socket: /var/run/docker.sock\n" + << "Reason: " << connection_error << "\n\n" + << "Ensure Docker is running and your user can access the Docker socket.\n"; + return 1; + } + + const auto initial_containers = docker_.getContainerList(); + const auto initial_images = docker_.getImageList(); + if (initial_containers.ok()) containers_ = initial_containers.items; + if (initial_images.ok()) images_ = initial_images.items; + if (!initial_containers.ok() || !initial_images.ok()) { + SetStatus("Docker connected, but initial state could not be fully loaded."); + } else if (containers_.empty() && images_.empty()) { + SetStatus("Docker connected. No containers or images found."); + } else { + SetStatus("Docker connected. State loaded."); + } + auto root = ftxui::Renderer(menu_component_, [this] { return Render(); }); auto app = ftxui::CatchEvent(root, [this](ftxui::Event e) { return OnEvent(e); }); auto screen = ftxui::ScreenInteractive::TerminalOutput(); @@ -231,5 +273,6 @@ void TuxDockApp::Run() { screen_ = nullptr; if (refresh_thread_.joinable()) refresh_thread_.join(); for (auto& thread : action_threads_) if (thread.joinable()) thread.join(); + return 0; } -int main() { TuxDockApp app; app.Run(); return 0; } +int main() { TuxDockApp app; return app.Run(); } diff --git a/src/container_parser.cpp b/src/container_parser.cpp new file mode 100644 index 0000000..8fc5b04 --- /dev/null +++ b/src/container_parser.cpp @@ -0,0 +1,63 @@ +#include "container_parser.hpp" + +#include + +#include + +using json = nlohmann::json; + +namespace { + +std::string optionalString(const json& object, const char* key) { + const auto it = object.find(key); + return it != object.end() && it->is_string() ? it->get() : std::string{}; +} + +std::string portsFor(const json& item) { + const auto it = item.find("Ports"); + if (it == item.end() || !it->is_array()) return {}; + std::ostringstream result; + bool first = true; + for (const auto& port : *it) { + if (!port.is_object()) continue; + const auto public_it = port.find("PublicPort"); + const auto private_it = port.find("PrivatePort"); + if (public_it == port.end() || private_it == port.end() || + !public_it->is_number() || !private_it->is_number()) continue; + if (!first) result << ", "; + result << *public_it << ":" << *private_it; + first = false; + } + return result.str(); +} + +} // namespace + +DockerManager::ListResult +parseContainerList(const std::string& body) { + DockerManager::ListResult result; + try { + const auto parsed = json::parse(body); + if (!parsed.is_array()) { + result.error = "Container response is not an array."; + return result; + } + for (const auto& item : parsed) { + if (!item.is_object()) continue; + DockerManager::ContainerInfo info; + info.id = optionalString(item, "Id"); + const auto names = item.find("Names"); + if (names != item.end() && names->is_array() && !names->empty() && names->front().is_string()) { + info.name = names->front().get(); + } + if (!info.name.empty() && info.name.front() == '/') info.name.erase(0, 1); + info.status = optionalString(item, "Status"); + info.running = optionalString(item, "State") == "running"; + info.ports = portsFor(item); + if (!info.id.empty() && !info.name.empty()) result.items.push_back(std::move(info)); + } + } catch (const json::exception& error) { + result.error = std::string("Could not parse container data: ") + error.what(); + } + return result; +} diff --git a/src/container_parser.hpp b/src/container_parser.hpp new file mode 100644 index 0000000..4dd325e --- /dev/null +++ b/src/container_parser.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include "docker_manager.hpp" + +#include +#include + +DockerManager::ListResult +parseContainerList(const std::string& body); diff --git a/src/docker_engine_client.cpp b/src/docker_engine_client.cpp index d1c949d..bf8826a 100644 --- a/src/docker_engine_client.cpp +++ b/src/docker_engine_client.cpp @@ -1,5 +1,7 @@ #include "docker_engine_client.hpp" +#include "http_response_parser.hpp" + #include #include #include @@ -12,6 +14,23 @@ 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 { @@ -72,22 +91,9 @@ EngineResponse DockerEngineClient::request(const std::string& method, return response; } - const std::size_t header_end = raw.find("\r\n\r\n"); - if (header_end == std::string::npos) { - response.error = "Invalid response from Docker Engine."; - return response; - } - const std::size_t status_end = raw.find("\r\n"); - std::istringstream status_line(raw.substr(0, status_end)); - std::string http_version; - status_line >> http_version >> response.status_code; - if (response.status_code == 0) { - response.error = "Invalid HTTP status from Docker Engine."; - return response; - } - response.body = raw.substr(header_end + 4); - if (!response.ok()) { - response.error = response.body.empty() ? "Docker Engine request failed." : response.body; - } + const auto parsed = parseHttpResponse(raw); + response.status_code = parsed.status_code; + response.body = parsed.body; + response.error = parsed.error; return response; } diff --git a/src/docker_engine_client.hpp b/src/docker_engine_client.hpp index e9781b3..234d456 100644 --- a/src/docker_engine_client.hpp +++ b/src/docker_engine_client.hpp @@ -14,6 +14,8 @@ 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; diff --git a/src/docker_manager.cpp b/src/docker_manager.cpp index e2d0469..eb783fa 100644 --- a/src/docker_manager.cpp +++ b/src/docker_manager.cpp @@ -1,6 +1,7 @@ #include "docker_manager.hpp" #include "process_runner.hpp" +#include "container_parser.hpp" #include #include @@ -12,20 +13,6 @@ using json = nlohmann::json; namespace { -std::string joinPorts(const json& ports) { - std::ostringstream result; - bool first = true; - for (const auto& port : ports) { - const auto public_port = port.value("PublicPort", 0); - const auto private_port = port.value("PrivatePort", 0); - if (public_port == 0 || private_port == 0) continue; - if (!first) result << ", "; - result << public_port << ":" << private_port; - first = false; - } - return result.str(); -} - std::string apiError(const EngineResponse& response, const std::string& fallback) { if (!response.error.empty()) return response.error; return fallback; @@ -53,47 +40,42 @@ bool DockerManager::runProcess(const std::vector& args, return true; } -std::vector DockerManager::getContainerList() const { - std::vector containers; - const EngineResponse response = engine_.request("GET", "/containers/json?all=true"); - if (!response.ok()) return containers; - - try { - for (const auto& item : json::parse(response.body)) { - ContainerInfo info; - info.id = item.value("Id", ""); - info.name = item.value("Names", std::vector{}).empty() - ? "" - : item.value("Names", std::vector{}).front(); - if (!info.name.empty() && info.name.front() == '/') info.name.erase(0, 1); - info.status = item.value("Status", ""); - info.running = item.value("State", "") == "running"; - info.ports = joinPorts(item.value("Ports", json::array())); - if (!info.id.empty() && !info.name.empty()) containers.push_back(std::move(info)); - } - } catch (const json::exception&) { - return {}; - } - return containers; +bool DockerManager::checkConnection(std::string& error) const { + return engine_.checkConnection(error); } -std::vector DockerManager::getImageList() const { - std::vector images; +DockerManager::ListResult DockerManager::getContainerList() const { + ListResult result; + const EngineResponse response = engine_.request("GET", "/containers/json?all=true"); + if (!response.ok()) { + result.error = apiError(response, "Could not list containers."); + return result; + } + + return parseContainerList(response.body); +} + +DockerManager::ListResult DockerManager::getImageList() const { + ListResult result; const EngineResponse response = engine_.request("GET", "/images/json"); - if (!response.ok()) return images; + if (!response.ok()) { + result.error = apiError(response, "Could not list images."); + return result; + } try { for (const auto& item : json::parse(response.body)) { const std::string id = item.value("Id", ""); const auto tags = item.value("RepoTags", std::vector{}); if (id.empty()) continue; - if (tags.empty()) images.emplace_back(id, ""); - else for (const auto& tag : tags) images.emplace_back(id, tag); + if (tags.empty()) result.items.emplace_back(id, ""); + else for (const auto& tag : tags) result.items.emplace_back(id, tag); } - } catch (const json::exception&) { - return {}; + } catch (const json::exception& error) { + result.items.clear(); + result.error = std::string("Could not parse image data: ") + error.what(); } - return images; + return result; } bool DockerManager::pullImage(const std::string& image, std::string& message) const { diff --git a/src/docker_manager.hpp b/src/docker_manager.hpp index 9fcd137..b6041b0 100644 --- a/src/docker_manager.hpp +++ b/src/docker_manager.hpp @@ -18,8 +18,18 @@ public: using ImageInfo = std::pair; - std::vector getContainerList() const; - std::vector getImageList() const; + template + struct ListResult { + std::vector items; + std::string error; + + bool ok() const { return error.empty(); } + }; + + bool checkConnection(std::string& error) const; + + ListResult getContainerList() const; + ListResult getImageList() const; bool pullImage(const std::string& image, std::string& message) const; bool runContainerInteractive(const std::string& image, diff --git a/src/http_response_parser.cpp b/src/http_response_parser.cpp new file mode 100644 index 0000000..ce93bab --- /dev/null +++ b/src/http_response_parser.cpp @@ -0,0 +1,108 @@ +#include "http_response_parser.hpp" + +#include +#include +#include + +namespace { + +std::string lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::string trim(std::string value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); +} + +bool decodeChunked(const std::string& input, std::string& body, std::string& error) { + std::size_t offset = 0; + while (offset < input.size()) { + const auto line_end = input.find("\r\n", offset); + if (line_end == std::string::npos) { + error = "Truncated chunk size."; + return false; + } + const std::string size_text = trim(input.substr(offset, line_end - offset)); + const auto semicolon = size_text.find(';'); + const std::string size_value = size_text.substr(0, semicolon); + std::size_t chunk_size = 0; + try { + chunk_size = std::stoull(size_value, nullptr, 16); + } catch (...) { + error = "Invalid chunk size."; + return false; + } + offset = line_end + 2; + if (chunk_size == 0) return true; + if (offset + chunk_size + 2 > input.size() || input.substr(offset + chunk_size, 2) != "\r\n") { + error = "Truncated chunk data."; + return false; + } + body.append(input, offset, chunk_size); + offset += chunk_size + 2; + } + error = "Missing terminating chunk."; + return false; +} + +} // namespace + +ParsedHttpResponse parseHttpResponse(const std::string& raw) { + ParsedHttpResponse response; + const auto header_end = raw.find("\r\n\r\n"); + if (header_end == std::string::npos) { + response.error = "Invalid response from Docker Engine: missing headers."; + return response; + } + + const auto status_end = raw.find("\r\n"); + std::istringstream status_line(raw.substr(0, status_end)); + std::string version; + status_line >> version >> response.status_code; + if (response.status_code == 0) { + response.error = "Invalid HTTP status from Docker Engine."; + return response; + } + + 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 (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) { + response.error = response.body.empty() ? "Docker Engine request failed." : response.body; + } + return response; +} diff --git a/src/http_response_parser.hpp b/src/http_response_parser.hpp new file mode 100644 index 0000000..1d7af0b --- /dev/null +++ b/src/http_response_parser.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +struct ParsedHttpResponse { + int status_code = 0; + std::string body; + std::string error; + + bool ok() const { return status_code >= 200 && status_code < 300 && error.empty(); } +}; + +ParsedHttpResponse parseHttpResponse(const std::string& raw); diff --git a/tests/test_container_parser.cpp b/tests/test_container_parser.cpp new file mode 100644 index 0000000..79d0ff9 --- /dev/null +++ b/tests/test_container_parser.cpp @@ -0,0 +1,27 @@ +#include "container_parser.hpp" + +#include +#include + +int main() { + const auto result = parseContainerList(R"([ + {"Id":"running-id","Names":["/web"],"State":"running","Status":"Up 2 minutes","Ports":[]}, + {"Id":"exited-id","Names":["/old"],"State":"exited","Status":"Exited (0) 1 minute ago","Ports":[{"PrivatePort":80,"Type":"tcp"}]}, + {"Id":"odd-id","Names":["/odd"],"State":"exited","Ports":"unexpected"} + ])"); + assert(result.ok()); + assert(result.items.size() == 3); + assert(result.items[0].running); + assert(!result.items[1].running); + assert(result.items[1].name == "old"); + assert(result.items[1].ports.empty()); + assert(result.items[2].name == "odd"); + + const auto empty = parseContainerList("[]"); + assert(empty.ok()); + assert(empty.items.empty()); + + const auto invalid = parseContainerList("not-json"); + assert(!invalid.ok()); + std::cout << "Container parser tests passed\n"; +} diff --git a/tests/test_http_response_parser.cpp b/tests/test_http_response_parser.cpp new file mode 100644 index 0000000..33a5ee2 --- /dev/null +++ b/tests/test_http_response_parser.cpp @@ -0,0 +1,43 @@ +#include "http_response_parser.hpp" + +#include +#include + +void testContentLength() { + const auto response = parseHttpResponse( + "HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\n[1,2]\n"); + if (!response.ok()) std::cerr << response.error << " status=" << response.status_code << " body=" << response.body << "\n"; + assert(response.ok()); + assert(response.body == "[1,2]\n"); +} + +void testChunked() { + const auto response = parseHttpResponse( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"); + assert(response.ok()); + assert(response.body == "hello world"); +} + +void testTruncatedBody() { + const auto response = parseHttpResponse( + "HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort"); + assert(!response.ok()); + assert(response.error == "Truncated HTTP response body."); +} + +void testErrorResponse() { + const auto response = parseHttpResponse( + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 3\r\n\r\nbad"); + assert(!response.ok()); + assert(response.status_code == 500); + assert(response.error == "bad"); +} + +int main() { + testContentLength(); + testChunked(); + testTruncatedBody(); + testErrorResponse(); + std::cout << "HTTP response parser tests passed\n"; +}