From 47edb1d533a470ccb8694551cba9b2b1a05b8040 Mon Sep 17 00:00:00 2001 From: Ben Meier Date: Fri, 10 May 2024 12:52:49 +0100 Subject: [PATCH] chore: added basic cli unit tests Signed-off-by: Ben Meier --- .../default/zz-default.provisioners.yaml | 12 +- main_generate.go | 2 + main_generate_test.go | 152 ++++++++++++++++++ main_init_test.go | 122 ++++++++++++++ main_test.go | 77 +++++++++ 5 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 main_generate_test.go create mode 100644 main_init_test.go create mode 100644 main_test.go diff --git a/internal/provisioners/default/zz-default.provisioners.yaml b/internal/provisioners/default/zz-default.provisioners.yaml index 9f286a2..a920510 100644 --- a/internal/provisioners/default/zz-default.provisioners.yaml +++ b/internal/provisioners/default/zz-default.provisioners.yaml @@ -3,12 +3,12 @@ - uri: template://example-provisioners/example-provisioner # (Required) Which resource type to match type: example-provisioner-resource - # (Optional) Which 'class' of the resource. Blank will match any class, a non-empty value like 'default' will match + # (Optional) Which 'class' of the resource. Null will match any class, a non-empty value like 'default' will match # only resources of that class. - class: "" - # (Optional) The exact resource id to match. Blank will match any resource, a non-empty value will only match + class: null + # (Optional) The exact resource id to match. Null will match any resource, a non-empty value will only match # the resource with exact same id. - id: "" + id: null # (Optional) The init template sets the initial context values on each provision request. This is a text template # that must evaluate to a YAML/JSON key-value map. init: | @@ -21,8 +21,8 @@ # state and the init context. Like init, this evaluates to a YAML/JSON object. This is the template that allows # state to be stored between each generate call. state: | - state-key: {{ .Init.key }} # will copy the value from init - state-key2: {{ default 0 .State.state-key2 | add 1 }} # will increment on each provision attempt + stateKey: {{ .Init.key }} # will copy the value from init + stateKey2: {{ default 0 .State.stateKey2 | add 1 }} # will increment on each provision attempt # (Optional) The shared state template is like state, but is a key-value structure shared between all resources. # This can be used to coordinate shared resources and state between resources of the same or related types. shared: | diff --git a/main_generate.go b/main_generate.go index e16b4ca..e478ce7 100644 --- a/main_generate.go +++ b/main_generate.go @@ -137,6 +137,8 @@ manifests. All resources and links between Workloads will be resolved and provis container.Image = v slog.Info(fmt.Sprintf("Set container image for container '%s' to %s from --%s", containerName, v, generateCmdImageFlag)) workload.Containers[containerName] = container + } else { + return errors.Errorf("failed to convert '%s' because container '%s' has no image and --image was not provided", arg, containerName) } } } diff --git a/main_generate_test.go b/main_generate_test.go new file mode 100644 index 0000000..fc4fbea --- /dev/null +++ b/main_generate_test.go @@ -0,0 +1,152 @@ +// Copyright 2024 Humanitec +// +// 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 main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/score-spec/score-k8s/internal/project" +) + +func changeToDir(t *testing.T, dir string) string { + t.Helper() + wd, _ := os.Getwd() + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(wd)) + }) + return dir +} + +func changeToTempDir(t *testing.T) string { + return changeToDir(t, t.TempDir()) +} + +func TestGenerateWithoutInit(t *testing.T) { + _ = changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"generate"}) + assert.EqualError(t, err, "state directory does not exist, please run \"score-k8s init\" first") + assert.Equal(t, "", stdout) +} + +func TestGenerateWithoutScoreFiles(t *testing.T) { + _ = changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate"}) + assert.EqualError(t, err, "Project is empty, please add a score file") + assert.Equal(t, "", stdout) +} + +func TestInitAndGenerateWithBadFile(t *testing.T) { + td := changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + + assert.NoError(t, os.WriteFile(filepath.Join(td, "thing"), []byte(`"blah"`), 0644)) + + stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "thing"}) + assert.EqualError(t, err, "failed to decode input score file: thing: yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `blah` into map[string]interface {}") + assert.Equal(t, "", stdout) +} + +func TestInitAndGenerateWithBadScore(t *testing.T) { + td := changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + + assert.NoError(t, os.WriteFile(filepath.Join(td, "thing"), []byte(`{}`), 0644)) + + stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "thing"}) + assert.EqualError(t, err, "invalid score file: thing: jsonschema: '' does not validate with https://score.dev/schemas/score#/required: missing properties: 'apiVersion', 'metadata', 'containers'") + assert.Equal(t, "", stdout) +} + +func TestInitAndGenerate_with_sample(t *testing.T) { + td := changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + require.NoError(t, err) + assert.Equal(t, "", stdout) + + // write overrides file + assert.NoError(t, os.WriteFile(filepath.Join(td, "overrides.yaml"), []byte(`{"resources": {"foo": {"type": "example-provisioner-resource"}}}`), 0644)) + // generate + stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{ + "generate", "-o", "manifests.yaml", + "--overrides-file", "overrides.yaml", + "--override-property", "containers.main.variables.THING=${resources.foo.plaintext}", + "--", "score.yaml", + }) + require.NoError(t, err) + assert.Equal(t, "", stdout) + raw, err := os.ReadFile(filepath.Join(td, "manifests.yaml")) + assert.NoError(t, err) + assert.Contains(t, string(raw), "\nkind: ConfigMap\n") + assert.Contains(t, string(raw), "\nkind: Service\n") + assert.Contains(t, string(raw), "\nkind: Deployment\n") + + // check that state was persisted + sd, ok, err := project.LoadStateDirectory(td) + assert.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "score.yaml", *sd.State.Workloads["example"].File) + assert.Len(t, sd.State.Workloads, 1) + assert.Len(t, sd.State.Resources, 1) +} + +func TestInitAndGenerate_with_image_override(t *testing.T) { + td := changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + + // write new score file + assert.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(` +apiVersion: score.dev/v1b1 +metadata: + name: example +containers: + example: + image: . +`), 0644)) + + t.Run("generate but fail due to missing override", func(t *testing.T) { + stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{ + "generate", "-o", "manifests.yaml", "--", "score.yaml", + }) + assert.EqualError(t, err, "failed to convert 'score.yaml' because container 'example' has no image and --image was not provided") + }) + + t.Run("generate with image", func(t *testing.T) { + // generate with image + stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{ + "generate", "-o", "manifests.yaml", "--image", "busybox:latest", "--", "score.yaml", + }) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + raw, err := os.ReadFile(filepath.Join(td, "manifests.yaml")) + assert.NoError(t, err) + assert.Contains(t, string(raw), "---\napiVersion: apps/v1\nkind: Deployment\n") + }) +} diff --git a/main_init_test.go b/main_init_test.go new file mode 100644 index 0000000..5ea91ed --- /dev/null +++ b/main_init_test.go @@ -0,0 +1,122 @@ +// Copyright 2024 Humanitec +// +// 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 main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/score-spec/score-go/framework" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/score-spec/score-k8s/internal/project" +) + +func TestInitNominal(t *testing.T) { + td := t.TempDir() + + wd, _ := os.Getwd() + require.NoError(t, os.Chdir(td)) + defer func() { + require.NoError(t, os.Chdir(wd)) + }() + + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + assert.NotEqual(t, "", strings.TrimSpace(stderr)) + + stdout, stderr, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"}) + assert.NoError(t, err) + assert.Equal(t, ``, stdout) + assert.NotEqual(t, "", strings.TrimSpace(stderr)) + + sd, ok, err := project.LoadStateDirectory(".") + assert.NoError(t, err) + if assert.True(t, ok) { + assert.Equal(t, project.DefaultRelativeStateDirectory, sd.Path) + assert.Len(t, sd.State.Workloads, 1) + assert.Equal(t, map[framework.ResourceUid]framework.ScoreResourceState[project.ResourceExtras]{}, sd.State.Resources) + assert.Equal(t, map[string]interface{}{}, sd.State.SharedState) + } +} + +func TestInitNoSample(t *testing.T) { + td := t.TempDir() + + wd, _ := os.Getwd() + require.NoError(t, os.Chdir(td)) + defer func() { + require.NoError(t, os.Chdir(wd)) + }() + + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--no-sample"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + assert.NotEqual(t, "", strings.TrimSpace(stderr)) + + _, err = os.Stat("score.yaml") + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestInitNominal_run_twice(t *testing.T) { + td := t.TempDir() + + wd, _ := os.Getwd() + require.NoError(t, os.Chdir(td)) + defer func() { + require.NoError(t, os.Chdir(wd)) + }() + + // first init + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--file", "score2.yaml"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + assert.NotEqual(t, "", strings.TrimSpace(stderr)) + + // check default provisioners exists and overwrite it with an empty array + dpf, err := os.Stat(filepath.Join(td, ".score-k8s", "zz-default.provisioners.yaml")) + assert.NoError(t, err) + assert.NoError(t, os.WriteFile(filepath.Join(td, ".score-k8s", dpf.Name()), []byte("[]"), 0644)) + + // init again + stdout, stderr, err = executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + assert.NotEqual(t, "", strings.TrimSpace(stderr)) + + // verify that default provisioners was not overwritten again + dpf, err = os.Stat(filepath.Join(td, ".score-k8s", dpf.Name())) + assert.NoError(t, err) + assert.Equal(t, 2, int(dpf.Size())) + + _, err = os.Stat("score.yaml") + assert.NoError(t, err) + _, err = os.Stat("score2.yaml") + assert.NoError(t, err) + + sd, ok, err := project.LoadStateDirectory(".") + assert.NoError(t, err) + if assert.True(t, ok) { + assert.Equal(t, project.DefaultRelativeStateDirectory, sd.Path) + assert.Equal(t, map[string]framework.ScoreWorkloadState[framework.NoExtras]{}, sd.State.Workloads) + assert.Equal(t, map[framework.ResourceUid]framework.ScoreResourceState[project.ResourceExtras]{}, sd.State.Resources) + assert.Equal(t, map[string]interface{}{}, sd.State.SharedState) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..0eca63b --- /dev/null +++ b/main_test.go @@ -0,0 +1,77 @@ +// Copyright 2024 Humanitec +// +// 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 main + +import ( + "bytes" + "context" + "regexp" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" +) + +// executeAndResetCommand is a test helper that runs and then resets a command for executing in another test. +func executeAndResetCommand(ctx context.Context, cmd *cobra.Command, args []string) (string, string, error) { + beforeOut, beforeErr := cmd.OutOrStdout(), cmd.ErrOrStderr() + defer func() { + cmd.SetOut(beforeOut) + cmd.SetErr(beforeErr) + // also have to remove completion commands which get auto added and bound to an output buffer + for _, command := range cmd.Commands() { + if command.Name() == "completion" { + cmd.RemoveCommand(command) + break + } + } + }() + + nowOut, nowErr := new(bytes.Buffer), new(bytes.Buffer) + cmd.SetOut(nowOut) + cmd.SetErr(nowErr) + cmd.SetArgs(args) + subCmd, err := cmd.ExecuteContextC(ctx) + if subCmd != nil { + subCmd.SetOut(nil) + subCmd.SetErr(nil) + subCmd.SetContext(nil) + subCmd.SilenceUsage = false + subCmd.Flags().VisitAll(func(f *pflag.Flag) { + if f.Value.Type() == "stringArray" { + _ = f.Value.(pflag.SliceValue).Replace(nil) + } else { + _ = f.Value.Set(f.DefValue) + } + }) + } + return nowOut.String(), nowErr.String(), err +} + +func TestRootVersion(t *testing.T) { + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"--version"}) + assert.NoError(t, err) + pattern := regexp.MustCompile(`^score-k8s 0.0.0 \(build: \S+, sha: \S+\)\n$`) + assert.Truef(t, pattern.MatchString(stdout), "%s does not match: '%s'", pattern.String(), stdout) + assert.Equal(t, "", stderr) +} + +func TestRootUnknown(t *testing.T) { + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"unknown"}) + assert.EqualError(t, err, "unknown command \"unknown\" for \"score-k8s\"") + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +}