diff --git a/cli/create.go b/cli/create.go index cbfa5ec9f4713..4f09f71bc7eb3 100644 --- a/cli/create.go +++ b/cli/create.go @@ -17,6 +17,7 @@ func create() *cobra.Command { var ( workspaceName string templateName string + parameterFile string ) cmd := &cobra.Command{ Annotations: workspaceCommand, @@ -116,23 +117,33 @@ func create() *cobra.Command { return err } - printed := false + // parameterMapFromFile can be nil if parameter file is not specified + var parameterMapFromFile map[string]string + if parameterFile != "" { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n") + parameterMapFromFile, err = createParameterMapFromFile(parameterFile) + if err != nil { + return err + } + } + + disclaimerPrinted := false parameters := make([]codersdk.CreateParameterRequest, 0) for _, parameterSchema := range parameterSchemas { if !parameterSchema.AllowOverrideSource { continue } - if !printed { + if !disclaimerPrinted { _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n") - printed = true + disclaimerPrinted = true } - value, err := cliui.ParameterSchema(cmd, parameterSchema) + parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema) if err != nil { return err } parameters = append(parameters, codersdk.CreateParameterRequest{ Name: parameterSchema.Name, - SourceValue: value, + SourceValue: parameterValue, SourceScheme: codersdk.ParameterSourceSchemeData, DestinationScheme: parameterSchema.DefaultDestinationScheme, }) @@ -194,5 +205,6 @@ func create() *cobra.Command { } cliflag.StringVarP(cmd.Flags(), &templateName, "template", "t", "CODER_TEMPLATE_NAME", "", "Specify a template name.") + cliflag.StringVarP(cmd.Flags(), ¶meterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.") return cmd } diff --git a/cli/create_test.go b/cli/create_test.go index f81f2a027d4d2..955a001fa1245 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -2,6 +2,7 @@ package cli_test import ( "fmt" + "os" "testing" "github.com/stretchr/testify/require" @@ -113,39 +114,7 @@ func TestCreate(t *testing.T) { defaultValue := "something" version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: []*proto.Parse_Response{{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ - ParameterSchemas: []*proto.ParameterSchema{ - { - AllowOverrideSource: true, - Name: "region", - Description: "description 1", - DefaultSource: &proto.ParameterSource{ - Scheme: proto.ParameterSource_DATA, - Value: defaultValue, - }, - DefaultDestination: &proto.ParameterDestination{ - Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE, - }, - }, - { - AllowOverrideSource: true, - Name: "username", - Description: "description 2", - DefaultSource: &proto.ParameterSource{ - Scheme: proto.ParameterSource_DATA, - // No default value - Value: "", - }, - DefaultDestination: &proto.ParameterDestination{ - Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE, - }, - }, - }, - }, - }, - }}, + Parse: createTestParseResponseWithDefault(defaultValue), Provision: echo.ProvisionComplete, ProvisionDryRun: echo.ProvisionComplete, }) @@ -178,4 +147,113 @@ func TestCreate(t *testing.T) { } <-doneChan }) + + t.Run("WithParameterFileContainingTheValue", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + + defaultValue := "something" + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: createTestParseResponseWithDefault(defaultValue), + Provision: echo.ProvisionComplete, + ProvisionDryRun: echo.ProvisionComplete, + }) + + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + tempDir := t.TempDir() + parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") + _, _ = parameterFile.WriteString("region: \"bingo\"\nusername: \"boingo\"") + cmd, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name()) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + + matches := []string{ + "Specify a name", "my-workspace", + "Confirm create?", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + <-doneChan + removeTmpDirUntilSuccess(t, tempDir) + }) + t.Run("WithParameterFileNotContainingTheValue", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + + defaultValue := "something" + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: createTestParseResponseWithDefault(defaultValue), + Provision: echo.ProvisionComplete, + ProvisionDryRun: echo.ProvisionComplete, + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + tempDir := t.TempDir() + parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") + _, _ = parameterFile.WriteString("zone: \"bananas\"") + cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--parameter-file", parameterFile.Name()) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.EqualError(t, err, "Parameter value absent in parameter file for \"region\"!") + }() + <-doneChan + removeTmpDirUntilSuccess(t, tempDir) + }) +} + +func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Response { + return []*proto.Parse_Response{{ + Type: &proto.Parse_Response_Complete{ + Complete: &proto.Parse_Complete{ + ParameterSchemas: []*proto.ParameterSchema{ + { + AllowOverrideSource: true, + Name: "region", + Description: "description 1", + DefaultSource: &proto.ParameterSource{ + Scheme: proto.ParameterSource_DATA, + Value: defaultValue, + }, + DefaultDestination: &proto.ParameterDestination{ + Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE, + }, + }, + { + AllowOverrideSource: true, + Name: "username", + Description: "description 2", + DefaultSource: &proto.ParameterSource{ + Scheme: proto.ParameterSource_DATA, + // No default value + Value: "", + }, + DefaultDestination: &proto.ParameterDestination{ + Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE, + }, + }, + }, + }, + }, + }} } diff --git a/cli/parameter.go b/cli/parameter.go new file mode 100644 index 0000000000000..5efb81f9fd405 --- /dev/null +++ b/cli/parameter.go @@ -0,0 +1,56 @@ +package cli + +import ( + "os" + + "golang.org/x/xerrors" + "gopkg.in/yaml.v3" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" + "github.com/spf13/cobra" +) + +// Reads a YAML file and populates a string -> string map. +// Throws an error if the file name is empty. +func createParameterMapFromFile(parameterFile string) (map[string]string, error) { + if parameterFile != "" { + parameterMap := make(map[string]string) + + parameterFileContents, err := os.ReadFile(parameterFile) + + if err != nil { + return nil, err + } + + err = yaml.Unmarshal(parameterFileContents, ¶meterMap) + + if err != nil { + return nil, err + } + + return parameterMap, nil + } + + return nil, xerrors.Errorf("Parameter file name is not specified") +} + +// Returns a parameter value from a given map, if the map exists, else takes input from the user. +// Throws an error if the map exists but does not include a value for the parameter. +func getParameterValueFromMapOrInput(cmd *cobra.Command, parameterMap map[string]string, parameterSchema codersdk.ParameterSchema) (string, error) { + var parameterValue string + if parameterMap != nil { + var ok bool + parameterValue, ok = parameterMap[parameterSchema.Name] + if !ok { + return "", xerrors.Errorf("Parameter value absent in parameter file for %q!", parameterSchema.Name) + } + } else { + var err error + parameterValue, err = cliui.ParameterSchema(cmd, parameterSchema) + if err != nil { + return "", err + } + } + return parameterValue, nil +} diff --git a/cli/parameter_internal_test.go b/cli/parameter_internal_test.go new file mode 100644 index 0000000000000..f1316a43a87ad --- /dev/null +++ b/cli/parameter_internal_test.go @@ -0,0 +1,79 @@ +package cli + +import ( + "os" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreateParameterMapFromFile(t *testing.T) { + t.Parallel() + t.Run("CreateParameterMapFromFile", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") + _, _ = parameterFile.WriteString("region: \"bananas\"\ndisk: \"20\"\n") + + parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name()) + + expectedMap := map[string]string{ + "region": "bananas", + "disk": "20", + } + + assert.Equal(t, expectedMap, parameterMapFromFile) + assert.Nil(t, err) + + removeTmpDirUntilSuccess(t, tempDir) + }) + t.Run("WithEmptyFilename", func(t *testing.T) { + t.Parallel() + + parameterMapFromFile, err := createParameterMapFromFile("") + + assert.Nil(t, parameterMapFromFile) + assert.EqualError(t, err, "Parameter file name is not specified") + }) + t.Run("WithInvalidFilename", func(t *testing.T) { + t.Parallel() + + parameterMapFromFile, err := createParameterMapFromFile("invalidFile.yaml") + + assert.Nil(t, parameterMapFromFile) + + // On Unix based systems, it is: `open invalidFile.yaml: no such file or directory` + // On Windows, it is `open invalidFile.yaml: The system cannot find the file specified.` + if runtime.GOOS == "windows" { + assert.EqualError(t, err, "open invalidFile.yaml: The system cannot find the file specified.") + } else { + assert.EqualError(t, err, "open invalidFile.yaml: no such file or directory") + } + }) + t.Run("WithInvalidYAML", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") + _, _ = parameterFile.WriteString("region = \"bananas\"\ndisk = \"20\"\n") + + parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name()) + + assert.Nil(t, parameterMapFromFile) + assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]string") + + removeTmpDirUntilSuccess(t, tempDir) + }) +} + +// Need this for Windows because of a known issue with Go: +// https://github.com/golang/go/issues/52986 +func removeTmpDirUntilSuccess(t *testing.T, tempDir string) { + t.Helper() + t.Cleanup(func() { + err := os.RemoveAll(tempDir) + for err != nil { + err = os.RemoveAll(tempDir) + } + }) +} diff --git a/cli/templatecreate.go b/cli/templatecreate.go index a0def78e72c07..6c98afd66cfc8 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -21,9 +21,10 @@ import ( func templateCreate() *cobra.Command { var ( - yes bool - directory string - provisioner string + yes bool + directory string + provisioner string + parameterFile string ) cmd := &cobra.Command{ Use: "create [name]", @@ -79,7 +80,7 @@ func templateCreate() *cobra.Command { } spin.Stop() - job, parameters, err := createValidTemplateVersion(cmd, client, organization, database.ProvisionerType(provisioner), resp.Hash) + job, parameters, err := createValidTemplateVersion(cmd, client, organization, database.ProvisionerType(provisioner), resp.Hash, parameterFile) if err != nil { return err } @@ -116,6 +117,7 @@ func templateCreate() *cobra.Command { currentDirectory, _ := os.Getwd() cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from") cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend") + cmd.Flags().StringVarP(¶meterFile, "parameter-file", "", "", "Specify a file path with parameter values.") // This is for testing! err := cmd.Flags().MarkHidden("test.provisioner") if err != nil { @@ -125,7 +127,7 @@ func templateCreate() *cobra.Command { return cmd } -func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, organization codersdk.Organization, provisioner database.ProvisionerType, hash string, parameters ...codersdk.CreateParameterRequest) (*codersdk.TemplateVersion, []codersdk.CreateParameterRequest, error) { +func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, organization codersdk.Organization, provisioner database.ProvisionerType, hash string, parameterFile string, parameters ...codersdk.CreateParameterRequest) (*codersdk.TemplateVersion, []codersdk.CreateParameterRequest, error) { before := time.Now() version, err := client.CreateTemplateVersion(cmd.Context(), organization.ID, codersdk.CreateTemplateVersionRequest{ StorageMethod: codersdk.ProvisionerStorageMethodFile, @@ -184,20 +186,33 @@ func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, org missingSchemas = append(missingSchemas, parameterSchema) } _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has required variables! They are scoped to the template, and not viewable after being set.")+"\r\n") + + // parameterMapFromFile can be nil if parameter file is not specified + var parameterMapFromFile map[string]string + if parameterFile != "" { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n") + parameterMapFromFile, err = createParameterMapFromFile(parameterFile) + if err != nil { + return nil, nil, err + } + } for _, parameterSchema := range missingSchemas { - value, err := cliui.ParameterSchema(cmd, parameterSchema) + parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema) if err != nil { return nil, nil, err } parameters = append(parameters, codersdk.CreateParameterRequest{ Name: parameterSchema.Name, - SourceValue: value, + SourceValue: parameterValue, SourceScheme: codersdk.ParameterSourceSchemeData, DestinationScheme: parameterSchema.DefaultDestinationScheme, }) _, _ = fmt.Fprintln(cmd.OutOrStdout()) } - return createValidTemplateVersion(cmd, client, organization, provisioner, hash, parameters...) + + // This recursion is only 1 level deep in practice. + // The first pass populates the missing parameters, so it does not enter this `if` block again. + return createValidTemplateVersion(cmd, client, organization, provisioner, hash, parameterFile, parameters...) } if version.Job.Status != codersdk.ProvisionerJobSucceeded { diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index 37bc0cb3a0080..2dead6ee24b69 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "os" "testing" "github.com/stretchr/testify/require" @@ -9,6 +10,7 @@ import ( "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/pty/ptytest" ) @@ -47,4 +49,146 @@ func TestTemplateCreate(t *testing.T) { require.NoError(t, <-execDone) }) + + t.Run("WithParameter", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + coderdtest.CreateFirstUser(t, client) + source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ + Parse: createTestParseResponse(), + Provision: echo.ProvisionComplete, + ProvisionDryRun: echo.ProvisionComplete, + }) + cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho)) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + + execDone := make(chan error) + go func() { + execDone <- cmd.Execute() + }() + + matches := []struct { + match string + write string + }{ + {match: "Create and upload", write: "yes"}, + {match: "Enter a value:", write: "bananas"}, + {match: "Confirm create?", write: "yes"}, + } + for _, m := range matches { + pty.ExpectMatch(m.match) + pty.WriteLine(m.write) + } + + require.NoError(t, <-execDone) + }) + + t.Run("WithParameterFileContainingTheValue", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + coderdtest.CreateFirstUser(t, client) + source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ + Parse: createTestParseResponse(), + Provision: echo.ProvisionComplete, + ProvisionDryRun: echo.ProvisionComplete, + }) + tempDir := t.TempDir() + parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") + _, _ = parameterFile.WriteString("region: \"bananas\"") + cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--parameter-file", parameterFile.Name()) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + + execDone := make(chan error) + go func() { + execDone <- cmd.Execute() + }() + + matches := []struct { + match string + write string + }{ + {match: "Create and upload", write: "yes"}, + {match: "Confirm create?", write: "yes"}, + } + for _, m := range matches { + pty.ExpectMatch(m.match) + pty.WriteLine(m.write) + } + + require.NoError(t, <-execDone) + removeTmpDirUntilSuccess(t, tempDir) + }) + + t.Run("WithParameterFileNotContainingTheValue", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + coderdtest.CreateFirstUser(t, client) + source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ + Parse: createTestParseResponse(), + Provision: echo.ProvisionComplete, + ProvisionDryRun: echo.ProvisionComplete, + }) + tempDir := t.TempDir() + parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") + _, _ = parameterFile.WriteString("zone: \"bananas\"") + cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--parameter-file", parameterFile.Name()) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + + execDone := make(chan error) + go func() { + execDone <- cmd.Execute() + }() + + matches := []struct { + match string + write string + }{ + {match: "Create and upload", write: "yes"}, + } + for _, m := range matches { + pty.ExpectMatch(m.match) + pty.WriteLine(m.write) + } + + require.EqualError(t, <-execDone, "Parameter value absent in parameter file for \"region\"!") + removeTmpDirUntilSuccess(t, tempDir) + }) +} + +func createTestParseResponse() []*proto.Parse_Response { + return []*proto.Parse_Response{{ + Type: &proto.Parse_Response_Complete{ + Complete: &proto.Parse_Complete{ + ParameterSchemas: []*proto.ParameterSchema{{ + AllowOverrideSource: true, + Name: "region", + Description: "description", + DefaultDestination: &proto.ParameterDestination{ + Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE, + }, + }}, + }, + }, + }} +} + +// Need this for Windows because of a known issue with Go: +// https://github.com/golang/go/issues/52986 +func removeTmpDirUntilSuccess(t *testing.T, tempDir string) { + t.Helper() + t.Cleanup(func() { + err := os.RemoveAll(tempDir) + for err != nil { + err = os.RemoveAll(tempDir) + } + }) } diff --git a/go.mod b/go.mod index acc12fe967b8c..f33ea28421ca0 100644 --- a/go.mod +++ b/go.mod @@ -120,6 +120,7 @@ require ( google.golang.org/api v0.79.0 google.golang.org/protobuf v1.28.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 nhooyr.io/websocket v1.8.7 storj.io/drpc v0.0.30 @@ -250,5 +251,4 @@ require ( gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect )