diff --git a/internal/controller/component_patches.go b/internal/controller/component_patches.go new file mode 100644 index 000000000..a2dd9982c --- /dev/null +++ b/internal/controller/component_patches.go @@ -0,0 +1,41 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/cluster-api-operator/internal/controller/genericprovider" + "sigs.k8s.io/cluster-api-operator/internal/patch" + ctrl "sigs.k8s.io/controller-runtime" +) + +func applyPatches(ctx context.Context, provider genericprovider.GenericProvider) func(objs []unstructured.Unstructured) ([]unstructured.Unstructured, error) { + log := ctrl.LoggerFrom(ctx) + + return func(objs []unstructured.Unstructured) ([]unstructured.Unstructured, error) { + if len(provider.GetSpec().ManifestPatches) == 0 { + log.V(5).Info("No resource patches to apply") + return objs, nil + } + + log.V(5).Info("Applying resource patches") + + return patch.ApplyPatches(objs, provider.GetSpec().ManifestPatches) + } +} diff --git a/internal/controller/phases.go b/internal/controller/phases.go index 1277c991e..7729adfa5 100644 --- a/internal/controller/phases.go +++ b/internal/controller/phases.go @@ -419,8 +419,12 @@ func (p *phaseReconciler) fetch(ctx context.Context) (reconcile.Result, error) { // ProviderSpec provides fields for customizing the provider deployment options. // We can use clusterctl library to apply this customizations. - err = repository.AlterComponents(p.components, customizeObjectsFn(p.provider)) - if err != nil { + if err := repository.AlterComponents(p.components, customizeObjectsFn(p.provider)); err != nil { + return reconcile.Result{}, wrapPhaseError(err, operatorv1.ComponentsFetchErrorReason) + } + + // Apply patches to the provider components if specified. + if err := repository.AlterComponents(p.components, applyPatches(ctx, p.provider)); err != nil { return reconcile.Result{}, wrapPhaseError(err, operatorv1.ComponentsFetchErrorReason) } diff --git a/internal/patch/matchinfo.go b/internal/patch/matchinfo.go new file mode 100644 index 000000000..bd143d571 --- /dev/null +++ b/internal/patch/matchinfo.go @@ -0,0 +1,44 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package patch + +import ( + "fmt" + + "sigs.k8s.io/yaml" +) + +// we match resources and patches on their v1 TypeMeta. +type matchInfo struct { + Kind string `json:"kind,omitempty"` + APIVersion string `json:"apiVersion,omitempty"` + Metadata Metadata `json:"metadata,omitempty"` +} + +type Metadata struct { + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +func parseYAMLMatchInfo(raw []byte) (matchInfo, error) { + m := matchInfo{} + if err := yaml.Unmarshal(raw, &m); err != nil { + return matchInfo{}, fmt.Errorf("failed to parse match info: %w", err) + } + + return m, nil +} diff --git a/internal/patch/mergepatch.go b/internal/patch/mergepatch.go new file mode 100644 index 000000000..0da109fd7 --- /dev/null +++ b/internal/patch/mergepatch.go @@ -0,0 +1,51 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package patch + +import ( + "fmt" + + "sigs.k8s.io/yaml" +) + +type mergePatch struct { + json []byte + matchInfo matchInfo +} + +func parseMergePatches(rawPatches []string) ([]mergePatch, error) { + patches := []mergePatch{} + + for _, patch := range rawPatches { + matchInfo, err := parseYAMLMatchInfo([]byte(patch)) + if err != nil { + return nil, fmt.Errorf("failed to parse patch: %w", err) + } + + json, err := yaml.YAMLToJSON([]byte(patch)) + if err != nil { + return nil, fmt.Errorf("failed to parse patch: %w", err) + } + + patches = append(patches, mergePatch{ + json: json, + matchInfo: matchInfo, + }) + } + + return patches, nil +} diff --git a/internal/patch/patch.go b/internal/patch/patch.go new file mode 100644 index 000000000..1f558ce9b --- /dev/null +++ b/internal/patch/patch.go @@ -0,0 +1,68 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package patch + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + utilyaml "sigs.k8s.io/cluster-api/util/yaml" + "sigs.k8s.io/yaml" +) + +// ApplyPatches patches a list of unstructured objects with a list of patches. +// Patches match if their kind and apiVersion match a document, with the exception +// that if the patch does not set apiVersion it will be ignored. +func ApplyPatches(toPatch []unstructured.Unstructured, patches []string) ([]unstructured.Unstructured, error) { + resources, err := parseResources(toPatch) + if err != nil { + return nil, fmt.Errorf("failed to parse resources: %w", err) + } + + mergePatches, err := parseMergePatches(patches) + if err != nil { + return nil, fmt.Errorf("failed to parse patches: %w", err) + } + + result := []unstructured.Unstructured{} + + for _, r := range resources { + for _, p := range mergePatches { + if _, err := r.applyMergePatch(p); err != nil { + return nil, fmt.Errorf("failed to apply patch: %w", err) + } + } + + r.patchedYAML, err = yaml.JSONToYAML(r.json) + if err != nil { + return nil, fmt.Errorf("failted to parse resource: %w", err) + } + + patchedObj, err := utilyaml.ToUnstructured(r.patchedYAML) + if err != nil { + return nil, fmt.Errorf("failed to parse resource: %w", err) + } + + if len(patchedObj) == 0 { + return nil, fmt.Errorf("patched object is empty") + } + + result = append(result, patchedObj...) + } + + return result, nil +} diff --git a/internal/patch/patch_test.go b/internal/patch/patch_test.go new file mode 100644 index 000000000..5987ab2b6 --- /dev/null +++ b/internal/patch/patch_test.go @@ -0,0 +1,192 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package patch + +import ( + "testing" + + . "github.com/onsi/gomega" + utilyaml "sigs.k8s.io/cluster-api/util/yaml" +) + +func TestApplyPatches(t *testing.T) { + testCases := []struct { + name string + objectsToPatchYaml string + expectedPatchedObjectsYaml string + patches []string + expectedError bool + }{ + { + name: "should patch objects with multiple patches", + objectsToPatchYaml: testObjectsToPatchYaml, + expectedPatchedObjectsYaml: expectedTestPatchedObjectsYaml, + patches: []string{addServiceAccoungPatchRBAC, addLabelPatchService, removeSelectorPatchService, addSelectorPatchService, changePortOnSecondService}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + objectToPatch, err := utilyaml.ToUnstructured([]byte(tc.objectsToPatchYaml)) + g.Expect(err).NotTo(HaveOccurred()) + + result, err := ApplyPatches(objectToPatch, tc.patches) + if tc.expectedError { + g.Expect(err).To(HaveOccurred()) + } + g.Expect(err).NotTo(HaveOccurred()) + + resultYaml, err := utilyaml.FromUnstructured(result) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(string(resultYaml)).To(Equal(tc.expectedPatchedObjectsYaml)) + }) + } +} + +const testObjectsToPatchYaml = `--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + some-label: value + name: rolebinding-name +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: role-name +subjects: +- kind: ServiceAccount + name: serviceaccount-name + namespace: namespace-name +--- +apiVersion: v1 +kind: Service +metadata: + labels: + some-label: value + name: service-name-1 + namespace: namespace-name +spec: + ports: + - port: 443 + targetPort: webhook-server + selector: + some-label: value +--- +apiVersion: v1 +kind: Service +metadata: + labels: + some-label: value + name: service-name-2 + namespace: namespace-name +spec: + ports: + - port: 443 + targetPort: webhook-server + selector: + some-label: value` + +const addServiceAccoungPatchRBAC = `apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +subjects: +- kind: ServiceAccount + name: serviceaccount-name + namespace: namespace-name +- kind: ServiceAccount + name: test-service-account + namespace: test-namespace` + +const addLabelPatchService = `--- +apiVersion: v1 +kind: Service +metadata: + labels: + test-label: test-value` + +const removeSelectorPatchService = `apiVersion: v1 +kind: Service +spec: + selector:` + +const addSelectorPatchService = `apiVersion: v1 +kind: Service +spec: + selector: + test-label: test-value` + +const changePortOnSecondService = `--- +apiVersion: v1 +kind: Service +metadata: + name: service-name-2 + namespace: namespace-name +spec: + ports: + - port: 7777 + targetPort: webhook-server` + +const expectedTestPatchedObjectsYaml = `apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + some-label: value + name: rolebinding-name +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: role-name +subjects: +- kind: ServiceAccount + name: serviceaccount-name + namespace: namespace-name +- kind: ServiceAccount + name: test-service-account + namespace: test-namespace +--- +apiVersion: v1 +kind: Service +metadata: + labels: + some-label: value + test-label: test-value + name: service-name-1 + namespace: namespace-name +spec: + ports: + - port: 443 + targetPort: webhook-server + selector: + test-label: test-value +--- +apiVersion: v1 +kind: Service +metadata: + labels: + some-label: value + test-label: test-value + name: service-name-2 + namespace: namespace-name +spec: + ports: + - port: 7777 + targetPort: webhook-server + selector: + test-label: test-value` diff --git a/internal/patch/resource.go b/internal/patch/resource.go new file mode 100644 index 000000000..5a0eb32ce --- /dev/null +++ b/internal/patch/resource.go @@ -0,0 +1,101 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package patch + +import ( + "fmt" + + jsonpatch "github.com/evanphx/json-patch/v5" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + utilyaml "sigs.k8s.io/cluster-api/util/yaml" + "sigs.k8s.io/yaml" +) + +type resource struct { + json []byte + patchedYAML []byte + matchInfo matchInfo +} + +func (r *resource) applyMergePatch(patch mergePatch) (matches bool, err error) { + if !r.matches(patch.matchInfo) { + return false, nil + } + + patched, err := jsonpatch.MergePatch(r.json, patch.json) + if err != nil { + return true, fmt.Errorf("failed to apply patch: %w", err) + } + + r.json = patched + + return true, nil +} + +func (r resource) matches(o matchInfo) bool { + m := &r.matchInfo + // we require kind to match, but if the patch does not specify + // APIVersion we ignore it. + if m.Kind != o.Kind { + return false + } + + // if api version not specified in patch we ignore it + if o.APIVersion != "" && m.APIVersion != o.APIVersion { + return false + } + + // if both namespace and name are specified in patch we require them to match + if o.Metadata.Namespace != "" && o.Metadata.Name != "" && m.Metadata.Namespace != o.Metadata.Namespace && m.Metadata.Name != o.Metadata.Name { + return false + } + + // if only name is specified in patch we require it to match(cluster scoped resources) + if o.Metadata.Name != "" && m.Metadata.Name != o.Metadata.Name { + return false + } + + return true +} + +func parseResources(toPatch []unstructured.Unstructured) ([]resource, error) { + resources := []resource{} + + for _, obj := range toPatch { + raw, err := utilyaml.FromUnstructured([]unstructured.Unstructured{obj}) + if err != nil { + return nil, fmt.Errorf("failed to parse resource: %w", err) + } + + matchInfo, err := parseYAMLMatchInfo(raw) + if err != nil { + return nil, fmt.Errorf("failed to parse resource: %w", err) + } + + json, err := yaml.YAMLToJSON(raw) + if err != nil { + return nil, fmt.Errorf("failted to parse resource: %w", err) + } + + resources = append(resources, resource{ + json: json, + matchInfo: matchInfo, + }) + } + + return resources, nil +}