diff --git a/cli/templatedelete.go b/cli/templatedelete.go index 6cb4213a93..e15fe4bd48 100644 --- a/cli/templatedelete.go +++ b/cli/templatedelete.go @@ -48,33 +48,13 @@ func (r *RootCmd) templateDelete() *clibase.Cmd { templates = append(templates, template) } } else { - allTemplates, err := client.TemplatesByOrganization(ctx, organization.ID) + template, err := selectTemplate(inv, client, organization) if err != nil { - return xerrors.Errorf("get templates by organization: %w", err) + return err } - if len(allTemplates) == 0 { - return xerrors.Errorf("no templates exist in the current organization %q", organization.Name) - } - - opts := make([]string, 0, len(allTemplates)) - for _, template := range allTemplates { - opts = append(opts, template.Name) - } - - selection, err := cliui.Select(inv, cliui.SelectOptions{ - Options: opts, - }) - if err != nil { - return xerrors.Errorf("select template: %w", err) - } - - for _, template := range allTemplates { - if template.Name == selection { - templates = append(templates, template) - templateNames = append(templateNames, template.Name) - } - } + templates = append(templates, template) + templateNames = append(templateNames, template.Name) } // Confirm deletion of the template. diff --git a/cli/templates.go b/cli/templates.go index 3d24ec14b5..d7cd02a467 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -3,9 +3,9 @@ package cli import ( "time" - "github.com/google/uuid" - "github.com/coder/pretty" + "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" @@ -43,12 +43,45 @@ func (r *RootCmd) templates() *clibase.Cmd { r.templateVersions(), r.templateDelete(), r.templatePull(), + r.archiveTemplateVersions(), }, } return cmd } +func selectTemplate(inv *clibase.Invocation, client *codersdk.Client, organization codersdk.Organization) (codersdk.Template, error) { + var empty codersdk.Template + ctx := inv.Context() + allTemplates, err := client.TemplatesByOrganization(ctx, organization.ID) + if err != nil { + return empty, xerrors.Errorf("get templates by organization: %w", err) + } + + if len(allTemplates) == 0 { + return empty, xerrors.Errorf("no templates exist in the current organization %q", organization.Name) + } + + opts := make([]string, 0, len(allTemplates)) + for _, template := range allTemplates { + opts = append(opts, template.Name) + } + + selection, err := cliui.Select(inv, cliui.SelectOptions{ + Options: opts, + }) + if err != nil { + return empty, xerrors.Errorf("select template: %w", err) + } + + for _, template := range allTemplates { + if template.Name == selection { + return template, nil + } + } + return empty, xerrors.Errorf("no template selected") +} + type templateTableRow struct { // Used by json format: Template codersdk.Template diff --git a/cli/templateversionarchive.go b/cli/templateversionarchive.go new file mode 100644 index 0000000000..9ffcb5f50a --- /dev/null +++ b/cli/templateversionarchive.go @@ -0,0 +1,184 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/coder/pretty" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" +) + +func (r *RootCmd) unarchiveTemplateVersion() *clibase.Cmd { + return r.setArchiveTemplateVersion(false) +} + +func (r *RootCmd) archiveTemplateVersion() *clibase.Cmd { + return r.setArchiveTemplateVersion(true) +} + +//nolint:revive +func (r *RootCmd) setArchiveTemplateVersion(archive bool) *clibase.Cmd { + presentVerb := "archive" + pastVerb := "archived" + if !archive { + presentVerb = "unarchive" + pastVerb = "unarchived" + } + + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Use: presentVerb + " [template-version-names...] ", + Short: strings.ToUpper(string(presentVerb[0])) + presentVerb[1:] + " a template version(s).", + Middleware: clibase.Chain( + r.InitClient(client), + ), + Options: clibase.OptionSet{ + cliui.SkipPromptOption(), + }, + Handler: func(inv *clibase.Invocation) error { + var ( + ctx = inv.Context() + versions []codersdk.TemplateVersion + ) + + organization, err := CurrentOrganization(inv, client) + if err != nil { + return err + } + + if len(inv.Args) == 0 { + return xerrors.Errorf("missing template name") + } + if len(inv.Args) < 2 { + return xerrors.Errorf("missing template version name(s)") + } + + templateName := inv.Args[0] + template, err := client.TemplateByName(ctx, organization.ID, templateName) + if err != nil { + return xerrors.Errorf("get template by name: %w", err) + } + for _, versionName := range inv.Args[1:] { + version, err := client.TemplateVersionByOrganizationAndName(ctx, organization.ID, template.Name, versionName) + if err != nil { + return xerrors.Errorf("get template version by name %q: %w", versionName, err) + } + versions = append(versions, version) + } + + for _, version := range versions { + if version.Archived == archive { + _, _ = fmt.Fprintln( + inv.Stdout, fmt.Sprintf("Version "+pretty.Sprint(cliui.DefaultStyles.Keyword, version.Name)+" already "+pastVerb), + ) + continue + } + + err := client.SetArchiveTemplateVersion(ctx, version.ID, archive) + if err != nil { + return xerrors.Errorf("%s template version %q: %w", presentVerb, version.Name, err) + } + + _, _ = fmt.Fprintln( + inv.Stdout, fmt.Sprintf("Version "+pretty.Sprint(cliui.DefaultStyles.Keyword, version.Name)+" "+pastVerb+" at "+cliui.Timestamp(time.Now())), + ) + } + return nil + }, + } + + return cmd +} + +func (r *RootCmd) archiveTemplateVersions() *clibase.Cmd { + var all clibase.Bool + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Use: "archive [template-name...] ", + Short: "Archive unused or failed template versions from a given template(s)", + Middleware: clibase.Chain( + r.InitClient(client), + ), + Options: clibase.OptionSet{ + cliui.SkipPromptOption(), + clibase.Option{ + Name: "all", + Description: "Include all unused template versions. By default, only failed template versions are archived.", + Flag: "all", + Value: &all, + }, + }, + Handler: func(inv *clibase.Invocation) error { + var ( + ctx = inv.Context() + templateNames = []string{} + templates = []codersdk.Template{} + ) + + organization, err := CurrentOrganization(inv, client) + if err != nil { + return err + } + + if len(inv.Args) > 0 { + templateNames = inv.Args + + for _, templateName := range templateNames { + template, err := client.TemplateByName(ctx, organization.ID, templateName) + if err != nil { + return xerrors.Errorf("get template by name: %w", err) + } + templates = append(templates, template) + } + } else { + template, err := selectTemplate(inv, client, organization) + if err != nil { + return err + } + + templates = append(templates, template) + templateNames = append(templateNames, template.Name) + } + + // Confirm archive of the template. + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: fmt.Sprintf("Archive template versions of these templates: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(templateNames, ", "))), + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + for _, template := range templates { + resp, err := client.ArchiveTemplateVersions(ctx, template.ID, all.Value()) + if err != nil { + return xerrors.Errorf("archive template %q: %w", template.Name, err) + } + + _, _ = fmt.Fprintln( + inv.Stdout, fmt.Sprintf("Archived %d versions from "+pretty.Sprint(cliui.DefaultStyles.Keyword, template.Name)+" at "+cliui.Timestamp(time.Now()), len(resp.ArchivedIDs)), + ) + + if ok, _ := inv.ParsedFlags().GetBool("verbose"); err == nil && ok { + data, err := json.Marshal(resp) + if err != nil { + return xerrors.Errorf("marshal verbose response: %w", err) + } + _, _ = fmt.Fprintln( + inv.Stdout, string(data), + ) + } + } + return nil + }, + } + + return cmd +} diff --git a/cli/templateversionarchive_test.go b/cli/templateversionarchive_test.go new file mode 100644 index 0000000000..02fb72a6b7 --- /dev/null +++ b/cli/templateversionarchive_test.go @@ -0,0 +1,108 @@ +package cli_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/testutil" +) + +func TestTemplateVersionsArchive(t *testing.T) { + t.Parallel() + t.Run("Archive-Unarchive", func(t *testing.T) { + t.Parallel() + ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + + client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + other := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(request *codersdk.CreateTemplateVersionRequest) { + request.TemplateID = template.ID + }) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, other.ID) + + // Archive + inv, root := clitest.New(t, "templates", "versions", "archive", template.Name, other.Name, "-y") + clitest.SetupConfig(t, client, root) + w := clitest.StartWithWaiter(t, inv) + w.RequireSuccess() + + // Verify archived + ctx := testutil.Context(t, testutil.WaitMedium) + found, err := client.TemplateVersion(ctx, other.ID) + require.NoError(t, err) + require.True(t, found.Archived, "expect archived") + + // Unarchive + inv, root = clitest.New(t, "templates", "versions", "unarchive", template.Name, other.Name, "-y") + clitest.SetupConfig(t, client, root) + w = clitest.StartWithWaiter(t, inv) + w.RequireSuccess() + + // Verify unarchived + ctx = testutil.Context(t, testutil.WaitMedium) + found, err = client.TemplateVersion(ctx, other.ID) + require.NoError(t, err) + require.False(t, found.Archived, "expect unarchived") + }) + + t.Run("ArchiveMany", func(t *testing.T) { + t.Parallel() + ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + + client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Add a failed + expArchived := map[uuid.UUID]bool{} + failed := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyFailed, + ProvisionPlan: echo.PlanFailed, + }, func(request *codersdk.CreateTemplateVersionRequest) { + request.TemplateID = template.ID + }) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, failed.ID) + expArchived[failed.ID] = true + // Add unused + unused := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(request *codersdk.CreateTemplateVersionRequest) { + request.TemplateID = template.ID + }) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, unused.ID) + expArchived[unused.ID] = true + + // Archive all unused versions + inv, root := clitest.New(t, "templates", "archive", template.Name, "-y", "--all") + clitest.SetupConfig(t, client, root) + w := clitest.StartWithWaiter(t, inv) + w.RequireSuccess() + + ctx := testutil.Context(t, testutil.WaitMedium) + all, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{ + TemplateID: template.ID, + IncludeArchived: true, + }) + require.NoError(t, err, "query all versions") + for _, v := range all { + if _, ok := expArchived[v.ID]; ok { + require.True(t, v.Archived, "expect archived") + delete(expArchived, v.ID) + } else { + require.False(t, v.Archived, "expect unarchived") + } + } + require.Len(t, expArchived, 0, "expect all archived") + }) +} diff --git a/cli/templateversions.go b/cli/templateversions.go index 299ae98e96..cc3b31277a 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/coder/pretty" "github.com/google/uuid" "golang.org/x/xerrors" @@ -29,6 +30,8 @@ func (r *RootCmd) templateVersions() *clibase.Cmd { }, Children: []*clibase.Cmd{ r.templateVersionsList(), + r.archiveTemplateVersion(), + r.unarchiveTemplateVersion(), }, } @@ -36,19 +39,59 @@ func (r *RootCmd) templateVersions() *clibase.Cmd { } func (r *RootCmd) templateVersionsList() *clibase.Cmd { + defaultColumns := []string{ + "Name", + "Created At", + "Created By", + "Status", + "Active", + } formatter := cliui.NewOutputFormatter( - cliui.TableFormat([]templateVersionRow{}, nil), + cliui.TableFormat([]templateVersionRow{}, defaultColumns), cliui.JSONFormat(), ) client := new(codersdk.Client) + var includeArchived clibase.Bool + cmd := &clibase.Cmd{ Use: "list