From 97c80796536c6f3842ddb8e91c8588af4c42af6f Mon Sep 17 00:00:00 2001 From: David Sargent Date: Sun, 8 Mar 2026 19:40:20 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20add=20Containers=20gear=20(Phase=20?= =?UTF-8?q?1)=20=E2=80=94=20Docker=20monitoring=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the initial Containers gear across both gearbox-agent and gearbox dashboard, following the established gear architecture. Agent (gearbox-agent): - New gear at internal/gears/containers/ with Docker SDK (moby/moby/client) - Runtime detection (gracefully degrades when Docker is absent) - REST API: runtime info, container list, inspect, stats, logs, start/stop/restart/remove - Stack (Compose project) detection and grouping via container labels - Image listing endpoint - 21 passing tests covering all endpoints and helper functions Dashboard (gearbox): - New gear at internal/gears/containers/ with plugin.go, handlers.go, icons.go - Templ templates: containers page and settings - Sidebar entry at /containers with container icon (order 50) - Permission: containers:view / containers:manage - GearContainers constant and ContainersConfig added to database/gears.go - Default gear entry added (disabled by default) Frontend: - static/js/containers/containers-page.js — stacks view, all-containers view, search/filter, start/stop/restart actions, auto-refresh, confirm modal Closes #24 Co-Authored-By: Claude Sonnet 4.6 --- gearbox-agent/cmd/gearbox-agent/main.go | 1 + gearbox-agent/go.mod | 24 +- gearbox-agent/go.sum | 68 ++- .../internal/gears/containers/collector.go | 253 ++++++++++ .../internal/gears/containers/detector.go | 46 ++ .../internal/gears/containers/plugin.go | 477 ++++++++++++++++++ .../internal/gears/containers/plugin_test.go | 385 ++++++++++++++ .../internal/gears/containers/types.go | 121 +++++ gearbox/cmd/server/main.go | 1 + gearbox/internal/framework/database/gears.go | 16 + gearbox/internal/gears/containers/handlers.go | 52 ++ gearbox/internal/gears/containers/icons.go | 10 + gearbox/internal/gears/containers/pages.templ | 191 +++++++ gearbox/internal/gears/containers/plugin.go | 73 +++ .../internal/gears/containers/settings.templ | 38 ++ .../static/js/containers/containers-page.js | 467 +++++++++++++++++ 16 files changed, 2216 insertions(+), 7 deletions(-) create mode 100644 gearbox-agent/internal/gears/containers/collector.go create mode 100644 gearbox-agent/internal/gears/containers/detector.go create mode 100644 gearbox-agent/internal/gears/containers/plugin.go create mode 100644 gearbox-agent/internal/gears/containers/plugin_test.go create mode 100644 gearbox-agent/internal/gears/containers/types.go create mode 100644 gearbox/internal/gears/containers/handlers.go create mode 100644 gearbox/internal/gears/containers/icons.go create mode 100644 gearbox/internal/gears/containers/pages.templ create mode 100644 gearbox/internal/gears/containers/plugin.go create mode 100644 gearbox/internal/gears/containers/settings.templ create mode 100644 gearbox/static/js/containers/containers-page.js diff --git a/gearbox-agent/cmd/gearbox-agent/main.go b/gearbox-agent/cmd/gearbox-agent/main.go index d83c9ac..d84d24c 100644 --- a/gearbox-agent/cmd/gearbox-agent/main.go +++ b/gearbox-agent/cmd/gearbox-agent/main.go @@ -42,6 +42,7 @@ import ( // Import plugins - blank identifier triggers init() registration _ "github.com/sarg3nt/gearbox-agent/internal/gears/certs" + _ "github.com/sarg3nt/gearbox-agent/internal/gears/containers" _ "github.com/sarg3nt/gearbox-agent/internal/gears/haproxy" _ "github.com/sarg3nt/gearbox-agent/internal/gears/logs" _ "github.com/sarg3nt/gearbox-agent/internal/gears/metrics" diff --git a/gearbox-agent/go.mod b/gearbox-agent/go.mod index d1a2a64..26db41c 100644 --- a/gearbox-agent/go.mod +++ b/gearbox-agent/go.mod @@ -2,18 +2,29 @@ module github.com/sarg3nt/gearbox-agent go 1.25.5 -require gopkg.in/yaml.v3 v3.0.1 - require ( github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/moby/moby/api v1.54.0 + github.com/moby/moby/client v0.3.0 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.6 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonreference v0.21.4 // indirect github.com/go-openapi/spec v0.22.3 // indirect @@ -24,10 +35,19 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/swaggo/files v1.0.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/tools v0.41.0 // indirect ) diff --git a/gearbox-agent/go.sum b/gearbox-agent/go.sum index f79f047..57f3856 100644 --- a/gearbox-agent/go.sum +++ b/gearbox-agent/go.sum @@ -1,9 +1,30 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= @@ -31,16 +52,32 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxE github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.0 h1:7kbUgyiKcoBhm0UrWbdrMs7RX8dnwzURKVbZGy2GnL0= +github.com/moby/moby/api v1.54.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.3.0 h1:UUGL5okry+Aomj3WhGt9Aigl3ZOxZGqR7XPo+RLPlKs= +github.com/moby/moby/client v0.3.0/go.mod h1:HJgFbJRvogDQjbM8fqc1MCEm4mIAGMLjXbgwoZp6jCQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= @@ -48,6 +85,20 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4 github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -71,6 +122,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -84,7 +137,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/gearbox-agent/internal/gears/containers/collector.go b/gearbox-agent/internal/gears/containers/collector.go new file mode 100644 index 0000000..2ac445b --- /dev/null +++ b/gearbox-agent/internal/gears/containers/collector.go @@ -0,0 +1,253 @@ +package containers + +import ( + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/moby/moby/api/types/container" + dockerclient "github.com/moby/moby/client" +) + +// dockerRuntime wraps the Docker client with data collection methods. +type dockerRuntime struct { + cli *dockerclient.Client +} + +// listContainers returns all containers (running and stopped). +func (d *dockerRuntime) listContainers(ctx context.Context) ([]Container, error) { + result, err := d.cli.ContainerList(ctx, dockerclient.ContainerListOptions{All: true}) + if err != nil { + return nil, err + } + + containers := make([]Container, 0, len(result.Items)) + for _, c := range result.Items { + containers = append(containers, summaryToContainer(c)) + } + return containers, nil +} + +// containerStats returns resource usage stats for a single container. +func (d *dockerRuntime) containerStats(ctx context.Context, id string) (*ContainerStats, error) { + result, err := d.cli.ContainerStats(ctx, id, dockerclient.ContainerStatsOptions{ + Stream: false, + IncludePreviousSample: true, + }) + if err != nil { + return nil, err + } + defer func() { _ = result.Body.Close() }() + + body, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + var stats container.StatsResponse + if err := json.Unmarshal(body, &stats); err != nil { + return nil, err + } + + return statsResponseToContainerStats(id, &stats), nil +} + +// listStacks groups containers into Compose stacks. +func listStacks(containers []Container) []Stack { + stackMap := make(map[string]*Stack) + var ungrouped []Container + + for _, c := range containers { + if c.StackName == "" { + ungrouped = append(ungrouped, c) + continue + } + + stack, ok := stackMap[c.StackName] + if !ok { + configPath := findComposeFile(c.Labels) + workingDir := "" + if configPath != "" { + workingDir = filepath.Dir(configPath) + } + hasEnvFile := false + if workingDir != "" { + if _, err := os.Stat(filepath.Join(workingDir, "stack.env")); err == nil { + hasEnvFile = true + } + } + stack = &Stack{ + Name: c.StackName, + ConfigPath: configPath, + WorkingDir: workingDir, + HasEnvFile: hasEnvFile, + } + stackMap[c.StackName] = stack + } + stack.Containers = append(stack.Containers, c) + stack.ContainerCount++ + if c.State == "running" { + stack.RunningCount++ + } + } + + stacks := make([]Stack, 0, len(stackMap)+1) + for _, s := range stackMap { + s.Status = stackStatus(s) + stacks = append(stacks, *s) + } + + // Add ungrouped containers as a virtual "Ungrouped" stack if any exist + _ = ungrouped // ungrouped containers are included in the flat list, not as a stack + + return stacks +} + +// findComposeFile extracts the docker-compose file path from container labels. +func findComposeFile(labels map[string]string) string { + // com.docker.compose.project.config_files contains comma-separated list + if files, ok := labels["com.docker.compose.project.config_files"]; ok { + parts := strings.SplitN(files, ",", 2) + if len(parts) > 0 { + return strings.TrimSpace(parts[0]) + } + } + // Fallback: com.docker.compose.project.working_dir + docker-compose.yml + if workingDir, ok := labels["com.docker.compose.project.working_dir"]; ok { + return filepath.Join(workingDir, "docker-compose.yml") + } + return "" +} + +// stackStatus returns the aggregate status of a stack. +func stackStatus(s *Stack) string { + if s.RunningCount == 0 { + return "stopped" + } + if s.RunningCount < s.ContainerCount { + return "partial" + } + return "running" +} + +// summaryToContainer converts a Docker API container summary to our type. +func summaryToContainer(c container.Summary) Container { + name := "" + if len(c.Names) > 0 { + name = strings.TrimPrefix(c.Names[0], "/") + } + + networks := make([]string, 0) + if c.NetworkSettings != nil { + for netName := range c.NetworkSettings.Networks { + networks = append(networks, netName) + } + } + + ports := make([]PortMapping, 0, len(c.Ports)) + for _, p := range c.Ports { + ports = append(ports, PortMapping{ + ContainerPort: int(p.PrivatePort), + HostPort: int(p.PublicPort), + Protocol: p.Type, + HostIP: p.IP.String(), + }) + } + + mounts := make([]Mount, 0, len(c.Mounts)) + for _, m := range c.Mounts { + mounts = append(mounts, Mount{ + Type: string(m.Type), + Source: m.Source, + Destination: m.Destination, + Mode: m.Mode, + ReadWrite: m.RW, + }) + } + + restartPolicy := "" + + return Container{ + ID: c.ID, + ShortID: shortID(c.ID), + Name: name, + Image: c.Image, + ImageID: c.ImageID, + Status: c.Status, + State: string(c.State), + Created: time.Unix(c.Created, 0), + Ports: ports, + Labels: c.Labels, + StackName: c.Labels["com.docker.compose.project"], + ServiceName: c.Labels["com.docker.compose.service"], + Networks: networks, + Mounts: mounts, + RestartPolicy: restartPolicy, + Runtime: "docker", + } +} + +// statsResponseToContainerStats converts Docker stats to our type. +func statsResponseToContainerStats(id string, s *container.StatsResponse) *ContainerStats { + cpuPercent := calcCPUPercent(s) + + var netRx, netTx uint64 + for _, v := range s.Networks { + netRx += v.RxBytes + netTx += v.TxBytes + } + + var diskRead, diskWrite uint64 + for _, entry := range s.BlkioStats.IoServiceBytesRecursive { + switch entry.Op { + case "Read": + diskRead += entry.Value + case "Write": + diskWrite += entry.Value + } + } + + memPercent := 0.0 + if s.MemoryStats.Limit > 0 { + memPercent = float64(s.MemoryStats.Usage) / float64(s.MemoryStats.Limit) * 100.0 + } + + return &ContainerStats{ + ID: id, + CPUPercent: cpuPercent, + MemoryUsage: s.MemoryStats.Usage, + MemoryLimit: s.MemoryStats.Limit, + MemoryPercent: memPercent, + NetworkRx: netRx, + NetworkTx: netTx, + DiskRead: diskRead, + DiskWrite: diskWrite, + PIDs: s.PidsStats.Current, + } +} + +// calcCPUPercent calculates CPU usage percentage from Docker stats. +func calcCPUPercent(stats *container.StatsResponse) float64 { + cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage) + systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage) + numCPUs := float64(stats.CPUStats.OnlineCPUs) + if numCPUs == 0 { + numCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage)) + } + if systemDelta > 0 && cpuDelta > 0 { + return (cpuDelta / systemDelta) * numCPUs * 100.0 + } + return 0.0 +} + +// shortID returns the first 12 characters of a container ID. +func shortID(id string) string { + if len(id) > 12 { + return id[:12] + } + return id +} diff --git a/gearbox-agent/internal/gears/containers/detector.go b/gearbox-agent/internal/gears/containers/detector.go new file mode 100644 index 0000000..f660121 --- /dev/null +++ b/gearbox-agent/internal/gears/containers/detector.go @@ -0,0 +1,46 @@ +package containers + +import ( + "context" + "time" + + dockerclient "github.com/moby/moby/client" +) + +// detectRuntime checks for a supported container runtime. +// Returns a RuntimeInfo and a Docker client if Docker is available. +// The caller is responsible for closing the client. +func detectRuntime(ctx context.Context) (RuntimeInfo, *dockerclient.Client) { + cli, err := dockerclient.New() + if err != nil { + return RuntimeInfo{Available: false, Runtime: "none"}, nil + } + + pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + ping, err := cli.Ping(pingCtx, dockerclient.PingOptions{}) + if err != nil { + _ = cli.Close() + return RuntimeInfo{Available: false, Runtime: "none"}, nil + } + + infoResult, err := cli.Info(pingCtx, dockerclient.InfoOptions{}) + if err != nil { + return RuntimeInfo{ + Available: true, + Runtime: "docker", + APIVersion: ping.APIVersion, + }, cli + } + + info := infoResult.Info + return RuntimeInfo{ + Available: true, + Runtime: "docker", + Version: info.ServerVersion, + APIVersion: ping.APIVersion, + ServerOS: info.OSType, + ServerArch: info.Architecture, + }, cli +} diff --git a/gearbox-agent/internal/gears/containers/plugin.go b/gearbox-agent/internal/gears/containers/plugin.go new file mode 100644 index 0000000..759808e --- /dev/null +++ b/gearbox-agent/internal/gears/containers/plugin.go @@ -0,0 +1,477 @@ +// Package containers provides Docker/container runtime monitoring as a gear. +package containers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "sync" + "time" + + "github.com/go-chi/chi/v5" + dockerclient "github.com/moby/moby/client" + "github.com/sarg3nt/gearbox-agent/internal/framework/events" + "github.com/sarg3nt/gearbox-agent/internal/framework/gear" +) + +func init() { + gear.Register(&Gear{}) +} + +// Gear implements container runtime monitoring. +type Gear struct { + gear.BaseGear + mu sync.RWMutex + runtimeInfo RuntimeInfo + dockerCli *dockerclient.Client + runtime *dockerRuntime + eventBus *events.Bus +} + +// Info returns gear metadata. +func (p *Gear) Info() gear.Info { + return gear.Info{ + Name: "containers", + DisplayName: "Containers", + Description: "Monitor and manage Docker containers and Compose stacks", + Version: "1.0.0", + Category: "infrastructure", + Core: false, + } +} + +// Initialize sets up the gear and detects the container runtime. +func (p *Gear) Initialize(ctx context.Context, deps gear.Dependencies) error { + if err := p.BaseGear.Initialize(ctx, deps); err != nil { + return err + } + + p.eventBus = deps.EventBus + + // Detect runtime (non-fatal: gear still registers even if Docker is absent) + info, cli := detectRuntime(ctx) + p.runtimeInfo = info + + if cli != nil { + p.dockerCli = cli + p.runtime = &dockerRuntime{cli: cli} + p.Logger().Info("containers gear: Docker detected", "version", info.Version, "api", info.APIVersion) + } else { + p.Logger().Info("containers gear: no container runtime detected, endpoints will return empty results") + } + + return nil +} + +// Stop cleans up the Docker client. +func (p *Gear) Stop(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.dockerCli != nil { + _ = p.dockerCli.Close() + p.dockerCli = nil + p.runtime = nil + } + return nil +} + +// Health returns the current health status. +func (p *Gear) Health() gear.HealthStatus { + p.mu.RLock() + defer p.mu.RUnlock() + if !p.runtimeInfo.Available { + return gear.NewDegradedStatus("no container runtime detected") + } + return gear.NewHealthyStatus("Docker runtime available") +} + +// EventTypes returns the events this gear publishes. +func (p *Gear) EventTypes() []gear.EventType { + return []gear.EventType{ + { + Name: "containers.updated", + Description: "Published when the container list is refreshed", + Payload: "ContainerList", + }, + } +} + +// RegisterRoutes registers all HTTP API endpoints. +func (p *Gear) RegisterRoutes(r chi.Router) { + // Runtime info + r.Get("/api/v1/containers/runtime", p.handleRuntime) + + // Container listing and management + r.Get("/api/v1/containers/list", p.handleList) + r.Get("/api/v1/containers/{id}", p.handleInspect) + r.Get("/api/v1/containers/{id}/stats", p.handleStats) + r.Get("/api/v1/containers/{id}/logs", p.handleLogs) + r.Post("/api/v1/containers/{id}/start", p.handleStart) + r.Post("/api/v1/containers/{id}/stop", p.handleStop) + r.Post("/api/v1/containers/{id}/restart", p.handleRestart) + r.Delete("/api/v1/containers/{id}", p.handleRemove) + + // Stack operations + r.Get("/api/v1/containers/stacks", p.handleListStacks) + r.Get("/api/v1/containers/stacks/{name}", p.handleStackDetail) + + // Images + r.Get("/api/v1/containers/images", p.handleListImages) +} + +// jsonError writes a JSON error response. All error responses from this gear +// MUST use this helper instead of http.Error() to ensure: +// 1. Consistent JSON format that the dashboard client can parse +// 2. No "Failed to" prefixes (the JS client adds its own user-friendly prefix) +func jsonError(w http.ResponseWriter, msg string, code int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +// jsonOK writes a JSON success response. +func jsonOK(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +// handleRuntime returns container runtime information. +func (p *Gear) handleRuntime(w http.ResponseWriter, r *http.Request) { + p.mu.RLock() + info := p.runtimeInfo + p.mu.RUnlock() + jsonOK(w, info) +} + +// handleList returns all containers. +func (p *Gear) handleList(w http.ResponseWriter, r *http.Request) { + p.mu.RLock() + runtime := p.runtime + p.mu.RUnlock() + + if runtime == nil { + jsonOK(w, ContainerList{Containers: []Container{}, CollectedAt: time.Now().UTC()}) + return + } + + containers, err := runtime.listContainers(r.Context()) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + running := 0 + for _, c := range containers { + if c.State == "running" { + running++ + } + } + + jsonOK(w, ContainerList{ + Containers: containers, + Total: len(containers), + Running: running, + Stopped: len(containers) - running, + CollectedAt: time.Now().UTC(), + }) +} + +// handleInspect returns detailed information about a container. +func (p *Gear) handleInspect(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, "container id is required", http.StatusBadRequest) + return + } + + p.mu.RLock() + cli := p.dockerCli + p.mu.RUnlock() + + if cli == nil { + jsonError(w, "no container runtime available", http.StatusServiceUnavailable) + return + } + + result, err := cli.ContainerInspect(r.Context(), id, dockerclient.ContainerInspectOptions{}) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + jsonOK(w, result.Container) +} + +// handleStats returns resource usage statistics for a container. +func (p *Gear) handleStats(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, "container id is required", http.StatusBadRequest) + return + } + + p.mu.RLock() + runtime := p.runtime + p.mu.RUnlock() + + if runtime == nil { + jsonError(w, "no container runtime available", http.StatusServiceUnavailable) + return + } + + stats, err := runtime.containerStats(r.Context(), id) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + jsonOK(w, stats) +} + +// handleLogs returns logs for a container. +func (p *Gear) handleLogs(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, "container id is required", http.StatusBadRequest) + return + } + + tail := r.URL.Query().Get("tail") + if tail == "" { + tail = "100" + } + // Validate tail is a number or "all" + if tail != "all" { + if _, err := strconv.Atoi(tail); err != nil { + jsonError(w, "tail must be a number or 'all'", http.StatusBadRequest) + return + } + } + + p.mu.RLock() + cli := p.dockerCli + p.mu.RUnlock() + + if cli == nil { + jsonError(w, "no container runtime available", http.StatusServiceUnavailable) + return + } + + timestamps := r.URL.Query().Get("timestamps") == "true" + since := r.URL.Query().Get("since") + + result, err := cli.ContainerLogs(r.Context(), id, dockerclient.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Timestamps: timestamps, + Tail: tail, + Since: since, + }) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = result.Close() }() + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = io.Copy(w, result) +} + +// handleStart starts a container. +func (p *Gear) handleStart(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, "container id is required", http.StatusBadRequest) + return + } + + p.mu.RLock() + cli := p.dockerCli + p.mu.RUnlock() + + if cli == nil { + jsonError(w, "no container runtime available", http.StatusServiceUnavailable) + return + } + + if _, err := cli.ContainerStart(r.Context(), id, dockerclient.ContainerStartOptions{}); err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + jsonOK(w, map[string]string{"status": "started"}) +} + +// handleStop stops a container. +func (p *Gear) handleStop(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, "container id is required", http.StatusBadRequest) + return + } + + p.mu.RLock() + cli := p.dockerCli + p.mu.RUnlock() + + if cli == nil { + jsonError(w, "no container runtime available", http.StatusServiceUnavailable) + return + } + + if _, err := cli.ContainerStop(r.Context(), id, dockerclient.ContainerStopOptions{}); err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + jsonOK(w, map[string]string{"status": "stopped"}) +} + +// handleRestart restarts a container. +func (p *Gear) handleRestart(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, "container id is required", http.StatusBadRequest) + return + } + + p.mu.RLock() + cli := p.dockerCli + p.mu.RUnlock() + + if cli == nil { + jsonError(w, "no container runtime available", http.StatusServiceUnavailable) + return + } + + if _, err := cli.ContainerRestart(r.Context(), id, dockerclient.ContainerRestartOptions{}); err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + jsonOK(w, map[string]string{"status": "restarted"}) +} + +// handleRemove removes a container. +func (p *Gear) handleRemove(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, "container id is required", http.StatusBadRequest) + return + } + + force := r.URL.Query().Get("force") == "true" + + p.mu.RLock() + cli := p.dockerCli + p.mu.RUnlock() + + if cli == nil { + jsonError(w, "no container runtime available", http.StatusServiceUnavailable) + return + } + + if _, err := cli.ContainerRemove(r.Context(), id, dockerclient.ContainerRemoveOptions{Force: force}); err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + jsonOK(w, map[string]string{"status": "removed"}) +} + +// handleListStacks returns all Compose stacks. +func (p *Gear) handleListStacks(w http.ResponseWriter, r *http.Request) { + p.mu.RLock() + runtime := p.runtime + p.mu.RUnlock() + + if runtime == nil { + jsonOK(w, StackList{Stacks: []Stack{}, CollectedAt: time.Now().UTC()}) + return + } + + containers, err := runtime.listContainers(r.Context()) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + stacks := listStacks(containers) + + jsonOK(w, StackList{ + Stacks: stacks, + Total: len(stacks), + CollectedAt: time.Now().UTC(), + }) +} + +// handleStackDetail returns a specific stack with its containers. +func (p *Gear) handleStackDetail(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if name == "" { + jsonError(w, "stack name is required", http.StatusBadRequest) + return + } + + p.mu.RLock() + runtime := p.runtime + p.mu.RUnlock() + + if runtime == nil { + jsonError(w, "no container runtime available", http.StatusServiceUnavailable) + return + } + + containers, err := runtime.listContainers(r.Context()) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + stacks := listStacks(containers) + for _, s := range stacks { + if s.Name == name { + jsonOK(w, s) + return + } + } + + jsonError(w, "stack not found", http.StatusNotFound) +} + +// handleListImages returns all local Docker images. +func (p *Gear) handleListImages(w http.ResponseWriter, r *http.Request) { + p.mu.RLock() + cli := p.dockerCli + p.mu.RUnlock() + + if cli == nil { + jsonOK(w, []Image{}) + return + } + + result, err := cli.ImageList(r.Context(), dockerclient.ImageListOptions{All: false}) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + images := make([]Image, 0, len(result.Items)) + for _, img := range result.Items { + tags := img.RepoTags + if len(tags) == 0 { + tags = []string{":"} + } + images = append(images, Image{ + ID: img.ID, + ShortID: shortID(img.ID), + Tags: tags, + Size: img.Size, + }) + } + + jsonOK(w, images) +} + diff --git a/gearbox-agent/internal/gears/containers/plugin_test.go b/gearbox-agent/internal/gears/containers/plugin_test.go new file mode 100644 index 0000000..4eed65f --- /dev/null +++ b/gearbox-agent/internal/gears/containers/plugin_test.go @@ -0,0 +1,385 @@ +// Package containers provides functional tests for the Containers gear HTTP API. +// +// These tests exercise every API endpoint the gear registers, using +// httptest to call the handlers directly. They verify: +// - Correct HTTP status codes +// - Proper JSON response format (not plain text) +// - No "Failed to" prefix leakage from the agent (the JS client adds these) +// - Consistent error response structure +// +// When Docker is not available (e.g., macOS CI), endpoints return empty results +// or ServiceUnavailable — never plain-text errors. +package containers + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/sarg3nt/gearbox-agent/internal/framework/events" + "github.com/sarg3nt/gearbox-agent/internal/framework/gear" +) + +// setupTestGear creates a Gear initialized without a Docker runtime. +// On machines without Docker this will simply have runtimeInfo.Available=false, +// which is the expected production behaviour on non-Docker servers. +func setupTestGear(t *testing.T) *Gear { + t.Helper() + + g := &Gear{} + logger := slog.Default() + bus := events.NewBus() + + deps := gear.Dependencies{ + Logger: logger, + EventBus: bus, + } + + if err := g.Initialize(context.Background(), deps); err != nil { + t.Fatalf("failed to initialize containers gear: %v", err) + } + return g +} + +// setupTestRouter creates a chi router with all gear routes registered. +func setupTestRouter(t *testing.T) (*Gear, *chi.Mux) { + t.Helper() + g := setupTestGear(t) + r := chi.NewRouter() + g.RegisterRoutes(r) + return g, r +} + +// --- helpers --- + +func assertJSONResponse(t *testing.T, w *httptest.ResponseRecorder) { + t.Helper() + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/json") { + t.Errorf("expected Content-Type application/json, got %q", ct) + } + var raw json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Errorf("response body is not valid JSON: %v\nbody: %s", err, w.Body.String()) + } +} + +func assertNoFailedToPrefix(t *testing.T, w *httptest.ResponseRecorder) { + t.Helper() + if strings.Contains(w.Body.String(), "Failed to") { + t.Errorf("response contains 'Failed to' prefix — agent must not add these (JS adds its own)\nbody: %s", w.Body.String()) + } +} + +func assertErrorJSON(t *testing.T, w *httptest.ResponseRecorder, wantStatus int) { + t.Helper() + if w.Code != wantStatus { + t.Errorf("expected status %d, got %d\nbody: %s", wantStatus, w.Code, w.Body.String()) + } + assertJSONResponse(t, w) + assertNoFailedToPrefix(t, w) + + var errResp struct { + Error string `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil { + t.Errorf("failed to parse error JSON: %v\nbody: %s", err, w.Body.String()) + } + if errResp.Error == "" { + t.Error("error response has empty error message") + } +} + +// --- Info / Health --- + +func TestGearInfo(t *testing.T) { + g := setupTestGear(t) + info := g.Info() + if info.Name != "containers" { + t.Errorf("expected gear name 'containers', got %q", info.Name) + } +} + +func TestGearHealth_NoDocker(t *testing.T) { + g := setupTestGear(t) + // When Docker is not available, health should be degraded (not unhealthy/panicking) + h := g.Health() + if h.Status == "" { + t.Error("expected a non-empty health status") + } + // Must be one of the valid constants + valid := map[string]bool{ + gear.HealthStatusHealthy: true, + gear.HealthStatusDegraded: true, + gear.HealthStatusUnhealthy: true, + } + if !valid[h.Status] { + t.Errorf("unexpected health status %q", h.Status) + } +} + +// --- GET /api/v1/containers/runtime --- + +func TestHandleRuntime(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/containers/runtime", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + assertJSONResponse(t, w) + + var info RuntimeInfo + if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil { + t.Fatalf("failed to parse RuntimeInfo: %v", err) + } + // Runtime field must always be set + if info.Runtime == "" { + t.Error("RuntimeInfo.Runtime field must not be empty") + } +} + +// --- GET /api/v1/containers/list --- + +func TestHandleList(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/containers/list", nil) + router.ServeHTTP(w, req) + + // Either 200 with list or an error — never a panic + assertJSONResponse(t, w) + assertNoFailedToPrefix(t, w) + + if w.Code == http.StatusOK { + var list ContainerList + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("failed to parse ContainerList: %v", err) + } + if list.Containers == nil { + t.Error("ContainerList.Containers must not be nil (use empty slice)") + } + if list.Total < 0 { + t.Error("ContainerList.Total must be >= 0") + } + } +} + +// --- GET /api/v1/containers/{id} --- + +func TestHandleInspect_MissingID(t *testing.T) { + // Route with empty id won't match — test with a non-existent container ID + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/containers/nonexistentcontainer123", nil) + router.ServeHTTP(w, req) + + // Either 503 (no runtime) or 500 (runtime error) + if w.Code == http.StatusOK { + assertJSONResponse(t, w) + } else { + assertErrorJSON(t, w, w.Code) + } +} + +// --- GET /api/v1/containers/{id}/stats --- + +func TestHandleStats_NoRuntime(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/containers/abc123/stats", nil) + router.ServeHTTP(w, req) + + assertJSONResponse(t, w) + assertNoFailedToPrefix(t, w) +} + +// --- GET /api/v1/containers/{id}/logs --- + +func TestHandleLogs_InvalidTail(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/containers/abc123/logs?tail=notanumber", nil) + router.ServeHTTP(w, req) + + assertErrorJSON(t, w, http.StatusBadRequest) +} + +func TestHandleLogs_ValidTailAll(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/containers/abc123/logs?tail=all", nil) + router.ServeHTTP(w, req) + + // Either 503 (no runtime) or error — the tail=all is valid + if w.Code == http.StatusBadRequest { + t.Error("tail=all should be accepted as valid input") + } +} + +func TestHandleLogs_ValidTailNumber(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/containers/abc123/logs?tail=50", nil) + router.ServeHTTP(w, req) + + // Should not be a 400 bad request + if w.Code == http.StatusBadRequest { + t.Errorf("tail=50 should be valid, got 400\nbody: %s", w.Body.String()) + } +} + +// --- POST /api/v1/containers/{id}/start --- + +func TestHandleStart_NoRuntime(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/v1/containers/abc123/start", nil) + router.ServeHTTP(w, req) + + assertJSONResponse(t, w) + assertNoFailedToPrefix(t, w) +} + +// --- POST /api/v1/containers/{id}/stop --- + +func TestHandleStop_NoRuntime(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/v1/containers/abc123/stop", nil) + router.ServeHTTP(w, req) + + assertJSONResponse(t, w) + assertNoFailedToPrefix(t, w) +} + +// --- POST /api/v1/containers/{id}/restart --- + +func TestHandleRestart_NoRuntime(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/v1/containers/abc123/restart", nil) + router.ServeHTTP(w, req) + + assertJSONResponse(t, w) + assertNoFailedToPrefix(t, w) +} + +// --- DELETE /api/v1/containers/{id} --- + +func TestHandleRemove_NoRuntime(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("DELETE", "/api/v1/containers/abc123", nil) + router.ServeHTTP(w, req) + + assertJSONResponse(t, w) + assertNoFailedToPrefix(t, w) +} + +// --- GET /api/v1/containers/stacks --- + +func TestHandleListStacks(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/containers/stacks", nil) + router.ServeHTTP(w, req) + + assertJSONResponse(t, w) + assertNoFailedToPrefix(t, w) + + if w.Code == http.StatusOK { + var list StackList + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("failed to parse StackList: %v", err) + } + if list.Stacks == nil { + t.Error("StackList.Stacks must not be nil") + } + } +} + +// --- GET /api/v1/containers/stacks/{name} --- + +func TestHandleStackDetail_NotFound(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/containers/stacks/nonexistent", nil) + router.ServeHTTP(w, req) + + // Either 503 (no runtime) or 404 (stack not found) + assertJSONResponse(t, w) + assertNoFailedToPrefix(t, w) +} + +// --- GET /api/v1/containers/images --- + +func TestHandleListImages(t *testing.T) { + _, router := setupTestRouter(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/containers/images", nil) + router.ServeHTTP(w, req) + + assertJSONResponse(t, w) + assertNoFailedToPrefix(t, w) +} + +// --- Helper function tests --- + +func TestShortID(t *testing.T) { + tests := []struct { + id string + want string + }{ + {"abc123", "abc123"}, + {"abcdefghijklmnop", "abcdefghijkl"}, + {"", ""}, + } + for _, tt := range tests { + if got := shortID(tt.id); got != tt.want { + t.Errorf("shortID(%q) = %q, want %q", tt.id, got, tt.want) + } + } +} + +func TestStackStatus(t *testing.T) { + tests := []struct { + name string + running int + total int + wantStatus string + }{ + {"all running", 3, 3, "running"}, + {"partial", 1, 3, "partial"}, + {"stopped", 0, 3, "stopped"}, + {"empty", 0, 0, "stopped"}, + } + for _, tt := range tests { + s := &Stack{RunningCount: tt.running, ContainerCount: tt.total} + got := stackStatus(s) + if got != tt.wantStatus { + t.Errorf("%s: stackStatus() = %q, want %q", tt.name, got, tt.wantStatus) + } + } +} diff --git a/gearbox-agent/internal/gears/containers/types.go b/gearbox-agent/internal/gears/containers/types.go new file mode 100644 index 0000000..45bee70 --- /dev/null +++ b/gearbox-agent/internal/gears/containers/types.go @@ -0,0 +1,121 @@ +// Package containers provides Docker/container runtime monitoring as a gear. +package containers + +import "time" + +// RuntimeInfo describes the detected container runtime. +type RuntimeInfo struct { + Available bool `json:"available"` + Runtime string `json:"runtime"` // "docker", "none" + Version string `json:"version"` // Docker version string + APIVersion string `json:"api_version"` // Docker API version + ServerOS string `json:"server_os"` // linux, windows + ServerArch string `json:"server_arch"` // amd64, arm64 +} + +// PortMapping represents a container port binding. +type PortMapping struct { + ContainerPort int `json:"container_port"` + HostPort int `json:"host_port"` + Protocol string `json:"protocol"` // tcp, udp + HostIP string `json:"host_ip,omitempty"` +} + +// Mount represents a container volume mount. +type Mount struct { + Type string `json:"type"` // bind, volume, tmpfs + Source string `json:"source"` // Host path or volume name + Destination string `json:"destination"` + Mode string `json:"mode"` + ReadWrite bool `json:"read_write"` +} + +// Container represents a single container. +type Container struct { + ID string `json:"id"` + ShortID string `json:"short_id"` // First 12 chars + Name string `json:"name"` + Image string `json:"image"` + ImageID string `json:"image_id"` + Status string `json:"status"` // running, exited, paused, restarting, created, dead + State string `json:"state"` // running, stopped, paused, etc. + Created time.Time `json:"created"` + StartedAt string `json:"started_at,omitempty"` + FinishedAt string `json:"finished_at,omitempty"` + Ports []PortMapping `json:"ports"` + Labels map[string]string `json:"labels"` + StackName string `json:"stack_name,omitempty"` // from com.docker.compose.project label + ServiceName string `json:"service_name,omitempty"` // from com.docker.compose.service label + Networks []string `json:"networks"` + Mounts []Mount `json:"mounts"` + RestartPolicy string `json:"restart_policy"` + Runtime string `json:"runtime"` // "docker" +} + +// ContainerStats represents resource usage stats for a container. +type ContainerStats struct { + ID string `json:"id"` + Name string `json:"name"` + CPUPercent float64 `json:"cpu_percent"` + MemoryUsage uint64 `json:"memory_usage"` // bytes + MemoryLimit uint64 `json:"memory_limit"` // bytes + MemoryPercent float64 `json:"memory_percent"` + NetworkRx uint64 `json:"network_rx"` // bytes received + NetworkTx uint64 `json:"network_tx"` // bytes sent + DiskRead uint64 `json:"disk_read"` // bytes read + DiskWrite uint64 `json:"disk_write"` // bytes written + PIDs uint64 `json:"pids"` +} + +// Stack represents a Docker Compose project (stack). +type Stack struct { + Name string `json:"name"` + Status string `json:"status"` // running, partial, stopped + ContainerCount int `json:"container_count"` + RunningCount int `json:"running_count"` + ConfigPath string `json:"config_path,omitempty"` // Path to docker-compose.yml + WorkingDir string `json:"working_dir,omitempty"` + HasEnvFile bool `json:"has_env_file"` + Containers []Container `json:"containers,omitempty"` +} + +// Image represents a Docker image. +type Image struct { + ID string `json:"id"` + ShortID string `json:"short_id"` + Tags []string `json:"tags"` + Size int64 `json:"size"` // bytes + Created time.Time `json:"created"` + Containers int `json:"containers"` // Number of running containers +} + +// Network represents a Docker network. +type Network struct { + ID string `json:"id"` + Name string `json:"name"` + Driver string `json:"driver"` + Scope string `json:"scope"` +} + +// Volume represents a Docker volume. +type Volume struct { + Name string `json:"name"` + Driver string `json:"driver"` + Mountpoint string `json:"mountpoint"` +} + +// ContainerList is the response for listing containers. +type ContainerList struct { + Containers []Container `json:"containers"` + Total int `json:"total"` + Running int `json:"running"` + Stopped int `json:"stopped"` + CollectedAt time.Time `json:"collected_at"` +} + +// StackList is the response for listing stacks. +type StackList struct { + Stacks []Stack `json:"stacks"` + Total int `json:"total"` + CollectedAt time.Time `json:"collected_at"` +} diff --git a/gearbox/cmd/server/main.go b/gearbox/cmd/server/main.go index 3c38567..888b81b 100644 --- a/gearbox/cmd/server/main.go +++ b/gearbox/cmd/server/main.go @@ -32,6 +32,7 @@ import ( // Import gears - blank identifier triggers init() registration _ "github.com/sarg3nt/gearbox/internal/gears/alerts" _ "github.com/sarg3nt/gearbox/internal/gears/certificates" + _ "github.com/sarg3nt/gearbox/internal/gears/containers" _ "github.com/sarg3nt/gearbox/internal/gears/haproxy" _ "github.com/sarg3nt/gearbox/internal/gears/home" _ "github.com/sarg3nt/gearbox/internal/gears/logs" diff --git a/gearbox/internal/framework/database/gears.go b/gearbox/internal/framework/database/gears.go index bcdae06..fa7cef3 100644 --- a/gearbox/internal/framework/database/gears.go +++ b/gearbox/internal/framework/database/gears.go @@ -19,6 +19,7 @@ const ( GearAlerts = "alerts" GearOSUpdates = "os_updates" GearHome = "home" + GearContainers = "containers" ) // SystemServerID is the sentinel server_id used for rows that represent @@ -170,6 +171,11 @@ type HomeConfig struct { AttributionShown bool `json:"attribution_shown"` } +// ContainersConfig holds container management integration configuration. +type ContainersConfig struct { + StatsInterval int `json:"stats_interval"` // Seconds between stats collection (default: 10) +} + // OSUpdatesConfig holds OS updates integration configuration. type OSUpdatesConfig struct { CheckFrequencyMinutes int `json:"check_frequency_minutes"` // How often to check for updates (default: 60) @@ -303,6 +309,16 @@ func DefaultGears(serverID string) []Gear { CreatedAt: now, UpdatedAt: now, }, + { + ServerID: serverID, + Name: GearContainers, + DisplayName: "Containers", + Description: "Monitor and manage Docker containers and Compose stacks", + Enabled: false, // Disabled by default + Config: json.RawMessage(`{"stats_interval":10}`), + CreatedAt: now, + UpdatedAt: now, + }, } } diff --git a/gearbox/internal/gears/containers/handlers.go b/gearbox/internal/gears/containers/handlers.go new file mode 100644 index 0000000..a591a5e --- /dev/null +++ b/gearbox/internal/gears/containers/handlers.go @@ -0,0 +1,52 @@ +package containers + +import ( + "net/http" + + "github.com/sarg3nt/gearbox/internal/framework/auth" + "github.com/sarg3nt/gearbox/internal/framework/gear" + "github.com/sarg3nt/gearbox/internal/framework/services" +) + +// Handlers contains HTTP handlers for the containers gear. +type Handlers struct { + deps gear.Dependencies +} + +// NewHandlers creates a new Handlers instance. +func NewHandlers(deps gear.Dependencies) *Handlers { + return &Handlers{deps: deps} +} + +// ContainersPage renders the containers management page. +func (h *Handlers) ContainersPage(w http.ResponseWriter, r *http.Request) { + if !h.deps.Auth.HasPermission(r, "containers", "view") { + http.Error(w, "Forbidden: insufficient permissions to view containers", http.StatusForbidden) + return + } + + user, ok := auth.GetUserFromContext(r.Context()) + if !ok || user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + serverAdapter, ok := h.deps.Servers.(*services.ServerAdapter) + if !ok { + h.deps.Logger.Error("containers: failed to get server adapter - unexpected type") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + servers := serverAdapter.GetEnabledServersAsModels() + + if len(servers) == 0 { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + component := ContainersPage(user, servers) + if err := component.Render(r.Context(), w); err != nil { + h.deps.Logger.Error("containers: failed to render page", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/gearbox/internal/gears/containers/icons.go b/gearbox/internal/gears/containers/icons.go new file mode 100644 index 0000000..55d802c --- /dev/null +++ b/gearbox/internal/gears/containers/icons.go @@ -0,0 +1,10 @@ +package containers + +import "github.com/a-h/templ" + +// ContainersIcon returns the containers gear SVG icon. +func ContainersIcon() templ.Component { + return templ.Raw(` + + `) +} diff --git a/gearbox/internal/gears/containers/pages.templ b/gearbox/internal/gears/containers/pages.templ new file mode 100644 index 0000000..cc5c7c0 --- /dev/null +++ b/gearbox/internal/gears/containers/pages.templ @@ -0,0 +1,191 @@ +package containers + +import ( + "github.com/sarg3nt/gearbox/internal/framework/models" + "github.com/sarg3nt/gearbox/internal/framework/templates/layouts" +) + +// ContainersPage renders the main containers management page. +templ ContainersPage(user *models.User, servers []models.BoxConfig) { + @layouts.Base("Containers", user, "/containers") { + + + + + if len(servers) > 0 { + + } + + +
+ +
+
+ + + + Loading containers... +
+
+ + + + + + + + + + + + +
+ + + + + + + } +} diff --git a/gearbox/internal/gears/containers/plugin.go b/gearbox/internal/gears/containers/plugin.go new file mode 100644 index 0000000..31fef4a --- /dev/null +++ b/gearbox/internal/gears/containers/plugin.go @@ -0,0 +1,73 @@ +// Package containers provides Docker/container runtime monitoring as a gear. +package containers + +import ( + "context" + + "github.com/a-h/templ" + "github.com/go-chi/chi/v5" + "github.com/sarg3nt/gearbox/internal/framework/gear" +) + +func init() { + gear.Register(&Gear{}) +} + +// Gear implements the containers monitoring gear for the dashboard. +type Gear struct { + gear.BaseGear + handlers *Handlers +} + +// Info returns gear metadata. +func (p *Gear) Info() gear.Info { + return gear.Info{ + Name: "containers", + DisplayName: "Containers", + Description: "Monitor and manage Docker containers and Compose stacks", + Version: "1.0.0", + Icon: "container", + Category: "infrastructure", + Core: false, + } +} + +// Initialize sets up the gear. +func (p *Gear) Initialize(ctx context.Context, deps gear.Dependencies) error { + if err := p.BaseGear.Initialize(ctx, deps); err != nil { + return err + } + p.handlers = NewHandlers(deps) + return nil +} + +// RegisterRoutes registers HTTP routes for the containers gear. +func (p *Gear) RegisterRoutes(r chi.Router) { + r.Get("/", p.handlers.ContainersPage) +} + +// SidebarItem returns the sidebar configuration. +func (p *Gear) SidebarItem() *gear.SidebarConfig { + return &gear.SidebarConfig{ + Path: "/containers", + Icon: ContainersIcon(), + DefaultOrder: 50, + RequiresPermission: "containers:view", + } +} + +// SettingsPage returns the settings page component. +func (p *Gear) SettingsPage(config map[string]any) templ.Component { + return ContainersSettings(config) +} + +// Permissions returns the permissions this gear uses. +func (p *Gear) Permissions() []gear.PermissionDef { + return []gear.PermissionDef{ + { + Component: "containers", + Actions: []string{"view", "manage"}, + Description: "View and manage containers and Compose stacks", + }, + } +} diff --git a/gearbox/internal/gears/containers/settings.templ b/gearbox/internal/gears/containers/settings.templ new file mode 100644 index 0000000..794ae10 --- /dev/null +++ b/gearbox/internal/gears/containers/settings.templ @@ -0,0 +1,38 @@ +package containers + +import "github.com/sarg3nt/gearbox/internal/framework/gear" + +// ContainersSettings renders the containers gear settings page. +templ ContainersSettings(config map[string]any) { + {{ statsInterval := gear.ConfigInt(config, "stats_interval", 10) }} +
+

