diff --git a/cmd/clusterctl/client/client_test.go b/cmd/clusterctl/client/client_test.go index fb5679c1369a..bfdbfc4658a8 100644 --- a/cmd/clusterctl/client/client_test.go +++ b/cmd/clusterctl/client/client_test.go @@ -436,7 +436,7 @@ func (f fakeRepositoryClient) Components() repository.ComponentsClient { } func (f fakeRepositoryClient) Templates(version string) repository.TemplateClient { - // use a fakeTemplateClient (instead of the internal client used in other fake objects) we can de deterministic on what is returned (e.g. avoid interferences from overrides) + // Use a fakeTemplateClient (instead of the internal client used in other fake objects) we can be deterministic on what is returned (e.g. avoid interferences from overrides) return &fakeTemplateClient{ version: version, fakeRepository: f.fakeRepository, @@ -445,8 +445,18 @@ func (f fakeRepositoryClient) Templates(version string) repository.TemplateClien } } +func (f fakeRepositoryClient) ClusterClasses(version string) repository.ClusterClassClient { + // Use a fakeTemplateClient (instead of the internal client used in other fake objects) we can be deterministic on what is returned (e.g. avoid interferences from overrides) + return &fakeClusterClassClient{ + version: version, + fakeRepository: f.fakeRepository, + configVariablesClient: f.configClient.Variables(), + processor: f.processor, + } +} + func (f fakeRepositoryClient) Metadata(version string) repository.MetadataClient { - // use a fakeMetadataClient (instead of the internal client used in other fake objects) we can de deterministic on what is returned (e.g. avoid interferences from overrides) + // Use a fakeMetadataClient (instead of the internal client used in other fake objects) we can be deterministic on what is returned (e.g. avoid interferences from overrides) return &fakeMetadataClient{ version: version, fakeRepository: f.fakeRepository, @@ -506,6 +516,29 @@ func (f *fakeTemplateClient) Get(flavor, targetNamespace string, skipTemplatePro }) } +// fakeClusterClassClient provides a super simple TemplateClient (e.g. without support for local overrides). +type fakeClusterClassClient struct { + version string + fakeRepository *repository.MemoryRepository + configVariablesClient config.VariablesClient + processor yaml.Processor +} + +func (f *fakeClusterClassClient) Get(class, targetNamespace string, skipTemplateProcess bool) (repository.Template, error) { + name := fmt.Sprintf("clusterclass-%s.yaml", class) + content, err := f.fakeRepository.GetFile(f.version, name) + if err != nil { + return nil, err + } + return repository.NewTemplate(repository.TemplateInput{ + RawArtifact: content, + ConfigVariablesClient: f.configVariablesClient, + Processor: f.processor, + TargetNamespace: targetNamespace, + SkipTemplateProcess: skipTemplateProcess, + }) +} + // fakeMetadataClient provides a super simple MetadataClient (e.g. without support for local overrides/embedded metadata). type fakeMetadataClient struct { version string diff --git a/cmd/clusterctl/client/cluster/inventory.go b/cmd/clusterctl/client/cluster/inventory.go index 321318f5036f..1adc670c8a27 100644 --- a/cmd/clusterctl/client/cluster/inventory.go +++ b/cmd/clusterctl/client/cluster/inventory.go @@ -53,6 +53,9 @@ type CheckCAPIContractOptions struct { // AllowCAPIContracts instructs CheckCAPIContract to tolerate management clusters with Cluster API with the given contract. AllowCAPIContracts []string + + // AllowCAPIAnyContract instructs CheckCAPIContract to tolerate management clusters with Cluster API installed with any contract. + AllowCAPIAnyContract bool } // AllowCAPINotInstalled instructs CheckCAPIContract to tolerate management clusters without Cluster API installed yet. @@ -64,6 +67,15 @@ func (t AllowCAPINotInstalled) Apply(in *CheckCAPIContractOptions) { in.AllowCAPINotInstalled = true } +// AllowCAPIAnyContract instructs CheckCAPIContract to tolerate management clusters with Cluster API with any contract. +// NOTE: This allows clusterctl generate cluster with managed topologies to work properly by performing checks to see if CAPI is installed. +type AllowCAPIAnyContract struct{} + +// Apply applies this configuration to the given CheckCAPIContractOptions. +func (t AllowCAPIAnyContract) Apply(in *CheckCAPIContractOptions) { + in.AllowCAPIAnyContract = true +} + // AllowCAPIContract instructs CheckCAPIContract to tolerate management clusters with Cluster API with the given contract. // NOTE: This allows clusterctl upgrade to work on management clusters with old contract. type AllowCAPIContract struct { @@ -103,6 +115,9 @@ type InventoryClient interface { // does not match the current one supported by clusterctl. CheckCAPIContract(...CheckCAPIContractOption) error + // CheckCAPIInstalled checks if Cluster API is installed on the management cluster. + CheckCAPIInstalled() (bool, error) + // CheckSingleProviderInstance ensures that only one instance of a provider is running, returns error otherwise. CheckSingleProviderInstance() error } @@ -395,6 +410,10 @@ func (p *inventoryClient) CheckCAPIContract(options ...CheckCAPIContractOption) return errors.Wrap(err, "failed to check Cluster API version") } + if opt.AllowCAPIAnyContract { + return nil + } + for _, version := range crd.Spec.Versions { if version.Storage { if version.Name == clusterv1.GroupVersion.Version { @@ -411,6 +430,17 @@ func (p *inventoryClient) CheckCAPIContract(options ...CheckCAPIContractOption) return errors.Errorf("failed to check Cluster API version") } +func (p *inventoryClient) CheckCAPIInstalled() (bool, error) { + if err := p.CheckCAPIContract(AllowCAPIAnyContract{}); err != nil { + if apierrors.IsNotFound(err) { + // The expected CRDs are not installed on the management. This would mean that Cluster API is not installed on the cluster. + return false, nil + } + return false, err + } + return true, nil +} + func (p *inventoryClient) CheckSingleProviderInstance() error { providers, err := p.List() if err != nil { @@ -436,7 +466,7 @@ func (p *inventoryClient) CheckSingleProviderInstance() error { if len(errs) > 0 { return errors.Wrap(kerrors.NewAggregate(errs), "detected multiple instances of the same provider, "+ - "but clusterctl v1alpha4 does not support this use case. See https://cluster-api.sigs.k8s.io/developer/architecture/controllers/support-multiple-instances.html for more details") + "but clusterctl does not support this use case. See https://cluster-api.sigs.k8s.io/developer/architecture/controllers/support-multiple-instances.html for more details") } return nil diff --git a/cmd/clusterctl/client/clusterclass.go b/cmd/clusterctl/client/clusterclass.go new file mode 100644 index 000000000000..f961aa10a9e4 --- /dev/null +++ b/cmd/clusterctl/client/clusterclass.go @@ -0,0 +1,177 @@ +/* +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 client + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/cluster" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/repository" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// addClusterClassIfMissing returns a Template that includes the base template and adds any cluster class definitions that +// are references in the template. If the cluster class referenced already exists in the cluster it is not added to the +// template. +func addClusterClassIfMissing(template Template, clusterClassClient repository.ClusterClassClient, clusterClient cluster.Client, targetNamespace string, listVariablesOnly bool) (Template, error) { + classes, err := clusterClassNamesFromTemplate(template) + if err != nil { + return nil, err + } + // If the template does not reference any ClusterClass, return early. + if len(classes) == 0 { + return template, nil + } + + clusterClassesTemplate, err := fetchMissingClusterClassTemplates(clusterClassClient, clusterClient, classes, targetNamespace, listVariablesOnly) + if err != nil { + return nil, err + } + + mergedTemplate, err := repository.MergeTemplates(template, clusterClassesTemplate) + if err != nil { + return nil, err + } + + return mergedTemplate, nil +} + +// clusterClassNamesFromTemplate returns the list of cluster classes referenced +// by custers defined in the template. If not clusters are defined in the template +// or if no cluster uses a cluster class it returns an empty list. +func clusterClassNamesFromTemplate(template Template) ([]string, error) { + classes := []string{} + + // loop thorugh all the objects and if the object is a cluster + // check and see if cluster.spec.topology.class is defined. + // If defined, add value to the result. + for i := range template.Objs() { + obj := template.Objs()[i] + if obj.GroupVersionKind().GroupKind() != clusterv1.GroupVersion.WithKind("Cluster").GroupKind() { + continue + } + cluster := &clusterv1.Cluster{} + if err := scheme.Scheme.Convert(&obj, cluster, nil); err != nil { + return nil, errors.Wrap(err, "failed to convert object to Cluster") + } + if cluster.Spec.Topology == nil { + continue + } + classes = append(classes, cluster.Spec.Topology.Class) + } + return classes, nil +} + +// fetchMissingClusterClassTemplates returns a list of templates for cluster classes that do not yet exist +// in the cluster. If the cluster is not initialized, all the ClusterClasses are added. +func fetchMissingClusterClassTemplates(clusterClassClient repository.ClusterClassClient, clusterClient cluster.Client, classes []string, targetNamespace string, listVariablesOnly bool) (Template, error) { + // first check if the cluster is initialized. + // If it is initialized: + // For every ClusterClass check if it already exists in the cluster. + // If the ClusterClass already exists there is nothing further to do. + // If not, get the ClusterClass from the repository + // If it is not initialized: + // For every ClusterClass fetch the class definition from the repository. + + // Check if the cluster is initialized + clusterInitialized := false + var err error + if err := clusterClient.Proxy().CheckClusterAvailable(); err == nil { + clusterInitialized, err = clusterClient.ProviderInventory().CheckCAPIInstalled() + if err != nil { + return nil, errors.Wrap(err, "failed to check if the cluster is initialized") + } + } + var c client.Client + if clusterInitialized { + c, err = clusterClient.Proxy().NewClient() + if err != nil { + return nil, err + } + } + + // Get the templates for all ClusterClasses and associated objects if the target + // CluterClass does not exits in the cluster. + templates := []repository.Template{} + for _, class := range classes { + if clusterInitialized { + exists, err := clusterClassExists(c, class, targetNamespace) + if err != nil { + return nil, err + } + if exists { + continue + } + } + // The cluster is either not initialized or the ClusterClass does not yet exist in the cluster. + // Fetch the cluster class to install. + clusterClassTemplate, err := clusterClassClient.Get(class, targetNamespace, listVariablesOnly) + if err != nil { + return nil, errors.Wrapf(err, "failed to get the cluster class template for %q", class) + } + + // If any of the objects in the ClusterClass template already exist in the cluster then + // we should error out. + // We do this to avoid adding partial items from the template in the output YAML. This ensures + // that we do not add a ClusterClass (and associated objects) who definition is unknown. + if clusterInitialized { + for _, obj := range clusterClassTemplate.Objs() { + if exists, err := objExists(c, obj); err != nil { + return nil, err + } else if exists { + return nil, fmt.Errorf("%s(%s) already exists in the cluster", obj.GetName(), obj.GetObjectKind().GroupVersionKind()) + } + } + } + templates = append(templates, clusterClassTemplate) + } + + merged, err := repository.MergeTemplates(templates...) + if err != nil { + return nil, err + } + + return merged, nil +} + +func clusterClassExists(c client.Client, class, targetNamespace string) (bool, error) { + clusterClass := &clusterv1.ClusterClass{} + if err := c.Get(context.TODO(), client.ObjectKey{Name: class, Namespace: targetNamespace}, clusterClass); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, errors.Wrapf(err, "failed to check if ClusterClass %q exists in the cluster", class) + } + return true, nil +} + +func objExists(c client.Client, obj unstructured.Unstructured) (bool, error) { + o := obj.DeepCopy() + if err := c.Get(context.TODO(), client.ObjectKeyFromObject(o), o); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/cmd/clusterctl/client/clusterclass_test.go b/cmd/clusterctl/client/clusterclass_test.go new file mode 100644 index 000000000000..172655bfcf49 --- /dev/null +++ b/cmd/clusterctl/client/clusterclass_test.go @@ -0,0 +1,267 @@ +/* +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 client + +import ( + "fmt" + "testing" + + . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + + 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/apimachinery/pkg/runtime/schema" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/cluster" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/repository" + yaml "sigs.k8s.io/cluster-api/cmd/clusterctl/client/yamlprocessor" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/test" + utilyaml "sigs.k8s.io/cluster-api/util/yaml" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestClusterClassExists(t *testing.T) { + tests := []struct { + name string + objs []client.Object + clusterClass string + want bool + }{ + { + name: "should return true when checking for an installed cluster class", + objs: []client.Object{ + &clusterv1.ClusterClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterclass-installed", + Namespace: metav1.NamespaceDefault, + }, + }, + }, + clusterClass: "clusterclass-installed", + want: true, + }, + { + name: "should return false when checking for a not-installed cluster class", + objs: []client.Object{}, + clusterClass: "clusterclass-not-installed", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + config := newFakeConfig() + client := newFakeCluster(cluster.Kubeconfig{Path: "kubeconfig", Context: "mgmt-context"}, config).WithObjs(tt.objs...) + c, _ := client.Proxy().NewClient() + + actual, err := clusterClassExists(c, tt.clusterClass, metav1.NamespaceDefault) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(actual).To(Equal(tt.want)) + }) + } +} + +func TestAddClusterClassIfMissing(t *testing.T) { + infraClusterTemplateNS4 := unstructured.Unstructured{} + infraClusterTemplateNS4.SetNamespace("ns4") + infraClusterTemplateNS4.SetGroupVersionKind(schema.GroupVersionKind{ + Version: "v1", + Kind: "InfrastructureClusterTemplate", + }) + infraClusterTemplateNS4.SetName("testInfraClusterTemplate") + infraClusterTemplateNS4Bytes, err := utilyaml.FromUnstructured([]unstructured.Unstructured{infraClusterTemplateNS4}) + if err != nil { + panic("failed to convert template to bytes") + } + + tests := []struct { + name string + clusterInitialized bool + objs []client.Object + clusterClassTemplateContent []byte + targetNamespace string + listVariablesOnly bool + wantClusterClassInTemplate bool + wantError bool + }{ + { + name: "should add the cluster class to the template if cluster is not initialized", + clusterInitialized: false, + objs: []client.Object{}, + targetNamespace: "ns1", + clusterClassTemplateContent: clusterClassYAML("ns1", "dev"), + listVariablesOnly: false, + wantClusterClassInTemplate: true, + wantError: false, + }, + { + name: "should add the cluster class to the template if cluster is initialized and cluster class is not installed", + clusterInitialized: true, + objs: []client.Object{}, + targetNamespace: "ns2", + clusterClassTemplateContent: clusterClassYAML("ns2", "dev"), + listVariablesOnly: false, + wantClusterClassInTemplate: true, + wantError: false, + }, + { + name: "should NOT add the cluster class to the template if cluster is initialized and cluster class is installed", + clusterInitialized: true, + objs: []client.Object{&clusterv1.ClusterClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dev", + Namespace: "ns3", + }, + }}, + targetNamespace: "ns3", + clusterClassTemplateContent: clusterClassYAML("ns3", "dev"), + listVariablesOnly: false, + wantClusterClassInTemplate: false, + wantError: false, + }, + { + name: "should throw error if the cluster is initialized and templates from the cluster class template already exist in the cluster", + clusterInitialized: true, + objs: []client.Object{ + &infraClusterTemplateNS4, + }, + clusterClassTemplateContent: utilyaml.JoinYaml(clusterClassYAML("ns4", "dev"), infraClusterTemplateNS4Bytes), + targetNamespace: "ns4", + listVariablesOnly: false, + wantClusterClassInTemplate: false, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config1 := newFakeConfig().WithProvider(infraProviderConfig) + repository1 := newFakeRepository(infraProviderConfig, config1). + WithPaths("root", ""). + WithDefaultVersion("v1.0.0"). + WithFile("v1.0.0", "clusterclass-dev.yaml", tt.clusterClassTemplateContent) + cluster := newFakeCluster(cluster.Kubeconfig{Path: "kubeconfig", Context: "mgt-cluster"}, config1).WithObjs(tt.objs...) + + if tt.clusterInitialized { + cluster.WithObjs(&apiextensionsv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiextensionsv1.SchemeGroupVersion.String(), + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("clusters.%s", clusterv1.GroupVersion.Group), + Labels: map[string]string{ + clusterv1.GroupVersion.String(): "v1beta1", + }, + }, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ + { + Storage: true, + Name: clusterv1.GroupVersion.Version, + }, + }, + }, + }) + } + + clusterClassClient := repository1.ClusterClasses("v1.0.0") + + clusterWithTopology := []byte(fmt.Sprintf("apiVersion: %s\n", clusterv1.GroupVersion.String()) + + "kind: Cluster\n" + + "metadata:\n" + + " name: cluster-dev\n" + + fmt.Sprintf(" namespace: %s\n", tt.targetNamespace) + + "spec:\n" + + " topology:\n" + + " class: dev") + + baseTemplate, err := repository.NewTemplate(repository.TemplateInput{ + RawArtifact: clusterWithTopology, + ConfigVariablesClient: test.NewFakeVariableClient(), + Processor: yaml.NewSimpleProcessor(), + TargetNamespace: tt.targetNamespace, + SkipTemplateProcess: false, + }) + if err != nil { + t.Fatalf("failed to create template %v", err) + } + + g := NewWithT(t) + template, err := addClusterClassIfMissing(baseTemplate, clusterClassClient, cluster, tt.targetNamespace, tt.listVariablesOnly) + if tt.wantError { + g.Expect(err).To(HaveOccurred()) + } else { + if tt.wantClusterClassInTemplate { + g.Expect(template.Objs()).To(ContainElement(MatchClusterClass("dev", tt.targetNamespace))) + } else { + g.Expect(template.Objs()).NotTo(ContainElement(MatchClusterClass("dev", tt.targetNamespace))) + } + } + }) + } +} + +func MatchClusterClass(name, namespace string) types.GomegaMatcher { + return &clusterClassMatcher{name, namespace} +} + +type clusterClassMatcher struct { + name string + namespace string +} + +func (cm *clusterClassMatcher) Match(actual interface{}) (bool, error) { + uClass := actual.(unstructured.Unstructured) + + // check name + name, ok, err := unstructured.NestedString(uClass.UnstructuredContent(), "metadata", "name") + if err != nil { + return false, err + } + if !ok { + return false, nil + } + if name != cm.name { + return false, nil + } + + // check namespace + namespace, ok, err := unstructured.NestedString(uClass.UnstructuredContent(), "metadata", "namespace") + if err != nil { + return false, err + } + if !ok { + return false, nil + } + if namespace != cm.namespace { + return false, nil + } + + return true, nil +} + +func (cm *clusterClassMatcher) FailureMessage(actual interface{}) string { + return fmt.Sprintf("Expected ClusterClass of name %v in namespace %v to be present", cm.name, cm.namespace) +} + +func (cm *clusterClassMatcher) NegatedFailureMessage(actual interface{}) string { + return fmt.Sprintf("Expected ClusterClass of name %v in namespace %v not to be present", cm.name, cm.namespace) +} diff --git a/cmd/clusterctl/client/config.go b/cmd/clusterctl/client/config.go index 5ccb25e1feef..3c79fe7bfce6 100644 --- a/cmd/clusterctl/client/config.go +++ b/cmd/clusterctl/client/config.go @@ -340,6 +340,14 @@ func (c *clusterctlClient) getTemplateFromRepository(cluster cluster.Client, opt if err != nil { return nil, err } + + clusterClassClient := repo.ClusterClasses(version) + + template, err = addClusterClassIfMissing(template, clusterClassClient, cluster, targetNamespace, listVariablesOnly) + if err != nil { + return nil, err + } + return template, nil } diff --git a/cmd/clusterctl/client/config_test.go b/cmd/clusterctl/client/config_test.go index a38816bc2755..bb836d58d1a1 100644 --- a/cmd/clusterctl/client/config_test.go +++ b/cmd/clusterctl/client/config_test.go @@ -616,6 +616,41 @@ func Test_clusterctlClient_GetClusterTemplate(t *testing.T) { } } +func Test_clusterctlClient_GetClusterTemplate_withClusterClass(t *testing.T) { + g := NewWithT(t) + + rawTemplate := mangedTopologyTemplateYAML("ns4", "${CLUSTER_NAME}", "dev") + rawClusterClassTemplate := clusterClassYAML("ns4", "dev") + config1 := newFakeConfig().WithProvider(infraProviderConfig) + + repository1 := newFakeRepository(infraProviderConfig, config1). + WithPaths("root", "components"). + WithDefaultVersion("v3.0.0"). + WithFile("v3.0.0", "cluster-template-dev.yaml", rawTemplate). + WithFile("v3.0.0", "clusterclass-dev.yaml", rawClusterClassTemplate) + + cluster1 := newFakeCluster(cluster.Kubeconfig{Path: "kubeconfig", Context: "mgmt-context"}, config1). + WithProviderInventory(infraProviderConfig.Name(), infraProviderConfig.Type(), "v3.0.0", "ns4"). + WithObjs(test.FakeCAPISetupObjects()...) + + client := newFakeClient(config1). + WithCluster(cluster1). + WithRepository(repository1) + + // Assert output + got, err := client.GetClusterTemplate(GetClusterTemplateOptions{ + Kubeconfig: Kubeconfig{Path: "kubeconfig", Context: "mgmt-context"}, + ClusterName: "test", + TargetNamespace: "ns1", + ProviderRepositorySource: &ProviderRepositorySourceOptions{ + Flavor: "dev", + }, + }) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got.Variables()).To(Equal([]string{"CLUSTER_NAME"})) + g.Expect(got.TargetNamespace()).To(Equal("ns1")) + g.Expect(got.Objs()).To(ContainElement(MatchClusterClass("dev", "ns1"))) +} func Test_clusterctlClient_GetClusterTemplate_onEmptyCluster(t *testing.T) { g := NewWithT(t) diff --git a/cmd/clusterctl/client/init_test.go b/cmd/clusterctl/client/init_test.go index 0f3b22defede..9ce56187e5da 100644 --- a/cmd/clusterctl/client/init_test.go +++ b/cmd/clusterctl/client/init_test.go @@ -23,6 +23,7 @@ import ( . "github.com/onsi/gomega" "github.com/pkg/errors" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" clusterctlv1 "sigs.k8s.io/cluster-api/cmd/clusterctl/api/v1alpha3" "sigs.k8s.io/cluster-api/cmd/clusterctl/client/cluster" "sigs.k8s.io/cluster-api/cmd/clusterctl/client/config" @@ -777,6 +778,27 @@ func templateYAML(ns string, clusterName string) []byte { return podYaml } +func mangedTopologyTemplateYAML(ns, clusterName, clusterClassName string) []byte { + return []byte(fmt.Sprintf("apiVersion: %s\n", clusterv1.GroupVersion.String()) + + "kind: Cluster\n" + + "metadata:\n" + + fmt.Sprintf(" name: %s\n", clusterName) + + fmt.Sprintf(" namespace: %s\n", ns) + + "spec:\n" + + " topology:\n" + + fmt.Sprintf(" class: %s", clusterClassName)) +} + +func clusterClassYAML(ns, clusterClassName string) []byte { + var podYaml = []byte(fmt.Sprintf("apiVersion: %s\n", clusterv1.GroupVersion.String()) + + "kind: ClusterClass\n" + + "metadata:\n" + + fmt.Sprintf(" name: %s\n", clusterClassName) + + fmt.Sprintf(" namespace: %s", ns)) + + return podYaml +} + // infraComponentsYAML defines a namespace and deployment with container // images and a variable. func infraComponentsYAML(namespace string) []byte { diff --git a/cmd/clusterctl/client/repository/client.go b/cmd/clusterctl/client/repository/client.go index 6105188689cd..82f91fca9c5c 100644 --- a/cmd/clusterctl/client/repository/client.go +++ b/cmd/clusterctl/client/repository/client.go @@ -41,6 +41,10 @@ type Client interface { // Please note that templates are expected to exist for the infrastructure providers only. Templates(version string) TemplateClient + // ClusterClasses provide access to YAML file for the cluster classes available + // for the provider. + ClusterClasses(version string) ClusterClassClient + // Metadata provide access to YAML with the provider's metadata. Metadata(version string) MetadataClient } @@ -68,6 +72,10 @@ func (c *repositoryClient) Templates(version string) TemplateClient { return newTemplateClient(TemplateClientInput{version, c.Provider, c.repository, c.configClient.Variables(), c.processor}) } +func (c *repositoryClient) ClusterClasses(version string) ClusterClassClient { + return newClusterClassClient(ClusterClassClientInput{version, c.Provider, c.repository, c.configClient.Variables(), c.processor}) +} + func (c *repositoryClient) Metadata(version string) MetadataClient { return newMetadataClient(c.Provider, version, c.repository, c.configClient.Variables()) } diff --git a/cmd/clusterctl/client/repository/clusterclass_client.go b/cmd/clusterctl/client/repository/clusterclass_client.go new file mode 100644 index 000000000000..fd2340a9c201 --- /dev/null +++ b/cmd/clusterctl/client/repository/clusterclass_client.go @@ -0,0 +1,97 @@ +/* +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 repository + +import ( + "github.com/pkg/errors" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/config" + yaml "sigs.k8s.io/cluster-api/cmd/clusterctl/client/yamlprocessor" + logf "sigs.k8s.io/cluster-api/cmd/clusterctl/log" +) + +// ClusterClassClient has methods to work with cluster class templates hosted on a provider repository. +// Templates are yaml files to be used for creating a guest cluster. +type ClusterClassClient interface { + Get(name, targetNamespace string, skipTemplateProcess bool) (Template, error) +} + +type clusterClassClient struct { + version string + provider config.Provider + repository Repository + configVariablesClient config.VariablesClient + processor yaml.Processor +} + +// ClusterClassClientInput is an input struct for newClusterClassClient. +type ClusterClassClientInput struct { + version string + provider config.Provider + repository Repository + configVariablesClient config.VariablesClient + processor yaml.Processor +} + +func newClusterClassClient(input ClusterClassClientInput) *clusterClassClient { + return &clusterClassClient{ + version: input.version, + provider: input.provider, + repository: input.repository, + configVariablesClient: input.configVariablesClient, + processor: input.processor, + } +} + +func (cc *clusterClassClient) Get(name, targetNamespace string, skipTemplateProcess bool) (Template, error) { + log := logf.Log + + if targetNamespace == "" { + return nil, errors.New("invalid arguments: please provide a targetNamespace") + } + + version := cc.version + filename := cc.processor.GetClusterClassTemplateName(version, name) + + // read the component YAML, reading the local override file if it exists, otherwise read from the provider repository + rawArtifact, err := getLocalOverride(&newOverrideInput{ + configVariablesClient: cc.configVariablesClient, + provider: cc.provider, + version: version, + filePath: filename, + }) + if err != nil { + return nil, err + } + + if rawArtifact == nil { + log.V(5).Info("Fetching", "File", filename, "Provider", cc.provider.Name(), "Type", cc.provider.Type(), "Version", version) + rawArtifact, err = cc.repository.GetFile(version, filename) + if err != nil { + return nil, errors.Wrapf(err, "failed to read %q from provider's repository %q", filename, cc.provider.ManifestLabel()) + } + } else { + log.V(1).Info("Using", "Override", filename, "Provider", cc.provider.ManifestLabel(), "Version", version) + } + + return NewTemplate(TemplateInput{ + rawArtifact, + cc.configVariablesClient, + cc.processor, + targetNamespace, + skipTemplateProcess, + }) +} diff --git a/cmd/clusterctl/client/repository/clusterclass_client_test.go b/cmd/clusterctl/client/repository/clusterclass_client_test.go new file mode 100644 index 000000000000..096721d04656 --- /dev/null +++ b/cmd/clusterctl/client/repository/clusterclass_client_test.go @@ -0,0 +1,197 @@ +/* +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 repository + +import ( + "fmt" + "testing" + + . "github.com/onsi/gomega" + "github.com/pkg/errors" + + clusterctlv1 "sigs.k8s.io/cluster-api/cmd/clusterctl/api/v1alpha3" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/config" + yaml "sigs.k8s.io/cluster-api/cmd/clusterctl/client/yamlprocessor" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/test" +) + +func Test_ClusterClassClient_Get(t *testing.T) { + p1 := config.NewProvider("p1", "", clusterctlv1.BootstrapProviderType) + + type fields struct { + version string + provider config.Provider + repository Repository + configVariablesClient config.VariablesClient + processor yaml.Processor + } + type args struct { + name string + targetNamespace string + listVariablesOnly bool + } + type want struct { + variables []string + targetNamespace string + } + tests := []struct { + name string + fields fields + args args + want want + wantErr bool + }{ + { + name: "pass if clusterclass of name exists", + fields: fields{ + version: "v1.0", + provider: p1, + repository: NewMemoryRepository(). + WithPaths("root", ""). + WithDefaultVersion("v1.0"). + WithFile("v1.0", "clusterclass-dev.yaml", templateMapYaml), + configVariablesClient: test.NewFakeVariableClient().WithVar(variableName, variableValue), + processor: yaml.NewSimpleProcessor(), + }, + args: args{ + name: "dev", + targetNamespace: "ns1", + listVariablesOnly: false, + }, + want: want{ + variables: []string{variableName}, + targetNamespace: "ns1", + }, + wantErr: false, + }, + { + name: "fails if clusterclass does not exists", + fields: fields{ + version: "v1.0", + provider: p1, + repository: NewMemoryRepository(). + WithPaths("root", ""). + WithDefaultVersion("v1.0"), + configVariablesClient: test.NewFakeVariableClient().WithVar(variableName, variableValue), + processor: yaml.NewSimpleProcessor(), + }, + args: args{ + name: "dev", + targetNamespace: "ns1", + listVariablesOnly: false, + }, + wantErr: true, + }, + { + name: "fails if variables does not exists", + fields: fields{ + version: "v1.0", + provider: p1, + repository: NewMemoryRepository(). + WithPaths("root", ""). + WithDefaultVersion("v1.0"). + WithFile("v1.0", "clusterclass-dev.yaml", templateMapYaml), + configVariablesClient: test.NewFakeVariableClient(), + processor: yaml.NewSimpleProcessor(), + }, + args: args{ + name: "dev", + targetNamespace: "ns1", + listVariablesOnly: false, + }, + wantErr: true, + }, + { + name: "pass if variables does not exists but skipTemplateProcess flag is set", + fields: fields{ + version: "v1.0", + provider: p1, + repository: NewMemoryRepository(). + WithPaths("root", ""). + WithDefaultVersion("v1.0"). + WithFile("v1.0", "clusterclass-dev.yaml", templateMapYaml), + configVariablesClient: test.NewFakeVariableClient(), + processor: yaml.NewSimpleProcessor(), + }, + args: args{ + name: "dev", + targetNamespace: "ns1", + listVariablesOnly: true, + }, + want: want{ + variables: []string{variableName}, + targetNamespace: "ns1", + }, + wantErr: false, + }, + { + name: "returns error if processor is unable to get variables", + fields: fields{ + version: "v1.0", + provider: p1, + repository: NewMemoryRepository(). + WithPaths("root", ""). + WithDefaultVersion("v1.0"). + WithFile("v1.0", "clusterclass-dev.yaml", templateMapYaml), + configVariablesClient: test.NewFakeVariableClient().WithVar(variableName, variableValue), + processor: test.NewFakeProcessor().WithGetVariablesErr(errors.New("cannot get vars")).WithTemplateName("clusterclass-dev.yaml"), + }, + args: args{ + targetNamespace: "ns1", + listVariablesOnly: true, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + f := newClusterClassClient( + ClusterClassClientInput{ + version: tt.fields.version, + provider: tt.fields.provider, + repository: tt.fields.repository, + configVariablesClient: tt.fields.configVariablesClient, + processor: tt.fields.processor, + }, + ) + got, err := f.Get(tt.args.name, tt.args.targetNamespace, tt.args.listVariablesOnly) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(got.Variables()).To(Equal(tt.want.variables)) + g.Expect(got.TargetNamespace()).To(Equal(tt.want.targetNamespace)) + + // check variable replaced in yaml + yaml, err := got.Yaml() + g.Expect(err).NotTo(HaveOccurred()) + + if !tt.args.listVariablesOnly { + g.Expect(yaml).To(ContainSubstring((fmt.Sprintf("variable: %s", variableValue)))) + } + + // check if target namespace is set + for _, o := range got.Objs() { + g.Expect(o.GetNamespace()).To(Equal(tt.want.targetNamespace)) + } + }) + } +} diff --git a/cmd/clusterctl/client/repository/template.go b/cmd/clusterctl/client/repository/template.go index 642d58bdc9d7..00e813abc8a7 100644 --- a/cmd/clusterctl/client/repository/template.go +++ b/cmd/clusterctl/client/repository/template.go @@ -17,8 +17,11 @@ limitations under the License. package repository import ( + "fmt" + "github.com/pkg/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/cluster-api/cmd/clusterctl/client/config" yaml "sigs.k8s.io/cluster-api/cmd/clusterctl/client/yamlprocessor" @@ -136,3 +139,53 @@ func NewTemplate(input TemplateInput) (Template, error) { objs: objs, }, nil } + +// MergeTemplates merges the provided Templates into one Template. +// Notes on the merge operation: +// - The merge operation returns an error if all the templates do not have the same TargetNamespace. +// - The Variables of the resulting template is a union of all Variables in the templates. +// - The default value is picked from the first temmplate that defines it. +// The defaults of the same variable in the subsequent templates will be ignored. +// (e.g when merging a cluster template and its ClusterClass, the default value from the template takes precedence) +// - The Objs of the final template will be a union of all the Objs in the templates. +func MergeTemplates(templates ...Template) (Template, error) { + templates = filterNilTemplates(templates...) + if len(templates) == 0 { + return nil, nil + } + + merged := &template{ + variables: []string{}, + variableMap: map[string]*string{}, + objs: []unstructured.Unstructured{}, + targetNamespace: templates[0].TargetNamespace(), + } + + for _, tmpl := range templates { + merged.variables = sets.NewString(merged.variables...).Union(sets.NewString(tmpl.Variables()...)).List() + + for key, val := range tmpl.VariableMap() { + if v, ok := merged.variableMap[key]; !ok || v == nil { + merged.variableMap[key] = val + } + } + + if merged.targetNamespace != tmpl.TargetNamespace() { + return nil, fmt.Errorf("cannot merge templates with different targetNamespaces") + } + + merged.objs = append(merged.objs, tmpl.Objs()...) + } + + return merged, nil +} + +func filterNilTemplates(templates ...Template) []Template { + res := []Template{} + for _, tmpl := range templates { + if tmpl != nil { + res = append(res, tmpl) + } + } + return res +} diff --git a/cmd/clusterctl/client/repository/template_test.go b/cmd/clusterctl/client/repository/template_test.go index 20a8e7ff5277..46685a658c29 100644 --- a/cmd/clusterctl/client/repository/template_test.go +++ b/cmd/clusterctl/client/repository/template_test.go @@ -116,3 +116,49 @@ func Test_newTemplate(t *testing.T) { }) } } + +func TestMergeTemplates(t *testing.T) { + g := NewWithT(t) + + templateYAMLGen := func(name, variableValue, sameVariableValue string) []byte { + return []byte(fmt.Sprintf(`apiVersion: v1 +data: + variable: ${%s} + samevariable: ${SAME_VARIABLE:-%s} +kind: ConfigMap +metadata: + name: %s`, variableValue, sameVariableValue, name)) + } + + template1, err := NewTemplate(TemplateInput{ + RawArtifact: templateYAMLGen("foo", "foo", "val-1"), + ConfigVariablesClient: test.NewFakeVariableClient().WithVar("foo", "foo-value"), + Processor: yaml.NewSimpleProcessor(), + TargetNamespace: "ns1", + SkipTemplateProcess: false, + }) + if err != nil { + t.Fatalf("failed to create template %v", err) + } + + template2, err := NewTemplate(TemplateInput{ + RawArtifact: templateYAMLGen("bar", "bar", "val-2"), + ConfigVariablesClient: test.NewFakeVariableClient().WithVar("bar", "bar-value"), + Processor: yaml.NewSimpleProcessor(), + TargetNamespace: "ns1", + SkipTemplateProcess: false, + }) + if err != nil { + t.Fatalf("failed to create template %v", err) + } + + merged, err := MergeTemplates(template1, template2) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(len(merged.Objs())).To(Equal(2)) + g.Expect(len(merged.VariableMap())).To(Equal(3)) + + // Make sure that the SAME_VARIABLE default value comes from the first template + // that defines it + g.Expect(merged.VariableMap()["SAME_VARIABLE"]).NotTo(BeNil()) + g.Expect(*merged.VariableMap()["SAME_VARIABLE"]).To(Equal("val-1")) +} diff --git a/cmd/clusterctl/client/yamlprocessor/processor.go b/cmd/clusterctl/client/yamlprocessor/processor.go index 82be4e9530fe..d2c2533a9b7c 100644 --- a/cmd/clusterctl/client/yamlprocessor/processor.go +++ b/cmd/clusterctl/client/yamlprocessor/processor.go @@ -20,10 +20,14 @@ package yamlprocessor // Processor defines the methods necessary for creating a specific yaml // processor. type Processor interface { - // GetTemplateName returns the name of the template that needs to be + // GetTemplateName returns the file name of the template that needs to be // retrieved from the source. GetTemplateName(version, flavor string) string + // GetClusterClassTemplateName returns the file name of the cluster class + // template that needs to be retrieved from the source. + GetClusterClassTemplateName(version, name string) string + // GetVariables parses the template blob of bytes and provides a // list of variables that the template uses. GetVariables([]byte) ([]string, error) diff --git a/cmd/clusterctl/client/yamlprocessor/simple_processor.go b/cmd/clusterctl/client/yamlprocessor/simple_processor.go index dda804df518a..e648e7786570 100644 --- a/cmd/clusterctl/client/yamlprocessor/simple_processor.go +++ b/cmd/clusterctl/client/yamlprocessor/simple_processor.go @@ -52,6 +52,13 @@ func (tp *SimpleProcessor) GetTemplateName(_, flavor string) string { return name } +// GetClusterClassTemplateName returns the name of the cluster class template +// that the simple processor uses. It follows the cluster class template naming convention +// of "clusterclass<-name>.yaml". +func (tp *SimpleProcessor) GetClusterClassTemplateName(_, name string) string { + return fmt.Sprintf("clusterclass-%s.yaml", name) +} + // GetVariables returns a list of the variables specified in the yaml. func (tp *SimpleProcessor) GetVariables(rawArtifact []byte) ([]string, error) { variables, err := tp.GetVariableMap(rawArtifact) diff --git a/cmd/clusterctl/internal/test/fake_processor.go b/cmd/clusterctl/internal/test/fake_processor.go index 7006fc580448..376a24ed8233 100644 --- a/cmd/clusterctl/internal/test/fake_processor.go +++ b/cmd/clusterctl/internal/test/fake_processor.go @@ -16,6 +16,8 @@ limitations under the License. package test +import "fmt" + type FakeProcessor struct { errGetVariables error errGetVariableMap error @@ -46,6 +48,10 @@ func (fp *FakeProcessor) GetTemplateName(version, flavor string) string { return fp.artifactName } +func (fp *FakeProcessor) GetClusterClassTemplateName(version, name string) string { + return fmt.Sprintf("clusterclass-%s.yaml", name) +} + func (fp *FakeProcessor) GetVariables(raw []byte) ([]string, error) { return nil, fp.errGetVariables } diff --git a/controllers/topology/cluster_controller.go b/controllers/topology/cluster_controller.go index 9e31a25a33c4..66e7e2748e8c 100644 --- a/controllers/topology/cluster_controller.go +++ b/controllers/topology/cluster_controller.go @@ -91,7 +91,11 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ // Fetch the Cluster instance. cluster := &clusterv1.Cluster{} - if err := r.Client.Get(ctx, req.NamespacedName, cluster); err != nil { + // Use the live client here so that we do not reconcile a stale cluster object. + // Example: If 2 reconcile loops are triggered in quick succession (one from the cluster and the other from the clusterclass) + // the first reconcile loop could update the cluster object (set the infrastructure clutser ref and control plane ref). If we + // do not use the live client the second reconcile loop could potentially pick up the stale cluster object from the cache. + if err := r.APIReader.Get(ctx, req.NamespacedName, cluster); err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil }