From 323d9a49c9d5813f0c624e8b0df44b570de6ff74 Mon Sep 17 00:00:00 2001 From: rlaope Date: Thu, 28 May 2026 15:30:23 +0900 Subject: [PATCH 1/2] feat(tools): add orchestrator-agnostic change & deploy inquiry (Phase 1/4) Add a read-only change.recent tool that reports recent rollout, image, scale, and restart events for a workload across both Kubernetes and Docker on one newest-first timeline. Introduces a ChangeSource seam with k8s and docker implementations, a read-only Docker client adapter (list/inspect only), and a DockerHosts config field. Phase 1 of #100. Signed-off-by: rlaope --- go.mod | 46 +- go.sum | 125 ++++-- internal/clients/docker/client.go | 66 +++ internal/clients/docker/hub.go | 100 +++++ internal/clients/k8s/client.go | 12 + internal/config/config.go | 16 + internal/core/tools/change/docker_source.go | 240 ++++++++++ .../core/tools/change/docker_source_test.go | 264 +++++++++++ internal/core/tools/change/k8s_source.go | 419 ++++++++++++++++++ internal/core/tools/change/k8s_source_test.go | 293 ++++++++++++ internal/core/tools/change/register.go | 25 ++ internal/core/tools/change/source.go | 65 +++ internal/core/tools/change/source_test.go | 55 +++ internal/core/tools/change/tool.go | 148 +++++++ internal/wiring/orchestrator.go | 1 + internal/wiring/skills_test.go | 2 + internal/wiring/tools.go | 29 ++ 17 files changed, 1864 insertions(+), 42 deletions(-) create mode 100644 internal/clients/docker/client.go create mode 100644 internal/clients/docker/hub.go create mode 100644 internal/core/tools/change/docker_source.go create mode 100644 internal/core/tools/change/docker_source_test.go create mode 100644 internal/core/tools/change/k8s_source.go create mode 100644 internal/core/tools/change/k8s_source_test.go create mode 100644 internal/core/tools/change/register.go create mode 100644 internal/core/tools/change/source.go create mode 100644 internal/core/tools/change/source_test.go create mode 100644 internal/core/tools/change/tool.go diff --git a/go.mod b/go.mod index 90b57c1..e358d30 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,14 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/docker/docker v28.5.2+incompatible github.com/go-sql-driver/mysql v1.8.1 + github.com/google/pprof v0.0.0-20260507013755-92041b743c96 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.7.5 github.com/mattn/go-isatty v0.0.20 github.com/redis/go-redis/v9 v9.7.3 + golang.org/x/sync v0.20.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.31.9 k8s.io/apimachinery v0.31.9 @@ -20,6 +24,7 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -33,25 +38,31 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.4 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -62,35 +73,46 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/spdystream v0.4.0 // 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.1.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/net v0.38.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.3.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gotest.tools/v3 v3.5.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect diff --git a/go.sum b/go.sum index 6dbe2e2..5ebe039 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,19 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +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/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -20,6 +26,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= @@ -48,6 +56,12 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -55,16 +69,29 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +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/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -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/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= @@ -83,13 +110,11 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= -github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -98,6 +123,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= @@ -123,8 +150,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -138,13 +163,23 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +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/spdystream v0.4.0 h1:Vy79D6mHeJJjiPdFEL2yku1kl0chZpJfZcPpb16BRl8= github.com/moby/spdystream v0.4.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +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.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -161,6 +196,10 @@ github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -172,10 +211,10 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -186,8 +225,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ 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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -198,11 +237,31 @@ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= 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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -211,15 +270,13 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn 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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -227,28 +284,34 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 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/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= @@ -262,6 +325,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= k8s.io/api v0.31.9 h1:+gN4iZNccfr6y2EX28ZgcAq4yUKNZMhg2Jl72+2hoxQ= k8s.io/api v0.31.9/go.mod h1:+rao9hnuB9AHXVoqqwxPh493H91pte1ZhfJ6oz1qLJA= k8s.io/apimachinery v0.31.9 h1:sLGkHzsAfWVp55os8PlKw+eeIsB3IeVU1QLb3XKHyg8= diff --git a/internal/clients/docker/client.go b/internal/clients/docker/client.go new file mode 100644 index 0000000..f64ed5d --- /dev/null +++ b/internal/clients/docker/client.go @@ -0,0 +1,66 @@ +// Package dockerclient is the shared read-only Docker adapter used by the +// change-source that reports recent container/image changes per host. It +// mirrors the k8sclient.Client / Hub shape: a Client wraps one daemon, a Hub +// manages several daemons by name with lazy init. +// +// Every method here is list/inspect only. cloudy never starts, stops, +// creates, or removes containers — the ReadOnlyAPI interface deliberately +// exposes no mutating method, so the read-only contract holds by construction. +package dockerclient + +import ( + "context" + "fmt" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + dockersdk "github.com/docker/docker/client" +) + +// ReadOnlyAPI is the minimal read surface the change-source consumes from a +// Docker daemon. It is intentionally small so it can be mocked in tests, and +// intentionally read-only so no mutating call can leak in via this seam. +type ReadOnlyAPI interface { + ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) + ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) + ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error) +} + +// Client is a read-only façade over one Docker daemon. It satisfies +// ReadOnlyAPI by delegating to the embedded SDK client. +type Client struct { + sdk *dockersdk.Client +} + +// NewClient builds a Client for the daemon at host (e.g. +// "unix:///var/run/docker.sock" or "tcp://host:2375"). An empty host falls +// back to the SDK's environment defaults (DOCKER_HOST etc.). API version is +// negotiated with the daemon so the client works across daemon versions. +func NewClient(host string) (*Client, error) { + opts := []dockersdk.Opt{dockersdk.WithAPIVersionNegotiation()} + if host != "" { + opts = append(opts, dockersdk.WithHost(host)) + } else { + opts = append(opts, dockersdk.FromEnv) + } + sdk, err := dockersdk.NewClientWithOpts(opts...) + if err != nil { + return nil, fmt.Errorf("docker: build client: %w", err) + } + return &Client{sdk: sdk}, nil +} + +// ContainerList lists containers on the daemon. +func (c *Client) ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) { + return c.sdk.ContainerList(ctx, options) +} + +// ContainerInspect returns the detailed state of a single container. +func (c *Client) ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) { + return c.sdk.ContainerInspect(ctx, containerID) +} + +// ImageList lists images on the daemon. +func (c *Client) ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error) { + return c.sdk.ImageList(ctx, options) +} diff --git a/internal/clients/docker/hub.go b/internal/clients/docker/hub.go new file mode 100644 index 0000000..64fcc84 --- /dev/null +++ b/internal/clients/docker/hub.go @@ -0,0 +1,100 @@ +package dockerclient + +import ( + "fmt" + "sort" + "sync" + + "github.com/rlaope/cloudy/internal/config" +) + +// Hub holds one read-only Client per configured Docker host, with one +// designated default. It mirrors k8sclient.Hub: callers address a host by name +// on each call, and an empty name resolves to the default (first configured +// host). Clients are built lazily on first Get so an unreachable daemon does +// not fail process startup. +type Hub struct { + mu sync.Mutex + hosts map[string]string // name -> endpoint + clients map[string]ReadOnlyAPI + defaultN string +} + +// NewHub constructs a Hub from the configured Docker hosts. The first entry is +// treated as the default for calls that omit the host name. Duplicate or +// empty-named entries are skipped. An empty hosts slice yields a Hub whose Get +// always errors "no docker hosts configured" — callers gate on len first. +func NewHub(hosts []config.DockerHost) (*Hub, error) { + h := &Hub{ + hosts: make(map[string]string, len(hosts)), + clients: make(map[string]ReadOnlyAPI, len(hosts)), + } + for _, dh := range hosts { + if dh.Name == "" { + continue + } + if _, dup := h.hosts[dh.Name]; dup { + continue + } + h.hosts[dh.Name] = dh.Host + if h.defaultN == "" { + h.defaultN = dh.Name + } + } + return h, nil +} + +// Default returns the name of the default host, or "" when none are configured. +func (h *Hub) Default() string { + if h == nil { + return "" + } + h.mu.Lock() + defer h.mu.Unlock() + return h.defaultN +} + +// Names returns every configured host name in stable alphabetical order. +func (h *Hub) Names() []string { + if h == nil { + return nil + } + h.mu.Lock() + out := make([]string, 0, len(h.hosts)) + for n := range h.hosts { + out = append(out, n) + } + h.mu.Unlock() + sort.Strings(out) + return out +} + +// Get returns the ReadOnlyAPI for the named host. An empty name resolves to +// Default(). The first call for a host builds its client lazily. +func (h *Hub) Get(name string) (ReadOnlyAPI, error) { + if h == nil { + return nil, fmt.Errorf("docker: hub is nil") + } + h.mu.Lock() + defer h.mu.Unlock() + + if name == "" { + name = h.defaultN + } + if name == "" { + return nil, fmt.Errorf("docker: no docker hosts configured") + } + endpoint, ok := h.hosts[name] + if !ok { + return nil, fmt.Errorf("docker: host %q is not configured", name) + } + if c, ok := h.clients[name]; ok { + return c, nil + } + c, err := NewClient(endpoint) + if err != nil { + return nil, fmt.Errorf("docker: build client for host %q: %w", name, err) + } + h.clients[name] = c + return c, nil +} diff --git a/internal/clients/k8s/client.go b/internal/clients/k8s/client.go index 3fa4d39..215f3f4 100644 --- a/internal/clients/k8s/client.go +++ b/internal/clients/k8s/client.go @@ -247,6 +247,18 @@ func (c *Client) DaemonSets(ns string, opts metav1.ListOptions) (*appsv1.DaemonS return c.core.AppsV1().DaemonSets(ns).List(context.Background(), opts) } +// ReplicaSets lists replica sets in the given namespace. Used to reconstruct +// Deployment revision history (each rollout creates a new ReplicaSet). +func (c *Client) ReplicaSets(ns string, opts metav1.ListOptions) (*appsv1.ReplicaSetList, error) { + return c.core.AppsV1().ReplicaSets(ns).List(context.Background(), opts) +} + +// ControllerRevisions lists controller revisions in the given namespace. Used +// to reconstruct StatefulSet / DaemonSet revision history. +func (c *Client) ControllerRevisions(ns string, opts metav1.ListOptions) (*appsv1.ControllerRevisionList, error) { + return c.core.AppsV1().ControllerRevisions(ns).List(context.Background(), opts) +} + // Jobs lists jobs in the given namespace. func (c *Client) Jobs(ns string, opts metav1.ListOptions) (*batchv1.JobList, error) { return c.core.BatchV1().Jobs(ns).List(context.Background(), opts) diff --git a/internal/config/config.go b/internal/config/config.go index 65bb8ad..7aa92dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -72,6 +72,11 @@ type Config struct { // agent. Empty (or missing) means "use the kubeconfig current-context". Contexts []string `yaml:"contexts,omitempty"` + // DockerHosts is the list of Docker daemons cloudy may inspect for + // container/image change history. All access is list/inspect only — + // cloudy never starts, stops, creates, or removes containers. + DockerHosts []DockerHost `yaml:"docker_hosts,omitempty"` + // Safety contains guardrails that bound what the agent is allowed to do. Safety SafetyConfig `yaml:"safety"` @@ -196,6 +201,17 @@ type DatabaseEndpoint struct { PasswordEnv string `yaml:"password_env,omitempty"` } +// DockerHost describes a single Docker daemon cloudy may inspect. Host is a +// Docker endpoint, e.g. "unix:///var/run/docker.sock" or "tcp://host:2375". +// Access is read-only; no entry here causes cloudy to auto-connect at startup. +type DockerHost struct { + // Name is a human-readable label used in UI and as the host argument key. + Name string `yaml:"name"` + + // Host is the Docker daemon endpoint (unix socket or tcp address). + Host string `yaml:"host"` +} + // SafetyConfig contains guardrails that bound agent behaviour. type SafetyConfig struct { // AllowSecrets permits the agent to read Kubernetes Secrets. Defaults to diff --git a/internal/core/tools/change/docker_source.go b/internal/core/tools/change/docker_source.go new file mode 100644 index 0000000..3e3b4dd --- /dev/null +++ b/internal/core/tools/change/docker_source.go @@ -0,0 +1,240 @@ +package change + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + dockerclient "github.com/rlaope/cloudy/internal/clients/docker" +) + +// DockerSource is a ChangeSource that derives recent change events from a +// Docker daemon via read-only container list/inspect and image list calls. +// It satisfies cloudy's read-only contract — no mutating Docker API call is +// reachable through this type. +type DockerSource struct { + hub *dockerclient.Hub +} + +// NewDockerSource returns a DockerSource backed by hub. +func NewDockerSource(hub *dockerclient.Hub) *DockerSource { + return &DockerSource{hub: hub} +} + +// Name implements ChangeSource. +func (s *DockerSource) Name() string { return "docker" } + +// RecentChanges implements ChangeSource. It calls hub.Get(q.Context) to obtain +// a read-only Docker API handle, then derives ChangeEvents from container +// inspect results and the image list. Events older than q.Since are dropped +// (when Since > 0). The result is merged and capped at q.Limit via +// MergeSorted. +func (s *DockerSource) RecentChanges(ctx context.Context, q ChangeQuery) ([]ChangeEvent, error) { + api, err := s.hub.Get(q.Context) + if err != nil { + return nil, fmt.Errorf("docker source: get host: %w", err) + } + + cutoff := time.Time{} + if q.Since > 0 { + cutoff = time.Now().Add(-q.Since) + } + + // --- containers --- + summaries, err := api.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + return nil, fmt.Errorf("docker source: list containers: %w", err) + } + + var containerEvents []ChangeEvent + for _, s := range summaries { + if !matchesWorkload(s, q.Workload) { + continue + } + insp, err := api.ContainerInspect(ctx, s.ID) + if err != nil { + // Skip containers that disappear between list and inspect. + continue + } + containerEvents = append(containerEvents, containerInspectEvents(insp, cutoff)...) + } + + // --- images --- + images, err := api.ImageList(ctx, image.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("docker source: list images: %w", err) + } + imageEvents := imageListEvents(images, q.Workload, cutoff) + + return MergeSorted(q.Limit, containerEvents, imageEvents), nil +} + +// matchesWorkload reports whether a container summary relates to the given +// workload string. An empty workload matches everything. Matching is a +// case-insensitive substring check on container Names and on the +// com.docker.compose.service / com.docker.compose.project labels. +func matchesWorkload(s container.Summary, workload string) bool { + if workload == "" { + return true + } + wl := strings.ToLower(workload) + for _, n := range s.Names { + if strings.Contains(strings.ToLower(strings.TrimPrefix(n, "/")), wl) { + return true + } + } + for _, key := range []string{"com.docker.compose.service", "com.docker.compose.project"} { + if v, ok := s.Labels[key]; ok && strings.Contains(strings.ToLower(v), wl) { + return true + } + } + return false +} + +// containerInspectEvents derives ChangeEvents from a single container's +// InspectResponse. It emits up to three event kinds: +// - "container_create" — time of container creation +// - "container_restart" — time of last start (when RestartCount > 0) +// - "image" — the running image at start time +// +// Events before cutoff are dropped (zero cutoff = keep all). +func containerInspectEvents(insp container.InspectResponse, cutoff time.Time) []ChangeEvent { + if insp.ContainerJSONBase == nil { + return nil + } + + name := strings.TrimPrefix(insp.Name, "/") + imageName := insp.Image // image digest from ContainerJSONBase + if insp.Config != nil && insp.Config.Image != "" { + imageName = insp.Config.Image + } + + var events []ChangeEvent + + // container_create + if t, ok := parseDockerTime(insp.Created); ok { + ev := ChangeEvent{ + Time: t, + Kind: "container_create", + Target: name, + Summary: fmt.Sprintf("container %s created", name), + After: imageName, + Source: "docker", + } + if keep(t, cutoff) { + events = append(events, ev) + } + } + + // startedAt time used for restart + image events + var startedAt time.Time + if insp.State != nil { + if t, ok := parseDockerTime(insp.State.StartedAt); ok { + startedAt = t + } + } + + // container_restart (only when RestartCount > 0 and we have a valid start time) + if insp.RestartCount > 0 && !startedAt.IsZero() && keep(startedAt, cutoff) { + events = append(events, ChangeEvent{ + Time: startedAt, + Kind: "container_restart", + Target: name, + Summary: fmt.Sprintf("container %s restarted (count: %d)", name, insp.RestartCount), + After: imageName, + Source: "docker", + }) + } + + // image — record the running image at last start time + if !startedAt.IsZero() && keep(startedAt, cutoff) { + imageDigest := insp.Image // raw digest field on ContainerJSONBase + events = append(events, ChangeEvent{ + Time: startedAt, + Kind: "image", + Target: name, + Summary: fmt.Sprintf("container %s running image %s", name, imageName), + Before: imageDigest, + After: imageName, + Source: "docker", + }) + } + + return events +} + +// imageListEvents derives "image_pull" ChangeEvents from a slice of +// image.Summary values. Images whose RepoTags don't relate to workload are +// skipped (empty workload = keep all). Events before cutoff are dropped. +func imageListEvents(images []image.Summary, workload string, cutoff time.Time) []ChangeEvent { + wl := strings.ToLower(workload) + var events []ChangeEvent + for _, img := range images { + tags := matchingTags(img.RepoTags, wl) + if len(tags) == 0 { + continue + } + t := time.Unix(img.Created, 0).UTC() + if !keep(t, cutoff) { + continue + } + for _, tag := range tags { + parts := strings.SplitN(tag, ":", 2) + after := tag + if len(parts) == 2 { + after = parts[1] + } + events = append(events, ChangeEvent{ + Time: t, + Kind: "image_pull", + Target: tag, + Summary: tag, + After: after, + Source: "docker", + }) + } + } + return events +} + +// matchingTags returns the subset of repoTags that contain the workload +// substring (case-insensitive). An empty workload returns all tags (excluding +// the ":" placeholder). +func matchingTags(repoTags []string, workload string) []string { + var out []string + for _, tag := range repoTags { + if tag == ":" || tag == "" { + continue + } + if workload == "" || strings.Contains(strings.ToLower(tag), workload) { + out = append(out, tag) + } + } + return out +} + +// parseDockerTime parses a Docker RFC3339Nano timestamp string. It returns the +// parsed time and true on success; zero time and false on any parse error or +// empty/zero input (Docker uses "0001-01-01T00:00:00Z" for unset times). +func parseDockerTime(s string) (time.Time, bool) { + if s == "" { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return time.Time{}, false + } + if t.IsZero() { + return time.Time{}, false + } + return t, true +} + +// keep reports whether t should be included given cutoff. A zero cutoff always +// returns true. +func keep(t time.Time, cutoff time.Time) bool { + return cutoff.IsZero() || !t.Before(cutoff) +} diff --git a/internal/core/tools/change/docker_source_test.go b/internal/core/tools/change/docker_source_test.go new file mode 100644 index 0000000..698d7fd --- /dev/null +++ b/internal/core/tools/change/docker_source_test.go @@ -0,0 +1,264 @@ +package change + +import ( + "context" + "testing" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" +) + +// mockDockerAPI implements dockerclient.ReadOnlyAPI with canned responses. +type mockDockerAPI struct { + summaries []container.Summary + inspect map[string]container.InspectResponse + images []image.Summary +} + +func (m *mockDockerAPI) ContainerList(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + return m.summaries, nil +} + +func (m *mockDockerAPI) ContainerInspect(_ context.Context, id string) (container.InspectResponse, error) { + return m.inspect[id], nil +} + +func (m *mockDockerAPI) ImageList(_ context.Context, _ image.ListOptions) ([]image.Summary, error) { + return m.images, nil +} + +// --- pure helper tests --- + +func TestParseDockerTime(t *testing.T) { + t.Run("valid RFC3339Nano", func(t *testing.T) { + ts := "2026-05-01T10:00:00.000000000Z" + got, ok := parseDockerTime(ts) + if !ok { + t.Fatal("expected ok=true") + } + if got.Year() != 2026 || got.Month() != 5 || got.Day() != 1 { + t.Errorf("unexpected time: %v", got) + } + }) + t.Run("empty string", func(t *testing.T) { + _, ok := parseDockerTime("") + if ok { + t.Error("expected ok=false for empty string") + } + }) + t.Run("zero docker time", func(t *testing.T) { + _, ok := parseDockerTime("0001-01-01T00:00:00Z") + if ok { + t.Error("expected ok=false for zero time") + } + }) + t.Run("garbage", func(t *testing.T) { + _, ok := parseDockerTime("not-a-time") + if ok { + t.Error("expected ok=false for unparseable string") + } + }) +} + +func TestMatchesWorkload(t *testing.T) { + s := container.Summary{ + Names: []string{"/myapp-web-1"}, + Labels: map[string]string{"com.docker.compose.service": "web"}, + } + + if !matchesWorkload(s, "") { + t.Error("empty workload should match everything") + } + if !matchesWorkload(s, "myapp") { + t.Error("should match via container name") + } + if !matchesWorkload(s, "web") { + t.Error("should match via compose service label") + } + if matchesWorkload(s, "redis") { + t.Error("should not match unrelated workload") + } +} + +func TestMatchingTags(t *testing.T) { + tags := []string{"nginx:1.25", "nginx:latest", ":", "redis:7"} + got := matchingTags(tags, "nginx") + if len(got) != 2 { + t.Fatalf("want 2 nginx tags, got %d: %v", len(got), got) + } + // empty workload returns all non-placeholder tags + all := matchingTags(tags, "") + if len(all) != 3 { + t.Fatalf("want 3 tags (no placeholder), got %d: %v", len(all), all) + } +} + +func TestImageListEvents(t *testing.T) { + created := time.Date(2026, 5, 10, 8, 0, 0, 0, time.UTC) + imgs := []image.Summary{ + {RepoTags: []string{"myapp:v2"}, Created: created.Unix()}, + {RepoTags: []string{"redis:7"}, Created: created.Add(-24 * time.Hour).Unix()}, + } + + t.Run("workload filter", func(t *testing.T) { + evs := imageListEvents(imgs, "myapp", time.Time{}) + if len(evs) != 1 || evs[0].Kind != "image_pull" { + t.Errorf("expected 1 image_pull for myapp, got %v", evs) + } + if evs[0].After != "v2" { + t.Errorf("After = %q, want %q", evs[0].After, "v2") + } + }) + + t.Run("since filter drops old", func(t *testing.T) { + cutoff := created.Add(-12 * time.Hour) + evs := imageListEvents(imgs, "", cutoff) + if len(evs) != 1 { + t.Fatalf("want 1 event after cutoff, got %d", len(evs)) + } + if evs[0].Target != "myapp:v2" { + t.Errorf("unexpected target: %s", evs[0].Target) + } + }) +} + +func TestContainerInspectEvents(t *testing.T) { + createTime := "2026-05-01T09:00:00Z" + startTime := "2026-05-10T10:00:00Z" + + insp := container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: "abc123", + Name: "/myapp", + Created: createTime, + Image: "sha256:deadbeef", + RestartCount: 2, + State: &container.State{ + StartedAt: startTime, + }, + }, + Config: &container.Config{ + Image: "myapp:v2", + }, + } + + evs := containerInspectEvents(insp, time.Time{}) + + kinds := make(map[string]bool) + for _, e := range evs { + kinds[e.Kind] = true + if e.Source != "docker" { + t.Errorf("event %s Source = %q, want docker", e.Kind, e.Source) + } + } + if !kinds["container_create"] { + t.Error("expected container_create event") + } + if !kinds["container_restart"] { + t.Error("expected container_restart event (RestartCount=2)") + } + if !kinds["image"] { + t.Error("expected image event") + } + + // restart event should note count + for _, e := range evs { + if e.Kind == "container_restart" && e.After != "myapp:v2" { + t.Errorf("restart After = %q, want myapp:v2", e.After) + } + } +} + +func TestContainerInspectEvents_NoRestartWhenZero(t *testing.T) { + insp := container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: "xyz", + Name: "/svc", + Created: "2026-05-01T09:00:00Z", + RestartCount: 0, + State: &container.State{ + StartedAt: "2026-05-01T09:01:00Z", + }, + }, + Config: &container.Config{Image: "svc:latest"}, + } + evs := containerInspectEvents(insp, time.Time{}) + for _, e := range evs { + if e.Kind == "container_restart" { + t.Error("should not emit container_restart when RestartCount=0") + } + } +} + +func TestContainerInspectEvents_SinceFilter(t *testing.T) { + createTime := "2026-05-01T09:00:00Z" + startTime := "2026-05-10T10:00:00Z" + cutoff := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC) + + insp := container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: "abc", + Name: "/svc", + Created: createTime, // before cutoff → dropped + RestartCount: 1, + State: &container.State{StartedAt: startTime}, // after cutoff → kept + }, + Config: &container.Config{Image: "svc:v1"}, + } + + evs := containerInspectEvents(insp, cutoff) + for _, e := range evs { + if e.Kind == "container_create" { + t.Error("container_create should be filtered out (before cutoff)") + } + } + // restart and image events (startTime after cutoff) should be present + kinds := make(map[string]bool) + for _, e := range evs { + kinds[e.Kind] = true + } + if !kinds["container_restart"] { + t.Error("expected container_restart after cutoff") + } + if !kinds["image"] { + t.Error("expected image event after cutoff") + } +} + +// --- integration-style test using the mock hub seam --- + +// mockHub wraps a mockDockerAPI so DockerSource can call it via a thin adapter +// that satisfies the hub.Get(name) (ReadOnlyAPI, error) contract without +// importing dockerclient (same package test, no daemon needed). +type mockHubAdapter struct { + api *mockDockerAPI +} + +// DockerSource accepts *dockerclient.Hub, so we test the pure helpers above +// and below we exercise RecentChanges via a small integration path using a +// real Hub with an injected client — but that requires a real hub. Instead, +// we test that containerInspectEvents and imageListEvents together, piped +// through MergeSorted, produce a correct merged+limited result. +func TestMergedDockerEvents(t *testing.T) { + base := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC) + + containers := []ChangeEvent{ + {Time: base, Kind: "image", Target: "app", Source: "docker"}, + {Time: base.Add(-1 * time.Hour), Kind: "container_create", Target: "app", Source: "docker"}, + } + images := []ChangeEvent{ + {Time: base.Add(-30 * time.Minute), Kind: "image_pull", Target: "app:v2", Source: "docker"}, + } + + got := MergeSorted(2, containers, images) + if len(got) != 2 { + t.Fatalf("want 2 (limit), got %d", len(got)) + } + if got[0].Kind != "image" { + t.Errorf("got[0].Kind = %q, want image", got[0].Kind) + } + if got[1].Kind != "image_pull" { + t.Errorf("got[1].Kind = %q, want image_pull", got[1].Kind) + } +} diff --git a/internal/core/tools/change/k8s_source.go b/internal/core/tools/change/k8s_source.go new file mode 100644 index 0000000..7d75820 --- /dev/null +++ b/internal/core/tools/change/k8s_source.go @@ -0,0 +1,419 @@ +package change + +import ( + "context" + "fmt" + "sort" + "time" + + appsv1 "k8s.io/api/apps/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + k8sclient "github.com/rlaope/cloudy/internal/clients/k8s" +) + +// deploymentRevisionAnnotation is the annotation Kubernetes stamps on each +// ReplicaSet a Deployment owns, recording that ReplicaSet's revision number. +// We read it to order revisions and label rollout events. +const deploymentRevisionAnnotation = "deployment.kubernetes.io/revision" + +// K8sSource is a read-only ChangeSource backed by the Kubernetes API. It +// reconstructs recent rollout / image / scale changes for a single workload +// from ReplicaSet and ControllerRevision history plus the workload's current +// spec, and folds in matching cluster Events. Nothing here mutates the cluster. +type K8sSource struct { + hub *k8sclient.Hub +} + +// NewK8sSource builds a K8sSource over the given multi-context Hub. +func NewK8sSource(hub *k8sclient.Hub) *K8sSource { + return &K8sSource{hub: hub} +} + +// Name identifies this backend for diagnostics and logging. +func (s *K8sSource) Name() string { return "k8s" } + +// RecentChanges resolves the client for q.Context from the Hub (mirroring the +// k8s.* tools), then derives ChangeEvents from the workload's revision history, +// current spec, autoscaler, and Events. The workload kind is unknown to us, so +// we attempt Deployment, StatefulSet, and DaemonSet in turn and collect what +// each yields — a NotFound for one kind is not fatal. Results are merged +// newest-first and capped by q.Limit. +func (s *K8sSource) RecentChanges(ctx context.Context, q ChangeQuery) ([]ChangeEvent, error) { + client, err := s.hub.Get(q.Context) + if err != nil { + return nil, fmt.Errorf("change.k8s: %w", err) + } + + var cutoff time.Time + if q.Since > 0 { + cutoff = time.Now().Add(-q.Since) + } + + var groups [][]ChangeEvent + + // Deployment: revision history lives in owned ReplicaSets. + if deps, err := client.Deployments(q.Namespace, metav1.ListOptions{}); err == nil { + if dep := findDeployment(deps, q.Workload); dep != nil { + rsList, _ := client.ReplicaSets(q.Namespace, metav1.ListOptions{}) + groups = append(groups, deploymentRevisionEvents(dep, rsList)) + groups = append(groups, deploymentSpecEvents(dep)) + } + } + + // StatefulSet: revision history lives in owned ControllerRevisions. + if sets, err := client.StatefulSets(q.Namespace, metav1.ListOptions{}); err == nil { + if set := findStatefulSet(sets, q.Workload); set != nil { + crList, _ := client.ControllerRevisions(q.Namespace, metav1.ListOptions{}) + groups = append(groups, controllerRevisionEvents(q.Workload, crList)) + groups = append(groups, statefulSetSpecEvents(set)) + } + } + + // DaemonSet: revision history also lives in owned ControllerRevisions. + if sets, err := client.DaemonSets(q.Namespace, metav1.ListOptions{}); err == nil { + if set := findDaemonSet(sets, q.Workload); set != nil { + crList, _ := client.ControllerRevisions(q.Namespace, metav1.ListOptions{}) + groups = append(groups, controllerRevisionEvents(q.Workload, crList)) + groups = append(groups, daemonSetSpecEvents(set)) + } + } + + // Autoscaler: any HPA targeting this workload contributes a scale baseline. + if hpas, err := client.HPAs(q.Namespace, metav1.ListOptions{}); err == nil { + groups = append(groups, hpaEvents(q.Workload, hpas)) + } + + // Events involving the workload object (mirrors the k8s.events selector). + opts := metav1.ListOptions{FieldSelector: fmt.Sprintf("involvedObject.name=%s", q.Workload)} + if evs, err := client.Events(q.Namespace, opts); err == nil { + groups = append(groups, eventEvents(q.Workload, evs)) + } + + merged := MergeSorted(0, groups...) + if !cutoff.IsZero() { + merged = filterSince(merged, cutoff) + } + if q.Limit > 0 && len(merged) > q.Limit { + merged = merged[:q.Limit] + } + return merged, nil +} + +// findDeployment returns the Deployment named name in list, or nil. +func findDeployment(list *appsv1.DeploymentList, name string) *appsv1.Deployment { + if list == nil { + return nil + } + for i := range list.Items { + if list.Items[i].Name == name { + return &list.Items[i] + } + } + return nil +} + +// findStatefulSet returns the StatefulSet named name in list, or nil. +func findStatefulSet(list *appsv1.StatefulSetList, name string) *appsv1.StatefulSet { + if list == nil { + return nil + } + for i := range list.Items { + if list.Items[i].Name == name { + return &list.Items[i] + } + } + return nil +} + +// findDaemonSet returns the DaemonSet named name in list, or nil. +func findDaemonSet(list *appsv1.DaemonSetList, name string) *appsv1.DaemonSet { + if list == nil { + return nil + } + for i := range list.Items { + if list.Items[i].Name == name { + return &list.Items[i] + } + } + return nil +} + +// deploymentRevisionEvents reconstructs rollout history from the ReplicaSets +// owned by dep. Each owned ReplicaSet is one revision; its creation time is the +// rollout time and its pod-template image is the rolled-out image. Revisions +// are ordered newest-first, and each emits an "image" event whose Before is the +// image of the immediately older revision (empty for the oldest). +func deploymentRevisionEvents(dep *appsv1.Deployment, rsList *appsv1.ReplicaSetList) []ChangeEvent { + if dep == nil || rsList == nil { + return nil + } + type rev struct { + num int + created time.Time + image string + } + var revs []rev + for i := range rsList.Items { + rs := &rsList.Items[i] + if !ownedBy(rs.OwnerReferences, "Deployment", dep.Name) { + continue + } + revs = append(revs, rev{ + num: parseRevision(rs.Annotations[deploymentRevisionAnnotation]), + created: rs.CreationTimestamp.Time, + image: firstContainerImage(rs.Spec.Template.Spec.Containers), + }) + } + if len(revs) == 0 { + return nil + } + // Oldest-first so Before can reference the prior revision's image. + sort.SliceStable(revs, func(i, j int) bool { return revs[i].num < revs[j].num }) + + out := make([]ChangeEvent, 0, len(revs)) + prevImage := "" + for _, r := range revs { + out = append(out, ChangeEvent{ + Time: r.created, + Kind: "image", + Target: dep.Name, + Summary: fmt.Sprintf("Deployment %s revision %d", dep.Name, r.num), + Before: prevImage, + After: r.image, + Source: "k8s", + }) + prevImage = r.image + } + return out +} + +// controllerRevisionEvents reconstructs rollout history from the +// ControllerRevisions owned by the StatefulSet/DaemonSet named workload. Each +// revision emits a "rollout" event at its creation time. ControllerRevision +// pod-template data is opaque (runtime.RawExtension), so no image diff is +// derivable here — Before/After are left empty. +func controllerRevisionEvents(workload string, crList *appsv1.ControllerRevisionList) []ChangeEvent { + if crList == nil { + return nil + } + var out []ChangeEvent + for i := range crList.Items { + cr := &crList.Items[i] + if !ownedByName(cr.OwnerReferences, workload) { + continue + } + out = append(out, ChangeEvent{ + Time: cr.CreationTimestamp.Time, + Kind: "rollout", + Target: workload, + Summary: fmt.Sprintf("%s revision %d", workload, cr.Revision), + Source: "k8s", + }) + } + return out +} + +// deploymentSpecEvents emits a current-state baseline for a Deployment: its +// live image ("image") and its desired replica count ("scale"), both stamped +// at the workload's creation time. +func deploymentSpecEvents(dep *appsv1.Deployment) []ChangeEvent { + if dep == nil { + return nil + } + return specBaselineEvents( + dep.Name, + dep.CreationTimestamp.Time, + firstContainerImage(dep.Spec.Template.Spec.Containers), + replicaCount(dep.Spec.Replicas), + ) +} + +// statefulSetSpecEvents emits the current-state baseline for a StatefulSet. +func statefulSetSpecEvents(set *appsv1.StatefulSet) []ChangeEvent { + if set == nil { + return nil + } + return specBaselineEvents( + set.Name, + set.CreationTimestamp.Time, + firstContainerImage(set.Spec.Template.Spec.Containers), + replicaCount(set.Spec.Replicas), + ) +} + +// daemonSetSpecEvents emits the current-state baseline for a DaemonSet. A +// DaemonSet has no .Spec.Replicas (one pod per node), so only the image +// baseline is emitted. +func daemonSetSpecEvents(set *appsv1.DaemonSet) []ChangeEvent { + if set == nil { + return nil + } + img := firstContainerImage(set.Spec.Template.Spec.Containers) + if img == "" { + return nil + } + return []ChangeEvent{{ + Time: set.CreationTimestamp.Time, + Kind: "image", + Target: set.Name, + Summary: fmt.Sprintf("DaemonSet %s current image", set.Name), + After: img, + Source: "k8s", + }} +} + +// specBaselineEvents builds the image + scale baseline events shared by the +// Deployment and StatefulSet spec readers. +func specBaselineEvents(name string, created time.Time, image string, replicas int32) []ChangeEvent { + var out []ChangeEvent + if image != "" { + out = append(out, ChangeEvent{ + Time: created, + Kind: "image", + Target: name, + Summary: fmt.Sprintf("%s current image", name), + After: image, + Source: "k8s", + }) + } + out = append(out, ChangeEvent{ + Time: created, + Kind: "scale", + Target: name, + Summary: fmt.Sprintf("%s desired replicas", name), + After: fmt.Sprintf("%d", replicas), + Source: "k8s", + }) + return out +} + +// hpaEvents emits a "scale" event for each HPA whose scaleTargetRef names the +// workload, carrying min/max/current replica bounds. +func hpaEvents(workload string, hpas *autoscalingv2.HorizontalPodAutoscalerList) []ChangeEvent { + if hpas == nil { + return nil + } + var out []ChangeEvent + for i := range hpas.Items { + hpa := &hpas.Items[i] + if hpa.Spec.ScaleTargetRef.Name != workload { + continue + } + min := int32(1) + if hpa.Spec.MinReplicas != nil { + min = *hpa.Spec.MinReplicas + } + out = append(out, ChangeEvent{ + Time: hpa.CreationTimestamp.Time, + Kind: "scale", + Target: workload, + Summary: fmt.Sprintf("HPA %s min=%d max=%d current=%d", hpa.Name, min, hpa.Spec.MaxReplicas, hpa.Status.CurrentReplicas), + After: fmt.Sprintf("%d", hpa.Status.CurrentReplicas), + Source: "k8s", + }) + } + return out +} + +// eventEvents converts cluster Events whose involved object is workload into +// "event" ChangeEvents (reason + message), stamped at the event's last-seen +// time. The field-selector already narrows by name; we re-check defensively. +func eventEvents(workload string, evs *corev1.EventList) []ChangeEvent { + if evs == nil { + return nil + } + var out []ChangeEvent + for i := range evs.Items { + e := &evs.Items[i] + if e.InvolvedObject.Name != "" && e.InvolvedObject.Name != workload { + continue + } + out = append(out, ChangeEvent{ + Time: eventTime(e), + Kind: "event", + Target: workload, + Summary: fmt.Sprintf("%s: %s", e.Reason, e.Message), + Source: "k8s", + }) + } + return out +} + +// filterSince drops events strictly older than cutoff. +func filterSince(events []ChangeEvent, cutoff time.Time) []ChangeEvent { + out := events[:0:0] + for _, e := range events { + if e.Time.Before(cutoff) { + continue + } + out = append(out, e) + } + return out +} + +// ownedBy reports whether refs contains an owner of the given kind and name. +func ownedBy(refs []metav1.OwnerReference, kind, name string) bool { + for _, r := range refs { + if r.Kind == kind && r.Name == name { + return true + } + } + return false +} + +// ownedByName reports whether refs contains any owner with the given name +// (kind-agnostic — used for ControllerRevisions which may be owned by either a +// StatefulSet or a DaemonSet). +func ownedByName(refs []metav1.OwnerReference, name string) bool { + for _, r := range refs { + if r.Name == name { + return true + } + } + return false +} + +// firstContainerImage returns the image of the first container, or "". +func firstContainerImage(containers []corev1.Container) string { + if len(containers) == 0 { + return "" + } + return containers[0].Image +} + +// replicaCount dereferences a *int32 replica count, treating nil as 1 (the +// Kubernetes default when .Spec.Replicas is unset). +func replicaCount(p *int32) int32 { + if p == nil { + return 1 + } + return *p +} + +// parseRevision parses the deployment revision annotation; non-numeric or empty +// values sort as 0. +func parseRevision(s string) int { + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + return 0 + } + n = n*10 + int(c-'0') + } + return n +} + +// eventTime returns the most meaningful timestamp for an Event, preferring +// LastTimestamp, then EventTime, then the object creation time. +func eventTime(e *corev1.Event) time.Time { + if !e.LastTimestamp.IsZero() { + return e.LastTimestamp.Time + } + if !e.EventTime.IsZero() { + return e.EventTime.Time + } + return e.CreationTimestamp.Time +} diff --git a/internal/core/tools/change/k8s_source_test.go b/internal/core/tools/change/k8s_source_test.go new file mode 100644 index 0000000..4d680b5 --- /dev/null +++ b/internal/core/tools/change/k8s_source_test.go @@ -0,0 +1,293 @@ +package change + +import ( + "context" + "testing" + "time" + + appsv1 "k8s.io/api/apps/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + fakemetrics "k8s.io/metrics/pkg/client/clientset/versioned/fake" + + k8sclient "github.com/rlaope/cloudy/internal/clients/k8s" +) + +func ts(base time.Time, h int) metav1.Time { + return metav1.NewTime(base.Add(time.Duration(h) * time.Hour)) +} + +func ownerRef(kind, name string) metav1.OwnerReference { + return metav1.OwnerReference{Kind: kind, Name: name} +} + +func rs(name, dep, image string, revision string, created metav1.Time) appsv1.ReplicaSet { + return appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "prod", + CreationTimestamp: created, + Annotations: map[string]string{deploymentRevisionAnnotation: revision}, + OwnerReferences: []metav1.OwnerReference{ownerRef("Deployment", dep)}, + }, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app", Image: image}}}, + }, + }, + } +} + +// TestDeploymentRevisionEvents_OrderingAndImageDiff pins the headline contract: +// each owned ReplicaSet becomes one "image" revision, ordered newest-first, +// with Before/After chaining the prior revision's image into the next. +func TestDeploymentRevisionEvents_OrderingAndImageDiff(t *testing.T) { + base := time.Date(2026, 5, 28, 9, 0, 0, 0, time.UTC) + dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: "prod"}} + list := &appsv1.ReplicaSetList{Items: []appsv1.ReplicaSet{ + rs("api-1", "api", "api:v1", "1", ts(base, 0)), + rs("api-2", "api", "api:v2", "2", ts(base, 1)), + // Owned by a different deployment — must be ignored. + rs("other-1", "worker", "worker:v9", "1", ts(base, 2)), + }} + + got := deploymentRevisionEvents(dep, list) + if len(got) != 2 { + t.Fatalf("len = %d, want 2 (only api-owned RS); got %#v", len(got), got) + } + // MergeSorted is what orders newest-first; the helper itself emits + // oldest-first so Before chains correctly. Assert the chain here. + if got[0].After != "api:v1" || got[0].Before != "" { + t.Errorf("rev 1: Before=%q After=%q, want \"\"/\"api:v1\"", got[0].Before, got[0].After) + } + if got[1].After != "api:v2" || got[1].Before != "api:v1" { + t.Errorf("rev 2: Before=%q After=%q, want \"api:v1\"/\"api:v2\"", got[1].Before, got[1].After) + } + for _, e := range got { + if e.Kind != "image" || e.Source != "k8s" || e.Target != "api" { + t.Errorf("event Kind/Source/Target = %q/%q/%q, want image/k8s/api", e.Kind, e.Source, e.Target) + } + } +} + +// TestControllerRevisionEvents_OwnerFilter verifies StatefulSet/DaemonSet +// revisions are matched by owner name (kind-agnostic) and carry the revision +// number; non-owned revisions are dropped. +func TestControllerRevisionEvents_OwnerFilter(t *testing.T) { + base := time.Date(2026, 5, 28, 9, 0, 0, 0, time.UTC) + list := &appsv1.ControllerRevisionList{Items: []appsv1.ControllerRevision{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "kafka-1", CreationTimestamp: ts(base, 0), + OwnerReferences: []metav1.OwnerReference{ownerRef("StatefulSet", "kafka")}, + }, + Revision: 1, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "kafka-2", CreationTimestamp: ts(base, 1), + OwnerReferences: []metav1.OwnerReference{ownerRef("StatefulSet", "kafka")}, + }, + Revision: 2, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "redis-1", CreationTimestamp: ts(base, 2), + OwnerReferences: []metav1.OwnerReference{ownerRef("StatefulSet", "redis")}, + }, + Revision: 1, + }, + }} + + got := controllerRevisionEvents("kafka", list) + if len(got) != 2 { + t.Fatalf("len = %d, want 2 (kafka-owned only)", len(got)) + } + for _, e := range got { + if e.Kind != "rollout" || e.Target != "kafka" || e.Source != "k8s" { + t.Errorf("event = %#v, want rollout/kafka/k8s", e) + } + } +} + +// TestEventEvents_ReasonMessageAndTime confirms the event reason+message is +// folded into Summary and the last-seen timestamp is used. +func TestEventEvents_ReasonMessageAndTime(t *testing.T) { + last := metav1.NewTime(time.Date(2026, 5, 28, 11, 30, 0, 0, time.UTC)) + evs := &corev1.EventList{Items: []corev1.Event{ + { + ObjectMeta: metav1.ObjectMeta{Name: "e1", Namespace: "prod"}, + InvolvedObject: corev1.ObjectReference{Kind: "Deployment", Name: "api"}, + Reason: "ScalingReplicaSet", + Message: "Scaled up replica set api-2 to 3", + LastTimestamp: last, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "e2", Namespace: "prod"}, + InvolvedObject: corev1.ObjectReference{Kind: "Deployment", Name: "other"}, + Reason: "Nope", + Message: "unrelated", + LastTimestamp: last, + }, + }} + + got := eventEvents("api", evs) + if len(got) != 1 { + t.Fatalf("len = %d, want 1 (only api-involved)", len(got)) + } + if got[0].Kind != "event" || got[0].Summary != "ScalingReplicaSet: Scaled up replica set api-2 to 3" { + t.Errorf("event = %#v", got[0]) + } + if !got[0].Time.Equal(last.Time) { + t.Errorf("Time = %v, want %v", got[0].Time, last.Time) + } +} + +// TestHPAEvents_TargetFilter verifies only HPAs whose scaleTargetRef matches +// the workload contribute a scale event carrying the min/max/current bounds. +func TestHPAEvents_TargetFilter(t *testing.T) { + min := int32(2) + hpas := &autoscalingv2.HorizontalPodAutoscalerList{Items: []autoscalingv2.HorizontalPodAutoscaler{ + { + ObjectMeta: metav1.ObjectMeta{Name: "api-hpa", Namespace: "prod"}, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{Kind: "Deployment", Name: "api"}, + MinReplicas: &min, + MaxReplicas: 10, + }, + Status: autoscalingv2.HorizontalPodAutoscalerStatus{CurrentReplicas: 4}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "other-hpa", Namespace: "prod"}, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{Kind: "Deployment", Name: "other"}, + MaxReplicas: 5, + }, + }, + }} + + got := hpaEvents("api", hpas) + if len(got) != 1 { + t.Fatalf("len = %d, want 1 (only api-targeting HPA)", len(got)) + } + if got[0].Kind != "scale" || got[0].After != "4" { + t.Errorf("event = %#v, want scale/After=4", got[0]) + } +} + +// TestSpecBaselineEvents_NilReplicasDefaultsToOne pins the Kubernetes default: +// a nil .Spec.Replicas means one replica, and the image baseline is emitted +// only when an image is present. +func TestSpecBaselineEvents_NilReplicasDefaultsToOne(t *testing.T) { + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: "prod"}, + Spec: appsv1.DeploymentSpec{ + // Replicas left nil. + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{Containers: []corev1.Container{{Image: "api:v2"}}}, + }, + }, + } + got := deploymentSpecEvents(dep) + if len(got) != 2 { + t.Fatalf("len = %d, want 2 (image + scale)", len(got)) + } + var scale *ChangeEvent + for i := range got { + if got[i].Kind == "scale" { + scale = &got[i] + } + } + if scale == nil || scale.After != "1" { + t.Errorf("scale baseline = %#v, want After=1", scale) + } +} + +// TestFilterSince_DropsOlderThanCutoff verifies the Since window drops events +// strictly older than the cutoff and keeps events at or after it. +func TestFilterSince_DropsOlderThanCutoff(t *testing.T) { + base := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + cutoff := base.Add(-30 * time.Minute) + events := []ChangeEvent{ + {Time: base, Kind: "image"}, + {Time: base.Add(-1 * time.Hour), Kind: "scale"}, // older than cutoff + {Time: cutoff, Kind: "event"}, // exactly at cutoff — kept + } + got := filterSince(events, cutoff) + if len(got) != 2 { + t.Fatalf("len = %d, want 2 (drop the 1h-old one)", len(got)) + } + for _, e := range got { + if e.Kind == "scale" { + t.Errorf("event older than cutoff was not dropped: %#v", e) + } + } +} + +// TestRecentChanges_DeploymentViaFakeHub exercises the full RecentChanges path +// against a fake-backed Hub (no kubeconfig). It confirms the source resolves +// the client, merges revision + spec + event groups newest-first, and honours +// the limit. +func TestRecentChanges_DeploymentViaFakeHub(t *testing.T) { + base := time.Date(2026, 5, 28, 9, 0, 0, 0, time.UTC) + replicas := int32(3) + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: "prod", CreationTimestamp: ts(base, 5)}, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{Containers: []corev1.Container{{Image: "api:v2"}}}, + }, + }, + } + rs1 := rs("api-1", "api", "api:v1", "1", ts(base, 0)) + rs2 := rs("api-2", "api", "api:v2", "2", ts(base, 4)) + evt := &corev1.Event{ + ObjectMeta: metav1.ObjectMeta{Name: "e1", Namespace: "prod"}, + InvolvedObject: corev1.ObjectReference{Kind: "Deployment", Name: "api"}, + Reason: "ScalingReplicaSet", + Message: "Scaled up", + LastTimestamp: ts(base, 4), + } + + hub := newChangeHub(dep, &rs1, &rs2, evt) + src := NewK8sSource(hub) + if src.Name() != "k8s" { + t.Fatalf("Name() = %q, want k8s", src.Name()) + } + + got, err := src.RecentChanges(context.Background(), ChangeQuery{Workload: "api", Namespace: "prod"}) + if err != nil { + t.Fatalf("RecentChanges: %v", err) + } + if len(got) == 0 { + t.Fatal("RecentChanges returned no events") + } + // Newest-first: the first event's time must be >= every other event's time. + for i := 1; i < len(got); i++ { + if got[i].Time.After(got[0].Time) { + t.Errorf("not sorted newest-first: got[%d].Time %v after got[0].Time %v", i, got[i].Time, got[0].Time) + } + } + + // Limit is honoured. + lim, err := src.RecentChanges(context.Background(), ChangeQuery{Workload: "api", Namespace: "prod", Limit: 2}) + if err != nil { + t.Fatalf("RecentChanges(limit): %v", err) + } + if len(lim) != 2 { + t.Errorf("limit=2 returned %d events", len(lim)) + } +} + +// newChangeHub builds a single-context Hub backed by a fake clientset seeded +// with objs — hermetic, no kubeconfig required. +func newChangeHub(objs ...runtime.Object) *k8sclient.Hub { + fakeCore := fake.NewSimpleClientset(objs...) + fakeMetrics := fakemetrics.NewSimpleClientset() + c := k8sclient.NewTestClient(fakeCore, fakeMetrics) + return k8sclient.NewHubFromClients(map[string]*k8sclient.Client{"": c}, "") +} diff --git a/internal/core/tools/change/register.go b/internal/core/tools/change/register.go new file mode 100644 index 0000000..793607c --- /dev/null +++ b/internal/core/tools/change/register.go @@ -0,0 +1,25 @@ +package change + +import ( + dockerclient "github.com/rlaope/cloudy/internal/clients/docker" + k8sclient "github.com/rlaope/cloudy/internal/clients/k8s" + "github.com/rlaope/cloudy/internal/core/tools" +) + +// RegisterAll adds the change.recent tool to reg, bound to whichever backends +// are available. A k8s source is added when k8sHub is non-nil; a docker source +// when dockerHub is non-nil. With no source available this is a no-op — the +// wiring layer marks the "change" group skipped instead. +func RegisterAll(reg *tools.Registry, k8sHub *k8sclient.Hub, dockerHub *dockerclient.Hub) { + var sources []ChangeSource + if k8sHub != nil { + sources = append(sources, NewK8sSource(k8sHub)) + } + if dockerHub != nil { + sources = append(sources, NewDockerSource(dockerHub)) + } + if len(sources) == 0 { + return + } + reg.MustRegister(NewRecentTool(sources...)) +} diff --git a/internal/core/tools/change/source.go b/internal/core/tools/change/source.go new file mode 100644 index 0000000..90101c3 --- /dev/null +++ b/internal/core/tools/change/source.go @@ -0,0 +1,65 @@ +// Package change exposes a read-only "what changed recently" capability over +// the workloads cloudy can already inspect. A ChangeSource enumerates recent +// rollout / image / scale / restart events from a single backend (Kubernetes +// or Docker); MergeSorted folds several sources into one newest-first view. +// +// Nothing in this package mutates cluster or host state — sources are built +// only from list/inspect/get reads, in line with cloudy's read-only contract. +package change + +import ( + "context" + "sort" + "time" +) + +// ChangeEvent is a single observed change to a workload, normalised across +// backends. Kind is one of "rollout", "image", "scale", "event", +// "container_restart", or "image_pull". Before/After carry the human-readable +// old/new values for the change (e.g. image tags, replica counts) and may be +// empty when not applicable. Source is "k8s" or "docker". +type ChangeEvent struct { + Time time.Time + Kind string + Target string + Summary string + Before string + After string + Source string +} + +// ChangeQuery narrows a RecentChanges call. Context is the k8s context or the +// docker host name; an empty Context resolves to the backend's default. Since +// bounds how far back to look (zero = backend default), and Limit caps the +// returned events (zero = no cap). +type ChangeQuery struct { + Workload string + Namespace string + Context string + Since time.Duration + Limit int +} + +// ChangeSource is one backend that can report recent changes. Name identifies +// the backend ("k8s" / "docker") for diagnostics and logging. +type ChangeSource interface { + Name() string + RecentChanges(ctx context.Context, q ChangeQuery) ([]ChangeEvent, error) +} + +// MergeSorted concatenates the supplied event groups, sorts them by Time +// descending (newest first), and applies limit when it is greater than zero +// (zero or negative = no cap). The input slices are not modified. +func MergeSorted(limit int, groups ...[]ChangeEvent) []ChangeEvent { + var out []ChangeEvent + for _, g := range groups { + out = append(out, g...) + } + sort.SliceStable(out, func(i, j int) bool { + return out[i].Time.After(out[j].Time) + }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out +} diff --git a/internal/core/tools/change/source_test.go b/internal/core/tools/change/source_test.go new file mode 100644 index 0000000..41ec345 --- /dev/null +++ b/internal/core/tools/change/source_test.go @@ -0,0 +1,55 @@ +package change + +import ( + "testing" + "time" +) + +func TestMergeSorted(t *testing.T) { + base := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + a := []ChangeEvent{ + {Time: base, Kind: "image", Source: "k8s"}, + {Time: base.Add(-2 * time.Hour), Kind: "scale", Source: "k8s"}, + } + b := []ChangeEvent{ + {Time: base.Add(-1 * time.Hour), Kind: "container_restart", Source: "docker"}, + } + + t.Run("sorts newest first across groups", func(t *testing.T) { + got := MergeSorted(0, a, b) + if len(got) != 3 { + t.Fatalf("len = %d, want 3", len(got)) + } + wantKinds := []string{"image", "container_restart", "scale"} + for i, k := range wantKinds { + if got[i].Kind != k { + t.Errorf("got[%d].Kind = %q, want %q", i, got[i].Kind, k) + } + } + }) + + t.Run("applies positive limit", func(t *testing.T) { + got := MergeSorted(2, a, b) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].Kind != "image" || got[1].Kind != "container_restart" { + t.Errorf("limited result = %q/%q, want image/container_restart", got[0].Kind, got[1].Kind) + } + }) + + t.Run("zero limit means no cap", func(t *testing.T) { + if got := MergeSorted(0, a, b); len(got) != 3 { + t.Errorf("len = %d, want 3 (no cap)", len(got)) + } + }) + + t.Run("empty input", func(t *testing.T) { + if got := MergeSorted(0); got != nil { + t.Errorf("MergeSorted() = %v, want nil", got) + } + if got := MergeSorted(5, nil, nil); got != nil { + t.Errorf("MergeSorted(nil, nil) = %v, want nil", got) + } + }) +} diff --git a/internal/core/tools/change/tool.go b/internal/core/tools/change/tool.go new file mode 100644 index 0000000..ef939bb --- /dev/null +++ b/internal/core/tools/change/tool.go @@ -0,0 +1,148 @@ +package change + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/rlaope/cloudy/internal/core/tools" +) + +// defaultSince is used when the caller omits `since` or supplies a value that +// time.ParseDuration cannot parse. +const defaultSince = 24 * time.Hour + +// defaultLimit caps the merged event list when the caller omits `limit`. +const defaultLimit = 50 + +type recentArgs struct { + Workload string `json:"workload"` + Namespace string `json:"namespace"` + Context string `json:"context"` + Since string `json:"since"` + Limit int `json:"limit"` +} + +// recentTool is the imperative implementation behind change.recent. It is not +// built with Spec[Args] because it must advertise RiskLow via the RiskRated +// interface, which Spec's generated tool does not expose. +type recentTool struct { + sources []ChangeSource +} + +// NewRecentTool returns the change.recent tool bound to the supplied sources. +// At least one source is expected; with none, the tool reports that no change +// sources are available. +func NewRecentTool(sources ...ChangeSource) tools.Tool { + return &recentTool{sources: sources} +} + +func (t *recentTool) Name() string { return "change.recent" } + +func (t *recentTool) Description() string { + return "List recent changes (rollouts, image updates, scale events, restarts) for a workload across the available Kubernetes contexts and Docker hosts. Read-only; results are newest-first." +} + +func (t *recentTool) Schema() json.RawMessage { + str := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } + s := map[string]any{ + "type": "object", + "properties": map[string]any{ + "workload": str("Workload to inspect (deployment/statefulset/daemonset name, or container/compose service name). Required."), + "namespace": str("Kubernetes namespace to scope the search; ignored by the Docker source."), + "context": str("kubeconfig context OR Docker host name to query; empty = each backend's default."), + "since": str("How far back to look, as a Go duration (e.g. \"24h\", \"90m\"); default \"24h\"."), + "limit": map[string]any{"type": "integer", "description": "Maximum number of events to return; default 50."}, + }, + "required": []string{"workload"}, + } + b, err := json.Marshal(s) + if err != nil { + panic("change: schema marshal: " + err.Error()) + } + return b +} + +// Risk implements tools.RiskRated. change.recent only performs list/inspect +// reads already classified RiskLow elsewhere. +func (t *recentTool) Risk() tools.RiskLevel { return tools.RiskLow } + +func (t *recentTool) Run(ctx context.Context, raw json.RawMessage) (tools.Observation, error) { + var a recentArgs + if len(raw) > 0 { + if err := json.Unmarshal(raw, &a); err != nil { + return tools.Observation{}, fmt.Errorf("change.recent: parse args: %w", err) + } + } + if a.Workload == "" { + return tools.Observation{Text: "change.recent: workload is required"}, nil + } + + since := defaultSince + if a.Since != "" { + if d, err := time.ParseDuration(a.Since); err == nil { + since = d + } + } + limit := defaultLimit + if a.Limit > 0 { + limit = a.Limit + } + + q := ChangeQuery{ + Workload: a.Workload, + Namespace: a.Namespace, + Context: a.Context, + Since: since, + Limit: limit, + } + + if len(t.sources) == 0 { + return tools.Observation{Text: "change.recent: no change sources available"}, nil + } + + var groups [][]ChangeEvent + var failures []string + for _, src := range t.sources { + events, err := src.RecentChanges(ctx, q) + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", src.Name(), err)) + continue + } + groups = append(groups, events) + } + + // Only error when every source failed; partial success still returns. + if len(groups) == 0 { + return tools.Observation{}, fmt.Errorf("change.recent: all sources failed: %s", strings.Join(failures, "; ")) + } + + merged := MergeSorted(limit, groups...) + return tools.Observation{ + Text: renderRecent(a.Workload, merged, failures), + Raw: merged, + }, nil +} + +// renderRecent formats the merged events as newest-first lines. Each line is +// "RFC3339 | source | kind | target | summary | before→after"; the +// before→after segment is omitted when both are empty. Per-source failures are +// appended as a short note so a partial result is still actionable. +func renderRecent(workload string, events []ChangeEvent, failures []string) string { + var b strings.Builder + fmt.Fprintf(&b, "%d change(s) for %q\n", len(events), workload) + for _, e := range events { + fmt.Fprintf(&b, "%s | %s | %s | %s | %s", + e.Time.UTC().Format(time.RFC3339), e.Source, e.Kind, e.Target, e.Summary) + if e.Before != "" || e.After != "" { + fmt.Fprintf(&b, " | %s→%s", e.Before, e.After) + } + b.WriteByte('\n') + } + if len(failures) > 0 { + fmt.Fprintf(&b, "note: %d source(s) failed: %s\n", len(failures), strings.Join(failures, "; ")) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/wiring/orchestrator.go b/internal/wiring/orchestrator.go index ceedbba..fba5241 100644 --- a/internal/wiring/orchestrator.go +++ b/internal/wiring/orchestrator.go @@ -49,6 +49,7 @@ func Rebuild(cfg config.Config, opts RebuildOpts) (*tools.Registry, error) { NodeInspectors: cfg.NodeInspectors, Alertmanager: cfg.Alertmanager, ArgoCD: cfg.ArgoCD, + DockerHosts: cfg.DockerHosts, }) Replace(reg) return reg, warn diff --git a/internal/wiring/skills_test.go b/internal/wiring/skills_test.go index 6f74f42..e190046 100644 --- a/internal/wiring/skills_test.go +++ b/internal/wiring/skills_test.go @@ -54,6 +54,8 @@ var canonicalToolNames = []string{ "ebpf.biolatency", "ebpf.tcptop", "ebpf.tcprtt", "ebpf.execsnoop", "ebpf.bpftrace_oneliner", + + "change.recent", } // stubTool mirrors the helper in internal/tools/registry_test.go; copied here diff --git a/internal/wiring/tools.go b/internal/wiring/tools.go index edc4c90..f288f51 100644 --- a/internal/wiring/tools.go +++ b/internal/wiring/tools.go @@ -13,11 +13,13 @@ import ( "fmt" "os" + dockerclient "github.com/rlaope/cloudy/internal/clients/docker" k8sclient "github.com/rlaope/cloudy/internal/clients/k8s" promclient "github.com/rlaope/cloudy/internal/clients/prom" "github.com/rlaope/cloudy/internal/config" "github.com/rlaope/cloudy/internal/core/tools" "github.com/rlaope/cloudy/internal/core/tools/alert" + "github.com/rlaope/cloudy/internal/core/tools/change" "github.com/rlaope/cloudy/internal/core/tools/db" "github.com/rlaope/cloudy/internal/core/tools/ebpf" "github.com/rlaope/cloudy/internal/core/tools/gitops" @@ -64,6 +66,8 @@ type Options struct { Alertmanager []config.AlertmanagerEndpoint // ArgoCD is the list of Argo CD API endpoints. ArgoCD []config.ArgoCDEndpoint + // DockerHosts is the list of Docker daemons cloudy may inspect. + DockerHosts []config.DockerHost } // KubeWarning is a non-fatal warning returned by BuildRegistry when the @@ -120,6 +124,19 @@ func BuildRegistry(opts Options) (*tools.Registry, error) { ebpf.RegisterAll(reg) + // change.* spans both k8s and docker. Register when at least one backend + // is available; skip the whole group only when neither is. + dockerHub, dockerErr := buildDockerHub(opts.DockerHosts) + if hub == nil && dockerHub == nil { + reason := "no kubeconfig and no docker hosts configured" + if dockerErr != nil { + reason = fmt.Sprintf("no kubeconfig; docker hosts configured but unavailable: %v", dockerErr) + } + reg.MarkSkipped("change", reason) + } else { + change.RegisterAll(reg, hub, dockerHub) + } + // Single Profile application point: namespace checker on the Hub plus // tool allow/deny filter on the returned registry. reg = permission.Apply(reg, opts.Profile, func(check func(string) error) { @@ -140,6 +157,18 @@ func buildHub(opts Options) (*k8sclient.Hub, error) { return k8sclient.NewHub(opts.KubeconfigPath, contexts) } +// buildDockerHub returns a *dockerclient.Hub for the configured Docker hosts, +// or (nil, nil) when none are configured. A non-nil error means hosts WERE +// configured but the hub could not be built, so callers can report an honest +// skip reason instead of "no docker hosts configured". Client connections are +// built lazily on first use. +func buildDockerHub(hosts []config.DockerHost) (*dockerclient.Hub, error) { + if len(hosts) == 0 { + return nil, nil + } + return dockerclient.NewHub(hosts) +} + // buildPromClients converts a slice of PrometheusEndpoint config entries into // a map of named promclient.Client values, resolving credentials from the // environment. From d9cad3ef771acf328fc61f27e8b09e2f2d1b8619 Mon Sep 17 00:00:00 2001 From: rlaope Date: Thu, 28 May 2026 15:42:38 +0900 Subject: [PATCH 2/2] fix(tools): address change-inquiry review findings - k8s: match ControllerRevisions by owner kind+name so a same-named StatefulSet and DaemonSet no longer duplicate each other's rollout events - k8s: skip the Events field-selector query when the workload contains selector metacharacters instead of silently returning zero events - docker: validate Docker host endpoints eagerly in NewHub (an empty endpoint is a config error, not a silent DOCKER_HOST fallback) - docker: match workloads and image repos by exact name/segment rather than substring, so "api" no longer matches "api-gateway" - docker: drop the misleading digest to tag "image" diff and skip images with an unset creation time Phase 1 of #100. Signed-off-by: rlaope --- internal/clients/docker/hub.go | 12 +- internal/core/tools/change/docker_source.go | 54 +++++--- .../core/tools/change/docker_source_test.go | 32 ++++- internal/core/tools/change/k8s_source.go | 46 ++++--- internal/core/tools/change/k8s_source_test.go | 17 ++- internal/core/tools/change/tool_test.go | 119 ++++++++++++++++++ 6 files changed, 233 insertions(+), 47 deletions(-) create mode 100644 internal/core/tools/change/tool_test.go diff --git a/internal/clients/docker/hub.go b/internal/clients/docker/hub.go index 64fcc84..77588bd 100644 --- a/internal/clients/docker/hub.go +++ b/internal/clients/docker/hub.go @@ -21,9 +21,12 @@ type Hub struct { } // NewHub constructs a Hub from the configured Docker hosts. The first entry is -// treated as the default for calls that omit the host name. Duplicate or -// empty-named entries are skipped. An empty hosts slice yields a Hub whose Get -// always errors "no docker hosts configured" — callers gate on len first. +// treated as the default for calls that omit the host name. Empty-named entries +// are skipped and duplicate names keep the first. A named entry with an empty +// endpoint is a configuration error and returns an error, rather than silently +// falling back to the SDK's DOCKER_HOST default. An empty hosts slice yields a +// Hub whose Get always errors "no docker hosts configured" — callers gate on +// len first. func NewHub(hosts []config.DockerHost) (*Hub, error) { h := &Hub{ hosts: make(map[string]string, len(hosts)), @@ -33,6 +36,9 @@ func NewHub(hosts []config.DockerHost) (*Hub, error) { if dh.Name == "" { continue } + if dh.Host == "" { + return nil, fmt.Errorf("docker: host %q has an empty endpoint", dh.Name) + } if _, dup := h.hosts[dh.Name]; dup { continue } diff --git a/internal/core/tools/change/docker_source.go b/internal/core/tools/change/docker_source.go index 3e3b4dd..c492050 100644 --- a/internal/core/tools/change/docker_source.go +++ b/internal/core/tools/change/docker_source.go @@ -73,21 +73,23 @@ func (s *DockerSource) RecentChanges(ctx context.Context, q ChangeQuery) ([]Chan } // matchesWorkload reports whether a container summary relates to the given -// workload string. An empty workload matches everything. Matching is a -// case-insensitive substring check on container Names and on the -// com.docker.compose.service / com.docker.compose.project labels. +// workload. An empty workload matches everything. Matching is case-insensitive +// and EXACT against the compose service/project labels or a container name — +// substring matching is deliberately avoided so "api" does not match +// "api-gateway". Compose containers carry the service in a label, so querying a +// compose service by name still resolves via the label. func matchesWorkload(s container.Summary, workload string) bool { if workload == "" { return true } wl := strings.ToLower(workload) - for _, n := range s.Names { - if strings.Contains(strings.ToLower(strings.TrimPrefix(n, "/")), wl) { + for _, key := range []string{"com.docker.compose.service", "com.docker.compose.project"} { + if v, ok := s.Labels[key]; ok && strings.ToLower(v) == wl { return true } } - for _, key := range []string{"com.docker.compose.service", "com.docker.compose.project"} { - if v, ok := s.Labels[key]; ok && strings.Contains(strings.ToLower(v), wl) { + for _, n := range s.Names { + if strings.ToLower(strings.TrimPrefix(n, "/")) == wl { return true } } @@ -149,15 +151,20 @@ func containerInspectEvents(insp container.InspectResponse, cutoff time.Time) [] }) } - // image — record the running image at last start time + // image — record the running image at last start time. This is a current + // baseline, not a transition, so Before is left empty (setting it to the + // digest would render a misleading "sha256:…→tag" diff); the resolved + // digest goes in the summary for traceability. if !startedAt.IsZero() && keep(startedAt, cutoff) { - imageDigest := insp.Image // raw digest field on ContainerJSONBase + summary := fmt.Sprintf("container %s running image %s", name, imageName) + if insp.Image != "" && insp.Image != imageName { + summary += fmt.Sprintf(" (%s)", insp.Image) + } events = append(events, ChangeEvent{ Time: startedAt, Kind: "image", Target: name, - Summary: fmt.Sprintf("container %s running image %s", name, imageName), - Before: imageDigest, + Summary: summary, After: imageName, Source: "docker", }) @@ -177,6 +184,11 @@ func imageListEvents(images []image.Summary, workload string, cutoff time.Time) if len(tags) == 0 { continue } + if img.Created <= 0 { + // Unset/invalid creation time (Docker stores seconds since epoch); + // skip rather than emit a 1970 timestamp. + continue + } t := time.Unix(img.Created, 0).UTC() if !keep(t, cutoff) { continue @@ -200,16 +212,28 @@ func imageListEvents(images []image.Summary, workload string, cutoff time.Time) return events } -// matchingTags returns the subset of repoTags that contain the workload -// substring (case-insensitive). An empty workload returns all tags (excluding -// the ":" placeholder). +// matchingTags returns the subset of repoTags whose repository matches workload +// EXACTLY — either the full repository ("registry/org/api") or its last path +// segment ("api"). The ":" placeholder and empty tags are skipped. +// An empty workload returns all real tags. Substring matching is avoided so +// "api" does not match "rapid". workload is expected pre-lowercased. func matchingTags(repoTags []string, workload string) []string { var out []string for _, tag := range repoTags { if tag == ":" || tag == "" { continue } - if workload == "" || strings.Contains(strings.ToLower(tag), workload) { + if workload == "" { + out = append(out, tag) + continue + } + repo := tag + if i := strings.LastIndex(tag, ":"); i >= 0 { + repo = tag[:i] + } + repo = strings.ToLower(repo) + seg := repo[strings.LastIndex(repo, "/")+1:] + if repo == workload || seg == workload { out = append(out, tag) } } diff --git a/internal/core/tools/change/docker_source_test.go b/internal/core/tools/change/docker_source_test.go index 698d7fd..0049969 100644 --- a/internal/core/tools/change/docker_source_test.go +++ b/internal/core/tools/change/docker_source_test.go @@ -63,19 +63,33 @@ func TestParseDockerTime(t *testing.T) { func TestMatchesWorkload(t *testing.T) { s := container.Summary{ - Names: []string{"/myapp-web-1"}, - Labels: map[string]string{"com.docker.compose.service": "web"}, + Names: []string{"/myapp-web-1"}, + Labels: map[string]string{ + "com.docker.compose.service": "web", + "com.docker.compose.project": "myapp", + }, } if !matchesWorkload(s, "") { t.Error("empty workload should match everything") } - if !matchesWorkload(s, "myapp") { - t.Error("should match via container name") - } if !matchesWorkload(s, "web") { t.Error("should match via compose service label") } + if !matchesWorkload(s, "myapp") { + t.Error("should match via compose project label") + } + if !matchesWorkload(s, "myapp-web-1") { + t.Error("should match via exact container name") + } + // Substring matches must NOT trigger (the bug this guards against): + // "web" is the service, so "we" / "eb" / a prefix of the name must miss. + if matchesWorkload(s, "we") { + t.Error("substring 'we' must not match service 'web'") + } + if matchesWorkload(s, "app") { + t.Error("substring 'app' must not match project 'myapp' or name 'myapp-web-1'") + } if matchesWorkload(s, "redis") { t.Error("should not match unrelated workload") } @@ -87,6 +101,14 @@ func TestMatchingTags(t *testing.T) { if len(got) != 2 { t.Fatalf("want 2 nginx tags, got %d: %v", len(got), got) } + // Registry/org-qualified repo matches on its last path segment. + if len(matchingTags([]string{"my.reg/org/api:v2"}, "api")) != 1 { + t.Error("should match repo last segment 'api'") + } + // Substring must NOT match: "ngin" is a prefix of "nginx" but not equal. + if len(matchingTags(tags, "ngin")) != 0 { + t.Error("substring 'ngin' must not match repo 'nginx'") + } // empty workload returns all non-placeholder tags all := matchingTags(tags, "") if len(all) != 3 { diff --git a/internal/core/tools/change/k8s_source.go b/internal/core/tools/change/k8s_source.go index 7d75820..7a64e07 100644 --- a/internal/core/tools/change/k8s_source.go +++ b/internal/core/tools/change/k8s_source.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sort" + "strings" "time" appsv1 "k8s.io/api/apps/v1" @@ -67,7 +68,7 @@ func (s *K8sSource) RecentChanges(ctx context.Context, q ChangeQuery) ([]ChangeE if sets, err := client.StatefulSets(q.Namespace, metav1.ListOptions{}); err == nil { if set := findStatefulSet(sets, q.Workload); set != nil { crList, _ := client.ControllerRevisions(q.Namespace, metav1.ListOptions{}) - groups = append(groups, controllerRevisionEvents(q.Workload, crList)) + groups = append(groups, controllerRevisionEvents("StatefulSet", q.Workload, crList)) groups = append(groups, statefulSetSpecEvents(set)) } } @@ -76,7 +77,7 @@ func (s *K8sSource) RecentChanges(ctx context.Context, q ChangeQuery) ([]ChangeE if sets, err := client.DaemonSets(q.Namespace, metav1.ListOptions{}); err == nil { if set := findDaemonSet(sets, q.Workload); set != nil { crList, _ := client.ControllerRevisions(q.Namespace, metav1.ListOptions{}) - groups = append(groups, controllerRevisionEvents(q.Workload, crList)) + groups = append(groups, controllerRevisionEvents("DaemonSet", q.Workload, crList)) groups = append(groups, daemonSetSpecEvents(set)) } } @@ -87,9 +88,14 @@ func (s *K8sSource) RecentChanges(ctx context.Context, q ChangeQuery) ([]ChangeE } // Events involving the workload object (mirrors the k8s.events selector). - opts := metav1.ListOptions{FieldSelector: fmt.Sprintf("involvedObject.name=%s", q.Workload)} - if evs, err := client.Events(q.Namespace, opts); err == nil { - groups = append(groups, eventEvents(q.Workload, evs)) + // A field-selector value containing ',' or '=' would be parsed as extra + // selector terms, so only query when the workload is a plain name (it + // always is for a real k8s object — guard against malformed input). + if isPlainSelectorValue(q.Workload) { + opts := metav1.ListOptions{FieldSelector: fmt.Sprintf("involvedObject.name=%s", q.Workload)} + if evs, err := client.Events(q.Namespace, opts); err == nil { + groups = append(groups, eventEvents(q.Workload, evs)) + } } merged := MergeSorted(0, groups...) @@ -191,18 +197,20 @@ func deploymentRevisionEvents(dep *appsv1.Deployment, rsList *appsv1.ReplicaSetL } // controllerRevisionEvents reconstructs rollout history from the -// ControllerRevisions owned by the StatefulSet/DaemonSet named workload. Each -// revision emits a "rollout" event at its creation time. ControllerRevision -// pod-template data is opaque (runtime.RawExtension), so no image diff is -// derivable here — Before/After are left empty. -func controllerRevisionEvents(workload string, crList *appsv1.ControllerRevisionList) []ChangeEvent { +// ControllerRevisions owned by the workload of the given kind +// ("StatefulSet"/"DaemonSet") and name. Each revision emits a "rollout" event +// at its creation time. ControllerRevision pod-template data is opaque +// (runtime.RawExtension), so no image diff is derivable here — Before/After are +// left empty. Ownership is matched on both kind and name so a StatefulSet and a +// DaemonSet that share a name do not claim each other's revisions. +func controllerRevisionEvents(kind, workload string, crList *appsv1.ControllerRevisionList) []ChangeEvent { if crList == nil { return nil } var out []ChangeEvent for i := range crList.Items { cr := &crList.Items[i] - if !ownedByName(cr.OwnerReferences, workload) { + if !ownedBy(cr.OwnerReferences, kind, workload) { continue } out = append(out, ChangeEvent{ @@ -364,16 +372,14 @@ func ownedBy(refs []metav1.OwnerReference, kind, name string) bool { return false } -// ownedByName reports whether refs contains any owner with the given name -// (kind-agnostic — used for ControllerRevisions which may be owned by either a -// StatefulSet or a DaemonSet). -func ownedByName(refs []metav1.OwnerReference, name string) bool { - for _, r := range refs { - if r.Name == name { - return true - } +// isPlainSelectorValue reports whether s is safe to interpolate into a +// field-selector value: no ',' or '=' (which start new selector terms) and no +// whitespace. Real Kubernetes object names always satisfy this. +func isPlainSelectorValue(s string) bool { + if s == "" { + return false } - return false + return !strings.ContainsAny(s, ",= \t\n") } // firstContainerImage returns the image of the first container, or "". diff --git a/internal/core/tools/change/k8s_source_test.go b/internal/core/tools/change/k8s_source_test.go index 4d680b5..2297fe3 100644 --- a/internal/core/tools/change/k8s_source_test.go +++ b/internal/core/tools/change/k8s_source_test.go @@ -74,8 +74,8 @@ func TestDeploymentRevisionEvents_OrderingAndImageDiff(t *testing.T) { } // TestControllerRevisionEvents_OwnerFilter verifies StatefulSet/DaemonSet -// revisions are matched by owner name (kind-agnostic) and carry the revision -// number; non-owned revisions are dropped. +// revisions are matched by owner kind+name and carry the revision number; +// non-owned revisions are dropped. func TestControllerRevisionEvents_OwnerFilter(t *testing.T) { base := time.Date(2026, 5, 28, 9, 0, 0, 0, time.UTC) list := &appsv1.ControllerRevisionList{Items: []appsv1.ControllerRevision{ @@ -100,11 +100,20 @@ func TestControllerRevisionEvents_OwnerFilter(t *testing.T) { }, Revision: 1, }, + { + // Same name "kafka" but owned by a DaemonSet — must NOT be claimed + // by the StatefulSet query (kind-aware ownership). + ObjectMeta: metav1.ObjectMeta{ + Name: "kafka-ds-1", CreationTimestamp: ts(base, 3), + OwnerReferences: []metav1.OwnerReference{ownerRef("DaemonSet", "kafka")}, + }, + Revision: 1, + }, }} - got := controllerRevisionEvents("kafka", list) + got := controllerRevisionEvents("StatefulSet", "kafka", list) if len(got) != 2 { - t.Fatalf("len = %d, want 2 (kafka-owned only)", len(got)) + t.Fatalf("len = %d, want 2 (kafka StatefulSet-owned only, not the DaemonSet)", len(got)) } for _, e := range got { if e.Kind != "rollout" || e.Target != "kafka" || e.Source != "k8s" { diff --git a/internal/core/tools/change/tool_test.go b/internal/core/tools/change/tool_test.go new file mode 100644 index 0000000..6fab1c4 --- /dev/null +++ b/internal/core/tools/change/tool_test.go @@ -0,0 +1,119 @@ +package change + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/rlaope/cloudy/internal/core/tools" +) + +// fakeSource is a ChangeSource returning canned events or an error. +type fakeSource struct { + name string + events []ChangeEvent + err error +} + +func (f fakeSource) Name() string { return f.name } + +func (f fakeSource) RecentChanges(_ context.Context, _ ChangeQuery) ([]ChangeEvent, error) { + return f.events, f.err +} + +func runRecent(t *testing.T, tool tools.Tool, args map[string]any) (tools.Observation, error) { + t.Helper() + raw, err := json.Marshal(args) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + return tool.Run(context.Background(), json.RawMessage(raw)) +} + +// TestRecent_PartialFailure: one source errors, the other succeeds. The tool +// must NOT return an error — it returns the working source's events plus a note +// naming the failed source. +func TestRecent_PartialFailure(t *testing.T) { + base := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC) + good := fakeSource{name: "docker", events: []ChangeEvent{ + {Time: base, Kind: "image", Target: "app", Summary: "running", Source: "docker"}, + }} + bad := fakeSource{name: "k8s", err: errors.New("no kubeconfig")} + + tool := NewRecentTool(bad, good) + obs, err := runRecent(t, tool, map[string]any{"workload": "app"}) + if err != nil { + t.Fatalf("partial failure must not error: %v", err) + } + if !strings.Contains(obs.Text, "app") || !strings.Contains(obs.Text, "image") { + t.Errorf("expected the working source's event in output, got:\n%s", obs.Text) + } + if !strings.Contains(obs.Text, "note:") || !strings.Contains(obs.Text, "k8s") { + t.Errorf("expected a failure note naming k8s, got:\n%s", obs.Text) + } +} + +// TestRecent_AllSourcesFail: every source errors → the tool returns an error. +func TestRecent_AllSourcesFail(t *testing.T) { + a := fakeSource{name: "k8s", err: errors.New("boom-k8s")} + b := fakeSource{name: "docker", err: errors.New("boom-docker")} + tool := NewRecentTool(a, b) + _, err := runRecent(t, tool, map[string]any{"workload": "app"}) + if err == nil { + t.Fatal("expected an error when all sources fail") + } + if !strings.Contains(err.Error(), "k8s") || !strings.Contains(err.Error(), "docker") { + t.Errorf("error should name both failed sources, got: %v", err) + } +} + +// TestRecent_WorkloadRequired: missing workload yields a guidance observation, +// not an error. +func TestRecent_WorkloadRequired(t *testing.T) { + tool := NewRecentTool(fakeSource{name: "docker"}) + obs, err := runRecent(t, tool, map[string]any{"limit": 5}) + if err != nil { + t.Fatalf("missing workload should not error: %v", err) + } + if !strings.Contains(obs.Text, "workload is required") { + t.Errorf("expected 'workload is required', got: %s", obs.Text) + } +} + +// TestRecent_NoSources: with no sources configured the tool reports that +// rather than erroring. +func TestRecent_NoSources(t *testing.T) { + tool := NewRecentTool() + obs, err := runRecent(t, tool, map[string]any{"workload": "app"}) + if err != nil { + t.Fatalf("no sources should not error: %v", err) + } + if !strings.Contains(obs.Text, "no change sources") { + t.Errorf("expected 'no change sources' note, got: %s", obs.Text) + } +} + +// TestRecent_MergesNewestFirst: events from two sources are merged newest-first. +func TestRecent_MergesNewestFirst(t *testing.T) { + base := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC) + k8s := fakeSource{name: "k8s", events: []ChangeEvent{ + {Time: base.Add(-2 * time.Hour), Kind: "rollout", Target: "app", Source: "k8s"}, + }} + docker := fakeSource{name: "docker", events: []ChangeEvent{ + {Time: base, Kind: "image", Target: "app", Source: "docker"}, + }} + tool := NewRecentTool(k8s, docker) + obs, err := runRecent(t, tool, map[string]any{"workload": "app"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + lines := strings.Split(strings.TrimSpace(obs.Text), "\n") + // First line is the header; the first event line should be the docker + // "image" event (newest). + if len(lines) < 2 || !strings.Contains(lines[1], "docker") || !strings.Contains(lines[1], "image") { + t.Errorf("expected newest (docker/image) first, got:\n%s", obs.Text) + } +}