├── .gitignore ├── codecov.yml ├── hack ├── boilerplate.go.txt ├── tools.go ├── update-codegen.sh └── go.mod ├── testing ├── doc.go ├── aliases.go ├── color.go ├── recorder.go ├── tracker.go ├── webhook.go └── reconciler.go ├── MAINTAINERS.md ├── internal ├── resources │ ├── resource_list_invalid.go │ ├── resource_list_with_pointer_items.go │ ├── resource_without_status.go │ ├── resource_with_empty_status.go │ ├── resource_list_with_interface_items.go │ ├── resource_duck.go │ ├── resource_with_nilable_status.go │ ├── dies │ │ ├── zz_generated.die_test.go │ │ └── dies.go │ ├── resource.go │ └── resource_with_unexported_fields.go └── util.go ├── reconcilers ├── alias.go ├── cmp.go ├── util.go ├── stash.go ├── sequence.go ├── stash_test.go ├── cmp_test.go ├── util_test.go ├── resourcemanager_test.go ├── cast.go ├── sync.go ├── finalizer.go ├── config.go ├── finalizer_test.go ├── webhook.go ├── reconcilers.go ├── aggregate.go ├── cast_test.go ├── resourcemanager.go └── config_test.go ├── NOTICE ├── .github ├── dependabot.yml └── workflows │ └── ci.yaml ├── time └── now.go ├── apis ├── zz_generated.deepcopy.go ├── conditionset_test.go └── status_types.go ├── tracker ├── doc.go ├── interface.go ├── enqueue.go └── enqueue_test.go ├── docs └── RestMapper.md ├── CONTRIBUTING.md ├── go.mod ├── CODE_OF_CONDUCT.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "**/zz_generated.*.go" 3 | - "internal/resources" 4 | github_checks: false 5 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ -------------------------------------------------------------------------------- /testing/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | // Package testing provides support for testing reconcilers. 7 | // It was inspired by knative.dev/pkg/reconciler/testing. 8 | package testing 9 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Maintainers 2 | 3 | * Rasheed Abdul-Aziz, [squeedee](https://github.com/squeedee) 4 | * Max Brauer, [mamachanko](https://github.com/mamachanko) 5 | 6 | # Emeritus maintainers 7 | 8 | * Scott Andrews, [scothis](https://github.com/scothis) 9 | -------------------------------------------------------------------------------- /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 | _ "dies.dev/diegen" 9 | _ "sigs.k8s.io/controller-tools/cmd/controller-gen" 10 | ) 11 | -------------------------------------------------------------------------------- /internal/resources/resource_list_invalid.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package resources 7 | 8 | import ( 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | ) 11 | 12 | // +kubebuilder:object:root=true 13 | 14 | type TestResourceInvalidList struct { 15 | metav1.TypeMeta `json:",inline"` 16 | metav1.ListMeta `json:"metadata"` 17 | 18 | Items []string `json:"items"` 19 | } 20 | -------------------------------------------------------------------------------- /reconcilers/alias.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "sigs.k8s.io/controller-runtime/pkg/builder" 10 | "sigs.k8s.io/controller-runtime/pkg/manager" 11 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 12 | ) 13 | 14 | type Builder = builder.Builder 15 | type Manager = manager.Manager 16 | type Request = reconcile.Request 17 | type Result = reconcile.Result 18 | -------------------------------------------------------------------------------- /internal/resources/resource_list_with_pointer_items.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package resources 7 | 8 | import ( 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | ) 11 | 12 | // +kubebuilder:object:root=true 13 | 14 | type TestResourcePointerList struct { 15 | metav1.TypeMeta `json:",inline"` 16 | metav1.ListMeta `json:"metadata"` 17 | 18 | Items []*TestResource `json:"items"` 19 | } 20 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | reconciler-runtime 2 | Copyright (c) 2020 VMware, Inc. All Rights Reserved. 3 | 4 | This product is licensed to you under the Apache 2.0 license (the "License"). You may not use this product except in compliance with the Apache 2.0 License. 5 | 6 | This product may include a number of subcomponents with separate copyright notices and license terms. Your use of these subcomponents is subject to the terms and conditions of the subcomponent's license, as noted in the LICENSE file. 7 | -------------------------------------------------------------------------------- /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 dies.dev/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 | -------------------------------------------------------------------------------- /reconcilers/cmp.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "unicode" 10 | "unicode/utf8" 11 | 12 | "github.com/google/go-cmp/cmp" 13 | ) 14 | 15 | // IgnoreAllUnexported is a cmp.Option that ignores unexported fields in all structs 16 | var IgnoreAllUnexported = cmp.FilterPath(func(p cmp.Path) bool { 17 | // from cmp.IgnoreUnexported with type info removed 18 | sf, ok := p.Index(-1).(cmp.StructField) 19 | if !ok { 20 | return false 21 | } 22 | r, _ := utf8.DecodeRuneInString(sf.Name()) 23 | return !unicode.IsUpper(r) 24 | }, cmp.Ignore()) 25 | -------------------------------------------------------------------------------- /.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: 0 8 | reviewers: 9 | - squeedee 10 | - mamachanko 11 | - package-ecosystem: gomod 12 | directory: "/" 13 | groups: 14 | kubernetes: 15 | patterns: 16 | - "k8s.io/*" 17 | schedule: 18 | interval: daily 19 | open-pull-requests-limit: 0 20 | reviewers: 21 | - squeedee 22 | - mamachanko 23 | - package-ecosystem: gomod 24 | directory: "/hack" 25 | schedule: 26 | interval: daily 27 | open-pull-requests-limit: 0 28 | reviewers: 29 | - squeedee 30 | - mamachanko 31 | -------------------------------------------------------------------------------- /testing/aliases.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package testing 7 | 8 | import ( 9 | clientgotesting "k8s.io/client-go/testing" 10 | ) 11 | 12 | type Reactor = clientgotesting.Reactor 13 | type ReactionFunc = clientgotesting.ReactionFunc 14 | 15 | type Action = clientgotesting.Action 16 | type GetAction = clientgotesting.GetAction 17 | type ListAction = clientgotesting.ListAction 18 | type CreateAction = clientgotesting.CreateAction 19 | type UpdateAction = clientgotesting.UpdateAction 20 | type PatchAction = clientgotesting.PatchAction 21 | type DeleteAction = clientgotesting.DeleteAction 22 | type DeleteCollectionAction = clientgotesting.DeleteCollectionAction 23 | -------------------------------------------------------------------------------- /time/now.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package time 7 | 8 | import ( 9 | "context" 10 | "time" 11 | ) 12 | 13 | type stashKey struct{} 14 | 15 | var nowStashKey = stashKey{} 16 | 17 | func StashNow(ctx context.Context, now time.Time) context.Context { 18 | if ctx.Value(nowStashKey) != nil { 19 | // avoid overwriting 20 | return ctx 21 | } 22 | return context.WithValue(ctx, nowStashKey, &now) 23 | } 24 | 25 | // RetrieveNow returns the stashed time, or the current time if not found. 26 | func RetrieveNow(ctx context.Context) time.Time { 27 | value := ctx.Value(nowStashKey) 28 | if now, ok := value.(*time.Time); ok { 29 | return *now 30 | } 31 | return time.Now() 32 | } 33 | -------------------------------------------------------------------------------- /internal/util.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package internal 7 | 8 | import "reflect" 9 | 10 | // IsNil returns true if the value is nil, false if the value is not nilable or not nil 11 | func IsNil(val interface{}) bool { 12 | if !IsNilable(val) { 13 | return false 14 | } 15 | return reflect.ValueOf(val).IsNil() 16 | } 17 | 18 | // IsNilable returns true if the value can be nil 19 | func IsNilable(val interface{}) bool { 20 | v := reflect.ValueOf(val) 21 | switch v.Kind() { 22 | case reflect.Chan: 23 | return true 24 | case reflect.Func: 25 | return true 26 | case reflect.Interface: 27 | return true 28 | case reflect.Map: 29 | return true 30 | case reflect.Ptr: 31 | return true 32 | case reflect.Slice: 33 | return true 34 | default: 35 | return false 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /testing/color.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package testing 7 | 8 | import ( 9 | "os" 10 | "strings" 11 | 12 | "github.com/fatih/color" 13 | ) 14 | 15 | func init() { 16 | if _, ok := os.LookupEnv("COLOR_DIFF"); ok { 17 | DiffAddedColor.EnableColor() 18 | DiffRemovedColor.EnableColor() 19 | } 20 | } 21 | 22 | var ( 23 | DiffAddedColor = color.New(color.FgGreen) 24 | DiffRemovedColor = color.New(color.FgRed) 25 | ) 26 | 27 | func ColorizeDiff(diff string) string { 28 | var b strings.Builder 29 | for _, line := range strings.Split(diff, "\n") { 30 | switch { 31 | case strings.HasPrefix(line, "+"): 32 | b.WriteString(DiffAddedColor.Sprint(line)) 33 | case strings.HasPrefix(line, "-"): 34 | b.WriteString(DiffRemovedColor.Sprint(line)) 35 | default: 36 | b.WriteString(line) 37 | } 38 | b.WriteString("\n") 39 | } 40 | return b.String() 41 | } 42 | -------------------------------------------------------------------------------- /apis/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | /* 4 | Copyright 2020 VMware, Inc. 5 | SPDX-License-Identifier: Apache-2.0 6 | */ 7 | 8 | // Code generated by controller-gen. DO NOT EDIT. 9 | 10 | package apis 11 | 12 | import ( 13 | "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | ) 15 | 16 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 17 | func (in *Status) DeepCopyInto(out *Status) { 18 | *out = *in 19 | if in.Conditions != nil { 20 | in, out := &in.Conditions, &out.Conditions 21 | *out = make([]v1.Condition, len(*in)) 22 | for i := range *in { 23 | (*in)[i].DeepCopyInto(&(*out)[i]) 24 | } 25 | } 26 | } 27 | 28 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Status. 29 | func (in *Status) DeepCopy() *Status { 30 | if in == nil { 31 | return nil 32 | } 33 | out := new(Status) 34 | in.DeepCopyInto(out) 35 | return out 36 | } 37 | -------------------------------------------------------------------------------- /.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@v5 18 | with: 19 | go-version: 1.21.x 20 | id: go 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v4 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: Report coverage 36 | run: | 37 | bash <(curl -s https://codecov.io/bash) 38 | env: 39 | CODECOV_TOKEN: 16421b44-8cd3-49d9-a01e-189c5141009e 40 | -------------------------------------------------------------------------------- /reconcilers/util.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "fmt" 10 | "reflect" 11 | 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | ) 14 | 15 | // extractItems returns a typed slice of objects from an object list 16 | func extractItems[T client.Object](list client.ObjectList) []T { 17 | items := []T{} 18 | listValue := reflect.ValueOf(list).Elem() 19 | itemsValue := listValue.FieldByName("Items") 20 | for i := 0; i < itemsValue.Len(); i++ { 21 | itemValue := itemsValue.Index(i) 22 | var item T 23 | switch itemValue.Kind() { 24 | case reflect.Pointer: 25 | item = itemValue.Interface().(T) 26 | case reflect.Interface: 27 | item = itemValue.Interface().(T) 28 | case reflect.Struct: 29 | item = itemValue.Addr().Interface().(T) 30 | default: 31 | panic(fmt.Errorf("unknown type %s for Items slice, expected Pointer or Struct", itemValue.Kind().String())) 32 | } 33 | items = append(items, item) 34 | } 35 | return items 36 | } 37 | -------------------------------------------------------------------------------- /internal/resources/resource_without_status.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package resources 7 | 8 | import ( 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "sigs.k8s.io/controller-runtime/pkg/webhook" 11 | ) 12 | 13 | var _ webhook.Defaulter = &TestResourceNoStatus{} 14 | 15 | // +kubebuilder:object:root=true 16 | // +genclient 17 | 18 | type TestResourceNoStatus struct { 19 | metav1.TypeMeta `json:",inline"` 20 | metav1.ObjectMeta `json:"metadata,omitempty"` 21 | 22 | Spec TestResourceSpec `json:"spec"` 23 | } 24 | 25 | func (r *TestResourceNoStatus) Default() { 26 | if r.Spec.Fields == nil { 27 | r.Spec.Fields = map[string]string{} 28 | } 29 | r.Spec.Fields["Defaulter"] = "ran" 30 | } 31 | 32 | // +kubebuilder:object:root=true 33 | 34 | type TestResourceNoStatusList struct { 35 | metav1.TypeMeta `json:",inline"` 36 | metav1.ListMeta `json:"metadata"` 37 | 38 | Items []TestResourceNoStatus `json:"items"` 39 | } 40 | 41 | func init() { 42 | SchemeBuilder.Register(&TestResourceNoStatus{}, &TestResourceNoStatusList{}) 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 | -------------------------------------------------------------------------------- /reconcilers/stash.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | ) 12 | 13 | const stashNonce string = "controller-stash-nonce" 14 | 15 | type stashMap map[StashKey]interface{} 16 | 17 | func WithStash(ctx context.Context) context.Context { 18 | return context.WithValue(ctx, stashNonce, stashMap{}) 19 | } 20 | 21 | type StashKey string 22 | 23 | func retrieveStashMap(ctx context.Context) stashMap { 24 | stash, ok := ctx.Value(stashNonce).(stashMap) 25 | if !ok { 26 | panic(fmt.Errorf("context not configured for stashing, call `ctx = WithStash(ctx)`")) 27 | } 28 | return stash 29 | } 30 | 31 | func StashValue(ctx context.Context, key StashKey, value interface{}) { 32 | stash := retrieveStashMap(ctx) 33 | stash[key] = value 34 | } 35 | 36 | func RetrieveValue(ctx context.Context, key StashKey) interface{} { 37 | stash := retrieveStashMap(ctx) 38 | return stash[key] 39 | } 40 | 41 | func ClearValue(ctx context.Context, key StashKey) interface{} { 42 | stash := retrieveStashMap(ctx) 43 | value := stash[key] 44 | delete(stash, key) 45 | return value 46 | } 47 | -------------------------------------------------------------------------------- /internal/resources/resource_with_empty_status.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package resources 7 | 8 | import ( 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "sigs.k8s.io/controller-runtime/pkg/webhook" 11 | ) 12 | 13 | var _ webhook.Defaulter = &TestResourceEmptyStatus{} 14 | 15 | // +kubebuilder:object:root=true 16 | // +genclient 17 | 18 | type TestResourceEmptyStatus struct { 19 | metav1.TypeMeta `json:",inline"` 20 | metav1.ObjectMeta `json:"metadata,omitempty"` 21 | 22 | Spec TestResourceSpec `json:"spec"` 23 | Status TestResourceEmptyStatusStatus `json:"status"` 24 | } 25 | 26 | func (r *TestResourceEmptyStatus) Default() { 27 | if r.Spec.Fields == nil { 28 | r.Spec.Fields = map[string]string{} 29 | } 30 | r.Spec.Fields["Defaulter"] = "ran" 31 | } 32 | 33 | // +kubebuilder:object:generate=true 34 | type TestResourceEmptyStatusStatus struct { 35 | } 36 | 37 | // +kubebuilder:object:root=true 38 | 39 | type TestResourceEmptyStatusList struct { 40 | metav1.TypeMeta `json:",inline"` 41 | metav1.ListMeta `json:"metadata"` 42 | 43 | Items []TestResourceNoStatus `json:"items"` 44 | } 45 | 46 | func init() { 47 | SchemeBuilder.Register(&TestResourceEmptyStatus{}, &TestResourceEmptyStatusList{}) 48 | } 49 | -------------------------------------------------------------------------------- /reconcilers/sequence.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | 12 | "github.com/go-logr/logr" 13 | ctrl "sigs.k8s.io/controller-runtime" 14 | "sigs.k8s.io/controller-runtime/pkg/builder" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | ) 17 | 18 | var ( 19 | _ SubReconciler[client.Object] = (Sequence[client.Object])(nil) 20 | ) 21 | 22 | // Sequence is a collection of SubReconcilers called in order. If a 23 | // reconciler errs, further reconcilers are skipped. 24 | type Sequence[Type client.Object] []SubReconciler[Type] 25 | 26 | func (r Sequence[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 27 | for i, reconciler := range r { 28 | log := logr.FromContextOrDiscard(ctx). 29 | WithName(fmt.Sprintf("%d", i)) 30 | ctx = logr.NewContext(ctx, log) 31 | 32 | err := reconciler.SetupWithManager(ctx, mgr, bldr) 33 | if err != nil { 34 | return err 35 | } 36 | } 37 | return nil 38 | } 39 | 40 | func (r Sequence[T]) Reconcile(ctx context.Context, resource T) (Result, error) { 41 | aggregateResult := Result{} 42 | for i, reconciler := range r { 43 | log := logr.FromContextOrDiscard(ctx). 44 | WithName(fmt.Sprintf("%d", i)) 45 | ctx := logr.NewContext(ctx, log) 46 | 47 | result, err := reconciler.Reconcile(ctx, resource) 48 | aggregateResult = AggregateResults(result, aggregateResult) 49 | if err != nil { 50 | return result, err 51 | } 52 | } 53 | 54 | return aggregateResult, nil 55 | } 56 | -------------------------------------------------------------------------------- /hack/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vmware-labs/reconciler-runtime/hack 2 | 3 | go 1.21 4 | 5 | toolchain go1.21.6 6 | 7 | require ( 8 | dies.dev/diegen v0.10.1 9 | sigs.k8s.io/controller-tools v0.14.0 10 | ) 11 | 12 | require ( 13 | github.com/fatih/color v1.16.0 // indirect 14 | github.com/go-logr/logr v1.4.1 // indirect 15 | github.com/gobuffalo/flect v1.0.2 // indirect 16 | github.com/gogo/protobuf v1.3.2 // indirect 17 | github.com/google/gofuzz v1.2.0 // indirect 18 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 19 | github.com/json-iterator/go v1.1.12 // indirect 20 | github.com/mattn/go-colorable v0.1.13 // indirect 21 | github.com/mattn/go-isatty v0.0.20 // indirect 22 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 23 | github.com/modern-go/reflect2 v1.0.2 // indirect 24 | github.com/spf13/cobra v1.8.0 // indirect 25 | github.com/spf13/pflag v1.0.5 // indirect 26 | golang.org/x/mod v0.14.0 // indirect 27 | golang.org/x/net v0.20.0 // indirect 28 | golang.org/x/sys v0.16.0 // indirect 29 | golang.org/x/text v0.14.0 // indirect 30 | golang.org/x/tools v0.17.0 // indirect 31 | gopkg.in/inf.v0 v0.9.1 // indirect 32 | gopkg.in/yaml.v2 v2.4.0 // indirect 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | k8s.io/api v0.29.1 // indirect 35 | k8s.io/apiextensions-apiserver v0.29.1 // indirect 36 | k8s.io/apimachinery v0.29.1 // indirect 37 | k8s.io/klog/v2 v2.120.1 // indirect 38 | k8s.io/utils v0.0.0-20240102154912-e7106e64919e // indirect 39 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 40 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 41 | sigs.k8s.io/yaml v1.4.0 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /internal/resources/resource_list_with_interface_items.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package resources 7 | 8 | import ( 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | runtime "k8s.io/apimachinery/pkg/runtime" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | ) 13 | 14 | type TestResourceInterfaceList struct { 15 | metav1.TypeMeta `json:",inline"` 16 | metav1.ListMeta `json:"metadata"` 17 | 18 | Items []client.Object `json:"items"` 19 | } 20 | 21 | // Directly host DeepCopy methods since controller-gen cannot handle the interface 22 | 23 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 24 | func (in *TestResourceInterfaceList) DeepCopyInto(out *TestResourceInterfaceList) { 25 | *out = *in 26 | out.TypeMeta = in.TypeMeta 27 | in.ListMeta.DeepCopyInto(&out.ListMeta) 28 | if in.Items != nil { 29 | in, out := &in.Items, &out.Items 30 | *out = make([]client.Object, len(*in)) 31 | for i := range *in { 32 | (*out)[i] = (*in)[i].DeepCopyObject().(client.Object) 33 | } 34 | } 35 | } 36 | 37 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceInterfaceList. 38 | func (in *TestResourceInterfaceList) DeepCopy() *TestResourceInterfaceList { 39 | if in == nil { 40 | return nil 41 | } 42 | out := new(TestResourceInterfaceList) 43 | in.DeepCopyInto(out) 44 | return out 45 | } 46 | 47 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 48 | func (in *TestResourceInterfaceList) DeepCopyObject() runtime.Object { 49 | if c := in.DeepCopy(); c != nil { 50 | return c 51 | } 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /internal/resources/resource_duck.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package resources 7 | 8 | import ( 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | runtime "k8s.io/apimachinery/pkg/runtime" 11 | "k8s.io/apimachinery/pkg/util/validation/field" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 14 | ) 15 | 16 | var ( 17 | _ client.Object = &TestDuck{} 18 | ) 19 | 20 | // +kubebuilder:object:root=true 21 | // +genclient 22 | 23 | type TestDuck struct { 24 | metav1.TypeMeta `json:",inline"` 25 | metav1.ObjectMeta `json:"metadata,omitempty"` 26 | 27 | Spec TestDuckSpec `json:"spec"` 28 | Status TestResourceStatus `json:"status"` 29 | } 30 | 31 | func (r *TestDuck) Default() { 32 | if r.Spec.Fields == nil { 33 | r.Spec.Fields = map[string]string{} 34 | } 35 | r.Spec.Fields["Defaulter"] = "ran" 36 | } 37 | 38 | func (r *TestDuck) ValidateCreate() (admission.Warnings, error) { 39 | return nil, r.validate().ToAggregate() 40 | } 41 | 42 | func (r *TestDuck) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { 43 | return nil, r.validate().ToAggregate() 44 | } 45 | 46 | func (r *TestDuck) ValidateDelete() (admission.Warnings, error) { 47 | return nil, nil 48 | } 49 | 50 | func (r *TestDuck) validate() field.ErrorList { 51 | errs := field.ErrorList{} 52 | 53 | if r.Spec.Fields != nil { 54 | if _, ok := r.Spec.Fields["invalid"]; ok { 55 | field.Invalid(field.NewPath("spec", "fields", "invalid"), r.Spec.Fields["invalid"], "") 56 | } 57 | } 58 | 59 | return errs 60 | } 61 | 62 | // +kubebuilder:object:generate=true 63 | type TestDuckSpec struct { 64 | Fields map[string]string `json:"fields,omitempty"` 65 | } 66 | 67 | // +kubebuilder:object:root=true 68 | 69 | type TestDuckList struct { 70 | metav1.TypeMeta `json:",inline"` 71 | metav1.ListMeta `json:"metadata"` 72 | 73 | Items []TestDuck `json:"items"` 74 | } 75 | -------------------------------------------------------------------------------- /reconcilers/stash_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "testing" 10 | 11 | "golang.org/x/net/context" 12 | "k8s.io/apimachinery/pkg/api/equality" 13 | ) 14 | 15 | func TestStash(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | value interface{} 19 | }{ 20 | { 21 | name: "string", 22 | value: "value", 23 | }, 24 | { 25 | name: "int", 26 | value: 42, 27 | }, 28 | { 29 | name: "map", 30 | value: map[string]string{"foo": "bar"}, 31 | }, 32 | { 33 | name: "nil", 34 | value: nil, 35 | }, 36 | } 37 | 38 | var key StashKey = "stash-key" 39 | ctx := WithStash(context.TODO()) 40 | for _, c := range tests { 41 | t.Run(c.name, func(t *testing.T) { 42 | StashValue(ctx, key, c.value) 43 | if expected, actual := c.value, RetrieveValue(ctx, key); !equality.Semantic.DeepEqual(expected, actual) { 44 | t.Errorf("%s: unexpected stash value, actually = %v, expected = %v", c.name, actual, expected) 45 | } 46 | }) 47 | } 48 | } 49 | 50 | func TestStash_StashValue_UndecoratedContext(t *testing.T) { 51 | ctx := context.TODO() 52 | var key StashKey = "stash-key" 53 | value := "value" 54 | 55 | defer func() { 56 | if r := recover(); r == nil { 57 | t.Error("expected StashValue() to panic") 58 | } 59 | }() 60 | StashValue(ctx, key, value) 61 | } 62 | 63 | func TestStash_RetrieveValue_UndecoratedContext(t *testing.T) { 64 | ctx := context.TODO() 65 | var key StashKey = "stash-key" 66 | 67 | defer func() { 68 | if r := recover(); r == nil { 69 | t.Error("expected RetrieveValue() to panic") 70 | } 71 | }() 72 | RetrieveValue(ctx, key) 73 | } 74 | 75 | func TestStash_RetrieveValue_Undefined(t *testing.T) { 76 | ctx := WithStash(context.TODO()) 77 | var key StashKey = "stash-key" 78 | 79 | if value := RetrieveValue(ctx, key); value != nil { 80 | t.Error("expected RetrieveValue() to return nil for undefined key") 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /apis/conditionset_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package apis 7 | 8 | import ( 9 | "testing" 10 | 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | ) 13 | 14 | func TestConditionStatus(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | condition *metav1.Condition 18 | expectedTrue bool 19 | expectedFalse bool 20 | expectedUnknown bool 21 | }{ 22 | { 23 | name: "true", 24 | condition: &metav1.Condition{Status: metav1.ConditionTrue}, 25 | expectedTrue: true, 26 | expectedFalse: false, 27 | expectedUnknown: false, 28 | }, 29 | { 30 | name: "false", 31 | condition: &metav1.Condition{Status: metav1.ConditionFalse}, 32 | expectedTrue: false, 33 | expectedFalse: true, 34 | expectedUnknown: false, 35 | }, 36 | { 37 | name: "unknown", 38 | condition: &metav1.Condition{Status: metav1.ConditionUnknown}, 39 | expectedTrue: false, 40 | expectedFalse: false, 41 | expectedUnknown: true, 42 | }, 43 | { 44 | name: "unset", 45 | condition: &metav1.Condition{}, 46 | expectedTrue: false, 47 | expectedFalse: false, 48 | expectedUnknown: true, 49 | }, 50 | { 51 | name: "nil", 52 | condition: nil, 53 | expectedTrue: false, 54 | expectedFalse: false, 55 | expectedUnknown: true, 56 | }, 57 | } 58 | for _, c := range tests { 59 | t.Run(c.name, func(t *testing.T) { 60 | if expected, actual := c.expectedTrue, ConditionIsTrue(c.condition); expected != actual { 61 | t.Errorf("%s: IsTrue() actually = %v, expected %v", c.name, actual, expected) 62 | } 63 | if expected, actual := c.expectedFalse, ConditionIsFalse(c.condition); expected != actual { 64 | t.Errorf("%s: IsFalse() actually = %v, expected %v", c.name, actual, expected) 65 | } 66 | if expected, actual := c.expectedUnknown, ConditionIsUnknown(c.condition); expected != actual { 67 | t.Errorf("%s: IsUnknown() actually = %v, expected %v", c.name, actual, expected) 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /testing/recorder.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package testing 7 | 8 | import ( 9 | "fmt" 10 | 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | "k8s.io/apimachinery/pkg/types" 14 | "k8s.io/client-go/tools/record" 15 | ref "k8s.io/client-go/tools/reference" 16 | "sigs.k8s.io/controller-runtime/pkg/client" 17 | ) 18 | 19 | type Event struct { 20 | metav1.TypeMeta 21 | types.NamespacedName 22 | Type string 23 | Reason string 24 | Message string 25 | } 26 | 27 | func NewEvent(factory client.Object, scheme *runtime.Scheme, eventtype, reason, messageFormat string, a ...interface{}) Event { 28 | obj := factory.DeepCopyObject() 29 | objref, err := ref.GetReference(scheme, obj) 30 | if err != nil { 31 | 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...))) 32 | } 33 | 34 | return Event{ 35 | TypeMeta: metav1.TypeMeta{ 36 | APIVersion: objref.APIVersion, 37 | Kind: objref.Kind, 38 | }, 39 | NamespacedName: types.NamespacedName{ 40 | Namespace: objref.Namespace, 41 | Name: objref.Name, 42 | }, 43 | Type: eventtype, 44 | Reason: reason, 45 | Message: fmt.Sprintf(messageFormat, a...), 46 | } 47 | } 48 | 49 | type eventRecorder struct { 50 | events []Event 51 | scheme *runtime.Scheme 52 | } 53 | 54 | var ( 55 | _ record.EventRecorder = (*eventRecorder)(nil) 56 | ) 57 | 58 | func (r *eventRecorder) Event(object runtime.Object, eventtype, reason, message string) { 59 | r.Eventf(object, eventtype, reason, message) 60 | } 61 | 62 | func (r *eventRecorder) Eventf(object runtime.Object, eventtype, reason, messageFmt string, args ...interface{}) { 63 | r.events = append(r.events, NewEvent(object.(client.Object), r.scheme, eventtype, reason, messageFmt, args...)) 64 | } 65 | 66 | func (r *eventRecorder) AnnotatedEventf(object runtime.Object, annotations map[string]string, eventtype, reason, messageFmt string, args ...interface{}) { 67 | r.Eventf(object, eventtype, reason, messageFmt, args...) 68 | } 69 | -------------------------------------------------------------------------------- /internal/resources/resource_with_nilable_status.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package resources 7 | 8 | import ( 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | runtime "k8s.io/apimachinery/pkg/runtime" 11 | "k8s.io/apimachinery/pkg/util/validation/field" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | "sigs.k8s.io/controller-runtime/pkg/webhook" 14 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 15 | ) 16 | 17 | var ( 18 | _ webhook.Defaulter = &TestResourceNilableStatus{} 19 | _ webhook.Validator = &TestResourceNilableStatus{} 20 | _ client.Object = &TestResourceNilableStatus{} 21 | ) 22 | 23 | // +kubebuilder:object:root=true 24 | // +genclient 25 | 26 | type TestResourceNilableStatus struct { 27 | metav1.TypeMeta `json:",inline"` 28 | metav1.ObjectMeta `json:"metadata,omitempty"` 29 | 30 | Spec TestResourceSpec `json:"spec"` 31 | Status *TestResourceStatus `json:"status"` 32 | } 33 | 34 | func (r *TestResourceNilableStatus) Default() { 35 | if r.Spec.Fields == nil { 36 | r.Spec.Fields = map[string]string{} 37 | } 38 | r.Spec.Fields["Defaulter"] = "ran" 39 | } 40 | 41 | func (r *TestResourceNilableStatus) ValidateCreate() (admission.Warnings, error) { 42 | return nil, r.validate().ToAggregate() 43 | } 44 | 45 | func (r *TestResourceNilableStatus) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { 46 | return nil, r.validate().ToAggregate() 47 | } 48 | 49 | func (r *TestResourceNilableStatus) ValidateDelete() (admission.Warnings, error) { 50 | return nil, nil 51 | } 52 | 53 | func (r *TestResourceNilableStatus) validate() field.ErrorList { 54 | errs := field.ErrorList{} 55 | 56 | if r.Spec.Fields != nil { 57 | if _, ok := r.Spec.Fields["invalid"]; ok { 58 | field.Invalid(field.NewPath("spec", "fields", "invalid"), r.Spec.Fields["invalid"], "") 59 | } 60 | } 61 | 62 | return errs 63 | } 64 | 65 | // +kubebuilder:object:root=true 66 | 67 | type TestResourceNilableStatusList struct { 68 | metav1.TypeMeta `json:",inline"` 69 | metav1.ListMeta `json:"metadata"` 70 | 71 | Items []TestResourceNilableStatus `json:"items"` 72 | } 73 | 74 | func init() { 75 | SchemeBuilder.Register(&TestResourceNilableStatus{}, &TestResourceNilableStatusList{}) 76 | } 77 | -------------------------------------------------------------------------------- /apis/status_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 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 | Copyright 2019-2020 VMware, Inc. 18 | SPDX-License-Identifier: Apache-2.0 19 | */ 20 | 21 | package apis 22 | 23 | import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | 25 | // Status is the minimally expected status subresource. Use this or provide your own. It also shows how Conditions are 26 | // expected to be embedded in the Status field. 27 | // 28 | // Example: 29 | // type MyResourceStatus struct { 30 | // apis.Status `json:",inline"` 31 | // UsefulMessage string `json:"usefulMessage,omitempty"` 32 | // } 33 | // 34 | // WARNING: Adding fields to this struct will add them to all resources. 35 | // +k8s:deepcopy-gen=true 36 | type Status struct { 37 | // ObservedGeneration is the 'Generation' of the resource that 38 | // was last processed by the controller. 39 | // +optional 40 | ObservedGeneration int64 `json:"observedGeneration,omitempty"` 41 | 42 | // Conditions the latest available observations of a resource's current state. 43 | // +optional 44 | // +patchMergeKey=type 45 | // +patchStrategy=merge 46 | Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` 47 | } 48 | 49 | var _ ConditionsAccessor = (*Status)(nil) 50 | 51 | // GetConditions implements ConditionsAccessor 52 | func (s *Status) GetConditions() []metav1.Condition { 53 | return s.Conditions 54 | } 55 | 56 | // SetConditions implements ConditionsAccessor 57 | func (s *Status) SetConditions(c []metav1.Condition) { 58 | s.Conditions = c 59 | } 60 | 61 | // GetCondition fetches the condition of the specified type. 62 | func (s *Status) GetCondition(t string) *metav1.Condition { 63 | for _, cond := range s.Conditions { 64 | if cond.Type == t { 65 | return &cond 66 | } 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /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` (Reconciler-Runtime's [testing](../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 | ``` -------------------------------------------------------------------------------- /reconcilers/cmp_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers_test 7 | 8 | import ( 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/vmware-labs/reconciler-runtime/internal/resources" 13 | "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" 14 | "github.com/vmware-labs/reconciler-runtime/reconcilers" 15 | ) 16 | 17 | type TestResourceUnexportedSpec struct { 18 | spec resources.TestResourceUnexportedFieldsSpec 19 | } 20 | 21 | func TestIgnoreAllUnexported(t *testing.T) { 22 | tests := map[string]struct { 23 | a interface{} 24 | b interface{} 25 | shouldDiff bool 26 | }{ 27 | "nil is equivalent": { 28 | a: nil, 29 | b: nil, 30 | shouldDiff: false, 31 | }, 32 | "different exported fields have a difference": { 33 | a: dies.TestResourceUnexportedFieldsSpecBlank. 34 | AddField("name", "hello"). 35 | DieRelease(), 36 | b: dies.TestResourceUnexportedFieldsSpecBlank. 37 | AddField("name", "world"). 38 | DieRelease(), 39 | shouldDiff: true, 40 | }, 41 | "different unexported fields do not have a difference": { 42 | a: dies.TestResourceUnexportedFieldsSpecBlank. 43 | AddUnexportedField("name", "hello"). 44 | DieRelease(), 45 | b: dies.TestResourceUnexportedFieldsSpecBlank. 46 | AddUnexportedField("name", "world"). 47 | DieRelease(), 48 | shouldDiff: false, 49 | }, 50 | "different exported fields nested in an unexported field do not have a difference": { 51 | a: TestResourceUnexportedSpec{ 52 | spec: dies.TestResourceUnexportedFieldsSpecBlank. 53 | AddField("name", "hello"). 54 | DieRelease(), 55 | }, 56 | b: TestResourceUnexportedSpec{ 57 | spec: dies.TestResourceUnexportedFieldsSpecBlank. 58 | AddField("name", "world"). 59 | DieRelease(), 60 | }, 61 | shouldDiff: false, 62 | }, 63 | } 64 | 65 | for name, tc := range tests { 66 | t.Run(name, func(t *testing.T) { 67 | if name[0:1] == "#" { 68 | t.SkipNow() 69 | } 70 | 71 | diff := cmp.Diff(tc.a, tc.b, reconcilers.IgnoreAllUnexported) 72 | hasDiff := diff != "" 73 | shouldDiff := tc.shouldDiff 74 | 75 | if !hasDiff && shouldDiff { 76 | t.Errorf("expected equality, found diff") 77 | } 78 | if hasDiff && !shouldDiff { 79 | t.Errorf("found diff, expected equality: %s", diff) 80 | } 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Contributing to reconciler-runtime 4 | 5 | The reconciler-runtime project team welcomes contributions from the community. If you wish to contribute code and you have not signed our contributor license agreement (CLA), our bot will update the issue when you open a Pull Request. For any questions about the CLA process, please refer to our [FAQ](https://cla.vmware.com/faq). 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/vmware/reconciler-runtime.git 21 | git checkout -b my-new-feature master 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 vmware/master branch, use the following to update: 29 | 30 | ``` shell 31 | git checkout my-new-feature 32 | git fetch -a 33 | git pull --rebase upstream master 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 master 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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vmware-labs/reconciler-runtime 2 | 3 | go 1.21 4 | 5 | toolchain go1.21.6 6 | 7 | require ( 8 | dies.dev v0.10.1 9 | github.com/evanphx/json-patch/v5 v5.9.0 10 | github.com/fatih/color v1.16.0 11 | github.com/go-logr/logr v1.4.1 12 | github.com/google/go-cmp v0.6.0 13 | golang.org/x/net v0.22.0 14 | gomodules.xyz/jsonpatch/v2 v2.4.0 15 | gomodules.xyz/jsonpatch/v3 v3.0.1 16 | k8s.io/api v0.29.2 17 | k8s.io/apimachinery v0.29.2 18 | k8s.io/client-go v0.29.2 19 | k8s.io/utils v0.0.0-20240102154912-e7106e64919e 20 | sigs.k8s.io/controller-runtime v0.17.2 21 | sigs.k8s.io/yaml v1.4.0 22 | ) 23 | 24 | require ( 25 | github.com/beorn7/perks v1.0.1 // indirect 26 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/emicklei/go-restful/v3 v3.11.2 // indirect 29 | github.com/evanphx/json-patch v5.9.0+incompatible // indirect 30 | github.com/fsnotify/fsnotify v1.7.0 // indirect 31 | github.com/go-openapi/jsonpointer v0.20.2 // indirect 32 | github.com/go-openapi/jsonreference v0.20.4 // indirect 33 | github.com/go-openapi/swag v0.22.9 // indirect 34 | github.com/gogo/protobuf v1.3.2 // indirect 35 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 36 | github.com/golang/protobuf v1.5.3 // indirect 37 | github.com/google/gnostic-models v0.6.8 // indirect 38 | github.com/google/gofuzz v1.2.0 // indirect 39 | github.com/google/uuid v1.6.0 // indirect 40 | github.com/imdario/mergo v0.3.16 // indirect 41 | github.com/josharian/intern v1.0.0 // indirect 42 | github.com/json-iterator/go v1.1.12 // indirect 43 | github.com/mailru/easyjson v0.7.7 // indirect 44 | github.com/mattn/go-colorable v0.1.13 // indirect 45 | github.com/mattn/go-isatty v0.0.20 // indirect 46 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 47 | github.com/modern-go/reflect2 v1.0.2 // indirect 48 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 49 | github.com/pkg/errors v0.9.1 // indirect 50 | github.com/prometheus/client_golang v1.18.0 // indirect 51 | github.com/prometheus/client_model v0.5.0 // indirect 52 | github.com/prometheus/common v0.46.0 // indirect 53 | github.com/prometheus/procfs v0.12.0 // indirect 54 | github.com/spf13/pflag v1.0.5 // indirect 55 | golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 // indirect 56 | golang.org/x/oauth2 v0.16.0 // indirect 57 | golang.org/x/sys v0.18.0 // indirect 58 | golang.org/x/term v0.18.0 // indirect 59 | golang.org/x/text v0.14.0 // indirect 60 | golang.org/x/time v0.5.0 // indirect 61 | gomodules.xyz/orderedmap v0.1.0 // indirect 62 | google.golang.org/appengine v1.6.8 // indirect 63 | google.golang.org/protobuf v1.32.0 // indirect 64 | gopkg.in/inf.v0 v0.9.1 // indirect 65 | gopkg.in/yaml.v2 v2.4.0 // indirect 66 | gopkg.in/yaml.v3 v3.0.1 // indirect 67 | k8s.io/apiextensions-apiserver v0.29.1 // indirect 68 | k8s.io/component-base v0.29.1 // indirect 69 | k8s.io/klog/v2 v2.120.1 // indirect 70 | k8s.io/kube-openapi v0.0.0-20240126223410-2919ad4fcfec // indirect 71 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 72 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 73 | ) 74 | -------------------------------------------------------------------------------- /reconcilers/util_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/vmware-labs/reconciler-runtime/internal/resources" 13 | corev1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | ) 16 | 17 | func TestExtractItems(t *testing.T) { 18 | tests := map[string]struct { 19 | list client.ObjectList 20 | expected []*resources.TestResource 21 | shouldPanic bool 22 | }{ 23 | "empty": { 24 | list: &resources.TestResourceList{ 25 | Items: []resources.TestResource{}, 26 | }, 27 | expected: []*resources.TestResource{}, 28 | }, 29 | "struct items": { 30 | list: &resources.TestResourceList{ 31 | Items: []resources.TestResource{ 32 | { 33 | ObjectMeta: corev1.ObjectMeta{ 34 | Name: "obj1", 35 | }, 36 | }, 37 | { 38 | ObjectMeta: corev1.ObjectMeta{ 39 | Name: "obj2", 40 | }, 41 | }, 42 | }, 43 | }, 44 | expected: []*resources.TestResource{ 45 | { 46 | ObjectMeta: corev1.ObjectMeta{ 47 | Name: "obj1", 48 | }, 49 | }, 50 | { 51 | ObjectMeta: corev1.ObjectMeta{ 52 | Name: "obj2", 53 | }, 54 | }, 55 | }, 56 | }, 57 | "struct-pointer items": { 58 | list: &resources.TestResourcePointerList{ 59 | Items: []*resources.TestResource{ 60 | { 61 | ObjectMeta: corev1.ObjectMeta{ 62 | Name: "obj1", 63 | }, 64 | }, 65 | { 66 | ObjectMeta: corev1.ObjectMeta{ 67 | Name: "obj2", 68 | }, 69 | }, 70 | }, 71 | }, 72 | expected: []*resources.TestResource{ 73 | { 74 | ObjectMeta: corev1.ObjectMeta{ 75 | Name: "obj1", 76 | }, 77 | }, 78 | { 79 | ObjectMeta: corev1.ObjectMeta{ 80 | Name: "obj2", 81 | }, 82 | }, 83 | }, 84 | }, 85 | "interface items": { 86 | list: &resources.TestResourceInterfaceList{ 87 | Items: []client.Object{ 88 | &resources.TestResource{ 89 | ObjectMeta: corev1.ObjectMeta{ 90 | Name: "obj1", 91 | }, 92 | }, 93 | &resources.TestResource{ 94 | ObjectMeta: corev1.ObjectMeta{ 95 | Name: "obj2", 96 | }, 97 | }, 98 | }, 99 | }, 100 | expected: []*resources.TestResource{ 101 | { 102 | ObjectMeta: corev1.ObjectMeta{ 103 | Name: "obj1", 104 | }, 105 | }, 106 | { 107 | ObjectMeta: corev1.ObjectMeta{ 108 | Name: "obj2", 109 | }, 110 | }, 111 | }, 112 | }, 113 | "invalid items": { 114 | list: &resources.TestResourceInvalidList{ 115 | Items: []string{ 116 | "boom", 117 | }, 118 | }, 119 | shouldPanic: true, 120 | }, 121 | } 122 | 123 | for name, tc := range tests { 124 | t.Run(name, func(t *testing.T) { 125 | defer func() { 126 | if r := recover(); r != nil { 127 | if !tc.shouldPanic { 128 | t.Errorf("unexpected panic: %s", r) 129 | } 130 | } 131 | }() 132 | actual := extractItems[*resources.TestResource](tc.list) 133 | if tc.shouldPanic { 134 | t.Errorf("expected to panic") 135 | } 136 | expected := tc.expected 137 | if diff := cmp.Diff(expected, actual); diff != "" { 138 | t.Errorf("expected items to match actual items: %s", diff) 139 | } 140 | }) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /reconcilers/resourcemanager_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers_test 7 | 8 | import ( 9 | "encoding/json" 10 | "fmt" 11 | "testing" 12 | 13 | "github.com/google/go-cmp/cmp" 14 | "github.com/vmware-labs/reconciler-runtime/reconcilers" 15 | corev1 "k8s.io/api/core/v1" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/apimachinery/pkg/runtime" 18 | "k8s.io/apimachinery/pkg/runtime/schema" 19 | "sigs.k8s.io/controller-runtime/pkg/client" 20 | ) 21 | 22 | func TestPatch(t *testing.T) { 23 | tests := []struct { 24 | name string 25 | base client.Object 26 | update client.Object 27 | rebase client.Object 28 | expected client.Object 29 | newShouldErr bool 30 | applyShouldErr bool 31 | }{ 32 | { 33 | name: "identity", 34 | base: &corev1.Pod{}, 35 | update: &corev1.Pod{}, 36 | rebase: &corev1.Pod{}, 37 | expected: &corev1.Pod{}, 38 | }, 39 | { 40 | name: "rebase", 41 | base: &corev1.Pod{}, 42 | update: &corev1.Pod{ 43 | Spec: corev1.PodSpec{ 44 | DNSPolicy: corev1.DNSClusterFirst, 45 | }, 46 | }, 47 | rebase: &corev1.Pod{ 48 | Spec: corev1.PodSpec{ 49 | DNSConfig: &corev1.PodDNSConfig{ 50 | Nameservers: []string{"1.1.1.1"}, 51 | }, 52 | }, 53 | }, 54 | expected: &corev1.Pod{ 55 | Spec: corev1.PodSpec{ 56 | DNSConfig: &corev1.PodDNSConfig{ 57 | Nameservers: []string{"1.1.1.1"}, 58 | }, 59 | DNSPolicy: corev1.DNSClusterFirst, 60 | }, 61 | }, 62 | }, 63 | { 64 | name: "bad base", 65 | base: &boom{ShouldErr: true}, 66 | update: &boom{}, 67 | newShouldErr: true, 68 | }, 69 | { 70 | name: "bad update", 71 | base: &boom{}, 72 | update: &boom{ShouldErr: true}, 73 | newShouldErr: true, 74 | }, 75 | { 76 | name: "bad rebase", 77 | base: &boom{}, 78 | update: &boom{}, 79 | rebase: &boom{ShouldErr: true}, 80 | applyShouldErr: true, 81 | }, 82 | { 83 | name: "generation mismatch", 84 | base: &corev1.Pod{}, 85 | update: &corev1.Pod{}, 86 | rebase: &corev1.Pod{ 87 | ObjectMeta: metav1.ObjectMeta{ 88 | Generation: 1, 89 | }, 90 | }, 91 | expected: &corev1.Pod{}, 92 | applyShouldErr: true, 93 | }, 94 | } 95 | 96 | for _, c := range tests { 97 | t.Run(c.name, func(t *testing.T) { 98 | patch, newErr := reconcilers.NewPatch(c.base, c.update) 99 | if actual, expected := newErr != nil, c.newShouldErr; actual != expected { 100 | t.Errorf("%s: unexpected new error, actually = %v, expected = %v", c.name, actual, expected) 101 | } 102 | if c.newShouldErr { 103 | return 104 | } 105 | 106 | applyErr := patch.Apply(c.rebase) 107 | if actual, expected := applyErr != nil, c.applyShouldErr; actual != expected { 108 | t.Errorf("%s: unexpected apply error, actually = %v, expected = %v", c.name, actual, expected) 109 | } 110 | if c.applyShouldErr { 111 | return 112 | } 113 | 114 | if diff := cmp.Diff(c.expected, c.rebase); diff != "" { 115 | t.Errorf("%s: unexpected value (-expected, +actual): %s", c.name, diff) 116 | } 117 | }) 118 | } 119 | } 120 | 121 | type boom struct { 122 | metav1.ObjectMeta `json:"metadata"` 123 | ShouldErr bool `json:"shouldErr"` 124 | } 125 | 126 | func (b *boom) MarshalJSON() ([]byte, error) { 127 | if b.ShouldErr { 128 | return nil, fmt.Errorf("object asked to err") 129 | } 130 | return json.Marshal(b.ObjectMeta) 131 | } 132 | 133 | func (b *boom) UnmarshalJSON(data []byte) error { 134 | if b.ShouldErr { 135 | return fmt.Errorf("object asked to err") 136 | } 137 | return json.Unmarshal(data, b.ObjectMeta) 138 | } 139 | 140 | func (b *boom) GetObjectKind() schema.ObjectKind { 141 | return schema.EmptyObjectKind 142 | } 143 | 144 | func (b *boom) DeepCopyObject() runtime.Object { 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /internal/resources/dies/zz_generated.die_test.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | // +build !ignore_autogenerated 3 | 4 | /* 5 | Copyright 2020 VMware, Inc. 6 | SPDX-License-Identifier: Apache-2.0 7 | */ 8 | 9 | // Code generated by diegen. DO NOT EDIT. 10 | 11 | package dies 12 | 13 | import ( 14 | testing "dies.dev/testing" 15 | testingx "testing" 16 | ) 17 | 18 | func TestTestResourceDie_MissingMethods(t *testingx.T) { 19 | die := TestResourceBlank 20 | ignore := []string{"TypeMeta", "ObjectMeta"} 21 | diff := testing.DieFieldDiff(die).Delete(ignore...) 22 | if diff.Len() != 0 { 23 | t.Errorf("found missing fields for TestResourceDie: %s", diff.List()) 24 | } 25 | } 26 | 27 | func TestTestResourceSpecDie_MissingMethods(t *testingx.T) { 28 | die := TestResourceSpecBlank 29 | ignore := []string{} 30 | diff := testing.DieFieldDiff(die).Delete(ignore...) 31 | if diff.Len() != 0 { 32 | t.Errorf("found missing fields for TestResourceSpecDie: %s", diff.List()) 33 | } 34 | } 35 | 36 | func TestTestResourceStatusDie_MissingMethods(t *testingx.T) { 37 | die := TestResourceStatusBlank 38 | ignore := []string{} 39 | diff := testing.DieFieldDiff(die).Delete(ignore...) 40 | if diff.Len() != 0 { 41 | t.Errorf("found missing fields for TestResourceStatusDie: %s", diff.List()) 42 | } 43 | } 44 | 45 | func TestTestResourceEmptyStatusDie_MissingMethods(t *testingx.T) { 46 | die := TestResourceEmptyStatusBlank 47 | ignore := []string{"TypeMeta", "ObjectMeta"} 48 | diff := testing.DieFieldDiff(die).Delete(ignore...) 49 | if diff.Len() != 0 { 50 | t.Errorf("found missing fields for TestResourceEmptyStatusDie: %s", diff.List()) 51 | } 52 | } 53 | 54 | func TestTestResourceEmptyStatusStatusDie_MissingMethods(t *testingx.T) { 55 | die := TestResourceEmptyStatusStatusBlank 56 | ignore := []string{} 57 | diff := testing.DieFieldDiff(die).Delete(ignore...) 58 | if diff.Len() != 0 { 59 | t.Errorf("found missing fields for TestResourceEmptyStatusStatusDie: %s", diff.List()) 60 | } 61 | } 62 | 63 | func TestTestResourceNoStatusDie_MissingMethods(t *testingx.T) { 64 | die := TestResourceNoStatusBlank 65 | ignore := []string{"TypeMeta", "ObjectMeta"} 66 | diff := testing.DieFieldDiff(die).Delete(ignore...) 67 | if diff.Len() != 0 { 68 | t.Errorf("found missing fields for TestResourceNoStatusDie: %s", diff.List()) 69 | } 70 | } 71 | 72 | func TestTestResourceNilableStatusDie_MissingMethods(t *testingx.T) { 73 | die := TestResourceNilableStatusBlank 74 | ignore := []string{"TypeMeta", "ObjectMeta"} 75 | diff := testing.DieFieldDiff(die).Delete(ignore...) 76 | if diff.Len() != 0 { 77 | t.Errorf("found missing fields for TestResourceNilableStatusDie: %s", diff.List()) 78 | } 79 | } 80 | 81 | func TestTestDuckDie_MissingMethods(t *testingx.T) { 82 | die := TestDuckBlank 83 | ignore := []string{"TypeMeta", "ObjectMeta"} 84 | diff := testing.DieFieldDiff(die).Delete(ignore...) 85 | if diff.Len() != 0 { 86 | t.Errorf("found missing fields for TestDuckDie: %s", diff.List()) 87 | } 88 | } 89 | 90 | func TestTestDuckSpecDie_MissingMethods(t *testingx.T) { 91 | die := TestDuckSpecBlank 92 | ignore := []string{} 93 | diff := testing.DieFieldDiff(die).Delete(ignore...) 94 | if diff.Len() != 0 { 95 | t.Errorf("found missing fields for TestDuckSpecDie: %s", diff.List()) 96 | } 97 | } 98 | 99 | func TestTestResourceUnexportedFieldsDie_MissingMethods(t *testingx.T) { 100 | die := TestResourceUnexportedFieldsBlank 101 | ignore := []string{"TypeMeta", "ObjectMeta"} 102 | diff := testing.DieFieldDiff(die).Delete(ignore...) 103 | if diff.Len() != 0 { 104 | t.Errorf("found missing fields for TestResourceUnexportedFieldsDie: %s", diff.List()) 105 | } 106 | } 107 | 108 | func TestTestResourceUnexportedFieldsSpecDie_MissingMethods(t *testingx.T) { 109 | die := TestResourceUnexportedFieldsSpecBlank 110 | ignore := []string{"unexportedFields"} 111 | diff := testing.DieFieldDiff(die).Delete(ignore...) 112 | if diff.Len() != 0 { 113 | t.Errorf("found missing fields for TestResourceUnexportedFieldsSpecDie: %s", diff.List()) 114 | } 115 | } 116 | 117 | func TestTestResourceUnexportedFieldsStatusDie_MissingMethods(t *testingx.T) { 118 | die := TestResourceUnexportedFieldsStatusBlank 119 | ignore := []string{"unexportedFields"} 120 | diff := testing.DieFieldDiff(die).Delete(ignore...) 121 | if diff.Len() != 0 { 122 | t.Errorf("found missing fields for TestResourceUnexportedFieldsStatusDie: %s", diff.List()) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /testing/tracker.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package testing 7 | 8 | import ( 9 | "time" 10 | 11 | "github.com/vmware-labs/reconciler-runtime/tracker" 12 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | "k8s.io/apimachinery/pkg/runtime/schema" 15 | "k8s.io/apimachinery/pkg/types" 16 | "k8s.io/client-go/tools/reference" 17 | "sigs.k8s.io/controller-runtime/pkg/client" 18 | ) 19 | 20 | // TrackRequest records that one object is tracking another object. 21 | type TrackRequest struct { 22 | // Tracker is the object doing the tracking 23 | Tracker types.NamespacedName 24 | 25 | // Deprecated use TrackedReference 26 | // Tracked is the object being tracked 27 | Tracked tracker.Key 28 | 29 | // TrackedReference is a ref to the object being tracked 30 | TrackedReference tracker.Reference 31 | } 32 | 33 | func (tr *TrackRequest) normalize() { 34 | if tr.TrackedReference != (tracker.Reference{}) { 35 | return 36 | } 37 | tr.TrackedReference = tracker.Reference{ 38 | APIGroup: tr.Tracked.GroupKind.Group, 39 | Kind: tr.Tracked.GroupKind.Kind, 40 | Namespace: tr.Tracked.NamespacedName.Namespace, 41 | Name: tr.Tracked.NamespacedName.Name, 42 | } 43 | tr.Tracked = tracker.Key{} 44 | } 45 | 46 | type trackBy func(trackingObjNamespace, trackingObjName string) TrackRequest 47 | 48 | func (t trackBy) By(trackingObjNamespace, trackingObjName string) TrackRequest { 49 | return t(trackingObjNamespace, trackingObjName) 50 | } 51 | 52 | func CreateTrackRequest(trackedObjGroup, trackedObjKind, trackedObjNamespace, trackedObjName string) trackBy { 53 | return func(trackingObjNamespace, trackingObjName string) TrackRequest { 54 | return TrackRequest{ 55 | TrackedReference: tracker.Reference{ 56 | APIGroup: trackedObjGroup, 57 | Kind: trackedObjKind, 58 | Namespace: trackedObjNamespace, 59 | Name: trackedObjName, 60 | }, 61 | Tracker: types.NamespacedName{Namespace: trackingObjNamespace, Name: trackingObjName}, 62 | } 63 | } 64 | } 65 | 66 | func NewTrackRequest(t, b client.Object, scheme *runtime.Scheme) TrackRequest { 67 | tracked, by := t.DeepCopyObject().(client.Object), b.DeepCopyObject().(client.Object) 68 | gvks, _, err := scheme.ObjectKinds(tracked) 69 | if err != nil { 70 | panic(err) 71 | } 72 | return TrackRequest{ 73 | TrackedReference: tracker.Reference{ 74 | APIGroup: gvks[0].Group, 75 | Kind: gvks[0].Kind, 76 | Namespace: tracked.GetNamespace(), 77 | Name: tracked.GetName(), 78 | }, 79 | Tracker: types.NamespacedName{Namespace: by.GetNamespace(), Name: by.GetName()}, 80 | } 81 | } 82 | 83 | func createTracker(given []TrackRequest, scheme *runtime.Scheme) *mockTracker { 84 | t := &mockTracker{ 85 | Tracker: tracker.New(scheme, 24*time.Hour), 86 | scheme: scheme, 87 | } 88 | for _, g := range given { 89 | g.normalize() 90 | obj := &unstructured.Unstructured{} 91 | obj.SetNamespace(g.Tracker.Namespace) 92 | obj.SetName(g.Tracker.Name) 93 | t.TrackReference(g.TrackedReference, obj) 94 | } 95 | // reset tracked requests 96 | t.reqs = []TrackRequest{} 97 | return t 98 | } 99 | 100 | type mockTracker struct { 101 | tracker.Tracker 102 | reqs []TrackRequest 103 | scheme *runtime.Scheme 104 | } 105 | 106 | var _ tracker.Tracker = &mockTracker{} 107 | 108 | // TrackObject tells us that "obj" is tracking changes to the 109 | // referenced object. 110 | func (t *mockTracker) TrackObject(ref client.Object, obj client.Object) error { 111 | or, err := reference.GetReference(t.scheme, ref) 112 | if err != nil { 113 | return err 114 | } 115 | gv := schema.FromAPIVersionAndKind(or.APIVersion, or.Kind) 116 | return t.TrackReference(tracker.Reference{ 117 | APIGroup: gv.Group, 118 | Kind: gv.Kind, 119 | Namespace: ref.GetNamespace(), 120 | Name: ref.GetName(), 121 | }, obj) 122 | } 123 | 124 | // TrackReference tells us that "obj" is tracking changes to the 125 | // referenced object. 126 | func (t *mockTracker) TrackReference(ref tracker.Reference, obj client.Object) error { 127 | t.reqs = append(t.reqs, TrackRequest{ 128 | Tracker: types.NamespacedName{ 129 | Namespace: obj.GetNamespace(), 130 | Name: obj.GetName(), 131 | }, 132 | TrackedReference: ref, 133 | }) 134 | return t.Tracker.TrackReference(ref, obj) 135 | } 136 | 137 | func (t *mockTracker) getTrackRequests() []TrackRequest { 138 | return append([]TrackRequest{}, t.reqs...) 139 | } 140 | -------------------------------------------------------------------------------- /reconcilers/cast.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "context" 10 | "encoding/json" 11 | "fmt" 12 | "k8s.io/apimachinery/pkg/util/errors" 13 | "reflect" 14 | "sync" 15 | 16 | "github.com/go-logr/logr" 17 | "k8s.io/apimachinery/pkg/api/equality" 18 | ctrl "sigs.k8s.io/controller-runtime" 19 | "sigs.k8s.io/controller-runtime/pkg/builder" 20 | "sigs.k8s.io/controller-runtime/pkg/client" 21 | ) 22 | 23 | var ( 24 | _ SubReconciler[client.Object] = (*CastResource[client.Object, client.Object])(nil) 25 | ) 26 | 27 | // CastResource casts the ResourceReconciler's type by projecting the resource data 28 | // onto a new struct. Casting the reconciled resource is useful to create cross 29 | // cutting reconcilers that can operate on common portion of multiple resources, 30 | // commonly referred to as a duck type. 31 | // 32 | // If the CastType generic is an interface rather than a struct, the resource is 33 | // passed directly rather than converted. 34 | type CastResource[Type, CastType client.Object] struct { 35 | // Name used to identify this reconciler. Defaults to `{Type}CastResource`. Ideally unique, but 36 | // not required to be so. 37 | // 38 | // +optional 39 | Name string 40 | 41 | // Reconciler is called for each reconciler request with the reconciled resource. Typically a 42 | // Sequence is used to compose multiple SubReconcilers. 43 | Reconciler SubReconciler[CastType] 44 | 45 | noop bool 46 | lazyInit sync.Once 47 | } 48 | 49 | func (r *CastResource[T, CT]) init() { 50 | r.lazyInit.Do(func() { 51 | var nilCT CT 52 | if reflect.ValueOf(nilCT).Kind() == reflect.Invalid { 53 | // not a real cast, just converting generic types 54 | r.noop = true 55 | return 56 | } 57 | emptyCT := newEmpty(nilCT) 58 | if r.Name == "" { 59 | r.Name = fmt.Sprintf("%sCastResource", typeName(emptyCT)) 60 | } 61 | }) 62 | } 63 | 64 | func (r *CastResource[T, CT]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 65 | r.init() 66 | 67 | if !r.noop { 68 | var nilCT CT 69 | emptyCT := newEmpty(nilCT).(CT) 70 | 71 | log := logr.FromContextOrDiscard(ctx). 72 | WithName(r.Name). 73 | WithValues("castResourceType", typeName(emptyCT)) 74 | ctx = logr.NewContext(ctx, log) 75 | 76 | if err := r.validate(ctx); err != nil { 77 | return err 78 | } 79 | } 80 | 81 | return r.Reconciler.SetupWithManager(ctx, mgr, bldr) 82 | } 83 | 84 | func (r *CastResource[T, CT]) validate(ctx context.Context) error { 85 | // validate Reconciler value 86 | if r.Reconciler == nil { 87 | return fmt.Errorf("CastResource %q must define Reconciler", r.Name) 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func (r *CastResource[T, CT]) Reconcile(ctx context.Context, resource T) (Result, error) { 94 | r.init() 95 | 96 | if r.noop { 97 | // cast the type rather than convert the object 98 | return r.Reconciler.Reconcile(ctx, client.Object(resource).(CT)) 99 | } 100 | 101 | var nilCT CT 102 | emptyCT := newEmpty(nilCT).(CT) 103 | 104 | log := logr.FromContextOrDiscard(ctx). 105 | WithName(r.Name). 106 | WithValues("castResourceType", typeName(emptyCT)) 107 | ctx = logr.NewContext(ctx, log) 108 | 109 | ctx, castResource, err := r.cast(ctx, resource) 110 | if err != nil { 111 | return Result{}, err 112 | } 113 | castOriginal := castResource.DeepCopyObject().(client.Object) 114 | result, err := r.Reconciler.Reconcile(ctx, castResource) 115 | var errs []error 116 | if err != nil { 117 | errs = append(errs, err) 118 | } 119 | if !equality.Semantic.DeepEqual(castResource, castOriginal) { 120 | // patch the reconciled resource with the updated duck values 121 | patch, err := NewPatch(castOriginal, castResource) 122 | if err != nil { 123 | errs = append(errs, err) 124 | return result, errors.NewAggregate(errs) 125 | } 126 | err = patch.Apply(resource) 127 | if err != nil { 128 | errs = append(errs, err) 129 | return result, errors.NewAggregate(errs) 130 | } 131 | 132 | } 133 | return result, errors.NewAggregate(errs) 134 | } 135 | 136 | func (r *CastResource[T, CT]) cast(ctx context.Context, resource T) (context.Context, CT, error) { 137 | var nilCT CT 138 | 139 | data, err := json.Marshal(resource) 140 | if err != nil { 141 | return nil, nilCT, err 142 | } 143 | castResource := newEmpty(nilCT).(CT) 144 | err = json.Unmarshal(data, castResource) 145 | if err != nil { 146 | return nil, nilCT, err 147 | } 148 | if kind := castResource.GetObjectKind(); kind.GroupVersionKind().Empty() { 149 | // default the apiVersion/kind with the real value from the resource if not already defined 150 | c := RetrieveConfigOrDie(ctx) 151 | kind.SetGroupVersionKind(gvk(c, resource)) 152 | } 153 | ctx = StashResourceType(ctx, castResource) 154 | return ctx, castResource, nil 155 | } 156 | -------------------------------------------------------------------------------- /internal/resources/resource.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package resources 7 | 8 | import ( 9 | "context" 10 | "encoding/json" 11 | "fmt" 12 | 13 | "github.com/vmware-labs/reconciler-runtime/apis" 14 | corev1 "k8s.io/api/core/v1" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | runtime "k8s.io/apimachinery/pkg/runtime" 17 | "k8s.io/apimachinery/pkg/runtime/schema" 18 | "k8s.io/apimachinery/pkg/util/validation/field" 19 | "sigs.k8s.io/controller-runtime/pkg/client" 20 | "sigs.k8s.io/controller-runtime/pkg/scheme" 21 | "sigs.k8s.io/controller-runtime/pkg/webhook" 22 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 23 | ) 24 | 25 | var ( 26 | _ webhook.Defaulter = &TestResource{} 27 | _ webhook.Validator = &TestResource{} 28 | _ client.Object = &TestResource{} 29 | ) 30 | 31 | // +kubebuilder:object:root=true 32 | // +genclient 33 | 34 | type TestResource 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 *TestResource) Default() { 43 | if r.Spec.Fields == nil { 44 | r.Spec.Fields = map[string]string{} 45 | } 46 | r.Spec.Fields["Defaulter"] = "ran" 47 | } 48 | 49 | func (r *TestResource) ValidateCreate() (admission.Warnings, error) { 50 | return nil, r.validate().ToAggregate() 51 | } 52 | 53 | func (r *TestResource) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { 54 | return nil, r.validate().ToAggregate() 55 | } 56 | 57 | func (r *TestResource) ValidateDelete() (admission.Warnings, error) { 58 | return nil, nil 59 | } 60 | 61 | func (r *TestResource) validate() field.ErrorList { 62 | errs := field.ErrorList{} 63 | 64 | if r.Spec.Fields != nil { 65 | if _, ok := r.Spec.Fields["invalid"]; ok { 66 | field.Invalid(field.NewPath("spec", "fields", "invalid"), r.Spec.Fields["invalid"], "") 67 | } 68 | } 69 | 70 | return errs 71 | } 72 | 73 | // +kubebuilder:object:generate=true 74 | type TestResourceSpec struct { 75 | Fields map[string]string `json:"fields,omitempty"` 76 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 77 | 78 | ErrOnMarshal bool `json:"errOnMarhsal,omitempty"` 79 | ErrOnUnmarshal bool `json:"errOnUnmarhsal,omitempty"` 80 | } 81 | 82 | func (r *TestResourceSpec) MarshalJSON() ([]byte, error) { 83 | if r.ErrOnMarshal { 84 | return nil, fmt.Errorf("ErrOnMarshal true") 85 | } 86 | return json.Marshal(&struct { 87 | Fields map[string]string `json:"fields,omitempty"` 88 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 89 | ErrOnMarshal bool `json:"errOnMarshal,omitempty"` 90 | ErrOnUnmarshal bool `json:"errOnUnmarshal,omitempty"` 91 | }{ 92 | Fields: r.Fields, 93 | Template: r.Template, 94 | ErrOnMarshal: r.ErrOnMarshal, 95 | ErrOnUnmarshal: r.ErrOnUnmarshal, 96 | }) 97 | } 98 | 99 | func (r *TestResourceSpec) UnmarshalJSON(data []byte) error { 100 | type alias struct { 101 | Fields map[string]string `json:"fields,omitempty"` 102 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 103 | ErrOnMarshal bool `json:"errOnMarshal,omitempty"` 104 | ErrOnUnmarshal bool `json:"errOnUnmarshal,omitempty"` 105 | } 106 | a := &alias{} 107 | if err := json.Unmarshal(data, a); err != nil { 108 | return err 109 | } 110 | r.Fields = a.Fields 111 | r.Template = a.Template 112 | r.ErrOnMarshal = a.ErrOnMarshal 113 | r.ErrOnUnmarshal = a.ErrOnUnmarshal 114 | if r.ErrOnUnmarshal { 115 | return fmt.Errorf("ErrOnUnmarshal true") 116 | } 117 | return nil 118 | } 119 | 120 | // +kubebuilder:object:generate=true 121 | type TestResourceStatus struct { 122 | apis.Status `json:",inline"` 123 | Fields map[string]string `json:"fields,omitempty"` 124 | } 125 | 126 | var condSet = apis.NewLivingConditionSet() 127 | 128 | func (rs *TestResourceStatus) InitializeConditions(ctx context.Context) { 129 | condSet.ManageWithContext(ctx, rs).InitializeConditions() 130 | } 131 | 132 | func (rs *TestResourceStatus) MarkReady(ctx context.Context) { 133 | condSet.ManageWithContext(ctx, rs).MarkTrue(apis.ConditionReady, "Ready", "") 134 | } 135 | 136 | func (rs *TestResourceStatus) MarkNotReady(ctx context.Context, reason, message string, messageA ...interface{}) { 137 | condSet.ManageWithContext(ctx, rs).MarkFalse(apis.ConditionReady, reason, message, messageA...) 138 | } 139 | 140 | // +kubebuilder:object:root=true 141 | 142 | type TestResourceList struct { 143 | metav1.TypeMeta `json:",inline"` 144 | metav1.ListMeta `json:"metadata"` 145 | 146 | Items []TestResource `json:"items"` 147 | } 148 | 149 | var ( 150 | // GroupVersion is group version used to register these objects 151 | GroupVersion = schema.GroupVersion{Group: "testing.reconciler.runtime", Version: "v1"} 152 | 153 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 154 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 155 | 156 | // AddToScheme adds the types in this group-version to the given scheme. 157 | AddToScheme = SchemeBuilder.AddToScheme 158 | ) 159 | 160 | // compatibility with k8s.io/code-generator 161 | var SchemeGroupVersion = GroupVersion 162 | 163 | func init() { 164 | SchemeBuilder.Register(&TestResource{}, &TestResourceList{}) 165 | } 166 | -------------------------------------------------------------------------------- /reconcilers/sync.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "context" 10 | "errors" 11 | "fmt" 12 | 13 | "github.com/go-logr/logr" 14 | ctrl "sigs.k8s.io/controller-runtime" 15 | "sigs.k8s.io/controller-runtime/pkg/builder" 16 | "sigs.k8s.io/controller-runtime/pkg/client" 17 | ) 18 | 19 | var ( 20 | _ SubReconciler[client.Object] = (*SyncReconciler[client.Object])(nil) 21 | ) 22 | 23 | // SyncReconciler is a sub reconciler for custom reconciliation logic. No 24 | // behavior is defined directly. 25 | type SyncReconciler[Type client.Object] struct { 26 | // Name used to identify this reconciler. Defaults to `SyncReconciler`. Ideally unique, but 27 | // not required to be so. 28 | // 29 | // +optional 30 | Name string 31 | 32 | // Setup performs initialization on the manager and builder this reconciler 33 | // will run with. It's common to setup field indexes and watch resources. 34 | // 35 | // +optional 36 | Setup func(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error 37 | 38 | // SyncDuringFinalization indicates the Sync method should be called when the resource is pending deletion. 39 | SyncDuringFinalization bool 40 | 41 | // Sync does whatever work is necessary for the reconciler. 42 | // 43 | // If SyncDuringFinalization is true this method is called when the resource is pending 44 | // deletion. This is useful if the reconciler is managing reference data. 45 | // 46 | // Mutually exclusive with SyncWithResult 47 | Sync func(ctx context.Context, resource Type) error 48 | 49 | // SyncWithResult does whatever work is necessary for the reconciler. 50 | // 51 | // If SyncDuringFinalization is true this method is called when the resource is pending 52 | // deletion. This is useful if the reconciler is managing reference data. 53 | // 54 | // Mutually exclusive with Sync 55 | SyncWithResult func(ctx context.Context, resource Type) (Result, error) 56 | 57 | // Finalize does whatever work is necessary for the reconciler when the resource is pending 58 | // deletion. If this reconciler sets a finalizer it should do the necessary work to clean up 59 | // state the finalizer represents and then clear the finalizer. 60 | // 61 | // Mutually exclusive with FinalizeWithResult 62 | // 63 | // +optional 64 | Finalize func(ctx context.Context, resource Type) error 65 | 66 | // Finalize does whatever work is necessary for the reconciler when the resource is pending 67 | // deletion. If this reconciler sets a finalizer it should do the necessary work to clean up 68 | // state the finalizer represents and then clear the finalizer. 69 | // 70 | // Mutually exclusive with Finalize 71 | // 72 | // +optional 73 | FinalizeWithResult func(ctx context.Context, resource Type) (Result, error) 74 | } 75 | 76 | func (r *SyncReconciler[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 77 | if r.Name == "" { 78 | r.Name = "SyncReconciler" 79 | } 80 | 81 | log := logr.FromContextOrDiscard(ctx). 82 | WithName(r.Name) 83 | ctx = logr.NewContext(ctx, log) 84 | 85 | if r.Setup == nil { 86 | return nil 87 | } 88 | if err := r.validate(ctx); err != nil { 89 | return err 90 | } 91 | return r.Setup(ctx, mgr, bldr) 92 | } 93 | 94 | func (r *SyncReconciler[T]) validate(ctx context.Context) error { 95 | // validate Sync and SyncWithResult 96 | if r.Sync == nil && r.SyncWithResult == nil { 97 | return fmt.Errorf("SyncReconciler %q must implement Sync or SyncWithResult", r.Name) 98 | } 99 | if r.Sync != nil && r.SyncWithResult != nil { 100 | return fmt.Errorf("SyncReconciler %q may not implement both Sync and SyncWithResult", r.Name) 101 | } 102 | 103 | // validate Finalize and FinalizeWithResult 104 | if r.Finalize != nil && r.FinalizeWithResult != nil { 105 | return fmt.Errorf("SyncReconciler %q may not implement both Finalize and FinalizeWithResult", r.Name) 106 | } 107 | 108 | return nil 109 | } 110 | 111 | func (r *SyncReconciler[T]) Reconcile(ctx context.Context, resource T) (Result, error) { 112 | log := logr.FromContextOrDiscard(ctx). 113 | WithName(r.Name) 114 | ctx = logr.NewContext(ctx, log) 115 | 116 | result := Result{} 117 | 118 | if resource.GetDeletionTimestamp() == nil || r.SyncDuringFinalization { 119 | syncResult, err := r.sync(ctx, resource) 120 | result = AggregateResults(result, syncResult) 121 | if err != nil { 122 | if !errors.Is(err, ErrQuiet) { 123 | log.Error(err, "unable to sync") 124 | } 125 | return result, err 126 | } 127 | } 128 | 129 | if resource.GetDeletionTimestamp() != nil { 130 | finalizeResult, err := r.finalize(ctx, resource) 131 | result = AggregateResults(result, finalizeResult) 132 | if err != nil { 133 | if !errors.Is(err, ErrQuiet) { 134 | log.Error(err, "unable to finalize") 135 | } 136 | return result, err 137 | } 138 | } 139 | 140 | return result, nil 141 | } 142 | 143 | func (r *SyncReconciler[T]) sync(ctx context.Context, resource T) (Result, error) { 144 | if r.Sync != nil { 145 | err := r.Sync(ctx, resource) 146 | return Result{}, err 147 | } 148 | return r.SyncWithResult(ctx, resource) 149 | } 150 | 151 | func (r *SyncReconciler[T]) finalize(ctx context.Context, resource T) (Result, error) { 152 | if r.Finalize != nil { 153 | err := r.Finalize(ctx, resource) 154 | return Result{}, err 155 | } 156 | if r.FinalizeWithResult != nil { 157 | return r.FinalizeWithResult(ctx, resource) 158 | } 159 | 160 | return Result{}, nil 161 | } 162 | -------------------------------------------------------------------------------- /internal/resources/dies/dies.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package dies 7 | 8 | import ( 9 | diecorev1 "dies.dev/apis/core/v1" 10 | diemetav1 "dies.dev/apis/meta/v1" 11 | "github.com/vmware-labs/reconciler-runtime/internal/resources" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | ) 14 | 15 | // +die:object=true 16 | type _ = resources.TestResource 17 | 18 | // +die 19 | type _ = resources.TestResourceSpec 20 | 21 | func (d *TestResourceSpecDie) AddField(key, value string) *TestResourceSpecDie { 22 | return d.DieStamp(func(r *resources.TestResourceSpec) { 23 | if r.Fields == nil { 24 | r.Fields = map[string]string{} 25 | } 26 | r.Fields[key] = value 27 | }) 28 | } 29 | 30 | func (d *TestResourceSpecDie) TemplateDie(fn func(d *diecorev1.PodTemplateSpecDie)) *TestResourceSpecDie { 31 | return d.DieStamp(func(r *resources.TestResourceSpec) { 32 | d := diecorev1.PodTemplateSpecBlank.DieImmutable(false).DieFeed(r.Template) 33 | fn(d) 34 | r.Template = d.DieRelease() 35 | }) 36 | } 37 | 38 | // +die 39 | type _ = resources.TestResourceStatus 40 | 41 | func (d *TestResourceStatusDie) ConditionsDie(conditions ...*diemetav1.ConditionDie) *TestResourceStatusDie { 42 | return d.DieStamp(func(r *resources.TestResourceStatus) { 43 | r.Conditions = make([]metav1.Condition, len(conditions)) 44 | for i := range conditions { 45 | r.Conditions[i] = conditions[i].DieRelease() 46 | } 47 | }) 48 | } 49 | 50 | func (d *TestResourceStatusDie) AddField(key, value string) *TestResourceStatusDie { 51 | return d.DieStamp(func(r *resources.TestResourceStatus) { 52 | if r.Fields == nil { 53 | r.Fields = map[string]string{} 54 | } 55 | r.Fields[key] = value 56 | }) 57 | } 58 | 59 | // +die:object=true,spec=TestResourceSpec 60 | type _ = resources.TestResourceEmptyStatus 61 | 62 | // +die 63 | type _ = resources.TestResourceEmptyStatusStatus 64 | 65 | // +die:object=true,spec=TestResourceSpec 66 | type _ = resources.TestResourceNoStatus 67 | 68 | // +die:object=true,spec=TestResourceSpec 69 | type _ = resources.TestResourceNilableStatus 70 | 71 | // StatusDie stamps the resource's status field with a mutable die. 72 | func (d *TestResourceNilableStatusDie) StatusDie(fn func(d *TestResourceStatusDie)) *TestResourceNilableStatusDie { 73 | return d.DieStamp(func(r *resources.TestResourceNilableStatus) { 74 | d := TestResourceStatusBlank.DieImmutable(false).DieFeedPtr(r.Status) 75 | fn(d) 76 | r.Status = d.DieReleasePtr() 77 | }) 78 | } 79 | 80 | // +die:object=true 81 | type _ = resources.TestDuck 82 | 83 | func (d *TestDuckDie) StatusDie(fn func(d *TestResourceStatusDie)) *TestDuckDie { 84 | return d.DieStamp(func(r *resources.TestDuck) { 85 | d := TestResourceStatusBlank.DieImmutable(false).DieFeed(r.Status) 86 | fn(d) 87 | r.Status = d.DieRelease() 88 | }) 89 | } 90 | 91 | // +die 92 | type _ = resources.TestDuckSpec 93 | 94 | func (d *TestDuckSpecDie) AddField(key, value string) *TestDuckSpecDie { 95 | return d.DieStamp(func(r *resources.TestDuckSpec) { 96 | if r.Fields == nil { 97 | r.Fields = map[string]string{} 98 | } 99 | r.Fields[key] = value 100 | }) 101 | } 102 | 103 | // +die:object=true 104 | type _ = resources.TestResourceUnexportedFields 105 | 106 | // +die:ignore={unexportedFields} 107 | type _ = resources.TestResourceUnexportedFieldsSpec 108 | 109 | func (d *TestResourceUnexportedFieldsSpecDie) AddField(key, value string) *TestResourceUnexportedFieldsSpecDie { 110 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { 111 | if r.Fields == nil { 112 | r.Fields = map[string]string{} 113 | } 114 | r.Fields[key] = value 115 | }) 116 | } 117 | 118 | func (d *TestResourceUnexportedFieldsSpecDie) AddUnexportedField(key, value string) *TestResourceUnexportedFieldsSpecDie { 119 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { 120 | f := r.GetUnexportedFields() 121 | if f == nil { 122 | f = map[string]string{} 123 | } 124 | f[key] = value 125 | r.SetUnexportedFields(f) 126 | }) 127 | } 128 | 129 | func (d *TestResourceUnexportedFieldsSpecDie) TemplateDie(fn func(d *diecorev1.PodTemplateSpecDie)) *TestResourceUnexportedFieldsSpecDie { 130 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { 131 | d := diecorev1.PodTemplateSpecBlank.DieImmutable(false).DieFeed(r.Template) 132 | fn(d) 133 | r.Template = d.DieRelease() 134 | }) 135 | } 136 | 137 | // +die:ignore={unexportedFields} 138 | type _ = resources.TestResourceUnexportedFieldsStatus 139 | 140 | func (d *TestResourceUnexportedFieldsStatusDie) ConditionsDie(conditions ...*diemetav1.ConditionDie) *TestResourceUnexportedFieldsStatusDie { 141 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { 142 | r.Conditions = make([]metav1.Condition, len(conditions)) 143 | for i := range conditions { 144 | r.Conditions[i] = conditions[i].DieRelease() 145 | } 146 | }) 147 | } 148 | 149 | func (d *TestResourceUnexportedFieldsStatusDie) AddField(key, value string) *TestResourceUnexportedFieldsStatusDie { 150 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { 151 | if r.Fields == nil { 152 | r.Fields = map[string]string{} 153 | } 154 | r.Fields[key] = value 155 | }) 156 | } 157 | 158 | func (d *TestResourceUnexportedFieldsStatusDie) AddUnexportedField(key, value string) *TestResourceUnexportedFieldsStatusDie { 159 | return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { 160 | f := r.GetUnexportedFields() 161 | if f == nil { 162 | f = map[string]string{} 163 | } 164 | f[key] = value 165 | r.SetUnexportedFields(f) 166 | }) 167 | } 168 | -------------------------------------------------------------------------------- /reconcilers/finalizer.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "context" 10 | "errors" 11 | "fmt" 12 | 13 | "github.com/go-logr/logr" 14 | corev1 "k8s.io/api/core/v1" 15 | ctrl "sigs.k8s.io/controller-runtime" 16 | "sigs.k8s.io/controller-runtime/pkg/builder" 17 | "sigs.k8s.io/controller-runtime/pkg/client" 18 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 19 | ) 20 | 21 | var ( 22 | _ SubReconciler[client.Object] = (*WithFinalizer[client.Object])(nil) 23 | ) 24 | 25 | // WithFinalizer ensures the resource being reconciled has the desired finalizer set so that state 26 | // can be cleaned up upon the resource being deleted. The finalizer is added to the resource, if not 27 | // already set, before calling the nested reconciler. When the resource is terminating, the 28 | // finalizer is cleared after returning from the nested reconciler without error. 29 | type WithFinalizer[Type client.Object] struct { 30 | // Name used to identify this reconciler. Defaults to `WithFinalizer`. Ideally unique, but 31 | // not required to be so. 32 | // 33 | // +optional 34 | Name string 35 | 36 | // Finalizer to set on the reconciled resource. The value must be unique to this specific 37 | // reconciler instance and not shared. Reusing a value may result in orphaned state when 38 | // the reconciled resource is deleted. 39 | // 40 | // Using a finalizer is encouraged when state needs to be manually cleaned up before a resource 41 | // is fully deleted. This commonly include state allocated outside of the current cluster. 42 | Finalizer string 43 | 44 | // Reconciler is called for each reconciler request with the reconciled 45 | // resource being reconciled. Typically a Sequence is used to compose 46 | // multiple SubReconcilers. 47 | Reconciler SubReconciler[Type] 48 | } 49 | 50 | func (r *WithFinalizer[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 51 | if r.Name == "" { 52 | r.Name = "WithFinalizer" 53 | } 54 | 55 | log := logr.FromContextOrDiscard(ctx). 56 | WithName(r.Name) 57 | ctx = logr.NewContext(ctx, log) 58 | 59 | if err := r.validate(ctx); err != nil { 60 | return err 61 | } 62 | return r.Reconciler.SetupWithManager(ctx, mgr, bldr) 63 | } 64 | 65 | func (r *WithFinalizer[T]) validate(ctx context.Context) error { 66 | // validate Finalizer value 67 | if r.Finalizer == "" { 68 | return fmt.Errorf("WithFinalizer %q must define Finalizer", r.Name) 69 | } 70 | 71 | // validate Reconciler value 72 | if r.Reconciler == nil { 73 | return fmt.Errorf("WithFinalizer %q must define Reconciler", r.Name) 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func (r *WithFinalizer[T]) Reconcile(ctx context.Context, resource T) (Result, error) { 80 | log := logr.FromContextOrDiscard(ctx). 81 | WithName(r.Name) 82 | ctx = logr.NewContext(ctx, log) 83 | 84 | if resource.GetDeletionTimestamp() == nil { 85 | if err := AddFinalizer(ctx, resource, r.Finalizer); err != nil { 86 | return Result{}, err 87 | } 88 | } 89 | result, err := r.Reconciler.Reconcile(ctx, resource) 90 | if err != nil { 91 | return result, err 92 | } 93 | if resource.GetDeletionTimestamp() != nil { 94 | if err := ClearFinalizer(ctx, resource, r.Finalizer); err != nil { 95 | return Result{}, err 96 | } 97 | } 98 | return result, err 99 | } 100 | 101 | // AddFinalizer ensures the desired finalizer exists on the reconciled resource. The client that 102 | // loaded the reconciled resource is used to patch it with the finalizer if not already set. 103 | func AddFinalizer(ctx context.Context, resource client.Object, finalizer string) error { 104 | return ensureFinalizer(ctx, resource, finalizer, true) 105 | } 106 | 107 | // ClearFinalizer ensures the desired finalizer does not exist on the reconciled resource. The 108 | // client that loaded the reconciled resource is used to patch it with the finalizer if set. 109 | func ClearFinalizer(ctx context.Context, resource client.Object, finalizer string) error { 110 | return ensureFinalizer(ctx, resource, finalizer, false) 111 | } 112 | 113 | func ensureFinalizer(ctx context.Context, current client.Object, finalizer string, add bool) error { 114 | if finalizer == "" || controllerutil.ContainsFinalizer(current, finalizer) == add { 115 | // nothing to do 116 | return nil 117 | } 118 | 119 | config := RetrieveOriginalConfigOrDie(ctx) 120 | log := logr.FromContextOrDiscard(ctx) 121 | 122 | desired := current.DeepCopyObject().(client.Object) 123 | if add { 124 | log.Info("adding finalizer", "finalizer", finalizer) 125 | controllerutil.AddFinalizer(desired, finalizer) 126 | } else { 127 | log.Info("removing finalizer", "finalizer", finalizer) 128 | controllerutil.RemoveFinalizer(desired, finalizer) 129 | } 130 | 131 | patch := client.MergeFromWithOptions(current, client.MergeFromWithOptimisticLock{}) 132 | if err := config.Patch(ctx, desired, patch); err != nil { 133 | if !errors.Is(err, ErrQuiet) { 134 | log.Error(err, "unable to patch finalizers", "finalizer", finalizer) 135 | config.Recorder.Eventf(current, corev1.EventTypeWarning, "FinalizerPatchFailed", 136 | "Failed to patch finalizer %q: %s", finalizer, err) 137 | } 138 | return err 139 | } 140 | config.Recorder.Eventf(current, corev1.EventTypeNormal, "FinalizerPatched", 141 | "Patched finalizer %q", finalizer) 142 | 143 | // update current object with values from the api server after patching 144 | current.SetFinalizers(desired.GetFinalizers()) 145 | current.SetResourceVersion(desired.GetResourceVersion()) 146 | current.SetGeneration(desired.GetGeneration()) 147 | 148 | return nil 149 | } 150 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in reconciler-runtime project and our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at oss-coc@vmware.com. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 119 | 120 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 121 | enforcement ladder](https://github.com/mozilla/diversity). 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | https://www.contributor-covenant.org/faq. Translations are available at 127 | https://www.contributor-covenant.org/translations. 128 | -------------------------------------------------------------------------------- /internal/resources/resource_with_unexported_fields.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package resources 7 | 8 | import ( 9 | "encoding/json" 10 | "fmt" 11 | 12 | "github.com/vmware-labs/reconciler-runtime/apis" 13 | corev1 "k8s.io/api/core/v1" 14 | "k8s.io/apimachinery/pkg/api/equality" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | runtime "k8s.io/apimachinery/pkg/runtime" 17 | "k8s.io/apimachinery/pkg/util/validation/field" 18 | "sigs.k8s.io/controller-runtime/pkg/client" 19 | "sigs.k8s.io/controller-runtime/pkg/webhook" 20 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 21 | ) 22 | 23 | var ( 24 | _ webhook.Defaulter = &TestResourceUnexportedFields{} 25 | _ webhook.Validator = &TestResourceUnexportedFields{} 26 | _ client.Object = &TestResourceUnexportedFields{} 27 | ) 28 | 29 | // +kubebuilder:object:root=true 30 | // +genclient 31 | 32 | type TestResourceUnexportedFields struct { 33 | metav1.TypeMeta `json:",inline"` 34 | metav1.ObjectMeta `json:"metadata,omitempty"` 35 | 36 | Spec TestResourceUnexportedFieldsSpec `json:"spec"` 37 | Status TestResourceUnexportedFieldsStatus `json:"status"` 38 | } 39 | 40 | func (r *TestResourceUnexportedFields) Default() { 41 | if r.Spec.Fields == nil { 42 | r.Spec.Fields = map[string]string{} 43 | } 44 | r.Spec.Fields["Defaulter"] = "ran" 45 | } 46 | 47 | func (r *TestResourceUnexportedFields) ValidateCreate() (admission.Warnings, error) { 48 | return nil, r.validate().ToAggregate() 49 | } 50 | 51 | func (r *TestResourceUnexportedFields) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { 52 | return nil, r.validate().ToAggregate() 53 | } 54 | 55 | func (r *TestResourceUnexportedFields) ValidateDelete() (admission.Warnings, error) { 56 | return nil, nil 57 | } 58 | 59 | func (r *TestResourceUnexportedFields) validate() field.ErrorList { 60 | errs := field.ErrorList{} 61 | 62 | if r.Spec.Fields != nil { 63 | if _, ok := r.Spec.Fields["invalid"]; ok { 64 | field.Invalid(field.NewPath("spec", "fields", "invalid"), r.Spec.Fields["invalid"], "") 65 | } 66 | } 67 | 68 | return errs 69 | } 70 | 71 | func (r *TestResourceUnexportedFields) ReflectUnexportedFieldsToStatus() { 72 | r.Status.unexportedFields = r.Spec.unexportedFields 73 | } 74 | 75 | // +kubebuilder:object:generate=true 76 | type TestResourceUnexportedFieldsSpec struct { 77 | Fields map[string]string `json:"fields,omitempty"` 78 | unexportedFields map[string]string 79 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 80 | 81 | ErrOnMarshal bool `json:"errOnMarhsal,omitempty"` 82 | ErrOnUnmarshal bool `json:"errOnUnmarhsal,omitempty"` 83 | } 84 | 85 | func (r *TestResourceUnexportedFieldsSpec) GetUnexportedFields() map[string]string { 86 | return r.unexportedFields 87 | } 88 | 89 | func (r *TestResourceUnexportedFieldsSpec) SetUnexportedFields(f map[string]string) { 90 | r.unexportedFields = f 91 | } 92 | 93 | func (r *TestResourceUnexportedFieldsSpec) AddUnexportedField(key, value string) { 94 | if r.unexportedFields == nil { 95 | r.unexportedFields = map[string]string{} 96 | } 97 | r.unexportedFields[key] = value 98 | } 99 | 100 | func (r *TestResourceUnexportedFieldsSpec) MarshalJSON() ([]byte, error) { 101 | if r.ErrOnMarshal { 102 | return nil, fmt.Errorf("ErrOnMarshal true") 103 | } 104 | return json.Marshal(&struct { 105 | Fields map[string]string `json:"fields,omitempty"` 106 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 107 | ErrOnMarshal bool `json:"errOnMarshal,omitempty"` 108 | ErrOnUnmarshal bool `json:"errOnUnmarshal,omitempty"` 109 | }{ 110 | Fields: r.Fields, 111 | Template: r.Template, 112 | ErrOnMarshal: r.ErrOnMarshal, 113 | ErrOnUnmarshal: r.ErrOnUnmarshal, 114 | }) 115 | } 116 | 117 | func (r *TestResourceUnexportedFieldsSpec) UnmarshalJSON(data []byte) error { 118 | type alias struct { 119 | Fields map[string]string `json:"fields,omitempty"` 120 | Template corev1.PodTemplateSpec `json:"template,omitempty"` 121 | ErrOnMarshal bool `json:"errOnMarshal,omitempty"` 122 | ErrOnUnmarshal bool `json:"errOnUnmarshal,omitempty"` 123 | } 124 | a := &alias{} 125 | if err := json.Unmarshal(data, a); err != nil { 126 | return err 127 | } 128 | r.Fields = a.Fields 129 | r.Template = a.Template 130 | r.ErrOnMarshal = a.ErrOnMarshal 131 | r.ErrOnUnmarshal = a.ErrOnUnmarshal 132 | if r.ErrOnUnmarshal { 133 | return fmt.Errorf("ErrOnUnmarshal true") 134 | } 135 | return nil 136 | } 137 | 138 | // +kubebuilder:object:generate=true 139 | type TestResourceUnexportedFieldsStatus struct { 140 | apis.Status `json:",inline"` 141 | Fields map[string]string `json:"fields,omitempty"` 142 | unexportedFields map[string]string 143 | } 144 | 145 | func (r *TestResourceUnexportedFieldsStatus) GetUnexportedFields() map[string]string { 146 | return r.unexportedFields 147 | } 148 | 149 | func (r *TestResourceUnexportedFieldsStatus) SetUnexportedFields(f map[string]string) { 150 | r.unexportedFields = f 151 | } 152 | 153 | func (r *TestResourceUnexportedFieldsStatus) AddUnexportedField(key, value string) { 154 | if r.unexportedFields == nil { 155 | r.unexportedFields = map[string]string{} 156 | } 157 | r.unexportedFields[key] = value 158 | } 159 | 160 | // +kubebuilder:object:root=true 161 | 162 | type TestResourceUnexportedFieldsList struct { 163 | metav1.TypeMeta `json:",inline"` 164 | metav1.ListMeta `json:"metadata"` 165 | 166 | Items []TestResourceUnexportedFields `json:"items"` 167 | } 168 | 169 | func init() { 170 | SchemeBuilder.Register(&TestResourceUnexportedFields{}, &TestResourceUnexportedFieldsList{}) 171 | 172 | if err := equality.Semantic.AddFuncs( 173 | func(a, b TestResourceUnexportedFieldsSpec) bool { 174 | return equality.Semantic.DeepEqual(a.Fields, b.Fields) && 175 | equality.Semantic.DeepEqual(a.Template, b.Template) && 176 | equality.Semantic.DeepEqual(a.ErrOnMarshal, b.ErrOnMarshal) && 177 | equality.Semantic.DeepEqual(a.ErrOnUnmarshal, b.ErrOnUnmarshal) 178 | }, 179 | func(a, b TestResourceUnexportedFieldsStatus) bool { 180 | return equality.Semantic.DeepEqual(a.Status, b.Status) && 181 | equality.Semantic.DeepEqual(a.Fields, b.Fields) 182 | }, 183 | ); err != nil { 184 | panic(err) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /reconcilers/config.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "strings" 12 | "time" 13 | 14 | "k8s.io/apimachinery/pkg/labels" 15 | "k8s.io/apimachinery/pkg/runtime/schema" 16 | "k8s.io/apimachinery/pkg/types" 17 | "k8s.io/client-go/tools/record" 18 | "k8s.io/client-go/tools/reference" 19 | ctrl "sigs.k8s.io/controller-runtime" 20 | "sigs.k8s.io/controller-runtime/pkg/builder" 21 | "sigs.k8s.io/controller-runtime/pkg/client" 22 | "sigs.k8s.io/controller-runtime/pkg/cluster" 23 | 24 | "github.com/go-logr/logr" 25 | "github.com/vmware-labs/reconciler-runtime/duck" 26 | "github.com/vmware-labs/reconciler-runtime/tracker" 27 | ) 28 | 29 | // Config holds common resources for controllers. The configuration may be 30 | // passed to sub-reconcilers. 31 | type Config struct { 32 | client.Client 33 | APIReader client.Reader 34 | Recorder record.EventRecorder 35 | Tracker tracker.Tracker 36 | } 37 | 38 | func (c Config) IsEmpty() bool { 39 | return c == Config{} 40 | } 41 | 42 | // WithCluster extends the config to access a new cluster. 43 | func (c Config) WithCluster(cluster cluster.Cluster) Config { 44 | return Config{ 45 | Client: duck.NewDuckAwareClientWrapper(cluster.GetClient()), 46 | APIReader: duck.NewDuckAwareAPIReaderWrapper(cluster.GetAPIReader(), cluster.GetClient()), 47 | Recorder: cluster.GetEventRecorderFor("controller"), 48 | Tracker: c.Tracker, 49 | } 50 | } 51 | 52 | // TrackAndGet tracks the resources for changes and returns the current value. The track is 53 | // registered even when the resource does not exists so that its creation can be tracked. 54 | // 55 | // Equivalent to calling both `c.Tracker.TrackObject(...)` and `c.Client.Get(...)` 56 | func (c Config) TrackAndGet(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { 57 | // create synthetic resource to track from known type and request 58 | req := RetrieveRequest(ctx) 59 | resource := RetrieveResourceType(ctx).DeepCopyObject().(client.Object) 60 | resource.SetNamespace(req.Namespace) 61 | resource.SetName(req.Name) 62 | ref := obj.DeepCopyObject().(client.Object) 63 | ref.SetNamespace(key.Namespace) 64 | ref.SetName(key.Name) 65 | c.Tracker.TrackObject(ref, resource) 66 | 67 | return c.Get(ctx, key, obj, opts...) 68 | } 69 | 70 | // TrackAndList tracks the resources for changes and returns the current value. 71 | // 72 | // Equivalent to calling both `c.Tracker.TrackReference(...)` and `c.Client.List(...)` 73 | func (c Config) TrackAndList(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { 74 | // create synthetic resource to track from known type and request 75 | req := RetrieveRequest(ctx) 76 | resource := RetrieveResourceType(ctx).DeepCopyObject().(client.Object) 77 | resource.SetNamespace(req.Namespace) 78 | resource.SetName(req.Name) 79 | 80 | or, err := reference.GetReference(c.Scheme(), list) 81 | if err != nil { 82 | return err 83 | } 84 | gvk := schema.FromAPIVersionAndKind(or.APIVersion, or.Kind) 85 | listOpts := (&client.ListOptions{}).ApplyOptions(opts) 86 | if listOpts.LabelSelector == nil { 87 | listOpts.LabelSelector = labels.Everything() 88 | } 89 | ref := tracker.Reference{ 90 | APIGroup: gvk.Group, 91 | Kind: strings.TrimSuffix(gvk.Kind, "List"), 92 | Namespace: listOpts.Namespace, 93 | Selector: listOpts.LabelSelector, 94 | } 95 | c.Tracker.TrackReference(ref, resource) 96 | 97 | return c.List(ctx, list, opts...) 98 | } 99 | 100 | // NewConfig creates a Config for a specific API type. Typically passed into a 101 | // reconciler. 102 | func NewConfig(mgr ctrl.Manager, apiType client.Object, syncPeriod time.Duration) Config { 103 | return Config{ 104 | Tracker: tracker.New(mgr.GetScheme(), 2*syncPeriod), 105 | }.WithCluster(mgr) 106 | } 107 | 108 | var ( 109 | _ SubReconciler[client.Object] = (*WithConfig[client.Object])(nil) 110 | ) 111 | 112 | // Experimental: WithConfig injects the provided config into the reconcilers nested under it. For 113 | // example, the client can be swapped to use a service account with different permissions, or to 114 | // target an entirely different cluster. 115 | // 116 | // The specified config can be accessed with `RetrieveConfig(ctx)`, the original config used to 117 | // load the reconciled resource can be accessed with `RetrieveOriginalConfig(ctx)`. 118 | type WithConfig[Type client.Object] struct { 119 | // Name used to identify this reconciler. Defaults to `WithConfig`. Ideally unique, but 120 | // not required to be so. 121 | // 122 | // +optional 123 | Name string 124 | 125 | // Config to use for this portion of the reconciler hierarchy. This method is called during 126 | // setup and during reconciliation, if context is needed, it should be available durring both 127 | // phases. 128 | Config func(context.Context, Config) (Config, error) 129 | 130 | // Reconciler is called for each reconciler request with the reconciled 131 | // resource being reconciled. Typically a Sequence is used to compose 132 | // multiple SubReconcilers. 133 | Reconciler SubReconciler[Type] 134 | } 135 | 136 | func (r *WithConfig[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { 137 | if r.Name == "" { 138 | r.Name = "WithConfig" 139 | } 140 | 141 | log := logr.FromContextOrDiscard(ctx). 142 | WithName(r.Name) 143 | ctx = logr.NewContext(ctx, log) 144 | 145 | if err := r.validate(ctx); err != nil { 146 | return err 147 | } 148 | c, err := r.Config(ctx, RetrieveConfigOrDie(ctx)) 149 | if err != nil { 150 | return err 151 | } 152 | ctx = StashConfig(ctx, c) 153 | return r.Reconciler.SetupWithManager(ctx, mgr, bldr) 154 | } 155 | 156 | func (r *WithConfig[T]) validate(ctx context.Context) error { 157 | // validate Config value 158 | if r.Config == nil { 159 | return fmt.Errorf("WithConfig %q must define Config", r.Name) 160 | } 161 | 162 | // validate Reconciler value 163 | if r.Reconciler == nil { 164 | return fmt.Errorf("WithConfig %q must define Reconciler", r.Name) 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func (r *WithConfig[T]) 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 | c, err := r.Config(ctx, RetrieveConfigOrDie(ctx)) 176 | if err != nil { 177 | return Result{}, err 178 | } 179 | ctx = StashConfig(ctx, c) 180 | return r.Reconciler.Reconcile(ctx, resource) 181 | } 182 | -------------------------------------------------------------------------------- /reconcilers/finalizer_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers_test 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "testing" 12 | "time" 13 | 14 | diemetav1 "dies.dev/apis/meta/v1" 15 | "github.com/vmware-labs/reconciler-runtime/internal/resources" 16 | "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" 17 | "github.com/vmware-labs/reconciler-runtime/reconcilers" 18 | rtesting "github.com/vmware-labs/reconciler-runtime/testing" 19 | corev1 "k8s.io/api/core/v1" 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | "k8s.io/apimachinery/pkg/runtime" 22 | "k8s.io/apimachinery/pkg/types" 23 | ) 24 | 25 | func TestWithFinalizer(t *testing.T) { 26 | testNamespace := "test-namespace" 27 | testName := "test-resource" 28 | testFinalizer := "test-finalizer" 29 | 30 | now := &metav1.Time{Time: time.Now().Truncate(time.Second)} 31 | 32 | scheme := runtime.NewScheme() 33 | _ = resources.AddToScheme(scheme) 34 | 35 | resource := dies.TestResourceBlank. 36 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 37 | d.Namespace(testNamespace) 38 | d.Name(testName) 39 | }) 40 | 41 | rts := rtesting.SubReconcilerTests[*resources.TestResource]{ 42 | "in sync": { 43 | Resource: resource. 44 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 45 | d.Finalizers(testFinalizer) 46 | }). 47 | DieReleasePtr(), 48 | ExpectEvents: []rtesting.Event{ 49 | rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Sync", ""), 50 | }, 51 | }, 52 | "add finalizer": { 53 | Resource: resource.DieReleasePtr(), 54 | ExpectResource: resource. 55 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 56 | d.Finalizers(testFinalizer) 57 | d.ResourceVersion("1000") 58 | }). 59 | DieReleasePtr(), 60 | ExpectEvents: []rtesting.Event{ 61 | rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", 62 | `Patched finalizer %q`, testFinalizer), 63 | rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Sync", ""), 64 | }, 65 | ExpectPatches: []rtesting.PatchRef{ 66 | { 67 | Group: "testing.reconciler.runtime", 68 | Kind: "TestResource", 69 | Namespace: testNamespace, 70 | Name: testName, 71 | PatchType: types.MergePatchType, 72 | Patch: []byte(`{"metadata":{"finalizers":["test-finalizer"],"resourceVersion":"999"}}`), 73 | }, 74 | }, 75 | }, 76 | "error adding finalizer": { 77 | Resource: resource.DieReleasePtr(), 78 | WithReactors: []rtesting.ReactionFunc{ 79 | rtesting.InduceFailure("patch", "TestResource"), 80 | }, 81 | ShouldErr: true, 82 | ExpectEvents: []rtesting.Event{ 83 | rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", 84 | `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), 85 | }, 86 | ExpectPatches: []rtesting.PatchRef{ 87 | { 88 | Group: "testing.reconciler.runtime", 89 | Kind: "TestResource", 90 | Namespace: testNamespace, 91 | Name: testName, 92 | PatchType: types.MergePatchType, 93 | Patch: []byte(`{"metadata":{"finalizers":["test-finalizer"],"resourceVersion":"999"}}`), 94 | }, 95 | }, 96 | }, 97 | "clear finalizer": { 98 | Resource: resource. 99 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 100 | d.DeletionTimestamp(now) 101 | d.Finalizers(testFinalizer) 102 | }). 103 | DieReleasePtr(), 104 | ExpectResource: resource. 105 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 106 | d.DeletionTimestamp(now) 107 | d.ResourceVersion("1000") 108 | }). 109 | DieReleasePtr(), 110 | ExpectEvents: []rtesting.Event{ 111 | rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Finalize", ""), 112 | rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", 113 | `Patched finalizer %q`, testFinalizer), 114 | }, 115 | ExpectPatches: []rtesting.PatchRef{ 116 | { 117 | Group: "testing.reconciler.runtime", 118 | Kind: "TestResource", 119 | Namespace: testNamespace, 120 | Name: testName, 121 | PatchType: types.MergePatchType, 122 | Patch: []byte(`{"metadata":{"finalizers":null,"resourceVersion":"999"}}`), 123 | }, 124 | }, 125 | }, 126 | "error clearing finalizer": { 127 | Resource: resource. 128 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 129 | d.DeletionTimestamp(now) 130 | d.Finalizers(testFinalizer) 131 | }). 132 | DieReleasePtr(), 133 | WithReactors: []rtesting.ReactionFunc{ 134 | rtesting.InduceFailure("patch", "TestResource"), 135 | }, 136 | ShouldErr: true, 137 | ExpectEvents: []rtesting.Event{ 138 | rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Finalize", ""), 139 | rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", 140 | `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), 141 | }, 142 | ExpectPatches: []rtesting.PatchRef{ 143 | { 144 | Group: "testing.reconciler.runtime", 145 | Kind: "TestResource", 146 | Namespace: testNamespace, 147 | Name: testName, 148 | PatchType: types.MergePatchType, 149 | Patch: []byte(`{"metadata":{"finalizers":null,"resourceVersion":"999"}}`), 150 | }, 151 | }, 152 | }, 153 | "keep finalizer on error": { 154 | Resource: resource. 155 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 156 | d.DeletionTimestamp(now) 157 | d.Finalizers(testFinalizer) 158 | }). 159 | DieReleasePtr(), 160 | ShouldErr: true, 161 | ExpectEvents: []rtesting.Event{ 162 | rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Finalize", ""), 163 | }, 164 | Metadata: map[string]interface{}{ 165 | "FinalizerError": fmt.Errorf("finalize error"), 166 | }, 167 | }, 168 | } 169 | 170 | rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 171 | var syncErr, finalizeErr error 172 | if err, ok := rtc.Metadata["SyncError"]; ok { 173 | syncErr = err.(error) 174 | } 175 | if err, ok := rtc.Metadata["FinalizerError"]; ok { 176 | finalizeErr = err.(error) 177 | } 178 | 179 | return &reconcilers.WithFinalizer[*resources.TestResource]{ 180 | Finalizer: testFinalizer, 181 | Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ 182 | Sync: func(ctx context.Context, resource *resources.TestResource) error { 183 | c.Recorder.Event(resource, corev1.EventTypeNormal, "Sync", "") 184 | return syncErr 185 | }, 186 | Finalize: func(ctx context.Context, resource *resources.TestResource) error { 187 | c.Recorder.Event(resource, corev1.EventTypeNormal, "Finalize", "") 188 | return finalizeErr 189 | }, 190 | }, 191 | } 192 | }) 193 | } 194 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /reconcilers/webhook.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "context" 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "net/http" 14 | "sync" 15 | "time" 16 | 17 | "github.com/go-logr/logr" 18 | "github.com/vmware-labs/reconciler-runtime/internal" 19 | rtime "github.com/vmware-labs/reconciler-runtime/time" 20 | "gomodules.xyz/jsonpatch/v3" 21 | admissionv1 "k8s.io/api/admission/v1" 22 | "k8s.io/apimachinery/pkg/api/equality" 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | "k8s.io/apimachinery/pkg/types" 25 | "sigs.k8s.io/controller-runtime/pkg/client" 26 | crlog "sigs.k8s.io/controller-runtime/pkg/log" 27 | "sigs.k8s.io/controller-runtime/pkg/webhook" 28 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 29 | ) 30 | 31 | // AdmissionWebhookAdapter allows using sub reconcilers to process admission webhooks. The full 32 | // suite of sub reconcilers are available, however, behavior that is generally not accepted within 33 | // a webhook is discouraged. For example, new requests against the API server are discouraged 34 | // (reading from an informer is ok), mutation requests against the API Server can cause a loop with 35 | // the webhook processing its own requests. 36 | // 37 | // All requests are allowed by default unless the response.Allowed field is explicitly unset, or 38 | // the reconciler returns an error. The raw admission request and response can be retrieved from 39 | // the context via the RetrieveAdmissionRequest and RetrieveAdmissionResponse methods, 40 | // respectively. The Result typically returned by a reconciler is unused. 41 | // 42 | // The request object is unmarshaled from the request object for most operations, and the old 43 | // object for delete operations. If the webhhook handles multiple resources or versions of the 44 | // same resource with different shapes, use of an unstructured type is recommended. 45 | // 46 | // If the resource being reconciled is mutated and the response does not already define a patch, a 47 | // json patch is computed for the mutation and set on the response. 48 | // 49 | // If the webhook can handle multiple types, use *unstructured.Unstructured as the generic type. 50 | type AdmissionWebhookAdapter[Type client.Object] struct { 51 | // Name used to identify this reconciler. Defaults to `{Type}AdmissionWebhookAdapter`. Ideally 52 | // unique, but not required to be so. 53 | // 54 | // +optional 55 | Name string 56 | 57 | // Type of resource to reconcile. Required when the generic type is not a struct. 58 | // 59 | // +optional 60 | Type Type 61 | 62 | // Reconciler is called for each reconciler request with the resource being reconciled. 63 | // Typically, Reconciler is a Sequence of multiple SubReconcilers. 64 | Reconciler SubReconciler[Type] 65 | 66 | Config Config 67 | 68 | lazyInit sync.Once 69 | } 70 | 71 | func (r *AdmissionWebhookAdapter[T]) init() { 72 | r.lazyInit.Do(func() { 73 | if internal.IsNil(r.Type) { 74 | var nilT T 75 | r.Type = newEmpty(nilT).(T) 76 | } 77 | if r.Name == "" { 78 | r.Name = fmt.Sprintf("%sAdmissionWebhookAdapter", typeName(r.Type)) 79 | } 80 | }) 81 | } 82 | 83 | func (r *AdmissionWebhookAdapter[T]) Build() *admission.Webhook { 84 | r.init() 85 | return &admission.Webhook{ 86 | Handler: r, 87 | WithContextFunc: func(ctx context.Context, req *http.Request) context.Context { 88 | log := crlog.FromContext(ctx). 89 | WithName("controller-runtime.webhook.webhooks"). 90 | WithName(r.Name). 91 | WithValues( 92 | "webhook", req.URL.Path, 93 | ) 94 | ctx = logr.NewContext(ctx, log) 95 | 96 | ctx = WithStash(ctx) 97 | 98 | ctx = StashConfig(ctx, r.Config) 99 | ctx = StashOriginalConfig(ctx, r.Config) 100 | ctx = StashResourceType(ctx, r.Type) 101 | ctx = StashOriginalResourceType(ctx, r.Type) 102 | ctx = StashHTTPRequest(ctx, req) 103 | 104 | return ctx 105 | }, 106 | } 107 | } 108 | 109 | // Handle implements admission.Handler 110 | func (r *AdmissionWebhookAdapter[T]) Handle(ctx context.Context, req admission.Request) admission.Response { 111 | r.init() 112 | 113 | log := logr.FromContextOrDiscard(ctx). 114 | WithValues( 115 | "UID", req.UID, 116 | "kind", req.Kind, 117 | "resource", req.Resource, 118 | "operation", req.Operation, 119 | ) 120 | ctx = logr.NewContext(ctx, log) 121 | 122 | resp := &admission.Response{ 123 | AdmissionResponse: admissionv1.AdmissionResponse{ 124 | UID: req.UID, 125 | // allow by default, a reconciler can flip this off, or return an err 126 | Allowed: true, 127 | }, 128 | } 129 | 130 | ctx = rtime.StashNow(ctx, time.Now()) 131 | ctx = StashAdmissionRequest(ctx, req) 132 | ctx = StashAdmissionResponse(ctx, resp) 133 | 134 | // defined for compatibility since this is not a reconciler 135 | ctx = StashRequest(ctx, Request{ 136 | NamespacedName: types.NamespacedName{ 137 | Namespace: req.Namespace, 138 | Name: req.Name, 139 | }, 140 | }) 141 | 142 | if err := r.reconcile(ctx, req, resp); err != nil { 143 | if !errors.Is(err, ErrQuiet) { 144 | log.Error(err, "reconcile error") 145 | } 146 | resp.Allowed = false 147 | if resp.Result == nil { 148 | resp.Result = &metav1.Status{ 149 | Code: 500, 150 | Message: err.Error(), 151 | } 152 | } 153 | } 154 | 155 | return *resp 156 | } 157 | 158 | func (r *AdmissionWebhookAdapter[T]) reconcile(ctx context.Context, req admission.Request, resp *admission.Response) error { 159 | log := logr.FromContextOrDiscard(ctx) 160 | 161 | resource := r.Type.DeepCopyObject().(T) 162 | resourceBytes := req.Object.Raw 163 | if req.Operation == admissionv1.Delete { 164 | resourceBytes = req.OldObject.Raw 165 | } 166 | if err := json.Unmarshal(resourceBytes, resource); err != nil { 167 | return err 168 | } 169 | 170 | if defaulter, ok := client.Object(resource).(webhook.Defaulter); ok { 171 | // resource.Default() 172 | defaulter.Default() 173 | } 174 | 175 | originalResource := resource.DeepCopyObject() 176 | if _, err := r.Reconciler.Reconcile(ctx, resource); err != nil { 177 | return err 178 | } 179 | 180 | if resp.Patches == nil && resp.Patch == nil && resp.PatchType == nil && !equality.Semantic.DeepEqual(originalResource, resource) { 181 | // add patch to response 182 | 183 | mutationBytes, err := json.Marshal(resource) 184 | if err != nil { 185 | return err 186 | } 187 | 188 | // create patch using jsonpatch v3 since it preserves order, then convert back to v2 used by AdmissionResponse 189 | patch, err := jsonpatch.CreatePatch(resourceBytes, mutationBytes) 190 | if err != nil { 191 | return err 192 | } 193 | data, _ := json.Marshal(patch) 194 | _ = json.Unmarshal(data, &resp.Patches) 195 | log.Info("mutating resource", "patch", resp.Patches) 196 | } 197 | 198 | return nil 199 | } 200 | 201 | const admissionRequestStashKey StashKey = "reconciler-runtime:admission-request" 202 | const admissionResponseStashKey StashKey = "reconciler-runtime:admission-response" 203 | const httpRequestStashKey StashKey = "reconciler-runtime:http-request" 204 | 205 | func StashAdmissionRequest(ctx context.Context, req admission.Request) context.Context { 206 | return context.WithValue(ctx, admissionRequestStashKey, req) 207 | } 208 | 209 | // RetrieveAdmissionRequest returns the admission Request from the context, or empty if not found. 210 | func RetrieveAdmissionRequest(ctx context.Context) admission.Request { 211 | value := ctx.Value(admissionRequestStashKey) 212 | if req, ok := value.(admission.Request); ok { 213 | return req 214 | } 215 | return admission.Request{} 216 | } 217 | 218 | func StashAdmissionResponse(ctx context.Context, resp *admission.Response) context.Context { 219 | return context.WithValue(ctx, admissionResponseStashKey, resp) 220 | } 221 | 222 | // RetrieveAdmissionResponse returns the admission Response from the context, or nil if not found. 223 | func RetrieveAdmissionResponse(ctx context.Context) *admission.Response { 224 | value := ctx.Value(admissionResponseStashKey) 225 | if resp, ok := value.(*admission.Response); ok { 226 | return resp 227 | } 228 | return nil 229 | } 230 | 231 | func StashHTTPRequest(ctx context.Context, req *http.Request) context.Context { 232 | return context.WithValue(ctx, httpRequestStashKey, req) 233 | } 234 | 235 | // RetrieveHTTPRequest returns the http Request from the context, or nil if not found. 236 | func RetrieveHTTPRequest(ctx context.Context) *http.Request { 237 | value := ctx.Value(httpRequestStashKey) 238 | if req, ok := value.(*http.Request); ok { 239 | return req 240 | } 241 | return nil 242 | } 243 | -------------------------------------------------------------------------------- /tracker/enqueue_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package tracker_test 7 | 8 | import ( 9 | "sort" 10 | "testing" 11 | "time" 12 | 13 | "github.com/google/go-cmp/cmp" 14 | "github.com/vmware-labs/reconciler-runtime/internal/resources" 15 | "github.com/vmware-labs/reconciler-runtime/tracker" 16 | corev1 "k8s.io/api/core/v1" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/apimachinery/pkg/labels" 19 | "k8s.io/apimachinery/pkg/runtime" 20 | "k8s.io/apimachinery/pkg/types" 21 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 22 | "sigs.k8s.io/controller-runtime/pkg/client" 23 | ) 24 | 25 | func TestTracker(t *testing.T) { 26 | scheme := runtime.NewScheme() 27 | _ = resources.AddToScheme(scheme) 28 | _ = clientgoscheme.AddToScheme(scheme) 29 | 30 | referrer := &resources.TestResource{ 31 | ObjectMeta: metav1.ObjectMeta{ 32 | Namespace: "test-namespace", 33 | Name: "test-name", 34 | }, 35 | } 36 | referrerOther := &resources.TestResource{ 37 | ObjectMeta: metav1.ObjectMeta{ 38 | Namespace: "test-namespace", 39 | Name: "other-name", 40 | }, 41 | } 42 | 43 | referent := &corev1.ConfigMap{ 44 | ObjectMeta: metav1.ObjectMeta{ 45 | Namespace: "test-namespace", 46 | Name: "test-name", 47 | Labels: map[string]string{ 48 | "app": "test", 49 | }, 50 | }, 51 | } 52 | 53 | type track struct { 54 | ref tracker.Reference 55 | obj client.Object 56 | } 57 | 58 | tests := map[string]struct { 59 | lease time.Duration 60 | tracks []track 61 | obj client.Object 62 | expected []types.NamespacedName 63 | }{ 64 | "empty tracker matches nothing": { 65 | lease: time.Hour, 66 | obj: referent, 67 | expected: []types.NamespacedName{}, 68 | }, 69 | "match by name": { 70 | lease: time.Hour, 71 | tracks: []track{ 72 | { 73 | ref: tracker.Reference{ 74 | APIGroup: "", 75 | Kind: "ConfigMap", 76 | Namespace: "test-namespace", 77 | Name: "test-name", 78 | }, 79 | obj: referrer, 80 | }, 81 | }, 82 | obj: referent, 83 | expected: []types.NamespacedName{ 84 | {Namespace: "test-namespace", Name: "test-name"}, 85 | }, 86 | }, 87 | "multiple matches by name": { 88 | lease: time.Hour, 89 | tracks: []track{ 90 | { 91 | ref: tracker.Reference{ 92 | APIGroup: "", 93 | Kind: "ConfigMap", 94 | Namespace: "test-namespace", 95 | Name: "test-name", 96 | }, 97 | obj: referrer, 98 | }, 99 | { 100 | ref: tracker.Reference{ 101 | APIGroup: "", 102 | Kind: "ConfigMap", 103 | Namespace: "test-namespace", 104 | Name: "test-name", 105 | }, 106 | obj: referrerOther, 107 | }, 108 | }, 109 | obj: referent, 110 | expected: []types.NamespacedName{ 111 | {Namespace: "test-namespace", Name: "other-name"}, 112 | {Namespace: "test-namespace", Name: "test-name"}, 113 | }, 114 | }, 115 | "does not match other names": { 116 | lease: time.Hour, 117 | tracks: []track{ 118 | { 119 | ref: tracker.Reference{ 120 | APIGroup: "", 121 | Kind: "ConfigMap", 122 | Namespace: "test-namespace", 123 | Name: "other-name", 124 | }, 125 | obj: referrer, 126 | }, 127 | }, 128 | obj: referent, 129 | expected: []types.NamespacedName{}, 130 | }, 131 | "does not match other namespaces": { 132 | lease: time.Hour, 133 | tracks: []track{ 134 | { 135 | ref: tracker.Reference{ 136 | APIGroup: "", 137 | Kind: "ConfigMap", 138 | Namespace: "other-namespace", 139 | Name: "test-name", 140 | }, 141 | obj: referrer, 142 | }, 143 | }, 144 | obj: referent, 145 | expected: []types.NamespacedName{}, 146 | }, 147 | "does not match other groups": { 148 | lease: time.Hour, 149 | tracks: []track{ 150 | { 151 | ref: tracker.Reference{ 152 | APIGroup: "fake", 153 | Kind: "ConfigMap", 154 | Namespace: "test-namespace", 155 | Name: "test-name", 156 | }, 157 | obj: referrer, 158 | }, 159 | }, 160 | obj: referent, 161 | expected: []types.NamespacedName{}, 162 | }, 163 | "does not match other kinds": { 164 | lease: time.Hour, 165 | tracks: []track{ 166 | { 167 | ref: tracker.Reference{ 168 | APIGroup: "", 169 | Kind: "Secret", 170 | Namespace: "test-namespace", 171 | Name: "test-name", 172 | }, 173 | obj: referrer, 174 | }, 175 | }, 176 | obj: referent, 177 | expected: []types.NamespacedName{}, 178 | }, 179 | "match by selector": { 180 | lease: time.Hour, 181 | tracks: []track{ 182 | { 183 | ref: tracker.Reference{ 184 | APIGroup: "", 185 | Kind: "ConfigMap", 186 | Selector: labels.SelectorFromSet(map[string]string{ 187 | "app": "test", 188 | }), 189 | }, 190 | obj: referrer, 191 | }, 192 | }, 193 | obj: referent, 194 | expected: []types.NamespacedName{ 195 | {Namespace: "test-namespace", Name: "test-name"}, 196 | }, 197 | }, 198 | "match by selector in namespace": { 199 | lease: time.Hour, 200 | tracks: []track{ 201 | { 202 | ref: tracker.Reference{ 203 | APIGroup: "", 204 | Kind: "ConfigMap", 205 | Namespace: "test-namespace", 206 | Selector: labels.SelectorFromSet(map[string]string{ 207 | "app": "test", 208 | }), 209 | }, 210 | obj: referrer, 211 | }, 212 | }, 213 | obj: referent, 214 | expected: []types.NamespacedName{ 215 | {Namespace: "test-namespace", Name: "test-name"}, 216 | }, 217 | }, 218 | "no match by selector in wrong namespace": { 219 | lease: time.Hour, 220 | tracks: []track{ 221 | { 222 | ref: tracker.Reference{ 223 | APIGroup: "", 224 | Kind: "ConfigMap", 225 | Namespace: "other-namespace", 226 | Selector: labels.SelectorFromSet(map[string]string{ 227 | "app": "test", 228 | }), 229 | }, 230 | obj: referrer, 231 | }, 232 | }, 233 | obj: referent, 234 | expected: []types.NamespacedName{}, 235 | }, 236 | "no match by selector missing label": { 237 | lease: time.Hour, 238 | tracks: []track{ 239 | { 240 | ref: tracker.Reference{ 241 | APIGroup: "", 242 | Kind: "ConfigMap", 243 | Namespace: "test-namespace", 244 | Selector: labels.SelectorFromSet(map[string]string{ 245 | "app": "other", 246 | }), 247 | }, 248 | obj: referrer, 249 | }, 250 | }, 251 | obj: referent, 252 | expected: []types.NamespacedName{}, 253 | }, 254 | "multiple matches by selector": { 255 | lease: time.Hour, 256 | tracks: []track{ 257 | { 258 | ref: tracker.Reference{ 259 | APIGroup: "", 260 | Kind: "ConfigMap", 261 | Selector: labels.SelectorFromSet(map[string]string{ 262 | "app": "test", 263 | }), 264 | }, 265 | obj: referrer, 266 | }, 267 | { 268 | ref: tracker.Reference{ 269 | APIGroup: "", 270 | Kind: "ConfigMap", 271 | Selector: labels.SelectorFromSet(map[string]string{ 272 | "app": "test", 273 | }), 274 | }, 275 | obj: referrerOther, 276 | }, 277 | }, 278 | obj: referent, 279 | expected: []types.NamespacedName{ 280 | {Namespace: "test-namespace", Name: "other-name"}, 281 | {Namespace: "test-namespace", Name: "test-name"}, 282 | }, 283 | }, 284 | "no match by name for expired lease": { 285 | lease: time.Nanosecond, 286 | tracks: []track{ 287 | { 288 | ref: tracker.Reference{ 289 | APIGroup: "", 290 | Kind: "ConfigMap", 291 | Namespace: "test-namespace", 292 | Name: "test-name", 293 | }, 294 | obj: referrer, 295 | }, 296 | }, 297 | obj: referent, 298 | expected: []types.NamespacedName{}, 299 | }, 300 | "no match by selector for expired lease": { 301 | lease: time.Nanosecond, 302 | tracks: []track{ 303 | { 304 | ref: tracker.Reference{ 305 | APIGroup: "", 306 | Kind: "ConfigMap", 307 | Selector: labels.SelectorFromSet(map[string]string{ 308 | "app": "test", 309 | }), 310 | }, 311 | obj: referrer, 312 | }, 313 | }, 314 | obj: referent, 315 | expected: []types.NamespacedName{}, 316 | }, 317 | } 318 | 319 | for name, tc := range tests { 320 | t.Run(name, func(t *testing.T) { 321 | tracker := tracker.New(scheme, tc.lease) 322 | for _, track := range tc.tracks { 323 | tracker.TrackReference(track.ref, track.obj) 324 | } 325 | 326 | actual, _ := tracker.GetObservers(tc.obj) 327 | sort.Slice(actual, func(i, j int) bool { 328 | if in, jn := actual[i].Namespace, actual[j].Namespace; in != jn { 329 | return in < jn 330 | } 331 | return actual[i].Name < actual[j].Name 332 | }) 333 | expected := tc.expected 334 | if diff := cmp.Diff(expected, actual); diff != "" { 335 | t.Errorf("expected observers to match actual observers: %s", diff) 336 | } 337 | }) 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /reconcilers/reconcilers.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "context" 10 | "errors" 11 | "fmt" 12 | "reflect" 13 | 14 | "github.com/go-logr/logr" 15 | "k8s.io/apimachinery/pkg/runtime" 16 | "k8s.io/apimachinery/pkg/runtime/schema" 17 | "k8s.io/apimachinery/pkg/types" 18 | ctrl "sigs.k8s.io/controller-runtime" 19 | "sigs.k8s.io/controller-runtime/pkg/builder" 20 | "sigs.k8s.io/controller-runtime/pkg/client" 21 | "sigs.k8s.io/controller-runtime/pkg/handler" 22 | ) 23 | 24 | // SubReconciler are participants in a larger reconciler request. The resource 25 | // being reconciled is passed directly to the sub reconciler. 26 | type SubReconciler[Type client.Object] interface { 27 | SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error 28 | Reconcile(ctx context.Context, resource Type) (Result, error) 29 | } 30 | 31 | var ( 32 | // ErrQuiet are not logged or recorded as events within reconciler-runtime. 33 | // They are propagated as errors unless otherwise defined. 34 | // 35 | // Test for ErrQuiet with errors.Is(err, ErrQuiet) 36 | ErrQuiet = fmt.Errorf("quiet errors are returned as errors, but not logged or recorded") 37 | 38 | // ErrHaltSubReconcilers is an error that instructs SubReconcilers to stop processing the request, 39 | // while the root reconciler proceeds as if there was no error. ErrHaltSubReconcilers may be 40 | // wrapped by other errors. 41 | // 42 | // ErrHaltSubReconcilers wraps ErrQuiet to suppress spurious logs. 43 | // 44 | // See documentation for the specific SubReconciler caller to see how they handle this case. 45 | ErrHaltSubReconcilers = fmt.Errorf("stop processing SubReconcilers, without returning an error: %w", ErrQuiet) 46 | 47 | // Deprecated HaltSubReconcilers use ErrHaltSubReconcilers instead 48 | HaltSubReconcilers = ErrHaltSubReconcilers 49 | ) 50 | 51 | const requestStashKey StashKey = "reconciler-runtime:request" 52 | const configStashKey StashKey = "reconciler-runtime:config" 53 | const originalConfigStashKey StashKey = "reconciler-runtime:originalConfig" 54 | const resourceTypeStashKey StashKey = "reconciler-runtime:resourceType" 55 | const originalResourceTypeStashKey StashKey = "reconciler-runtime:originalResourceType" 56 | const additionalConfigsStashKey StashKey = "reconciler-runtime:additionalConfigs" 57 | 58 | func StashRequest(ctx context.Context, req Request) context.Context { 59 | return context.WithValue(ctx, requestStashKey, req) 60 | } 61 | 62 | // RetrieveRequest returns the reconciler Request from the context, or empty if not found. 63 | func RetrieveRequest(ctx context.Context) Request { 64 | value := ctx.Value(requestStashKey) 65 | if req, ok := value.(Request); ok { 66 | return req 67 | } 68 | return Request{} 69 | } 70 | 71 | func StashConfig(ctx context.Context, config Config) context.Context { 72 | return context.WithValue(ctx, configStashKey, config) 73 | } 74 | 75 | // RetrieveConfig returns the Config from the context. An error is returned if not found. 76 | func RetrieveConfig(ctx context.Context) (Config, error) { 77 | value := ctx.Value(configStashKey) 78 | if config, ok := value.(Config); ok { 79 | return config, nil 80 | } 81 | return Config{}, fmt.Errorf("config must exist on the context. Check that the context is from a ResourceReconciler or WithConfig") 82 | } 83 | 84 | // RetrieveConfigOrDie returns the Config from the context. Panics if not found. 85 | func RetrieveConfigOrDie(ctx context.Context) Config { 86 | config, err := RetrieveConfig(ctx) 87 | if err != nil { 88 | panic(err) 89 | } 90 | return config 91 | } 92 | 93 | func StashOriginalConfig(ctx context.Context, resourceConfig Config) context.Context { 94 | return context.WithValue(ctx, originalConfigStashKey, resourceConfig) 95 | } 96 | 97 | // RetrieveOriginalConfig returns the Config from the context used to load the reconciled resource. An 98 | // error is returned if not found. 99 | func RetrieveOriginalConfig(ctx context.Context) (Config, error) { 100 | value := ctx.Value(originalConfigStashKey) 101 | if config, ok := value.(Config); ok { 102 | return config, nil 103 | } 104 | return Config{}, fmt.Errorf("resource config must exist on the context. Check that the context is from a ResourceReconciler") 105 | } 106 | 107 | // RetrieveOriginalConfigOrDie returns the Config from the context used to load the reconciled resource. 108 | // Panics if not found. 109 | func RetrieveOriginalConfigOrDie(ctx context.Context) Config { 110 | config, err := RetrieveOriginalConfig(ctx) 111 | if err != nil { 112 | panic(err) 113 | } 114 | return config 115 | } 116 | 117 | func StashResourceType(ctx context.Context, currentType client.Object) context.Context { 118 | return context.WithValue(ctx, resourceTypeStashKey, currentType) 119 | } 120 | 121 | // RetrieveResourceType returns the reconciled resource type object, or nil if not found. 122 | func RetrieveResourceType(ctx context.Context) client.Object { 123 | value := ctx.Value(resourceTypeStashKey) 124 | if currentType, ok := value.(client.Object); ok { 125 | return currentType 126 | } 127 | return nil 128 | } 129 | 130 | func StashOriginalResourceType(ctx context.Context, resourceType client.Object) context.Context { 131 | return context.WithValue(ctx, originalResourceTypeStashKey, resourceType) 132 | } 133 | 134 | // RetrieveOriginalResourceType returns the reconciled resource type object, or nil if not found. 135 | func RetrieveOriginalResourceType(ctx context.Context) client.Object { 136 | value := ctx.Value(originalResourceTypeStashKey) 137 | if resourceType, ok := value.(client.Object); ok { 138 | return resourceType 139 | } 140 | return nil 141 | } 142 | 143 | func StashAdditionalConfigs(ctx context.Context, additionalConfigs map[string]Config) context.Context { 144 | return context.WithValue(ctx, additionalConfigsStashKey, additionalConfigs) 145 | } 146 | 147 | // RetrieveAdditionalConfigs returns the additional configs defined for this request. Uncommon 148 | // outside of the context of a test. An empty map is returned when no value is stashed. 149 | func RetrieveAdditionalConfigs(ctx context.Context) map[string]Config { 150 | value := ctx.Value(additionalConfigsStashKey) 151 | if additionalConfigs, ok := value.(map[string]Config); ok { 152 | return additionalConfigs 153 | } 154 | return map[string]Config{} 155 | } 156 | 157 | func typeName(i interface{}) string { 158 | if obj, ok := i.(client.Object); ok { 159 | kind := obj.GetObjectKind().GroupVersionKind().Kind 160 | if kind != "" { 161 | return kind 162 | } 163 | } 164 | 165 | t := reflect.TypeOf(i) 166 | // TODO do we need this? 167 | if t.Kind() == reflect.Ptr { 168 | t = t.Elem() 169 | } 170 | return t.Name() 171 | } 172 | 173 | func gvk(c client.Client, obj runtime.Object) schema.GroupVersionKind { 174 | gvk, err := c.GroupVersionKindFor(obj) 175 | if err != nil { 176 | return schema.GroupVersionKind{} 177 | } 178 | return gvk 179 | } 180 | 181 | func namespaceName(obj client.Object) types.NamespacedName { 182 | return types.NamespacedName{ 183 | Namespace: obj.GetNamespace(), 184 | Name: obj.GetName(), 185 | } 186 | } 187 | 188 | // AggregateResults combines multiple results into a single result. If any result requests 189 | // requeue, the aggregate is requeued. The shortest non-zero requeue after is the aggregate value. 190 | func AggregateResults(results ...Result) Result { 191 | aggregate := Result{} 192 | for _, result := range results { 193 | if result.RequeueAfter != 0 && (aggregate.RequeueAfter == 0 || result.RequeueAfter < aggregate.RequeueAfter) { 194 | aggregate.RequeueAfter = result.RequeueAfter 195 | } 196 | if result.Requeue { 197 | aggregate.Requeue = true 198 | } 199 | } 200 | return aggregate 201 | } 202 | 203 | // MergeMaps flattens a sequence of maps into a single map. Keys in latter maps 204 | // overwrite previous keys. None of the arguments are mutated. 205 | func MergeMaps(maps ...map[string]string) map[string]string { 206 | out := map[string]string{} 207 | for _, m := range maps { 208 | for k, v := range m { 209 | out[k] = v 210 | } 211 | } 212 | return out 213 | } 214 | 215 | func EnqueueTracked(ctx context.Context) handler.EventHandler { 216 | c := RetrieveConfigOrDie(ctx) 217 | log := logr.FromContextOrDiscard(ctx) 218 | 219 | return handler.EnqueueRequestsFromMapFunc( 220 | func(ctx context.Context, obj client.Object) []Request { 221 | var requests []Request 222 | 223 | items, err := c.Tracker.GetObservers(obj) 224 | if err != nil { 225 | if !errors.Is(err, ErrQuiet) { 226 | log.Error(err, "unable to get tracked requests") 227 | } 228 | return nil 229 | } 230 | 231 | for _, item := range items { 232 | requests = append(requests, Request{NamespacedName: item}) 233 | } 234 | 235 | return requests 236 | }, 237 | ) 238 | } 239 | 240 | // replaceWithEmpty overwrite the underlying value with it's empty value 241 | func replaceWithEmpty(x interface{}) { 242 | v := reflect.ValueOf(x).Elem() 243 | v.Set(reflect.Zero(v.Type())) 244 | } 245 | 246 | // newEmpty returns a new empty value of the same underlying type, preserving the existing value 247 | func newEmpty(x interface{}) interface{} { 248 | t := reflect.TypeOf(x).Elem() 249 | return reflect.New(t).Interface() 250 | } 251 | -------------------------------------------------------------------------------- /reconcilers/aggregate.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "context" 10 | "errors" 11 | "fmt" 12 | "reflect" 13 | "sync" 14 | "time" 15 | 16 | "github.com/go-logr/logr" 17 | apierrs "k8s.io/apimachinery/pkg/api/errors" 18 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 19 | ctrl "sigs.k8s.io/controller-runtime" 20 | "sigs.k8s.io/controller-runtime/pkg/client" 21 | "sigs.k8s.io/controller-runtime/pkg/controller" 22 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 23 | 24 | "github.com/vmware-labs/reconciler-runtime/duck" 25 | "github.com/vmware-labs/reconciler-runtime/internal" 26 | rtime "github.com/vmware-labs/reconciler-runtime/time" 27 | "github.com/vmware-labs/reconciler-runtime/tracker" 28 | ) 29 | 30 | var ( 31 | _ reconcile.Reconciler = (*AggregateReconciler[client.Object])(nil) 32 | ) 33 | 34 | // AggregateReconciler is a controller-runtime reconciler that reconciles a specific resource. The 35 | // Type resource is fetched for the reconciler 36 | // request and passed in turn to each SubReconciler. Finally, the reconciled 37 | // resource's status is compared with the original status, updating the API 38 | // server if needed. 39 | type AggregateReconciler[Type client.Object] struct { 40 | // Name used to identify this reconciler. Defaults to `{Type}ResourceReconciler`. Ideally 41 | // unique, but not required to be so. 42 | // 43 | // +optional 44 | Name string 45 | 46 | // Setup performs initialization on the manager and builder this reconciler 47 | // will run with. It's common to setup field indexes and watch resources. 48 | // 49 | // +optional 50 | Setup func(ctx context.Context, mgr Manager, bldr *Builder) error 51 | 52 | // Type of resource to reconcile. Required when the generic type is not a 53 | // struct, or is unstructured. 54 | // 55 | // +optional 56 | Type Type 57 | 58 | // Request of resource to reconcile. Only the specific resource matching the namespace and name 59 | // is reconciled. The namespace may be empty for cluster scoped resources. 60 | Request Request 61 | 62 | // Reconciler is called for each reconciler request with the resource being reconciled. 63 | // Typically, Reconciler is a Sequence of multiple SubReconcilers. 64 | // 65 | // When ErrHaltSubReconcilers is returned as an error, execution continues as if no error was 66 | // returned. 67 | // 68 | // +optional 69 | Reconciler SubReconciler[Type] 70 | 71 | // DesiredResource returns the desired resource to create/update, or nil if 72 | // the resource should not exist. 73 | // 74 | // +optional 75 | DesiredResource func(ctx context.Context, resource Type) (Type, error) 76 | 77 | // HarmonizeImmutableFields allows fields that are immutable on the current 78 | // object to be copied to the desired object in order to avoid creating 79 | // updates which are guaranteed to fail. 80 | // 81 | // +optional 82 | HarmonizeImmutableFields func(current, desired Type) 83 | 84 | // MergeBeforeUpdate copies desired fields on to the current object before 85 | // calling update. Typically fields to copy are the Spec, Labels and 86 | // Annotations. 87 | MergeBeforeUpdate func(current, desired Type) 88 | 89 | // Sanitize is called with an object before logging the value. Any value may 90 | // be returned. A meaningful subset of the resource is typically returned, 91 | // like the Spec. 92 | // 93 | // +optional 94 | Sanitize func(resource Type) interface{} 95 | 96 | Config Config 97 | 98 | // stamp manages the lifecycle of the aggregated resource. 99 | stamp *ResourceManager[Type] 100 | lazyInit sync.Once 101 | } 102 | 103 | func (r *AggregateReconciler[T]) init() { 104 | r.lazyInit.Do(func() { 105 | if internal.IsNil(r.Type) { 106 | var nilT T 107 | r.Type = newEmpty(nilT).(T) 108 | } 109 | if r.Name == "" { 110 | r.Name = fmt.Sprintf("%sAggregateReconciler", typeName(r.Type)) 111 | } 112 | if r.Reconciler == nil { 113 | r.Reconciler = Sequence[T]{} 114 | } 115 | if r.DesiredResource == nil { 116 | r.DesiredResource = func(ctx context.Context, resource T) (T, error) { 117 | return resource, nil 118 | } 119 | } 120 | 121 | r.stamp = &ResourceManager[T]{ 122 | Name: r.Name, 123 | Type: r.Type, 124 | 125 | HarmonizeImmutableFields: r.HarmonizeImmutableFields, 126 | MergeBeforeUpdate: r.MergeBeforeUpdate, 127 | Sanitize: r.Sanitize, 128 | } 129 | }) 130 | } 131 | 132 | func (r *AggregateReconciler[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { 133 | _, err := r.SetupWithManagerYieldingController(ctx, mgr) 134 | return err 135 | } 136 | 137 | func (r *AggregateReconciler[T]) SetupWithManagerYieldingController(ctx context.Context, mgr ctrl.Manager) (controller.Controller, error) { 138 | r.init() 139 | 140 | log := logr.FromContextOrDiscard(ctx). 141 | WithName(r.Name). 142 | WithValues( 143 | "resourceType", gvk(r.Config, r.Type), 144 | "request", r.Request, 145 | ) 146 | ctx = logr.NewContext(ctx, log) 147 | 148 | ctx = StashConfig(ctx, r.Config) 149 | ctx = StashOriginalConfig(ctx, r.Config) 150 | ctx = StashResourceType(ctx, r.Type) 151 | ctx = StashOriginalResourceType(ctx, r.Type) 152 | 153 | if err := r.validate(ctx); err != nil { 154 | return nil, err 155 | } 156 | 157 | bldr := ctrl.NewControllerManagedBy(mgr) 158 | if !duck.IsDuck(r.Type, r.Config.Scheme()) { 159 | bldr.For(r.Type) 160 | } else { 161 | gvk, err := r.Config.GroupVersionKindFor(r.Type) 162 | if err != nil { 163 | return nil, err 164 | } 165 | apiVersion, kind := gvk.ToAPIVersionAndKind() 166 | u := &unstructured.Unstructured{} 167 | u.SetAPIVersion(apiVersion) 168 | u.SetKind(kind) 169 | bldr.For(u) 170 | } 171 | if r.Setup != nil { 172 | if err := r.Setup(ctx, mgr, bldr); err != nil { 173 | return nil, err 174 | } 175 | } 176 | if err := r.Reconciler.SetupWithManager(ctx, mgr, bldr); err != nil { 177 | return nil, err 178 | } 179 | if err := r.stamp.Setup(ctx); err != nil { 180 | return nil, err 181 | } 182 | return bldr.Build(r) 183 | } 184 | 185 | func (r *AggregateReconciler[T]) validate(ctx context.Context) error { 186 | // validate Request value 187 | if r.Request.Name == "" { 188 | return fmt.Errorf("AggregateReconciler %q must define Request", r.Name) 189 | } 190 | 191 | // validate Reconciler value 192 | if r.Reconciler == nil && r.DesiredResource == nil { 193 | return fmt.Errorf("AggregateReconciler %q must define Reconciler and/or DesiredResource", r.Name) 194 | } 195 | 196 | return nil 197 | } 198 | 199 | func (r *AggregateReconciler[T]) Reconcile(ctx context.Context, req Request) (Result, error) { 200 | r.init() 201 | 202 | if req.Namespace != r.Request.Namespace || req.Name != r.Request.Name { 203 | // ignore other requests 204 | return Result{}, nil 205 | } 206 | 207 | ctx = WithStash(ctx) 208 | 209 | c := r.Config 210 | 211 | log := logr.FromContextOrDiscard(ctx). 212 | WithName(r.Name). 213 | WithValues("resourceType", gvk(c, r.Type)) 214 | ctx = logr.NewContext(ctx, log) 215 | 216 | ctx = rtime.StashNow(ctx, time.Now()) 217 | ctx = StashRequest(ctx, req) 218 | ctx = StashConfig(ctx, c) 219 | ctx = StashOriginalConfig(ctx, r.Config) 220 | ctx = StashOriginalResourceType(ctx, r.Type) 221 | ctx = StashResourceType(ctx, r.Type) 222 | 223 | resource := r.Type.DeepCopyObject().(T) 224 | if err := c.Get(ctx, req.NamespacedName, resource); err != nil { 225 | if apierrs.IsNotFound(err) { 226 | // not found is ok 227 | resource.SetNamespace(r.Request.Namespace) 228 | resource.SetName(r.Request.Name) 229 | } else { 230 | if !errors.Is(err, ErrQuiet) { 231 | log.Error(err, "unable to fetch resource") 232 | } 233 | return Result{}, err 234 | } 235 | } 236 | 237 | if resource.GetDeletionTimestamp() != nil { 238 | // resource is being deleted, nothing to do 239 | return Result{}, nil 240 | } 241 | 242 | result, err := r.Reconciler.Reconcile(ctx, resource) 243 | if err != nil && !errors.Is(err, ErrHaltSubReconcilers) { 244 | return result, err 245 | } 246 | 247 | // hack, ignore track requests from the child reconciler, we have it covered 248 | ctx = StashConfig(ctx, Config{ 249 | Client: c.Client, 250 | APIReader: c.APIReader, 251 | Recorder: c.Recorder, 252 | Tracker: tracker.New(c.Scheme(), 0), 253 | }) 254 | desired, err := r.desiredResource(ctx, resource) 255 | if err != nil { 256 | return Result{}, err 257 | } 258 | _, err = r.stamp.Manage(ctx, resource, resource, desired) 259 | if err != nil { 260 | return Result{}, err 261 | } 262 | return result, nil 263 | } 264 | 265 | func (r *AggregateReconciler[T]) desiredResource(ctx context.Context, resource T) (T, error) { 266 | var nilT T 267 | 268 | if resource.GetDeletionTimestamp() != nil { 269 | // the reconciled resource is pending deletion, cleanup the child resource 270 | return nilT, nil 271 | } 272 | 273 | fn := reflect.ValueOf(r.DesiredResource) 274 | out := fn.Call([]reflect.Value{ 275 | reflect.ValueOf(ctx), 276 | reflect.ValueOf(resource.DeepCopyObject()), 277 | }) 278 | var obj T 279 | if !out[0].IsNil() { 280 | obj = out[0].Interface().(T) 281 | } 282 | var err error 283 | if !out[1].IsNil() { 284 | err = out[1].Interface().(error) 285 | } 286 | return obj, err 287 | } 288 | -------------------------------------------------------------------------------- /reconcilers/cast_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers_test 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "testing" 12 | 13 | diecorev1 "dies.dev/apis/core/v1" 14 | diemetav1 "dies.dev/apis/meta/v1" 15 | "github.com/vmware-labs/reconciler-runtime/apis" 16 | "github.com/vmware-labs/reconciler-runtime/internal/resources" 17 | "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" 18 | "github.com/vmware-labs/reconciler-runtime/reconcilers" 19 | rtesting "github.com/vmware-labs/reconciler-runtime/testing" 20 | appsv1 "k8s.io/api/apps/v1" 21 | corev1 "k8s.io/api/core/v1" 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | "k8s.io/apimachinery/pkg/runtime" 24 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 25 | ) 26 | 27 | func TestCastResource(t *testing.T) { 28 | testNamespace := "test-namespace" 29 | testName := "test-resource" 30 | 31 | scheme := runtime.NewScheme() 32 | _ = resources.AddToScheme(scheme) 33 | _ = clientgoscheme.AddToScheme(scheme) 34 | 35 | resource := dies.TestResourceBlank. 36 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 37 | d.Namespace(testNamespace) 38 | d.Name(testName) 39 | }). 40 | StatusDie(func(d *dies.TestResourceStatusDie) { 41 | d.ConditionsDie( 42 | diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), 43 | ) 44 | }) 45 | 46 | rts := rtesting.SubReconcilerTests[*resources.TestResource]{ 47 | "sync success": { 48 | Resource: resource. 49 | SpecDie(func(d *dies.TestResourceSpecDie) { 50 | d.TemplateDie(func(d *diecorev1.PodTemplateSpecDie) { 51 | d.SpecDie(func(d *diecorev1.PodSpecDie) { 52 | d.ContainerDie("test-container", func(d *diecorev1.ContainerDie) {}) 53 | }) 54 | }) 55 | }). 56 | DieReleasePtr(), 57 | Metadata: map[string]interface{}{ 58 | "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 59 | return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ 60 | Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ 61 | Sync: func(ctx context.Context, resource *appsv1.Deployment) error { 62 | reconcilers.RetrieveConfigOrDie(ctx). 63 | Recorder.Event(resource, corev1.EventTypeNormal, "Test", 64 | resource.Spec.Template.Spec.Containers[0].Name) 65 | return nil 66 | }, 67 | }, 68 | } 69 | }, 70 | }, 71 | ExpectEvents: []rtesting.Event{ 72 | rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Test", "test-container"), 73 | }, 74 | }, 75 | "cast mutation": { 76 | Resource: resource.DieReleasePtr(), 77 | ExpectResource: resource. 78 | SpecDie(func(d *dies.TestResourceSpecDie) { 79 | d.TemplateDie(func(d *diecorev1.PodTemplateSpecDie) { 80 | d.MetadataDie(func(d *diemetav1.ObjectMetaDie) { 81 | d.Name("mutation") 82 | }) 83 | }) 84 | }). 85 | DieReleasePtr(), 86 | Metadata: map[string]interface{}{ 87 | "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 88 | return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ 89 | Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ 90 | Sync: func(ctx context.Context, resource *appsv1.Deployment) error { 91 | // mutation that exists on the original resource and will be reflected 92 | resource.Spec.Template.Name = "mutation" 93 | // mutation that does not exists on the original resource and will be dropped 94 | resource.Spec.Paused = true 95 | return nil 96 | }, 97 | }, 98 | } 99 | }, 100 | }, 101 | }, 102 | "return subreconciler result": { 103 | Resource: resource.DieReleasePtr(), 104 | Metadata: map[string]interface{}{ 105 | "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 106 | return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ 107 | Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ 108 | SyncWithResult: func(ctx context.Context, resource *appsv1.Deployment) (reconcilers.Result, error) { 109 | return reconcilers.Result{Requeue: true}, nil 110 | }, 111 | }, 112 | } 113 | }, 114 | }, 115 | ExpectedResult: reconcilers.Result{Requeue: true}, 116 | }, 117 | "return subreconciler err, preserves result and status update": { 118 | Resource: resource.DieReleasePtr(), 119 | Metadata: map[string]interface{}{ 120 | "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 121 | return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ 122 | Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ 123 | SyncWithResult: func(ctx context.Context, resource *appsv1.Deployment) (reconcilers.Result, error) { 124 | resource.Status.Conditions[0] = appsv1.DeploymentCondition{ 125 | Type: apis.ConditionReady, 126 | Status: corev1.ConditionFalse, 127 | Reason: "Failed", 128 | Message: "expected error", 129 | } 130 | return reconcilers.Result{Requeue: true}, fmt.Errorf("subreconciler error") 131 | }, 132 | }, 133 | } 134 | }, 135 | }, 136 | ExpectedResult: reconcilers.Result{Requeue: true}, 137 | ExpectResource: resource. 138 | StatusDie(func(d *dies.TestResourceStatusDie) { 139 | d.ConditionsDie( 140 | diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionFalse).Reason("Failed").Message("expected error"), 141 | ) 142 | }). 143 | DieReleasePtr(), 144 | ShouldErr: true, 145 | }, 146 | "marshal error": { 147 | Resource: resource. 148 | SpecDie(func(d *dies.TestResourceSpecDie) { 149 | d.ErrOnMarshal(true) 150 | }). 151 | DieReleasePtr(), 152 | Metadata: map[string]interface{}{ 153 | "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 154 | return &reconcilers.CastResource[*resources.TestResource, *resources.TestResource]{ 155 | Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ 156 | Sync: func(ctx context.Context, resource *resources.TestResource) error { 157 | c.Recorder.Event(resource, corev1.EventTypeNormal, "Test", resource.Name) 158 | return nil 159 | }, 160 | }, 161 | } 162 | }, 163 | }, 164 | ShouldErr: true, 165 | }, 166 | "unmarshal error": { 167 | Resource: resource. 168 | SpecDie(func(d *dies.TestResourceSpecDie) { 169 | d.ErrOnUnmarshal(true) 170 | }). 171 | DieReleasePtr(), 172 | Metadata: map[string]interface{}{ 173 | "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 174 | return &reconcilers.CastResource[*resources.TestResource, *resources.TestResource]{ 175 | Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ 176 | Sync: func(ctx context.Context, resource *resources.TestResource) error { 177 | c.Recorder.Event(resource, corev1.EventTypeNormal, "Test", resource.Name) 178 | return nil 179 | }, 180 | }, 181 | } 182 | }, 183 | }, 184 | ShouldErr: true, 185 | }, 186 | "cast mutation patch error": { 187 | Resource: resource.DieReleasePtr(), 188 | Metadata: map[string]interface{}{ 189 | "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 190 | return &reconcilers.CastResource[*resources.TestResource, *resources.TestResource]{ 191 | Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ 192 | Sync: func(ctx context.Context, r *resources.TestResource) error { 193 | r.Spec.ErrOnMarshal = true 194 | return fmt.Errorf("subreconciler error") 195 | }, 196 | }, 197 | } 198 | }, 199 | }, 200 | ShouldErr: true, 201 | }, 202 | "cast mutation patch apply error": { 203 | Resource: resource.DieReleasePtr(), 204 | Metadata: map[string]interface{}{ 205 | "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 206 | return &reconcilers.CastResource[*resources.TestResource, *resources.TestResource]{ 207 | Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ 208 | Sync: func(ctx context.Context, r *resources.TestResource) error { 209 | r.Spec.ErrOnUnmarshal = true 210 | return fmt.Errorf("subreconciler error") 211 | }, 212 | }, 213 | } 214 | }, 215 | }, 216 | ExpectResource: resource. 217 | SpecDie(func(d *dies.TestResourceSpecDie) { 218 | d.ErrOnUnmarshal(true) 219 | }). 220 | StatusDie(func(d *dies.TestResourceStatusDie) { 221 | d.ConditionsDie() // The unmarshal error would result in losing the initializing Ready condition during applying the patch 222 | }). 223 | DieReleasePtr(), 224 | ShouldErr: true, 225 | }, 226 | } 227 | 228 | rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 229 | return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) 230 | }) 231 | } 232 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | reconciler-runtime 2 | Copyright (c) 2020 VMware, Inc. All rights reserved. 3 | 4 | The Apache 2.0 license (the "License") set forth below applies to all parts of the reconciler-runtime project. You may not use this file except in compliance with the License. 5 | 6 | Apache License 7 | 8 | Version 2.0, January 2004 9 | http://www.apache.org/licenses/ 10 | 11 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 12 | 13 | 1. Definitions. 14 | 15 | "License" shall mean the terms and conditions for use, reproduction, 16 | and distribution as defined by Sections 1 through 9 of this document. 17 | 18 | "Licensor" shall mean the copyright owner or entity authorized by the 19 | copyright owner that is granting the License. 20 | 21 | "Legal Entity" shall mean the union of the acting entity and all other 22 | entities that control, are controlled by, or are under common control 23 | with that entity. For the purposes of this definition, "control" means 24 | (i) the power, direct or indirect, to cause the direction or management 25 | of such entity, whether by contract or otherwise, or (ii) ownership 26 | of fifty percent (50%) or more of the outstanding shares, or (iii) 27 | beneficial ownership of such entity. 28 | 29 | "You" (or "Your") shall mean an individual or Legal Entity exercising 30 | permissions granted by this License. 31 | 32 | "Source" form shall mean the preferred form for making modifications, 33 | including but not limited to software source code, documentation source, 34 | and configuration files. 35 | 36 | "Object" form shall mean any form resulting from mechanical transformation 37 | or translation of a Source form, including but not limited to compiled 38 | object code, generated documentation, and conversions to other media 39 | types. 40 | 41 | "Work" shall mean the work of authorship, whether in Source or 42 | Object form, made available under the License, as indicated by a copyright 43 | notice that is included in or attached to the work (an example is provided 44 | in the Appendix below). 45 | 46 | "Derivative Works" shall mean any work, whether in Source or Object form, 47 | that is based on (or derived from) the Work and for which the editorial 48 | revisions, annotations, elaborations, or other modifications represent, 49 | as a whole, an original work of authorship. For the purposes of this 50 | License, Derivative Works shall not include works that remain separable 51 | from, or merely link (or bind by name) to the interfaces of, the Work 52 | and Derivative Works thereof. 53 | 54 | "Contribution" shall mean any work of authorship, including the 55 | original version of the Work and any modifications or additions to 56 | that Work or Derivative Works thereof, that is intentionally submitted 57 | to Licensor for inclusion in the Work by the copyright owner or by an 58 | individual or Legal Entity authorized to submit on behalf of the copyright 59 | owner. For the purposes of this definition, "submitted" means any form of 60 | electronic, verbal, or written communication sent to the Licensor or its 61 | representatives, including but not limited to communication on electronic 62 | mailing lists, source code control systems, and issue tracking systems 63 | that are managed by, or on behalf of, the Licensor for the purpose of 64 | discussing and improving the Work, but excluding communication that is 65 | conspicuously marked or otherwise designated in writing by the copyright 66 | owner as "Not a Contribution." 67 | 68 | "Contributor" shall mean Licensor and any individual or Legal Entity 69 | on behalf of whom a Contribution has been received by Licensor and 70 | subsequently incorporated within the Work. 71 | 72 | 2. Grant of Copyright License. 73 | Subject to the terms and conditions of this License, each Contributor 74 | hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, 75 | royalty-free, irrevocable copyright license to reproduce, prepare 76 | Derivative Works of, publicly display, publicly perform, sublicense, and 77 | distribute the Work and such Derivative Works in Source or Object form. 78 | 79 | 3. Grant of Patent License. 80 | Subject to the terms and conditions of this License, each Contributor 81 | hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, 82 | royalty- free, irrevocable (except as stated in this section) patent 83 | license to make, have made, use, offer to sell, sell, import, and 84 | otherwise transfer the Work, where such license applies only to those 85 | patent claims licensable by such Contributor that are necessarily 86 | infringed by their Contribution(s) alone or by combination of 87 | their Contribution(s) with the Work to which such Contribution(s) 88 | was submitted. If You institute patent litigation against any entity 89 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 90 | Work or a Contribution incorporated within the Work constitutes direct 91 | or contributory patent infringement, then any patent licenses granted 92 | to You under this License for that Work shall terminate as of the date 93 | such litigation is filed. 94 | 95 | 4. Redistribution. 96 | You may reproduce and distribute copies of the Work or Derivative Works 97 | thereof in any medium, with or without modifications, and in Source or 98 | Object form, provided that You meet the following conditions: 99 | 100 | a. You must give any other recipients of the Work or Derivative Works 101 | a copy of this License; and 102 | 103 | b. You must cause any modified files to carry prominent notices stating 104 | that You changed the files; and 105 | 106 | c. You must retain, in the Source form of any Derivative Works that 107 | You distribute, all copyright, patent, trademark, and attribution 108 | notices from the Source form of the Work, excluding those notices 109 | that do not pertain to any part of the Derivative Works; and 110 | 111 | d. If the Work includes a "NOTICE" text file as part of its 112 | distribution, then any Derivative Works that You distribute must 113 | include a readable copy of the attribution notices contained 114 | within such NOTICE file, excluding those notices that do not 115 | pertain to any part of the Derivative Works, in at least one of 116 | the following places: within a NOTICE text file distributed as part 117 | of the Derivative Works; within the Source form or documentation, 118 | if provided along with the Derivative Works; or, within a display 119 | generated by the Derivative Works, if and wherever such third-party 120 | notices normally appear. The contents of the NOTICE file are for 121 | informational purposes only and do not modify the License. You 122 | may add Your own attribution notices within Derivative Works that 123 | You distribute, alongside or as an addendum to the NOTICE text 124 | from the Work, provided that such additional attribution notices 125 | cannot be construed as modifying the License. You may add Your own 126 | copyright statement to Your modifications and may provide additional 127 | or different license terms and conditions for use, reproduction, or 128 | distribution of Your modifications, or for any such Derivative Works 129 | as a whole, provided Your use, reproduction, and distribution of the 130 | Work otherwise complies with the conditions stated in this License. 131 | 132 | 5. Submission of Contributions. 133 | Unless You explicitly state otherwise, any Contribution intentionally 134 | submitted for inclusion in the Work by You to the Licensor shall be 135 | under the terms and conditions of this License, without any additional 136 | terms or conditions. Notwithstanding the above, nothing herein shall 137 | supersede or modify the terms of any separate license agreement you may 138 | have executed with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. 141 | This License does not grant permission to use the trade names, trademarks, 142 | service marks, or product names of the Licensor, except as required for 143 | reasonable and customary use in describing the origin of the Work and 144 | reproducing the content of the NOTICE file. 145 | 146 | 7. Disclaimer of Warranty. 147 | Unless required by applicable law or agreed to in writing, Licensor 148 | provides the Work (and each Contributor provides its Contributions) on 149 | an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 150 | express or implied, including, without limitation, any warranties or 151 | conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR 152 | A PARTICULAR PURPOSE. You are solely responsible for determining the 153 | appropriateness of using or redistributing the Work and assume any risks 154 | associated with Your exercise of permissions under this License. 155 | 156 | 8. Limitation of Liability. 157 | In no event and under no legal theory, whether in tort (including 158 | negligence), contract, or otherwise, unless required by applicable law 159 | (such as deliberate and grossly negligent acts) or agreed to in writing, 160 | shall any Contributor be liable to You for damages, including any direct, 161 | indirect, special, incidental, or consequential damages of any character 162 | arising as a result of this License or out of the use or inability to 163 | use the Work (including but not limited to damages for loss of goodwill, 164 | work stoppage, computer failure or malfunction, or any and all other 165 | commercial damages or losses), even if such Contributor has been advised 166 | of the possibility of such damages. 167 | 168 | 9. Accepting Warranty or Additional Liability. 169 | While redistributing the Work or Derivative Works thereof, You may 170 | choose to offer, and charge a fee for, acceptance of support, warranty, 171 | indemnity, or other liability obligations and/or rights consistent with 172 | this License. However, in accepting such obligations, You may act only 173 | on Your own behalf and on Your sole responsibility, not on behalf of 174 | any other Contributor, and only if You agree to indemnify, defend, and 175 | hold each Contributor harmless for any liability incurred by, or claims 176 | asserted against, such Contributor by reason of your accepting any such 177 | warranty or additional liability. 178 | 179 | END OF TERMS AND CONDITIONS 180 | -------------------------------------------------------------------------------- /testing/webhook.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package testing 7 | 8 | import ( 9 | "context" 10 | "net/http" 11 | "net/url" 12 | "testing" 13 | "time" 14 | 15 | "github.com/go-logr/logr" 16 | "github.com/go-logr/logr/testr" 17 | "github.com/google/go-cmp/cmp" 18 | "github.com/vmware-labs/reconciler-runtime/reconcilers" 19 | rtime "github.com/vmware-labs/reconciler-runtime/time" 20 | "k8s.io/apimachinery/pkg/runtime" 21 | "sigs.k8s.io/controller-runtime/pkg/client" 22 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 23 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 24 | ) 25 | 26 | // AdmissionWebhookTestCase holds a single testcase of an admission webhook. 27 | type AdmissionWebhookTestCase struct { 28 | // Name is a descriptive name for this test suitable as a first argument to t.Run() 29 | Name string 30 | // Focus is true if and only if only this and any other focused tests are to be executed. 31 | // If one or more tests are focused, the overall test suite will fail. 32 | Focus bool 33 | // Skip is true if and only if this test should be skipped. 34 | Skip bool 35 | // Metadata contains arbitrary values that are stored with the test case 36 | Metadata map[string]interface{} 37 | 38 | // inputs 39 | 40 | // Request is the admission request passed to the handler 41 | Request *admission.Request 42 | // HTTPRequest is the http request used to create the admission request object. If not defined, a minimal request is provided. 43 | HTTPRequest *http.Request 44 | // WithClientBuilder allows a test to modify the fake client initialization. 45 | WithClientBuilder func(*fake.ClientBuilder) *fake.ClientBuilder 46 | // WithReactors installs each ReactionFunc into each fake clientset. ReactionFuncs intercept 47 | // each call to the clientset providing the ability to mutate the resource or inject an error. 48 | WithReactors []ReactionFunc 49 | // StatusSubResourceTypes is a set of object types that support the status sub-resource. For 50 | // these types, the only way to modify the resource's status is update or patch the status 51 | // sub-resource. Patching or updating the main resource will not mutated the status field. 52 | // Built-in Kubernetes types (e.g. Pod, Deployment, etc) are already accounted for and do not 53 | // need to be listed. 54 | // 55 | // Interacting with a status sub-resource for a type not enumerated as having a status 56 | // sub-resource will return a not found error. 57 | StatusSubResourceTypes []client.Object 58 | // GivenObjects build the kubernetes objects which are present at the onset of reconciliation 59 | GivenObjects []client.Object 60 | // APIGivenObjects contains objects that are only available via an API reader instead of the normal cache 61 | APIGivenObjects []client.Object 62 | // GivenTracks provide a set of tracked resources to seed the tracker with 63 | GivenTracks []TrackRequest 64 | 65 | // side effects 66 | 67 | // ExpectTracks holds the ordered list of Track calls expected during reconciliation 68 | ExpectTracks []TrackRequest 69 | // ExpectEvents holds the ordered list of events recorded during the reconciliation 70 | ExpectEvents []Event 71 | // ExpectCreates builds the ordered list of objects expected to be created during reconciliation 72 | ExpectCreates []client.Object 73 | // ExpectUpdates builds the ordered list of objects expected to be updated during reconciliation 74 | ExpectUpdates []client.Object 75 | // ExpectPatches builds the ordered list of objects expected to be patched during reconciliation 76 | ExpectPatches []PatchRef 77 | // ExpectDeletes holds the ordered list of objects expected to be deleted during reconciliation 78 | ExpectDeletes []DeleteRef 79 | // ExpectDeleteCollections holds the ordered list of collections expected to be deleted during reconciliation 80 | ExpectDeleteCollections []DeleteCollectionRef 81 | // ExpectStatusUpdates builds the ordered list of objects whose status is updated during reconciliation 82 | ExpectStatusUpdates []client.Object 83 | // ExpectStatusPatches builds the ordered list of objects whose status is patched during reconciliation 84 | ExpectStatusPatches []PatchRef 85 | 86 | // outputs 87 | 88 | // ShouldPanic is true if and only if webhook is expected to panic. A panic should only be 89 | // used to indicate the webhook is misconfigured. 90 | ShouldPanic bool 91 | // ExpectedResponse is compared to the response returned from the webhook 92 | ExpectedResponse admission.Response 93 | 94 | // lifecycle 95 | 96 | // Prepare is called before the reconciler is executed. It is intended to prepare the broader 97 | // environment before the specific test case is executed. For example, setting mock 98 | // expectations, or adding values to the context. 99 | Prepare func(t *testing.T, ctx context.Context, tc *AdmissionWebhookTestCase) (context.Context, error) 100 | // CleanUp is called after the test case is finished and all defined assertions complete. 101 | // It is intended to clean up any state created in the Prepare step or during the test 102 | // execution, or to make assertions for mocks. 103 | CleanUp func(t *testing.T, ctx context.Context, tc *AdmissionWebhookTestCase) error 104 | // Now is the time the test should run as, defaults to the current time. This value can be used 105 | // by reconcilers via the reconcilers.RetireveNow(ctx) method. 106 | Now time.Time 107 | } 108 | 109 | // AdmissionWebhookTests represents a map of reconciler test cases. The map key is the name of each 110 | // test case. Test cases are executed in random order. 111 | type AdmissionWebhookTests map[string]AdmissionWebhookTestCase 112 | 113 | // Run executes the test cases. 114 | func (wt AdmissionWebhookTests) Run(t *testing.T, scheme *runtime.Scheme, factory AdmissionWebhookFactory) { 115 | t.Helper() 116 | wts := AdmissionWebhookTestSuite{} 117 | for name, wtc := range wt { 118 | wtc.Name = name 119 | wts = append(wts, wtc) 120 | } 121 | wts.Run(t, scheme, factory) 122 | } 123 | 124 | // AdmissionWebhookTestSuite represents a list of webhook test cases. The test cases are 125 | // executed in order. 126 | type AdmissionWebhookTestSuite []AdmissionWebhookTestCase 127 | 128 | // Run executes the test case. 129 | func (tc *AdmissionWebhookTestCase) Run(t *testing.T, scheme *runtime.Scheme, factory AdmissionWebhookFactory) { 130 | t.Helper() 131 | if tc.Skip { 132 | t.SkipNow() 133 | } 134 | 135 | ctx := reconcilers.WithStash(context.Background()) 136 | if tc.Now == (time.Time{}) { 137 | tc.Now = time.Now() 138 | } 139 | ctx = rtime.StashNow(ctx, tc.Now) 140 | ctx = logr.NewContext(ctx, testr.New(t)) 141 | if deadline, ok := t.Deadline(); ok { 142 | var cancel context.CancelFunc 143 | ctx, cancel = context.WithDeadline(ctx, deadline) 144 | defer cancel() 145 | } 146 | 147 | if tc.Metadata == nil { 148 | tc.Metadata = map[string]interface{}{} 149 | } 150 | 151 | if tc.Prepare != nil { 152 | var err error 153 | if ctx, err = tc.Prepare(t, ctx, tc); err != nil { 154 | t.Errorf("error during prepare: %s", err) 155 | } 156 | } 157 | if tc.CleanUp != nil { 158 | defer func() { 159 | if err := tc.CleanUp(t, ctx, tc); err != nil { 160 | t.Fatalf("error during clean up: %s", err) 161 | } 162 | }() 163 | } 164 | 165 | expectConfig := &ExpectConfig{ 166 | Name: "default", 167 | Scheme: scheme, 168 | StatusSubResourceTypes: tc.StatusSubResourceTypes, 169 | GivenObjects: tc.GivenObjects, 170 | APIGivenObjects: tc.APIGivenObjects, 171 | WithClientBuilder: tc.WithClientBuilder, 172 | WithReactors: tc.WithReactors, 173 | GivenTracks: tc.GivenTracks, 174 | ExpectTracks: tc.ExpectTracks, 175 | ExpectEvents: tc.ExpectEvents, 176 | ExpectCreates: tc.ExpectCreates, 177 | ExpectUpdates: tc.ExpectUpdates, 178 | ExpectPatches: tc.ExpectPatches, 179 | ExpectDeletes: tc.ExpectDeletes, 180 | ExpectDeleteCollections: tc.ExpectDeleteCollections, 181 | ExpectStatusUpdates: tc.ExpectStatusUpdates, 182 | ExpectStatusPatches: tc.ExpectStatusPatches, 183 | } 184 | 185 | c := expectConfig.Config() 186 | r := factory(t, tc, c) 187 | 188 | // Run the Reconcile we're testing. 189 | response := func() admission.Response { 190 | if tc.ShouldPanic { 191 | defer func() { 192 | if r := recover(); r == nil { 193 | t.Error("expected Reconcile() to panic") 194 | } 195 | }() 196 | } 197 | 198 | httpRequest := tc.HTTPRequest 199 | if httpRequest == nil { 200 | // provide a minimal default 201 | httpRequest = &http.Request{ 202 | URL: &url.URL{Path: "/"}, 203 | } 204 | } 205 | request := tc.Request 206 | if request == nil { 207 | // TODO parse admission request from http request 208 | t.Fatal("Request field is required") 209 | } 210 | 211 | if r.WithContextFunc != nil { 212 | ctx = r.WithContextFunc(ctx, httpRequest) 213 | } 214 | 215 | return r.Handle(ctx, *request) 216 | }() 217 | 218 | tc.ExpectedResponse.Complete(*tc.Request) 219 | if diff := cmp.Diff(tc.ExpectedResponse, response, reconcilers.IgnoreAllUnexported); diff != "" { 220 | t.Errorf("ExpectedResponse differs (%s, %s): %s", DiffRemovedColor.Sprint("-expected"), DiffAddedColor.Sprint("+actual"), ColorizeDiff(diff)) 221 | } 222 | 223 | expectConfig.AssertExpectations(t) 224 | } 225 | 226 | // Run executes the webhook test suite. 227 | func (ts AdmissionWebhookTestSuite) Run(t *testing.T, scheme *runtime.Scheme, factory AdmissionWebhookFactory) { 228 | t.Helper() 229 | focused := AdmissionWebhookTestSuite{} 230 | for _, test := range ts { 231 | if test.Focus { 232 | focused = append(focused, test) 233 | break 234 | } 235 | } 236 | testsToExecute := ts 237 | if len(focused) > 0 { 238 | testsToExecute = focused 239 | } 240 | for _, test := range testsToExecute { 241 | t.Run(test.Name, func(t *testing.T) { 242 | t.Helper() 243 | test.Run(t, scheme, factory) 244 | }) 245 | } 246 | if len(focused) > 0 { 247 | t.Errorf("%d tests out of %d are still focused, so the test suite fails", len(focused), len(ts)) 248 | } 249 | } 250 | 251 | type AdmissionWebhookFactory func(t *testing.T, wtc *AdmissionWebhookTestCase, c reconcilers.Config) *admission.Webhook 252 | -------------------------------------------------------------------------------- /reconcilers/resourcemanager.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers 7 | 8 | import ( 9 | "context" 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "sync" 14 | "time" 15 | 16 | jsonmergepatch "github.com/evanphx/json-patch/v5" 17 | "github.com/go-logr/logr" 18 | "github.com/google/go-cmp/cmp" 19 | jsonpatch "gomodules.xyz/jsonpatch/v2" 20 | corev1 "k8s.io/api/core/v1" 21 | "k8s.io/apimachinery/pkg/api/equality" 22 | "k8s.io/apimachinery/pkg/util/cache" 23 | "sigs.k8s.io/controller-runtime/pkg/client" 24 | 25 | "github.com/vmware-labs/reconciler-runtime/internal" 26 | ) 27 | 28 | // ResourceManager compares the actual and desired resources to create/update/delete as desired. 29 | type ResourceManager[Type client.Object] struct { 30 | // Name used to identify this reconciler. Defaults to `{Type}ResourceManager`. Ideally 31 | // unique, but not required to be so. 32 | // 33 | // +optional 34 | Name string 35 | 36 | // Type is the resource being created/updated/deleted by the reconciler. Required when the 37 | // generic type is not a struct, or is unstructured. 38 | // 39 | // +optional 40 | Type Type 41 | 42 | // Finalizer is set on the reconciled resource before a managed resource is created, and cleared 43 | // after a managed resource is deleted. The value must be unique to this specific manager 44 | // instance and not shared. Reusing a value may result in orphaned resources when the 45 | // reconciled resource is deleted. 46 | // 47 | // Using a finalizer is encouraged when the Kubernetes garbage collector is unable to delete 48 | // the child resource automatically, like when the reconciled resource and child are in different 49 | // namespaces, scopes or clusters. 50 | // 51 | // +optional 52 | Finalizer string 53 | 54 | // TrackDesired when true, the desired resource is tracked after creates, before 55 | // updates, and on delete errors. 56 | TrackDesired bool 57 | 58 | // HarmonizeImmutableFields allows fields that are immutable on the current 59 | // object to be copied to the desired object in order to avoid creating 60 | // updates which are guaranteed to fail. 61 | // 62 | // +optional 63 | HarmonizeImmutableFields func(current, desired Type) 64 | 65 | // MergeBeforeUpdate copies desired fields on to the current object before 66 | // calling update. Typically fields to copy are the Spec, Labels and 67 | // Annotations. 68 | MergeBeforeUpdate func(current, desired Type) 69 | 70 | // Sanitize is called with an object before logging the value. Any value may 71 | // be returned. A meaningful subset of the resource is typically returned, 72 | // like the Spec. 73 | // 74 | // +optional 75 | Sanitize func(child Type) interface{} 76 | 77 | // mutationCache holds patches received from updates to a resource made by 78 | // mutation webhooks. This cache is used to avoid unnecessary update calls 79 | // that would actually have no effect. 80 | mutationCache *cache.Expiring 81 | lazyInit sync.Once 82 | } 83 | 84 | func (r *ResourceManager[T]) init() { 85 | r.lazyInit.Do(func() { 86 | if internal.IsNil(r.Type) { 87 | var nilT T 88 | r.Type = newEmpty(nilT).(T) 89 | } 90 | if r.Name == "" { 91 | r.Name = fmt.Sprintf("%sResourceManager", typeName(r.Type)) 92 | } 93 | r.mutationCache = cache.NewExpiring() 94 | }) 95 | } 96 | 97 | func (r *ResourceManager[T]) Setup(ctx context.Context) error { 98 | r.init() 99 | return r.validate(ctx) 100 | } 101 | 102 | func (r *ResourceManager[T]) validate(ctx context.Context) error { 103 | // require MergeBeforeUpdate 104 | if r.MergeBeforeUpdate == nil { 105 | return fmt.Errorf("ResourceManager %q must define MergeBeforeUpdate", r.Name) 106 | } 107 | 108 | return nil 109 | } 110 | 111 | // Manage a specific resource to create/update/delete based on the actual and desired state. The 112 | // resource is the reconciled resource and used to record events for mutations. The actual and 113 | // desired objects represent the managed resource and must be compatible with the type field. 114 | func (r *ResourceManager[T]) Manage(ctx context.Context, resource client.Object, actual, desired T) (T, error) { 115 | r.init() 116 | 117 | var nilT T 118 | 119 | log := logr.FromContextOrDiscard(ctx) 120 | pc := RetrieveOriginalConfigOrDie(ctx) 121 | c := RetrieveConfigOrDie(ctx) 122 | 123 | if (internal.IsNil(actual) || actual.GetCreationTimestamp().Time.IsZero()) && internal.IsNil(desired) { 124 | if err := ClearFinalizer(ctx, resource, r.Finalizer); err != nil { 125 | return nilT, err 126 | } 127 | return nilT, nil 128 | } 129 | 130 | // delete resource if no longer needed 131 | if internal.IsNil(desired) { 132 | if !actual.GetCreationTimestamp().Time.IsZero() && actual.GetDeletionTimestamp() == nil { 133 | log.Info("deleting unwanted resource", "resource", namespaceName(actual)) 134 | if err := c.Delete(ctx, actual); err != nil { 135 | if !errors.Is(err, ErrQuiet) { 136 | log.Error(err, "unable to delete unwanted resource", "resource", namespaceName(actual)) 137 | pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "DeleteFailed", 138 | "Failed to delete %s %q: %v", typeName(actual), actual.GetName(), err) 139 | } 140 | return nilT, err 141 | } 142 | pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Deleted", 143 | "Deleted %s %q", typeName(actual), actual.GetName()) 144 | 145 | } 146 | return nilT, nil 147 | } 148 | 149 | if err := AddFinalizer(ctx, resource, r.Finalizer); err != nil { 150 | return nilT, err 151 | } 152 | 153 | // create resource if it doesn't exist 154 | if internal.IsNil(actual) || actual.GetCreationTimestamp().Time.IsZero() { 155 | log.Info("creating resource", "resource", r.sanitize(desired)) 156 | if err := c.Create(ctx, desired); err != nil { 157 | if !errors.Is(err, ErrQuiet) { 158 | log.Error(err, "unable to create resource", "resource", namespaceName(desired)) 159 | pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "CreationFailed", 160 | "Failed to create %s %q: %v", typeName(desired), desired.GetName(), err) 161 | } 162 | return nilT, err 163 | } 164 | if r.TrackDesired { 165 | // normally tracks should occur before API operations, but when creating a resource with a 166 | // generated name, we need to know the actual resource name. 167 | 168 | if err := c.Tracker.TrackObject(desired, resource); err != nil { 169 | return nilT, err 170 | } 171 | } 172 | pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Created", 173 | "Created %s %q", typeName(desired), desired.GetName()) 174 | return desired, nil 175 | } 176 | 177 | // overwrite fields that should not be mutated 178 | if r.HarmonizeImmutableFields != nil { 179 | r.HarmonizeImmutableFields(actual, desired) 180 | } 181 | 182 | // lookup and apply remote mutations 183 | desiredPatched := desired.DeepCopyObject().(T) 184 | if patch, ok := r.mutationCache.Get(actual.GetUID()); ok { 185 | // the only object added to the cache is *Patch 186 | err := patch.(*Patch).Apply(desiredPatched) 187 | if err != nil { 188 | // there's not much we can do, let the normal update proceed 189 | log.Info("unable to patch desired child from mutation cache, this error is usually benign", "error", err.Error()) 190 | } 191 | } 192 | 193 | // update resource with desired changes 194 | current := actual.DeepCopyObject().(T) 195 | if r.TrackDesired { 196 | if err := c.Tracker.TrackObject(current, resource); err != nil { 197 | return nilT, err 198 | } 199 | } 200 | r.MergeBeforeUpdate(current, desiredPatched) 201 | if equality.Semantic.DeepEqual(current, actual) { 202 | // resource is unchanged 203 | log.Info("resource is in sync, no update required") 204 | return actual, nil 205 | } 206 | log.Info("updating resource", "diff", cmp.Diff(r.sanitize(actual), r.sanitize(current), IgnoreAllUnexported)) 207 | if err := c.Update(ctx, current); err != nil { 208 | if !errors.Is(err, ErrQuiet) { 209 | log.Error(err, "unable to update resource", "resource", namespaceName(current)) 210 | pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "UpdateFailed", 211 | "Failed to update %s %q: %v", typeName(current), current.GetName(), err) 212 | } 213 | return nilT, err 214 | } 215 | 216 | // capture admission mutation patch 217 | base := current.DeepCopyObject().(T) 218 | r.MergeBeforeUpdate(base, desired) 219 | patch, err := NewPatch(base, current) 220 | if err != nil { 221 | if !errors.Is(err, ErrQuiet) { 222 | log.Info("unable to generate mutation patch", "snapshot", r.sanitize(desired), "base", r.sanitize(base), "error", err.Error()) 223 | } 224 | } else { 225 | r.mutationCache.Set(current.GetUID(), patch, 1*time.Hour) 226 | } 227 | 228 | log.Info("updated resource") 229 | pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Updated", 230 | "Updated %s %q", typeName(current), current.GetName()) 231 | 232 | return current, nil 233 | } 234 | 235 | func (r *ResourceManager[T]) sanitize(resource T) interface{} { 236 | if r.Sanitize == nil { 237 | return resource 238 | } 239 | if internal.IsNil(resource) { 240 | return nil 241 | } 242 | 243 | // avoid accidental mutations in Sanitize method 244 | resource = resource.DeepCopyObject().(T) 245 | return r.Sanitize(resource) 246 | } 247 | 248 | func NewPatch(base, update client.Object) (*Patch, error) { 249 | baseBytes, err := json.Marshal(base) 250 | if err != nil { 251 | return nil, err 252 | } 253 | updateBytes, err := json.Marshal(update) 254 | if err != nil { 255 | return nil, err 256 | } 257 | patch, err := jsonpatch.CreatePatch(baseBytes, updateBytes) 258 | if err != nil { 259 | return nil, err 260 | } 261 | patchBytes, err := json.Marshal(patch) 262 | if err != nil { 263 | return nil, err 264 | } 265 | 266 | return &Patch{ 267 | generation: base.GetGeneration(), 268 | bytes: patchBytes, 269 | }, nil 270 | } 271 | 272 | type Patch struct { 273 | generation int64 274 | bytes []byte 275 | } 276 | 277 | var PatchGenerationMismatch = errors.New("patch generation did not match target") 278 | 279 | func (p *Patch) Apply(rebase client.Object) error { 280 | if rebase.GetGeneration() != p.generation { 281 | return PatchGenerationMismatch 282 | } 283 | 284 | rebaseBytes, err := json.Marshal(rebase) 285 | if err != nil { 286 | return err 287 | } 288 | merge, err := jsonmergepatch.DecodePatch(p.bytes) 289 | if err != nil { 290 | return err 291 | } 292 | patchedBytes, err := merge.Apply(rebaseBytes) 293 | if err != nil { 294 | return err 295 | } 296 | // reset rebase to its empty value before unmarshaling into it 297 | replaceWithEmpty(rebase) 298 | return json.Unmarshal(patchedBytes, rebase) 299 | } 300 | -------------------------------------------------------------------------------- /testing/reconciler.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package testing 7 | 8 | import ( 9 | "context" 10 | "testing" 11 | "time" 12 | 13 | "github.com/go-logr/logr" 14 | "github.com/go-logr/logr/testr" 15 | "github.com/google/go-cmp/cmp" 16 | "github.com/vmware-labs/reconciler-runtime/reconcilers" 17 | rtime "github.com/vmware-labs/reconciler-runtime/time" 18 | "k8s.io/apimachinery/pkg/runtime" 19 | "sigs.k8s.io/controller-runtime/pkg/client" 20 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 21 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 22 | ) 23 | 24 | // ReconcilerTestCase holds a single test case of a reconciler test suite. 25 | type ReconcilerTestCase struct { 26 | // Name is a descriptive name for this test suitable as a first argument to t.Run() 27 | Name string 28 | // Focus is true if and only if only this and any other focused tests are to be executed. 29 | // If one or more tests are focused, the overall test suite will fail. 30 | Focus bool 31 | // Skip is true if and only if this test should be skipped. 32 | Skip bool 33 | // Metadata contains arbitrary values that are stored with the test case 34 | Metadata map[string]interface{} 35 | 36 | // inputs 37 | 38 | // Request identifies the object to be reconciled 39 | Request reconcilers.Request 40 | // WithReactors installs each ReactionFunc into each fake clientset. ReactionFuncs intercept 41 | // each call to the clientset providing the ability to mutate the resource or inject an error. 42 | WithReactors []ReactionFunc 43 | // WithClientBuilder allows a test to modify the fake client initialization. 44 | WithClientBuilder func(*fake.ClientBuilder) *fake.ClientBuilder 45 | // StatusSubResourceTypes is a set of object types that support the status sub-resource. For 46 | // these types, the only way to modify the resource's status is update or patch the status 47 | // sub-resource. Patching or updating the main resource will not mutated the status field. 48 | // Built-in Kubernetes types (e.g. Pod, Deployment, etc) are already accounted for and do not 49 | // need to be listed. 50 | // 51 | // Interacting with a status sub-resource for a type not enumerated as having a status 52 | // sub-resource will return a not found error. 53 | StatusSubResourceTypes []client.Object 54 | // GivenObjects build the kubernetes objects which are present at the onset of reconciliation 55 | GivenObjects []client.Object 56 | // APIGivenObjects contains objects that are only available via an API reader instead of the normal cache 57 | APIGivenObjects []client.Object 58 | // GivenTracks provide a set of tracked resources to seed the tracker with 59 | GivenTracks []TrackRequest 60 | 61 | // side effects 62 | 63 | // ExpectTracks holds the ordered list of Track calls expected during reconciliation 64 | ExpectTracks []TrackRequest 65 | // ExpectEvents holds the ordered list of events recorded during the reconciliation 66 | ExpectEvents []Event 67 | // ExpectCreates builds the ordered list of objects expected to be created during reconciliation 68 | ExpectCreates []client.Object 69 | // ExpectUpdates builds the ordered list of objects expected to be updated during reconciliation 70 | ExpectUpdates []client.Object 71 | // ExpectPatches builds the ordered list of objects expected to be patched during reconciliation 72 | ExpectPatches []PatchRef 73 | // ExpectDeletes holds the ordered list of objects expected to be deleted during reconciliation 74 | ExpectDeletes []DeleteRef 75 | // ExpectDeleteCollections holds the ordered list of collections expected to be deleted during reconciliation 76 | ExpectDeleteCollections []DeleteCollectionRef 77 | // ExpectStatusUpdates builds the ordered list of objects whose status is updated during reconciliation 78 | ExpectStatusUpdates []client.Object 79 | // ExpectStatusPatches builds the ordered list of objects whose status is patched during reconciliation 80 | ExpectStatusPatches []PatchRef 81 | 82 | // AdditionalConfigs holds ExceptConfigs that are available to the test case and will have 83 | // their expectations checked again the observed config interactions. The key in this map is 84 | // set as the ExpectConfig's name. 85 | AdditionalConfigs map[string]ExpectConfig 86 | 87 | // outputs 88 | 89 | // ShouldErr is true if and only if reconciliation is expected to return an error 90 | ShouldErr bool 91 | // ExpectedResult is compared to the result returned from the reconciler if there was no error 92 | ExpectedResult reconcilers.Result 93 | // Verify provides the reconciliation Result and error for custom assertions 94 | Verify VerifyFunc 95 | 96 | // lifecycle 97 | 98 | // Prepare is called before the reconciler is executed. It is intended to prepare the broader 99 | // environment before the specific test case is executed. For example, setting mock 100 | // expectations, or adding values to the context. 101 | Prepare func(t *testing.T, ctx context.Context, tc *ReconcilerTestCase) (context.Context, error) 102 | // CleanUp is called after the test case is finished and all defined assertions complete. 103 | // It is intended to clean up any state created in the Prepare step or during the test 104 | // execution, or to make assertions for mocks. 105 | CleanUp func(t *testing.T, ctx context.Context, tc *ReconcilerTestCase) error 106 | // Now is the time the test should run as, defaults to the current time. This value can be used 107 | // by reconcilers via the reconcilers.RetireveNow(ctx) method. 108 | Now time.Time 109 | } 110 | 111 | // VerifyFunc is a verification function for a reconciler's result 112 | type VerifyFunc func(t *testing.T, result reconcilers.Result, err error) 113 | 114 | // VerifyStashedValueFunc is a verification function for the entries in the stash 115 | type VerifyStashedValueFunc func(t *testing.T, key reconcilers.StashKey, expected, actual interface{}) 116 | 117 | // ReconcilerTests represents a map of reconciler test cases. The map key is the name of each test 118 | // case. Test cases are executed in random order. 119 | type ReconcilerTests map[string]ReconcilerTestCase 120 | 121 | // Run executes the test cases. 122 | func (rt ReconcilerTests) Run(t *testing.T, scheme *runtime.Scheme, factory ReconcilerFactory) { 123 | t.Helper() 124 | rts := ReconcilerTestSuite{} 125 | for name, rtc := range rt { 126 | rtc.Name = name 127 | rts = append(rts, rtc) 128 | } 129 | rts.Run(t, scheme, factory) 130 | } 131 | 132 | // ReconcilerTestSuite represents a list of reconciler test cases. The test cases are executed in order. 133 | type ReconcilerTestSuite []ReconcilerTestCase 134 | 135 | // Run executes the test case. 136 | func (tc *ReconcilerTestCase) Run(t *testing.T, scheme *runtime.Scheme, factory ReconcilerFactory) { 137 | t.Helper() 138 | if tc.Skip { 139 | t.SkipNow() 140 | } 141 | 142 | ctx := context.Background() 143 | if tc.Now == (time.Time{}) { 144 | tc.Now = time.Now() 145 | } 146 | ctx = rtime.StashNow(ctx, tc.Now) 147 | ctx = logr.NewContext(ctx, testr.New(t)) 148 | if deadline, ok := t.Deadline(); ok { 149 | var cancel context.CancelFunc 150 | ctx, cancel = context.WithDeadline(ctx, deadline) 151 | defer cancel() 152 | } 153 | 154 | if tc.Metadata == nil { 155 | tc.Metadata = map[string]interface{}{} 156 | } 157 | 158 | if tc.Prepare != nil { 159 | var err error 160 | if ctx, err = tc.Prepare(t, ctx, tc); err != nil { 161 | t.Fatalf("error during prepare: %s", err) 162 | } 163 | } 164 | if tc.CleanUp != nil { 165 | defer func() { 166 | if err := tc.CleanUp(t, ctx, tc); err != nil { 167 | t.Errorf("error during clean up: %s", err) 168 | } 169 | }() 170 | } 171 | 172 | expectConfig := &ExpectConfig{ 173 | Name: "default", 174 | Scheme: scheme, 175 | StatusSubResourceTypes: tc.StatusSubResourceTypes, 176 | GivenObjects: tc.GivenObjects, 177 | APIGivenObjects: tc.APIGivenObjects, 178 | WithClientBuilder: tc.WithClientBuilder, 179 | WithReactors: tc.WithReactors, 180 | GivenTracks: tc.GivenTracks, 181 | ExpectTracks: tc.ExpectTracks, 182 | ExpectEvents: tc.ExpectEvents, 183 | ExpectCreates: tc.ExpectCreates, 184 | ExpectUpdates: tc.ExpectUpdates, 185 | ExpectPatches: tc.ExpectPatches, 186 | ExpectDeletes: tc.ExpectDeletes, 187 | ExpectDeleteCollections: tc.ExpectDeleteCollections, 188 | ExpectStatusUpdates: tc.ExpectStatusUpdates, 189 | ExpectStatusPatches: tc.ExpectStatusPatches, 190 | } 191 | 192 | configs := make(map[string]reconcilers.Config, len(tc.AdditionalConfigs)) 193 | for k, v := range tc.AdditionalConfigs { 194 | v.Name = k 195 | configs[k] = v.Config() 196 | } 197 | ctx = reconcilers.StashAdditionalConfigs(ctx, configs) 198 | 199 | r := factory(t, tc, expectConfig.Config()) 200 | 201 | // Run the Reconcile we're testing. 202 | result, err := r.Reconcile(ctx, tc.Request) 203 | 204 | if (err != nil) != tc.ShouldErr { 205 | t.Errorf("Reconcile() error = %v, ShouldErr %v", err, tc.ShouldErr) 206 | } 207 | if err == nil { 208 | // result is only significant if there wasn't an error 209 | if diff := cmp.Diff(normalizeResult(tc.ExpectedResult), normalizeResult(result)); diff != "" { 210 | t.Errorf("ExpectedResult differs (%s, %s): %s", DiffRemovedColor.Sprint("-expected"), DiffAddedColor.Sprint("+actual"), ColorizeDiff(diff)) 211 | } 212 | } 213 | 214 | if tc.Verify != nil { 215 | tc.Verify(t, result, err) 216 | } 217 | 218 | expectConfig.AssertExpectations(t) 219 | for _, config := range tc.AdditionalConfigs { 220 | config.AssertExpectations(t) 221 | } 222 | } 223 | 224 | func normalizeResult(result reconcilers.Result) reconcilers.Result { 225 | // RequeueAfter implies Requeue, no need to set both 226 | if result.RequeueAfter != 0 { 227 | result.Requeue = false 228 | } 229 | return result 230 | } 231 | 232 | // Run executes the reconciler test suite. 233 | func (ts ReconcilerTestSuite) Run(t *testing.T, scheme *runtime.Scheme, factory ReconcilerFactory) { 234 | t.Helper() 235 | focused := ReconcilerTestSuite{} 236 | for _, test := range ts { 237 | if test.Focus { 238 | focused = append(focused, test) 239 | break 240 | } 241 | } 242 | testsToExecute := ts 243 | if len(focused) > 0 { 244 | testsToExecute = focused 245 | } 246 | for _, test := range testsToExecute { 247 | t.Run(test.Name, func(t *testing.T) { 248 | t.Helper() 249 | test.Run(t, scheme, factory) 250 | }) 251 | } 252 | if len(focused) > 0 { 253 | t.Errorf("%d tests out of %d are still focused, so the test suite fails", len(focused), len(ts)) 254 | } 255 | } 256 | 257 | // ReconcilerFactory returns a Reconciler.Interface to perform reconciliation of a test case, 258 | // ActionRecorderList/EventList to capture k8s actions/events produced during reconciliation 259 | // and FakeStatsReporter to capture stats. 260 | type ReconcilerFactory func(t *testing.T, rtc *ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler 261 | -------------------------------------------------------------------------------- /reconcilers/config_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 VMware, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package reconcilers_test 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "testing" 12 | 13 | diecorev1 "dies.dev/apis/core/v1" 14 | diemetav1 "dies.dev/apis/meta/v1" 15 | "github.com/vmware-labs/reconciler-runtime/internal/resources" 16 | "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" 17 | "github.com/vmware-labs/reconciler-runtime/reconcilers" 18 | rtesting "github.com/vmware-labs/reconciler-runtime/testing" 19 | "github.com/vmware-labs/reconciler-runtime/tracker" 20 | corev1 "k8s.io/api/core/v1" 21 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 22 | "k8s.io/apimachinery/pkg/labels" 23 | "k8s.io/apimachinery/pkg/runtime" 24 | "k8s.io/apimachinery/pkg/types" 25 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 26 | "sigs.k8s.io/controller-runtime/pkg/client" 27 | ) 28 | 29 | func TestConfig_TrackAndGet(t *testing.T) { 30 | testNamespace := "test-namespace" 31 | testName := "test-resource" 32 | 33 | scheme := runtime.NewScheme() 34 | _ = resources.AddToScheme(scheme) 35 | _ = clientgoscheme.AddToScheme(scheme) 36 | 37 | resource := dies.TestResourceBlank. 38 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 39 | d.Namespace(testNamespace) 40 | d.Name(testName) 41 | }) 42 | 43 | configMap := diecorev1.ConfigMapBlank. 44 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 45 | d.Namespace("track-namespace") 46 | d.Name("track-name") 47 | }). 48 | AddData("greeting", "hello") 49 | 50 | rts := rtesting.SubReconcilerTests[*resources.TestResource]{ 51 | "track and get": { 52 | Resource: resource.DieReleasePtr(), 53 | GivenObjects: []client.Object{ 54 | configMap, 55 | }, 56 | ExpectTracks: []rtesting.TrackRequest{ 57 | rtesting.NewTrackRequest(configMap, resource, scheme), 58 | }, 59 | }, 60 | "track with not found get": { 61 | Resource: resource.DieReleasePtr(), 62 | ShouldErr: true, 63 | ExpectTracks: []rtesting.TrackRequest{ 64 | rtesting.NewTrackRequest(configMap, resource, scheme), 65 | }, 66 | }, 67 | } 68 | 69 | // run with typed objects 70 | t.Run("typed", func(t *testing.T) { 71 | rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 72 | return &reconcilers.SyncReconciler[*resources.TestResource]{ 73 | Sync: func(ctx context.Context, resource *resources.TestResource) error { 74 | c := reconcilers.RetrieveConfigOrDie(ctx) 75 | 76 | cm := &corev1.ConfigMap{} 77 | err := c.TrackAndGet(ctx, types.NamespacedName{Namespace: "track-namespace", Name: "track-name"}, cm) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | if expected, actual := "hello", cm.Data["greeting"]; expected != actual { 83 | // should never get here 84 | panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) 85 | } 86 | return nil 87 | }, 88 | } 89 | }) 90 | }) 91 | 92 | // run with unstructured objects 93 | t.Run("unstructured", func(t *testing.T) { 94 | rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 95 | return &reconcilers.SyncReconciler[*resources.TestResource]{ 96 | Sync: func(ctx context.Context, resource *resources.TestResource) error { 97 | c := reconcilers.RetrieveConfigOrDie(ctx) 98 | 99 | cm := &unstructured.Unstructured{} 100 | cm.SetAPIVersion("v1") 101 | cm.SetKind("ConfigMap") 102 | err := c.TrackAndGet(ctx, types.NamespacedName{Namespace: "track-namespace", Name: "track-name"}, cm) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | if expected, actual := "hello", cm.UnstructuredContent()["data"].(map[string]interface{})["greeting"].(string); expected != actual { 108 | // should never get here 109 | panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) 110 | } 111 | return nil 112 | }, 113 | } 114 | }) 115 | }) 116 | } 117 | 118 | func TestConfig_TrackAndList(t *testing.T) { 119 | testNamespace := "test-namespace" 120 | testName := "test-resource" 121 | testSelector, _ := labels.Parse("app=test-app") 122 | 123 | scheme := runtime.NewScheme() 124 | _ = resources.AddToScheme(scheme) 125 | _ = clientgoscheme.AddToScheme(scheme) 126 | 127 | resource := dies.TestResourceBlank. 128 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 129 | d.Namespace(testNamespace) 130 | d.Name(testName) 131 | }) 132 | 133 | configMap := diecorev1.ConfigMapBlank. 134 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 135 | d.Namespace("track-namespace") 136 | d.Name("track-name") 137 | d.AddLabel("app", "test-app") 138 | }). 139 | AddData("greeting", "hello") 140 | 141 | rts := rtesting.SubReconcilerTests[*resources.TestResource]{ 142 | "track and list": { 143 | Resource: resource.DieReleasePtr(), 144 | GivenObjects: []client.Object{ 145 | configMap, 146 | }, 147 | Metadata: map[string]interface{}{ 148 | "listOpts": []client.ListOption{}, 149 | }, 150 | ExpectTracks: []rtesting.TrackRequest{ 151 | { 152 | Tracker: types.NamespacedName{ 153 | Namespace: testNamespace, 154 | Name: testName, 155 | }, 156 | TrackedReference: tracker.Reference{ 157 | Kind: "ConfigMap", 158 | Selector: labels.Everything(), 159 | }, 160 | }, 161 | }, 162 | }, 163 | "track and list constrained": { 164 | Resource: resource.DieReleasePtr(), 165 | GivenObjects: []client.Object{ 166 | configMap, 167 | }, 168 | Metadata: map[string]interface{}{ 169 | "listOpts": []client.ListOption{ 170 | client.InNamespace("track-namespace"), 171 | client.MatchingLabels(map[string]string{"app": "test-app"}), 172 | }, 173 | }, 174 | ExpectTracks: []rtesting.TrackRequest{ 175 | { 176 | Tracker: types.NamespacedName{ 177 | Namespace: testNamespace, 178 | Name: testName, 179 | }, 180 | TrackedReference: tracker.Reference{ 181 | Kind: "ConfigMap", 182 | Namespace: "track-namespace", 183 | Selector: testSelector, 184 | }, 185 | }, 186 | }, 187 | }, 188 | "track with errored list": { 189 | Resource: resource.DieReleasePtr(), 190 | ShouldErr: true, 191 | WithReactors: []rtesting.ReactionFunc{ 192 | rtesting.InduceFailure("list", "ConfigMapList"), 193 | }, 194 | Metadata: map[string]interface{}{ 195 | "listOpts": []client.ListOption{}, 196 | }, 197 | ExpectTracks: []rtesting.TrackRequest{ 198 | { 199 | Tracker: types.NamespacedName{ 200 | Namespace: testNamespace, 201 | Name: testName, 202 | }, 203 | TrackedReference: tracker.Reference{ 204 | Kind: "ConfigMap", 205 | Selector: labels.Everything(), 206 | }, 207 | }, 208 | }, 209 | }, 210 | } 211 | 212 | // run with typed objects 213 | t.Run("typed", func(t *testing.T) { 214 | rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 215 | return &reconcilers.SyncReconciler[*resources.TestResource]{ 216 | Sync: func(ctx context.Context, resource *resources.TestResource) error { 217 | c := reconcilers.RetrieveConfigOrDie(ctx) 218 | 219 | cms := &corev1.ConfigMapList{} 220 | listOpts := rtc.Metadata["listOpts"].([]client.ListOption) 221 | err := c.TrackAndList(ctx, cms, listOpts...) 222 | if err != nil { 223 | return err 224 | } 225 | 226 | if expected, actual := "hello", cms.Items[0].Data["greeting"]; expected != actual { 227 | // should never get here 228 | panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) 229 | } 230 | return nil 231 | }, 232 | } 233 | }) 234 | }) 235 | 236 | // run with unstructured objects 237 | t.Run("unstructured", func(t *testing.T) { 238 | rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 239 | return &reconcilers.SyncReconciler[*resources.TestResource]{ 240 | Sync: func(ctx context.Context, resource *resources.TestResource) error { 241 | c := reconcilers.RetrieveConfigOrDie(ctx) 242 | 243 | cms := &unstructured.UnstructuredList{} 244 | cms.SetAPIVersion("v1") 245 | cms.SetKind("ConfigMapList") 246 | listOpts := rtc.Metadata["listOpts"].([]client.ListOption) 247 | err := c.TrackAndList(ctx, cms, listOpts...) 248 | if err != nil { 249 | return err 250 | } 251 | 252 | if expected, actual := "hello", cms.UnstructuredContent()["items"].([]interface{})[0].(map[string]interface{})["data"].(map[string]interface{})["greeting"].(string); expected != actual { 253 | // should never get here 254 | panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) 255 | } 256 | return nil 257 | }, 258 | } 259 | }) 260 | }) 261 | } 262 | 263 | func TestWithConfig(t *testing.T) { 264 | testNamespace := "test-namespace" 265 | testName := "test-resource" 266 | 267 | scheme := runtime.NewScheme() 268 | _ = resources.AddToScheme(scheme) 269 | 270 | resource := dies.TestResourceBlank. 271 | MetadataDie(func(d *diemetav1.ObjectMetaDie) { 272 | d.Namespace(testNamespace) 273 | d.Name(testName) 274 | }) 275 | 276 | rts := rtesting.SubReconcilerTests[*resources.TestResource]{ 277 | "with config": { 278 | Resource: resource.DieReleasePtr(), 279 | Metadata: map[string]interface{}{ 280 | "SubReconciler": func(t *testing.T, oc reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 281 | c := reconcilers.Config{ 282 | Tracker: tracker.New(oc.Scheme(), 0), 283 | } 284 | 285 | return &reconcilers.WithConfig[*resources.TestResource]{ 286 | Config: func(ctx context.Context, _ reconcilers.Config) (reconcilers.Config, error) { 287 | return c, nil 288 | }, 289 | Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ 290 | Sync: func(ctx context.Context, parent *resources.TestResource) error { 291 | rc := reconcilers.RetrieveConfigOrDie(ctx) 292 | roc := reconcilers.RetrieveOriginalConfigOrDie(ctx) 293 | 294 | if rc != c { 295 | t.Errorf("unexpected config") 296 | } 297 | if roc != oc { 298 | t.Errorf("unexpected original config") 299 | } 300 | 301 | oc.Recorder.Event(resource, corev1.EventTypeNormal, "AllGood", "") 302 | 303 | return nil 304 | }, 305 | }, 306 | } 307 | }, 308 | }, 309 | ExpectEvents: []rtesting.Event{ 310 | rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "AllGood", ""), 311 | }, 312 | }, 313 | } 314 | 315 | rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { 316 | return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) 317 | }) 318 | } 319 | --------------------------------------------------------------------------------