diff --git a/src/socketd/Makefile b/src/socketd/Makefile index b3e1b8f7..0f074fc4 100644 --- a/src/socketd/Makefile +++ b/src/socketd/Makefile @@ -42,6 +42,9 @@ endif SRCS := \ main.cpp \ backend_client.cpp \ + container_list.cpp \ + snapshot_lists.cpp \ + event_log.cpp \ api_server.cpp OBJS := $(SRCS:%.cpp=$(OBJ_DIR)/%.o) diff --git a/src/socketd/api_server.cpp b/src/socketd/api_server.cpp index 3cbb00ed..95f6aae5 100644 --- a/src/socketd/api_server.cpp +++ b/src/socketd/api_server.cpp @@ -1,4 +1,8 @@ #include "api_server.h" +#include "container_list.h" +#include "snapshot_lists.h" +#include "event_log.h" +#include "../droidspace.h" #include #include @@ -9,15 +13,193 @@ #include #include #include +#include +#include +#include #include #include +#include #include namespace droidspaces::socketd { namespace { constexpr std::size_t kMaxRequestHeaderBytes = 16 * 1024; +constexpr const char* kSocketApiVersion = "1.40"; +constexpr const char* kSocketMinApiVersion = "1.40"; +constexpr const char* kSocketOsType = "linux"; + +std::string socketd_arch_name() { +#if defined(__x86_64__) + return "amd64"; +#elif defined(__i386__) + return "386"; +#elif defined(__aarch64__) + return "arm64"; +#elif defined(__arm__) + return "arm"; +#elif defined(__riscv) && (__riscv_xlen == 64) + return "riscv64"; +#else + return "unknown"; +#endif +} + +std::string socketd_kernel_version() { + struct utsname uts {}; + if (::uname(&uts) != 0) { + return {}; + } + + return uts.release; +} + +std::uint64_t socketd_mem_total_bytes() { + std::ifstream meminfo("/proc/meminfo"); + if (!meminfo.is_open()) { + return 0; + } + + std::string key; + std::uint64_t value_kib = 0; + std::string unit; + + while (meminfo >> key >> value_kib >> unit) { + if (key == "MemTotal:") { + /* + * /proc/meminfo reports MemTotal in KiB. + */ + return value_kib * 1024ULL; + } + } + + return 0; +} + +unsigned int socketd_ncpu() { + const long value = ::sysconf(_SC_NPROCESSORS_ONLN); + if (value <= 0) { + return 0; + } + + if (static_cast(value) > + std::numeric_limits::max()) { + return std::numeric_limits::max(); + } + + return static_cast(value); +} + +std::string socketd_hostname() { + char hostname[256] {}; + if (::gethostname(hostname, sizeof(hostname) - 1) != 0) { + return "droidspaces"; + } + + hostname[sizeof(hostname) - 1] = '\0'; + + if (hostname[0] == '\0') { + return "droidspaces"; + } + + return hostname; +} + +std::string socketd_system_time_utc() { + std::time_t now = std::time(nullptr); + if (now == static_cast(-1)) { + return {}; + } + + std::tm tm {}; +#if defined(_POSIX_THREAD_SAFE_FUNCTIONS) || defined(__ANDROID__) || defined(__linux__) + if (::gmtime_r(&now, &tm) == nullptr) { + return {}; + } +#else + const std::tm* tmp = std::gmtime(&now); + if (tmp == nullptr) { + return {}; + } + tm = *tmp; +#endif + + char buffer[64] {}; + if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm) == 0) { + return {}; + } + + return buffer; +} + +std::string json_escape(const std::string& input) { + std::string out; + out.reserve(input.size()); + + constexpr char kHex[] = "0123456789abcdef"; + + for (unsigned char ch : input) { + switch (ch) { + case '"': + out += "\\\""; + break; + case '\\': + out += "\\\\"; + break; + case '\b': + out += "\\b"; + break; + case '\f': + out += "\\f"; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + if (ch < 0x20) { + out += "\\u00"; + out += kHex[(ch >> 4) & 0x0f]; + out += kHex[ch & 0x0f]; + } else { + out += static_cast(ch); + } + break; + } + } + + return out; +} + +void debug_log_request_headers(const std::string& request) { + const std::size_t header_end = request.find("\r\n\r\n"); + + const std::size_t visible_len = + header_end == std::string::npos + ? request.size() + : header_end + 4; + + std::cerr << "socketd: received HTTP request headers\n"; + std::cerr << "----- BEGIN REQUEST -----\n"; + std::cerr.write(request.data(), static_cast(visible_len)); + + /* + * HTTP headers already end with CRLF CRLF, but ensure the terminal output + * does not visually run into the separator if a malformed request arrives. + */ + if (visible_len == 0 || + request[visible_len - 1] != '\n') { + std::cerr << '\n'; + } + + std::cerr << "----- END REQUEST -----\n"; +} bool send_all(int fd, const void* data, std::size_t len, std::string& error) { const auto* p = static_cast(data); @@ -72,6 +254,14 @@ bool send_http_response(int fd, header += "Server: Droidspaces/6 (Container, like Docker)\r\n"; + header += "Api-Version: "; + header += kSocketApiVersion; + header += "\r\n"; + + header += "Ostype: "; + header += kSocketOsType; + header += "\r\n"; + header += "Connection: close\r\n"; header += "\r\n"; @@ -136,27 +326,30 @@ bool is_ascii_digit(char c) { return c >= '0' && c <= '9'; } -bool is_versioned_ping_path(const std::string& path) { - constexpr const char* kSuffix = "/_ping"; - constexpr std::size_t kSuffixLen = 6; +bool is_versioned_api_path(const std::string& path, + const char* endpoint_path) { + const std::size_t endpoint_len = std::strlen(endpoint_path); - if (path.size() <= kSuffixLen) { + if (path.size() <= endpoint_len) { return false; } - if (path.compare(path.size() - kSuffixLen, kSuffixLen, kSuffix) != 0) { + if (path.compare(path.size() - endpoint_len, + endpoint_len, + endpoint_path) != 0) { return false; } - const std::string prefix = path.substr(0, path.size() - kSuffixLen); + const std::string prefix = + path.substr(0, path.size() - endpoint_len); /* - * Accept forms like: + * Accept: * * /v1.40/_ping - * /v1.51/_ping + * /v1.40/version * - * Prefix must be exactly: + * Prefix must be: * * /v. */ @@ -197,12 +390,110 @@ bool is_versioned_ping_path(const std::string& path) { return i == prefix.size(); } -bool is_ping_target(const std::string& target) { +bool is_api_target(const std::string& target, + const char* endpoint_path) { const std::size_t query_pos = target.find('?'); const std::string path = query_pos == std::string::npos ? target : target.substr(0, query_pos); - return path == "/_ping" || is_versioned_ping_path(path); + return path == endpoint_path || + is_versioned_api_path(path, endpoint_path); +} + +bool is_truthy_query_value(const std::string& value) { + return value.empty() || + value == "1" || + value == "true"; +} + +ContainerListRequest parse_container_list_request( + const std::string& target) { + ContainerListRequest request {}; + + const std::size_t query_pos = target.find('?'); + if (query_pos == std::string::npos || + query_pos + 1 >= target.size()) { + return request; + } + + /* + * This is intentionally a very small query parser for the bring-up phase. + * It extracts only the public API semantic that socketd currently cares + * about: ?all=1. Unknown query parameters are ignored. + */ + std::size_t pos = query_pos + 1; + + while (pos <= target.size()) { + const std::size_t amp = target.find('&', pos); + const std::size_t end = + amp == std::string::npos ? target.size() : amp; + + const std::string item = target.substr(pos, end - pos); + const std::size_t eq = item.find('='); + + const std::string key = + eq == std::string::npos ? item : item.substr(0, eq); + + const std::string value = + eq == std::string::npos ? "" : item.substr(eq + 1); + + if (key == "all") { + request.include_all = is_truthy_query_value(value); + } + + if (amp == std::string::npos) { + break; + } + + pos = amp + 1; + } + + return request; +} + +EventsRequest parse_events_request(const std::string& target) { + EventsRequest request {}; + + const std::size_t query_pos = target.find('?'); + if (query_pos == std::string::npos || + query_pos + 1 >= target.size()) { + return request; + } + + /* + * Small, deliberate parser for only the fields observed from Portainer's + * event-log request. Unknown parameters are ignored for now. + */ + std::size_t pos = query_pos + 1; + + while (pos <= target.size()) { + const std::size_t amp = target.find('&', pos); + const std::size_t end = + amp == std::string::npos ? target.size() : amp; + + const std::string item = target.substr(pos, end - pos); + const std::size_t eq = item.find('='); + + const std::string key = + eq == std::string::npos ? item : item.substr(0, eq); + + const std::string value = + eq == std::string::npos ? "" : item.substr(eq + 1); + + if (key == "since") { + request.since = value; + } else if (key == "until") { + request.until = value; + } + + if (amp == std::string::npos) { + break; + } + + pos = amp + 1; + } + + return request; } bool parse_port(const std::string& value, @@ -233,6 +524,295 @@ bool parse_port(const std::string& value, return true; } +std::string build_version_json() { + const std::string arch = socketd_arch_name(); + const std::string kernel_version = socketd_kernel_version(); + + std::string body; + body.reserve(512); + + body += "{"; + + body += "\"Platform\":{\"Name\":\""; + body += json_escape(DS_PROJECT_NAME); + body += "\"},"; + + body += "\"Components\":[{"; + body += "\"Name\":\"Engine\","; + body += "\"Version\":\""; + body += json_escape(DS_VERSION); + body += "\","; + body += "\"Details\":{}"; + body += "}],"; + + body += "\"Version\":\""; + body += json_escape(DS_VERSION); + body += "\","; + + body += "\"ApiVersion\":\""; + body += kSocketApiVersion; + body += "\","; + + body += "\"MinAPIVersion\":\""; + body += kSocketMinApiVersion; + body += "\","; + + body += "\"Os\":\""; + body += kSocketOsType; + body += "\","; + + body += "\"Arch\":\""; + body += json_escape(arch); + body += "\""; + + if (!kernel_version.empty()) { + body += ",\"KernelVersion\":\""; + body += json_escape(kernel_version); + body += "\""; + } + + body += "}\n"; + return body; +} + +bool send_version_ok(int fd, bool suppress_body, std::string& error) { + const std::string body = build_version_json(); + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + +std::string build_info_json() { + /* + * TODO(socketd): + * This /info response is intentionally synthesized locally so that early + * Portainer integration can proceed and reveal the next compatibility + * requirements. Replace or enrich this with DS_SOCKETD_OP_INFO once the + * privileged backend bridge has a stable information payload. + */ + const std::string arch = socketd_arch_name(); + const std::string kernel_version = socketd_kernel_version(); + const std::string hostname = socketd_hostname(); + const std::string system_time = socketd_system_time_utc(); + const unsigned int ncpu = socketd_ncpu(); + const std::uint64_t mem_total = socketd_mem_total_bytes(); + + std::string body; + body.reserve(1400); + + body += "{"; + + /* + * Container and image counters are placeholders until INFO is backed by + * the privileged daemon. + */ + body += "\"ID\":\"\","; + body += "\"Containers\":0,"; + body += "\"ContainersRunning\":0,"; + body += "\"ContainersPaused\":0,"; + body += "\"ContainersStopped\":0,"; + body += "\"Images\":0,"; + + body += "\"Driver\":\"droidspaces\","; + body += "\"DriverStatus\":[],"; + body += "\"Plugins\":{"; + body += "\"Volume\":[],"; + body += "\"Network\":[],"; + body += "\"Authorization\":[],"; + body += "\"Log\":[]"; + body += "},"; + + body += "\"MemoryLimit\":false,"; + body += "\"SwapLimit\":false,"; + body += "\"CpuCfsPeriod\":false,"; + body += "\"CpuCfsQuota\":false,"; + body += "\"CPUShares\":false,"; + body += "\"CPUSet\":false,"; + body += "\"PidsLimit\":false,"; + body += "\"IPv4Forwarding\":false,"; + body += "\"Debug\":false,"; + body += "\"NFd\":0,"; + body += "\"OomKillDisable\":false,"; + body += "\"NGoroutines\":0,"; + + body += "\"SystemTime\":\""; + body += json_escape(system_time); + body += "\","; + + body += "\"LoggingDriver\":\"\","; + body += "\"CgroupDriver\":\"\","; + body += "\"NEventsListener\":0,"; + + body += "\"KernelVersion\":\""; + body += json_escape(kernel_version); + body += "\","; + + body += "\"OperatingSystem\":\"Droidspaces\","; + body += "\"OSVersion\":\"\","; + body += "\"OSType\":\"linux\","; + + body += "\"Architecture\":\""; + body += json_escape(arch); + body += "\","; + + body += "\"IndexServerAddress\":\"\","; + body += "\"RegistryConfig\":null,"; + + body += "\"NCPU\":"; + body += std::to_string(ncpu); + body += ","; + + body += "\"MemTotal\":"; + body += std::to_string(mem_total); + body += ","; + + body += "\"GenericResources\":[],"; + body += "\"DockerRootDir\":\"\","; + body += "\"HttpProxy\":\"\","; + body += "\"HttpsProxy\":\"\","; + body += "\"NoProxy\":\"\","; + + body += "\"Name\":\""; + body += json_escape(hostname); + body += "\","; + + body += "\"Labels\":[],"; + body += "\"ExperimentalBuild\":false,"; + + body += "\"ServerVersion\":\""; + body += json_escape(DS_VERSION); + body += "\","; + + body += "\"Runtimes\":{},"; + body += "\"DefaultRuntime\":\"\","; + body += "\"Swarm\":{\"NodeID\":\"\"},"; + body += "\"LiveRestoreEnabled\":false,"; + body += "\"Isolation\":\"\","; + body += "\"InitBinary\":\"\","; + body += "\"ContainerdCommit\":{\"ID\":\"\"},"; + body += "\"RuncCommit\":{\"ID\":\"\"},"; + body += "\"InitCommit\":{\"ID\":\"\"},"; + body += "\"SecurityOptions\":[],"; + body += "\"Warnings\":[]"; + + body += "}\n"; + return body; +} + +bool send_info_ok(int fd, bool suppress_body, std::string& error) { + const std::string body = build_info_json(); + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + +bool send_container_list_ok(int fd, + const std::string& target, + bool suppress_body, + std::string& error) { + const ContainerListRequest request = + parse_container_list_request(target); + + std::string body; + if (!request_container_list_json_from_core(request, body, error)) { + return false; + } + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + +bool send_image_list_ok(int fd, + bool suppress_body, + std::string& error) { + std::string body; + if (!request_image_list_json_from_core(body, error)) { + return false; + } + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + +bool send_volume_list_ok(int fd, + bool suppress_body, + std::string& error) { + std::string body; + if (!request_volume_list_json_from_core(body, error)) { + return false; + } + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + +bool send_network_list_ok(int fd, + bool suppress_body, + std::string& error) { + std::string body; + if (!request_network_list_json_from_core(body, error)) { + return false; + } + + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + +bool send_events_ok(int fd, + const std::string& target, + bool suppress_body, + std::string& error) { + const EventsRequest request = parse_events_request(target); + + std::string body; + if (!request_event_log_stream_from_core(request, body, error)) { + return false; + } + + /* + * API v1.40-compatible behavior: + * Moby used application/json for event streams at this API level. + * An empty body is intentional and accepted by Portainer's event-log parser. + */ + return send_http_response(fd, + 200, + "OK", + "application/json", + body, + suppress_body, + error); +} + } // namespace bool parse_tcp_listen_endpoint(const std::string& value, @@ -354,6 +934,8 @@ bool ApiServer::handle_client(int client_fd, std::string& error) const { return false; } } +// DEBUG FEATURE: perhaps remove later; do NOT expect this to stay. + debug_log_request_headers(request); const std::size_t line_end = request.find("\r\n"); if (line_end == std::string::npos) { @@ -380,9 +962,37 @@ bool ApiServer::handle_client(int client_fd, std::string& error) const { const bool is_head = method == "HEAD"; const bool is_get = method == "GET"; - if ((is_get || is_head) && is_ping_target(target)) { + if ((is_get || is_head) && is_api_target(target, "/_ping")) { return send_ping_ok(client_fd, is_head, error); } + + if (is_get && is_api_target(target, "/version")) { + return send_version_ok(client_fd, false, error); + } + + if (is_get && is_api_target(target, "/info")) { + return send_info_ok(client_fd, false, error); + } + + if (is_get && is_api_target(target, "/containers/json")) { + return send_container_list_ok(client_fd, target, false, error); + } + + if (is_get && is_api_target(target, "/images/json")) { + return send_image_list_ok(client_fd, false, error); + } + + if (is_get && is_api_target(target, "/volumes")) { + return send_volume_list_ok(client_fd, false, error); + } + + if (is_get && is_api_target(target, "/networks")) { + return send_network_list_ok(client_fd, false, error); + } + + if (is_get && is_api_target(target, "/events")) { + return send_events_ok(client_fd, target, false, error); + } return send_not_found(client_fd, is_head, error); } @@ -392,12 +1002,27 @@ bool ApiServer::run(std::string& error) { if (!create_listener(listener_fd, error)) { return false; } - +// To tty std::cerr << "socketd: listening on http://" << config_.bind_address << ':' << config_.port << '\n'; +// To API + const std::string listen_target = + "tcp://" + config_.bind_address + ":" + std::to_string(config_.port); + + const SocketdEventAttributes attrs[] = { + {"name", "droidspaces-socketd"}, + {"component", "socketd"}, + {"listen", listen_target}, + }; + + record_socketd_event("daemon", + "start", + "droidspaces-socketd", + attrs, + sizeof(attrs) / sizeof(attrs[0])); for (;;) { const int client_fd = ::accept(listener_fd, nullptr, nullptr); diff --git a/src/socketd/container_list.cpp b/src/socketd/container_list.cpp new file mode 100644 index 00000000..084351a9 --- /dev/null +++ b/src/socketd/container_list.cpp @@ -0,0 +1,25 @@ +#include "container_list.h" + +namespace droidspaces::socketd { + +bool request_container_list_json_from_core( + const ContainerListRequest& request, + std::string& json_out, + std::string& error) { + (void)request; + error.clear(); + + /* + * TODO(socketd-core-bridge): + * Replace this dummy payload with a request to the Droidspaces core through + * the private socketd backend protocol once Portainer-side behavior for the + * public /containers/json endpoint has been confirmed. + * + * The HTTP layer must continue to depend on this socketd-owned seam, not on + * core runtime internals directly. + */ + json_out = "[]\n"; + return true; +} + +} // namespace droidspaces::socketd diff --git a/src/socketd/container_list.h b/src/socketd/container_list.h new file mode 100644 index 00000000..436c9d57 --- /dev/null +++ b/src/socketd/container_list.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +namespace droidspaces::socketd { + +struct ContainerListRequest { + /* + * Docker-compatible public API spelling: + * + * GET /containers/json?all=1 + * + * The extension keeps this as a semantic flag rather than exposing the + * raw HTTP query string to any eventual core-side implementation. + */ + bool include_all = false; +}; + +/* + * Socketd-owned seam for obtaining the container-list payload. + * + * For the current extension-only bring-up step this is deliberately a dummy: + * it returns an empty Docker-shaped JSON list so Portainer can advance to its + * next probe. Once that behavior is confirmed, this function becomes the + * place where socketd requests real container data from the core bridge. + */ +bool request_container_list_json_from_core( + const ContainerListRequest& request, + std::string& json_out, + std::string& error); + +} // namespace droidspaces::socketd diff --git a/src/socketd/event_log.cpp b/src/socketd/event_log.cpp new file mode 100644 index 00000000..306c095b --- /dev/null +++ b/src/socketd/event_log.cpp @@ -0,0 +1,257 @@ +#include "event_log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace droidspaces::socketd { +namespace { + +constexpr std::size_t kMaxSocketdEvents = 128; + +struct EventAttribute { + std::string key; + std::string value; +}; + +struct SocketdEvent { + std::string type; + std::string action; + std::string actor_id; + std::vector attributes; + std::int64_t time = 0; + std::int64_t time_nano = 0; +}; + +std::deque g_socketd_events; + +std::int64_t unix_time_seconds_now() { + using clock = std::chrono::system_clock; + const auto now = clock::now(); + return std::chrono::duration_cast( + now.time_since_epoch()) + .count(); +} + +std::int64_t unix_time_nanos_now() { + using clock = std::chrono::system_clock; + const auto now = clock::now(); + return std::chrono::duration_cast( + now.time_since_epoch()) + .count(); +} + +std::string json_escape(const std::string& input) { + std::string out; + out.reserve(input.size()); + + constexpr char kHex[] = "0123456789abcdef"; + + for (unsigned char ch : input) { + switch (ch) { + case '"': + out += "\\\""; + break; + case '\\': + out += "\\\\"; + break; + case '\b': + out += "\\b"; + break; + case '\f': + out += "\\f"; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + if (ch < 0x20) { + out += "\\u00"; + out += kHex[(ch >> 4) & 0x0f]; + out += kHex[ch & 0x0f]; + } else { + out += static_cast(ch); + } + break; + } + } + + return out; +} + +bool parse_optional_epoch_seconds(const std::string& value, + std::int64_t& out, + std::string& error) { + out = 0; + + if (value.empty()) { + return true; + } + + char* end = nullptr; + errno = 0; + const long long parsed = std::strtoll(value.c_str(), &end, 10); + + if (errno != 0 || end == value.c_str() || *end != '\0') { + error = "invalid event timestamp: "; + error += value; + return false; + } + + if (parsed < 0) { + error = "negative event timestamp is unsupported: "; + error += value; + return false; + } + + out = static_cast(parsed); + return true; +} + +bool event_in_window(const SocketdEvent& event, + std::int64_t since, + std::int64_t until) { + if (since > 0 && event.time < since) { + return false; + } + + if (until > 0 && event.time > until) { + return false; + } + + return true; +} + +std::string encode_event_json_line(const SocketdEvent& event) { + std::string out; + out.reserve(512); + + out += "{"; + + out += "\"Type\":\""; + out += json_escape(event.type); + out += "\","; + + out += "\"Action\":\""; + out += json_escape(event.action); + out += "\","; + + out += "\"Actor\":{"; + out += "\"ID\":\""; + out += json_escape(event.actor_id); + out += "\","; + out += "\"Attributes\":{"; + + for (std::size_t i = 0; i < event.attributes.size(); ++i) { + if (i != 0) { + out += ","; + } + + out += "\""; + out += json_escape(event.attributes[i].key); + out += "\":\""; + out += json_escape(event.attributes[i].value); + out += "\""; + } + + out += "}"; + out += "},"; + + out += "\"scope\":\"local\","; + + out += "\"time\":"; + out += std::to_string(event.time); + out += ","; + + out += "\"timeNano\":"; + out += std::to_string(event.time_nano); + + out += "}\n"; + return out; +} + +} // namespace + +void record_socketd_event(const std::string& type, + const std::string& action, + const std::string& actor_id, + const SocketdEventAttributes* attributes, + std::size_t attribute_count) { + SocketdEvent event; + event.type = type; + event.action = action; + event.actor_id = actor_id; + event.time = unix_time_seconds_now(); + event.time_nano = unix_time_nanos_now(); + + event.attributes.reserve(attribute_count); + for (std::size_t i = 0; i < attribute_count; ++i) { + event.attributes.push_back(EventAttribute{ + attributes[i].key, + attributes[i].value, + }); + } + + if (g_socketd_events.size() >= kMaxSocketdEvents) { + g_socketd_events.pop_front(); + } + + g_socketd_events.push_back(std::move(event)); +} + +bool request_event_log_stream_from_core( + const EventsRequest& request, + std::string& stream_out, + std::string& error) { + error.clear(); + stream_out.clear(); + + /* + * TODO(socketd-core-events): + * This endpoint currently exposes extension-owned daemon events from the + * socketd-local event journal. When the socket extension has a validated + * need for core-runtime lifecycle events, merge in a backend-provided event + * stream behind this same socketd-owned seam. + */ + std::int64_t since = 0; + std::int64_t until = 0; + + if (!parse_optional_epoch_seconds(request.since, since, error)) { + return false; + } + + if (!parse_optional_epoch_seconds(request.until, until, error)) { + return false; + } + + if (since > 0 && until > 0 && since > until) { + error = "event 'since' timestamp is after 'until'"; + return false; + } + + for (const SocketdEvent& event : g_socketd_events) { + if (!event_in_window(event, since, until)) { + continue; + } + + stream_out += encode_event_json_line(event); + } + + return true; +} + +} // namespace droidspaces::socketd diff --git a/src/socketd/event_log.h b/src/socketd/event_log.h new file mode 100644 index 00000000..13e3f7ba --- /dev/null +++ b/src/socketd/event_log.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +namespace droidspaces::socketd { + +struct EventsRequest { + std::string since; + std::string until; +}; + +struct SocketdEventAttributes { + std::string key; + std::string value; +}; + +/* + * Record a socketd-owned engine-style event in the extension-local journal. + * + * The public /events endpoint exposes this journal in Docker-compatible + * event-stream form. This deliberately covers only events owned by the socket + * extension itself; core-engine events remain a later, separate backend seam. + */ +void record_socketd_event(const std::string& type, + const std::string& action, + const std::string& actor_id, + const SocketdEventAttributes* attributes, + std::size_t attribute_count); + +/* + * Return historical socketd-owned events matching the requested time window + * as a Docker-compatible stream of JSON objects. + */ +bool request_event_log_stream_from_core( + const EventsRequest& request, + std::string& stream_out, + std::string& error); + +} // namespace droidspaces::socketd diff --git a/src/socketd/main.cpp b/src/socketd/main.cpp index 19572786..d9c5c316 100644 --- a/src/socketd/main.cpp +++ b/src/socketd/main.cpp @@ -1,9 +1,11 @@ #include "api_server.h" #include "backend_client.h" +#include "event_log.h" #include "socketd_protocol.h" #include +#include #include #include #include @@ -15,6 +17,8 @@ using droidspaces::socketd::BackendClient; using droidspaces::socketd::CapabilitiesResult; using droidspaces::socketd::TcpListenConfig; using droidspaces::socketd::parse_tcp_listen_endpoint; +using droidspaces::socketd::SocketdEventAttributes; +using droidspaces::socketd::record_socketd_event; constexpr std::uint32_t kRequiredBackendCapabilities = DS_SOCKETD_CAP_PROTOCOL_V1 | @@ -53,12 +57,31 @@ bool check_backend(std::string& error) { error = "backend is missing required base capabilities"; return false; } - +// To tty std::cerr << "socketd: backend handshake OK, capabilities mask: 0x" << std::hex << caps.mask << std::dec << '\n'; + +// To API +const std::string caps_text = "0x" + [&caps]() { + char buffer[32] {}; + std::snprintf(buffer, sizeof(buffer), "%x", caps.mask); + return std::string(buffer); +}(); + +const SocketdEventAttributes attrs[] = { + {"name", "droidspaces-backend"}, + {"component", "socketd"}, + {"capabilities", caps_text}, +}; + +record_socketd_event("daemon", + "connect", + "droidspaces-backend", + attrs, + sizeof(attrs) / sizeof(attrs[0])); return true; } diff --git a/src/socketd/snapshot_lists.cpp b/src/socketd/snapshot_lists.cpp new file mode 100644 index 00000000..8f32d389 --- /dev/null +++ b/src/socketd/snapshot_lists.cpp @@ -0,0 +1,48 @@ +#include "snapshot_lists.h" + +namespace droidspaces::socketd { + +bool request_image_list_json_from_core(std::string& json_out, + std::string& error) { + error.clear(); + + /* + * TODO(socketd-snapshot-images): + * This is a deliberate dummy response. Portainer's snapshot pass requests + * /images/json, but Droidspaces has not yet decided whether Docker-style + * image inventory is meaningful to expose. Keep this extension-local until + * a socketd-owned need for core data is established. + */ + json_out = "[]\n"; + return true; +} + +bool request_volume_list_json_from_core(std::string& json_out, + std::string& error) { + error.clear(); + + /* + * TODO(socketd-snapshot-volumes): + * This is a deliberate dummy response. The Docker-compatible /volumes route + * returns an object, not a bare array, so preserve that shape while exposing + * no volume inventory. + */ + json_out = "{\"Volumes\":[],\"Warnings\":[]}\n"; + return true; +} + +bool request_network_list_json_from_core(std::string& json_out, + std::string& error) { + error.clear(); + + /* + * TODO(socketd-snapshot-networks): + * This is a deliberate dummy response. Portainer requests /networks during + * its snapshot pass; return a structurally valid empty list and avoid + * projecting Docker network semantics into the Droidspaces core. + */ + json_out = "[]\n"; + return true; +} + +} // namespace droidspaces::socketd diff --git a/src/socketd/snapshot_lists.h b/src/socketd/snapshot_lists.h new file mode 100644 index 00000000..579e699f --- /dev/null +++ b/src/socketd/snapshot_lists.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace droidspaces::socketd { + +/* + * Extension-owned seams for Portainer snapshot inventory probes. + * + * These functions intentionally return dummy payloads for now. Portainer has + * been observed requesting images, volumes, and networks during its Docker + * snapshot pass after /containers/json succeeds. + * + * No core-engine change is warranted at this stage: first we confirm that + * Portainer's UI settles when these discovery probes receive structurally + * valid empty responses. + */ + +bool request_image_list_json_from_core(std::string& json_out, + std::string& error); + +bool request_volume_list_json_from_core(std::string& json_out, + std::string& error); + +bool request_network_list_json_from_core(std::string& json_out, + std::string& error); + +} // namespace droidspaces::socketd