From de9691b43f86fabbc9afdb520a8cb2ba3abd6ca1 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sun, 15 Jun 2025 22:21:34 -0500 Subject: [PATCH 1/8] feat: simplified agent --- .env.example | 5 + Dockerfile | 2 +- cmd/agent/main.go | 10 +- cmd/agent/main_test.go | 25 - go.mod | 61 ++- go.sum | 214 +++++++- internal/agent/agent.go | 92 +++- internal/agent/agent_test.go | 107 ---- internal/agent/http_client.go | 214 -------- internal/agent/http_client_test.go | 280 ---------- internal/api/router.go | 73 +++ internal/compose/manager.go | 212 -------- internal/compose/manager_test.go | 263 --------- internal/config/config.go | 119 ++--- internal/config/config_test.go | 393 -------------- internal/docker/client.go | 413 +++------------ internal/docker/client_test.go | 95 ---- internal/handlers/container_handler.go | 88 ++++ internal/handlers/docker_handler.go | 28 + internal/handlers/image_handler.go | 160 ++++++ internal/handlers/status_handler.go | 26 + internal/middleware/middleware.go | 31 ++ internal/tasks/docker.go | 67 --- internal/tasks/manager.go | 704 ------------------------- internal/tasks/manager_test.go | 471 ----------------- internal/tasks/system.go | 83 --- pkg/types/message.go | 52 -- pkg/types/message_test.go | 154 ------ 28 files changed, 888 insertions(+), 3554 deletions(-) create mode 100644 .env.example delete mode 100644 cmd/agent/main_test.go delete mode 100644 internal/agent/agent_test.go delete mode 100644 internal/agent/http_client.go delete mode 100644 internal/agent/http_client_test.go create mode 100644 internal/api/router.go delete mode 100644 internal/compose/manager.go delete mode 100644 internal/compose/manager_test.go delete mode 100644 internal/config/config_test.go delete mode 100644 internal/docker/client_test.go create mode 100644 internal/handlers/container_handler.go create mode 100644 internal/handlers/docker_handler.go create mode 100644 internal/handlers/image_handler.go create mode 100644 internal/handlers/status_handler.go create mode 100644 internal/middleware/middleware.go delete mode 100644 internal/tasks/docker.go delete mode 100644 internal/tasks/manager.go delete mode 100644 internal/tasks/manager_test.go delete mode 100644 internal/tasks/system.go delete mode 100644 pkg/types/message.go delete mode 100644 pkg/types/message_test.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e3c21a9 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +AGENT_ID=arcane-agent-example +AGENT_LISTEN_ADDRESS=0.0.0.0 +AGENT_PORT=3552 +API_KEY=your-secret-api-key-here + diff --git a/Dockerfile b/Dockerfile index 900ec07..e8ef936 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,5 +35,5 @@ FROM docker:28.2.2-cli-alpine3.21 AS runtime # Copy your built binary from builder stage COPY --from=builder /app/arcane-agent /arcane-agent -EXPOSE 8080 +EXPOSE 3552 ENTRYPOINT ["/arcane-agent"] \ No newline at end of file diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 3b954cc..6213485 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -14,7 +14,7 @@ import ( func main() { // Load .env file if it exists if err := godotenv.Load(); err != nil { - log.Printf("No .env file found: %v", err) + log.Printf("No .env file found (this is okay): %v", err) } cfg, err := config.Load() @@ -22,8 +22,8 @@ func main() { log.Fatalf("Failed to load configuration: %v", err) } - // Create and start agent - agent := agent.New(cfg) + // Create agent + a := agent.New(cfg) // Handle shutdown signals sigChan := make(chan os.Signal, 1) @@ -32,11 +32,11 @@ func main() { go func() { <-sigChan log.Printf("Received shutdown signal") - agent.Stop() + a.Stop() }() // Start agent (blocks until shutdown) - if err := agent.Start(); err != nil { + if err := a.Start(); err != nil { log.Fatalf("Agent failed: %v", err) } diff --git a/cmd/agent/main_test.go b/cmd/agent/main_test.go deleted file mode 100644 index 4158c4a..0000000 --- a/cmd/agent/main_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "os" - "testing" -) - -func TestMain(t *testing.T) { - // This is a basic test to ensure main package compiles - // In a real scenario, you might test CLI argument parsing, etc. - - // Test that we can import and the package compiles - if os.Getenv("RUN_MAIN_TEST") == "1" { - // Set test environment variables - os.Setenv("ARCANE_HOST", "localhost") - os.Setenv("ARCANE_PORT", "3000") - os.Setenv("AGENT_ID", "test-agent") - - // We don't actually call main() here as it would start the agent - // Instead, we just test that it compiles - t.Log("Main package compiles successfully") - } else { - t.Skip("Skipping main test (set RUN_MAIN_TEST=1 to run)") - } -} diff --git a/go.mod b/go.mod index 66e6018..5c2413e 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,63 @@ module github.com/ofkm/arcane-agent go 1.24.3 -require github.com/joho/godotenv v1.5.1 +require ( + github.com/docker/docker v28.2.2+incompatible + github.com/gin-gonic/gin v1.10.1 + github.com/joho/godotenv v1.5.1 +) + +require ( + github.com/Microsoft/go-winio v0.4.14 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect +) diff --git a/go.sum b/go.sum index 86beca6..108a1eb 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,214 @@ -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= +github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +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/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= +github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= +go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= +google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 4e798f1..839c747 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -2,76 +2,132 @@ package agent import ( "context" + "fmt" "log" + "net/http" "sync" "time" + "github.com/ofkm/arcane-agent/internal/api" "github.com/ofkm/arcane-agent/internal/config" "github.com/ofkm/arcane-agent/internal/docker" - "github.com/ofkm/arcane-agent/internal/tasks" ) type Agent struct { config *config.Config - httpClient *HTTPClient dockerClient *docker.Client - taskManager *tasks.Manager - - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup + apiServer *http.Server + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup shutdown chan struct{} startTime time.Time + + status string + mu sync.RWMutex } func New(cfg *config.Config) *Agent { ctx, cancel := context.WithCancel(context.Background()) - dockerClient := docker.NewClient() - taskManager := tasks.NewManager(dockerClient, cfg) - httpClient := NewHTTPClient(cfg, taskManager) + dockerClient, err := docker.NewClient() + if err != nil { + log.Printf("Warning: Docker client creation failed: %v", err) + dockerClient = nil + } return &Agent{ config: cfg, - httpClient: httpClient, dockerClient: dockerClient, - taskManager: taskManager, ctx: ctx, cancel: cancel, shutdown: make(chan struct{}), startTime: time.Now(), + status: "initializing", } } func (a *Agent) Start() error { - log.Printf("Starting Arcane Agent %s", a.config.AgentID) + a.setStatus("starting") + log.Printf("Starting Arcane Agent %s (version: %s)", a.config.AgentID, a.config.Version) + + // Validate Docker + if a.dockerClient == nil || !a.dockerClient.IsDockerAvailable() { + log.Printf("Warning: Docker is not available") + } else { + log.Printf("Docker connection successful") + } - // Start HTTP client (handles registration, heartbeat, and task polling) + // Setup API server + router := api.NewRouter(a.config, a.dockerClient) + listenAddr := fmt.Sprintf("%s:%d", a.config.AgentListenAddress, a.config.AgentPort) + + a.apiServer = &http.Server{ + Addr: listenAddr, + Handler: router, + } + + // Start API server a.wg.Add(1) go func() { defer a.wg.Done() - if err := a.httpClient.Start(a.ctx); err != nil { - log.Printf("HTTP client error: %v", err) + log.Printf("Agent API server listening on %s", listenAddr) + a.setStatus("running") + + if err := a.apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("Agent API server error: %v", err) } + log.Println("Agent API server shut down.") }() - // Wait for shutdown signal + log.Printf("Agent started successfully") + + // Wait for shutdown <-a.shutdown log.Printf("Shutting down agent...") + a.setStatus("stopping") + + // Graceful shutdown + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := a.apiServer.Shutdown(shutdownCtx); err != nil { + log.Printf("Agent API server shutdown error: %v", err) + } + a.cancel() a.wg.Wait() + a.setStatus("stopped") + + if a.dockerClient != nil { + a.dockerClient.Close() + } + log.Println("Agent stopped gracefully.") return nil } func (a *Agent) Stop() { + log.Println("Stop called on agent.") select { case <-a.shutdown: - // Already closed return default: close(a.shutdown) } } + +func (a *Agent) GetStatus() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.status +} + +func (a *Agent) setStatus(status string) { + a.mu.Lock() + defer a.mu.Unlock() + a.status = status + log.Printf("Agent status: %s", status) +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go deleted file mode 100644 index 553786c..0000000 --- a/internal/agent/agent_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package agent - -import ( - "testing" - "time" - - "github.com/ofkm/arcane-agent/internal/config" -) - -func TestNew(t *testing.T) { - cfg := &config.Config{ - ArcaneHost: "localhost", - ArcanePort: 3000, - AgentID: "test-agent", - ReconnectDelay: 5 * time.Second, - HeartbeatRate: 30 * time.Second, - TLSEnabled: false, - } - - agent := New(cfg) - - if agent == nil { - t.Fatal("Expected non-nil agent") - } - - if agent.config != cfg { - t.Error("Expected config to be set") - } - - if agent.httpClient == nil { - t.Error("Expected httpClient to be initialized") - } - - if agent.dockerClient == nil { - t.Error("Expected dockerClient to be initialized") - } - - if agent.taskManager == nil { - t.Error("Expected taskManager to be initialized") - } - - if agent.shutdown == nil { - t.Error("Expected shutdown channel to be initialized") - } -} - -func TestAgentStartStop(t *testing.T) { - cfg := &config.Config{ - ArcaneHost: "localhost", - ArcanePort: 3000, - AgentID: "test-agent", - ReconnectDelay: 5 * time.Second, - HeartbeatRate: 30 * time.Second, - TLSEnabled: false, - } - - agent := New(cfg) - - // Start agent in goroutine - done := make(chan error, 1) - go func() { - done <- agent.Start() - }() - - // Give it a moment to start - time.Sleep(100 * time.Millisecond) - - // Stop the agent - agent.Stop() - - // Wait for start to complete - select { - case err := <-done: - if err != nil { - t.Errorf("Expected no error from Start(), got %v", err) - } - case <-time.After(5 * time.Second): - t.Error("Agent.Start() did not complete within timeout") - } -} - -func TestAgentStop(t *testing.T) { - cfg := &config.Config{ - ArcaneHost: "localhost", - ArcanePort: 3000, - AgentID: "test-agent", - ReconnectDelay: 5 * time.Second, - HeartbeatRate: 30 * time.Second, - TLSEnabled: false, - } - - agent := New(cfg) - - // First Stop() should work fine - agent.Stop() - - // Second Stop() should not panic - we need to fix the Agent.Stop() method - // For now, let's test that it doesn't crash the test - func() { - defer func() { - if r := recover(); r != nil { - t.Errorf("Second Stop() call should not panic: %v", r) - } - }() - agent.Stop() - }() -} diff --git a/internal/agent/http_client.go b/internal/agent/http_client.go deleted file mode 100644 index f1cbffd..0000000 --- a/internal/agent/http_client.go +++ /dev/null @@ -1,214 +0,0 @@ -// internal/agent/http_client.go -package agent - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "runtime" - "strings" - "time" - - "github.com/ofkm/arcane-agent/internal/config" - "github.com/ofkm/arcane-agent/internal/tasks" - "github.com/ofkm/arcane-agent/internal/version" - "github.com/ofkm/arcane-agent/pkg/types" -) - -type HTTPClient struct { - config *config.Config - httpClient *http.Client - baseURL string - taskManager *tasks.Manager -} - -func NewHTTPClient(cfg *config.Config, taskManager *tasks.Manager) *HTTPClient { - scheme := "http" - if cfg.TLSEnabled { - scheme = "https" - } - - return &HTTPClient{ - config: cfg, - taskManager: taskManager, - baseURL: fmt.Sprintf("%s://%s:%d", scheme, cfg.ArcaneHost, cfg.ArcanePort), - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, - } -} - -func (h *HTTPClient) Start(ctx context.Context) error { - // Register agent first - if err := h.registerAgent(); err != nil { - return fmt.Errorf("failed to register: %v", err) - } - - log.Printf("Agent registered successfully") - - // Start polling loop - ticker := time.NewTicker(5 * time.Second) // Poll every 5 seconds - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - log.Printf("HTTP client shutting down") - return nil - case <-ticker.C: - // Send heartbeat and check for tasks - if err := h.sendHeartbeat(); err != nil { - log.Printf("Heartbeat failed: %v", err) - } - - if err := h.pollForTasks(); err != nil { - log.Printf("Task polling failed: %v", err) - } - } - } -} - -func (h *HTTPClient) registerAgent() error { - hostname := getHostname() - - regData := map[string]interface{}{ - "agent_id": h.config.AgentID, - "hostname": hostname, - "platform": runtime.GOOS, - "arch": runtime.GOARCH, - "version": version.GetVersion(), - "capabilities": []string{"docker", "compose"}, - } - - return h.makeRequest("POST", "/api/agents/register", regData, nil) -} - -func (h *HTTPClient) sendHeartbeat() error { - // Get current metrics - metrics, err := h.taskManager.ExecuteTask("metrics", map[string]interface{}{}) - if err != nil { - metrics = map[string]interface{}{ - "containerCount": 0, - "imageCount": 0, - "stackCount": 0, - "networkCount": 0, - "volumeCount": 0, - } - } - - heartbeatData := map[string]interface{}{ - "agent_id": h.config.AgentID, - "status": "online", - "timestamp": time.Now().Unix(), - "metrics": metrics, - } - - return h.makeRequest("POST", "/api/agents/heartbeat", heartbeatData, nil) -} - -func (h *HTTPClient) pollForTasks() error { - var tasks []types.TaskRequest - - url := fmt.Sprintf("/api/agents/%s/tasks", h.config.AgentID) - err := h.makeRequest("GET", url, nil, &tasks) - - if err != nil { - // Check if it's a JSON parsing error (likely empty response or HTML) - if strings.Contains(err.Error(), "invalid character") { - log.Printf("No JSON response from tasks endpoint (likely no tasks available)") - return nil // Don't treat this as an error - } - return err - } - - // Process each task - for _, task := range tasks { - go h.executeTask(task) - } - - return nil -} - -func (h *HTTPClient) executeTask(task types.TaskRequest) { - log.Printf("Executing task %s of type %s", task.ID, task.Type) - - // Execute the task using task manager - result, err := h.taskManager.ExecuteTask(task.Type, task.Payload) - - // Send result back - taskResult := types.TaskResult{ - TaskID: task.ID, - Status: "completed", - Result: result, - } - - if err != nil { - taskResult.Status = "failed" - taskResult.Error = err.Error() - log.Printf("Task %s failed: %v", task.ID, err) - } else { - log.Printf("Task %s completed successfully", task.ID) - } - - url := fmt.Sprintf("/api/agents/%s/tasks/%s/result", h.config.AgentID, task.ID) - if err := h.makeRequest("POST", url, taskResult, nil); err != nil { - log.Printf("Failed to send task result: %v", err) - } -} - -func (h *HTTPClient) makeRequest(method, path string, body interface{}, response interface{}) error { - var reqBody io.Reader - - if body != nil { - jsonData, err := json.Marshal(body) - if err != nil { - return err - } - reqBody = bytes.NewBuffer(jsonData) - } - - req, err := http.NewRequest(method, h.baseURL+path, reqBody) - if err != nil { - return err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "arcane-agent/1.1.1") - - resp, err := h.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - // Read the response body first - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("HTTP %d: %s - %s", resp.StatusCode, resp.Status, string(bodyBytes)) - } - - if response != nil { - // Parse the body we already read - return json.Unmarshal(bodyBytes, response) - } - - return nil -} - -// Helper function to get hostname -func getHostname() string { - hostname, err := os.Hostname() - if err != nil { - return "unknown" - } - return hostname -} diff --git a/internal/agent/http_client_test.go b/internal/agent/http_client_test.go deleted file mode 100644 index 310c1b7..0000000 --- a/internal/agent/http_client_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/ofkm/arcane-agent/internal/config" - "github.com/ofkm/arcane-agent/internal/docker" - "github.com/ofkm/arcane-agent/internal/tasks" - "github.com/ofkm/arcane-agent/pkg/types" -) - -func TestNewHTTPClient(t *testing.T) { - cfg := &config.Config{ - ArcaneHost: "localhost", - ArcanePort: 3000, - AgentID: "test-agent", - TLSEnabled: false, - ComposeBasePath: "/opt/compose-projects", // Add this - } - - dockerClient := docker.NewClient() - taskManager := tasks.NewManager(dockerClient, cfg) // Pass config - httpClient := NewHTTPClient(cfg, taskManager) - - if httpClient == nil { - t.Fatal("Expected non-nil HTTP client") - } - - if httpClient.config != cfg { - t.Error("Expected config to be set") - } - - if httpClient.taskManager != taskManager { - t.Error("Expected task manager to be set") - } - - expectedURL := "http://localhost:3000" - if httpClient.baseURL != expectedURL { - t.Errorf("Expected baseURL %s, got %s", expectedURL, httpClient.baseURL) - } -} - -func TestNewHTTPClientWithTLS(t *testing.T) { - cfg := &config.Config{ - ArcaneHost: "example.com", - ArcanePort: 443, - AgentID: "test-agent", - TLSEnabled: true, - ComposeBasePath: "/opt/compose-projects", // Add this - } - - dockerClient := docker.NewClient() - taskManager := tasks.NewManager(dockerClient, cfg) // Pass config - httpClient := NewHTTPClient(cfg, taskManager) - - expectedURL := "https://example.com:443" - if httpClient.baseURL != expectedURL { - t.Errorf("Expected baseURL %s, got %s", expectedURL, httpClient.baseURL) - } -} - -func TestHTTPClientMakeRequest(t *testing.T) { - // Create test server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/test": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) - case "/api/error": - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - cfg := &config.Config{ - ArcaneHost: "localhost", - ArcanePort: 3000, - AgentID: "test-agent", - TLSEnabled: false, - ComposeBasePath: "/opt/compose-projects", // Add this - } - - dockerClient := docker.NewClient() - taskManager := tasks.NewManager(dockerClient, cfg) // Pass config - httpClient := NewHTTPClient(cfg, taskManager) - - // Override baseURL to use test server - httpClient.baseURL = server.URL - - t.Run("successful request", func(t *testing.T) { - var response map[string]string - err := httpClient.makeRequest("GET", "/api/test", nil, &response) - - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - if response["status"] != "ok" { - t.Errorf("Expected status 'ok', got '%s'", response["status"]) - } - }) - - t.Run("error response", func(t *testing.T) { - err := httpClient.makeRequest("GET", "/api/error", nil, nil) - - if err == nil { - t.Error("Expected error for 500 response") - } - }) - - t.Run("request with body", func(t *testing.T) { - body := map[string]string{"key": "value"} - err := httpClient.makeRequest("POST", "/api/test", body, nil) - - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - }) -} - -func TestHTTPClientStart(t *testing.T) { - // Create test server - var registrationCalled, heartbeatCalled, tasksCalled bool - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/agents/register": - registrationCalled = true - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "registered"}) - case "/api/agents/heartbeat": - heartbeatCalled = true - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) - case "/api/agents/test-agent/tasks": - tasksCalled = true - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]types.TaskRequest{}) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - cfg := &config.Config{ - ArcaneHost: "localhost", - ArcanePort: 3000, - AgentID: "test-agent", - TLSEnabled: false, - } - - dockerClient := docker.NewClient() - taskManager := tasks.NewManager(dockerClient, cfg) // Pass config - httpClient := NewHTTPClient(cfg, taskManager) - - // Override baseURL to use test server - httpClient.baseURL = server.URL - - // Test registration - err := httpClient.registerAgent() - if err != nil { - t.Errorf("Registration failed: %v", err) - } - if !registrationCalled { - t.Error("Expected registration to be called") - } - - // Test heartbeat - err = httpClient.sendHeartbeat() - if err != nil { - t.Errorf("Heartbeat failed: %v", err) - } - if !heartbeatCalled { - t.Error("Expected heartbeat to be called") - } - - // Test task polling - err = httpClient.pollForTasks() - if err != nil { - t.Errorf("Task polling failed: %v", err) - } - if !tasksCalled { - t.Error("Expected tasks polling to be called") - } -} - -func TestHTTPClientStartIntegration(t *testing.T) { - // This is a simpler integration test that just checks the client starts and stops - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/agents/register": - json.NewEncoder(w).Encode(map[string]string{"status": "registered"}) - case "/api/agents/heartbeat": - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) - default: - json.NewEncoder(w).Encode([]types.TaskRequest{}) - } - })) - defer server.Close() - - cfg := &config.Config{ - ArcaneHost: "localhost", - ArcanePort: 3000, - AgentID: "test-agent", - TLSEnabled: false, - } - - dockerClient := docker.NewClient() - taskManager := tasks.NewManager(dockerClient, cfg) // Pass config - httpClient := NewHTTPClient(cfg, taskManager) - httpClient.baseURL = server.URL - - // Start with short timeout - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - err := httpClient.Start(ctx) - if err != nil && err != context.DeadlineExceeded { - t.Errorf("Unexpected error: %v", err) - } -} - -func TestExecuteTask(t *testing.T) { - // Create test server to receive task results - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/agents/test-agent/tasks/task-123/result" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "received"}) - } else { - http.NotFound(w, r) - } - })) - defer server.Close() - - cfg := &config.Config{ - ArcaneHost: "localhost", - ArcanePort: 3000, - AgentID: "test-agent", - TLSEnabled: false, - } - - dockerClient := docker.NewClient() - taskManager := tasks.NewManager(dockerClient, cfg) // Pass config - httpClient := NewHTTPClient(cfg, taskManager) - - // Override baseURL to use test server - httpClient.baseURL = server.URL - - task := types.TaskRequest{ - ID: "task-123", - Type: "system_info", - Payload: map[string]interface{}{}, - } - - // Execute task (this will run in background) - httpClient.executeTask(task) - - // Give it time to complete - time.Sleep(100 * time.Millisecond) -} - -func TestGetHostname(t *testing.T) { - hostname := getHostname() - - if hostname == "" { - t.Error("Expected non-empty hostname") - } - - if hostname == "unknown" { - t.Log("Hostname returned 'unknown' (this might be expected in some environments)") - } -} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..59f51cb --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,73 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-agent/internal/config" + "github.com/ofkm/arcane-agent/internal/docker" + "github.com/ofkm/arcane-agent/internal/handlers" + "github.com/ofkm/arcane-agent/internal/middleware" +) + +func NewRouter(cfg *config.Config, dockerClient *docker.Client) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + router := gin.Default() + + if cfg.APIKey != "" { + router.Use(middleware.APIKeyMiddleware(cfg.APIKey)) + } + + // Initialize handlers + statusHandler := handlers.NewStatusHandler(cfg) + containerHandler := handlers.NewContainerHandler(dockerClient) + dockerHandler := handlers.NewDockerHandler(dockerClient) + imageHandler := handlers.NewImageHandler(dockerClient) + + api := router.Group("/api") + { + setupStatusRoutes(api, statusHandler) + setupContainerRoutes(api, containerHandler, dockerClient) + setupDockerRoutes(api, dockerHandler, dockerClient) + setupImageRoutes(api, imageHandler, dockerClient) + } + + return router +} + +// Status routes +func setupStatusRoutes(api *gin.RouterGroup, statusHandler *handlers.StatusHandler) { + api.GET("/status", statusHandler.GetStatus) +} + +// Container routes +func setupContainerRoutes(api *gin.RouterGroup, containerHandler *handlers.ContainerHandler, dockerClient *docker.Client) { + containers := api.Group("/containers") + containers.Use(middleware.DockerAvailabilityMiddleware(dockerClient)) + { + containers.GET("", containerHandler.ListContainers) + containers.GET("/:id", containerHandler.GetContainer) + containers.POST("/:id/start", containerHandler.StartContainer) + containers.POST("/:id/stop", containerHandler.StopContainer) + containers.POST("/:id/restart", containerHandler.RestartContainer) + } +} + +// Docker system routes +func setupDockerRoutes(api *gin.RouterGroup, dockerHandler *handlers.DockerHandler, dockerClient *docker.Client) { + docker := api.Group("/docker") + docker.Use(middleware.DockerAvailabilityMiddleware(dockerClient)) + { + docker.GET("/info", dockerHandler.GetDockerInfo) + } +} + +// Image routes +func setupImageRoutes(api *gin.RouterGroup, imageHandler *handlers.ImageHandler, dockerClient *docker.Client) { + images := api.Group("/images") + images.Use(middleware.DockerAvailabilityMiddleware(dockerClient)) + { + images.GET("", imageHandler.ListImages) + images.GET("/:id", imageHandler.GetImage) + images.POST("", imageHandler.CreateImage) + images.DELETE("/:id", imageHandler.DeleteImage) + } +} diff --git a/internal/compose/manager.go b/internal/compose/manager.go deleted file mode 100644 index edd270d..0000000 --- a/internal/compose/manager.go +++ /dev/null @@ -1,212 +0,0 @@ -package compose - -import ( - "fmt" - "os" - "path/filepath" - "time" -) - -type Manager struct { - basePath string -} - -type ProjectConfig struct { - Name string `json:"name"` - ComposeFile string `json:"compose_file,omitempty"` // Optional, defaults to docker-compose.yml - Content string `json:"content"` // Docker compose YAML content - EnvVars map[string]string `json:"env_vars,omitempty"` // Environment variables for .env file - Override bool `json:"override,omitempty"` // Whether to override existing files -} - -func NewManager(basePath string) *Manager { - return &Manager{ - basePath: basePath, - } -} - -// EnsureBaseDirectory creates the base compose directory if it doesn't exist -func (m *Manager) EnsureBaseDirectory() error { - if err := os.MkdirAll(m.basePath, 0755); err != nil { - return fmt.Errorf("failed to create base directory %s: %w", m.basePath, err) - } - return nil -} - -// CreateProject creates a new compose project directory with files -func (m *Manager) CreateProject(config ProjectConfig) error { - if config.Name == "" { - return fmt.Errorf("project name is required") - } - - if config.Content == "" { - return fmt.Errorf("compose content is required") - } - - // Set default compose file name - if config.ComposeFile == "" { - config.ComposeFile = "docker-compose.yml" - } - - projectPath := filepath.Join(m.basePath, config.Name) - - // Create project directory - if err := os.MkdirAll(projectPath, 0755); err != nil { - return fmt.Errorf("failed to create project directory %s: %w", projectPath, err) - } - - // Create compose file - composeFilePath := filepath.Join(projectPath, config.ComposeFile) - if err := m.writeFileIfNotExists(composeFilePath, config.Content, config.Override); err != nil { - return fmt.Errorf("failed to create compose file: %w", err) - } - - // Create .env file if env vars provided - if len(config.EnvVars) > 0 { - envFilePath := filepath.Join(projectPath, ".env") - envContent := m.generateEnvContent(config.EnvVars) - if err := m.writeFileIfNotExists(envFilePath, envContent, config.Override); err != nil { - return fmt.Errorf("failed to create .env file: %w", err) - } - } - - return nil -} - -// UpdateProject updates an existing project's files -func (m *Manager) UpdateProject(config ProjectConfig) error { - config.Override = true // Force override for updates - return m.CreateProject(config) -} - -// DeleteProject removes a project directory -func (m *Manager) DeleteProject(projectName string) error { - if projectName == "" { - return fmt.Errorf("project name is required") - } - - projectPath := filepath.Join(m.basePath, projectName) - - // Check if project exists - if _, err := os.Stat(projectPath); os.IsNotExist(err) { - return fmt.Errorf("project %s does not exist", projectName) - } - - // Remove project directory - if err := os.RemoveAll(projectPath); err != nil { - return fmt.Errorf("failed to delete project %s: %w", projectName, err) - } - - return nil -} - -// ListProjects returns a list of all compose projects -func (m *Manager) ListProjects() ([]map[string]interface{}, error) { - // Read directory entries - entries, err := os.ReadDir(m.basePath) - if err != nil { - return nil, fmt.Errorf("failed to read projects directory: %w", err) - } - - projects := make([]map[string]interface{}, 0) - for _, entry := range entries { - if !entry.IsDir() { - continue // Skip non-directories - } - - projectName := entry.Name() - projectPath := filepath.Join(m.basePath, projectName) - - // Get file info for timestamps - info, err := os.Stat(projectPath) - if err != nil { - continue // Skip if can't get info - } - - // Look for compose file - composeFilePath := filepath.Join(projectPath, "docker-compose.yml") - if _, err := os.Stat(composeFilePath); os.IsNotExist(err) { - // Try alternate filename - composeFilePath = filepath.Join(projectPath, "compose.yml") - if _, err := os.Stat(composeFilePath); os.IsNotExist(err) { - continue // Skip if no compose file - } - } - - // Read compose content - composeContent, _ := os.ReadFile(composeFilePath) - - // Check for .env file - envContent := "" - envFilePath := filepath.Join(projectPath, ".env") - if envBytes, err := os.ReadFile(envFilePath); err == nil { - envContent = string(envBytes) - } - - // Format timestamps in RFC3339 - createdAt := info.ModTime().UTC().Format(time.RFC3339) - updatedAt := createdAt - - project := map[string]interface{}{ - "id": projectName, - "name": projectName, - "path": projectPath, - "dirName": projectName, - "createdAt": createdAt, - "updatedAt": updatedAt, - "composeContent": string(composeContent), - "envContent": envContent, - } - - projects = append(projects, project) - } - - return projects, nil -} - -// ProjectExists checks if a project directory exists -func (m *Manager) ProjectExists(projectName string) bool { - projectPath := filepath.Join(m.basePath, projectName) - _, err := os.Stat(projectPath) - return !os.IsNotExist(err) -} - -// GetProjectPath returns the full path to a project directory -func (m *Manager) GetProjectPath(projectName string) string { - return filepath.Join(m.basePath, projectName) -} - -// GetComposePath returns the full path to a project's compose file -func (m *Manager) GetComposePath(projectName, composeFile string) string { - if composeFile == "" { - composeFile = "docker-compose.yml" - } - return filepath.Join(m.basePath, projectName, composeFile) -} - -// writeFileIfNotExists writes content to a file, optionally overriding existing files -func (m *Manager) writeFileIfNotExists(filePath, content string, override bool) error { - // Check if file exists - if _, err := os.Stat(filePath); err == nil && !override { - return fmt.Errorf("file %s already exists and override is false", filePath) - } - - // Write file - if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { - return fmt.Errorf("failed to write file %s: %w", filePath, err) - } - - return nil -} - -// generateEnvContent creates .env file content from environment variables -func (m *Manager) generateEnvContent(envVars map[string]string) string { - content := "# Environment variables for Docker Compose\n" - content += "# Generated by Arcane Agent\n\n" - - for key, value := range envVars { - content += fmt.Sprintf("%s=%s\n", key, value) - } - - return content -} diff --git a/internal/compose/manager_test.go b/internal/compose/manager_test.go deleted file mode 100644 index defeda6..0000000 --- a/internal/compose/manager_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package compose - -import ( - "os" - "path/filepath" - "testing" -) - -func TestNewManager(t *testing.T) { - manager := NewManager("/tmp/test-compose") - - if manager == nil { - t.Error("Expected non-nil manager") - } - - if manager.basePath != "/tmp/test-compose" { - t.Errorf("Expected basePath '/tmp/test-compose', got '%s'", manager.basePath) - } -} - -func TestEnsureBaseDirectory(t *testing.T) { - tempDir := filepath.Join(os.TempDir(), "arcane-test-compose") - defer os.RemoveAll(tempDir) - - manager := NewManager(tempDir) - - err := manager.EnsureBaseDirectory() - if err != nil { - t.Errorf("EnsureBaseDirectory failed: %v", err) - } - - // Check if directory was created - if _, err := os.Stat(tempDir); os.IsNotExist(err) { - t.Error("Base directory was not created") - } -} - -func TestCreateProject(t *testing.T) { - tempDir := filepath.Join(os.TempDir(), "arcane-test-compose") - defer os.RemoveAll(tempDir) - - manager := NewManager(tempDir) - manager.EnsureBaseDirectory() - - config := ProjectConfig{ - Name: "test-project", - Content: "version: '3.8'\nservices:\n web:\n image: nginx", - EnvVars: map[string]string{ - "ENV": "test", - "PORT": "8080", - }, - } - - err := manager.CreateProject(config) - if err != nil { - t.Errorf("CreateProject failed: %v", err) - } - - // Check if project directory was created - projectPath := filepath.Join(tempDir, "test-project") - if _, err := os.Stat(projectPath); os.IsNotExist(err) { - t.Error("Project directory was not created") - } - - // Check if compose file was created - composeFile := filepath.Join(projectPath, "docker-compose.yml") - if _, err := os.Stat(composeFile); os.IsNotExist(err) { - t.Error("Compose file was not created") - } - - // Check if .env file was created - envFile := filepath.Join(projectPath, ".env") - if _, err := os.Stat(envFile); os.IsNotExist(err) { - t.Error(".env file was not created") - } - - // Check .env file content - envContent, err := os.ReadFile(envFile) - if err != nil { - t.Errorf("Failed to read .env file: %v", err) - } - - envStr := string(envContent) - if !contains(envStr, "ENV=test") || !contains(envStr, "PORT=8080") { - t.Errorf("Unexpected .env content: %s", envStr) - } -} - -func TestCreateProjectWithCustomComposeFile(t *testing.T) { - tempDir := filepath.Join(os.TempDir(), "arcane-test-compose") - defer os.RemoveAll(tempDir) - - manager := NewManager(tempDir) - manager.EnsureBaseDirectory() - - config := ProjectConfig{ - Name: "test-project", - ComposeFile: "docker-compose.prod.yml", - Content: "version: '3.8'\nservices:\n web:\n image: nginx", - } - - err := manager.CreateProject(config) - if err != nil { - t.Errorf("CreateProject failed: %v", err) - } - - // Check if custom compose file was created - composeFile := filepath.Join(tempDir, "test-project", "docker-compose.prod.yml") - if _, err := os.Stat(composeFile); os.IsNotExist(err) { - t.Error("Custom compose file was not created") - } -} - -func TestCreateProjectValidation(t *testing.T) { - tempDir := filepath.Join(os.TempDir(), "arcane-test-compose") - defer os.RemoveAll(tempDir) - - manager := NewManager(tempDir) - - tests := []struct { - name string - config ProjectConfig - wantErr bool - }{ - { - name: "missing project name", - config: ProjectConfig{ - Content: "version: '3.8'", - }, - wantErr: true, - }, - { - name: "missing content", - config: ProjectConfig{ - Name: "test-project", - }, - wantErr: true, - }, - { - name: "valid config", - config: ProjectConfig{ - Name: "test-project", - Content: "version: '3.8'", - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := manager.CreateProject(tt.config) - if tt.wantErr && err == nil { - t.Error("Expected error but got none") - } - if !tt.wantErr && err != nil { - t.Errorf("Unexpected error: %v", err) - } - }) - } -} - -func TestListProjects(t *testing.T) { - tempDir := filepath.Join(os.TempDir(), "arcane-test-compose") - defer os.RemoveAll(tempDir) - - manager := NewManager(tempDir) - manager.EnsureBaseDirectory() - - // Create test projects - projects := []string{"project1", "project2", "project3"} - for _, name := range projects { - config := ProjectConfig{ - Name: name, - Content: "version: '3.8'", - } - manager.CreateProject(config) - } - - // List projects - listedProjects, err := manager.ListProjects() - if err != nil { - t.Errorf("ListProjects failed: %v", err) - } - - if len(listedProjects) != len(projects) { - t.Errorf("Expected %d projects, got %d", len(projects), len(listedProjects)) - } - - // Check each project exists in the list - for _, expected := range projects { - found := false - for _, actual := range listedProjects { - if actual["name"] == expected { - found = true - break - } - } - if !found { - t.Errorf("Project %s not found in list", expected) - } - } -} - -func TestDeleteProject(t *testing.T) { - tempDir := filepath.Join(os.TempDir(), "arcane-test-compose") - defer os.RemoveAll(tempDir) - - manager := NewManager(tempDir) - manager.EnsureBaseDirectory() - - // Create project - config := ProjectConfig{ - Name: "test-project", - Content: "version: '3.8'", - } - manager.CreateProject(config) - - // Verify project exists - if !manager.ProjectExists("test-project") { - t.Error("Project should exist before deletion") - } - - // Delete project - err := manager.DeleteProject("test-project") - if err != nil { - t.Errorf("DeleteProject failed: %v", err) - } - - // Verify project no longer exists - if manager.ProjectExists("test-project") { - t.Error("Project should not exist after deletion") - } -} - -func TestProjectExists(t *testing.T) { - tempDir := filepath.Join(os.TempDir(), "arcane-test-compose") - defer os.RemoveAll(tempDir) - - manager := NewManager(tempDir) - manager.EnsureBaseDirectory() - - // Check non-existent project - if manager.ProjectExists("nonexistent") { - t.Error("Non-existent project should not exist") - } - - // Create project - config := ProjectConfig{ - Name: "test-project", - Content: "version: '3.8'", - } - manager.CreateProject(config) - - // Check existing project - if !manager.ProjectExists("test-project") { - t.Error("Created project should exist") - } -} - -func contains(s, substr string) bool { - return len(s) >= len(substr) && s[:len(substr)] == substr || - (len(s) > len(substr) && contains(s[1:], substr)) -} diff --git a/internal/config/config.go b/internal/config/config.go index 7b15a40..e89db1a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,41 +3,55 @@ package config import ( "fmt" "os" - "path/filepath" "strconv" - "time" + + "github.com/ofkm/arcane-agent/internal/version" ) type Config struct { - ArcaneHost string `json:"arcane_host"` - ArcanePort int `json:"arcane_port"` - AgentID string `json:"agent_id"` - TLSEnabled bool `json:"tls_enabled"` - ReconnectDelay time.Duration `json:"reconnect_delay"` - HeartbeatRate time.Duration `json:"heartbeat_rate"` - ComposeBasePath string `json:"compose_base_path"` + // Agent identity + AgentID string `json:"agent_id"` + Version string `json:"version"` + + // Agent API Server + AgentListenAddress string `json:"agent_listen_address"` + AgentPort int `json:"agent_port"` + APIKey string `json:"api_key"` } func Load() (*Config, error) { - cfg := &Config{ - ArcaneHost: getEnv("ARCANE_HOST", "localhost"), - ArcanePort: getEnvInt("ARCANE_PORT", 3000), - TLSEnabled: getEnvBool("TLS_ENABLED", false), - ReconnectDelay: getEnvDuration("RECONNECT_DELAY", 5*time.Second), - HeartbeatRate: getEnvDuration("HEARTBEAT_RATE", 30*time.Second), - ComposeBasePath: getEnv("COMPOSE_BASE_PATH", "data/agent/compose-projects"), - } - - // Get or generate agent ID + // Get or create agent ID agentID, err := getOrCreateAgentID() if err != nil { return nil, fmt.Errorf("failed to get agent ID: %w", err) } - cfg.AgentID = agentID + + cfg := &Config{ + AgentID: agentID, + Version: version.GetVersion(), + AgentListenAddress: getEnv("AGENT_LISTEN_ADDRESS", "0.0.0.0"), + AgentPort: getEnvInt("AGENT_PORT", 3552), + APIKey: getEnv("API_KEY", ""), + } + + // Validate configuration + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } return cfg, nil } +func (c *Config) Validate() error { + if c.AgentPort <= 0 || c.AgentPort > 65535 { + return fmt.Errorf("invalid AGENT_PORT: %d", c.AgentPort) + } + if c.AgentID == "" { + return fmt.Errorf("AGENT_ID cannot be empty") + } + return nil +} + func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value @@ -54,73 +68,16 @@ func getEnvInt(key string, defaultValue int) int { return defaultValue } -func getEnvDuration(key string, defaultValue time.Duration) time.Duration { - if value := os.Getenv(key); value != "" { - if duration, err := time.ParseDuration(value); err == nil { - return duration - } - } - return defaultValue -} - -func getEnvBool(key string, defaultValue bool) bool { - if value := os.Getenv(key); value != "" { - if boolValue, err := strconv.ParseBool(value); err == nil { - return boolValue - } - } - return defaultValue -} - func getOrCreateAgentID() (string, error) { - // First check if AGENT_ID is set in environment if agentID := os.Getenv("AGENT_ID"); agentID != "" { return agentID, nil } - // Try to load from file - agentIDFile := getAgentIDFile() - if data, err := os.ReadFile(agentIDFile); err == nil { - agentID := string(data) - if agentID != "" { - return agentID, nil - } - } - - // Generate new agent ID and save it - agentID := generateAgentID() - if err := saveAgentID(agentID); err != nil { - return "", err - } - return agentID, nil -} - -func generateAgentID() string { - hostname, _ := os.Hostname() - return fmt.Sprintf("agent-%s-%d", hostname, time.Now().Unix()) -} - -func getAgentIDFile() string { - // Store in user's home directory or current directory - homeDir, err := os.UserHomeDir() + // Generate a simple agent ID based on hostname + hostname, err := os.Hostname() if err != nil { - return ".agent_id" - } - return filepath.Join(homeDir, ".arcane-agent", "agent_id") -} - -func saveAgentID(agentID string) error { - agentIDFile := getAgentIDFile() - - // Create directory if it doesn't exist - dir := filepath.Dir(agentIDFile) - if err := os.MkdirAll(dir, 0755); err != nil { - return err + hostname = "unknown" } - // Write agent ID to file - if err := os.WriteFile(agentIDFile, []byte(agentID), 0644); err != nil { - return err - } - return nil + return fmt.Sprintf("arcane-agent-%s", hostname), nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index 65e618d..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,393 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestLoad(t *testing.T) { - // Save original env vars - originalEnv := map[string]string{ - "ARCANE_HOST": os.Getenv("ARCANE_HOST"), - "ARCANE_PORT": os.Getenv("ARCANE_PORT"), - "AGENT_ID": os.Getenv("AGENT_ID"), - "RECONNECT_DELAY": os.Getenv("RECONNECT_DELAY"), - "HEARTBEAT_RATE": os.Getenv("HEARTBEAT_RATE"), - "TLS_ENABLED": os.Getenv("TLS_ENABLED"), - "COMPOSE_BASE_PATH": os.Getenv("COMPOSE_BASE_PATH"), - } - - // Clean env vars - defer func() { - for key, value := range originalEnv { - if value == "" { - os.Unsetenv(key) - } else { - os.Setenv(key, value) - } - } - }() - - // Clear all env vars - for key := range originalEnv { - os.Unsetenv(key) - } - - t.Run("default values", func(t *testing.T) { - cfg, err := Load() - if err != nil { - t.Fatalf("Load() failed: %v", err) - } - - if cfg.ArcaneHost != "localhost" { - t.Errorf("Expected ArcaneHost 'localhost', got '%s'", cfg.ArcaneHost) - } - - if cfg.ArcanePort != 3000 { - t.Errorf("Expected ArcanePort 3000, got %d", cfg.ArcanePort) - } - - if cfg.ReconnectDelay != 5*time.Second { - t.Errorf("Expected ReconnectDelay 5s, got %v", cfg.ReconnectDelay) - } - - if cfg.HeartbeatRate != 30*time.Second { - t.Errorf("Expected HeartbeatRate 30s, got %v", cfg.HeartbeatRate) - } - - if cfg.TLSEnabled != false { - t.Errorf("Expected TLSEnabled false, got %v", cfg.TLSEnabled) - } - - if cfg.ComposeBasePath != "data/agent/compose-projects" { - t.Errorf("Expected ComposeBasePath 'data/agent/compose-projects', got '%s'", cfg.ComposeBasePath) - } - - if cfg.AgentID == "" { - t.Error("Expected AgentID to be generated, got empty string") - } - }) - - t.Run("custom values from env", func(t *testing.T) { - os.Setenv("ARCANE_HOST", "example.com") - os.Setenv("ARCANE_PORT", "8080") - os.Setenv("AGENT_ID", "test-agent-123") - os.Setenv("RECONNECT_DELAY", "10s") - os.Setenv("HEARTBEAT_RATE", "60s") - os.Setenv("TLS_ENABLED", "true") - os.Setenv("COMPOSE_BASE_PATH", "/custom/compose/path") - - cfg, err := Load() - if err != nil { - t.Fatalf("Load() failed: %v", err) - } - - if cfg.ArcaneHost != "example.com" { - t.Errorf("Expected ArcaneHost 'example.com', got '%s'", cfg.ArcaneHost) - } - - if cfg.ArcanePort != 8080 { - t.Errorf("Expected ArcanePort 8080, got %d", cfg.ArcanePort) - } - - if cfg.AgentID != "test-agent-123" { - t.Errorf("Expected AgentID 'test-agent-123', got '%s'", cfg.AgentID) - } - - if cfg.ReconnectDelay != 10*time.Second { - t.Errorf("Expected ReconnectDelay 10s, got %v", cfg.ReconnectDelay) - } - - if cfg.HeartbeatRate != 60*time.Second { - t.Errorf("Expected HeartbeatRate 60s, got %v", cfg.HeartbeatRate) - } - - if cfg.TLSEnabled != true { - t.Errorf("Expected TLSEnabled true, got %v", cfg.TLSEnabled) - } - - if cfg.ComposeBasePath != "/custom/compose/path" { - t.Errorf("Expected ComposeBasePath '/custom/compose/path', got '%s'", cfg.ComposeBasePath) - } - - // Clean up env vars for this test - os.Unsetenv("ARCANE_HOST") - os.Unsetenv("ARCANE_PORT") - os.Unsetenv("AGENT_ID") - os.Unsetenv("RECONNECT_DELAY") - os.Unsetenv("HEARTBEAT_RATE") - os.Unsetenv("TLS_ENABLED") - os.Unsetenv("COMPOSE_BASE_PATH") - }) -} - -func TestLoadWithComposeConfig(t *testing.T) { - // Save original env vars - originalComposeBasePath := os.Getenv("COMPOSE_BASE_PATH") - defer func() { - if originalComposeBasePath == "" { - os.Unsetenv("COMPOSE_BASE_PATH") - } else { - os.Setenv("COMPOSE_BASE_PATH", originalComposeBasePath) - } - }() - - // Set environment variables - os.Setenv("COMPOSE_BASE_PATH", "/opt/my-compose-projects") - - cfg, err := Load() - if err != nil { - t.Fatalf("Load() failed: %v", err) - } - - if cfg.ComposeBasePath != "/opt/my-compose-projects" { - t.Errorf("Expected ComposeBasePath='/opt/my-compose-projects', got %q", cfg.ComposeBasePath) - } -} - -func TestGetEnv(t *testing.T) { - tests := []struct { - name string - key string - defaultValue string - envValue string - expected string - }{ - { - name: "returns env value when set", - key: "TEST_KEY", - defaultValue: "default", - envValue: "env_value", - expected: "env_value", - }, - { - name: "returns default when env not set", - key: "NONEXISTENT_KEY", - defaultValue: "default", - envValue: "", - expected: "default", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.envValue != "" { - os.Setenv(tt.key, tt.envValue) - defer os.Unsetenv(tt.key) - } - - result := getEnv(tt.key, tt.defaultValue) - if result != tt.expected { - t.Errorf("Expected %s, got %s", tt.expected, result) - } - }) - } -} - -func TestGetEnvInt(t *testing.T) { - tests := []struct { - name string - key string - defaultValue int - envValue string - expected int - }{ - { - name: "returns env value when valid int", - key: "TEST_INT", - defaultValue: 42, - envValue: "123", - expected: 123, - }, - { - name: "returns default when env not set", - key: "NONEXISTENT_INT", - defaultValue: 42, - envValue: "", - expected: 42, - }, - { - name: "returns default when env invalid", - key: "INVALID_INT", - defaultValue: 42, - envValue: "not_a_number", - expected: 42, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.envValue != "" { - os.Setenv(tt.key, tt.envValue) - defer os.Unsetenv(tt.key) - } - - result := getEnvInt(tt.key, tt.defaultValue) - if result != tt.expected { - t.Errorf("Expected %d, got %d", tt.expected, result) - } - }) - } -} - -func TestGetEnvDuration(t *testing.T) { - tests := []struct { - name string - key string - defaultValue time.Duration - envValue string - expected time.Duration - }{ - { - name: "returns env value when valid duration", - key: "TEST_DURATION", - defaultValue: 5 * time.Second, - envValue: "10s", - expected: 10 * time.Second, - }, - { - name: "returns default when env not set", - key: "NONEXISTENT_DURATION", - defaultValue: 5 * time.Second, - envValue: "", - expected: 5 * time.Second, - }, - { - name: "returns default when env invalid", - key: "INVALID_DURATION", - defaultValue: 5 * time.Second, - envValue: "not_a_duration", - expected: 5 * time.Second, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.envValue != "" { - os.Setenv(tt.key, tt.envValue) - defer os.Unsetenv(tt.key) - } - - result := getEnvDuration(tt.key, tt.defaultValue) - if result != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, result) - } - }) - } -} - -func TestGetEnvBool(t *testing.T) { - tests := []struct { - name string - key string - defaultValue bool - envValue string - expected bool - }{ - { - name: "returns true when env is 'true'", - key: "TEST_BOOL", - defaultValue: false, - envValue: "true", - expected: true, - }, - { - name: "returns false when env is 'false'", - key: "TEST_BOOL", - defaultValue: true, - envValue: "false", - expected: false, - }, - { - name: "returns default when env not set", - key: "NONEXISTENT_BOOL", - defaultValue: true, - envValue: "", - expected: true, - }, - { - name: "returns default when env invalid", - key: "INVALID_BOOL", - defaultValue: false, - envValue: "not_a_bool", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.envValue != "" { - os.Setenv(tt.key, tt.envValue) - defer os.Unsetenv(tt.key) - } - - result := getEnvBool(tt.key, tt.defaultValue) - if result != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, result) - } - }) - } -} - -func TestGenerateAgentID(t *testing.T) { - agentID := generateAgentID() - - if agentID == "" { - t.Error("Expected non-empty agent ID") - } - - if len(agentID) < 10 { - t.Errorf("Expected agent ID to be at least 10 characters, got %d", len(agentID)) - } - - // Should start with "agent-" - if agentID[:6] != "agent-" { - t.Errorf("Expected agent ID to start with 'agent-', got %s", agentID) - } -} - -func TestGetOrCreateAgentID(t *testing.T) { - // Save original env - originalAgentID := os.Getenv("AGENT_ID") - defer func() { - if originalAgentID == "" { - os.Unsetenv("AGENT_ID") - } else { - os.Setenv("AGENT_ID", originalAgentID) - } - }() - - t.Run("returns env AGENT_ID when set", func(t *testing.T) { - os.Setenv("AGENT_ID", "test-env-agent") - agentID, err := getOrCreateAgentID() - if err != nil { - t.Fatalf("getOrCreateAgentID() failed: %v", err) - } - if agentID != "test-env-agent" { - t.Errorf("Expected 'test-env-agent', got '%s'", agentID) - } - }) - - t.Run("generates new agent ID when env not set", func(t *testing.T) { - os.Unsetenv("AGENT_ID") - - // Clean up any existing agent ID file - agentIDFile := getAgentIDFile() - os.Remove(agentIDFile) - os.RemoveAll(filepath.Dir(agentIDFile)) - - agentID, err := getOrCreateAgentID() - if err != nil { - t.Fatalf("getOrCreateAgentID() failed: %v", err) - } - if agentID == "" { - t.Error("Expected non-empty agent ID") - } - - if agentID[:6] != "agent-" { - t.Errorf("Expected agent ID to start with 'agent-', got %s", agentID) - } - }) -} diff --git a/internal/docker/client.go b/internal/docker/client.go index 0d23f82..cb28eef 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -2,394 +2,155 @@ package docker import ( "context" - "encoding/json" "fmt" - "os/exec" - "strings" + "io" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/system" + "github.com/docker/docker/client" ) type Client struct { - // Simple Docker CLI client -} - -func NewClient() *Client { - return &Client{} + cli *client.Client } -// ExecuteCommand runs any docker command with args -func (c *Client) ExecuteCommand(command string, args []string) (string, error) { - cmdArgs := append([]string{command}, args...) - cmd := exec.Command("docker", cmdArgs...) - - output, err := cmd.CombinedOutput() +func NewClient() (*Client, error) { + cli, err := client.NewClientWithOpts( + client.WithHost("unix:///var/run/docker.sock"), + client.WithAPIVersionNegotiation(), + ) if err != nil { - return "", fmt.Errorf("docker %s failed: %s", command, string(output)) + return nil, fmt.Errorf("failed to create Docker client: %w", err) } - return strings.TrimSpace(string(output)), nil + return &Client{cli: cli}, nil } -// IsDockerAvailable checks if Docker is available func (c *Client) IsDockerAvailable() bool { - cmd := exec.Command("docker", "version") - return cmd.Run() == nil -} - -// ListContainers gets all containers in JSON format -func (c *Client) ListContainers(ctx context.Context) (interface{}, error) { - output, err := c.ExecuteCommand("ps", []string{"-a", "--format", "json"}) - if err != nil { - return nil, err + if c.cli == nil { + return false } - // Parse JSON lines into array - lines := strings.Split(output, "\n") - containers := make([]interface{}, 0) - - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - var container map[string]interface{} - if err := json.Unmarshal([]byte(line), &container); err == nil { - containers = append(containers, container) - } - } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() - return map[string]interface{}{ - "containers": containers, - }, nil + _, err := c.cli.Ping(ctx) + return err == nil } -// StartContainer starts a container by ID or name -func (c *Client) StartContainer(ctx context.Context, containerID string) (interface{}, error) { - output, err := c.ExecuteCommand("start", []string{containerID}) - if err != nil { - return nil, err - } - - return map[string]interface{}{ - "container_id": containerID, - "status": "started", - "output": output, - }, nil +func (c *Client) ListContainers(ctx context.Context, all bool) ([]container.Summary, error) { + return c.cli.ContainerList(ctx, container.ListOptions{All: all}) } -// StopContainer stops a container by ID or name -func (c *Client) StopContainer(ctx context.Context, containerID string) (interface{}, error) { - output, err := c.ExecuteCommand("stop", []string{containerID}) - if err != nil { - return nil, err - } - - return map[string]interface{}{ - "container_id": containerID, - "status": "stopped", - "output": output, - }, nil +func (c *Client) GetContainer(ctx context.Context, containerID string) (container.InspectResponse, error) { + return c.cli.ContainerInspect(ctx, containerID) } -// RestartContainer restarts a container by ID or name -func (c *Client) RestartContainer(ctx context.Context, containerID string) (interface{}, error) { - output, err := c.ExecuteCommand("restart", []string{containerID}) - if err != nil { - return nil, err - } - - return map[string]interface{}{ - "container_id": containerID, - "status": "restarted", - "output": output, - }, nil +func (c *Client) StartContainer(ctx context.Context, containerID string) error { + return c.cli.ContainerStart(ctx, containerID, container.StartOptions{}) } -// PullImage pulls a Docker image -func (c *Client) PullImage(ctx context.Context, image string) (interface{}, error) { - output, err := c.ExecuteCommand("pull", []string{image}) - if err != nil { - return nil, err - } - - return map[string]interface{}{ - "image": image, - "status": "pulled", - "output": output, - }, nil +func (c *Client) StopContainer(ctx context.Context, containerID string) error { + timeout := 10 + return c.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}) } -// ListImages gets all images in JSON format -func (c *Client) ListImages(ctx context.Context) (interface{}, error) { - output, err := c.ExecuteCommand("images", []string{"--format", "json"}) - if err != nil { - return nil, err - } - - // Parse JSON lines into array - lines := strings.Split(output, "\n") - images := make([]interface{}, 0) - - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - var image map[string]interface{} - if err := json.Unmarshal([]byte(line), &image); err == nil { - images = append(images, image) - } - } - - return map[string]interface{}{ - "images": images, - }, nil +func (c *Client) RestartContainer(ctx context.Context, containerID string) error { + timeout := 10 + return c.cli.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout}) } -// GetSystemInfo gets Docker system information -func (c *Client) GetSystemInfo(ctx context.Context) (interface{}, error) { - output, err := c.ExecuteCommand("system", []string{"info", "--format", "json"}) - if err != nil { - return nil, err - } - - var systemInfo map[string]interface{} - if err := json.Unmarshal([]byte(output), &systemInfo); err != nil { - // If JSON parsing fails, return raw output - return map[string]interface{}{ - "system_info": output, - }, nil - } - - return systemInfo, nil +func (c *Client) GetSystemInfo(ctx context.Context) (system.Info, error) { + return c.cli.Info(ctx) } -// Additional useful methods +// Image methods +func (c *Client) ListImages(ctx context.Context, all bool) ([]image.Summary, error) { + return c.cli.ImageList(ctx, image.ListOptions{All: all}) +} -// RemoveContainer removes a container -func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) (interface{}, error) { - args := []string{"rm", containerID} - if force { - args = []string{"rm", "-f", containerID} - } +func (c *Client) GetImage(ctx context.Context, id string) (image.InspectResponse, error) { + return c.cli.ImageInspect(ctx, id) +} - output, err := c.ExecuteCommand("rm", args[1:]) - if err != nil { - return nil, err +func (c *Client) RemoveImage(ctx context.Context, id string, force bool, noPrune bool) ([]image.DeleteResponse, error) { + options := image.RemoveOptions{ + Force: force, + PruneChildren: !noPrune, } - return map[string]interface{}{ - "container_id": containerID, - "status": "removed", - "output": output, - }, nil + return c.cli.ImageRemove(ctx, id, options) } -// GetContainerLogs gets logs from a container -func (c *Client) GetContainerLogs(ctx context.Context, containerID string, tail int) (interface{}, error) { - args := []string{"logs"} - if tail > 0 { - args = append(args, "--tail", fmt.Sprintf("%d", tail)) +func (c *Client) PullImage(ctx context.Context, fromImage string, tag string, platform string) error { + pullOptions := image.PullOptions{ + Platform: platform, } - args = append(args, containerID) - output, err := c.ExecuteCommand("logs", args[1:]) - if err != nil { - return nil, err + imageRef := fromImage + if tag != "" { + imageRef = fmt.Sprintf("%s:%s", fromImage, tag) } - return map[string]interface{}{ - "container_id": containerID, - "logs": output, - }, nil -} - -// ComposeUp runs docker-compose up -func (c *Client) ComposeUp(ctx context.Context, composeFile string) (interface{}, error) { - cmd := exec.Command("docker-compose", "-f", composeFile, "up", "-d") - output, err := cmd.CombinedOutput() + reader, err := c.cli.ImagePull(ctx, imageRef, pullOptions) if err != nil { - return nil, fmt.Errorf("docker-compose up failed: %s", string(output)) + return fmt.Errorf("failed to pull image: %w", err) } + defer reader.Close() - return map[string]interface{}{ - "compose_file": composeFile, - "status": "started", - "output": string(output), - }, nil -} - -// ComposeDown runs docker-compose down -func (c *Client) ComposeDown(ctx context.Context, composeFile string) (interface{}, error) { - cmd := exec.Command("docker-compose", "-f", composeFile, "down") - output, err := cmd.CombinedOutput() + // Read the response to ensure the pull completes + _, err = io.ReadAll(reader) if err != nil { - return nil, fmt.Errorf("docker-compose down failed: %s", string(output)) + return fmt.Errorf("failed to read pull response: %w", err) } - return map[string]interface{}{ - "compose_file": composeFile, - "status": "stopped", - "output": string(output), - }, nil + return nil } -// ComposeUpWithProject runs docker-compose up with a specific project name -func (c *Client) ComposeUpWithProject(ctx context.Context, composeFile, projectName string) (interface{}, error) { - args := []string{"-f", composeFile} - if projectName != "" { - args = append(args, "-p", projectName) - } - args = append(args, "up", "-d") - - cmd := exec.Command("docker-compose", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("docker-compose up failed: %s", string(output)) - } - - return map[string]interface{}{ - "compose_file": composeFile, - "project_name": projectName, - "status": "started", - "output": string(output), - }, nil +func (c *Client) BuildImage(ctx context.Context, contextPath string, dockerfile string, tags []string, buildArgs map[string]string, target string, platform string) (string, error) { + // This is a simplified implementation + // In a real implementation, you'd need to create a tar archive of the build context + // and handle the build response stream properly + return "", fmt.Errorf("build image not implemented yet") } -// ComposeDownWithProject runs docker-compose down with a specific project name -func (c *Client) ComposeDownWithProject(ctx context.Context, composeFile, projectName string) (interface{}, error) { - args := []string{"-f", composeFile} - if projectName != "" { - args = append(args, "-p", projectName) +func (c *Client) TagImage(ctx context.Context, source string, repository string, tag string) error { + targetRef := repository + if tag != "" { + targetRef = fmt.Sprintf("%s:%s", repository, tag) } - args = append(args, "down") - cmd := exec.Command("docker-compose", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("docker-compose down failed: %s", string(output)) - } - - return map[string]interface{}{ - "compose_file": composeFile, - "project_name": projectName, - "status": "stopped", - "output": string(output), - }, nil + return c.cli.ImageTag(ctx, source, targetRef) } -func (c *Client) ComposePs(ctx context.Context, composeFile, projectName string) (interface{}, error) { - args := []string{"-f", composeFile} - if projectName != "" { - args = append(args, "-p", projectName) +func (c *Client) PushImage(ctx context.Context, imageID string, tag string) error { + pushRef := imageID + if tag != "" { + pushRef = fmt.Sprintf("%s:%s", imageID, tag) } - args = append(args, "ps", "--format", "json") - cmd := exec.Command("docker-compose", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("docker-compose ps failed: %s", string(output)) - } - - return map[string]interface{}{ - "compose_file": composeFile, - "project_name": projectName, - "services": string(output), - }, nil -} + pushOptions := image.PushOptions{} -// ComposeLogs gets logs from compose services -func (c *Client) ComposeLogs(ctx context.Context, composeFile, projectName, serviceName string, tail int) (interface{}, error) { - args := []string{"-f", composeFile} - if projectName != "" { - args = append(args, "-p", projectName) - } - args = append(args, "logs") - if tail > 0 { - args = append(args, "--tail", fmt.Sprintf("%d", tail)) - } - if serviceName != "" { - args = append(args, serviceName) + reader, err := c.cli.ImagePush(ctx, pushRef, pushOptions) + if err != nil { + return fmt.Errorf("failed to push image: %w", err) } + defer reader.Close() - cmd := exec.Command("docker-compose", args...) - output, err := cmd.CombinedOutput() + // Read the response to ensure the push completes + _, err = io.ReadAll(reader) if err != nil { - return nil, fmt.Errorf("docker-compose logs failed: %s", string(output)) + return fmt.Errorf("failed to read push response: %w", err) } - return map[string]interface{}{ - "compose_file": composeFile, - "project_name": projectName, - "service_name": serviceName, - "logs": string(output), - }, nil + return nil } -// GetMetrics collects various Docker metrics -func (c *Client) GetMetrics(ctx context.Context) (interface{}, error) { - metrics := make(map[string]interface{}) - - // Get container count - if containerResult, err := c.ListContainers(ctx); err == nil { - if containerMap, ok := containerResult.(map[string]interface{}); ok { - if containers, ok := containerMap["containers"].([]interface{}); ok { - metrics["containerCount"] = len(containers) - } - } - } else { - metrics["containerCount"] = 0 - } - - // Get image count - if imageResult, err := c.ListImages(ctx); err == nil { - if imageMap, ok := imageResult.(map[string]interface{}); ok { - if images, ok := imageMap["images"].([]interface{}); ok { - metrics["imageCount"] = len(images) - } - } - } else { - metrics["imageCount"] = 0 +func (c *Client) Close() error { + if c.cli != nil { + return c.cli.Close() } - - // Get stack count (using docker stack ls) - if stackOutput, err := c.ExecuteCommand("stack", []string{"ls", "--format", "json"}); err == nil { - lines := strings.Split(strings.TrimSpace(stackOutput), "\n") - stackCount := 0 - for _, line := range lines { - if strings.TrimSpace(line) != "" { - stackCount++ - } - } - metrics["stackCount"] = stackCount - } else { - metrics["stackCount"] = 0 - } - - // Get network count - if networkOutput, err := c.ExecuteCommand("network", []string{"ls", "--format", "json"}); err == nil { - lines := strings.Split(strings.TrimSpace(networkOutput), "\n") - networkCount := 0 - for _, line := range lines { - if strings.TrimSpace(line) != "" { - networkCount++ - } - } - metrics["networkCount"] = networkCount - } else { - metrics["networkCount"] = 0 - } - - // Get volume count - if volumeOutput, err := c.ExecuteCommand("volume", []string{"ls", "--format", "json"}); err == nil { - lines := strings.Split(strings.TrimSpace(volumeOutput), "\n") - volumeCount := 0 - for _, line := range lines { - if strings.TrimSpace(line) != "" { - volumeCount++ - } - } - metrics["volumeCount"] = volumeCount - } else { - metrics["volumeCount"] = 0 - } - - return metrics, nil + return nil } diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go deleted file mode 100644 index 0405585..0000000 --- a/internal/docker/client_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package docker - -import ( - "context" - "testing" -) - -func TestNewClient(t *testing.T) { - client := NewClient() - if client == nil { - t.Error("Expected non-nil client") - } -} - -func TestIsDockerAvailable(t *testing.T) { - client := NewClient() - - // This test will pass/fail based on whether Docker is installed - available := client.IsDockerAvailable() - t.Logf("Docker available: %v", available) - - // We don't assert true/false since Docker may not be available in CI -} - -// Only test the command structure, not actual Docker execution -func TestExecuteCommand(t *testing.T) { - client := NewClient() - - t.Run("invalid command should return error", func(t *testing.T) { - _, err := client.ExecuteCommand("invalid-command-that-does-not-exist", []string{}) - if err == nil { - t.Error("Expected error for invalid command") - } - }) -} - -// Skip Docker-dependent tests in CI -func TestDockerOperations(t *testing.T) { - client := NewClient() - - if !client.IsDockerAvailable() { - t.Skip("Docker not available, skipping Docker-dependent tests") - return - } - - ctx := context.Background() - - t.Run("list containers", func(t *testing.T) { - result, err := client.ListContainers(ctx) - if err != nil { - t.Logf("List containers failed (expected if no containers): %v", err) - return - } - - if result == nil { - t.Error("Expected non-nil result") - } - }) - - t.Run("get system info", func(t *testing.T) { - result, err := client.GetSystemInfo(ctx) - if err != nil { - t.Logf("Get system info failed: %v", err) - return - } - - if result == nil { - t.Error("Expected non-nil result") - } - }) -} - -// Remove the failing TestRemoveContainer or fix it -func TestRemoveContainer(t *testing.T) { - client := NewClient() - - if !client.IsDockerAvailable() { - t.Skip("Docker not available") - return - } - - ctx := context.Background() - - // Test with a non-existent container (should fail) - _, err := client.RemoveContainer(ctx, "non-existent-container", false) - if err == nil { - t.Error("Expected error for non-existent container") - } - - // Force removal should also fail for non-existent container - // But Docker might not return an error in some cases - _, err = client.RemoveContainer(ctx, "non-existent-container", true) - // Don't assert error here as Docker behavior may vary - t.Logf("Force remove result: %v", err) -} diff --git a/internal/handlers/container_handler.go b/internal/handlers/container_handler.go new file mode 100644 index 0000000..4853fb1 --- /dev/null +++ b/internal/handlers/container_handler.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-agent/internal/docker" +) + +type ContainerHandler struct { + dockerClient *docker.Client +} + +func NewContainerHandler(dockerClient *docker.Client) *ContainerHandler { + return &ContainerHandler{ + dockerClient: dockerClient, + } +} + +func (h *ContainerHandler) ListContainers(c *gin.Context) { + allQuery := c.DefaultQuery("all", "true") + all, _ := strconv.ParseBool(allQuery) + + containerList, err := h.dockerClient.ListContainers(c.Request.Context(), all) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "containers": containerList, + "total": len(containerList), + }) +} + +func (h *ContainerHandler) GetContainer(c *gin.Context) { + containerID := c.Param("id") + container, err := h.dockerClient.GetContainer(c.Request.Context(), containerID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, container) +} + +func (h *ContainerHandler) StartContainer(c *gin.Context) { + containerID := c.Param("id") + err := h.dockerClient.StartContainer(c.Request.Context(), containerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Container started successfully", + "container_id": containerID, + }) +} + +func (h *ContainerHandler) StopContainer(c *gin.Context) { + containerID := c.Param("id") + err := h.dockerClient.StopContainer(c.Request.Context(), containerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Container stopped successfully", + "container_id": containerID, + }) +} + +func (h *ContainerHandler) RestartContainer(c *gin.Context) { + containerID := c.Param("id") + err := h.dockerClient.RestartContainer(c.Request.Context(), containerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Container restarted successfully", + "container_id": containerID, + }) +} diff --git a/internal/handlers/docker_handler.go b/internal/handlers/docker_handler.go new file mode 100644 index 0000000..838c5a7 --- /dev/null +++ b/internal/handlers/docker_handler.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-agent/internal/docker" +) + +type DockerHandler struct { + dockerClient *docker.Client +} + +func NewDockerHandler(dockerClient *docker.Client) *DockerHandler { + return &DockerHandler{ + dockerClient: dockerClient, + } +} + +func (h *DockerHandler) GetDockerInfo(c *gin.Context) { + info, err := h.dockerClient.GetSystemInfo(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, info) +} diff --git a/internal/handlers/image_handler.go b/internal/handlers/image_handler.go new file mode 100644 index 0000000..12a7275 --- /dev/null +++ b/internal/handlers/image_handler.go @@ -0,0 +1,160 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-agent/internal/docker" +) + +type ImageHandler struct { + dockerClient *docker.Client +} + +func NewImageHandler(dockerClient *docker.Client) *ImageHandler { + return &ImageHandler{ + dockerClient: dockerClient, + } +} + +func (h *ImageHandler) ListImages(c *gin.Context) { + allQuery := c.DefaultQuery("all", "false") + all := allQuery == "true" + + images, err := h.dockerClient.ListImages(c.Request.Context(), all) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "images": images, + "total": len(images), + }) +} + +func (h *ImageHandler) GetImage(c *gin.Context) { + imageID := c.Param("id") + image, err := h.dockerClient.GetImage(c.Request.Context(), imageID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, image) +} + +func (h *ImageHandler) CreateImage(c *gin.Context) { + var req struct { + FromImage string `json:"fromImage" binding:"required"` + Tag string `json:"tag"` + Platform string `json:"platform"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Set default tag if not provided + if req.Tag == "" { + req.Tag = "latest" + } + + err := h.dockerClient.PullImage(c.Request.Context(), req.FromImage, req.Tag, req.Platform) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Image pulled successfully", + "image": req.FromImage + ":" + req.Tag, + }) +} + +func (h *ImageHandler) DeleteImage(c *gin.Context) { + imageID := c.Param("id") + + var req struct { + Force bool `json:"force"` + NoPrune bool `json:"noPrune"` + } + + // Bind query parameters or JSON body + c.ShouldBindJSON(&req) + if c.Query("force") == "true" { + req.Force = true + } + if c.Query("noPrune") == "true" { + req.NoPrune = true + } + + deletedImages, err := h.dockerClient.RemoveImage(c.Request.Context(), imageID, req.Force, req.NoPrune) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Image deleted successfully", + "deleted_images": deletedImages, + }) +} + +func (h *ImageHandler) TagImage(c *gin.Context) { + imageID := c.Param("id") + + var req struct { + Repository string `json:"repository" binding:"required"` + Tag string `json:"tag"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Set default tag if not provided + if req.Tag == "" { + req.Tag = "latest" + } + + err := h.dockerClient.TagImage(c.Request.Context(), imageID, req.Repository, req.Tag) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Image tagged successfully", + "source": imageID, + "target": req.Repository + ":" + req.Tag, + }) +} + +func (h *ImageHandler) PushImage(c *gin.Context) { + imageID := c.Param("id") + + var req struct { + Tag string `json:"tag"` + } + + c.ShouldBindJSON(&req) + + err := h.dockerClient.PushImage(c.Request.Context(), imageID, req.Tag) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + pushTarget := imageID + if req.Tag != "" { + pushTarget = imageID + ":" + req.Tag + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Image pushed successfully", + "image": pushTarget, + }) +} diff --git a/internal/handlers/status_handler.go b/internal/handlers/status_handler.go new file mode 100644 index 0000000..298c29e --- /dev/null +++ b/internal/handlers/status_handler.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-agent/internal/config" +) + +type StatusHandler struct { + config *config.Config +} + +func NewStatusHandler(cfg *config.Config) *StatusHandler { + return &StatusHandler{ + config: cfg, + } +} + +func (h *StatusHandler) GetStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "running", + "agent_id": h.config.AgentID, + "version": h.config.Version, + }) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..c9dd5ad --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-agent/internal/docker" +) + +// APIKeyMiddleware for API key authentication +func APIKeyMiddleware(expectedAPIKey string) gin.HandlerFunc { + return func(c *gin.Context) { + apiKey := c.GetHeader("X-API-Key") + if apiKey != expectedAPIKey { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + c.Next() + } +} + +// DockerAvailabilityMiddleware checks if Docker client is available +func DockerAvailabilityMiddleware(dockerClient *docker.Client) gin.HandlerFunc { + return func(c *gin.Context) { + if dockerClient == nil { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "Docker not available"}) + return + } + c.Next() + } +} diff --git a/internal/tasks/docker.go b/internal/tasks/docker.go deleted file mode 100644 index 45a93ca..0000000 --- a/internal/tasks/docker.go +++ /dev/null @@ -1,67 +0,0 @@ -package tasks - -import ( - "context" - "fmt" - "os/exec" -) - -// DockerTaskExecutor handles Docker-specific tasks -type DockerTaskExecutor struct{} - -func NewDockerTaskExecutor() *DockerTaskExecutor { - return &DockerTaskExecutor{} -} - -func (d *DockerTaskExecutor) ExecuteDockerCommand(command string, args []string) (string, error) { - cmdArgs := append([]string{command}, args...) - cmd := exec.Command("docker", cmdArgs...) - - output, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("docker command failed: %s", string(output)) - } - - return string(output), nil -} - -func (d *DockerTaskExecutor) DeployStack(ctx context.Context, stackName, composeFile string) (interface{}, error) { - cmd := exec.Command("docker", "stack", "deploy", "-c", composeFile, stackName) - output, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("failed to deploy stack: %s", string(output)) - } - - return map[string]interface{}{ - "stack_name": stackName, - "status": "deployed", - "output": string(output), - }, nil -} - -func (d *DockerTaskExecutor) RemoveStack(ctx context.Context, stackName string) (interface{}, error) { - cmd := exec.Command("docker", "stack", "rm", stackName) - output, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("failed to remove stack: %s", string(output)) - } - - return map[string]interface{}{ - "stack_name": stackName, - "status": "removed", - "output": string(output), - }, nil -} - -func (d *DockerTaskExecutor) GetStackServices(ctx context.Context, stackName string) (interface{}, error) { - cmd := exec.Command("docker", "stack", "services", stackName, "--format", "json") - output, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("failed to get stack services: %s", string(output)) - } - - return map[string]interface{}{ - "stack_name": stackName, - "services": string(output), - }, nil -} diff --git a/internal/tasks/manager.go b/internal/tasks/manager.go deleted file mode 100644 index 7ca9c8d..0000000 --- a/internal/tasks/manager.go +++ /dev/null @@ -1,704 +0,0 @@ -// internal/tasks/manager.go -package tasks - -import ( - "context" - "encoding/json" - "fmt" - "os" - "strconv" - "strings" - - "github.com/ofkm/arcane-agent/internal/compose" - "github.com/ofkm/arcane-agent/internal/config" - "github.com/ofkm/arcane-agent/internal/docker" -) - -type Manager struct { - dockerClient *docker.Client - composeManager *compose.Manager - config *config.Config -} - -func NewManager(dockerClient *docker.Client, cfg *config.Config) *Manager { - composeManager := compose.NewManager(cfg.ComposeBasePath) - - // Ensure base directory exists - if err := composeManager.EnsureBaseDirectory(); err != nil { - // Log error but don't fail initialization - fmt.Printf("Warning: failed to create compose base directory: %v\n", err) - } - - return &Manager{ - dockerClient: dockerClient, - composeManager: composeManager, - config: cfg, - } -} - -func (m *Manager) ExecuteTask(taskType string, payload map[string]interface{}) (interface{}, error) { - ctx := context.Background() - - switch taskType { - case "docker_command": - return m.executeDockerCommand(payload) - case "container_start": - return m.executeContainerStart(ctx, payload) - case "container_stop": - return m.executeContainerStop(ctx, payload) - case "container_restart": - return m.executeContainerRestart(ctx, payload) - case "container_list": - return m.dockerClient.ListContainers(ctx) - case "container_remove": - return m.executeContainerRemove(ctx, payload) - case "container_logs": - return m.executeContainerLogs(ctx, payload) - case "image_pull": - return m.executeImagePull(ctx, payload) - case "image_list": - return m.dockerClient.ListImages(ctx) - case "system_info": - return m.dockerClient.GetSystemInfo(ctx) - case "metrics": - return m.dockerClient.GetMetrics(ctx) - - // Compose operations - case "compose_up": - return m.executeComposeUp(ctx, payload) - case "compose_down": - return m.executeComposeDown(ctx, payload) - case "compose_ps": - return m.executeComposePs(ctx, payload) - case "compose_logs": - return m.executeComposeLogs(ctx, payload) - case "compose_deploy": - return m.executeComposeDeploy(ctx, payload) - case "compose_remove": - return m.executeComposeRemove(ctx, payload) - - // Compose project management - case "compose_create_project": - return m.executeComposeCreateProject(payload) - case "compose_update_project": - return m.executeComposeUpdateProject(payload) - case "compose_delete_project": - return m.executeComposeDeleteProject(payload) - case "compose_list_projects": - return m.executeComposeListProjects() - - case "stack_list": - return m.executeStackList(ctx) - case "stack_services": - return m.executeStackServices(ctx, payload) - - default: - return nil, fmt.Errorf("unknown task type: %s", taskType) - } -} - -func (m *Manager) executeDockerCommand(payload map[string]interface{}) (interface{}, error) { - command, ok := payload["command"].(string) - if !ok { - return nil, fmt.Errorf("missing command") - } - - args := []string{} - if argsInterface, exists := payload["args"]; exists { - if argsList, ok := argsInterface.([]interface{}); ok { - for _, arg := range argsList { - if argStr, ok := arg.(string); ok { - args = append(args, argStr) - } - } - } - } - - output, err := m.dockerClient.ExecuteCommand(command, args) - if err != nil { - return nil, err - } - - return map[string]interface{}{ - "output": output, - "command": fmt.Sprintf("docker %s %v", command, args), - }, nil -} - -func (m *Manager) executeContainerStart(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - containerID, ok := payload["container_id"].(string) - if !ok { - return nil, fmt.Errorf("missing container_id") - } - - return m.dockerClient.StartContainer(ctx, containerID) -} - -func (m *Manager) executeContainerStop(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - containerID, ok := payload["container_id"].(string) - if !ok { - return nil, fmt.Errorf("missing container_id") - } - - return m.dockerClient.StopContainer(ctx, containerID) -} - -func (m *Manager) executeContainerRestart(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - containerID, ok := payload["container_id"].(string) - if !ok { - return nil, fmt.Errorf("missing container_id") - } - - return m.dockerClient.RestartContainer(ctx, containerID) -} - -func (m *Manager) executeContainerRemove(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - containerID, ok := payload["container_id"].(string) - if !ok { - return nil, fmt.Errorf("missing container_id") - } - - force := false - if f, ok := payload["force"].(bool); ok { - force = f - } - - return m.dockerClient.RemoveContainer(ctx, containerID, force) -} - -func (m *Manager) executeContainerLogs(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - containerID, ok := payload["container_id"].(string) - if !ok { - return nil, fmt.Errorf("missing container_id") - } - - tail := 100 - if t, ok := payload["tail"].(float64); ok { - tail = int(t) - } - - return m.dockerClient.GetContainerLogs(ctx, containerID, tail) -} - -func (m *Manager) executeImagePull(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - var image string - var ok bool - - if image, ok = payload["imageName"].(string); !ok { - if image, ok = payload["image"].(string); !ok { - return nil, fmt.Errorf("missing imageName or image") - } - } - - result, err := m.dockerClient.PullImage(ctx, image) - if err != nil { - return map[string]interface{}{ - "status": "failed", - "error": fmt.Sprintf("Failed to pull image %s: %v", image, err), - }, nil - } - - var output string - if resultMap, ok := result.(map[string]interface{}); ok { - if outputStr, exists := resultMap["output"]; exists { - output = fmt.Sprintf("%v", outputStr) - } - } - - return map[string]interface{}{ - "status": "completed", - "result": map[string]interface{}{ - "output": output, - "image": image, - }, - }, nil -} - -// New Compose methods with project-based paths -func (m *Manager) executeComposeUp(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - projectName, composePath, err := m.getComposeProjectPath(payload) - if err != nil { - return nil, err - } - - return m.dockerClient.ComposeUpWithProject(ctx, composePath, projectName) -} - -func (m *Manager) executeComposeDown(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - projectName, composePath, err := m.getComposeProjectPath(payload) - if err != nil { - return nil, err - } - - return m.dockerClient.ComposeDownWithProject(ctx, composePath, projectName) -} - -func (m *Manager) executeComposePs(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - projectName, composePath, err := m.getComposeProjectPath(payload) - if err != nil { - return nil, err - } - - return m.dockerClient.ComposePs(ctx, composePath, projectName) -} - -func (m *Manager) executeComposeLogs(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - projectName, composePath, err := m.getComposeProjectPath(payload) - if err != nil { - return nil, err - } - - serviceName := "" - tail := 100 - - if service, ok := payload["service_name"].(string); ok { - serviceName = service - } - if t, ok := payload["tail"].(float64); ok { - tail = int(t) - } - - return m.dockerClient.ComposeLogs(ctx, composePath, projectName, serviceName, tail) -} - -func (m *Manager) executeComposeDeploy(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - projectName, composePath, err := m.getComposeProjectPath(payload) - if err != nil { - return nil, err - } - - // First bring down existing deployment - if _, err := m.dockerClient.ComposeDownWithProject(ctx, composePath, projectName); err != nil { - // Log but don't fail if down fails (might not exist) - } - - // Then bring up new deployment - return m.dockerClient.ComposeUpWithProject(ctx, composePath, projectName) -} - -// New Compose project management methods -func (m *Manager) executeComposeCreateProject(payload map[string]interface{}) (interface{}, error) { - config, err := m.parseProjectConfig(payload) - if err != nil { - return nil, err - } - - if err := m.composeManager.CreateProject(config); err != nil { - return nil, fmt.Errorf("failed to create project: %w", err) - } - - return map[string]interface{}{ - "status": "created", - "project": config.Name, - "path": m.composeManager.GetProjectPath(config.Name), - "compose_file": config.ComposeFile, - }, nil -} - -func (m *Manager) executeComposeUpdateProject(payload map[string]interface{}) (interface{}, error) { - config, err := m.parseProjectConfig(payload) - if err != nil { - return nil, err - } - - if err := m.composeManager.UpdateProject(config); err != nil { - return nil, fmt.Errorf("failed to update project: %w", err) - } - - return map[string]interface{}{ - "status": "updated", - "project": config.Name, - "path": m.composeManager.GetProjectPath(config.Name), - "compose_file": config.ComposeFile, - }, nil -} - -func (m *Manager) executeComposeDeleteProject(payload map[string]interface{}) (interface{}, error) { - projectName, ok := payload["project_name"].(string) - if !ok || projectName == "" { - return nil, fmt.Errorf("project_name is required") - } - - if err := m.composeManager.DeleteProject(projectName); err != nil { - return nil, fmt.Errorf("failed to delete project: %w", err) - } - - return map[string]interface{}{ - "status": "deleted", - "project": projectName, - }, nil -} - -func (m *Manager) executeComposeListProjects() (interface{}, error) { - projects, err := m.composeManager.ListProjects() - if err != nil { - return nil, fmt.Errorf("failed to list projects: %w", err) - } - - return map[string]interface{}{ - "projects": projects, - "count": len(projects), - "base_path": m.config.ComposeBasePath, - }, nil -} - -// executeComposeRemove removes a compose project and its files -func (m *Manager) executeComposeRemove(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - // Extract project name from payload - projectName, ok := payload["project_name"].(string) - if !ok || projectName == "" { - return nil, fmt.Errorf("project_name is required") - } - - // Check if the project exists before trying to remove it - if !m.composeManager.ProjectExists(projectName) { - return nil, fmt.Errorf("project %s does not exist", projectName) - } - - // Get project path for logging - projectPath := m.composeManager.GetProjectPath(projectName) - - // First, try to bring down the compose project if it's running - composePath := m.composeManager.GetComposePath(projectName, "docker-compose.yml") - if _, err := os.Stat(composePath); err == nil { - // The compose file exists, try to bring it down - _, _ = m.dockerClient.ComposeDown(ctx, composePath) - // We ignore errors from ComposeDown since we want to proceed with deletion regardless - } - - // Now delete the project files and directory - if err := m.composeManager.DeleteProject(projectName); err != nil { - return nil, fmt.Errorf("failed to delete project %s: %w", projectName, err) - } - - return map[string]interface{}{ - "status": "removed", - "message": fmt.Sprintf("Successfully removed project %s at %s", projectName, projectPath), - "project": map[string]interface{}{ - "id": projectName, - "name": projectName, - "path": projectPath, - }, - }, nil -} - -// Helper method to parse project configuration from payload -func (m *Manager) parseProjectConfig(payload map[string]interface{}) (compose.ProjectConfig, error) { - var config compose.ProjectConfig - - // Project name (required) - if name, ok := payload["project_name"].(string); ok { - config.Name = name - } else { - return config, fmt.Errorf("project_name is required") - } - - // Compose content (required) - if content, ok := payload["compose_content"].(string); ok { - config.Content = content - } else { - return config, fmt.Errorf("compose_content is required") - } - - // Optional compose file name - if file, ok := payload["compose_file"].(string); ok { - config.ComposeFile = file - } - - // Optional environment variables - if envVarsInterface, ok := payload["env_vars"]; ok { - if envVarsMap, ok := envVarsInterface.(map[string]interface{}); ok { - config.EnvVars = make(map[string]string) - for key, value := range envVarsMap { - if valueStr, ok := value.(string); ok { - config.EnvVars[key] = valueStr - } - } - } - } - - // Optional override flag - if override, ok := payload["override"].(bool); ok { - config.Override = override - } - - return config, nil -} - -// Updated helper method to resolve project name and compose file path -func (m *Manager) getComposeProjectPath(payload map[string]interface{}) (string, string, error) { - // Get project name from payload (required) - projectName, ok := payload["project_name"].(string) - if !ok || projectName == "" { - return "", "", fmt.Errorf("project_name is required") - } - - // Allow custom compose file name, default to docker-compose.yml - composeFile := "docker-compose.yml" - if file, ok := payload["compose_file"].(string); ok && file != "" { - composeFile = file - } - - // Use compose manager to get the path - composePath := m.composeManager.GetComposePath(projectName, composeFile) - - return projectName, composePath, nil -} - -func (m *Manager) executeStackList(ctx context.Context) (interface{}, error) { - // Get all compose projects from the compose manager - projects, err := m.composeManager.ListProjects() - if err != nil { - return nil, fmt.Errorf("failed to list projects: %w", err) - } - - // Format as stack interface - stacks := make([]map[string]interface{}, 0, len(projects)) - - for _, project := range projects { - projectName := project["name"].(string) - - // Create stack with basic info - stack := map[string]interface{}{ - "id": projectName, - "name": projectName, - "path": project["path"], - "createdAt": project["createdAt"], - "updatedAt": project["updatedAt"], - "composeContent": project["composeContent"], - "envContent": project["envContent"], - "isLegacy": false, - "isExternal": false, - "isRemote": false, - "agentId": m.config.AgentID, - "agentHostname": getHostname(), - "status": "unknown", // Will update after checking services - "serviceCount": 0, - "runningCount": 0, - } - - // Get services for this project to determine status - projectName, composePath, _ := m.getComposeProjectPath(map[string]interface{}{ - "project_name": projectName, - }) - - serviceResult, err := m.dockerClient.ComposePs(ctx, composePath, projectName) - if err == nil { - // Parse the services output - if resultMap, ok := serviceResult.(map[string]interface{}); ok { - if servicesOutput, ok := resultMap["services"].(string); ok && servicesOutput != "" { - services := m.parseComposeServicesOutput(servicesOutput) - - serviceCount := len(services) - runningCount := 0 - for _, svc := range services { - if state, ok := svc["state"].(map[string]interface{}); ok { - if running, ok := state["Running"].(bool); ok && running { - runningCount++ - } - } - } - - stack["serviceCount"] = serviceCount - stack["runningCount"] = runningCount - stack["services"] = services - - // Determine status based on service counts - if serviceCount == 0 { - stack["status"] = "unknown" - } else if runningCount == 0 { - stack["status"] = "stopped" - } else if runningCount == serviceCount { - stack["status"] = "running" - } else { - stack["status"] = "partially running" - } - } - } - } - - stacks = append(stacks, stack) - } - - return map[string]interface{}{ - "stacks": stacks, - }, nil -} - -func (m *Manager) executeStackServices(ctx context.Context, payload map[string]interface{}) (interface{}, error) { - projectName, ok := payload["stack_name"].(string) - if !ok || projectName == "" { - return nil, fmt.Errorf("stack_name is required") - } - - projectName, composePath, err := m.getComposeProjectPath(map[string]interface{}{ - "project_name": projectName, - }) - if err != nil { - return nil, err - } - - serviceResult, err := m.dockerClient.ComposePs(ctx, composePath, projectName) - if err != nil { - return nil, err - } - - // Parse the services output - services := []map[string]interface{}{} - if resultMap, ok := serviceResult.(map[string]interface{}); ok { - if servicesOutput, ok := resultMap["services"].(string); ok { - services = m.parseComposeServicesOutput(servicesOutput) - } - } - - return map[string]interface{}{ - "stack_name": projectName, - "services": services, - }, nil -} - -// Helper method to parse compose ps output into service objects -func (m *Manager) parseComposeServicesOutput(output string) []map[string]interface{} { - services := []map[string]interface{}{} - - // Split output by lines - lines := strings.Split(strings.TrimSpace(output), "\n") - - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - - var serviceInfo map[string]interface{} - if err := json.Unmarshal([]byte(line), &serviceInfo); err != nil { - continue - } - - // Extract service name - serviceName := "" - if name, ok := serviceInfo["Name"].(string); ok { - parts := strings.Split(name, "-") - if len(parts) > 1 { - serviceName = parts[len(parts)-1] - } else { - serviceName = name - } - } else if service, ok := serviceInfo["Service"].(string); ok { - serviceName = service - } else { - continue // Skip if no service name - } - - // Get container ID - containerID := "" - if id, ok := serviceInfo["ID"].(string); ok { - containerID = id - } else if id, ok := serviceInfo["ContainerID"].(string); ok { - containerID = id - } - - // Create service entry with required format - service := map[string]interface{}{ - "id": containerID, - "name": serviceName, - "state": map[string]interface{}{ - "Running": false, - "Status": "unknown", - "ExitCode": 0, - }, - "ports": []map[string]interface{}{}, - "networkSettings": map[string]interface{}{ - "Networks": map[string]interface{}{}, - }, - } - - // Update state - if state, ok := serviceInfo["State"].(string); ok { - isRunning := strings.Contains(strings.ToLower(state), "running") - service["state"].(map[string]interface{})["Running"] = isRunning - service["state"].(map[string]interface{})["Status"] = state - } - - // Parse ports if available - if ports, ok := serviceInfo["Ports"].(string); ok && ports != "" { - portsList := []map[string]interface{}{} - portMappings := strings.Split(ports, ", ") - - for _, portMapping := range portMappings { - // Parse port mapping (format: "0.0.0.0:8080->80/tcp") - parts := strings.Split(portMapping, "->") - if len(parts) != 2 { - continue - } - - hostPart := strings.TrimSpace(parts[0]) - containerPart := strings.TrimSpace(parts[1]) - - // Extract host port (public port) - hostPortStr := "" - if strings.Contains(hostPart, ":") { - hostPortStr = strings.Split(hostPart, ":")[1] - } else { - hostPortStr = hostPart - } - - // Extract container port and protocol - containerPortAndProto := strings.Split(containerPart, "/") - if len(containerPortAndProto) != 2 { - continue - } - - containerPortStr := containerPortAndProto[0] - proto := containerPortAndProto[1] - - port := map[string]interface{}{ - "Type": proto, - } - - if publicPort, err := strconv.Atoi(hostPortStr); err == nil { - port["PublicPort"] = publicPort - } - - if privatePort, err := strconv.Atoi(containerPortStr); err == nil { - port["PrivatePort"] = privatePort - } - - portsList = append(portsList, port) - } - - service["ports"] = portsList - } - - // Parse networks if available - if networks, ok := serviceInfo["Networks"].(string); ok && networks != "" { - networksList := strings.Split(networks, ",") - networksMap := map[string]interface{}{} - - for _, network := range networksList { - network = strings.TrimSpace(network) - if network == "" { - continue - } - - networksMap[network] = map[string]interface{}{ - "Driver": "bridge", // Default value - } - } - - service["networkSettings"].(map[string]interface{})["Networks"] = networksMap - } - - services = append(services, service) - } - - return services -} - -// Helper function to get hostname -func getHostname() string { - hostname, err := os.Hostname() - if err != nil { - return "unknown" - } - return hostname -} diff --git a/internal/tasks/manager_test.go b/internal/tasks/manager_test.go deleted file mode 100644 index f275ac7..0000000 --- a/internal/tasks/manager_test.go +++ /dev/null @@ -1,471 +0,0 @@ -package tasks - -import ( - "testing" - - "github.com/ofkm/arcane-agent/internal/config" - "github.com/ofkm/arcane-agent/internal/docker" -) - -func TestNewManager(t *testing.T) { - cfg := &config.Config{ - ComposeBasePath: "/opt/compose-projects", - } - dockerClient := docker.NewClient() - manager := NewManager(dockerClient, cfg) - - if manager == nil { - t.Error("Expected non-nil manager") - } - - if manager.dockerClient != dockerClient { - t.Error("Expected docker client to be set") - } - - if manager.config != cfg { - t.Error("Expected config to be set") - } -} - -func TestExecuteTask(t *testing.T) { - cfg := &config.Config{ - ComposeBasePath: "/opt/compose-projects", - } - dockerClient := docker.NewClient() - manager := NewManager(dockerClient, cfg) - - // Test structure validation (doesn't require Docker) - tests := []struct { - name string - taskType string - payload map[string]interface{} - wantErr bool - }{ - { - name: "unknown task type", - taskType: "unknown_task", - payload: map[string]interface{}{}, - wantErr: true, - }, - { - name: "docker_command missing command", - taskType: "docker_command", - payload: map[string]interface{}{}, - wantErr: true, - }, - { - name: "container_start missing container_id", - taskType: "container_start", - payload: map[string]interface{}{}, - wantErr: true, - }, - { - name: "container_stop missing container_id", - taskType: "container_stop", - payload: map[string]interface{}{}, - wantErr: true, - }, - { - name: "compose_up missing project_name", - taskType: "compose_up", - payload: map[string]interface{}{}, - wantErr: true, - }, - { - name: "compose_down missing project_name", - taskType: "compose_down", - payload: map[string]interface{}{}, - wantErr: true, - }, - { - name: "compose_ps missing project_name", - taskType: "compose_ps", - payload: map[string]interface{}{}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := manager.ExecuteTask(tt.taskType, tt.payload) - - if tt.wantErr && err == nil { - t.Error("Expected error but got none") - } - - if !tt.wantErr && err != nil { - t.Logf("Task failed (might be expected): %v", err) - } - - // For unknown task type, we should definitely get an error - if tt.taskType == "unknown_task" && err == nil { - t.Error("Expected error for unknown task type") - } - - if err == nil && result == nil { - t.Error("Expected non-nil result for successful task") - } - }) - } -} - -// Test Docker operations only if Docker is available -func TestExecuteTaskWithDocker(t *testing.T) { - cfg := &config.Config{ - ComposeBasePath: "/opt/compose-projects", - } - dockerClient := docker.NewClient() - - if !dockerClient.IsDockerAvailable() { - t.Skip("Docker not available, skipping Docker-dependent tests") - return - } - - manager := NewManager(dockerClient, cfg) - - t.Run("docker version command", func(t *testing.T) { - result, err := manager.ExecuteTask("docker_command", map[string]interface{}{ - "command": "version", - "args": []interface{}{"--format", "json"}, - }) - - if err != nil { - t.Logf("Docker command failed: %v", err) - return - } - - if result == nil { - t.Error("Expected non-nil result") - } - }) - - t.Run("list containers", func(t *testing.T) { - result, err := manager.ExecuteTask("container_list", map[string]interface{}{}) - - if err != nil { - t.Logf("Container list failed: %v", err) - return - } - - if result == nil { - t.Error("Expected non-nil result") - } - }) -} - -func TestExecuteDockerCommand(t *testing.T) { - cfg := &config.Config{ - ComposeBasePath: "/opt/compose-projects", - } - dockerClient := docker.NewClient() - manager := NewManager(dockerClient, cfg) - - tests := []struct { - name string - payload map[string]interface{} - wantErr bool - }{ - { - name: "missing command", - payload: map[string]interface{}{}, - wantErr: true, - }, - { - name: "command without args", - payload: map[string]interface{}{ - "command": "version", - }, - wantErr: false, // May fail if Docker not available - }, - { - name: "command with args", - payload: map[string]interface{}{ - "command": "version", - "args": []interface{}{"--format", "json"}, - }, - wantErr: false, // May fail if Docker not available - }, - { - name: "command with invalid args type", - payload: map[string]interface{}{ - "command": "version", - "args": "not_an_array", - }, - wantErr: false, // Args will be ignored, command will run - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := manager.executeDockerCommand(tt.payload) - - if tt.wantErr && err == nil { - t.Error("Expected error but got none") - } - - if !tt.wantErr && err != nil { - t.Logf("Docker command failed (likely Docker not available): %v", err) - } - - if !tt.wantErr && err == nil { - resultMap, ok := result.(map[string]interface{}) - if !ok { - t.Error("Expected result to be a map") - return - } - - if _, exists := resultMap["output"]; !exists { - t.Error("Expected 'output' key in result") - } - - if _, exists := resultMap["command"]; !exists { - t.Error("Expected 'command' key in result") - } - } - }) - } -} - -func TestExecuteMetricsTask(t *testing.T) { - cfg := &config.Config{ - ComposeBasePath: "/opt/compose-projects", - } - dockerClient := docker.NewClient() - manager := NewManager(dockerClient, cfg) - - result, err := manager.ExecuteTask("metrics", map[string]interface{}{}) - - // May fail if Docker not available, but structure should be correct - if err != nil { - t.Logf("Metrics task failed (likely Docker not available): %v", err) - return - } - - if result == nil { - t.Error("Expected non-nil result for metrics task") - return - } - - metricsMap, ok := result.(map[string]interface{}) - if !ok { - t.Error("Expected metrics result to be a map") - return - } - - expectedKeys := []string{"containerCount", "imageCount", "stackCount", "networkCount", "volumeCount"} - for _, key := range expectedKeys { - if _, exists := metricsMap[key]; !exists { - t.Errorf("Expected '%s' key in metrics", key) - } - } -} - -func TestGetComposeProjectPath(t *testing.T) { - cfg := &config.Config{ - ComposeBasePath: "/opt/compose-projects", - } - dockerClient := docker.NewClient() - manager := NewManager(dockerClient, cfg) - - tests := []struct { - name string - payload map[string]interface{} - expectedProject string - expectedPath string - expectError bool - }{ - { - name: "basic project", - payload: map[string]interface{}{ - "project_name": "web-app", - }, - expectedProject: "web-app", - expectedPath: "/opt/compose-projects/web-app/docker-compose.yml", - expectError: false, - }, - { - name: "project with custom compose file", - payload: map[string]interface{}{ - "project_name": "api-gateway", - "compose_file": "docker-compose.prod.yml", - }, - expectedProject: "api-gateway", - expectedPath: "/opt/compose-projects/api-gateway/docker-compose.prod.yml", - expectError: false, - }, - { - name: "missing project name", - payload: map[string]interface{}{}, - expectError: true, - }, - { - name: "empty project name", - payload: map[string]interface{}{ - "project_name": "", - }, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - projectName, composePath, err := manager.getComposeProjectPath(tt.payload) - - if tt.expectError { - if err == nil { - t.Error("Expected error but got none") - } - return - } - - if err != nil { - t.Errorf("Unexpected error: %v", err) - return - } - - if projectName != tt.expectedProject { - t.Errorf("Expected project name %s, got %s", tt.expectedProject, projectName) - } - - if composePath != tt.expectedPath { - t.Errorf("Expected compose path %s, got %s", tt.expectedPath, composePath) - } - }) - } -} - -func TestExecuteComposeTaskStructure(t *testing.T) { - cfg := &config.Config{ - ComposeBasePath: "/opt/compose-projects", - } - dockerClient := docker.NewClient() - manager := NewManager(dockerClient, cfg) - - tests := []struct { - name string - taskType string - payload map[string]interface{} - wantErr bool - }{ - { - name: "compose_up missing project name", - taskType: "compose_up", - payload: map[string]interface{}{}, - wantErr: true, - }, - { - name: "compose_up with project name", - taskType: "compose_up", - payload: map[string]interface{}{ - "project_name": "test-project", - }, - wantErr: true, // Will fail because compose file doesn't exist - }, - { - name: "compose_ps missing project name", - taskType: "compose_ps", - payload: map[string]interface{}{}, - wantErr: true, - }, - { - name: "compose_logs with service", - taskType: "compose_logs", - payload: map[string]interface{}{ - "project_name": "test-project", - "service_name": "web", - "tail": 50, - }, - wantErr: true, // Will fail because compose file doesn't exist - }, - { - name: "compose_deploy with project", - taskType: "compose_deploy", - payload: map[string]interface{}{ - "project_name": "test-project", - }, - wantErr: true, // Will fail because compose file doesn't exist - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := manager.ExecuteTask(tt.taskType, tt.payload) - - if tt.wantErr && err == nil { - t.Error("Expected error but got none") - } - - if !tt.wantErr && err != nil { - t.Errorf("Unexpected error: %v", err) - } - - // Log result for debugging - t.Logf("Task %s result: %v, error: %v", tt.taskType, result, err) - }) - } -} - -// Test compose operations with Docker (if available) -func TestExecuteComposeTaskWithDocker(t *testing.T) { - cfg := &config.Config{ - ComposeBasePath: "/tmp/test-compose", - } - dockerClient := docker.NewClient() - - if !dockerClient.IsDockerAvailable() { - t.Skip("Docker not available, skipping Docker-dependent tests") - return - } - - manager := NewManager(dockerClient, cfg) - - t.Run("compose operations require project name", func(t *testing.T) { - composeTasks := []string{"compose_up", "compose_down", "compose_ps", "compose_logs", "compose_deploy"} - - for _, taskType := range composeTasks { - t.Run(taskType, func(t *testing.T) { - // Test without project name (should fail) - _, err := manager.ExecuteTask(taskType, map[string]interface{}{}) - if err == nil { - t.Errorf("Expected error for %s without project_name", taskType) - } - - // Test with project name (will fail because compose file doesn't exist, but error should be different) - _, err = manager.ExecuteTask(taskType, map[string]interface{}{ - "project_name": "nonexistent-project", - }) - if err == nil { - t.Logf("Unexpectedly succeeded for %s with nonexistent project", taskType) - } else { - t.Logf("Expected failure for %s with nonexistent project: %v", taskType, err) - } - }) - } - }) -} - -// Test the updated ExecuteTask signature compatibility -func TestExecuteTaskSignature(t *testing.T) { - cfg := &config.Config{ - ComposeBasePath: "/opt/compose-projects", - } - dockerClient := docker.NewClient() - manager := NewManager(dockerClient, cfg) - - // Test that ExecuteTask accepts the expected parameters - result, err := manager.ExecuteTask("unknown_task", map[string]interface{}{}) - - if err == nil { - t.Error("Expected error for unknown task type") - } - - if result != nil { - t.Error("Expected nil result for failed task") - } - - // Verify the error message - expectedErrorMsg := "unknown task type: unknown_task" - if err.Error() != expectedErrorMsg { - t.Errorf("Expected error message '%s', got '%s'", expectedErrorMsg, err.Error()) - } -} diff --git a/internal/tasks/system.go b/internal/tasks/system.go deleted file mode 100644 index 29f641b..0000000 --- a/internal/tasks/system.go +++ /dev/null @@ -1,83 +0,0 @@ -package tasks - -import ( - "context" - "os/exec" - "runtime" -) - -// SystemTaskExecutor handles system-level tasks -type SystemTaskExecutor struct{} - -func NewSystemTaskExecutor() *SystemTaskExecutor { - return &SystemTaskExecutor{} -} - -func (s *SystemTaskExecutor) GetSystemInfo(ctx context.Context) (interface{}, error) { - return map[string]interface{}{ - "platform": runtime.GOOS, - "architecture": runtime.GOARCH, - "go_version": runtime.Version(), - "num_cpu": runtime.NumCPU(), - }, nil -} - -func (s *SystemTaskExecutor) ExecuteCommand(ctx context.Context, command string, args []string) (interface{}, error) { - cmd := exec.Command(command, args...) - output, err := cmd.CombinedOutput() - if err != nil { - return nil, err - } - - return map[string]interface{}{ - "command": command, - "args": args, - "output": string(output), - }, nil -} - -func (s *SystemTaskExecutor) GetDiskUsage(ctx context.Context) (interface{}, error) { - var cmd *exec.Cmd - - switch runtime.GOOS { - case "windows": - cmd = exec.Command("wmic", "logicaldisk", "get", "size,freespace,caption") - case "darwin": - cmd = exec.Command("df", "-h") - default: // linux - cmd = exec.Command("df", "-h") - } - - output, err := cmd.CombinedOutput() - if err != nil { - return nil, err - } - - return map[string]interface{}{ - "disk_usage": string(output), - "platform": runtime.GOOS, - }, nil -} - -func (s *SystemTaskExecutor) GetMemoryUsage(ctx context.Context) (interface{}, error) { - var cmd *exec.Cmd - - switch runtime.GOOS { - case "windows": - cmd = exec.Command("wmic", "OS", "get", "TotalVisibleMemorySize,FreePhysicalMemory") - case "darwin": - cmd = exec.Command("vm_stat") - default: // linux - cmd = exec.Command("free", "-h") - } - - output, err := cmd.CombinedOutput() - if err != nil { - return nil, err - } - - return map[string]interface{}{ - "memory_usage": string(output), - "platform": runtime.GOOS, - }, nil -} diff --git a/pkg/types/message.go b/pkg/types/message.go deleted file mode 100644 index 12a8403..0000000 --- a/pkg/types/message.go +++ /dev/null @@ -1,52 +0,0 @@ -package types - -import "time" - -type Message struct { - Type string `json:"type"` - AgentID string `json:"agent_id"` - Timestamp time.Time `json:"timestamp"` - Data map[string]interface{} `json:"data,omitempty"` -} - -type TaskRequest struct { - ID string `json:"id"` - Type string `json:"type"` - Payload map[string]interface{} `json:"payload"` -} - -type TaskResult struct { - TaskID string `json:"task_id"` - Status string `json:"status"` - Result interface{} `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -type AgentMetrics struct { - ContainerCount *int `json:"containerCount,omitempty"` - ImageCount *int `json:"imageCount,omitempty"` - StackCount *int `json:"stackCount,omitempty"` - NetworkCount *int `json:"networkCount,omitempty"` - VolumeCount *int `json:"volumeCount,omitempty"` -} - -type HeartbeatMessage struct { - AgentID string `json:"agent_id"` - Status string `json:"status"` - Timestamp time.Time `json:"timestamp"` - Metrics *AgentMetrics `json:"metrics,omitempty"` -} - -type ComposeDeployRequest struct { - ComposeFile string `json:"compose_file"` - Action string `json:"action"` // up or dwon - ProjectName string `json:"project_name,omitempty"` -} - -type ComposeDeployResult struct { - Status string `json:"status"` - ComposeFile string `json:"compose_file"` - Action string `json:"action"` - Output string `json:"output"` - Error string `json:"error,omitempty"` -} diff --git a/pkg/types/message_test.go b/pkg/types/message_test.go deleted file mode 100644 index 8f6a6ee..0000000 --- a/pkg/types/message_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package types - -import ( - "encoding/json" - "testing" - "time" -) - -func TestMessageSerialization(t *testing.T) { - now := time.Now() - msg := Message{ - Type: "test", - AgentID: "agent-123", - Timestamp: now, - Data: map[string]interface{}{ - "key": "value", - }, - } - - // Test JSON marshaling - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Failed to marshal message: %v", err) - } - - // Test JSON unmarshaling - var unmarshaled Message - err = json.Unmarshal(data, &unmarshaled) - if err != nil { - t.Fatalf("Failed to unmarshal message: %v", err) - } - - if unmarshaled.Type != msg.Type { - t.Errorf("Expected Type %s, got %s", msg.Type, unmarshaled.Type) - } - - if unmarshaled.AgentID != msg.AgentID { - t.Errorf("Expected AgentID %s, got %s", msg.AgentID, unmarshaled.AgentID) - } - - if unmarshaled.Timestamp.Unix() != msg.Timestamp.Unix() { - t.Errorf("Expected Timestamp %v, got %v", msg.Timestamp, unmarshaled.Timestamp) - } - - if len(unmarshaled.Data) != len(msg.Data) { - t.Errorf("Expected Data length %d, got %d", len(msg.Data), len(unmarshaled.Data)) - } -} - -func TestTaskRequestSerialization(t *testing.T) { - task := TaskRequest{ - ID: "task-123", - Type: "docker_command", - Payload: map[string]interface{}{ - "command": "version", - "args": []string{"--format", "json"}, - }, - } - - // Test JSON marshaling - data, err := json.Marshal(task) - if err != nil { - t.Fatalf("Failed to marshal task request: %v", err) - } - - // Test JSON unmarshaling - var unmarshaled TaskRequest - err = json.Unmarshal(data, &unmarshaled) - if err != nil { - t.Fatalf("Failed to unmarshal task request: %v", err) - } - - if unmarshaled.ID != task.ID { - t.Errorf("Expected ID %s, got %s", task.ID, unmarshaled.ID) - } - - if unmarshaled.Type != task.Type { - t.Errorf("Expected Type %s, got %s", task.Type, unmarshaled.Type) - } - - if len(unmarshaled.Payload) != len(task.Payload) { - t.Errorf("Expected Payload length %d, got %d", len(task.Payload), len(unmarshaled.Payload)) - } -} - -func TestTaskResultSerialization(t *testing.T) { - result := TaskResult{ - TaskID: "task-123", - Status: "completed", - Result: map[string]interface{}{ - "output": "docker version output", - }, - Error: "", - } - - // Test JSON marshaling - data, err := json.Marshal(result) - if err != nil { - t.Fatalf("Failed to marshal task result: %v", err) - } - - // Test JSON unmarshaling - var unmarshaled TaskResult - err = json.Unmarshal(data, &unmarshaled) - if err != nil { - t.Fatalf("Failed to unmarshal task result: %v", err) - } - - if unmarshaled.TaskID != result.TaskID { - t.Errorf("Expected TaskID %s, got %s", result.TaskID, unmarshaled.TaskID) - } - - if unmarshaled.Status != result.Status { - t.Errorf("Expected Status %s, got %s", result.Status, unmarshaled.Status) - } - - if unmarshaled.Error != result.Error { - t.Errorf("Expected Error %s, got %s", result.Error, unmarshaled.Error) - } -} - -func TestTaskResultWithError(t *testing.T) { - result := TaskResult{ - TaskID: "task-123", - Status: "failed", - Result: nil, - Error: "container not found", - } - - // Test JSON marshaling - data, err := json.Marshal(result) - if err != nil { - t.Fatalf("Failed to marshal task result: %v", err) - } - - // Test JSON unmarshaling - var unmarshaled TaskResult - err = json.Unmarshal(data, &unmarshaled) - if err != nil { - t.Fatalf("Failed to unmarshal task result: %v", err) - } - - if unmarshaled.Status != "failed" { - t.Errorf("Expected Status 'failed', got %s", unmarshaled.Status) - } - - if unmarshaled.Error != "container not found" { - t.Errorf("Expected Error 'container not found', got %s", unmarshaled.Error) - } - - if unmarshaled.Result != nil { - t.Errorf("Expected Result to be nil, got %v", unmarshaled.Result) - } -} From 1ecf0b10930ba34827b1e58f885764dd2a299528 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Mon, 16 Jun 2025 14:04:57 -0500 Subject: [PATCH 2/8] change api reponse layout --- internal/handlers/container_handler.go | 63 ++++++++++++++---- internal/handlers/docker_handler.go | 11 +++- internal/handlers/image_handler.go | 90 ++++++++++++++++++++------ internal/handlers/status_handler.go | 9 ++- internal/middleware/middleware.go | 12 +++- 5 files changed, 144 insertions(+), 41 deletions(-) diff --git a/internal/handlers/container_handler.go b/internal/handlers/container_handler.go index 4853fb1..a75b368 100644 --- a/internal/handlers/container_handler.go +++ b/internal/handlers/container_handler.go @@ -24,13 +24,20 @@ func (h *ContainerHandler) ListContainers(c *gin.Context) { containerList, err := h.dockerClient.ListContainers(c.Request.Context(), all) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } c.JSON(http.StatusOK, gin.H{ - "containers": containerList, - "total": len(containerList), + "data": gin.H{ + "containers": containerList, + "total": len(containerList), + }, + "success": true, }) } @@ -38,24 +45,38 @@ func (h *ContainerHandler) GetContainer(c *gin.Context) { containerID := c.Param("id") container, err := h.dockerClient.GetContainer(c.Request.Context(), containerID) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } - c.JSON(http.StatusOK, container) + c.JSON(http.StatusOK, gin.H{ + "data": container, + "success": true, + }) } func (h *ContainerHandler) StartContainer(c *gin.Context) { containerID := c.Param("id") err := h.dockerClient.StartContainer(c.Request.Context(), containerID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } c.JSON(http.StatusOK, gin.H{ - "message": "Container started successfully", - "container_id": containerID, + "data": gin.H{ + "message": "Container started successfully", + "container_id": containerID, + }, + "success": true, }) } @@ -63,13 +84,20 @@ func (h *ContainerHandler) StopContainer(c *gin.Context) { containerID := c.Param("id") err := h.dockerClient.StopContainer(c.Request.Context(), containerID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } c.JSON(http.StatusOK, gin.H{ - "message": "Container stopped successfully", - "container_id": containerID, + "data": gin.H{ + "message": "Container stopped successfully", + "container_id": containerID, + }, + "success": true, }) } @@ -77,12 +105,19 @@ func (h *ContainerHandler) RestartContainer(c *gin.Context) { containerID := c.Param("id") err := h.dockerClient.RestartContainer(c.Request.Context(), containerID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } c.JSON(http.StatusOK, gin.H{ - "message": "Container restarted successfully", - "container_id": containerID, + "data": gin.H{ + "message": "Container restarted successfully", + "container_id": containerID, + }, + "success": true, }) } diff --git a/internal/handlers/docker_handler.go b/internal/handlers/docker_handler.go index 838c5a7..b3444a1 100644 --- a/internal/handlers/docker_handler.go +++ b/internal/handlers/docker_handler.go @@ -20,9 +20,16 @@ func NewDockerHandler(dockerClient *docker.Client) *DockerHandler { func (h *DockerHandler) GetDockerInfo(c *gin.Context) { info, err := h.dockerClient.GetSystemInfo(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } - c.JSON(http.StatusOK, info) + c.JSON(http.StatusOK, gin.H{ + "data": info, + "success": true, + }) } diff --git a/internal/handlers/image_handler.go b/internal/handlers/image_handler.go index 12a7275..c638f04 100644 --- a/internal/handlers/image_handler.go +++ b/internal/handlers/image_handler.go @@ -23,13 +23,20 @@ func (h *ImageHandler) ListImages(c *gin.Context) { images, err := h.dockerClient.ListImages(c.Request.Context(), all) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } c.JSON(http.StatusOK, gin.H{ - "images": images, - "total": len(images), + "data": gin.H{ + "images": images, + "total": len(images), + }, + "success": true, }) } @@ -37,11 +44,18 @@ func (h *ImageHandler) GetImage(c *gin.Context) { imageID := c.Param("id") image, err := h.dockerClient.GetImage(c.Request.Context(), imageID) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } - c.JSON(http.StatusOK, image) + c.JSON(http.StatusOK, gin.H{ + "data": image, + "success": true, + }) } func (h *ImageHandler) CreateImage(c *gin.Context) { @@ -52,7 +66,11 @@ func (h *ImageHandler) CreateImage(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } @@ -63,13 +81,20 @@ func (h *ImageHandler) CreateImage(c *gin.Context) { err := h.dockerClient.PullImage(c.Request.Context(), req.FromImage, req.Tag, req.Platform) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } c.JSON(http.StatusOK, gin.H{ - "message": "Image pulled successfully", - "image": req.FromImage + ":" + req.Tag, + "data": gin.H{ + "message": "Image pulled successfully", + "image": req.FromImage + ":" + req.Tag, + }, + "success": true, }) } @@ -92,13 +117,20 @@ func (h *ImageHandler) DeleteImage(c *gin.Context) { deletedImages, err := h.dockerClient.RemoveImage(c.Request.Context(), imageID, req.Force, req.NoPrune) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } c.JSON(http.StatusOK, gin.H{ - "message": "Image deleted successfully", - "deleted_images": deletedImages, + "data": gin.H{ + "message": "Image deleted successfully", + "deleted_images": deletedImages, + }, + "success": true, }) } @@ -111,7 +143,11 @@ func (h *ImageHandler) TagImage(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } @@ -122,14 +158,21 @@ func (h *ImageHandler) TagImage(c *gin.Context) { err := h.dockerClient.TagImage(c.Request.Context(), imageID, req.Repository, req.Tag) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } c.JSON(http.StatusOK, gin.H{ - "message": "Image tagged successfully", - "source": imageID, - "target": req.Repository + ":" + req.Tag, + "data": gin.H{ + "message": "Image tagged successfully", + "source": imageID, + "target": req.Repository + ":" + req.Tag, + }, + "success": true, }) } @@ -144,7 +187,11 @@ func (h *ImageHandler) PushImage(c *gin.Context) { err := h.dockerClient.PushImage(c.Request.Context(), imageID, req.Tag) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) return } @@ -154,7 +201,10 @@ func (h *ImageHandler) PushImage(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "message": "Image pushed successfully", - "image": pushTarget, + "data": gin.H{ + "message": "Image pushed successfully", + "image": pushTarget, + }, + "success": true, }) } diff --git a/internal/handlers/status_handler.go b/internal/handlers/status_handler.go index 298c29e..2ccde8b 100644 --- a/internal/handlers/status_handler.go +++ b/internal/handlers/status_handler.go @@ -19,8 +19,11 @@ func NewStatusHandler(cfg *config.Config) *StatusHandler { func (h *StatusHandler) GetStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ - "status": "running", - "agent_id": h.config.AgentID, - "version": h.config.Version, + "data": gin.H{ + "status": "running", + "agent_id": h.config.AgentID, + "version": h.config.Version, + }, + "success": true, }) } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index c9dd5ad..b9e4ddc 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -12,7 +12,11 @@ func APIKeyMiddleware(expectedAPIKey string) gin.HandlerFunc { return func(c *gin.Context) { apiKey := c.GetHeader("X-API-Key") if apiKey != expectedAPIKey { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "data": nil, + "success": false, + "error": "Unauthorized", + }) return } c.Next() @@ -23,7 +27,11 @@ func APIKeyMiddleware(expectedAPIKey string) gin.HandlerFunc { func DockerAvailabilityMiddleware(dockerClient *docker.Client) gin.HandlerFunc { return func(c *gin.Context) { if dockerClient == nil { - c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "Docker not available"}) + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{ + "data": nil, + "success": false, + "error": "Docker not available", + }) return } c.Next() From edd8c38a631bb71f9d352faef1528c852c4b6acd Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Mon, 16 Jun 2025 23:12:49 -0500 Subject: [PATCH 3/8] more work in api agent --- internal/api/router.go | 32 +++- internal/docker/client.go | 97 +++++++++++ internal/dto/image_dto.go | 5 + internal/handlers/image_handler.go | 75 +++++++++ internal/handlers/network_handler.go | 232 +++++++++++++++++++++++++++ internal/handlers/volume_handler.go | 188 ++++++++++++++++++++++ internal/middleware/middleware.go | 2 - 7 files changed, 628 insertions(+), 3 deletions(-) create mode 100644 internal/dto/image_dto.go create mode 100644 internal/handlers/network_handler.go create mode 100644 internal/handlers/volume_handler.go diff --git a/internal/api/router.go b/internal/api/router.go index 59f51cb..a408c5b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -28,6 +28,8 @@ func NewRouter(cfg *config.Config, dockerClient *docker.Client) *gin.Engine { setupContainerRoutes(api, containerHandler, dockerClient) setupDockerRoutes(api, dockerHandler, dockerClient) setupImageRoutes(api, imageHandler, dockerClient) + setupNetworkRoutes(api, handlers.NewNetworkHandler(dockerClient)) + setupVolumeRoutes(api, handlers.NewVolumeHandler(dockerClient), dockerClient) } return router @@ -66,8 +68,36 @@ func setupImageRoutes(api *gin.RouterGroup, imageHandler *handlers.ImageHandler, images.Use(middleware.DockerAvailabilityMiddleware(dockerClient)) { images.GET("", imageHandler.ListImages) - images.GET("/:id", imageHandler.GetImage) images.POST("", imageHandler.CreateImage) + images.POST("/pull", imageHandler.Pull) + images.GET("/:id", imageHandler.GetImage) images.DELETE("/:id", imageHandler.DeleteImage) + images.POST("/:id/tag", imageHandler.TagImage) + images.POST("/:id/push", imageHandler.PushImage) + } +} + +func setupNetworkRoutes(router *gin.RouterGroup, networkHandler *handlers.NetworkHandler) { + networks := router.Group("/networks") + { + networks.GET("", networkHandler.ListNetworks) + networks.POST("", networkHandler.CreateNetwork) + networks.GET("/:id", networkHandler.GetNetwork) + networks.DELETE("/:id", networkHandler.DeleteNetwork) + networks.POST("/:id/connect", networkHandler.ConnectContainer) + networks.POST("/:id/disconnect", networkHandler.DisconnectContainer) + networks.POST("/prune", networkHandler.PruneNetworks) + } +} + +func setupVolumeRoutes(api *gin.RouterGroup, volumeHandler *handlers.VolumeHandler, dockerClient *docker.Client) { + volumes := api.Group("/volumes") + volumes.Use(middleware.DockerAvailabilityMiddleware(dockerClient)) + { + volumes.GET("", volumeHandler.ListVolumes) + volumes.POST("", volumeHandler.CreateVolume) + volumes.GET("/:id", volumeHandler.GetVolume) + volumes.DELETE("/:id", volumeHandler.DeleteVolume) + volumes.POST("/prune", volumeHandler.PruneVolumes) } } diff --git a/internal/docker/client.go b/internal/docker/client.go index cb28eef..c6cf2c9 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -7,8 +7,11 @@ import ( "time" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/system" + "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" ) @@ -109,6 +112,43 @@ func (c *Client) PullImage(ctx context.Context, fromImage string, tag string, pl return nil } +// New method for streaming pull +func (c *Client) PullImageStream(ctx context.Context, fromImage string, tag string, platform string) (io.ReadCloser, error) { + pullOptions := image.PullOptions{ + Platform: platform, + } + + imageRef := fromImage + if tag != "" { + imageRef = fmt.Sprintf("%s:%s", fromImage, tag) + } + + reader, err := c.cli.ImagePull(ctx, imageRef, pullOptions) + if err != nil { + return nil, fmt.Errorf("failed to pull image: %w", err) + } + + return reader, nil +} + +func (c *Client) PullImageWithStream(ctx context.Context, imageName string, writer io.Writer) error { + pullOptions := image.PullOptions{} + + reader, err := c.cli.ImagePull(ctx, imageName, pullOptions) + if err != nil { + return fmt.Errorf("failed to pull image: %w", err) + } + defer reader.Close() + + // Stream the response directly to the writer + _, err = io.Copy(writer, reader) + if err != nil { + return fmt.Errorf("failed to stream pull response: %w", err) + } + + return nil +} + func (c *Client) BuildImage(ctx context.Context, contextPath string, dockerfile string, tags []string, buildArgs map[string]string, target string, platform string) (string, error) { // This is a simplified implementation // In a real implementation, you'd need to create a tar archive of the build context @@ -148,6 +188,63 @@ func (c *Client) PushImage(ctx context.Context, imageID string, tag string) erro return nil } +// Network methods +func (c *Client) ListNetworks(ctx context.Context) ([]network.Summary, error) { + return c.cli.NetworkList(ctx, network.ListOptions{}) +} + +func (c *Client) GetNetwork(ctx context.Context, networkID string) (network.Inspect, error) { + return c.cli.NetworkInspect(ctx, networkID, network.InspectOptions{}) +} + +func (c *Client) CreateNetwork(ctx context.Context, name string, options network.CreateOptions) (network.CreateResponse, error) { + return c.cli.NetworkCreate(ctx, name, options) +} + +func (c *Client) RemoveNetwork(ctx context.Context, networkID string) error { + return c.cli.NetworkRemove(ctx, networkID) +} + +func (c *Client) ConnectContainerToNetwork(ctx context.Context, networkID string, containerID string, config *network.EndpointSettings) error { + return c.cli.NetworkConnect(ctx, networkID, containerID, config) +} + +func (c *Client) DisconnectContainerFromNetwork(ctx context.Context, networkID string, containerID string, force bool) error { + return c.cli.NetworkDisconnect(ctx, networkID, containerID, force) +} + +func (c *Client) PruneNetworks(ctx context.Context) (network.PruneReport, error) { + filterArgs := filters.NewArgs() + return c.cli.NetworksPrune(ctx, filterArgs) + +} + +// Volume methods +func (c *Client) ListVolumes(ctx context.Context) (volume.ListResponse, error) { + return c.cli.VolumeList(ctx, volume.ListOptions{}) +} + +func (c *Client) GetVolume(ctx context.Context, volumeID string) (volume.Volume, error) { + return c.cli.VolumeInspect(ctx, volumeID) +} + +func (c *Client) CreateVolume(ctx context.Context, options volume.CreateOptions) (volume.Volume, error) { + return c.cli.VolumeCreate(ctx, options) +} + +func (c *Client) RemoveVolume(ctx context.Context, volumeID string, force bool) error { + return c.cli.VolumeRemove(ctx, volumeID, force) +} + +func (c *Client) PruneVolumes(ctx context.Context) (volume.PruneReport, error) { + filterArgs := filters.NewArgs() + return c.cli.VolumesPrune(ctx, filterArgs) +} + +func (c *Client) PruneVolumesWithFilters(ctx context.Context, filterArgs filters.Args) (volume.PruneReport, error) { + return c.cli.VolumesPrune(ctx, filterArgs) +} + func (c *Client) Close() error { if c.cli != nil { return c.cli.Close() diff --git a/internal/dto/image_dto.go b/internal/dto/image_dto.go new file mode 100644 index 0000000..05d47a7 --- /dev/null +++ b/internal/dto/image_dto.go @@ -0,0 +1,5 @@ +package dto + +type ImagePullDto struct { + ImageName string `json:"imageName" binding:"required"` +} diff --git a/internal/handlers/image_handler.go b/internal/handlers/image_handler.go index c638f04..342d3e9 100644 --- a/internal/handlers/image_handler.go +++ b/internal/handlers/image_handler.go @@ -1,10 +1,17 @@ package handlers import ( + "bytes" + "fmt" + "io" "net/http" + "strings" + + "log/slog" "github.com/gin-gonic/gin" "github.com/ofkm/arcane-agent/internal/docker" + "github.com/ofkm/arcane-agent/internal/dto" ) type ImageHandler struct { @@ -58,6 +65,74 @@ func (h *ImageHandler) GetImage(c *gin.Context) { }) } +func (h *ImageHandler) Pull(c *gin.Context) { + var req dto.ImagePullDto + + // Read the raw body to log it + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + slog.Error("Failed to read request body", "error", err.Error()) + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": "Failed to read request body", + }) + return + } + + // Restore the body for binding + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + // Add debug logging + slog.Info("Pull request received", + "method", c.Request.Method, + "contentType", c.GetHeader("Content-Type"), + "bodyContent", string(bodyBytes)) + + if err := c.ShouldBindJSON(&req); err != nil { + slog.Error("Failed to bind JSON", "error", err.Error(), "rawBody", string(bodyBytes)) + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": "Invalid request body: " + err.Error(), + }) + return + } + + slog.Info("Pull request parsed", "imageName", req.ImageName) + + c.Writer.Header().Set("Content-Type", "application/x-json-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + + err = h.dockerClient.PullImageWithStream(c.Request.Context(), req.ImageName, c.Writer) + + if err != nil { + if !c.Writer.Written() { + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "manifest unknown") { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "error": fmt.Sprintf("Failed to pull image '%s': %s. Ensure the image name and tag are correct and the image exists in the registry.", req.ImageName, err.Error()), + }) + } else { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": fmt.Sprintf("Failed to pull image '%s': %s", req.ImageName, err.Error()), + }) + } + } else { + slog.Error("Error during image pull stream or post-stream operation", "imageName", req.ImageName, "error", err.Error()) + fmt.Fprintf(c.Writer, `{"error": {"code": 500, "message": "Stream interrupted or post-stream operation failed: %s"}}`+"\n", strings.ReplaceAll(err.Error(), "\"", "'")) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } + } + return + } + + slog.Info("Image pull stream completed", "imageName", req.ImageName) +} + +// Keep the existing CreateImage method for backward compatibility func (h *ImageHandler) CreateImage(c *gin.Context) { var req struct { FromImage string `json:"fromImage" binding:"required"` diff --git a/internal/handlers/network_handler.go b/internal/handlers/network_handler.go new file mode 100644 index 0000000..2f5ba80 --- /dev/null +++ b/internal/handlers/network_handler.go @@ -0,0 +1,232 @@ +package handlers + +import ( + "net/http" + + "github.com/docker/docker/api/types/network" + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-agent/internal/docker" +) + +type NetworkHandler struct { + dockerClient *docker.Client +} + +func NewNetworkHandler(dockerClient *docker.Client) *NetworkHandler { + return &NetworkHandler{ + dockerClient: dockerClient, + } +} + +func (h *NetworkHandler) ListNetworks(c *gin.Context) { + networks, err := h.dockerClient.ListNetworks(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "networks": networks, + "total": len(networks), + }, + "success": true, + }) +} + +func (h *NetworkHandler) GetNetwork(c *gin.Context) { + networkID := c.Param("id") + network, err := h.dockerClient.GetNetwork(c.Request.Context(), networkID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": network, + "success": true, + }) +} + +func (h *NetworkHandler) CreateNetwork(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Driver string `json:"driver"` + Internal bool `json:"internal"` + Attachable bool `json:"attachable"` + Ingress bool `json:"ingress"` + EnableIPv6 bool `json:"enableIPv6"` + Options map[string]string `json:"options"` + Labels map[string]string `json:"labels"` + IPAM *network.IPAM `json:"ipam"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + // Set default driver + if req.Driver == "" { + req.Driver = "bridge" + } + + options := network.CreateOptions{ + Driver: req.Driver, + Options: req.Options, + Labels: req.Labels, + Internal: req.Internal, + Attachable: req.Attachable, + Ingress: req.Ingress, + EnableIPv6: &req.EnableIPv6, + IPAM: req.IPAM, + } + + response, err := h.dockerClient.CreateNetwork(c.Request.Context(), req.Name, options) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "data": gin.H{ + "message": "Network created successfully", + "network_id": response.ID, + "name": req.Name, + "warning": response.Warning, + }, + "success": true, + }) +} + +func (h *NetworkHandler) DeleteNetwork(c *gin.Context) { + networkID := c.Param("id") + + err := h.dockerClient.RemoveNetwork(c.Request.Context(), networkID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "message": "Network deleted successfully", + "network_id": networkID, + }, + "success": true, + }) +} + +func (h *NetworkHandler) ConnectContainer(c *gin.Context) { + networkID := c.Param("id") + + var req struct { + Container string `json:"container" binding:"required"` + EndpointConfig *network.EndpointSettings `json:"endpointConfig"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + err := h.dockerClient.ConnectContainerToNetwork(c.Request.Context(), networkID, req.Container, req.EndpointConfig) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "message": "Container connected to network successfully", + "network_id": networkID, + "container_id": req.Container, + }, + "success": true, + }) +} + +func (h *NetworkHandler) DisconnectContainer(c *gin.Context) { + networkID := c.Param("id") + + var req struct { + Container string `json:"container" binding:"required"` + Force bool `json:"force"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + err := h.dockerClient.DisconnectContainerFromNetwork(c.Request.Context(), networkID, req.Container, req.Force) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "message": "Container disconnected from network successfully", + "network_id": networkID, + "container_id": req.Container, + }, + "success": true, + }) +} + +func (h *NetworkHandler) PruneNetworks(c *gin.Context) { + response, err := h.dockerClient.PruneNetworks(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "message": "Networks pruned successfully", + "networks_deleted": response.NetworksDeleted, + }, + "success": true, + }) +} diff --git a/internal/handlers/volume_handler.go b/internal/handlers/volume_handler.go new file mode 100644 index 0000000..df9ce0b --- /dev/null +++ b/internal/handlers/volume_handler.go @@ -0,0 +1,188 @@ +package handlers + +import ( + "net/http" + + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/volume" + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-agent/internal/docker" +) + +type VolumeHandler struct { + dockerClient *docker.Client +} + +func NewVolumeHandler(dockerClient *docker.Client) *VolumeHandler { + return &VolumeHandler{ + dockerClient: dockerClient, + } +} + +func (h *VolumeHandler) ListVolumes(c *gin.Context) { + response, err := h.dockerClient.ListVolumes(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "volumes": response.Volumes, + "total": len(response.Volumes), + }, + "success": true, + }) +} + +func (h *VolumeHandler) GetVolume(c *gin.Context) { + volumeID := c.Param("id") + volume, err := h.dockerClient.GetVolume(c.Request.Context(), volumeID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": volume, + "success": true, + }) +} + +func (h *VolumeHandler) CreateVolume(c *gin.Context) { + var req struct { + Name string `json:"name"` + Driver string `json:"driver"` + DriverOpts map[string]string `json:"driverOpts"` + Labels map[string]string `json:"labels"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + // Set default driver + if req.Driver == "" { + req.Driver = "local" + } + + options := volume.CreateOptions{ + Name: req.Name, + Driver: req.Driver, + DriverOpts: req.DriverOpts, + Labels: req.Labels, + } + + volume, err := h.dockerClient.CreateVolume(c.Request.Context(), options) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "data": gin.H{ + "message": "Volume created successfully", + "volume_id": volume.Name, + "driver": volume.Driver, + "mountpoint": volume.Mountpoint, + }, + "success": true, + }) +} + +func (h *VolumeHandler) DeleteVolume(c *gin.Context) { + volumeID := c.Param("id") + + var req struct { + Force bool `json:"force"` + } + + // Check for force parameter in query or body + c.ShouldBindJSON(&req) + if c.Query("force") == "true" { + req.Force = true + } + + err := h.dockerClient.RemoveVolume(c.Request.Context(), volumeID, req.Force) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "message": "Volume deleted successfully", + "volume_id": volumeID, + }, + "success": true, + }) +} + +func (h *VolumeHandler) PruneVolumes(c *gin.Context) { + var req struct { + Filters map[string][]string `json:"filters"` + } + + // Try to bind JSON body for filters (optional) + c.ShouldBindJSON(&req) + + // Create filter args + filterArgs := filters.NewArgs() + if req.Filters != nil { + for key, values := range req.Filters { + for _, value := range values { + filterArgs.Add(key, value) + } + } + } + + var response volume.PruneReport + var err error + + if len(filterArgs.Get("")) > 0 { + // Use the method with custom filters if available + response, err = h.dockerClient.PruneVolumesWithFilters(c.Request.Context(), filterArgs) + } else { + // Use the basic method + response, err = h.dockerClient.PruneVolumes(c.Request.Context()) + } + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "message": "Volumes pruned successfully", + "volumes_deleted": response.VolumesDeleted, + "space_reclaimed": response.SpaceReclaimed, + }, + "success": true, + }) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index b9e4ddc..7a706c5 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -7,7 +7,6 @@ import ( "github.com/ofkm/arcane-agent/internal/docker" ) -// APIKeyMiddleware for API key authentication func APIKeyMiddleware(expectedAPIKey string) gin.HandlerFunc { return func(c *gin.Context) { apiKey := c.GetHeader("X-API-Key") @@ -23,7 +22,6 @@ func APIKeyMiddleware(expectedAPIKey string) gin.HandlerFunc { } } -// DockerAvailabilityMiddleware checks if Docker client is available func DockerAvailabilityMiddleware(dockerClient *docker.Client) gin.HandlerFunc { return func(c *gin.Context) { if dockerClient == nil { From 4e575cda7d7c405093b237f3d9f8a2e73ef2c27b Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Thu, 19 Jun 2025 21:36:00 -0500 Subject: [PATCH 4/8] feat: add GetVolumeUsage endpoint to retrieve volume usage details --- internal/api/router.go | 1 + internal/docker/client.go | 31 +++++++++++++++++++++++++++++ internal/handlers/image_handler.go | 2 -- internal/handlers/volume_handler.go | 22 ++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index a408c5b..dd70204 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -97,6 +97,7 @@ func setupVolumeRoutes(api *gin.RouterGroup, volumeHandler *handlers.VolumeHandl volumes.GET("", volumeHandler.ListVolumes) volumes.POST("", volumeHandler.CreateVolume) volumes.GET("/:id", volumeHandler.GetVolume) + volumes.GET("/:id/usage", volumeHandler.GetVolumeUsage) volumes.DELETE("/:id", volumeHandler.DeleteVolume) volumes.POST("/prune", volumeHandler.PruneVolumes) } diff --git a/internal/docker/client.go b/internal/docker/client.go index c6cf2c9..57b4100 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -245,6 +245,37 @@ func (c *Client) PruneVolumesWithFilters(ctx context.Context, filterArgs filters return c.cli.VolumesPrune(ctx, filterArgs) } +func (c *Client) GetVolumeUsage(ctx context.Context, name string) (bool, []string, error) { + if _, err := c.cli.VolumeInspect(ctx, name); err != nil { + return false, nil, fmt.Errorf("volume not found: %w", err) + } + + containers, err := c.cli.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + return false, nil, fmt.Errorf("failed to list containers: %w", err) + } + + inUse := false + var usingContainers []string + + for _, container := range containers { + containerInfo, err := c.cli.ContainerInspect(ctx, container.ID) + if err != nil { + continue + } + + for _, mount := range containerInfo.Mounts { + if mount.Type == "volume" && mount.Name == name { + inUse = true + usingContainers = append(usingContainers, container.ID) + break + } + } + } + + return inUse, usingContainers, nil +} + func (c *Client) Close() error { if c.cli != nil { return c.cli.Close() diff --git a/internal/handlers/image_handler.go b/internal/handlers/image_handler.go index 342d3e9..2491cb5 100644 --- a/internal/handlers/image_handler.go +++ b/internal/handlers/image_handler.go @@ -79,10 +79,8 @@ func (h *ImageHandler) Pull(c *gin.Context) { return } - // Restore the body for binding c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - // Add debug logging slog.Info("Pull request received", "method", c.Request.Method, "contentType", c.GetHeader("Content-Type"), diff --git a/internal/handlers/volume_handler.go b/internal/handlers/volume_handler.go index df9ce0b..ff6e86b 100644 --- a/internal/handlers/volume_handler.go +++ b/internal/handlers/volume_handler.go @@ -57,6 +57,28 @@ func (h *VolumeHandler) GetVolume(c *gin.Context) { }) } +func (h *VolumeHandler) GetVolumeUsage(c *gin.Context) { + volumeID := c.Param("id") + inUse, usingContainers, err := h.dockerClient.GetVolumeUsage(c.Request.Context(), volumeID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(200, gin.H{ + "success": true, + "data": gin.H{ + "inUse": inUse, + "containers": usingContainers, + }, + }) + +} + func (h *VolumeHandler) CreateVolume(c *gin.Context) { var req struct { Name string `json:"name"` From eb63f82beaa08781b6abfd8d9ba7d54609e3757e Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Thu, 19 Jun 2025 23:14:58 -0500 Subject: [PATCH 5/8] add stack functionality --- go.mod | 10 + go.sum | 17 + internal/api/router.go | 28 + internal/dto/stack.go | 25 + internal/handlers/stack_handler.go | 590 +++++++++++++++++++ internal/models/stack.go | 72 +++ internal/models/stack_service_info.go | 9 + internal/services/stack.go | 810 ++++++++++++++++++++++++++ 8 files changed, 1561 insertions(+) create mode 100644 internal/dto/stack.go create mode 100644 internal/handlers/stack_handler.go create mode 100644 internal/models/stack.go create mode 100644 internal/models/stack_service_info.go create mode 100644 internal/services/stack.go diff --git a/go.mod b/go.mod index 5c2413e..b0e9028 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/ofkm/arcane-agent go 1.24.3 require ( + github.com/compose-spec/compose-go/v2 v2.6.4 github.com/docker/docker v28.2.2+incompatible github.com/gin-gonic/gin v1.10.1 + github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 ) @@ -28,12 +30,14 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-viper/mapstructure/v2 v2.0.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-shellwords v1.0.12 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect @@ -44,8 +48,13 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xhit/go-str2duration/v2 v2.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.36.0 // indirect @@ -55,6 +64,7 @@ require ( golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.38.0 // indirect golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect golang.org/x/time v0.12.0 // indirect diff --git a/go.sum b/go.sum index 108a1eb..7d8dddd 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/compose-spec/compose-go/v2 v2.6.4 h1:Gjv6x8eAhqwwWvoXIo0oZ4bDQBh0OMwdU7LUL9PDLiM= +github.com/compose-spec/compose-go/v2 v2.6.4/go.mod h1:vPlkN0i+0LjLf9rv52lodNMUTJF5YHVfHVGLLIP67NA= 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= @@ -50,6 +52,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc= +github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -80,6 +84,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= 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/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -130,6 +136,14 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -171,11 +185,14 @@ golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= diff --git a/internal/api/router.go b/internal/api/router.go index dd70204..73c19c7 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -6,6 +6,7 @@ import ( "github.com/ofkm/arcane-agent/internal/docker" "github.com/ofkm/arcane-agent/internal/handlers" "github.com/ofkm/arcane-agent/internal/middleware" + "github.com/ofkm/arcane-agent/internal/services" ) func NewRouter(cfg *config.Config, dockerClient *docker.Client) *gin.Engine { @@ -21,6 +22,7 @@ func NewRouter(cfg *config.Config, dockerClient *docker.Client) *gin.Engine { containerHandler := handlers.NewContainerHandler(dockerClient) dockerHandler := handlers.NewDockerHandler(dockerClient) imageHandler := handlers.NewImageHandler(dockerClient) + stackHandler := handlers.NewStackHandler(services.NewStackService()) api := router.Group("/api") { @@ -28,6 +30,7 @@ func NewRouter(cfg *config.Config, dockerClient *docker.Client) *gin.Engine { setupContainerRoutes(api, containerHandler, dockerClient) setupDockerRoutes(api, dockerHandler, dockerClient) setupImageRoutes(api, imageHandler, dockerClient) + setupStackRoutes(api, stackHandler, dockerClient) setupNetworkRoutes(api, handlers.NewNetworkHandler(dockerClient)) setupVolumeRoutes(api, handlers.NewVolumeHandler(dockerClient), dockerClient) } @@ -77,6 +80,31 @@ func setupImageRoutes(api *gin.RouterGroup, imageHandler *handlers.ImageHandler, } } +// Stack routes +func setupStackRoutes(api *gin.RouterGroup, stackHandler *handlers.StackHandler, dockerClient *docker.Client) { + stacks := api.Group("/stacks") + stacks.Use(middleware.DockerAvailabilityMiddleware(dockerClient)) + { + stacks.GET("", stackHandler.ListStacks) + stacks.POST("", stackHandler.CreateStack) + stacks.GET("/:id", stackHandler.GetStack) + stacks.PUT("/:id", stackHandler.UpdateStack) + stacks.DELETE("/:id", stackHandler.DeleteStack) + stacks.POST("/:id/start", stackHandler.StartStack) + stacks.POST("/:id/stop", stackHandler.StopStack) + stacks.POST("/:id/restart", stackHandler.RestartStack) + stacks.POST("/:id/redeploy", stackHandler.RedeployStack) + stacks.POST("/:id/down", stackHandler.DownStack) + stacks.DELETE("/:id/destroy", stackHandler.DestroyStack) + stacks.POST("/:id/pull", stackHandler.PullStack) + stacks.POST("/:id/deploy", stackHandler.DeployStack) + stacks.GET("/:id/services", stackHandler.GetStackServices) + stacks.POST("/:id/pull-images", stackHandler.PullImages) + stacks.POST("/convert", stackHandler.ConvertDockerRun) + stacks.GET("/:id/logs/stream", stackHandler.GetStackLogsStream) + } +} + func setupNetworkRoutes(router *gin.RouterGroup, networkHandler *handlers.NetworkHandler) { networks := router.Group("/networks") { diff --git a/internal/dto/stack.go b/internal/dto/stack.go new file mode 100644 index 0000000..fcd450f --- /dev/null +++ b/internal/dto/stack.go @@ -0,0 +1,25 @@ +package dto + +type CreateStackDto struct { + Name string `json:"name" binding:"required"` + ComposeContent string `json:"composeContent" binding:"required"` + EnvContent *string `json:"envContent,omitempty"` + AgentID *string `json:"agentId,omitempty"` +} + +type UpdateStackDto struct { + Name *string `json:"name,omitempty"` + ComposeContent *string `json:"composeContent,omitempty"` + EnvContent *string `json:"envContent,omitempty"` + AutoUpdate *bool `json:"autoUpdate,omitempty"` +} + +type RedeployStackDto struct { + Profiles []string `json:"profiles,omitempty"` + EnvOverrides map[string]string `json:"envOverrides,omitempty"` +} + +type DestroyStackDto struct { + RemoveFiles bool `json:"removeFiles,omitempty"` + RemoveVolumes bool `json:"removeVolumes,omitempty"` +} diff --git a/internal/handlers/stack_handler.go b/internal/handlers/stack_handler.go new file mode 100644 index 0000000..7ba7db6 --- /dev/null +++ b/internal/handlers/stack_handler.go @@ -0,0 +1,590 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-agent/internal/dto" + "github.com/ofkm/arcane-agent/internal/models" + "github.com/ofkm/arcane-agent/internal/services" +) + +type StackHandler struct { + stackService *services.StackService +} + +func NewStackHandler(stackService *services.StackService) *StackHandler { + return &StackHandler{ + stackService: stackService, + } +} + +func (h *StackHandler) ListStacks(c *gin.Context) { + stacks, err := h.stackService.ListStacks(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": fmt.Sprintf("Failed to fetch stacks: %v", err), + }) + return + } + + var stackList []map[string]interface{} + for _, stack := range stacks { + services, err := h.stackService.GetStackServices(c.Request.Context(), stack.ID) + var serviceCount, runningCount int + if err != nil { + fmt.Printf("Warning: failed to get services for stack %s: %v\n", stack.ID, err) + serviceCount = stack.ServiceCount + runningCount = stack.RunningCount + services = nil + } else { + serviceCount = len(services) + runningCount = 0 + for _, service := range services { + if service.Status == "running" { + runningCount++ + } + } + } + + stackResponse := map[string]interface{}{ + "id": stack.ID, + "name": stack.Name, + "path": stack.Path, + "status": stack.Status, + "serviceCount": serviceCount, + "runningCount": runningCount, + "createdAt": stack.CreatedAt, + "updatedAt": stack.UpdatedAt, + "autoUpdate": stack.AutoUpdate, + "isExternal": stack.IsExternal, + "isLegacy": stack.IsLegacy, + "isRemote": stack.IsRemote, + "services": services, + } + stackList = append(stackList, stackResponse) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "stacks": stackList, + "count": len(stackList), + }) +} + +func (h *StackHandler) CreateStack(c *gin.Context) { + var req dto.CreateStackDto + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": "Invalid request format", + }) + return + } + + createdStack, err := h.stackService.CreateStack( + c.Request.Context(), + req.Name, + req.ComposeContent, + req.EnvContent, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Failed to create stack", + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "stack": createdStack, + }) +} + +func (h *StackHandler) GetStack(c *gin.Context) { + stackID := c.Param("id") + + stack, err := h.stackService.GetStackByID(c.Request.Context(), stackID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "error": "Stack not found", + }) + return + } + + composeContent, envContent, err := h.stackService.GetStackContent(c.Request.Context(), stackID) + if err != nil { + fmt.Printf("Warning: failed to read stack content: %v\n", err) + composeContent, envContent = "", "" + } + + services, err := h.stackService.GetStackServices(c.Request.Context(), stackID) + if err != nil { + fmt.Printf("Warning: failed to get services: %v\n", err) + services = nil + } + + var serviceCount, runningCount int + if services != nil { + serviceCount = len(services) + for _, service := range services { + if service.Status == "running" || service.Status == "Up" { + runningCount++ + } + } + } else { + serviceCount = stack.ServiceCount + runningCount = stack.RunningCount + } + + stackResponse := map[string]interface{}{ + "id": stack.ID, + "name": stack.Name, + "path": stack.Path, + "composeContent": composeContent, + "envContent": envContent, + "status": stack.Status, + "serviceCount": serviceCount, + "runningCount": runningCount, + "createdAt": stack.CreatedAt, + "updatedAt": stack.UpdatedAt, + "autoUpdate": stack.AutoUpdate, + "isExternal": stack.IsExternal, + "isLegacy": stack.IsLegacy, + "isRemote": stack.IsRemote, + "services": services, + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "stack": stackResponse, + }) +} + +func (h *StackHandler) UpdateStack(c *gin.Context) { + stackID := c.Param("id") + + var req dto.UpdateStackDto + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": "Invalid request format", + }) + return + } + + if req.ComposeContent != nil || req.EnvContent != nil { + if err := h.stackService.UpdateStackContent(c.Request.Context(), stackID, req.ComposeContent, req.EnvContent); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Failed to update stack content", + }) + return + } + } + + if req.Name != nil || req.AutoUpdate != nil { + stack, err := h.stackService.GetStackByID(c.Request.Context(), stackID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "error": "Stack not found", + }) + return + } + + if req.Name != nil { + stack.Name = *req.Name + } + if req.AutoUpdate != nil { + stack.AutoUpdate = *req.AutoUpdate + } + + if _, err := h.stackService.UpdateStack(c.Request.Context(), stack); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Failed to update stack", + }) + return + } + } + + updatedStack, err := h.stackService.GetStackByID(c.Request.Context(), stackID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Failed to get updated stack", + }) + return + } + + services, err := h.stackService.GetStackServices(c.Request.Context(), stackID) + if err != nil { + fmt.Printf("Warning: failed to get services: %v\n", err) + services = nil + } + + stackResponse := map[string]interface{}{ + "id": updatedStack.ID, + "name": updatedStack.Name, + "path": updatedStack.Path, + "status": updatedStack.Status, + "serviceCount": len(services), + "runningCount": updatedStack.RunningCount, + "createdAt": updatedStack.CreatedAt, + "updatedAt": updatedStack.UpdatedAt, + "autoUpdate": updatedStack.AutoUpdate, + "isExternal": updatedStack.IsExternal, + "isLegacy": updatedStack.IsLegacy, + "isRemote": updatedStack.IsRemote, + "services": services, + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "stack": stackResponse, + }) +} + +func (h *StackHandler) DeleteStack(c *gin.Context) { + stackID := c.Param("id") + + err := h.stackService.DeleteStack(c.Request.Context(), stackID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Failed to delete stack", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Stack deleted successfully", + }) +} + +func (h *StackHandler) StartStack(c *gin.Context) { + stackID := c.Param("id") + + err := h.stackService.DeployStack(c.Request.Context(), stackID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Failed to start stack", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Stack started successfully", + }) +} + +func (h *StackHandler) StopStack(c *gin.Context) { + stackID := c.Param("id") + + if err := h.stackService.StopStack(c.Request.Context(), stackID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Failed to stop stack", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Stack stopped successfully", + }) +} + +func (h *StackHandler) RestartStack(c *gin.Context) { + stackID := c.Param("id") + + if err := h.stackService.RestartStack(c.Request.Context(), stackID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Failed to restart stack", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Stack restarted successfully", + "stackId": stackID, + }) +} + +func (h *StackHandler) RedeployStack(c *gin.Context) { + stackID := c.Param("id") + + var req dto.RedeployStackDto + if err := c.ShouldBindJSON(&req); err != nil { + req = dto.RedeployStackDto{ + Profiles: []string{}, + EnvOverrides: map[string]string{}, + } + } + + if err := h.stackService.RedeployStack(c.Request.Context(), stackID, req.Profiles, req.EnvOverrides); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": fmt.Sprintf("Failed to redeploy stack: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Stack redeployed successfully", + }) +} + +func (h *StackHandler) DownStack(c *gin.Context) { + stackID := c.Param("id") + + if err := h.stackService.DownStack(c.Request.Context(), stackID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": fmt.Sprintf("Failed to bring down stack: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Stack brought down successfully", + }) +} + +func (h *StackHandler) DestroyStack(c *gin.Context) { + stackID := c.Param("id") + + var req dto.DestroyStackDto + if err := c.ShouldBindJSON(&req); err != nil { + req = dto.DestroyStackDto{ + RemoveFiles: false, + RemoveVolumes: false, + } + } + + if err := h.stackService.DestroyStack(c.Request.Context(), stackID, req.RemoveFiles, req.RemoveVolumes); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": fmt.Sprintf("Failed to destroy stack: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Stack destroyed successfully", + }) +} + +func (h *StackHandler) PullStack(c *gin.Context) { + stackID := c.Param("id") + + if err := h.stackService.PullStackImages(c.Request.Context(), stackID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Failed to pull stack images", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Stack images pulled successfully", + "stackId": stackID, + }) +} + +func (h *StackHandler) DeployStack(c *gin.Context) { + stackID := c.Param("id") + + var req struct { + Profiles []string `json:"profiles"` + EnvOverrides map[string]string `json:"env_overrides"` + ForceRecreate bool `json:"force_recreate"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + if err := h.stackService.DeployStack(c.Request.Context(), stackID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Stack deployed successfully", + }) +} + +func (h *StackHandler) GetStackServices(c *gin.Context) { + stackID := c.Param("id") + + services, err := h.stackService.GetStackServices(c.Request.Context(), stackID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": services, + }) +} + +func (h *StackHandler) PullImages(c *gin.Context) { + stackID := c.Param("id") + + if err := h.stackService.PullStackImages(c.Request.Context(), stackID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Images pulled successfully", + }) +} + +func (h *StackHandler) ConvertDockerRun(c *gin.Context) { + var req models.ConvertDockerRunRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": "Invalid request format: " + err.Error(), + }) + return + } + + // For now, return a simple conversion - you can implement a full converter later + c.JSON(http.StatusOK, models.ConvertDockerRunResponse{ + Success: true, + DockerCompose: "# Docker Compose conversion not implemented in agent yet", + EnvVars: map[string]string{}, + ServiceName: "app", + }) +} + +func (h *StackHandler) GetStackLogsStream(c *gin.Context) { + stackID := c.Param("id") + if stackID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": "Stack ID is required", + }) + return + } + + // Get query parameters for log options + follow := c.DefaultQuery("follow", "true") == "true" + tail := c.DefaultQuery("tail", "100") + since := c.Query("since") + timestamps := c.DefaultQuery("timestamps", "true") == "true" + + // Set headers for SSE + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Access-Control-Allow-Origin", "*") + + logsChan := make(chan string, 100) + errChan := make(chan error, 1) + + // Start streaming logs in a goroutine + go func() { + defer close(logsChan) + defer close(errChan) + + err := h.stackService.StreamStackLogs(c.Request.Context(), stackID, logsChan, follow, tail, since, timestamps) + if err != nil { + errChan <- err + } + }() + + // Send logs to client + c.Stream(func(w io.Writer) bool { + select { + case logLine, ok := <-logsChan: + if !ok { + return false + } + + logData := h.parseStackLogLine(logLine) + c.SSEvent("log", logData) + return true + + case err := <-errChan: + c.SSEvent("error", gin.H{"error": err.Error()}) + return false + + case <-c.Request.Context().Done(): + return false + } + }) +} + +func (h *StackHandler) parseStackLogLine(logLine string) gin.H { + var service, message, timestamp string + var level = "info" + + if strings.HasPrefix(logLine, "[STDERR] ") { + level = "stderr" + logLine = strings.TrimPrefix(logLine, "[STDERR] ") + } + + parts := strings.SplitN(logLine, " ", 2) + if len(parts) == 2 && strings.Contains(parts[0], "T") && strings.Contains(parts[0], "Z") { + timestamp = parts[0] + logLine = parts[1] + } else { + timestamp = time.Now().Format(time.RFC3339Nano) + } + + if strings.Contains(logLine, " | ") { + serviceParts := strings.SplitN(logLine, " | ", 2) + if len(serviceParts) == 2 { + service = strings.TrimSpace(serviceParts[0]) + message = serviceParts[1] + } else { + message = logLine + } + } else { + message = logLine + } + + return gin.H{ + "level": level, + "message": message, + "timestamp": timestamp, + "service": service, + } +} diff --git a/internal/models/stack.go b/internal/models/stack.go new file mode 100644 index 0000000..2318209 --- /dev/null +++ b/internal/models/stack.go @@ -0,0 +1,72 @@ +package models + +import ( + "time" +) + +type StackPort struct { + PublicPort *int `json:"publicPort,omitempty"` + PrivatePort *int `json:"privatePort,omitempty"` + Type string `json:"type"` +} + +type StackService struct { + ID string `json:"id"` + Name string `json:"name"` + State *StackServiceState `json:"state,omitempty"` + Ports []StackPort `json:"ports,omitempty"` + NetworkSettings *NetworkSettings `json:"networkSettings,omitempty"` +} + +type StackServiceState struct { + Running bool `json:"running"` + Status string `json:"status"` + ExitCode int `json:"exitCode"` +} + +type NetworkSettings struct { + Networks map[string]NetworkConfig `json:"networks,omitempty"` +} + +type NetworkConfig struct { + IPAddress *string `json:"ipAddress,omitempty"` + Gateway *string `json:"gateway,omitempty"` + MacAddress *string `json:"macAddress,omitempty"` + Driver *string `json:"driver,omitempty"` +} + +type StackStatus string + +const ( + StackStatusRunning StackStatus = "running" + StackStatusStopped StackStatus = "stopped" + StackStatusPartiallyRunning StackStatus = "partially running" + StackStatusUnknown StackStatus = "unknown" +) + +type Stack struct { + ID string `json:"id"` + Name string `json:"name"` + DirName *string `json:"dir_name"` + Path string `json:"path"` + Status StackStatus `json:"status"` + ServiceCount int `json:"service_count"` + RunningCount int `json:"running_count"` + AutoUpdate bool `json:"auto_update"` + IsExternal bool `json:"is_external"` + IsLegacy bool `json:"is_legacy"` + IsRemote bool `json:"is_remote"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ConvertDockerRunRequest struct { + DockerRunCommand string `json:"dockerRunCommand" binding:"required"` +} + +type ConvertDockerRunResponse struct { + Success bool `json:"success"` + DockerCompose string `json:"dockerCompose"` + EnvVars map[string]string `json:"envVars"` + ServiceName string `json:"serviceName"` +} diff --git a/internal/models/stack_service_info.go b/internal/models/stack_service_info.go new file mode 100644 index 0000000..cbb8957 --- /dev/null +++ b/internal/models/stack_service_info.go @@ -0,0 +1,9 @@ +package models + +type StackServiceInfo struct { + Name string `json:"name"` + Image string `json:"image"` + Status string `json:"status"` + ContainerID string `json:"container_id"` + Ports []string `json:"ports"` +} diff --git a/internal/services/stack.go b/internal/services/stack.go new file mode 100644 index 0000000..945d790 --- /dev/null +++ b/internal/services/stack.go @@ -0,0 +1,810 @@ +package services + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/compose-spec/compose-go/v2/cli" + "github.com/google/uuid" + "github.com/ofkm/arcane-agent/internal/models" +) + +type StackService struct { + stacksDir string +} + +func NewStackService() *StackService { + return &StackService{ + stacksDir: "data/stacks", + } +} + +type StackInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Services []models.StackServiceInfo `json:"services"` + ServiceCount int `json:"service_count"` + RunningCount int `json:"running_count"` + ComposeYAML string `json:"compose_yaml,omitempty"` +} + +func (s *StackService) CreateStack(ctx context.Context, name, composeContent string, envContent *string) (*models.Stack, error) { + stackID := uuid.New().String() + folderName := s.sanitizeStackName(name) + + stackPath := filepath.Join(s.stacksDir, folderName) + + counter := 1 + originalPath := stackPath + for { + if _, err := os.Stat(stackPath); os.IsNotExist(err) { + break + } + stackPath = fmt.Sprintf("%s-%d", originalPath, counter) + folderName = fmt.Sprintf("%s-%d", s.sanitizeStackName(name), counter) + counter++ + } + + stack := &models.Stack{ + ID: stackID, + Name: name, + DirName: &folderName, + Path: stackPath, + Status: models.StackStatusStopped, + ServiceCount: 0, + RunningCount: 0, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.saveStackFiles(stackPath, composeContent, envContent); err != nil { + return nil, fmt.Errorf("failed to save stack files: %w", err) + } + + return stack, nil +} + +func (s *StackService) DeployStack(ctx context.Context, stackID string) error { + // Find stack directly by ID without calling ListStacks + stack, err := s.GetStackByID(ctx, stackID) + if err != nil { + return fmt.Errorf("stack not found: %w", err) + } + + cmd := exec.CommandContext(ctx, "docker-compose", "up", "-d") + cmd.Dir = stack.Path + + cmd.Env = append(os.Environ(), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to deploy stack: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func (s *StackService) StopStack(ctx context.Context, stackID string) error { + stack, err := s.GetStackByID(ctx, stackID) + if err != nil { + return fmt.Errorf("stack not found: %w", err) + } + + cmd := exec.CommandContext(ctx, "docker-compose", "stop") + cmd.Dir = stack.Path + cmd.Env = append(os.Environ(), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to stop stack: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func (s *StackService) DownStack(ctx context.Context, stackID string) error { + stack, err := s.GetStackByID(ctx, stackID) + if err != nil { + return fmt.Errorf("stack not found: %w", err) + } + + cmd := exec.CommandContext(ctx, "docker-compose", "down") + cmd.Dir = stack.Path + cmd.Env = append(os.Environ(), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to down stack: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func (s *StackService) RestartStack(ctx context.Context, stackID string) error { + stack, err := s.GetStackByID(ctx, stackID) + if err != nil { + return fmt.Errorf("stack not found: %w", err) + } + + cmd := exec.CommandContext(ctx, "docker-compose", "restart") + cmd.Dir = stack.Path + cmd.Env = append(os.Environ(), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to restart stack: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func (s *StackService) PullStackImages(ctx context.Context, stackID string) error { + stack, err := s.GetStackByID(ctx, stackID) + if err != nil { + return fmt.Errorf("stack not found: %w", err) + } + + cmd := exec.CommandContext(ctx, "docker-compose", "pull") + cmd.Dir = stack.Path + cmd.Env = append(os.Environ(), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to pull stack images: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func (s *StackService) RedeployStack(ctx context.Context, stackID string, profiles []string, envOverrides map[string]string) error { + if err := s.PullStackImages(ctx, stackID); err != nil { + fmt.Printf("Warning: failed to pull images: %v\n", err) + } + + if err := s.StopStack(ctx, stackID); err != nil { + return fmt.Errorf("failed to stop stack for redeploy: %w", err) + } + + return s.DeployStack(ctx, stackID) +} + +func (s *StackService) DestroyStack(ctx context.Context, stackID string, removeFiles, removeVolumes bool) error { + stacks, err := s.ListStacks(ctx) + if err != nil { + return err + } + + var stack *models.Stack + for _, st := range stacks { + if st.ID == stackID { + stack = &st + break + } + } + + if stack == nil { + return fmt.Errorf("stack not found") + } + + if err := s.DownStack(ctx, stackID); err != nil { + fmt.Printf("Warning: failed to bring down stack: %v\n", err) + } + + if removeVolumes { + cmd := exec.CommandContext(ctx, "docker-compose", "down", "-v") + cmd.Dir = stack.Path + cmd.Env = append(os.Environ(), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + ) + + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf("Warning: failed to remove volumes: %v\nOutput: %s\n", err, string(output)) + } + } + + if removeFiles { + if err := os.RemoveAll(stack.Path); err != nil { + return fmt.Errorf("failed to remove stack files: %w", err) + } + } + + return nil +} + +func (s *StackService) ListStacks(ctx context.Context) ([]models.Stack, error) { + var stacks []models.Stack + + if _, err := os.Stat(s.stacksDir); os.IsNotExist(err) { + return stacks, nil + } + + entries, err := os.ReadDir(s.stacksDir) + if err != nil { + return nil, fmt.Errorf("failed to read stacks directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + stackPath := filepath.Join(s.stacksDir, entry.Name()) + folderName := fmt.Sprintf("%s-%d", s.sanitizeStackName(entry.Name())) + composeFile := s.findComposeFile(stackPath) + if composeFile == "" { + continue + } + + // Read stack metadata if exists + metadataPath := filepath.Join(stackPath, ".stack-metadata.json") + stack := models.Stack{ + ID: entry.Name(), // Use directory name as ID for now + Name: entry.Name(), + DirName: &folderName, + Path: stackPath, + Status: models.StackStatusUnknown, + ServiceCount: 0, + RunningCount: 0, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Try to read metadata + if metadataBytes, err := os.ReadFile(metadataPath); err == nil { + var metadata struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + } + if err := json.Unmarshal(metadataBytes, &metadata); err == nil { + stack.ID = metadata.ID + stack.Name = metadata.Name + stack.CreatedAt = metadata.CreatedAt + } + } + + services, err := s.getStackServicesDirectly(ctx, &stack) + if err == nil { + stack.ServiceCount = len(services) + runningCount := 0 + for _, service := range services { + if service.Status == "running" || service.Status == "Up" { + runningCount++ + } + } + stack.RunningCount = runningCount + + if stack.ServiceCount == 0 { + stack.Status = models.StackStatusStopped + } else if runningCount == stack.ServiceCount { + stack.Status = models.StackStatusRunning + } else if runningCount > 0 { + stack.Status = models.StackStatusPartiallyRunning + } else { + stack.Status = models.StackStatusStopped + } + } + + stacks = append(stacks, stack) + } + + return stacks, nil +} + +// Add this helper method to avoid recursion +func (s *StackService) getStackServicesDirectly(ctx context.Context, stack *models.Stack) ([]models.StackServiceInfo, error) { + cmd := exec.CommandContext(ctx, "docker-compose", "ps", "--format", "json") + cmd.Dir = stack.Path + cmd.Env = append(os.Environ(), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + ) + + var services []models.StackServiceInfo + + output, err := cmd.Output() + if err == nil { + services, err = s.parseComposePS(string(output)) + if err != nil { + return nil, fmt.Errorf("failed to parse compose ps output: %w", err) + } + } + + if len(services) > 0 { + return services, nil + } + + composeFile := s.findComposeFile(stack.Path) + if composeFile == "" { + return []models.StackServiceInfo{}, nil + } + + servicesFromFile, err := s.parseServicesFromComposeFile(composeFile, stack.Name) + if err != nil { + return []models.StackServiceInfo{}, nil + } + + return servicesFromFile, nil +} + +func (s *StackService) GetStackByID(ctx context.Context, id string) (*models.Stack, error) { + stacks, err := s.ListStacks(ctx) + if err != nil { + return nil, err + } + + for _, stack := range stacks { + if stack.ID == id { + return &stack, nil + } + } + + return nil, fmt.Errorf("stack not found") +} + +func (s *StackService) UpdateStack(ctx context.Context, stack *models.Stack) (*models.Stack, error) { + // Save metadata + metadataPath := filepath.Join(stack.Path, ".stack-metadata.json") + metadata := struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + }{ + ID: stack.ID, + Name: stack.Name, + CreatedAt: stack.CreatedAt, + UpdatedAt: time.Now(), + } + + metadataBytes, _ := json.Marshal(metadata) + os.WriteFile(metadataPath, metadataBytes, 0644) + + stack.UpdatedAt = time.Now() + return stack, nil +} + +func (s *StackService) UpdateStackContent(ctx context.Context, stackID string, composeContent, envContent *string) error { + stack, err := s.GetStackByID(ctx, stackID) + if err != nil { + return err + } + + if composeContent != nil { + existingComposeFile := s.findComposeFile(stack.Path) + var composePath string + + if existingComposeFile != "" { + composePath = existingComposeFile + } else { + composePath = filepath.Join(stack.Path, "compose.yaml") + } + + if err := os.WriteFile(composePath, []byte(*composeContent), 0644); err != nil { + return fmt.Errorf("failed to update compose file: %w", err) + } + } + + if envContent != nil { + envPath := filepath.Join(stack.Path, ".env") + if *envContent == "" { + os.Remove(envPath) + } else { + if err := os.WriteFile(envPath, []byte(*envContent), 0644); err != nil { + return fmt.Errorf("failed to update env file: %w", err) + } + } + } + + return nil +} + +func (s *StackService) GetStackContent(ctx context.Context, stackID string) (composeContent, envContent string, err error) { + stack, err := s.GetStackByID(ctx, stackID) + if err != nil { + return "", "", err + } + + composeFile := s.findComposeFile(stack.Path) + if composeFile != "" { + if content, err := os.ReadFile(composeFile); err == nil { + composeContent = string(content) + } + } + + envPath := filepath.Join(stack.Path, ".env") + if content, err := os.ReadFile(envPath); err == nil { + envContent = string(content) + } + + return composeContent, envContent, nil +} + +func (s *StackService) DeleteStack(ctx context.Context, stackID string) error { + stack, err := s.GetStackByID(ctx, stackID) + if err != nil { + return err + } + + if stack.Status == models.StackStatusRunning { + if err := s.DownStack(ctx, stackID); err != nil { + fmt.Printf("Warning: failed to stop stack before deletion: %v\n", err) + } + } + + if err := os.RemoveAll(stack.Path); err != nil { + fmt.Printf("Warning: failed to remove stack directory %s: %v\n", stack.Path, err) + } + + return nil +} + +func (s *StackService) GetStackServices(ctx context.Context, stackID string) ([]models.StackServiceInfo, error) { + stack, err := s.GetStackByID(ctx, stackID) + if err != nil { + return nil, err + } + + cmd := exec.CommandContext(ctx, "docker-compose", "ps", "--format", "json") + cmd.Dir = stack.Path + cmd.Env = append(os.Environ(), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + ) + + var services []models.StackServiceInfo + + output, err := cmd.Output() + if err == nil { + services, err = s.parseComposePS(string(output)) + if err != nil { + return nil, fmt.Errorf("failed to parse compose ps output: %w", err) + } + } + + if len(services) > 0 { + return services, nil + } + + composeFile := s.findComposeFile(stack.Path) + if composeFile == "" { + return []models.StackServiceInfo{}, nil + } + + servicesFromFile, err := s.parseServicesFromComposeFile(composeFile, stack.Name) + if err != nil { + return []models.StackServiceInfo{}, nil + } + + return servicesFromFile, nil +} + +func (s *StackService) StreamStackLogs(ctx context.Context, stackID string, logsChan chan<- string, follow bool, tail, since string, timestamps bool) error { + stack, err := s.GetStackByID(ctx, stackID) + if err != nil { + return err + } + + args := []string{"logs"} + if tail != "" { + args = append(args, "--tail", tail) + } + if since != "" { + args = append(args, "--since", since) + } + if timestamps { + args = append(args, "--timestamps") + } + if follow { + args = append(args, "--follow") + } + + cmd := exec.CommandContext(ctx, "docker-compose", args...) + cmd.Dir = stack.Path + cmd.Env = append(os.Environ(), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + ) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start docker-compose logs: %w", err) + } + + // Handle stdout and stderr concurrently + done := make(chan error, 2) + + // Read stdout + go func() { + done <- s.readStackLogsFromReader(ctx, stdout, logsChan, "stdout") + }() + + // Read stderr + go func() { + done <- s.readStackLogsFromReader(ctx, stderr, logsChan, "stderr") + }() + + // Wait for command completion or context cancellation + go func() { + done <- cmd.Wait() + }() + + // Wait for context cancellation or error + select { + case <-ctx.Done(): + if cmd.Process != nil { + cmd.Process.Kill() + } + return ctx.Err() + case err := <-done: + if cmd.Process != nil { + cmd.Process.Kill() + } + if err != nil && err != io.EOF { + return err + } + return nil + } +} + +func (s *StackService) readStackLogsFromReader(ctx context.Context, reader io.Reader, logsChan chan<- string, source string) error { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + line := scanner.Text() + if line != "" { + if source == "stderr" { + line = "[STDERR] " + line + } + + select { + case logsChan <- line: + case <-ctx.Done(): + return ctx.Err() + } + } + } + } + + return scanner.Err() +} + +// Helper methods +func (s *StackService) sanitizeStackName(name string) string { + name = strings.TrimSpace(name) + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' { + return r + } + return '_' + }, name) +} + +func (s *StackService) saveStackFiles(stackPath, composeContent string, envContent *string) error { + if err := os.MkdirAll(stackPath, 0755); err != nil { + return fmt.Errorf("failed to create stack directory: %w", err) + } + + // Save metadata + stackID := uuid.New().String() + metadata := struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + }{ + ID: stackID, + Name: filepath.Base(stackPath), + CreatedAt: time.Now(), + } + + metadataBytes, _ := json.Marshal(metadata) + metadataPath := filepath.Join(stackPath, ".stack-metadata.json") + os.WriteFile(metadataPath, metadataBytes, 0644) + + existingComposeFile := s.findComposeFile(stackPath) + var composePath string + + if existingComposeFile != "" { + composePath = existingComposeFile + } else { + composePath = filepath.Join(stackPath, "compose.yaml") + } + + if err := os.WriteFile(composePath, []byte(composeContent), 0644); err != nil { + return fmt.Errorf("failed to save compose file: %w", err) + } + + if envContent != nil && *envContent != "" { + envPath := filepath.Join(stackPath, ".env") + if err := os.WriteFile(envPath, []byte(*envContent), 0644); err != nil { + return fmt.Errorf("failed to save env file: %w", err) + } + } + + return nil +} + +func (s *StackService) findComposeFile(stackDir string) string { + possibleFiles := []string{ + "compose.yaml", + "compose.yml", + "docker-compose.yml", + "docker-compose.yaml", + } + + for _, filename := range possibleFiles { + fullPath := filepath.Join(stackDir, filename) + if _, err := os.Stat(fullPath); err == nil { + return fullPath + } + } + + return "" +} + +func (s *StackService) parseComposePS(output string) ([]models.StackServiceInfo, error) { + if strings.TrimSpace(output) == "" { + return []models.StackServiceInfo{}, nil + } + + var services []models.StackServiceInfo + + if strings.HasPrefix(strings.TrimSpace(output), "[") { + var psOutput []map[string]interface{} + if err := json.Unmarshal([]byte(output), &psOutput); err == nil { + for _, item := range psOutput { + service := s.parseComposeService(item) + if service != nil { + services = append(services, *service) + } + } + return services, nil + } + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var item map[string]interface{} + if err := json.Unmarshal([]byte(line), &item); err != nil { + continue + } + + service := s.parseComposeService(item) + if service != nil { + services = append(services, *service) + } + } + + return services, nil +} + +func (s *StackService) parseComposeService(item map[string]interface{}) *models.StackServiceInfo { + service := &models.StackServiceInfo{} + + if name, ok := item["Name"].(string); ok { + service.Name = name + } else if service_name, ok := item["Service"].(string); ok { + service.Name = service_name + } + + if image, ok := item["Image"].(string); ok { + service.Image = image + } + + if state, ok := item["State"].(string); ok { + service.Status = state + } else if status, ok := item["Status"].(string); ok { + service.Status = status + } + + if id, ok := item["ID"].(string); ok { + service.ContainerID = id + } else if container_id, ok := item["ContainerID"].(string); ok { + service.ContainerID = container_id + } + + if portsInterface, ok := item["Ports"]; ok { + switch ports := portsInterface.(type) { + case string: + if ports != "" { + service.Ports = []string{ports} + } + case []interface{}: + for _, port := range ports { + if portStr, ok := port.(string); ok && portStr != "" { + service.Ports = append(service.Ports, portStr) + } + } + case []string: + service.Ports = ports + } + } + + if service.Name == "" { + return nil + } + + return service +} + +func (s *StackService) parseServicesFromComposeFile(composeFile, stackName string) ([]models.StackServiceInfo, error) { + options, err := cli.NewProjectOptions( + []string{composeFile}, + cli.WithOsEnv, + cli.WithDotEnv, + cli.WithName(stackName), + cli.WithWorkingDirectory(filepath.Dir(composeFile)), + ) + if err != nil { + return nil, fmt.Errorf("failed to create project options: %w", err) + } + + project, err := options.LoadProject(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to load project: %w", err) + } + + var services []models.StackServiceInfo + + for _, service := range project.Services { + serviceInfo := models.StackServiceInfo{ + Name: service.Name, + Image: service.Image, + Status: "not created", + ContainerID: "", + Ports: []string{}, + } + + for _, port := range service.Ports { + if port.Published != "" && port.Target != 0 { + portStr := fmt.Sprintf("%s:%d", port.Published, port.Target) + if port.Protocol != "" { + portStr += "/" + port.Protocol + } + serviceInfo.Ports = append(serviceInfo.Ports, portStr) + } + } + + services = append(services, serviceInfo) + } + + return services, nil +} From 5d58252017eed7b9925551e3b38ab14e81d50da6 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Thu, 19 Jun 2025 23:16:02 -0500 Subject: [PATCH 6/8] fix: remove timestamp from folder name in ListStacks method --- internal/services/stack.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/services/stack.go b/internal/services/stack.go index 945d790..8a4366b 100644 --- a/internal/services/stack.go +++ b/internal/services/stack.go @@ -248,7 +248,7 @@ func (s *StackService) ListStacks(ctx context.Context) ([]models.Stack, error) { } stackPath := filepath.Join(s.stacksDir, entry.Name()) - folderName := fmt.Sprintf("%s-%d", s.sanitizeStackName(entry.Name())) + folderName := s.sanitizeStackName(entry.Name()) composeFile := s.findComposeFile(stackPath) if composeFile == "" { continue From 0427d273d922584105e2c0e92b8046fd0d311c6d Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Fri, 20 Jun 2025 12:02:46 -0500 Subject: [PATCH 7/8] refactor: update stack methods to use stack name instead of ID for consistency --- internal/services/stack.go | 196 +++++++++++++++++++++---------------- 1 file changed, 112 insertions(+), 84 deletions(-) diff --git a/internal/services/stack.go b/internal/services/stack.go index 8a4366b..39f1a83 100644 --- a/internal/services/stack.go +++ b/internal/services/stack.go @@ -73,157 +73,159 @@ func (s *StackService) CreateStack(ctx context.Context, name, composeContent str return stack, nil } -func (s *StackService) DeployStack(ctx context.Context, stackID string) error { - // Find stack directly by ID without calling ListStacks - stack, err := s.GetStackByID(ctx, stackID) - if err != nil { - return fmt.Errorf("stack not found: %w", err) +func (s *StackService) DeployStack(ctx context.Context, stackName string) error { + stackPath := filepath.Join(s.stacksDir, stackName) + + // Check if stack directory exists + if _, err := os.Stat(stackPath); os.IsNotExist(err) { + return fmt.Errorf("stack '%s' not found", stackName) } - cmd := exec.CommandContext(ctx, "docker-compose", "up", "-d") - cmd.Dir = stack.Path + // Check if compose file exists + composeFile := s.findComposeFile(stackPath) + if composeFile == "" { + return fmt.Errorf("no compose file found in stack '%s'", stackName) + } + cmd := exec.CommandContext(ctx, "docker-compose", "up", "-d") + cmd.Dir = stackPath cmd.Env = append(os.Environ(), - fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stackName), ) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to deploy stack: %w\nOutput: %s", err, string(output)) + return fmt.Errorf("failed to deploy stack '%s': %w\nOutput: %s", stackName, err, string(output)) } return nil } -func (s *StackService) StopStack(ctx context.Context, stackID string) error { - stack, err := s.GetStackByID(ctx, stackID) - if err != nil { - return fmt.Errorf("stack not found: %w", err) +func (s *StackService) StopStack(ctx context.Context, stackName string) error { + stackPath := filepath.Join(s.stacksDir, stackName) + + if _, err := os.Stat(stackPath); os.IsNotExist(err) { + return fmt.Errorf("stack '%s' not found", stackName) } cmd := exec.CommandContext(ctx, "docker-compose", "stop") - cmd.Dir = stack.Path + cmd.Dir = stackPath cmd.Env = append(os.Environ(), - fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stackName), ) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to stop stack: %w\nOutput: %s", err, string(output)) + return fmt.Errorf("failed to stop stack '%s': %w\nOutput: %s", stackName, err, string(output)) } return nil } -func (s *StackService) DownStack(ctx context.Context, stackID string) error { - stack, err := s.GetStackByID(ctx, stackID) - if err != nil { - return fmt.Errorf("stack not found: %w", err) +func (s *StackService) DownStack(ctx context.Context, stackName string) error { + stackPath := filepath.Join(s.stacksDir, stackName) + + if _, err := os.Stat(stackPath); os.IsNotExist(err) { + return fmt.Errorf("stack '%s' not found", stackName) } cmd := exec.CommandContext(ctx, "docker-compose", "down") - cmd.Dir = stack.Path + cmd.Dir = stackPath cmd.Env = append(os.Environ(), - fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stackName), ) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to down stack: %w\nOutput: %s", err, string(output)) + return fmt.Errorf("failed to down stack '%s': %w\nOutput: %s", stackName, err, string(output)) } return nil } -func (s *StackService) RestartStack(ctx context.Context, stackID string) error { - stack, err := s.GetStackByID(ctx, stackID) - if err != nil { - return fmt.Errorf("stack not found: %w", err) +func (s *StackService) RestartStack(ctx context.Context, stackName string) error { + stackPath := filepath.Join(s.stacksDir, stackName) + + if _, err := os.Stat(stackPath); os.IsNotExist(err) { + return fmt.Errorf("stack '%s' not found", stackName) } cmd := exec.CommandContext(ctx, "docker-compose", "restart") - cmd.Dir = stack.Path + cmd.Dir = stackPath cmd.Env = append(os.Environ(), - fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stackName), ) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to restart stack: %w\nOutput: %s", err, string(output)) + return fmt.Errorf("failed to restart stack '%s': %w\nOutput: %s", stackName, err, string(output)) } return nil } -func (s *StackService) PullStackImages(ctx context.Context, stackID string) error { - stack, err := s.GetStackByID(ctx, stackID) - if err != nil { - return fmt.Errorf("stack not found: %w", err) +func (s *StackService) PullStackImages(ctx context.Context, stackName string) error { + stackPath := filepath.Join(s.stacksDir, stackName) + + if _, err := os.Stat(stackPath); os.IsNotExist(err) { + return fmt.Errorf("stack '%s' not found", stackName) } cmd := exec.CommandContext(ctx, "docker-compose", "pull") - cmd.Dir = stack.Path + cmd.Dir = stackPath cmd.Env = append(os.Environ(), - fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stackName), ) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to pull stack images: %w\nOutput: %s", err, string(output)) + return fmt.Errorf("failed to pull stack images '%s': %w\nOutput: %s", stackName, err, string(output)) } return nil } -func (s *StackService) RedeployStack(ctx context.Context, stackID string, profiles []string, envOverrides map[string]string) error { - if err := s.PullStackImages(ctx, stackID); err != nil { - fmt.Printf("Warning: failed to pull images: %v\n", err) +func (s *StackService) RedeployStack(ctx context.Context, stackName string, profiles []string, envOverrides map[string]string) error { + if err := s.PullStackImages(ctx, stackName); err != nil { + fmt.Printf("Warning: failed to pull images for stack '%s': %v\n", stackName, err) } - if err := s.StopStack(ctx, stackID); err != nil { - return fmt.Errorf("failed to stop stack for redeploy: %w", err) + if err := s.StopStack(ctx, stackName); err != nil { + return fmt.Errorf("failed to stop stack '%s' for redeploy: %w", stackName, err) } - return s.DeployStack(ctx, stackID) + return s.DeployStack(ctx, stackName) } -func (s *StackService) DestroyStack(ctx context.Context, stackID string, removeFiles, removeVolumes bool) error { - stacks, err := s.ListStacks(ctx) - if err != nil { - return err - } - - var stack *models.Stack - for _, st := range stacks { - if st.ID == stackID { - stack = &st - break - } - } +func (s *StackService) DestroyStack(ctx context.Context, stackName string, removeFiles, removeVolumes bool) error { + stackPath := filepath.Join(s.stacksDir, stackName) - if stack == nil { - return fmt.Errorf("stack not found") + if _, err := os.Stat(stackPath); os.IsNotExist(err) { + return fmt.Errorf("stack '%s' not found", stackName) } - if err := s.DownStack(ctx, stackID); err != nil { - fmt.Printf("Warning: failed to bring down stack: %v\n", err) + // Try to bring down the stack first + if err := s.DownStack(ctx, stackName); err != nil { + fmt.Printf("Warning: failed to bring down stack '%s': %v\n", stackName, err) } + // Remove volumes if requested if removeVolumes { cmd := exec.CommandContext(ctx, "docker-compose", "down", "-v") - cmd.Dir = stack.Path + cmd.Dir = stackPath cmd.Env = append(os.Environ(), - fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stack.Name), + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", stackName), ) if output, err := cmd.CombinedOutput(); err != nil { - fmt.Printf("Warning: failed to remove volumes: %v\nOutput: %s\n", err, string(output)) + fmt.Printf("Warning: failed to remove volumes for stack '%s': %v\nOutput: %s\n", stackName, err, string(output)) } } + // Remove files if requested if removeFiles { - if err := os.RemoveAll(stack.Path); err != nil { - return fmt.Errorf("failed to remove stack files: %w", err) + if err := os.RemoveAll(stackPath); err != nil { + return fmt.Errorf("failed to remove stack files for '%s': %w", stackName, err) } } @@ -248,18 +250,15 @@ func (s *StackService) ListStacks(ctx context.Context) ([]models.Stack, error) { } stackPath := filepath.Join(s.stacksDir, entry.Name()) - folderName := s.sanitizeStackName(entry.Name()) composeFile := s.findComposeFile(stackPath) if composeFile == "" { continue } - // Read stack metadata if exists - metadataPath := filepath.Join(stackPath, ".stack-metadata.json") + // Use folder name as both ID and Name - simple and consistent stack := models.Stack{ - ID: entry.Name(), // Use directory name as ID for now - Name: entry.Name(), - DirName: &folderName, + ID: entry.Name(), // Folder name is the ID + Name: entry.Name(), // Folder name is also the display name Path: stackPath, Status: models.StackStatusUnknown, ServiceCount: 0, @@ -268,20 +267,24 @@ func (s *StackService) ListStacks(ctx context.Context) ([]models.Stack, error) { UpdatedAt: time.Now(), } - // Try to read metadata + // Try to read metadata for additional info (but ID stays as folder name) + metadataPath := filepath.Join(stackPath, ".stack-metadata.json") if metadataBytes, err := os.ReadFile(metadataPath); err == nil { var metadata struct { - ID string `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"createdAt"` } if err := json.Unmarshal(metadataBytes, &metadata); err == nil { - stack.ID = metadata.ID - stack.Name = metadata.Name - stack.CreatedAt = metadata.CreatedAt + if metadata.Name != "" { + stack.Name = metadata.Name // Use metadata name if available + } + if !metadata.CreatedAt.IsZero() { + stack.CreatedAt = metadata.CreatedAt + } } } + // Get services and status services, err := s.getStackServicesDirectly(ctx, &stack) if err == nil { stack.ServiceCount = len(services) @@ -345,19 +348,44 @@ func (s *StackService) getStackServicesDirectly(ctx context.Context, stack *mode return servicesFromFile, nil } -func (s *StackService) GetStackByID(ctx context.Context, id string) (*models.Stack, error) { - stacks, err := s.ListStacks(ctx) - if err != nil { - return nil, err +func (s *StackService) GetStackByID(ctx context.Context, stackName string) (*models.Stack, error) { + stackPath := filepath.Join(s.stacksDir, stackName) + + if _, err := os.Stat(stackPath); os.IsNotExist(err) { + return nil, fmt.Errorf("stack '%s' not found", stackName) } - for _, stack := range stacks { - if stack.ID == id { - return &stack, nil + composeFile := s.findComposeFile(stackPath) + if composeFile == "" { + return nil, fmt.Errorf("no compose file found in stack '%s'", stackName) + } + + stack := &models.Stack{ + ID: stackName, + Name: stackName, + Path: stackPath, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Try to read metadata + metadataPath := filepath.Join(stackPath, ".stack-metadata.json") + if metadataBytes, err := os.ReadFile(metadataPath); err == nil { + var metadata struct { + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + } + if err := json.Unmarshal(metadataBytes, &metadata); err == nil { + if metadata.Name != "" { + stack.Name = metadata.Name + } + if !metadata.CreatedAt.IsZero() { + stack.CreatedAt = metadata.CreatedAt + } } } - return nil, fmt.Errorf("stack not found") + return stack, nil } func (s *StackService) UpdateStack(ctx context.Context, stack *models.Stack) (*models.Stack, error) { From 28cf7eec3d3f05a07a6ccb36b22125f4e19beb2f Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Fri, 20 Jun 2025 15:31:28 -0500 Subject: [PATCH 8/8] feat: add container stats endpoints and service methods for resource usage --- internal/api/router.go | 3 + internal/docker/client.go | 4 ++ internal/handlers/container_handler.go | 87 +++++++++++++++++++++++++- internal/services/container.go | 67 ++++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 internal/services/container.go diff --git a/internal/api/router.go b/internal/api/router.go index 73c19c7..ef25b23 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -53,6 +53,9 @@ func setupContainerRoutes(api *gin.RouterGroup, containerHandler *handlers.Conta containers.POST("/:id/start", containerHandler.StartContainer) containers.POST("/:id/stop", containerHandler.StopContainer) containers.POST("/:id/restart", containerHandler.RestartContainer) + containers.GET("/:id/stats", containerHandler.GetStats) + containers.GET("/:id/stats/stream", containerHandler.GetStatsStream) + } } diff --git a/internal/docker/client.go b/internal/docker/client.go index 57b4100..1b006e7 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -276,6 +276,10 @@ func (c *Client) GetVolumeUsage(ctx context.Context, name string) (bool, []strin return inUse, usingContainers, nil } +func (c *Client) ContainerStats(ctx context.Context, containerID string, stream bool) (container.StatsResponseReader, error) { + return c.cli.ContainerStats(ctx, containerID, stream) +} + func (c *Client) Close() error { if c.cli != nil { return c.cli.Close() diff --git a/internal/handlers/container_handler.go b/internal/handlers/container_handler.go index a75b368..cf429c2 100644 --- a/internal/handlers/container_handler.go +++ b/internal/handlers/container_handler.go @@ -1,20 +1,24 @@ package handlers import ( + "io" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/ofkm/arcane-agent/internal/docker" + "github.com/ofkm/arcane-agent/internal/services" ) type ContainerHandler struct { - dockerClient *docker.Client + dockerClient *docker.Client + containerService *services.ContainerService } func NewContainerHandler(dockerClient *docker.Client) *ContainerHandler { return &ContainerHandler{ - dockerClient: dockerClient, + dockerClient: dockerClient, + containerService: services.NewContainerService(dockerClient), } } @@ -121,3 +125,82 @@ func (h *ContainerHandler) RestartContainer(c *gin.Context) { "success": true, }) } + +// GetStats returns container resource usage statistics +func (h *ContainerHandler) GetStats(c *gin.Context) { + containerID := c.Param("id") + if containerID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "data": nil, + "success": false, + "error": "Container ID is required", + }) + return + } + + // Check if streaming is requested + stream := c.Query("stream") == "true" + + stats, err := h.containerService.GetStats(c.Request.Context(), containerID, stream) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "data": nil, + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": stats, + "success": true, + }) +} + +func (h *ContainerHandler) GetStatsStream(c *gin.Context) { + containerID := c.Param("id") + if containerID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "data": nil, + "success": false, + "error": "Container ID is required", + }) + return + } + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Access-Control-Allow-Origin", "*") + c.Header("X-Accel-Buffering", "no") + + statsChan := make(chan interface{}, 10) + errChan := make(chan error, 1) + + go func() { + defer close(statsChan) + defer close(errChan) + + err := h.containerService.StreamStats(c.Request.Context(), containerID, statsChan) + if err != nil { + errChan <- err + } + }() + + // Send stats to client + c.Stream(func(w io.Writer) bool { + select { + case stats, ok := <-statsChan: + if !ok { + return false + } + c.SSEvent("stats", stats) + return true + case err := <-errChan: + c.SSEvent("error", gin.H{"error": err.Error()}) + return false + case <-c.Request.Context().Done(): + return false + } + }) +} diff --git a/internal/services/container.go b/internal/services/container.go new file mode 100644 index 0000000..ccb1349 --- /dev/null +++ b/internal/services/container.go @@ -0,0 +1,67 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/ofkm/arcane-agent/internal/docker" +) + +type ContainerService struct { + dockerClient *docker.Client +} + +func NewContainerService(dockerClient *docker.Client) *ContainerService { + return &ContainerService{ + dockerClient: dockerClient, + } +} + +func (s *ContainerService) GetStats(ctx context.Context, containerID string, stream bool) (interface{}, error) { + stats, err := s.dockerClient.ContainerStats(ctx, containerID, stream) + if err != nil { + return nil, fmt.Errorf("failed to get container stats: %w", err) + } + defer stats.Body.Close() + + var statsData interface{} + decoder := json.NewDecoder(stats.Body) + if err := decoder.Decode(&statsData); err != nil { + return nil, fmt.Errorf("failed to decode stats: %w", err) + } + + return statsData, nil +} + +func (s *ContainerService) StreamStats(ctx context.Context, containerID string, statsChan chan<- interface{}) error { + stats, err := s.dockerClient.ContainerStats(ctx, containerID, true) + if err != nil { + return fmt.Errorf("failed to start stats stream: %w", err) + } + defer stats.Body.Close() + + decoder := json.NewDecoder(stats.Body) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + var statsData interface{} + if err := decoder.Decode(&statsData); err != nil { + if err == io.EOF { + return nil + } + return fmt.Errorf("failed to decode stats: %w", err) + } + + select { + case statsChan <- statsData: + case <-ctx.Done(): + return ctx.Err() + } + } + } +}