Skip to content

Commit

Permalink
Add generate yaml subcommand to clusterctl
Browse files Browse the repository at this point in the history
Currently, this only works with --from flag since that is the most
generic and can be used for any template.
It also provides the --list-variables flag to list variables present in
the template
  • Loading branch information
Warren Fernandes committed Jul 27, 2020
1 parent f8cac93 commit f881a45
Show file tree
Hide file tree
Showing 7 changed files with 363 additions and 0 deletions.
14 changes: 14 additions & 0 deletions cmd/clusterctl/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions cmd/clusterctl/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions cmd/clusterctl/client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions cmd/clusterctl/client/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

})
}

}
31 changes: 31 additions & 0 deletions cmd/clusterctl/cmd/generate.go
Original file line number Diff line number Diff line change
@@ -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)
}
107 changes: 107 additions & 0 deletions cmd/clusterctl/cmd/generate_yaml.go
Original file line number Diff line number Diff line change
@@ -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
}
110 changes: 110 additions & 0 deletions cmd/clusterctl/cmd/generate_yaml_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit f881a45

Please sign in to comment.