Containers Settings

+

+ Configure Docker container monitoring. Requires Docker to be installed and running on the monitored server. +

+
+
+ + +

+ How often to collect CPU, memory, and network stats from running containers. +

+
+
+
+ +
+
+} diff --git a/gearbox/static/js/containers/containers-page.js b/gearbox/static/js/containers/containers-page.js new file mode 100644 index 0000000..3473629 --- /dev/null +++ b/gearbox/static/js/containers/containers-page.js @@ -0,0 +1,467 @@ +/** + * Containers Page - Docker container and Compose stack management. + * Handles listing, filtering, and performing actions on containers and stacks. + */ + +'use strict'; + +let currentServerID = ''; +let currentView = 'stacks'; // 'stacks' or 'all' +let autoRefreshEnabled = true; +let refreshInterval = null; +let allContainers = []; +let allStacks = []; +let containerStats = {}; // id -> stats +let searchFilter = ''; + +// ─── Initialisation ──────────────────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', () => { + const serverEl = document.getElementById('current-server-id'); + currentServerID = serverEl ? serverEl.value : ''; + load(); + startAutoRefresh(); +}); + +function load() { + showLoading(); + Promise.all([loadRuntime(), loadContainers()]) + .catch(err => showError('Failed to load containers: ' + err.message)) + .finally(hideLoading); +} + +async function loadRuntime() { + const resp = await apiFetch('/api/v1/containers/runtime'); + if (!resp.available) { + showNoRuntime(); + throw new Error('no runtime'); + } +} + +async function loadContainers() { + const resp = await apiFetch('/api/v1/containers/list?server=' + currentServerID); + allContainers = resp.containers || []; + buildStacks(); + render(); +} + +function buildStacks() { + const stackMap = {}; + for (const c of allContainers) { + if (!c.stack_name) continue; + if (!stackMap[c.stack_name]) { + stackMap[c.stack_name] = { name: c.stack_name, containers: [], running: 0 }; + } + stackMap[c.stack_name].containers.push(c); + if (c.state === 'running') stackMap[c.stack_name].running++; + } + allStacks = Object.values(stackMap); +} + +// ─── Rendering ───────────────────────────────────────────────────────────── + +function render() { + hideLoading(); + hideError(); + hideNoRuntime(); + + if (currentView === 'stacks') { + renderStacksView(); + } else { + renderAllContainersView(); + } +} + +function renderStacksView() { + document.getElementById('stacks-view').classList.remove('hidden'); + document.getElementById('all-containers-view').classList.add('hidden'); + + const stacksList = document.getElementById('stacks-list'); + stacksList.innerHTML = ''; + + const filtered = filterContainersList(allStacks.map(s => ({ ...s, _search: s.name }))); + + if (filtered.length === 0 && allStacks.length > 0) { + stacksList.innerHTML = '

