diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 8c8ce9e034610..7ab36feb3e6de 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -9,7 +9,6 @@ import ( "time" "unicode/utf8" - "github.com/briandowns/spinner" "github.com/google/uuid" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -19,16 +18,16 @@ import ( "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisionerd" - "github.com/coder/coder/provisionersdk" ) func templateCreate() *cobra.Command { var ( - directory string provisioner string provisionerTags []string parameterFile string defaultTTL time.Duration + + uploadFlags templateUploadFlags ) cmd := &cobra.Command{ Use: "create [name]", @@ -45,11 +44,9 @@ func templateCreate() *cobra.Command { return err } - var templateName string - if len(args) == 0 { - templateName = filepath.Base(directory) - } else { - templateName = args[0] + templateName, err := uploadFlags.templateName(args) + if err != nil { + return err } if utf8.RuneCountInString(templateName) > 31 { @@ -62,31 +59,10 @@ func templateCreate() *cobra.Command { } // Confirm upload of the directory. - prettyDir := prettyDirectoryPath(directory) - _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: fmt.Sprintf("Create and upload %q?", prettyDir), - IsConfirm: true, - Default: cliui.ConfirmYes, - }) - if err != nil { - return err - } - - spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond) - spin.Writer = cmd.OutOrStdout() - spin.Suffix = cliui.Styles.Keyword.Render(" Uploading directory...") - spin.Start() - defer spin.Stop() - archive, err := provisionersdk.Tar(directory, provisionersdk.TemplateArchiveLimit) - if err != nil { - return err - } - - resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, archive) + resp, err := uploadFlags.upload(cmd, client) if err != nil { return err } - spin.Stop() tags, err := ParseProvisionerTags(provisionerTags) if err != nil { @@ -105,12 +81,14 @@ func templateCreate() *cobra.Command { return err } - _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: "Confirm create?", - IsConfirm: true, - }) - if err != nil { - return err + if !uploadFlags.stdin() { + _, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Confirm create?", + IsConfirm: true, + }) + if err != nil { + return err + } } createReq := codersdk.CreateTemplateRequest{ @@ -134,12 +112,11 @@ func templateCreate() *cobra.Command { return nil }, } - 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.") cmd.Flags().StringArrayVarP(&provisionerTags, "provisioner-tag", "", []string{}, "Specify a set of tags to target provisioner daemons.") cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 24*time.Hour, "Specify a default TTL for workspaces created from this template.") + uploadFlags.register(cmd.Flags()) + cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend") // This is for testing! err := cmd.Flags().MarkHidden("test.provisioner") if err != nil { diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index d9ed25ebdb99c..e44c8c7e2a2ce 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "bytes" "os" "testing" @@ -69,7 +70,7 @@ func TestTemplateCreate(t *testing.T) { match string write string }{ - {match: "Create and upload", write: "yes"}, + {match: "Upload", write: "yes"}, {match: "compute.main"}, {match: "smith (linux, i386)"}, {match: "Confirm create?", write: "yes"}, @@ -84,6 +85,38 @@ func TestTemplateCreate(t *testing.T) { require.NoError(t, <-execDone) }) + t.Run("CreateStdin", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + coderdtest.CreateFirstUser(t, client) + source, err := echo.Tar(&echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: provisionCompleteWithAgent, + }) + require.NoError(t, err) + + args := []string{ + "templates", + "create", + "my-template", + "--directory", "-", + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--default-ttl", "24h", + } + cmd, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(bytes.NewReader(source)) + cmd.SetOut(pty.Output()) + + execDone := make(chan error) + go func() { + execDone <- cmd.Execute() + }() + + require.NoError(t, <-execDone) + }) + t.Run("WithParameter", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) @@ -108,7 +141,7 @@ func TestTemplateCreate(t *testing.T) { match string write string }{ - {match: "Create and upload", write: "yes"}, + {match: "Upload", write: "yes"}, {match: "Enter a value:", write: "bananas"}, {match: "Confirm create?", write: "yes"}, } @@ -148,7 +181,7 @@ func TestTemplateCreate(t *testing.T) { match string write string }{ - {match: "Create and upload", write: "yes"}, + {match: "Upload", write: "yes"}, {match: "Confirm create?", write: "yes"}, } for _, m := range matches { @@ -188,7 +221,7 @@ func TestTemplateCreate(t *testing.T) { write string }{ { - match: "Create and upload", + match: "Upload", write: "yes", }, { diff --git a/cli/templatepush.go b/cli/templatepush.go index 9c146a9b8cbcf..3ba612d7e674f 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -2,12 +2,14 @@ package cli import ( "fmt" + "io" "os" "path/filepath" "time" "github.com/briandowns/spinner" "github.com/spf13/cobra" + "github.com/spf13/pflag" "golang.org/x/xerrors" "github.com/coder/coder/cli/cliui" @@ -16,14 +18,81 @@ import ( "github.com/coder/coder/provisionersdk" ) +// templateUploadFlags is shared by `templates create` and `templates push`. +type templateUploadFlags struct { + directory string +} + +func (pf *templateUploadFlags) register(f *pflag.FlagSet) { + currentDirectory, _ := os.Getwd() + f.StringVarP(&pf.directory, "directory", "d", currentDirectory, "Specify the directory to create from, use '-' to read tar from stdin") +} + +func (pf *templateUploadFlags) stdin() bool { + return pf.directory == "-" +} + +func (pf *templateUploadFlags) upload(cmd *cobra.Command, client *codersdk.Client) (*codersdk.UploadResponse, error) { + var ( + content []byte + err error + ) + if pf.stdin() { + content, err = io.ReadAll(cmd.InOrStdin()) + } else { + prettyDir := prettyDirectoryPath(pf.directory) + _, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: fmt.Sprintf("Upload %q?", prettyDir), + IsConfirm: true, + Default: cliui.ConfirmYes, + }) + if err != nil { + return nil, err + } + + content, err = provisionersdk.Tar(pf.directory, provisionersdk.TemplateArchiveLimit) + } + if err != nil { + return nil, xerrors.Errorf("read tar: %w", err) + } + + spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond) + spin.Writer = cmd.OutOrStdout() + spin.Suffix = cliui.Styles.Keyword.Render(" Uploading directory...") + spin.Start() + defer spin.Stop() + + resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, content) + if err != nil { + return nil, xerrors.Errorf("upload: %w", err) + } + return &resp, nil +} + +func (pf *templateUploadFlags) templateName(args []string) (string, error) { + if pf.stdin() { + // Can't infer name from directory if none provided. + if len(args) == 0 { + return "", xerrors.New("template name argument must be provided") + } + return args[0], nil + } + + name := filepath.Base(pf.directory) + if len(args) > 0 { + name = args[0] + } + return name, nil +} + func templatePush() *cobra.Command { var ( - directory string versionName string provisioner string parameterFile string alwaysPrompt bool provisionerTags []string + uploadFlags templateUploadFlags ) cmd := &cobra.Command{ @@ -40,41 +109,20 @@ func templatePush() *cobra.Command { return err } - name := filepath.Base(directory) - if len(args) > 0 { - name = args[0] - } - - template, err := client.TemplateByName(cmd.Context(), organization.ID, name) + name, err := uploadFlags.templateName(args) if err != nil { return err } - // Confirm upload of the directory. - prettyDir := prettyDirectoryPath(directory) - _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: fmt.Sprintf("Upload %q?", prettyDir), - IsConfirm: true, - Default: cliui.ConfirmYes, - }) + template, err := client.TemplateByName(cmd.Context(), organization.ID, name) if err != nil { return err } - spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond) - spin.Writer = cmd.OutOrStdout() - spin.Suffix = cliui.Styles.Keyword.Render(" Uploading directory...") - spin.Start() - defer spin.Stop() - content, err := provisionersdk.Tar(directory, provisionersdk.TemplateArchiveLimit) + resp, err := uploadFlags.upload(cmd, client) if err != nil { return err } - resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, content) - if err != nil { - return err - } - spin.Stop() tags, err := ParseProvisionerTags(provisionerTags) if err != nil { @@ -112,13 +160,12 @@ func templatePush() *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.") cmd.Flags().StringVarP(&versionName, "name", "", "", "Specify a name for the new template version. It will be automatically generated if not provided.") cmd.Flags().StringArrayVarP(&provisionerTags, "provisioner-tag", "", []string{}, "Specify a set of tags to target provisioner daemons.") cmd.Flags().BoolVar(&alwaysPrompt, "always-prompt", false, "Always prompt all parameters. Does not pull parameter values from active template version") + uploadFlags.register(cmd.Flags()) cliui.AllowSkipPrompt(cmd) // This is for testing! err := cmd.Flags().MarkHidden("test.provisioner") diff --git a/cli/templatepush_test.go b/cli/templatepush_test.go index 4aa7867c0df8b..0ade770324813 100644 --- a/cli/templatepush_test.go +++ b/cli/templatepush_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "bytes" "context" "path/filepath" "testing" @@ -208,6 +209,47 @@ func TestTemplatePush(t *testing.T) { assert.Len(t, templateVersions, 2) assert.NotEqual(t, template.ActiveVersionID, templateVersions[1].ID) }) + + t.Run("Stdin", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + source, err := echo.Tar(&echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ProvisionComplete, + }) + require.NoError(t, err) + + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + cmd, root := clitest.New( + t, "templates", "push", "--directory", "-", + "--test.provisioner", string(database.ProvisionerTypeEcho), + template.Name, + ) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(bytes.NewReader(source)) + cmd.SetOut(pty.Output()) + + execDone := make(chan error) + go func() { + execDone <- cmd.Execute() + }() + + require.NoError(t, <-execDone) + + // Assert that the template version changed. + templateVersions, err := client.TemplateVersionsByTemplate(context.Background(), codersdk.TemplateVersionsByTemplateRequest{ + TemplateID: template.ID, + }) + require.NoError(t, err) + assert.Len(t, templateVersions, 2) + assert.NotEqual(t, template.ActiveVersionID, templateVersions[1].ID) + }) } func latestTemplateVersion(t *testing.T, client *codersdk.Client, templateID uuid.UUID) (codersdk.TemplateVersion, []codersdk.Parameter) { diff --git a/cli/testdata/coder_templates_create_--help.golden b/cli/testdata/coder_templates_create_--help.golden index 7fd1d9121d58f..aebabdd55d1f5 100644 --- a/cli/testdata/coder_templates_create_--help.golden +++ b/cli/testdata/coder_templates_create_--help.golden @@ -6,8 +6,8 @@ Usage: Flags: --default-ttl duration Specify a default TTL for workspaces created from this template. (default 24h0m0s) - -d, --directory string Specify the directory to create from (default - "/tmp/coder-cli-test-workdir") + -d, --directory string Specify the directory to create from, use '-' to read + tar from stdin (default "/tmp/coder-cli-test-workdir") -h, --help help for create --parameter-file string Specify a file path with parameter values. --provisioner-tag stringArray Specify a set of tags to target provisioner daemons. diff --git a/cli/testdata/coder_templates_push_--help.golden b/cli/testdata/coder_templates_push_--help.golden index 348adaf179a92..baf55609e7f17 100644 --- a/cli/testdata/coder_templates_push_--help.golden +++ b/cli/testdata/coder_templates_push_--help.golden @@ -6,8 +6,8 @@ Usage: Flags: --always-prompt Always prompt all parameters. Does not pull parameter values from active template version - -d, --directory string Specify the directory to create from (default - "/tmp/coder-cli-test-workdir") + -d, --directory string Specify the directory to create from, use '-' to read + tar from stdin (default "/tmp/coder-cli-test-workdir") -h, --help help for push --name string Specify a name for the new template version. It will be automatically generated if not provided. diff --git a/provisioner/echo/serve.go b/provisioner/echo/serve.go index 6684b92c6ee94..705fb0a3be2ae 100644 --- a/provisioner/echo/serve.go +++ b/provisioner/echo/serve.go @@ -88,7 +88,7 @@ func (e *echo) Parse(request *proto.Parse_Request, stream proto.DRPCProvisioner_ if err != nil { if index == 0 { // Error if nothing is around to enable failed states. - return xerrors.New("no state") + return xerrors.Errorf("no state: %w", err) } break }