Add unit testing, stabilize the container and image listing

This commit is contained in:
mrkmntal 2026-08-14 13:37:22 -04:00
commit 91fa50370a
12 changed files with 394 additions and 66 deletions

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

@ -1,5 +1,7 @@
#include "docker_engine_client.hpp"
#include "http_response_parser.hpp"
#include <cerrno>
#include <cstring>
#include <sys/socket.h>
@ -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;
}

View file

@ -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;

View file

@ -1,6 +1,7 @@
#include "docker_manager.hpp"
#include "process_runner.hpp"
#include "container_parser.hpp"
#include <filesystem>
#include <fstream>
@ -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<std::string>& args,
return true;
}
std::vector<DockerManager::ContainerInfo> DockerManager::getContainerList() const {
std::vector<ContainerInfo> 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<std::string>{}).empty()
? ""
: item.value("Names", std::vector<std::string>{}).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::ImageInfo> DockerManager::getImageList() const {
std::vector<ImageInfo> images;
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()) 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<std::string>{});
if (id.empty()) continue;
if (tags.empty()) images.emplace_back(id, "<untagged>");
else for (const auto& tag : tags) images.emplace_back(id, tag);
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&) {
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 {

View file

@ -18,8 +18,18 @@ public:
using ImageInfo = std::pair<std::string, std::string>;
std::vector<ContainerInfo> getContainerList() const;
std::vector<ImageInfo> getImageList() const;
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,

View file

@ -0,0 +1,108 @@
#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;
}
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;
}

View file

@ -0,0 +1,13 @@
#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 && error.empty(); }
};
ParsedHttpResponse parseHttpResponse(const std::string& raw);