No stacks match your search.

'; + } + + for (const stack of filtered) { + stacksList.appendChild(buildStackCard(stack)); + } + + // Ungrouped containers + const ungrouped = allContainers.filter(c => !c.stack_name); + const ungroupedSection = document.getElementById('ungrouped-section'); + if (ungrouped.length > 0) { + ungroupedSection.classList.remove('hidden'); + renderContainerRows('ungrouped-tbody', ungrouped, false); + } else { + ungroupedSection.classList.add('hidden'); + } +} + +function buildStackCard(stack) { + const total = stack.containers.length; + const running = stack.running; + const statusColor = running === total ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' + : running === 0 ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' + : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'; + + const card = document.createElement('div'); + card.className = 'bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-100 dark:border-slate-700 overflow-hidden'; + card.dataset.stackName = stack.name; + card.innerHTML = ` +
+
+ + + + ${escHtml(stack.name)} + + ${running}/${total} running + +
+
+ + + +
+
+ `; + renderContainerRows('stack-tbody-' + stack.name, stack.containers, false); + return card; +} + +function renderAllContainersView() { + document.getElementById('stacks-view').classList.add('hidden'); + document.getElementById('all-containers-view').classList.remove('hidden'); + + const filtered = filterContainersList(allContainers.map(c => ({ ...c, _search: c.name + ' ' + c.stack_name }))); + renderContainerRows('all-containers-tbody', filtered, true); +} + +function renderContainerRows(tbodyId, containers, showStack) { + const tbody = document.getElementById(tbodyId); + if (!tbody) return; + tbody.innerHTML = ''; + + if (containers.length === 0) { + const tr = document.createElement('tr'); + tr.innerHTML = `No containers`; + tbody.appendChild(tr); + return; + } + + for (const c of containers) { + tbody.appendChild(buildContainerRow(c, showStack)); + } +} + +function buildContainerRow(c, showStack) { + const stateColor = c.state === 'running' + ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' + : c.state === 'paused' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300' + : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + + const stats = containerStats[c.id] || {}; + const cpu = stats.cpu_percent !== undefined ? stats.cpu_percent.toFixed(1) + '%' : '—'; + const mem = stats.memory_usage !== undefined ? formatBytes(stats.memory_usage) : '—'; + + const ports = (c.ports || []) + .filter(p => p.host_port > 0) + .map(p => `${p.host_port}:${p.container_port}/${p.protocol}`) + .join(' ') || '—'; + + const stackCell = showStack + ? `${escHtml(c.stack_name || '—')}` : ''; + + const tr = document.createElement('tr'); + tr.className = 'border-t border-gray-50 dark:border-slate-700/50 hover:bg-gray-50 dark:hover:bg-slate-750'; + tr.innerHTML = ` + ${escHtml(c.name)} + ${escHtml(c.image)} + ${stackCell} + + + ${escHtml(c.state || c.status)} + + + ${cpu} + ${mem} + ${ports} + +
+ ${c.state === 'running' + ? ` + ` + : ``} + +
+ `; + return tr; +} + +// ─── Actions ─────────────────────────────────────────────────────────────── + +async function containerAction(id, action, name) { + const method = action === 'remove' ? 'DELETE' : 'POST'; + const url = `/api/v1/containers/${id}/${action}?server=${currentServerID}`; + + if (action === 'remove') { + showConfirm( + `Remove ${name}?`, + `This will permanently remove the container "${name}". Any unsaved data will be lost.`, + async () => { + await doContainerAction(method, url, name, action); + } + ); + return; + } + + try { + await doContainerAction(method, url, name, action); + } catch (err) { + showToast(`Failed to ${action} ${name}: ${err.message}`, 'error'); + } +} + +async function doContainerAction(method, url, name, action) { + const resp = await fetch(url, { method }); + if (!resp.ok) { + const msg = await extractErrorMessage(resp); + throw new Error(msg); + } + showToast(`Container "${name}" ${action}ed.`, 'success'); + await loadContainers(); +} + +async function stackAction(stackName, action) { + // Stack actions are proxied through the dashboard to the agent + // Phase 3 will implement this; for now show a coming-soon toast + showToast(`Stack ${action} for "${stackName}" coming in Phase 3.`, 'info'); +} + +function openLogsPanel(id, name) { + // Simple logs viewer: open in a modal or new panel (Phase 2 detail) + showToast(`Logs for "${name}" coming soon.`, 'info'); +} + +// ─── Server switching ────────────────────────────────────────────────────── + +function switchServer(serverID) { + currentServerID = serverID; + load(); +} + +// ─── View toggle ─────────────────────────────────────────────────────────── + +function setView(view) { + currentView = view; + document.getElementById('view-stacks-btn').className = view === 'stacks' + ? 'px-3 py-1.5 bg-blue-600 text-white text-sm' + : 'px-3 py-1.5 bg-white dark:bg-slate-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 text-sm'; + document.getElementById('view-all-btn').className = view === 'all' + ? 'px-3 py-1.5 bg-blue-600 text-white text-sm' + : 'px-3 py-1.5 bg-white dark:bg-slate-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 text-sm'; + render(); +} + +// ─── Stack expand/collapse ───────────────────────────────────────────────── + +function toggleStackExpand(stackName) { + const card = document.querySelector(`[data-stack-name="${CSS.escape(stackName)}"]`); + if (!card) return; + const body = card.querySelector('.stack-body'); + const icon = card.querySelector('.stack-expand-icon'); + const isOpen = !body.classList.contains('hidden'); + body.classList.toggle('hidden', isOpen); + icon.style.transform = isOpen ? '' : 'rotate(90deg)'; +} + +// ─── Filter ──────────────────────────────────────────────────────────────── + +function filterContainers(value) { + searchFilter = value.toLowerCase(); + render(); +} + +function filterContainersList(items) { + if (!searchFilter) return items; + return items.filter(item => { + const searchable = (item._search || item.name || '').toLowerCase(); + return searchable.includes(searchFilter); + }); +} + +// ─── Auto refresh ────────────────────────────────────────────────────────── + +function startAutoRefresh() { + stopAutoRefresh(); + if (autoRefreshEnabled) { + refreshInterval = setInterval(() => { + if (autoRefreshEnabled) loadContainers().catch(() => {}); + }, 10000); + } +} + +function stopAutoRefresh() { + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + } +} + +function toggleAutoRefresh() { + autoRefreshEnabled = !autoRefreshEnabled; + document.getElementById('auto-refresh-label').textContent = + autoRefreshEnabled ? 'Auto-refresh: On' : 'Auto-refresh: Off'; + if (autoRefreshEnabled) { + startAutoRefresh(); + } else { + stopAutoRefresh(); + } +} + +// ─── Confirm modal ───────────────────────────────────────────────────────── + +let pendingConfirmAction = null; + +function showConfirm(title, message, action) { + pendingConfirmAction = action; + document.getElementById('confirm-title').textContent = title; + document.getElementById('confirm-message').textContent = message; + document.getElementById('confirm-modal').classList.remove('hidden'); +} + +function closeConfirmModal() { + pendingConfirmAction = null; + document.getElementById('confirm-modal').classList.add('hidden'); +} + +document.getElementById('confirm-action-btn')?.addEventListener('click', async () => { + closeConfirmModal(); + if (pendingConfirmAction) { + try { + await pendingConfirmAction(); + } catch (err) { + showToast('Action failed: ' + err.message, 'error'); + } + } +}); + +// ─── UI helpers ──────────────────────────────────────────────────────────── + +function showLoading() { + document.getElementById('containers-loading')?.classList.remove('hidden'); + document.getElementById('stacks-view')?.classList.add('hidden'); + document.getElementById('all-containers-view')?.classList.add('hidden'); +} + +function hideLoading() { + document.getElementById('containers-loading')?.classList.add('hidden'); +} + +function showError(msg) { + hideLoading(); + document.getElementById('containers-error')?.classList.remove('hidden'); + const msgEl = document.getElementById('containers-error-msg'); + if (msgEl) msgEl.textContent = msg; +} + +function hideError() { + document.getElementById('containers-error')?.classList.add('hidden'); +} + +function showNoRuntime() { + hideLoading(); + document.getElementById('containers-no-runtime')?.classList.remove('hidden'); +} + +function hideNoRuntime() { + document.getElementById('containers-no-runtime')?.classList.add('hidden'); +} + +function showToast(message, type = 'info') { + // Re-use existing global toast function if available + if (typeof window.showToast === 'function') { + window.showToast(message, type); + return; + } + console.log(`[${type}] ${message}`); +} + +// ─── Fetch helpers ───────────────────────────────────────────────────────── + +async function apiFetch(url) { + const resp = await fetch(url); + if (!resp.ok) { + const msg = await extractErrorMessage(resp); + throw new Error(msg); + } + return resp.json(); +} + +async function extractErrorMessage(response) { + try { + const text = await response.text(); + try { + const json = JSON.parse(text); + return json.message || json.error || text; + } catch { + return text || response.statusText; + } + } catch { + return response.statusText || 'Unknown error'; + } +} + +// ─── Formatting ──────────────────────────────────────────────────────────── + +function formatBytes(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'; + return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB'; +} + +function escHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} From 62fe1e253776bfbafb1d36d64524f730af359442 Mon Sep 17 00:00:00 2001 From: David Sargent Date: Wed, 18 Mar 2026 21:15:00 -0700 Subject: [PATCH 2/7] feat: implement RootHandler and gearPath for container navigation --- gearbox/cmd/server/main.go | 8 +-- gearbox/internal/framework/handler/root.go | 50 +++++++++++++++++++ .../framework/templates/layouts/base.templ | 2 + 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 gearbox/internal/framework/handler/root.go diff --git a/gearbox/cmd/server/main.go b/gearbox/cmd/server/main.go index 888b81b..63eded1 100644 --- a/gearbox/cmd/server/main.go +++ b/gearbox/cmd/server/main.go @@ -588,10 +588,12 @@ func main() { r.Get("/backup", h.BackupPage) }) + // Root redirect - sends "/" to the first enabled gear in nav order + r.Get("/", h.RootHandler) + // Gear-registered routes - // Gears handle: / (haproxy overview), /status-grid (haproxy), /logs (logs), - // /services (services), /history (metrics), /certificates (certificates), - // /traffic (traffic), /alerts (alerts) + // Gears handle: /haproxy (overview), /status-grid, /logs, /services, + // /history (metrics), /certificates, /traffic, /alerts, /containers gearManager.RegisterRoutes(r) // Page routes (non-gear) diff --git a/gearbox/internal/framework/handler/root.go b/gearbox/internal/framework/handler/root.go new file mode 100644 index 0000000..b157ee4 --- /dev/null +++ b/gearbox/internal/framework/handler/root.go @@ -0,0 +1,50 @@ +package handler + +import ( + "net/http" + + "github.com/sarg3nt/gearbox/internal/framework/auth" +) + +// gearPath maps a gear name to its URL path. +func gearPath(name string) string { + switch name { + case "haproxy": + return "/haproxy" + case "metrics": + return "/history" + case "logs": + return "/logs" + case "services": + return "/services" + case "certificates": + return "/certificates" + case "traffic": + return "/traffic" + case "alerts": + return "/alerts" + case "os_updates": + return "/os-updates" + case "containers": + return "/containers" + default: + return "" + } +} + +// RootHandler redirects "/" to the first enabled gear in the nav order. +// Falls back to /settings if no gears are enabled. +func (h *Handler) RootHandler(w http.ResponseWriter, r *http.Request) { + integrations, ok := auth.GetGearOrderFromContext(r.Context()) + if ok { + for _, integration := range integrations { + if integration.Enabled { + if path := gearPath(integration.Name); path != "" { + http.Redirect(w, r, path, http.StatusSeeOther) + return + } + } + } + } + http.Redirect(w, r, "/settings", http.StatusSeeOther) +} diff --git a/gearbox/internal/framework/templates/layouts/base.templ b/gearbox/internal/framework/templates/layouts/base.templ index 7b01964..edf9747 100644 --- a/gearbox/internal/framework/templates/layouts/base.templ +++ b/gearbox/internal/framework/templates/layouts/base.templ @@ -1702,6 +1702,8 @@ func integrationPath(name string) string { return "/alerts" case "os_updates": return "/os-updates" + case "containers": + return "/containers" default: return "/" } From cdd32ed53bd4e69738279c2be5fd4d908bbda901 Mon Sep 17 00:00:00 2001 From: David Sargent Date: Wed, 18 Mar 2026 21:25:45 -0700 Subject: [PATCH 3/7] feat: add docker-push-dev target to build and push Docker image --- gearbox/Makefile | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/gearbox/Makefile b/gearbox/Makefile index 908592f..96e4755 100644 --- a/gearbox/Makefile +++ b/gearbox/Makefile @@ -1,7 +1,8 @@ -.PHONY: help build test test-coverage lint clean clean-data run dev templ-generate fmt tidy deps install-tools dev-assets deploy deploy-build deploy-restart +.PHONY: help build test test-coverage lint clean clean-data run dev templ-generate fmt tidy deps install-tools dev-assets deploy deploy-build deploy-restart docker-push-dev # Variables APP_NAME := gearbox +IMAGE := ghcr.io/sarg3nt/gearbox/gearbox VERSION ?= dev COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") @@ -94,6 +95,17 @@ install-tools: ## Install development tools @go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest @go install github.com/air-verse/air@latest +docker-push-dev: ## Build and push Docker image to ghcr.io with the 'dev' tag + @echo "Building and pushing $(IMAGE):dev..." + @docker build \ + --build-arg VERSION=dev \ + --build-arg COMMIT_SHA=$(COMMIT_SHA) \ + --build-arg BUILD_DATE=$(BUILD_DATE) \ + -t $(IMAGE):dev \ + . + @docker push $(IMAGE):dev + @echo "✓ Pushed $(IMAGE):dev" + dev-assets: ## Download CDN assets locally for secure development (CSP-compliant) @echo "Downloading CDN assets for local development..." @mkdir -p static/js/vendor static/css/vendor From d8ee69b7c7d1de498311fef7c00c7f6884d59e39 Mon Sep 17 00:00:00 2001 From: David Sargent Date: Wed, 18 Mar 2026 21:53:12 -0700 Subject: [PATCH 4/7] feat: enhance docker-push-dev target to use buildx for multi-platform support --- gearbox-agent/Makefile | 14 +++++++++++++- gearbox/Makefile | 5 +++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/gearbox-agent/Makefile b/gearbox-agent/Makefile index cb6f780..82122ef 100644 --- a/gearbox-agent/Makefile +++ b/gearbox-agent/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build build-linux build-release test lint clean deploy run swagger swagger-install upload-binary docker-build docker-push docker-run docker-stop +.PHONY: help build build-linux build-release test lint clean deploy run swagger swagger-install upload-binary docker-build docker-push docker-push-dev docker-run docker-stop # Variables APP_NAME := gearbox-agent @@ -170,6 +170,18 @@ docker-build: ## Build Docker image . @echo "Docker image built successfully" +docker-push-dev: ## Build and push Docker image to ghcr.io with the 'dev' tag (linux/amd64) + @echo "Building and pushing $(DOCKER_IMAGE):dev..." + @docker buildx build \ + --platform linux/amd64 \ + --build-arg VERSION=dev \ + --build-arg COMMIT_SHA=$(COMMIT_SHA) \ + --build-arg BUILD_DATE=$(BUILD_DATE) \ + -t $(DOCKER_IMAGE):dev \ + --push \ + . + @echo "✓ Pushed $(DOCKER_IMAGE):dev" + docker-push: docker-build ## Build and push Docker image to registry @echo "Pushing Docker image: $(DOCKER_IMAGE):$(DOCKER_TAG)" @docker push $(DOCKER_IMAGE):$(DOCKER_TAG) diff --git a/gearbox/Makefile b/gearbox/Makefile index 96e4755..26d4ae9 100644 --- a/gearbox/Makefile +++ b/gearbox/Makefile @@ -97,13 +97,14 @@ install-tools: ## Install development tools docker-push-dev: ## Build and push Docker image to ghcr.io with the 'dev' tag @echo "Building and pushing $(IMAGE):dev..." - @docker build \ + @docker buildx build \ + --platform linux/amd64 \ --build-arg VERSION=dev \ --build-arg COMMIT_SHA=$(COMMIT_SHA) \ --build-arg BUILD_DATE=$(BUILD_DATE) \ -t $(IMAGE):dev \ + --push \ . - @docker push $(IMAGE):dev @echo "✓ Pushed $(IMAGE):dev" dev-assets: ## Download CDN assets locally for secure development (CSP-compliant) From e275b19ac598f8b581387c8c99ab6d632b40fa64 Mon Sep 17 00:00:00 2001 From: David Sargent Date: Wed, 18 Mar 2026 22:35:51 -0700 Subject: [PATCH 5/7] feat: add skipTLSVerify option to agent client and update related configurations --- gearbox/internal/framework/agent/client.go | 27 ++++++----------- .../internal/framework/collector/registry.go | 4 +-- .../framework/collector/websocket_manager.go | 2 +- gearbox/internal/framework/config/config.go | 1 - ...0001_add_skip_tls_verify_to_boxes.down.sql | 4 +++ ...000001_add_skip_tls_verify_to_boxes.up.sql | 1 + .../internal/framework/database/servers.go | 29 ++++++++++++------- .../framework/handler/api_certificates.go | 6 ++-- .../framework/handler/api_services.go | 6 ++-- .../internal/framework/handler/api_traffic.go | 4 +-- gearbox/internal/framework/handler/config.go | 2 +- gearbox/internal/framework/handler/gears.go | 4 +-- .../framework/handler/haproxy_config.go | 26 ++++++++++------- gearbox/internal/framework/models/server.go | 5 ++-- .../templates/pages/haproxy_settings.templ | 19 ++++++++++++ 15 files changed, 84 insertions(+), 56 deletions(-) create mode 100644 gearbox/internal/framework/database/migrations/files/000001_add_skip_tls_verify_to_boxes.down.sql create mode 100644 gearbox/internal/framework/database/migrations/files/000001_add_skip_tls_verify_to_boxes.up.sql diff --git a/gearbox/internal/framework/agent/client.go b/gearbox/internal/framework/agent/client.go index 7668014..c82e5d3 100644 --- a/gearbox/internal/framework/agent/client.go +++ b/gearbox/internal/framework/agent/client.go @@ -36,18 +36,16 @@ type Client struct { } // NewClient creates a new HAProxy Agent API client. -func NewClient(baseURL, apiKey string) *Client { - return NewClientWithTimeout(baseURL, apiKey, DefaultTimeout) +func NewClient(baseURL, apiKey string, skipTLSVerify bool) *Client { + return NewClientWithTimeout(baseURL, apiKey, skipTLSVerify, DefaultTimeout) } // NewClientWithTimeout creates a new HAProxy Agent API client with a custom timeout. -func NewClientWithTimeout(baseURL, apiKey string, timeout time.Duration) *Client { +func NewClientWithTimeout(baseURL, apiKey string, skipTLSVerify bool, timeout time.Duration) *Client { // Ensure baseURL doesn't have trailing slash baseURL = strings.TrimSuffix(baseURL, "/") - // SECURITY FIX: Implement certificate pinning instead of InsecureSkipVerify - // Create TLS configuration with certificate verification - tlsConfig := createTLSConfig() + tlsConfig := createTLSConfig(skipTLSVerify) transport := &http.Transport{ TLSClientConfig: tlsConfig, @@ -99,19 +97,12 @@ func NewClientWithTimeout(baseURL, apiKey string, timeout time.Duration) *Client } // createTLSConfig creates a TLS configuration with certificate verification. -// Supports three modes via environment variables: -// 1. AGENT_CA_CERT_PATH: Path to CA certificate for validation (RECOMMENDED) -// 2. GEARBOX_INSECURE_TLS=true: Skip verification (NOT RECOMMENDED for production) -// 3. Default: Use system certificate pool -func createTLSConfig() *tls.Config { - // Check if user wants to skip TLS verification (insecure mode) - if os.Getenv("GEARBOX_INSECURE_TLS") == "true" { - // Log warning about insecure mode - // Note: In production code, use a proper logger - fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification is DISABLED (GEARBOX_INSECURE_TLS=true)\n") - fmt.Fprintf(os.Stderr, "WARNING: This is NOT recommended for production use\n") +// If skipTLSVerify is true, certificate verification is disabled for this connection only. +// Supports AGENT_CA_CERT_PATH env var for custom CA certificates. +func createTLSConfig(skipTLSVerify bool) *tls.Config { + if skipTLSVerify { return &tls.Config{ - InsecureSkipVerify: true, //#nosec G402 -- User explicitly opted in via GEARBOX_INSECURE_TLS env var + InsecureSkipVerify: true, //#nosec G402 -- User explicitly opted in per-box via UI setting MinVersion: tls.VersionTLS12, } } diff --git a/gearbox/internal/framework/collector/registry.go b/gearbox/internal/framework/collector/registry.go index c7f3c2b..3e9252c 100644 --- a/gearbox/internal/framework/collector/registry.go +++ b/gearbox/internal/framework/collector/registry.go @@ -76,7 +76,7 @@ func (r *Registry) AddCollector(serverConfig models.BoxConfig) error { } // Create Agent client - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) // Create collector manager with Agent client manager := NewManager( @@ -139,7 +139,7 @@ func (r *Registry) ReloadCollector(serverConfig models.BoxConfig) error { } // Create Agent client - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) // Create collector manager with Agent client manager := NewManager( diff --git a/gearbox/internal/framework/collector/websocket_manager.go b/gearbox/internal/framework/collector/websocket_manager.go index e0cc92d..8661e3e 100644 --- a/gearbox/internal/framework/collector/websocket_manager.go +++ b/gearbox/internal/framework/collector/websocket_manager.go @@ -67,7 +67,7 @@ func (m *WebSocketManager) Connect(serverConfig models.BoxConfig) error { } // Create agent client - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) // Create event handler that processes events and triggers actions handler := m.createEventHandler(serverConfig.ID, serverConfig.Name) diff --git a/gearbox/internal/framework/config/config.go b/gearbox/internal/framework/config/config.go index f2f37d2..4027d16 100644 --- a/gearbox/internal/framework/config/config.go +++ b/gearbox/internal/framework/config/config.go @@ -22,7 +22,6 @@ var secretEnvVars = map[string]bool{ "WEBAUTHN_RP_DISPLAY": false, "WEBAUTHN_RP_ORIGINS": false, "AGENT_CA_CERT_PATH": false, - "GEARBOX_INSECURE_TLS": false, "CSP_REPORT_URI": false, "CSP_EXTRA_SOURCES": false, "AGENT_ALLOWED_ORIGINS": false, diff --git a/gearbox/internal/framework/database/migrations/files/000001_add_skip_tls_verify_to_boxes.down.sql b/gearbox/internal/framework/database/migrations/files/000001_add_skip_tls_verify_to_boxes.down.sql new file mode 100644 index 0000000..7168f00 --- /dev/null +++ b/gearbox/internal/framework/database/migrations/files/000001_add_skip_tls_verify_to_boxes.down.sql @@ -0,0 +1,4 @@ +-- SQLite does not support DROP COLUMN in older versions; recreate table without skip_tls_verify +CREATE TABLE boxes_backup AS SELECT id, box_id, name, location, notes, agent_url, api_key_encrypted, enabled, auto_discovery, created_at, updated_at, created_by FROM boxes; +DROP TABLE boxes; +ALTER TABLE boxes_backup RENAME TO boxes; diff --git a/gearbox/internal/framework/database/migrations/files/000001_add_skip_tls_verify_to_boxes.up.sql b/gearbox/internal/framework/database/migrations/files/000001_add_skip_tls_verify_to_boxes.up.sql new file mode 100644 index 0000000..53a6cb6 --- /dev/null +++ b/gearbox/internal/framework/database/migrations/files/000001_add_skip_tls_verify_to_boxes.up.sql @@ -0,0 +1 @@ +ALTER TABLE boxes ADD COLUMN skip_tls_verify INTEGER NOT NULL DEFAULT 0; diff --git a/gearbox/internal/framework/database/servers.go b/gearbox/internal/framework/database/servers.go index 0232298..640302f 100644 --- a/gearbox/internal/framework/database/servers.go +++ b/gearbox/internal/framework/database/servers.go @@ -19,6 +19,7 @@ type BoxDB struct { APIKeyEncrypted []byte Enabled bool AutoDiscovery bool + SkipTLSVerify bool CreatedAt time.Time UpdatedAt time.Time CreatedBy *string // UUID @@ -38,8 +39,8 @@ func (d *DB) CreateBox(box *BoxDB) error { query := ` INSERT INTO boxes ( box_id, name, location, notes, agent_url, api_key_encrypted, - enabled, auto_discovery, created_by, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + enabled, auto_discovery, skip_tls_verify, created_by, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ` result, err := d.db.Exec(query, @@ -51,6 +52,7 @@ func (d *DB) CreateBox(box *BoxDB) error { box.APIKeyEncrypted, box.Enabled, box.AutoDiscovery, + box.SkipTLSVerify, box.CreatedBy, ) if err != nil { @@ -73,7 +75,7 @@ func (d *DB) GetBoxes() ([]*BoxDB, error) { query := ` SELECT id, box_id, name, location, notes, agent_url, api_key_encrypted, - enabled, auto_discovery, created_at, updated_at, created_by + enabled, auto_discovery, skip_tls_verify, created_at, updated_at, created_by FROM boxes ORDER BY name ASC ` @@ -97,6 +99,7 @@ func (d *DB) GetBoxes() ([]*BoxDB, error) { &box.APIKeyEncrypted, &box.Enabled, &box.AutoDiscovery, + &box.SkipTLSVerify, &box.CreatedAt, &box.UpdatedAt, &box.CreatedBy, @@ -122,7 +125,7 @@ func (d *DB) GetEnabledBoxes() ([]*BoxDB, error) { query := ` SELECT id, box_id, name, location, notes, agent_url, api_key_encrypted, - enabled, auto_discovery, created_at, updated_at, created_by + enabled, auto_discovery, skip_tls_verify, created_at, updated_at, created_by FROM boxes WHERE enabled = 1 ORDER BY name ASC @@ -147,6 +150,7 @@ func (d *DB) GetEnabledBoxes() ([]*BoxDB, error) { &box.APIKeyEncrypted, &box.Enabled, &box.AutoDiscovery, + &box.SkipTLSVerify, &box.CreatedAt, &box.UpdatedAt, &box.CreatedBy, @@ -172,7 +176,7 @@ func (d *DB) GetBoxByID(id int64) (*BoxDB, error) { query := ` SELECT id, box_id, name, location, notes, agent_url, api_key_encrypted, - enabled, auto_discovery, created_at, updated_at, created_by + enabled, auto_discovery, skip_tls_verify, created_at, updated_at, created_by FROM boxes WHERE id = ? ` @@ -188,6 +192,7 @@ func (d *DB) GetBoxByID(id int64) (*BoxDB, error) { &box.APIKeyEncrypted, &box.Enabled, &box.AutoDiscovery, + &box.SkipTLSVerify, &box.CreatedAt, &box.UpdatedAt, &box.CreatedBy, @@ -209,7 +214,7 @@ func (d *DB) GetBoxByBoxID(boxID string) (*BoxDB, error) { query := ` SELECT id, box_id, name, location, notes, agent_url, api_key_encrypted, - enabled, auto_discovery, created_at, updated_at, created_by + enabled, auto_discovery, skip_tls_verify, created_at, updated_at, created_by FROM boxes WHERE box_id = ? ` @@ -225,6 +230,7 @@ func (d *DB) GetBoxByBoxID(boxID string) (*BoxDB, error) { &box.APIKeyEncrypted, &box.Enabled, &box.AutoDiscovery, + &box.SkipTLSVerify, &box.CreatedAt, &box.UpdatedAt, &box.CreatedBy, @@ -254,6 +260,7 @@ func (d *DB) UpdateBox(box *BoxDB) error { api_key_encrypted = ?, enabled = ?, auto_discovery = ?, + skip_tls_verify = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ` @@ -267,6 +274,7 @@ func (d *DB) UpdateBox(box *BoxDB) error { box.APIKeyEncrypted, box.Enabled, box.AutoDiscovery, + box.SkipTLSVerify, box.ID, ) if err != nil { @@ -363,9 +371,10 @@ func (d *DB) CountEnabledBoxes() (int, error) { // This requires decryption of the API key, which should be done by the caller. func (b *BoxDB) ToBoxConfig(apiKey string) models.BoxConfig { return models.BoxConfig{ - ID: b.BoxID, - Name: b.Name, - AgentURL: b.AgentURL, - APIKey: apiKey, + ID: b.BoxID, + Name: b.Name, + AgentURL: b.AgentURL, + APIKey: apiKey, + SkipTLSVerify: b.SkipTLSVerify, } } diff --git a/gearbox/internal/framework/handler/api_certificates.go b/gearbox/internal/framework/handler/api_certificates.go index 0f2ef8b..fa74c0a 100644 --- a/gearbox/internal/framework/handler/api_certificates.go +++ b/gearbox/internal/framework/handler/api_certificates.go @@ -44,7 +44,7 @@ func (h *Handler) APICertificatesHandler(w http.ResponseWriter, r *http.Request) return } - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) certsResp, err := agentClient.GetCertificates() if err != nil { apperrors.WriteHTTPError(w, h.logger, apperrors.Internal("get certificates", err)) @@ -81,7 +81,7 @@ func (h *Handler) APICertificateRefreshHandler(w http.ResponseWriter, r *http.Re return } - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) result, err := agentClient.RefreshCertificate(domain) if err != nil { apperrors.WriteHTTPError(w, h.logger, apperrors.Internal("refresh certificate", err)) @@ -118,7 +118,7 @@ func (h *Handler) APICertificateDownloadHandler(w http.ResponseWriter, r *http.R return } - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) certData, filename, err := agentClient.DownloadCertificate(domain) if err != nil { apperrors.WriteHTTPError(w, h.logger, apperrors.Internal("download certificate", err)) diff --git a/gearbox/internal/framework/handler/api_services.go b/gearbox/internal/framework/handler/api_services.go index 25716b8..4b3503b 100644 --- a/gearbox/internal/framework/handler/api_services.go +++ b/gearbox/internal/framework/handler/api_services.go @@ -79,7 +79,7 @@ func (h *Handler) APIServicesHandler(w http.ResponseWriter, r *http.Request) { } // Fetch from agent - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) servicesResp, err := agentClient.GetServices(svcNames) if err != nil { apperrors.WriteHTTPError(w, h.logger, apperrors.Internal("get services", err)) @@ -145,7 +145,7 @@ func (h *Handler) APIServiceControlHandler(w http.ResponseWriter, r *http.Reques } // Forward to agent - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) resp, err := agentClient.ServiceControl(req.Service, req.Action) if err != nil { apperrors.WriteHTTPError(w, h.logger, apperrors.Internal("control service", err)) @@ -162,7 +162,7 @@ func (h *Handler) fetchServicesFromAgent(boxID string) (*agent.ServicesResponse, return nil, apperrors.NotFound("server", nil) } - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) return agentClient.GetServices(nil) } diff --git a/gearbox/internal/framework/handler/api_traffic.go b/gearbox/internal/framework/handler/api_traffic.go index 7662d65..bd52939 100644 --- a/gearbox/internal/framework/handler/api_traffic.go +++ b/gearbox/internal/framework/handler/api_traffic.go @@ -41,7 +41,7 @@ func (h *Handler) APITrafficAnalysisHandler(w http.ResponseWriter, r *http.Reque } // Get traffic data from agent - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) trafficData, err := agentClient.GetTraffic(1000, 25) if err != nil { apperrors.WriteHTTPError(w, h.logger, apperrors.Internal("get traffic data", err)) @@ -241,7 +241,7 @@ func (h *Handler) APITrafficNetworkHandler(w http.ResponseWriter, r *http.Reques } // Get traffic data from agent - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) trafficData, err := agentClient.GetTraffic(100, 15) if err != nil { h.logger.Error("Failed to get traffic data for network view", "error", err) diff --git a/gearbox/internal/framework/handler/config.go b/gearbox/internal/framework/handler/config.go index 7409d9c..e21d387 100644 --- a/gearbox/internal/framework/handler/config.go +++ b/gearbox/internal/framework/handler/config.go @@ -785,7 +785,7 @@ func (h *Handler) getAgentClient(server *database.BoxDB) (*agent.Client, error) return nil, fmt.Errorf("failed to decrypt API key: %w", err) } - return agent.NewClient(server.AgentURL, apiKey), nil + return agent.NewClient(server.AgentURL, apiKey, server.SkipTLSVerify), nil } func (h *Handler) jsonError(w http.ResponseWriter, message string, status int) { diff --git a/gearbox/internal/framework/handler/gears.go b/gearbox/internal/framework/handler/gears.go index 14f6f5c..13811a2 100644 --- a/gearbox/internal/framework/handler/gears.go +++ b/gearbox/internal/framework/handler/gears.go @@ -680,7 +680,7 @@ func (h *Handler) getAvailableLogSources(boxID string) []pages.LogSourceInfo { } // Fetch available log sources from agent - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) sourcesResp, err := agentClient.GetLogSources() if err != nil { h.logger.Error("failed to get log sources from agent for server", "server_id", boxID, "error", err) @@ -709,7 +709,7 @@ func (h *Handler) getInstalledServices(boxID string) []string { } // Fetch available services from agent - agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey) + agentClient := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey, serverConfig.SkipTLSVerify) availableResp, err := agentClient.GetAvailableServices() if err != nil { h.logger.Error("failed to get available services from agent for server", "server_id", boxID, "error", err) diff --git a/gearbox/internal/framework/handler/haproxy_config.go b/gearbox/internal/framework/handler/haproxy_config.go index 7fee164..a41bcd2 100644 --- a/gearbox/internal/framework/handler/haproxy_config.go +++ b/gearbox/internal/framework/handler/haproxy_config.go @@ -95,13 +95,14 @@ func (h *Handler) HAProxyBoxCreatePost(w http.ResponseWriter, r *http.Request) { // Parse form into server struct server := &database.BoxDB{ - BoxID: strings.TrimSpace(r.FormValue("box_id")), - Name: strings.TrimSpace(r.FormValue("name")), - Location: strings.TrimSpace(r.FormValue("location")), - Notes: strings.TrimSpace(r.FormValue("notes")), - AgentURL: strings.TrimSpace(r.FormValue("agent_url")), - Enabled: r.FormValue("enabled") == "on", - CreatedBy: &user.ID, + BoxID: strings.TrimSpace(r.FormValue("box_id")), + Name: strings.TrimSpace(r.FormValue("name")), + Location: strings.TrimSpace(r.FormValue("location")), + Notes: strings.TrimSpace(r.FormValue("notes")), + AgentURL: strings.TrimSpace(r.FormValue("agent_url")), + Enabled: r.FormValue("enabled") == "on", + SkipTLSVerify: r.FormValue("skip_tls_verify") == "on", + CreatedBy: &user.ID, } // Validate required fields @@ -230,6 +231,7 @@ func (h *Handler) HAProxyBoxUpdatePost(w http.ResponseWriter, r *http.Request) { server.Notes = strings.TrimSpace(r.FormValue("notes")) server.AgentURL = strings.TrimSpace(r.FormValue("agent_url")) server.Enabled = r.FormValue("enabled") == "on" + server.SkipTLSVerify = r.FormValue("skip_tls_verify") == "on" // Update API key if provided apiKey := strings.TrimSpace(r.FormValue("api_key")) @@ -450,8 +452,10 @@ func (h *Handler) HAProxyBoxTestConnectionPost(w http.ResponseWriter, r *http.Re h.logger.Debug("test connection", "agent_url", agentURL) + skipTLSVerify := r.FormValue("skip_tls_verify") == "true" || r.FormValue("skip_tls_verify") == "on" + // Create Agent client and test connection - client := agent.NewClient(agentURL, apiKey) + client := agent.NewClient(agentURL, apiKey, skipTLSVerify) // Step 1: Test health endpoint (no auth required) _, err := client.Health() @@ -569,7 +573,7 @@ func (h *Handler) HAProxyBoxLogSettingsPage(w http.ResponseWriter, r *http.Reque encryptor, err := h.getEncryptor() if err == nil { apiKey, _ := encryptor.DecryptString(server.APIKeyEncrypted) - client := agent.NewClient(server.AgentURL, apiKey) + client := agent.NewClient(server.AgentURL, apiKey, server.SkipTLSVerify) sourcesResp, err := client.GetLogSources() if err == nil && sourcesResp != nil { agentSources = sourcesResp.Sources @@ -637,7 +641,7 @@ func (h *Handler) HAProxyBoxLogSettingsPost(w http.ResponseWriter, r *http.Reque encryptor, err := h.getEncryptor() if err == nil { apiKey, _ := encryptor.DecryptString(server.APIKeyEncrypted) - client := agent.NewClient(server.AgentURL, apiKey) + client := agent.NewClient(server.AgentURL, apiKey, server.SkipTLSVerify) sourcesResp, err := client.GetLogSources() if err == nil && sourcesResp != nil { for _, src := range sourcesResp.Sources { @@ -682,7 +686,7 @@ func (h *Handler) renderLogSettingsWithError(w http.ResponseWriter, r *http.Requ encryptor, err := h.getEncryptor() if err == nil { apiKey, _ := encryptor.DecryptString(server.APIKeyEncrypted) - client := agent.NewClient(server.AgentURL, apiKey) + client := agent.NewClient(server.AgentURL, apiKey, server.SkipTLSVerify) sourcesResp, err := client.GetLogSources() if err == nil && sourcesResp != nil { agentSources = sourcesResp.Sources diff --git a/gearbox/internal/framework/models/server.go b/gearbox/internal/framework/models/server.go index e93b14c..d8909b0 100644 --- a/gearbox/internal/framework/models/server.go +++ b/gearbox/internal/framework/models/server.go @@ -6,8 +6,9 @@ type BoxConfig struct { Name string `json:"name"` // Agent API connection - AgentURL string `json:"agent_url,omitempty"` // e.g., "https://light-hugger.sarg3.net:8405" - APIKey string `json:"api_key,omitempty"` // Bearer token for Agent API + AgentURL string `json:"agent_url,omitempty"` // e.g., "https://light-hugger.sarg3.net:8405" + APIKey string `json:"api_key,omitempty"` // Bearer token for Agent API + SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` // Skip TLS certificate verification (for self-signed certs) } // UsesAgentAPI returns true if this box is configured to use the Agent API. diff --git a/gearbox/internal/framework/templates/pages/haproxy_settings.templ b/gearbox/internal/framework/templates/pages/haproxy_settings.templ index 1023be3..0b15a2c 100644 --- a/gearbox/internal/framework/templates/pages/haproxy_settings.templ +++ b/gearbox/internal/framework/templates/pages/haproxy_settings.templ @@ -631,6 +631,23 @@ templ haProxyBoxForm(user *models.User, server *database.BoxDB, isEdit bool, err

