├── .github ├── dependabot.yml └── workflows │ └── ci.yaml ├── .gitignore ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MAINTAINERS.md ├── NOTICE ├── README.md ├── apis ├── conditionset.go ├── conditionset_test.go ├── status_types.go └── zz_generated.deepcopy.go ├── codecov.yml ├── docs └── RestMapper.md ├── duck └── client.go ├── go.mod ├── go.sum ├── hack ├── boilerplate.go.txt ├── go.mod ├── go.sum ├── tools.go └── update-codegen.sh ├── internal ├── resources │ ├── dies │ │ ├── dies.go │ │ ├── zz_generated.die.go │ │ └── zz_generated.die_test.go │ ├── resource.go │ ├── resource_duck.go │ ├── resource_list_invalid.go │ ├── resource_list_with_interface_items.go │ ├── resource_list_with_pointer_items.go │ ├── resource_with_empty_status.go │ ├── resource_with_legacy_default.go │ ├── resource_with_nilable_status.go │ ├── resource_with_unexported_fields.go │ ├── resource_without_status.go │ └── zz_generated.deepcopy.go └── util.go ├── reconcilers ├── advice.go ├── advice_test.go ├── aggregate.go ├── aggregate_test.go ├── alias.go ├── always.go ├── always_test.go ├── cast.go ├── cast_test.go ├── child.go ├── child_test.go ├── childset.go ├── childset_test.go ├── cmp.go ├── cmp_test.go ├── config.go ├── config_test.go ├── errors.go ├── errors_test.go ├── finalizer.go ├── finalizer_test.go ├── flow.go ├── flow_test.go ├── objectmanager.go ├── objectmanager_test.go ├── reconcilers.go ├── reconcilers_test.go ├── resource.go ├── resource_test.go ├── sequence.go ├── sequence_test.go ├── sync.go ├── sync_test.go ├── testing_test.go ├── util.go ├── util_test.go ├── webhook.go └── webhook_test.go ├── stash ├── stash.go └── stash_test.go ├── testing ├── actions.go ├── aliases.go ├── client.go ├── color.go ├── config.go ├── config_test.go ├── diff.go ├── diff_test.go ├── doc.go ├── objectmanager.go ├── reconciler.go ├── reconciler_test.go ├── recorder.go ├── subreconciler.go ├── tracker.go └── webhook.go ├── time └── now.go ├── tracker ├── doc.go ├── enqueue.go ├── enqueue_test.go └── interface.go └── validation ├── validation.go └── validation_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: gomod 9 | directory: "/" 10 | groups: 11 | kubernetes: 12 | patterns: 13 | - "k8s.io/*" 14 | schedule: 15 | interval: daily 16 | open-pull-requests-limit: 10 17 | - package-ecosystem: gomod 18 | directory: "/hack" 19 | schedule: 20 | interval: daily 21 | open-pull-requests-limit: 10 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | - '!dependabot/**' 8 | pull_request: {} 9 | 10 | jobs: 11 | 12 | test: 13 | name: Test 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Set up Go 1.x 17 | uses: actions/setup-go@v6 18 | with: 19 | go-version: 1.24.x 20 | id: go 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v6 23 | - name: Get dependencies 24 | run: | 25 | go get -v -t -d ./... 26 | - name: Regenerate files 27 | run: | 28 | ./hack/update-codegen.sh 29 | - name: Check for drift in generated files 30 | run: | 31 | git diff --exit-code . 32 | - name: Test 33 | run: | 34 | go test -v ./... -coverprofile cover.out 35 | - name: Upload coverage to Codecov 36 | uses: codecov/codecov-action@v5 37 | with: 38 | token: ${{ secrets.CODECOV_TOKEN }} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @scothis @mamachanko 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official email address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [`conduct@reconciler.io`](mailto:conduct@reconciler.io?reconciler.io%2Fruntime%20conduct). 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Contributing to reconciler.io runtime 4 | 5 | The reconciler.io runtime project team welcomes contributions from the community. A contributor license agreement (CLA) is not required. You own full rights to your contribution and agree to license the work to the community under the Apache License v2.0, via a [Developer Certificate of Origin (DCO)](https://developercertificate.org). 6 | 7 | ## Contribution Flow 8 | 9 | This is a rough outline of what a contributor's workflow looks like: 10 | 11 | - Create a topic branch from where you want to base your work 12 | - Make commits of logical units 13 | - Make sure your commit messages are in the proper format (see below) 14 | - Push your changes to a topic branch in your fork of the repository 15 | - Submit a pull request 16 | 17 | Example: 18 | 19 | ``` shell 20 | git remote add upstream https://github.com/reconcilerio/runtime.git 21 | git checkout -b my-new-feature main 22 | git commit -a 23 | git push origin my-new-feature 24 | ``` 25 | 26 | ### Staying In Sync With Upstream 27 | 28 | When your branch gets out of sync with the reconcilerio/main branch, use the following to update: 29 | 30 | ``` shell 31 | git checkout my-new-feature 32 | git fetch -a 33 | git pull --rebase upstream main 34 | git push --force-with-lease origin my-new-feature 35 | ``` 36 | 37 | ### Updating pull requests 38 | 39 | If your PR fails to pass CI or needs changes based on code review, you'll most likely want to squash these changes into 40 | existing commits. 41 | 42 | If your pull request contains a single commit or your changes are related to the most recent commit, you can simply 43 | amend the commit. 44 | 45 | ``` shell 46 | git add . 47 | git commit --amend 48 | git push --force-with-lease origin my-new-feature 49 | ``` 50 | 51 | If you need to squash changes into an earlier commit, you can use: 52 | 53 | ``` shell 54 | git add . 55 | git commit --fixup 56 | git rebase -i --autosquash main 57 | git push --force-with-lease origin my-new-feature 58 | ``` 59 | 60 | Be sure to add a comment to the PR indicating your new changes are ready to review, as GitHub does not generate a 61 | notification when you git push. 62 | 63 | ### Code Style 64 | 65 | ### Formatting Commit Messages 66 | 67 | We follow the conventions on [How to Write a Git Commit Message](http://chris.beams.io/posts/git-commit/). 68 | 69 | Be sure to include any related GitHub issue references in the commit message. See 70 | [GFM syntax](https://guides.github.com/features/mastering-markdown/#GitHub-flavored-markdown) for referencing issues 71 | and commits. 72 | 73 | ## Reporting Bugs and Creating Issues 74 | 75 | When opening a new issue, try to roughly follow the commit message format conventions above. 76 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Maintainers 2 | 3 | * Scott Andrews, [scothis](https://github.com/scothis) 4 | * Max Brauer, [mamachanko](https://github.com/mamachanko) 5 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Reconciler.io runtime 2 | Copyright 2024 the original author or authors. 3 | 4 | Reconciler.io runtime is a fork of reconciler-runtime (https://github.com/vmware-labs/reconciler-runtime), 5 | consumed under the Apache 2.0 license. 6 | Copyright 2019-2024 VMware, Inc. All Rights Reserved. 7 | 8 | The initial development of some parts of the framework, which are copied from, derived from, or 9 | inspired by Knative (https://knative.dev/). 10 | Copyright 2019 The Knative Authors 11 | 12 | This product may include a number of subcomponents with separate copyright notices and license terms. 13 | Your use of these subcomponents is subject to the terms and conditions of the subcomponent's license, 14 | as noted in the LICENSE file. 15 | -------------------------------------------------------------------------------- /apis/conditionset_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package apis 18 | 19 | import ( 20 | "testing" 21 | 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | ) 24 | 25 | func TestConditionStatus(t *testing.T) { 26 | tests := []struct { 27 | name string 28 | condition *metav1.Condition 29 | expectedTrue bool 30 | expectedFalse bool 31 | expectedUnknown bool 32 | }{ 33 | { 34 | name: "true", 35 | condition: &metav1.Condition{Status: metav1.ConditionTrue}, 36 | expectedTrue: true, 37 | expectedFalse: false, 38 | expectedUnknown: false, 39 | }, 40 | { 41 | name: "false", 42 | condition: &metav1.Condition{Status: metav1.ConditionFalse}, 43 | expectedTrue: false, 44 | expectedFalse: true, 45 | expectedUnknown: false, 46 | }, 47 | { 48 | name: "unknown", 49 | condition: &metav1.Condition{Status: metav1.ConditionUnknown}, 50 | expectedTrue: false, 51 | expectedFalse: false, 52 | expectedUnknown: true, 53 | }, 54 | { 55 | name: "unset", 56 | condition: &metav1.Condition{}, 57 | expectedTrue: false, 58 | expectedFalse: false, 59 | expectedUnknown: true, 60 | }, 61 | { 62 | name: "nil", 63 | condition: nil, 64 | expectedTrue: false, 65 | expectedFalse: false, 66 | expectedUnknown: true, 67 | }, 68 | } 69 | for _, c := range tests { 70 | t.Run(c.name, func(t *testing.T) { 71 | if expected, actual := c.expectedTrue, ConditionIsTrue(c.condition); expected != actual { 72 | t.Errorf("%s: IsTrue() actually = %v, expected %v", c.name, actual, expected) 73 | } 74 | if expected, actual := c.expectedFalse, ConditionIsFalse(c.condition); expected != actual { 75 | t.Errorf("%s: IsFalse() actually = %v, expected %v", c.name, actual, expected) 76 | } 77 | if expected, actual := c.expectedUnknown, ConditionIsUnknown(c.condition); expected != actual { 78 | t.Errorf("%s: IsUnknown() actually = %v, expected %v", c.name, actual, expected) 79 | } 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /apis/status_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019-2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package apis 18 | 19 | import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 20 | 21 | // Status is the minimally expected status subresource. Use this or provide your own. It also shows how Conditions are 22 | // expected to be embedded in the Status field. 23 | // 24 | // Example: 25 | // 26 | // type MyResourceStatus struct { 27 | // apis.Status `json:",inline"` 28 | // UsefulMessage string `json:"usefulMessage,omitempty"` 29 | // } 30 | // 31 | // WARNING: Adding fields to this struct will add them to all resources. 32 | // +k8s:deepcopy-gen=true 33 | type Status struct { 34 | // ObservedGeneration is the 'Generation' of the resource that 35 | // was last processed by the controller. 36 | // +optional 37 | ObservedGeneration int64 `json:"observedGeneration,omitempty"` 38 | 39 | // Conditions the latest available observations of a resource's current state. 40 | // +optional 41 | // +patchMergeKey=type 42 | // +patchStrategy=merge 43 | Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` 44 | } 45 | 46 | var _ ConditionsAccessor = (*Status)(nil) 47 | 48 | // GetConditions implements ConditionsAccessor 49 | func (s *Status) GetConditions() []metav1.Condition { 50 | return s.Conditions 51 | } 52 | 53 | // SetConditions implements ConditionsAccessor 54 | func (s *Status) SetConditions(c []metav1.Condition) { 55 | s.Conditions = c 56 | } 57 | 58 | // GetCondition fetches the condition of the specified type. 59 | func (s *Status) GetCondition(t string) *metav1.Condition { 60 | for _, cond := range s.Conditions { 61 | if cond.Type == t { 62 | return &cond 63 | } 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /apis/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | /* 4 | Copyright 2019-2024 the original author or authors. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | // Code generated by controller-gen. DO NOT EDIT. 20 | 21 | package apis 22 | 23 | import ( 24 | "k8s.io/apimachinery/pkg/apis/meta/v1" 25 | ) 26 | 27 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 28 | func (in *Status) DeepCopyInto(out *Status) { 29 | *out = *in 30 | if in.Conditions != nil { 31 | in, out := &in.Conditions, &out.Conditions 32 | *out = make([]v1.Condition, len(*in)) 33 | for i := range *in { 34 | (*in)[i].DeepCopyInto(&(*out)[i]) 35 | } 36 | } 37 | } 38 | 39 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Status. 40 | func (in *Status) DeepCopy() *Status { 41 | if in == nil { 42 | return nil 43 | } 44 | out := new(Status) 45 | in.DeepCopyInto(out) 46 | return out 47 | } 48 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "**/zz_generated.*.go" 3 | - "internal/resources" 4 | github_checks: false 5 | -------------------------------------------------------------------------------- /docs/RestMapper.md: -------------------------------------------------------------------------------- 1 | # RestMapper 2 | 3 | ## Using the RestMapper in a reconciler. 4 | 5 | It's sometimes necessary to access the RestMapper in your reconciler. The RestMapper's 6 | purpose is to map between the `R` (Resource) in `GVR` and the `K` (Kind) in `GVK`. 7 | 8 | Unfortunately [`rtesting`](../README.md#testing)) cannot 9 | populate the RestMapper automatically. The information for this mapping is (typically) 10 | generated by controller-gen and placed into manifests. 11 | 12 | For RestMapper to work, you will need to populate the RestMapper with the GVK you want 13 | it to resolve. 14 | 15 | ### Example: Using the resource guesser 16 | 17 | [DefaultRESTMapper.Add](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/meta@v0.28.4#DefaultRESTMapper.Add) adds the GVK 18 | with a guessed singular and plural resource name. (See [UnsafeGuessKindToResource](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/meta@v0.28.4#UnsafeGuessKindToResource) 19 | for how this is done) 20 | 21 | ``` 22 | rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { 23 | gvk := schema.GroupVersionKind{ 24 | Group: "example.com", 25 | Version: "v1alpha1", 26 | Kind: "Widget", 27 | } 28 | restMapper := c.RESTMapper().(*meta.DefaultRESTMapper) 29 | restMapper.Add(gvk, meta.RESTScopeNamespace) 30 | return widget.Reconciler(c) 31 | }) 32 | ``` 33 | 34 | ### Example: Using an explicit mapping 35 | 36 | [DefaultRESTMapper.AddSpecific](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/meta@v0.28.4#DefaultRESTMapper.AddSpecific) 37 | adds the GVK with an explicit resource name. 38 | 39 | ``` 40 | rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { 41 | gvk := schema.GroupVersionKind{ 42 | Group: "example.com", 43 | Version: "v1alpha1", 44 | Kind: "Widget", 45 | } 46 | gvrSingular := schema.GroupVersionKind{ 47 | Group: "example.com", 48 | Version: "v1alpha1", 49 | Resource: "widget", 50 | } 51 | gvrPlural := schema.GroupVersionKind{ 52 | Group: "example.com", 53 | Version: "v1alpha1", 54 | Resource: "widgets", 55 | } 56 | 57 | restMapper := c.RESTMapper().(*meta.DefaultRESTMapper) 58 | restMapper.AddSpecific(gvk, gvrSingular, gvrPlural, meta.RESTScopeNamespace) 59 | return widget.Reconciler(c) 60 | }) 61 | ``` -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module reconciler.io/runtime 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/evanphx/json-patch/v5 v5.9.11 7 | github.com/fatih/color v1.18.0 8 | github.com/go-logr/logr v1.4.3 9 | github.com/google/go-cmp v0.7.0 10 | golang.org/x/net v0.47.0 11 | gomodules.xyz/jsonpatch/v2 v2.5.0 12 | gomodules.xyz/jsonpatch/v3 v3.0.1 13 | k8s.io/api v0.34.2 14 | k8s.io/apimachinery v0.34.2 15 | k8s.io/client-go v0.34.2 16 | k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d 17 | reconciler.io/dies v0.17.0 18 | sigs.k8s.io/controller-runtime v0.22.4 19 | sigs.k8s.io/yaml v1.6.0 20 | ) 21 | 22 | require ( 23 | github.com/beorn7/perks v1.0.1 // indirect 24 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 25 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 26 | github.com/emicklei/go-restful/v3 v3.12.2 // indirect 27 | github.com/evanphx/json-patch v5.9.0+incompatible // indirect 28 | github.com/fsnotify/fsnotify v1.9.0 // indirect 29 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 30 | github.com/go-openapi/jsonpointer v0.21.2 // indirect 31 | github.com/go-openapi/jsonreference v0.21.0 // indirect 32 | github.com/go-openapi/swag v0.23.1 // indirect 33 | github.com/gogo/protobuf v1.3.2 // indirect 34 | github.com/google/btree v1.1.3 // indirect 35 | github.com/google/gnostic-models v0.7.0 // indirect 36 | github.com/google/uuid v1.6.0 // indirect 37 | github.com/josharian/intern v1.0.0 // indirect 38 | github.com/json-iterator/go v1.1.12 // indirect 39 | github.com/mailru/easyjson v0.9.0 // indirect 40 | github.com/mattn/go-colorable v0.1.13 // indirect 41 | github.com/mattn/go-isatty v0.0.20 // indirect 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 43 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 44 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 45 | github.com/pkg/errors v0.9.1 // indirect 46 | github.com/pmezard/go-difflib v1.0.0 // indirect 47 | github.com/prometheus/client_golang v1.22.0 // indirect 48 | github.com/prometheus/client_model v0.6.1 // indirect 49 | github.com/prometheus/common v0.62.0 // indirect 50 | github.com/prometheus/procfs v0.15.1 // indirect 51 | github.com/spf13/pflag v1.0.6 // indirect 52 | github.com/x448/float16 v0.8.4 // indirect 53 | go.yaml.in/yaml/v2 v2.4.2 // indirect 54 | go.yaml.in/yaml/v3 v3.0.4 // indirect 55 | golang.org/x/oauth2 v0.27.0 // indirect 56 | golang.org/x/sync v0.18.0 // indirect 57 | golang.org/x/sys v0.38.0 // indirect 58 | golang.org/x/term v0.37.0 // indirect 59 | golang.org/x/text v0.31.0 // indirect 60 | golang.org/x/time v0.9.0 // indirect 61 | gomodules.xyz/orderedmap v0.1.0 // indirect 62 | google.golang.org/protobuf v1.36.8 // indirect 63 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 64 | gopkg.in/inf.v0 v0.9.1 // indirect 65 | gopkg.in/yaml.v3 v3.0.1 // indirect 66 | k8s.io/apiextensions-apiserver v0.34.1 // indirect 67 | k8s.io/klog/v2 v2.130.1 // indirect 68 | k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3 // indirect 69 | sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect 70 | sigs.k8s.io/randfill v1.0.0 // indirect 71 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 72 | ) 73 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019-2024 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ -------------------------------------------------------------------------------- /hack/go.mod: -------------------------------------------------------------------------------- 1 | module reconciler.io/runtime/hack 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | reconciler.io/dies/diegen v0.17.0 7 | sigs.k8s.io/controller-tools v0.19.0 8 | ) 9 | 10 | require ( 11 | github.com/fatih/color v1.18.0 // indirect 12 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 13 | github.com/go-logr/logr v1.4.3 // indirect 14 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 15 | github.com/go-openapi/jsonreference v0.20.2 // indirect 16 | github.com/go-openapi/swag v0.23.0 // indirect 17 | github.com/gobuffalo/flect v1.0.3 // indirect 18 | github.com/gogo/protobuf v1.3.2 // indirect 19 | github.com/google/gnostic-models v0.7.0 // indirect 20 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 21 | github.com/josharian/intern v1.0.0 // indirect 22 | github.com/json-iterator/go v1.1.12 // indirect 23 | github.com/mailru/easyjson v0.7.7 // indirect 24 | github.com/mattn/go-colorable v0.1.13 // indirect 25 | github.com/mattn/go-isatty v0.0.20 // indirect 26 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 27 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 28 | github.com/spf13/cobra v1.9.1 // indirect 29 | github.com/spf13/pflag v1.0.7 // indirect 30 | github.com/x448/float16 v0.8.4 // indirect 31 | go.yaml.in/yaml/v2 v2.4.2 // indirect 32 | go.yaml.in/yaml/v3 v3.0.4 // indirect 33 | golang.org/x/mod v0.27.0 // indirect 34 | golang.org/x/net v0.43.0 // indirect 35 | golang.org/x/sync v0.16.0 // indirect 36 | golang.org/x/sys v0.35.0 // indirect 37 | golang.org/x/text v0.28.0 // indirect 38 | golang.org/x/tools v0.36.0 // indirect 39 | google.golang.org/protobuf v1.36.7 // indirect 40 | gopkg.in/inf.v0 v0.9.1 // indirect 41 | gopkg.in/yaml.v2 v2.4.0 // indirect 42 | gopkg.in/yaml.v3 v3.0.1 // indirect 43 | k8s.io/api v0.34.0 // indirect 44 | k8s.io/apiextensions-apiserver v0.34.0 // indirect 45 | k8s.io/apimachinery v0.34.0 // indirect 46 | k8s.io/code-generator v0.34.0 // indirect 47 | k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f // indirect 48 | k8s.io/klog/v2 v2.130.1 // indirect 49 | k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect 50 | k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect 51 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 52 | sigs.k8s.io/randfill v1.0.0 // indirect 53 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 54 | sigs.k8s.io/yaml v1.6.0 // indirect 55 | ) 56 | -------------------------------------------------------------------------------- /hack/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | // This package imports things required by build scripts, to force `go mod` to see them as dependencies 5 | package tools 6 | 7 | import ( 8 | _ "reconciler.io/dies/diegen" 9 | _ "sigs.k8s.io/controller-tools/cmd/controller-gen" 10 | ) 11 | -------------------------------------------------------------------------------- /hack/update-codegen.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | SCRIPT_ROOT=$(cd $(dirname "${BASH_SOURCE[0]}")/.. && pwd) 8 | CONTROLLER_GEN="go run -modfile=${SCRIPT_ROOT}/hack/go.mod sigs.k8s.io/controller-tools/cmd/controller-gen" 9 | DIEGEN="go run -modfile=${SCRIPT_ROOT}/hack/go.mod reconciler.io/dies/diegen" 10 | 11 | ( cd $SCRIPT_ROOT ; $CONTROLLER_GEN object:headerFile="./hack/boilerplate.go.txt" paths="./..." ) 12 | ( cd $SCRIPT_ROOT ; $DIEGEN die:headerFile="./hack/boilerplate.go.txt" paths="./..." ) 13 | 14 | ( cd $SCRIPT_ROOT ; go mod tidy ) 15 | ( cd $SCRIPT_ROOT/hack ; go mod tidy ) 16 | -------------------------------------------------------------------------------- /internal/resources/dies/dies.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package dies 18 | 19 | import ( 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | diemetav1 "reconciler.io/dies/apis/meta/v1" 22 | "reconciler.io/runtime/internal/resources" 23 | ) 24 | 25 | // +die:object=true 26 | type _ = resources.TestResource 27 | 28 | // +die 29 | // +die:field:name=Template,package=_/core/v1,die=PodTemplateSpecDie 30 | type _ = resources.TestResourceSpec 31 | 32 | func (d *TestResourceSpecDie) AddField(key, value string) *TestResourceSpecDie { 33 | return d.DieStamp(func(r *resources.TestResourceSpec) { 34 | if r.Fields == nil { 35 | r.Fields = map[string]string{} 36 | } 37 | r.Fields[key] = value 38 | }) 39 | } 40 | 41 | // +die 42 | type _ = resources.TestResourceStatus 43 | 44 | func (d *TestResourceStatusDie) ConditionsDie(conditions ...*diemetav1.ConditionDie) *TestResourceStatusDie { 45 | return d.DieStamp(func(r *resources.TestResourceStatus) { 46 | r.Conditions = make([]metav1.Condition, len(conditions)) 47 | for i := range conditions { 48 | r.Conditions[i] = conditions[i].DieRelease() 49 | } 50 | }) 51 | } 52 | 53 | func (d *TestResourceStatusDie) AddField(key, value string) *TestResourceStatusDie { 54 | return d.DieStamp(func(r *resources.TestResourceStatus) { 55 | if r.Fields == nil { 56 | r.Fields = map[string]string{} 57 | } 58 | r.Fields[key] = value 59 | }) 60 | } 61 | 62 | // +die:object=true,spec=TestResourceSpec 63 | type _ = resources.TestResourceEmptyStatus 64 | 65 | // +die 66 | type _ = resources.TestResourceEmptyStatusStatus 67 | 68 | // +die:object=true,spec=TestResourceSpec 69 | type _ = resources.TestResourceNoStatus 70 | 71 | // +die:object=true,spec=TestResourceSpec 72 | // +die:field:name=Status,die=TestResourceStatusDie,pointer=true 73 | type _ = resources.TestResourceNilableStatus 74 | 75 | // +die:object=true 76 | // +die:field:name=Status,die=TestResourceStatusDie 77 | type _ = resources.TestDuck 78 | 79 | // +die 80 | type _ = resources.TestDuckSpec 81 | 82 | func (d *TestDuckSpecDie) AddField(key, value string) *TestDuckSpecDie { 83 | return d.DieStamp(func(r *resources.TestDuckSpec) { 84 | if r.Fields == nil { 85 | r.Fields = map[string]string{} 86 | } 87 | r.Fields[key] = value 88 | }) 89 | } 90 | 91 | // +die:object=true 92 | type _ = resources.TestResourceUnexportedFields 93 | 94 | // +die:ignore={unexportedFields} 95 | // +die:field:name=Template,package=_/core/v1,die=PodTemplateSpecDie 96 | type _ = resources.TestResourceUnexportedFieldsSpec 97 | 98 | func (d *TestResourceUnexportedFieldsSpecDie) AddField(key, value string) *TestResourceUnexportedFieldsSpecDie { 99 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { 100 | if r.Fields == nil { 101 | r.Fields = map[string]string{} 102 | } 103 | r.Fields[key] = value 104 | }) 105 | } 106 | 107 | func (d *TestResourceUnexportedFieldsSpecDie) AddUnexportedField(key, value string) *TestResourceUnexportedFieldsSpecDie { 108 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { 109 | f := r.GetUnexportedFields() 110 | if f == nil { 111 | f = map[string]string{} 112 | } 113 | f[key] = value 114 | r.SetUnexportedFields(f) 115 | }) 116 | } 117 | 118 | // +die:ignore={unexportedFields} 119 | type _ = resources.TestResourceUnexportedFieldsStatus 120 | 121 | func (d *TestResourceUnexportedFieldsStatusDie) ConditionsDie(conditions ...*diemetav1.ConditionDie) *TestResourceUnexportedFieldsStatusDie { 122 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { 123 | r.Conditions = make([]metav1.Condition, len(conditions)) 124 | for i := range conditions { 125 | r.Conditions[i] = conditions[i].DieRelease() 126 | } 127 | }) 128 | } 129 | 130 | func (d *TestResourceUnexportedFieldsStatusDie) AddField(key, value string) *TestResourceUnexportedFieldsStatusDie { 131 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { 132 | if r.Fields == nil { 133 | r.Fields = map[string]string{} 134 | } 135 | r.Fields[key] = value 136 | }) 137 | } 138 | 139 | func (d *TestResourceUnexportedFieldsStatusDie) AddUnexportedField(key, value string) *TestResourceUnexportedFieldsStatusDie { 140 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { 141 | f := r.GetUnexportedFields() 142 | if f == nil { 143 | f = map[string]string{} 144 | } 145 | f[key] = value 146 | r.SetUnexportedFields(f) 147 | }) 148 | } 149 | 150 | // +die:object=true,spec=TestResourceSpec,status=TestResourceStatus 151 | type _ = resources.TestResourceWithLegacyDefault 152 | -------------------------------------------------------------------------------- /internal/resources/dies/zz_generated.die_test.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | // +build !ignore_autogenerated 3 | 4 | /* 5 | Copyright 2019-2024 the original author or authors. 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | */ 19 | 20 | // Code generated by diegen. DO NOT EDIT. 21 | 22 | package dies 23 | 24 | import ( 25 | testing "reconciler.io/dies/testing" 26 | testingx "testing" 27 | ) 28 | 29 | func TestTestResourceDie_MissingMethods(t *testingx.T) { 30 | die := TestResourceBlank 31 | ignore := []string{"TypeMeta", "ObjectMeta"} 32 | diff := testing.DieFieldDiff(die).Delete(ignore...) 33 | if diff.Len() != 0 { 34 | t.Errorf("found missing fields for TestResourceDie: %s", diff.List()) 35 | } 36 | } 37 | 38 | func TestTestResourceSpecDie_MissingMethods(t *testingx.T) { 39 | die := TestResourceSpecBlank 40 | ignore := []string{} 41 | diff := testing.DieFieldDiff(die).Delete(ignore...) 42 | if diff.Len() != 0 { 43 | t.Errorf("found missing fields for TestResourceSpecDie: %s", diff.List()) 44 | } 45 | } 46 | 47 | func TestTestResourceStatusDie_MissingMethods(t *testingx.T) { 48 | die := TestResourceStatusBlank 49 | ignore := []string{} 50 | diff := testing.DieFieldDiff(die).Delete(ignore...) 51 | if diff.Len() != 0 { 52 | t.Errorf("found missing fields for TestResourceStatusDie: %s", diff.List()) 53 | } 54 | } 55 | 56 | func TestTestResourceEmptyStatusDie_MissingMethods(t *testingx.T) { 57 | die := TestResourceEmptyStatusBlank 58 | ignore := []string{"TypeMeta", "ObjectMeta"} 59 | diff := testing.DieFieldDiff(die).Delete(ignore...) 60 | if diff.Len() != 0 { 61 | t.Errorf("found missing fields for TestResourceEmptyStatusDie: %s", diff.List()) 62 | } 63 | } 64 | 65 | func TestTestResourceEmptyStatusStatusDie_MissingMethods(t *testingx.T) { 66 | die := TestResourceEmptyStatusStatusBlank 67 | ignore := []string{} 68 | diff := testing.DieFieldDiff(die).Delete(ignore...) 69 | if diff.Len() != 0 { 70 | t.Errorf("found missing fields for TestResourceEmptyStatusStatusDie: %s", diff.List()) 71 | } 72 | } 73 | 74 | func TestTestResourceNoStatusDie_MissingMethods(t *testingx.T) { 75 | die := TestResourceNoStatusBlank 76 | ignore := []string{"TypeMeta", "ObjectMeta"} 77 | diff := testing.DieFieldDiff(die).Delete(ignore...) 78 | if diff.Len() != 0 { 79 | t.Errorf("found missing fields for TestResourceNoStatusDie: %s", diff.List()) 80 | } 81 | } 82 | 83 | func TestTestResourceNilableStatusDie_MissingMethods(t *testingx.T) { 84 | die := TestResourceNilableStatusBlank 85 | ignore := []string{"TypeMeta", "ObjectMeta"} 86 | diff := testing.DieFieldDiff(die).Delete(ignore...) 87 | if diff.Len() != 0 { 88 | t.Errorf("found missing fields for TestResourceNilableStatusDie: %s", diff.List()) 89 | } 90 | } 91 | 92 | func TestTestDuckDie_MissingMethods(t *testingx.T) { 93 | die := TestDuckBlank 94 | ignore := []string{"TypeMeta", "ObjectMeta"} 95 | diff := testing.DieFieldDiff(die).Delete(ignore...) 96 | if diff.Len() != 0 { 97 | t.Errorf("found missing fields for TestDuckDie: %s", diff.List()) 98 | } 99 | } 100 | 101 | func TestTestDuckSpecDie_MissingMethods(t *testingx.T) { 102 | die := TestDuckSpecBlank 103 | ignore := []string{} 104 | diff := testing.DieFieldDiff(die).Delete(ignore...) 105 | if diff.Len() != 0 { 106 | t.Errorf("found missing fields for TestDuckSpecDie: %s", diff.List()) 107 | } 108 | } 109 | 110 | func TestTestResourceUnexportedFieldsDie_MissingMethods(t *testingx.T) { 111 | die := TestResourceUnexportedFieldsBlank 112 | ignore := []string{"TypeMeta", "ObjectMeta"} 113 | diff := testing.DieFieldDiff(die).Delete(ignore...) 114 | if diff.Len() != 0 { 115 | t.Errorf("found missing fields for TestResourceUnexportedFieldsDie: %s", diff.List()) 116 | } 117 | } 118 | 119 | func TestTestResourceUnexportedFieldsSpecDie_MissingMethods(t *testingx.T) { 120 | die := TestResourceUnexportedFieldsSpecBlank 121 | ignore := []string{"unexportedFields"} 122 | diff := testing.DieFieldDiff(die).Delete(ignore...) 123 | if diff.Len() != 0 { 124 | t.Errorf("found missing fields for TestResourceUnexportedFieldsSpecDie: %s", diff.List()) 125 | } 126 | } 127 | 128 | func TestTestResourceUnexportedFieldsStatusDie_MissingMethods(t *testingx.T) { 129 | die := TestResourceUnexportedFieldsStatusBlank 130 | ignore := []string{"unexportedFields"} 131 | diff := testing.DieFieldDiff(die).Delete(ignore...) 132 | if diff.Len() != 0 { 133 | t.Errorf("found missing fields for TestResourceUnexportedFieldsStatusDie: %s", diff.List()) 134 | } 135 | } 136 | 137 | func TestTestResourceWithLegacyDefaultDie_MissingMethods(t *testingx.T) { 138 | die := TestResourceWithLegacyDefaultBlank 139 | ignore := []string{"TypeMeta", "ObjectMeta"} 140 | diff := testing.DieFieldDiff(die).Delete(ignore...) 141 | if diff.Len() != 0 { 142 | t.Errorf("found missing fields for TestResourceWithLegacyDefaultDie: %s", diff.List()) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /internal/resources/resource.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package resources 18 | 19 | import ( 20 | "context" 21 | "encoding/json" 22 | "fmt" 23 | 24 | corev1 "k8s.io/api/core/v1" 25 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 26 | runtime "k8s.io/apimachinery/pkg/runtime" 27 | "k8s.io/apimachinery/pkg/runtime/schema" 28 | "k8s.io/apimachinery/pkg/util/validation/field" 29 | "reconciler.io/runtime/apis" 30 | "sigs.k8s.io/controller-runtime/pkg/client" 31 | "sigs.k8s.io/controller-runtime/pkg/scheme" 32 | "sigs.k8s.io/controller-runtime/pkg/webhook" 33 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 34 | ) 35 | 36 | var ( 37 | _ webhook.CustomDefaulter = &TestResource{} 38 | _ webhook.CustomValidator = &TestResource{} 39 | _ client.Object = &TestResource{} 40 | ) 41 | 42 | // +kubebuilder:object:root=true 43 | // +genclient 44 | 45 | type TestResource struct { 46 | metav1.TypeMeta `json:",inline"` 47 | metav1.ObjectMeta `json:"metadata,omitempty"` 48 | 49 | Spec TestResourceSpec `json:"spec"` 50 | Status TestResourceStatus `json:"status"` 51 | } 52 | 53 | func (*TestResource) Default(ctx context.Context, obj runtime.Object) error { 54 | r, ok := obj.(*TestResource) 55 | if !ok { 56 | return fmt.Errorf("expected object to be TestResource") 57 | } 58 | 59 | if r.Spec.Fields == nil { 60 | r.Spec.Fields = map[string]string{} 61 | } 62 | r.Spec.Fields["Defaulter"] = "ran" 63 | 64 | return nil 65 | } 66 | 67 | func (*TestResource) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 68 | r, ok := obj.(*TestResource) 69 | if !ok { 70 | return nil, fmt.Errorf("expected obj to be TestResource") 71 | } 72 | 73 | return nil, r.validate(ctx).ToAggregate() 74 | } 75 | 76 | func (*TestResource) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { 77 | _, ok := oldObj.(*TestResource) 78 | if !ok { 79 | return nil, fmt.Errorf("expected oldObj to be TestResource") 80 | } 81 | r, ok := newObj.(*TestResource) 82 | if !ok { 83 | return nil, fmt.Errorf("expected newObj to be TestResource") 84 | } 85 | 86 | return nil, r.validate(ctx).ToAggregate() 87 | } 88 | 89 | func (*TestResource) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 90 | _, ok := obj.(*TestResource) 91 | if !ok { 92 | return nil, fmt.Errorf("expected obj to be TestResource") 93 | } 94 | 95 | return nil, nil 96 | } 97 | 98 | func (r *TestResource) validate(ctx context.Context) field.ErrorList { 99 | errs := field.ErrorList{} 100 | 101 | if r.Spec.Fields != nil { 102 | if _, ok := r.Spec.Fields["invalid"]; ok { 103 | field.Invalid(field.NewPath("spec", "fields", "invalid"), r.Spec.Fields["invalid"], "") 104 | } 105 | } 106 | 107 | return errs 108 | } 109 | 110 | // +kubebuilder:object:generate=true 111 | type TestResourceSpec struct { 112 | Fields map[string]string `json:"fields,omitempty"` 113 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 114 | 115 | ErrOnMarshal bool `json:"errOnMarhsal,omitempty"` 116 | ErrOnUnmarshal bool `json:"errOnUnmarhsal,omitempty"` 117 | } 118 | 119 | func (r *TestResourceSpec) MarshalJSON() ([]byte, error) { 120 | if r.ErrOnMarshal { 121 | return nil, fmt.Errorf("ErrOnMarshal true") 122 | } 123 | return json.Marshal(&struct { 124 | Fields map[string]string `json:"fields,omitempty"` 125 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 126 | ErrOnMarshal bool `json:"errOnMarshal,omitempty"` 127 | ErrOnUnmarshal bool `json:"errOnUnmarshal,omitempty"` 128 | }{ 129 | Fields: r.Fields, 130 | Template: r.Template, 131 | ErrOnMarshal: r.ErrOnMarshal, 132 | ErrOnUnmarshal: r.ErrOnUnmarshal, 133 | }) 134 | } 135 | 136 | func (r *TestResourceSpec) UnmarshalJSON(data []byte) error { 137 | type alias struct { 138 | Fields map[string]string `json:"fields,omitempty"` 139 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 140 | ErrOnMarshal bool `json:"errOnMarshal,omitempty"` 141 | ErrOnUnmarshal bool `json:"errOnUnmarshal,omitempty"` 142 | } 143 | a := &alias{} 144 | if err := json.Unmarshal(data, a); err != nil { 145 | return err 146 | } 147 | r.Fields = a.Fields 148 | r.Template = a.Template 149 | r.ErrOnMarshal = a.ErrOnMarshal 150 | r.ErrOnUnmarshal = a.ErrOnUnmarshal 151 | if r.ErrOnUnmarshal { 152 | return fmt.Errorf("ErrOnUnmarshal true") 153 | } 154 | return nil 155 | } 156 | 157 | // +kubebuilder:object:generate=true 158 | type TestResourceStatus struct { 159 | apis.Status `json:",inline"` 160 | Fields map[string]string `json:"fields,omitempty"` 161 | } 162 | 163 | var condSet = apis.NewLivingConditionSet() 164 | 165 | func (rs *TestResourceStatus) InitializeConditions(ctx context.Context) { 166 | condSet.ManageWithContext(ctx, rs).InitializeConditions() 167 | } 168 | 169 | func (rs *TestResourceStatus) MarkReady(ctx context.Context) { 170 | condSet.ManageWithContext(ctx, rs).MarkTrue(apis.ConditionReady, "Ready", "") 171 | } 172 | 173 | func (rs *TestResourceStatus) MarkNotReady(ctx context.Context, reason, message string, messageA ...interface{}) { 174 | condSet.ManageWithContext(ctx, rs).MarkFalse(apis.ConditionReady, reason, message, messageA...) 175 | } 176 | 177 | // +kubebuilder:object:root=true 178 | 179 | type TestResourceList struct { 180 | metav1.TypeMeta `json:",inline"` 181 | metav1.ListMeta `json:"metadata"` 182 | 183 | Items []TestResource `json:"items"` 184 | } 185 | 186 | var ( 187 | // GroupVersion is group version used to register these objects 188 | GroupVersion = schema.GroupVersion{Group: "testing.reconciler.runtime", Version: "v1"} 189 | 190 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 191 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 192 | 193 | // AddToScheme adds the types in this group-version to the given scheme. 194 | AddToScheme = SchemeBuilder.AddToScheme 195 | ) 196 | 197 | // compatibility with k8s.io/code-generator 198 | var SchemeGroupVersion = GroupVersion 199 | 200 | func init() { 201 | SchemeBuilder.Register(&TestResource{}, &TestResourceList{}) 202 | } 203 | -------------------------------------------------------------------------------- /internal/resources/resource_duck.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package resources 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | runtime "k8s.io/apimachinery/pkg/runtime" 25 | "k8s.io/apimachinery/pkg/util/validation/field" 26 | "sigs.k8s.io/controller-runtime/pkg/client" 27 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 28 | ) 29 | 30 | var ( 31 | _ client.Object = &TestDuck{} 32 | ) 33 | 34 | // +kubebuilder:object:root=true 35 | // +genclient 36 | 37 | type TestDuck struct { 38 | metav1.TypeMeta `json:",inline"` 39 | metav1.ObjectMeta `json:"metadata,omitempty"` 40 | 41 | Spec TestDuckSpec `json:"spec"` 42 | Status TestResourceStatus `json:"status"` 43 | } 44 | 45 | func (*TestDuck) Default(ctx context.Context, obj runtime.Object) error { 46 | r, ok := obj.(*TestDuck) 47 | if !ok { 48 | return fmt.Errorf("expected obj to be TestDuck") 49 | } 50 | if r.Spec.Fields == nil { 51 | r.Spec.Fields = map[string]string{} 52 | } 53 | r.Spec.Fields["Defaulter"] = "ran" 54 | return nil 55 | } 56 | 57 | func (r *TestDuck) ValidateCreate() (admission.Warnings, error) { 58 | return nil, r.validate().ToAggregate() 59 | } 60 | 61 | func (r *TestDuck) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { 62 | return nil, r.validate().ToAggregate() 63 | } 64 | 65 | func (r *TestDuck) ValidateDelete() (admission.Warnings, error) { 66 | return nil, nil 67 | } 68 | 69 | func (r *TestDuck) validate() field.ErrorList { 70 | errs := field.ErrorList{} 71 | 72 | if r.Spec.Fields != nil { 73 | if _, ok := r.Spec.Fields["invalid"]; ok { 74 | field.Invalid(field.NewPath("spec", "fields", "invalid"), r.Spec.Fields["invalid"], "") 75 | } 76 | } 77 | 78 | return errs 79 | } 80 | 81 | // +kubebuilder:object:generate=true 82 | type TestDuckSpec struct { 83 | Immutable *bool `json:"immutable,omitempty"` 84 | Fields map[string]string `json:"fields,omitempty"` 85 | } 86 | 87 | // +kubebuilder:object:root=true 88 | 89 | type TestDuckList struct { 90 | metav1.TypeMeta `json:",inline"` 91 | metav1.ListMeta `json:"metadata"` 92 | 93 | Items []TestDuck `json:"items"` 94 | } 95 | -------------------------------------------------------------------------------- /internal/resources/resource_list_invalid.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package resources 18 | 19 | import ( 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | ) 22 | 23 | // +kubebuilder:object:root=true 24 | 25 | type TestResourceInvalidList struct { 26 | metav1.TypeMeta `json:",inline"` 27 | metav1.ListMeta `json:"metadata"` 28 | 29 | Items []string `json:"items"` 30 | } 31 | -------------------------------------------------------------------------------- /internal/resources/resource_list_with_interface_items.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package resources 18 | 19 | import ( 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | runtime "k8s.io/apimachinery/pkg/runtime" 22 | "sigs.k8s.io/controller-runtime/pkg/client" 23 | ) 24 | 25 | type TestResourceInterfaceList struct { 26 | metav1.TypeMeta `json:",inline"` 27 | metav1.ListMeta `json:"metadata"` 28 | 29 | Items []client.Object `json:"items"` 30 | } 31 | 32 | // Directly host DeepCopy methods since controller-gen cannot handle the interface 33 | 34 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 35 | func (in *TestResourceInterfaceList) DeepCopyInto(out *TestResourceInterfaceList) { 36 | *out = *in 37 | out.TypeMeta = in.TypeMeta 38 | in.ListMeta.DeepCopyInto(&out.ListMeta) 39 | if in.Items != nil { 40 | in, out := &in.Items, &out.Items 41 | *out = make([]client.Object, len(*in)) 42 | for i := range *in { 43 | (*out)[i] = (*in)[i].DeepCopyObject().(client.Object) 44 | } 45 | } 46 | } 47 | 48 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceInterfaceList. 49 | func (in *TestResourceInterfaceList) DeepCopy() *TestResourceInterfaceList { 50 | if in == nil { 51 | return nil 52 | } 53 | out := new(TestResourceInterfaceList) 54 | in.DeepCopyInto(out) 55 | return out 56 | } 57 | 58 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 59 | func (in *TestResourceInterfaceList) DeepCopyObject() runtime.Object { 60 | if c := in.DeepCopy(); c != nil { 61 | return c 62 | } 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/resources/resource_list_with_pointer_items.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package resources 18 | 19 | import ( 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | ) 22 | 23 | // +kubebuilder:object:root=true 24 | 25 | type TestResourcePointerList struct { 26 | metav1.TypeMeta `json:",inline"` 27 | metav1.ListMeta `json:"metadata"` 28 | 29 | Items []*TestResource `json:"items"` 30 | } 31 | -------------------------------------------------------------------------------- /internal/resources/resource_with_empty_status.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package resources 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | runtime "k8s.io/apimachinery/pkg/runtime" 25 | "sigs.k8s.io/controller-runtime/pkg/webhook" 26 | ) 27 | 28 | var _ webhook.CustomDefaulter = &TestResourceEmptyStatus{} 29 | 30 | // +kubebuilder:object:root=true 31 | // +genclient 32 | 33 | type TestResourceEmptyStatus struct { 34 | metav1.TypeMeta `json:",inline"` 35 | metav1.ObjectMeta `json:"metadata,omitempty"` 36 | 37 | Spec TestResourceSpec `json:"spec"` 38 | Status TestResourceEmptyStatusStatus `json:"status"` 39 | } 40 | 41 | func (*TestResourceEmptyStatus) Default(ctx context.Context, obj runtime.Object) error { 42 | r, ok := obj.(*TestResourceEmptyStatus) 43 | if !ok { 44 | return fmt.Errorf("expected obj to be TestResourceEmptyStatus") 45 | } 46 | 47 | if r.Spec.Fields == nil { 48 | r.Spec.Fields = map[string]string{} 49 | } 50 | r.Spec.Fields["Defaulter"] = "ran" 51 | 52 | return nil 53 | } 54 | 55 | // +kubebuilder:object:generate=true 56 | type TestResourceEmptyStatusStatus struct { 57 | } 58 | 59 | // +kubebuilder:object:root=true 60 | 61 | type TestResourceEmptyStatusList struct { 62 | metav1.TypeMeta `json:",inline"` 63 | metav1.ListMeta `json:"metadata"` 64 | 65 | Items []TestResourceNoStatus `json:"items"` 66 | } 67 | 68 | func init() { 69 | SchemeBuilder.Register(&TestResourceEmptyStatus{}, &TestResourceEmptyStatusList{}) 70 | } 71 | -------------------------------------------------------------------------------- /internal/resources/resource_with_legacy_default.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package resources 18 | 19 | import ( 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | "sigs.k8s.io/controller-runtime/pkg/client" 22 | "sigs.k8s.io/controller-runtime/pkg/webhook" 23 | ) 24 | 25 | var ( 26 | _ webhook.CustomDefaulter = &TestResource{} 27 | _ webhook.CustomValidator = &TestResource{} 28 | _ client.Object = &TestResource{} 29 | ) 30 | 31 | // +kubebuilder:object:root=true 32 | // +genclient 33 | 34 | type TestResourceWithLegacyDefault struct { 35 | metav1.TypeMeta `json:",inline"` 36 | metav1.ObjectMeta `json:"metadata,omitempty"` 37 | 38 | Spec TestResourceSpec `json:"spec"` 39 | Status TestResourceStatus `json:"status"` 40 | } 41 | 42 | func (r *TestResourceWithLegacyDefault) Default() { 43 | if r.Spec.Fields == nil { 44 | r.Spec.Fields = map[string]string{} 45 | } 46 | r.Spec.Fields["Defaulter"] = "ran" 47 | } 48 | 49 | // +kubebuilder:object:root=true 50 | 51 | type TestResourceWithLegacyDefaultList struct { 52 | metav1.TypeMeta `json:",inline"` 53 | metav1.ListMeta `json:"metadata"` 54 | 55 | Items []TestResourceWithLegacyDefault `json:"items"` 56 | } 57 | 58 | func init() { 59 | SchemeBuilder.Register(&TestResourceWithLegacyDefault{}, &TestResourceWithLegacyDefaultList{}) 60 | } 61 | -------------------------------------------------------------------------------- /internal/resources/resource_with_nilable_status.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package resources 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | runtime "k8s.io/apimachinery/pkg/runtime" 25 | "k8s.io/apimachinery/pkg/util/validation/field" 26 | "sigs.k8s.io/controller-runtime/pkg/client" 27 | "sigs.k8s.io/controller-runtime/pkg/webhook" 28 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 29 | ) 30 | 31 | var ( 32 | _ webhook.CustomDefaulter = &TestResourceNilableStatus{} 33 | _ webhook.CustomValidator = &TestResourceNilableStatus{} 34 | _ client.Object = &TestResourceNilableStatus{} 35 | ) 36 | 37 | // +kubebuilder:object:root=true 38 | // +genclient 39 | 40 | type TestResourceNilableStatus struct { 41 | metav1.TypeMeta `json:",inline"` 42 | metav1.ObjectMeta `json:"metadata,omitempty"` 43 | 44 | Spec TestResourceSpec `json:"spec"` 45 | Status *TestResourceStatus `json:"status"` 46 | } 47 | 48 | func (TestResourceNilableStatus) Default(ctx context.Context, obj runtime.Object) error { 49 | r, ok := obj.(*TestResourceNilableStatus) 50 | if !ok { 51 | return fmt.Errorf("expected obj to be TestResourceNilableStatus") 52 | } 53 | if r.Spec.Fields == nil { 54 | r.Spec.Fields = map[string]string{} 55 | } 56 | r.Spec.Fields["Defaulter"] = "ran" 57 | 58 | return nil 59 | } 60 | 61 | func (*TestResourceNilableStatus) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 62 | r, ok := obj.(*TestResourceNilableStatus) 63 | if !ok { 64 | return nil, fmt.Errorf("expected obj to be TestResourceNilableStatus") 65 | } 66 | 67 | return nil, r.validate(ctx).ToAggregate() 68 | } 69 | 70 | func (*TestResourceNilableStatus) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { 71 | _, ok := oldObj.(*TestResourceNilableStatus) 72 | if !ok { 73 | return nil, fmt.Errorf("expected oldObj to be TestResourceNilableStatus") 74 | } 75 | r, ok := newObj.(*TestResourceNilableStatus) 76 | if !ok { 77 | return nil, fmt.Errorf("expected newObj to be TestResourceNilableStatus") 78 | } 79 | 80 | return nil, r.validate(ctx).ToAggregate() 81 | } 82 | 83 | func (*TestResourceNilableStatus) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 84 | _, ok := obj.(*TestResourceNilableStatus) 85 | if !ok { 86 | return nil, fmt.Errorf("expected obj to be TestResourceNilableStatus") 87 | } 88 | 89 | return nil, nil 90 | } 91 | 92 | func (r *TestResourceNilableStatus) validate(ctx context.Context) field.ErrorList { 93 | errs := field.ErrorList{} 94 | 95 | if r.Spec.Fields != nil { 96 | if _, ok := r.Spec.Fields["invalid"]; ok { 97 | field.Invalid(field.NewPath("spec", "fields", "invalid"), r.Spec.Fields["invalid"], "") 98 | } 99 | } 100 | 101 | return errs 102 | } 103 | 104 | // +kubebuilder:object:root=true 105 | 106 | type TestResourceNilableStatusList struct { 107 | metav1.TypeMeta `json:",inline"` 108 | metav1.ListMeta `json:"metadata"` 109 | 110 | Items []TestResourceNilableStatus `json:"items"` 111 | } 112 | 113 | func init() { 114 | SchemeBuilder.Register(&TestResourceNilableStatus{}, &TestResourceNilableStatusList{}) 115 | } 116 | -------------------------------------------------------------------------------- /internal/resources/resource_with_unexported_fields.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package resources 18 | 19 | import ( 20 | "context" 21 | "encoding/json" 22 | "fmt" 23 | 24 | corev1 "k8s.io/api/core/v1" 25 | "k8s.io/apimachinery/pkg/api/equality" 26 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 27 | runtime "k8s.io/apimachinery/pkg/runtime" 28 | "k8s.io/apimachinery/pkg/util/validation/field" 29 | "reconciler.io/runtime/apis" 30 | "sigs.k8s.io/controller-runtime/pkg/client" 31 | "sigs.k8s.io/controller-runtime/pkg/webhook" 32 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 33 | ) 34 | 35 | var ( 36 | _ webhook.CustomDefaulter = &TestResourceUnexportedFields{} 37 | _ webhook.CustomValidator = &TestResourceUnexportedFields{} 38 | _ client.Object = &TestResourceUnexportedFields{} 39 | ) 40 | 41 | // +kubebuilder:object:root=true 42 | // +genclient 43 | 44 | type TestResourceUnexportedFields struct { 45 | metav1.TypeMeta `json:",inline"` 46 | metav1.ObjectMeta `json:"metadata,omitempty"` 47 | 48 | Spec TestResourceUnexportedFieldsSpec `json:"spec"` 49 | Status TestResourceUnexportedFieldsStatus `json:"status"` 50 | } 51 | 52 | func (*TestResourceUnexportedFields) Default(ctx context.Context, obj runtime.Object) error { 53 | r, ok := obj.(*TestResourceUnexportedFields) 54 | if !ok { 55 | return fmt.Errorf("expected obj to be TestResourceUnexportedFields") 56 | } 57 | if r.Spec.Fields == nil { 58 | r.Spec.Fields = map[string]string{} 59 | } 60 | r.Spec.Fields["Defaulter"] = "ran" 61 | 62 | return nil 63 | } 64 | 65 | func (*TestResourceUnexportedFields) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 66 | r, ok := obj.(*TestResourceUnexportedFields) 67 | if !ok { 68 | return nil, fmt.Errorf("expected obj to be TestResourceUnexportedFields") 69 | } 70 | 71 | return nil, r.validate(ctx).ToAggregate() 72 | } 73 | 74 | func (*TestResourceUnexportedFields) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { 75 | _, ok := oldObj.(*TestResourceUnexportedFields) 76 | if !ok { 77 | return nil, fmt.Errorf("expected oldObj to be TestResourceUnexportedFields") 78 | } 79 | r, ok := newObj.(*TestResourceUnexportedFields) 80 | if !ok { 81 | return nil, fmt.Errorf("expected newObj to be TestResourceUnexportedFields") 82 | } 83 | 84 | return nil, r.validate(ctx).ToAggregate() 85 | } 86 | 87 | func (*TestResourceUnexportedFields) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 88 | _, ok := obj.(*TestResourceUnexportedFields) 89 | if !ok { 90 | return nil, fmt.Errorf("expected obj to be TestResourceUnexportedFields") 91 | } 92 | 93 | return nil, nil 94 | } 95 | 96 | func (r *TestResourceUnexportedFields) validate(ctx context.Context) field.ErrorList { 97 | errs := field.ErrorList{} 98 | 99 | if r.Spec.Fields != nil { 100 | if _, ok := r.Spec.Fields["invalid"]; ok { 101 | field.Invalid(field.NewPath("spec", "fields", "invalid"), r.Spec.Fields["invalid"], "") 102 | } 103 | } 104 | 105 | return errs 106 | } 107 | 108 | func (r *TestResourceUnexportedFields) ReflectUnexportedFieldsToStatus() { 109 | r.Status.unexportedFields = r.Spec.unexportedFields 110 | } 111 | 112 | // +kubebuilder:object:generate=true 113 | type TestResourceUnexportedFieldsSpec struct { 114 | Fields map[string]string `json:"fields,omitempty"` 115 | unexportedFields map[string]string 116 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 117 | 118 | ErrOnMarshal bool `json:"errOnMarhsal,omitempty"` 119 | ErrOnUnmarshal bool `json:"errOnUnmarhsal,omitempty"` 120 | } 121 | 122 | func (r *TestResourceUnexportedFieldsSpec) GetUnexportedFields() map[string]string { 123 | return r.unexportedFields 124 | } 125 | 126 | func (r *TestResourceUnexportedFieldsSpec) SetUnexportedFields(f map[string]string) { 127 | r.unexportedFields = f 128 | } 129 | 130 | func (r *TestResourceUnexportedFieldsSpec) AddUnexportedField(key, value string) { 131 | if r.unexportedFields == nil { 132 | r.unexportedFields = map[string]string{} 133 | } 134 | r.unexportedFields[key] = value 135 | } 136 | 137 | func (r *TestResourceUnexportedFieldsSpec) MarshalJSON() ([]byte, error) { 138 | if r.ErrOnMarshal { 139 | return nil, fmt.Errorf("ErrOnMarshal true") 140 | } 141 | return json.Marshal(&struct { 142 | Fields map[string]string `json:"fields,omitempty"` 143 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 144 | ErrOnMarshal bool `json:"errOnMarshal,omitempty"` 145 | ErrOnUnmarshal bool `json:"errOnUnmarshal,omitempty"` 146 | }{ 147 | Fields: r.Fields, 148 | Template: r.Template, 149 | ErrOnMarshal: r.ErrOnMarshal, 150 | ErrOnUnmarshal: r.ErrOnUnmarshal, 151 | }) 152 | } 153 | 154 | func (r *TestResourceUnexportedFieldsSpec) UnmarshalJSON(data []byte) error { 155 | type alias struct { 156 | Fields map[string]string `json:"fields,omitempty"` 157 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 158 | ErrOnMarshal bool `json:"errOnMarshal,omitempty"` 159 | ErrOnUnmarshal bool `json:"errOnUnmarshal,omitempty"` 160 | } 161 | a := &alias{} 162 | if err := json.Unmarshal(data, a); err != nil { 163 | return err 164 | } 165 | r.Fields = a.Fields 166 | r.Template = a.Template 167 | r.ErrOnMarshal = a.ErrOnMarshal 168 | r.ErrOnUnmarshal = a.ErrOnUnmarshal 169 | if r.ErrOnUnmarshal { 170 | return fmt.Errorf("ErrOnUnmarshal true") 171 | } 172 | return nil 173 | } 174 | 175 | // +kubebuilder:object:generate=true 176 | type TestResourceUnexportedFieldsStatus struct { 177 | apis.Status `json:",inline"` 178 | Fields map[string]string `json:"fields,omitempty"` 179 | unexportedFields map[string]string 180 | } 181 | 182 | func (r *TestResourceUnexportedFieldsStatus) GetUnexportedFields() map[string]string { 183 | return r.unexportedFields 184 | } 185 | 186 | func (r *TestResourceUnexportedFieldsStatus) SetUnexportedFields(f map[string]string) { 187 | r.unexportedFields = f 188 | } 189 | 190 | func (r *TestResourceUnexportedFieldsStatus) AddUnexportedField(key, value string) { 191 | if r.unexportedFields == nil { 192 | r.unexportedFields = map[string]string{} 193 | } 194 | r.unexportedFields[key] = value 195 | } 196 | 197 | // +kubebuilder:object:root=true 198 | 199 | type TestResourceUnexportedFieldsList struct { 200 | metav1.TypeMeta `json:",inline"` 201 | metav1.ListMeta `json:"metadata"` 202 | 203 | Items []TestResourceUnexportedFields `json:"items"` 204 | } 205 | 206 | func init() { 207 | SchemeBuilder.Register(&TestResourceUnexportedFields{}, &TestResourceUnexportedFieldsList{}) 208 | 209 | if err := equality.Semantic.AddFuncs( 210 | func(a, b TestResourceUnexportedFieldsSpec) bool { 211 | return equality.Semantic.DeepEqual(a.Fields, b.Fields) && 212 | equality.Semantic.DeepEqual(a.Template, b.Template) && 213 | equality.Semantic.DeepEqual(a.ErrOnMarshal, b.ErrOnMarshal) && 214 | equality.Semantic.DeepEqual(a.ErrOnUnmarshal, b.ErrOnUnmarshal) 215 | }, 216 | func(a, b TestResourceUnexportedFieldsStatus) bool { 217 | return equality.Semantic.DeepEqual(a.Status, b.Status) && 218 | equality.Semantic.DeepEqual(a.Fields, b.Fields) 219 | }, 220 | ); err != nil { 221 | panic(err) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /internal/resources/resource_without_status.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package resources 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | runtime "k8s.io/apimachinery/pkg/runtime" 25 | "sigs.k8s.io/controller-runtime/pkg/webhook" 26 | ) 27 | 28 | var _ webhook.CustomDefaulter = &TestResourceNoStatus{} 29 | 30 | // +kubebuilder:object:root=true 31 | // +genclient 32 | 33 | type TestResourceNoStatus struct { 34 | metav1.TypeMeta `json:",inline"` 35 | metav1.ObjectMeta `json:"metadata,omitempty"` 36 | 37 | Spec TestResourceSpec `json:"spec"` 38 | } 39 | 40 | func (*TestResourceNoStatus) Default(ctx context.Context, obj runtime.Object) error { 41 | r, ok := obj.(*TestResourceNoStatus) 42 | if !ok { 43 | return fmt.Errorf("expected object to be TestResourceNoStatus") 44 | } 45 | 46 | if r.Spec.Fields == nil { 47 | r.Spec.Fields = map[string]string{} 48 | } 49 | r.Spec.Fields["Defaulter"] = "ran" 50 | 51 | return nil 52 | } 53 | 54 | // +kubebuilder:object:root=true 55 | 56 | type TestResourceNoStatusList struct { 57 | metav1.TypeMeta `json:",inline"` 58 | metav1.ListMeta `json:"metadata"` 59 | 60 | Items []TestResourceNoStatus `json:"items"` 61 | } 62 | 63 | func init() { 64 | SchemeBuilder.Register(&TestResourceNoStatus{}, &TestResourceNoStatusList{}) 65 | } 66 | -------------------------------------------------------------------------------- /internal/util.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package internal 18 | 19 | import "reflect" 20 | 21 | // IsNil returns true if the value is nil, false if the value is not nilable or not nil 22 | func IsNil(val interface{}) bool { 23 | if val == nil { 24 | return true 25 | } 26 | if !IsNilable(val) { 27 | return false 28 | } 29 | return reflect.ValueOf(val).IsNil() 30 | } 31 | 32 | // IsNilable returns true if the value can be nil 33 | func IsNilable(val interface{}) bool { 34 | v := reflect.ValueOf(val) 35 | switch v.Kind() { 36 | case reflect.Chan: 37 | return true 38 | case reflect.Func: 39 | return true 40 | case reflect.Interface: 41 | return true 42 | case reflect.Map: 43 | return true 44 | case reflect.Ptr: 45 | return true 46 | case reflect.Slice: 47 | return true 48 | default: 49 | return false 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /reconcilers/advice.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "sync" 23 | 24 | "github.com/go-logr/logr" 25 | "reconciler.io/runtime/validation" 26 | ctrl "sigs.k8s.io/controller-runtime" 27 | "sigs.k8s.io/controller-runtime/pkg/builder" 28 | "sigs.k8s.io/controller-runtime/pkg/client" 29 | ) 30 | 31 | var _ SubReconciler[client.Object] = (*Advice[client.Object])(nil) 32 | 33 | // Advice is a sub reconciler for advising the lifecycle of another sub reconciler in an aspect 34 | // oriented programming style. 35 | type Advice[Type client.Object] struct { 36 | // Name used to identify this reconciler. Defaults to `Advice`. Ideally unique, but 37 | // not required to be so. 38 | // 39 | // +optional 40 | Name string 41 | 42 | // Setup performs initialization on the manager and builder this reconciler 43 | // will run with. It's common to setup field indexes and watch resources. 44 | // 45 | // +optional 46 | Setup func(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error 47 | 48 | // Reconciler being advised 49 | Reconciler SubReconciler[Type] 50 | 51 | // Before is called preceding Around. A modified context may be returned. Errors are returned 52 | // immediately. 53 | // 54 | // If Before is not defined, there is no effect. 55 | // 56 | // +optional 57 | Before func(ctx context.Context, resource Type) (context.Context, Result, error) 58 | 59 | // Around is responsible for invoking the reconciler and returning the result. Implementations 60 | // may choose to not invoke the reconciler, invoke a different reconciler or invoke the 61 | // reconciler multiple times. 62 | // 63 | // If Around is not defined, the Reconciler is invoked once. 64 | // 65 | // +optional 66 | Around func(ctx context.Context, resource Type, reconciler SubReconciler[Type]) (Result, error) 67 | 68 | // After is called following Around. The result and error from Around are provided and may be 69 | // modified before returning. 70 | // 71 | // If After is not defined, the result and error are returned directly. 72 | // 73 | // +optional 74 | After func(ctx context.Context, resource Type, result Result, err error) (Result, error) 75 | 76 | lazyInit sync.Once 77 | } 78 | 79 | func (r *Advice[T]) init() { 80 | r.lazyInit.Do(func() { 81 | if r.Name == "" { 82 | r.Name = "Advice" 83 | } 84 | if r.Before == nil { 85 | r.Before = func(ctx context.Context, resource T) (context.Context, Result, error) { 86 | return nil, Result{}, nil 87 | } 88 | } 89 | if r.Around == nil { 90 | r.Around = func(ctx context.Context, resource T, reconciler SubReconciler[T]) (Result, error) { 91 | return reconciler.Reconcile(ctx, resource) 92 | } 93 | } 94 | if r.After == nil { 95 | r.After = func(ctx context.Context, resource T, result Result, err error) (Result, error) { 96 | return result, err 97 | } 98 | } 99 | }) 100 | } 101 | 102 | func (r *Advice[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 103 | r.init() 104 | 105 | log := logr.FromContextOrDiscard(ctx). 106 | WithName(r.Name) 107 | ctx = logr.NewContext(ctx, log) 108 | 109 | if r.Setup == nil { 110 | return nil 111 | } 112 | if err := r.Validate(ctx); err != nil { 113 | return err 114 | } 115 | if err := r.Setup(ctx, mgr, bldr); err != nil { 116 | return err 117 | } 118 | return r.Reconciler.SetupWithManager(ctx, mgr, bldr) 119 | } 120 | 121 | func (r *Advice[T]) Validate(ctx context.Context) error { 122 | r.init() 123 | 124 | if r.Reconciler == nil { 125 | return fmt.Errorf("Advice %q must implement Reconciler", r.Name) 126 | } 127 | if r.Before == nil && r.Around == nil && r.After == nil { 128 | return fmt.Errorf("Advice %q must implement at least one of Before, Around or After", r.Name) 129 | } 130 | 131 | if validation.IsRecursive(ctx) { 132 | if v, ok := r.Reconciler.(validation.Validator); ok { 133 | if err := v.Validate(ctx); err != nil { 134 | return fmt.Errorf("Advice %q must have a valid Reconciler: %w", r.Name, err) 135 | } 136 | } 137 | } 138 | 139 | return nil 140 | } 141 | 142 | func (r *Advice[T]) Reconcile(ctx context.Context, resource T) (Result, error) { 143 | r.init() 144 | 145 | log := logr.FromContextOrDiscard(ctx). 146 | WithName(r.Name) 147 | ctx = logr.NewContext(ctx, log) 148 | 149 | // before phase 150 | beforeCtx, result, err := r.Before(ctx, resource) 151 | if err != nil { 152 | return result, err 153 | } 154 | if beforeCtx != nil { 155 | ctx = beforeCtx 156 | } 157 | 158 | // around phase 159 | aroundResult, err := r.Around(ctx, resource, r.Reconciler) 160 | result = AggregateResults(result, aroundResult) 161 | 162 | // after phase 163 | return r.After(ctx, resource, result, err) 164 | } 165 | -------------------------------------------------------------------------------- /reconcilers/aggregate.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "reflect" 24 | "sync" 25 | "time" 26 | 27 | "github.com/go-logr/logr" 28 | apierrs "k8s.io/apimachinery/pkg/api/errors" 29 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 30 | ctrl "sigs.k8s.io/controller-runtime" 31 | "sigs.k8s.io/controller-runtime/pkg/client" 32 | "sigs.k8s.io/controller-runtime/pkg/controller" 33 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 34 | 35 | "reconciler.io/runtime/duck" 36 | "reconciler.io/runtime/internal" 37 | "reconciler.io/runtime/stash" 38 | rtime "reconciler.io/runtime/time" 39 | "reconciler.io/runtime/tracker" 40 | "reconciler.io/runtime/validation" 41 | ) 42 | 43 | var _ reconcile.Reconciler = (*AggregateReconciler[client.Object])(nil) 44 | 45 | // AggregateReconciler is a controller-runtime reconciler that reconciles a specific resource. The 46 | // Type resource is fetched for the reconciler 47 | // request and passed in turn to each SubReconciler. Finally, the reconciled 48 | // resource's status is compared with the original status, updating the API 49 | // server if needed. 50 | type AggregateReconciler[Type client.Object] struct { 51 | // Name used to identify this reconciler. Defaults to `{Type}ResourceReconciler`. Ideally 52 | // unique, but not required to be so. 53 | // 54 | // +optional 55 | Name string 56 | 57 | // Setup performs initialization on the manager and builder this reconciler 58 | // will run with. It's common to setup field indexes and watch resources. 59 | // 60 | // +optional 61 | Setup func(ctx context.Context, mgr Manager, bldr *Builder) error 62 | 63 | // Type of resource to reconcile. Required when the generic type is not a 64 | // struct, or is unstructured. 65 | // 66 | // +optional 67 | Type Type 68 | 69 | // Request of resource to reconcile. Only the specific resource matching the namespace and name 70 | // is reconciled. The namespace may be empty for cluster scoped resources. 71 | Request Request 72 | 73 | // Reconciler is called for each reconciler request with the resource being reconciled. 74 | // Typically, Reconciler is a Sequence of multiple SubReconcilers. 75 | // 76 | // When ErrHaltSubReconcilers is returned as an error, execution continues as if no error was 77 | // returned. 78 | // 79 | // +optional 80 | Reconciler SubReconciler[Type] 81 | 82 | // DesiredResource returns the desired resource to create/update, or nil if 83 | // the resource should not exist. 84 | // 85 | // +optional 86 | DesiredResource func(ctx context.Context, resource Type) (Type, error) 87 | 88 | // AggregateObjectManager synchronizes the aggregated resource with the API Server. 89 | AggregateObjectManager ObjectManager[Type] 90 | 91 | // BeforeReconcile is called first thing for each reconcile request. A modified context may be 92 | // returned. Errors are returned immediately. 93 | // 94 | // If BeforeReconcile is not defined, there is no effect. 95 | // 96 | // +optional 97 | BeforeReconcile func(ctx context.Context, req Request) (context.Context, Result, error) 98 | 99 | // AfterReconcile is called following all work for the reconcile request. The result and error 100 | // are provided and may be modified before returning. 101 | // 102 | // If AfterReconcile is not defined, the result and error are returned directly. 103 | // 104 | // +optional 105 | AfterReconcile func(ctx context.Context, req Request, res Result, err error) (Result, error) 106 | 107 | Config Config 108 | 109 | lazyInit sync.Once 110 | } 111 | 112 | func (r *AggregateReconciler[T]) init() { 113 | r.lazyInit.Do(func() { 114 | if internal.IsNil(r.Type) { 115 | var nilT T 116 | r.Type = newEmpty(nilT).(T) 117 | } 118 | if r.Name == "" { 119 | r.Name = fmt.Sprintf("%sAggregateReconciler", typeName(r.Type)) 120 | } 121 | if r.Reconciler == nil { 122 | r.Reconciler = Sequence[T]{} 123 | } 124 | if r.DesiredResource == nil { 125 | r.DesiredResource = func(ctx context.Context, resource T) (T, error) { 126 | return resource, nil 127 | } 128 | } 129 | if r.BeforeReconcile == nil { 130 | r.BeforeReconcile = func(ctx context.Context, req reconcile.Request) (context.Context, reconcile.Result, error) { 131 | return ctx, Result{}, nil 132 | } 133 | } 134 | if r.AfterReconcile == nil { 135 | r.AfterReconcile = func(ctx context.Context, req reconcile.Request, res reconcile.Result, err error) (reconcile.Result, error) { 136 | return res, err 137 | } 138 | } 139 | }) 140 | } 141 | 142 | func (r *AggregateReconciler[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { 143 | _, err := r.SetupWithManagerYieldingController(ctx, mgr) 144 | return err 145 | } 146 | 147 | func (r *AggregateReconciler[T]) SetupWithManagerYieldingController(ctx context.Context, mgr ctrl.Manager) (controller.Controller, error) { 148 | r.init() 149 | 150 | log := logr.FromContextOrDiscard(ctx). 151 | WithName(r.Name). 152 | WithValues( 153 | "resourceType", gvk(r.Config, r.Type), 154 | "request", r.Request, 155 | ) 156 | ctx = logr.NewContext(ctx, log) 157 | 158 | ctx = StashConfig(ctx, r.Config) 159 | ctx = StashOriginalConfig(ctx, r.Config) 160 | ctx = StashResourceType(ctx, r.Type) 161 | ctx = StashOriginalResourceType(ctx, r.Type) 162 | 163 | if err := r.Validate(ctx); err != nil { 164 | return nil, err 165 | } 166 | 167 | bldr := ctrl.NewControllerManagedBy(mgr) 168 | if !duck.IsDuck(r.Type, r.Config.Scheme()) { 169 | bldr.For(r.Type) 170 | } else { 171 | gvk, err := r.Config.GroupVersionKindFor(r.Type) 172 | if err != nil { 173 | return nil, err 174 | } 175 | apiVersion, kind := gvk.ToAPIVersionAndKind() 176 | u := &unstructured.Unstructured{} 177 | u.SetAPIVersion(apiVersion) 178 | u.SetKind(kind) 179 | bldr.For(u) 180 | } 181 | if r.Setup != nil { 182 | if err := r.Setup(ctx, mgr, bldr); err != nil { 183 | return nil, err 184 | } 185 | } 186 | if err := r.Reconciler.SetupWithManager(ctx, mgr, bldr); err != nil { 187 | return nil, err 188 | } 189 | if err := r.AggregateObjectManager.SetupWithManager(ctx, mgr, bldr); err != nil { 190 | return nil, err 191 | } 192 | return bldr.Build(r) 193 | } 194 | 195 | func (r *AggregateReconciler[T]) Validate(ctx context.Context) error { 196 | r.init() 197 | 198 | // validate Request value 199 | if r.Request.Name == "" { 200 | return fmt.Errorf("AggregateReconciler %q must define Request", r.Name) 201 | } 202 | 203 | // validate AggregateObjectManager value 204 | if r.AggregateObjectManager == nil { 205 | return fmt.Errorf("AggregateReconciler %q must define AggregateObjectManager", r.Name) 206 | } 207 | 208 | // validate Reconciler value 209 | if r.Reconciler == nil && r.DesiredResource == nil { 210 | return fmt.Errorf("AggregateReconciler %q must define Reconciler and/or DesiredResource", r.Name) 211 | } 212 | if validation.IsRecursive(ctx) { 213 | if v, ok := r.Reconciler.(validation.Validator); ok { 214 | if err := v.Validate(ctx); err != nil { 215 | return fmt.Errorf("AggregateReconciler %q must have a valid Reconciler: %w", r.Name, err) 216 | } 217 | } 218 | } 219 | 220 | return nil 221 | } 222 | 223 | func (r *AggregateReconciler[T]) Reconcile(ctx context.Context, req Request) (Result, error) { 224 | r.init() 225 | 226 | if req.Namespace != r.Request.Namespace || req.Name != r.Request.Name { 227 | // ignore other requests 228 | return Result{}, nil 229 | } 230 | 231 | ctx = stash.WithContext(ctx) 232 | 233 | c := r.Config 234 | 235 | log := logr.FromContextOrDiscard(ctx). 236 | WithName(r.Name). 237 | WithValues("resourceType", gvk(c, r.Type)) 238 | ctx = logr.NewContext(ctx, log) 239 | 240 | ctx = rtime.StashNow(ctx, time.Now()) 241 | ctx = StashRequest(ctx, req) 242 | ctx = StashConfig(ctx, c) 243 | ctx = StashOriginalConfig(ctx, r.Config) 244 | ctx = StashOriginalResourceType(ctx, r.Type) 245 | ctx = StashResourceType(ctx, r.Type) 246 | 247 | beforeCtx, beforeResult, err := r.BeforeReconcile(ctx, req) 248 | if err != nil { 249 | if errors.Is(err, ErrQuiet) { 250 | // suppress error, while forcing a requeue 251 | beforeResult = Result{Requeue: true} 252 | return beforeResult, nil 253 | } 254 | return beforeResult, err 255 | } 256 | if beforeCtx != nil { 257 | ctx = beforeCtx 258 | } 259 | 260 | reconcileResult, err := r.reconcile(ctx, req) 261 | 262 | result, err := r.AfterReconcile(ctx, req, AggregateResults(beforeResult, reconcileResult), err) 263 | if errors.Is(err, ErrQuiet) { 264 | // suppress error, while forcing a requeue 265 | result = Result{Requeue: true} 266 | return result, nil 267 | } 268 | return result, err 269 | } 270 | 271 | func (r *AggregateReconciler[T]) reconcile(ctx context.Context, req Request) (Result, error) { 272 | log := logr.FromContextOrDiscard(ctx) 273 | c := RetrieveConfigOrDie(ctx) 274 | 275 | resource := r.Type.DeepCopyObject().(T) 276 | if err := c.Get(ctx, req.NamespacedName, resource); err != nil { 277 | if apierrs.IsNotFound(err) { 278 | // not found is ok 279 | resource.SetNamespace(r.Request.Namespace) 280 | resource.SetName(r.Request.Name) 281 | } else { 282 | if !errors.Is(err, ErrQuiet) { 283 | log.Error(err, "unable to fetch resource") 284 | } 285 | return Result{}, err 286 | } 287 | } 288 | 289 | if resource.GetDeletionTimestamp() != nil { 290 | // resource is being deleted, nothing to do 291 | return Result{}, nil 292 | } 293 | 294 | result, err := r.Reconciler.Reconcile(ctx, resource) 295 | if err != nil && !errors.Is(err, ErrHaltSubReconcilers) { 296 | return result, err 297 | } 298 | 299 | // hack, ignore track requests from the child reconciler, we have it covered 300 | ctx = StashConfig(ctx, Config{ 301 | Client: c.Client, 302 | APIReader: c.APIReader, 303 | Recorder: c.Recorder, 304 | Tracker: tracker.New(c.Scheme(), 0), 305 | }) 306 | desired, err := r.desiredResource(ctx, resource) 307 | if err != nil { 308 | return result, err 309 | } 310 | _, err = r.AggregateObjectManager.Manage(ctx, resource, resource, desired) 311 | return result, err 312 | } 313 | 314 | func (r *AggregateReconciler[T]) desiredResource(ctx context.Context, resource T) (T, error) { 315 | var nilT T 316 | 317 | if resource.GetDeletionTimestamp() != nil { 318 | // the reconciled resource is pending deletion, cleanup the child resource 319 | return nilT, nil 320 | } 321 | 322 | fn := reflect.ValueOf(r.DesiredResource) 323 | out := fn.Call([]reflect.Value{ 324 | reflect.ValueOf(ctx), 325 | reflect.ValueOf(resource.DeepCopyObject()), 326 | }) 327 | var obj T 328 | if !out[0].IsNil() { 329 | obj = out[0].Interface().(T) 330 | } 331 | var err error 332 | if !out[1].IsNil() { 333 | err = out[1].Interface().(error) 334 | } 335 | return obj, err 336 | } 337 | -------------------------------------------------------------------------------- /reconcilers/alias.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "reconciler.io/runtime/stash" 21 | "sigs.k8s.io/controller-runtime/pkg/builder" 22 | "sigs.k8s.io/controller-runtime/pkg/manager" 23 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 24 | ) 25 | 26 | type Builder = builder.Builder 27 | type Manager = manager.Manager 28 | type Request = reconcile.Request 29 | type Result = reconcile.Result 30 | 31 | var WithStash = stash.WithContext 32 | var StashValue = stash.StoreValue 33 | var RetrieveValue = stash.RetrieveValue 34 | var HasValue = stash.HasValue 35 | var ClearValue = stash.ClearValue 36 | var ErrStashValueNotFound = stash.ErrValueNotFound 37 | var ErrStashValueNotAssignable = stash.ErrValueNotAssignable 38 | 39 | type StashKey = stash.Key 40 | type Stasher[T any] = stash.Stasher[T] 41 | 42 | // NewStasher creates a stasher for the value type 43 | func NewStasher[T any](key stash.Key) stash.Stasher[T] { 44 | // TODO switch to an alias once generic functions can be aliased 45 | return stash.New[T](key) 46 | } 47 | -------------------------------------------------------------------------------- /reconcilers/always.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | 24 | "github.com/go-logr/logr" 25 | "reconciler.io/runtime/validation" 26 | ctrl "sigs.k8s.io/controller-runtime" 27 | "sigs.k8s.io/controller-runtime/pkg/builder" 28 | "sigs.k8s.io/controller-runtime/pkg/client" 29 | ) 30 | 31 | var _ SubReconciler[client.Object] = (Always[client.Object])(nil) 32 | 33 | // Always is a collection of SubReconcilers called in order. Each reconciler is called regardless 34 | // of previous errors. The resulting errors are joined. The resulting joined error will only match 35 | // ErrQuiet if all contributing errors match ErrQuiet. 36 | type Always[Type client.Object] []SubReconciler[Type] 37 | 38 | func (r Always[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 39 | for i, reconciler := range r { 40 | log := logr.FromContextOrDiscard(ctx). 41 | WithName(fmt.Sprintf("%d", i)) 42 | ctx = logr.NewContext(ctx, log) 43 | 44 | err := reconciler.SetupWithManager(ctx, mgr, bldr) 45 | if err != nil { 46 | return err 47 | } 48 | } 49 | return nil 50 | } 51 | 52 | func (r Always[T]) Reconcile(ctx context.Context, resource T) (Result, error) { 53 | aggregateResult := Result{} 54 | aggregateErrors := []error{} 55 | aggregateNonQuietErrors := []error{} 56 | for i, reconciler := range r { 57 | log := logr.FromContextOrDiscard(ctx). 58 | WithName(fmt.Sprintf("%d", i)) 59 | ctx := logr.NewContext(ctx, log) 60 | 61 | result, err := reconciler.Reconcile(ctx, resource) 62 | aggregateResult = AggregateResults(result, aggregateResult) 63 | if err != nil { 64 | aggregateErrors = append(aggregateErrors, err) 65 | if !errors.Is(err, ErrQuiet) { 66 | aggregateNonQuietErrors = append(aggregateNonQuietErrors, err) 67 | } 68 | } 69 | } 70 | // only return an ErrQuiet if all errors are quiet 71 | if err := errors.Join(aggregateNonQuietErrors...); err != nil { 72 | return aggregateResult, err 73 | } 74 | return aggregateResult, errors.Join(aggregateErrors...) 75 | } 76 | 77 | func (r *Always[T]) Validate(ctx context.Context) error { 78 | // validate Always 79 | if validation.IsRecursive(ctx) { 80 | for i, reconciler := range *r { 81 | if v, ok := reconciler.(validation.Validator); ok { 82 | if err := v.Validate(ctx); err != nil { 83 | return fmt.Errorf("Always must have a valid Always[%d]: %w", i, err) 84 | } 85 | } 86 | } 87 | } 88 | 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /reconcilers/cast.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "context" 21 | "encoding/json" 22 | "fmt" 23 | "reflect" 24 | "sync" 25 | 26 | "k8s.io/apimachinery/pkg/util/errors" 27 | "reconciler.io/runtime/validation" 28 | 29 | "github.com/go-logr/logr" 30 | "k8s.io/apimachinery/pkg/api/equality" 31 | ctrl "sigs.k8s.io/controller-runtime" 32 | "sigs.k8s.io/controller-runtime/pkg/builder" 33 | "sigs.k8s.io/controller-runtime/pkg/client" 34 | ) 35 | 36 | var _ SubReconciler[client.Object] = (*CastResource[client.Object, client.Object])(nil) 37 | 38 | // CastResource casts the ResourceReconciler's type by projecting the resource data 39 | // onto a new struct. Casting the reconciled resource is useful to create cross 40 | // cutting reconcilers that can operate on common portion of multiple resources, 41 | // commonly referred to as a duck type. 42 | // 43 | // If the CastType generic is an interface rather than a struct, the resource is 44 | // passed directly rather than converted. 45 | type CastResource[Type, CastType client.Object] struct { 46 | // Name used to identify this reconciler. Defaults to `{Type}CastResource`. Ideally unique, but 47 | // not required to be so. 48 | // 49 | // +optional 50 | Name string 51 | 52 | // Reconciler is called for each reconciler request with the reconciled resource. Typically a 53 | // Sequence is used to compose multiple SubReconcilers. 54 | Reconciler SubReconciler[CastType] 55 | 56 | noop bool 57 | lazyInit sync.Once 58 | } 59 | 60 | func (r *CastResource[T, CT]) init() { 61 | r.lazyInit.Do(func() { 62 | var nilCT CT 63 | if reflect.ValueOf(nilCT).Kind() == reflect.Invalid { 64 | // not a real cast, just converting generic types 65 | r.noop = true 66 | return 67 | } 68 | emptyCT := newEmpty(nilCT) 69 | if r.Name == "" { 70 | r.Name = fmt.Sprintf("%sCastResource", typeName(emptyCT)) 71 | } 72 | }) 73 | } 74 | 75 | func (r *CastResource[T, CT]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 76 | r.init() 77 | 78 | if !r.noop { 79 | var nilCT CT 80 | emptyCT := newEmpty(nilCT).(CT) 81 | 82 | log := logr.FromContextOrDiscard(ctx). 83 | WithName(r.Name). 84 | WithValues("castResourceType", typeName(emptyCT)) 85 | ctx = logr.NewContext(ctx, log) 86 | 87 | if err := r.Validate(ctx); err != nil { 88 | return err 89 | } 90 | } 91 | 92 | return r.Reconciler.SetupWithManager(ctx, mgr, bldr) 93 | } 94 | 95 | func (r *CastResource[T, CT]) Validate(ctx context.Context) error { 96 | r.init() 97 | 98 | // validate Reconciler value 99 | if r.Reconciler == nil { 100 | return fmt.Errorf("CastResource %q must define Reconciler", r.Name) 101 | } 102 | if validation.IsRecursive(ctx) { 103 | if v, ok := r.Reconciler.(validation.Validator); ok { 104 | if err := v.Validate(ctx); err != nil { 105 | return fmt.Errorf("CastResource %q must have a valid Reconciler: %w", r.Name, err) 106 | } 107 | } 108 | } 109 | 110 | return nil 111 | } 112 | 113 | func (r *CastResource[T, CT]) Reconcile(ctx context.Context, resource T) (Result, error) { 114 | r.init() 115 | 116 | if r.noop { 117 | // cast the type rather than convert the object 118 | return r.Reconciler.Reconcile(ctx, client.Object(resource).(CT)) 119 | } 120 | 121 | var nilCT CT 122 | emptyCT := newEmpty(nilCT).(CT) 123 | 124 | log := logr.FromContextOrDiscard(ctx). 125 | WithName(r.Name). 126 | WithValues("castResourceType", typeName(emptyCT)) 127 | ctx = logr.NewContext(ctx, log) 128 | 129 | ctx, castResource, err := r.cast(ctx, resource) 130 | if err != nil { 131 | return Result{}, err 132 | } 133 | castOriginal := castResource.DeepCopyObject().(client.Object) 134 | result, err := r.Reconciler.Reconcile(ctx, castResource) 135 | var errs []error 136 | if err != nil { 137 | errs = append(errs, err) 138 | } 139 | if !equality.Semantic.DeepEqual(castResource, castOriginal) { 140 | // patch the reconciled resource with the updated duck values 141 | patch, err := NewPatch(castOriginal, castResource) 142 | if err != nil { 143 | errs = append(errs, err) 144 | return result, errors.NewAggregate(errs) 145 | } 146 | err = patch.Apply(resource) 147 | if err != nil { 148 | errs = append(errs, err) 149 | return result, errors.NewAggregate(errs) 150 | } 151 | 152 | } 153 | return result, errors.NewAggregate(errs) 154 | } 155 | 156 | func (r *CastResource[T, CT]) cast(ctx context.Context, resource T) (context.Context, CT, error) { 157 | var nilCT CT 158 | 159 | data, err := json.Marshal(resource) 160 | if err != nil { 161 | return nil, nilCT, err 162 | } 163 | castResource := newEmpty(nilCT).(CT) 164 | err = json.Unmarshal(data, castResource) 165 | if err != nil { 166 | return nil, nilCT, err 167 | } 168 | if kind := castResource.GetObjectKind(); kind.GroupVersionKind().Empty() { 169 | // default the apiVersion/kind with the real value from the resource if not already defined 170 | c := RetrieveConfigOrDie(ctx) 171 | kind.SetGroupVersionKind(gvk(c, resource)) 172 | } 173 | ctx = StashResourceType(ctx, castResource) 174 | return ctx, castResource, nil 175 | } 176 | -------------------------------------------------------------------------------- /reconcilers/cmp.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "unicode" 21 | "unicode/utf8" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | ) 25 | 26 | // IgnoreAllUnexported is a cmp.Option that ignores unexported fields in all structs 27 | var IgnoreAllUnexported = cmp.FilterPath(func(p cmp.Path) bool { 28 | // from cmp.IgnoreUnexported with type info removed 29 | sf, ok := p.Index(-1).(cmp.StructField) 30 | if !ok { 31 | return false 32 | } 33 | r, _ := utf8.DecodeRuneInString(sf.Name()) 34 | return !unicode.IsUpper(r) 35 | }, cmp.Ignore()) 36 | -------------------------------------------------------------------------------- /reconcilers/cmp_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers_test 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/google/go-cmp/cmp" 23 | "reconciler.io/runtime/internal/resources" 24 | "reconciler.io/runtime/internal/resources/dies" 25 | "reconciler.io/runtime/reconcilers" 26 | ) 27 | 28 | type TestResourceUnexportedSpec struct { 29 | spec resources.TestResourceUnexportedFieldsSpec 30 | } 31 | 32 | func TestIgnoreAllUnexported(t *testing.T) { 33 | tests := map[string]struct { 34 | a interface{} 35 | b interface{} 36 | shouldDiff bool 37 | }{ 38 | "nil is equivalent": { 39 | a: nil, 40 | b: nil, 41 | shouldDiff: false, 42 | }, 43 | "different exported fields have a difference": { 44 | a: dies.TestResourceUnexportedFieldsSpecBlank. 45 | AddField("name", "hello"). 46 | DieRelease(), 47 | b: dies.TestResourceUnexportedFieldsSpecBlank. 48 | AddField("name", "world"). 49 | DieRelease(), 50 | shouldDiff: true, 51 | }, 52 | "different unexported fields do not have a difference": { 53 | a: dies.TestResourceUnexportedFieldsSpecBlank. 54 | AddUnexportedField("name", "hello"). 55 | DieRelease(), 56 | b: dies.TestResourceUnexportedFieldsSpecBlank. 57 | AddUnexportedField("name", "world"). 58 | DieRelease(), 59 | shouldDiff: false, 60 | }, 61 | "different exported fields nested in an unexported field do not have a difference": { 62 | a: TestResourceUnexportedSpec{ 63 | spec: dies.TestResourceUnexportedFieldsSpecBlank. 64 | AddField("name", "hello"). 65 | DieRelease(), 66 | }, 67 | b: TestResourceUnexportedSpec{ 68 | spec: dies.TestResourceUnexportedFieldsSpecBlank. 69 | AddField("name", "world"). 70 | DieRelease(), 71 | }, 72 | shouldDiff: false, 73 | }, 74 | } 75 | 76 | for name, tc := range tests { 77 | t.Run(name, func(t *testing.T) { 78 | if name[0:1] == "#" { 79 | t.SkipNow() 80 | } 81 | 82 | diff := cmp.Diff(tc.a, tc.b, reconcilers.IgnoreAllUnexported) 83 | hasDiff := diff != "" 84 | shouldDiff := tc.shouldDiff 85 | 86 | if !hasDiff && shouldDiff { 87 | t.Errorf("expected equality, found diff") 88 | } 89 | if hasDiff && !shouldDiff { 90 | t.Errorf("found diff, expected equality: %s", diff) 91 | } 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /reconcilers/config.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "strings" 23 | "sync" 24 | "time" 25 | 26 | "k8s.io/apimachinery/pkg/labels" 27 | "k8s.io/apimachinery/pkg/runtime/schema" 28 | "k8s.io/apimachinery/pkg/types" 29 | "k8s.io/client-go/discovery" 30 | "k8s.io/client-go/tools/record" 31 | "k8s.io/client-go/tools/reference" 32 | ctrl "sigs.k8s.io/controller-runtime" 33 | "sigs.k8s.io/controller-runtime/pkg/builder" 34 | "sigs.k8s.io/controller-runtime/pkg/client" 35 | "sigs.k8s.io/controller-runtime/pkg/cluster" 36 | 37 | "github.com/go-logr/logr" 38 | "reconciler.io/runtime/duck" 39 | "reconciler.io/runtime/tracker" 40 | "reconciler.io/runtime/validation" 41 | ) 42 | 43 | // Config holds common resources for controllers. The configuration may be 44 | // passed to sub-reconcilers. 45 | type Config struct { 46 | client.Client 47 | APIReader client.Reader 48 | Discovery discovery.DiscoveryInterface 49 | Recorder record.EventRecorder 50 | Tracker tracker.Tracker 51 | 52 | syncPeriod time.Duration 53 | } 54 | 55 | func (c Config) IsEmpty() bool { 56 | return c == Config{} 57 | } 58 | 59 | // WithCluster extends the config to access a new cluster. 60 | func (c Config) WithCluster(cluster cluster.Cluster) Config { 61 | return Config{ 62 | Client: duck.NewDuckAwareClientWrapper(cluster.GetClient()), 63 | APIReader: duck.NewDuckAwareAPIReaderWrapper(cluster.GetAPIReader(), cluster.GetClient()), 64 | Discovery: discovery.NewDiscoveryClientForConfigOrDie(cluster.GetConfig()), 65 | Recorder: cluster.GetEventRecorderFor("controller"), 66 | Tracker: c.Tracker, 67 | 68 | syncPeriod: c.syncPeriod, 69 | } 70 | } 71 | 72 | // WithTracker extends the config with a new tracker. 73 | func (c Config) WithTracker() Config { 74 | return Config{ 75 | Client: c.Client, 76 | APIReader: c.APIReader, 77 | Discovery: c.Discovery, 78 | Recorder: c.Recorder, 79 | Tracker: tracker.New(c.Scheme(), 2*c.syncPeriod), 80 | 81 | syncPeriod: c.syncPeriod, 82 | } 83 | } 84 | 85 | // WithDangerousDuckClientOperations returns a new Config with client Create and Update methods for 86 | // duck typed objects enabled. 87 | // 88 | // This is dangerous because duck types typically represent a subset of the target resource and may 89 | // cause data loss if the resource's server representation contains fields that do not exist on the 90 | // duck typed object. 91 | func (c Config) WithDangerousDuckClientOperations() Config { 92 | return Config{ 93 | Client: duck.NewDangerousDuckAwareClientWrapper(c.Client), 94 | APIReader: c.APIReader, 95 | Discovery: c.Discovery, 96 | Recorder: c.Recorder, 97 | Tracker: c.Tracker, 98 | 99 | syncPeriod: c.syncPeriod, 100 | } 101 | } 102 | 103 | // TrackAndGet tracks the resources for changes and returns the current value. The track is 104 | // registered even when the resource does not exists so that its creation can be tracked. 105 | // 106 | // Equivalent to calling both `c.Tracker.TrackObject(...)` and `c.Client.Get(...)` 107 | func (c Config) TrackAndGet(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { 108 | // create synthetic resource to track from known type and request 109 | req := RetrieveRequest(ctx) 110 | resource := RetrieveResourceType(ctx).DeepCopyObject().(client.Object) 111 | resource.SetNamespace(req.Namespace) 112 | resource.SetName(req.Name) 113 | ref := obj.DeepCopyObject().(client.Object) 114 | ref.SetNamespace(key.Namespace) 115 | ref.SetName(key.Name) 116 | c.Tracker.TrackObject(ref, resource) 117 | 118 | return c.Get(ctx, key, obj, opts...) 119 | } 120 | 121 | // TrackAndList tracks the resources for changes and returns the current value. 122 | // 123 | // Equivalent to calling both `c.Tracker.TrackReference(...)` and `c.Client.List(...)` 124 | func (c Config) TrackAndList(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { 125 | // create synthetic resource to track from known type and request 126 | req := RetrieveRequest(ctx) 127 | resource := RetrieveResourceType(ctx).DeepCopyObject().(client.Object) 128 | resource.SetNamespace(req.Namespace) 129 | resource.SetName(req.Name) 130 | 131 | or, err := reference.GetReference(c.Scheme(), list) 132 | if err != nil { 133 | return err 134 | } 135 | gvk := schema.FromAPIVersionAndKind(or.APIVersion, or.Kind) 136 | listOpts := (&client.ListOptions{}).ApplyOptions(opts) 137 | if listOpts.LabelSelector == nil { 138 | listOpts.LabelSelector = labels.Everything() 139 | } 140 | ref := tracker.Reference{ 141 | APIGroup: gvk.Group, 142 | Kind: strings.TrimSuffix(gvk.Kind, "List"), 143 | Namespace: listOpts.Namespace, 144 | Selector: listOpts.LabelSelector, 145 | } 146 | c.Tracker.TrackReference(ref, resource) 147 | 148 | return c.List(ctx, list, opts...) 149 | } 150 | 151 | // NewConfig creates a Config for a specific API type. Typically passed into a 152 | // reconciler. 153 | func NewConfig(mgr ctrl.Manager, apiType client.Object, syncPeriod time.Duration) Config { 154 | return Config{syncPeriod: syncPeriod}.WithCluster(mgr).WithTracker() 155 | } 156 | 157 | var _ SubReconciler[client.Object] = (*WithConfig[client.Object])(nil) 158 | 159 | // Experimental: WithConfig injects the provided config into the reconcilers nested under it. For 160 | // example, the client can be swapped to use a service account with different permissions, or to 161 | // target an entirely different cluster. 162 | // 163 | // The specified config can be accessed with `RetrieveConfig(ctx)`, the original config used to 164 | // load the reconciled resource can be accessed with `RetrieveOriginalConfig(ctx)`. 165 | type WithConfig[Type client.Object] struct { 166 | // Name used to identify this reconciler. Defaults to `WithConfig`. Ideally unique, but 167 | // not required to be so. 168 | // 169 | // +optional 170 | Name string 171 | 172 | // Config to use for this portion of the reconciler hierarchy. This method is called during 173 | // setup and during reconciliation, if context is needed, it should be available durring both 174 | // phases. 175 | Config func(context.Context, Config) (Config, error) 176 | 177 | // Reconciler is called for each reconciler request with the reconciled 178 | // resource being reconciled. Typically a Sequence is used to compose 179 | // multiple SubReconcilers. 180 | Reconciler SubReconciler[Type] 181 | 182 | lazyInit sync.Once 183 | } 184 | 185 | func (r *WithConfig[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 186 | r.init() 187 | 188 | log := logr.FromContextOrDiscard(ctx). 189 | WithName(r.Name) 190 | ctx = logr.NewContext(ctx, log) 191 | 192 | if err := r.Validate(ctx); err != nil { 193 | return err 194 | } 195 | c, err := r.Config(ctx, RetrieveConfigOrDie(ctx)) 196 | if err != nil { 197 | return err 198 | } 199 | ctx = StashConfig(ctx, c) 200 | return r.Reconciler.SetupWithManager(ctx, mgr, bldr) 201 | } 202 | 203 | func (r *WithConfig[T]) init() { 204 | r.lazyInit.Do(func() { 205 | if r.Name == "" { 206 | r.Name = "WithConfig" 207 | } 208 | }) 209 | } 210 | 211 | func (r *WithConfig[T]) Validate(ctx context.Context) error { 212 | r.init() 213 | 214 | // validate Config value 215 | if r.Config == nil { 216 | return fmt.Errorf("WithConfig %q must define Config", r.Name) 217 | } 218 | 219 | // validate Reconciler value 220 | if r.Reconciler == nil { 221 | return fmt.Errorf("WithConfig %q must define Reconciler", r.Name) 222 | } 223 | if validation.IsRecursive(ctx) { 224 | if v, ok := r.Reconciler.(validation.Validator); ok { 225 | if err := v.Validate(ctx); err != nil { 226 | return fmt.Errorf("WithConfig %q must have a valid Reconciler: %w", r.Name, err) 227 | } 228 | } 229 | } 230 | 231 | return nil 232 | } 233 | 234 | func (r *WithConfig[T]) Reconcile(ctx context.Context, resource T) (Result, error) { 235 | log := logr.FromContextOrDiscard(ctx). 236 | WithName(r.Name) 237 | ctx = logr.NewContext(ctx, log) 238 | 239 | c, err := r.Config(ctx, RetrieveConfigOrDie(ctx)) 240 | if err != nil { 241 | return Result{}, err 242 | } 243 | ctx = StashConfig(ctx, c) 244 | return r.Reconciler.Reconcile(ctx, resource) 245 | } 246 | -------------------------------------------------------------------------------- /reconcilers/errors.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "sync" 24 | "time" 25 | 26 | "github.com/go-logr/logr" 27 | "k8s.io/apimachinery/pkg/types" 28 | "k8s.io/apimachinery/pkg/util/sets" 29 | "reconciler.io/runtime/internal" 30 | rtime "reconciler.io/runtime/time" 31 | "reconciler.io/runtime/validation" 32 | ctrl "sigs.k8s.io/controller-runtime" 33 | "sigs.k8s.io/controller-runtime/pkg/builder" 34 | "sigs.k8s.io/controller-runtime/pkg/client" 35 | ) 36 | 37 | var ( 38 | // ErrDurable is an error which the reconcile request should not be retried until the observed 39 | // state has changed. Meaningful state about the error has been captured on the status 40 | ErrDurable = errors.Join(ErrQuiet, ErrHaltSubReconcilers) 41 | ) 42 | 43 | type SuppressTransientErrors[Type client.Object, ListType client.ObjectList] struct { 44 | // Name used to identify this reconciler. Defaults to `ForEach`. Ideally unique, but not 45 | // required to be so. 46 | // 47 | // +optional 48 | Name string 49 | 50 | // ListType is the listing type for the type. For example, PodList is the list type for Pod. 51 | // Required when the generic type is not a struct, or is unstructured. 52 | // 53 | // +optional 54 | ListType ListType 55 | 56 | // Setup performs initialization on the manager and builder this reconciler will run with. It's 57 | // common to setup field indexes and watch resources. 58 | // 59 | // +optional 60 | Setup func(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error 61 | 62 | // Threshold is the number of non-ErrDurable error reconciles encountered for a specific 63 | // resource generation before which status update is suppressed. 64 | Threshold uint8 65 | 66 | // Reconciler to be called for each iterable item 67 | Reconciler SubReconciler[Type] 68 | 69 | lazyInit sync.Once 70 | m sync.Mutex 71 | lastPurge time.Time 72 | errorCounter map[types.UID]transientErrorCounter 73 | } 74 | 75 | func (r *SuppressTransientErrors[T, LT]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 76 | r.init() 77 | 78 | log := logr.FromContextOrDiscard(ctx). 79 | WithName(r.Name) 80 | ctx = logr.NewContext(ctx, log) 81 | 82 | if err := r.Validate(ctx); err != nil { 83 | return err 84 | } 85 | if err := r.Reconciler.SetupWithManager(ctx, mgr, bldr); err != nil { 86 | return err 87 | } 88 | if r.Setup == nil { 89 | return nil 90 | } 91 | return r.Setup(ctx, mgr, bldr) 92 | } 93 | 94 | func (r *SuppressTransientErrors[T, LT]) init() { 95 | r.lazyInit.Do(func() { 96 | if r.Name == "" { 97 | r.Name = "SuppressTransientErrors" 98 | } 99 | if internal.IsNil(r.ListType) { 100 | var nilLT LT 101 | r.ListType = newEmpty(nilLT).(LT) 102 | } 103 | if r.Threshold == 0 { 104 | r.Threshold = 3 105 | } 106 | r.errorCounter = map[types.UID]transientErrorCounter{} 107 | }) 108 | } 109 | 110 | func (r *SuppressTransientErrors[T, LT]) checkStaleCounters(ctx context.Context) { 111 | now := rtime.RetrieveNow(ctx) 112 | log := logr.FromContextOrDiscard(ctx) 113 | 114 | r.m.Lock() 115 | defer r.m.Unlock() 116 | 117 | if r.lastPurge.IsZero() { 118 | r.lastPurge = now 119 | return 120 | } 121 | if r.lastPurge.Add(24 * time.Hour).After(now) { 122 | return 123 | } 124 | 125 | log.Info("purging stale resource counters") 126 | 127 | c := RetrieveConfigOrDie(ctx) 128 | list := r.ListType.DeepCopyObject().(LT) 129 | if err := c.List(ctx, list); err != nil { 130 | log.Error(err, "purge failed to list resources") 131 | return 132 | } 133 | 134 | validIds := sets.New[types.UID]() 135 | for _, item := range extractItems[T](list) { 136 | validIds.Insert(item.GetUID()) 137 | } 138 | 139 | counterIds := sets.New[types.UID]() 140 | for uid := range r.errorCounter { 141 | counterIds.Insert(uid) 142 | } 143 | 144 | for _, uid := range counterIds.Difference(validIds).UnsortedList() { 145 | log.V(2).Info("purging counter", "id", uid) 146 | delete(r.errorCounter, uid) 147 | } 148 | 149 | r.lastPurge = now 150 | } 151 | 152 | func (r *SuppressTransientErrors[T, LT]) Validate(ctx context.Context) error { 153 | r.init() 154 | 155 | // validate Reconciler 156 | if r.Reconciler == nil { 157 | return fmt.Errorf("SuppressTransientErrors %q must implement Reconciler", r.Name) 158 | } 159 | if validation.IsRecursive(ctx) { 160 | if v, ok := r.Reconciler.(validation.Validator); ok { 161 | if err := v.Validate(ctx); err != nil { 162 | return fmt.Errorf("SuppressTransientErrors %q must have a valid Reconciler: %w", r.Name, err) 163 | } 164 | } 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func (r *SuppressTransientErrors[T, LT]) Reconcile(ctx context.Context, resource T) (Result, error) { 171 | log := logr.FromContextOrDiscard(ctx). 172 | WithName(r.Name) 173 | ctx = logr.NewContext(ctx, log) 174 | 175 | defer r.checkStaleCounters(ctx) 176 | 177 | result, err := r.Reconciler.Reconcile(ctx, resource) 178 | 179 | if err == nil || errors.Is(err, ErrDurable) { 180 | delete(r.errorCounter, resource.GetUID()) 181 | return result, err 182 | } 183 | 184 | // concurrent map access is ok, since keys are resources specific and a given resource will never be processed concurrently 185 | counter, ok := r.errorCounter[resource.GetUID()] 186 | if !ok || counter.Generation != resource.GetGeneration() { 187 | counter = transientErrorCounter{ 188 | Generation: resource.GetGeneration(), 189 | Count: 0, 190 | } 191 | } 192 | 193 | // check overflow before incrementing 194 | if counter.Count != uint8(255) { 195 | counter.Count = counter.Count + 1 196 | r.errorCounter[resource.GetUID()] = counter 197 | } 198 | 199 | if counter.Count < r.Threshold { 200 | // suppress status update 201 | return result, errors.Join(err, ErrSkipStatusUpdate, ErrQuiet) 202 | } 203 | 204 | return result, err 205 | } 206 | 207 | type transientErrorCounter struct { 208 | Generation int64 209 | Count uint8 210 | } 211 | -------------------------------------------------------------------------------- /reconcilers/finalizer.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "sync" 24 | 25 | "github.com/go-logr/logr" 26 | corev1 "k8s.io/api/core/v1" 27 | "reconciler.io/runtime/validation" 28 | ctrl "sigs.k8s.io/controller-runtime" 29 | "sigs.k8s.io/controller-runtime/pkg/builder" 30 | "sigs.k8s.io/controller-runtime/pkg/client" 31 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 32 | ) 33 | 34 | var _ SubReconciler[client.Object] = (*WithFinalizer[client.Object])(nil) 35 | 36 | // WithFinalizer ensures the resource being reconciled has the desired finalizer set so that state 37 | // can be cleaned up upon the resource being deleted. The finalizer is added to the resource, if not 38 | // already set, before calling the nested reconciler. When the resource is terminating, the 39 | // finalizer is cleared after returning from the nested reconciler without error and 40 | // ReadyToClearFinalizer returns true. 41 | type WithFinalizer[Type client.Object] struct { 42 | // Name used to identify this reconciler. Defaults to `WithFinalizer`. Ideally unique, but 43 | // not required to be so. 44 | // 45 | // +optional 46 | Name string 47 | 48 | // Finalizer to set on the reconciled resource. The value must be unique to this specific 49 | // reconciler instance and not shared. Reusing a value may result in orphaned state when 50 | // the reconciled resource is deleted. 51 | // 52 | // Using a finalizer is encouraged when state needs to be manually cleaned up before a resource 53 | // is fully deleted. This commonly include state allocated outside of the current cluster. 54 | Finalizer string 55 | 56 | // ReadyToClearFinalizer must return true before the finalizer is cleared from the resource. 57 | // Only called when the resource is terminating. 58 | // 59 | // Defaults to always return true. 60 | // 61 | // +optional 62 | ReadyToClearFinalizer func(ctx context.Context, resource Type) bool 63 | 64 | // Reconciler is called for each reconciler request with the reconciled 65 | // resource being reconciled. Typically a Sequence is used to compose 66 | // multiple SubReconcilers. 67 | Reconciler SubReconciler[Type] 68 | 69 | initOnce sync.Once 70 | } 71 | 72 | func (r *WithFinalizer[T]) init() { 73 | r.initOnce.Do(func() { 74 | if r.Name == "" { 75 | r.Name = "WithFinalizer" 76 | } 77 | if r.ReadyToClearFinalizer == nil { 78 | r.ReadyToClearFinalizer = func(ctx context.Context, resource T) bool { 79 | return true 80 | } 81 | } 82 | }) 83 | } 84 | 85 | func (r *WithFinalizer[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 86 | r.init() 87 | 88 | log := logr.FromContextOrDiscard(ctx). 89 | WithName(r.Name) 90 | ctx = logr.NewContext(ctx, log) 91 | 92 | if err := r.Validate(ctx); err != nil { 93 | return err 94 | } 95 | return r.Reconciler.SetupWithManager(ctx, mgr, bldr) 96 | } 97 | 98 | func (r *WithFinalizer[T]) Validate(ctx context.Context) error { 99 | r.init() 100 | 101 | // validate Finalizer value 102 | if r.Finalizer == "" { 103 | return fmt.Errorf("WithFinalizer %q must define Finalizer", r.Name) 104 | } 105 | 106 | // validate Reconciler value 107 | if r.Reconciler == nil { 108 | return fmt.Errorf("WithFinalizer %q must define Reconciler", r.Name) 109 | } 110 | if validation.IsRecursive(ctx) { 111 | if v, ok := r.Reconciler.(validation.Validator); ok { 112 | if err := v.Validate(ctx); err != nil { 113 | return fmt.Errorf("WithFinalizer %q must have a valid Reconciler: %w", r.Name, err) 114 | } 115 | } 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func (r *WithFinalizer[T]) Reconcile(ctx context.Context, resource T) (Result, error) { 122 | r.init() 123 | 124 | log := logr.FromContextOrDiscard(ctx). 125 | WithName(r.Name) 126 | ctx = logr.NewContext(ctx, log) 127 | 128 | if resource.GetDeletionTimestamp() == nil { 129 | if err := AddFinalizer(ctx, resource, r.Finalizer); err != nil { 130 | return Result{}, err 131 | } 132 | } 133 | result, err := r.Reconciler.Reconcile(ctx, resource) 134 | if err != nil { 135 | return result, err 136 | } 137 | if resource.GetDeletionTimestamp() != nil && r.ReadyToClearFinalizer(ctx, resource) { 138 | if err := ClearFinalizer(ctx, resource, r.Finalizer); err != nil { 139 | return Result{}, err 140 | } 141 | } 142 | return result, err 143 | } 144 | 145 | // AddFinalizer ensures the desired finalizer exists on the reconciled resource. The client that 146 | // loaded the reconciled resource is used to patch it with the finalizer if not already set. 147 | func AddFinalizer(ctx context.Context, resource client.Object, finalizer string) error { 148 | return ensureFinalizer(ctx, resource, finalizer, true) 149 | } 150 | 151 | // ClearFinalizer ensures the desired finalizer does not exist on the reconciled resource. The 152 | // client that loaded the reconciled resource is used to patch it with the finalizer if set. 153 | func ClearFinalizer(ctx context.Context, resource client.Object, finalizer string) error { 154 | return ensureFinalizer(ctx, resource, finalizer, false) 155 | } 156 | 157 | func ensureFinalizer(ctx context.Context, current client.Object, finalizer string, add bool) error { 158 | if finalizer == "" || controllerutil.ContainsFinalizer(current, finalizer) == add { 159 | // nothing to do 160 | return nil 161 | } 162 | 163 | config := RetrieveOriginalConfigOrDie(ctx) 164 | log := logr.FromContextOrDiscard(ctx) 165 | 166 | desired := current.DeepCopyObject().(client.Object) 167 | if add { 168 | log.Info("adding finalizer", "finalizer", finalizer) 169 | controllerutil.AddFinalizer(desired, finalizer) 170 | } else { 171 | log.Info("removing finalizer", "finalizer", finalizer) 172 | controllerutil.RemoveFinalizer(desired, finalizer) 173 | } 174 | 175 | patch := client.MergeFromWithOptions(current, client.MergeFromWithOptimisticLock{}) 176 | if err := config.Patch(ctx, desired, patch); err != nil { 177 | if !errors.Is(err, ErrQuiet) { 178 | log.Error(err, "unable to patch finalizers", "finalizer", finalizer) 179 | config.Recorder.Eventf(current, corev1.EventTypeWarning, "FinalizerPatchFailed", 180 | "Failed to patch finalizer %q: %s", finalizer, err) 181 | } 182 | return err 183 | } 184 | config.Recorder.Eventf(current, corev1.EventTypeNormal, "FinalizerPatched", 185 | "Patched finalizer %q", finalizer) 186 | 187 | // update current object with values from the api server after patching 188 | current.SetFinalizers(desired.GetFinalizers()) 189 | current.SetResourceVersion(desired.GetResourceVersion()) 190 | current.SetGeneration(desired.GetGeneration()) 191 | 192 | return nil 193 | } 194 | -------------------------------------------------------------------------------- /reconcilers/reconcilers.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "reflect" 24 | 25 | "github.com/go-logr/logr" 26 | "k8s.io/apimachinery/pkg/runtime" 27 | "k8s.io/apimachinery/pkg/runtime/schema" 28 | "k8s.io/apimachinery/pkg/types" 29 | "reconciler.io/runtime/stash" 30 | ctrl "sigs.k8s.io/controller-runtime" 31 | "sigs.k8s.io/controller-runtime/pkg/builder" 32 | "sigs.k8s.io/controller-runtime/pkg/client" 33 | "sigs.k8s.io/controller-runtime/pkg/handler" 34 | ) 35 | 36 | // SubReconciler are participants in a larger reconciler request. The resource 37 | // being reconciled is passed directly to the sub reconciler. 38 | type SubReconciler[Type client.Object] interface { 39 | SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error 40 | Reconcile(ctx context.Context, resource Type) (Result, error) 41 | } 42 | 43 | var ( 44 | // ErrQuiet are not logged or recorded as events within reconciler.io/runtime. 45 | // They are propagated as errors unless otherwise defined. 46 | // 47 | // Test for ErrQuiet with errors.Is(err, ErrQuiet) 48 | ErrQuiet = fmt.Errorf("quiet errors are returned as errors, but not logged or recorded") 49 | 50 | // ErrHaltSubReconcilers is an error that instructs SubReconcilers to stop processing the request, 51 | // while the root reconciler proceeds as if there was no error. ErrHaltSubReconcilers may be 52 | // wrapped by other errors. 53 | // 54 | // ErrHaltSubReconcilers wraps ErrQuiet to suppress spurious logs. 55 | // 56 | // See documentation for the specific SubReconciler caller to see how they handle this case. 57 | ErrHaltSubReconcilers = fmt.Errorf("stop processing SubReconcilers, without returning an error: %w", ErrQuiet) 58 | ) 59 | 60 | const requestStashKey stash.Key = "reconciler.io/runtime:request" 61 | const configStashKey stash.Key = "reconciler.io/runtime:config" 62 | const originalConfigStashKey stash.Key = "reconciler.io/runtime:originalConfig" 63 | const resourceTypeStashKey stash.Key = "reconciler.io/runtime:resourceType" 64 | const originalResourceTypeStashKey stash.Key = "reconciler.io/runtime:originalResourceType" 65 | const additionalConfigsStashKey stash.Key = "reconciler.io/runtime:additionalConfigs" 66 | 67 | func StashRequest(ctx context.Context, req Request) context.Context { 68 | return context.WithValue(ctx, requestStashKey, req) 69 | } 70 | 71 | // RetrieveRequest returns the reconciler Request from the context, or empty if not found. 72 | func RetrieveRequest(ctx context.Context) Request { 73 | value := ctx.Value(requestStashKey) 74 | if req, ok := value.(Request); ok { 75 | return req 76 | } 77 | return Request{} 78 | } 79 | 80 | func StashConfig(ctx context.Context, config Config) context.Context { 81 | return context.WithValue(ctx, configStashKey, config) 82 | } 83 | 84 | // RetrieveConfig returns the Config from the context. An error is returned if not found. 85 | func RetrieveConfig(ctx context.Context) (Config, error) { 86 | value := ctx.Value(configStashKey) 87 | if config, ok := value.(Config); ok { 88 | return config, nil 89 | } 90 | return Config{}, fmt.Errorf("config must exist on the context. Check that the context is from a ResourceReconciler or WithConfig") 91 | } 92 | 93 | // RetrieveConfigOrDie returns the Config from the context. Panics if not found. 94 | func RetrieveConfigOrDie(ctx context.Context) Config { 95 | config, err := RetrieveConfig(ctx) 96 | if err != nil { 97 | panic(err) 98 | } 99 | return config 100 | } 101 | 102 | func StashOriginalConfig(ctx context.Context, resourceConfig Config) context.Context { 103 | return context.WithValue(ctx, originalConfigStashKey, resourceConfig) 104 | } 105 | 106 | // RetrieveOriginalConfig returns the Config from the context used to load the reconciled resource. An 107 | // error is returned if not found. 108 | func RetrieveOriginalConfig(ctx context.Context) (Config, error) { 109 | value := ctx.Value(originalConfigStashKey) 110 | if config, ok := value.(Config); ok { 111 | return config, nil 112 | } 113 | return Config{}, fmt.Errorf("resource config must exist on the context. Check that the context is from a ResourceReconciler") 114 | } 115 | 116 | // RetrieveOriginalConfigOrDie returns the Config from the context used to load the reconciled resource. 117 | // Panics if not found. 118 | func RetrieveOriginalConfigOrDie(ctx context.Context) Config { 119 | config, err := RetrieveOriginalConfig(ctx) 120 | if err != nil { 121 | panic(err) 122 | } 123 | return config 124 | } 125 | 126 | func StashResourceType(ctx context.Context, currentType client.Object) context.Context { 127 | return context.WithValue(ctx, resourceTypeStashKey, currentType) 128 | } 129 | 130 | // RetrieveResourceType returns the reconciled resource type object, or nil if not found. 131 | func RetrieveResourceType(ctx context.Context) client.Object { 132 | value := ctx.Value(resourceTypeStashKey) 133 | if currentType, ok := value.(client.Object); ok { 134 | return currentType 135 | } 136 | return nil 137 | } 138 | 139 | func StashOriginalResourceType(ctx context.Context, resourceType client.Object) context.Context { 140 | return context.WithValue(ctx, originalResourceTypeStashKey, resourceType) 141 | } 142 | 143 | // RetrieveOriginalResourceType returns the reconciled resource type object, or nil if not found. 144 | func RetrieveOriginalResourceType(ctx context.Context) client.Object { 145 | value := ctx.Value(originalResourceTypeStashKey) 146 | if resourceType, ok := value.(client.Object); ok { 147 | return resourceType 148 | } 149 | return nil 150 | } 151 | 152 | func StashAdditionalConfigs(ctx context.Context, additionalConfigs map[string]Config) context.Context { 153 | return context.WithValue(ctx, additionalConfigsStashKey, additionalConfigs) 154 | } 155 | 156 | // RetrieveAdditionalConfigs returns the additional configs defined for this request. Uncommon 157 | // outside of the context of a test. An empty map is returned when no value is stashed. 158 | func RetrieveAdditionalConfigs(ctx context.Context) map[string]Config { 159 | value := ctx.Value(additionalConfigsStashKey) 160 | if additionalConfigs, ok := value.(map[string]Config); ok { 161 | return additionalConfigs 162 | } 163 | return map[string]Config{} 164 | } 165 | 166 | func typeName(i interface{}) string { 167 | if obj, ok := i.(client.Object); ok { 168 | kind := obj.GetObjectKind().GroupVersionKind().Kind 169 | if kind != "" { 170 | return kind 171 | } 172 | } 173 | 174 | t := reflect.TypeOf(i) 175 | if t.Kind() == reflect.Ptr { 176 | t = t.Elem() 177 | } 178 | return t.Name() 179 | } 180 | 181 | func gvk(c client.Client, obj runtime.Object) schema.GroupVersionKind { 182 | gvk, err := c.GroupVersionKindFor(obj) 183 | if err != nil { 184 | return schema.GroupVersionKind{} 185 | } 186 | return gvk 187 | } 188 | 189 | func namespaceName(obj client.Object) types.NamespacedName { 190 | return types.NamespacedName{ 191 | Namespace: obj.GetNamespace(), 192 | Name: obj.GetName(), 193 | } 194 | } 195 | 196 | // AggregateResults combines multiple results into a single result. If any result requests 197 | // requeue, the aggregate is requeued. The shortest non-zero requeue after is the aggregate value. 198 | func AggregateResults(results ...Result) Result { 199 | aggregate := Result{} 200 | for _, result := range results { 201 | if result.RequeueAfter != 0 && (aggregate.RequeueAfter == 0 || result.RequeueAfter < aggregate.RequeueAfter) { 202 | aggregate.RequeueAfter = result.RequeueAfter 203 | } 204 | if result.Requeue { 205 | aggregate.Requeue = true 206 | } 207 | } 208 | return aggregate 209 | } 210 | 211 | // MergeMaps flattens a sequence of maps into a single map. Keys in latter maps 212 | // overwrite previous keys. None of the arguments are mutated. 213 | func MergeMaps(maps ...map[string]string) map[string]string { 214 | out := map[string]string{} 215 | for _, m := range maps { 216 | for k, v := range m { 217 | out[k] = v 218 | } 219 | } 220 | return out 221 | } 222 | 223 | func EnqueueTracked(ctx context.Context) handler.EventHandler { 224 | c := RetrieveConfigOrDie(ctx) 225 | log := logr.FromContextOrDiscard(ctx) 226 | 227 | return handler.EnqueueRequestsFromMapFunc( 228 | func(ctx context.Context, obj client.Object) []Request { 229 | var requests []Request 230 | 231 | items, err := c.Tracker.GetObservers(obj) 232 | if err != nil { 233 | if !errors.Is(err, ErrQuiet) { 234 | log.Error(err, "unable to get tracked requests") 235 | } 236 | return nil 237 | } 238 | 239 | for _, item := range items { 240 | requests = append(requests, Request{NamespacedName: item}) 241 | } 242 | 243 | return requests 244 | }, 245 | ) 246 | } 247 | 248 | // replaceWithEmpty overwrite the underlying value with it's empty value 249 | func replaceWithEmpty(x interface{}) { 250 | v := reflect.ValueOf(x).Elem() 251 | v.Set(reflect.Zero(v.Type())) 252 | } 253 | 254 | // newEmpty returns a new empty value of the same underlying type, preserving the existing value 255 | func newEmpty(x interface{}) interface{} { 256 | t := reflect.TypeOf(x).Elem() 257 | return reflect.New(t).Interface() 258 | } 259 | -------------------------------------------------------------------------------- /reconcilers/reconcilers_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers_test 18 | 19 | import ( 20 | "errors" 21 | "testing" 22 | 23 | "reconciler.io/runtime/reconcilers" 24 | ) 25 | 26 | func TestErrHaltSubReconcilers(t *testing.T) { 27 | if !errors.Is(reconcilers.ErrHaltSubReconcilers, reconcilers.ErrQuiet) { 28 | t.Errorf("ErrHaltSubReconcilers should be ErrQuiet") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /reconcilers/sequence.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "github.com/go-logr/logr" 24 | "reconciler.io/runtime/validation" 25 | ctrl "sigs.k8s.io/controller-runtime" 26 | "sigs.k8s.io/controller-runtime/pkg/builder" 27 | "sigs.k8s.io/controller-runtime/pkg/client" 28 | ) 29 | 30 | var _ SubReconciler[client.Object] = (Sequence[client.Object])(nil) 31 | 32 | // Sequence is a collection of SubReconcilers called in order. If a 33 | // reconciler errs, further reconcilers are skipped. 34 | type Sequence[Type client.Object] []SubReconciler[Type] 35 | 36 | func (r Sequence[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 37 | for i, reconciler := range r { 38 | log := logr.FromContextOrDiscard(ctx). 39 | WithName(fmt.Sprintf("%d", i)) 40 | ctx = logr.NewContext(ctx, log) 41 | 42 | err := reconciler.SetupWithManager(ctx, mgr, bldr) 43 | if err != nil { 44 | return err 45 | } 46 | } 47 | return nil 48 | } 49 | 50 | func (r Sequence[T]) Reconcile(ctx context.Context, resource T) (Result, error) { 51 | aggregateResult := Result{} 52 | for i, reconciler := range r { 53 | log := logr.FromContextOrDiscard(ctx). 54 | WithName(fmt.Sprintf("%d", i)) 55 | ctx := logr.NewContext(ctx, log) 56 | 57 | result, err := reconciler.Reconcile(ctx, resource) 58 | aggregateResult = AggregateResults(result, aggregateResult) 59 | if err != nil { 60 | return result, err 61 | } 62 | } 63 | 64 | return aggregateResult, nil 65 | } 66 | 67 | func (r *Sequence[T]) Validate(ctx context.Context) error { 68 | // validate Sequence 69 | if validation.IsRecursive(ctx) { 70 | for i, reconciler := range *r { 71 | if v, ok := reconciler.(validation.Validator); ok { 72 | if err := v.Validate(ctx); err != nil { 73 | return fmt.Errorf("Sequence must have a valid Sequence[%d]: %w", i, err) 74 | } 75 | } 76 | } 77 | } 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /reconcilers/sync.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "sync" 24 | 25 | "github.com/go-logr/logr" 26 | ctrl "sigs.k8s.io/controller-runtime" 27 | "sigs.k8s.io/controller-runtime/pkg/builder" 28 | "sigs.k8s.io/controller-runtime/pkg/client" 29 | ) 30 | 31 | var _ SubReconciler[client.Object] = (*SyncReconciler[client.Object])(nil) 32 | 33 | // SyncReconciler is a sub reconciler for custom reconciliation logic. No 34 | // behavior is defined directly. 35 | type SyncReconciler[Type client.Object] struct { 36 | // Name used to identify this reconciler. Defaults to `SyncReconciler`. Ideally unique, but 37 | // not required to be so. 38 | // 39 | // +optional 40 | Name string 41 | 42 | // Setup performs initialization on the manager and builder this reconciler 43 | // will run with. It's common to setup field indexes and watch resources. 44 | // 45 | // +optional 46 | Setup func(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error 47 | 48 | // SyncDuringFinalization indicates the Sync method should be called when the resource is pending deletion. 49 | SyncDuringFinalization bool 50 | 51 | // Sync does whatever work is necessary for the reconciler. 52 | // 53 | // If SyncDuringFinalization is true this method is called when the resource is pending 54 | // deletion. This is useful if the reconciler is managing reference data. 55 | // 56 | // Mutually exclusive with SyncWithResult 57 | Sync func(ctx context.Context, resource Type) error 58 | 59 | // SyncWithResult does whatever work is necessary for the reconciler. 60 | // 61 | // If SyncDuringFinalization is true this method is called when the resource is pending 62 | // deletion. This is useful if the reconciler is managing reference data. 63 | // 64 | // Mutually exclusive with Sync 65 | SyncWithResult func(ctx context.Context, resource Type) (Result, error) 66 | 67 | // Finalize does whatever work is necessary for the reconciler when the resource is pending 68 | // deletion. If this reconciler sets a finalizer it should do the necessary work to clean up 69 | // state the finalizer represents and then clear the finalizer. 70 | // 71 | // Mutually exclusive with FinalizeWithResult 72 | // 73 | // +optional 74 | Finalize func(ctx context.Context, resource Type) error 75 | 76 | // Finalize does whatever work is necessary for the reconciler when the resource is pending 77 | // deletion. If this reconciler sets a finalizer it should do the necessary work to clean up 78 | // state the finalizer represents and then clear the finalizer. 79 | // 80 | // Mutually exclusive with Finalize 81 | // 82 | // +optional 83 | FinalizeWithResult func(ctx context.Context, resource Type) (Result, error) 84 | 85 | lazyInit sync.Once 86 | } 87 | 88 | func (r *SyncReconciler[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 89 | r.init() 90 | 91 | log := logr.FromContextOrDiscard(ctx). 92 | WithName(r.Name) 93 | ctx = logr.NewContext(ctx, log) 94 | 95 | if r.Setup == nil { 96 | return nil 97 | } 98 | if err := r.Validate(ctx); err != nil { 99 | return err 100 | } 101 | return r.Setup(ctx, mgr, bldr) 102 | } 103 | 104 | func (r *SyncReconciler[T]) init() { 105 | r.lazyInit.Do(func() { 106 | if r.Name == "" { 107 | r.Name = "SyncReconciler" 108 | } 109 | }) 110 | } 111 | 112 | func (r *SyncReconciler[T]) Validate(ctx context.Context) error { 113 | r.init() 114 | 115 | // validate Sync and SyncWithResult 116 | if r.Sync == nil && r.SyncWithResult == nil { 117 | return fmt.Errorf("SyncReconciler %q must implement Sync or SyncWithResult", r.Name) 118 | } 119 | if r.Sync != nil && r.SyncWithResult != nil { 120 | return fmt.Errorf("SyncReconciler %q may not implement both Sync and SyncWithResult", r.Name) 121 | } 122 | 123 | // validate Finalize and FinalizeWithResult 124 | if r.Finalize != nil && r.FinalizeWithResult != nil { 125 | return fmt.Errorf("SyncReconciler %q may not implement both Finalize and FinalizeWithResult", r.Name) 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func (r *SyncReconciler[T]) Reconcile(ctx context.Context, resource T) (Result, error) { 132 | log := logr.FromContextOrDiscard(ctx). 133 | WithName(r.Name) 134 | ctx = logr.NewContext(ctx, log) 135 | 136 | result := Result{} 137 | 138 | if resource.GetDeletionTimestamp() == nil || r.SyncDuringFinalization { 139 | syncResult, err := r.sync(ctx, resource) 140 | result = AggregateResults(result, syncResult) 141 | if err != nil { 142 | if !errors.Is(err, ErrQuiet) { 143 | log.Error(err, "unable to sync") 144 | } 145 | return result, err 146 | } 147 | } 148 | 149 | if resource.GetDeletionTimestamp() != nil { 150 | finalizeResult, err := r.finalize(ctx, resource) 151 | result = AggregateResults(result, finalizeResult) 152 | if err != nil { 153 | if !errors.Is(err, ErrQuiet) { 154 | log.Error(err, "unable to finalize") 155 | } 156 | return result, err 157 | } 158 | } 159 | 160 | return result, nil 161 | } 162 | 163 | func (r *SyncReconciler[T]) sync(ctx context.Context, resource T) (Result, error) { 164 | if r.Sync != nil { 165 | err := r.Sync(ctx, resource) 166 | return Result{}, err 167 | } 168 | return r.SyncWithResult(ctx, resource) 169 | } 170 | 171 | func (r *SyncReconciler[T]) finalize(ctx context.Context, resource T) (Result, error) { 172 | if r.Finalize != nil { 173 | err := r.Finalize(ctx, resource) 174 | return Result{}, err 175 | } 176 | if r.FinalizeWithResult != nil { 177 | return r.FinalizeWithResult(ctx, resource) 178 | } 179 | 180 | return Result{}, nil 181 | } 182 | -------------------------------------------------------------------------------- /reconcilers/testing_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers_test 18 | 19 | import ( 20 | "github.com/go-logr/logr" 21 | ) 22 | 23 | var _ logr.LogSink = &bufferedSink{} 24 | 25 | type bufferedSink struct { 26 | Lines []string 27 | } 28 | 29 | func (s *bufferedSink) Init(info logr.RuntimeInfo) {} 30 | func (s *bufferedSink) Enabled(level int) bool { 31 | return true 32 | } 33 | func (s *bufferedSink) Info(level int, msg string, keysAndValues ...interface{}) { 34 | s.Lines = append(s.Lines, msg) 35 | } 36 | func (s *bufferedSink) Error(err error, msg string, keysAndValues ...interface{}) { 37 | s.Lines = append(s.Lines, msg) 38 | } 39 | func (s *bufferedSink) WithValues(keysAndValues ...interface{}) logr.LogSink { 40 | return s 41 | } 42 | func (s *bufferedSink) WithName(name string) logr.LogSink { 43 | return s 44 | } 45 | -------------------------------------------------------------------------------- /reconcilers/util.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "fmt" 21 | "reflect" 22 | 23 | "sigs.k8s.io/controller-runtime/pkg/client" 24 | ) 25 | 26 | // objectDefaulter mirrors the former upstream interface webhook.Defaulter which was deprecated and 27 | // removed. We use this interface when reconciling a resource to set default values to a common 28 | // baseline. 29 | type objectDefaulter interface { 30 | Default() 31 | } 32 | 33 | // extractItems returns a typed slice of objects from an object list 34 | func extractItems[T client.Object](list client.ObjectList) []T { 35 | items := []T{} 36 | listValue := reflect.ValueOf(list).Elem() 37 | itemsValue := listValue.FieldByName("Items") 38 | for i := 0; i < itemsValue.Len(); i++ { 39 | itemValue := itemsValue.Index(i) 40 | var item T 41 | switch itemValue.Kind() { 42 | case reflect.Pointer: 43 | item = itemValue.Interface().(T) 44 | case reflect.Interface: 45 | item = itemValue.Interface().(T) 46 | case reflect.Struct: 47 | item = itemValue.Addr().Interface().(T) 48 | default: 49 | panic(fmt.Errorf("unknown type %s for Items slice, expected Pointer or Struct", itemValue.Kind().String())) 50 | } 51 | items = append(items, item) 52 | } 53 | return items 54 | } 55 | -------------------------------------------------------------------------------- /reconcilers/util_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package reconcilers 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/google/go-cmp/cmp" 23 | corev1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | "reconciler.io/runtime/internal/resources" 25 | "sigs.k8s.io/controller-runtime/pkg/client" 26 | ) 27 | 28 | func TestExtractItems(t *testing.T) { 29 | tests := map[string]struct { 30 | list client.ObjectList 31 | expected []*resources.TestResource 32 | shouldPanic bool 33 | }{ 34 | "empty": { 35 | list: &resources.TestResourceList{ 36 | Items: []resources.TestResource{}, 37 | }, 38 | expected: []*resources.TestResource{}, 39 | }, 40 | "struct items": { 41 | list: &resources.TestResourceList{ 42 | Items: []resources.TestResource{ 43 | { 44 | ObjectMeta: corev1.ObjectMeta{ 45 | Name: "obj1", 46 | }, 47 | }, 48 | { 49 | ObjectMeta: corev1.ObjectMeta{ 50 | Name: "obj2", 51 | }, 52 | }, 53 | }, 54 | }, 55 | expected: []*resources.TestResource{ 56 | { 57 | ObjectMeta: corev1.ObjectMeta{ 58 | Name: "obj1", 59 | }, 60 | }, 61 | { 62 | ObjectMeta: corev1.ObjectMeta{ 63 | Name: "obj2", 64 | }, 65 | }, 66 | }, 67 | }, 68 | "struct-pointer items": { 69 | list: &resources.TestResourcePointerList{ 70 | Items: []*resources.TestResource{ 71 | { 72 | ObjectMeta: corev1.ObjectMeta{ 73 | Name: "obj1", 74 | }, 75 | }, 76 | { 77 | ObjectMeta: corev1.ObjectMeta{ 78 | Name: "obj2", 79 | }, 80 | }, 81 | }, 82 | }, 83 | expected: []*resources.TestResource{ 84 | { 85 | ObjectMeta: corev1.ObjectMeta{ 86 | Name: "obj1", 87 | }, 88 | }, 89 | { 90 | ObjectMeta: corev1.ObjectMeta{ 91 | Name: "obj2", 92 | }, 93 | }, 94 | }, 95 | }, 96 | "interface items": { 97 | list: &resources.TestResourceInterfaceList{ 98 | Items: []client.Object{ 99 | &resources.TestResource{ 100 | ObjectMeta: corev1.ObjectMeta{ 101 | Name: "obj1", 102 | }, 103 | }, 104 | &resources.TestResource{ 105 | ObjectMeta: corev1.ObjectMeta{ 106 | Name: "obj2", 107 | }, 108 | }, 109 | }, 110 | }, 111 | expected: []*resources.TestResource{ 112 | { 113 | ObjectMeta: corev1.ObjectMeta{ 114 | Name: "obj1", 115 | }, 116 | }, 117 | { 118 | ObjectMeta: corev1.ObjectMeta{ 119 | Name: "obj2", 120 | }, 121 | }, 122 | }, 123 | }, 124 | "invalid items": { 125 | list: &resources.TestResourceInvalidList{ 126 | Items: []string{ 127 | "boom", 128 | }, 129 | }, 130 | shouldPanic: true, 131 | }, 132 | } 133 | 134 | for name, tc := range tests { 135 | t.Run(name, func(t *testing.T) { 136 | defer func() { 137 | if r := recover(); r != nil { 138 | if !tc.shouldPanic { 139 | t.Errorf("unexpected panic: %s", r) 140 | } 141 | } 142 | }() 143 | actual := extractItems[*resources.TestResource](tc.list) 144 | if tc.shouldPanic { 145 | t.Errorf("expected to panic") 146 | } 147 | expected := tc.expected 148 | if diff := cmp.Diff(expected, actual); diff != "" { 149 | t.Errorf("expected items to match actual items: %s", diff) 150 | } 151 | }) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /stash/stash.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package stash 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | ) 24 | 25 | const stashNonce string = "controller-stash-nonce" 26 | 27 | type stashMap map[Key]interface{} 28 | 29 | func WithContext(ctx context.Context) context.Context { 30 | return context.WithValue(ctx, stashNonce, stashMap{}) 31 | } 32 | 33 | type Key string 34 | 35 | func retrieveStashMap(ctx context.Context) stashMap { 36 | stash, ok := ctx.Value(stashNonce).(stashMap) 37 | if !ok { 38 | panic(fmt.Errorf("context not configured for stashing, call `ctx = WithContext(ctx)` before use")) 39 | } 40 | return stash 41 | } 42 | 43 | func StoreValue(ctx context.Context, key Key, value interface{}) { 44 | stash := retrieveStashMap(ctx) 45 | stash[key] = value 46 | } 47 | 48 | func HasValue(ctx context.Context, key Key) bool { 49 | stash := retrieveStashMap(ctx) 50 | _, ok := stash[key] 51 | return ok 52 | } 53 | 54 | func RetrieveValue(ctx context.Context, key Key) interface{} { 55 | stash := retrieveStashMap(ctx) 56 | return stash[key] 57 | } 58 | 59 | func ClearValue(ctx context.Context, key Key) interface{} { 60 | stash := retrieveStashMap(ctx) 61 | value := stash[key] 62 | delete(stash, key) 63 | return value 64 | } 65 | 66 | // Stasher stores and retrieves values from the stash context. The context which gets passed to its methods must be configured 67 | // with a stash via WithStash(). The stash is pre-configured for the context within a reconciler. 68 | type Stasher[T any] interface { 69 | // Key is the stash key used to store and retrieve the value 70 | Key() Key 71 | 72 | // Store saves the value in the stash under the key 73 | Store(ctx context.Context, value T) 74 | 75 | // Clear removes the key from the stash returning the previous value, if any. 76 | Clear(ctx context.Context) T 77 | 78 | // Has returns true when the stash contains the key. The type of the value is not checked. 79 | Has(ctx context.Context) bool 80 | 81 | // RetrieveOrDie retrieves the value from the stash, or panics if the key is not in the stash 82 | RetrieveOrDie(ctx context.Context) T 83 | 84 | // RetrieveOrEmpty retrieves the value from the stash, or an error if the key is not in the stash 85 | RetrieveOrError(ctx context.Context) (T, error) 86 | 87 | // RetrieveOrEmpty retrieves the value from the stash, or the empty value if the key is not in the stash 88 | RetrieveOrEmpty(ctx context.Context) T 89 | } 90 | 91 | // New creates a stasher for the value type 92 | func New[T any](key Key) Stasher[T] { 93 | return &stasher[T]{ 94 | key: key, 95 | } 96 | } 97 | 98 | type stasher[T any] struct { 99 | key Key 100 | } 101 | 102 | func (s *stasher[T]) Key() Key { 103 | return s.key 104 | } 105 | 106 | func (s *stasher[T]) Store(ctx context.Context, value T) { 107 | StoreValue(ctx, s.Key(), value) 108 | } 109 | 110 | func (s *stasher[T]) Clear(ctx context.Context) T { 111 | previous, _ := ClearValue(ctx, s.Key()).(T) 112 | return previous 113 | } 114 | 115 | func (s *stasher[T]) Has(ctx context.Context) bool { 116 | return HasValue(ctx, s.Key()) 117 | } 118 | 119 | func (s *stasher[T]) RetrieveOrDie(ctx context.Context) T { 120 | value, err := s.RetrieveOrError(ctx) 121 | if err != nil { 122 | panic(err) 123 | } 124 | return value 125 | } 126 | 127 | var ErrValueNotFound = errors.New("value not found in stash") 128 | var ErrValueNotAssignable = errors.New("value found in stash is not of an assignable type") 129 | 130 | func (s *stasher[T]) RetrieveOrError(ctx context.Context) (T, error) { 131 | var emptyT T 132 | value := RetrieveValue(ctx, s.Key()) 133 | if value == nil { 134 | // distinguish nil and missing values in stash 135 | if !s.Has(ctx) { 136 | return emptyT, ErrValueNotFound 137 | } 138 | return emptyT, nil 139 | } 140 | typedValue, ok := value.(T) 141 | if !ok { 142 | return emptyT, ErrValueNotAssignable 143 | } 144 | return typedValue, nil 145 | } 146 | 147 | func (s *stasher[T]) RetrieveOrEmpty(ctx context.Context) T { 148 | value, _ := s.RetrieveOrError(ctx) 149 | return value 150 | } 151 | -------------------------------------------------------------------------------- /stash/stash_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package stash 18 | 19 | import ( 20 | "testing" 21 | 22 | "golang.org/x/net/context" 23 | "k8s.io/apimachinery/pkg/api/equality" 24 | ) 25 | 26 | func TestStash(t *testing.T) { 27 | tests := []struct { 28 | name string 29 | value interface{} 30 | }{ 31 | { 32 | name: "string", 33 | value: "value", 34 | }, 35 | { 36 | name: "int", 37 | value: 42, 38 | }, 39 | { 40 | name: "map", 41 | value: map[string]string{"foo": "bar"}, 42 | }, 43 | { 44 | name: "nil", 45 | value: nil, 46 | }, 47 | } 48 | 49 | var key Key = "stash-key" 50 | ctx := WithContext(context.Background()) 51 | for _, c := range tests { 52 | t.Run(c.name, func(t *testing.T) { 53 | StoreValue(ctx, key, c.value) 54 | if expected, actual := c.value, RetrieveValue(ctx, key); !equality.Semantic.DeepEqual(expected, actual) { 55 | t.Errorf("%s: unexpected stash value, actually = %v, expected = %v", c.name, actual, expected) 56 | } 57 | }) 58 | } 59 | } 60 | 61 | func TestStash_StashValue_UndecoratedContext(t *testing.T) { 62 | ctx := context.Background() 63 | var key Key = "stash-key" 64 | value := "value" 65 | 66 | defer func() { 67 | if r := recover(); r == nil { 68 | t.Error("expected StashValue() to panic") 69 | } 70 | }() 71 | StoreValue(ctx, key, value) 72 | } 73 | 74 | func TestStash_RetrieveValue_UndecoratedContext(t *testing.T) { 75 | ctx := context.Background() 76 | var key Key = "stash-key" 77 | 78 | defer func() { 79 | if r := recover(); r == nil { 80 | t.Error("expected RetrieveValue() to panic") 81 | } 82 | }() 83 | RetrieveValue(ctx, key) 84 | } 85 | 86 | func TestStash_RetrieveValue_Undefined(t *testing.T) { 87 | ctx := WithContext(context.Background()) 88 | var key Key = "stash-key" 89 | 90 | if value := RetrieveValue(ctx, key); value != nil { 91 | t.Error("expected RetrieveValue() to return nil for undefined key") 92 | } 93 | } 94 | 95 | func TestStasher(t *testing.T) { 96 | ctx := WithContext(context.Background()) 97 | stasher := New[string]("my-key") 98 | 99 | if key := stasher.Key(); key != Key("my-key") { 100 | t.Errorf("expected key to be %q got %q", Key("my-key"), key) 101 | } 102 | 103 | t.Run("no value", func(t *testing.T) { 104 | t.Run("RetrieveOrEmpty", func(t *testing.T) { 105 | if value := stasher.RetrieveOrEmpty(ctx); value != "" { 106 | t.Error("expected value to be empty") 107 | } 108 | }) 109 | t.Run("RetrieveOrError", func(t *testing.T) { 110 | if value, err := stasher.RetrieveOrError(ctx); err == nil { 111 | t.Error("expected err") 112 | } else if value != "" { 113 | t.Error("expected value to be empty") 114 | } 115 | }) 116 | t.Run("RetrieveOrDie", func(t *testing.T) { 117 | defer func() { 118 | if r := recover(); r != nil { 119 | return 120 | } 121 | t.Errorf("expected to recover") 122 | }() 123 | value := stasher.RetrieveOrDie(ctx) 124 | t.Errorf("expected to panic, got %q", value) 125 | }) 126 | }) 127 | 128 | t.Run("has value", func(t *testing.T) { 129 | stasher.Store(ctx, "hello world") 130 | t.Run("RetrieveOrEmpty", func(t *testing.T) { 131 | if value := stasher.RetrieveOrEmpty(ctx); value != "hello world" { 132 | t.Errorf("expected value to be %q got %q", "hello world", value) 133 | } 134 | }) 135 | t.Run("RetrieveOrError", func(t *testing.T) { 136 | if value, err := stasher.RetrieveOrError(ctx); err != nil { 137 | t.Errorf("unexpected err: %s", err) 138 | } else if value != "hello world" { 139 | t.Errorf("expected value to be %q got %q", "hello world", value) 140 | } 141 | }) 142 | t.Run("RetrieveOrDie", func(t *testing.T) { 143 | if value := stasher.RetrieveOrDie(ctx); value != "hello world" { 144 | t.Errorf("expected value to be %q got %q", "hello world", value) 145 | } 146 | }) 147 | }) 148 | 149 | t.Run("context scoped", func(t *testing.T) { 150 | stasher.Store(ctx, "hello world") 151 | if value := stasher.RetrieveOrEmpty(ctx); value != "hello world" { 152 | t.Errorf("expected value to be %q got %q", "hello world", value) 153 | } 154 | altCtx := WithContext(context.Background()) 155 | if value := stasher.RetrieveOrEmpty(altCtx); value != "" { 156 | t.Error("expected value to be empty") 157 | } 158 | }) 159 | 160 | t.Run("Clear", func(t *testing.T) { 161 | stasher.Store(ctx, "hello world") 162 | if value := stasher.RetrieveOrEmpty(ctx); value != "hello world" { 163 | t.Errorf("expected value to be %q got %q", "hello world", value) 164 | } 165 | stasher.Clear(ctx) 166 | if value := stasher.RetrieveOrEmpty(ctx); value != "" { 167 | t.Error("expected value to be empty") 168 | } 169 | }) 170 | } 171 | -------------------------------------------------------------------------------- /testing/actions.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package testing 18 | 19 | import ( 20 | "fmt" 21 | 22 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 23 | "k8s.io/apimachinery/pkg/runtime" 24 | "k8s.io/apimachinery/pkg/util/json" 25 | clientgotesting "k8s.io/client-go/testing" 26 | ) 27 | 28 | type Action = clientgotesting.Action 29 | type GetAction = clientgotesting.GetAction 30 | type ListAction = clientgotesting.ListAction 31 | type CreateAction = clientgotesting.CreateAction 32 | type UpdateAction = clientgotesting.UpdateAction 33 | type PatchAction = clientgotesting.PatchAction 34 | type DeleteAction = clientgotesting.DeleteAction 35 | type DeleteCollectionAction = clientgotesting.DeleteCollectionAction 36 | 37 | type ApplyAction interface { 38 | Action 39 | GetName() string 40 | GetApplyConfiguration() runtime.ApplyConfiguration 41 | } 42 | 43 | type ApplyActionImpl struct { 44 | clientgotesting.ActionImpl 45 | Name string 46 | ApplyConfiguration runtime.ApplyConfiguration 47 | } 48 | 49 | func (a ApplyActionImpl) GetName() string { 50 | return a.Name 51 | } 52 | 53 | func (a ApplyActionImpl) GetApplyConfiguration() runtime.ApplyConfiguration { 54 | return a.ApplyConfiguration 55 | } 56 | 57 | func NewApplyAction(ac runtime.ApplyConfiguration) ApplyAction { 58 | data, err := json.Marshal(ac) 59 | if err != nil { 60 | panic(fmt.Errorf("failed to marshal apply configuration: %w", err)) 61 | } 62 | 63 | obj := &unstructured.Unstructured{} 64 | if err := json.Unmarshal(data, obj); err != nil { 65 | panic(fmt.Errorf("failed to unmarshal apply configuration: %w", err)) 66 | } 67 | gvk := obj.GetObjectKind().GroupVersionKind() 68 | 69 | action := ApplyActionImpl{} 70 | 71 | action.Verb = "apply" 72 | action.Resource.Group = gvk.Group 73 | action.Resource.Version = gvk.Version 74 | // gvk != gvr, following established practice 75 | action.Resource.Resource = gvk.Kind 76 | action.Namespace = obj.GetNamespace() 77 | action.Name = obj.GetName() 78 | action.ApplyConfiguration = ac 79 | 80 | return action 81 | } 82 | -------------------------------------------------------------------------------- /testing/aliases.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package testing 18 | 19 | import ( 20 | clientgotesting "k8s.io/client-go/testing" 21 | ) 22 | 23 | type Reactor = clientgotesting.Reactor 24 | type ReactionFunc = clientgotesting.ReactionFunc 25 | -------------------------------------------------------------------------------- /testing/color.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package testing 18 | 19 | import ( 20 | "os" 21 | "strings" 22 | 23 | "github.com/fatih/color" 24 | ) 25 | 26 | func init() { 27 | if _, ok := os.LookupEnv("COLOR_DIFF"); ok { 28 | DiffAddedColor.EnableColor() 29 | DiffRemovedColor.EnableColor() 30 | } 31 | } 32 | 33 | var ( 34 | DiffAddedColor = color.New(color.FgGreen) 35 | DiffRemovedColor = color.New(color.FgRed) 36 | ) 37 | 38 | func ColorizeDiff(diff string) string { 39 | var b strings.Builder 40 | for _, line := range strings.Split(diff, "\n") { 41 | switch { 42 | case strings.HasPrefix(line, "+"): 43 | b.WriteString(DiffAddedColor.Sprint(line)) 44 | case strings.HasPrefix(line, "-"): 45 | b.WriteString(DiffRemovedColor.Sprint(line)) 46 | default: 47 | b.WriteString(line) 48 | } 49 | b.WriteString("\n") 50 | } 51 | return b.String() 52 | } 53 | -------------------------------------------------------------------------------- /testing/diff.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package testing 18 | 19 | import ( 20 | "github.com/google/go-cmp/cmp" 21 | "github.com/google/go-cmp/cmp/cmpopts" 22 | "reconciler.io/runtime/reconcilers" 23 | "reconciler.io/runtime/stash" 24 | "sigs.k8s.io/controller-runtime/pkg/client" 25 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 26 | ) 27 | 28 | type Differ interface { 29 | Result(expected, actual reconcilers.Result) string 30 | TrackRequest(expected, actual TrackRequest) string 31 | Event(expected, actual Event) string 32 | ApplyRef(expected, actual ApplyRef) string 33 | PatchRef(expected, actual PatchRef) string 34 | DeleteRef(expected, actual DeleteRef) string 35 | DeleteCollectionRef(expected, actual DeleteCollectionRef) string 36 | StashedValue(expected, actual any, key stash.Key) string 37 | Resource(expected, actual client.Object) string 38 | ResourceStatusUpdate(expected, actual client.Object) string 39 | ResourceUpdate(expected, actual client.Object) string 40 | ResourceCreate(expected, actual client.Object) string 41 | WebhookResponse(expected, actual admission.Response) string 42 | } 43 | 44 | // DefaultDiffer is a basic implementation of the Differ interface that is used by default unless 45 | // overridden for a specific test case or globally. 46 | var DefaultDiffer Differ = &differ{} 47 | 48 | type differ struct{} 49 | 50 | func (*differ) Result(expected, actual reconcilers.Result) string { 51 | return cmp.Diff(expected, actual) 52 | } 53 | 54 | func (*differ) TrackRequest(expected, actual TrackRequest) string { 55 | return cmp.Diff(expected, actual, NormalizeLabelSelector) 56 | } 57 | 58 | func (*differ) Event(expected, actual Event) string { 59 | return cmp.Diff(expected, actual) 60 | } 61 | 62 | func (*differ) ApplyRef(expected, actual ApplyRef) string { 63 | return cmp.Diff(expected, actual, NormalizeApplyConfiguration) 64 | } 65 | 66 | func (*differ) PatchRef(expected, actual PatchRef) string { 67 | return cmp.Diff(expected, actual) 68 | } 69 | 70 | func (*differ) DeleteRef(expected, actual DeleteRef) string { 71 | return cmp.Diff(expected, actual) 72 | } 73 | 74 | func (*differ) DeleteCollectionRef(expected, actual DeleteCollectionRef) string { 75 | return cmp.Diff(expected, actual, NormalizeLabelSelector, NormalizeFieldSelector) 76 | } 77 | 78 | func (*differ) StashedValue(expected, actual any, key stash.Key) string { 79 | return cmp.Diff(expected, actual, reconcilers.IgnoreAllUnexported, 80 | IgnoreLastTransitionTime, 81 | IgnoreTypeMeta, 82 | IgnoreCreationTimestamp, 83 | IgnoreResourceVersion, 84 | cmpopts.EquateEmpty()) 85 | } 86 | 87 | func (*differ) Resource(expected, actual client.Object) string { 88 | return cmp.Diff(expected, actual, reconcilers.IgnoreAllUnexported, 89 | IgnoreLastTransitionTime, 90 | IgnoreTypeMeta, 91 | cmpopts.EquateEmpty()) 92 | } 93 | 94 | func (*differ) ResourceStatusUpdate(expected, actual client.Object) string { 95 | return cmp.Diff(expected, actual, statusSubresourceOnly, 96 | reconcilers.IgnoreAllUnexported, 97 | IgnoreLastTransitionTime, 98 | cmpopts.EquateEmpty()) 99 | } 100 | 101 | func (*differ) ResourceUpdate(expected, actual client.Object) string { 102 | return cmp.Diff(expected, actual, reconcilers.IgnoreAllUnexported, 103 | IgnoreLastTransitionTime, 104 | IgnoreTypeMeta, 105 | IgnoreCreationTimestamp, 106 | IgnoreResourceVersion, 107 | cmpopts.EquateEmpty()) 108 | } 109 | 110 | func (*differ) ResourceCreate(expected, actual client.Object) string { 111 | return cmp.Diff(expected, actual, reconcilers.IgnoreAllUnexported, 112 | IgnoreLastTransitionTime, 113 | IgnoreTypeMeta, 114 | IgnoreCreationTimestamp, 115 | IgnoreResourceVersion, 116 | cmpopts.EquateEmpty()) 117 | } 118 | 119 | func (*differ) WebhookResponse(expected, actual admission.Response) string { 120 | return cmp.Diff(expected, actual, reconcilers.IgnoreAllUnexported) 121 | } 122 | -------------------------------------------------------------------------------- /testing/diff_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package testing 18 | 19 | import ( 20 | "reconciler.io/runtime/reconcilers" 21 | "reconciler.io/runtime/stash" 22 | "sigs.k8s.io/controller-runtime/pkg/client" 23 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 24 | ) 25 | 26 | type staticDiffer struct { 27 | diff string 28 | } 29 | 30 | func (d *staticDiffer) Result(expected, actual reconcilers.Result) string { 31 | return d.diff 32 | } 33 | 34 | func (d *staticDiffer) TrackRequest(expected, actual TrackRequest) string { 35 | return d.diff 36 | } 37 | 38 | func (d *staticDiffer) Event(expected, actual Event) string { 39 | return d.diff 40 | } 41 | 42 | func (d *staticDiffer) ApplyRef(expected, actual ApplyRef) string { 43 | return d.diff 44 | } 45 | 46 | func (d *staticDiffer) PatchRef(expected, actual PatchRef) string { 47 | return d.diff 48 | } 49 | 50 | func (d *staticDiffer) DeleteRef(expected, actual DeleteRef) string { 51 | return d.diff 52 | } 53 | 54 | func (d *staticDiffer) DeleteCollectionRef(expected, actual DeleteCollectionRef) string { 55 | return d.diff 56 | } 57 | 58 | func (d *staticDiffer) StashedValue(expected, actual any, key stash.Key) string { 59 | return d.diff 60 | } 61 | 62 | func (d *staticDiffer) Resource(expected, actual client.Object) string { 63 | return d.diff 64 | } 65 | 66 | func (d *staticDiffer) ResourceStatusUpdate(expected, actual client.Object) string { 67 | return d.diff 68 | } 69 | 70 | func (d *staticDiffer) ResourceUpdate(expected, actual client.Object) string { 71 | return d.diff 72 | } 73 | 74 | func (d *staticDiffer) ResourceCreate(expected, actual client.Object) string { 75 | return d.diff 76 | } 77 | 78 | func (d *staticDiffer) WebhookResponse(expected, actual admission.Response) string { 79 | return d.diff 80 | } 81 | -------------------------------------------------------------------------------- /testing/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package testing provides support for testing reconcilers. 18 | // It was inspired by knative.dev/pkg/reconciler/testing. 19 | package testing 20 | -------------------------------------------------------------------------------- /testing/objectmanager.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package testing 18 | 19 | import ( 20 | "context" 21 | 22 | "k8s.io/apimachinery/pkg/api/equality" 23 | "k8s.io/apimachinery/pkg/runtime" 24 | "reconciler.io/runtime/internal" 25 | "reconciler.io/runtime/reconcilers" 26 | "reconciler.io/runtime/stash" 27 | ctrl "sigs.k8s.io/controller-runtime" 28 | "sigs.k8s.io/controller-runtime/pkg/builder" 29 | "sigs.k8s.io/controller-runtime/pkg/client" 30 | ) 31 | 32 | var _ reconcilers.ObjectManager[client.Object] = (*StubObjectManager[client.Object])(nil) 33 | 34 | // StubObjectManager is used for testing the orchestration of an ObjectManager's essential behavior 35 | // without the full robustness of a real ObjectManager implementation. 36 | // 37 | // There are four possible outcomes: 38 | // - create a desired resource, returning the created object 39 | // - update an outdated resource, returning the updated object 40 | // - delete an undesired resource, returning nil 41 | // - do nothing when the resources are in sync, returning the actual object 42 | // 43 | // If any of the client calls error, the error is returned. 44 | // 45 | // There are no user configurable values. Merging of desired and actual objects is handled 46 | // reflectively and may be unsafe for some resources. 47 | type StubObjectManager[Type client.Object] struct{} 48 | 49 | func (m *StubObjectManager[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 50 | return nil 51 | } 52 | 53 | func (m *StubObjectManager[T]) Manage(ctx context.Context, resource client.Object, actual, desired T) (T, error) { 54 | var nilT T 55 | c := reconcilers.RetrieveConfigOrDie(ctx) 56 | 57 | if (internal.IsNil(actual) || actual.GetCreationTimestamp().Time.IsZero()) && internal.IsNil(desired) { 58 | // nothing to do 59 | return nilT, nil 60 | } 61 | if internal.IsNil(desired) { 62 | current := actual.DeepCopyObject().(T) 63 | if current.GetDeletionTimestamp() != nil { 64 | // object is already terminating 65 | return nilT, nil 66 | } 67 | if err := c.Delete(ctx, current); err != nil { 68 | return nilT, err 69 | } 70 | return nilT, nil 71 | } 72 | if internal.IsNil(actual) || actual.GetCreationTimestamp().Time.IsZero() { 73 | current := desired.DeepCopyObject().(T) 74 | if err := c.Create(ctx, current); err != nil { 75 | return nilT, err 76 | } 77 | return current, nil 78 | } 79 | current := actual.DeepCopyObject().(T) 80 | // merge desired into current 81 | m.unsafeMergeInto(current, desired) 82 | if !equality.Semantic.DeepEqual(actual, current) { 83 | if err := c.Update(ctx, current); err != nil { 84 | return nilT, err 85 | } 86 | return current, nil 87 | } 88 | 89 | return current, nil 90 | } 91 | 92 | func (m *StubObjectManager[T]) unsafeMergeInto(target, source T) { 93 | ut, err := runtime.DefaultUnstructuredConverter.ToUnstructured(target) 94 | if err != nil { 95 | panic(err) 96 | } 97 | us, err := runtime.DefaultUnstructuredConverter.ToUnstructured(source) 98 | if err != nil { 99 | panic(err) 100 | } 101 | 102 | // copy allowed top-level fields 103 | for k := range us { 104 | if k != "apiVersion" && k != "kind" && k != "metadata" && k != "status" { 105 | ut[k] = us[k] 106 | } 107 | } 108 | 109 | if err := runtime.DefaultUnstructuredConverter.FromUnstructured(ut, target); err != nil { 110 | panic(err) 111 | } 112 | 113 | // copy allowed metadata fields 114 | target.SetAnnotations(source.GetAnnotations()) 115 | target.SetLabels(source.GetLabels()) 116 | } 117 | 118 | var _ reconcilers.SubReconciler[client.Object] = (*ObjectManagerReconcilerTestHarness[client.Object])(nil) 119 | 120 | // ObjectManagerReconcilerTestHarness orchestrates an ObjectManager as a SubReconciler so that it 121 | // can be easily tested within a SubReconcilerTestCase. 122 | // 123 | // The actual and desired objects must be defined in the stash via the 124 | // ObjectManagerReconcilerTestHarnessActualStasher and 125 | // ObjectManagerReconcilerTestHarnessDesiredStasher stashers. Failure to stash these values will 126 | // panic the reconciler. The resulting object is stashed with 127 | // ObjectManagerReconcilerTestHarnessResultStasher. 128 | // 129 | // The actual object, when defined, is automatically added as a given object. 130 | type ObjectManagerReconcilerTestHarness[T client.Object] struct { 131 | ObjectManager reconcilers.ObjectManager[T] 132 | } 133 | 134 | func (h *ObjectManagerReconcilerTestHarness[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 135 | return h.ObjectManager.SetupWithManager(ctx, mgr, bldr) 136 | } 137 | 138 | func (h *ObjectManagerReconcilerTestHarness[T]) Reconcile(ctx context.Context, resource client.Object) (reconcilers.Result, error) { 139 | actual := ObjectManagerReconcilerTestHarnessActualStasher[T]().RetrieveOrDie(ctx) 140 | desired := ObjectManagerReconcilerTestHarnessDesiredStasher[T]().RetrieveOrDie(ctx) 141 | if !internal.IsNil(actual) { 142 | c := reconcilers.RetrieveConfigOrDie(ctx) 143 | c.Client.(TestClient).AddGiven(actual) 144 | } 145 | 146 | result, err := h.ObjectManager.Manage(ctx, resource, actual, desired) 147 | ObjectManagerReconcilerTestHarnessResultStasher[T]().Store(ctx, result) 148 | return reconcilers.Result{}, err 149 | } 150 | 151 | func ObjectManagerReconcilerTestHarnessActualStasher[T client.Object]() stash.Stasher[T] { 152 | return stash.New[T]("reconciler.io/object-manager-reconciler-test-harness-actual") 153 | } 154 | 155 | func ObjectManagerReconcilerTestHarnessDesiredStasher[T client.Object]() stash.Stasher[T] { 156 | return stash.New[T]("reconciler.io/object-manager-reconciler-test-harness-desired") 157 | } 158 | 159 | func ObjectManagerReconcilerTestHarnessResultStasher[T client.Object]() stash.Stasher[T] { 160 | return stash.New[T]("reconciler.io/object-manager-reconciler-test-harness-result") 161 | } 162 | -------------------------------------------------------------------------------- /testing/reconciler_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package testing 18 | 19 | import ( 20 | "context" 21 | "testing" 22 | "time" 23 | 24 | "k8s.io/apimachinery/pkg/runtime" 25 | "reconciler.io/runtime/reconcilers" 26 | rtime "reconciler.io/runtime/time" 27 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 28 | ) 29 | 30 | func TestReconcilerTestCase_Now(t *testing.T) { 31 | start := time.Now() 32 | 33 | type testCase struct { 34 | name string 35 | rtc *ReconcilerTestCase 36 | verify func(*testing.T, context.Context) 37 | } 38 | tests := []*testCase{ 39 | { 40 | name: "Now defaults to time.Now()", 41 | rtc: &ReconcilerTestCase{}, 42 | verify: func(t *testing.T, ctx context.Context) { 43 | now := rtime.RetrieveNow(ctx) 44 | // compare with a range to allow for time skew 45 | // test must complete within 1 hour of wall clock time 46 | if now.Before(start.Add(-1*time.Second)) || now.After(start.Add(time.Hour)) { 47 | t.Error("expected test case to be initialized with time.Now()") 48 | } 49 | }, 50 | }, 51 | { 52 | name: "Now is set explicitly", 53 | rtc: &ReconcilerTestCase{ 54 | Now: time.UnixMilli(1000), 55 | }, 56 | verify: func(t *testing.T, ctx context.Context) { 57 | now := rtime.RetrieveNow(ctx) 58 | if !now.Equal(time.UnixMilli(1000)) { 59 | t.Error("expected time to be initialized from the test case") 60 | } 61 | }, 62 | }, 63 | } 64 | for _, tc := range tests { 65 | t.Run(tc.name, func(t *testing.T) { 66 | tc.rtc.ExpectedResult = reconcile.Result{RequeueAfter: time.Second} 67 | tc.rtc.Run(t, runtime.NewScheme(), func(t *testing.T, rtc *ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { 68 | return reconcile.Func(func(ctx context.Context, o reconcile.Request) (reconcile.Result, error) { 69 | tc.verify(t, ctx) 70 | return reconcile.Result{RequeueAfter: time.Second}, nil 71 | }) 72 | }) 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /testing/recorder.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package testing 18 | 19 | import ( 20 | "fmt" 21 | 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | "k8s.io/apimachinery/pkg/runtime" 24 | "k8s.io/apimachinery/pkg/types" 25 | "k8s.io/client-go/tools/record" 26 | ref "k8s.io/client-go/tools/reference" 27 | "sigs.k8s.io/controller-runtime/pkg/client" 28 | ) 29 | 30 | type Event struct { 31 | metav1.TypeMeta 32 | types.NamespacedName 33 | Type string 34 | Reason string 35 | Message string 36 | } 37 | 38 | func NewEvent(factory client.Object, scheme *runtime.Scheme, eventtype, reason, messageFormat string, a ...interface{}) Event { 39 | obj := factory.DeepCopyObject() 40 | objref, err := ref.GetReference(scheme, obj) 41 | if err != nil { 42 | panic(fmt.Sprintf("Could not construct reference to: '%#v' due to: '%v'. Will not report event: '%v' '%v' '%v'", obj, err, eventtype, reason, fmt.Sprintf(messageFormat, a...))) 43 | } 44 | 45 | return Event{ 46 | TypeMeta: metav1.TypeMeta{ 47 | APIVersion: objref.APIVersion, 48 | Kind: objref.Kind, 49 | }, 50 | NamespacedName: types.NamespacedName{ 51 | Namespace: objref.Namespace, 52 | Name: objref.Name, 53 | }, 54 | Type: eventtype, 55 | Reason: reason, 56 | Message: fmt.Sprintf(messageFormat, a...), 57 | } 58 | } 59 | 60 | type eventRecorder struct { 61 | events []Event 62 | scheme *runtime.Scheme 63 | } 64 | 65 | var ( 66 | _ record.EventRecorder = (*eventRecorder)(nil) 67 | ) 68 | 69 | func (r *eventRecorder) Event(object runtime.Object, eventtype, reason, message string) { 70 | r.Eventf(object, eventtype, reason, "%s", message) 71 | } 72 | 73 | func (r *eventRecorder) Eventf(object runtime.Object, eventtype, reason, messageFmt string, args ...interface{}) { 74 | r.events = append(r.events, NewEvent(object.(client.Object), r.scheme, eventtype, reason, messageFmt, args...)) 75 | } 76 | 77 | func (r *eventRecorder) AnnotatedEventf(object runtime.Object, annotations map[string]string, eventtype, reason, messageFmt string, args ...interface{}) { 78 | r.Eventf(object, eventtype, reason, messageFmt, args...) 79 | } 80 | -------------------------------------------------------------------------------- /testing/tracker.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package testing 18 | 19 | import ( 20 | "time" 21 | 22 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 23 | "k8s.io/apimachinery/pkg/runtime" 24 | "k8s.io/apimachinery/pkg/runtime/schema" 25 | "k8s.io/apimachinery/pkg/types" 26 | "k8s.io/client-go/tools/reference" 27 | "reconciler.io/runtime/duck" 28 | "reconciler.io/runtime/tracker" 29 | "sigs.k8s.io/controller-runtime/pkg/client" 30 | ) 31 | 32 | // TrackRequest records that one object is tracking another object. 33 | type TrackRequest struct { 34 | // Tracker is the object doing the tracking 35 | Tracker types.NamespacedName 36 | 37 | // Deprecated use TrackedReference 38 | // Tracked is the object being tracked 39 | Tracked tracker.Key 40 | 41 | // TrackedReference is a ref to the object being tracked 42 | TrackedReference tracker.Reference 43 | } 44 | 45 | func (tr *TrackRequest) normalize() { 46 | if tr.TrackedReference != (tracker.Reference{}) { 47 | return 48 | } 49 | tr.TrackedReference = tracker.Reference{ 50 | APIGroup: tr.Tracked.GroupKind.Group, 51 | Kind: tr.Tracked.GroupKind.Kind, 52 | Namespace: tr.Tracked.NamespacedName.Namespace, 53 | Name: tr.Tracked.NamespacedName.Name, 54 | } 55 | tr.Tracked = tracker.Key{} 56 | } 57 | 58 | type trackBy func(trackingObjNamespace, trackingObjName string) TrackRequest 59 | 60 | func (t trackBy) By(trackingObjNamespace, trackingObjName string) TrackRequest { 61 | return t(trackingObjNamespace, trackingObjName) 62 | } 63 | 64 | func CreateTrackRequest(trackedObjGroup, trackedObjKind, trackedObjNamespace, trackedObjName string) trackBy { 65 | return func(trackingObjNamespace, trackingObjName string) TrackRequest { 66 | return TrackRequest{ 67 | TrackedReference: tracker.Reference{ 68 | APIGroup: trackedObjGroup, 69 | Kind: trackedObjKind, 70 | Namespace: trackedObjNamespace, 71 | Name: trackedObjName, 72 | }, 73 | Tracker: types.NamespacedName{Namespace: trackingObjNamespace, Name: trackingObjName}, 74 | } 75 | } 76 | } 77 | 78 | func NewTrackRequest(t, b client.Object, scheme *runtime.Scheme) TrackRequest { 79 | tracked, by := t.DeepCopyObject().(client.Object), b.DeepCopyObject().(client.Object) 80 | 81 | gvk := tracked.GetObjectKind().GroupVersionKind() 82 | if !duck.IsDuck(tracked, scheme) { 83 | gvks, _, err := scheme.ObjectKinds(tracked) 84 | if err != nil { 85 | panic(err) 86 | } 87 | gvk = gvks[0] 88 | } 89 | 90 | return TrackRequest{ 91 | TrackedReference: tracker.Reference{ 92 | APIGroup: gvk.Group, 93 | Kind: gvk.Kind, 94 | Namespace: tracked.GetNamespace(), 95 | Name: tracked.GetName(), 96 | }, 97 | Tracker: types.NamespacedName{Namespace: by.GetNamespace(), Name: by.GetName()}, 98 | } 99 | } 100 | 101 | func createTracker(given []TrackRequest, scheme *runtime.Scheme) *mockTracker { 102 | t := &mockTracker{ 103 | Tracker: tracker.New(scheme, 24*time.Hour), 104 | scheme: scheme, 105 | } 106 | for _, g := range given { 107 | g.normalize() 108 | obj := &unstructured.Unstructured{} 109 | obj.SetNamespace(g.Tracker.Namespace) 110 | obj.SetName(g.Tracker.Name) 111 | t.TrackReference(g.TrackedReference, obj) 112 | } 113 | // reset tracked requests 114 | t.reqs = []TrackRequest{} 115 | return t 116 | } 117 | 118 | type mockTracker struct { 119 | tracker.Tracker 120 | reqs []TrackRequest 121 | scheme *runtime.Scheme 122 | } 123 | 124 | var _ tracker.Tracker = &mockTracker{} 125 | 126 | // TrackObject tells us that "obj" is tracking changes to the 127 | // referenced object. 128 | func (t *mockTracker) TrackObject(ref client.Object, obj client.Object) error { 129 | or, err := reference.GetReference(t.scheme, ref) 130 | if err != nil { 131 | return err 132 | } 133 | gv := schema.FromAPIVersionAndKind(or.APIVersion, or.Kind) 134 | return t.TrackReference(tracker.Reference{ 135 | APIGroup: gv.Group, 136 | Kind: gv.Kind, 137 | Namespace: ref.GetNamespace(), 138 | Name: ref.GetName(), 139 | }, obj) 140 | } 141 | 142 | // TrackReference tells us that "obj" is tracking changes to the 143 | // referenced object. 144 | func (t *mockTracker) TrackReference(ref tracker.Reference, obj client.Object) error { 145 | t.reqs = append(t.reqs, TrackRequest{ 146 | Tracker: types.NamespacedName{ 147 | Namespace: obj.GetNamespace(), 148 | Name: obj.GetName(), 149 | }, 150 | TrackedReference: ref, 151 | }) 152 | return t.Tracker.TrackReference(ref, obj) 153 | } 154 | 155 | func (t *mockTracker) getTrackRequests() []TrackRequest { 156 | return append([]TrackRequest{}, t.reqs...) 157 | } 158 | -------------------------------------------------------------------------------- /time/now.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package time 18 | 19 | import ( 20 | "context" 21 | "time" 22 | ) 23 | 24 | type stashKey struct{} 25 | 26 | var nowStashKey = stashKey{} 27 | 28 | func StashNow(ctx context.Context, now time.Time) context.Context { 29 | if ctx.Value(nowStashKey) != nil { 30 | // avoid overwriting 31 | return ctx 32 | } 33 | return context.WithValue(ctx, nowStashKey, &now) 34 | } 35 | 36 | // RetrieveNow returns the stashed time, or the current time if not found. 37 | func RetrieveNow(ctx context.Context) time.Time { 38 | value := ctx.Value(nowStashKey) 39 | if now, ok := value.(*time.Time); ok { 40 | return *now 41 | } 42 | return time.Now() 43 | } 44 | -------------------------------------------------------------------------------- /tracker/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Knative Authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package tracker defines a utility to enable Reconcilers to trigger 18 | // reconciliations when objects that are cross-referenced change, so 19 | // that the level-based reconciliation can react to the change. The 20 | // prototypical cross-reference in Kubernetes is corev1.ObjectReference. 21 | // 22 | // Imported from https://github.com/knative/pkg/tree/db8a35330281c41c7e8e90df6059c23a36af0643/tracker 23 | // liberated from Knative runtime dependencies and evolved to better 24 | // fit controller-runtime patterns. 25 | package tracker 26 | -------------------------------------------------------------------------------- /tracker/enqueue.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Knative Authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package tracker 18 | 19 | import ( 20 | "fmt" 21 | "sort" 22 | "strings" 23 | "sync" 24 | "time" 25 | 26 | "k8s.io/apimachinery/pkg/labels" 27 | "k8s.io/apimachinery/pkg/runtime" 28 | "k8s.io/apimachinery/pkg/runtime/schema" 29 | "k8s.io/apimachinery/pkg/types" 30 | "k8s.io/apimachinery/pkg/util/sets" 31 | "k8s.io/apimachinery/pkg/util/validation" 32 | "k8s.io/client-go/tools/reference" 33 | "sigs.k8s.io/controller-runtime/pkg/client" 34 | ) 35 | 36 | // New returns an implementation of Interface that lets a Reconciler 37 | // register a particular resource as watching an ObjectReference for 38 | // a particular lease duration. This watch must be refreshed 39 | // periodically (e.g. by a controller resync) or it will expire. 40 | func New(scheme *runtime.Scheme, lease time.Duration) Tracker { 41 | return &impl{ 42 | scheme: scheme, 43 | leaseDuration: lease, 44 | } 45 | } 46 | 47 | type impl struct { 48 | m sync.Mutex 49 | // exact maps from an object reference to the set of 50 | // keys for objects watching it. 51 | exact map[Reference]exactSet 52 | // inexact maps from a partial object reference (no name/selector) to 53 | // a map from watcher keys to the compiled selector and expiry. 54 | inexact map[Reference]inexactSet 55 | 56 | // scheme used to convert typed objects to GVKs 57 | scheme *runtime.Scheme 58 | // The amount of time that an object may watch another 59 | // before having to renew the lease. 60 | leaseDuration time.Duration 61 | } 62 | 63 | // Check that impl implements Interface. 64 | var _ Tracker = (*impl)(nil) 65 | 66 | // exactSet is a map from keys to expirations. 67 | type exactSet map[types.NamespacedName]time.Time 68 | 69 | // inexactSet is a map from keys to matchers. 70 | type inexactSet map[types.NamespacedName]matchers 71 | 72 | // matchers is a map from matcherKeys to matchers 73 | type matchers map[matcherKey]matcher 74 | 75 | // matcher holds the selector and expiry for matching tracked objects. 76 | type matcher struct { 77 | // The selector to complete the match. 78 | selector labels.Selector 79 | 80 | // When this lease expires. 81 | expiry time.Time 82 | } 83 | 84 | // matcherKey holds a stringified selector and namespace. 85 | type matcherKey struct { 86 | // The selector for the matcher stringified 87 | selector string 88 | 89 | // The namespace to complete the match. Empty matches cluster scope 90 | // and all namespaced resources. 91 | namespace string 92 | } 93 | 94 | // Track implements Interface. 95 | func (i *impl) TrackObject(ref client.Object, obj client.Object) error { 96 | or, err := reference.GetReference(i.scheme, ref) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | gvk := schema.FromAPIVersionAndKind(or.APIVersion, or.Kind) 102 | return i.TrackReference(Reference{ 103 | APIGroup: gvk.Group, 104 | Kind: gvk.Kind, 105 | Namespace: or.Namespace, 106 | Name: or.Name, 107 | }, obj) 108 | } 109 | 110 | func (i *impl) TrackReference(ref Reference, obj client.Object) error { 111 | invalidFields := map[string][]string{ 112 | "Kind": validation.IsCIdentifier(ref.Kind), 113 | } 114 | // Allow apiGroup to be empty for core resources 115 | if ref.APIGroup != "" { 116 | invalidFields["APIGroup"] = validation.IsDNS1123Subdomain(ref.APIGroup) 117 | } 118 | // Allow namespace to be empty for cluster-scoped references. 119 | if ref.Namespace != "" { 120 | invalidFields["Namespace"] = validation.IsDNS1123Label(ref.Namespace) 121 | } 122 | fieldErrors := []string{} 123 | switch { 124 | case ref.Selector != nil && ref.Name != "": 125 | fieldErrors = append(fieldErrors, "cannot provide both Name and Selector") 126 | case ref.Name != "": 127 | invalidFields["Name"] = validation.IsDNS1123Subdomain(ref.Name) 128 | case ref.Selector != nil: 129 | default: 130 | fieldErrors = append(fieldErrors, "must provide either Name or Selector") 131 | } 132 | for k, v := range invalidFields { 133 | for _, msg := range v { 134 | fieldErrors = append(fieldErrors, fmt.Sprintf("%s: %s", k, msg)) 135 | } 136 | } 137 | if len(fieldErrors) > 0 { 138 | sort.Strings(fieldErrors) 139 | return fmt.Errorf("invalid Reference:\n%s", strings.Join(fieldErrors, "\n")) 140 | } 141 | 142 | key := types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()} 143 | 144 | i.m.Lock() 145 | defer i.m.Unlock() 146 | 147 | if i.exact == nil { 148 | i.exact = make(map[Reference]exactSet) 149 | } 150 | if i.inexact == nil { 151 | i.inexact = make(map[Reference]inexactSet) 152 | } 153 | 154 | // If the reference uses Name then it is an exact match. 155 | if ref.Selector == nil { 156 | l, ok := i.exact[ref] 157 | if !ok { 158 | l = exactSet{} 159 | } 160 | 161 | // Overwrite the key with a new expiration. 162 | l[key] = time.Now().Add(i.leaseDuration) 163 | 164 | i.exact[ref] = l 165 | return nil 166 | } 167 | 168 | // Otherwise, it is an inexact match by selector. 169 | partialRef := Reference{ 170 | APIGroup: ref.APIGroup, 171 | Kind: ref.Kind, 172 | // Exclude the namespace and selector, they are captured in the matcher. 173 | } 174 | is, ok := i.inexact[partialRef] 175 | if !ok { 176 | is = inexactSet{} 177 | } 178 | m, ok := is[key] 179 | if !ok { 180 | m = matchers{} 181 | } 182 | 183 | // Overwrite the key with a new expiration. 184 | m[matcherKey{ 185 | selector: ref.Selector.String(), 186 | namespace: ref.Namespace, 187 | }] = matcher{ 188 | selector: ref.Selector, 189 | expiry: time.Now().Add(i.leaseDuration), 190 | } 191 | 192 | is[key] = m 193 | 194 | i.inexact[partialRef] = is 195 | return nil 196 | } 197 | 198 | // GetObservers implements Interface. 199 | func (i *impl) GetObservers(obj client.Object) ([]types.NamespacedName, error) { 200 | or, err := reference.GetReference(i.scheme, obj) 201 | if err != nil { 202 | return nil, err 203 | } 204 | 205 | gv, err := schema.ParseGroupVersion(or.APIVersion) 206 | if err != nil { 207 | return nil, err 208 | } 209 | ref := Reference{ 210 | APIGroup: gv.Group, 211 | Kind: or.Kind, 212 | Namespace: or.Namespace, 213 | Name: or.Name, 214 | } 215 | 216 | keys := sets.Set[types.NamespacedName]{} 217 | 218 | i.m.Lock() 219 | defer i.m.Unlock() 220 | 221 | now := time.Now() 222 | 223 | // Handle exact matches. 224 | s, ok := i.exact[ref] 225 | if ok { 226 | for key, expiry := range s { 227 | // If the expiration has lapsed, then delete the key. 228 | if now.After(expiry) { 229 | delete(s, key) 230 | continue 231 | } 232 | keys.Insert(key) 233 | } 234 | if len(s) == 0 { 235 | delete(i.exact, ref) 236 | } 237 | } 238 | 239 | // Handle inexact matches. 240 | ref.Name = "" 241 | ref.Namespace = "" 242 | is, ok := i.inexact[ref] 243 | if ok { 244 | ls := labels.Set(obj.GetLabels()) 245 | for key, ms := range is { 246 | for k, m := range ms { 247 | // If the expiration has lapsed, then delete the key. 248 | if now.After(m.expiry) { 249 | delete(ms, k) 250 | continue 251 | } 252 | // Match namespace, allowing for a cluster wide match. 253 | if k.namespace != "" && k.namespace != obj.GetNamespace() { 254 | continue 255 | } 256 | if !m.selector.Matches(ls) { 257 | continue 258 | } 259 | keys.Insert(key) 260 | } 261 | if len(ms) == 0 { 262 | delete(is, key) 263 | } 264 | } 265 | if len(is) == 0 { 266 | delete(i.inexact, ref) 267 | } 268 | } 269 | 270 | return keys.UnsortedList(), nil 271 | } 272 | -------------------------------------------------------------------------------- /tracker/enqueue_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package tracker_test 18 | 19 | import ( 20 | "sort" 21 | "testing" 22 | "time" 23 | 24 | "github.com/google/go-cmp/cmp" 25 | corev1 "k8s.io/api/core/v1" 26 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 27 | "k8s.io/apimachinery/pkg/labels" 28 | "k8s.io/apimachinery/pkg/runtime" 29 | "k8s.io/apimachinery/pkg/types" 30 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 31 | "reconciler.io/runtime/internal/resources" 32 | "reconciler.io/runtime/tracker" 33 | "sigs.k8s.io/controller-runtime/pkg/client" 34 | ) 35 | 36 | func TestTracker(t *testing.T) { 37 | scheme := runtime.NewScheme() 38 | _ = resources.AddToScheme(scheme) 39 | _ = clientgoscheme.AddToScheme(scheme) 40 | 41 | referrer := &resources.TestResource{ 42 | ObjectMeta: metav1.ObjectMeta{ 43 | Namespace: "test-namespace", 44 | Name: "test-name", 45 | }, 46 | } 47 | referrerOther := &resources.TestResource{ 48 | ObjectMeta: metav1.ObjectMeta{ 49 | Namespace: "test-namespace", 50 | Name: "other-name", 51 | }, 52 | } 53 | 54 | referent := &corev1.ConfigMap{ 55 | ObjectMeta: metav1.ObjectMeta{ 56 | Namespace: "test-namespace", 57 | Name: "test-name", 58 | Labels: map[string]string{ 59 | "app": "test", 60 | }, 61 | }, 62 | } 63 | 64 | type track struct { 65 | ref tracker.Reference 66 | obj client.Object 67 | } 68 | 69 | tests := map[string]struct { 70 | lease time.Duration 71 | tracks []track 72 | obj client.Object 73 | expected []types.NamespacedName 74 | }{ 75 | "empty tracker matches nothing": { 76 | lease: time.Hour, 77 | obj: referent, 78 | expected: []types.NamespacedName{}, 79 | }, 80 | "match by name": { 81 | lease: time.Hour, 82 | tracks: []track{ 83 | { 84 | ref: tracker.Reference{ 85 | APIGroup: "", 86 | Kind: "ConfigMap", 87 | Namespace: "test-namespace", 88 | Name: "test-name", 89 | }, 90 | obj: referrer, 91 | }, 92 | }, 93 | obj: referent, 94 | expected: []types.NamespacedName{ 95 | {Namespace: "test-namespace", Name: "test-name"}, 96 | }, 97 | }, 98 | "multiple matches by name": { 99 | lease: time.Hour, 100 | tracks: []track{ 101 | { 102 | ref: tracker.Reference{ 103 | APIGroup: "", 104 | Kind: "ConfigMap", 105 | Namespace: "test-namespace", 106 | Name: "test-name", 107 | }, 108 | obj: referrer, 109 | }, 110 | { 111 | ref: tracker.Reference{ 112 | APIGroup: "", 113 | Kind: "ConfigMap", 114 | Namespace: "test-namespace", 115 | Name: "test-name", 116 | }, 117 | obj: referrerOther, 118 | }, 119 | }, 120 | obj: referent, 121 | expected: []types.NamespacedName{ 122 | {Namespace: "test-namespace", Name: "other-name"}, 123 | {Namespace: "test-namespace", Name: "test-name"}, 124 | }, 125 | }, 126 | "does not match other names": { 127 | lease: time.Hour, 128 | tracks: []track{ 129 | { 130 | ref: tracker.Reference{ 131 | APIGroup: "", 132 | Kind: "ConfigMap", 133 | Namespace: "test-namespace", 134 | Name: "other-name", 135 | }, 136 | obj: referrer, 137 | }, 138 | }, 139 | obj: referent, 140 | expected: []types.NamespacedName{}, 141 | }, 142 | "does not match other namespaces": { 143 | lease: time.Hour, 144 | tracks: []track{ 145 | { 146 | ref: tracker.Reference{ 147 | APIGroup: "", 148 | Kind: "ConfigMap", 149 | Namespace: "other-namespace", 150 | Name: "test-name", 151 | }, 152 | obj: referrer, 153 | }, 154 | }, 155 | obj: referent, 156 | expected: []types.NamespacedName{}, 157 | }, 158 | "does not match other groups": { 159 | lease: time.Hour, 160 | tracks: []track{ 161 | { 162 | ref: tracker.Reference{ 163 | APIGroup: "fake", 164 | Kind: "ConfigMap", 165 | Namespace: "test-namespace", 166 | Name: "test-name", 167 | }, 168 | obj: referrer, 169 | }, 170 | }, 171 | obj: referent, 172 | expected: []types.NamespacedName{}, 173 | }, 174 | "does not match other kinds": { 175 | lease: time.Hour, 176 | tracks: []track{ 177 | { 178 | ref: tracker.Reference{ 179 | APIGroup: "", 180 | Kind: "Secret", 181 | Namespace: "test-namespace", 182 | Name: "test-name", 183 | }, 184 | obj: referrer, 185 | }, 186 | }, 187 | obj: referent, 188 | expected: []types.NamespacedName{}, 189 | }, 190 | "match by selector": { 191 | lease: time.Hour, 192 | tracks: []track{ 193 | { 194 | ref: tracker.Reference{ 195 | APIGroup: "", 196 | Kind: "ConfigMap", 197 | Selector: labels.SelectorFromSet(map[string]string{ 198 | "app": "test", 199 | }), 200 | }, 201 | obj: referrer, 202 | }, 203 | }, 204 | obj: referent, 205 | expected: []types.NamespacedName{ 206 | {Namespace: "test-namespace", Name: "test-name"}, 207 | }, 208 | }, 209 | "match by selector in namespace": { 210 | lease: time.Hour, 211 | tracks: []track{ 212 | { 213 | ref: tracker.Reference{ 214 | APIGroup: "", 215 | Kind: "ConfigMap", 216 | Namespace: "test-namespace", 217 | Selector: labels.SelectorFromSet(map[string]string{ 218 | "app": "test", 219 | }), 220 | }, 221 | obj: referrer, 222 | }, 223 | }, 224 | obj: referent, 225 | expected: []types.NamespacedName{ 226 | {Namespace: "test-namespace", Name: "test-name"}, 227 | }, 228 | }, 229 | "no match by selector in wrong namespace": { 230 | lease: time.Hour, 231 | tracks: []track{ 232 | { 233 | ref: tracker.Reference{ 234 | APIGroup: "", 235 | Kind: "ConfigMap", 236 | Namespace: "other-namespace", 237 | Selector: labels.SelectorFromSet(map[string]string{ 238 | "app": "test", 239 | }), 240 | }, 241 | obj: referrer, 242 | }, 243 | }, 244 | obj: referent, 245 | expected: []types.NamespacedName{}, 246 | }, 247 | "no match by selector missing label": { 248 | lease: time.Hour, 249 | tracks: []track{ 250 | { 251 | ref: tracker.Reference{ 252 | APIGroup: "", 253 | Kind: "ConfigMap", 254 | Namespace: "test-namespace", 255 | Selector: labels.SelectorFromSet(map[string]string{ 256 | "app": "other", 257 | }), 258 | }, 259 | obj: referrer, 260 | }, 261 | }, 262 | obj: referent, 263 | expected: []types.NamespacedName{}, 264 | }, 265 | "multiple matches by selector": { 266 | lease: time.Hour, 267 | tracks: []track{ 268 | { 269 | ref: tracker.Reference{ 270 | APIGroup: "", 271 | Kind: "ConfigMap", 272 | Selector: labels.SelectorFromSet(map[string]string{ 273 | "app": "test", 274 | }), 275 | }, 276 | obj: referrer, 277 | }, 278 | { 279 | ref: tracker.Reference{ 280 | APIGroup: "", 281 | Kind: "ConfigMap", 282 | Selector: labels.SelectorFromSet(map[string]string{ 283 | "app": "test", 284 | }), 285 | }, 286 | obj: referrerOther, 287 | }, 288 | }, 289 | obj: referent, 290 | expected: []types.NamespacedName{ 291 | {Namespace: "test-namespace", Name: "other-name"}, 292 | {Namespace: "test-namespace", Name: "test-name"}, 293 | }, 294 | }, 295 | "no match by name for expired lease": { 296 | lease: time.Nanosecond, 297 | tracks: []track{ 298 | { 299 | ref: tracker.Reference{ 300 | APIGroup: "", 301 | Kind: "ConfigMap", 302 | Namespace: "test-namespace", 303 | Name: "test-name", 304 | }, 305 | obj: referrer, 306 | }, 307 | }, 308 | obj: referent, 309 | expected: []types.NamespacedName{}, 310 | }, 311 | "no match by selector for expired lease": { 312 | lease: time.Nanosecond, 313 | tracks: []track{ 314 | { 315 | ref: tracker.Reference{ 316 | APIGroup: "", 317 | Kind: "ConfigMap", 318 | Selector: labels.SelectorFromSet(map[string]string{ 319 | "app": "test", 320 | }), 321 | }, 322 | obj: referrer, 323 | }, 324 | }, 325 | obj: referent, 326 | expected: []types.NamespacedName{}, 327 | }, 328 | } 329 | 330 | for name, tc := range tests { 331 | t.Run(name, func(t *testing.T) { 332 | tracker := tracker.New(scheme, tc.lease) 333 | for _, track := range tc.tracks { 334 | tracker.TrackReference(track.ref, track.obj) 335 | } 336 | 337 | actual, _ := tracker.GetObservers(tc.obj) 338 | sort.Slice(actual, func(i, j int) bool { 339 | if in, jn := actual[i].Namespace, actual[j].Namespace; in != jn { 340 | return in < jn 341 | } 342 | return actual[i].Name < actual[j].Name 343 | }) 344 | expected := tc.expected 345 | if diff := cmp.Diff(expected, actual); diff != "" { 346 | t.Errorf("expected observers to match actual observers: %s", diff) 347 | } 348 | }) 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /tracker/interface.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Knative Authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package tracker 18 | 19 | import ( 20 | "k8s.io/apimachinery/pkg/labels" 21 | "k8s.io/apimachinery/pkg/runtime/schema" 22 | "k8s.io/apimachinery/pkg/types" 23 | "sigs.k8s.io/controller-runtime/pkg/client" 24 | ) 25 | 26 | // Reference is modeled after corev1.ObjectReference, but omits fields 27 | // unsupported by the tracker, and permits us to extend things in 28 | // divergent ways. 29 | // 30 | // APIVersion is reduce to APIGroup as the version of a tracked object 31 | // is irrelevant. 32 | type Reference struct { 33 | // APIGroup of the referent. 34 | // +optional 35 | APIGroup string 36 | 37 | // Kind of the referent. 38 | // +optional 39 | Kind string 40 | 41 | // Namespace of the referent. 42 | // +optional 43 | Namespace string 44 | 45 | // Name of the referent. 46 | // Mutually exclusive with Selector. 47 | // +optional 48 | Name string 49 | 50 | // Selector of the referents. 51 | // Mutually exclusive with Name. 52 | // +optional 53 | Selector labels.Selector 54 | } 55 | 56 | // Tracker defines the interface through which an object can register 57 | // that it is tracking another object by reference. 58 | type Tracker interface { 59 | // TrackReference tells us that "obj" is tracking changes to the 60 | // referenced object. 61 | TrackReference(ref Reference, obj client.Object) error 62 | 63 | // TrackObject tells us that "obj" is tracking changes to the 64 | // referenced object. 65 | TrackObject(ref client.Object, obj client.Object) error 66 | 67 | // GetObservers returns the names of all observers for the given 68 | // object. 69 | GetObservers(obj client.Object) ([]types.NamespacedName, error) 70 | } 71 | 72 | // Deprecated: use Reference 73 | func NewKey(gvk schema.GroupVersionKind, namespacedName types.NamespacedName) Key { 74 | return Key{ 75 | GroupKind: gvk.GroupKind(), 76 | NamespacedName: namespacedName, 77 | } 78 | } 79 | 80 | // Deprecated: use Reference 81 | type Key struct { 82 | GroupKind schema.GroupKind 83 | NamespacedName types.NamespacedName 84 | } 85 | -------------------------------------------------------------------------------- /validation/validation.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package validation 18 | 19 | import "context" 20 | 21 | type Validator interface { 22 | Validate(ctx context.Context) error 23 | } 24 | 25 | type recursiveKey struct{} 26 | 27 | func WithRecursive(ctx context.Context) context.Context { 28 | return context.WithValue(ctx, recursiveKey{}, struct{}{}) 29 | } 30 | 31 | func IsRecursive(ctx context.Context) bool { 32 | return ctx.Value(recursiveKey{}) != nil 33 | } 34 | -------------------------------------------------------------------------------- /validation/validation_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 the original author or authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package validation_test 18 | 19 | import ( 20 | "context" 21 | "testing" 22 | 23 | "reconciler.io/runtime/validation" 24 | ) 25 | 26 | func TestRecursiveValidation(t *testing.T) { 27 | t.Run("without recursive validation", func(t *testing.T) { 28 | ctx := context.Background() 29 | if validation.IsRecursive(ctx) { 30 | t.Errorf("expected %v not to have recursive validation", ctx) 31 | } 32 | }) 33 | t.Run("with recursive validation", func(t *testing.T) { 34 | ctx := validation.WithRecursive(context.Background()) 35 | if !validation.IsRecursive(ctx) { 36 | t.Errorf("expected %v to have nested validation", ctx) 37 | } 38 | }) 39 | } 40 | --------------------------------------------------------------------------------