Skip to content

Commit 95713c5

Browse files
committed
feat: add max-age option to provisioners list command
1 parent d55026e commit 95713c5

File tree

13 files changed

+176
-20
lines changed

13 files changed

+176
-20
lines changed

cli/provisioners.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"fmt"
5+
"time"
56

67
"golang.org/x/xerrors"
78

@@ -43,6 +44,7 @@ func (r *RootCmd) provisionerList() *serpent.Command {
4344
limit int64
4445
offline bool
4546
status []string
47+
maxAge time.Duration
4648
)
4749

4850
cmd := &serpent.Command{
@@ -65,6 +67,7 @@ func (r *RootCmd) provisionerList() *serpent.Command {
6567
Limit: int(limit),
6668
Offline: offline,
6769
Status: slice.StringEnums[codersdk.ProvisionerDaemonStatus](status),
70+
MaxAge: maxAge,
6871
})
6972
if err != nil {
7073
return xerrors.Errorf("list provisioner daemons: %w", err)
@@ -104,10 +107,11 @@ func (r *RootCmd) provisionerList() *serpent.Command {
104107
Value: serpent.Int64Of(&limit),
105108
},
106109
{
107-
Flag: "show-offline",
108-
Env: "CODER_PROVISIONER_SHOW_OFFLINE",
109-
Description: "Show offline provisioners.",
110-
Value: serpent.BoolOf(&offline),
110+
Flag: "show-offline",
111+
FlagShorthand: "f",
112+
Env: "CODER_PROVISIONER_SHOW_OFFLINE",
113+
Description: "Show offline provisioners.",
114+
Value: serpent.BoolOf(&offline),
111115
},
112116
{
113117
Flag: "status",
@@ -116,6 +120,13 @@ func (r *RootCmd) provisionerList() *serpent.Command {
116120
Description: "Filter by provisioner status.",
117121
Value: serpent.EnumArrayOf(&status, slice.ToStrings(codersdk.ProvisionerDaemonStatusEnums())...),
118122
},
123+
{
124+
Flag: "max-age",
125+
FlagShorthand: "m",
126+
Env: "CODER_PROVISIONER_LIST_MAX_AGE",
127+
Description: "Filter provisioners by maximum age.",
128+
Value: serpent.DurationOf(&maxAge),
129+
},
119130
}...)
120131

121132
orgContext.AttachOptions(cmd)

cli/provisioners_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,23 @@ func TestProvisioners_Golden(t *testing.T) {
249249
clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace)
250250
})
251251

252+
t.Run("list provisioner daemons by max age", func(t *testing.T) {
253+
t.Parallel()
254+
255+
var got bytes.Buffer
256+
inv, root := clitest.New(t,
257+
"provisioners",
258+
"list",
259+
"--max-age=1h",
260+
)
261+
inv.Stdout = &got
262+
clitest.SetupConfig(t, templateAdminClient, root)
263+
err := inv.Run()
264+
require.NoError(t, err)
265+
266+
clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace)
267+
})
268+
252269
// Test jobs list with template admin as members are currently
253270
// unable to access provisioner jobs. In the future (with RBAC
254271
// changes), we may allow them to view _their_ jobs.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS
2+
====[timestamp]===== ====[timestamp]===== built-in default-provisioner v0.0.0-devel idle map[owner: scope:organization]
3+
====[timestamp]===== ====[timestamp]===== built-in provisioner-1 v0.0.0 busy map[foo:bar owner: scope:organization]
4+
====[timestamp]===== ====[timestamp]===== built-in provisioner-3 v0.0.0 idle map[owner: scope:organization]

cli/testdata/coder_provisioner_list_--help.golden

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ OPTIONS:
1717
-l, --limit int, $CODER_PROVISIONER_LIST_LIMIT (default: 50)
1818
Limit the number of provisioners returned.
1919

