diff --git a/cli/templatepull.go b/cli/templatepull.go new file mode 100644 index 0000000000000..61cc7e7b8e62b --- /dev/null +++ b/cli/templatepull.go @@ -0,0 +1,123 @@ +package cli + +import ( + "fmt" + "io/fs" + "os" + "sort" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" +) + +func templatePull() *cobra.Command { + cmd := &cobra.Command{ + Use: "pull [destination]", + Short: "Download the latest version of a template to a path.", + Args: cobra.MaximumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + var ( + ctx = cmd.Context() + templateName = args[0] + dest string + ) + + if len(args) > 1 { + dest = args[1] + } + + client, err := createClient(cmd) + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + + // TODO(JonA): Do we need to add a flag for organization? + organization, err := currentOrganization(cmd, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + template, err := client.TemplateByName(ctx, organization.ID, templateName) + if err != nil { + return xerrors.Errorf("template by name: %w", err) + } + + // Pull the versions for the template. We'll find the latest + // one and download the source. + versions, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{ + TemplateID: template.ID, + }) + if err != nil { + return xerrors.Errorf("template versions by template: %w", err) + } + + if len(versions) == 0 { + return xerrors.Errorf("no template versions for template %q", templateName) + } + + // Sort the slice from newest to oldest template. + sort.SliceStable(versions, func(i, j int) bool { + return versions[i].CreatedAt.After(versions[j].CreatedAt) + }) + + latest := versions[0] + + // Download the tar archive. + raw, ctype, err := client.Download(ctx, latest.Job.StorageSource) + if err != nil { + return xerrors.Errorf("download template: %w", err) + } + + if ctype != codersdk.ContentTypeTar { + return xerrors.Errorf("unexpected Content-Type %q, expecting %q", ctype, codersdk.ContentTypeTar) + } + + // If the destination is empty then we write to stdout + // and bail early. + if dest == "" { + _, err = cmd.OutOrStdout().Write(raw) + if err != nil { + return xerrors.Errorf("write stdout: %w", err) + } + return nil + } + + // Stat the destination to ensure nothing exists already. + fi, err := os.Stat(dest) + if err != nil && !xerrors.Is(err, fs.ErrNotExist) { + return xerrors.Errorf("stat destination: %w", err) + } + + if fi != nil && fi.IsDir() { + // If the destination is a directory we just bail. + return xerrors.Errorf("%q already exists.", dest) + } + + // If a file exists at the destination prompt the user + // to ensure we don't overwrite something valuable. + if fi != nil { + _, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: fmt.Sprintf("%q already exists, do you want to overwrite it?", dest), + IsConfirm: true, + }) + if err != nil { + return xerrors.Errorf("parse prompt: %w", err) + } + } + + err = os.WriteFile(dest, raw, 0600) + if err != nil { + return xerrors.Errorf("write to path: %w", err) + } + + return nil + }, + } + + cliui.AllowSkipPrompt(cmd) + + return cmd +} diff --git a/cli/templatepull_test.go b/cli/templatepull_test.go new file mode 100644 index 0000000000000..249733c8c043b --- /dev/null +++ b/cli/templatepull_test.go @@ -0,0 +1,143 @@ +package cli_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/pty/ptytest" +) + +func TestTemplatePull(t *testing.T) { + t.Parallel() + + // Stdout tests that 'templates pull' pulls down the latest template + // and writes it to stdout. + t.Run("Stdout", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Create an initial template bundle. + source1 := genTemplateVersionSource() + // Create an updated template bundle. This will be used to ensure + // that templates are correctly returned in order from latest to oldest. + source2 := genTemplateVersionSource() + + expected, err := echo.Tar(source2) + require.NoError(t, err) + + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) + + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + + // Update the template version so that we can assert that templates + // are being sorted correctly. + _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + + cmd, root := clitest.New(t, "templates", "pull", template.Name) + clitest.SetupConfig(t, client, root) + + var buf bytes.Buffer + cmd.SetOut(&buf) + + err = cmd.Execute() + require.NoError(t, err) + + require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ") + }) + + // ToFile tests that 'templates pull' pulls down the latest template + // and writes it to the correct directory. + t.Run("ToFile", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Create an initial template bundle. + source1 := genTemplateVersionSource() + // Create an updated template bundle. This will be used to ensure + // that templates are correctly returned in order from latest to oldest. + source2 := genTemplateVersionSource() + + expected, err := echo.Tar(source2) + require.NoError(t, err) + + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) + + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + + // Update the template version so that we can assert that templates + // are being sorted correctly. + _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + + dir := t.TempDir() + + dest := filepath.Join(dir, "actual.tar") + + // Create the file so that we can test that the command + // warns the user before overwriting a preexisting file. + fi, err := os.OpenFile(dest, os.O_CREATE|os.O_RDONLY, 0600) + require.NoError(t, err) + _ = fi.Close() + + cmd, root := clitest.New(t, "templates", "pull", template.Name, dest) + clitest.SetupConfig(t, client, root) + + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + + errChan := make(chan error) + go func() { + defer close(errChan) + errChan <- cmd.Execute() + }() + + // We expect to be prompted that a file already exists. + pty.ExpectMatch("already exists") + pty.WriteLine("yes") + + require.NoError(t, <-errChan) + + actual, err := os.ReadFile(dest) + require.NoError(t, err) + + require.True(t, bytes.Equal(actual, expected), "tar files differ") + }) +} + +// genTemplateVersionSource returns a unique bundle that can be used to create +// a template version source. +func genTemplateVersionSource() *echo.Responses { + return &echo.Responses{ + Parse: []*proto.Parse_Response{ + { + Type: &proto.Parse_Response_Log{ + Log: &proto.Log{ + Output: uuid.NewString(), + }, + }, + }, + + { + Type: &proto.Parse_Response_Complete{ + Complete: &proto.Parse_Complete{}, + }, + }, + }, + Provision: echo.ProvisionComplete, + } +} diff --git a/cli/templates.go b/cli/templates.go index c737bb6c134c0..e06e60ab33e93 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -33,6 +33,7 @@ func templates() *cobra.Command { templateUpdate(), templateVersions(), templateDelete(), + templatePull(), ) return cmd diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 325c2aaccb7d7..f10ba0358aa2c 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -287,9 +287,10 @@ func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) code func convertProvisionerJob(provisionerJob database.ProvisionerJob) codersdk.ProvisionerJob { job := codersdk.ProvisionerJob{ - ID: provisionerJob.ID, - CreatedAt: provisionerJob.CreatedAt, - Error: provisionerJob.Error.String, + ID: provisionerJob.ID, + CreatedAt: provisionerJob.CreatedAt, + Error: provisionerJob.Error.String, + StorageSource: provisionerJob.StorageSource, } // Applying values optional to the struct. if provisionerJob.StartedAt.Valid { diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index d9d232bb0ec0d..5027d30ab9d60 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -60,13 +60,14 @@ const ( ) type ProvisionerJob struct { - ID uuid.UUID `json:"id"` - CreatedAt time.Time `json:"created_at"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Error string `json:"error,omitempty"` - Status ProvisionerJobStatus `json:"status"` - WorkerID *uuid.UUID `json:"worker_id,omitempty"` + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Error string `json:"error,omitempty"` + Status ProvisionerJobStatus `json:"status"` + WorkerID *uuid.UUID `json:"worker_id,omitempty"` + StorageSource string `json:"storage_source"` } type ProvisionerJobLog struct { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 49ae60058a061..3a142b208cb53 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -211,9 +211,10 @@ export interface ProvisionerJob { readonly error?: string readonly status: ProvisionerJobStatus readonly worker_id?: string + readonly storage_source: string } -// From codersdk/provisionerdaemons.go:72:6 +// From codersdk/provisionerdaemons.go:73:6 export interface ProvisionerJobLog { readonly id: string readonly created_at: string diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index b37e038529dbc..1c8a513a3027e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -69,7 +69,9 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = { created_at: "", id: "test-provisioner-job", status: "succeeded", + storage_source: "asdf", } + export const MockFailedProvisionerJob: TypesGen.ProvisionerJob = { ...MockProvisionerJob, status: "failed",