feat: add AI Governance seat capacity banners (#23411)

## Summary

Add site-wide banners for AI Governance seat usage thresholds:

1. **90% capacity warning (admin-only):** When actual AI Governance
seats are ≥90% and <100% of the license limit, admins see:
   > "You have used 90% of your AI governance add-on seats."

2. **Over-limit banner (admin-only):** When actual seats exceed the
license limit, admins see a prominent warning:
> "Your organization is using {actual} / {limit} AI Governance user
seats ({X}% over the limit). Contact sales@coder.com"
   - Uses floor whole percentage (Go int division / `Math.floor`)
   - Includes a clickable `mailto:sales@coder.com` link
This commit is contained in:
Jaayden Halko
2026-03-27 05:51:51 +00:00
committed by GitHub
parent 2312e5c428
commit 86c3983fc0
12 changed files with 1036 additions and 174 deletions
+38 -7
View File
@@ -490,21 +490,31 @@ func LicensesEntitlements(
featureArguments.ActiveUserCount, *userLimit.Limit))
}
if featureArguments.ActiveAISeatCount > 0 {
actual := featureArguments.ActiveAISeatCount
feature := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
switch {
case feature.Entitlement == codersdk.EntitlementNotEntitled:
// If the limit is not set
entitlements.Errors = append(entitlements.Errors,
fmt.Sprintf("Your deployment has %d active AI Governance seats but the license is not entitled to this feature.", featureArguments.ActiveAISeatCount))
fmt.Sprintf("Your deployment has %d active AI Governance seats but the license is not entitled to this feature.", actual))
case feature.Entitlement == codersdk.EntitlementGracePeriod && feature.Limit != nil:
entitlements.Warnings = append(entitlements.Warnings,
fmt.Sprintf(
"Your deployment has %d active AI Governance seats but the license with the limit %d is expired.",
featureArguments.ActiveAISeatCount, *feature.Limit))
case feature.Limit != nil && featureArguments.ActiveAISeatCount > *feature.Limit:
entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf(
"Your deployment has %d active AI Governance seats but is only licensed for %d.",
featureArguments.ActiveAISeatCount, *feature.Limit))
actual, *feature.Limit))
// Also emit seat-capacity warnings during grace period so admins
// see both expiry and usage details.
entitlements.Warnings = appendAIGovernanceSeatLimitWarning(
entitlements.Warnings,
actual,
*feature.Limit,
)
case feature.Limit != nil:
entitlements.Warnings = appendAIGovernanceSeatLimitWarning(
entitlements.Warnings,
actual,
*feature.Limit,
)
}
}
@@ -555,7 +565,7 @@ func LicensesEntitlements(
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
if aiBridgeFeature.Enabled && aiBridgeFeature.Entitlement.Entitled() && !hasExplicitAIBridgeEntitlement {
entitlements.Warnings = append(entitlements.Warnings,
"The AI Governance Add-On is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more.")
"The AI Governance add-on is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more.")
}
}
@@ -572,6 +582,27 @@ func LicensesEntitlements(
return entitlements, nil
}
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
}
if actual > limit {
overLimitSeats := actual - limit
return append(warnings, fmt.Sprintf(
codersdk.LicenseAIGovernanceOverLimitWarningText,
actual,
limit,
overLimitSeats,
))
} else if actual*10 >= limit*9 {
usedPercent := (actual * 100) / limit
return append(warnings, fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, usedPercent))
}
return warnings
}
const (
CurrentVersion = 3
HeaderKeyID = "kid"
+240 -47
View File
@@ -900,59 +900,252 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
t.Run("AIGovernanceSeatLimitExceededWarning", func(t *testing.T) {
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
licenseOpts := (&coderdenttest.LicenseOptions{
FeatureSet: codersdk.FeatureSetPremium,
NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second),
ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second),
Addons: []codersdk.Addon{codersdk.AddonAIGovernance},
Features: license.Features{
codersdk.FeatureAIGovernanceUserLimit: 100,
testCases := []struct {
name string
limit int64
activeSeatCount int64
expectedWarning string
}{
{
name: "At90Percent",
limit: 100,
activeSeatCount: 90,
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, 90),
},
{
name: "Below90Percent",
limit: 100,
activeSeatCount: 89,
},
{
name: "OverLimit",
limit: 100,
activeSeatCount: 110,
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, 110, 100, 10),
},
{
name: "AtLimit",
limit: 100,
activeSeatCount: 100,
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, 100),
},
{
name: "OverLimitRoundingDown",
limit: 101,
activeSeatCount: 106,
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, 106, 101, 5),
},
{
name: "TinyOverage",
limit: 1000,
activeSeatCount: 1001,
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, 1001, 1000, 1),
},
{
name: "ZeroLimitGuard",
limit: 0,
activeSeatCount: 5,
},
}).
UserLimit(100)
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(127), nil)
mDB.EXPECT().
GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
Return(int64(0), nil)
mDB.EXPECT().
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
Return([]database.Template{}, nil)
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
aiGovernanceSeatLimit, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
require.True(t, ok)
require.NotNil(t, aiGovernanceSeatLimit.Actual)
require.EqualValues(t, 127, *aiGovernanceSeatLimit.Actual)
require.NotNil(t, aiGovernanceSeatLimit.Limit)
require.EqualValues(t, 100, *aiGovernanceSeatLimit.Limit)
licenseOpts := (&coderdenttest.LicenseOptions{
FeatureSet: codersdk.FeatureSetPremium,
NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second),
ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second),
Addons: []codersdk.Addon{codersdk.AddonAIGovernance},
Features: license.Features{
codersdk.FeatureAIGovernanceUserLimit: tc.limit,
},
}).
UserLimit(100)
require.Len(t, entitlements.Warnings, 1)
require.Equal(t, "Your deployment has 127 active AI Governance seats but is only licensed for 100.", entitlements.Warnings[0])
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(tc.activeSeatCount, nil)
mDB.EXPECT().
GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
Return(int64(0), nil)
mDB.EXPECT().
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
Return([]database.Template{}, nil)
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
aiGovernanceSeatLimit, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
require.True(t, ok)
if tc.limit > 0 {
require.NotNil(t, aiGovernanceSeatLimit.Actual)
require.EqualValues(t, tc.activeSeatCount, *aiGovernanceSeatLimit.Actual)
require.NotNil(t, aiGovernanceSeatLimit.Limit)
require.EqualValues(t, tc.limit, *aiGovernanceSeatLimit.Limit)
} else {
require.Nil(t, aiGovernanceSeatLimit.Actual)
require.Nil(t, aiGovernanceSeatLimit.Limit)
}
if tc.expectedWarning == "" {
require.Len(t, entitlements.Warnings, 0)
} else {
require.Len(t, entitlements.Warnings, 1)
require.Equal(t, tc.expectedWarning, entitlements.Warnings[0])
}
})
}
t.Run("GracePeriodOverLimit", func(t *testing.T) {
t.Parallel()
const (
limit int64 = 100
activeSeatCount int64 = 127
)
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
licenseOpts := &coderdenttest.LicenseOptions{
NotBefore: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
GraceAt: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
ExpiresAt: dbtime.Now().Add(24 * time.Hour).Truncate(time.Second),
Addons: []codersdk.Addon{codersdk.AddonAIGovernance},
Features: license.Features{
codersdk.FeatureAIGovernanceUserLimit: limit,
},
}
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(activeSeatCount, nil)
mDB.EXPECT().
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
Return([]database.Template{}, nil)
enablements := map[codersdk.FeatureName]bool{
codersdk.FeatureAIGovernanceUserLimit: true,
}
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
feature, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
require.True(t, ok)
require.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
require.Contains(t, entitlements.Warnings,
fmt.Sprintf(
"Your deployment has %d active AI Governance seats but the license with the limit %d is expired.",
activeSeatCount, limit,
),
)
require.Contains(t, entitlements.Warnings,
fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, activeSeatCount, limit, 27),
)
})
t.Run("GracePeriod90Percent", func(t *testing.T) {
t.Parallel()
const (
limit int64 = 100
activeSeatCount int64 = 95
)
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
licenseOpts := &coderdenttest.LicenseOptions{
NotBefore: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
GraceAt: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
ExpiresAt: dbtime.Now().Add(24 * time.Hour).Truncate(time.Second),
Addons: []codersdk.Addon{codersdk.AddonAIGovernance},
Features: license.Features{
codersdk.FeatureAIGovernanceUserLimit: limit,
},
}
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(activeSeatCount, nil)
mDB.EXPECT().
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
Return([]database.Template{}, nil)
enablements := map[codersdk.FeatureName]bool{
codersdk.FeatureAIGovernanceUserLimit: true,
}
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
feature, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
require.True(t, ok)
require.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
expiryWarning := fmt.Sprintf(
"Your deployment has %d active AI Governance seats but the license with the limit %d is expired.",
activeSeatCount,
limit,
)
require.Contains(t, entitlements.Warnings, expiryWarning)
require.Contains(t, entitlements.Warnings,
fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, 95))
for _, warning := range entitlements.Warnings {
require.NotContains(t, warning, "over the limit")
}
})
})
}
@@ -1344,7 +1537,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
codersdk.FeatureAIBridge: false,
}
aiBridgeWarningMessage := "The AI Governance Add-On is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
@@ -1924,7 +2117,7 @@ func TestAIGovernanceAddon(t *testing.T) {
// AI Bridge should be enabled without warning when addon is present.
aibridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
require.True(t, aibridgeFeature.Enabled, "AI Bridge should be enabled when addon is present and enablements are set")
aiBridgeWarningMessage := "The AI Governance Add-On is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage, "AI Bridge warning should not appear when AI Governance addon is present")
// require.Equal(t, codersdk.EntitlementEntitled, aibridgeFeature.Entitlement, "AI Bridge should be entitled when addon is present")