├── .github ├── dependabot.yaml └── workflows │ ├── postsubmit.yaml │ └── presubmit.yaml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── aws ├── go.mod ├── go.sum ├── metrics │ └── emf.go └── middleware │ └── middleware.go ├── context └── context.go ├── controller └── controller.go ├── env └── env.go ├── events ├── controller.go ├── metrics.go └── suite_test.go ├── go.mod ├── go.sum ├── leaderelection └── leasehijacker.go ├── metrics ├── metrics.go ├── multi.go ├── prometheus.go └── types.go ├── mock ├── atomic.go └── function.go ├── object └── object.go ├── option ├── environment.go ├── function.go ├── function_test.go └── suite_test.go ├── reasonable └── reasonable.go ├── serrors ├── logger.go ├── serrors.go └── suite_test.go ├── singleton └── controller.go ├── status ├── condition.go ├── condition_set.go ├── condition_set_test.go ├── controller.go ├── controller_test.go ├── doc.go ├── metrics.go ├── suite_test.go ├── unstructured_adapter.go ├── unstructured_adapter_test.go └── zz_generated.deepcopy.go ├── test ├── expectations │ └── expectations.go └── object.go └── unstructured └── unstructured.go /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates#package-ecosystem 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | groups: 9 | # Group updates together, so that they are all applied in a single PR. 10 | # Grouped updates are currently in beta and is subject to change. 11 | # xref: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups 12 | go-deps: 13 | patterns: 14 | - "*" 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "weekly" 19 | groups: 20 | # Group updates together, so that they are all applied in a single PR. 21 | # Grouped updates are currently in beta and is subject to change. 22 | # xref: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups 23 | actions-deps: 24 | patterns: 25 | - "*" -------------------------------------------------------------------------------- /.github/workflows/postsubmit.yaml: -------------------------------------------------------------------------------- 1 | name: postsubmit 2 | on: 3 | push: 4 | branches: [main] 5 | workflow_dispatch: 6 | jobs: 7 | postsubmit: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: hydrate-goproxy 12 | run: | 13 | mkdir -p hydrate-goproxy 14 | cd hydrate-goproxy 15 | go mod init hydrate-goproxy 16 | go get github.com/awslabs/operatorpkg@${GITHUB_SHA} -------------------------------------------------------------------------------- /.github/workflows/presubmit.yaml: -------------------------------------------------------------------------------- 1 | name: presubmit 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | workflow_dispatch: 7 | jobs: 8 | presubmit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - run: make presubmit -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @awslabs/operatorpkg -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MOD_DIRS = $(shell find . -path "./website" -prune -o -name go.mod -type f -print | xargs dirname) 2 | 3 | .PHONY: help 4 | help: ## Display help 5 | @awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 6 | 7 | .PHONY: presubmit 8 | presubmit: verify test ## Run before submitting code 9 | 10 | .PHONY: verify 11 | verify: tidy ## 12 | $(foreach dir,$(MOD_DIRS),cd $(dir) && go generate ./... $(newline)) 13 | $(foreach dir,$(MOD_DIRS),cd $(dir) && go vet ./... $(newline)) 14 | $(foreach dir,$(MOD_DIRS),cd $(dir) && go fmt ./... $(newline)) 15 | 16 | .PHONY: tidy 17 | tidy: ## Recursively "go mod tidy" on all directories where go.mod exists 18 | $(foreach dir,$(MOD_DIRS),cd $(dir) && go mod tidy $(newline)) 19 | 20 | .PHONY: test 21 | test: ## 22 | $(foreach dir,$(MOD_DIRS),cd $(dir) && go test ./... $(newline)) 23 | 24 | define newline 25 | 26 | 27 | endef -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Operatorpkg is a set of packages used to develop Kubernetes operators at AWS. It contains opinions on top of existing projects like https://github.com/kubernetes/apimachinery and https://github.com/kubernetes-sigs/controller-runtime. In many cases, we plan to mature packages in operatorpkg before commiting them upstream. 2 | 3 | We strive to maintain a relatively minimal dependency footprint, but some dependencies are necessary to provide value. 4 | 5 | ## Maintainers 6 | 7 | Maintainers are limited to AWS employees, but we may consider external contributions and bug fixes. This project is maintained in service of a set of projects well known to the maintainers. We will not consider feature requests unless they are in direct support of these projects. For example, we are delighted to accept contributions from the community behind https://github.com/kubernetes-sigs/karpenter. Before depending on this package, please speak with the maintainers. 8 | 9 | ## Versioning 10 | 11 | * We respect the standards defined at https://semver.org/. 12 | * We model releases using github tags and create branches for each minor version. 13 | * We use dependabot to keep dependencies up to date. 14 | * We do not guarantee that patches will be backported to minor versions. 15 | * SEMVER4: Major version zero (0.y.z) is for initial development. Anything MAY change at any time. The public API SHOULD NOT be considered stable. 16 | -------------------------------------------------------------------------------- /aws/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/awslabs/operatorpkg/aws 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.36.3 7 | github.com/aws/smithy-go v1.22.2 8 | github.com/awslabs/operatorpkg v0.0.0-20250414183006-52b415225a54 9 | github.com/samber/lo v1.49.1 10 | ) 11 | 12 | require ( 13 | github.com/beorn7/perks v1.0.1 // indirect 14 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 15 | github.com/go-logr/logr v1.4.2 // indirect 16 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 17 | github.com/prometheus/client_golang v1.21.1 // indirect 18 | github.com/prometheus/client_model v0.6.1 // indirect 19 | github.com/prometheus/common v0.62.0 // indirect 20 | github.com/prometheus/procfs v0.15.1 // indirect 21 | golang.org/x/sys v0.32.0 // indirect 22 | golang.org/x/text v0.23.0 // indirect 23 | google.golang.org/protobuf v1.36.5 // indirect 24 | k8s.io/client-go v0.32.3 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /aws/go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= 2 | github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= 3 | github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= 4 | github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 5 | github.com/awslabs/operatorpkg v0.0.0-20250414183006-52b415225a54 h1:nIOnLnyvDMAnaDAdrY2wV9Or6fRgbBtD9o+FGZK3MR8= 6 | github.com/awslabs/operatorpkg v0.0.0-20250414183006-52b415225a54/go.mod h1:YpeKKPpLRAeLzXrlEUALZn+KWqch4bKMLyvf54HCUB8= 7 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 8 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 9 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 10 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 11 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 14 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 15 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 16 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 17 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 18 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 19 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 20 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 21 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 22 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 24 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 25 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 26 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 27 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 28 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 29 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 30 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 31 | github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= 32 | github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= 33 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 34 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 35 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 36 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 37 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 38 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 39 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 40 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 41 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 42 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 43 | k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= 44 | k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= 45 | -------------------------------------------------------------------------------- /aws/metrics/emf.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "time" 7 | 8 | "github.com/awslabs/operatorpkg/metrics" 9 | "github.com/samber/lo" 10 | ) 11 | 12 | type EMF struct { 13 | writer io.Writer 14 | namespace string 15 | name string 16 | dimensions [][]string 17 | additionalProperties []map[string]string 18 | } 19 | 20 | func NewEMF(writer io.Writer, namespace, name string, dimensions [][]string, additionalProperties ...map[string]string) *EMF { 21 | return &EMF{writer: writer, namespace: namespace, name: name, dimensions: dimensions, additionalProperties: additionalProperties} 22 | } 23 | 24 | func (e *EMF) withDimensions(entry Entry, dimensions ...[]string) Entry { 25 | entry.AddDimensions(dimensions...) 26 | return entry 27 | } 28 | 29 | func (e *EMF) withProperties(entry Entry, labels map[string]string) Entry { 30 | for k, v := range labels { 31 | entry.AddProperty(k, v) 32 | } 33 | for _, m := range e.additionalProperties { 34 | for k, v := range m { 35 | entry.AddProperty(k, v) 36 | } 37 | } 38 | return entry 39 | } 40 | 41 | type EMFCounter struct { 42 | *EMF 43 | } 44 | 45 | func NewEMFCounter(writer io.Writer, namespace, name string, dimensions [][]string, additionalProperties ...map[string]string) metrics.CounterMetric { 46 | return &EMFCounter{EMF: NewEMF(writer, namespace, name, dimensions, additionalProperties...)} 47 | } 48 | 49 | func (e *EMFCounter) Inc(labels map[string]string) { 50 | entry := e.withDimensions(e.withProperties(NewEntry(e.namespace), labels), e.dimensions...) 51 | entry.AddMetric(e.name, 1) 52 | lo.Must(e.writer.Write([]byte(lo.Must(entry.Build()) + "\n"))) 53 | } 54 | 55 | func (e *EMFCounter) Add(v float64, labels map[string]string) { 56 | entry := e.withProperties(NewEntry(e.namespace), labels) 57 | entry.AddMetric(e.name, v) 58 | lo.Must(e.writer.Write([]byte(lo.Must(entry.Build()) + "\n"))) 59 | } 60 | 61 | func (e *EMFCounter) Delete(_ map[string]string) {} 62 | 63 | func (e *EMFCounter) DeletePartialMatch(_ map[string]string) {} 64 | 65 | func (e *EMF) Reset() {} 66 | 67 | type EMFGauge struct { 68 | *EMF 69 | } 70 | 71 | func NewEMFGauge(writer io.Writer, namespace, name string, dimensions [][]string, additionalProperties ...map[string]string) metrics.GaugeMetric { 72 | return &EMFGauge{EMF: NewEMF(writer, namespace, name, dimensions, additionalProperties...)} 73 | } 74 | 75 | func (e *EMFGauge) Set(v float64, labels map[string]string) { 76 | entry := e.withDimensions(e.withProperties(NewEntry(e.namespace), labels), e.dimensions...) 77 | entry.AddMetric(e.name, v) 78 | lo.Must(e.writer.Write([]byte(lo.Must(entry.Build()) + "\n"))) 79 | } 80 | 81 | func (e *EMFGauge) Delete(_ map[string]string) {} 82 | 83 | func (e *EMFGauge) DeletePartialMatch(_ map[string]string) {} 84 | 85 | func (e *EMFGauge) Reset() {} 86 | 87 | type EMFObservation struct { 88 | *EMF 89 | } 90 | 91 | func NewEMFObservation(writer io.Writer, namespace, name string, dimensions [][]string, additionalProperties ...map[string]string) metrics.ObservationMetric { 92 | return &EMFObservation{EMF: NewEMF(writer, namespace, name, dimensions, additionalProperties...)} 93 | } 94 | 95 | func (e *EMFObservation) Observe(v float64, labels map[string]string) { 96 | entry := e.withDimensions(e.withProperties(NewEntry(e.namespace), labels), e.dimensions...) 97 | entry.AddMetric(e.name, v) 98 | lo.Must(e.writer.Write([]byte(lo.Must(entry.Build()) + "\n"))) 99 | } 100 | 101 | func (e *EMFObservation) Delete(_ map[string]string) { 102 | 103 | } 104 | 105 | func (e *EMFObservation) DeletePartialMatch(_ map[string]string) { 106 | 107 | } 108 | 109 | func (e *EMFObservation) Reset() { 110 | 111 | } 112 | 113 | // Copied from https://github.com/aws/aws-sdk-go-v2/blob/v1.32.0/aws/middleware/private/metrics/emf/emf.go#L23 114 | // We needed to make edits to this code since we need to be able to represent different sets of dimensions 115 | 116 | const ( 117 | emfIdentifier = "_aws" 118 | timestampKey = "Timestamp" 119 | cloudWatchMetricsKey = "CloudWatchMetrics" 120 | namespaceKey = "Namespace" 121 | dimensionsKey = "Dimensions" 122 | metricsKey = "Metrics" 123 | ) 124 | 125 | // Entry represents a log entry in the EMF format. 126 | type Entry struct { 127 | namespace string 128 | metrics []metric 129 | dimensions [][]string 130 | fields map[string]interface{} 131 | } 132 | 133 | type metric struct { 134 | Name string 135 | } 136 | 137 | // NewEntry creates a new Entry with the specified namespace and serializer. 138 | func NewEntry(namespace string) Entry { 139 | return Entry{ 140 | namespace: namespace, 141 | metrics: []metric{}, 142 | dimensions: [][]string{}, 143 | fields: map[string]interface{}{}, 144 | } 145 | } 146 | 147 | // Build constructs the EMF log entry as a JSON string. 148 | func (e *Entry) Build() (string, error) { 149 | 150 | entry := map[string]interface{}{} 151 | 152 | entry[emfIdentifier] = map[string]interface{}{ 153 | timestampKey: time.Now().UnixNano() / 1e6, 154 | cloudWatchMetricsKey: []map[string]interface{}{ 155 | { 156 | namespaceKey: e.namespace, 157 | dimensionsKey: e.dimensions, 158 | metricsKey: e.metrics, 159 | }, 160 | }, 161 | } 162 | 163 | for k, v := range e.fields { 164 | entry[k] = v 165 | } 166 | 167 | jsonEntry, err := json.Marshal(entry) 168 | if err != nil { 169 | return "", err 170 | } 171 | return string(jsonEntry), nil 172 | } 173 | 174 | // AddDimensions adds a CW Dimension to the EMF entry. 175 | func (e *Entry) AddDimensions(dimensions ...[]string) { 176 | // Dimensions are a list of lists. We only support a single list. 177 | e.dimensions = append(e.dimensions, dimensions...) 178 | } 179 | 180 | // AddMetric adds a CW Metric to the EMF entry. 181 | func (e *Entry) AddMetric(key string, value float64) { 182 | e.metrics = append(e.metrics, metric{key}) 183 | e.fields[key] = value 184 | } 185 | 186 | // AddProperty adds a CW Property to the EMF entry. 187 | // Properties are not published as metrics, but they are available in logs and in CW insights. 188 | func (e *Entry) AddProperty(key string, value interface{}) { 189 | e.fields[key] = value 190 | } 191 | -------------------------------------------------------------------------------- /aws/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/aws/aws-sdk-go-v2/aws/transport/http" 8 | "github.com/aws/smithy-go" 9 | "github.com/aws/smithy-go/middleware" 10 | "github.com/awslabs/operatorpkg/serrors" 11 | ) 12 | 13 | const ( 14 | AWSRequestIDLogKey = "aws-request-id" 15 | AWSStatusCodeLogKey = "aws-status-code" 16 | AWSServiceNameLogKey = "aws-service-name" 17 | AWSOperationNameLogKey = "aws-operation-name" 18 | AWSErrorCodeLogKey = "aws-error-code" 19 | ) 20 | 21 | // StructuredErrorHandler injects structured keys and values into the error returned by the AWS request 22 | // It doesn't modify the error message so error messages will still contain the structured values 23 | var StructuredErrorHandler = func(stack *middleware.Stack) error { 24 | return stack.Deserialize.Add(middleware.DeserializeMiddlewareFunc("StructuredErrorHandler", func(ctx context.Context, in middleware.DeserializeInput, next middleware.DeserializeHandler) (middleware.DeserializeOutput, middleware.Metadata, error) { 25 | out, metadata, err := next.HandleDeserialize(ctx, in) 26 | if err == nil { 27 | return out, metadata, nil 28 | } 29 | values := []any{AWSServiceNameLogKey, middleware.GetServiceID(ctx), AWSOperationNameLogKey, middleware.GetOperationName(ctx)} 30 | temp := err 31 | for temp != nil { 32 | if v, ok := temp.(*http.ResponseError); ok { 33 | values = append(values, AWSRequestIDLogKey, v.RequestID, AWSStatusCodeLogKey, v.Response.StatusCode) 34 | } 35 | if v, ok := temp.(*smithy.GenericAPIError); ok { 36 | values = append(values, AWSErrorCodeLogKey, v.Code) 37 | } 38 | temp = errors.Unwrap(temp) 39 | } 40 | return out, metadata, serrors.Wrap(err, values...) 41 | }), middleware.Before) 42 | } 43 | -------------------------------------------------------------------------------- /context/context.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | 7 | "github.com/go-logr/zapr" 8 | "github.com/samber/lo" 9 | "go.uber.org/zap" 10 | "k8s.io/klog/v2" 11 | controllerruntime "sigs.k8s.io/controller-runtime" 12 | "sigs.k8s.io/controller-runtime/pkg/log" 13 | ) 14 | 15 | type Context = context.Context 16 | 17 | func New() context.Context { 18 | ctx := controllerruntime.SetupSignalHandler() 19 | logger := zapr.NewLogger(lo.Must(zap.NewDevelopment())) 20 | klog.SetLogger(logger) 21 | log.SetLogger(logger) 22 | ctx = Into(ctx, &logger) 23 | return ctx 24 | } 25 | 26 | func Into[T any](parent context.Context, v *T) context.Context { 27 | return context.WithValue(parent, reflect.TypeOf((*T)(nil)), v) 28 | } 29 | 30 | func From[T any](ctx context.Context) *T { 31 | v, _ := ctx.Value(reflect.TypeOf((*T)(nil))).(*T) 32 | return v 33 | } 34 | -------------------------------------------------------------------------------- /controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | 6 | "sigs.k8s.io/controller-runtime/pkg/manager" 7 | ) 8 | 9 | // Controller is a reconciler that allows registration with a controller-runtime Manager 10 | type Controller interface { 11 | Register(context.Context, manager.Manager) error 12 | } 13 | -------------------------------------------------------------------------------- /env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | // WithDefaultInt returns the int value of the supplied environment variable or, if not present, 10 | // the supplied default value. If the int conversion fails, returns the default 11 | func WithDefaultInt(key string, def int) int { 12 | val, ok := os.LookupEnv(key) 13 | if !ok { 14 | return def 15 | } 16 | i, err := strconv.Atoi(val) 17 | if err != nil { 18 | return def 19 | } 20 | return i 21 | } 22 | 23 | // WithDefaultInt64 returns the int value of the supplied environment variable or, if not present, 24 | // the supplied default value. If the int conversion fails, returns the default 25 | func WithDefaultInt64(key string, def int64) int64 { 26 | val, ok := os.LookupEnv(key) 27 | if !ok { 28 | return def 29 | } 30 | i, err := strconv.ParseInt(val, 10, 64) 31 | if err != nil { 32 | return def 33 | } 34 | return i 35 | } 36 | 37 | // WithDefaultString returns the string value of the supplied environment variable or, if not present, 38 | // the supplied default value. 39 | func WithDefaultString(key string, def string) string { 40 | val, ok := os.LookupEnv(key) 41 | if !ok { 42 | return def 43 | } 44 | return val 45 | } 46 | 47 | // WithDefaultBool returns the boolean value of the supplied environment variable or, if not present, 48 | // the supplied default value. 49 | func WithDefaultBool(key string, def bool) bool { 50 | val, ok := os.LookupEnv(key) 51 | if !ok { 52 | return def 53 | } 54 | parsedVal, err := strconv.ParseBool(val) 55 | if err != nil { 56 | return def 57 | } 58 | return parsedVal 59 | } 60 | 61 | // WithDefaultDuration returns the duration value of the supplied environment variable or, if not present, 62 | // the supplied default value. 63 | func WithDefaultDuration(key string, def time.Duration) time.Duration { 64 | val, ok := os.LookupEnv(key) 65 | if !ok { 66 | return def 67 | } 68 | parsedVal, err := time.ParseDuration(val) 69 | if err != nil { 70 | return def 71 | } 72 | return parsedVal 73 | } 74 | -------------------------------------------------------------------------------- /events/controller.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | pmetrics "github.com/awslabs/operatorpkg/metrics" 10 | "github.com/awslabs/operatorpkg/object" 11 | v1 "k8s.io/api/core/v1" 12 | "k8s.io/apimachinery/pkg/runtime/schema" 13 | "k8s.io/utils/clock" 14 | controllerruntime "sigs.k8s.io/controller-runtime" 15 | "sigs.k8s.io/controller-runtime/pkg/builder" 16 | "sigs.k8s.io/controller-runtime/pkg/client" 17 | "sigs.k8s.io/controller-runtime/pkg/controller" 18 | "sigs.k8s.io/controller-runtime/pkg/manager" 19 | "sigs.k8s.io/controller-runtime/pkg/predicate" 20 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 21 | ) 22 | 23 | type Controller[T client.Object] struct { 24 | gvk schema.GroupVersionKind 25 | startTime time.Time 26 | kubeClient client.Client 27 | EventCount pmetrics.CounterMetric 28 | } 29 | 30 | func NewController[T client.Object](client client.Client, clock clock.Clock) *Controller[T] { 31 | gvk := object.GVK(object.New[T]()) 32 | return &Controller[T]{ 33 | gvk: gvk, 34 | startTime: clock.Now(), 35 | kubeClient: client, 36 | EventCount: eventTotalMetric(strings.ToLower(gvk.Kind)), 37 | } 38 | } 39 | 40 | func (c *Controller[T]) Register(_ context.Context, m manager.Manager) error { 41 | return controllerruntime.NewControllerManagedBy(m). 42 | For(&v1.Event{}, builder.WithPredicates(predicate.NewTypedPredicateFuncs(func(o client.Object) bool { 43 | // Only reconcile on the object kind we care about 44 | event := o.(*v1.Event) 45 | return event.InvolvedObject.Kind == c.gvk.Kind && event.InvolvedObject.APIVersion == c.gvk.GroupVersion().String() 46 | }))). 47 | WithOptions(controller.Options{MaxConcurrentReconciles: 10}). 48 | Named(fmt.Sprintf("operatorpkg.%s.events", strings.ToLower(c.gvk.Kind))). 49 | Complete(reconcile.AsReconciler(m.GetClient(), c)) 50 | } 51 | 52 | func (c *Controller[T]) Reconcile(ctx context.Context, event *v1.Event) (reconcile.Result, error) { 53 | // We check if the event was created in the lifetime of this controller 54 | // since we don't duplicate metrics on controller restart or lease handover 55 | if c.startTime.Before(event.LastTimestamp.Time) { 56 | c.EventCount.Inc(map[string]string{ 57 | pmetrics.LabelType: event.Type, 58 | pmetrics.LabelReason: event.Reason, 59 | }) 60 | } 61 | 62 | return reconcile.Result{}, nil 63 | } 64 | -------------------------------------------------------------------------------- /events/metrics.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | pmetrics "github.com/awslabs/operatorpkg/metrics" 5 | "github.com/prometheus/client_golang/prometheus" 6 | "sigs.k8s.io/controller-runtime/pkg/metrics" 7 | ) 8 | 9 | func eventTotalMetric(objectName string) pmetrics.CounterMetric { 10 | return pmetrics.NewPrometheusCounter( 11 | metrics.Registry, 12 | prometheus.CounterOpts{ 13 | Namespace: pmetrics.Namespace, 14 | Subsystem: objectName, 15 | Name: "event_total", 16 | Help: "The total of events of a given type for an object.", 17 | }, 18 | []string{ 19 | pmetrics.LabelType, 20 | pmetrics.LabelReason, 21 | }, 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /events/suite_test.go: -------------------------------------------------------------------------------- 1 | package events_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/awslabs/operatorpkg/events" 10 | pmetrics "github.com/awslabs/operatorpkg/metrics" 11 | "github.com/awslabs/operatorpkg/object" 12 | "github.com/awslabs/operatorpkg/test" 13 | . "github.com/awslabs/operatorpkg/test/expectations" 14 | "github.com/onsi/ginkgo/v2" 15 | . "github.com/onsi/ginkgo/v2" 16 | . "github.com/onsi/gomega" 17 | "github.com/samber/lo" 18 | corev1 "k8s.io/api/core/v1" 19 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 20 | "k8s.io/apimachinery/pkg/runtime" 21 | "k8s.io/apimachinery/pkg/runtime/schema" 22 | "k8s.io/client-go/kubernetes/scheme" 23 | clock "k8s.io/utils/clock/testing" 24 | "sigs.k8s.io/controller-runtime/pkg/client" 25 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 26 | "sigs.k8s.io/controller-runtime/pkg/log" 27 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 28 | ) 29 | 30 | var ( 31 | SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error { 32 | scheme.AddKnownTypes(schema.GroupVersion{Group: test.APIGroup, Version: "v1alpha1"}, &test.CustomObject{}) 33 | return nil 34 | }) 35 | ) 36 | 37 | var ctx context.Context 38 | var fakeClock *clock.FakeClock 39 | var controller *events.Controller[*test.CustomObject] 40 | var kubeClient client.Client 41 | 42 | func Test(t *testing.T) { 43 | lo.Must0(SchemeBuilder.AddToScheme(scheme.Scheme)) 44 | RegisterFailHandler(Fail) 45 | RunSpecs(t, "Events") 46 | } 47 | 48 | var _ = BeforeSuite(func() { 49 | fakeClock = clock.NewFakeClock(time.Now()) 50 | kubeClient = fake.NewClientBuilder().WithScheme(scheme.Scheme).WithIndex(&corev1.Event{}, "involvedObject.kind", func(o client.Object) []string { 51 | evt := o.(*corev1.Event) 52 | return []string{evt.InvolvedObject.Kind} 53 | }).Build() 54 | controller = events.NewController[*test.CustomObject](kubeClient, fakeClock) 55 | ctx = log.IntoContext(context.Background(), ginkgo.GinkgoLogr) 56 | }) 57 | 58 | var _ = Describe("Controller", func() { 59 | BeforeEach(func() { 60 | controller.EventCount.Reset() 61 | }) 62 | It("should emit metrics on an event", func() { 63 | events := []*corev1.Event{} 64 | 65 | for i := range 5 { 66 | // create an event for custom object 67 | events = append(events, createEvent("test-object", fmt.Sprintf("Test-type-%d", i), fmt.Sprintf("Test-reason-%d", i))) 68 | ExpectApplied(ctx, kubeClient, events[i]) 69 | 70 | // expect an metrics for custom object to be zero, waiting on controller reconcile 71 | Expect(GetMetric("operator_customobject_event_total", conditionLabels(fmt.Sprintf("Test-type-%d", i), fmt.Sprintf("Test-reason-%d", i)))).To(BeNil()) 72 | 73 | // reconcile on the event 74 | _, err := reconcile.AsReconciler(kubeClient, controller).Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(events[i])}) 75 | Expect(err).ToNot(HaveOccurred()) 76 | 77 | // expect an emitted metric to for the event 78 | Expect(GetMetric("operator_customobject_event_total", conditionLabels(fmt.Sprintf("Test-type-%d", i), fmt.Sprintf("Test-reason-%d", i))).GetCounter().GetValue()).To(BeEquivalentTo(1)) 79 | } 80 | }) 81 | It("should not fire metrics if the last transition was before controller start-up", func() { 82 | // create an event for custom object that was produced before the controller start-up time 83 | event := createEvent("test-name", corev1.EventTypeNormal, "reason") 84 | event.LastTimestamp.Time = time.Now().Add(30 * time.Minute) 85 | ExpectApplied(ctx, kubeClient, event) 86 | 87 | // expect an metrics for custom object to be zero, waiting on controller reconcile 88 | Expect(GetMetric("operator_ustomobject_event_total", conditionLabels(corev1.EventTypeNormal, "reason"))).To(BeNil()) 89 | 90 | // reconcile on the event 91 | _, err := reconcile.AsReconciler(kubeClient, controller).Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(event)}) 92 | Expect(err).ToNot(HaveOccurred()) 93 | 94 | // expect not have an emitted metric to for the event 95 | Expect(GetMetric("operator_customobject_event_total", conditionLabels(corev1.EventTypeNormal, "reason")).GetCounter().GetValue()).To(BeEquivalentTo(1)) 96 | 97 | // create an event for custom object that was produced after the controller start-up time 98 | event.LastTimestamp.Time = time.Now().Add(-30 * time.Minute) 99 | ExpectApplied(ctx, kubeClient, event) 100 | 101 | // reconcile on the event 102 | _, err = reconcile.AsReconciler(kubeClient, controller).Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(event)}) 103 | Expect(err).ToNot(HaveOccurred()) 104 | 105 | // expect an emitted metric to for the event 106 | Expect(GetMetric("operator_customobject_event_total", conditionLabels(corev1.EventTypeNormal, "reason")).GetCounter().GetValue()).To(BeEquivalentTo(1)) 107 | }) 108 | }) 109 | 110 | func createEvent(name string, eventType string, reason string) *corev1.Event { 111 | return &corev1.Event{ 112 | ObjectMeta: metav1.ObjectMeta{ 113 | Name: test.RandomName(), 114 | }, 115 | InvolvedObject: corev1.ObjectReference{ 116 | Namespace: "default", 117 | Name: name, 118 | Kind: object.GVK(&test.CustomObject{}).Kind, 119 | }, 120 | LastTimestamp: metav1.Time{Time: time.Now().Add(30 * time.Minute)}, 121 | Type: eventType, 122 | Reason: reason, 123 | Count: 5, 124 | } 125 | } 126 | 127 | func conditionLabels(eventType string, reason string) map[string]string { 128 | return map[string]string{ 129 | pmetrics.LabelType: eventType, 130 | pmetrics.LabelReason: reason, 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/awslabs/operatorpkg 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/Pallinder/go-randomdata v1.2.0 7 | github.com/go-logr/logr v1.4.3 8 | github.com/go-logr/zapr v1.3.0 9 | github.com/imdario/mergo v0.3.16 10 | github.com/onsi/ginkgo/v2 v2.23.4 11 | github.com/onsi/gomega v1.37.0 12 | github.com/prometheus/client_golang v1.22.0 13 | github.com/prometheus/client_model v0.6.2 14 | github.com/samber/lo v1.50.0 15 | go.uber.org/multierr v1.11.0 16 | go.uber.org/zap v1.27.0 17 | golang.org/x/time v0.12.0 18 | k8s.io/api v0.33.1 19 | k8s.io/apimachinery v0.33.1 20 | k8s.io/client-go v0.33.1 21 | k8s.io/klog/v2 v2.130.1 22 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 23 | sigs.k8s.io/controller-runtime v0.21.0 24 | sigs.k8s.io/yaml v1.4.0 25 | ) 26 | 27 | require ( 28 | github.com/beorn7/perks v1.0.1 // indirect 29 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 30 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 31 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 32 | github.com/evanphx/json-patch v5.6.0+incompatible // indirect 33 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 34 | github.com/fsnotify/fsnotify v1.7.0 // indirect 35 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 36 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 37 | github.com/go-openapi/jsonreference v0.20.2 // indirect 38 | github.com/go-openapi/swag v0.23.0 // indirect 39 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 40 | github.com/gogo/protobuf v1.3.2 // indirect 41 | github.com/google/btree v1.1.3 // indirect 42 | github.com/google/gnostic-models v0.6.9 // indirect 43 | github.com/google/go-cmp v0.7.0 // indirect 44 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect 45 | github.com/google/uuid v1.6.0 // indirect 46 | github.com/josharian/intern v1.0.0 // indirect 47 | github.com/json-iterator/go v1.1.12 // indirect 48 | github.com/mailru/easyjson v0.7.7 // indirect 49 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 50 | github.com/modern-go/reflect2 v1.0.2 // indirect 51 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 52 | github.com/pkg/errors v0.9.1 // indirect 53 | github.com/prometheus/common v0.62.0 // indirect 54 | github.com/prometheus/procfs v0.15.1 // indirect 55 | github.com/spf13/pflag v1.0.5 // indirect 56 | github.com/x448/float16 v0.8.4 // indirect 57 | go.uber.org/automaxprocs v1.6.0 // indirect 58 | golang.org/x/net v0.38.0 // indirect 59 | golang.org/x/oauth2 v0.27.0 // indirect 60 | golang.org/x/sync v0.12.0 // indirect 61 | golang.org/x/sys v0.32.0 // indirect 62 | golang.org/x/term v0.30.0 // indirect 63 | golang.org/x/text v0.23.0 // indirect 64 | golang.org/x/tools v0.31.0 // indirect 65 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 66 | google.golang.org/protobuf v1.36.6 // indirect 67 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 68 | gopkg.in/inf.v0 v0.9.1 // indirect 69 | gopkg.in/yaml.v3 v3.0.1 // indirect 70 | k8s.io/apiextensions-apiserver v0.33.0 // indirect 71 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 72 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 73 | sigs.k8s.io/randfill v1.0.0 // indirect 74 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 75 | ) 76 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Pallinder/go-randomdata v1.2.0 h1:DZ41wBchNRb/0GfsePLiSwb0PHZmT67XY00lCDlaYPg= 2 | github.com/Pallinder/go-randomdata v1.2.0/go.mod h1:yHmJgulpD2Nfrm0cR9tI/+oAgRqCQQixsA8HyRZfV9Y= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= 6 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 7 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 15 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 16 | github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= 17 | github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= 18 | github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= 19 | github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= 20 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 21 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 22 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 23 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 24 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 25 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 26 | github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= 27 | github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= 28 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 29 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 30 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 31 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 32 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 33 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 34 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 35 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 36 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 37 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 38 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 39 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 40 | github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= 41 | github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 42 | github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= 43 | github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= 44 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 45 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 46 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 47 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 48 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 49 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 50 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= 51 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 52 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 53 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 54 | github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 55 | github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= 56 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 57 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 58 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 59 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 60 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 61 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 62 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 63 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 64 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 65 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 66 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 67 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 68 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 69 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 70 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 71 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 72 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 73 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 74 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 75 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 76 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 77 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 78 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 79 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 80 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 81 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 82 | github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= 83 | github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= 84 | github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 85 | github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 86 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 87 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 88 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 89 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 90 | github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= 91 | github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= 92 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 93 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 94 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 95 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 96 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 97 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 98 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 99 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 100 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 101 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 102 | github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY= 103 | github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc= 104 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 105 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 106 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 107 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 108 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 109 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 110 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 111 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 112 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 113 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 114 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 115 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 116 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 117 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 118 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 119 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 120 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 121 | go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= 122 | go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= 123 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 124 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 125 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 126 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 127 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 128 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 129 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 130 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 131 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 132 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 133 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 134 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 135 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 136 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 137 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 138 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 139 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 140 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 141 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 142 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 143 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 144 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 145 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 146 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 147 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 148 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 149 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 150 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 151 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 152 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 153 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 154 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 155 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 156 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 157 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 158 | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 159 | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 160 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 161 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 162 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 163 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 164 | golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= 165 | golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= 166 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 167 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 168 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 169 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 170 | gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= 171 | gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= 172 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 173 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 174 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 175 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 176 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 177 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 178 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 179 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 180 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 181 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 182 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 183 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 184 | k8s.io/api v0.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw= 185 | k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= 186 | k8s.io/apiextensions-apiserver v0.33.0 h1:d2qpYL7Mngbsc1taA4IjJPRJ9ilnsXIrndH+r9IimOs= 187 | k8s.io/apiextensions-apiserver v0.33.0/go.mod h1:VeJ8u9dEEN+tbETo+lFkwaaZPg6uFKLGj5vyNEwwSzc= 188 | k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4= 189 | k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= 190 | k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4= 191 | k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= 192 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 193 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 194 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= 195 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= 196 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= 197 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 198 | sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= 199 | sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= 200 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= 201 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= 202 | sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 203 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 204 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 205 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= 206 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= 207 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 208 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 209 | -------------------------------------------------------------------------------- /leaderelection/leasehijacker.go: -------------------------------------------------------------------------------- 1 | package leaderelection 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/samber/lo" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/util/uuid" 12 | coordinationv1client "k8s.io/client-go/kubernetes/typed/coordination/v1" 13 | corev1client "k8s.io/client-go/kubernetes/typed/core/v1" 14 | "k8s.io/client-go/rest" 15 | "k8s.io/client-go/tools/leaderelection/resourcelock" 16 | "sigs.k8s.io/controller-runtime/pkg/log" 17 | ) 18 | 19 | /* 20 | LeaseHijacker implements lease stealing to accelerate development workflows. 21 | When starting your controller manager, your local process will forcibly 22 | become leader. This is useful when developing locally against a cluster 23 | which already has a controller running in it 24 | 25 | TODO: migrate to https://kubernetes.io/docs/concepts/cluster-administration/coordinated-leader-election/ when it's past alpha. 26 | 27 | Include this in your controller manager as follows: 28 | ``` 29 | controllerruntime.NewManager(..., controllerruntime.Options{ 30 | 31 | // Used if HIJACK_LEASE is not set 32 | LeaderElectionID: name, 33 | LeaderElectionNamespace: "namespace", 34 | 35 | // Used if HIJACK_LEASE=true 36 | LeaderElectionResourceLockInterface: leaderelection.LeaseHijacker(...) 37 | } 38 | ``` 39 | */ 40 | func LeaseHijacker(ctx context.Context, config *rest.Config, namespace string, name string) resourcelock.Interface { 41 | if os.Getenv("HIJACK_LEASE") != "true" { 42 | return nil // If not set, fallback to other controller-runtime lease settings 43 | } 44 | kubeClient := coordinationv1client.NewForConfigOrDie(config) 45 | lease := lo.Must(kubeClient.Leases(namespace).Get(ctx, name, metav1.GetOptions{})) 46 | 47 | untilElection := time.Until(lease.Spec.RenewTime.Add(time.Duration(*lease.Spec.LeaseDurationSeconds) * time.Second)) 48 | 49 | lease.Spec.HolderIdentity = lo.ToPtr(fmt.Sprintf("%s_%s", lo.Must(os.Hostname()), uuid.NewUUID())) 50 | lease.Spec.AcquireTime = lo.ToPtr(metav1.NowMicro()) 51 | lease.Spec.RenewTime = lo.ToPtr(metav1.NowMicro()) 52 | *lease.Spec.LeaseDurationSeconds += 5 // Make our lease longer to guarantee we win the next election 53 | *lease.Spec.LeaseTransitions += 1 54 | lo.Must(kubeClient.Leases(namespace).Update(ctx, lease, metav1.UpdateOptions{})) 55 | 56 | log.FromContext(ctx).Info(fmt.Sprintf("hijacked lease, waiting %s for election", untilElection), "namespace", namespace, "name", name) 57 | time.Sleep(untilElection) 58 | 59 | return lo.Must(resourcelock.New( 60 | resourcelock.LeasesResourceLock, 61 | namespace, 62 | name, 63 | corev1client.NewForConfigOrDie(config), 64 | coordinationv1client.NewForConfigOrDie(config), 65 | resourcelock.ResourceLockConfig{Identity: *lease.Spec.HolderIdentity}, 66 | )) 67 | } 68 | -------------------------------------------------------------------------------- /metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | "strings" 7 | "time" 8 | 9 | "github.com/prometheus/client_golang/prometheus" 10 | "github.com/samber/lo" 11 | clientmetrics "k8s.io/client-go/tools/metrics" 12 | ) 13 | 14 | // This package adds client-go metrics that can be surfaced through the Prometheus metrics server 15 | // This is based on the reference implementation that was pulled out in controller-runtime in https://github.com/kubernetes-sigs/controller-runtime/pull/2298 16 | 17 | // RegisterClientMetrics sets up the client latency and result metrics from client-go. 18 | func RegisterClientMetrics(r prometheus.Registerer) { 19 | clientmetrics.RequestLatency = &LatencyAdapter{Metric: NewPrometheusHistogram( 20 | r, 21 | prometheus.HistogramOpts{ 22 | Name: "client_go_request_duration_seconds", 23 | Help: "Request latency in seconds. Broken down by verb, group, version, kind, and subresource.", 24 | Buckets: prometheus.ExponentialBuckets(0.001, 1.5, 20), 25 | }, 26 | []string{"verb", "group", "version", "kind", "subresource"}, 27 | )} 28 | clientmetrics.RequestResult = &ResultAdapter{Metric: NewPrometheusCounter( 29 | r, 30 | prometheus.CounterOpts{ 31 | Name: "client_go_request_total", 32 | Help: "Number of HTTP requests, partitioned by status code and method.", 33 | }, 34 | []string{"code", "method"}, 35 | )} 36 | } 37 | 38 | type ResultAdapter struct { 39 | Metric CounterMetric 40 | } 41 | 42 | func (r *ResultAdapter) Increment(_ context.Context, code, method, _ string) { 43 | r.Metric.Inc(map[string]string{"code": code, "method": method}) 44 | } 45 | 46 | // LatencyAdapter implements LatencyMetric. 47 | type LatencyAdapter struct { 48 | Metric ObservationMetric 49 | } 50 | 51 | // Observe increments the request latency metric for the given verb/group/version/kind/subresource. 52 | func (l *LatencyAdapter) Observe(_ context.Context, verb string, u url.URL, latency time.Duration) { 53 | if data := parsePath(u.Path); data != nil { 54 | // We update the "verb" to better reflect the action being taken by client-go 55 | switch verb { 56 | case "POST": 57 | verb = "CREATE" 58 | case "GET": 59 | if !strings.Contains(u.Path, "{name}") { 60 | verb = "LIST" 61 | } 62 | case "PUT": 63 | if !strings.Contains(u.Path, "{name}") { 64 | verb = "CREATE" 65 | } else { 66 | verb = "UPDATE" 67 | } 68 | } 69 | l.Metric.Observe(latency.Seconds(), map[string]string{ 70 | "verb": verb, 71 | "group": data.group, 72 | "version": data.version, 73 | "kind": data.kind, 74 | "subresource": data.subresource, 75 | }) 76 | } 77 | } 78 | 79 | // pathData stores data parsed out from the URL path 80 | type pathData struct { 81 | group string 82 | version string 83 | kind string 84 | subresource string 85 | } 86 | 87 | // parsePath parses out the URL called from client-go to return back the group, version, kind, and subresource 88 | // urls are formatted similar to /apis/coordination.k8s.io/v1/namespaces/{namespace}/leases/{name} or /apis/karpenter.sh/v1beta1/nodeclaims/{name} 89 | func parsePath(path string) *pathData { 90 | parts := strings.Split(path, "/")[1:] 91 | 92 | var groupIdx, versionIdx, kindIdx int 93 | switch parts[0] { 94 | case "api": 95 | groupIdx = 0 96 | case "apis": 97 | groupIdx = 1 98 | default: 99 | return nil 100 | } 101 | // If the url is too short, then it's not interesting to us 102 | if len(parts) < groupIdx+3 { 103 | return nil 104 | } 105 | // This resource is namespaced and the resource is not the namespace 106 | if parts[groupIdx+2] == "namespaces" && len(parts) > groupIdx+4 { 107 | versionIdx = groupIdx + 1 108 | kindIdx = versionIdx + 3 109 | } else { 110 | versionIdx = groupIdx + 1 111 | kindIdx = versionIdx + 1 112 | } 113 | 114 | // If we have a subresource, it's going to be two indices after the kind 115 | var subresource string 116 | if len(parts) == kindIdx+3 { 117 | subresource = parts[kindIdx+2] 118 | } 119 | return &pathData{ 120 | // If the group index is 0, this is part of the core API, so there's no group 121 | group: lo.Ternary(groupIdx == 0, "", parts[groupIdx]), 122 | version: parts[versionIdx], 123 | kind: parts[kindIdx], 124 | subresource: subresource, 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /metrics/multi.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | type MultiCounter struct { 4 | counters []CounterMetric 5 | } 6 | 7 | func NewMultiCounter(counters ...CounterMetric) CounterMetric { 8 | return &MultiCounter{counters: counters} 9 | } 10 | 11 | func (mc *MultiCounter) Inc(labels map[string]string) { 12 | for _, c := range mc.counters { 13 | c.Inc(labels) 14 | } 15 | } 16 | 17 | func (mc *MultiCounter) Add(v float64, labels map[string]string) { 18 | for _, c := range mc.counters { 19 | c.Add(v, labels) 20 | } 21 | } 22 | 23 | func (mc *MultiCounter) Delete(labels map[string]string) { 24 | for _, c := range mc.counters { 25 | c.Delete(labels) 26 | } 27 | } 28 | 29 | func (mc *MultiCounter) DeletePartialMatch(labels map[string]string) { 30 | for _, c := range mc.counters { 31 | c.DeletePartialMatch(labels) 32 | } 33 | } 34 | 35 | func (mc *MultiCounter) Reset() { 36 | for _, c := range mc.counters { 37 | c.Reset() 38 | } 39 | } 40 | 41 | type MultiGauge struct { 42 | gauges []GaugeMetric 43 | } 44 | 45 | func NewMultiGauge(gauges ...GaugeMetric) GaugeMetric { 46 | return &MultiGauge{gauges: gauges} 47 | } 48 | 49 | func (mg *MultiGauge) Set(v float64, labels map[string]string) { 50 | for _, g := range mg.gauges { 51 | g.Set(v, labels) 52 | } 53 | } 54 | 55 | func (mg *MultiGauge) Delete(labels map[string]string) { 56 | for _, g := range mg.gauges { 57 | g.Delete(labels) 58 | } 59 | } 60 | 61 | func (mg *MultiGauge) DeletePartialMatch(labels map[string]string) { 62 | for _, g := range mg.gauges { 63 | g.DeletePartialMatch(labels) 64 | } 65 | } 66 | 67 | func (mg *MultiGauge) Reset() { 68 | for _, g := range mg.gauges { 69 | g.Reset() 70 | } 71 | } 72 | 73 | type MultiObservation struct { 74 | observations []ObservationMetric 75 | } 76 | 77 | func NewMultiObservation(observations ...ObservationMetric) ObservationMetric { 78 | return &MultiObservation{observations: observations} 79 | } 80 | 81 | func (mo *MultiObservation) Observe(v float64, labels map[string]string) { 82 | for _, o := range mo.observations { 83 | o.Observe(v, labels) 84 | } 85 | } 86 | 87 | func (mo *MultiObservation) Delete(labels map[string]string) { 88 | for _, o := range mo.observations { 89 | o.Delete(labels) 90 | } 91 | } 92 | 93 | func (mo *MultiObservation) DeletePartialMatch(labels map[string]string) { 94 | for _, o := range mo.observations { 95 | o.DeletePartialMatch(labels) 96 | } 97 | } 98 | 99 | func (mo *MultiObservation) Reset() { 100 | for _, o := range mo.observations { 101 | o.Reset() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /metrics/prometheus.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | ) 6 | 7 | type PrometheusCounter struct { 8 | *prometheus.CounterVec 9 | } 10 | 11 | func NewPrometheusCounter(registry prometheus.Registerer, opts prometheus.CounterOpts, labelNames []string) CounterMetric { 12 | c := prometheus.NewCounterVec(opts, labelNames) 13 | registry.MustRegister(c) 14 | return &PrometheusCounter{CounterVec: c} 15 | } 16 | 17 | func (pc *PrometheusCounter) Inc(labels map[string]string) { 18 | pc.CounterVec.With(labels).Inc() 19 | } 20 | 21 | func (pc *PrometheusCounter) Add(v float64, labels map[string]string) { 22 | pc.CounterVec.With(labels).Add(v) 23 | } 24 | 25 | func (pc *PrometheusCounter) Delete(labels map[string]string) { 26 | pc.CounterVec.Delete(labels) 27 | } 28 | 29 | func (pc *PrometheusCounter) DeletePartialMatch(labels map[string]string) { 30 | pc.CounterVec.DeletePartialMatch(labels) 31 | } 32 | 33 | func (pc *PrometheusCounter) Reset() { 34 | pc.CounterVec.Reset() 35 | } 36 | 37 | type PrometheusGauge struct { 38 | *prometheus.GaugeVec 39 | } 40 | 41 | func NewPrometheusGauge(registry prometheus.Registerer, opts prometheus.GaugeOpts, labelNames []string) GaugeMetric { 42 | g := prometheus.NewGaugeVec(opts, labelNames) 43 | registry.MustRegister(g) 44 | return &PrometheusGauge{GaugeVec: g} 45 | } 46 | 47 | func (pg *PrometheusGauge) Set(v float64, labels map[string]string) { 48 | pg.GaugeVec.With(labels).Set(v) 49 | } 50 | 51 | func (pg *PrometheusGauge) Delete(labels map[string]string) { 52 | pg.GaugeVec.Delete(labels) 53 | } 54 | 55 | func (pg *PrometheusGauge) DeletePartialMatch(labels map[string]string) { 56 | pg.GaugeVec.DeletePartialMatch(labels) 57 | } 58 | 59 | func (pg *PrometheusGauge) Reset() { 60 | pg.GaugeVec.Reset() 61 | } 62 | 63 | type PrometheusHistogram struct { 64 | *prometheus.HistogramVec 65 | } 66 | 67 | func NewPrometheusHistogram(registry prometheus.Registerer, opts prometheus.HistogramOpts, labelNames []string) ObservationMetric { 68 | h := prometheus.NewHistogramVec(opts, labelNames) 69 | registry.MustRegister(h) 70 | return &PrometheusHistogram{HistogramVec: h} 71 | } 72 | 73 | func (ph *PrometheusHistogram) Observe(v float64, labels map[string]string) { 74 | ph.HistogramVec.With(labels).Observe(v) 75 | } 76 | 77 | func (ph *PrometheusHistogram) Delete(labels map[string]string) { 78 | ph.HistogramVec.Delete(labels) 79 | } 80 | 81 | func (ph *PrometheusHistogram) DeletePartialMatch(labels map[string]string) { 82 | ph.HistogramVec.DeletePartialMatch(labels) 83 | } 84 | 85 | func (ph *PrometheusHistogram) Reset() { 86 | ph.HistogramVec.Reset() 87 | } 88 | 89 | type PrometheusSummary struct { 90 | *prometheus.SummaryVec 91 | } 92 | 93 | func NewPrometheusSummary(registry prometheus.Registerer, opts prometheus.SummaryOpts, labelNames []string) ObservationMetric { 94 | s := prometheus.NewSummaryVec(opts, labelNames) 95 | registry.MustRegister(s) 96 | return &PrometheusSummary{SummaryVec: s} 97 | } 98 | 99 | func (ps *PrometheusSummary) Observe(v float64, labels map[string]string) { 100 | ps.SummaryVec.With(labels).Observe(v) 101 | } 102 | 103 | func (ps *PrometheusSummary) Delete(labels map[string]string) { 104 | ps.SummaryVec.Delete(labels) 105 | } 106 | 107 | func (ps *PrometheusSummary) DeletePartialMatch(labels map[string]string) { 108 | ps.SummaryVec.DeletePartialMatch(labels) 109 | } 110 | 111 | func (ps *PrometheusSummary) Reset() { 112 | ps.SummaryVec.Reset() 113 | } 114 | -------------------------------------------------------------------------------- /metrics/types.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | const ( 4 | Namespace = "operator" 5 | LabelGroup = "group" 6 | LabelKind = "kind" 7 | LabelType = "type" 8 | LabelReason = "reason" 9 | ) 10 | 11 | type ObservationMetric interface { 12 | Observe(v float64, labels map[string]string) 13 | Delete(labels map[string]string) 14 | DeletePartialMatch(labels map[string]string) 15 | Reset() 16 | } 17 | 18 | type CounterMetric interface { 19 | Add(v float64, labels map[string]string) 20 | Inc(labels map[string]string) 21 | Delete(labels map[string]string) 22 | DeletePartialMatch(labels map[string]string) 23 | Reset() 24 | } 25 | 26 | type GaugeMetric interface { 27 | Set(v float64, labels map[string]string) 28 | Delete(labels map[string]string) 29 | DeletePartialMatch(labels map[string]string) 30 | Reset() 31 | } 32 | -------------------------------------------------------------------------------- /mock/atomic.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "log" 7 | "sync" 8 | ) 9 | 10 | // atomicPtr is intended for use in mocks to easily expose variables for use in testing. It makes setting and retrieving 11 | // the values race free by wrapping the pointer itself in a mutex. There is no Get() method, but instead a Clone() method 12 | // deep copies the object being stored by serializing/de-serializing it from JSON. This pattern shouldn't be followed 13 | // anywhere else but is an easy way to eliminate races in our tests. 14 | type atomicPtr[T any] struct { 15 | mu sync.Mutex 16 | value *T 17 | } 18 | 19 | func (a *atomicPtr[T]) Set(v *T) { 20 | a.mu.Lock() 21 | defer a.mu.Unlock() 22 | a.value = v 23 | } 24 | 25 | func (a *atomicPtr[T]) IsNil() bool { 26 | a.mu.Lock() 27 | defer a.mu.Unlock() 28 | return a.value == nil 29 | } 30 | 31 | func (a *atomicPtr[T]) Clone() *T { 32 | a.mu.Lock() 33 | defer a.mu.Unlock() 34 | return clone(a.value) 35 | } 36 | 37 | func clone[T any](v *T) *T { 38 | var buf bytes.Buffer 39 | enc := json.NewEncoder(&buf) 40 | if err := enc.Encode(v); err != nil { 41 | log.Fatalf("encoding %T, %s", v, err) 42 | } 43 | dec := json.NewDecoder(&buf) 44 | var cp T 45 | if err := dec.Decode(&cp); err != nil { 46 | log.Fatalf("encoding %T, %s", v, err) 47 | } 48 | return &cp 49 | } 50 | 51 | func (a *atomicPtr[T]) Reset() { 52 | a.mu.Lock() 53 | defer a.mu.Unlock() 54 | a.value = nil 55 | } 56 | 57 | type atomicError struct { 58 | mu sync.Mutex 59 | err error 60 | 61 | calls int 62 | maxCalls int 63 | } 64 | 65 | func (e *atomicError) Reset() { 66 | e.mu.Lock() 67 | defer e.mu.Unlock() 68 | e.err = nil 69 | e.calls = 0 70 | e.maxCalls = 0 71 | } 72 | 73 | func (e *atomicError) IsNil() bool { 74 | e.mu.Lock() 75 | defer e.mu.Unlock() 76 | return e.err == nil 77 | } 78 | 79 | // Get is equivalent to the error being called, so we increase 80 | // number of calls in this function 81 | func (e *atomicError) Get() error { 82 | e.mu.Lock() 83 | defer e.mu.Unlock() 84 | if e.calls >= e.maxCalls { 85 | return nil 86 | } 87 | e.calls++ 88 | return e.err 89 | } 90 | 91 | func (e *atomicError) Set(err error, opts ...atomicErrorOption) { 92 | e.mu.Lock() 93 | defer e.mu.Unlock() 94 | e.err = err 95 | for _, opt := range opts { 96 | opt(e) 97 | } 98 | if e.maxCalls == 0 { 99 | e.maxCalls = 1 100 | } 101 | } 102 | 103 | type atomicErrorOption func(atomicError *atomicError) 104 | 105 | // atomicPtrSlice exposes a slice of a pointer type in a race-free manner. The interface is just enough to replace the 106 | // set.Set usage in our previous tests. 107 | type atomicPtrSlice[T any] struct { 108 | mu sync.RWMutex 109 | values []*T 110 | } 111 | 112 | func (a *atomicPtrSlice[T]) Reset() { 113 | a.mu.Lock() 114 | defer a.mu.Unlock() 115 | a.values = nil 116 | } 117 | 118 | func (a *atomicPtrSlice[T]) Add(input *T) { 119 | a.mu.Lock() 120 | defer a.mu.Unlock() 121 | a.values = append(a.values, clone(input)) 122 | } 123 | 124 | func (a *atomicPtrSlice[T]) Len() int { 125 | a.mu.RLock() 126 | defer a.mu.RUnlock() 127 | return len(a.values) 128 | } 129 | 130 | func (a *atomicPtrSlice[T]) Pop() *T { 131 | a.mu.Lock() 132 | defer a.mu.Unlock() 133 | last := a.values[len(a.values)-1] 134 | a.values = a.values[0 : len(a.values)-1] 135 | return last 136 | } 137 | 138 | func (a *atomicPtrSlice[T]) ForEach(fn func(*T)) { 139 | a.mu.RLock() 140 | defer a.mu.RUnlock() 141 | for _, t := range a.values { 142 | fn(clone(t)) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /mock/function.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "sync/atomic" 5 | ) 6 | 7 | type Function[I any, O any] struct { 8 | Output atomicPtr[O] // Output to return on call to this function 9 | CalledWithInput atomicPtrSlice[I] // Slice used to keep track of passed input to this function 10 | Error atomicError // Error to return a certain number of times defined by custom error options 11 | 12 | successfulCalls atomic.Int32 // Internal construct to keep track of the number of times this function has successfully been called 13 | failedCalls atomic.Int32 // Internal construct to keep track of the number of times this function has failed (with error) 14 | } 15 | 16 | // Reset must be called between tests otherwise tests will pollute 17 | // each other. 18 | func (m *Function[I, O]) Reset() { 19 | m.Output.Reset() 20 | m.CalledWithInput.Reset() 21 | m.Error.Reset() 22 | 23 | m.successfulCalls.Store(0) 24 | m.failedCalls.Store(0) 25 | } 26 | 27 | func (m *Function[I, O]) Invoke(input *I, defaultTransformer func(*I) (*O, error)) (*O, error) { 28 | err := m.Error.Get() 29 | if err != nil { 30 | m.failedCalls.Add(1) 31 | return nil, err 32 | } 33 | m.CalledWithInput.Add(input) 34 | 35 | if !m.Output.IsNil() { 36 | m.successfulCalls.Add(1) 37 | return m.Output.Clone(), nil 38 | } 39 | out, err := defaultTransformer(input) 40 | if err != nil { 41 | m.failedCalls.Add(1) 42 | } else { 43 | m.successfulCalls.Add(1) 44 | } 45 | return out, err 46 | } 47 | 48 | func (m *Function[I, O]) Calls() int { 49 | return m.SuccessfulCalls() + m.FailedCalls() 50 | } 51 | 52 | func (m *Function[I, O]) SuccessfulCalls() int { 53 | return int(m.successfulCalls.Load()) 54 | } 55 | 56 | func (m *Function[I, O]) FailedCalls() int { 57 | return int(m.failedCalls.Load()) 58 | } 59 | -------------------------------------------------------------------------------- /object/object.go: -------------------------------------------------------------------------------- 1 | package object 2 | 3 | import ( 4 | "fmt" 5 | "hash/fnv" 6 | "reflect" 7 | "strconv" 8 | 9 | "github.com/samber/lo" 10 | "k8s.io/apimachinery/pkg/runtime" 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | "k8s.io/apimachinery/pkg/types" 13 | "k8s.io/apimachinery/pkg/util/dump" 14 | "k8s.io/client-go/kubernetes/scheme" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | "sigs.k8s.io/controller-runtime/pkg/client/apiutil" 17 | "sigs.k8s.io/yaml" 18 | ) 19 | 20 | // GroupVersionKindNamespacedName uniquely identifies an object 21 | type GroupVersionKindNamespacedName struct { 22 | schema.GroupVersionKind 23 | types.NamespacedName 24 | } 25 | 26 | // GVKNN returns a GroupVersionKindNamespacedName that uniquely identifies the object 27 | func GVKNN(o client.Object) GroupVersionKindNamespacedName { 28 | return GroupVersionKindNamespacedName{ 29 | GroupVersionKind: GVK(o), 30 | NamespacedName: client.ObjectKeyFromObject(o), 31 | } 32 | } 33 | 34 | func (gvknn GroupVersionKindNamespacedName) String() string { 35 | str := fmt.Sprintf("%s/%s", gvknn.Group, gvknn.Kind) 36 | if gvknn.Namespace != "" { 37 | str += "/" + gvknn.Namespace 38 | } 39 | str += "/" + gvknn.Name 40 | return str 41 | } 42 | 43 | func GVK(o runtime.Object) schema.GroupVersionKind { 44 | return lo.Must(apiutil.GVKForObject(o, scheme.Scheme)) 45 | } 46 | 47 | func New[T any]() T { 48 | return reflect.New(reflect.TypeOf(*new(T)).Elem()).Interface().(T) 49 | } 50 | 51 | func Unmarshal[T any](raw []byte) *T { 52 | t := *new(T) 53 | lo.Must0(yaml.Unmarshal(raw, &t)) 54 | return &t 55 | } 56 | 57 | func Hash(o any) string { 58 | h := fnv.New64a() 59 | h.Reset() 60 | fmt.Fprintf(h, "%v", dump.ForHash(o)) 61 | return strconv.FormatUint(h.Sum64(), 10) 62 | } 63 | -------------------------------------------------------------------------------- /option/environment.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/samber/lo" 7 | ) 8 | 9 | func MustGetEnv(name string) string { 10 | env, exists := os.LookupEnv(name) 11 | lo.Must0(lo.Validate(exists, "env var %s must exist", name)) 12 | return env 13 | } 14 | -------------------------------------------------------------------------------- /option/function.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | type Function[T any] func(*T) 4 | 5 | func Resolve[T any](opts ...Function[T]) *T { 6 | o := new(T) 7 | for _, opt := range opts { 8 | if opt != nil { 9 | opt(o) 10 | } 11 | } 12 | return o 13 | } 14 | -------------------------------------------------------------------------------- /option/function_test.go: -------------------------------------------------------------------------------- 1 | package option_test 2 | 3 | import ( 4 | options "github.com/awslabs/operatorpkg/option" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | type Option struct { 10 | Foo bool 11 | Bar string 12 | Baz int 13 | } 14 | 15 | func FooOptions(o *Option) { 16 | o.Foo = true 17 | } 18 | func BarOptions(o *Option) { 19 | o.Bar = "bar" 20 | } 21 | 22 | func BazOptions(baz int) options.Function[Option] { 23 | return func(o *Option) { 24 | o.Baz = baz 25 | } 26 | } 27 | 28 | var _ = Describe("Function", func() { 29 | It("should resolve options", func() { 30 | Expect(options.Resolve( 31 | FooOptions, 32 | BarOptions, 33 | BazOptions(5), 34 | )).To(Equal(&Option{ 35 | Foo: true, 36 | Bar: "bar", 37 | Baz: 5, 38 | })) 39 | 40 | Expect(options.Resolve( 41 | FooOptions, 42 | BarOptions, 43 | )).To(Equal(&Option{ 44 | Foo: true, 45 | Bar: "bar", 46 | })) 47 | 48 | Expect(options.Resolve[Option]()).To(Equal(&Option{})) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /option/suite_test.go: -------------------------------------------------------------------------------- 1 | package option_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/onsi/ginkgo/v2" 7 | "github.com/onsi/gomega" 8 | ) 9 | 10 | func Test(t *testing.T) { 11 | gomega.RegisterFailHandler(ginkgo.Fail) 12 | ginkgo.RunSpecs(t, "Options") 13 | } 14 | -------------------------------------------------------------------------------- /reasonable/reasonable.go: -------------------------------------------------------------------------------- 1 | // reasonable contains a set of reasonable defaults that disagree with upstream alternatives 2 | package reasonable 3 | 4 | import ( 5 | "time" 6 | 7 | "golang.org/x/time/rate" 8 | "k8s.io/client-go/util/workqueue" 9 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 10 | ) 11 | 12 | func RateLimiter() workqueue.TypedRateLimiter[reconcile.Request] { 13 | return workqueue.NewTypedMaxOfRateLimiter[reconcile.Request]( 14 | workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](100*time.Millisecond, 1*time.Minute), 15 | &workqueue.TypedBucketRateLimiter[reconcile.Request]{Limiter: rate.NewLimiter(rate.Limit(10), 100)}, 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /serrors/logger.go: -------------------------------------------------------------------------------- 1 | package serrors 2 | 3 | import "github.com/go-logr/logr" 4 | 5 | // Logger is a structured error logger that can be used as a wrapper for other logr.Loggers 6 | // It unwraps the values for structured errors and calls WithValues() for them 7 | type Logger struct { 8 | name string 9 | sink logr.LogSink 10 | } 11 | 12 | // NewLogger creates a new log logr.Logger using the serrors.Logger 13 | func NewLogger(logger logr.Logger) logr.Logger { 14 | return logr.New(&Logger{sink: logger.GetSink()}) 15 | } 16 | 17 | func (l *Logger) Init(ri logr.RuntimeInfo) { 18 | l.sink.Init(ri) 19 | } 20 | 21 | func (l *Logger) Enabled(level int) bool { 22 | return l.sink.Enabled(level) 23 | } 24 | 25 | func (l *Logger) Info(level int, msg string, keysAndValues ...interface{}) { 26 | l.sink.Info(level, msg, keysAndValues...) 27 | } 28 | 29 | func (l *Logger) Error(err error, msg string, keysAndValues ...interface{}) { 30 | l.sink.Error(err, msg, append(keysAndValues, UnwrapValues(err)...)...) 31 | } 32 | 33 | func (l *Logger) WithValues(keysAndValues ...interface{}) logr.LogSink { 34 | return &Logger{name: l.name, sink: l.sink.WithValues(keysAndValues...)} 35 | } 36 | 37 | func (l *Logger) WithName(name string) logr.LogSink { 38 | return &Logger{name: name, sink: l.sink.WithName(name)} 39 | } 40 | -------------------------------------------------------------------------------- /serrors/serrors.go: -------------------------------------------------------------------------------- 1 | package serrors 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/samber/lo" 10 | "go.uber.org/multierr" 11 | ) 12 | 13 | // Error is a structured error that stores structured errors and values alongside the error 14 | type Error struct { 15 | error 16 | keysAndValues map[string]any 17 | } 18 | 19 | // Unwrap returns the unwrapped error 20 | func (e *Error) Unwrap() error { 21 | return e.error 22 | } 23 | 24 | // Error returns the string representation of the error 25 | func (e *Error) Error() string { 26 | var elems []string 27 | keys := lo.Keys(e.keysAndValues) 28 | sort.Strings(keys) // sort keys so we always get a consistent ordering 29 | for _, k := range keys { 30 | v := e.keysAndValues[k] 31 | elems = append(elems, fmt.Sprintf("%s=%v", k, v)) 32 | } 33 | return fmt.Sprintf("%s (%s)", e.error.Error(), strings.Join(elems, ", ")) 34 | } 35 | 36 | // WithValues injects additional structured keys and values into the error 37 | func (e *Error) WithValues(keysAndValues ...any) *Error { 38 | lo.Must0(validateKeysAndValues(keysAndValues)) 39 | if e.keysAndValues == nil { 40 | e.keysAndValues = map[string]any{} 41 | } 42 | for i := 0; i < len(keysAndValues); i += 2 { 43 | e.keysAndValues[keysAndValues[i].(string)] = keysAndValues[i+1] 44 | } 45 | return e 46 | } 47 | 48 | // Wrap wraps and existing error with additional structured keys and values 49 | func Wrap(err error, keysAndValues ...any) error { 50 | e := &Error{error: err} 51 | return e.WithValues(keysAndValues...) 52 | } 53 | 54 | func validateKeysAndValues(keysAndValues []any) error { 55 | if len(keysAndValues)%2 != 0 { 56 | return fmt.Errorf("keysAndValues must have an even number of elements") 57 | } 58 | for i := 0; i < len(keysAndValues); i += 2 { 59 | if _, ok := keysAndValues[i].(string); !ok { 60 | return fmt.Errorf("keys must be strings") 61 | } 62 | } 63 | return nil 64 | } 65 | 66 | // UnwrapValues returns a combined set of keys and values from every wrapped error 67 | func UnwrapValues(err error) (res []any) { 68 | values := map[string][]any{} 69 | for err != nil { 70 | for _, elem := range multierr.Errors(err) { 71 | if e, ok := elem.(*Error); ok { 72 | for k, v := range e.keysAndValues { 73 | if _, mOk := values[k]; mOk { 74 | values[k] = append(values[k], v) 75 | } else { 76 | values[k] = []any{v} 77 | } 78 | } 79 | } 80 | } 81 | err = errors.Unwrap(err) 82 | } 83 | if len(values) == 0 { 84 | return nil 85 | } 86 | keys := lo.Keys(values) 87 | sort.Strings(keys) // sort keys so we always get a consistent ordering 88 | for _, k := range keys { 89 | v := values[k] 90 | if len(v) == 1 { 91 | res = append(res, k, v[0]) 92 | } else { 93 | res = append(res, fmt.Sprintf("%ss", k), v) 94 | } 95 | } 96 | return res 97 | } 98 | -------------------------------------------------------------------------------- /serrors/suite_test.go: -------------------------------------------------------------------------------- 1 | package serrors_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/awslabs/operatorpkg/serrors" 10 | "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/ginkgo/v2" 12 | . "github.com/onsi/gomega" 13 | "go.uber.org/multierr" 14 | "k8s.io/klog/v2" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | "sigs.k8s.io/controller-runtime/pkg/log" 17 | ) 18 | 19 | var ctx context.Context 20 | var kubeClient client.Client 21 | 22 | func Test(t *testing.T) { 23 | RegisterFailHandler(Fail) 24 | RunSpecs(t, "Structured Errors") 25 | } 26 | 27 | var _ = BeforeSuite(func() { 28 | ctx = log.IntoContext(context.Background(), ginkgo.GinkgoLogr) 29 | }) 30 | 31 | var _ = Describe("Structured Errors", func() { 32 | It("should parse values from a structured error", func() { 33 | err := serrors.Wrap(fmt.Errorf("test"), "key1", "value1", "key2", "value2", "key3", "value3", "key4", "value4") 34 | values := serrors.UnwrapValues(err) 35 | Expect(values).To(HaveLen(8)) 36 | Expect(values).To(HaveExactElements("key1", "value1", "key2", "value2", "key3", "value3", "key4", "value4")) 37 | Expect(err.Error()).To(Equal("test (key1=value1, key2=value2, key3=value3, key4=value4)")) 38 | }) 39 | It("should handle a multierr with the same key", func() { 40 | var err error 41 | 42 | var parts []string 43 | for i := range 100 { 44 | err = multierr.Append(err, serrors.Wrap(fmt.Errorf("test error %d", i), "key", fmt.Sprintf("value%d", i))) 45 | parts = append(parts, fmt.Sprintf("test error %d (key=value%d)", i, i)) 46 | } 47 | values := serrors.UnwrapValues(err) 48 | Expect(values).To(HaveLen(2)) 49 | Expect(values[0]).To(Equal("keys")) 50 | Expect(values[1]).To(HaveLen(100)) 51 | 52 | Expect(err.Error()).To(Equal(strings.Join(parts, "; "))) 53 | }) 54 | It("should handle a multierr with the same key using klog.KRef", func() { 55 | var err error 56 | err = multierr.Append(err, serrors.Wrap(fmt.Errorf("test error"), "TestObject", klog.KRef("elem", "test-object-1"))) 57 | err = multierr.Append(err, serrors.Wrap(fmt.Errorf("test error"), "TestObject", klog.KRef("elem", "test-object-2"))) 58 | err = multierr.Append(err, serrors.Wrap(fmt.Errorf("test error"), "TestObject", klog.KRef("elem", "test-object-3"))) 59 | 60 | values := serrors.UnwrapValues(err) 61 | Expect(values).To(HaveLen(2)) 62 | Expect(values[0]).To(Equal("TestObjects")) 63 | Expect(values[1]).To(HaveLen(3)) 64 | 65 | Expect(err.Error()).To(Equal("test error (TestObject=elem/test-object-1); test error (TestObject=elem/test-object-2); test error (TestObject=elem/test-object-3)")) 66 | }) 67 | It("should handle a multierr that's wrapped with another structured error", func() { 68 | var err error 69 | for i := range 100 { 70 | err = multierr.Append(err, serrors.Wrap(fmt.Errorf("test error %d", i), "key", fmt.Sprintf("value%d", i))) 71 | } 72 | err = serrors.Wrap(fmt.Errorf("wrapped error 1, %w", err), "wrappedKey1", "wrappedValue1") 73 | err = serrors.Wrap(fmt.Errorf("wrapped error 2, %w", err), "wrappedKey2", "wrappedValue2") 74 | 75 | values := serrors.UnwrapValues(err) 76 | Expect(values).To(HaveLen(6)) 77 | Expect(values).To(ContainElements("keys", "wrappedKey1", "wrappedKey2")) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /singleton/controller.go: -------------------------------------------------------------------------------- 1 | package singleton 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "k8s.io/client-go/util/workqueue" 8 | "sigs.k8s.io/controller-runtime/pkg/event" 9 | "sigs.k8s.io/controller-runtime/pkg/handler" 10 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 11 | "sigs.k8s.io/controller-runtime/pkg/source" 12 | ) 13 | 14 | const ( 15 | // RequeueImmediately is a constant that allows for immediate RequeueAfter when you want to run your 16 | // singleton controller as hot as possible in a fast requeuing loop 17 | RequeueImmediately = 1 * time.Nanosecond 18 | ) 19 | 20 | type Reconciler interface { 21 | Reconcile(ctx context.Context) (reconcile.Result, error) 22 | } 23 | 24 | func AsReconciler(reconciler Reconciler) reconcile.Reconciler { 25 | return reconcile.Func(func(ctx context.Context, r reconcile.Request) (reconcile.Result, error) { 26 | return reconciler.Reconcile(ctx) 27 | }) 28 | } 29 | 30 | func Source() source.Source { 31 | eventSource := make(chan event.GenericEvent, 1) 32 | eventSource <- event.GenericEvent{} 33 | return source.Channel(eventSource, handler.Funcs{ 34 | GenericFunc: func(_ context.Context, _ event.GenericEvent, queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { 35 | queue.Add(reconcile.Request{}) 36 | }, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /status/condition.go: -------------------------------------------------------------------------------- 1 | // Inspired by https://github.com/knative/pkg/tree/97c7258e3a98b81459936bc7a29dc6a9540fa357/apis, 2 | // but we chose to diverge due to the unacceptably large dependency closure of knative/pkg. 3 | package status 4 | 5 | import ( 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "sigs.k8s.io/controller-runtime/pkg/client" 8 | ) 9 | 10 | type Object interface { 11 | client.Object 12 | GetConditions() []Condition 13 | SetConditions([]Condition) 14 | StatusConditions() ConditionSet 15 | } 16 | 17 | // ConditionType is a upper-camel-cased condition type. 18 | type ConditionType string 19 | 20 | const ( 21 | // ConditionReady specifies that the resource is ready. 22 | // For long-running resources. 23 | ConditionReady = "Ready" 24 | // ConditionSucceeded specifies that the resource has finished. 25 | // For resource which run to completion. 26 | ConditionSucceeded = "Succeeded" 27 | ) 28 | 29 | // Condition aliases the upstream type and adds additional helper methods 30 | type Condition metav1.Condition 31 | 32 | func (c *Condition) IsTrue() bool { 33 | if c == nil { 34 | return false 35 | } 36 | return c.Status == metav1.ConditionTrue 37 | } 38 | 39 | func (c *Condition) IsFalse() bool { 40 | if c == nil { 41 | return false 42 | } 43 | return c.Status == metav1.ConditionFalse 44 | } 45 | 46 | func (c *Condition) IsUnknown() bool { 47 | if c == nil { 48 | return true 49 | } 50 | return c.Status == metav1.ConditionUnknown 51 | } 52 | 53 | func (c *Condition) GetStatus() metav1.ConditionStatus { 54 | if c == nil { 55 | return metav1.ConditionUnknown 56 | } 57 | return c.Status 58 | } 59 | -------------------------------------------------------------------------------- /status/condition_set.go: -------------------------------------------------------------------------------- 1 | // Inspired by https://github.com/knative/pkg/tree/97c7258e3a98b81459936bc7a29dc6a9540fa357/apis, 2 | // but we chose to diverge due to the unacceptably large dependency closure of knative/pkg. 3 | package status 4 | 5 | import ( 6 | "fmt" 7 | "reflect" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/samber/lo" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | ) 14 | 15 | // ConditionTypes is an abstract collection of the possible ConditionType values 16 | // that a particular resource might expose. It also holds the "root condition" 17 | // for that resource, which we define to be one of Ready or Succeeded depending 18 | // on whether it is a Living or Batch process respectively. 19 | type ConditionTypes struct { 20 | root string 21 | dependents []string 22 | } 23 | 24 | // NewReadyConditions returns a ConditionTypes to hold the conditions for the 25 | // resource. ConditionReady is used as the root condition. 26 | // The set of condition types provided are those of the terminal subconditions. 27 | func NewReadyConditions(d ...string) ConditionTypes { 28 | return newConditionTypes(ConditionReady, d...) 29 | } 30 | 31 | // NewSucceededConditions returns a ConditionTypes to hold the conditions for the 32 | // batch resource. ConditionSucceeded is used as the root condition. 33 | // The set of condition types provided are those of the terminal subconditions. 34 | func NewSucceededConditions(d ...string) ConditionTypes { 35 | return newConditionTypes(ConditionSucceeded, d...) 36 | } 37 | 38 | func newConditionTypes(root string, dependents ...string) ConditionTypes { 39 | return ConditionTypes{ 40 | root: root, 41 | dependents: lo.Reject(lo.Uniq(dependents), func(c string, _ int) bool { return c == root }), 42 | } 43 | } 44 | 45 | // ConditionSet provides methods for evaluating Conditions. 46 | // +k8s:deepcopy-gen=false 47 | type ConditionSet struct { 48 | ConditionTypes 49 | object Object 50 | } 51 | 52 | // For creates a ConditionSet from an object using the original 53 | // ConditionTypes as a reference. Status must be a pointer to a struct. 54 | func (r ConditionTypes) For(object Object) ConditionSet { 55 | cs := ConditionSet{object: object, ConditionTypes: r} 56 | // Set known conditions Unknown if not set. 57 | // Set the root condition first to get consistent timing for LastTransitionTime 58 | for _, t := range append([]string{r.root}, r.dependents...) { 59 | if cs.Get(t) == nil { 60 | cs.SetUnknown(t) 61 | } 62 | } 63 | return cs 64 | } 65 | 66 | // Root returns the root Condition, typically "Ready" or "Succeeded" 67 | func (c ConditionSet) Root() *Condition { 68 | if c.object == nil { 69 | return nil 70 | } 71 | return c.Get(c.root) 72 | } 73 | 74 | func (c ConditionSet) List() []Condition { 75 | if c.object == nil { 76 | return nil 77 | } 78 | return c.object.GetConditions() 79 | } 80 | 81 | // Get finds and returns the Condition that matches the ConditionType 82 | // previously set on Conditions. 83 | func (c ConditionSet) Get(t string) *Condition { 84 | if c.object == nil { 85 | return nil 86 | } 87 | if condition, found := lo.Find(c.object.GetConditions(), func(c Condition) bool { return c.Type == t }); found { 88 | return &condition 89 | } 90 | return nil 91 | } 92 | 93 | // IsTrue returns true if all condition types are true. 94 | func (c ConditionSet) IsTrue(conditionTypes ...string) bool { 95 | for _, conditionType := range conditionTypes { 96 | if !c.Get(conditionType).IsTrue() { 97 | return false 98 | } 99 | } 100 | return true 101 | } 102 | 103 | func (c ConditionSet) IsDependentCondition(t string) bool { 104 | return t == c.root || lo.Contains(c.dependents, t) 105 | } 106 | 107 | // Set sets or updates the Condition on Conditions for Condition.Type. 108 | // If there is an update, Conditions are stored back sorted. 109 | func (c ConditionSet) Set(condition Condition) (modified bool) { 110 | var conditions []Condition 111 | var foundCondition bool 112 | 113 | condition.ObservedGeneration = c.object.GetGeneration() 114 | for _, cond := range c.object.GetConditions() { 115 | if cond.Type != condition.Type { 116 | // If we are deleting, we just bump all the observed generations 117 | if !c.object.GetDeletionTimestamp().IsZero() { 118 | cond.ObservedGeneration = c.object.GetGeneration() 119 | } 120 | conditions = append(conditions, cond) 121 | } else { 122 | foundCondition = true 123 | if condition.Status == cond.Status { 124 | condition.LastTransitionTime = cond.LastTransitionTime 125 | } else { 126 | condition.LastTransitionTime = metav1.Now() 127 | } 128 | if reflect.DeepEqual(condition, cond) { 129 | return false 130 | } 131 | } 132 | } 133 | if !foundCondition { 134 | // Dependent conditions should always be set, so if it's not found, that means 135 | // that we are initializing the condition type, and it's last "transition" was object creation 136 | if c.IsDependentCondition(condition.Type) { 137 | condition.LastTransitionTime = c.object.GetCreationTimestamp() 138 | } else { 139 | condition.LastTransitionTime = metav1.Now() 140 | } 141 | } 142 | conditions = append(conditions, condition) 143 | // Sorted for convenience of the consumer, i.e. kubectl. 144 | sort.SliceStable(conditions, func(i, j int) bool { 145 | // Order the root status condition at the end 146 | if conditions[i].Type == c.root || conditions[j].Type == c.root { 147 | return conditions[j].Type == c.root 148 | } 149 | return conditions[i].LastTransitionTime.Time.Before(conditions[j].LastTransitionTime.Time) 150 | }) 151 | c.object.SetConditions(conditions) 152 | 153 | // Recompute the root condition after setting any other condition 154 | c.recomputeRootCondition(condition.Type) 155 | return true 156 | } 157 | 158 | // Clear removes the independent condition that matches the ConditionType 159 | // Not implemented for dependent conditions 160 | func (c ConditionSet) Clear(t string) error { 161 | var conditions []Condition 162 | 163 | if c.object == nil { 164 | return nil 165 | } 166 | // Dependent conditions are not handled as they can't be nil 167 | if c.IsDependentCondition(t) { 168 | return fmt.Errorf("clearing dependent conditions not implemented") 169 | } 170 | cond := c.Get(t) 171 | if cond == nil { 172 | return nil 173 | } 174 | for _, c := range c.object.GetConditions() { 175 | if c.Type != t { 176 | conditions = append(conditions, c) 177 | } 178 | } 179 | 180 | // Sorted for convenience of the consumer, i.e. kubectl. 181 | sort.Slice(conditions, func(i, j int) bool { return conditions[i].Type < conditions[j].Type }) 182 | c.object.SetConditions(conditions) 183 | 184 | return nil 185 | } 186 | 187 | // SetTrue sets the status of conditionType to true with the reason, and then marks the root condition to 188 | // true if all other dependents are also true. 189 | func (c ConditionSet) SetTrue(conditionType string) (modified bool) { 190 | return c.SetTrueWithReason(conditionType, conditionType, "") 191 | } 192 | 193 | // SetTrueWithReason sets the status of conditionType to true with the reason, and then marks the root condition to 194 | // true if all other dependents are also true. 195 | func (c ConditionSet) SetTrueWithReason(conditionType string, reason, message string) (modified bool) { 196 | return c.Set(Condition{ 197 | Type: conditionType, 198 | Status: metav1.ConditionTrue, 199 | Reason: reason, 200 | Message: message, 201 | }) 202 | } 203 | 204 | // SetUnknown sets the status of conditionType to Unknown and also sets the root condition 205 | // to Unknown if no other dependent condition is in an error state. 206 | func (c ConditionSet) SetUnknown(conditionType string) (modified bool) { 207 | return c.SetUnknownWithReason(conditionType, "AwaitingReconciliation", "object is awaiting reconciliation") 208 | } 209 | 210 | // SetUnknownWithReason sets the status of conditionType to Unknown with the reason, and also sets the root condition 211 | // to Unknown if no other dependent condition is in an error state. 212 | func (c ConditionSet) SetUnknownWithReason(conditionType string, reason, message string) (modified bool) { 213 | return c.Set(Condition{ 214 | Type: conditionType, 215 | Status: metav1.ConditionUnknown, 216 | Reason: reason, 217 | Message: message, 218 | }) 219 | } 220 | 221 | // SetFalse sets the status of conditionType and the root condition to False. 222 | func (c ConditionSet) SetFalse(conditionType string, reason, message string) (modified bool) { 223 | return c.Set(Condition{ 224 | Type: conditionType, 225 | Status: metav1.ConditionFalse, 226 | Reason: reason, 227 | Message: message, 228 | }) 229 | } 230 | 231 | // recomputeRootCondition marks the root condition to true if all other dependents are also true. 232 | func (c ConditionSet) recomputeRootCondition(conditionType string) { 233 | if conditionType == c.root { 234 | return 235 | } 236 | if conditions := c.findUnhealthyDependents(); len(conditions) == 0 { 237 | c.SetTrue(c.root) 238 | } else { 239 | // The root condition is no longer unknown as soon as any dependent condition goes false with the latest observedGeneration 240 | status := lo.Ternary( 241 | lo.ContainsBy(conditions, func(condition Condition) bool { 242 | return condition.IsFalse() && 243 | condition.ObservedGeneration == c.object.GetGeneration() 244 | }), 245 | metav1.ConditionFalse, 246 | metav1.ConditionUnknown, 247 | ) 248 | c.Set(Condition{ 249 | Type: c.root, 250 | Status: status, 251 | Reason: lo.Ternary( 252 | status == metav1.ConditionUnknown, 253 | "ReconcilingDependents", 254 | "UnhealthyDependents", 255 | ), 256 | Message: strings.Join(lo.Map(conditions, func(condition Condition, _ int) string { 257 | return fmt.Sprintf("%s=%s", condition.Type, condition.Status) 258 | }), ", "), 259 | }) 260 | } 261 | } 262 | 263 | func (c ConditionSet) findUnhealthyDependents() []Condition { 264 | if len(c.dependents) == 0 { 265 | return nil 266 | } 267 | // Get dependent conditions 268 | conditions := c.object.GetConditions() 269 | conditions = lo.Filter(conditions, func(condition Condition, _ int) bool { 270 | return lo.Contains(c.dependents, condition.Type) 271 | }) 272 | conditions = lo.Filter(conditions, func(condition Condition, _ int) bool { 273 | return condition.IsFalse() || condition.IsUnknown() || condition.ObservedGeneration != c.object.GetGeneration() 274 | }) 275 | 276 | // Sort set conditions by time. 277 | sort.Slice(conditions, func(i, j int) bool { 278 | return conditions[i].LastTransitionTime.After(conditions[j].LastTransitionTime.Time) 279 | }) 280 | return conditions 281 | } 282 | -------------------------------------------------------------------------------- /status/condition_set_test.go: -------------------------------------------------------------------------------- 1 | package status_test 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/awslabs/operatorpkg/status" 7 | "github.com/awslabs/operatorpkg/test" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | "github.com/samber/lo" 11 | 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | ) 14 | 15 | var _ = Describe("Conditions", func() { 16 | It("should correctly toggle conditions", func() { 17 | testObject := test.Object(&test.CustomObject{}) 18 | // Conditions should be initialized 19 | conditions := testObject.StatusConditions() 20 | Expect(conditions.Get(test.ConditionTypeFoo).GetStatus()).To(Equal(metav1.ConditionUnknown)) 21 | Expect(conditions.Get(test.ConditionTypeBar).GetStatus()).To(Equal(metav1.ConditionUnknown)) 22 | Expect(conditions.Root().GetStatus()).To(Equal(metav1.ConditionUnknown)) 23 | // Update the condition to unknown with reason 24 | Expect(conditions.SetUnknownWithReason(test.ConditionTypeFoo, "reason", "message")).To(BeTrue()) 25 | fooCondition := conditions.Get(test.ConditionTypeFoo) 26 | Expect(fooCondition.Type).To(Equal(test.ConditionTypeFoo)) 27 | Expect(fooCondition.Status).To(Equal(metav1.ConditionUnknown)) 28 | Expect(fooCondition.Reason).To(Equal("reason")) // default to type 29 | Expect(fooCondition.Message).To(Equal("message")) // default to type 30 | Expect(fooCondition.LastTransitionTime.UnixNano()).To(BeNumerically(">", 0)) 31 | Expect(fooCondition.ObservedGeneration).To(Equal(int64(1))) 32 | Expect(conditions.Root().GetStatus()).To(Equal(metav1.ConditionUnknown)) 33 | time.Sleep(1 * time.Nanosecond) 34 | // Update the condition to true 35 | Expect(conditions.SetTrue(test.ConditionTypeFoo)).To(BeTrue()) 36 | fooCondition = conditions.Get(test.ConditionTypeFoo) 37 | Expect(fooCondition.Type).To(Equal(test.ConditionTypeFoo)) 38 | Expect(fooCondition.Status).To(Equal(metav1.ConditionTrue)) 39 | Expect(fooCondition.Reason).To(Equal(test.ConditionTypeFoo)) // default to type 40 | Expect(fooCondition.Message).To(Equal("")) // default to type 41 | Expect(fooCondition.LastTransitionTime.UnixNano()).To(BeNumerically(">", 0)) 42 | Expect(fooCondition.ObservedGeneration).To(Equal(int64(1))) 43 | Expect(conditions.Root().GetStatus()).To(Equal(metav1.ConditionUnknown)) 44 | time.Sleep(1 * time.Nanosecond) 45 | // Update the other condition to false 46 | Expect(conditions.SetFalse(test.ConditionTypeBar, "reason", "message")).To(BeTrue()) 47 | fooCondition2 := conditions.Get(test.ConditionTypeBar) 48 | Expect(fooCondition2.Type).To(Equal(test.ConditionTypeBar)) 49 | Expect(fooCondition2.Status).To(Equal(metav1.ConditionFalse)) 50 | Expect(fooCondition2.Reason).To(Equal("reason")) 51 | Expect(fooCondition2.Message).To(Equal("message")) 52 | Expect(fooCondition2.LastTransitionTime.UnixNano()).To(BeNumerically(">", 0)) 53 | Expect(fooCondition2.ObservedGeneration).To(Equal(int64(1))) 54 | Expect(conditions.Root().GetStatus()).To(Equal(metav1.ConditionFalse)) 55 | time.Sleep(1 * time.Nanosecond) 56 | // transition the root condition to true 57 | Expect(conditions.SetTrueWithReason(test.ConditionTypeBar, "reason", "message")).To(BeTrue()) 58 | updatedFooCondition := conditions.Get(test.ConditionTypeBar) 59 | Expect(updatedFooCondition.Type).To(Equal(test.ConditionTypeBar)) 60 | Expect(updatedFooCondition.Status).To(Equal(metav1.ConditionTrue)) 61 | Expect(updatedFooCondition.Reason).To(Equal("reason")) 62 | Expect(updatedFooCondition.Message).To(Equal("message")) 63 | Expect(updatedFooCondition.LastTransitionTime.UnixNano()).To(BeNumerically(">", fooCondition.LastTransitionTime.UnixNano())) 64 | Expect(updatedFooCondition.ObservedGeneration).To(Equal(int64(1))) 65 | Expect(conditions.Root().GetStatus()).To(Equal(metav1.ConditionTrue)) 66 | time.Sleep(1 * time.Nanosecond) 67 | // Transition if the status is the same, but the Reason is different 68 | Expect(conditions.SetFalse(test.ConditionTypeBar, "another-reason", "another-message")).To(BeTrue()) 69 | updatedBarCondition := conditions.Get(test.ConditionTypeBar) 70 | Expect(updatedBarCondition.Type).To(Equal(test.ConditionTypeBar)) 71 | Expect(updatedBarCondition.Status).To(Equal(metav1.ConditionFalse)) 72 | Expect(updatedBarCondition.Reason).To(Equal("another-reason")) 73 | Expect(updatedBarCondition.LastTransitionTime.UnixNano()).ToNot(BeNumerically("==", fooCondition2.LastTransitionTime.UnixNano())) 74 | Expect(updatedBarCondition.ObservedGeneration).To(Equal(int64(1))) 75 | // Dont transition if reason and message are the same 76 | Expect(conditions.SetTrue(test.ConditionTypeFoo)).To(BeFalse()) 77 | Expect(conditions.SetFalse(test.ConditionTypeBar, "another-reason", "another-message")).To(BeFalse()) 78 | // set certain condition for first time when it is never set in object conditions 79 | Expect(conditions.SetTrue(test.ConditionTypeBaz)).To(BeTrue()) 80 | updatedBazCondition := conditions.Get(test.ConditionTypeBaz) 81 | Expect(updatedBazCondition.LastTransitionTime.UnixNano()).To(BeNumerically(">", 0)) 82 | Expect(updatedBazCondition.ObservedGeneration).To(Equal(int64(1))) 83 | testObject.Generation = 2 84 | Expect(conditions.SetFalse(test.ConditionTypeBar, "another-reason", "another-message")).To(BeTrue()) 85 | updatedBarCondition2 := conditions.Get(test.ConditionTypeBar) 86 | Expect(updatedBarCondition2.LastTransitionTime.UnixNano()).To(BeNumerically("==", updatedBarCondition.LastTransitionTime.UnixNano())) 87 | Expect(updatedBarCondition2.ObservedGeneration).To(Equal(int64(2))) 88 | // root should be false when any dependent condition is false 89 | Expect(conditions.Root().GetStatus()).To(Equal(metav1.ConditionFalse)) 90 | Expect(conditions.Root().Reason).To(Equal("UnhealthyDependents")) 91 | // root should be unknown when no dependent condition is false and any observedGeneration doesn't match with latest generation 92 | Expect(conditions.SetTrue(test.ConditionTypeBar)).To(BeTrue()) 93 | Expect(conditions.Root().GetStatus()).To(Equal(metav1.ConditionUnknown)) 94 | Expect(conditions.Root().Reason).To(Equal("ReconcilingDependents")) 95 | Expect(conditions.SetTrue(test.ConditionTypeFoo)).To(BeTrue()) 96 | Expect(conditions.Root().GetStatus()).To(Equal(metav1.ConditionTrue)) 97 | }) 98 | 99 | It("all true", func() { 100 | testObject := test.CustomObject{} 101 | Expect(testObject.StatusConditions().IsTrue()).To(BeTrue()) 102 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo)).To(BeFalse()) 103 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeBar)).To(BeFalse()) 104 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeBaz)).To(BeFalse()) 105 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBar)).To(BeFalse()) 106 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBaz)).To(BeFalse()) 107 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBar, test.ConditionTypeBaz)).To(BeFalse()) 108 | 109 | testObject.StatusConditions().SetFalse(test.ConditionTypeBaz, "reason", "message") 110 | Expect(testObject.StatusConditions().IsTrue()).To(BeTrue()) 111 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo)).To(BeFalse()) 112 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeBar)).To(BeFalse()) 113 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeBaz)).To(BeFalse()) 114 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBar)).To(BeFalse()) 115 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBaz)).To(BeFalse()) 116 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBar, test.ConditionTypeBaz)).To(BeFalse()) 117 | 118 | testObject.StatusConditions().SetTrue(test.ConditionTypeFoo) 119 | Expect(testObject.StatusConditions().IsTrue()).To(BeTrue()) 120 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo)).To(BeTrue()) 121 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeBar)).To(BeFalse()) 122 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeBaz)).To(BeFalse()) 123 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBar)).To(BeFalse()) 124 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBaz)).To(BeFalse()) 125 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBar, test.ConditionTypeBaz)).To(BeFalse()) 126 | 127 | testObject.StatusConditions().SetTrue(test.ConditionTypeBar) 128 | Expect(testObject.StatusConditions().IsTrue()).To(BeTrue()) 129 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo)).To(BeTrue()) 130 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeBar)).To(BeTrue()) 131 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeBaz)).To(BeFalse()) 132 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBar)).To(BeTrue()) 133 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBaz)).To(BeFalse()) 134 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBar, test.ConditionTypeBaz)).To(BeFalse()) 135 | 136 | testObject.StatusConditions().SetTrue(test.ConditionTypeBaz) 137 | Expect(testObject.StatusConditions().IsTrue()).To(BeTrue()) 138 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo)).To(BeTrue()) 139 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeBar)).To(BeTrue()) 140 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeBaz)).To(BeTrue()) 141 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBar)).To(BeTrue()) 142 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBaz)).To(BeTrue()) 143 | Expect(testObject.StatusConditions().IsTrue(test.ConditionTypeFoo, test.ConditionTypeBar, test.ConditionTypeBaz)).To(BeTrue()) 144 | }) 145 | It("should sort status conditions", func() { 146 | testObject := test.CustomObject{} 147 | // Ready condition should be at the end 148 | Expect(testObject.StatusConditions().List()[len(testObject.StatusConditions().List())-1].Type).To(Equal(status.ConditionReady)) 149 | 150 | testObject.StatusConditions().SetTrue(test.ConditionTypeFoo) 151 | // Ready condition should be last with Foo condition second to last since it was recently updated 152 | Expect(testObject.StatusConditions().List()[len(testObject.StatusConditions().List())-1].Type).To(Equal(status.ConditionReady)) 153 | Expect(testObject.StatusConditions().List()[len(testObject.StatusConditions().List())-2].Type).To(Equal(test.ConditionTypeFoo)) 154 | 155 | testObject.StatusConditions().SetTrue(test.ConditionTypeBar) 156 | // Ready condition should be last with Bar condition second to last since it was recently updated and Foo condition third to last 157 | Expect(testObject.StatusConditions().List()[len(testObject.StatusConditions().List())-1].Type).To(Equal(status.ConditionReady)) 158 | Expect(testObject.StatusConditions().List()[len(testObject.StatusConditions().List())-2].Type).To(Equal(test.ConditionTypeBar)) 159 | Expect(testObject.StatusConditions().List()[len(testObject.StatusConditions().List())-3].Type).To(Equal(test.ConditionTypeFoo)) 160 | 161 | testObject.StatusConditions().SetTrue(test.ConditionTypeBaz) 162 | // Ready condition should be last with Bar condition second to last since it was recently updated, Bar condition third to last, and Foo condition at the top 163 | Expect(testObject.StatusConditions().List()[len(testObject.StatusConditions().List())-1].Type).To(Equal(status.ConditionReady)) 164 | Expect(testObject.StatusConditions().List()[len(testObject.StatusConditions().List())-2].Type).To(Equal(test.ConditionTypeBaz)) 165 | Expect(testObject.StatusConditions().List()[len(testObject.StatusConditions().List())-3].Type).To(Equal(test.ConditionTypeBar)) 166 | Expect(testObject.StatusConditions().List()[len(testObject.StatusConditions().List())-4].Type).To(Equal(test.ConditionTypeFoo)) 167 | }) 168 | 169 | It("should bump generation of all conditions when deleting", func() { 170 | testObject := test.Object(&test.CustomObject{}) 171 | // Conditions should be initialized 172 | conditions := testObject.StatusConditions() 173 | // Expect status to be unkown 174 | Expect(conditions.Get(test.ConditionTypeFoo).GetStatus()).To(Equal(metav1.ConditionUnknown)) 175 | Expect(conditions.Get(test.ConditionTypeBar).GetStatus()).To(Equal(metav1.ConditionUnknown)) 176 | Expect(conditions.Root().GetStatus()).To(Equal(metav1.ConditionUnknown)) 177 | 178 | // set conditions to true and expect generation set 179 | Expect(conditions.SetTrue(test.ConditionTypeFoo)).To(BeTrue()) 180 | Expect(conditions.SetTrue(test.ConditionTypeBar)).To(BeTrue()) 181 | Expect(conditions.Get(test.ConditionTypeFoo).Status).To(Equal(metav1.ConditionTrue)) 182 | Expect(conditions.Get(test.ConditionTypeBar).Status).To(Equal(metav1.ConditionTrue)) 183 | Expect(conditions.Get(test.ConditionTypeFoo).ObservedGeneration).To(Equal(int64(1))) 184 | Expect(conditions.Get(test.ConditionTypeBar).ObservedGeneration).To(Equal(int64(1))) 185 | 186 | // set deletion timestamp and bump observed generation 187 | testObject.SetDeletionTimestamp(lo.ToPtr(metav1.Now())) 188 | testObject.SetGeneration(2) 189 | 190 | // set one condition to true again; ensure all the other conditions observed generation is bumped 191 | // make sure root condition is also true and not unknown 192 | Expect(conditions.SetTrue(test.ConditionTypeFoo)).To(BeTrue()) 193 | Expect(conditions.Get(test.ConditionTypeFoo).ObservedGeneration).To(Equal(int64(2))) 194 | Expect(conditions.Get(test.ConditionTypeBar).ObservedGeneration).To(Equal(int64(2))) 195 | Expect(conditions.Root().Status).To(Equal(metav1.ConditionTrue)) 196 | Expect(conditions.Root().ObservedGeneration).To(Equal(int64(2))) 197 | }) 198 | }) 199 | -------------------------------------------------------------------------------- /status/controller.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | "reflect" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | opunstructured "github.com/awslabs/operatorpkg/unstructured" 13 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 14 | "k8s.io/apimachinery/pkg/runtime" 15 | "k8s.io/apimachinery/pkg/runtime/schema" 16 | 17 | pmetrics "github.com/awslabs/operatorpkg/metrics" 18 | "github.com/awslabs/operatorpkg/object" 19 | "github.com/awslabs/operatorpkg/option" 20 | "github.com/samber/lo" 21 | v1 "k8s.io/api/core/v1" 22 | "k8s.io/apimachinery/pkg/api/errors" 23 | "k8s.io/client-go/tools/record" 24 | controllerruntime "sigs.k8s.io/controller-runtime" 25 | "sigs.k8s.io/controller-runtime/pkg/client" 26 | "sigs.k8s.io/controller-runtime/pkg/controller" 27 | "sigs.k8s.io/controller-runtime/pkg/manager" 28 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 29 | ) 30 | 31 | type Controller[T Object] struct { 32 | gvk schema.GroupVersionKind 33 | additionalMetricLabels []string 34 | additionalGaugeMetricLabels []string 35 | additionalMetricFields map[string]string 36 | additionalGaugeMetricFields map[string]string 37 | kubeClient client.Client 38 | eventRecorder record.EventRecorder 39 | observedConditions sync.Map // map[reconcile.Request]ConditionSet 40 | observedGaugeLabels sync.Map // map[reconcile.Request]map[string]string 41 | observedFinalizers sync.Map // map[reconcile.Request]Finalizer 42 | terminatingObjects sync.Map // map[reconcile.Request]Object 43 | emitDeprecatedMetrics bool 44 | ConditionDuration pmetrics.ObservationMetric 45 | ConditionCount pmetrics.GaugeMetric 46 | ConditionCurrentStatusSeconds pmetrics.GaugeMetric 47 | ConditionTransitionsTotal pmetrics.CounterMetric 48 | TerminationCurrentTimeSeconds pmetrics.GaugeMetric 49 | TerminationDuration pmetrics.ObservationMetric 50 | } 51 | 52 | type Option struct { 53 | // Current list of deprecated metrics 54 | // - operator_status_condition_transitions_total 55 | // - operator_status_condition_transition_seconds 56 | // - operator_status_condition_current_status_seconds 57 | // - operator_status_condition_count 58 | // - operator_termination_current_time_seconds 59 | // - operator_termination_duration_seconds 60 | EmitDeprecatedMetrics bool 61 | MetricLabels []string 62 | GaugeMetricLabels []string 63 | MetricFields map[string]string 64 | GaugeMetricFields map[string]string 65 | } 66 | 67 | func EmitDeprecatedMetrics(o *Option) { 68 | o.EmitDeprecatedMetrics = true 69 | } 70 | 71 | func WithLabels(labels ...string) func(*Option) { 72 | return func(o *Option) { 73 | o.MetricLabels = append(o.MetricLabels, labels...) 74 | } 75 | } 76 | 77 | func WithGaugeLabels(labels ...string) func(*Option) { 78 | return func(o *Option) { 79 | o.GaugeMetricLabels = append(o.GaugeMetricLabels, labels...) 80 | } 81 | } 82 | 83 | func WithFields(fields map[string]string) func(*Option) { 84 | return func(o *Option) { 85 | o.MetricFields = lo.Assign(o.MetricFields, fields) 86 | } 87 | } 88 | 89 | func WithGaugeFields(fields map[string]string) func(*Option) { 90 | return func(o *Option) { 91 | o.GaugeMetricFields = lo.Assign(o.GaugeMetricFields, fields) 92 | } 93 | } 94 | 95 | func NewController[T Object](client client.Client, eventRecorder record.EventRecorder, opts ...option.Function[Option]) *Controller[T] { 96 | options := option.Resolve(opts...) 97 | obj := reflect.New(reflect.TypeOf(*new(T)).Elem()).Interface().(runtime.Object) 98 | obj.GetObjectKind().SetGroupVersionKind(obj.GetObjectKind().GroupVersionKind()) 99 | gvk := object.GVK(obj) 100 | 101 | return &Controller[T]{ 102 | gvk: gvk, 103 | additionalMetricLabels: options.MetricLabels, 104 | additionalGaugeMetricLabels: options.GaugeMetricLabels, 105 | additionalMetricFields: options.MetricFields, 106 | additionalGaugeMetricFields: options.GaugeMetricFields, 107 | kubeClient: client, 108 | eventRecorder: eventRecorder, 109 | emitDeprecatedMetrics: options.EmitDeprecatedMetrics, 110 | ConditionDuration: conditionDurationMetric(strings.ToLower(gvk.Kind), lo.Map( 111 | append(options.MetricLabels, lo.Keys(options.MetricFields)...), 112 | func(k string, _ int) string { return toPrometheusLabel(k) })...), 113 | ConditionCount: conditionCountMetric(strings.ToLower(gvk.Kind), lo.Map( 114 | append( 115 | append(lo.Keys(options.MetricFields), lo.Keys(options.GaugeMetricFields)...), 116 | append(options.MetricLabels, options.GaugeMetricLabels...)..., 117 | ), func(k string, _ int) string { return toPrometheusLabel(k) })...), 118 | ConditionCurrentStatusSeconds: conditionCurrentStatusSecondsMetric(strings.ToLower(gvk.Kind), lo.Map( 119 | append( 120 | append(lo.Keys(options.MetricFields), lo.Keys(options.GaugeMetricFields)...), 121 | append(options.MetricLabels, options.GaugeMetricLabels...)..., 122 | ), func(k string, _ int) string { return toPrometheusLabel(k) })...), 123 | ConditionTransitionsTotal: conditionTransitionsTotalMetric(strings.ToLower(gvk.Kind), lo.Map( 124 | append(options.MetricLabels, lo.Keys(options.MetricFields)...), 125 | func(k string, _ int) string { return toPrometheusLabel(k) })...), 126 | TerminationCurrentTimeSeconds: terminationCurrentTimeSecondsMetric(strings.ToLower(gvk.Kind), lo.Map( 127 | append( 128 | append(lo.Keys(options.MetricFields), lo.Keys(options.GaugeMetricFields)...), 129 | append(options.MetricLabels, options.GaugeMetricLabels...)..., 130 | ), func(k string, _ int) string { return toPrometheusLabel(k) })...), 131 | TerminationDuration: terminationDurationMetric(strings.ToLower(gvk.Kind), lo.Map( 132 | append(options.MetricLabels, lo.Keys(options.MetricFields)...), 133 | func(k string, _ int) string { return toPrometheusLabel(k) })...), 134 | } 135 | } 136 | 137 | func (c *Controller[T]) Register(_ context.Context, m manager.Manager) error { 138 | return controllerruntime.NewControllerManagedBy(m). 139 | For(object.New[T]()). 140 | WithOptions(controller.Options{MaxConcurrentReconciles: 10}). 141 | Named(fmt.Sprintf("operatorpkg.%s.status", strings.ToLower(c.gvk.Kind))). 142 | Complete(c) 143 | } 144 | 145 | func (c *Controller[T]) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { 146 | return c.reconcile(ctx, req, object.New[T]()) 147 | } 148 | 149 | type GenericObjectController[T client.Object] struct { 150 | *Controller[*UnstructuredAdapter[T]] 151 | } 152 | 153 | func NewGenericObjectController[T client.Object](client client.Client, eventRecorder record.EventRecorder, opts ...option.Function[Option]) *GenericObjectController[T] { 154 | return &GenericObjectController[T]{ 155 | Controller: NewController[*UnstructuredAdapter[T]](client, eventRecorder, opts...), 156 | } 157 | } 158 | 159 | func (c *GenericObjectController[T]) Register(_ context.Context, m manager.Manager) error { 160 | return controllerruntime.NewControllerManagedBy(m). 161 | For(object.New[T]()). 162 | WithOptions(controller.Options{MaxConcurrentReconciles: 10}). 163 | Named(fmt.Sprintf("operatorpkg.%s.status", strings.ToLower(reflect.TypeOf(object.New[T]()).Elem().Name()))). 164 | Complete(c) 165 | } 166 | 167 | func (c *GenericObjectController[T]) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { 168 | return c.reconcile(ctx, req, NewUnstructuredAdapter[T](object.New[T]())) 169 | } 170 | 171 | func (c *Controller[T]) toAdditionalMetricLabels(obj Object) map[string]string { 172 | u := opunstructured.ToPartialUnstructured(obj, lo.Values(c.additionalMetricFields)...) 173 | return lo.Assign( 174 | lo.MapEntries(c.additionalMetricFields, func(k string, v string) (string, string) { 175 | elem, _, _ := unstructured.NestedString(u, lo.Filter(strings.Split(v, "."), func(s string, _ int) bool { return s != "" })...) 176 | return toPrometheusLabel(k), elem 177 | }), 178 | lo.SliceToMap(c.additionalMetricLabels, func(label string) (string, string) { return toPrometheusLabel(label), obj.GetLabels()[label] }), 179 | ) 180 | } 181 | 182 | func (c *Controller[T]) toAdditionalGaugeMetricLabels(obj Object) map[string]string { 183 | u := opunstructured.ToPartialUnstructured(obj, lo.Values(c.additionalGaugeMetricFields)...) 184 | return lo.Assign( 185 | lo.MapEntries(c.additionalGaugeMetricFields, func(k string, v string) (string, string) { 186 | elem, _, _ := unstructured.NestedString(u, lo.Filter(strings.Split(v, "."), func(s string, _ int) bool { return s != "" })...) 187 | return toPrometheusLabel(k), elem 188 | }), 189 | c.toAdditionalMetricLabels(obj), lo.SliceToMap(c.additionalGaugeMetricLabels, func(label string) (string, string) { return toPrometheusLabel(label), obj.GetLabels()[label] }), 190 | ) 191 | } 192 | 193 | func toPrometheusLabel(k string) string { 194 | unsupportedChars := []string{"/", ".", "-"} 195 | for _, char := range unsupportedChars { 196 | k = strings.ReplaceAll(k, char, "_") 197 | } 198 | return k 199 | } 200 | 201 | func (c *Controller[T]) reconcile(ctx context.Context, req reconcile.Request, o Object) (reconcile.Result, error) { 202 | if err := c.kubeClient.Get(ctx, req.NamespacedName, o); err != nil { 203 | if errors.IsNotFound(err) { 204 | c.observedConditions.Delete(req) 205 | c.observedGaugeLabels.Delete(req) 206 | c.deletePartialMatchGaugeMetric(c.ConditionCount, ConditionCount, map[string]string{ 207 | MetricLabelNamespace: req.Namespace, 208 | MetricLabelName: req.Name, 209 | }) 210 | c.deletePartialMatchGaugeMetric(c.ConditionCurrentStatusSeconds, ConditionCurrentStatusSeconds, map[string]string{ 211 | MetricLabelNamespace: req.Namespace, 212 | MetricLabelName: req.Name, 213 | }) 214 | c.deletePartialMatchGaugeMetric(c.TerminationCurrentTimeSeconds, TerminationCurrentTimeSeconds, map[string]string{ 215 | MetricLabelNamespace: req.Namespace, 216 | MetricLabelName: req.Name, 217 | }) 218 | if obj, ok := c.terminatingObjects.LoadAndDelete(req); ok { 219 | c.observeHistogram(c.TerminationDuration, TerminationDuration, time.Since(obj.(Object).GetDeletionTimestamp().Time).Seconds(), map[string]string{}, c.toAdditionalMetricLabels(obj.(Object))) 220 | } 221 | if finalizers, ok := c.observedFinalizers.LoadAndDelete(req); ok { 222 | for _, finalizer := range finalizers.([]string) { 223 | c.eventRecorder.Event(o, v1.EventTypeNormal, "Finalized", fmt.Sprintf("Finalized %s", finalizer)) 224 | } 225 | } 226 | return reconcile.Result{}, nil 227 | } 228 | return reconcile.Result{}, fmt.Errorf("getting object, %w", err) 229 | } 230 | 231 | // Detect and record terminations 232 | observedFinalizers, _ := c.observedFinalizers.Swap(req, o.GetFinalizers()) 233 | if observedFinalizers != nil { 234 | for _, finalizer := range lo.Without(observedFinalizers.([]string), o.GetFinalizers()...) { 235 | c.eventRecorder.Event(o, v1.EventTypeNormal, "Finalized", fmt.Sprintf("Finalized %s", finalizer)) 236 | } 237 | } 238 | 239 | if o.GetDeletionTimestamp() != nil { 240 | c.setGaugeMetric(c.TerminationCurrentTimeSeconds, TerminationCurrentTimeSeconds, time.Since(o.GetDeletionTimestamp().Time).Seconds(), map[string]string{ 241 | MetricLabelNamespace: req.Namespace, 242 | MetricLabelName: req.Name, 243 | }, c.toAdditionalGaugeMetricLabels(o)) 244 | c.terminatingObjects.Store(req, o) 245 | } 246 | 247 | // Detect and record condition counts 248 | currentConditions := o.StatusConditions() 249 | observedConditions := ConditionSet{} 250 | if v, ok := c.observedConditions.Load(req); ok { 251 | observedConditions = v.(ConditionSet) 252 | } 253 | observedGaugeLabels := map[string]string{} 254 | if v, ok := c.observedGaugeLabels.Load(req); ok { 255 | observedGaugeLabels = v.(map[string]string) 256 | } 257 | c.observedConditions.Store(req, currentConditions) 258 | c.observedGaugeLabels.Store(req, c.toAdditionalGaugeMetricLabels(o)) 259 | 260 | for _, condition := range o.GetConditions() { 261 | c.setGaugeMetric(c.ConditionCount, ConditionCount, 1, map[string]string{ 262 | MetricLabelNamespace: req.Namespace, 263 | MetricLabelName: req.Name, 264 | pmetrics.LabelType: condition.Type, 265 | MetricLabelConditionStatus: string(condition.Status), 266 | pmetrics.LabelReason: condition.Reason, 267 | }, c.toAdditionalGaugeMetricLabels(o)) 268 | c.setGaugeMetric(c.ConditionCurrentStatusSeconds, ConditionCurrentStatusSeconds, time.Since(condition.LastTransitionTime.Time).Seconds(), map[string]string{ 269 | MetricLabelNamespace: req.Namespace, 270 | MetricLabelName: req.Name, 271 | pmetrics.LabelType: condition.Type, 272 | MetricLabelConditionStatus: string(condition.Status), 273 | pmetrics.LabelReason: condition.Reason, 274 | }, c.toAdditionalGaugeMetricLabels(o)) 275 | } 276 | 277 | for _, observedCondition := range observedConditions.List() { 278 | if currentCondition := currentConditions.Get(observedCondition.Type); currentCondition == nil || currentCondition.Status != observedCondition.Status || currentCondition.Reason != observedCondition.Reason || !maps.Equal(c.toAdditionalGaugeMetricLabels(o), observedGaugeLabels) { 279 | c.deletePartialMatchGaugeMetric(c.ConditionCount, ConditionCount, lo.Assign(map[string]string{ 280 | MetricLabelNamespace: req.Namespace, 281 | MetricLabelName: req.Name, 282 | pmetrics.LabelType: observedCondition.Type, 283 | MetricLabelConditionStatus: string(observedCondition.Status), 284 | pmetrics.LabelReason: observedCondition.Reason, 285 | }, observedGaugeLabels)) 286 | c.deletePartialMatchGaugeMetric(c.ConditionCurrentStatusSeconds, ConditionCurrentStatusSeconds, lo.Assign(map[string]string{ 287 | MetricLabelNamespace: req.Namespace, 288 | MetricLabelName: req.Name, 289 | pmetrics.LabelType: observedCondition.Type, 290 | MetricLabelConditionStatus: string(observedCondition.Status), 291 | pmetrics.LabelReason: observedCondition.Reason, 292 | }, observedGaugeLabels)) 293 | } 294 | } 295 | 296 | // Detect and record status transitions. This approach is best effort, 297 | // since we may batch multiple writes within a single reconcile loop. 298 | // It's exceedingly difficult to atomically track all changes to an 299 | // object, since the Kubernetes is evenutally consistent by design. 300 | // Despite this, we can catch the majority of transition by remembering 301 | // what we saw last, and reporting observed changes. 302 | // 303 | // We rejected the alternative of tracking these changes within the 304 | // condition library itself, since you cannot guarantee that a 305 | // transition made in memory was successfully persisted. 306 | // 307 | // Automatic monitoring systems must assume that these observations are 308 | // lossy, specifically for when a condition transition rapidly. However, 309 | // for the common case, we want to alert when a transition took a long 310 | // time, and our likelyhood of observing this is much higher. 311 | for _, condition := range currentConditions.List() { 312 | observedCondition := observedConditions.Get(condition.Type) 313 | if observedCondition.GetStatus() == condition.GetStatus() { 314 | continue 315 | } 316 | // A condition transitions if it either didn't exist before or it has changed 317 | c.incCounterMetric(c.ConditionTransitionsTotal, ConditionTransitionsTotal, map[string]string{ 318 | pmetrics.LabelType: condition.Type, 319 | MetricLabelConditionStatus: string(condition.Status), 320 | pmetrics.LabelReason: condition.Reason, 321 | }, c.toAdditionalMetricLabels(o)) 322 | if observedCondition == nil { 323 | continue 324 | } 325 | duration := condition.LastTransitionTime.Time.Sub(observedCondition.LastTransitionTime.Time).Seconds() 326 | c.observeHistogram(c.ConditionDuration, ConditionDuration, duration, map[string]string{ 327 | pmetrics.LabelType: observedCondition.Type, 328 | MetricLabelConditionStatus: string(observedCondition.Status), 329 | }, c.toAdditionalMetricLabels(o)) 330 | c.eventRecorder.Event(o, v1.EventTypeNormal, condition.Type, fmt.Sprintf("Status condition transitioned, Type: %s, Status: %s -> %s, Reason: %s%s", 331 | condition.Type, 332 | observedCondition.Status, 333 | condition.Status, 334 | condition.Reason, 335 | lo.Ternary(condition.Message != "", fmt.Sprintf(", Message: %s", condition.Message), ""), 336 | )) 337 | } 338 | return reconcile.Result{RequeueAfter: time.Second * 10}, nil 339 | } 340 | 341 | func (c *Controller[T]) incCounterMetric(current pmetrics.CounterMetric, deprecated pmetrics.CounterMetric, labels, additionalLabels map[string]string) { 342 | current.Inc(lo.Assign(labels, additionalLabels)) 343 | if c.emitDeprecatedMetrics { 344 | labels[pmetrics.LabelKind] = c.gvk.Kind 345 | labels[pmetrics.LabelGroup] = c.gvk.Group 346 | deprecated.Inc(labels) 347 | } 348 | } 349 | 350 | func (c *Controller[T]) setGaugeMetric(current pmetrics.GaugeMetric, deprecated pmetrics.GaugeMetric, value float64, labels, additionalLabels map[string]string) { 351 | current.Set(value, lo.Assign(labels, additionalLabels)) 352 | if c.emitDeprecatedMetrics { 353 | labels[pmetrics.LabelKind] = c.gvk.Kind 354 | labels[pmetrics.LabelGroup] = c.gvk.Group 355 | deprecated.Set(value, labels) 356 | } 357 | } 358 | 359 | func (c *Controller[T]) deletePartialMatchGaugeMetric(current pmetrics.GaugeMetric, deprecated pmetrics.GaugeMetric, labels map[string]string) { 360 | current.DeletePartialMatch(labels) 361 | if c.emitDeprecatedMetrics { 362 | labels[pmetrics.LabelKind] = c.gvk.Kind 363 | labels[pmetrics.LabelGroup] = c.gvk.Group 364 | deprecated.DeletePartialMatch(labels) 365 | } 366 | } 367 | 368 | func (c *Controller[T]) observeHistogram(current pmetrics.ObservationMetric, deprecated pmetrics.ObservationMetric, value float64, labels, additionalLabels map[string]string) { 369 | current.Observe(value, lo.Assign(labels, additionalLabels)) 370 | if c.emitDeprecatedMetrics { 371 | labels[pmetrics.LabelKind] = c.gvk.Kind 372 | labels[pmetrics.LabelGroup] = c.gvk.Group 373 | deprecated.Observe(value, labels) 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /status/doc.go: -------------------------------------------------------------------------------- 1 | // +k8s:deepcopy-gen=package,register 2 | // +kubebuilder:object:generate=false 3 | package status // doc.go is discovered by codegen 4 | -------------------------------------------------------------------------------- /status/metrics.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | 6 | pmetrics "github.com/awslabs/operatorpkg/metrics" 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/samber/lo" 9 | "sigs.k8s.io/controller-runtime/pkg/metrics" 10 | ) 11 | 12 | const ( 13 | MetricLabelNamespace = "namespace" 14 | MetricLabelName = "name" 15 | MetricLabelConditionStatus = "status" 16 | ) 17 | 18 | const ( 19 | MetricSubsystem = "status_condition" 20 | TerminationSubsystem = "termination" 21 | ) 22 | 23 | // Cardinality is limited to # objects * # conditions * # objectives 24 | var ConditionDuration = conditionDurationMetric("", pmetrics.LabelGroup, pmetrics.LabelKind) 25 | 26 | func conditionDurationMetric(objectName string, additionalLabels ...string) pmetrics.ObservationMetric { 27 | subsystem := lo.Ternary(len(objectName) == 0, MetricSubsystem, fmt.Sprintf("%s_%s", objectName, MetricSubsystem)) 28 | 29 | return pmetrics.NewPrometheusHistogram( 30 | metrics.Registry, 31 | prometheus.HistogramOpts{ 32 | Namespace: pmetrics.Namespace, 33 | Subsystem: subsystem, 34 | Name: "transition_seconds", 35 | Help: "The amount of time a condition was in a given state before transitioning. e.g. Alarm := P99(Updated=False) > 5 minutes", 36 | }, 37 | append([]string{ 38 | pmetrics.LabelType, 39 | MetricLabelConditionStatus, 40 | }, additionalLabels...), 41 | ) 42 | } 43 | 44 | // Cardinality is limited to # objects * # conditions 45 | var ConditionCount = conditionCountMetric("", pmetrics.LabelGroup, pmetrics.LabelKind) 46 | 47 | func conditionCountMetric(objectName string, additionalLabels ...string) pmetrics.GaugeMetric { 48 | subsystem := lo.Ternary(len(objectName) == 0, MetricSubsystem, fmt.Sprintf("%s_%s", objectName, MetricSubsystem)) 49 | 50 | return pmetrics.NewPrometheusGauge( 51 | metrics.Registry, 52 | prometheus.GaugeOpts{ 53 | Namespace: pmetrics.Namespace, 54 | Subsystem: subsystem, 55 | Name: "count", 56 | Help: "The number of a condition for a given object, type and status. e.g. Alarm := Available=False > 0", 57 | }, 58 | append([]string{ 59 | MetricLabelNamespace, 60 | MetricLabelName, 61 | pmetrics.LabelType, 62 | MetricLabelConditionStatus, 63 | pmetrics.LabelReason, 64 | }, additionalLabels...), 65 | ) 66 | } 67 | 68 | // Cardinality is limited to # objects * # conditions 69 | // NOTE: This metric is based on a requeue so it won't show the current status seconds with extremely high accuracy. 70 | // This metric is useful for aggregations. If you need a high accuracy metric, use operator_status_condition_last_transition_time_seconds 71 | var ConditionCurrentStatusSeconds = conditionCurrentStatusSecondsMetric("", pmetrics.LabelGroup, pmetrics.LabelKind) 72 | 73 | func conditionCurrentStatusSecondsMetric(objectName string, additionalLabels ...string) pmetrics.GaugeMetric { 74 | subsystem := lo.Ternary(len(objectName) == 0, MetricSubsystem, fmt.Sprintf("%s_%s", objectName, MetricSubsystem)) 75 | 76 | return pmetrics.NewPrometheusGauge( 77 | metrics.Registry, 78 | prometheus.GaugeOpts{ 79 | Namespace: pmetrics.Namespace, 80 | Subsystem: subsystem, 81 | Name: "current_status_seconds", 82 | Help: "The current amount of time in seconds that a status condition has been in a specific state. Alarm := P99(Updated=Unknown) > 5 minutes", 83 | }, 84 | append([]string{ 85 | MetricLabelNamespace, 86 | MetricLabelName, 87 | pmetrics.LabelType, 88 | MetricLabelConditionStatus, 89 | pmetrics.LabelReason, 90 | }, additionalLabels...), 91 | ) 92 | } 93 | 94 | // Cardinality is limited to # objects * # conditions 95 | var ConditionTransitionsTotal = conditionTransitionsTotalMetric("", pmetrics.LabelGroup, pmetrics.LabelKind) 96 | 97 | func conditionTransitionsTotalMetric(objectName string, additionalLabels ...string) pmetrics.CounterMetric { 98 | subsystem := lo.Ternary(len(objectName) == 0, MetricSubsystem, fmt.Sprintf("%s_%s", objectName, MetricSubsystem)) 99 | 100 | return pmetrics.NewPrometheusCounter( 101 | metrics.Registry, 102 | prometheus.CounterOpts{ 103 | Namespace: pmetrics.Namespace, 104 | Subsystem: subsystem, 105 | Name: "transitions_total", 106 | Help: "The count of transitions of a given object, type and status.", 107 | }, 108 | append([]string{ 109 | pmetrics.LabelType, 110 | MetricLabelConditionStatus, 111 | pmetrics.LabelReason, 112 | }, additionalLabels...), 113 | ) 114 | 115 | } 116 | 117 | var TerminationCurrentTimeSeconds = terminationCurrentTimeSecondsMetric("", pmetrics.LabelGroup, pmetrics.LabelKind) 118 | 119 | func terminationCurrentTimeSecondsMetric(objectName string, additionalLabels ...string) pmetrics.GaugeMetric { 120 | subsystem := lo.Ternary(len(objectName) == 0, TerminationSubsystem, fmt.Sprintf("%s_%s", objectName, TerminationSubsystem)) 121 | 122 | return pmetrics.NewPrometheusGauge( 123 | metrics.Registry, 124 | prometheus.GaugeOpts{ 125 | Namespace: pmetrics.Namespace, 126 | Subsystem: subsystem, 127 | Name: "current_time_seconds", 128 | Help: "The current amount of time in seconds that an object has been in terminating state.", 129 | }, 130 | append([]string{ 131 | MetricLabelNamespace, 132 | MetricLabelName, 133 | }, additionalLabels...), 134 | ) 135 | } 136 | 137 | var TerminationDuration = terminationDurationMetric("", pmetrics.LabelGroup, pmetrics.LabelKind) 138 | 139 | func terminationDurationMetric(objectName string, additionalLabels ...string) pmetrics.ObservationMetric { 140 | subsystem := lo.Ternary(len(objectName) == 0, TerminationSubsystem, fmt.Sprintf("%s_%s", objectName, TerminationSubsystem)) 141 | 142 | return pmetrics.NewPrometheusHistogram( 143 | metrics.Registry, 144 | prometheus.HistogramOpts{ 145 | Namespace: pmetrics.Namespace, 146 | Subsystem: subsystem, 147 | Name: "duration_seconds", 148 | Help: "The amount of time taken by an object to terminate completely.", 149 | }, 150 | additionalLabels, 151 | ) 152 | } 153 | -------------------------------------------------------------------------------- /status/suite_test.go: -------------------------------------------------------------------------------- 1 | package status_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/awslabs/operatorpkg/test" 7 | "github.com/onsi/ginkgo/v2" 8 | "github.com/onsi/gomega" 9 | "github.com/samber/lo" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/runtime" 12 | "k8s.io/apimachinery/pkg/runtime/schema" 13 | "k8s.io/client-go/kubernetes/scheme" 14 | ) 15 | 16 | func Test(t *testing.T) { 17 | lo.Must0(SchemeBuilder.AddToScheme(scheme.Scheme)) 18 | gomega.RegisterFailHandler(ginkgo.Fail) 19 | ginkgo.RunSpecs(t, "Status") 20 | } 21 | 22 | var ( 23 | SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error { 24 | scheme.AddKnownTypes(schema.GroupVersion{Group: test.APIGroup, Version: "v1alpha1"}, &test.CustomObject{}, &TestGenericObject{}) 25 | return nil 26 | }) 27 | ) 28 | 29 | // +k8s:deepcopy-gen=true 30 | // +kubebuilder:object:root=true 31 | type TestGenericObject struct { 32 | metav1.TypeMeta `json:",inline"` 33 | metav1.ObjectMeta `json:"metadata,omitempty"` 34 | Status TestGenericStatus `json:"status"` 35 | } 36 | 37 | // +k8s:deepcopy-gen=true 38 | type TestGenericStatus struct { 39 | Conditions []metav1.Condition `json:"conditions"` 40 | } 41 | 42 | const ( 43 | // Normal Conditions 44 | ConditionTypeFoo = "Foo" 45 | ConditionTypeBar = "Bar" 46 | // Abnormal Conditions 47 | ConditionTypeBaz = "Baz" 48 | ) 49 | 50 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 51 | func (in *TestGenericObject) DeepCopyInto(out *TestGenericObject) { 52 | *out = *in 53 | out.TypeMeta = in.TypeMeta 54 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 55 | in.Status.DeepCopyInto(&out.Status) 56 | } 57 | 58 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestObject. 59 | func (in *TestGenericObject) DeepCopy() *TestGenericObject { 60 | if in == nil { 61 | return nil 62 | } 63 | out := new(TestGenericObject) 64 | in.DeepCopyInto(out) 65 | return out 66 | } 67 | 68 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 69 | func (in *TestGenericObject) DeepCopyObject() runtime.Object { 70 | if c := in.DeepCopy(); c != nil { 71 | return c 72 | } 73 | return nil 74 | } 75 | 76 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 77 | func (in *TestGenericStatus) DeepCopyInto(out *TestGenericStatus) { 78 | *out = *in 79 | if in.Conditions != nil { 80 | in, out := &in.Conditions, &out.Conditions 81 | *out = make([]metav1.Condition, len(*in)) 82 | for i := range *in { 83 | (*in)[i].DeepCopyInto(&(*out)[i]) 84 | } 85 | } 86 | } 87 | 88 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestStatus. 89 | func (in *TestGenericStatus) DeepCopy() *TestGenericStatus { 90 | if in == nil { 91 | return nil 92 | } 93 | out := new(TestGenericStatus) 94 | in.DeepCopyInto(out) 95 | return out 96 | } 97 | -------------------------------------------------------------------------------- /status/unstructured_adapter.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/awslabs/operatorpkg/object" 7 | opunstructured "github.com/awslabs/operatorpkg/unstructured" 8 | "github.com/samber/lo" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | ) 14 | 15 | // UnstructuredAdapter is an adapter for the status.Object interface. unstructuredAdapter 16 | // makes the assumption that status conditions are found on status.conditions path. 17 | type UnstructuredAdapter[T client.Object] struct { 18 | unstructured.Unstructured 19 | } 20 | 21 | func NewUnstructuredAdapter[T client.Object](obj client.Object) *UnstructuredAdapter[T] { 22 | u := unstructured.Unstructured{Object: opunstructured.ToPartialUnstructured(obj, ".status.conditions")} 23 | ua := &UnstructuredAdapter[T]{Unstructured: u} 24 | ua.SetGroupVersionKind(object.GVK(obj)) 25 | return ua 26 | } 27 | 28 | func (u *UnstructuredAdapter[T]) GetObjectKind() schema.ObjectKind { 29 | return u 30 | } 31 | func (u *UnstructuredAdapter[T]) SetGroupVersionKind(gvk schema.GroupVersionKind) { 32 | u.Unstructured.SetGroupVersionKind(gvk) 33 | } 34 | func (u *UnstructuredAdapter[T]) GroupVersionKind() schema.GroupVersionKind { 35 | return object.GVK(object.New[T]()) 36 | } 37 | 38 | func (u *UnstructuredAdapter[T]) GetConditions() []Condition { 39 | conditions, _, _ := unstructured.NestedFieldNoCopy(u.Object, "status", "conditions") 40 | if conditions == nil { 41 | return nil 42 | } 43 | return lo.Map(conditions.([]interface{}), func(condition interface{}, _ int) Condition { 44 | var newCondition Condition 45 | cond := condition.(map[string]interface{}) 46 | newCondition.Type, _, _ = unstructured.NestedString(cond, "type") 47 | newCondition.Reason, _, _ = unstructured.NestedString(cond, "reason") 48 | status, _, _ := unstructured.NestedString(cond, "status") 49 | if status != "" { 50 | newCondition.Status = metav1.ConditionStatus(status) 51 | } 52 | newCondition.Message, _, _ = unstructured.NestedString(cond, "message") 53 | transitionTime, _, _ := unstructured.NestedString(cond, "lastTransitionTime") 54 | if transitionTime != "" { 55 | newCondition.LastTransitionTime = metav1.Time{Time: lo.Must(time.Parse(time.RFC3339, transitionTime))} 56 | } 57 | newCondition.ObservedGeneration, _, _ = unstructured.NestedInt64(cond, "observedGeneration") 58 | return newCondition 59 | }) 60 | } 61 | func (u *UnstructuredAdapter[T]) SetConditions(conditions []Condition) { 62 | unstructured.SetNestedSlice(u.Object, lo.Map(conditions, func(condition Condition, _ int) interface{} { 63 | b := map[string]interface{}{} 64 | if condition.Type != "" { 65 | b["type"] = condition.Type 66 | } 67 | if condition.Reason != "" { 68 | b["reason"] = condition.Reason 69 | } 70 | if condition.Status != "" { 71 | b["status"] = string(condition.Status) 72 | } 73 | if condition.Message != "" { 74 | b["message"] = condition.Message 75 | } 76 | if !condition.LastTransitionTime.IsZero() { 77 | b["lastTransitionTime"] = condition.LastTransitionTime.Format(time.RFC3339) 78 | } 79 | if condition.ObservedGeneration != 0 { 80 | b["observedGeneration"] = condition.ObservedGeneration 81 | } 82 | return b 83 | }), "status", "conditions") 84 | } 85 | 86 | func (u *UnstructuredAdapter[T]) StatusConditions() ConditionSet { 87 | conditionTypes := lo.Map(u.GetConditions(), func(condition Condition, _ int) string { 88 | return condition.Type 89 | }) 90 | return NewReadyConditions(conditionTypes...).For(u) 91 | } 92 | -------------------------------------------------------------------------------- /status/unstructured_adapter_test.go: -------------------------------------------------------------------------------- 1 | package status_test 2 | 3 | import ( 4 | "github.com/awslabs/operatorpkg/status" 5 | "github.com/awslabs/operatorpkg/test" 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | "k8s.io/apimachinery/pkg/runtime/schema" 11 | ) 12 | 13 | var _ = Describe("Unstructured Adapter", func() { 14 | It("Get unstructured conditions condition values values", func() { 15 | testObject := &unstructured.Unstructured{Object: map[string]interface{}{ 16 | "status": map[string]interface{}{ 17 | "conditions": []interface{}{ 18 | map[string]interface{}{ 19 | "type": "TestType", 20 | "status": "False", 21 | "message": "test message", 22 | "reason": "test reason", 23 | }, 24 | map[string]interface{}{ 25 | "type": "TestType2", 26 | "status": "True", 27 | "message": "test message 2", 28 | "reason": "test reason 2", 29 | }, 30 | }, 31 | }, 32 | }} 33 | testObject.SetGroupVersionKind(schema.GroupVersionKind{ 34 | Group: "testGroup", 35 | Version: "testVersion", 36 | Kind: "testKind", 37 | }) 38 | 39 | conditionObj := status.NewUnstructuredAdapter[*test.CustomObject](testObject) 40 | Expect(conditionObj).ToNot(BeNil()) 41 | Expect(conditionObj.StatusConditions().Get("TestType").Message).To(Equal("test message")) 42 | Expect(conditionObj.StatusConditions().Get("TestType").Status).To(Equal(metav1.ConditionFalse)) 43 | Expect(conditionObj.StatusConditions().Get("TestType").Reason).To(Equal("test reason")) 44 | Expect(conditionObj.StatusConditions().Get("TestType").Type).To(Equal("TestType")) 45 | Expect(conditionObj.StatusConditions().Get("TestType").ObservedGeneration).To(Equal(int64(0))) 46 | 47 | Expect(conditionObj.StatusConditions().Get("TestType2").Message).To(Equal("test message 2")) 48 | Expect(conditionObj.StatusConditions().Get("TestType2").Status).To(Equal(metav1.ConditionTrue)) 49 | Expect(conditionObj.StatusConditions().Get("TestType2").Reason).To(Equal("test reason 2")) 50 | Expect(conditionObj.StatusConditions().Get("TestType2").Type).To(Equal("TestType2")) 51 | Expect(conditionObj.StatusConditions().Get("TestType2").ObservedGeneration).To(Equal(int64(0))) 52 | }) 53 | It("Set unstructured Conditions", func() { 54 | testObject := &unstructured.Unstructured{Object: map[string]interface{}{ 55 | "status": map[string]interface{}{ 56 | "conditions": []interface{}{}, 57 | }, 58 | }} 59 | testObject.SetGroupVersionKind(schema.GroupVersionKind{ 60 | Group: "testGroup", 61 | Version: "testVersion", 62 | Kind: "testKind", 63 | }) 64 | 65 | conditions := []status.Condition{ 66 | { 67 | Type: status.ConditionSucceeded, 68 | Status: metav1.ConditionFalse, 69 | Reason: "test reason", 70 | Message: "test message", 71 | }, 72 | } 73 | conditionObj := status.NewUnstructuredAdapter[*test.CustomObject](testObject) 74 | conditionObj.SetConditions(conditions) 75 | c, found, err := unstructured.NestedSlice(conditionObj.Object, "status", "conditions") 76 | Expect(err).To(BeNil()) 77 | Expect(found).To(BeTrue()) 78 | Expect(len(c)).To(BeEquivalentTo(1)) 79 | Expect(conditionObj.StatusConditions().Get(status.ConditionSucceeded).Message).To(Equal("test message")) 80 | Expect(conditionObj.StatusConditions().Get(status.ConditionSucceeded).Status).To(Equal(metav1.ConditionFalse)) 81 | Expect(conditionObj.StatusConditions().Get(status.ConditionSucceeded).Reason).To(Equal("test reason")) 82 | Expect(conditionObj.StatusConditions().Get(status.ConditionSucceeded).Type).To(Equal(status.ConditionSucceeded)) 83 | Expect(conditionObj.StatusConditions().Get(status.ConditionSucceeded).ObservedGeneration).To(Equal(int64(0))) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /status/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | // +build !ignore_autogenerated 3 | 4 | // Code generated by controller-gen. DO NOT EDIT. 5 | 6 | package status 7 | 8 | import () 9 | 10 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 11 | func (in *Condition) DeepCopyInto(out *Condition) { 12 | *out = *in 13 | in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) 14 | } 15 | 16 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Condition. 17 | func (in *Condition) DeepCopy() *Condition { 18 | if in == nil { 19 | return nil 20 | } 21 | out := new(Condition) 22 | in.DeepCopyInto(out) 23 | return out 24 | } 25 | -------------------------------------------------------------------------------- /test/expectations/expectations.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "sigs.k8s.io/controller-runtime/pkg/client/apiutil" 11 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 12 | "sigs.k8s.io/controller-runtime/pkg/metrics" 13 | 14 | "github.com/awslabs/operatorpkg/object" 15 | "github.com/awslabs/operatorpkg/singleton" 16 | "github.com/awslabs/operatorpkg/status" 17 | . "github.com/onsi/ginkgo/v2" 18 | . "github.com/onsi/gomega" 19 | "github.com/onsi/gomega/types" 20 | prometheus "github.com/prometheus/client_model/go" 21 | "github.com/samber/lo" 22 | "k8s.io/apimachinery/pkg/api/errors" 23 | "sigs.k8s.io/controller-runtime/pkg/client" 24 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 25 | ) 26 | 27 | const ( 28 | SlowTimeout = 100 * time.Second 29 | SlowPolling = 10 * time.Second 30 | FastTimeout = 1 * time.Second 31 | FastPolling = 10 * time.Millisecond 32 | ) 33 | 34 | func ExpectObjectReconciled[T client.Object](ctx context.Context, c client.Client, reconciler reconcile.ObjectReconciler[T], object T) types.Assertion { 35 | GinkgoHelper() 36 | result, err := reconcile.AsReconciler(c, reconciler).Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(object)}) 37 | Expect(err).ToNot(HaveOccurred()) 38 | return Expect(result) 39 | } 40 | 41 | func ExpectObjectReconcileFailed[T client.Object](ctx context.Context, c client.Client, reconciler reconcile.ObjectReconciler[T], object T) types.Assertion { 42 | GinkgoHelper() 43 | _, err := reconcile.AsReconciler(c, reconciler).Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(object)}) 44 | Expect(err).To(HaveOccurred()) 45 | return Expect(err) 46 | } 47 | 48 | func ExpectSingletonReconciled(ctx context.Context, reconciler singleton.Reconciler) reconcile.Result { 49 | GinkgoHelper() 50 | result, err := singleton.AsReconciler(reconciler).Reconcile(ctx, reconcile.Request{}) 51 | Expect(err).ToNot(HaveOccurred()) 52 | return result 53 | } 54 | 55 | func ExpectSingletonReconcileFailed(ctx context.Context, reconciler singleton.Reconciler) error { 56 | GinkgoHelper() 57 | _, err := singleton.AsReconciler(reconciler).Reconcile(ctx, reconcile.Request{}) 58 | Expect(err).To(HaveOccurred()) 59 | return err 60 | } 61 | 62 | // Deprecated: Use the more modern ExpectObjectReconciled and reconcile.AsReconciler instead 63 | func ExpectReconciled(ctx context.Context, reconciler reconcile.Reconciler, object client.Object) reconcile.Result { 64 | GinkgoHelper() 65 | result, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(object)}) 66 | Expect(err).ToNot(HaveOccurred()) 67 | return result 68 | } 69 | 70 | func ExpectRequeued(result reconcile.Result) { 71 | GinkgoHelper() 72 | Expect(result.Requeue || result.RequeueAfter != lo.Empty[time.Duration]()) 73 | } 74 | 75 | func ExpectNotRequeued(result reconcile.Result) { 76 | GinkgoHelper() 77 | Expect(!result.Requeue && result.RequeueAfter == lo.Empty[time.Duration]()) 78 | } 79 | 80 | func ExpectObject[T client.Object](ctx context.Context, c client.Client, obj T) types.Assertion { 81 | GinkgoHelper() 82 | Expect(c.Get(ctx, client.ObjectKeyFromObject(obj), obj)).To(Succeed()) 83 | return Expect(obj) 84 | } 85 | 86 | func ExpectNotFound(ctx context.Context, c client.Client, objects ...client.Object) { 87 | GinkgoHelper() 88 | for _, o := range objects { 89 | Eventually(func() bool { return errors.IsNotFound(c.Get(ctx, client.ObjectKeyFromObject(o), o)) }). 90 | WithTimeout(FastTimeout). 91 | WithPolling(FastPolling). 92 | Should(BeTrue(), func() string { 93 | return fmt.Sprintf("expected %s to be deleted, but it still exists", object.GVKNN(o)) 94 | }) 95 | } 96 | } 97 | 98 | func ExpectApplied(ctx context.Context, c client.Client, objects ...client.Object) { 99 | GinkgoHelper() 100 | for _, o := range objects { 101 | current := o.DeepCopyObject().(client.Object) 102 | // Create or Update 103 | if err := c.Get(ctx, client.ObjectKeyFromObject(current), current); err != nil { 104 | if errors.IsNotFound(err) { 105 | Expect(c.Create(ctx, o)).To(Succeed()) 106 | } else { 107 | Expect(err).ToNot(HaveOccurred()) 108 | } 109 | } else { 110 | o.SetResourceVersion(current.GetResourceVersion()) 111 | Expect(c.Update(ctx, o)).To(Succeed()) 112 | } 113 | 114 | // Re-get the object to grab the updated spec and status 115 | ExpectObject(ctx, c, o) 116 | } 117 | } 118 | 119 | // ExpectDeletionTimestampSet ensures that the deletion timestamp is set on the objects by adding a finalizer 120 | // and then deleting the object immediately after. This will hold the object until the finalizer is patched out 121 | func ExpectDeletionTimestampSet(ctx context.Context, c client.Client, objects ...client.Object) { 122 | GinkgoHelper() 123 | for _, o := range objects { 124 | Expect(c.Get(ctx, client.ObjectKeyFromObject(o), o)).To(Succeed()) 125 | controllerutil.AddFinalizer(o, "testing/finalizer") 126 | Expect(c.Update(ctx, o)).To(Succeed()) 127 | Expect(c.Delete(ctx, o)).To(Succeed()) 128 | } 129 | } 130 | 131 | func ExpectStatusConditions(ctx context.Context, c client.Client, timeout time.Duration, obj status.Object, conditions ...status.Condition) { 132 | GinkgoHelper() 133 | Eventually(func(g Gomega) { 134 | g.Expect(c.Get(ctx, client.ObjectKeyFromObject(obj), obj)).To(BeNil()) 135 | objStatus := obj.StatusConditions() 136 | for _, cond := range conditions { 137 | objCondition := objStatus.Get(cond.Type) 138 | g.Expect(objCondition).ToNot(BeNil()) 139 | if cond.Status != "" { 140 | g.Expect(objCondition.Status).To(Equal(cond.Status)) 141 | } 142 | if cond.Message != "" { 143 | g.Expect(objCondition.Message).To(Equal(cond.Message)) 144 | } 145 | if cond.Reason != "" { 146 | g.Expect(objCondition.Reason).To(Equal(cond.Reason)) 147 | } 148 | } 149 | }). 150 | WithTimeout(timeout). 151 | // each polling interval 152 | WithPolling(timeout / 20). 153 | Should(Succeed()) 154 | } 155 | 156 | func ExpectStatusUpdated(ctx context.Context, c client.Client, objects ...client.Object) { 157 | GinkgoHelper() 158 | for _, o := range objects { 159 | // Previous implementations attempted the following: 160 | // 1. Using merge patch, instead 161 | // 2. Including this logic in ExpectApplied to simplify test code 162 | // The former doesn't work, as merge patches cannot reset 163 | // primitives like strings and integers to "" or 0, and CRDs 164 | // don't support strategic merge patch. The latter doesn't work 165 | // since status must be updated in another call, which can cause 166 | // optimistic locking issues if other threads are updating objects 167 | // e.g. pod statuses being updated during integration tests. 168 | Expect(c.Status().Update(ctx, o.DeepCopyObject().(client.Object))).To(Succeed()) 169 | ExpectObject(ctx, c, o) 170 | } 171 | } 172 | 173 | func ExpectDeleted(ctx context.Context, c client.Client, objects ...client.Object) { 174 | GinkgoHelper() 175 | for _, o := range objects { 176 | Expect(client.IgnoreNotFound(c.Delete(ctx, o))).To(Succeed()) 177 | Expect(client.IgnoreNotFound(c.Get(ctx, client.ObjectKeyFromObject(o), o))).To(Succeed()) 178 | } 179 | } 180 | 181 | // ExpectCleanedUp waits to cleanup all items passed through objectLists 182 | func ExpectCleanedUp(ctx context.Context, c client.Client, objectLists ...client.ObjectList) { 183 | expectCleanedUp(ctx, c, false, objectLists...) 184 | } 185 | 186 | // ExpectForceCleanedUp waits to cleanup all items passed through objectLists 187 | // It forcefully removes any finalizers from all of these objects to unblock delete 188 | func ExpectForceCleanedUp(ctx context.Context, c client.Client, objectLists ...client.ObjectList) { 189 | expectCleanedUp(ctx, c, true, objectLists...) 190 | } 191 | 192 | func expectCleanedUp(ctx context.Context, c client.Client, force bool, objectLists ...client.ObjectList) { 193 | GinkgoHelper() 194 | wg := sync.WaitGroup{} 195 | for _, objectList := range objectLists { 196 | wg.Add(1) 197 | go func(objectList client.ObjectList) { 198 | defer GinkgoRecover() 199 | defer wg.Done() 200 | 201 | Eventually(func(g Gomega) { 202 | metaList := &metav1.PartialObjectMetadataList{} 203 | metaList.SetGroupVersionKind(lo.Must(apiutil.GVKForObject(objectList, c.Scheme()))) 204 | g.Expect(c.List(ctx, metaList)).To(Succeed()) 205 | 206 | for _, item := range metaList.Items { 207 | if force { 208 | stored := item.DeepCopy() 209 | item.SetFinalizers([]string{}) 210 | g.Expect(c.Patch(ctx, &item, client.MergeFrom(stored))).To(Succeed()) 211 | } 212 | if item.GetDeletionTimestamp().IsZero() { 213 | g.Expect(client.IgnoreNotFound(c.Delete(ctx, &item, client.PropagationPolicy(metav1.DeletePropagationForeground), &client.DeleteOptions{GracePeriodSeconds: lo.ToPtr(int64(0))}))).To(Succeed()) 214 | } 215 | } 216 | g.Expect(c.List(ctx, metaList, client.Limit(1))).To(Succeed()) 217 | g.Expect(metaList.Items).To(HaveLen(0)) 218 | }).Should(Succeed()) 219 | }(objectList) 220 | } 221 | wg.Wait() 222 | } 223 | 224 | // GetMetric attempts to find a metric given name and labels 225 | // If no metric is found, the *prometheus.Metric will be nil 226 | func GetMetric(name string, labels ...map[string]string) *prometheus.Metric { 227 | family, found := lo.Find(lo.Must(metrics.Registry.Gather()), func(family *prometheus.MetricFamily) bool { return family.GetName() == name }) 228 | if !found { 229 | return nil 230 | } 231 | for _, m := range family.Metric { 232 | temp := lo.Assign(labels...) 233 | for _, labelPair := range m.Label { 234 | if v, ok := temp[labelPair.GetName()]; ok && v == labelPair.GetValue() { 235 | delete(temp, labelPair.GetName()) 236 | } 237 | } 238 | if len(temp) == 0 { 239 | return m 240 | } 241 | } 242 | return nil 243 | } 244 | -------------------------------------------------------------------------------- /test/object.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/Pallinder/go-randomdata" 10 | "github.com/awslabs/operatorpkg/status" 11 | "github.com/imdario/mergo" 12 | "github.com/samber/lo" 13 | corev1 "k8s.io/api/core/v1" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/runtime" 16 | "sigs.k8s.io/controller-runtime/pkg/client" 17 | ) 18 | 19 | var ( 20 | APIGroup = "operators.k8s.aws" 21 | DiscoveryLabel = APIGroup + "/test-id" 22 | sequentialNumber = 0 23 | sequentialNumberLock = new(sync.Mutex) 24 | ) 25 | 26 | var Namespace = &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}} 27 | 28 | func Object[T client.Object](base T, overrides ...T) T { 29 | dest := reflect.New(reflect.TypeOf(base).Elem()).Interface().(T) 30 | dest.SetName(RandomName()) 31 | dest.SetNamespace(Namespace.Name) 32 | dest.SetGeneration(1) 33 | dest.SetLabels(lo.Assign(dest.GetLabels(), map[string]string{DiscoveryLabel: dest.GetName()})) 34 | for _, src := range append([]T{base}, overrides...) { 35 | lo.Must0(mergo.Merge(dest, src, mergo.WithOverride)) 36 | } 37 | dest.SetCreationTimestamp(metav1.Now()) 38 | return dest 39 | } 40 | 41 | func RandomName() string { 42 | sequentialNumberLock.Lock() 43 | defer sequentialNumberLock.Unlock() 44 | sequentialNumber++ 45 | return strings.ToLower(fmt.Sprintf("%s-%d-%s", randomdata.SillyName(), sequentialNumber, randomdata.Alphanumeric(10))) 46 | } 47 | 48 | // +k8s:deepcopy-gen=true 49 | // +kubebuilder:object:root=true 50 | type CustomObject struct { 51 | metav1.TypeMeta `json:",inline"` 52 | metav1.ObjectMeta `json:"metadata,omitempty"` 53 | Spec CustomSpec `json:"spec"` 54 | Status CustomStatus `json:"status"` 55 | } 56 | 57 | // +k8s:deepcopy-gen=true 58 | type CustomSpec struct { 59 | Field1 string `json:"field1,omitempty"` 60 | Field2 string `json:"field2,omitempty"` 61 | } 62 | 63 | // +k8s:deepcopy-gen=true 64 | type CustomStatus struct { 65 | Conditions []status.Condition `json:"conditions,omitempty"` 66 | } 67 | 68 | const ( 69 | // Normal Conditions 70 | ConditionTypeFoo = "Foo" 71 | ConditionTypeBar = "Bar" 72 | // Abnormal Conditions 73 | ConditionTypeBaz = "Baz" 74 | ) 75 | 76 | func (t *CustomObject) StatusConditions() status.ConditionSet { 77 | return status.NewReadyConditions(ConditionTypeFoo, ConditionTypeBar).For(t) 78 | } 79 | 80 | func (t *CustomObject) GetConditions() []status.Condition { 81 | return t.Status.Conditions 82 | } 83 | 84 | func (t *CustomObject) SetConditions(conditions []status.Condition) { 85 | t.Status.Conditions = conditions 86 | } 87 | 88 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 89 | func (in *CustomObject) DeepCopyInto(out *CustomObject) { 90 | *out = *in 91 | out.TypeMeta = in.TypeMeta 92 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 93 | in.Status.DeepCopyInto(&out.Status) 94 | } 95 | 96 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomObject. 97 | func (in *CustomObject) DeepCopy() *CustomObject { 98 | if in == nil { 99 | return nil 100 | } 101 | out := new(CustomObject) 102 | in.DeepCopyInto(out) 103 | return out 104 | } 105 | 106 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 107 | func (in *CustomObject) DeepCopyObject() runtime.Object { 108 | if c := in.DeepCopy(); c != nil { 109 | return c 110 | } 111 | return nil 112 | } 113 | 114 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 115 | func (in *CustomStatus) DeepCopyInto(out *CustomStatus) { 116 | *out = *in 117 | if in.Conditions != nil { 118 | in, out := &in.Conditions, &out.Conditions 119 | *out = make([]status.Condition, len(*in)) 120 | for i := range *in { 121 | (*in)[i].DeepCopyInto(&(*out)[i]) 122 | } 123 | } 124 | } 125 | 126 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomStatus. 127 | func (in *CustomStatus) DeepCopy() *CustomStatus { 128 | if in == nil { 129 | return nil 130 | } 131 | out := new(CustomStatus) 132 | in.DeepCopyInto(out) 133 | return out 134 | } 135 | -------------------------------------------------------------------------------- /unstructured/unstructured.go: -------------------------------------------------------------------------------- 1 | package unstructured 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/samber/lo" 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | ) 11 | 12 | // ToPartialUnstructured converts an object to unstructured, but only converts specific field paths 13 | // This is more memory efficient than using runtime.DefaultUnstructuredConverter since that requires the full 14 | // object to be converted and stored before extracting specific values from that object 15 | func ToPartialUnstructured(obj interface{}, fieldPaths ...string) map[string]interface{} { 16 | if u, ok := obj.(unstructured.Unstructured); ok { 17 | obj = u.UnstructuredContent() 18 | } 19 | if u, ok := obj.(*unstructured.Unstructured); ok { 20 | obj = u.UnstructuredContent() 21 | } 22 | 23 | result := make(map[string]interface{}) 24 | for _, fieldPath := range fieldPaths { 25 | _ = extractNestedField(obj, result, lo.Filter(strings.Split(fieldPath, "."), func(s string, _ int) bool { return s != "" })...) 26 | } 27 | return result 28 | } 29 | 30 | // extractNestedField extracts a field using a path and populates the result map accordingly 31 | func extractNestedField(obj interface{}, result map[string]interface{}, field ...string) error { 32 | v := reflect.ValueOf(obj) 33 | if v.Kind() == reflect.Ptr { 34 | v = v.Elem() 35 | } 36 | var val reflect.Value 37 | switch v.Kind() { 38 | case reflect.Struct: 39 | for i := range v.Type().NumField() { 40 | f := v.Type().Field(i) 41 | tag := getJSONKey(f) 42 | if f.Name == field[0] || tag == field[0] { 43 | val = v.Field(i) 44 | break 45 | } 46 | } 47 | case reflect.Map: 48 | for _, key := range v.MapKeys() { 49 | if key.String() == field[0] { 50 | val = v.MapIndex(key) 51 | break 52 | } 53 | } 54 | default: 55 | } 56 | if !val.IsValid() { 57 | return fmt.Errorf("field %q not found in %T", field[0], obj) 58 | } 59 | if len(field) == 1 { 60 | // Final field — assign directly 61 | result[field[0]] = val.Interface() 62 | return nil 63 | } 64 | // Intermediate map — recurse 65 | childMap := map[string]interface{}{} 66 | err := extractNestedField(val.Interface(), childMap, field[1:]...) 67 | if err != nil { 68 | return err 69 | } 70 | // Merge into parent map 71 | if _, ok := result[field[0]]; !ok { 72 | result[field[0]] = map[string]interface{}{} 73 | } 74 | for k, v := range childMap { 75 | m, ok := result[field[0]].(map[string]interface{}) 76 | // In general, this should never happen because we have a check higher up in the function for field existence 77 | if !ok { 78 | panic(fmt.Sprintf("full field path %q not found in %T", field, obj)) 79 | } 80 | m[k] = v 81 | } 82 | return nil 83 | } 84 | 85 | // getJSONKey returns the JSON key from a struct tag 86 | func getJSONKey(field reflect.StructField) string { 87 | tag := field.Tag.Get("json") 88 | if tag == "" { 89 | return field.Name 90 | } 91 | if commaIdx := strings.Index(tag, ","); commaIdx != -1 { 92 | return tag[:commaIdx] 93 | } 94 | return tag 95 | } 96 | --------------------------------------------------------------------------------