diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a2d85f5..6e4a1c3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,12 +28,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "~1.22" + go-version: "~1.24" - name: Test run: go test ./... -race + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "~1.24" + + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.1.6 diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml new file mode 100644 index 0000000..b48f889 --- /dev/null +++ b/.github/workflows/integration.yaml @@ -0,0 +1,46 @@ +name: integration + +on: + push: + branches: + - main + pull_request: + +permissions: + actions: none + checks: none + contents: read + deployments: none + issues: none + packages: none + pull-requests: none + repository-projects: none + security-events: none + statuses: none + +# Cancel in-progress runs for pull requests when developers push +# additional changes +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + integration-test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Setup Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: "~1.24" + + - name: Create KinD cluster + uses: helm/kind-action@92086f6be054225fa813e0a4b13787fc9088faab # v1.13.0 + with: + cluster_name: integration-test + + - name: Run integration tests + run: go test -tags=integration -v -timeout=8m ./... diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index eb8c1e2..c943ad6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Go uses: actions/setup-go@v5 @@ -65,21 +65,23 @@ jobs: gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/${version}.tgz gs://helm.coder.com/logstream-kube gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/index.yaml gs://helm.coder.com/logstream-kube - - name: Create Release - uses: actions/create-release@v1 - id: create_release - env: - GITHUB_TOKEN: ${{ github.token }} - with: - release_name: ${{ steps.version.outputs.version }} - tag_name: ${{ github.ref }} + - name: Create and upload release + run: | + set -euo pipefail + version=${{ steps.version.outputs.version }} + + # check if release already exists and match the version + if [[ $(gh release view $version --json name -q '.name' | cat) == $version ]]; then + echo "Release $version already exists" + exit 0 + fi - - name: Upload Helm Release Asset - uses: actions/upload-release-asset@v1 + echo "Creating release $version" + # if version contains -rc, publish as a pre-release and don't set as latest + if [[ $version == *-rc* ]]; then + gh release create $version -t $version --generate-notes --prerelease --latest=false --verify-tag build/${version}.tgz#helm.tar.gz + else + gh release create $version -t $version --generate-notes --verify-tag build/${version}.tgz#helm.tar.gz + fi env: GITHUB_TOKEN: ${{ github.token }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: build/${{ steps.version.outputs.version }}.tgz - asset_name: helm.tar.gz - asset_content_type: application/gzip diff --git a/.gitignore b/.gitignore index 5278fea..bda1548 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +coder-logstream-kube coder-logstream-kube-* -build \ No newline at end of file +build/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..bec718e --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,29 @@ +# See https://golangci-lint.run/usage/configuration/ +version: "2" + +linters: + enable: + - govet + - errcheck + - staticcheck + - unused + - ineffassign + - misspell + - revive + settings: + govet: + enable-all: true + disable: + - fieldalignment + - shadow + misspell: + locale: US + revive: + rules: + - name: exported + arguments: + - "checkPrivateReceivers" + +formatters: + enable: + - goimports diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0f04106 --- /dev/null +++ b/Makefile @@ -0,0 +1,87 @@ +# Colors for output +GREEN := $(shell printf '\033[32m') +RESET := $(shell printf '\033[0m') +BOLD := $(shell printf '\033[1m') + +# Shell source files - use shfmt to find them (respects .editorconfig) +SHELL_SRC_FILES := $(shell shfmt -f .) + +.PHONY: all +all: build + +.PHONY: build +build: + go build ./... + +.PHONY: test +test: + go test ./... -race + +.PHONY: test/integration +test/integration: + go test -tags=integration -v -timeout=8m ./... + +.PHONY: lint +lint: lint/go lint/shellcheck + +.PHONY: lint/go +lint/go: + golangci-lint run --timeout=5m + +.PHONY: lint/shellcheck +lint/shellcheck: $(SHELL_SRC_FILES) + echo "--- shellcheck" + shellcheck --external-sources $(SHELL_SRC_FILES) + +.PHONY: fmt +fmt: fmt/go fmt/shfmt + +.PHONY: fmt/go +fmt/go: + go fmt ./... + +.PHONY: fmt/shfmt +fmt/shfmt: $(SHELL_SRC_FILES) +ifdef FILE + # Format single shell script + if [[ -f "$(FILE)" ]] && [[ "$(FILE)" == *.sh ]]; then \ + echo "$(GREEN)==>$(RESET) $(BOLD)fmt/shfmt$(RESET) $(FILE)"; \ + shfmt -w "$(FILE)"; \ + fi +else + echo "$(GREEN)==>$(RESET) $(BOLD)fmt/shfmt$(RESET)" +# Only do diff check in CI, errors on diff. +ifdef CI + shfmt -d $(SHELL_SRC_FILES) +else + shfmt -w $(SHELL_SRC_FILES) +endif +endif + +.PHONY: clean +clean: + rm -f coder-logstream-kube + +.PHONY: kind/create +kind/create: + ./scripts/kind-setup.sh create + +.PHONY: kind/delete +kind/delete: + ./scripts/kind-setup.sh delete + +.PHONY: help +help: + @echo "Available targets:" + @echo " build - Build the project" + @echo " test - Run unit tests" + @echo " test/integration - Run integration tests (requires KinD cluster)" + @echo " lint - Run all linters" + @echo " lint/go - Run golangci-lint" + @echo " lint/shellcheck - Run shellcheck on shell scripts" + @echo " fmt - Format all code" + @echo " fmt/go - Format Go code" + @echo " fmt/shfmt - Format shell scripts" + @echo " kind/create - Create KinD cluster for integration tests" + @echo " kind/delete - Delete KinD cluster" + @echo " clean - Remove build artifacts" diff --git a/README.md b/README.md index 2a292fe..6a4f300 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,20 @@ helm install coder-logstream-kube coder-logstream-kube/coder-logstream-kube \ --set url= ``` +> **Multi-Namespace support** +> +> By default, coder-logstream-kube will watch all namespaces in the cluster. To limit which namespaces are monitored, you can specify them in the [values.yaml](helm/values.yaml) file: +> +> ```yaml +> # Watch specific namespaces only +> namespaces: ["default", "kube-system"] +> +> # Watch all namespaces (default) +> namespaces: [] +> ``` +> +> When `namespaces` is empty or not specified, the service will monitor all namespaces in the cluster. + > **Note** > For additional customization (such as customizing the image, pull secrets, annotations, etc.), you can use the > [values.yaml](helm/values.yaml) file directly. @@ -50,3 +64,42 @@ Kubernetes provides an [informers](https://pkg.go.dev/k8s.io/client-go/informers - [`SSL_CERT_FILE`](https://go.dev/src/crypto/x509/root_unix.go#L19): Specifies the path to an SSL certificate. - [`SSL_CERT_DIR`](https://go.dev/src/crypto/x509/root_unix.go#L25): Identifies which directory to check for SSL certificate files. + +## Development + +### Running Tests + +Unit tests can be run with: + +```console +go test ./... -race +``` + +### Integration Tests + +Integration tests run against a real Kubernetes cluster using [KinD (Kubernetes in Docker)](https://kind.sigs.k8s.io/). + +**Prerequisites:** +- [Docker](https://docs.docker.com/get-docker/) +- [KinD](https://kind.sigs.k8s.io/docs/user/quick-start/#installation) +- [kubectl](https://kubernetes.io/docs/tasks/tools/) + +**Setup and run:** + +```console +# Create a KinD cluster +./scripts/kind-setup.sh create + +# Run integration tests +go test -tags=integration -v ./... + +# Clean up when done +./scripts/kind-setup.sh delete +``` + +The integration tests validate: +- Pod event streaming with real Kubernetes informers +- ReplicaSet event handling +- Multi-namespace support +- Label selector filtering + diff --git a/go.mod b/go.mod index baf8734..728ba5f 100644 --- a/go.mod +++ b/go.mod @@ -1,26 +1,24 @@ module github.com/coder/coder-logstream-kube -go 1.22.4 - -toolchain go1.22.5 +go 1.24.0 // Required to import the agentsdk! replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20240702054557-aa558fbe5374 require ( cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 - github.com/breml/rootcerts v0.2.17 + github.com/breml/rootcerts v0.3.1 github.com/coder/coder/v2 v2.10.1-0.20240703121105-f6639b788f7b - github.com/coder/quartz v0.1.0 - github.com/fatih/color v1.17.0 - github.com/go-chi/chi/v5 v5.1.0 + github.com/coder/quartz v0.2.1 + github.com/fatih/color v1.18.0 + github.com/go-chi/chi/v5 v5.2.2 github.com/google/uuid v1.6.0 - github.com/hashicorp/yamux v0.1.1 + github.com/hashicorp/yamux v0.1.2 github.com/spf13/cobra v1.7.0 - github.com/stretchr/testify v1.9.0 - k8s.io/api v0.30.3 - k8s.io/apimachinery v0.30.3 - k8s.io/client-go v0.30.3 + github.com/stretchr/testify v1.10.0 + k8s.io/api v0.33.2 + k8s.io/apimachinery v0.33.2 + k8s.io/client-go v0.33.2 nhooyr.io/websocket v1.8.11 storj.io/drpc v0.0.34 ) @@ -71,16 +69,15 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.6.0-alpha.5 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/evanphx/json-patch v5.6.0+incompatible // indirect - github.com/fxamacker/cbor/v2 v2.6.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-jose/go-jose/v4 v4.0.1 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.8 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.19.0 // indirect @@ -88,12 +85,11 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/btree v1.1.2 // indirect - github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/nftables v0.2.0 // indirect - github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b // indirect + github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 // indirect @@ -109,7 +105,6 @@ require ( github.com/hdevalence/ed25519consensus v0.1.0 // indirect github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect github.com/illarion/gonotify v1.0.1 // indirect - github.com/imdario/mergo v0.3.15 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect @@ -187,17 +182,17 @@ require ( go.uber.org/atomic v1.11.0 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 // indirect - golang.org/x/crypto v0.24.0 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect - golang.org/x/mod v0.18.0 // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/oauth2 v0.20.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.22.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.26.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect @@ -205,18 +200,19 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect google.golang.org/grpc v1.64.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect + google.golang.org/protobuf v1.36.5 // indirect gopkg.in/DataDog/dd-trace-go.v1 v1.64.0 // 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 gopkg.in/yaml.v3 v3.0.1 // indirect gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc // indirect inet.af/peercred v0.0.0-20210906144145-0893ea02156a // indirect - k8s.io/klog/v2 v2.120.1 // indirect - k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect - k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect tailscale.com v1.46.1 // indirect ) diff --git a/go.sum b/go.sum index d247ed3..15e5732 100644 --- a/go.sum +++ b/go.sum @@ -101,8 +101,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= -github.com/breml/rootcerts v0.2.17 h1:0/M2BE2Apw0qEJCXDOkaiu7d5Sx5ObNfe1BkImJ4u1I= -github.com/breml/rootcerts v0.2.17/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= +github.com/breml/rootcerts v0.3.1 h1:PTO35OcW58K2ZYtdBykCsZh9k/eRd57bY65EHrKK/xA= +github.com/breml/rootcerts v0.3.1/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 h1:BjkPE3785EwPhhyuFkbINB+2a1xATwk8SNDWnJiD41g= github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5/go.mod h1:jtAfVaU/2cu1+wdSRPWE2c1N2qeAA3K4RH9pYgqwets= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -122,8 +122,8 @@ github.com/coder/coder/v2 v2.10.1-0.20240703121105-f6639b788f7b h1:c9zXTYVCFW+7A github.com/coder/coder/v2 v2.10.1-0.20240703121105-f6639b788f7b/go.mod h1:Wq39Bje3wvtydrWgGHxngVJFEPSDibZwZeBlW3k1CG8= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= -github.com/coder/quartz v0.1.0 h1:cLL+0g5l7xTf6ordRnUMMiZtRE8Sq5LxpghS63vEXrQ= -github.com/coder/quartz v0.1.0/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA= +github.com/coder/quartz v0.2.1 h1:QgQ2Vc1+mvzewg2uD/nj8MJ9p9gE+QhGJm+Z+NGnrSE= +github.com/coder/quartz v0.2.1/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA= github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc= github.com/coder/retry v1.5.1/go.mod h1:blHMk9vs6LkoRT9ZHyuZo360cufXEhrxqvEzeMtRGoY= github.com/coder/serpent v0.7.0 h1:zGpD2GlF3lKIVkMjNGKbkip88qzd5r/TRcc30X/SrT0= @@ -160,25 +160,23 @@ github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7 github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= 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/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= -github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= -github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= github.com/gliderlabs/ssh v0.3.4 h1:+AXBtim7MTKaLVPgvE+3mhewYRawNLTd+jEEz/wExZw= github.com/gliderlabs/ssh v0.3.4/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914= -github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= -github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.9.0 h1:21A+4WDMDA5FyWcg7mNrhj63aNT8CGh+Z1alOE/piU8= @@ -192,22 +190,22 @@ github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQr github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= -github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw= -github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -216,8 +214,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4= github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -244,15 +242,15 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k= github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -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/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/go-github/v43 v43.0.1-0.20220414155304-00e42332e405 h1:DdHws/YnnPrSywrjNYu2lEHqYHWp/LnEx56w59esd54= github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405/go.mod h1:4RgUDSnsxP19d65zJWqvqJ/poJxBCvmna50eXmIvoR8= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -263,8 +261,8 @@ 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/nftables v0.2.0 h1:PbJwaBmbVLzpeldoeUKGkE2RjstrjPKMl6oLrfEJ6/8= github.com/google/nftables v0.2.0/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= -github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b h1:h9U78+dx9a4BKdQkBBos92HalKpaGKHrp+3Uo6yTodo= -github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -316,8 +314,8 @@ github.com/hashicorp/terraform-registry-address v0.0.0-20220623143253-7d51757b57 github.com/hashicorp/terraform-registry-address v0.0.0-20220623143253-7d51757b572c/go.mod h1:Wn3Na71knbXc1G8Lh+yu/dQWWJeFQEpDeJMtWMtlmNI= github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 h1:HKLsbzeOsfXmKNpr3GiT18XAblV0BjCbzL8KQAMZGa0= github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734/go.mod h1:kNDNcF7sN4DocDLBkQYz73HGKwN1ANB1blq4lIYLYvg= -github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= -github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.1.0 h1:jtBwzzcHuTmFrQN6xQZn6CQEO/V9f7HsjsjeEZ6auqU= github.com/hdevalence/ed25519consensus v0.1.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= @@ -326,8 +324,6 @@ github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJ github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= -github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= -github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= @@ -429,10 +425,10 @@ github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY= -github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM= -github.com/onsi/gomega v1.31.0 h1:54UJxxj6cPInHS3a35wm6BK/F9nHYueZ1NVujHDrnXE= -github.com/onsi/gomega v1.31.0/go.mod h1:DW9aCi7U6Yi40wNVAvT6kzFnEVEI5n3DloYBiKiT6zk= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/open-policy-agent/opa v0.58.0 h1:S5qvevW8JoFizU7Hp66R/Y1SOXol0aCdFYVkzIqIpUo= github.com/open-policy-agent/opa v0.58.0/go.mod h1:EGWBwvmyt50YURNvL8X4W5hXdlKeNhAHn3QXsetmYcc= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= @@ -474,8 +470,8 @@ github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/secure-systems-lab/go-securesystemslib v0.7.0 h1:OwvJ5jQf9LnIAS83waAjPbcMsODrTQUpJ02eNLUoxBg= github.com/secure-systems-lab/go-securesystemslib v0.7.0/go.mod h1:/2gYnlnHVQ6xeGtfIqFy7Do03K4cdCY0A/GlJLDKLHI= @@ -508,8 +504,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= @@ -631,8 +627,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk 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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -640,8 +636,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= -golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -653,10 +649,10 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= -golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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= @@ -664,8 +660,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -693,14 +689,14 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -708,10 +704,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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= @@ -719,8 +715,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= -golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 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= @@ -749,14 +745,16 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/DataDog/dd-trace-go.v1 v1.64.0 h1:zXQo6iv+dKRrDBxMXjRXLSKN2lY9uM34XFI4nPyp0eA= gopkg.in/DataDog/dd-trace-go.v1 v1.64.0/go.mod h1:qzwVu8Qr8CqzQNw2oKEXRdD+fMnjYatjYMGE0tdCVG4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -775,24 +773,27 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= inet.af/peercred v0.0.0-20210906144145-0893ea02156a h1:qdkS8Q5/i10xU2ArJMKYhVa1DORzBfYS/qA2UK2jheg= inet.af/peercred v0.0.0-20210906144145-0893ea02156a/go.mod h1:FjawnflS/udxX+SvpsMgZfdqx2aykOlkISeAsADi5IU= -k8s.io/api v0.30.3 h1:ImHwK9DCsPA9uoU3rVh4QHAHHK5dTSv1nxJUapx8hoQ= -k8s.io/api v0.30.3/go.mod h1:GPc8jlzoe5JG3pb0KJCSLX5oAFIW3/qNJITlDj8BH04= -k8s.io/apimachinery v0.30.3 h1:q1laaWCmrszyQuSQCfNB8cFgCuDAoPszKY4ucAjDwHc= -k8s.io/apimachinery v0.30.3/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= -k8s.io/client-go v0.30.3 h1:bHrJu3xQZNXIi8/MoxYtZBBWQQXwy16zqJwloXXfD3k= -k8s.io/client-go v0.30.3/go.mod h1:8d4pf8vYu665/kUbsxWAQ/JDBNWqfFeZnvFiVdmx89U= -k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= -k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= -k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= -k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= +k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= +k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= +k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= +k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= software.sslmate.com/src/go-pkcs12 v0.2.0 h1:nlFkj7bTysH6VkC4fGphtjXRbezREPgrHuJG20hBGPE= diff --git a/helm/templates/service.yaml b/helm/templates/service.yaml index a010a86..9aee16c 100644 --- a/helm/templates/service.yaml +++ b/helm/templates/service.yaml @@ -1,33 +1,66 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: coder-logstream-kube-role -rules: +{{- define "coder-logstream-kube.rules" -}} - apiGroups: [""] resources: ["pods", "events"] verbs: ["get", "watch", "list"] - apiGroups: ["apps"] resources: ["replicasets", "events"] verbs: ["get", "watch", "list"] +{{- end -}} + +{{- if .Values.namespaces }} +{{- range .Values.namespaces }} --- -apiVersion: v1 -kind: ServiceAccount +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role metadata: - name: {{ .Values.serviceAccount.name | quote }} - annotations: {{ toYaml .Values.serviceAccount.annotations | nindent 4 }} - labels: {{ toYaml .Values.serviceAccount.labels | nindent 4 }} + name: coder-logstream-kube-role + namespace: {{ . }} +rules: +{{ include "coder-logstream-kube.rules" . | nindent 2 }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: coder-logstream-kube-rolebinding + namespace: {{ . }} roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: coder-logstream-kube-role subjects: +- kind: ServiceAccount + name: {{ $.Values.serviceAccount.name | quote }} + namespace: {{ $.Release.Namespace }} +{{- end }} +{{- else }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: coder-logstream-kube-role +rules: +{{ include "coder-logstream-kube.rules" . | nindent 2 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: coder-logstream-kube-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: coder-logstream-kube-role +subjects: - kind: ServiceAccount name: {{ .Values.serviceAccount.name | quote }} + namespace: {{ .Release.Namespace }} +{{- end }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.serviceAccount.name | quote }} + annotations: {{ toYaml .Values.serviceAccount.annotations | nindent 4 }} + labels: {{ toYaml .Values.serviceAccount.labels | nindent 4 }} --- apiVersion: apps/v1 kind: Deployment @@ -65,6 +98,10 @@ spec: nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.podSecurityContext }} + podSecurityContext: + {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: coder-logstream-kube image: "{{ .Values.image.repo }}:{{ .Values.image.tag | default .Chart.AppVersion }}" @@ -75,8 +112,10 @@ spec: env: - name: CODER_URL value: {{ .Values.url }} - - name: CODER_NAMESPACE - value: {{ .Values.namespace | default .Release.Namespace }} + {{- if .Values.namespaces }} + - name: CODER_NAMESPACES + value: {{ join "," .Values.namespaces }} + {{- end }} {{- if .Values.image.sslCertFile }} - name: SSL_CERT_FILE value: {{ .Values.image.sslCertFile }} @@ -85,6 +124,10 @@ spec: - name: SSL_CERT_DIR value: {{ .Values.image.sslCertDir }} {{- end }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} {{- if .Values.volumeMounts }} volumeMounts: {{- toYaml .Values.volumeMounts | nindent 12 }} {{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index 2919afd..daa847f 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -1,9 +1,9 @@ # url -- The URL of your Coder deployment. Must prefix with http or https url: "" -# namespace -- The namespace to searching for Pods within. -# If unspecified, this defaults to the Helm namespace. -namespace: "" +# namespace -- List of namespaces to search for Pods within. +# If unspecified or empty it will watch all namespaces. +namespaces: [] # volumes -- A list of extra volumes to add to the coder-logstream pod. volumes: @@ -89,3 +89,23 @@ tolerations: # labels -- The pod labels for coder-logstream-kube. See: # https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ labels: {} + +# securityContext -- Container-level security context +# See: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ +securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + allowPrivilegeEscalation: false + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # seccompProfile: + # type: RuntimeDefault + +podSecurityContext: {} +# Optional, only if your cluster requires group ownership for mounted volumes: +# podSecurityContext: +# fsGroup: 65532 \ No newline at end of file diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..0e14fba --- /dev/null +++ b/integration_test.go @@ -0,0 +1,514 @@ +//go:build integration + +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" +) + +// getKubeClient creates a Kubernetes client from the default kubeconfig. +// It will use KUBECONFIG env var if set, otherwise ~/.kube/config. +// It also verifies the cluster is a KinD cluster to prevent accidentally +// running tests against production clusters. +func getKubeClient(t *testing.T) kubernetes.Interface { + t.Helper() + + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + home, err := os.UserHomeDir() + require.NoError(t, err, "failed to get user home dir") + kubeconfig = home + "/.kube/config" + } + + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + require.NoError(t, err, "failed to build kubeconfig") + + // Safety check: ensure we're connecting to a KinD cluster. + // KinD clusters run on localhost or have "kind" in the host. + // This prevents accidentally running destructive tests against production clusters. + if config.Host != "" && os.Getenv("INTEGRATION_TEST_UNSAFE") != "1" { + isKind := strings.Contains(config.Host, "127.0.0.1") || + strings.Contains(config.Host, "localhost") || + strings.Contains(strings.ToLower(config.Host), "kind") + if !isKind { + t.Fatalf("Safety check failed: integration tests must run against a KinD cluster. "+ + "Current context points to %q. Set KUBECONFIG to a KinD cluster config or "+ + "set INTEGRATION_TEST_UNSAFE=1 to bypass this check.", config.Host) + } + } + + client, err := kubernetes.NewForConfig(config) + require.NoError(t, err, "failed to create kubernetes client") + + return client +} + +// createTestNamespace creates a unique namespace for test isolation. +// It registers cleanup to delete the namespace after the test. +func createTestNamespace(t *testing.T, ctx context.Context, client kubernetes.Interface) string { + t.Helper() + + name := fmt.Sprintf("logstream-test-%d", time.Now().UnixNano()) + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + + _, err := client.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + require.NoError(t, err, "failed to create test namespace") + + t.Cleanup(func() { + // Use a fresh context for cleanup in case the test context is cancelled + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := client.CoreV1().Namespaces().Delete(cleanupCtx, name, metav1.DeleteOptions{}) + if err != nil { + t.Logf("warning: failed to delete test namespace %s: %v", name, err) + } + }) + + return name +} + +// waitForLogContaining waits until a log containing the given substring is received. +// It collects all logs seen and returns them along with whether the target was found. +func waitForLogContaining(t *testing.T, ctx context.Context, api *fakeAgentAPI, timeout time.Duration, substring string) (allLogs []string, found bool) { + t.Helper() + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for { + select { + case logs := <-api.logs: + for _, log := range logs { + allLogs = append(allLogs, log.Output) + if strings.Contains(log.Output, substring) { + return allLogs, true + } + } + case <-timeoutCtx.Done(): + return allLogs, false + } + } +} + +// waitForLogSource waits for log source registration with a timeout. +func waitForLogSource(t *testing.T, ctx context.Context, api *fakeAgentAPI, timeout time.Duration) { + t.Helper() + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + select { + case <-api.logSource: + return + case <-timeoutCtx.Done(): + t.Fatal("timeout waiting for log source registration") + } +} + +func TestIntegration_PodEvents(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + client := getKubeClient(t) + namespace := createTestNamespace(t, ctx, client) + + // Start fake Coder API server + api := newFakeAgentAPI(t) + defer api.server.Close() + + agentURL, err := url.Parse(api.server.URL) + require.NoError(t, err) + + // Create the pod event logger + // Note: We don't set clock, so it uses a real clock for integration tests + reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{ + client: client, + coderURL: agentURL, + namespaces: []string{namespace}, + logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + logDebounce: 5 * time.Second, // Use shorter debounce for faster tests + }) + require.NoError(t, err) + defer reporter.Close() + + // Wait a bit for informers to sync + time.Sleep(1 * time.Second) + + // Create a pod with CODER_AGENT_TOKEN + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: namespace, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Image: "busybox:latest", + Command: []string{"sleep", "3600"}, + Env: []corev1.EnvVar{ + { + Name: "CODER_AGENT_TOKEN", + Value: "test-token-integration", + }, + }, + }, + }, + // Use a non-existent node to keep the pod in Pending state + // This avoids needing to actually run the container + NodeSelector: map[string]string{ + "non-existent-label": "non-existent-value", + }, + }, + } + + _, err = client.CoreV1().Pods(namespace).Create(ctx, pod, metav1.CreateOptions{}) + require.NoError(t, err) + + // Wait for log source registration + waitForLogSource(t, ctx, api, 30*time.Second) + + // Wait for the "Created pod" log (may receive other logs first like scheduling warnings) + logs, found := waitForLogContaining(t, ctx, api, 30*time.Second, "Created pod") + require.True(t, found, "expected 'Created pod' log, got: %v", logs) + + // Delete the pod and verify deletion event + err = client.CoreV1().Pods(namespace).Delete(ctx, pod.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + + // Wait for the "Deleted pod" log + logs, found = waitForLogContaining(t, ctx, api, 30*time.Second, "Deleted pod") + require.True(t, found, "expected 'Deleted pod' log, got: %v", logs) +} + +func TestIntegration_ReplicaSetEvents(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + client := getKubeClient(t) + namespace := createTestNamespace(t, ctx, client) + + // Start fake Coder API server + api := newFakeAgentAPI(t) + defer api.server.Close() + + agentURL, err := url.Parse(api.server.URL) + require.NoError(t, err) + + // Create the pod event logger + // Note: We don't set clock, so it uses a real clock for integration tests + reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{ + client: client, + coderURL: agentURL, + namespaces: []string{namespace}, + logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + logDebounce: 5 * time.Second, // Use shorter debounce for faster tests + }) + require.NoError(t, err) + defer reporter.Close() + + // Wait a bit for informers to sync + time.Sleep(1 * time.Second) + + // Create a ReplicaSet with CODER_AGENT_TOKEN + replicas := int32(1) + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: namespace, + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "test-rs", + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "test-rs", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Image: "busybox:latest", + Command: []string{"sleep", "3600"}, + Env: []corev1.EnvVar{ + { + Name: "CODER_AGENT_TOKEN", + Value: "test-token-rs-integration", + }, + }, + }, + }, + // Use a non-existent node to keep pods in Pending state + NodeSelector: map[string]string{ + "non-existent-label": "non-existent-value", + }, + }, + }, + }, + } + + _, err = client.AppsV1().ReplicaSets(namespace).Create(ctx, rs, metav1.CreateOptions{}) + require.NoError(t, err) + + // Wait for log source registration + waitForLogSource(t, ctx, api, 30*time.Second) + + // Wait for the "Queued pod from ReplicaSet" log + logs, found := waitForLogContaining(t, ctx, api, 30*time.Second, "Queued pod from ReplicaSet") + require.True(t, found, "expected 'Queued pod from ReplicaSet' log, got: %v", logs) + + // Delete the ReplicaSet + err = client.AppsV1().ReplicaSets(namespace).Delete(ctx, rs.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + + // Wait for the "Deleted ReplicaSet" log + logs, found = waitForLogContaining(t, ctx, api, 30*time.Second, "Deleted ReplicaSet") + require.True(t, found, "expected 'Deleted ReplicaSet' log, got: %v", logs) +} + +func TestIntegration_MultiNamespace(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + client := getKubeClient(t) + + // Create two namespaces + namespace1 := createTestNamespace(t, ctx, client) + namespace2 := createTestNamespace(t, ctx, client) + + // Start fake Coder API server + api := newFakeAgentAPI(t) + defer api.server.Close() + + agentURL, err := url.Parse(api.server.URL) + require.NoError(t, err) + + // Create the pod event logger watching both namespaces + // Note: We don't set clock, so it uses a real clock for integration tests + reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{ + client: client, + coderURL: agentURL, + namespaces: []string{namespace1, namespace2}, + logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + logDebounce: 5 * time.Second, // Use shorter debounce for faster tests + }) + require.NoError(t, err) + defer reporter.Close() + + // Wait for informers to sync + time.Sleep(1 * time.Second) + + // Create a pod in namespace1 + pod1 := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod-ns1", + Namespace: namespace1, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Image: "busybox:latest", + Command: []string{"sleep", "3600"}, + Env: []corev1.EnvVar{ + { + Name: "CODER_AGENT_TOKEN", + Value: "test-token-ns1", + }, + }, + }, + }, + NodeSelector: map[string]string{ + "non-existent-label": "non-existent-value", + }, + }, + } + + _, err = client.CoreV1().Pods(namespace1).Create(ctx, pod1, metav1.CreateOptions{}) + require.NoError(t, err) + + // Wait for log source and logs from first pod + waitForLogSource(t, ctx, api, 30*time.Second) + logs, found := waitForLogContaining(t, ctx, api, 30*time.Second, "Created pod") + require.True(t, found, "expected 'Created pod' log for first pod, got: %v", logs) + + // Create a pod in namespace2 + pod2 := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod-ns2", + Namespace: namespace2, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Image: "busybox:latest", + Command: []string{"sleep", "3600"}, + Env: []corev1.EnvVar{ + { + Name: "CODER_AGENT_TOKEN", + Value: "test-token-ns2", + }, + }, + }, + }, + NodeSelector: map[string]string{ + "non-existent-label": "non-existent-value", + }, + }, + } + + _, err = client.CoreV1().Pods(namespace2).Create(ctx, pod2, metav1.CreateOptions{}) + require.NoError(t, err) + + // Wait for log source and logs from second pod + waitForLogSource(t, ctx, api, 30*time.Second) + logs, found = waitForLogContaining(t, ctx, api, 30*time.Second, "Created pod") + require.True(t, found, "expected 'Created pod' log for second pod, got: %v", logs) + + // Both namespaces should have received events + t.Log("Successfully received events from both namespaces") +} + +func TestIntegration_LabelSelector(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + client := getKubeClient(t) + namespace := createTestNamespace(t, ctx, client) + + // Start fake Coder API server + api := newFakeAgentAPI(t) + defer api.server.Close() + + agentURL, err := url.Parse(api.server.URL) + require.NoError(t, err) + + // Create the pod event logger with a label selector + // Note: We don't set clock, so it uses a real clock for integration tests + reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{ + client: client, + coderURL: agentURL, + namespaces: []string{namespace}, + labelSelector: "coder-workspace=true", + logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + logDebounce: 5 * time.Second, // Use shorter debounce for faster tests + }) + require.NoError(t, err) + defer reporter.Close() + + // Wait for informers to sync + time.Sleep(1 * time.Second) + + // Create a pod WITHOUT the matching label - should be ignored + podNoLabel := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod-no-label", + Namespace: namespace, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Image: "busybox:latest", + Command: []string{"sleep", "3600"}, + Env: []corev1.EnvVar{ + { + Name: "CODER_AGENT_TOKEN", + Value: "test-token-no-label", + }, + }, + }, + }, + NodeSelector: map[string]string{ + "non-existent-label": "non-existent-value", + }, + }, + } + + _, err = client.CoreV1().Pods(namespace).Create(ctx, podNoLabel, metav1.CreateOptions{}) + require.NoError(t, err) + + // Wait a bit to ensure the pod without label is not picked up + time.Sleep(2 * time.Second) + + // Create a pod WITH the matching label - should be tracked + podWithLabel := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod-with-label", + Namespace: namespace, + Labels: map[string]string{ + "coder-workspace": "true", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Image: "busybox:latest", + Command: []string{"sleep", "3600"}, + Env: []corev1.EnvVar{ + { + Name: "CODER_AGENT_TOKEN", + Value: "test-token-with-label", + }, + }, + }, + }, + NodeSelector: map[string]string{ + "non-existent-label": "non-existent-value", + }, + }, + } + + _, err = client.CoreV1().Pods(namespace).Create(ctx, podWithLabel, metav1.CreateOptions{}) + require.NoError(t, err) + + // Wait for log source registration - this should only happen for the labeled pod + waitForLogSource(t, ctx, api, 30*time.Second) + + // Wait for logs - look specifically for "Created pod" with the labeled pod name + logs, found := waitForLogContaining(t, ctx, api, 30*time.Second, "Created pod") + require.True(t, found, "expected 'Created pod' log for labeled pod, got: %v", logs) + + // Verify that none of the logs mention the unlabeled pod + for _, log := range logs { + require.NotContains(t, log, "test-pod-no-label", "should not receive logs for unlabeled pod") + } +} diff --git a/logger.go b/logger.go index 231d01b..0e5c29f 100644 --- a/logger.go +++ b/logger.go @@ -1,3 +1,5 @@ +// Package main implements coder-logstream-kube, a Kubernetes controller +// that streams pod logs to the Coder agent API. package main import ( @@ -34,9 +36,11 @@ type podEventLoggerOptions struct { logger slog.Logger logDebounce time.Duration + // maxRetries is the maximum number of retries for a log send failure. + maxRetries int // The following fields are optional! - namespace string + namespaces []string fieldSelector string labelSelector string } @@ -52,6 +56,10 @@ func newPodEventLogger(ctx context.Context, opts podEventLoggerOptions) (*podEve opts.clock = quartz.NewReal() } + if opts.maxRetries == 0 { + opts.maxRetries = 10 + } + logCh := make(chan agentLog, 512) ctx, cancelFunc := context.WithCancel(ctx) reporter := &podEventLogger{ @@ -75,10 +83,24 @@ func newPodEventLogger(ctx context.Context, opts podEventLoggerOptions) (*podEve logCache: logCache{ logs: map[string][]agentsdk.Log{}, }, + maxRetries: opts.maxRetries, }, } - return reporter, reporter.init() + // If no namespaces are provided, we listen for events in all namespaces. + if len(opts.namespaces) == 0 { + if err := reporter.initNamespace(""); err != nil { + return nil, fmt.Errorf("init namespace: %w", err) + } + } else { + for _, namespace := range opts.namespaces { + if err := reporter.initNamespace(namespace); err != nil { + return nil, err + } + } + } + + return reporter, nil } type podEventLogger struct { @@ -95,22 +117,23 @@ type podEventLogger struct { lq *logQueuer } -// init starts the informer factory and registers event handlers. -func (p *podEventLogger) init() error { +// initNamespace starts the informer factory and registers event handlers for a given namespace. +// If provided namespace is empty, it will start the informer factory and register event handlers for all namespaces. +func (p *podEventLogger) initNamespace(namespace string) error { // We only track events that happen after the reporter starts. // This is to prevent us from sending duplicate events. startTime := time.Now() go p.lq.work(p.ctx) - podFactory := informers.NewSharedInformerFactoryWithOptions(p.client, 0, informers.WithNamespace(p.namespace), informers.WithTweakListOptions(func(lo *v1.ListOptions) { + podFactory := informers.NewSharedInformerFactoryWithOptions(p.client, 0, informers.WithNamespace(namespace), informers.WithTweakListOptions(func(lo *v1.ListOptions) { lo.FieldSelector = p.fieldSelector lo.LabelSelector = p.labelSelector })) eventFactory := podFactory if p.fieldSelector != "" || p.labelSelector != "" { // Events cannot filter on labels and fields! - eventFactory = informers.NewSharedInformerFactoryWithOptions(p.client, 0, informers.WithNamespace(p.namespace)) + eventFactory = informers.NewSharedInformerFactoryWithOptions(p.client, 0, informers.WithNamespace(namespace)) } // We listen for Pods and Events in the informer factory. @@ -277,7 +300,7 @@ func (p *podEventLogger) init() error { p.logger.Info(p.ctx, "listening for pod events", slog.F("coder_url", p.coderURL.String()), - slog.F("namespace", p.namespace), + slog.F("namespace", namespace), slog.F("field_selector", p.fieldSelector), slog.F("label_selector", p.labelSelector), ) @@ -310,6 +333,7 @@ func (p *podEventLogger) sendDelete(token string) { } } +// Close stops the pod event logger and releases all resources. func (p *podEventLogger) Close() error { p.cancelFunc() close(p.stopChan) @@ -395,6 +419,11 @@ type logQueuer struct { loggerTTL time.Duration loggers map[string]agentLoggerLifecycle logCache logCache + + // retries maps agent tokens to their retry state for exponential backoff + retries map[string]*retryState + // maxRetries is the maximum number of retries for a log send failure. + maxRetries int } func (l *logQueuer) work(ctx context.Context) { @@ -415,87 +444,119 @@ func (l *logQueuer) work(ctx context.Context) { } } +func (l *logQueuer) newLogger(ctx context.Context, log agentLog) (agentLoggerLifecycle, error) { + client := agentsdk.New(l.coderURL) + client.SetSessionToken(log.agentToken) + logger := l.logger.With(slog.F("resource_name", log.resourceName)) + client.SDK.SetLogger(logger) + + _, err := client.PostLogSource(ctx, agentsdk.PostLogSourceRequest{ + ID: sourceUUID, + Icon: "/icon/k8s.png", + DisplayName: "Kubernetes", + }) + if err != nil { + // Posting the log source failed, which affects how logs appear. + // We'll retry to ensure the log source is properly registered. + logger.Error(ctx, "post log source", slog.Error(err)) + return agentLoggerLifecycle{}, err + } + + ls := agentsdk.NewLogSender(logger) + sl := ls.GetScriptLogger(sourceUUID) + + gracefulCtx, gracefulCancel := context.WithCancel(context.Background()) + + // connect to Agent v2.0 API, since we don't need features added later. + // This maximizes compatibility. + arpc, err := client.ConnectRPC20(gracefulCtx) + if err != nil { + logger.Error(ctx, "drpc connect", slog.Error(err)) + gracefulCancel() + return agentLoggerLifecycle{}, err + } + go func() { + err := ls.SendLoop(gracefulCtx, arpc) + // if the send loop exits on its own without the context + // canceling, timeout the logger and force it to recreate. + if err != nil && ctx.Err() == nil { + l.loggerTimeout(log.agentToken) + } + }() + + closeTimer := l.clock.AfterFunc(l.loggerTTL, func() { + logger.Info(ctx, "logger timeout firing") + l.loggerTimeout(log.agentToken) + }) + lifecycle := agentLoggerLifecycle{ + scriptLogger: sl, + close: func() { + defer func() { + _ = arpc.DRPCConn().Close() + }() + defer client.SDK.HTTPClient.CloseIdleConnections() + // We could be stopping for reasons other than the timeout. If + // so, stop the timer. + closeTimer.Stop() + defer gracefulCancel() + timeout := l.clock.AfterFunc(5*time.Second, gracefulCancel) + defer timeout.Stop() + logger.Info(ctx, "logger closing") + + if err := sl.Flush(gracefulCtx); err != nil { + // ctx err + logger.Warn(gracefulCtx, "timeout reached while flushing") + return + } + + if err := ls.WaitUntilEmpty(gracefulCtx); err != nil { + // ctx err + logger.Warn(gracefulCtx, "timeout reached while waiting for log queue to empty") + } + }, + } + lifecycle.closeTimer = closeTimer + return lifecycle, nil +} + func (l *logQueuer) processLog(ctx context.Context, log agentLog) { l.mu.Lock() defer l.mu.Unlock() - queuedLogs := l.logCache.push(log) + + queuedLogs := l.logCache.get(log.agentToken) + if isAgentLogEmpty(log) { + if queuedLogs == nil { + return + } + } else { + queuedLogs = l.logCache.push(log) + } + lgr, ok := l.loggers[log.agentToken] if !ok { - client := agentsdk.New(l.coderURL) - client.SetSessionToken(log.agentToken) - logger := l.logger.With(slog.F("resource_name", log.resourceName)) - client.SDK.SetLogger(logger) - - _, err := client.PostLogSource(ctx, agentsdk.PostLogSourceRequest{ - ID: sourceUUID, - Icon: "/icon/k8s.png", - DisplayName: "Kubernetes", - }) - if err != nil { - // This shouldn't fail sending the log, as it only affects how they - // appear. - logger.Error(ctx, "post log source", slog.Error(err)) + // skip if we're in a retry cooldown window + if rs := l.retries[log.agentToken]; rs != nil && rs.timer != nil { + return } - ls := agentsdk.NewLogSender(logger) - sl := ls.GetScriptLogger(sourceUUID) - - gracefulCtx, gracefulCancel := context.WithCancel(context.Background()) - - // connect to Agent v2.0 API, since we don't need features added later. - // This maximizes compatibility. - arpc, err := client.ConnectRPC20(gracefulCtx) + var err error + lgr, err = l.newLogger(ctx, log) if err != nil { - logger.Error(ctx, "drpc connect", slog.Error(err)) - gracefulCancel() + l.scheduleRetry(ctx, log.agentToken) return } - go func() { - err := ls.SendLoop(gracefulCtx, arpc) - // if the send loop exits on its own without the context - // canceling, timeout the logger and force it to recreate. - if err != nil && ctx.Err() == nil { - l.loggerTimeout(log.agentToken) - } - }() - - closeTimer := l.clock.AfterFunc(l.loggerTTL, func() { - logger.Info(ctx, "logger timeout firing") - l.loggerTimeout(log.agentToken) - }) - lifecycle := agentLoggerLifecycle{ - scriptLogger: sl, - close: func() { - // We could be stopping for reasons other than the timeout. If - // so, stop the timer. - closeTimer.Stop() - defer gracefulCancel() - timeout := l.clock.AfterFunc(5*time.Second, gracefulCancel) - defer timeout.Stop() - logger.Info(ctx, "logger closing") - - if err := sl.Flush(gracefulCtx); err != nil { - // ctx err - logger.Warn(gracefulCtx, "timeout reached while flushing") - return - } - - if err := ls.WaitUntilEmpty(gracefulCtx); err != nil { - // ctx err - logger.Warn(gracefulCtx, "timeout reached while waiting for log queue to empty") - } - - _ = arpc.DRPCConn().Close() - client.SDK.HTTPClient.CloseIdleConnections() - }, - } - lifecycle.closeTimer = closeTimer - l.loggers[log.agentToken] = lifecycle - lgr = lifecycle + l.loggers[log.agentToken] = lgr } lgr.resetCloseTimer(l.loggerTTL) - _ = lgr.scriptLogger.Send(ctx, queuedLogs...) + if len(queuedLogs) == 0 { + return + } + if err := lgr.scriptLogger.Send(ctx, queuedLogs...); err != nil { + l.scheduleRetry(ctx, log.agentToken) + return + } + l.clearRetryLocked(log.agentToken) l.logCache.delete(log.agentToken) } @@ -504,8 +565,9 @@ func (l *logQueuer) processDelete(log agentLog) { lgr, ok := l.loggers[log.agentToken] if ok { delete(l.loggers, log.agentToken) - } + l.clearRetryLocked(log.agentToken) + l.logCache.delete(log.agentToken) l.mu.Unlock() if ok { @@ -537,6 +599,81 @@ func (l *agentLoggerLifecycle) resetCloseTimer(ttl time.Duration) { } } +// retryState tracks exponential backoff for an agent token. +type retryState struct { + delay time.Duration + timer *quartz.Timer + retryCount int + exhausted bool // prevent retry state recreation after max retries +} + +func (l *logQueuer) scheduleRetry(ctx context.Context, token string) { + if l.retries == nil { + l.retries = make(map[string]*retryState) + } + + rs := l.retries[token] + + if rs != nil && rs.exhausted { + return + } + + if rs == nil { + rs = &retryState{delay: time.Second, retryCount: 0, exhausted: false} + l.retries[token] = rs + } + + rs.retryCount++ + + // If we've reached the max retries, clear the retry state and delete the log cache. + if rs.retryCount >= l.maxRetries { + l.logger.Error(ctx, "max retries exceeded", + slog.F("retryCount", rs.retryCount), + slog.F("maxRetries", l.maxRetries)) + rs.exhausted = true + if rs.timer != nil { + rs.timer.Stop() + rs.timer = nil + } + l.logCache.delete(token) + return + } + + if rs.timer != nil { + return + } + + l.logger.Info(ctx, "scheduling retry", + slog.F("delay", rs.delay.String()), + slog.F("retryCount", rs.retryCount)) + + rs.timer = l.clock.AfterFunc(rs.delay, func() { + l.mu.Lock() + defer l.mu.Unlock() + + if cur := l.retries[token]; cur != nil && !cur.exhausted { + cur.timer = nil + l.q <- agentLog{op: opLog, agentToken: token} + } + }) + + rs.delay *= 2 + if rs.delay > 30*time.Second { + rs.delay = 30 * time.Second + } +} + +// clearRetryLocked clears the retry state for the given token. +// The caller must hold the mutex lock. +func (l *logQueuer) clearRetryLocked(token string) { + if rs := l.retries[token]; rs != nil { + if rs.timer != nil { + rs.timer.Stop() + } + delete(l.retries, token) + } +} + func newColor(value ...color.Attribute) *color.Color { c := color.New(value...) c.EnableColor() @@ -560,3 +697,15 @@ func (l *logCache) push(log agentLog) []agentsdk.Log { func (l *logCache) delete(token string) { delete(l.logs, token) } + +func (l *logCache) get(token string) []agentsdk.Log { + logs, ok := l.logs[token] + if !ok { + return nil + } + return logs +} + +func isAgentLogEmpty(log agentLog) bool { + return log.resourceName == "" && log.log.Output == "" && log.log.CreatedAt.IsZero() +} diff --git a/logger_test.go b/logger_test.go index 51d99f5..49a1836 100644 --- a/logger_test.go +++ b/logger_test.go @@ -47,7 +47,7 @@ func TestReplicaSetEvents(t *testing.T) { reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{ client: client, coderURL: agentURL, - namespace: namespace, + namespaces: []string{namespace}, logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), logDebounce: 5 * time.Second, clock: cMock, @@ -144,7 +144,7 @@ func TestPodEvents(t *testing.T) { reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{ client: client, coderURL: agentURL, - namespace: namespace, + namespaces: []string{namespace}, logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), logDebounce: 5 * time.Second, clock: cMock, @@ -221,6 +221,153 @@ func TestPodEvents(t *testing.T) { require.NoError(t, err) } +func Test_newPodEventLogger_multipleNamespaces(t *testing.T) { + t.Parallel() + + api := newFakeAgentAPI(t) + + ctx := testutil.Context(t, testutil.WaitShort) + agentURL, err := url.Parse(api.server.URL) + require.NoError(t, err) + namespaces := []string{"test-namespace1", "test-namespace2"} + client := fake.NewSimpleClientset() + + cMock := quartz.NewMock(t) + reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{ + client: client, + coderURL: agentURL, + namespaces: namespaces, + logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + logDebounce: 5 * time.Second, + clock: cMock, + }) + require.NoError(t, err) + + // Create a pod in the test-namespace1 namespace + pod1 := &corev1.Pod{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-pod-1", + Namespace: "test-namespace1", + CreationTimestamp: v1.Time{ + Time: time.Now().Add(time.Hour), + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Env: []corev1.EnvVar{ + { + Name: "CODER_AGENT_TOKEN", + Value: "test-token-1", + }, + }, + }, + }, + }, + } + _, err = client.CoreV1().Pods("test-namespace1").Create(ctx, pod1, v1.CreateOptions{}) + require.NoError(t, err) + + // Create a pod in the test-namespace2 namespace + pod2 := &corev1.Pod{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-pod-2", + Namespace: "test-namespace2", + CreationTimestamp: v1.Time{ + Time: time.Now().Add(time.Hour), + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Env: []corev1.EnvVar{ + { + Name: "CODER_AGENT_TOKEN", + Value: "test-token-2", + }, + }, + }, + }, + }, + } + _, err = client.CoreV1().Pods("test-namespace2").Create(ctx, pod2, v1.CreateOptions{}) + require.NoError(t, err) + + // Wait for both pods to be registered + source1 := testutil.RequireRecvCtx(ctx, t, api.logSource) + require.Equal(t, sourceUUID, source1.ID) + require.Equal(t, "Kubernetes", source1.DisplayName) + require.Equal(t, "/icon/k8s.png", source1.Icon) + + source2 := testutil.RequireRecvCtx(ctx, t, api.logSource) + require.Equal(t, sourceUUID, source2.ID) + require.Equal(t, "Kubernetes", source2.DisplayName) + require.Equal(t, "/icon/k8s.png", source2.Icon) + + // Wait for both creation logs + logs1 := testutil.RequireRecvCtx(ctx, t, api.logs) + require.Len(t, logs1, 1) + require.Contains(t, logs1[0].Output, "Created pod") + + logs2 := testutil.RequireRecvCtx(ctx, t, api.logs) + require.Len(t, logs2, 1) + require.Contains(t, logs2[0].Output, "Created pod") + + // Create an event in the first namespace + event1 := &corev1.Event{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-event-1", + Namespace: "test-namespace1", + CreationTimestamp: v1.Time{ + Time: time.Now().Add(time.Hour), + }, + }, + InvolvedObject: corev1.ObjectReference{ + Kind: "Pod", + Name: "test-pod-1", + Namespace: "test-namespace1", + }, + Reason: "Test", + Message: "Test event for namespace1", + } + _, err = client.CoreV1().Events("test-namespace1").Create(ctx, event1, v1.CreateOptions{}) + require.NoError(t, err) + + // Wait for the event log + eventLogs := testutil.RequireRecvCtx(ctx, t, api.logs) + require.Len(t, eventLogs, 1) + require.Contains(t, eventLogs[0].Output, "Test event for namespace1") + + // Create an event in the first namespace + event2 := &corev1.Event{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-event-2", + Namespace: "test-namespace2", + CreationTimestamp: v1.Time{ + Time: time.Now().Add(time.Hour), + }, + }, + InvolvedObject: corev1.ObjectReference{ + Kind: "Pod", + Name: "test-pod-2", + Namespace: "test-namespace2", + }, + Reason: "Test", + Message: "Test event for namespace2", + } + _, err = client.CoreV1().Events("test-namespace2").Create(ctx, event2, v1.CreateOptions{}) + require.NoError(t, err) + + // Wait for the event log + eventLogs2 := testutil.RequireRecvCtx(ctx, t, api.logs) + require.Len(t, eventLogs2, 1) + require.Contains(t, eventLogs2[0].Output, "Test event for namespace2") + + // Clean up + err = reporter.Close() + require.NoError(t, err) +} + func Test_tokenCache(t *testing.T) { t.Parallel() @@ -339,6 +486,471 @@ func Test_logQueuer(t *testing.T) { // wait for the client to disconnect _ = testutil.RequireRecvCtx(ctx, t, api.disconnect) }) + + t.Run("RetryMechanism", func(t *testing.T) { + t.Parallel() + + // Create a failing API that will reject connections + failingAPI := newFailingAgentAPI(t) + agentURL, err := url.Parse(failingAPI.server.URL) + require.NoError(t, err) + clock := quartz.NewMock(t) + ttl := time.Second + + ch := make(chan agentLog, 10) + logger := slogtest.Make(t, &slogtest.Options{ + IgnoreErrors: true, + }) + lq := &logQueuer{ + logger: logger, + clock: clock, + q: ch, + coderURL: agentURL, + loggerTTL: ttl, + loggers: map[string]agentLoggerLifecycle{}, + logCache: logCache{ + logs: map[string][]agentsdk.Log{}, + }, + maxRetries: 10, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + go lq.work(ctx) + + token := "retry-token" + ch <- agentLog{ + op: opLog, + resourceName: "hello", + agentToken: token, + log: agentsdk.Log{ + CreatedAt: time.Now(), + Output: "This is a log.", + Level: codersdk.LogLevelInfo, + }, + } + + // Wait for the initial failure to be processed and retry state to be created + require.Eventually(t, func() bool { + lq.mu.Lock() + defer lq.mu.Unlock() + rs := lq.retries[token] + return rs != nil && rs.timer != nil && rs.delay == 2*time.Second + }, testutil.WaitShort, testutil.IntervalFast) + + // Verify retry state exists and has correct doubled delay (it gets doubled after scheduling) + lq.mu.Lock() + rs := lq.retries[token] + require.NotNil(t, rs) + require.Equal(t, 2*time.Second, rs.delay) // Delay gets doubled after scheduling + require.NotNil(t, rs.timer) + lq.mu.Unlock() + + // Advance clock to trigger first retry + clock.Advance(time.Second) + + // Wait for retry to be processed and delay to double again + require.Eventually(t, func() bool { + lq.mu.Lock() + defer lq.mu.Unlock() + rs := lq.retries[token] + return rs != nil && rs.delay == 4*time.Second + }, testutil.WaitShort, testutil.IntervalFast) + + // Check that delay doubled again for next retry + lq.mu.Lock() + rs = lq.retries[token] + require.NotNil(t, rs) + require.Equal(t, 4*time.Second, rs.delay) + lq.mu.Unlock() + + // Advance clock to trigger second retry + clock.Advance(2 * time.Second) + + // Wait for retry to be processed and delay to double again + require.Eventually(t, func() bool { + lq.mu.Lock() + defer lq.mu.Unlock() + rs := lq.retries[token] + return rs != nil && rs.delay == 8*time.Second + }, testutil.WaitShort, testutil.IntervalFast) + + // Check that delay doubled again + lq.mu.Lock() + rs = lq.retries[token] + require.NotNil(t, rs) + require.Equal(t, 8*time.Second, rs.delay) + lq.mu.Unlock() + }) + + t.Run("RetryMaxDelay", func(t *testing.T) { + t.Parallel() + + clock := quartz.NewMock(t) + ch := make(chan agentLog, 10) + lq := &logQueuer{ + logger: slogtest.Make(t, nil), + clock: clock, + q: ch, + logCache: logCache{ + logs: map[string][]agentsdk.Log{}, + }, + maxRetries: 10, + } + + ctx := context.Background() + token := "test-token" + + // Set up a retry state with a large delay + lq.retries = make(map[string]*retryState) + lq.retries[token] = &retryState{ + delay: 20 * time.Second, + retryCount: 0, + } + + // Schedule a retry - should cap at 30 seconds + lq.scheduleRetry(ctx, token) + + rs := lq.retries[token] + require.NotNil(t, rs) + require.Equal(t, 30*time.Second, rs.delay) + + // Schedule another retry - should stay at 30 seconds + lq.scheduleRetry(ctx, token) + rs = lq.retries[token] + require.NotNil(t, rs) + require.Equal(t, 30*time.Second, rs.delay) + }) + + t.Run("ClearRetry", func(t *testing.T) { + t.Parallel() + + clock := quartz.NewMock(t) + ch := make(chan agentLog, 10) + lq := &logQueuer{ + logger: slogtest.Make(t, nil), + clock: clock, + q: ch, + logCache: logCache{ + logs: map[string][]agentsdk.Log{}, + }, + maxRetries: 2, + } + + ctx := context.Background() + token := "test-token" + + // Schedule a retry + lq.scheduleRetry(ctx, token) + require.NotNil(t, lq.retries[token]) + + // Clear the retry + lq.clearRetryLocked(token) + require.Nil(t, lq.retries[token]) + }) + + t.Run("MaxRetries", func(t *testing.T) { + t.Parallel() + + // Create a failing API that will reject connections + failingAPI := newFailingAgentAPI(t) + agentURL, err := url.Parse(failingAPI.server.URL) + require.NoError(t, err) + clock := quartz.NewMock(t) + ttl := time.Second + + ch := make(chan agentLog, 10) + logger := slogtest.Make(t, &slogtest.Options{ + IgnoreErrors: true, + }) + lq := &logQueuer{ + logger: logger, + clock: clock, + q: ch, + coderURL: agentURL, + loggerTTL: ttl, + loggers: map[string]agentLoggerLifecycle{}, + logCache: logCache{ + logs: map[string][]agentsdk.Log{}, + }, + retries: make(map[string]*retryState), + maxRetries: 2, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + go lq.work(ctx) + + token := "max-retry-token" + ch <- agentLog{ + op: opLog, + resourceName: "hello", + agentToken: token, + log: agentsdk.Log{ + CreatedAt: time.Now(), + Output: "This is a log.", + Level: codersdk.LogLevelInfo, + }, + } + + require.Eventually(t, func() bool { + lq.mu.Lock() + defer lq.mu.Unlock() + rs := lq.retries[token] + return rs != nil && rs.retryCount == 1 + }, testutil.WaitShort, testutil.IntervalFast) + + clock.Advance(time.Second) + + require.Eventually(t, func() bool { + lq.mu.Lock() + defer lq.mu.Unlock() + rs := lq.retries[token] + return rs != nil && rs.retryCount == 2 + }, testutil.WaitShort, testutil.IntervalFast) + + clock.Advance(2 * time.Second) + + require.Eventually(t, func() bool { + lq.mu.Lock() + defer lq.mu.Unlock() + rs := lq.retries[token] + return rs == nil || rs.exhausted + }, testutil.WaitShort, testutil.IntervalFast) + + lq.mu.Lock() + cachedLogs := lq.logCache.get(token) + lq.mu.Unlock() + require.Nil(t, cachedLogs) + }) +} + +func Test_logCache(t *testing.T) { + t.Parallel() + + t.Run("PushAndGet", func(t *testing.T) { + t.Parallel() + + lc := logCache{ + logs: map[string][]agentsdk.Log{}, + } + + token := "test-token" + + // Initially should return nil + logs := lc.get(token) + require.Nil(t, logs) + + // Push first log + log1 := agentLog{ + agentToken: token, + log: agentsdk.Log{ + CreatedAt: time.Now(), + Output: "First log", + Level: codersdk.LogLevelInfo, + }, + } + returnedLogs := lc.push(log1) + require.Len(t, returnedLogs, 1) + require.Equal(t, "First log", returnedLogs[0].Output) + + // Get should return the cached logs + cachedLogs := lc.get(token) + require.Len(t, cachedLogs, 1) + require.Equal(t, "First log", cachedLogs[0].Output) + + // Push second log to same token + log2 := agentLog{ + agentToken: token, + log: agentsdk.Log{ + CreatedAt: time.Now(), + Output: "Second log", + Level: codersdk.LogLevelWarn, + }, + } + returnedLogs = lc.push(log2) + require.Len(t, returnedLogs, 2) + require.Equal(t, "First log", returnedLogs[0].Output) + require.Equal(t, "Second log", returnedLogs[1].Output) + + // Get should return both logs + cachedLogs = lc.get(token) + require.Len(t, cachedLogs, 2) + require.Equal(t, "First log", cachedLogs[0].Output) + require.Equal(t, "Second log", cachedLogs[1].Output) + }) + + t.Run("Delete", func(t *testing.T) { + t.Parallel() + + lc := logCache{ + logs: map[string][]agentsdk.Log{}, + } + + token := "test-token" + + // Push a log + log := agentLog{ + agentToken: token, + log: agentsdk.Log{ + CreatedAt: time.Now(), + Output: "Test log", + Level: codersdk.LogLevelInfo, + }, + } + lc.push(log) + + // Verify it exists + cachedLogs := lc.get(token) + require.Len(t, cachedLogs, 1) + + // Delete it + lc.delete(token) + + // Should return nil now + cachedLogs = lc.get(token) + require.Nil(t, cachedLogs) + }) + + t.Run("MultipleTokens", func(t *testing.T) { + t.Parallel() + + lc := logCache{ + logs: map[string][]agentsdk.Log{}, + } + + token1 := "token1" + token2 := "token2" + + // Push logs for different tokens + log1 := agentLog{ + agentToken: token1, + log: agentsdk.Log{ + CreatedAt: time.Now(), + Output: "Log for token1", + Level: codersdk.LogLevelInfo, + }, + } + log2 := agentLog{ + agentToken: token2, + log: agentsdk.Log{ + CreatedAt: time.Now(), + Output: "Log for token2", + Level: codersdk.LogLevelError, + }, + } + + lc.push(log1) + lc.push(log2) + + // Each token should have its own logs + logs1 := lc.get(token1) + require.Len(t, logs1, 1) + require.Equal(t, "Log for token1", logs1[0].Output) + + logs2 := lc.get(token2) + require.Len(t, logs2, 1) + require.Equal(t, "Log for token2", logs2[0].Output) + + // Delete one token shouldn't affect the other + lc.delete(token1) + require.Nil(t, lc.get(token1)) + + logs2 = lc.get(token2) + require.Len(t, logs2, 1) + require.Equal(t, "Log for token2", logs2[0].Output) + }) + + t.Run("EmptyLogHandling", func(t *testing.T) { + t.Parallel() + + api := newFakeAgentAPI(t) + agentURL, err := url.Parse(api.server.URL) + require.NoError(t, err) + clock := quartz.NewMock(t) + ttl := time.Second + + ch := make(chan agentLog, 10) + lq := &logQueuer{ + logger: slogtest.Make(t, nil), + clock: clock, + q: ch, + coderURL: agentURL, + loggerTTL: ttl, + loggers: map[string]agentLoggerLifecycle{}, + logCache: logCache{ + logs: map[string][]agentsdk.Log{}, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + go lq.work(ctx) + + token := "test-token" + + // Send an empty log first - should be ignored since no cached logs exist + emptyLog := agentLog{ + op: opLog, + resourceName: "", + agentToken: token, + log: agentsdk.Log{ + Output: "", + CreatedAt: time.Time{}, + }, + } + ch <- emptyLog + + // Wait to ensure processing completes - no logger should be created for empty log with no cache + require.Eventually(t, func() bool { + lq.mu.Lock() + defer lq.mu.Unlock() + _, exists := lq.loggers[token] + return !exists + }, testutil.WaitShort, testutil.IntervalFast) + + // No logger should be created for empty log with no cache + lq.mu.Lock() + _, exists := lq.loggers[token] + require.False(t, exists) + lq.mu.Unlock() + + // Now send a real log to establish the logger + realLog := agentLog{ + op: opLog, + resourceName: "hello", + agentToken: token, + log: agentsdk.Log{ + CreatedAt: time.Now(), + Output: "Real log", + Level: codersdk.LogLevelInfo, + }, + } + ch <- realLog + + // Should create logger and send log + _ = testutil.RequireRecvCtx(ctx, t, api.logSource) + logs := testutil.RequireRecvCtx(ctx, t, api.logs) + require.Len(t, logs, 1) + require.Contains(t, logs[0].Output, "Real log") + + // Now send empty log - should trigger flush of any cached logs + ch <- emptyLog + + // Wait for processing - logger should still exist after empty log + require.Eventually(t, func() bool { + lq.mu.Lock() + defer lq.mu.Unlock() + _, exists := lq.loggers[token] + return exists + }, testutil.WaitShort, testutil.IntervalFast) + + // Logger should still exist + lq.mu.Lock() + _, exists = lq.loggers[token] + require.True(t, exists) + lq.mu.Unlock() + }) } func newFakeAgentAPI(t *testing.T) *fakeAgentAPI { @@ -377,7 +989,9 @@ func newFakeAgentAPI(t *testing.T) *fakeAgentAPI { } ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary) - defer wsNetConn.Close() + defer func() { + _ = wsNetConn.Close() + }() config := yamux.DefaultConfig() config.LogOutput = io.Discard @@ -400,6 +1014,21 @@ func newFakeAgentAPI(t *testing.T) *fakeAgentAPI { return fakeAPI } +func newFailingAgentAPI(_ *testing.T) *fakeAgentAPI { + fakeAPI := &fakeAgentAPI{ + disconnect: make(chan struct{}), + logs: make(chan []*proto.Log), + logSource: make(chan agentsdk.PostLogSourceRequest), + } + + // Create a server that always returns 401 Unauthorized errors + fakeAPI.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + })) + + return fakeAPI +} + type fakeAgentAPI struct { disconnect chan struct{} logs chan []*proto.Log diff --git a/main.go b/main.go index 3d48cb9..ce425f8 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "strings" "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" @@ -27,13 +28,13 @@ func root() *cobra.Command { coderURL string fieldSelector string kubeConfig string - namespace string + namespacesStr string labelSelector string ) cmd := &cobra.Command{ Use: "coder-logstream-kube", Short: "Stream Kubernetes Pod events to the Coder startup logs.", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { if coderURL == "" { return fmt.Errorf("--coder-url is required") } @@ -63,18 +64,29 @@ func root() *cobra.Command { return fmt.Errorf("create kubernetes client: %w", err) } + var namespaces []string + if namespacesStr != "" { + namespaces = strings.Split(namespacesStr, ",") + for i, namespace := range namespaces { + namespaces[i] = strings.TrimSpace(namespace) + } + } + reporter, err := newPodEventLogger(cmd.Context(), podEventLoggerOptions{ coderURL: parsedURL, client: client, - namespace: namespace, + namespaces: namespaces, fieldSelector: fieldSelector, labelSelector: labelSelector, logger: slog.Make(sloghuman.Sink(cmd.ErrOrStderr())).Leveled(slog.LevelDebug), + maxRetries: 15, // 15 retries is the default max retries for a log send failure. }) if err != nil { return fmt.Errorf("create pod event reporter: %w", err) } - defer reporter.Close() + defer func() { + _ = reporter.Close() + }() select { case err := <-reporter.errChan: return fmt.Errorf("pod event reporter: %w", err) @@ -85,7 +97,7 @@ func root() *cobra.Command { } cmd.Flags().StringVarP(&coderURL, "coder-url", "u", os.Getenv("CODER_URL"), "URL of the Coder instance") cmd.Flags().StringVarP(&kubeConfig, "kubeconfig", "k", "~/.kube/config", "Path to the kubeconfig file") - cmd.Flags().StringVarP(&namespace, "namespace", "n", os.Getenv("CODER_NAMESPACE"), "Namespace to use when listing pods") + cmd.Flags().StringVarP(&namespacesStr, "namespaces", "n", os.Getenv("CODER_NAMESPACES"), "List of namespaces to use when listing pods") cmd.Flags().StringVarP(&fieldSelector, "field-selector", "f", "", "Field selector to use when listing pods") cmd.Flags().StringVarP(&labelSelector, "label-selector", "l", "", "Label selector to use when listing pods") diff --git a/scripts/Dockerfile b/scripts/Dockerfile index fe869e7..8f380e9 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -1,4 +1,5 @@ FROM --platform=$BUILDPLATFORM scratch AS base ARG TARGETARCH -COPY ./coder-logstream-kube-${TARGETARCH} /coder-logstream-kube +COPY --chmod=0555 ./coder-logstream-kube-${TARGETARCH} /coder-logstream-kube +USER 65532:65532 ENTRYPOINT ["/coder-logstream-kube"] \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh index 7f8b1a5..e6805b7 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -13,8 +13,8 @@ archs=(amd64 arm64 arm) # build for all architectures for arch in "${archs[@]}"; do - echo "Building for $arch" - GOARCH=$arch GOOS=linux CGO_ENABLED=0 go build -ldflags "-s -w" -o ./coder-logstream-kube-"$arch" ../ + echo "Building for $arch" + GOARCH=$arch GOOS=linux CGO_ENABLED=0 go build -ldflags "-s -w" -o ./coder-logstream-kube-"$arch" ../ done # We have to use docker buildx to tag multiple images with @@ -24,21 +24,26 @@ BUILDER_EXISTS=$(docker buildx ls | grep $BUILDER_NAME || true) # If builder doesn't exist, create it if [ -z "$BUILDER_EXISTS" ]; then - echo "Creating dockerx builder $BUILDER_NAME..." - docker buildx create --use --platform=linux/arm64,linux/amd64,linux/arm/v7 --name $BUILDER_NAME + echo "Creating dockerx builder $BUILDER_NAME..." + docker buildx create --use --platform=linux/arm64,linux/amd64,linux/arm/v7 --name $BUILDER_NAME else - echo "Builder $BUILDER_NAME already exists. Using it." + echo "Builder $BUILDER_NAME already exists. Using it." fi # Ensure the builder is bootstrapped and ready to use docker buildx inspect --bootstrap &>/dev/null -# Build +# Build and push the image if [ "$CI" = "false" ]; then - docker buildx build --platform linux/"$current" -t coder-logstream-kube --load . + docker buildx build --platform linux/"$current" -t coder-logstream-kube --load . else - VERSION=$(../scripts/version.sh) - BASE=ghcr.io/coder/coder-logstream-kube - IMAGE=$BASE:$VERSION - docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t "$IMAGE" -t $BASE:latest --push . + VERSION=$(../scripts/version.sh) + BASE=ghcr.io/coder/coder-logstream-kube + IMAGE=$BASE:$VERSION + # if version contains "rc" skip pushing to latest + if [[ $VERSION == *"rc"* ]]; then + docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t "$IMAGE" --push . + else + docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t "$IMAGE" -t $BASE:latest --push . + fi fi diff --git a/scripts/helm.sh b/scripts/helm.sh index fa3bfed..d06e870 100755 --- a/scripts/helm.sh +++ b/scripts/helm.sh @@ -15,7 +15,7 @@ # to the Coder OSS repo. This requires `gsutil` to be installed and configured. set -euo pipefail -cd $(dirname $(dirname "${BASH_SOURCE[0]}")) +cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" log() { echo "$*" 1>&2 diff --git a/scripts/kind-setup.sh b/scripts/kind-setup.sh new file mode 100755 index 0000000..b2f602a --- /dev/null +++ b/scripts/kind-setup.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +# This script sets up a KinD cluster for running integration tests locally. +# Usage: ./scripts/kind-setup.sh [create|delete] + +set -euo pipefail + +CLUSTER_NAME="${KIND_CLUSTER_NAME:-logstream-integration-test}" + +usage() { + echo "Usage: $0 [create|delete|status]" + echo "" + echo "Commands:" + echo " create - Create a KinD cluster for integration tests" + echo " delete - Delete the KinD cluster" + echo " status - Check if the cluster exists and is running" + echo "" + echo "Environment variables:" + echo " KIND_CLUSTER_NAME - Name of the cluster (default: logstream-integration-test)" + exit 1 +} + +check_kind() { + if ! command -v kind &>/dev/null; then + echo "Error: 'kind' is not installed." + echo "Install it from: https://kind.sigs.k8s.io/docs/user/quick-start/#installation" + exit 1 + fi +} + +check_kubectl() { + if ! command -v kubectl &>/dev/null; then + echo "Error: 'kubectl' is not installed." + echo "Install it from: https://kubernetes.io/docs/tasks/tools/" + exit 1 + fi +} + +cluster_exists() { + kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$" +} + +create_cluster() { + check_kind + check_kubectl + + if cluster_exists; then + echo "Cluster '${CLUSTER_NAME}' already exists." + echo "Use '$0 delete' to remove it first, or '$0 status' to check its status." + exit 0 + fi + + echo "Creating KinD cluster '${CLUSTER_NAME}'..." + kind create cluster --name "${CLUSTER_NAME}" --wait 60s + + echo "" + echo "Cluster created successfully!" + echo "" + echo "To run integration tests:" + echo " go test -tags=integration -v ./..." + echo "" + echo "To delete the cluster when done:" + echo " $0 delete" +} + +delete_cluster() { + check_kind + + if ! cluster_exists; then + echo "Cluster '${CLUSTER_NAME}' does not exist." + exit 0 + fi + + echo "Deleting KinD cluster '${CLUSTER_NAME}'..." + kind delete cluster --name "${CLUSTER_NAME}" + echo "Cluster deleted successfully!" +} + +status_cluster() { + check_kind + + if cluster_exists; then + echo "Cluster '${CLUSTER_NAME}' exists." + echo "" + echo "Cluster info:" + kubectl cluster-info --context "kind-${CLUSTER_NAME}" 2>/dev/null || echo " (unable to get cluster info)" + echo "" + echo "Nodes:" + kubectl get nodes --context "kind-${CLUSTER_NAME}" 2>/dev/null || echo " (unable to get nodes)" + else + echo "Cluster '${CLUSTER_NAME}' does not exist." + echo "Use '$0 create' to create it." + fi +} + +case "${1:-}" in +create) + create_cluster + ;; +delete) + delete_cluster + ;; +status) + status_cluster + ;; +*) + usage + ;; +esac diff --git a/scripts/version.sh b/scripts/version.sh index 72a2f5b..82385be 100755 --- a/scripts/version.sh +++ b/scripts/version.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -cd $(dirname "${BASH_SOURCE[0]}") +cd "$(dirname "${BASH_SOURCE[0]}")" last_tag="$(git describe --tags --abbrev=0)" version="$last_tag"