diff --git a/cli/provisionerjobs.go b/cli/provisionerjobs.go new file mode 100644 index 0000000000000..b3e0a861ba4d3 --- /dev/null +++ b/cli/provisionerjobs.go @@ -0,0 +1,126 @@ +package cli + +import ( + "fmt" + "slices" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) provisionerJobs() *serpent.Command { + cmd := &serpent.Command{ + Use: "jobs", + Short: "View and manage provisioner jobs", + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Aliases: []string{"job"}, + Children: []*serpent.Command{ + r.provisionerJobsList(), + }, + } + return cmd +} + +func (r *RootCmd) provisionerJobsList() *serpent.Command { + type provisionerJobRow struct { + codersdk.ProvisionerJob `table:"provisioner_job,recursive_inline,nosort"` + OrganizationName string `json:"organization_name" table:"organization"` + Queue string `json:"-" table:"queue"` + } + + var ( + client = new(codersdk.Client) + orgContext = NewOrganizationContext() + formatter = cliui.NewOutputFormatter( + cliui.TableFormat([]provisionerJobRow{}, []string{"created at", "id", "organization", "status", "type", "queue", "tags"}), + cliui.JSONFormat(), + ) + status []string + limit int64 + ) + + cmd := &serpent.Command{ + Use: "list", + Short: "List provisioner jobs", + Aliases: []string{"ls"}, + Middleware: serpent.Chain( + serpent.RequireNArgs(0), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + org, err := orgContext.Selected(inv, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + jobs, err := client.OrganizationProvisionerJobs(ctx, org.ID, &codersdk.OrganizationProvisionerJobsOptions{ + Status: slice.StringEnums[codersdk.ProvisionerJobStatus](status), + Limit: int(limit), + }) + if err != nil { + return xerrors.Errorf("list provisioner jobs: %w", err) + } + + if len(jobs) == 0 { + _, _ = fmt.Fprintln(inv.Stdout, "No provisioner jobs found") + return nil + } + + var rows []provisionerJobRow + for _, job := range jobs { + row := provisionerJobRow{ + ProvisionerJob: job, + OrganizationName: org.HumanName(), + } + if job.Status == codersdk.ProvisionerJobPending { + row.Queue = fmt.Sprintf("%d/%d", job.QueuePosition, job.QueueSize) + } + rows = append(rows, row) + } + // Sort manually because the cliui table truncates timestamps and + // produces an unstable sort with timestamps that are all the same. + slices.SortStableFunc(rows, func(a provisionerJobRow, b provisionerJobRow) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + + out, err := formatter.Format(ctx, rows) + if err != nil { + return xerrors.Errorf("display provisioner daemons: %w", err) + } + + _, _ = fmt.Fprintln(inv.Stdout, out) + + return nil + }, + } + + cmd.Options = append(cmd.Options, []serpent.Option{ + { + Flag: "status", + FlagShorthand: "s", + Env: "CODER_PROVISIONER_JOB_LIST_STATUS", + Description: "Filter by job status.", + Value: serpent.EnumArrayOf(&status, slice.ToStrings(codersdk.ProvisionerJobStatusEnums())...), + }, + { + Flag: "limit", + FlagShorthand: "l", + Env: "CODER_PROVISIONER_JOB_LIST_LIMIT", + Description: "Limit the number of jobs returned.", + Default: "50", + Value: serpent.Int64Of(&limit), + }, + }...) + + orgContext.AttachOptions(cmd) + formatter.AttachOptions(&cmd.Options) + + return cmd +} diff --git a/cli/provisioners.go b/cli/provisioners.go new file mode 100644 index 0000000000000..08d96493b87aa --- /dev/null +++ b/cli/provisioners.go @@ -0,0 +1,93 @@ +package cli + +import ( + "fmt" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) Provisioners() *serpent.Command { + cmd := &serpent.Command{ + Use: "provisioner", + Short: "View and manage provisioner daemons and jobs", + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Aliases: []string{"provisioners"}, + Children: []*serpent.Command{ + r.provisionerList(), + r.provisionerJobs(), + }, + } + + return cmd +} + +func (r *RootCmd) provisionerList() *serpent.Command { + type provisionerDaemonRow struct { + codersdk.ProvisionerDaemon `table:"provisioner_daemon,recursive_inline"` + OrganizationName string `json:"organization_name" table:"organization"` + } + var ( + client = new(codersdk.Client) + orgContext = NewOrganizationContext() + formatter = cliui.NewOutputFormatter( + cliui.TableFormat([]provisionerDaemonRow{}, []string{"name", "organization", "status", "key name", "created at", "last seen at", "version", "tags"}), + cliui.JSONFormat(), + ) + ) + + cmd := &serpent.Command{ + Use: "list", + Short: "List provisioner daemons in an organization", + Aliases: []string{"ls"}, + Middleware: serpent.Chain( + serpent.RequireNArgs(0), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + + org, err := orgContext.Selected(inv, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + daemons, err := client.OrganizationProvisionerDaemons(ctx, org.ID, nil) + if err != nil { + return xerrors.Errorf("list provisioner daemons: %w", err) + } + + if len(daemons) == 0 { + _, _ = fmt.Fprintln(inv.Stdout, "No provisioner daemons found") + return nil + } + + var rows []provisionerDaemonRow + for _, daemon := range daemons { + rows = append(rows, provisionerDaemonRow{ + ProvisionerDaemon: daemon, + OrganizationName: org.HumanName(), + }) + } + + out, err := formatter.Format(ctx, rows) + if err != nil { + return xerrors.Errorf("display provisioner daemons: %w", err) + } + + _, _ = fmt.Fprintln(inv.Stdout, out) + + return nil + }, + } + + orgContext.AttachOptions(cmd) + formatter.AttachOptions(&cmd.Options) + + return cmd +} diff --git a/cli/provisioners_test.go b/cli/provisioners_test.go new file mode 100644 index 0000000000000..54e9f03851855 --- /dev/null +++ b/cli/provisioners_test.go @@ -0,0 +1,218 @@ +package cli_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "slices" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" +) + +func TestProvisioners_Golden(t *testing.T) { + t.Parallel() + + // Replace UUIDs with predictable values for golden files. + replace := make(map[string]string) + updateReplaceUUIDs := func(coderdAPI *coderd.API) { + //nolint:gocritic // This is a test. + systemCtx := dbauthz.AsSystemRestricted(context.Background()) + provisioners, err := coderdAPI.Database.GetProvisionerDaemons(systemCtx) + require.NoError(t, err) + slices.SortFunc(provisioners, func(a, b database.ProvisionerDaemon) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + pIdx := 0 + for _, p := range provisioners { + if _, ok := replace[p.ID.String()]; !ok { + replace[p.ID.String()] = fmt.Sprintf("00000000-0000-0000-aaaa-%012d", pIdx) + pIdx++ + } + } + jobs, err := coderdAPI.Database.GetProvisionerJobsCreatedAfter(systemCtx, time.Time{}) + require.NoError(t, err) + slices.SortFunc(jobs, func(a, b database.ProvisionerJob) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + jIdx := 0 + for _, j := range jobs { + if _, ok := replace[j.ID.String()]; !ok { + replace[j.ID.String()] = fmt.Sprintf("00000000-0000-0000-bbbb-%012d", jIdx) + jIdx++ + } + } + } + + db, ps := dbtestutil.NewDB(t, + dbtestutil.WithDumpOnFailure(), + //nolint:gocritic // Use UTC for consistent timestamp length in golden files. + dbtestutil.WithTimezone("UTC"), + ) + client, _, coderdAPI := coderdtest.NewWithAPI(t, &coderdtest.Options{ + IncludeProvisionerDaemon: false, + Database: db, + Pubsub: ps, + }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID)) + memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Create initial resources with a running provisioner. + firstProvisioner := coderdtest.NewProvisionerDaemon(t, coderdAPI) + t.Cleanup(func() { _ = firstProvisioner.Close() }) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Stop the provisioner so it doesn't grab any more jobs. + firstProvisioner.Close() + + // Sanitize the UUIDs for the initial resources. + replace[version.ID.String()] = "00000000-0000-0000-cccc-000000000000" + replace[workspace.LatestBuild.ID.String()] = "00000000-0000-0000-dddd-000000000000" + + // Create a provisioner that's working on a job. + pd1 := dbgen.ProvisionerDaemon(t, coderdAPI.Database, database.ProvisionerDaemon{ + Name: "provisioner-1", + CreatedAt: dbtime.Now().Add(1 * time.Second), + KeyID: uuid.MustParse(codersdk.ProvisionerKeyIDBuiltIn), + Tags: database.StringMap{"owner": "", "scope": "organization", "foo": "bar"}, + }) + w1 := dbgen.Workspace(t, coderdAPI.Database, database.WorkspaceTable{ + OwnerID: member.ID, + TemplateID: template.ID, + }) + wb1ID := uuid.MustParse("00000000-0000-0000-dddd-000000000001") + job1 := dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{ + WorkerID: uuid.NullUUID{UUID: pd1.ID, Valid: true}, + Input: json.RawMessage(`{"workspace_build_id":"` + wb1ID.String() + `"}`), + CreatedAt: dbtime.Now().Add(2 * time.Second), + StartedAt: sql.NullTime{Time: coderdAPI.Clock.Now(), Valid: true}, + Tags: database.StringMap{"owner": "", "scope": "organization", "foo": "bar"}, + }) + dbgen.WorkspaceBuild(t, coderdAPI.Database, database.WorkspaceBuild{ + ID: wb1ID, + JobID: job1.ID, + WorkspaceID: w1.ID, + TemplateVersionID: version.ID, + }) + + // Create a provisioner that completed a job previously and is offline. + pd2 := dbgen.ProvisionerDaemon(t, coderdAPI.Database, database.ProvisionerDaemon{ + Name: "provisioner-2", + CreatedAt: dbtime.Now().Add(2 * time.Second), + LastSeenAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-time.Hour), Valid: true}, + KeyID: uuid.MustParse(codersdk.ProvisionerKeyIDBuiltIn), + Tags: database.StringMap{"owner": "", "scope": "organization"}, + }) + w2 := dbgen.Workspace(t, coderdAPI.Database, database.WorkspaceTable{ + OwnerID: member.ID, + TemplateID: template.ID, + }) + wb2ID := uuid.MustParse("00000000-0000-0000-dddd-000000000002") + job2 := dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{ + WorkerID: uuid.NullUUID{UUID: pd2.ID, Valid: true}, + Input: json.RawMessage(`{"workspace_build_id":"` + wb2ID.String() + `"}`), + CreatedAt: dbtime.Now().Add(3 * time.Second), + StartedAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-2 * time.Hour), Valid: true}, + CompletedAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-time.Hour), Valid: true}, + Tags: database.StringMap{"owner": "", "scope": "organization"}, + }) + dbgen.WorkspaceBuild(t, coderdAPI.Database, database.WorkspaceBuild{ + ID: wb2ID, + JobID: job2.ID, + WorkspaceID: w2.ID, + TemplateVersionID: version.ID, + }) + + // Create a pending job. + w3 := dbgen.Workspace(t, coderdAPI.Database, database.WorkspaceTable{ + OwnerID: member.ID, + TemplateID: template.ID, + }) + wb3ID := uuid.MustParse("00000000-0000-0000-dddd-000000000003") + job3 := dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{ + Input: json.RawMessage(`{"workspace_build_id":"` + wb3ID.String() + `"}`), + CreatedAt: dbtime.Now().Add(4 * time.Second), + Tags: database.StringMap{"owner": "", "scope": "organization"}, + }) + dbgen.WorkspaceBuild(t, coderdAPI.Database, database.WorkspaceBuild{ + ID: wb3ID, + JobID: job3.ID, + WorkspaceID: w3.ID, + TemplateVersionID: version.ID, + }) + + // Create a provisioner that is idle. + _ = dbgen.ProvisionerDaemon(t, coderdAPI.Database, database.ProvisionerDaemon{ + Name: "provisioner-3", + CreatedAt: dbtime.Now().Add(3 * time.Second), + KeyID: uuid.MustParse(codersdk.ProvisionerKeyIDBuiltIn), + Tags: database.StringMap{"owner": "", "scope": "organization"}, + }) + + updateReplaceUUIDs(coderdAPI) + + for id, replaceID := range replace { + t.Logf("replace[%q] = %q", id, replaceID) + } + + // Test provisioners list with member as members can access + // provisioner daemons. + t.Run("list", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "list", + "--column", "id,created at,last seen at,name,version,tags,key name,status,current job id,current job status,previous job id,previous job status,organization", + ) + inv.Stdout = &got + clitest.SetupConfig(t, memberClient, root) + err := inv.Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) + }) + + // Test jobs list with template admin as members are currently + // unable to access provisioner jobs. In the future (with RBAC + // changes), we may allow them to view _their_ jobs. + t.Run("jobs list", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "jobs", + "list", + "--column", "id,created at,status,worker id,tags,template version id,workspace build id,type,available workers,organization,queue", + ) + inv.Stdout = &got + clitest.SetupConfig(t, templateAdminClient, root) + err := inv.Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) + }) +} diff --git a/cli/root.go b/cli/root.go index 3f674db6d2bb5..778cf2c24215f 100644 --- a/cli/root.go +++ b/cli/root.go @@ -132,7 +132,11 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { } func (r *RootCmd) AGPL() []*serpent.Command { - all := append(r.CoreSubcommands(), r.Server( /* Do not import coderd here. */ nil)) + all := append( + r.CoreSubcommands(), + r.Server( /* Do not import coderd here. */ nil), + r.Provisioners(), + ) return all } diff --git a/cli/root_test.go b/cli/root_test.go index 897aea18fec3e..ac1454152672e 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -55,6 +55,22 @@ func TestCommandHelp(t *testing.T) { Name: "coder users list", Cmd: []string{"users", "list"}, }, + clitest.CommandHelpCase{ + Name: "coder provisioner list", + Cmd: []string{"provisioner", "list"}, + }, + clitest.CommandHelpCase{ + Name: "coder provisioner list --output json", + Cmd: []string{"provisioner", "list", "--output", "json"}, + }, + clitest.CommandHelpCase{ + Name: "coder provisioner jobs list", + Cmd: []string{"provisioner", "jobs", "list"}, + }, + clitest.CommandHelpCase{ + Name: "coder provisioner jobs list --output json", + Cmd: []string{"provisioner", "jobs", "list", "--output", "json"}, + }, )) } diff --git a/cli/testdata/TestProvisioners_Golden/jobs_list.golden b/cli/testdata/TestProvisioners_Golden/jobs_list.golden new file mode 100644 index 0000000000000..3f446de71db35 --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/jobs_list.golden @@ -0,0 +1,6 @@ +ID CREATED AT STATUS WORKER ID TAGS TEMPLATE VERSION ID WORKSPACE BUILD ID TYPE AVAILABLE WORKERS ORGANIZATION QUEUE +00000000-0000-0000-bbbb-000000000000 ====[timestamp]===== succeeded 00000000-0000-0000-aaaa-000000000000 map[owner: scope:organization] 00000000-0000-0000-cccc-000000000000 template_version_import [] Coder +00000000-0000-0000-bbbb-000000000001 ====[timestamp]===== succeeded 00000000-0000-0000-aaaa-000000000000 map[owner: scope:organization] 00000000-0000-0000-dddd-000000000000 workspace_build [] Coder +00000000-0000-0000-bbbb-000000000002 ====[timestamp]===== running 00000000-0000-0000-aaaa-000000000001 map[00000000-0000-0000-bbbb-000000000002:true foo:bar owner: scope:organization] 00000000-0000-0000-dddd-000000000001 workspace_build [] Coder +00000000-0000-0000-bbbb-000000000003 ====[timestamp]===== succeeded 00000000-0000-0000-aaaa-000000000002 map[00000000-0000-0000-bbbb-000000000003:true owner: scope:organization] 00000000-0000-0000-dddd-000000000002 workspace_build [] Coder +00000000-0000-0000-bbbb-000000000004 ====[timestamp]===== pending map[owner: scope:organization] 00000000-0000-0000-dddd-000000000003 workspace_build [00000000-0000-0000-aaaa-000000000000, 00000000-0000-0000-aaaa-000000000002, 00000000-0000-0000-aaaa-000000000003] Coder 1/1 diff --git a/cli/testdata/TestProvisioners_Golden/list.golden b/cli/testdata/TestProvisioners_Golden/list.golden new file mode 100644 index 0000000000000..be18fcd491e3b --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list.golden @@ -0,0 +1,5 @@ +ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION +00000000-0000-0000-aaaa-000000000001 ====[timestamp]===== ====[timestamp]===== provisioner-1 v0.0.0 map[foo:bar owner: scope:organization] built-in busy 00000000-0000-0000-bbbb-000000000002 running Coder +00000000-0000-0000-aaaa-000000000002 ====[timestamp]===== ====[timestamp]===== provisioner-2 v0.0.0 map[owner: scope:organization] built-in offline 00000000-0000-0000-bbbb-000000000003 succeeded Coder +00000000-0000-0000-aaaa-000000000003 ====[timestamp]===== ====[timestamp]===== provisioner-3 v0.0.0 map[owner: scope:organization] built-in idle Coder +00000000-0000-0000-aaaa-000000000000 ====[timestamp]===== ====[timestamp]===== test v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index c25301169002e..4e0a5e92f63b5 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -35,6 +35,7 @@ SUBCOMMANDS: ping Ping a workspace port-forward Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". + provisioner View and manage provisioner daemons and jobs publickey Output your Coder public key used for Git operations rename Rename a workspace reset-password Directly connect to the database to reset a user's diff --git a/cli/testdata/coder_provisioner_--help.golden b/cli/testdata/coder_provisioner_--help.golden new file mode 100644 index 0000000000000..4f4a783dcc477 --- /dev/null +++ b/cli/testdata/coder_provisioner_--help.golden @@ -0,0 +1,15 @@ +coder v0.0.0-devel + +USAGE: + coder provisioner + + View and manage provisioner daemons and jobs + + Aliases: provisioners + +SUBCOMMANDS: + jobs View and manage provisioner jobs + list List provisioner daemons in an organization + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_provisioner_jobs_--help.golden b/cli/testdata/coder_provisioner_jobs_--help.golden new file mode 100644 index 0000000000000..6442c78a03a8e --- /dev/null +++ b/cli/testdata/coder_provisioner_jobs_--help.golden @@ -0,0 +1,14 @@ +coder v0.0.0-devel + +USAGE: + coder provisioner jobs + + View and manage provisioner jobs + + Aliases: job + +SUBCOMMANDS: + list List provisioner jobs + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_provisioner_jobs_list.golden b/cli/testdata/coder_provisioner_jobs_list.golden new file mode 100644 index 0000000000000..b41f4fc531316 --- /dev/null +++ b/cli/testdata/coder_provisioner_jobs_list.golden @@ -0,0 +1,3 @@ +ID CREATED AT STATUS TAGS TYPE ORGANIZATION QUEUE +==========[version job ID]========== ====[timestamp]===== succeeded map[owner: scope:organization] template_version_import Coder +======[workspace build job ID]====== ====[timestamp]===== succeeded map[owner: scope:organization] workspace_build Coder diff --git a/cli/testdata/coder_provisioner_jobs_list_--help.golden b/cli/testdata/coder_provisioner_jobs_list_--help.golden new file mode 100644 index 0000000000000..585e918c23e7b --- /dev/null +++ b/cli/testdata/coder_provisioner_jobs_list_--help.golden @@ -0,0 +1,27 @@ +coder v0.0.0-devel + +USAGE: + coder provisioner jobs list [flags] + + List provisioner jobs + + Aliases: ls + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + + -c, --column [id|created at|started at|completed at|canceled at|error|error code|status|worker id|file id|tags|queue position|queue size|organization id|template version id|workspace build id|type|available workers|organization|queue] (default: created at,id,organization,status,type,queue,tags) + Columns to display in table output. + + -l, --limit int, $CODER_PROVISIONER_JOB_LIST_LIMIT (default: 50) + Limit the number of jobs returned. + + -o, --output table|json (default: table) + Output format. + + -s, --status [pending|running|succeeded|canceling|canceled|failed|unknown], $CODER_PROVISIONER_JOB_LIST_STATUS + Filter by job status. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_provisioner_jobs_list_--output_json.golden b/cli/testdata/coder_provisioner_jobs_list_--output_json.golden new file mode 100644 index 0000000000000..a19683573bba2 --- /dev/null +++ b/cli/testdata/coder_provisioner_jobs_list_--output_json.golden @@ -0,0 +1,44 @@ +[ + { + "id": "==========[version job ID]==========", + "created_at": "====[timestamp]=====", + "started_at": "====[timestamp]=====", + "completed_at": "====[timestamp]=====", + "status": "succeeded", + "worker_id": "====[workspace build worker ID]=====", + "file_id": "=====[workspace build file ID]======", + "tags": { + "owner": "", + "scope": "organization" + }, + "queue_position": 0, + "queue_size": 0, + "organization_id": "===========[first org ID]===========", + "input": { + "template_version_id": "============[version ID]============" + }, + "type": "template_version_import", + "organization_name": "Coder" + }, + { + "id": "======[workspace build job ID]======", + "created_at": "====[timestamp]=====", + "started_at": "====[timestamp]=====", + "completed_at": "====[timestamp]=====", + "status": "succeeded", + "worker_id": "====[workspace build worker ID]=====", + "file_id": "=====[workspace build file ID]======", + "tags": { + "owner": "", + "scope": "organization" + }, + "queue_position": 0, + "queue_size": 0, + "organization_id": "===========[first org ID]===========", + "input": { + "workspace_build_id": "========[workspace build ID]========" + }, + "type": "workspace_build", + "organization_name": "Coder" + } +] diff --git a/cli/testdata/coder_provisioner_list.golden b/cli/testdata/coder_provisioner_list.golden new file mode 100644 index 0000000000000..056571547939e --- /dev/null +++ b/cli/testdata/coder_provisioner_list.golden @@ -0,0 +1,2 @@ +CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS ORGANIZATION +====[timestamp]===== ====[timestamp]===== test v0.0.0-devel map[owner: scope:organization] built-in idle Coder diff --git a/cli/testdata/coder_provisioner_list_--help.golden b/cli/testdata/coder_provisioner_list_--help.golden new file mode 100644 index 0000000000000..a9943cb9da392 --- /dev/null +++ b/cli/testdata/coder_provisioner_list_--help.golden @@ -0,0 +1,21 @@ +coder v0.0.0-devel + +USAGE: + coder provisioner list [flags] + + List provisioner daemons in an organization + + Aliases: ls + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + + -c, --column [id|organization id|created at|last seen at|name|version|api version|tags|key name|status|current job id|current job status|previous job id|previous job status|organization] (default: name,organization,status,key name,created at,last seen at,version,tags) + Columns to display in table output. + + -o, --output table|json (default: table) + Output format. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_provisioner_list_--output_json.golden b/cli/testdata/coder_provisioner_list_--output_json.golden new file mode 100644 index 0000000000000..bb8e2fd0d09c9 --- /dev/null +++ b/cli/testdata/coder_provisioner_list_--output_json.golden @@ -0,0 +1,27 @@ +[ + { + "id": "====[workspace build worker ID]=====", + "organization_id": "===========[first org ID]===========", + "key_id": "00000000-0000-0000-0000-000000000001", + "created_at": "====[timestamp]=====", + "last_seen_at": "====[timestamp]=====", + "name": "test", + "version": "v0.0.0-devel", + "api_version": "1.2", + "provisioners": [ + "echo" + ], + "tags": { + "owner": "", + "scope": "organization" + }, + "key_name": "built-in", + "status": "idle", + "current_job": null, + "previous_job": { + "id": "======[workspace build job ID]======", + "status": "succeeded" + }, + "organization_name": "Coder" + } +] diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 25997cafd736f..986932268e5eb 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -4091,10 +4091,14 @@ func (q *FakeQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePosition if row.QueuePosition > 0 { var availableWorkers []database.ProvisionerDaemon for _, daemon := range q.provisionerDaemons { - if daemon.OrganizationID == job.OrganizationID && - slices.Contains(daemon.Provisioners, job.Provisioner) && - tagsSubset(job.Tags, daemon.Tags) { - availableWorkers = append(availableWorkers, daemon) + if daemon.OrganizationID == job.OrganizationID && slices.Contains(daemon.Provisioners, job.Provisioner) { + if tagsEqual(job.Tags, tagsUntagged) { + if tagsEqual(job.Tags, daemon.Tags) { + availableWorkers = append(availableWorkers, daemon) + } + } else if tagsSubset(job.Tags, daemon.Tags) { + availableWorkers = append(availableWorkers, daemon) + } } } slices.SortFunc(availableWorkers, func(a, b database.ProvisionerDaemon) int { diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 808cc14298cce..33177c52bcf6b 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -156,7 +156,7 @@ func JobIsMissingParameterErrorCode(code JobErrorCode) bool { // ProvisionerJob describes the job executed by the provisioning daemon. type ProvisionerJob struct { ID uuid.UUID `json:"id" format:"uuid" table:"id"` - CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at,default_sort"` + CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at"` StartedAt *time.Time `json:"started_at,omitempty" format:"date-time" table:"started at"` CompletedAt *time.Time `json:"completed_at,omitempty" format:"date-time" table:"completed at"` CanceledAt *time.Time `json:"canceled_at,omitempty" format:"date-time" table:"canceled at"` diff --git a/docs/manifest.json b/docs/manifest.json index 171dd8ac38b9c..f1f9f2808f0a4 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1140,9 +1140,19 @@ }, { "title": "provisioner", - "description": "Manage provisioner daemons", + "description": "View and manage provisioner daemons and jobs", "path": "reference/cli/provisioner.md" }, + { + "title": "provisioner jobs", + "description": "View and manage provisioner jobs", + "path": "reference/cli/provisioner_jobs.md" + }, + { + "title": "provisioner jobs list", + "description": "List provisioner jobs", + "path": "reference/cli/provisioner_jobs_list.md" + }, { "title": "provisioner keys", "description": "Manage provisioner keys", @@ -1163,6 +1173,11 @@ "description": "List provisioner keys in an organization", "path": "reference/cli/provisioner_keys_list.md" }, + { + "title": "provisioner list", + "description": "List provisioner daemons in an organization", + "path": "reference/cli/provisioner_list.md" + }, { "title": "provisioner start", "description": "Run a provisioner daemon", diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 71a1a2b4e2c68..9ad8f5590e727 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -65,7 +65,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [features](./features.md) | List Enterprise features | | [licenses](./licenses.md) | Add, delete, and list licenses | | [groups](./groups.md) | Manage groups | -| [provisioner](./provisioner.md) | Manage provisioner daemons | +| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | ## Options diff --git a/docs/reference/cli/provisioner.md b/docs/reference/cli/provisioner.md index 08f4918ec1cf0..20acfd4fa5c69 100644 --- a/docs/reference/cli/provisioner.md +++ b/docs/reference/cli/provisioner.md @@ -1,7 +1,7 @@ # provisioner -Manage provisioner daemons +View and manage provisioner daemons and jobs Aliases: @@ -15,7 +15,9 @@ coder provisioner ## Subcommands -| Name | Purpose | -|----------------------------------------------|--------------------------| -| [start](./provisioner_start.md) | Run a provisioner daemon | -| [keys](./provisioner_keys.md) | Manage provisioner keys | +| Name | Purpose | +|----------------------------------------------|---------------------------------------------| +| [list](./provisioner_list.md) | List provisioner daemons in an organization | +| [jobs](./provisioner_jobs.md) | View and manage provisioner jobs | +| [start](./provisioner_start.md) | Run a provisioner daemon | +| [keys](./provisioner_keys.md) | Manage provisioner keys | diff --git a/docs/reference/cli/provisioner_jobs.md b/docs/reference/cli/provisioner_jobs.md new file mode 100644 index 0000000000000..359c5d8c0f7b5 --- /dev/null +++ b/docs/reference/cli/provisioner_jobs.md @@ -0,0 +1,20 @@ + +# provisioner jobs + +View and manage provisioner jobs + +Aliases: + +* job + +## Usage + +```console +coder provisioner jobs +``` + +## Subcommands + +| Name | Purpose | +|-------------------------------------------------|-----------------------| +| [list](./provisioner_jobs_list.md) | List provisioner jobs | diff --git a/docs/reference/cli/provisioner_jobs_list.md b/docs/reference/cli/provisioner_jobs_list.md new file mode 100644 index 0000000000000..03e187b1c6720 --- /dev/null +++ b/docs/reference/cli/provisioner_jobs_list.md @@ -0,0 +1,62 @@ + +# provisioner jobs list + +List provisioner jobs + +Aliases: + +* ls + +## Usage + +```console +coder provisioner jobs list [flags] +``` + +## Options + +### -s, --status + +| | | +|-------------|----------------------------------------------------------------------------------| +| Type | [pending\|running\|succeeded\|canceling\|canceled\|failed\|unknown] | +| Environment | $CODER_PROVISIONER_JOB_LIST_STATUS | + +Filter by job status. + +### -l, --limit + +| | | +|-------------|------------------------------------------------| +| Type | int | +| Environment | $CODER_PROVISIONER_JOB_LIST_LIMIT | +| Default | 50 | + +Limit the number of jobs returned. + +### -O, --org + +| | | +|-------------|----------------------------------| +| Type | string | +| Environment | $CODER_ORGANIZATION | + +Select which organization (uuid or name) to use. + +### -c, --column + +| | | +|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Type | [id\|created at\|started at\|completed at\|canceled at\|error\|error code\|status\|worker id\|file id\|tags\|queue position\|queue size\|organization id\|template version id\|workspace build id\|type\|available workers\|organization\|queue] | +| Default | created at,id,organization,status,type,queue,tags | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format. diff --git a/docs/reference/cli/provisioner_keys_list.md b/docs/reference/cli/provisioner_keys_list.md index 61e00dde759a9..4f05a5e9b5dcc 100644 --- a/docs/reference/cli/provisioner_keys_list.md +++ b/docs/reference/cli/provisioner_keys_list.md @@ -23,3 +23,21 @@ coder provisioner keys list [flags] | Environment | $CODER_ORGANIZATION | Select which organization (uuid or name) to use. + +### -c, --column + +| | | +|---------|---------------------------------------| +| Type | [created at\|name\|tags] | +| Default | created at,name,tags | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format. diff --git a/docs/reference/cli/provisioner_list.md b/docs/reference/cli/provisioner_list.md new file mode 100644 index 0000000000000..11abd7dcc3d75 --- /dev/null +++ b/docs/reference/cli/provisioner_list.md @@ -0,0 +1,43 @@ + +# provisioner list + +List provisioner daemons in an organization + +Aliases: + +* ls + +## Usage + +```console +coder provisioner list [flags] +``` + +## Options + +### -O, --org + +| | | +|-------------|----------------------------------| +| Type | string | +| Environment | $CODER_ORGANIZATION | + +Select which organization (uuid or name) to use. + +### -c, --column + +| | | +|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Type | [id\|organization id\|created at\|last seen at\|name\|version\|api version\|tags\|key name\|status\|current job id\|current job status\|previous job id\|previous job status\|organization] | +| Default | name,organization,status,key name,created at,last seen at,version,tags | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format. diff --git a/enterprise/cli/provisionerdaemons.go b/enterprise/cli/provisionerdaemons.go index 8d39723a269e3..690762dcc613b 100644 --- a/enterprise/cli/provisionerdaemons.go +++ b/enterprise/cli/provisionerdaemons.go @@ -1,20 +1,15 @@ package cli -import "github.com/coder/serpent" +import ( + "github.com/coder/serpent" +) func (r *RootCmd) provisionerDaemons() *serpent.Command { - cmd := &serpent.Command{ - Use: "provisioner", - Short: "Manage provisioner daemons", - Handler: func(inv *serpent.Invocation) error { - return inv.Command.HelpHandler(inv) - }, - Aliases: []string{"provisioners"}, - Children: []*serpent.Command{ - r.provisionerDaemonStart(), - r.provisionerKeys(), - }, - } + cmd := r.RootCmd.Provisioners() + cmd.AddSubcommands( + r.provisionerDaemonStart(), + r.provisionerKeys(), + ) return cmd } diff --git a/enterprise/cli/provisionerdaemonstart_slim.go b/enterprise/cli/provisionerdaemonstart_slim.go index aa399e9b9a46c..5e43393480c6d 100644 --- a/enterprise/cli/provisionerdaemonstart_slim.go +++ b/enterprise/cli/provisionerdaemonstart_slim.go @@ -15,7 +15,7 @@ func (r *RootCmd) provisionerDaemonStart() *serpent.Command { RawArgs: true, Hidden: true, Handler: func(inv *serpent.Invocation) error { - agplcli.SlimUnsupported(inv.Stderr, "provisionerd start") + agplcli.SlimUnsupported(inv.Stderr, "provisioner start") return nil }, } diff --git a/enterprise/cli/provisionerkeys.go b/enterprise/cli/provisionerkeys.go index 99d8bd8acf9ab..f88a5ffe851e6 100644 --- a/enterprise/cli/provisionerkeys.go +++ b/enterprise/cli/provisionerkeys.go @@ -138,8 +138,8 @@ func (r *RootCmd) provisionerKeysList() *serpent.Command { }, } - cmd.Options = serpent.OptionSet{} orgContext.AttachOptions(cmd) + formatter.AttachOptions(&cmd.Options) return cmd } diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden index 9b3584a3e48b2..ca5d8c8c886ef 100644 --- a/enterprise/cli/testdata/coder_--help.golden +++ b/enterprise/cli/testdata/coder_--help.golden @@ -17,7 +17,7 @@ SUBCOMMANDS: features List Enterprise features groups Manage groups licenses Add, delete, and list licenses - provisioner Manage provisioner daemons + provisioner View and manage provisioner daemons and jobs server Start a Coder server GLOBAL OPTIONS: diff --git a/enterprise/cli/testdata/coder_provisioner_--help.golden b/enterprise/cli/testdata/coder_provisioner_--help.golden index e6cd69feeceac..79c82987f1311 100644 --- a/enterprise/cli/testdata/coder_provisioner_--help.golden +++ b/enterprise/cli/testdata/coder_provisioner_--help.golden @@ -3,12 +3,14 @@ coder v0.0.0-devel USAGE: coder provisioner - Manage provisioner daemons + View and manage provisioner daemons and jobs Aliases: provisioners SUBCOMMANDS: + jobs View and manage provisioner jobs keys Manage provisioner keys + list List provisioner daemons in an organization start Run a provisioner daemon ——— diff --git a/enterprise/cli/testdata/coder_provisioner_jobs_--help.golden b/enterprise/cli/testdata/coder_provisioner_jobs_--help.golden new file mode 100644 index 0000000000000..6442c78a03a8e --- /dev/null +++ b/enterprise/cli/testdata/coder_provisioner_jobs_--help.golden @@ -0,0 +1,14 @@ +coder v0.0.0-devel + +USAGE: + coder provisioner jobs + + View and manage provisioner jobs + + Aliases: job + +SUBCOMMANDS: + list List provisioner jobs + +——— +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_provisioner_jobs_list_--help.golden b/enterprise/cli/testdata/coder_provisioner_jobs_list_--help.golden new file mode 100644 index 0000000000000..585e918c23e7b --- /dev/null +++ b/enterprise/cli/testdata/coder_provisioner_jobs_list_--help.golden @@ -0,0 +1,27 @@ +coder v0.0.0-devel + +USAGE: + coder provisioner jobs list [flags] + + List provisioner jobs + + Aliases: ls + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + + -c, --column [id|created at|started at|completed at|canceled at|error|error code|status|worker id|file id|tags|queue position|queue size|organization id|template version id|workspace build id|type|available workers|organization|queue] (default: created at,id,organization,status,type,queue,tags) + Columns to display in table output. + + -l, --limit int, $CODER_PROVISIONER_JOB_LIST_LIMIT (default: 50) + Limit the number of jobs returned. + + -o, --output table|json (default: table) + Output format. + + -s, --status [pending|running|succeeded|canceling|canceled|failed|unknown], $CODER_PROVISIONER_JOB_LIST_STATUS + Filter by job status. + +——— +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_provisioner_keys_list_--help.golden b/enterprise/cli/testdata/coder_provisioner_keys_list_--help.golden index 59bddf9f71991..e7bc4c46895c3 100644 --- a/enterprise/cli/testdata/coder_provisioner_keys_list_--help.golden +++ b/enterprise/cli/testdata/coder_provisioner_keys_list_--help.golden @@ -11,5 +11,11 @@ OPTIONS: -O, --org string, $CODER_ORGANIZATION Select which organization (uuid or name) to use. + -c, --column [created at|name|tags] (default: created at,name,tags) + Columns to display in table output. + + -o, --output table|json (default: table) + Output format. + ——— Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_provisioner_list_--help.golden b/enterprise/cli/testdata/coder_provisioner_list_--help.golden new file mode 100644 index 0000000000000..a9943cb9da392 --- /dev/null +++ b/enterprise/cli/testdata/coder_provisioner_list_--help.golden @@ -0,0 +1,21 @@ +coder v0.0.0-devel + +USAGE: + coder provisioner list [flags] + + List provisioner daemons in an organization + + Aliases: ls + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + + -c, --column [id|organization id|created at|last seen at|name|version|api version|tags|key name|status|current job id|current job status|previous job id|previous job status|organization] (default: name,organization,status,key name,created at,last seen at,version,tags) + Columns to display in table output. + + -o, --output table|json (default: table) + Output format. + +——— +Run `coder --help` for a list of global options.