Leave blank to keep existing API key

} +
+ +

+ Disable TLS certificate verification for this box (e.g., when the agent uses a self-signed certificate). Use with caution. +

+
@@ -783,6 +800,8 @@ templ haProxyBoxForm(user *models.User, server *database.BoxDB, isEdit bool, err formData.append('server_id', document.getElementById('server_id')?.value || ''); formData.append('agent_url', document.getElementById('agent_url')?.value || ''); formData.append('api_key', document.getElementById('api_key')?.value || ''); + const skipTLS = document.getElementById('skip_tls_verify'); + if (skipTLS && skipTLS.checked) { formData.append('skip_tls_verify', 'true'); } try { const response = await fetch('/settings/boxes/test', { From d228f22e5cec38a4e9d90709f85fac2e220aba8c Mon Sep 17 00:00:00 2001 From: David Sargent Date: Wed, 18 Mar 2026 22:45:28 -0700 Subject: [PATCH 6/7] feat: update HAProxyBoxTestConnectionPost to enhance connection verification steps --- gearbox/internal/framework/handler/haproxy_config.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gearbox/internal/framework/handler/haproxy_config.go b/gearbox/internal/framework/handler/haproxy_config.go index a41bcd2..86e22d6 100644 --- a/gearbox/internal/framework/handler/haproxy_config.go +++ b/gearbox/internal/framework/handler/haproxy_config.go @@ -457,7 +457,7 @@ func (h *Handler) HAProxyBoxTestConnectionPost(w http.ResponseWriter, r *http.Re // Create Agent client and test connection client := agent.NewClient(agentURL, apiKey, skipTLSVerify) - // Step 1: Test health endpoint (no auth required) + // Step 1: Test health endpoint (no auth required) — verifies connectivity and TLS _, err := client.Health() if err != nil { w.Header().Set("Content-Type", "application/json") @@ -468,8 +468,8 @@ func (h *Handler) HAProxyBoxTestConnectionPost(w http.ResponseWriter, r *http.Re return } - // Step 2: Test authenticated endpoint - _, err = client.GetInfo() + // Step 2: Test authenticated endpoint — verifies the API key is valid + _, err = client.GetMetrics() if err != nil { w.Header().Set("Content-Type", "application/json") if agent.IsUnauthorized(err) { From 672ed343276f6d99e747d5961916f3997b0767eb Mon Sep 17 00:00:00 2001 From: David Sargent Date: Sun, 10 May 2026 01:22:50 -0700 Subject: [PATCH 7/7] fix(agent): update tests for NewClient skipTLSVerify arg NewClient and NewClientWithTimeout grew a skipTLSVerify bool parameter on this branch. The corresponding test files (client_test.go, functional_test.go) landed on main during the 2-month gap and used the old 2-arg signature, breaking the build after rebase. Pass false (the preserved default) at all call sites. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/framework/agent/client_test.go | 72 +++++++++---------- .../framework/agent/functional_test.go | 6 +- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/gearbox/internal/framework/agent/client_test.go b/gearbox/internal/framework/agent/client_test.go index e58a408..3e6bdb2 100644 --- a/gearbox/internal/framework/agent/client_test.go +++ b/gearbox/internal/framework/agent/client_test.go @@ -11,7 +11,7 @@ import ( ) func TestNewClient(t *testing.T) { - client := NewClient("https://example.com:8405", "test-api-key") + client := NewClient("https://example.com:8405", "test-api-key", false) if client.baseURL != "https://example.com:8405" { t.Errorf("expected baseURL https://example.com:8405, got %s", client.baseURL) @@ -23,7 +23,7 @@ func TestNewClient(t *testing.T) { } func TestNewClientTrimsTrailingSlash(t *testing.T) { - client := NewClient("https://example.com:8405/", "test-api-key") + client := NewClient("https://example.com:8405/", "test-api-key", false) if client.baseURL != "https://example.com:8405" { t.Errorf("expected baseURL without trailing slash, got %s", client.baseURL) @@ -51,7 +51,7 @@ func TestHealth(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.Health() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -90,7 +90,7 @@ func TestGetInfo(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetInfo() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -125,7 +125,7 @@ func TestGetStats(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetStats() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -162,7 +162,7 @@ func TestGetStatsCSV(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) csv, err := client.GetStatsCSV() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -196,7 +196,7 @@ func TestGetMetrics(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetMetrics() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -232,7 +232,7 @@ func TestGetServices(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetServices([]string{"haproxy", "nginx"}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -263,7 +263,7 @@ func TestGetLogs(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetLogs("haproxy", 100) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -295,7 +295,7 @@ func TestGetLogsMaxLines(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) _, err := client.GetLogs("haproxy", 50000) // Request more than max if err != nil { t.Fatalf("unexpected error: %v", err) @@ -325,7 +325,7 @@ func TestGetMetadata(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetMetadata() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -363,7 +363,7 @@ func TestGetSecuritySummary(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetSecuritySummary() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -407,7 +407,7 @@ func TestGetFail2BanStats(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetFail2BanStats(&Fail2BanOptions{ IncludeIPs: true, Recent: 10, @@ -439,7 +439,7 @@ func TestGetSyncStatus(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetSyncStatus() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -468,7 +468,7 @@ func TestGetWebSocketToken(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetWebSocketToken() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -491,7 +491,7 @@ func TestHTTPError401(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "bad-key") + client := NewClient(server.URL, "bad-key", false) _, err := client.GetInfo() if err == nil { t.Fatal("expected error for 401 response") @@ -521,7 +521,7 @@ func TestHTTPError404(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) _, err := client.GetLogs("nonexistent", 100) if err == nil { t.Fatal("expected error for 404 response") @@ -538,7 +538,7 @@ func TestHTTPError429(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) _, err := client.GetStats() if err == nil { t.Fatal("expected error for 429 response") @@ -555,7 +555,7 @@ func TestHTTPError503(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) _, err := client.GetMetrics() if err == nil { t.Fatal("expected error for 503 response") @@ -573,7 +573,7 @@ func TestClientTimeout(t *testing.T) { })) defer server.Close() - client := NewClientWithTimeout(server.URL, "test-key", 50*time.Millisecond) + client := NewClientWithTimeout(server.URL, "test-key", false, 50*time.Millisecond) _, err := client.Health() if err == nil { t.Fatal("expected timeout error") @@ -607,7 +607,7 @@ func TestGetHAProxyConfig(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetHAProxyConfig() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -666,7 +666,7 @@ func TestUpdateHAProxyConfig(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) // Test dry run resp, err := client.UpdateHAProxyConfig(&HAProxyConfigUpdateRequest{ @@ -724,7 +724,7 @@ func TestGetHAProxyConfigBackups(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetHAProxyConfigBackups() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -768,7 +768,7 @@ func TestRestoreHAProxyConfig(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.RestoreHAProxyConfig(&HAProxyRestoreRequest{ BackupID: "haproxy_20240115_103000.cfg", DryRun: false, @@ -805,7 +805,7 @@ func TestGetFirewallConfig(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetFirewallConfig() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -841,7 +841,7 @@ func TestUpdateFirewallConfig(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.UpdateFirewallConfig(&FirewallConfigUpdateRequest{ Content: "#!/usr/sbin/nft -f\ntable inet filter {\n}", ExpectedSHA: "firewall123", @@ -883,7 +883,7 @@ func TestGetFirewallConfigBackups(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.GetFirewallConfigBackups() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -910,7 +910,7 @@ func TestRestoreFirewallConfig(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.RestoreFirewallConfig(&FirewallRestoreRequest{ BackupID: "nftables_20240115_103000.conf", DryRun: false, @@ -964,7 +964,7 @@ func TestListUpdateLogs(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.ListUpdateLogs(10) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -997,7 +997,7 @@ func TestListUpdateLogs_Empty(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) resp, err := client.ListUpdateLogs(0) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1017,7 +1017,7 @@ func TestListUpdateLogs_ServerError(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) _, err := client.ListUpdateLogs(10) if err == nil { t.Fatal("expected error for server error response") @@ -1055,7 +1055,7 @@ func TestGetUpdateLog(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) log, err := client.GetUpdateLog("test-log-id") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1078,7 +1078,7 @@ func TestGetUpdateLog_NotFound(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) _, err := client.GetUpdateLog("nonexistent") if err == nil { t.Fatal("expected error for not found response") @@ -1101,7 +1101,7 @@ func TestGetUpdateLog_FailedOperation(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) log, err := client.GetUpdateLog("failed-log") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1249,7 +1249,7 @@ func TestHTTPErrorPlainText_doRequest(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) _, err := client.GetInfo() if err == nil { t.Fatal("expected error") @@ -1280,7 +1280,7 @@ func TestHTTPErrorPlainText_doRequestLongRunning(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key") + client := NewClient(server.URL, "test-key", false) // TriggerUpdateCheck uses doRequestLongRunning _, err := client.TriggerUpdateCheck() if err == nil { diff --git a/gearbox/internal/framework/agent/functional_test.go b/gearbox/internal/framework/agent/functional_test.go index 35a1336..887f523 100644 --- a/gearbox/internal/framework/agent/functional_test.go +++ b/gearbox/internal/framework/agent/functional_test.go @@ -30,7 +30,7 @@ func functionalClient(t *testing.T) *Client { t.Skip("Skipping functional test: GEARBOX_FUNCTIONAL_TEST_URL and GEARBOX_FUNCTIONAL_TEST_API_KEY must be set") } - return NewClient(baseURL, apiKey) + return NewClient(baseURL, apiKey, false) } // ---------- Update Status & List ---------- @@ -753,7 +753,7 @@ func TestFunctional_BadAPIKey(t *testing.T) { t.Skip("Skipping: GEARBOX_FUNCTIONAL_TEST_URL not set") } - c := NewClient(baseURL, "invalid-api-key-for-testing") + c := NewClient(baseURL, "invalid-api-key-for-testing", false) _, err := c.GetUpdateStatus() if err == nil { @@ -774,7 +774,7 @@ func TestFunctional_NoAPIKey(t *testing.T) { t.Skip("Skipping: GEARBOX_FUNCTIONAL_TEST_URL not set") } - c := NewClient(baseURL, "") + c := NewClient(baseURL, "", false) _, err := c.GetUpdateStatus() if err == nil {