From e81ad77b43151dd616241a2d2a2159ba58ae17a5 Mon Sep 17 00:00:00 2001 From: Stefan Bueringer Date: Fri, 29 Oct 2021 15:56:55 +0200 Subject: [PATCH] Add ClusterClass patch engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stefan Büringer buringerst@vmware.com Co-authored-by: fabriziopandini --- controllers/topology/desired_state.go | 27 +- .../extensions/patches/api/interface.go | 149 +++++ .../internal/extensions/patches/engine.go | 495 ++++++++++++++++ .../extensions/patches/engine_test.go | 543 ++++++++++++++++++ .../internal/extensions/patches/patch.go | 198 +++++++ .../internal/extensions/patches/patch_test.go | 369 ++++++++++++ .../extensions/patches/variables/variables.go | 158 +++++ .../patches/variables/variables_test.go | 190 ++++++ controllers/topology/internal/log/log.go | 9 + .../internal/mergepatch/mergepatch.go | 5 + ...56-cluster-class-and-managed-topologies.md | 2 +- internal/builder/builders.go | 27 +- 12 files changed, 2161 insertions(+), 11 deletions(-) create mode 100644 controllers/topology/internal/extensions/patches/api/interface.go create mode 100644 controllers/topology/internal/extensions/patches/engine.go create mode 100644 controllers/topology/internal/extensions/patches/engine_test.go create mode 100644 controllers/topology/internal/extensions/patches/patch.go create mode 100644 controllers/topology/internal/extensions/patches/patch_test.go create mode 100644 controllers/topology/internal/extensions/patches/variables/variables.go create mode 100644 controllers/topology/internal/extensions/patches/variables/variables_test.go diff --git a/controllers/topology/desired_state.go b/controllers/topology/desired_state.go index ceaeb072aef3..07a91e881054 100644 --- a/controllers/topology/desired_state.go +++ b/controllers/topology/desired_state.go @@ -29,6 +29,7 @@ import ( clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/controllers/external" "sigs.k8s.io/cluster-api/controllers/topology/internal/contract" + "sigs.k8s.io/cluster-api/controllers/topology/internal/extensions/patches" tlog "sigs.k8s.io/cluster-api/controllers/topology/internal/log" "sigs.k8s.io/cluster-api/controllers/topology/internal/scope" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -36,8 +37,9 @@ import ( // computeDesiredState computes the desired state of the cluster topology. // NOTE: We are assuming all the required objects are provided as input; also, in case of any error, -// the entire compute operation operation will fail. This might be improved in the future if support for reconciling +// the entire compute operation will fail. This might be improved in the future if support for reconciling // subset of a topology will be implemented. +// NOTE: We have to make sure all spec fields we explicitly set in desired objects are preserved during patching. func (r *ClusterReconciler) computeDesiredState(ctx context.Context, s *scope.Scope) (*scope.ClusterState, error) { var err error desiredState := &scope.ClusterState{ @@ -66,16 +68,23 @@ func (r *ClusterReconciler) computeDesiredState(ctx context.Context, s *scope.Sc // InfrastructureCluster and the ControlPlane objects generated by the previous step. desiredState.Cluster = computeCluster(ctx, s, desiredState.InfrastructureCluster, desiredState.ControlPlane.Object) - // If required by the blueprint, compute the desired state of the MachineDeployment objects for the worker nodes, if any. - if !s.Blueprint.HasMachineDeployments() { - return desiredState, nil + // If required, compute the desired state of the MachineDeployments from the list of MachineDeploymentTopologies + // defined in the cluster. + if s.Blueprint.HasMachineDeployments() { + desiredState.MachineDeployments, err = computeMachineDeployments(ctx, s, desiredState.ControlPlane) + if err != nil { + return nil, err + } } - // Compute the desired state of the MachineDeployments from the list of MachineDeploymentTopologies - // defined in the cluster. - desiredState.MachineDeployments, err = computeMachineDeployments(ctx, s, desiredState.ControlPlane) - if err != nil { - return nil, err + // Apply patches the desired state according to the patches from the ClusterClass, variables from the Cluster + // and builtin variables. + // NOTE: We have to make sure all spec fields that were explicitly set in desired objects during the computation above + // are preserved during patching. When desired objects are computed their spec is copied from a template, in some cases + // further modifications to the spec are made afterwards. In those cases we have to make sure those fields are not overwritten + // in apply patches. Some examples are .spec.machineTemplate and .spec.version in control planes. + if err := patches.Apply(ctx, s.Blueprint, desiredState); err != nil { + return nil, errors.Wrap(err, "failed to apply patches") } return desiredState, nil diff --git a/controllers/topology/internal/extensions/patches/api/interface.go b/controllers/topology/internal/extensions/patches/api/interface.go new file mode 100644 index 000000000000..d37087608482 --- /dev/null +++ b/controllers/topology/internal/extensions/patches/api/interface.go @@ -0,0 +1,149 @@ +/* +Copyright 2021 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 api contains the API definition for the patch engine. +package api + +import ( + "context" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// Generator defines a component that can generate patches for ClusterClass templates. +// NOTE: We are introducing this interface as a decoupling layer between the patch engine and the concrete components +// responsible for generating patches, because we aim to provide support for external patches in a future iteration. +// We also assume that this interface and all the related types will be moved in a separated (versioned) package thus +// providing a versioned contract between Cluster API and the components implementing external patch extensions. +type Generator interface { + // Generate generates patches for templates. + // GenerateRequest contains templates and the corresponding variables. + // GenerateResponse contains the patches which should be applied to the templates of the GenerateRequest. + Generate(ctx context.Context, request *GenerateRequest) (*GenerateResponse, error) +} + +// GenerateRequest defines the input for a Generate request. +type GenerateRequest struct { + // Variables is a name/value map containing variables. + Variables map[string]apiextensionsv1.JSON + + // Items contains the list of templates to generate patches for. + Items []*GenerateRequestTemplate +} + +// GenerateRequestTemplate defines one of the ClusterClass templates to generate patches for. +type GenerateRequestTemplate struct { + // TemplateID identifies a template to generate patches for; + // the same TemplateID must be used when specifying to which template a generated patch should be applied to. + TemplateID TemplateID + + // Variables is a name/value map containing variables specifically for the current template. + // For example some builtin variables like MachineDeployment replicas and version are context-sensitive + // and thus are only added to templates for MachineDeployments and with values which correspond to the + // current MachineDeployment. + Variables map[string]apiextensionsv1.JSON + + // Template contains the template. + Template apiextensionsv1.JSON +} + +// TemplateID identifies one of the ClusterClass templates to generate patches for; +// the same TemplateID must be used when specifying where a generated patch should apply to. +type TemplateID struct { + // APIVersion of the current template. + APIVersion string + + // Kind of the current template. + Kind string + + // TargetType defines where the template is used. + TargetType TargetType + + // MachineDeployment specifies the MachineDeployment in which the template is used. + // This field is only set if the template is used in the context of a MachineDeployment. + MachineDeployment MachineDeploymentID +} + +// MachineDeploymentID specifies the MachineDeployment in which the template is used. +type MachineDeploymentID struct { + // TopologyName is the name of the MachineDeploymentTopology. + TopologyName string + + // Class is the name of the MachineDeploymentClass. + Class string +} + +// TargetType define the type for target types enum. +type TargetType int32 + +const ( + // InfrastructureClusterTemplateTargetType identifies a template for the InfrastructureCluster object. + InfrastructureClusterTemplateTargetType TargetType = 0 + + // ControlPlaneTemplateTargetType identifies a template for the ControlPlane object. + ControlPlaneTemplateTargetType TargetType = 1 + + // ControlPlaneInfrastructureMachineTemplateTargetType identifies a template for the InfrastructureMachines to be used for the ControlPlane object. + ControlPlaneInfrastructureMachineTemplateTargetType TargetType = 2 + + // MachineDeploymentBootstrapConfigTemplateTargetType identifies a template for the BootstrapConfig to be used for a MachineDeployment object. + MachineDeploymentBootstrapConfigTemplateTargetType TargetType = 3 + + // MachineDeploymentInfrastructureMachineTemplateTargetType identifies a template for the InfrastructureMachines to be used for a MachineDeployment object. + MachineDeploymentInfrastructureMachineTemplateTargetType TargetType = 4 +) + +var ( + // TargetTypeToName maps from a TargetType to its string representation. + TargetTypeToName = map[TargetType]string{ + InfrastructureClusterTemplateTargetType: "InfrastructureClusterTemplate", + ControlPlaneTemplateTargetType: "ControlPlaneTemplate", + ControlPlaneInfrastructureMachineTemplateTargetType: "ControlPlane/InfrastructureMachineTemplate", + MachineDeploymentBootstrapConfigTemplateTargetType: "MachineDeployment/BootstrapConfigTemplate", + MachineDeploymentInfrastructureMachineTemplateTargetType: "MachineDeployment/InfrastructureMachineTemplate", + } +) + +// PatchType define the type for patch types enum. +type PatchType int32 + +const ( + // JSONPatchType identifies a https://datatracker.ietf.org/doc/html/rfc6902 json patch. + JSONPatchType PatchType = 0 + + // JSONMergePatchType identifies a https://datatracker.ietf.org/doc/html/rfc7386 json merge patch. + JSONMergePatchType PatchType = 1 +) + +// GenerateResponse defines the response of a Generate request. +// NOTE: Patches defined in GenerateResponse will be applied to the original GenerateRequest object, thus +// adding changes on templates across all the subsequent Generate calls. +type GenerateResponse struct { + // Items contains the list of generated patches. + Items []GenerateResponsePatch +} + +// GenerateResponsePatch defines a Patch targeting a specific GenerateRequestTemplate. +type GenerateResponsePatch struct { + // TemplateID identifies the template the patch should apply to. + TemplateID TemplateID + + // Patch contains the patch. + Patch apiextensionsv1.JSON + + // Patch defines the type of the JSON patch. + PatchType PatchType +} diff --git a/controllers/topology/internal/extensions/patches/engine.go b/controllers/topology/internal/extensions/patches/engine.go new file mode 100644 index 000000000000..4e5dd945d9cf --- /dev/null +++ b/controllers/topology/internal/extensions/patches/engine.go @@ -0,0 +1,495 @@ +/* +Copyright 2021 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 patches implement the patch engine. +package patches + +import ( + "context" + "encoding/json" + "fmt" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/pkg/errors" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/controllers/topology/internal/contract" + "sigs.k8s.io/cluster-api/controllers/topology/internal/extensions/patches/api" + "sigs.k8s.io/cluster-api/controllers/topology/internal/extensions/patches/variables" + tlog "sigs.k8s.io/cluster-api/controllers/topology/internal/log" + "sigs.k8s.io/cluster-api/controllers/topology/internal/scope" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Apply applies patches to the desired state according to the patches from the ClusterClass, variables from the Cluster +// and builtin variables. +// * A GenerateRequest with all templates and global and template-specific variables is created. +// * Then for all patches of a ClusterClass, patches are generated and successively applied to the templates +// in the GenerateRequest. +// * Eventually the patched templates are used to update the specs of the desired objects. +func Apply(ctx context.Context, blueprint *scope.ClusterBlueprint, desired *scope.ClusterState) error { + log := tlog.LoggerFrom(ctx) + + // Create a patch generation request. + req, err := request(blueprint, desired) + if err != nil { + return errors.Wrapf(err, "failed to generate patch request") + } + + // Loop over patches in ClusterClass, generate patches and apply them to the request. + for i := range blueprint.ClusterClass.Spec.Patches { + patch := blueprint.ClusterClass.Spec.Patches[i] + ctx, log = log.WithValues("patch", patch.Name).Into(ctx) + + log.V(5).Infof("Applying patch to templates") + + // Create patch generator for the current patch. + generator, err := createPatchGenerator(&patch) + if err != nil { + return err + } + + // Generate patches. + resp, err := generator.Generate(ctx, req) + if err != nil { + return errors.Errorf("failed to generate patches for patch %q", patch.Name) + } + + // Apply patches to the request. + if err := applyPatchesToRequest(ctx, req, resp); err != nil { + return err + } + } + + // Use patched templates to update the desired state objects. + log.V(5).Infof("Applying patches to desired state") + if err := updateDesiredState(ctx, req, blueprint, desired); err != nil { + return errors.Wrapf(err, "failed to apply patches to desired state") + } + + return nil +} + +// request creates a GenerateRequest based on the ClusterBlueprint and the desired state. +// ClusterBlueprint supplies the templates. Desired state is used to calculate variables which are later used +// as input for the patch generation. +// NOTE: GenerateRequestTemplates are created for the templates of each individual MachineDeployment in the desired +// state. This is necessary because some builtin variables are MachineDeployment specific. For example version and +// replicas of a MachineDeployment. +// NOTE: A single GenerateRequest object is used to carry templates state across subsequent Generate calls. +func request(blueprint *scope.ClusterBlueprint, desired *scope.ClusterState) (*api.GenerateRequest, error) { + req := &api.GenerateRequest{} + + // Calculate global variables. + globalVariables, err := variables.Global(blueprint.Topology, desired.Cluster) + if err != nil { + return nil, errors.Wrapf(err, "failed to calculate global variables") + } + req.Variables = globalVariables + + // Add the InfrastructureClusterTemplate. + t, err := newTemplate(blueprint.InfrastructureClusterTemplate, api.TemplateID{ + APIVersion: blueprint.InfrastructureClusterTemplate.GetAPIVersion(), + Kind: blueprint.InfrastructureClusterTemplate.GetKind(), + TargetType: api.InfrastructureClusterTemplateTargetType, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to create InfrastructureCluster template %s", + tlog.KObj{Obj: blueprint.InfrastructureClusterTemplate}) + } + req.Items = append(req.Items, t) + + // Calculate controlPlane variables. + controlPlaneVariables, err := variables.ControlPlane(&blueprint.Topology.ControlPlane, desired.ControlPlane.Object) + if err != nil { + return nil, errors.Wrapf(err, "failed to calculate ControlPlane variables") + } + + // Add the ControlPlaneTemplate. + t, err = newTemplate(blueprint.ControlPlane.Template, api.TemplateID{ + APIVersion: blueprint.ControlPlane.Template.GetAPIVersion(), + Kind: blueprint.ControlPlane.Template.GetKind(), + TargetType: api.ControlPlaneTemplateTargetType, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to create ControlPlane template %s", + tlog.KObj{Obj: blueprint.ControlPlane.Template}) + } + t.Variables = controlPlaneVariables + req.Items = append(req.Items, t) + + // If the clusterClass mandates the controlPlane has infrastructureMachines, + // add the InfrastructureMachineTemplate for control plane machines. + if blueprint.HasControlPlaneInfrastructureMachine() { + t, err = newTemplate(blueprint.ControlPlane.InfrastructureMachineTemplate, api.TemplateID{ + APIVersion: blueprint.ControlPlane.InfrastructureMachineTemplate.GetAPIVersion(), + Kind: blueprint.ControlPlane.InfrastructureMachineTemplate.GetKind(), + TargetType: api.ControlPlaneInfrastructureMachineTemplateTargetType, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to create ControlPlane's machine template %s", + tlog.KObj{Obj: blueprint.ControlPlane.InfrastructureMachineTemplate}) + } + t.Variables = controlPlaneVariables + req.Items = append(req.Items, t) + } + + // Add BootstrapConfigTemplate and InfrastructureMachine template for all MachineDeploymentTopologies + // in the Cluster. + // NOTE: We intentionally iterate over MachineDeploymentTopologies in the Cluster instead of over + // MachineDeploymentClasses in the ClusterClass. We have to calculate patches per MachineDeploymentTopology + // instead of per MachineDeploymentClass, because the builtin variable values depend on the current + // MachineDeployment. E.g., there are variables for the version or replicas of the MachineDeployment. + for mdTopologyName, md := range desired.MachineDeployments { + // Lookup MachineDeploymentTopology. + mdTopology, err := lookupMDTopology(blueprint.Topology, mdTopologyName) + if err != nil { + return nil, err + } + + // Get corresponding MachineDeploymentClass. + mdClass, ok := blueprint.MachineDeployments[mdTopology.Class] + if !ok { + return nil, errors.Errorf("failed to lookup MachineDeployment class %q in ClusterClass", mdTopology.Class) + } + + // Calculate MachineDeployment variables. + mdVariables, err := variables.MachineDeployment(mdTopology, md.Object) + if err != nil { + return nil, errors.Wrapf(err, "failed to calculate variables for %s", tlog.KObj{Obj: md.Object}) + } + + // Add the BootstrapTemplate. + t, err := newTemplate(mdClass.BootstrapTemplate, api.TemplateID{ + APIVersion: mdClass.BootstrapTemplate.GetAPIVersion(), + Kind: mdClass.BootstrapTemplate.GetKind(), + TargetType: api.MachineDeploymentBootstrapConfigTemplateTargetType, + MachineDeployment: api.MachineDeploymentID{ + TopologyName: mdTopologyName, + Class: mdTopology.Class, + }, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to create BootstrapConfig template %s for MachineDeployment topology %s", + tlog.KObj{Obj: mdClass.BootstrapTemplate}, mdTopologyName) + } + t.Variables = mdVariables + req.Items = append(req.Items, t) + + // Add the InfrastructureMachineTemplate. + t, err = newTemplate(mdClass.InfrastructureMachineTemplate, api.TemplateID{ + APIVersion: mdClass.InfrastructureMachineTemplate.GetAPIVersion(), + Kind: mdClass.InfrastructureMachineTemplate.GetKind(), + TargetType: api.MachineDeploymentInfrastructureMachineTemplateTargetType, + MachineDeployment: api.MachineDeploymentID{ + TopologyName: mdTopologyName, + Class: mdTopology.Class, + }, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to create InfrastructureMachine template %s for MachineDeployment topology %s", + tlog.KObj{Obj: mdClass.InfrastructureMachineTemplate}, mdTopologyName) + } + t.Variables = mdVariables + req.Items = append(req.Items, t) + } + + return req, nil +} + +// newTemplate returns a GenerateRequestTemplate object based on the client.Object and the templateID. +func newTemplate(obj client.Object, templateID api.TemplateID) (*api.GenerateRequestTemplate, error) { + t := &api.GenerateRequestTemplate{ + TemplateID: templateID, + } + + jsonObj, err := json.Marshal(obj) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal object to json") + } + t.Template = apiextensionsv1.JSON{Raw: jsonObj} + + return t, nil +} + +// lookupMDTopology looks up the MachineDeploymentTopology based on a mdTopologyName in a topology. +func lookupMDTopology(topology *clusterv1.Topology, mdTopologyName string) (*clusterv1.MachineDeploymentTopology, error) { + for _, mdTopology := range topology.Workers.MachineDeployments { + if mdTopology.Name == mdTopologyName { + return &mdTopology, nil + } + } + return nil, errors.Errorf("failed to lookup MachineDeployment topology %q in Cluster topology", mdTopologyName) +} + +// createPatchGenerator is used to inject a fake patch generator in unit tests. +var createPatchGenerator = createPatchGeneratorImpl + +// createPatchGeneratorImpl creates a patch generator for the given patch. +// NOTE: Currently only inline JSON patches are supported and it's not clear yet how +// e.g. external patches will be configured in the future. +// Note: This func is required to be able to inject a fake patch generator for unit tests. +func createPatchGeneratorImpl(patch *clusterv1.ClusterClassPatch) (api.Generator, error) { + // Return a JSONPatchGenerator if there are PatchDefinitions in the patch. + if len(patch.Definitions) > 0 { + // TODO(sbueringer): will be implemented in a follow-up PR + // return inline.NewJSONPatchGenerator(patch), nil + return nil, nil + } + + return nil, errors.Errorf("failed to create patch generator for patch %q", patch.Name) +} + +// applyPatchesToRequest updates the templates of a GenerateRequest by applying the patches +// of a GenerateResponse. +func applyPatchesToRequest(ctx context.Context, req *api.GenerateRequest, resp *api.GenerateResponse) error { + log := tlog.LoggerFrom(ctx) + + for _, patch := range resp.Items { + log = log.WithValues("templateID", templateIDRef(patch.TemplateID)) + + // Get the template the patch should be applied to. + template := getTemplate(req, patch.TemplateID) + + // If a patch doesn't apply to any template, this is a misconfiguration. + if template == nil { + return errors.Errorf("could not find template with id %q in GenerateRequest", templateIDRef(patch.TemplateID)) + } + + // Use the patch to create a patched copy of the template. + var patchedTemplate []byte + var err error + + switch patch.PatchType { + case api.JSONPatchType: + log.V(5).Infof("Accumulating JSON patch", "patch", string(patch.Patch.Raw)) + jsonPatch, err := jsonpatch.DecodePatch(patch.Patch.Raw) + if err != nil { + return errors.Wrapf(err, "failed to apply patch to template %s: error decoding json patch (RFC6902): %s", + templateIDRef(template.TemplateID), string(patch.Patch.Raw)) + } + + patchedTemplate, err = jsonPatch.Apply(template.Template.Raw) + if err != nil { + return errors.Wrapf(err, "failed to apply patch to template %s: error applying json patch (RFC6902): %s", + templateIDRef(template.TemplateID), string(patch.Patch.Raw)) + } + case api.JSONMergePatchType: + log.V(5).Infof("Accumulating JSON merge patch", "patch", string(patch.Patch.Raw)) + patchedTemplate, err = jsonpatch.MergePatch(template.Template.Raw, patch.Patch.Raw) + if err != nil { + return errors.Wrapf(err, "failed to apply patch to template %s: error applying json merge patch (RFC7386): %s", + templateIDRef(template.TemplateID), string(patch.Patch.Raw)) + } + } + + // Overwrite the spec of template.Template with the spec of the patchedTemplate, + // to ensure that we only pick up changes to the spec. + if err := patchTemplateSpec(&template.Template, patchedTemplate); err != nil { + return errors.Wrapf(err, "failed to apply patch to template %s", + templateIDRef(template.TemplateID)) + } + } + return nil +} + +// updateDesiredState uses the patched templates of a GenerateRequest to update the desired state. +// NOTE: This func should be called after all the patches have been applied to the GenerateRequest. +// TODO(sbueringer): Let's only overwrite desired state objects if the corresponding template actually have been patched. +func updateDesiredState(ctx context.Context, req *api.GenerateRequest, blueprint *scope.ClusterBlueprint, desired *scope.ClusterState) error { + var err error + + // Update the InfrastructureCluster. + infrastructureClusterTemplate, err := getTemplateAsUnstructured(req, + api.TemplateID{ + APIVersion: blueprint.InfrastructureClusterTemplate.GetAPIVersion(), + Kind: blueprint.InfrastructureClusterTemplate.GetKind(), + TargetType: api.InfrastructureClusterTemplateTargetType, + }, + ) + if err != nil { + return err + } + if err := patchObject(ctx, desired.InfrastructureCluster, infrastructureClusterTemplate); err != nil { + return err + } + + // Update the ControlPlane. + controlPlaneTemplate, err := getTemplateAsUnstructured(req, + api.TemplateID{ + APIVersion: blueprint.ControlPlane.Template.GetAPIVersion(), + Kind: blueprint.ControlPlane.Template.GetKind(), + TargetType: api.ControlPlaneTemplateTargetType, + }, + ) + if err != nil { + return err + } + if err := patchObject(ctx, desired.ControlPlane.Object, controlPlaneTemplate, PreserveFields{ + contract.ControlPlane().MachineTemplate().Metadata().Path(), + contract.ControlPlane().MachineTemplate().InfrastructureRef().Path(), + contract.ControlPlane().MachineTemplate().NodeDrainTimeout().Path(), + contract.ControlPlane().Replicas().Path(), + contract.ControlPlane().Version().Path(), + }); err != nil { + return err + } + + // If the ClusterClass mandates the ControlPlane has InfrastructureMachines, + // update the InfrastructureMachineTemplate for ControlPlane machines. + if blueprint.HasControlPlaneInfrastructureMachine() { + infrastructureMachineTemplate, err := getTemplateAsUnstructured(req, + api.TemplateID{ + APIVersion: blueprint.ControlPlane.InfrastructureMachineTemplate.GetAPIVersion(), + Kind: blueprint.ControlPlane.InfrastructureMachineTemplate.GetKind(), + TargetType: api.ControlPlaneInfrastructureMachineTemplateTargetType, + }, + ) + if err != nil { + return err + } + if err := patchTemplate(ctx, desired.ControlPlane.InfrastructureMachineTemplate, infrastructureMachineTemplate); err != nil { + return err + } + } + + // Update the templates for all MachineDeployments. + for mdTopologyName, md := range desired.MachineDeployments { + // Lookup MachineDeploymentTopology. + mdTopology, err := lookupMDTopology(blueprint.Topology, mdTopologyName) + if err != nil { + return err + } + + // Update the BootstrapConfigTemplate. + bootstrapTemplate, err := getTemplateAsUnstructured(req, + api.TemplateID{ + APIVersion: md.BootstrapTemplate.GetAPIVersion(), + Kind: md.BootstrapTemplate.GetKind(), + TargetType: api.MachineDeploymentBootstrapConfigTemplateTargetType, + MachineDeployment: api.MachineDeploymentID{ + TopologyName: mdTopologyName, + Class: mdTopology.Class, + }, + }, + ) + if err != nil { + return err + } + if err := patchTemplate(ctx, md.BootstrapTemplate, bootstrapTemplate); err != nil { + return err + } + + // Update the InfrastructureMachineTemplate. + infrastructureMachineTemplate, err := getTemplateAsUnstructured(req, + api.TemplateID{ + APIVersion: md.InfrastructureMachineTemplate.GetAPIVersion(), + Kind: md.InfrastructureMachineTemplate.GetKind(), + TargetType: api.MachineDeploymentInfrastructureMachineTemplateTargetType, + MachineDeployment: api.MachineDeploymentID{ + TopologyName: mdTopologyName, + Class: mdTopology.Class, + }, + }, + ) + if err != nil { + return err + } + if err := patchTemplate(ctx, md.InfrastructureMachineTemplate, infrastructureMachineTemplate); err != nil { + return err + } + } + + return nil +} + +// getTemplateAsUnstructured is a utility func that returns a template matching the templateID from a GenerateRequest. +func getTemplateAsUnstructured(req *api.GenerateRequest, id api.TemplateID) (*unstructured.Unstructured, error) { + // Find the template the patch should be applied to. + template := getTemplate(req, id) + + // If a patch doesn't apply to any object, this is a misconfiguration. + if template == nil { + return nil, errors.Errorf("failed to get template %s", templateIDRef(id)) + } + + return templateToUnstructured(template) +} + +// getTemplate is a utility func that returns a template matching the templateID from a GenerateRequest. +func getTemplate(req *api.GenerateRequest, templateID api.TemplateID) *api.GenerateRequestTemplate { + for _, template := range req.Items { + if templateIDsAreEqual(templateID, template.TemplateID) { + return template + } + } + return nil +} + +// templateIDsAreEqual returns true if the TemplateIDs are equal. +func templateIDsAreEqual(a, b api.TemplateID) bool { + return a.APIVersion == b.APIVersion && + a.Kind == b.Kind && + a.TargetType == b.TargetType && + a.MachineDeployment.TopologyName == b.MachineDeployment.TopologyName && + a.MachineDeployment.Class == b.MachineDeployment.Class +} + +// templateIDString returns a string representing the TemplateID. +func templateIDRef(t api.TemplateID) string { + ret := fmt.Sprintf("%s %s/%s", api.TargetTypeToName[t.TargetType], t.APIVersion, t.Kind) + if t.MachineDeployment.TopologyName != "" { + ret = fmt.Sprintf("%s, MachineDeployment topology %s", ret, t.MachineDeployment.TopologyName) + } + if t.MachineDeployment.Class != "" { + ret = fmt.Sprintf("%s, MachineDeployment class %s", ret, t.MachineDeployment.Class) + } + return ret +} + +// templateToUnstructured converts a GenerateRequestTemplate into an Unstructured object. +func templateToUnstructured(t *api.GenerateRequestTemplate) (*unstructured.Unstructured, error) { + // Unmarshal the template. + u, err := bytesToUnstructured(t.Template.Raw) + if err != nil { + return nil, errors.Wrapf(err, "failed to convert template to Unstructured") + } + + // Set the GVK. + gv, err := schema.ParseGroupVersion(t.TemplateID.APIVersion) + if err != nil { + return nil, errors.Wrap(err, "failed to convert template to Unstructured: failed to parse group version") + } + u.SetGroupVersionKind(gv.WithKind(t.TemplateID.Kind)) + return u, nil +} + +// jsonToUnstructured provides a utility method that converts a (JSON) byte array into an Unstructured object. +func bytesToUnstructured(b []byte) (*unstructured.Unstructured, error) { + // Unmarshal the JSON. + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal object from json") + } + + // Set the content. + u := &unstructured.Unstructured{} + u.SetUnstructuredContent(m) + + return u, nil +} diff --git a/controllers/topology/internal/extensions/patches/engine_test.go b/controllers/topology/internal/extensions/patches/engine_test.go new file mode 100644 index 000000000000..80fd3d76b023 --- /dev/null +++ b/controllers/topology/internal/extensions/patches/engine_test.go @@ -0,0 +1,543 @@ +/* +Copyright 2021 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 patches + +import ( + "context" + "fmt" + "strings" + "testing" + + . "github.com/onsi/gomega" + "github.com/pkg/errors" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/pointer" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/controllers/topology/internal/extensions/patches/api" + "sigs.k8s.io/cluster-api/controllers/topology/internal/scope" + "sigs.k8s.io/cluster-api/internal/builder" + . "sigs.k8s.io/cluster-api/internal/matchers" +) + +func TestApply(t *testing.T) { + type patch struct { + name string + patches []api.GenerateResponsePatch + } + type expectedFields struct { + infrastructureCluster map[string]interface{} + controlPlane map[string]interface{} + controlPlaneInfrastructureMachineTemplate map[string]interface{} + machineDeploymentBootstrapTemplate map[string]map[string]interface{} + machineDeploymentInfrastructureMachineTemplate map[string]map[string]interface{} + } + + tests := []struct { + name string + patches []patch + expectedFields expectedFields + }{ + { + name: "Should preserve desired state, if there are no patches", + }, + { + name: "Should apply JSON patches to InfraCluster, ControlPlane and ControlPlaneInfrastructureMachineTemplate", + patches: []patch{ + { + name: "fake-patch1", + patches: []api.GenerateResponsePatch{ + { + TemplateID: api.TemplateID{ + APIVersion: builder.InfrastructureGroupVersion.String(), + Kind: builder.GenericInfrastructureClusterTemplateKind, + TargetType: api.InfrastructureClusterTemplateTargetType, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"add","path":"/spec/template/spec/resource","value":"infraCluster"}] + `)}, + PatchType: api.JSONPatchType, + }, + { + TemplateID: api.TemplateID{ + APIVersion: builder.ControlPlaneGroupVersion.String(), + Kind: builder.GenericControlPlaneTemplateKind, + TargetType: api.ControlPlaneTemplateTargetType, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"add","path":"/spec/template/spec/resource","value":"controlPlane"}] + `)}, + PatchType: api.JSONPatchType, + }, + { + TemplateID: api.TemplateID{ + APIVersion: builder.InfrastructureGroupVersion.String(), + Kind: builder.GenericInfrastructureMachineTemplateKind, + TargetType: api.ControlPlaneInfrastructureMachineTemplateTargetType, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"add","path":"/spec/template/spec/resource","value":"controlPlaneInfrastructureMachineTemplate"}] + `)}, + PatchType: api.JSONPatchType, + }, + }, + }}, + expectedFields: expectedFields{ + infrastructureCluster: map[string]interface{}{ + "spec.resource": "infraCluster", + }, + controlPlane: map[string]interface{}{ + "spec.resource": "controlPlane", + }, + controlPlaneInfrastructureMachineTemplate: map[string]interface{}{ + "spec.template.spec.resource": "controlPlaneInfrastructureMachineTemplate", + }, + }, + }, + { + name: "Should apply JSON patches to MachineDeployment templates", + patches: []patch{ + { + name: "fake-patch1", + patches: []api.GenerateResponsePatch{ + { + TemplateID: api.TemplateID{ + APIVersion: builder.InfrastructureGroupVersion.String(), + Kind: builder.GenericInfrastructureMachineTemplateKind, + TargetType: api.MachineDeploymentInfrastructureMachineTemplateTargetType, + MachineDeployment: api.MachineDeploymentID{ + TopologyName: "default-worker-topo1", + Class: "default-worker", + }, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"add","path":"/spec/template/spec/resource","value":"topo1-infra"}] + `)}, + PatchType: api.JSONPatchType, + }, + { + TemplateID: api.TemplateID{ + APIVersion: builder.BootstrapGroupVersion.String(), + Kind: builder.GenericBootstrapConfigTemplateKind, + TargetType: api.MachineDeploymentBootstrapConfigTemplateTargetType, + MachineDeployment: api.MachineDeploymentID{ + TopologyName: "default-worker-topo1", + Class: "default-worker", + }, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"add","path":"/spec/template/spec/resource","value":"topo1-bootstrap"}] + `)}, + PatchType: api.JSONPatchType, + }, + { + TemplateID: api.TemplateID{ + APIVersion: builder.InfrastructureGroupVersion.String(), + Kind: builder.GenericInfrastructureMachineTemplateKind, + TargetType: api.MachineDeploymentInfrastructureMachineTemplateTargetType, + MachineDeployment: api.MachineDeploymentID{ + TopologyName: "default-worker-topo2", + Class: "default-worker", + }, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"add","path":"/spec/template/spec/resource","value":"topo2-infra"}] + `)}, + PatchType: api.JSONPatchType, + }, + { + TemplateID: api.TemplateID{ + APIVersion: builder.BootstrapGroupVersion.String(), + Kind: builder.GenericBootstrapConfigTemplateKind, + TargetType: api.MachineDeploymentBootstrapConfigTemplateTargetType, + MachineDeployment: api.MachineDeploymentID{ + TopologyName: "default-worker-topo2", + Class: "default-worker", + }, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"add","path":"/spec/template/spec/resource","value":"topo2-bootstrap"}] + `)}, + PatchType: api.JSONPatchType, + }, + }, + }, + }, + expectedFields: expectedFields{ + machineDeploymentBootstrapTemplate: map[string]map[string]interface{}{ + "default-worker-topo1": {"spec.template.spec.resource": "topo1-bootstrap"}, + "default-worker-topo2": {"spec.template.spec.resource": "topo2-bootstrap"}, + }, + machineDeploymentInfrastructureMachineTemplate: map[string]map[string]interface{}{ + "default-worker-topo1": {"spec.template.spec.resource": "topo1-infra"}, + "default-worker-topo2": {"spec.template.spec.resource": "topo2-infra"}, + }, + }, + }, + { + name: "Should apply JSON patches in the correct order", + patches: []patch{ + { + name: "fake-patch1", + patches: []api.GenerateResponsePatch{ + { + TemplateID: api.TemplateID{ + APIVersion: builder.ControlPlaneGroupVersion.String(), + Kind: builder.GenericControlPlaneTemplateKind, + TargetType: api.ControlPlaneTemplateTargetType, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"add","path":"/spec/template/spec/clusterName","value":"cluster1"}, +{"op":"add","path":"/spec/template/spec/files","value":[{"key1":"value1"}]}] + `)}, + PatchType: api.JSONPatchType, + }, + }, + }, + { + name: "fake-patch2", + patches: []api.GenerateResponsePatch{ + { + TemplateID: api.TemplateID{ + APIVersion: builder.ControlPlaneGroupVersion.String(), + Kind: builder.GenericControlPlaneTemplateKind, + TargetType: api.ControlPlaneTemplateTargetType, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"replace","path":"/spec/template/spec/clusterName","value":"cluster1-overwritten"}] +`)}, + PatchType: api.JSONPatchType, + }, + }, + }, + }, + expectedFields: expectedFields{ + controlPlane: map[string]interface{}{ + "spec.clusterName": "cluster1-overwritten", + "spec.files": []interface{}{ + map[string]interface{}{ + "key1": "value1", + }, + }, + }, + }, + }, + { + name: "Should apply JSON patches and preserve ControlPlane fields", + patches: []patch{ + { + name: "fake-patch1", + patches: []api.GenerateResponsePatch{ + { + TemplateID: api.TemplateID{ + APIVersion: builder.ControlPlaneGroupVersion.String(), + Kind: builder.GenericControlPlaneTemplateKind, + TargetType: api.ControlPlaneTemplateTargetType, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"replace","path":"/spec/template/spec/replicas","value":1}, +{"op":"replace","path":"/spec/template/spec/version","value":"v1.15.0"}, +{"op":"replace","path":"/spec/template/spec/machineTemplate/infrastructureRef","value":{"apiVersion":"invalid","kind":"invalid","namespace":"invalid","name":"invalid"}}] + `)}, + PatchType: api.JSONPatchType, + }, + }, + }, + }, + }, + { + name: "Should apply JSON patches without metadata", + patches: []patch{ + { + name: "fake-patch1", + patches: []api.GenerateResponsePatch{ + { + TemplateID: api.TemplateID{ + APIVersion: builder.InfrastructureGroupVersion.String(), + Kind: builder.GenericInfrastructureClusterTemplateKind, + TargetType: api.InfrastructureClusterTemplateTargetType, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +[{"op":"add","path":"/spec/template/spec/clusterName","value":"cluster1"}, +{"op":"replace","path":"/metadata/name","value": "overwrittenName"}] + `)}, + PatchType: api.JSONPatchType, + }, + }, + }, + }, + expectedFields: expectedFields{ + infrastructureCluster: map[string]interface{}{ + "spec.clusterName": "cluster1", + }, + }, + }, + { + name: "Should apply JSON merge patches", + patches: []patch{ + { + name: "fake-patch1", + patches: []api.GenerateResponsePatch{ + { + TemplateID: api.TemplateID{ + APIVersion: builder.InfrastructureGroupVersion.String(), + Kind: builder.GenericInfrastructureClusterTemplateKind, + TargetType: api.InfrastructureClusterTemplateTargetType, + }, + Patch: apiextensionsv1.JSON{Raw: []byte(` +{"spec": {"template": {"spec": {"resource": "infraCluster"}}}} + `)}, + PatchType: api.JSONMergePatchType, + }, + }, + }, + }, + expectedFields: expectedFields{ + infrastructureCluster: map[string]interface{}{ + "spec.resource": "infraCluster", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + // Set up test objects. + blueprint, desired := setupTestObjects() + + // If there are patches, set up patch generators. + if len(tt.patches) > 0 { + for _, patch := range tt.patches { + // Add the patches to ensure the patch generator is called. + blueprint.ClusterClass.Spec.Patches = append(blueprint.ClusterClass.Spec.Patches, clusterv1.ClusterClassPatch{Name: patch.name}) + } + + // Overwrite createPatchGenerator temporarily, so that it returns the + // fakePatchGenerator corresponding to the current patch. + createPatchGeneratorOrig := createPatchGenerator + defer func() { + createPatchGenerator = createPatchGeneratorOrig + }() + createPatchGenerator = func(patch *clusterv1.ClusterClassPatch) (api.Generator, error) { + for _, p := range tt.patches { + if p.name == patch.Name { + return &fakePatchGenerator{patches: p.patches}, nil + } + } + return nil, errors.Errorf("could not find patch generator for patch %q", patch.Name) + } + } + + // Copy the desired objects before applying patches. + expectedCluster := desired.Cluster.DeepCopy() + expectedInfrastructureCluster := desired.InfrastructureCluster.DeepCopy() + expectedControlPlane := desired.ControlPlane.Object.DeepCopy() + expectedControlPlaneInfrastructureMachineTemplate := desired.ControlPlane.InfrastructureMachineTemplate.DeepCopy() + expectedBootstrapTemplates := map[string]*unstructured.Unstructured{} + expectedInfrastructureMachineTemplate := map[string]*unstructured.Unstructured{} + for mdTopology, md := range desired.MachineDeployments { + expectedBootstrapTemplates[mdTopology] = md.BootstrapTemplate.DeepCopy() + expectedInfrastructureMachineTemplate[mdTopology] = md.InfrastructureMachineTemplate.DeepCopy() + } + // Set expected fields. + if tt.expectedFields.infrastructureCluster != nil { + setSpecFields(expectedInfrastructureCluster, tt.expectedFields.infrastructureCluster) + } + if tt.expectedFields.controlPlane != nil { + setSpecFields(expectedControlPlane, tt.expectedFields.controlPlane) + } + if tt.expectedFields.controlPlaneInfrastructureMachineTemplate != nil { + setSpecFields(expectedControlPlaneInfrastructureMachineTemplate, tt.expectedFields.controlPlaneInfrastructureMachineTemplate) + } + for mdTopology, expectedFields := range tt.expectedFields.machineDeploymentBootstrapTemplate { + setSpecFields(expectedBootstrapTemplates[mdTopology], expectedFields) + } + for mdTopology, expectedFields := range tt.expectedFields.machineDeploymentInfrastructureMachineTemplate { + setSpecFields(expectedInfrastructureMachineTemplate[mdTopology], expectedFields) + } + + // Apply patches. + g.Expect(Apply(context.Background(), blueprint, desired)).To(Succeed()) + + // Compare the patched desired objects with the expected desired objects. + // TODO(sbueringer): drop IgnoreAutogeneratedMetadata once https://github.com/kubernetes-sigs/cluster-api/pull/5565 is merged + g.Expect(desired.Cluster).To(EqualObject(expectedCluster, IgnoreAutogeneratedMetadata)) + g.Expect(desired.InfrastructureCluster).To(EqualObject(expectedInfrastructureCluster)) + g.Expect(desired.ControlPlane.Object).To(EqualObject(expectedControlPlane)) + g.Expect(desired.ControlPlane.InfrastructureMachineTemplate).To(EqualObject(expectedControlPlaneInfrastructureMachineTemplate)) + for mdTopology, bootstrapTemplate := range expectedBootstrapTemplates { + g.Expect(desired.MachineDeployments[mdTopology].BootstrapTemplate).To(EqualObject(bootstrapTemplate)) + } + for mdTopology, infrastructureMachineTemplate := range expectedInfrastructureMachineTemplate { + g.Expect(desired.MachineDeployments[mdTopology].InfrastructureMachineTemplate).To(EqualObject(infrastructureMachineTemplate)) + } + }) + } +} + +func setupTestObjects() (*scope.ClusterBlueprint, *scope.ClusterState) { + infrastructureClusterTemplate := builder.InfrastructureClusterTemplate(metav1.NamespaceDefault, "infraClusterTemplate1"). + Build() + + controlPlaneInfrastructureMachineTemplate := builder.InfrastructureMachineTemplate(metav1.NamespaceDefault, "controlplaneinframachinetemplate1"). + Build() + controlPlaneTemplate := builder.ControlPlaneTemplate(metav1.NamespaceDefault, "controlPlaneTemplate1"). + WithInfrastructureMachineTemplate(controlPlaneInfrastructureMachineTemplate). + Build() + + workerInfrastructureMachineTemplate := builder.InfrastructureMachineTemplate(metav1.NamespaceDefault, "linux-worker-inframachinetemplate"). + Build() + workerBootstrapTemplate := builder.BootstrapTemplate(metav1.NamespaceDefault, "linux-worker-bootstraptemplate"). + Build() + mdClass1 := builder.MachineDeploymentClass(metav1.NamespaceDefault, "class1"). + WithClass("default-worker"). + WithInfrastructureTemplate(workerInfrastructureMachineTemplate). + WithBootstrapTemplate(workerBootstrapTemplate). + Build() + + clusterClass := builder.ClusterClass(metav1.NamespaceDefault, "clusterClass1"). + WithInfrastructureClusterTemplate(infrastructureClusterTemplate). + WithControlPlaneTemplate(controlPlaneTemplate). + WithControlPlaneInfrastructureMachineTemplate(controlPlaneInfrastructureMachineTemplate). + WithWorkerMachineDeploymentClasses([]clusterv1.MachineDeploymentClass{*mdClass1}). + Build() + + cluster := &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster1", + Namespace: metav1.NamespaceDefault, + }, + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Version: "v1.21.2", + Class: clusterClass.Name, + ControlPlane: clusterv1.ControlPlaneTopology{ + Replicas: pointer.Int32(3), + }, + Workers: &clusterv1.WorkersTopology{ + MachineDeployments: []clusterv1.MachineDeploymentTopology{ + { + Metadata: clusterv1.ObjectMeta{}, + Class: "default-worker", + Name: "default-worker-topo1", + }, + { + Metadata: clusterv1.ObjectMeta{}, + Class: "default-worker", + Name: "default-worker-topo2", + Replicas: pointer.Int32(5), + }, + }, + }, + }, + }, + } + + // Aggregating Cluster, Templates and ClusterClass into a blueprint. + blueprint := &scope.ClusterBlueprint{ + Topology: cluster.Spec.Topology, + ClusterClass: clusterClass, + InfrastructureClusterTemplate: infrastructureClusterTemplate, + ControlPlane: &scope.ControlPlaneBlueprint{ + Template: controlPlaneTemplate, + InfrastructureMachineTemplate: controlPlaneInfrastructureMachineTemplate, + }, + MachineDeployments: map[string]*scope.MachineDeploymentBlueprint{ + "default-worker": { + InfrastructureMachineTemplate: workerInfrastructureMachineTemplate, + BootstrapTemplate: workerBootstrapTemplate, + }, + }, + } + + // Create a Cluster using the ClusterClass from above with multiple MachineDeployments + // using the same MachineDeployment class. + desiredCluster := cluster.DeepCopy() + + infrastructureCluster := builder.InfrastructureCluster(metav1.NamespaceDefault, "infraClusterTemplate1"). + WithSpecFields(map[string]interface{}{ + // Add an empty spec field, to make sure the InfrastructureCluster matches + // the one calculated by computeInfrastructureCluster. + "spec": map[string]interface{}{}, + }). + Build() + + controlPlane := builder.ControlPlane(metav1.NamespaceDefault, "controlPlane1"). + WithVersion("v1.21.2"). + WithReplicas(3). + // Make sure we're using an independent instance of the template. + WithInfrastructureMachineTemplate(controlPlaneInfrastructureMachineTemplate.DeepCopy()). + Build() + + desired := &scope.ClusterState{ + Cluster: desiredCluster, + InfrastructureCluster: infrastructureCluster, + ControlPlane: &scope.ControlPlaneState{ + Object: controlPlane, + // Make sure we're using an independent instance of the template. + InfrastructureMachineTemplate: controlPlaneInfrastructureMachineTemplate.DeepCopy(), + }, + MachineDeployments: map[string]*scope.MachineDeploymentState{ + "default-worker-topo1": { + Object: builder.MachineDeployment(metav1.NamespaceDefault, "md1"). + WithVersion("v1.21.2"). + Build(), + // Make sure we're using an independent instance of the template. + InfrastructureMachineTemplate: workerInfrastructureMachineTemplate.DeepCopy(), + BootstrapTemplate: workerBootstrapTemplate.DeepCopy(), + }, + "default-worker-topo2": { + Object: builder.MachineDeployment(metav1.NamespaceDefault, "md2"). + WithVersion("v1.20.6"). + WithReplicas(5). + Build(), + // Make sure we're using an independent instance of the template. + InfrastructureMachineTemplate: workerInfrastructureMachineTemplate.DeepCopy(), + BootstrapTemplate: workerBootstrapTemplate.DeepCopy(), + }, + }, + } + return blueprint, desired +} + +type fakePatchGenerator struct { + patches []api.GenerateResponsePatch +} + +func (g *fakePatchGenerator) Generate(_ context.Context, _ *api.GenerateRequest) (*api.GenerateResponse, error) { + return &api.GenerateResponse{ + Items: g.patches, + }, nil +} + +// setSpecFields sets fields on an unstructured object from a map. +func setSpecFields(obj *unstructured.Unstructured, fields map[string]interface{}) { + for k, v := range fields { + fieldParts := strings.Split(k, ".") + if len(fieldParts) == 0 { + panic(fmt.Errorf("fieldParts invalid")) + } + if fieldParts[0] != "spec" { + panic(fmt.Errorf("can not set fields outside spec")) + } + if err := unstructured.SetNestedField(obj.UnstructuredContent(), v, strings.Split(k, ".")...); err != nil { + panic(err) + } + } +} diff --git a/controllers/topology/internal/extensions/patches/patch.go b/controllers/topology/internal/extensions/patches/patch.go new file mode 100644 index 000000000000..4c0529bcfdc6 --- /dev/null +++ b/controllers/topology/internal/extensions/patches/patch.go @@ -0,0 +1,198 @@ +/* +Copyright 2021 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 patches + +import ( + "context" + "strings" + + "github.com/pkg/errors" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/cluster-api/controllers/topology/internal/contract" + tlog "sigs.k8s.io/cluster-api/controllers/topology/internal/log" + "sigs.k8s.io/cluster-api/controllers/topology/internal/mergepatch" +) + +// PatchOption represents an option for the patchObject and patchTemplate funcs. +type PatchOption interface { + // ApplyToHelper applies configuration to the given options. + ApplyToHelper(*PatchOptions) +} + +// PatchOptions contains options for patchObject and patchTemplate. +type PatchOptions struct { + preserveFields []contract.Path +} + +// ApplyOptions applies the given patch options +// and then returns itself (for convenient chaining). +func (o *PatchOptions) ApplyOptions(opts []PatchOption) *PatchOptions { + for _, opt := range opts { + opt.ApplyToHelper(o) + } + return o +} + +// PreserveFields instructs the patch func to preserve fields. +type PreserveFields []contract.Path + +// ApplyToHelper applies this configuration to the given patch options. +func (i PreserveFields) ApplyToHelper(opts *PatchOptions) { + opts.preserveFields = i +} + +// patchObject overwrites spec in object with spec.template.spec of patchedTemplate, +// while preserving the configured fields. +func patchObject(ctx context.Context, object, patchedTemplate *unstructured.Unstructured, opts ...PatchOption) error { + log := tlog.LoggerFrom(ctx) + + patchOptions := &PatchOptions{} + patchOptions = patchOptions.ApplyOptions(opts) + + // Create a copy of the object. + original := object.DeepCopy() + + if err := copySpec(copySpecInput{ + src: patchedTemplate, + dest: object, + srcSpecPath: "spec.template.spec", + destSpecPath: "spec", + fieldsToPreserve: patchOptions.preserveFields, + }); err != nil { + return errors.Wrapf(err, "failed to patch object %s by appying changes from the patched template", + tlog.KObj{Obj: object}) + } + + // Log the delta between the object before and after applying the accumulated patches. + helper, err := mergepatch.NewHelper(original, object, nil) + if err != nil { + return err + } + log.V(4).WithObject(object).Infof("Applying accumulated patches", "delta", string(helper.Changes())) + + return nil +} + +// patchTemplate overwrites spec.template.spec in template with spec.template.spec of patchedTemplate, +// while preserving the configured fields. +func patchTemplate(ctx context.Context, template, patchedTemplate *unstructured.Unstructured, opts ...PatchOption) error { + log := tlog.LoggerFrom(ctx) + + patchOptions := &PatchOptions{} + patchOptions = patchOptions.ApplyOptions(opts) + + // Create a copy of the template. + original := template.DeepCopy() + + if err := copySpec(copySpecInput{ + src: patchedTemplate, + dest: template, + srcSpecPath: "spec.template.spec", + destSpecPath: "spec.template.spec", + fieldsToPreserve: patchOptions.preserveFields, + }); err != nil { + return errors.Wrapf(err, "failed to patch template %s by appying changes from the patched template", + tlog.KObj{Obj: template}) + } + + // Log the delta between the object before and after applying the accumulated patches. + helper, err := mergepatch.NewHelper(original, template, nil) + if err != nil { + return err + } + log.V(4).WithObject(template).Infof("Applying accumulated patches", "delta", string(helper.Changes())) + + return nil +} + +// patchTemplateSpec overwrites spec in templateJSON with spec of patchedTemplateBytes. +func patchTemplateSpec(templateJSON *apiextensionsv1.JSON, patchedTemplateBytes []byte) error { + // Convert templates to Unstructured. + template, err := bytesToUnstructured(templateJSON.Raw) + if err != nil { + return errors.Wrap(err, "failed to convert template to Unstructured") + } + patchedTemplate, err := bytesToUnstructured(patchedTemplateBytes) + if err != nil { + return errors.Wrap(err, "failed to convert patched template to Unstructured") + } + + // Copy spec from patchedTemplate to template. + if err := copySpec(copySpecInput{ + src: patchedTemplate, + dest: template, + srcSpecPath: "spec", + destSpecPath: "spec", + }); err != nil { + return errors.Wrap(err, "failed to apply patch to template") + } + + // Marshal template and store it in templateJSON. + templateBytes, err := template.MarshalJSON() + if err != nil { + return errors.Wrapf(err, "failed to marshal patched template") + } + templateJSON.Raw = templateBytes + return nil +} + +type copySpecInput struct { + src *unstructured.Unstructured + dest *unstructured.Unstructured + srcSpecPath string + destSpecPath string + fieldsToPreserve []contract.Path +} + +// copySpec copies a field from a srcSpecPath in src to a destSpecPath in dest, +// while preserving fieldsToPreserve. +func copySpec(in copySpecInput) error { + // Backup fields that should be preserved from dest. + preservedFields := map[string]interface{}{} + for _, field := range in.fieldsToPreserve { + value, found, err := unstructured.NestedFieldNoCopy(in.dest.Object, field...) + if !found { + // Continue if the field does not exist in src. fieldsToPreserve don't have to exist. + continue + } else if err != nil { + return errors.Wrapf(err, "failed to get field %q from %s", strings.Join(field, "."), tlog.KObj{Obj: in.dest}) + } + preservedFields[strings.Join(field, ".")] = value + } + + // Get spec from src. + srcSpec, found, err := unstructured.NestedFieldNoCopy(in.src.Object, strings.Split(in.srcSpecPath, ".")...) + if !found { + return errors.Errorf("missing field %q in %s", in.srcSpecPath, tlog.KObj{Obj: in.src}) + } else if err != nil { + return errors.Wrapf(err, "failed to get field %q from %s", in.srcSpecPath, tlog.KObj{Obj: in.src}) + } + + // Set spec in dest. + if err := unstructured.SetNestedField(in.dest.Object, srcSpec, strings.Split(in.destSpecPath, ".")...); err != nil { + return errors.Wrapf(err, "failed to set field %q on %s", in.destSpecPath, tlog.KObj{Obj: in.dest}) + } + + // Restore preserved fields. + for path, value := range preservedFields { + if err := unstructured.SetNestedField(in.dest.Object, value, strings.Split(path, ".")...); err != nil { + return errors.Wrapf(err, "failed to set field %q on %s", path, tlog.KObj{Obj: in.dest}) + } + } + return nil +} diff --git a/controllers/topology/internal/extensions/patches/patch_test.go b/controllers/topology/internal/extensions/patches/patch_test.go new file mode 100644 index 000000000000..2e1b41b30e2c --- /dev/null +++ b/controllers/topology/internal/extensions/patches/patch_test.go @@ -0,0 +1,369 @@ +/* +Copyright 2021 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 patches + +import ( + "testing" + + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/cluster-api/controllers/topology/internal/contract" +) + +func TestCopySpec(t *testing.T) { + tests := []struct { + name string + input copySpecInput + want *unstructured.Unstructured + wantErr bool + }{ + { + name: "Field both in src and dest, no-op when equal", + input: copySpecInput{ + src: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + dest: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + srcSpecPath: "spec", + destSpecPath: "spec", + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + { + name: "Field both in src and dest, overwrite dest when different", + input: copySpecInput{ + src: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + dest: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A-different", + }, + }, + }, + srcSpecPath: "spec", + destSpecPath: "spec", + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + { + name: "Nested field both in src and dest, no-op when equal", + input: copySpecInput{ + src: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + dest: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + srcSpecPath: "spec", + destSpecPath: "spec", + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + }, + { + name: "Nested field both in src and dest, overwrite dest when different", + input: copySpecInput{ + src: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + dest: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A-different", + }, + }, + }, + }, + }, + srcSpecPath: "spec", + destSpecPath: "spec", + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + }, + { + name: "Field only in src, copy to dest", + input: copySpecInput{ + src: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + dest: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + srcSpecPath: "spec", + destSpecPath: "spec", + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + { + name: "Nested field only in src, copy to dest", + input: copySpecInput{ + src: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + dest: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + srcSpecPath: "spec", + destSpecPath: "spec", + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + }, + { + name: "Copy field from spec.template.spec in src to spec in dest", + input: copySpecInput{ + src: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + dest: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + srcSpecPath: "spec.template.spec", + destSpecPath: "spec", + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + { + name: "Copy field from spec.template.spec in src to spec in dest (overwrite when different)", + input: copySpecInput{ + src: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + dest: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A-different", + }, + }, + }, + srcSpecPath: "spec.template.spec", + destSpecPath: "spec", + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + { + name: "Field both in src and dest, overwrite when different and preserve fields", + input: copySpecInput{ + src: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "machineTemplate": map[string]interface{}{ + "infrastructureRef": map[string]interface{}{ + "apiVersion": "invalid", + "kind": "invalid", + "namespace": "invalid", + "name": "invalid", + }, + }, + "replicas": float64(10), + "version": "v1.15.0", + "A": "A", + }, + }, + }, + }, + }, + dest: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "machineTemplate": map[string]interface{}{ + "infrastructureRef": map[string]interface{}{ + "apiVersion": "v1", + "kind": "kind", + "namespace": "namespace", + "name": "name", + }, + }, + "replicas": float64(3), + "version": "v1.22.0", + "A": "A-different", + }, + }, + }, + srcSpecPath: "spec.template.spec", + destSpecPath: "spec", + fieldsToPreserve: []contract.Path{ + {"spec", "machineTemplate", "infrastructureRef"}, + {"spec", "replicas"}, + {"spec", "version"}, + }, + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "machineTemplate": map[string]interface{}{ + "infrastructureRef": map[string]interface{}{ + "apiVersion": "v1", + "kind": "kind", + "namespace": "namespace", + "name": "name", + }, + }, + "replicas": float64(3), + "version": "v1.22.0", + "A": "A", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + err := copySpec(tt.input) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(tt.input.dest).To(Equal(tt.want)) + }) + } +} diff --git a/controllers/topology/internal/extensions/patches/variables/variables.go b/controllers/topology/internal/extensions/patches/variables/variables.go new file mode 100644 index 000000000000..644dba05c4e9 --- /dev/null +++ b/controllers/topology/internal/extensions/patches/variables/variables.go @@ -0,0 +1,158 @@ +/* +Copyright 2021 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 variables calculates variables for patching. +package variables + +import ( + "encoding/json" + "fmt" + + "github.com/pkg/errors" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/controllers/topology/internal/contract" +) + +var ( + // Builtin is the prefix of all builtin variables. + Builtin = "builtin" + + // Builtin Cluster variables. + + // BuiltinClusterName is the name of the cluster. + BuiltinClusterName = builtinVariable("cluster.name") + // BuiltinClusterNamespace is the namespace of the cluster. + BuiltinClusterNamespace = builtinVariable("cluster.namespace") + // BuiltinClusterTopologyVersion is the Kubernetes version of the cluster. + BuiltinClusterTopologyVersion = builtinVariable("cluster.topology.version") + // BuiltinClusterTopologyClass is the name of the ClusterClass of the cluster. + BuiltinClusterTopologyClass = builtinVariable("cluster.topology.class") + + // Builtin ControlPlane variables. + + // BuiltinClusterTopologyControlPlaneReplicas is the value of the replicas field of the ControlPlane. + BuiltinClusterTopologyControlPlaneReplicas = builtinVariable("cluster.topology.controlPlane.replicas") + // BuiltinClusterTopologyControlPlaneVersion is the Kubernetes version of the ControlPlane. + BuiltinClusterTopologyControlPlaneVersion = builtinVariable("cluster.topology.controlPlane.version") + + // Builtin MachineDeployment variables. + + // BuiltinClusterTopologyMachineDeploymentReplicas is the value of the replicas field of the MachineDeployment. + BuiltinClusterTopologyMachineDeploymentReplicas = builtinVariable("cluster.topology.machineDeployment.current.replicas") + // BuiltinClusterTopologyMachineDeploymentVersion is the Kubernetes version of the MachineDeployment. + BuiltinClusterTopologyMachineDeploymentVersion = builtinVariable("cluster.topology.machineDeployment.current.version") + // BuiltinClusterTopologyMachineDeploymentClass is the class name of the MachineDeployment. + BuiltinClusterTopologyMachineDeploymentClass = builtinVariable("cluster.topology.machineDeployment.current.class") + // BuiltinClusterTopologyMachineDeploymentName is the name of the MachineDeployment. + BuiltinClusterTopologyMachineDeploymentName = builtinVariable("cluster.topology.machineDeployment.current.name") + // BuiltinClusterTopologyMachineDeploymentTopologyName is the topology name of the MachineDeployment. + BuiltinClusterTopologyMachineDeploymentTopologyName = builtinVariable("cluster.topology.machineDeployment.current.topologyName") +) + +func builtinVariable(name string) string { + return fmt.Sprintf("%s.%s", Builtin, name) +} + +// VariableMap is a name/value map of variables. +// Values are marshalled as JSON. +type VariableMap map[string]apiextensionsv1.JSON + +// Global returns global variables. +func Global(clusterTopology *clusterv1.Topology, cluster *clusterv1.Cluster) (VariableMap, error) { + variables := VariableMap{} + + for _, variable := range clusterTopology.Variables { + variables[variable.Name] = variable.Value + } + + if err := setVariable(variables, BuiltinClusterName, cluster.Name); err != nil { + return nil, err + } + if err := setVariable(variables, BuiltinClusterNamespace, cluster.Namespace); err != nil { + return nil, err + } + if err := setVariable(variables, BuiltinClusterTopologyVersion, cluster.Spec.Topology.Version); err != nil { + return nil, err + } + if err := setVariable(variables, BuiltinClusterTopologyClass, cluster.Spec.Topology.Class); err != nil { + return nil, err + } + + return variables, nil +} + +// ControlPlane returns variables for the ControlPlane. +func ControlPlane(controlPlaneTopology *clusterv1.ControlPlaneTopology, controlPlane *unstructured.Unstructured) (VariableMap, error) { + variables := VariableMap{} + + if controlPlaneTopology.Replicas != nil { + replicas, err := contract.ControlPlane().Replicas().Get(controlPlane) + if err != nil { + return nil, errors.Wrap(err, "failed to get spec.replicas from the ControlPlane") + } + if err := setVariable(variables, BuiltinClusterTopologyControlPlaneReplicas, *replicas); err != nil { + return nil, err + } + } + + version, err := contract.ControlPlane().Version().Get(controlPlane) + if err != nil { + return nil, errors.Wrap(err, "failed to get spec.version from the ControlPlane") + } + if err := setVariable(variables, BuiltinClusterTopologyControlPlaneVersion, version); err != nil { + return nil, err + } + + return variables, nil +} + +// MachineDeployment returns variables for a MachineDeployment. +func MachineDeployment(mdTopology *clusterv1.MachineDeploymentTopology, md *clusterv1.MachineDeployment) (VariableMap, error) { + variables := VariableMap{} + + if md.Spec.Replicas != nil { + if err := setVariable(variables, BuiltinClusterTopologyMachineDeploymentReplicas, *md.Spec.Replicas); err != nil { + return nil, err + } + } + if err := setVariable(variables, BuiltinClusterTopologyMachineDeploymentVersion, *md.Spec.Template.Spec.Version); err != nil { + return nil, err + } + if err := setVariable(variables, BuiltinClusterTopologyMachineDeploymentClass, mdTopology.Class); err != nil { + return nil, err + } + if err := setVariable(variables, BuiltinClusterTopologyMachineDeploymentName, md.Name); err != nil { + return nil, err + } + if err := setVariable(variables, BuiltinClusterTopologyMachineDeploymentTopologyName, mdTopology.Name); err != nil { + return nil, err + } + + return variables, nil +} + +// setVariable converts value to JSON and adds the variable to the variables map. +func setVariable(variables VariableMap, name string, value interface{}) error { + marshalledValue, err := json.Marshal(value) + if err != nil { + return errors.Wrapf(err, "failed to set variable %q: error marshalling", name) + } + + variables[name] = apiextensionsv1.JSON{Raw: marshalledValue} + return nil +} diff --git a/controllers/topology/internal/extensions/patches/variables/variables_test.go b/controllers/topology/internal/extensions/patches/variables/variables_test.go new file mode 100644 index 000000000000..c589b984d0de --- /dev/null +++ b/controllers/topology/internal/extensions/patches/variables/variables_test.go @@ -0,0 +1,190 @@ +/* +Copyright 2021 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 variables + +import ( + "testing" + + . "github.com/onsi/gomega" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/pointer" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/internal/builder" +) + +func TestGlobal(t *testing.T) { + tests := []struct { + name string + clusterTopology *clusterv1.Topology + cluster *clusterv1.Cluster + want VariableMap + }{ + { + name: "Should calculate global variables", + clusterTopology: &clusterv1.Topology{ + Variables: []clusterv1.ClusterVariable{ + { + Name: "location", + Value: toJSON("\"us-central\""), + }, + { + Name: "cpu", + Value: toJSON("8"), + }, + { + // This is blocked by a webhook, but let's make sure we overwrite + // the user-defined variable with the builtin variable anyway. + Name: "builtin.cluster.name", + Value: toJSON("8"), + }, + }, + }, + cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster1", + Namespace: metav1.NamespaceDefault, + }, + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Class: "clusterClass1", + Version: "v1.21.1", + }, + }, + }, + want: VariableMap{ + "location": toJSON("\"us-central\""), + "cpu": toJSON("8"), + BuiltinClusterName: toJSON("\"cluster1\""), + BuiltinClusterNamespace: toJSON("\"default\""), + BuiltinClusterTopologyVersion: toJSON("\"v1.21.1\""), + BuiltinClusterTopologyClass: toJSON("\"clusterClass1\""), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := Global(tt.clusterTopology, tt.cluster) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func TestControlPlane(t *testing.T) { + tests := []struct { + name string + controlPlaneTopology *clusterv1.ControlPlaneTopology + controlPlane *unstructured.Unstructured + want VariableMap + }{ + { + name: "Should calculate ControlPlane variables", + controlPlaneTopology: &clusterv1.ControlPlaneTopology{ + Replicas: pointer.Int32(3), + }, + controlPlane: builder.ControlPlane(metav1.NamespaceDefault, "controlPlane1"). + WithReplicas(3). + WithVersion("v1.21.1"). + Build(), + want: VariableMap{ + BuiltinClusterTopologyControlPlaneReplicas: toJSON("3"), + BuiltinClusterTopologyControlPlaneVersion: toJSON("\"v1.21.1\""), + }, + }, + { + name: "Should calculate ControlPlane variables, replicas not set", + controlPlaneTopology: &clusterv1.ControlPlaneTopology{}, + controlPlane: builder.ControlPlane(metav1.NamespaceDefault, "controlPlane1"). + WithVersion("v1.21.1"). + Build(), + want: VariableMap{ + BuiltinClusterTopologyControlPlaneVersion: toJSON("\"v1.21.1\""), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := ControlPlane(tt.controlPlaneTopology, tt.controlPlane) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func TestMachineDeployment(t *testing.T) { + tests := []struct { + name string + mdTopology *clusterv1.MachineDeploymentTopology + md *clusterv1.MachineDeployment + want VariableMap + }{ + { + name: "Should calculate MachineDeployment variables", + mdTopology: &clusterv1.MachineDeploymentTopology{ + Replicas: pointer.Int32(3), + Name: "md-topology", + Class: "md-class", + }, + md: builder.MachineDeployment(metav1.NamespaceDefault, "md1"). + WithReplicas(3). + WithVersion("v1.21.1"). + Build(), + want: VariableMap{ + BuiltinClusterTopologyMachineDeploymentReplicas: toJSON("3"), + BuiltinClusterTopologyMachineDeploymentVersion: toJSON("\"v1.21.1\""), + BuiltinClusterTopologyMachineDeploymentClass: toJSON("\"md-class\""), + BuiltinClusterTopologyMachineDeploymentName: toJSON("\"md1\""), + BuiltinClusterTopologyMachineDeploymentTopologyName: toJSON("\"md-topology\""), + }, + }, + { + name: "Should calculate MachineDeployment variables, replicas not set", + mdTopology: &clusterv1.MachineDeploymentTopology{ + Name: "md-topology", + Class: "md-class", + }, + md: builder.MachineDeployment(metav1.NamespaceDefault, "md1"). + WithVersion("v1.21.1"). + Build(), + want: VariableMap{ + BuiltinClusterTopologyMachineDeploymentVersion: toJSON("\"v1.21.1\""), + BuiltinClusterTopologyMachineDeploymentClass: toJSON("\"md-class\""), + BuiltinClusterTopologyMachineDeploymentName: toJSON("\"md1\""), + BuiltinClusterTopologyMachineDeploymentTopologyName: toJSON("\"md-topology\""), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := MachineDeployment(tt.mdTopology, tt.md) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func toJSON(value string) apiextensionsv1.JSON { + return apiextensionsv1.JSON{Raw: []byte(value)} +} diff --git a/controllers/topology/internal/log/log.go b/controllers/topology/internal/log/log.go index b655084e2d13..564caa03f68e 100644 --- a/controllers/topology/internal/log/log.go +++ b/controllers/topology/internal/log/log.go @@ -54,6 +54,9 @@ type Logger interface { // WithMachineDeployment adds to the logger information about the MachineDeployment object being processed. WithMachineDeployment(md *clusterv1.MachineDeployment) Logger + // WithValues adds key-value pairs of context to a logger. + WithValues(keysAndValues ...interface{}) Logger + // V returns a logger value for a specific verbosity level, relative to // this logger. V(level int) Logger @@ -105,6 +108,12 @@ func (l *topologyReconcileLogger) WithMachineDeployment(md *clusterv1.MachineDep return l } +// WithValues adds key-value pairs of context to a logger. +func (l *topologyReconcileLogger) WithValues(keysAndValues ...interface{}) Logger { + l.Logger = l.Logger.WithValues(keysAndValues...) + return l +} + // V returns a logger value for a specific verbosity level, relative to // this logger. func (l *topologyReconcileLogger) V(level int) Logger { diff --git a/controllers/topology/internal/mergepatch/mergepatch.go b/controllers/topology/internal/mergepatch/mergepatch.go index 2f17238134a5..86e1216439a4 100644 --- a/controllers/topology/internal/mergepatch/mergepatch.go +++ b/controllers/topology/internal/mergepatch/mergepatch.go @@ -193,6 +193,11 @@ func removePath(patchMap map[string]interface{}, path contract.Path) { } } +// Changes returns the changes, i.e. the patch. +func (h *Helper) Changes() []byte { + return h.patch +} + // HasSpecChanges return true if the patch has changes to the spec field. func (h *Helper) HasSpecChanges() bool { return h.hasSpecChanges diff --git a/docs/proposals/202105256-cluster-class-and-managed-topologies.md b/docs/proposals/202105256-cluster-class-and-managed-topologies.md index 63586cf30915..0bf9e762f578 100644 --- a/docs/proposals/202105256-cluster-class-and-managed-topologies.md +++ b/docs/proposals/202105256-cluster-class-and-managed-topologies.md @@ -575,7 +575,7 @@ It’s also possible to use so-called builtin variables in addition to user-defi - `cluster.topology.{version,class}` - `cluster.topology.controlPlane.{labels,annotations,replicas,version}` - **Note**: these variables are only available when patching control plane or control plane machine templates. -- `cluster.topology.machineDeployment.current.{labels,annotations,replicas,version,class}` +- `cluster.topology.machineDeployment.current.{labels,annotations,replicas,version,class,name,topologyName}` - **Note**: these variables are only available when patching MachineDeployment templates and contain the values of the current `MachineDeploymentTopology`. diff --git a/internal/builder/builders.go b/internal/builder/builders.go index 322936e5bac4..2016319c40b7 100644 --- a/internal/builder/builders.go +++ b/internal/builder/builders.go @@ -554,6 +554,8 @@ type ControlPlaneBuilder struct { namespace string name string infrastructureMachineTemplate *unstructured.Unstructured + replicas *int64 + version *string specFields map[string]interface{} statusFields map[string]interface{} } @@ -572,6 +574,18 @@ func (f *ControlPlaneBuilder) WithInfrastructureMachineTemplate(t *unstructured. return f } +// WithReplicas sets the number of replicas for the ControlPlaneBuilder. +func (f *ControlPlaneBuilder) WithReplicas(replicas int64) *ControlPlaneBuilder { + f.replicas = &replicas + return f +} + +// WithVersion adds the passed version to the ControlPlaneBuilder. +func (f *ControlPlaneBuilder) WithVersion(version string) *ControlPlaneBuilder { + f.version = &version + return f +} + // WithSpecFields sets a map of spec fields on the unstructured object. The keys in the map represent the path and the value corresponds // to the value of the spec field. // @@ -609,12 +623,23 @@ func (f *ControlPlaneBuilder) Build() *unstructured.Unstructured { setSpecFields(obj, f.specFields) setStatusFields(obj, f.statusFields) + // TODO(killianmuldoon): Update to use the internal/contract package, when it is importable from here if f.infrastructureMachineTemplate != nil { - // TODO(killianmuldoon): Update to use the internal/contract package if err := setNestedRef(obj, f.infrastructureMachineTemplate, "spec", "machineTemplate", "infrastructureRef"); err != nil { panic(err) } } + if f.replicas != nil { + if err := unstructured.SetNestedField(obj.UnstructuredContent(), *f.replicas, "spec", "replicas"); err != nil { + panic(err) + } + } + if f.version != nil { + if err := unstructured.SetNestedField(obj.UnstructuredContent(), *f.version, "spec", "version"); err != nil { + panic(err) + } + } + return obj }