20+
-m, --max-age duration, $CODER_PROVISIONER_LIST_MAX_AGE
21+
Filter provisioners by maximum age.
22+
2023
-o, --output table|json (default: table)
2124
Output format.
2225

23-
--show-offline bool, $CODER_PROVISIONER_SHOW_OFFLINE
26+
-f, --show-offline bool, $CODER_PROVISIONER_SHOW_OFFLINE
2427
Show offline provisioners.
2528

2629
-s, --status [offline|idle|busy], $CODER_PROVISIONER_LIST_STATUS

coderd/database/querier_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,73 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) {
635635
})
636636
}
637637
})
638+
639+
t.Run("FilterByMaxAge", func(t *testing.T) {
640+
t.Parallel()
641+
db, _ := dbtestutil.NewDB(t)
642+
org := dbgen.Organization(t, db, database.Organization{})
643+
644+
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
645+
Name: "foo-daemon",
646+
OrganizationID: org.ID,
647+
CreatedAt: dbtime.Now().Add(-(45 * time.Minute)),
648+
LastSeenAt: sql.NullTime{
649+
Valid: true,
650+
Time: dbtime.Now().Add(-(45 * time.Minute)),
651+
},
652+
})
653+
654+
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
655+
Name: "bar-daemon",
656+
OrganizationID: org.ID,
657+
CreatedAt: dbtime.Now().Add(-(25 * time.Minute)),
658+
LastSeenAt: sql.NullTime{
659+
Valid: true,
660+
Time: dbtime.Now().Add(-(25 * time.Minute)),
661+
},
662+
})
663+
664+
type testCase struct {
665+
name string
666+
maxAge sql.NullInt64
667+
expectedNum int
668+
}
669+
670+
tests := []testCase{
671+
{
672+
name: "Max age 1 hour",
673+
maxAge: sql.NullInt64{Int64: time.Hour.Milliseconds(), Valid: true},
674+
expectedNum: 2,
675+
},
676+
{
677+
name: "Max age 30 minutes",
678+
maxAge: sql.NullInt64{Int64: (30 * time.Minute).Milliseconds(), Valid: true},
679+
expectedNum: 1,
680+
},
681+
{
682+
name: "Max age 15 minutes",
683+
maxAge: sql.NullInt64{Int64: (15 * time.Minute).Milliseconds(), Valid: true},
684+
expectedNum: 0,
685+
},
686+
{
687+
name: "No max age",
688+
maxAge: sql.NullInt64{Valid: false},
689+
expectedNum: 2,
690+
},
691+
}
692+
for _, tc := range tests {
693+
//nolint:tparallel,paralleltest
694+
t.Run(tc.name, func(t *testing.T) {
695+
daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{
696+
OrganizationID: org.ID,
697+
StaleIntervalMS: 60 * time.Minute.Milliseconds(),
698+
MaxAgeMs: tc.maxAge,
699+
})
700+
require.NoError(t, err)
701+
require.Len(t, daemons, tc.expectedNum)
702+
})
703+
}
704+
})
638705
}
639706

