diff --git a/cli/provisioners.go b/cli/provisioners.go index 8f90a52589939..1d564810e2d11 100644 --- a/cli/provisioners.go +++ b/cli/provisioners.go @@ -39,7 +39,8 @@ func (r *RootCmd) provisionerList() *serpent.Command { cliui.TableFormat([]provisionerDaemonRow{}, []string{"created at", "last seen at", "key name", "name", "version", "status", "tags"}), cliui.JSONFormat(), ) - limit int64 + limit int64 + includeOffline bool ) cmd := &serpent.Command{ @@ -59,7 +60,8 @@ func (r *RootCmd) provisionerList() *serpent.Command { } daemons, err := client.OrganizationProvisionerDaemons(ctx, org.ID, &codersdk.OrganizationProvisionerDaemonsOptions{ - Limit: int(limit), + Limit: int(limit), + IncludeOffline: includeOffline, }) if err != nil { return xerrors.Errorf("list provisioner daemons: %w", err) @@ -98,6 +100,12 @@ func (r *RootCmd) provisionerList() *serpent.Command { Default: "50", Value: serpent.Int64Of(&limit), }, + { + Flag: "show-offline", + Env: "CODER_PROVISIONER_LIST_SHOW_OFFLINE", // TODO: Is this needed? + Description: "Show offline provisioners.", + Value: serpent.BoolOf(&includeOffline), + }, }...) orgContext.AttachOptions(cmd) diff --git a/cli/provisioners_test.go b/cli/provisioners_test.go index 30a89714ff57f..1b11028cb3006 100644 --- a/cli/provisioners_test.go +++ b/cli/provisioners_test.go @@ -198,6 +198,23 @@ func TestProvisioners_Golden(t *testing.T) { clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) }) + t.Run("list with offline", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "list", + "--show-offline", + ) + inv.Stdout = &got + clitest.SetupConfig(t, templateAdminClient, 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. diff --git a/cli/testdata/TestProvisioners_Golden/list.golden b/cli/testdata/TestProvisioners_Golden/list.golden index 3f50f90746744..8f10eec458f7d 100644 --- a/cli/testdata/TestProvisioners_Golden/list.golden +++ b/cli/testdata/TestProvisioners_Golden/list.golden @@ -1,5 +1,4 @@ -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-000000000000 ====[timestamp]===== ====[timestamp]===== default-provisioner v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder -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 +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-000000000000 ====[timestamp]===== ====[timestamp]===== default-provisioner v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder +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-000000000003 ====[timestamp]===== ====[timestamp]===== provisioner-3 v0.0.0 map[owner: scope:organization] built-in idle Coder diff --git a/cli/testdata/TestProvisioners_Golden/list_with_offline.golden b/cli/testdata/TestProvisioners_Golden/list_with_offline.golden new file mode 100644 index 0000000000000..fd7b966d8d982 --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_with_offline.golden @@ -0,0 +1,5 @@ +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +====[timestamp]===== ====[timestamp]===== built-in default-provisioner v0.0.0-devel idle map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-1 v0.0.0 busy map[foo:bar owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-2 v0.0.0 offline map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-3 v0.0.0 idle map[owner: scope:organization] diff --git a/cli/testdata/coder_provisioner_list_--help.golden b/cli/testdata/coder_provisioner_list_--help.golden index 7a1807bb012f5..d872e4fa5bd28 100644 --- a/cli/testdata/coder_provisioner_list_--help.golden +++ b/cli/testdata/coder_provisioner_list_--help.golden @@ -20,5 +20,8 @@ OPTIONS: -o, --output table|json (default: table) Output format. + --show-offline bool, $CODER_PROVISIONER_LIST_SHOW_OFFLINE + Show offline provisioners. + ——— Run `coder --help` for a list of global options. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index c964a066c58eb..17997397fe0bf 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -397,6 +397,7 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, IDs: []uuid.UUID{matchingDaemon0.ID, matchingDaemon1.ID}, + IncludeOffline: sql.NullBool{Bool: true, Valid: true}, }) require.NoError(t, err) require.Len(t, daemons, 2) @@ -430,6 +431,7 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, Tags: database.StringMap{"foo": "bar"}, + IncludeOffline: sql.NullBool{Bool: true, Valid: true}, }) require.NoError(t, err) require.Len(t, daemons, 1) @@ -463,6 +465,7 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, StaleIntervalMS: 45 * time.Minute.Milliseconds(), + IncludeOffline: sql.NullBool{Bool: true, Valid: true}, }) require.NoError(t, err) require.Len(t, daemons, 2) @@ -475,6 +478,89 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { require.Equal(t, database.ProvisionerDaemonStatusOffline, daemons[0].Status) require.Equal(t, database.ProvisionerDaemonStatusIdle, daemons[1].Status) }) + + t.Run("Excludes offline daemons", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + _ = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "offline-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-time.Hour), + }, + }) + fooDaemon := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "foo-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(30 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(30 * time.Minute)), + }, + }) + + daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ + OrganizationID: org.ID, + StaleIntervalMS: 45 * time.Minute.Milliseconds(), + }) + require.NoError(t, err) + require.Len(t, daemons, 1) + + require.Equal(t, fooDaemon.ID, daemons[0].ProvisionerDaemon.ID) + require.Equal(t, database.ProvisionerDaemonStatusIdle, daemons[0].Status) + }) + + t.Run("Includes offline daemons", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + _ = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "offline-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-time.Hour), + }, + }) + _ = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "foo-daemon", + OrganizationID: org.ID, + Tags: database.StringMap{ + "foo": "bar", + }, + }) + _ = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "bar-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(30 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(30 * time.Minute)), + }, + }) + + daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ + OrganizationID: org.ID, + StaleIntervalMS: 45 * time.Minute.Milliseconds(), + IncludeOffline: sql.NullBool{Bool: true, Valid: true}, + }) + require.NoError(t, err) + require.Len(t, daemons, 3) + + statusCounts := make(map[database.ProvisionerDaemonStatus]int) + for _, daemon := range daemons { + statusCounts[daemon.Status]++ + } + + require.Equal(t, 2, statusCounts[database.ProvisionerDaemonStatusIdle]) + require.Equal(t, 1, statusCounts[database.ProvisionerDaemonStatusOffline]) + }) } func TestGetWorkspaceAgentUsageStats(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 74cefd09359b0..05247f019b518 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8256,10 +8256,16 @@ WHERE pd.organization_id = $2::uuid AND (COALESCE(array_length($3::uuid[], 1), 0) = 0 OR pd.id = ANY($3::uuid[])) AND ($4::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, $4::tagset)) + -- Include offline daemons only if include_offline is set to true + AND ( + COALESCE($5::bool, false) = true + OR + (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - ($1::bigint || ' ms')::interval)) + ) ORDER BY pd.created_at DESC LIMIT - $5::int + $6::int ` type GetProvisionerDaemonsWithStatusByOrganizationParams struct { @@ -8267,6 +8273,7 @@ type GetProvisionerDaemonsWithStatusByOrganizationParams struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` IDs []uuid.UUID `db:"ids" json:"ids"` Tags StringMap `db:"tags" json:"tags"` + IncludeOffline sql.NullBool `db:"include_offline" json:"include_offline"` Limit sql.NullInt32 `db:"limit" json:"limit"` } @@ -8294,6 +8301,7 @@ func (q *sqlQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.C arg.OrganizationID, pq.Array(arg.IDs), arg.Tags, + arg.IncludeOffline, arg.Limit, ) if err != nil { diff --git a/coderd/database/queries/provisionerdaemons.sql b/coderd/database/queries/provisionerdaemons.sql index 4f7c7a8b2200a..ca6e58b1b4766 100644 --- a/coderd/database/queries/provisionerdaemons.sql +++ b/coderd/database/queries/provisionerdaemons.sql @@ -110,6 +110,12 @@ WHERE pd.organization_id = @organization_id::uuid AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pd.id = ANY(@ids::uuid[])) AND (@tags::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, @tags::tagset)) + -- Include offline daemons only if include_offline is set to true + AND ( + COALESCE(sqlc.narg('include_offline')::bool, false) = true + OR + (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + ) ORDER BY pd.created_at DESC LIMIT diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 332ae3b352e0a..f4e4794519511 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -45,6 +45,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { limit := p.PositiveInt32(qp, 50, "limit") ids := p.UUIDs(qp, nil, "ids") tags := p.JSONStringMap(qp, database.StringMap{}, "tags") + includeOffline := p.NullableBoolean(qp, sql.NullBool{}, "include_offline") p.ErrorExcessParams(qp) if len(p.Errors) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -59,6 +60,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, StaleIntervalMS: provisionerdserver.StaleInterval.Milliseconds(), + IncludeOffline: includeOffline, Limit: sql.NullInt32{Int32: limit, Valid: limit > 0}, IDs: ids, Tags: tags, diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f87d0eae188ba..34c62ab572513 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -344,9 +344,10 @@ func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, e } type OrganizationProvisionerDaemonsOptions struct { - Limit int - IDs []uuid.UUID - Tags map[string]string + Limit int + IncludeOffline bool + IDs []uuid.UUID + Tags map[string]string } func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizationID uuid.UUID, opts *OrganizationProvisionerDaemonsOptions) ([]ProvisionerDaemon, error) { @@ -355,6 +356,9 @@ func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizatio if opts.Limit > 0 { qp.Add("limit", strconv.Itoa(opts.Limit)) } + if opts.IncludeOffline { + qp.Add("include_offline", "true") // TODO: Better way of representing this? + } if len(opts.IDs) > 0 { qp.Add("ids", joinSliceStringer(opts.IDs)) } diff --git a/docs/reference/cli/provisioner_list.md b/docs/reference/cli/provisioner_list.md index 128d76caf4c7e..3db0ba8657c36 100644 --- a/docs/reference/cli/provisioner_list.md +++ b/docs/reference/cli/provisioner_list.md @@ -25,6 +25,15 @@ coder provisioner list [flags] Limit the number of provisioners returned. +### --show-offline + +| | | +|-------------|---------------------------------------------------| +| Type | bool | +| Environment | $CODER_PROVISIONER_LIST_SHOW_OFFLINE | + +Show offline provisioners. + ### -O, --org | | | diff --git a/enterprise/cli/testdata/coder_provisioner_list_--help.golden b/enterprise/cli/testdata/coder_provisioner_list_--help.golden index 7a1807bb012f5..d872e4fa5bd28 100644 --- a/enterprise/cli/testdata/coder_provisioner_list_--help.golden +++ b/enterprise/cli/testdata/coder_provisioner_list_--help.golden @@ -20,5 +20,8 @@ OPTIONS: -o, --output table|json (default: table) Output format. + --show-offline bool, $CODER_PROVISIONER_LIST_SHOW_OFFLINE + Show offline provisioners. + ——— Run `coder --help` for a list of global options. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 52fdb1d6effc4..c587e4c09c60d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1825,6 +1825,7 @@ export interface OrganizationMemberWithUserData extends OrganizationMember { // From codersdk/organizations.go export interface OrganizationProvisionerDaemonsOptions { readonly Limit: number; + readonly IncludeOffline: boolean; readonly IDs: readonly string[]; readonly Tags: Record; }