diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b597120655c40..6fe7e722fd414 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20841,6 +20841,11 @@ const docTemplate = `{ "type": "object", "properties": { "actual": { + "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds, with the precise value available in\nActualMs. FeatureAgentRuntimeHours usage can trail by roughly one\nhour because the current hour is not emitted, plus the entitlement\nrefresh interval.", + "type": "integer" + }, + "actual_ms": { + "description": "ActualMs is the precise usage backing Actual, in milliseconds, for\nfeatures measured in time. It has the same freshness as Actual.\nOnly FeatureAgentRuntimeHours sets this field.", "type": "integer" }, "enabled": { @@ -20854,14 +20859,15 @@ const docTemplate = `{ "type": "integer" }, "limit": { + "description": "Limit is the maximum value the license grants for the feature, in the\nfeature's own unit. For FeatureAgentRuntimeHours, an enabled feature\nwith Limit omitted means the license grants unlimited runtime hours.", "type": "integer" }, "soft_limit": { - "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation.\n\nOnly certain features set this field:\n- FeatureAgentRuntimeHours", + "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation; an unlimited allocation has no thresholds,\nso SoftLimit is omitted alongside the omitted Limit. Only\nFeatureAgentRuntimeHours sets this field.", "type": "integer" }, "usage_period": { - "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit\n- FeatureAgentRuntimeHours", + "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense). These dates are determined from the license that this\nentitlement comes from, see enterprise/coderd/license/license.go.\nOnly FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this\nfield.", "allOf": [ { "$ref": "#/definitions/codersdk.UsagePeriod" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b4a0291e90ebf..d658d05092b76 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18963,6 +18963,11 @@ "type": "object", "properties": { "actual": { + "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds, with the precise value available in\nActualMs. FeatureAgentRuntimeHours usage can trail by roughly one\nhour because the current hour is not emitted, plus the entitlement\nrefresh interval.", + "type": "integer" + }, + "actual_ms": { + "description": "ActualMs is the precise usage backing Actual, in milliseconds, for\nfeatures measured in time. It has the same freshness as Actual.\nOnly FeatureAgentRuntimeHours sets this field.", "type": "integer" }, "enabled": { @@ -18976,14 +18981,15 @@ "type": "integer" }, "limit": { + "description": "Limit is the maximum value the license grants for the feature, in the\nfeature's own unit. For FeatureAgentRuntimeHours, an enabled feature\nwith Limit omitted means the license grants unlimited runtime hours.", "type": "integer" }, "soft_limit": { - "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation.\n\nOnly certain features set this field:\n- FeatureAgentRuntimeHours", + "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation; an unlimited allocation has no thresholds,\nso SoftLimit is omitted alongside the omitted Limit. Only\nFeatureAgentRuntimeHours sets this field.", "type": "integer" }, "usage_period": { - "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit\n- FeatureAgentRuntimeHours", + "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense). These dates are determined from the license that this\nentitlement comes from, see enterprise/coderd/license/license.go.\nOnly FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this\nfield.", "allOf": [ { "$ref": "#/definitions/codersdk.UsagePeriod" diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c661819287712..321ec1e61d681 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4949,6 +4949,13 @@ func (q *querier) GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg databa return q.db.GetTotalUsageDCManagedAgentsV1(ctx, arg) } +func (q *querier) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg database.GetTotalUsageHBAgentRuntimeV1Params) (int64, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUsageEvent); err != nil { + return 0, err + } + return q.db.GetTotalUsageHBAgentRuntimeV1(ctx, arg) +} + func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceLicense); err != nil { return nil, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a9de44c460148..1c6497a2e128d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6532,6 +6532,14 @@ func (s *MethodTestSuite) TestUsageEvents() { }).Asserts(rbac.ResourceUsageEvent, policy.ActionRead) })) + s.Run("GetTotalUsageHBAgentRuntimeV1", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + db.EXPECT().GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()).Return(int64(1), nil) + check.Args(database.GetTotalUsageHBAgentRuntimeV1Params{ + StartTime: time.Time{}, + EndTime: time.Time{}, + }).Asserts(rbac.ResourceUsageEvent, policy.ActionRead) + })) + s.Run("ListUsageEventCreatedAtsByTypeSince", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { params := database.ListUsageEventCreatedAtsByTypeSinceParams{ EventType: "hb_agent_runtime_v1", diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 4a986ad1eb12a..63c9c0f563385 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3129,6 +3129,14 @@ func (m queryMetricsStore) GetTotalUsageDCManagedAgentsV1(ctx context.Context, a return r0, r1 } +func (m queryMetricsStore) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg database.GetTotalUsageHBAgentRuntimeV1Params) (int64, error) { + start := time.Now() + r0, r1 := m.s.GetTotalUsageHBAgentRuntimeV1(ctx, arg) + m.queryLatencies.WithLabelValues("GetTotalUsageHBAgentRuntimeV1").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetTotalUsageHBAgentRuntimeV1").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) { start := time.Now() r0, r1 := m.s.GetUnexpiredLicenses(ctx) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 4f09e5a4b7fd7..923416c68bdd8 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -5865,6 +5865,21 @@ func (mr *MockStoreMockRecorder) GetTotalUsageDCManagedAgentsV1(ctx, arg any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTotalUsageDCManagedAgentsV1", reflect.TypeOf((*MockStore)(nil).GetTotalUsageDCManagedAgentsV1), ctx, arg) } +// GetTotalUsageHBAgentRuntimeV1 mocks base method. +func (m *MockStore) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg database.GetTotalUsageHBAgentRuntimeV1Params) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTotalUsageHBAgentRuntimeV1", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTotalUsageHBAgentRuntimeV1 indicates an expected call of GetTotalUsageHBAgentRuntimeV1. +func (mr *MockStoreMockRecorder) GetTotalUsageHBAgentRuntimeV1(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTotalUsageHBAgentRuntimeV1", reflect.TypeOf((*MockStore)(nil).GetTotalUsageHBAgentRuntimeV1), ctx, arg) +} + // GetUnexpiredLicenses mocks base method. func (m *MockStore) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index bbc90e02859ef..9b489e25a5dc4 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -870,6 +870,20 @@ type sqlcQuerier interface { // the events that happened on and between the two dates. Both dates are // inclusive. GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error) + // Gets the total Coder Agent runtime in milliseconds between two timestamps. + // The start bound is inclusive and the end bound is exclusive. + // + // Unlike GetTotalUsageDCManagedAgentsV1 this reads usage_events directly + // rather than the usage_events_daily rollup: hb_agent_runtime_v1 is exactly + // one row per hourly bucket deployment-wide, with created_at at the bucket + // start, enforced by the unique partial index + // idx_usage_events_agent_runtime (which also keeps SUM from counting a + // bucket twice and serves this query). The result is bucket-granular: a + // bucket counts entirely against the period containing its start. See + // enterprise/coderd/usage/generator.go for what a bucket holds. If a + // usage_events retention policy ever lands, this must move to the daily + // rollup and accept day-granularity bounds. + GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg GetTotalUsageHBAgentRuntimeV1Params) (int64, error) GetUnexpiredLicenses(ctx context.Context) ([]License, error) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAIBudgetOverride, error) GetUserAIProviderKeyByProviderID(ctx context.Context, arg GetUserAIProviderKeyByProviderIDParams) (UserAIProviderKey, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 39d27c4475fef..2c4df71578110 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -35,6 +35,7 @@ import ( "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/usage/usagetypes" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/codersdk" @@ -10924,8 +10925,8 @@ func TestUsageEventsTrigger(t *testing.T) { require.Len(t, rows, 3) // The same bucket under a different id is not an idempotent - // re-insert but a duplicate that would double any aggregate summing - // runtime_ms; the unique partial index + // re-insert but a duplicate that would double the SUM in + // GetTotalUsageHBAgentRuntimeV1; the unique partial index // idx_usage_events_agent_runtime rejects it loudly instead of the // (id) arbiter silently dropping it. err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{ @@ -10989,6 +10990,87 @@ func TestUsageEventsTrigger(t *testing.T) { }) } +func TestGetTotalUsageHBAgentRuntimeV1(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + // hb_agent_runtime_v1 events are one row per hourly bucket, created_at + // set to the bucket start. + hour := func(d, h int) time.Time { + return time.Date(2025, 1, d, h, 0, 0, 0, time.UTC) + } + // The event type and payload are built from the producer's types rather + // than hand-written literals, so a rename in usagetypes fails this test + // instead of leaving the query silently summing a key nothing writes. + insert := func(id string, runtimeMs int64, createdAt time.Time) { + t.Helper() + event := usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs} + eventData, err := json.Marshal(event.Fields()) + require.NoError(t, err) + err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{ + ID: id, + EventType: string(event.EventType()), + EventData: eventData, + CreatedAt: createdAt, + }) + require.NoError(t, err) + } + total := func(start, end time.Time) int64 { + t.Helper() + got, err := db.GetTotalUsageHBAgentRuntimeV1(ctx, database.GetTotalUsageHBAgentRuntimeV1Params{ + StartTime: start, + EndTime: end, + }) + require.NoError(t, err) + return got + } + + // No events at all sums to zero rather than NULL. + require.EqualValues(t, 0, total(hour(1, 0), hour(5, 0))) + + insert("rt-d1h0", 1000, hour(1, 0)) + insert("rt-d1h12", 500, hour(1, 12)) + insert("rt-d1h18", 0, hour(1, 18)) + insert("rt-d2h0", 250, hour(2, 0)) + insert("rt-d4h0", 7, hour(4, 0)) + + // A multi-day range sums every bucket it covers. + require.EqualValues(t, 1757, total(hour(1, 0), hour(5, 0))) + + // The start bound is inclusive and the end bound is exclusive: a bucket + // starting exactly at the end timestamp belongs to the next period. + require.EqualValues(t, 1500, total(hour(1, 0), hour(2, 0))) + require.EqualValues(t, 1750, total(hour(1, 0), hour(2, 1))) + require.EqualValues(t, 250, total(hour(2, 0), hour(4, 0))) + require.EqualValues(t, 0, total(hour(3, 0), hour(4, 0))) + + // Bounds are exact timestamps rather than whole days: a period starting + // mid-day excludes that day's earlier buckets. + require.EqualValues(t, 757, total(hour(1, 12), hour(5, 0))) + + // A non-UTC timestamp addresses the same instant. Sydney is UTC+11 in + // January, so 23:00 on Jan 1 in Sydney is 12:00 on Jan 1 in UTC. + locSydney, err := time.LoadLocation("Australia/Sydney") + require.NoError(t, err) + require.EqualValues(t, 750, total( + time.Date(2025, 1, 1, 23, 0, 0, 0, locSydney), + time.Date(2025, 1, 2, 12, 0, 0, 0, locSydney), + )) + + // Other event types are never mixed in, even when they carry a + // runtime_ms key: without the event_type filter this would add 9999. + err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{ + ID: "seats-1", + EventType: "hb_ai_seats_v1", + EventData: []byte(`{"count": 1, "runtime_ms": 9999}`), + CreatedAt: hour(1, 0), + }) + require.NoError(t, err) + require.EqualValues(t, 1757, total(hour(1, 0), hour(5, 0))) +} + func TestGetTotalChatMessageRuntimeMsInRange(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f1029f44a15b6..c4a5ea782b4a3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -28685,6 +28685,44 @@ func (q *sqlQuerier) GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg Get return total_count, err } +const getTotalUsageHBAgentRuntimeV1 = `-- name: GetTotalUsageHBAgentRuntimeV1 :one +SELECT + -- The first cast is necessary since you can't sum strings, and the second + -- cast is necessary to make sqlc happy. + COALESCE(SUM((event_data->>'runtime_ms')::bigint), 0)::bigint AS total_runtime_ms +FROM + usage_events +WHERE + event_type = 'hb_agent_runtime_v1' + AND created_at >= $1::timestamptz + AND created_at < $2::timestamptz +` + +type GetTotalUsageHBAgentRuntimeV1Params struct { + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` +} + +// Gets the total Coder Agent runtime in milliseconds between two timestamps. +// The start bound is inclusive and the end bound is exclusive. +// +// Unlike GetTotalUsageDCManagedAgentsV1 this reads usage_events directly +// rather than the usage_events_daily rollup: hb_agent_runtime_v1 is exactly +// one row per hourly bucket deployment-wide, with created_at at the bucket +// start, enforced by the unique partial index +// idx_usage_events_agent_runtime (which also keeps SUM from counting a +// bucket twice and serves this query). The result is bucket-granular: a +// bucket counts entirely against the period containing its start. See +// enterprise/coderd/usage/generator.go for what a bucket holds. If a +// usage_events retention policy ever lands, this must move to the daily +// rollup and accept day-granularity bounds. +func (q *sqlQuerier) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg GetTotalUsageHBAgentRuntimeV1Params) (int64, error) { + row := q.db.QueryRowContext(ctx, getTotalUsageHBAgentRuntimeV1, arg.StartTime, arg.EndTime) + var total_runtime_ms int64 + err := row.Scan(&total_runtime_ms) + return total_runtime_ms, err +} + const insertUsageEvent = `-- name: InsertUsageEvent :exec INSERT INTO usage_events ( diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql index 8ba706b0c8ae9..32c4594295e13 100644 --- a/coderd/database/queries/usageevents.sql +++ b/coderd/database/queries/usageevents.sql @@ -117,3 +117,28 @@ WHERE -- Parentheses are necessary to avoid sqlc from generating an extra -- argument. AND day BETWEEN date_trunc('day', (@start_date::timestamptz) AT TIME ZONE 'UTC')::date AND date_trunc('day', (@end_date::timestamptz) AT TIME ZONE 'UTC')::date; + +-- name: GetTotalUsageHBAgentRuntimeV1 :one +-- Gets the total Coder Agent runtime in milliseconds between two timestamps. +-- The start bound is inclusive and the end bound is exclusive. +-- +-- Unlike GetTotalUsageDCManagedAgentsV1 this reads usage_events directly +-- rather than the usage_events_daily rollup: hb_agent_runtime_v1 is exactly +-- one row per hourly bucket deployment-wide, with created_at at the bucket +-- start, enforced by the unique partial index +-- idx_usage_events_agent_runtime (which also keeps SUM from counting a +-- bucket twice and serves this query). The result is bucket-granular: a +-- bucket counts entirely against the period containing its start. See +-- enterprise/coderd/usage/generator.go for what a bucket holds. If a +-- usage_events retention policy ever lands, this must move to the daily +-- rollup and accept day-granularity bounds. +SELECT + -- The first cast is necessary since you can't sum strings, and the second + -- cast is necessary to make sqlc happy. + COALESCE(SUM((event_data->>'runtime_ms')::bigint), 0)::bigint AS total_runtime_ms +FROM + usage_events +WHERE + event_type = 'hb_agent_runtime_v1' + AND created_at >= @start_time::timestamptz + AND created_at < @end_time::timestamptz; diff --git a/coderd/database/queries_internal_test.go b/coderd/database/queries_internal_test.go new file mode 100644 index 0000000000000..798a340533591 --- /dev/null +++ b/coderd/database/queries_internal_test.go @@ -0,0 +1,28 @@ +package database + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/usage/usagetypes" +) + +// TestGetTotalUsageHBAgentRuntimeV1QueryEventType pins the event type and +// payload extraction literals in the generated SQL to the Go producer. +// Renaming either would make this read-only query silently return 0 (->> on +// a missing key yields NULL, SUM skips NULLs, COALESCE reports 0), which is +// indistinguishable from zero usage at every layer above it. +func TestGetTotalUsageHBAgentRuntimeV1QueryEventType(t *testing.T) { + t.Parallel() + + require.Contains(t, getTotalUsageHBAgentRuntimeV1, + string(usagetypes.UsageEventTypeHBAgentRuntimeV1)) + // The full extraction expression is pinned, not the bare key: the + // query's result alias (total_runtime_ms) contains "runtime_ms", so a + // bare-key assertion would keep passing after the ->> key was renamed. + for field := range (usagetypes.HBAgentRuntime{}).Fields() { + require.Contains(t, getTotalUsageHBAgentRuntimeV1, + "event_data->>'"+field+"'") + } +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index d5550c6a888ac..769fade108cab 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -380,32 +380,42 @@ func (set FeatureSet) Features() []FeatureName { type Feature struct { Entitlement Entitlement `json:"entitlement"` Enabled bool `json:"enabled"` - Limit *int64 `json:"limit,omitempty"` + // Limit is the maximum value the license grants for the feature, in the + // feature's own unit. For FeatureAgentRuntimeHours, an enabled feature + // with Limit omitted means the license grants unlimited runtime hours. + Limit *int64 `json:"limit,omitempty"` // SoftLimit is the advisory warning threshold that accompanies Limit for // features whose license carries it. For these features, Limit carries - // the purchased allocation. - // - // Only certain features set this field: - // - FeatureAgentRuntimeHours + // the purchased allocation; an unlimited allocation has no thresholds, + // so SoftLimit is omitted alongside the omitted Limit. Only + // FeatureAgentRuntimeHours sets this field. SoftLimit *int64 `json:"soft_limit,omitempty"` // HardLimit is the enforcement threshold that accompanies Limit for // features whose license carries it. See SoftLimit for the set of // features that use these thresholds. HardLimit *int64 `json:"hard_limit,omitempty"` - Actual *int64 `json:"actual,omitempty"` + // Actual is the usage measured against Limit, when known: a + // point-in-time count for most features, or usage accumulated over + // UsagePeriod for features that set one. Its unit matches Limit's; + // FeatureAgentRuntimeHours reports whole hours floored from the + // recorded milliseconds, with the precise value available in + // ActualMs. FeatureAgentRuntimeHours usage can trail by roughly one + // hour because the current hour is not emitted, plus the entitlement + // refresh interval. + Actual *int64 `json:"actual,omitempty"` + // ActualMs is the precise usage backing Actual, in milliseconds, for + // features measured in time. It has the same freshness as Actual. + // Only FeatureAgentRuntimeHours sets this field. + ActualMs *int64 `json:"actual_ms,omitempty"` // Below is only for features that use usage periods. // UsagePeriod denotes that the usage is a counter that accumulates over // this period (and most likely resets with the issuance of the next - // license). - // - // These dates are determined from the license that this entitlement comes - // from, see enterprise/coderd/license/license.go. - // - // Only certain features set these fields: - // - FeatureManagedAgentLimit - // - FeatureAgentRuntimeHours + // license). These dates are determined from the license that this + // entitlement comes from, see enterprise/coderd/license/license.go. + // Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this + // field. UsagePeriod *UsagePeriod `json:"usage_period,omitempty"` } diff --git a/codersdk/licenses.go b/codersdk/licenses.go index 414cfbcf04b49..59dc470e217bb 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -12,12 +12,19 @@ import ( ) const ( - LicenseExpiryClaim = "license_expires" - LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled" - LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows." - LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats." - LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)." - LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued." + LicenseExpiryClaim = "license_expires" + LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled" + LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows." + LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats." + LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)." + // The dashboard's LicenseBanner matches this text's pre-placeholder + // prefix to render it muted and without a sales link, so the license + // warning texts must stay pairwise distinct before their first + // placeholder. See TestLicenseAgentRuntimeHoursWarningTexts. + LicenseAgentRuntimeHoursSoftLimitWarningText = "Your deployment is approaching its Coder Agent runtime hours allocation: %d of the %d hours included in the current license term are used, at or above the advisory soft limit of %d hours." + LicenseAgentRuntimeHoursAllocationReachedWarningText = "Your deployment has used %d of the %d Coder Agent runtime hours included in the current license term." + LicenseAgentRuntimeUsageUnavailableErrorText = "Unable to determine Coder Agent runtime usage. Reported runtime hours are unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details." + LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued." ) type AddLicenseRequest struct { diff --git a/codersdk/licenses_test.go b/codersdk/licenses_test.go new file mode 100644 index 0000000000000..2042cc50aaf2a --- /dev/null +++ b/codersdk/licenses_test.go @@ -0,0 +1,50 @@ +package codersdk_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +// TestLicenseAgentRuntimeHoursWarningTexts pins the warning-text prefix +// couplings consumed by the dashboard's LicenseBanner +// (site/src/modules/dashboard/LicenseBanner). +func TestLicenseAgentRuntimeHoursWarningTexts(t *testing.T) { + t.Parallel() + + // Cut rather than Split so a template losing its placeholder fails the + // test instead of silently turning the whole message into the "prefix". + templatePrefix := func(text, placeholder string) string { + t.Helper() + prefix, _, ok := strings.Cut(text, placeholder) + require.True(t, ok, "template %q must contain placeholder %q", text, placeholder) + return prefix + } + + aiGovNearLimitPrefix := templatePrefix(codersdk.LicenseAIGovernance90PercentWarningText, "%d%%") + aiGovOverLimitPrefix := templatePrefix(codersdk.LicenseAIGovernanceOverLimitWarningText, "%d") + softLimitPrefix := templatePrefix(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, "%d") + + runtimeTexts := map[string]string{ + "SoftLimit": codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, + "AllocationReached": codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, + } + for name, text := range runtimeTexts { + // isMutedWarning renders near-limit matches muted, and + // isAIGovernanceWarning matches either AI Governance prefix to + // suppress the banner's client-side over-limit fallback. + require.False(t, strings.HasPrefix(text, aiGovNearLimitPrefix), + "%s warning must not share the AI Governance near-limit prefix %q", name, aiGovNearLimitPrefix) + require.False(t, strings.HasPrefix(text, aiGovOverLimitPrefix), + "%s warning must not share the AI Governance over-limit prefix %q", name, aiGovOverLimitPrefix) + } + + // isMutedWarning renders soft-limit matches muted and messageLink drops + // their sales link, so the allocation-reached warning must not match. + allocationReachedText := codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText + require.False(t, strings.HasPrefix(allocationReachedText, softLimitPrefix), + "the soft-limit prefix must not classify the allocation-reached warning") +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index a6c7114b16252..aada5a73777a8 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -574,6 +574,7 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \ "features": { "property1": { "actual": 0, + "actual_ms": 0, "enabled": true, "entitlement": "entitled", "hard_limit": 0, @@ -587,6 +588,7 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \ }, "property2": { "actual": 0, + "actual_ms": 0, "enabled": true, "entitlement": "entitled", "hard_limit": 0, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 70d14b8e750c1..b7c446a9347f2 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7541,6 +7541,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "features": { "property1": { "actual": 0, + "actual_ms": 0, "enabled": true, "entitlement": "entitled", "hard_limit": 0, @@ -7554,6 +7555,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o }, "property2": { "actual": 0, + "actual_ms": 0, "enabled": true, "entitlement": "entitled", "hard_limit": 0, @@ -7841,6 +7843,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ```json { "actual": 0, + "actual_ms": 0, "enabled": true, "entitlement": "entitled", "hard_limit": 0, @@ -7856,18 +7859,16 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|----------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `actual` | integer | false | | | -| `enabled` | boolean | false | | | -| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | -| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. | -| `limit` | integer | false | | | -|`soft_limit`|integer|false||Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation. -Only certain features set this field: - FeatureAgentRuntimeHours| -|`usage_period`|[codersdk.UsagePeriod](#codersdkusageperiod)|false||Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). -These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. -Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgentRuntimeHours| +| Name | Type | Required | Restrictions | Description | +|----------------|----------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `actual` | integer | false | | Actual is the usage measured against Limit, when known: a point-in-time count for most features, or usage accumulated over UsagePeriod for features that set one. Its unit matches Limit's; FeatureAgentRuntimeHours reports whole hours floored from the recorded milliseconds, with the precise value available in ActualMs. FeatureAgentRuntimeHours usage can trail by roughly one hour because the current hour is not emitted, plus the entitlement refresh interval. | +| `actual_ms` | integer | false | | Actual ms is the precise usage backing Actual, in milliseconds, for features measured in time. It has the same freshness as Actual. Only FeatureAgentRuntimeHours sets this field. | +| `enabled` | boolean | false | | | +| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | +| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. | +| `limit` | integer | false | | Limit is the maximum value the license grants for the feature, in the feature's own unit. For FeatureAgentRuntimeHours, an enabled feature with Limit omitted means the license grants unlimited runtime hours. | +| `soft_limit` | integer | false | | Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation; an unlimited allocation has no thresholds, so SoftLimit is omitted alongside the omitted Limit. Only FeatureAgentRuntimeHours sets this field. | +| `usage_period` | [codersdk.UsagePeriod](#codersdkusageperiod) | false | | Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this field. | ## codersdk.FriendlyDiagnostic diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 366a853706e2e..b58ee4e79aba5 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -121,6 +121,15 @@ func Entitlements( EndDate: endTime, }) }, + AgentRuntimeMsFn: func(ctx context.Context, startTime time.Time, endTime time.Time) (int64, error) { + // Bounds and bucket semantics are documented on the query. + // + // nolint:gocritic // Reading usage events requires the usage publisher subject. + return db.GetTotalUsageHBAgentRuntimeV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageHBAgentRuntimeV1Params{ + StartTime: startTime, + EndTime: endTime, + }) + }, }) if err != nil { return entitlements, err @@ -140,6 +149,9 @@ type FeatureArguments struct { // state of the world, but a count between two points in time determined by // the licenses. ManagedAgentCountFn ManagedAgentCountFn + // AgentRuntimeMsFn is queried with two points in time determined by the + // licenses, like the managed agent count above. + AgentRuntimeMsFn AgentRuntimeMsFn // UserCountingMode selects the count that FeatureUserLimit candidates // from AI Governance addon licenses are evaluated against. Under // UserCountingModeWorkspaceCapable they use WorkspaceCapableUserCountFn's @@ -173,6 +185,10 @@ const ( type ManagedAgentCountFn func(ctx context.Context, from time.Time, to time.Time) (int64, error) +// AgentRuntimeMsFn returns the total Coder Agent runtime in milliseconds +// recorded between from (inclusive) and to (exclusive). +type AgentRuntimeMsFn = ManagedAgentCountFn + type WorkspaceCapableUserCountFn func(ctx context.Context) (int64, error) // userLimitCandidate is one license's FeatureUserLimit terms: its seat limit, @@ -465,6 +481,37 @@ func LicensesEntitlements( End: defaultManagedAgentsEnd, }, }) + + // Premium licenses without agent_runtime_hours_* claims are + // grandfathered into a zero-hour allocation: the feature is + // granted disabled with a zero limit, which measures and + // publishes usage (see the measureAgentRuntimeMs call below) + // and caps concurrent agentic chats the same as an explicit + // zero allocation. + var ( + // A fixed issue time that predates any license issued with + // agent_runtime_hours_* claims, so a license that actually + // carries those claims outranks this default in + // Feature.Compare (IssuedAt-first for usage period features) + // regardless of the licenses' relative issue dates. This + // must remain earlier than the earliest legitimately issued + // claim-bearing license. + defaultAgentRuntimeHoursIssuedAt = time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + defaultAgentRuntimeHoursLimit int64 + ) + entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, codersdk.Feature{ + Enabled: false, + Entitlement: entitlement, + Limit: &defaultAgentRuntimeHoursLimit, + UsagePeriod: &codersdk.UsagePeriod{ + IssuedAt: defaultAgentRuntimeHoursIssuedAt, + // The license term, matching a license with an explicit + // zero allocation, so measured usage covers the current + // term. + Start: usagePeriodStart, + End: usagePeriodEnd, + }, + }) } // TODO: Remove this tracking once AI Bridge is enforced as an add-on license. @@ -736,6 +783,44 @@ func LicensesEntitlements( } } + // Usage is measured even for a zero allocation, which reports the + // feature disabled: see decodeAgentRuntimeHours. Premium licenses + // without agent runtime hour claims grant the same disabled zero-limit + // feature (see the grandfather default above), so every premium + // deployment reports usage here. Reported usage can trail real usage; + // the sources of staleness and loss are documented on the + // enterprise/coderd/usage.AgentRuntime* constants. + runtimeHours := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + if entitlements.HasLicense && runtimeHours.UsagePeriod != nil { + runtimeMs, ok, err := measureAgentRuntimeMs(ctx, &entitlements, + featureArguments.Logger, featureArguments.AgentRuntimeMsFn, *runtimeHours.UsagePeriod) + if err != nil { + return entitlements, err + } + if ok { + actualHours := agentRuntimeMsToHours(runtimeMs) + runtimeHours.Actual = &actualHours + // ActualMs carries the exact stored milliseconds so clients can + // render fractional hours. Negative input clamps to 0, mirroring + // agentRuntimeMsToHours, since AgentRuntimeMsFn is a + // caller-supplied seam. + actualMs := max(runtimeMs, 0) + runtimeHours.ActualMs = &actualMs + // Written back directly rather than through AddFeature: + // AddFeature only replaces the existing entry when the new one + // strictly outranks it, so setting Actual on an otherwise + // identical feature would be dropped as a tie. + entitlements.Features[codersdk.FeatureAgentRuntimeHours] = runtimeHours + + // A nil Limit means the license grants unlimited runtime + // hours: no thresholds can exist, so no warnings. + if runtimeHours.Limit != nil { + entitlements.Warnings = appendAgentRuntimeHoursWarning( + entitlements.Warnings, actualHours, *runtimeHours.Limit, runtimeHours.SoftLimit) + } + } + } + if entitlements.HasLicense { userLimit := entitlements.Features[codersdk.FeatureUserLimit] // The enforced count and its meaning come from the selected @@ -865,6 +950,63 @@ func LicensesEntitlements( return entitlements, nil } +// measureAgentRuntimeMs runs fn over the feature's usage period. A nil fn +// or a failure with a dead context fails the whole call; any other failure +// logs the cause and publishes the stable unavailable text instead. It +// returns the measured milliseconds and true only on success. +func measureAgentRuntimeMs( + ctx context.Context, + entitlements *codersdk.Entitlements, + logger slog.Logger, + fn AgentRuntimeMsFn, + usagePeriod codersdk.UsagePeriod, +) (int64, bool, error) { + if fn == nil { + return 0, false, xerrors.New("developer error: no closure provided to measure agent runtime usage") + } + value, err := fn(ctx, usagePeriod.Start, usagePeriod.End) + switch { + case err != nil && ctx.Err() != nil: + // Do not classify cancellation by error shape instead of ctx.Err(): + // Postgres raises SQLSTATE 57014 (query_canceled) for + // statement_timeout kills as well as client cancels, and aborting on + // those would fail every entitlements refresh on a deployment whose + // statement_timeout is shorter than a usage query. + return 0, false, xerrors.Errorf("get agent runtime: %w", err) + case err != nil: + logger.Error(ctx, "get agent runtime for entitlements", slog.Error(err)) + entitlements.Errors = append(entitlements.Errors, codersdk.LicenseAgentRuntimeUsageUnavailableErrorText) + return 0, false, nil + } + return value, true, nil +} + +// appendAgentRuntimeHoursWarning appends at most one warning: reaching the +// allocation supersedes the advisory soft limit, so the dashboard banner +// never stacks both messages. +func appendAgentRuntimeHoursWarning(warnings []string, actualHours int64, allocation int64, softLimit *int64) []string { + // A zero allocation (explicit or the grandfathered premium default) has + // no thresholds to warn about: those deployments are steered by the + // in-page upgrade CTA and the concurrent chat cap, not a + // deployment-wide banner. + if allocation <= 0 { + return warnings + } + + switch { + case actualHours >= allocation: + return append(warnings, fmt.Sprintf( + codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, + actualHours, allocation)) + case softLimit != nil && actualHours >= *softLimit: + return append(warnings, fmt.Sprintf( + codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, + actualHours, allocation, *softLimit)) + } + + return warnings +} + func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string { if limit <= 0 { return warnings @@ -948,6 +1090,19 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool { } } +// agentRuntimeMsToHours floors milliseconds of Coder Agent runtime to whole +// hours, the unit shared by the agent_runtime_hours_* claims and the +// feature's limits. Flooring keeps the rendered value and the whole-hour +// warning thresholds in agreement. Negative input (not producible by the +// production query, but AgentRuntimeMsFn is a caller-supplied seam) clamps +// to 0. +func agentRuntimeMsToHours(ms int64) int64 { + if ms <= 0 { + return 0 + } + return ms / int64(time.Hour/time.Millisecond) +} + // decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours // feature from its claims. granted is false when there is no usable // allocation claim; per-claim validity rules live on the Claim* constants diff --git a/enterprise/coderd/license/license_internal_test.go b/enterprise/coderd/license/license_internal_test.go index 616f0b5b989b9..161c980e436c8 100644 --- a/enterprise/coderd/license/license_internal_test.go +++ b/enterprise/coderd/license/license_internal_test.go @@ -1,10 +1,15 @@ package license import ( + "fmt" + "math" "testing" "time" "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" ) func TestNextLicenseValidityPeriod(t *testing.T) { @@ -138,3 +143,80 @@ func permutations[T any](arr []T) [][]T { helper(arr, 0) return res } + +func TestAgentRuntimeMsToHours(t *testing.T) { + t.Parallel() + + const hourMs = int64(60 * 60 * 1000) + + testCases := []struct { + name string + ms int64 + want int64 + }{ + {"Zero", 0, 0}, + // Any runtime below an hour floors to zero. + {"OneMillisecond", 1, 0}, + {"JustUnderAnHour", hourMs - 1, 0}, + {"ExactlyOneHour", hourMs, 1}, + {"JustOverAnHour", hourMs + 1, 1}, + {"JustUnderTwoHours", 2*hourMs - 1, 1}, + {"ExactlyTwoHours", 2 * hourMs, 2}, + // A realistic month of continuous runtime. + {"Large", 720 * hourMs, 720}, + // Pins the divisor as milliseconds per hour. + {"MaxInt64", math.MaxInt64, math.MaxInt64 / hourMs}, + // Negative input is not expected from the production query, which + // coalesces NULL to 0, but it must never produce a negative hour + // count that would compare oddly against the license limits. + {"Negative", -1, 0}, + {"NegativeHour", -hourMs, 0}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, agentRuntimeMsToHours(tc.ms)) + }) + } +} + +// TestAppendAgentRuntimeHoursWarning pins the warning arithmetic: thresholds +// are "reached" (>=), and reaching the allocation supersedes the advisory +// soft limit so at most one warning is appended. +func TestAppendAgentRuntimeHoursWarning(t *testing.T) { + t.Parallel() + + softLimit := ptr.Ref[int64](80) + softWarning := func(actual int64) []string { + return []string{fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, actual, 100, 80)} + } + allocationWarning := func(actual int64) []string { + return []string{fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, actual, 100)} + } + + testCases := []struct { + name string + actual int64 + allocation int64 + softLimit *int64 + want []string + }{ + {"ZeroAllocation", 50, 0, softLimit, nil}, + {"NegativeAllocation", 50, -1, softLimit, nil}, + {"BelowSoftLimit", 79, 100, softLimit, nil}, + {"AtSoftLimit", 80, 100, softLimit, softWarning(80)}, + {"BetweenSoftLimitAndAllocation", 99, 100, softLimit, softWarning(99)}, + {"AtAllocationSupersedesSoftLimit", 100, 100, softLimit, allocationWarning(100)}, + {"OverAllocation", 150, 100, softLimit, allocationWarning(150)}, + {"NoSoftLimitBelowAllocation", 99, 100, nil, nil}, + {"NoSoftLimitAtAllocation", 100, 100, nil, allocationWarning(100)}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, appendAgentRuntimeHoursWarning(nil, tc.actual, tc.allocation, tc.softLimit)) + }) + } +} diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 24bd86b326c18..e52eaab094de8 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -11,13 +11,16 @@ import ( "time" "github.com/google/uuid" + "github.com/lib/pq" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "golang.org/x/xerrors" "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/sloghuman" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -35,6 +38,44 @@ import ( // licensing experiment, so it is never asked to authorize anything. var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry()) +// premiumRuntimeHoursFixture returns a mock store primed with a Premium +// license carrying runtime hour claims (allocation 100, soft limit 80, hard +// limit 120) plus the store expectations every entitlements refresh consumes +// before usage is measured. Callers add expectations for the usage queries +// under test. +func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) { + t.Helper() + + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + + licenseOpts := (&coderdenttest.LicenseOptions{ + FeatureSet: codersdk.FeatureSetPremium, + IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second), + NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second), + // GraceAt and ExpiresAt are far enough out that the license-expiry + // warning cannot pollute the callers' warning assertions. + GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), + ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), + // The addon marks AI Bridge as explicitly entitled, suppressing + // the unrelated "AI Governance add-on is required to use AI + // Gateway" warning that Premium would otherwise produce. + }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120)) + + lic := database.License{ + ID: 1, + JWT: coderdenttest.GenerateLicense(t, *licenseOpts), + Exp: licenseOpts.ExpiresAt, + } + + mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil) + mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil) + mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil) + mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil) + + return mDB, licenseOpts +} + func TestEntitlements(t *testing.T) { t.Parallel() all := make(map[codersdk.FeatureName]bool) @@ -601,6 +642,24 @@ func TestEntitlements(t *testing.T) { require.WithinDuration(t, agentUsagePeriodEnd, agentEntitlement.UsagePeriod.End, time.Second) continue } + if featureName == codersdk.FeatureAgentRuntimeHours { + // Premium licenses without agent runtime hour claims are + // grandfathered into a zero-hour allocation over the + // license term, with usage still measured. See license.go + // for more details. + runtimeEntitlement := entitlements.Features[featureName] + require.False(t, runtimeEntitlement.Enabled) + require.Equal(t, codersdk.EntitlementEntitled, runtimeEntitlement.Entitlement) + require.NotNil(t, runtimeEntitlement.Limit) + require.EqualValues(t, 0, *runtimeEntitlement.Limit) + require.NotNil(t, runtimeEntitlement.UsagePeriod) + require.Equal(t, time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC), runtimeEntitlement.UsagePeriod.IssuedAt) + require.WithinDuration(t, licenseOptions.NotBefore, runtimeEntitlement.UsagePeriod.Start, time.Second) + require.WithinDuration(t, licenseOptions.ExpiresAt, runtimeEntitlement.UsagePeriod.End, time.Second) + require.NotNil(t, runtimeEntitlement.Actual) + require.EqualValues(t, 0, *runtimeEntitlement.Actual) + continue + } if featureName.IsAddonFeature() { continue } @@ -896,6 +955,12 @@ func TestEntitlements(t *testing.T) { return true })). Return(int64(175), nil) + // The premium grandfather default grants a zero-hour agent runtime + // allocation, so that usage is queried too. It is not what this + // test is about. + mDB.EXPECT(). + GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()). + Return(int64(0), nil) mDB.EXPECT(). GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) @@ -925,6 +990,167 @@ func TestEntitlements(t *testing.T) { require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0]) }) + t.Run("AgentRuntimeHoursHasValue", func(t *testing.T) { + t.Parallel() + + // Use a mock database so the production closure that reads + // usage_events can be observed directly. + mDB, licenseOpts := premiumRuntimeHoursFixture(t) + + // The Premium feature set grants a default managed agent limit, so + // that usage is queried too. It is not what this test is about. + mDB.EXPECT(). + GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()). + Return(int64(0), nil) + mDB.EXPECT(). + GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Cond(func(params database.GetTotalUsageHBAgentRuntimeV1Params) bool { + // gomock doesn't seem to compare times very nicely, so check + // them manually. The bounds must be the usage period of the + // winning license. + if !assert.WithinDuration(t, licenseOpts.NotBefore, params.StartTime, time.Second) { + return false + } + if !assert.WithinDuration(t, licenseOpts.ExpiresAt, params.EndTime, time.Second) { + return false + } + return true + })). + // 90h30m of runtime floors to 90 hours. + Return((90*time.Hour + 30*time.Minute).Milliseconds(), nil) + + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.Empty(t, entitlements.Errors) + + runtimeHours, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.True(t, ok) + require.NotNil(t, runtimeHours.Actual) + require.EqualValues(t, 90, *runtimeHours.Actual) + require.NotNil(t, runtimeHours.Limit) + require.EqualValues(t, 100, *runtimeHours.Limit) + + // 90 hours is past the soft limit of 80 but below the allocation of + // 100, so only the soft warning is emitted. + require.Len(t, entitlements.Warnings, 1) + require.Equal(t, + fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 90, 100, 80), + entitlements.Warnings[0]) + }) + + // An unlimited (-1) allocation still measures and publishes Actual, but + // never emits a runtime hours warning regardless of usage. + t.Run("AgentRuntimeHoursUnlimited", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + + licenseOpts := (&coderdenttest.LicenseOptions{ + FeatureSet: codersdk.FeatureSetPremium, + IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second), + NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second), + GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning + ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning + }).UserLimit(100).AIGovernanceAddon(100). + AgentRuntimeHours(license.AgentRuntimeHoursUnlimitedAllocation, nil, nil) + + lic := database.License{ + ID: 1, + JWT: coderdenttest.GenerateLicense(t, *licenseOpts), + Exp: licenseOpts.ExpiresAt, + } + + mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil) + mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil) + mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil) + mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil) + mDB.EXPECT().GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).Return(int64(0), nil) + mDB.EXPECT(). + GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()). + // Usage far beyond any plausible metered allocation. + Return((1_000_000 * time.Hour).Milliseconds(), nil) + + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.Empty(t, entitlements.Errors) + + runtimeHours, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.True(t, ok) + require.True(t, runtimeHours.Enabled) + require.Nil(t, runtimeHours.Limit) + require.Nil(t, runtimeHours.SoftLimit) + require.Nil(t, runtimeHours.HardLimit) + require.NotNil(t, runtimeHours.UsagePeriod) + require.NotNil(t, runtimeHours.Actual) + require.EqualValues(t, 1_000_000, *runtimeHours.Actual) + + require.Empty(t, entitlements.Warnings) + }) + + t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) { + t.Parallel() + + // Drive the real Entitlements closures with a mock database so + // measureAgentRuntimeMs's failure path is exercised end to end: the cause + // must land in the coderd log, which the stable payload texts point + // at, and must not land on the unauthenticated entitlements payload. + mDB, _ := premiumRuntimeHoursFixture(t) + + mDB.EXPECT(). + GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()). + Return(int64(0), nil) + mDB.EXPECT(). + GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()). + Return(int64(0), xerrors.New("kaboom runtime")) + + // The error-level logs are the behavior under test, so the default + // failing test logger cannot be used. + var logBuf bytes.Buffer + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}). + AppendSinks(sloghuman.Sink(&logBuf)) + + entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + + // The failure surfaces its stable text without the raw cause. + require.Contains(t, entitlements.Errors, codersdk.LicenseAgentRuntimeUsageUnavailableErrorText) + for _, entry := range append(entitlements.Errors, entitlements.Warnings...) { + require.NotContains(t, entry, "kaboom") + } + + logs := logBuf.String() + require.Contains(t, logs, "get agent runtime for entitlements") + require.Contains(t, logs, "kaboom runtime") + }) + + t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) { + t.Parallel() + + // A query failing while the refresh's own context is canceled, + // e.g. during shutdown, aborts the whole entitlements refresh and + // must not log a false query-failure alarm at error level. + mDB, _ := premiumRuntimeHoursFixture(t) + + mDB.EXPECT(). + GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()). + Return(int64(0), nil) + mDB.EXPECT(). + GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()). + Return(int64(0), context.Canceled) + + var logBuf bytes.Buffer + logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil) + require.ErrorContains(t, err, "get agent runtime") + require.NotContains(t, logBuf.String(), "get agent runtime for entitlements") + }) + t.Run("AIGovernanceSeatWarnings", func(t *testing.T) { t.Parallel() @@ -1014,6 +1240,12 @@ func TestEntitlements(t *testing.T) { mDB.EXPECT(). GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()). Return(int64(0), nil) + // The premium grandfather default grants a zero-hour agent + // runtime allocation, so that usage is queried too. It is + // not what this test is about. + mDB.EXPECT(). + GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()). + Return(int64(0), nil) mDB.EXPECT(). GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) @@ -1207,6 +1439,12 @@ func TestEntitlements(t *testing.T) { mDB.EXPECT(). GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()). Return(int64(0), nil) + // The premium grandfather default grants a zero-hour agent + // runtime allocation, so that usage is queried too. It is not + // what this test is about. + mDB.EXPECT(). + GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()). + Return(int64(0), nil) mDB.EXPECT(). GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) @@ -1284,6 +1522,42 @@ func TestLicenseEntitlements(t *testing.T) { }).Valid(time.Now()) } + // agentRuntimeHoursLicense builds an enterprise license carrying the + // agent runtime hour claims. A nil softLimit omits the claim; any + // non-nil value is minted verbatim so tests can construct zero or + // nonsensical soft limits. A positive allocation also carries a hard + // limit above the allocation (decodeAgentRuntimeHours ignores a lower + // one). + agentRuntimeHoursLicense := func(allocation int64, softLimit *int64) *coderdenttest.LicenseOptions { + var hard *int64 + if allocation > 0 { + hard = ptr.Ref(allocation + 20) + } + return enterpriseLicense().UserLimit(100).AgentRuntimeHours(allocation, softLimit, hard) + } + + // hoursToMsFn reports whole hours of runtime as the milliseconds the usage + // events actually record. + hoursToMsFn := func(hours int64) license.AgentRuntimeMsFn { + return func(_ context.Context, _, _ time.Time) (int64, error) { + return (time.Duration(hours) * time.Hour).Milliseconds(), nil + } + } + + // Captured by AgentRuntimeHours/UsagePeriodBounds. Only that case + // reads or writes these, so parallel siblings cannot race them. + var agentRuntimeUsageQueryFrom, agentRuntimeUsageQueryTo time.Time + var agentRuntimeUsageQueryCalled bool + + // grandfatherIssuedAt is the fixed UsagePeriod.IssuedAt carried by the + // zero-hour agent runtime allocation that premium licenses without + // agent runtime hour claims are grandfathered into; see license.go. + grandfatherIssuedAt := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + // runtimeClaimIssuedAt mints claim-bearing licenses in the grandfather + // precedence cases below, so the merged feature's UsagePeriod.IssuedAt + // identifies which candidate won. + runtimeClaimIssuedAt := dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second) + premiumLicense := func() *coderdenttest.LicenseOptions { return (&coderdenttest.LicenseOptions{ AccountType: "salesforce", @@ -1304,6 +1578,12 @@ func TestLicenseEntitlements(t *testing.T) { Licenses []*coderdenttest.LicenseOptions Enablements map[codersdk.FeatureName]bool Arguments license.FeatureArguments + // KeepNilAgentRuntimeMsFn skips the default AgentRuntimeMsFn + // injection below so the nil dev-error path can be exercised. + KeepNilAgentRuntimeMsFn bool + // CancelContext cancels the context passed to LicensesEntitlements + // before the call, exercising the usage-measurement abort policy. + CancelContext bool ExpectedErrorContains string AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements) @@ -1555,6 +1835,411 @@ func TestLicenseEntitlements(t *testing.T) { assert.Equal(t, int64(150), *feature.Actual) }, }, + { + // hoursToMsFn discards the period bounds, so a swapped or wrong + // license period would still pass the other cases. Capture the + // arguments here and require they match the feature's UsagePeriod. + Name: "AgentRuntimeHours/UsagePeriodBounds", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: func(_ context.Context, from, to time.Time) (int64, error) { + agentRuntimeUsageQueryFrom = from + agentRuntimeUsageQueryTo = to + agentRuntimeUsageQueryCalled = true + return 0, nil + }, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + require.True(t, agentRuntimeUsageQueryCalled) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.NotNil(t, feature.UsagePeriod) + assert.Equal(t, feature.UsagePeriod.Start, agentRuntimeUsageQueryFrom) + assert.Equal(t, feature.UsagePeriod.End, agentRuntimeUsageQueryTo) + }, + }, + { + // The soft warning end to end: the remaining threshold + // arithmetic is pinned by TestAppendAgentRuntimeHoursWarning. + Name: "AgentRuntimeHours/AtSoftLimit", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: hoursToMsFn(80), + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + require.Len(t, entitlements.Warnings, 1) + assert.Equal(t, fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 80, 100, 80), + entitlements.Warnings[0]) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.NotNil(t, feature.Actual) + assert.Equal(t, int64(80), *feature.Actual) + require.NotNil(t, feature.ActualMs) + assert.Equal(t, (80 * time.Hour).Milliseconds(), *feature.ActualMs) + }, + }, + { + // At the allocation the soft warning is suppressed, so exactly one + // warning is emitted rather than both. + Name: "AgentRuntimeHours/AtAllocation", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: hoursToMsFn(100), + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + require.Len(t, entitlements.Warnings, 1) + assert.Equal(t, fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, 100, 100), + entitlements.Warnings[0]) + assert.NotContains(t, entitlements.Warnings, + fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 100, 100, 80)) + }, + }, + { + // A zero allocation carries no hour budget, so Enabled reports + // false and the hour thresholds never warn, but Actual is still + // reported. See decodeAgentRuntimeHours. + Name: "AgentRuntimeHours/ZeroAllocation", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(0, nil), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: hoursToMsFn(50), + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + assertNoWarnings(t, entitlements) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + assert.False(t, feature.Enabled) + require.NotNil(t, feature.Limit) + assert.Equal(t, int64(0), *feature.Limit) + require.NotNil(t, feature.Actual) + assert.Equal(t, int64(50), *feature.Actual) + }, + }, + { + // Partial hours are floored, so 99h59m59s does not reach the + // 100 hour allocation. ActualMs still carries the exact + // milliseconds so clients can render the fraction. + Name: "AgentRuntimeHours/PartialHourFloored", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return (100 * time.Hour).Milliseconds() - 1, nil + }, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + require.Len(t, entitlements.Warnings, 1) + assert.Equal(t, fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 99, 100, 80), + entitlements.Warnings[0]) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.NotNil(t, feature.Actual) + assert.Equal(t, int64(99), *feature.Actual) + require.NotNil(t, feature.ActualMs) + assert.Equal(t, (100*time.Hour).Milliseconds()-1, *feature.ActualMs) + }, + }, + { + // A fractional-hour runtime: Actual floors to whole hours + // while ActualMs preserves the fraction (10.3 hours here). + Name: "AgentRuntimeHours/FractionalHours", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return (10*time.Hour + 18*time.Minute).Milliseconds(), nil + }, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + assertNoWarnings(t, entitlements) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.NotNil(t, feature.Actual) + assert.Equal(t, int64(10), *feature.Actual) + require.NotNil(t, feature.ActualMs) + assert.Equal(t, int64(37_080_000), *feature.ActualMs) + }, + }, + { + // Negative runtime is not producible by the production query, + // but AgentRuntimeMsFn is a caller-supplied seam, so both + // Actual and ActualMs clamp to 0. + Name: "AgentRuntimeHours/NegativeRuntimeClamped", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return -1, nil + }, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + assertNoWarnings(t, entitlements) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.NotNil(t, feature.Actual) + assert.Equal(t, int64(0), *feature.Actual) + require.NotNil(t, feature.ActualMs) + assert.Equal(t, int64(0), *feature.ActualMs) + }, + }, + { + // An enterprise license without the allocation claim does not + // grant the feature, so usage is never queried and nothing + // warns. Only premium licenses are grandfathered into a + // zero-hour allocation. + Name: "AgentRuntimeHours/NoClaimNoFeature", + Licenses: []*coderdenttest.LicenseOptions{ + enterpriseLicense().UserLimit(100), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + // Poison value: if the runtime block ever ran without the + // allocation claim, Actual would be set and the Nil + // assertion below would fail on the subtest's t. + return (9999 * time.Hour).Milliseconds(), nil + }, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + assertNoWarnings(t, entitlements) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + assert.Nil(t, feature.Actual) + assert.Nil(t, feature.UsagePeriod) + }, + }, + { + // A query failure is surfaced as a stable text in Errors and + // leaves Actual unset without aborting the rest of the + // entitlements. + Name: "AgentRuntimeHours/QueryError", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, xerrors.New("kaboom") + }, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoWarnings(t, entitlements) + require.Len(t, entitlements.Errors, 1) + assert.Equal(t, codersdk.LicenseAgentRuntimeUsageUnavailableErrorText, entitlements.Errors[0]) + // The raw error is logged rather than exposed on the + // unauthenticated entitlements payload. + assert.NotContains(t, entitlements.Errors[0], "kaboom") + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + assert.Nil(t, feature.Actual) + // The rest of the entitlements are still computed. + require.NotNil(t, feature.Limit) + assert.Equal(t, int64(100), *feature.Limit) + }, + }, + { + // Forgetting to wire AgentRuntimeMsFn is a dev error: production + // always provides both closures, so it fails the whole call + // loudly instead of degrading into an operator-facing message. + Name: "AgentRuntimeHours/NilRuntimeFnDevError", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)), + }, + KeepNilAgentRuntimeMsFn: true, + ExpectedErrorContains: "developer error: no closure provided to measure agent runtime usage", + }, + { + // A failure while the computation's own context is canceled + // aborts the whole call rather than degrading to an + // entitlements error. + Name: "AgentRuntimeHours/ContextCanceled", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)), + }, + CancelContext: true, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, context.Canceled + }, + }, + ExpectedErrorContains: "get agent runtime", + }, + { + // Postgres raises the same SQLSTATE 57014 for statement_timeout + // kills. With a live context that is a query failure, not a + // shutdown: it must degrade into the stable diagnostic instead + // of aborting every refresh (and coderd startup) on deployments + // with an aggressive statement_timeout. + Name: "AgentRuntimeHours/StatementTimeout", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, xerrors.Errorf("query: %w", &pq.Error{Code: "57014", Message: "canceling statement due to statement timeout"}) + }, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoWarnings(t, entitlements) + require.Len(t, entitlements.Errors, 1) + assert.Equal(t, codersdk.LicenseAgentRuntimeUsageUnavailableErrorText, entitlements.Errors[0]) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + assert.Nil(t, feature.Actual) + }, + }, + { + // A grace-period license still reports Actual and still warns at + // its thresholds. + Name: "AgentRuntimeHours/GracePeriod", + Licenses: []*coderdenttest.LicenseOptions{ + agentRuntimeHoursLicense(100, ptr.Ref[int64](80)).GracePeriod(time.Now()), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: hoursToMsFn(100), + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + assert.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement) + require.NotNil(t, feature.Actual) + assert.Equal(t, int64(100), *feature.Actual) + assert.Contains(t, entitlements.Warnings, + fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, 100, 100)) + }, + }, + { + // A premium license without agent runtime hour claims is + // grandfathered into a zero-hour allocation: granted disabled + // with a zero limit over the license term, usage still + // measured, and no deployment-wide warning even with nonzero + // usage. + Name: "AgentRuntimeHours/PremiumGrandfathered", + Licenses: []*coderdenttest.LicenseOptions{ + premiumLicense().UserLimit(100), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: hoursToMsFn(50), + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + assertNoWarnings(t, entitlements) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + assert.False(t, feature.Enabled) + assert.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) + require.NotNil(t, feature.Limit) + assert.Equal(t, int64(0), *feature.Limit) + assert.Nil(t, feature.SoftLimit) + assert.Nil(t, feature.HardLimit) + require.NotNil(t, feature.UsagePeriod) + assert.Equal(t, grandfatherIssuedAt, feature.UsagePeriod.IssuedAt) + // The usage period is the license term (premiumLicense is + // valid from roughly now until 60 days out), not the + // managed-agent default's fixed 100-year window. + assert.WithinDuration(t, time.Now(), feature.UsagePeriod.Start, 5*time.Minute) + assert.WithinDuration(t, time.Now().Add(60*24*time.Hour), feature.UsagePeriod.End, 5*time.Minute) + require.NotNil(t, feature.Actual) + assert.Equal(t, int64(50), *feature.Actual) + }, + }, + { + // A grace-period premium license grandfathers the same + // zero-hour allocation with a grace entitlement. + Name: "AgentRuntimeHours/PremiumGrandfatheredGracePeriod", + Licenses: []*coderdenttest.LicenseOptions{ + premiumLicense().UserLimit(100).GracePeriod(time.Now()), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: hoursToMsFn(50), + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + assert.False(t, feature.Enabled) + assert.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement) + require.NotNil(t, feature.Limit) + assert.Equal(t, int64(0), *feature.Limit) + require.NotNil(t, feature.Actual) + assert.Equal(t, int64(50), *feature.Actual) + }, + }, + { + // The grandfathered default carries a fixed early + // UsagePeriod.IssuedAt, so a license actually carrying the + // allocation claim wins the merge even when the claim-less + // premium license is issued later. + Name: "AgentRuntimeHours/GrandfatherLosesToAllocation", + Licenses: []*coderdenttest.LicenseOptions{ + premiumLicense().UserLimit(100).WithIssuedAt(dbtime.Now().Add(-time.Hour)), + agentRuntimeHoursLicense(20000, nil).WithIssuedAt(runtimeClaimIssuedAt), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: hoursToMsFn(50), + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + assertNoWarnings(t, entitlements) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + assert.True(t, feature.Enabled) + require.NotNil(t, feature.Limit) + assert.Equal(t, int64(20000), *feature.Limit) + require.NotNil(t, feature.UsagePeriod) + assert.WithinDuration(t, runtimeClaimIssuedAt, feature.UsagePeriod.IssuedAt, time.Second) + }, + }, + { + // An unlimited allocation on any license outranks the + // grandfathered zero-hour default. + Name: "AgentRuntimeHours/GrandfatherLosesToUnlimited", + Licenses: []*coderdenttest.LicenseOptions{ + premiumLicense().UserLimit(100), + enterpriseLicense().UserLimit(100).AgentRuntimeHours(license.AgentRuntimeHoursUnlimitedAllocation, nil, nil), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: hoursToMsFn(1_000_000), + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + assertNoWarnings(t, entitlements) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + assert.True(t, feature.Enabled) + assert.Nil(t, feature.Limit) + require.NotNil(t, feature.Actual) + assert.Equal(t, int64(1_000_000), *feature.Actual) + }, + }, + { + // An explicit zero allocation and the grandfathered default + // have identical semantics; the explicit claim's later issue + // time wins the merge, which pins the Compare path. + Name: "AgentRuntimeHours/GrandfatherLosesToExplicitZero", + Licenses: []*coderdenttest.LicenseOptions{ + premiumLicense().UserLimit(100), + agentRuntimeHoursLicense(0, nil).WithIssuedAt(runtimeClaimIssuedAt), + }, + Arguments: license.FeatureArguments{ + AgentRuntimeMsFn: hoursToMsFn(50), + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assertNoErrors(t, entitlements) + assertNoWarnings(t, entitlements) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + assert.False(t, feature.Enabled) + require.NotNil(t, feature.Limit) + assert.Equal(t, int64(0), *feature.Limit) + require.NotNil(t, feature.UsagePeriod) + assert.WithinDuration(t, runtimeClaimIssuedAt, feature.UsagePeriod.IssuedAt, time.Second) + require.NotNil(t, feature.Actual) + assert.Equal(t, int64(50), *feature.Actual) + }, + }, { Name: "ExternalTemplate", Licenses: []*coderdenttest.LicenseOptions{ @@ -1591,8 +2276,20 @@ func TestLicenseEntitlements(t *testing.T) { return 0, nil } } + // Default to 0 agent runtime. + if tc.Arguments.AgentRuntimeMsFn == nil && !tc.KeepNilAgentRuntimeMsFn { + tc.Arguments.AgentRuntimeMsFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) { + return 0, nil + } + } - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments) + ctx := context.Background() + if tc.CancelContext { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments) if tc.ExpectedErrorContains != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.ExpectedErrorContains) @@ -1617,6 +2314,19 @@ func TestAIBridgeSoftWarning(t *testing.T) { aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more." + // A Premium license grants a managed agent limit and a grandfathered + // agent runtime allocation by default: a nil AgentRuntimeMsFn is a hard + // developer error and a nil ManagedAgentCountFn degrades into an + // entitlements error, so these subtests wire zero-usage closures. + zeroUsageArgs := license.FeatureArguments{ + ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, nil + }, + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, nil + }, + } + t.Run("NoAddon_AIBridgeOff", func(t *testing.T) { t.Parallel() // License without addon and AI Bridge disabled should NOT show warning. @@ -1636,7 +2346,7 @@ func TestAIBridgeSoftWarning(t *testing.T) { }, } - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs) require.NoError(t, err) aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] @@ -1663,7 +2373,7 @@ func TestAIBridgeSoftWarning(t *testing.T) { }, } - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs) require.NoError(t, err) aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] @@ -1695,7 +2405,7 @@ func TestAIBridgeSoftWarning(t *testing.T) { }, } - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs) require.NoError(t, err) aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] @@ -1726,7 +2436,7 @@ func TestAIBridgeSoftWarning(t *testing.T) { }, } - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs) require.NoError(t, err) aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] @@ -1739,7 +2449,7 @@ func TestAIBridgeSoftWarning(t *testing.T) { t.Parallel() // No license with AI Bridge enabled should NOT show the soft warning // (it will show the generic "not entitled" warning instead). - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs) require.NoError(t, err) aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] @@ -1969,6 +2679,11 @@ func TestOldStyleManagedAgentLicenses(t *testing.T) { ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) { return actualAgents, nil }, + // The premium grandfather default grants a zero-hour agent + // runtime allocation, so a runtime closure is required too. + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, nil + }, } entitlements, err := license.LicensesEntitlements( @@ -2053,6 +2768,11 @@ func TestManagedAgentLimitDefault(t *testing.T) { ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) { return actualAgents, nil }, + // The premium grandfather default grants a zero-hour agent + // runtime allocation, so a runtime closure is required too. + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, nil + }, } entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments) @@ -2098,6 +2818,11 @@ func TestManagedAgentLimitDefault(t *testing.T) { ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) { return actualAgents, nil }, + // The premium grandfather default grants a zero-hour agent + // runtime allocation, so a runtime closure is required too. + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, nil + }, } entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments) @@ -2143,6 +2868,11 @@ func TestManagedAgentLimitDefault(t *testing.T) { ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) { return actualAgents, nil }, + // The premium grandfather default grants a zero-hour agent + // runtime allocation, so a runtime closure is required too. + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, nil + }, } entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments) @@ -2169,6 +2899,18 @@ func TestManagedAgentLimitDefault(t *testing.T) { func TestAgentRuntimeHoursLicenses(t *testing.T) { t.Parallel() + // These cases exercise claim decoding rather than usage accounting, so + // they report no runtime. A nil AgentRuntimeMsFn fails the whole + // LicensesEntitlements call as a developer error when the feature is + // present, so the closure must always be supplied. + noRuntime := func() license.FeatureArguments { + return license.FeatureArguments{ + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, nil + }, + } + } + t.Run("AllClaims", func(t *testing.T) { t.Parallel() @@ -2194,7 +2936,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { entitlements, err := license.LicensesEntitlements( context.Background(), time.Now(), []database.License{lic}, - map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(), ) require.NoError(t, err) require.Empty(t, entitlements.Errors) @@ -2209,7 +2951,11 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { require.EqualValues(t, 80, *feature.SoftLimit) require.NotNil(t, feature.HardLimit) require.EqualValues(t, 120, *feature.HardLimit) - require.Nil(t, feature.Actual) + // Actual is populated from usage, which is zero for this license. + require.NotNil(t, feature.Actual) + require.EqualValues(t, 0, *feature.Actual) + require.NotNil(t, feature.ActualMs) + require.EqualValues(t, 0, *feature.ActualMs) require.NotNil(t, feature.UsagePeriod) require.WithinDuration(t, licIat, feature.UsagePeriod.IssuedAt, 2*time.Second) require.WithinDuration(t, licNbf, feature.UsagePeriod.Start, 2*time.Second) @@ -2227,6 +2973,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { require.EqualValues(t, 100, rawFeature["limit"]) require.EqualValues(t, 80, rawFeature["soft_limit"]) require.EqualValues(t, 120, rawFeature["hard_limit"]) + require.EqualValues(t, 0, rawFeature["actual_ms"]) require.Contains(t, rawFeature, "usage_period") }) @@ -2252,7 +2999,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { entitlements, err := license.LicensesEntitlements( context.Background(), now, []database.License{lic}, - map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(), ) require.NoError(t, err) require.Empty(t, entitlements.Errors) @@ -2286,7 +3033,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { entitlements, err := license.LicensesEntitlements( context.Background(), time.Now(), []database.License{lic}, - map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(), ) require.NoError(t, err) require.Empty(t, entitlements.Errors) @@ -2320,7 +3067,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { entitlements, err := license.LicensesEntitlements( context.Background(), time.Now(), []database.License{lic}, - map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(), ) require.NoError(t, err) require.Empty(t, entitlements.Errors) @@ -2353,7 +3100,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { entitlements, err := license.LicensesEntitlements( context.Background(), time.Now(), []database.License{lic}, - map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(), ) require.NoError(t, err) require.Empty(t, entitlements.Errors) @@ -2431,7 +3178,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { {lic1, lic2}, {lic2, lic1}, } { - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}) + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime()) require.NoError(t, err) feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours] @@ -2496,7 +3243,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { {unlimited, metered}, {metered, unlimited}, } { - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}) + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime()) require.NoError(t, err) feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours] @@ -2551,7 +3298,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { {lic1, lic2}, {lic2, lic1}, } { - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}) + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime()) require.NoError(t, err) feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] @@ -2582,7 +3329,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { entitlements, err := license.LicensesEntitlements( context.Background(), time.Now(), []database.License{lic}, - map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(), ) require.NoError(t, err) require.Empty(t, entitlements.Errors) @@ -2633,7 +3380,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { entitlements, err := license.LicensesEntitlements( context.Background(), time.Now(), []database.License{lic}, - map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(), ) require.NoError(t, err) require.Empty(t, entitlements.Errors) @@ -2903,6 +3650,9 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) { context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{ Logger: slog.Make(sloghuman.Sink(&logBuf)), + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, nil + }, }, ) require.NoError(t, err) @@ -2977,6 +3727,9 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) { context.Background(), time.Now(), licenses, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{ Logger: slog.Make(sloghuman.Sink(&logBuf)), + AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) { + return 0, nil + }, }, ) require.NoError(t, err) diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index 811929f09390a..953f9dd19351c 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "context" + "fmt" "net/http" "testing" "time" @@ -155,6 +156,22 @@ func TestPostLicense(t *testing.T) { require.NotNil(t, feature.HardLimit) require.EqualValues(t, 120, *feature.HardLimit) require.NotNil(t, feature.UsagePeriod) + // Actual is read from usage_events, which has no runtime events in + // this deployment. It is reported in whole hours, matching the unit + // of the claims above, with the precise milliseconds in ActualMs. + require.NotNil(t, feature.Actual) + require.EqualValues(t, 0, *feature.Actual) + require.NotNil(t, feature.ActualMs) + require.EqualValues(t, 0, *feature.ActualMs) + require.Empty(t, entitlements.Errors) + // Zero usage is below both thresholds, so no runtime warning + // fires. Unrelated warnings from this bare license are ignored. + // The negatives are built from the exported constants so a reword + // cannot silently disarm this guard. + require.NotContains(t, entitlements.Warnings, + fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 0, 100, 80)) + require.NotContains(t, entitlements.Warnings, + fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, 0, 100)) }) t.Run("Unauthorized", func(t *testing.T) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2e96aa99572b7..605917317280d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5187,14 +5187,18 @@ export interface ExternalAuthUser { export interface Feature { readonly entitlement: Entitlement; readonly enabled: boolean; + /** + * Limit is the maximum value the license grants for the feature, in the + * feature's own unit. For FeatureAgentRuntimeHours, an enabled feature + * with Limit omitted means the license grants unlimited runtime hours. + */ readonly limit?: number; /** * SoftLimit is the advisory warning threshold that accompanies Limit for * features whose license carries it. For these features, Limit carries - * the purchased allocation. - * - * Only certain features set this field: - * - FeatureAgentRuntimeHours + * the purchased allocation; an unlimited allocation has no thresholds, + * so SoftLimit is omitted alongside the omitted Limit. Only + * FeatureAgentRuntimeHours sets this field. */ readonly soft_limit?: number; /** @@ -5203,18 +5207,30 @@ export interface Feature { * features that use these thresholds. */ readonly hard_limit?: number; + /** + * Actual is the usage measured against Limit, when known: a + * point-in-time count for most features, or usage accumulated over + * UsagePeriod for features that set one. Its unit matches Limit's; + * FeatureAgentRuntimeHours reports whole hours floored from the + * recorded milliseconds, with the precise value available in + * ActualMs. FeatureAgentRuntimeHours usage can trail by roughly one + * hour because the current hour is not emitted, plus the entitlement + * refresh interval. + */ readonly actual?: number; + /** + * ActualMs is the precise usage backing Actual, in milliseconds, for + * features measured in time. It has the same freshness as Actual. + * Only FeatureAgentRuntimeHours sets this field. + */ + readonly actual_ms?: number; /** * UsagePeriod denotes that the usage is a counter that accumulates over * this period (and most likely resets with the issuance of the next - * license). - * - * These dates are determined from the license that this entitlement comes - * from, see enterprise/coderd/license/license.go. - * - * Only certain features set these fields: - * - FeatureManagedAgentLimit - * - FeatureAgentRuntimeHours + * license). These dates are determined from the license that this + * entitlement comes from, see enterprise/coderd/license/license.go. + * Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this + * field. */ readonly usage_period?: UsagePeriod; } @@ -5763,10 +5779,28 @@ export const LicenseAIGovernance90PercentWarningText = export const LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."; +// From codersdk/licenses.go +export const LicenseAgentRuntimeHoursAllocationReachedWarningText = + "Your deployment has used %d of the %d Coder Agent runtime hours included in the current license term."; + // From codersdk/licenses.go export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."; +// From codersdk/licenses.go +/** + * The dashboard's LicenseBanner matches this text's pre-placeholder + * prefix to render it muted and without a sales link, so the license + * warning texts must stay pairwise distinct before their first + * placeholder. See TestLicenseAgentRuntimeHoursWarningTexts. + */ +export const LicenseAgentRuntimeHoursSoftLimitWarningText = + "Your deployment is approaching its Coder Agent runtime hours allocation: %d of the %d hours included in the current license term are used, at or above the advisory soft limit of %d hours."; + +// From codersdk/licenses.go +export const LicenseAgentRuntimeUsageUnavailableErrorText = + "Unable to determine Coder Agent runtime usage. Reported runtime hours are unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."; + // From codersdk/licenses.go export const LicenseExpiryClaim = "license_expires"; diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx index 2c33e9fcbf428..768fb09c58ffc 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx @@ -1,6 +1,8 @@ import type { FC } from "react"; import { LicenseAgentRuntimeHoursClaimsIgnoredWarningText, + LicenseAgentRuntimeHoursSoftLimitWarningText, + LicenseAgentRuntimeUsageUnavailableErrorText, LicenseAIGovernance90PercentWarningText, LicenseAIGovernanceOverLimitWarningText, LicenseManagedAgentLimitExceededWarningText, @@ -18,6 +20,8 @@ const aiGovernanceOverLimitWarningPrefix = LicenseAIGovernanceOverLimitWarningText.split("%d")[0]; const aiGovernanceNearLimitWarningPrefix = LicenseAIGovernance90PercentWarningText.split("%d%%")[0]; +const agentRuntimeSoftLimitWarningPrefix = + LicenseAgentRuntimeHoursSoftLimitWarningText.split("%d")[0]; const AI_GOVERNANCE_NEAR_LIMIT_FALLBACK_MESSAGE = "You are approaching your AI Governance add-on seat limit."; @@ -26,8 +30,12 @@ const isAIGovernanceWarning = (message: string): boolean => message.startsWith(aiGovernanceOverLimitWarningPrefix); // Substitutes the given values into the template's %d placeholders in order. -// No other fmt verb, width, or flag is implemented. -const formatLicenseMessage = (template: string, ...values: number[]): string => +// No other fmt verb, width, or flag is implemented. Exported for the +// stories, so what they pin is what production renders. +export const formatLicenseMessage = ( + template: string, + ...values: number[] +): string => values.reduce( (message, value) => message.replace("%d", `${value}`), template, @@ -37,6 +45,7 @@ const formatLicenseMessage = (template: string, ...values: number[]): string => // usage itself. They render muted, without the exceedance heading or a sales // link, even when they arrive via entitlements.errors. const diagnosticMessages: readonly string[] = [ + LicenseAgentRuntimeUsageUnavailableErrorText, LicenseAgentRuntimeHoursClaimsIgnoredWarningText, ]; @@ -44,9 +53,10 @@ const isDiagnosticMessage = (message: string): boolean => diagnosticMessages.includes(message); // Advisories render muted to stay visually distinct from warnings that -// demand action, such as exceeding a license limit. +// demand action, such as reaching the runtime hours allocation. const isAdvisoryMessage = (message: string): boolean => - message.startsWith(aiGovernanceNearLimitWarningPrefix); + message.startsWith(aiGovernanceNearLimitWarningPrefix) || + message.startsWith(agentRuntimeSoftLimitWarningPrefix); const aiGovernanceOverLimitMessage = ( feature: ReturnType< @@ -139,6 +149,11 @@ const messageLink = (message: string): LicenseBannerLink | undefined => { showExternalIcon: false, }; } + // The soft-limit advisory fires inside the purchased allocation, so it + // does not get a sales link. + if (message.startsWith(agentRuntimeSoftLimitWarningPrefix)) { + return undefined; + } return { href: "mailto:sales@coder.com", label: "Contact sales@coder.com.", diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx index 94b04f29e5d5f..24fd9013656c2 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx @@ -2,7 +2,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; import { type Entitlements, + LicenseAgentRuntimeHoursAllocationReachedWarningText, LicenseAgentRuntimeHoursClaimsIgnoredWarningText, + LicenseAgentRuntimeHoursSoftLimitWarningText, + LicenseAgentRuntimeUsageUnavailableErrorText, LicenseAIGovernance90PercentWarningText, LicenseManagedAgentLimitExceededWarningText, LicenseTelemetryRequiredErrorText, @@ -16,7 +19,7 @@ import { } from "#/testHelpers/entities"; import { docs } from "#/utils/docs"; import { DashboardContext, type DashboardValue } from "../DashboardProvider"; -import { LicenseBanner } from "./LicenseBanner"; +import { formatLicenseMessage, LicenseBanner } from "./LicenseBanner"; import { LicenseBannerView } from "./LicenseBannerView"; const meta: Meta = { @@ -60,7 +63,7 @@ export const TwoWarnings: Story = { const canvas = within(canvasElement); await expect(canvas.getByRole("status")).toBeInTheDocument(); await expect( - canvas.getByText("Your license limits have been exceeded"), + canvas.getByText("Your license limits have been reached"), ).toBeInTheDocument(); await expect( canvas.queryByRole("button", { name: "Show more" }), @@ -295,6 +298,83 @@ export const AIGovernanceOverLimitGracePeriod: Story = { }, }; +export const AgentRuntimeHoursSoftLimit: Story = { + render: () => + renderLicenseBanner({ + warnings: [ + formatLicenseMessage( + LicenseAgentRuntimeHoursSoftLimitWarningText, + 90, + 100, + 80, + ), + ], + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const banner = canvas.getByRole("status"); + await expect(banner).toHaveTextContent( + "Your deployment is approaching its Coder Agent runtime hours allocation: 90 of the 100 hours included in the current license term are used, at or above the advisory soft limit of 80 hours.", + ); + await expect( + canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }), + ).not.toBeInTheDocument(); + }, +}; + +export const AgentRuntimeHoursAllocationReached: Story = { + render: () => + renderLicenseBanner({ + warnings: [ + formatLicenseMessage( + LicenseAgentRuntimeHoursAllocationReachedWarningText, + 100, + 100, + ), + ], + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const banner = canvas.getByRole("status"); + await expect(banner).toHaveTextContent( + "Your deployment has used 100 of the 100 Coder Agent runtime hours included in the current license term.", + ); + await expect( + canvas.getByRole("link", { name: /Contact sales@coder\.com/i }), + ).toHaveAttribute("href", "mailto:sales@coder.com"); + }, +}; + +// The allocation warning fires at exact equality (actual >= allocation), so +// a multi-message banner containing it must use a heading that stays +// accurate when the allocation is reached but not exceeded. +export const AgentRuntimeHoursAllocationReachedWithDiagnostic: Story = { + render: () => + renderLicenseBanner({ + errors: [LicenseAgentRuntimeUsageUnavailableErrorText], + warnings: [ + formatLicenseMessage( + LicenseAgentRuntimeHoursAllocationReachedWarningText, + 100, + 100, + ), + ], + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const banner = canvas.getByRole("status"); + await expect( + canvas.getByText("Your license limits have been reached"), + ).toBeInTheDocument(); + await expect(banner).toHaveTextContent( + "Your deployment has used 100 of the 100 Coder Agent runtime hours included in the current license term.", + ); + await expect(banner).toHaveTextContent( + LicenseAgentRuntimeUsageUnavailableErrorText, + ); + }, +}; + // Each diagnostic pins role=status (not alert) and a suppressed sales // link. Background mutedness is covered by the visual snapshot. const playMutedDiagnostic = @@ -308,6 +388,14 @@ const playMutedDiagnostic = ).not.toBeInTheDocument(); }; +export const AgentRuntimeUsageUnavailable: Story = { + render: () => + renderLicenseBanner({ + errors: [LicenseAgentRuntimeUsageUnavailableErrorText], + }), + play: playMutedDiagnostic(LicenseAgentRuntimeUsageUnavailableErrorText), +}; + export const AgentRuntimeHoursClaimsIgnored: Story = { render: () => renderLicenseBanner({ @@ -316,10 +404,12 @@ export const AgentRuntimeHoursClaimsIgnored: Story = { play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText), }; -// An all-diagnostic banner must not claim license limits were exceeded. +// An all-diagnostic banner must not claim license limits were exceeded, +// even when a diagnostic arrives via entitlements.errors. export const UsageDiagnosticsOnlyHeading: Story = { render: () => renderLicenseBanner({ + errors: [LicenseAgentRuntimeUsageUnavailableErrorText], warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText], }), play: async ({ canvasElement }) => { @@ -327,7 +417,7 @@ export const UsageDiagnosticsOnlyHeading: Story = { await expect(canvas.getByRole("status")).toBeInTheDocument(); await expect(canvas.getByText("License notices")).toBeInTheDocument(); await expect( - canvas.queryByText("Your license limits have been exceeded"), + canvas.queryByText("Your license limits have been reached"), ).not.toBeInTheDocument(); await expect( canvas.queryByText("License errors require attention"), diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx index 8212fe7073657..657385d010c62 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx @@ -75,13 +75,15 @@ const getBannerVariant = ( }; // The muted "warning" variant means every message is an advisory or -// diagnostic, so the heading must not assert exceedance. +// diagnostic, so the heading must not assert a limit was hit. The prominent +// heading says "reached" rather than "exceeded" because some limit warnings +// fire at exact equality, which "reached" covers in both cases. const bannerTitle = (variant: LicenseBannerVariant): string => { switch (variant) { case "error": return "License errors require attention"; case "warningProminent": - return "Your license limits have been exceeded"; + return "Your license limits have been reached"; case "warning": return "License notices"; }