640707
func TestGetWorkspaceAgentUsageStats(t *testing.T) {

coderd/database/queries.sql.go

Lines changed: 17 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/provisionerdaemons.sql

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,16 @@ WHERE
110110
pd.organization_id = @organization_id::uuid
111111
AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pd.id = ANY(@ids::uuid[]))
112112
AND (@tags::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, @tags::tagset))
113+
-- Filter by max age if provided
113114
AND (
114-
-- Include daemons that have been seen recently
115+
sqlc.narg('max_age_ms')::bigint IS NULL
116+
OR pd.last_seen_at IS NULL
117+
OR pd.last_seen_at >= (NOW() - (sqlc.narg('max_age_ms')::bigint || ' ms')::interval)
118+
)
119+
AND (
120+
-- Always include online daemons
115121
(pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - (@stale_interval_ms::bigint || ' ms')::interval))
116-
-- Include offline daemons only when offline param is set OR 'offline' is in the list of statuses
122+
-- Include offline daemons if offline param is true or 'offline' status is requested
117123
OR (
118124
(pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval))
119125
AND (
@@ -122,15 +128,17 @@ WHERE
122128
)
123129
)
124130
)
125-
-- Filter daemons by their current status if statuses are provided
126131
AND (
132+
-- Filter daemons by any statuses if provided
127133
COALESCE(array_length(@statuses::provisioner_daemon_status[], 1), 0) = 0
134+
OR (current_job.id IS NOT NULL AND 'busy'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[]))
135+
OR (current_job.id IS NULL AND 'idle'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[]))
128136
OR (
129-
(current_job.id IS NOT NULL AND 'busy'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[]))
130-
OR (current_job.id IS NULL AND 'idle'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[]))
137+
'offline'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[])
138+
AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval))
131139
)
132140
OR (
133-
(COALESCE(sqlc.narg('offline')::bool, false) = true OR 'offline'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[]))
141+
COALESCE(sqlc.narg('offline')::bool, false) = true
134142
AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval))
135143
)
136144
)

coderd/httpapi/queryparams.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,23 @@ func (p *QueryParamParser) ProvisionerDaemonStatuses(vals url.Values, def []code
293293
})
294294
}
295295

296+
func (p *QueryParamParser) Duration(vals url.Values, def time.Duration, queryParam string) time.Duration {
297+
v, err := parseQueryParam(p, vals, func(v string) (time.Duration, error) {
298+
d, err := time.ParseDuration(v)
299+
if err != nil {
300+
return 0, err
301+
}
302+
return d, nil
303+
}, def, queryParam)
304+
if err != nil {
305+
p.Errors = append(p.Errors, codersdk.ValidationError{
306+
Field: queryParam,
307+
Detail: fmt.Sprintf("Query param %q must be a valid duration (e.g., '24h', '30m', '1h30m'): %s", queryParam, err.Error()),
308+
})
309+
}
310+
return v
311+
}
312+
296313
// ValidEnum represents an enum that can be parsed and validated.
297314
type ValidEnum interface {
298315
// Add more types as needed (avoid importing large dependency trees).

coderd/provisionerdaemons.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
4848
tags := p.JSONStringMap(qp, database.StringMap{}, "tags")
4949
includeOffline := p.NullableBoolean(qp, sql.NullBool{}, "offline")
5050
statuses := p.ProvisionerDaemonStatuses(qp, []codersdk.ProvisionerDaemonStatus{}, "status")
51+
maxAge := p.Duration(qp, 0, "max_age")
5152
p.ErrorExcessParams(qp)
5253
if len(p.Errors) > 0 {
5354
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -67,6 +68,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
6768
Limit: sql.NullInt32{Int32: limit, Valid: limit > 0},
6869
Offline: includeOffline,
6970
Statuses: dbStatuses,
71+
MaxAgeMs: sql.NullInt64{Int64: maxAge.Milliseconds(), Valid: maxAge > 0},
7072
IDs: ids,
7173
Tags: tags,
7274
},

codersdk/organizations.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ type OrganizationProvisionerDaemonsOptions struct {
347347
Limit int
348348
Offline bool
349349
Status []ProvisionerDaemonStatus
350+
MaxAge time.Duration
350351
IDs []uuid.UUID
351352
Tags map[string]string
352353
}
@@ -363,6 +364,9 @@ func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizatio
363364
if len(opts.Status) > 0 {
364365
qp.Add("status", joinSlice(opts.Status))
365366
}
367+
if opts.MaxAge > 0 {
368+
qp.Add("max_age", opts.MaxAge.String())
369+
}
366370
if len(opts.IDs) > 0 {
367371
qp.Add("ids", joinSliceStringer(opts.IDs))
368372
}

0 commit comments

Comments
 (0)