diff --git a/cmd/clusterctl/client/client.go b/cmd/clusterctl/client/client.go index 7b0d9999ec36..50b1c4040611 100644 --- a/cmd/clusterctl/client/client.go +++ b/cmd/clusterctl/client/client.go @@ -55,6 +55,20 @@ type Client interface { // ApplyUpgrade executes an upgrade plan. ApplyUpgrade(options ApplyUpgradeOptions) error + + // ProcessYAML provides a direct way to process a yaml and inspect it's + // variables + ProcessYAML(options ProcessYAMLOptions) (YamlPrinter, error) +} + +// YamlPrinter exposes methods that prints the processed template and +// variables. +type YamlPrinter interface { + // Variables required by the template. + Variables() []string + + // Yaml returns yaml defining all the cluster template objects as a byte array. + Yaml() ([]byte, error) } // clusterctlClient implements Client. diff --git a/cmd/clusterctl/client/client_test.go b/cmd/clusterctl/client/client_test.go index dccae1273d42..70e8a9743ad9 100644 --- a/cmd/clusterctl/client/client_test.go +++ b/cmd/clusterctl/client/client_test.go @@ -105,6 +105,10 @@ func (f fakeClient) ApplyUpgrade(options ApplyUpgradeOptions) error { return f.internalClient.ApplyUpgrade(options) } +func (f fakeClient) ProcessYAML(options ProcessYAMLOptions) (YamlPrinter, error) { + return f.internalClient.ProcessYAML(options) +} + // newFakeClient returns a clusterctl client that allows to execute tests on a set of fake config, fake repositories and fake clusters. // you can use WithCluster and WithRepository to prepare for the test case. func newFakeClient(configClient config.Client) *fakeClient { diff --git a/cmd/clusterctl/client/config.go b/cmd/clusterctl/client/config.go index 392658368113..bc1599022ef4 100644 --- a/cmd/clusterctl/client/config.go +++ b/cmd/clusterctl/client/config.go @@ -58,6 +58,38 @@ func (c *clusterctlClient) GetProviderComponents(provider string, providerType c return components, nil } +type ProcessYAMLOptions struct { + // URLSource to be used for reading the template + URLSource *URLSourceOptions + + // ListVariablesOnly return the list of variables expected by the template + // without executing any further processing. + ListVariablesOnly bool +} + +func (c *clusterctlClient) ProcessYAML(options ProcessYAMLOptions) (YamlPrinter, error) { + + // Technically we do not need to connect to the cluster. However, we are + // leveraging the template client which exposes GetFromURL() is available + // on the cluster client so we create a cluster client with default + // configs to access it. + cluster, err := c.clusterClientFactory( + ClusterClientFactoryInput{ + // use the default kubeconfig + kubeconfig: Kubeconfig{}, + }, + ) + if err != nil { + return nil, err + } + + if options.URLSource != nil { + return c.getTemplateFromURL(cluster, *options.URLSource, "", options.ListVariablesOnly) + } + + return nil, errors.New("unable to read custom template. Please specify a template source") +} + // GetClusterTemplateOptions carries the options supported by GetClusterTemplate. type GetClusterTemplateOptions struct { // Kubeconfig defines the kubeconfig to use for accessing the management cluster. If empty, diff --git a/cmd/clusterctl/client/config_test.go b/cmd/clusterctl/client/config_test.go index 9471dc7d1127..c7b42571795d 100644 --- a/cmd/clusterctl/client/config_test.go +++ b/cmd/clusterctl/client/config_test.go @@ -598,3 +598,68 @@ func Test_clusterctlClient_GetClusterTemplate(t *testing.T) { }) } } + +func Test_clusterctlClient_ProcessYAML(t *testing.T) { + g := NewWithT(t) + template := `v1: ${VAR1:=default1} +v2: ${VAR2=default2} +v3: ${VAR3:-default3}` + dir, err := ioutil.TempDir("", "clusterctl") + g.Expect(err).NotTo(HaveOccurred()) + defer os.RemoveAll(dir) + + templateFile := filepath.Join(dir, "template.yaml") + g.Expect(ioutil.WriteFile(templateFile, []byte(template), 0600)).To(Succeed()) + + tests := []struct { + name string + options ProcessYAMLOptions + expectErr bool + expectedYaml string + expectedVars []string + }{ + { + name: "returns the expected yaml and variables", + options: ProcessYAMLOptions{ + URLSource: &URLSourceOptions{ + URL: templateFile, + }, + }, + expectErr: false, + expectedYaml: `v1: default1 +v2: default2 +v3: default3`, + expectedVars: []string{"VAR1", "VAR2", "VAR3"}, + }, + { + name: "returns error if no source was specified", + options: ProcessYAMLOptions{}, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config1 := newFakeConfig(). + WithProvider(infraProviderConfig) + cluster1 := newFakeCluster(cluster.Kubeconfig{}, config1) + + client := newFakeClient(config1).WithCluster(cluster1) + + printer, err := client.ProcessYAML(tt.options) + if tt.expectErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).ToNot(HaveOccurred()) + expectedYaml, err := printer.Yaml() + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(string(expectedYaml)).To(Equal(tt.expectedYaml)) + + expectedVars := printer.Variables() + g.Expect(expectedVars).To(ConsistOf(tt.expectedVars)) + + }) + } + +} diff --git a/cmd/clusterctl/cmd/generate.go b/cmd/clusterctl/cmd/generate.go new file mode 100644 index 000000000000..d14c71c4647b --- /dev/null +++ b/cmd/clusterctl/cmd/generate.go @@ -0,0 +1,31 @@ +/* +Copyright 2019 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 cmd + +import ( + "github.com/spf13/cobra" +) + +var generateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate yaml using clusterctl yaml processor.", + Long: `Generate yaml using clusterctl yaml processor.`, +} + +func init() { + RootCmd.AddCommand(generateCmd) +} diff --git a/cmd/clusterctl/cmd/generate_yaml.go b/cmd/clusterctl/cmd/generate_yaml.go new file mode 100644 index 000000000000..b278ad308c25 --- /dev/null +++ b/cmd/clusterctl/cmd/generate_yaml.go @@ -0,0 +1,107 @@ +/* +Copyright 2019 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 cmd + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client" +) + +type generateYAMLOptions struct { + url string + listVariables bool +} + +var gyOpts = &generateYAMLOptions{} + +var generateYamlCmd = &cobra.Command{ + Use: "yaml", + Short: "Process yaml using clusterctl's yaml processor", + Long: LongDesc(` + Process yaml using clusterctl's yaml processor. + + clusterctl ships with a simple yaml processor that performs variable + substitution that takes into account of default values. + + Variable values are either sourced from the clusterctl config file or + from environment variables`), + + Example: Examples(` + # Generates a configuration file with variable values using + a template from a specific URL. + clusterctl generate yaml --from https://github.com/foo-org/foo-repository/blob/master/cluster-template.yaml + + # Generates a configuration file with variable values using + a template stored locally. + clusterctl generate yaml --from ~/workspace/cluster-template.yaml`), + + RunE: func(cmd *cobra.Command, args []string) error { + return generateYAML(os.Stdout) + }, +} + +func init() { + // flags for the url source + generateYamlCmd.Flags().StringVar(&gyOpts.url, "from", "", + "The URL to read the template from.") + + // other flags + generateYamlCmd.Flags().BoolVar(&gyOpts.listVariables, "list-variables", false, + "Returns the list of variables expected by the template instead of the template yaml") + + generateCmd.AddCommand(generateYamlCmd) +} + +func generateYAML(w io.Writer) error { + c, err := client.New(cfgFile) + if err != nil { + return err + } + options := client.ProcessYAMLOptions{ + ListVariablesOnly: gyOpts.listVariables, + } + if gyOpts.url != "" { + options.URLSource = &client.URLSourceOptions{ + URL: gyOpts.url, + } + } + printer, err := c.ProcessYAML(options) + if err != nil { + return err + } + if gyOpts.listVariables { + if len(printer.Variables()) > 0 { + fmt.Fprintln(w, "Variables:") + for _, v := range printer.Variables() { + fmt.Fprintf(w, " - %s\n", v) + } + } else { + fmt.Fprintln(w) + } + return nil + } + out, err := printer.Yaml() + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(out)) + return err +} diff --git a/cmd/clusterctl/cmd/generate_yaml_test.go b/cmd/clusterctl/cmd/generate_yaml_test.go new file mode 100644 index 000000000000..4721cada2154 --- /dev/null +++ b/cmd/clusterctl/cmd/generate_yaml_test.go @@ -0,0 +1,110 @@ +/* +Copyright 2020 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 cmd + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/gomega" +) + +func Test_generateYAML(t *testing.T) { + g := NewWithT(t) + // create a local template + template, cleanup1 := createTempFile(g, `v1: ${VAR1:=default1} +v2: ${VAR2=default2} +v3: ${VAR3:-default3}`) + defer cleanup1() + + templateWithoutVars, cleanup2 := createTempFile(g, `v1: foobar +v2: bazfoo`) + defer cleanup2() + + tests := []struct { + name string + options *generateYAMLOptions + expectErr bool + expectedOutput string + }{ + { + name: "prints processed yaml using --from flag", + options: &generateYAMLOptions{url: template}, + expectErr: false, + expectedOutput: `v1: default1 +v2: default2 +v3: default3 +`, + }, + { + name: "prints variables using --list-variables flag", + options: &generateYAMLOptions{url: template, listVariables: true}, + expectErr: false, + expectedOutput: `Variables: + - VAR1 + - VAR2 + - VAR3 +`, + }, + { + name: "returns error for bad templateFile path", + options: &generateYAMLOptions{url: "/tmp/do-not-exist", listVariables: true}, + expectErr: true, + }, + { + name: "prints nothing if there are no variables in the template", + options: &generateYAMLOptions{url: templateWithoutVars, listVariables: true}, + expectErr: false, + expectedOutput: "\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + gyOpts = tt.options + buf := bytes.NewBufferString("") + err := generateYAML(buf) + if tt.expectErr { + g.Expect(err).To(HaveOccurred()) + return + } + + output, err := ioutil.ReadAll(buf) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(string(output)).To(Equal(tt.expectedOutput)) + }) + } + +} + +// createTempFile creates a temporary yaml file inside a temp dir. It returns +// the filepath and a cleanup function for the temp directory. +func createTempFile(g *WithT, contents string) (string, func()) { + dir, err := ioutil.TempDir("", "clusterctl") + g.Expect(err).NotTo(HaveOccurred()) + + templateFile := filepath.Join(dir, "templ.yaml") + g.Expect(ioutil.WriteFile(templateFile, []byte(contents), 0600)).To(Succeed()) + + return templateFile, func() { + os.RemoveAll(dir) + } +}