├── .dockerignore ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── publish-latest.yml │ ├── publish-release.yml │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── client.go ├── go.mod ├── go.sum ├── main.go └── main_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .idea -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | labels: ["dependencies"] 6 | schedule: 7 | interval: "daily" 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | labels: ["dependencies"] 11 | schedule: 12 | interval: "daily" 13 | -------------------------------------------------------------------------------- /.github/workflows/publish-latest.yml: -------------------------------------------------------------------------------- 1 | name: publish-latest 2 | on: 3 | workflow_run: 4 | workflows: [test] 5 | branches: [master] 6 | types: [completed] 7 | concurrency: 8 | group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }} 9 | cancel-in-progress: true 10 | jobs: 11 | publish-latest: 12 | runs-on: ubuntu-latest 13 | if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }} 14 | permissions: 15 | contents: read 16 | packages: write 17 | timeout-minutes: 60 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: docker/setup-qemu-action@v3 21 | - uses: docker/setup-buildx-action@v3 22 | - name: Get image repository 23 | run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 24 | - name: Login to registry 25 | uses: docker/login-action@v3 26 | with: 27 | registry: ghcr.io 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | - name: Build and push Docker image 31 | uses: docker/build-push-action@v6 32 | with: 33 | platforms: linux/amd64,linux/arm64 34 | pull: true 35 | push: true 36 | tags: ${{ env.IMAGE_REPOSITORY }}:latest 37 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: publish-release 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish-release: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | packages: write 11 | timeout-minutes: 60 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: docker/setup-qemu-action@v3 15 | - uses: docker/setup-buildx-action@v3 16 | - name: Get image repository 17 | run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 18 | - name: Get the release 19 | run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV 20 | - name: Login to registry 21 | uses: docker/login-action@v3 22 | with: 23 | registry: ghcr.io 24 | username: ${{ github.actor }} 25 | password: ${{ secrets.GITHUB_TOKEN }} 26 | - name: Build and push Docker image 27 | uses: docker/build-push-action@v6 28 | with: 29 | platforms: linux/amd64,linux/arm64 30 | pull: true 31 | push: true 32 | tags: | 33 | ${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }} 34 | ${{ env.IMAGE_REPOSITORY }}:stable 35 | ${{ env.IMAGE_REPOSITORY }}:latest 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | paths-ignore: 5 | - '*.md' 6 | push: 7 | branches: 8 | - master 9 | paths-ignore: 10 | - '*.md' 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 5 15 | steps: 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version: 1.22.2 19 | - uses: actions/checkout@v4 20 | - run: go build 21 | - run: go test ./... -cover 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | *.iml 3 | .idea 4 | .vscode 5 | 6 | # OS 7 | .DS_Store 8 | 9 | # JS 10 | node_modules 11 | 12 | # Go 13 | /vendor 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS builder 2 | WORKDIR /app 3 | ADD . ./ 4 | RUN go mod tidy 5 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o k8s-ttl-controller . 6 | RUN apk --update add ca-certificates 7 | 8 | FROM scratch 9 | COPY --from=builder /app/k8s-ttl-controller . 10 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 11 | ENTRYPOINT ["/k8s-ttl-controller"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2023 TwiN 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY=k8s-ttl-controller 2 | 3 | .PHONY: build clean run test kind-create-cluster kind-clean 4 | 5 | build: 6 | go build -o $(BINARY) . 7 | 8 | clean: 9 | -rm $(BINARY) 10 | 11 | run: build 12 | ENVIRONMENT=dev ./$(BINARY) 13 | 14 | test: 15 | go test ./... -cover 16 | 17 | ######## 18 | # Kind # 19 | ######## 20 | 21 | kind-create-cluster: 22 | kind create cluster --name k8s-ttl-controller 23 | 24 | kind-clean: 25 | kind delete cluster --name k8s-ttl-controller 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # k8s-ttl-controller 2 | ![test](https://github.com/TwiN/k8s-ttl-controller/actions/workflows/test.yml/badge.svg) 3 | 4 | This application allow you to specify a TTL (time to live) on your Kubernetes resources. Once the TTL is reached, 5 | the resource will be automatically deleted. 6 | 7 | To configure the TTL, all you have to do is annotate the relevant resource(s) with `k8s-ttl-controller.twin.sh/ttl` and 8 | a value such as `30m`, `24h` and `7d`. 9 | 10 | The resource is deleted after the current timestamp surpasses the sum of the resource's `metadata.creationTimestamp` and 11 | the duration specified by the `k8s-ttl-controller.twin.sh/ttl` annotation. 12 | 13 | If the resource is annotated with `k8s-ttl-controller.twin.sh/refreshed-at`, the TTL will be calculated from the value of 14 | this annotation instead of the `metadata.creationTimestamp`. 15 | 16 | ## Usage 17 | ### Setting a TTL on a resource 18 | To set a TTL on a resource, all you have to do is add the annotation `k8s-ttl-controller.twin.sh/ttl` on the resource 19 | you want to eventually expire with a duration from the creation of the resource as value. 20 | 21 | In other words, if you had a pod named `hello-world` that was created 20 minutes ago, and you annotated it with: 22 | ```console 23 | kubectl annotate pod hello-world k8s-ttl-controller.twin.sh/ttl=1h 24 | ``` 25 | The pod `hello-world` would be deleted in approximately 40 minutes, because 20 minutes have already elapsed, leaving 26 | 40 minutes until the target TTL of 1h is reached. 27 | 28 | Alternatively, you can create resources with the annotation already present: 29 | ```yaml 30 | apiVersion: v1 31 | kind: Pod 32 | metadata: 33 | name: nginx 34 | annotations: 35 | k8s-ttl-controller.twin.sh/ttl: "1h" 36 | spec: 37 | containers: 38 | - name: web 39 | image: nginx 40 | ``` 41 | The above would cause the pod to be deleted 1 hour after its creation. 42 | 43 | This is especially useful if you want to create temporary resources without having to worry about unnecessary 44 | resources accumulating over time. 45 | 46 | You can delay a resource from being deleted by using the `k8s-ttl-controller.twin.sh/refreshed-at` annotation, as 47 | the value of said annotation will be used instead of `metadata.creationTimestamp` to calculate the TTL: 48 | ```console 49 | kubectl annotate pod hello-world k8s-ttl-controller.twin.sh/refreshed-at=2024-12-08T20:48:11Z 50 | ``` 51 | You can use the following to save yourself from timezone shenanigans: 52 | ```console 53 | kubectl annotate pod hello-world k8s-ttl-controller.twin.sh/refreshed-at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 54 | ``` 55 | 56 | You can use environment variable `API_RESOURCES_TO_WATCH` to specify the resources to watch. By default, the controller watches 57 | all resources in the cluster. You can specify a comma-separated list of resources to watch, such as `pods,deployments`. 58 | 59 | ```console 60 | export API_RESOURCES_TO_WATCH=pods,deployments 61 | ``` 62 | 63 | ## Deploying on Kubernetes 64 | ### Using Helm 65 | For the chart associated to this project, see [TwiN/helm-charts](https://github.com/TwiN/helm-charts): 66 | ```console 67 | helm repo add twin https://twin.github.io/helm-charts 68 | helm repo update 69 | helm install k8s-ttl-controller twin/k8s-ttl-controller -n kube-system 70 | ``` 71 | 72 | ### Using a YAML file 73 | ```yaml 74 | apiVersion: v1 75 | kind: ServiceAccount 76 | metadata: 77 | name: k8s-ttl-controller 78 | namespace: kube-system 79 | labels: 80 | app: k8s-ttl-controller 81 | --- 82 | apiVersion: rbac.authorization.k8s.io/v1 83 | kind: ClusterRole 84 | metadata: 85 | name: k8s-ttl-controller 86 | labels: 87 | app: k8s-ttl-controller 88 | rules: 89 | - apiGroups: 90 | - "*" 91 | resources: 92 | - "*" 93 | verbs: 94 | - "get" 95 | - "list" 96 | - "delete" 97 | - apiGroups: 98 | - "" 99 | - "events.k8s.io" 100 | resources: 101 | - "events" 102 | verbs: 103 | - "create" 104 | --- 105 | apiVersion: rbac.authorization.k8s.io/v1 106 | kind: ClusterRoleBinding 107 | metadata: 108 | name: k8s-ttl-controller 109 | labels: 110 | app: k8s-ttl-controller 111 | roleRef: 112 | kind: ClusterRole 113 | name: k8s-ttl-controller 114 | apiGroup: rbac.authorization.k8s.io 115 | subjects: 116 | - kind: ServiceAccount 117 | name: k8s-ttl-controller 118 | namespace: kube-system 119 | --- 120 | apiVersion: apps/v1 121 | kind: Deployment 122 | metadata: 123 | name: k8s-ttl-controller 124 | namespace: kube-system 125 | labels: 126 | app: k8s-ttl-controller 127 | spec: 128 | replicas: 1 129 | selector: 130 | matchLabels: 131 | app: k8s-ttl-controller 132 | template: 133 | metadata: 134 | labels: 135 | app: k8s-ttl-controller 136 | spec: 137 | automountServiceAccountToken: true 138 | serviceAccountName: k8s-ttl-controller 139 | restartPolicy: Always 140 | dnsPolicy: Default 141 | containers: 142 | - name: k8s-ttl-controller 143 | image: ghcr.io/twin/k8s-ttl-controller 144 | imagePullPolicy: Always 145 | ``` 146 | 147 | ### Docker 148 | ```console 149 | docker pull ghcr.io/twin/k8s-ttl-controller 150 | ``` 151 | 152 | 153 | ## Development 154 | First, you need to configure your kubeconfig to point to an existing, accessible cluster from your machine so that `kubectl` can be used. 155 | 156 | If you don't have one or wish to use a different cluster, you can create a kind cluster using the following command: 157 | ```console 158 | make kind-create-cluster 159 | ``` 160 | Next, you must start k8s-ttl-controller locally: 161 | ```console 162 | make run 163 | ``` 164 | 165 | To test the application, you can create any resource and annotate it with the `k8s-ttl-controller.twin.sh/ttl` annotation: 166 | ```console 167 | kubectl run nginx --image=nginx 168 | kubectl annotate pod nginx k8s-ttl-controller.twin.sh/ttl=1h 169 | ``` 170 | You should then see something like this in the logs: 171 | ```console 172 | 2022/07/10 13:31:40 [pods/nginx] is configured with a TTL of 1h, which means it will expire in 57m10s 173 | ``` 174 | If you want to ensure that expired resources are properly deleted, you can simply set a very low TTL, such as: 175 | ```console 176 | kubectl annotate pod nginx k8s-ttl-controller.twin.sh/ttl=1s 177 | ``` 178 | You would then see something like this in the logs: 179 | ```console 180 | 2022/07/10 13:36:53 [pods/nginx2] is configured with a TTL of 1s, which means it has expired 2m3s ago 181 | 2022/07/10 13:36:53 [pods/nginx2] deleted 182 | ``` 183 | 184 | To clean up the kind cluster: 185 | ```console 186 | make kind-clean 187 | ``` 188 | 189 | 190 | ## Debugging 191 | To enable debugging logs, you may set the `DEBUG` environment variable to `true` 192 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | 8 | "k8s.io/client-go/dynamic" 9 | "k8s.io/client-go/kubernetes" 10 | "k8s.io/client-go/rest" 11 | "k8s.io/client-go/tools/clientcmd" 12 | ) 13 | 14 | // CreateClients initializes a Kubernetes client and a dynamic client using either the kubeconfig file 15 | // (if ENVIRONMENT is set to dev) or the in-cluster config otherwise. 16 | func CreateClients() (kubernetes.Interface, dynamic.Interface, error) { 17 | var cfg *rest.Config 18 | if os.Getenv("ENVIRONMENT") == "dev" { 19 | var kubeconfig string 20 | if home := homeDir(); home != "" { 21 | kubeconfig = filepath.Join(home, ".kube", "config") 22 | } else { 23 | return nil, nil, errors.New("home directory not found") 24 | } 25 | // use the current context in kubeconfig 26 | clientConfig, err := clientcmd.BuildConfigFromFlags("", kubeconfig) 27 | if err != nil { 28 | return nil, nil, err 29 | } 30 | cfg = clientConfig 31 | } else { 32 | clientConfig, err := rest.InClusterConfig() 33 | if err != nil { 34 | return nil, nil, err 35 | } 36 | cfg = clientConfig 37 | } 38 | cfg.WarningHandler = rest.NoWarnings{} 39 | cfg.UserAgent = "k8s-ttl-controller/1.0" 40 | kubernetesClient, err := kubernetes.NewForConfig(cfg) 41 | if err != nil { 42 | return nil, nil, err 43 | } 44 | dynamicClient, err := dynamic.NewForConfig(cfg) 45 | if err != nil { 46 | return nil, nil, err 47 | } 48 | return kubernetesClient, dynamicClient, nil 49 | } 50 | 51 | func homeDir() string { 52 | if h := os.Getenv("HOME"); h != "" { 53 | return h 54 | } 55 | return os.Getenv("USERPROFILE") // windows 56 | } 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/TwiN/k8s-ttl-controller 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | github.com/TwiN/kevent v0.2.0 7 | github.com/xhit/go-str2duration/v2 v2.1.0 8 | k8s.io/api v0.31.0 9 | k8s.io/apimachinery v0.31.0 10 | k8s.io/client-go v0.31.0 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 15 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 16 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 17 | github.com/go-logr/logr v1.4.2 // indirect 18 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 19 | github.com/go-openapi/jsonreference v0.20.2 // indirect 20 | github.com/go-openapi/swag v0.22.4 // indirect 21 | github.com/gogo/protobuf v1.3.2 // indirect 22 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 23 | github.com/golang/protobuf v1.5.4 // indirect 24 | github.com/google/gnostic-models v0.6.8 // indirect 25 | github.com/google/go-cmp v0.6.0 // indirect 26 | github.com/google/gofuzz v1.2.0 // indirect 27 | github.com/google/uuid v1.6.0 // indirect 28 | github.com/imdario/mergo v0.3.6 // indirect 29 | github.com/josharian/intern v1.0.0 // indirect 30 | github.com/json-iterator/go v1.1.12 // indirect 31 | github.com/mailru/easyjson v0.7.7 // indirect 32 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 33 | github.com/modern-go/reflect2 v1.0.2 // indirect 34 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 35 | github.com/pkg/errors v0.9.1 // indirect 36 | github.com/spf13/pflag v1.0.5 // indirect 37 | github.com/x448/float16 v0.8.4 // indirect 38 | golang.org/x/net v0.26.0 // indirect 39 | golang.org/x/oauth2 v0.21.0 // indirect 40 | golang.org/x/sys v0.21.0 // indirect 41 | golang.org/x/term v0.21.0 // indirect 42 | golang.org/x/text v0.16.0 // indirect 43 | golang.org/x/time v0.3.0 // indirect 44 | google.golang.org/protobuf v1.34.2 // indirect 45 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 46 | gopkg.in/inf.v0 v0.9.1 // indirect 47 | gopkg.in/yaml.v2 v2.4.0 // indirect 48 | gopkg.in/yaml.v3 v3.0.1 // indirect 49 | k8s.io/klog/v2 v2.130.1 // indirect 50 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 51 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect 52 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 53 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 54 | sigs.k8s.io/yaml v1.4.0 // indirect 55 | ) 56 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/TwiN/kevent v0.2.0 h1:RW1NMrIv8myG3QHvLLDYLF/pJliW03TPkb9Zf97bzRM= 2 | github.com/TwiN/kevent v0.2.0/go.mod h1:UVXHFfpbWkYLzV4GPyw5JO1UjGg9/Qgle1Cu0ttO95Y= 3 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 7 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 9 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 10 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 11 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 12 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 13 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 14 | github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= 15 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 16 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 17 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 18 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 19 | github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= 20 | github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 21 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 22 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 23 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 24 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 25 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 26 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 27 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 28 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 29 | github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= 30 | github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= 31 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 32 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 33 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 34 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 35 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 36 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 37 | github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= 38 | github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= 39 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 40 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 41 | github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= 42 | github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= 43 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 44 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 45 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 46 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 47 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 48 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 49 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 50 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 51 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 52 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 53 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 54 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 55 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 56 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 57 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 58 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 59 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 60 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 61 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 62 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 63 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 64 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 65 | github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= 66 | github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= 67 | github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= 68 | github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= 69 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 70 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 71 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 72 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 73 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 74 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 75 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 76 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 77 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 78 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 79 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 80 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 81 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 82 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 83 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 84 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 85 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 86 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 87 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 88 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 89 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 90 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 91 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 92 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 93 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 94 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 95 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 96 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 97 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 98 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 99 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 100 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 101 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 102 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 103 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 104 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 105 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 106 | golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= 107 | golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 108 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 109 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 110 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 111 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 112 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 113 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 114 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 115 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 116 | golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= 117 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 118 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 119 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 120 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 121 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 122 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 123 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 124 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 125 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 126 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 127 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 128 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 129 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 130 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 131 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 132 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 133 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 134 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 135 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 136 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 137 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 138 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 139 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 140 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 141 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 142 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 143 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 144 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 145 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 146 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 147 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 148 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 149 | k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= 150 | k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= 151 | k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= 152 | k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= 153 | k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= 154 | k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= 155 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 156 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 157 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= 158 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= 159 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= 160 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 161 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 162 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 163 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= 164 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 165 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 166 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 167 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "github.com/TwiN/kevent" 13 | "github.com/xhit/go-str2duration/v2" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 16 | "k8s.io/apimachinery/pkg/runtime/schema" 17 | "k8s.io/client-go/dynamic" 18 | "k8s.io/client-go/kubernetes" 19 | ) 20 | 21 | const ( 22 | AnnotationTTL = "k8s-ttl-controller.twin.sh/ttl" 23 | AnnotationRefreshedAt = "k8s-ttl-controller.twin.sh/refreshed-at" 24 | 25 | MaximumFailedExecutionBeforePanic = 10 // Maximum number of allowed failed executions before panicking 26 | ExecutionTimeout = 20 * time.Minute // Maximum time for each reconciliation before timing out 27 | ExecutionInterval = 5 * time.Minute // Interval between each reconciliation 28 | ThrottleDuration = 50 * time.Millisecond // Duration to sleep for throttling purposes 29 | 30 | ListLimit = 500 // Maximum number of items to list at once 31 | 32 | APIResourcesToWatchEnv = "API_RESOURCES_TO_WATCH" 33 | ) 34 | 35 | var ( 36 | ErrTimedOut = errors.New("execution timed out") 37 | 38 | listTimeoutSeconds = int64(60) 39 | executionFailedCounter = 0 40 | 41 | logger *slog.Logger // Global logger 42 | programLevel slog.LevelVar // Info by default 43 | 44 | apiResourcesToWatch []string 45 | ) 46 | 47 | func init() { 48 | // Create a new logger, either in JSON or text format 49 | if os.Getenv("JSON_LOG") == "true" { 50 | logger = slog.New(slog.NewJSONHandler(os.Stderr, nil)) 51 | } else { 52 | logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) 53 | } 54 | 55 | // Set the log level based on the DEBUG environment variable 56 | if os.Getenv("DEBUG") == "true" { 57 | programLevel.Set(slog.LevelDebug) 58 | } 59 | 60 | // Parse the trackable resources from the environment 61 | if os.Getenv(APIResourcesToWatchEnv) != "" { 62 | apiResourcesToWatch = strings.Split(os.Getenv(APIResourcesToWatchEnv), ",") 63 | } 64 | 65 | } 66 | 67 | func main() { 68 | for { 69 | start := time.Now() 70 | kubernetesClient, dynamicClient, err := CreateClients() 71 | if err != nil { 72 | panic("failed to create Kubernetes clients: " + err.Error()) 73 | } 74 | eventManager := kevent.NewEventManager(kubernetesClient, "k8s-ttl-controller") 75 | if err = Reconcile(kubernetesClient, dynamicClient, eventManager); err != nil { 76 | logger.Info(fmt.Sprintf("Error during execution: %s", err.Error())) 77 | executionFailedCounter++ 78 | if executionFailedCounter > MaximumFailedExecutionBeforePanic { 79 | panic(fmt.Errorf("execution failed %d times: %w", executionFailedCounter, err)) 80 | } 81 | } else if executionFailedCounter > 0 { 82 | logger.Info(fmt.Sprintf("Execution was successful after %d failed attempts, resetting counter to 0", executionFailedCounter)) 83 | executionFailedCounter = 0 84 | } 85 | logger.Info(fmt.Sprintf("Execution took %dms, sleeping for %s", time.Since(start).Milliseconds(), ExecutionInterval)) 86 | time.Sleep(ExecutionInterval) 87 | } 88 | } 89 | 90 | // Reconcile loops over all resources and deletes all sub resources that have expired 91 | // 92 | // Returns an error if an execution lasts for longer than ExecutionTimeout 93 | func Reconcile(kubernetesClient kubernetes.Interface, dynamicClient dynamic.Interface, eventManager *kevent.EventManager) error { 94 | // Use Kubernetes' discovery API to retrieve all resources 95 | _, resources, err := kubernetesClient.Discovery().ServerGroupsAndResources() 96 | if err != nil { 97 | return err 98 | } 99 | logger.Debug(fmt.Sprintf("[Reconcile] Found %d API resources", len(resources))) 100 | timeout := make(chan bool, 1) 101 | result := make(chan bool, 1) 102 | go func() { 103 | time.Sleep(ExecutionTimeout) 104 | timeout <- true 105 | }() 106 | go func() { 107 | result <- DoReconcile(dynamicClient, eventManager, resources) 108 | }() 109 | select { 110 | case <-timeout: 111 | return ErrTimedOut 112 | case <-result: 113 | return nil 114 | } 115 | } 116 | 117 | func getStartTime(item unstructured.Unstructured) metav1.Time { 118 | refreshedAt, exists := item.GetAnnotations()[AnnotationRefreshedAt] 119 | if exists { 120 | t, err := time.Parse(time.RFC3339, refreshedAt) 121 | if err == nil { 122 | return metav1.NewTime(t) 123 | } 124 | logger.Info("Failed to parse refreshed-at timestamp '%s' for %s/%s: %s", refreshedAt, item.GetKind(), item.GetName(), err) 125 | } 126 | return item.GetCreationTimestamp() 127 | } 128 | 129 | func contains(slice []string, item string) bool { 130 | for _, s := range slice { 131 | if s == item { 132 | return true 133 | } 134 | } 135 | return false 136 | } 137 | 138 | // DoReconcile goes over all API resources specified, retrieves all sub resources and deletes those who have expired 139 | func DoReconcile(dynamicClient dynamic.Interface, eventManager *kevent.EventManager, resources []*metav1.APIResourceList) bool { 140 | for _, resource := range resources { 141 | if len(resource.APIResources) == 0 { 142 | continue 143 | } 144 | gv := strings.Split(resource.GroupVersion, "/") 145 | gvr := schema.GroupVersionResource{} 146 | if len(gv) == 2 { 147 | gvr.Group = gv[0] 148 | gvr.Version = gv[1] 149 | } else if len(gv) == 1 { 150 | gvr.Version = gv[0] 151 | } else { 152 | continue 153 | } 154 | for _, apiResource := range resource.APIResources { 155 | // Skip resources that are not in the list of trackable resources 156 | if len(apiResourcesToWatch) != 0 && !contains(apiResourcesToWatch, apiResource.Name) { 157 | continue 158 | } 159 | // Make sure that we can list and delete the resource. If we can't, then there's no point querying it. 160 | verbs := apiResource.Verbs.String() 161 | if !strings.Contains(verbs, "list") || !strings.Contains(verbs, "delete") { 162 | continue 163 | } 164 | // List all items under the resource 165 | gvr.Resource = apiResource.Name 166 | var list *unstructured.UnstructuredList 167 | var continueToken string 168 | var ttlInDuration time.Duration 169 | var err error 170 | for list == nil || continueToken != "" { 171 | list, err = dynamicClient.Resource(gvr).List(context.TODO(), metav1.ListOptions{TimeoutSeconds: &listTimeoutSeconds, Continue: continueToken, Limit: ListLimit}) 172 | if err != nil { 173 | logger.Info(fmt.Sprintf("Error checking %s from %s: %s", gvr.Resource, gvr.GroupVersion(), err)) 174 | continue 175 | } 176 | if list != nil { 177 | continueToken = list.GetContinue() 178 | } 179 | logger.Debug(fmt.Sprintf("Checking %d %s from %s", len(list.Items), gvr.Resource, gvr.GroupVersion())) 180 | for _, item := range list.Items { 181 | ttl, exists := item.GetAnnotations()[AnnotationTTL] 182 | if !exists { 183 | continue 184 | } 185 | ttlInDuration, err = str2duration.ParseDuration(ttl) 186 | if err != nil { 187 | logger.Info(fmt.Sprintf("[%s/%s] has an invalid TTL '%s': %s", apiResource.Name, item.GetName(), ttl, err)) 188 | continue 189 | } 190 | ttlExpired := time.Now().After(getStartTime(item).Add(ttlInDuration)) 191 | if ttlExpired { 192 | durationSinceExpired := time.Since(getStartTime(item).Add(ttlInDuration)).Round(time.Second) 193 | logger.Info(fmt.Sprintf("[%s/%s] is configured with a TTL of %s, which means it has expired %s ago", apiResource.Name, item.GetName(), ttl, durationSinceExpired)) 194 | err = dynamicClient.Resource(gvr).Namespace(item.GetNamespace()).Delete(context.TODO(), item.GetName(), metav1.DeleteOptions{}) 195 | if err != nil { 196 | logger.Info(fmt.Sprintf("[%s/%s] failed to delete: %s", apiResource.Name, item.GetName(), err)) 197 | eventManager.Create(item.GetNamespace(), item.GetKind(), item.GetName(), "FailedToDeleteExpiredTTL", "Unable to delete expired resource:"+err.Error(), true) 198 | // XXX: Should we retry with GracePeriodSeconds set to &0 to force immediate deletion after the first attempt failed? 199 | } else { 200 | logger.Info(fmt.Sprintf("[%s/%s] deleted", apiResource.Name, item.GetName())) 201 | eventManager.Create(item.GetNamespace(), item.GetKind(), item.GetName(), "DeletedExpiredTTL", "Deleted resource because "+ttl+" or more has elapsed", false) 202 | } 203 | // Cool off a tiny bit to avoid hitting the API too often 204 | time.Sleep(ThrottleDuration) 205 | } else { 206 | logger.Info(fmt.Sprintf("[%s/%s] is configured with a TTL of %s, which means it will expire in %s", apiResource.Name, item.GetName(), ttl, time.Until(getStartTime(item).Add(ttlInDuration)).Round(time.Second))) 207 | } 208 | } 209 | // Cool off a tiny bit to avoid hitting the API too often 210 | time.Sleep(ThrottleDuration) 211 | } 212 | // Cool off a tiny bit to avoid hitting the API too often 213 | time.Sleep(ThrottleDuration) 214 | } 215 | } 216 | return true 217 | } 218 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/TwiN/kevent" 9 | appsv1 "k8s.io/api/apps/v1" 10 | v1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | "k8s.io/apimachinery/pkg/runtime/schema" 15 | fakediscovery "k8s.io/client-go/discovery/fake" 16 | fakedynamic "k8s.io/client-go/dynamic/fake" 17 | fakekubernetes "k8s.io/client-go/kubernetes/fake" 18 | ) 19 | 20 | func TestReconcile(t *testing.T) { 21 | // Before jumping into this test, you must understand that kubernetesClient and dynamicClient are used for two different purposes: 22 | // - kubernetesClient: Takes care of discovery. This means that we need to inject the API resources we want to test through dynamicClient 23 | // - dynamicClient: Takes care of listing and deleting resources. 24 | 25 | // Create scheme 26 | scheme := runtime.NewScheme() 27 | _ = appsv1.AddToScheme(scheme) 28 | _ = v1.AddToScheme(scheme) 29 | _ = metav1.AddMetaToScheme(scheme) 30 | 31 | // Create scenarios 32 | scenarios := []struct { 33 | name string 34 | podsToCreate []*unstructured.Unstructured 35 | expectedResourcesLeftAfterReconciliation int 36 | }{ 37 | { 38 | name: "expired-pod-is-deleted", 39 | podsToCreate: []*unstructured.Unstructured{ 40 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "5m"}), 41 | }, 42 | expectedResourcesLeftAfterReconciliation: 0, 43 | }, 44 | { 45 | name: "not-expired-pod-is-not-deleted", 46 | podsToCreate: []*unstructured.Unstructured{ 47 | newUnstructuredWithAnnotations("v1", "Pod", "default", "not-expired-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "3d"}), 48 | }, 49 | expectedResourcesLeftAfterReconciliation: 1, 50 | }, 51 | { 52 | name: "unannotated-pod-is-not-deleted", 53 | podsToCreate: []*unstructured.Unstructured{ 54 | newUnstructuredWithAnnotations("v1", "Pod", "default", "unannotated-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{}), 55 | }, 56 | expectedResourcesLeftAfterReconciliation: 1, 57 | }, 58 | { 59 | name: "one-out-of-two-pods-is-deleted-because-only-one-expired", 60 | podsToCreate: []*unstructured.Unstructured{ 61 | newUnstructuredWithAnnotations("v1", "Pod", "default", "not-expired-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "3d"}), 62 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "5m"}), 63 | }, 64 | expectedResourcesLeftAfterReconciliation: 1, 65 | }, 66 | { 67 | name: "multiple-expired-pods-are-deleted", 68 | podsToCreate: []*unstructured.Unstructured{ 69 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name-1", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "5m"}), 70 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name-2", time.Now().Add(-72*time.Hour), map[string]interface{}{AnnotationTTL: "2d"}), 71 | }, 72 | expectedResourcesLeftAfterReconciliation: 0, 73 | }, 74 | { 75 | name: "only-expired-pods-are-deleted", 76 | podsToCreate: []*unstructured.Unstructured{ 77 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name-1", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "5m"}), 78 | newUnstructuredWithAnnotations("v1", "Pod", "default", "not-expired-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "3d"}), 79 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name-2", time.Now().Add(-72*time.Hour), map[string]interface{}{AnnotationTTL: "2d"}), 80 | newUnstructuredWithAnnotations("v1", "Pod", "default", "unannotated-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{}), 81 | }, 82 | expectedResourcesLeftAfterReconciliation: 2, 83 | }, 84 | { 85 | name: "expired-pod-is-deleted-by-refreshed-at", 86 | podsToCreate: []*unstructured.Unstructured{ 87 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "5m", AnnotationRefreshedAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339)}), 88 | }, 89 | expectedResourcesLeftAfterReconciliation: 0, 90 | }, 91 | { 92 | name: "not-expired-pod-is-not-deleted-by-refreshed-at", 93 | podsToCreate: []*unstructured.Unstructured{ 94 | newUnstructuredWithAnnotations("v1", "Pod", "default", "not-expired-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "3d", AnnotationRefreshedAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339)}), 95 | }, 96 | expectedResourcesLeftAfterReconciliation: 1, 97 | }, 98 | { 99 | name: "unannotated-pod-is-not-deleted-by-refreshed-at", 100 | podsToCreate: []*unstructured.Unstructured{ 101 | newUnstructuredWithAnnotations("v1", "Pod", "default", "unannotated-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationRefreshedAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339)}), 102 | }, 103 | expectedResourcesLeftAfterReconciliation: 1, 104 | }, 105 | { 106 | name: "one-out-of-two-pods-is-deleted-because-only-one-expired-by-refreshed-at", 107 | podsToCreate: []*unstructured.Unstructured{ 108 | newUnstructuredWithAnnotations("v1", "Pod", "default", "not-expired-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "3d", AnnotationRefreshedAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339)}), 109 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "5m", AnnotationRefreshedAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339)}), 110 | }, 111 | expectedResourcesLeftAfterReconciliation: 1, 112 | }, 113 | { 114 | name: "multiple-expired-pods-are-deleted-by-refreshed-at", 115 | podsToCreate: []*unstructured.Unstructured{ 116 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name-1", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "5m", AnnotationRefreshedAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339)}), 117 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name-2", time.Now().Add(-72*time.Hour), map[string]interface{}{AnnotationTTL: "2d", AnnotationRefreshedAt: time.Now().Add(-49 * time.Hour).Format(time.RFC3339)}), 118 | }, 119 | expectedResourcesLeftAfterReconciliation: 0, 120 | }, 121 | { 122 | name: "only-expired-pods-are-deleted-by-refreshed-at", 123 | podsToCreate: []*unstructured.Unstructured{ 124 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name-1", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "5m", AnnotationRefreshedAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339)}), 125 | newUnstructuredWithAnnotations("v1", "Pod", "default", "not-expired-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationTTL: "3d", AnnotationRefreshedAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339)}), 126 | newUnstructuredWithAnnotations("v1", "Pod", "default", "expired-pod-name-2", time.Now().Add(-72*time.Hour), map[string]interface{}{AnnotationTTL: "2d", AnnotationRefreshedAt: time.Now().Add(-49 * time.Hour).Format(time.RFC3339)}), 127 | newUnstructuredWithAnnotations("v1", "Pod", "default", "unannotated-pod-name", time.Now().Add(-time.Hour), map[string]interface{}{AnnotationRefreshedAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339)}), 128 | }, 129 | expectedResourcesLeftAfterReconciliation: 2, 130 | }, 131 | } 132 | 133 | // Run scenarios 134 | for _, scenario := range scenarios { 135 | // Create clients 136 | kubernetesClient := fakekubernetes.NewSimpleClientset() 137 | dynamicClient := fakedynamic.NewSimpleDynamicClient(scheme) 138 | eventManager := kevent.NewEventManager(kubernetesClient, "k8s-ttl-controller") 139 | 140 | fakeDiscovery, _ := kubernetesClient.Discovery().(*fakediscovery.FakeDiscovery) 141 | fakeDiscovery.Fake.Resources = []*metav1.APIResourceList{ 142 | { 143 | GroupVersion: "v1", 144 | APIResources: []metav1.APIResource{ 145 | { 146 | Name: "pods", 147 | Kind: "Pod", 148 | Namespaced: true, 149 | Verbs: []string{"create", "delete", "get", "list", "patch", "update", "watch"}, 150 | }, 151 | }, 152 | }, 153 | } 154 | // Run scenario 155 | t.Run(scenario.name, func(t *testing.T) { 156 | for _, podToCreate := range scenario.podsToCreate { 157 | _, err := dynamicClient.Resource(schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}).Namespace("default").Create(context.TODO(), podToCreate, metav1.CreateOptions{}) 158 | if err != nil { 159 | t.Errorf("unexpected error: %v", err) 160 | } 161 | } 162 | // Make sure that the resources have been created 163 | list, err := dynamicClient.Resource(schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}).Namespace("default").List(context.TODO(), metav1.ListOptions{}) 164 | if err != nil { 165 | t.Errorf("unexpected error: %v", err) 166 | } 167 | if len(list.Items) != len(scenario.podsToCreate) { 168 | t.Errorf("expected 3 resources, got %d", len(list.Items)) 169 | } 170 | // Reconcile once 171 | if err = Reconcile(kubernetesClient, dynamicClient, eventManager); err != nil { 172 | t.Errorf("unexpected error: %v", err) 173 | } 174 | // Make sure that the expired resources have been deleted 175 | list, err = dynamicClient.Resource(schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}).Namespace("default").List(context.TODO(), metav1.ListOptions{}) 176 | if err != nil { 177 | t.Errorf("unexpected error: %v", err) 178 | } 179 | if len(list.Items) != scenario.expectedResourcesLeftAfterReconciliation { 180 | t.Errorf("expected 3 resources, got %d", len(list.Items)) 181 | } 182 | }) 183 | } 184 | } 185 | 186 | func newUnstructuredWithAnnotations(apiVersion, kind, namespace, name string, creationTimestamp time.Time, annotations map[string]interface{}) *unstructured.Unstructured { 187 | return &unstructured.Unstructured{ 188 | Object: map[string]interface{}{ 189 | "apiVersion": apiVersion, 190 | "kind": kind, 191 | "metadata": map[string]interface{}{ 192 | "namespace": namespace, 193 | "name": name, 194 | "creationTimestamp": creationTimestamp.Format(time.RFC3339), 195 | "annotations": annotations, 196 | }, 197 | }, 198 | } 199 | } 200 | --------------------------------------------------------------------------------