diff --git a/controllers/topology/desired_state.go b/controllers/topology/desired_state.go index 4541f5e06555..5d237be785bb 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..a272dd57884c --- /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 + + // MergePatchType identifies a https://datatracker.ietf.org/doc/html/rfc7386 json merge patch. + MergePatchType 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..a3b77f8cb516 --- /dev/null +++ b/controllers/topology/internal/extensions/patches/engine.go @@ -0,0 +1,668 @@ +/* +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" + "strings" + + 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/mergepatch" + "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. + patches, 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, patches); err != nil { + return err + } + } + + // Use patched templates to update the desired state objects. + log.V(5).Infof("Applying patches to desired state") + if err := requestToDesiredState(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) { + ret := &api.GenerateRequestTemplate{ + TemplateID: templateID, + } + + jsonObj, err := json.Marshal(obj) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal object to json") + } + ret.Template = apiextensionsv1.JSON{Raw: jsonObj} + + return ret, 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 { + log.V(5).Infof("Could not find template in GenerateRequest") + // TODO(TBD) @fabrizio should we return an error here? + continue + } + + // 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.MergePatchType: + 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 +} + +// requestToDesiredState 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: Let's only overwrite desired state objects if the corresponding template actually have been patched. +func requestToDesiredState(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 +} + +// 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/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/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