mirror of
https://github.com/coder/coder.git
synced 2026-06-03 21:18:24 +00:00
183a6ebbdf
Note that enforcement and checking usage will come in a future PR. This feature is implemented differently than existing features in a few ways. It's highly recommended that reviewers read: - This document which outlines the methods we could've used for license enforcement: https://www.notion.so/coderhq/AI-Agent-License-Enforcement-21ed579be59280c088b9c1dc5e364ee8 - Phase 0 of the actual RFC document: https://www.notion.so/coderhq/Usage-based-Billing-AI-b-210d579be592800eb257de7eecd2d26d ### Multiple features in the license, a single feature in codersdk Firstly, the feature is represented as a single feature in the codersdk world, but is represented with multiple features in the license. E.g. in the license you may have: { "features": { "managed_agent_limit_soft": 100, "managed_agent_limit_hard": 200 } } But the entitlements endpoint will return a single feature: { "features": { "managed_agent_limit": { "limit": 200, "soft_limit": 100 } } } This is required because of our rigid parsing that uses a `map[string]int64` for features in the license. To avoid requiring all customers to upgrade to use new licenses, the decision was made to just use two features and merge them into one. Older Coder deployments will parse this feature (from new licenses) as two separate features, but it's not a problem because they don't get used anywhere obviously. The reason we want to differentiate between a "soft" and "hard" limit is so we can show admins how much of the usage is "included" vs. how much they can use before they get hard cut-off. ### Usage period features will be compared and trump based on license issuance time The second major difference to other features is that "usage period" features such as `managed_agent_limit` will now be primarily compared by the `iat` (issued at) claim of the license they come from. This differs from previous features. The reason this was done was so we could reduce limits with newer licenses, which the current comparison code does not allow for. This effectively means if you have two active licenses: - `iat`: 2025-07-14, `managed_agent_limit_soft`: 100, `managed_agent_limit_hard`: 200 - `iat`: 2025-07-15, `managed_agent_limit_soft`: 50, `managed_agent_limit_hard`: 100 Then the resulting `managed_agent_limit` entitlement will come from the second license, even though the values are smaller than another valid license. The existing comparison code would prefer the first license even though it was issued earlier. ### Usage period features will count usage between the start and end dates of the license Existing limit features, like the user limit, just measure the current usage value of the feature. The active user count is a gauge that goes up and down, whereas agent usage can only be incremented, so it doesn't make sense to use a continually incrementing counter forever and ever for managed agents. For managed agent limit, we count the usage between `nbf` (not before) and `exp` (expires at) of the license that the entitlement comes from. In the example above, we'd use the issued at date and expiry of the second license as this date range. This essentially means, when you get a new license, the usage resets to zero. The actual usage counting code will be implemented in a follow-up PR. ### Managed agent limit has a default entitlement value Temporarily (until further notice), we will be providing licenses with `feature_set` set to `premium` a default limit. - Soft limit: `800 * user_limit` - Hard limit: `1000 * user_limit` "Enterprise" licenses do not get any default limit and are not entitled to use the feature. Unlicensed customers (e.g. OSS) will be permitted to use the feature as much as they want without limits. This will be implemented when the counting code is implemented in a follow-up PR. Closes https://github.com/coder/internal/issues/760
440 lines
15 KiB
Go
440 lines
15 KiB
Go
package coderdenttest
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"io"
|
|
"net/http"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v4"
|
|
"github.com/google/uuid"
|
|
"github.com/moby/moby/pkg/namesgenerator"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/pubsub"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/drpcsdk"
|
|
"github.com/coder/coder/v2/enterprise/coderd"
|
|
"github.com/coder/coder/v2/enterprise/coderd/license"
|
|
"github.com/coder/coder/v2/enterprise/dbcrypt"
|
|
"github.com/coder/coder/v2/provisioner/echo"
|
|
"github.com/coder/coder/v2/provisioner/terraform"
|
|
"github.com/coder/coder/v2/provisionerd"
|
|
provisionerdproto "github.com/coder/coder/v2/provisionerd/proto"
|
|
"github.com/coder/coder/v2/provisionersdk"
|
|
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
const (
|
|
testKeyID = "enterprise-test"
|
|
)
|
|
|
|
var (
|
|
testPrivateKey ed25519.PrivateKey
|
|
testPublicKey ed25519.PublicKey
|
|
|
|
Keys = map[string]ed25519.PublicKey{}
|
|
)
|
|
|
|
func init() {
|
|
var err error
|
|
testPublicKey, testPrivateKey, err = ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
Keys[testKeyID] = testPublicKey
|
|
}
|
|
|
|
type Options struct {
|
|
*coderdtest.Options
|
|
ConnectionLogging bool
|
|
AuditLogging bool
|
|
BrowserOnly bool
|
|
EntitlementsUpdateInterval time.Duration
|
|
SCIMAPIKey []byte
|
|
UserWorkspaceQuota int
|
|
ProxyHealthInterval time.Duration
|
|
LicenseOptions *LicenseOptions
|
|
DontAddLicense bool
|
|
DontAddFirstUser bool
|
|
ReplicaSyncUpdateInterval time.Duration
|
|
ReplicaErrorGracePeriod time.Duration
|
|
ExternalTokenEncryption []dbcrypt.Cipher
|
|
ProvisionerDaemonPSK string
|
|
}
|
|
|
|
// New constructs a codersdk client connected to an in-memory Enterprise API instance.
|
|
func New(t *testing.T, options *Options) (*codersdk.Client, codersdk.CreateFirstUserResponse) {
|
|
client, _, _, user := NewWithAPI(t, options)
|
|
return client, user
|
|
}
|
|
|
|
func NewWithDatabase(t *testing.T, options *Options) (*codersdk.Client, database.Store, codersdk.CreateFirstUserResponse) {
|
|
client, _, api, user := NewWithAPI(t, options)
|
|
return client, api.Database, user
|
|
}
|
|
|
|
func NewWithAPI(t *testing.T, options *Options) (
|
|
*codersdk.Client, io.Closer, *coderd.API, codersdk.CreateFirstUserResponse,
|
|
) {
|
|
t.Helper()
|
|
|
|
if options == nil {
|
|
options = &Options{}
|
|
}
|
|
if options.Options == nil {
|
|
options.Options = &coderdtest.Options{}
|
|
}
|
|
require.False(t, options.DontAddFirstUser && !options.DontAddLicense, "DontAddFirstUser requires DontAddLicense")
|
|
setHandler, cancelFunc, serverURL, oop := coderdtest.NewOptions(t, options.Options)
|
|
coderAPI, err := coderd.New(context.Background(), &coderd.Options{
|
|
RBAC: true,
|
|
ConnectionLogging: options.ConnectionLogging,
|
|
AuditLogging: options.AuditLogging,
|
|
BrowserOnly: options.BrowserOnly,
|
|
SCIMAPIKey: options.SCIMAPIKey,
|
|
DERPServerRelayAddress: oop.AccessURL.String(),
|
|
DERPServerRegionID: oop.BaseDERPMap.RegionIDs()[0],
|
|
ReplicaSyncUpdateInterval: options.ReplicaSyncUpdateInterval,
|
|
ReplicaErrorGracePeriod: options.ReplicaErrorGracePeriod,
|
|
Options: oop,
|
|
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
|
|
LicenseKeys: Keys,
|
|
ProxyHealthInterval: options.ProxyHealthInterval,
|
|
DefaultQuietHoursSchedule: oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(),
|
|
ProvisionerDaemonPSK: options.ProvisionerDaemonPSK,
|
|
ExternalTokenEncryption: options.ExternalTokenEncryption,
|
|
})
|
|
require.NoError(t, err)
|
|
setHandler(coderAPI.AGPL.RootHandler)
|
|
var provisionerCloser io.Closer = nopcloser{}
|
|
if options.IncludeProvisionerDaemon {
|
|
provisionerCloser = coderdtest.NewProvisionerDaemon(t, coderAPI.AGPL)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
cancelFunc()
|
|
_ = provisionerCloser.Close()
|
|
_ = coderAPI.Close()
|
|
})
|
|
client := codersdk.New(serverURL)
|
|
client.HTTPClient = &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
//nolint:gosec
|
|
InsecureSkipVerify: true,
|
|
},
|
|
},
|
|
}
|
|
var user codersdk.CreateFirstUserResponse
|
|
if !options.DontAddFirstUser {
|
|
user = coderdtest.CreateFirstUser(t, client)
|
|
if !options.DontAddLicense {
|
|
lo := LicenseOptions{}
|
|
if options.LicenseOptions != nil {
|
|
lo = *options.LicenseOptions
|
|
// The pgCoord is not supported by the fake DB & in-memory Pubsub. It only works on a real postgres.
|
|
if lo.AllFeatures || (lo.Features != nil && lo.Features[codersdk.FeatureHighAvailability] != 0) {
|
|
// we check for the in-memory test types so that the real types don't have to exported
|
|
_, ok := coderAPI.Pubsub.(*pubsub.MemoryPubsub)
|
|
require.False(t, ok, "FeatureHighAvailability is incompatible with MemoryPubsub")
|
|
}
|
|
}
|
|
_ = AddLicense(t, client, lo)
|
|
}
|
|
}
|
|
return client, provisionerCloser, coderAPI, user
|
|
}
|
|
|
|
// LicenseOptions is used to generate a license for testing.
|
|
// It supports the builder pattern for easy customization.
|
|
type LicenseOptions struct {
|
|
AccountType string
|
|
AccountID string
|
|
DeploymentIDs []string
|
|
Trial bool
|
|
FeatureSet codersdk.FeatureSet
|
|
AllFeatures bool
|
|
// GraceAt is the time at which the license will enter the grace period.
|
|
GraceAt time.Time
|
|
// ExpiresAt is the time at which the license will hard expire.
|
|
// ExpiresAt should always be greater then GraceAt.
|
|
ExpiresAt time.Time
|
|
// NotBefore is the time at which the license becomes valid. If set to the
|
|
// zero value, the `nbf` claim on the license is set to 1 minute in the
|
|
// past.
|
|
NotBefore time.Time
|
|
// IssuedAt is the time at which the license was issued. If set to the
|
|
// zero value, the `iat` claim on the license is set to 1 minute in the
|
|
// past.
|
|
IssuedAt time.Time
|
|
Features license.Features
|
|
}
|
|
|
|
func (opts *LicenseOptions) WithIssuedAt(now time.Time) *LicenseOptions {
|
|
opts.IssuedAt = now
|
|
return opts
|
|
}
|
|
|
|
func (opts *LicenseOptions) Expired(now time.Time) *LicenseOptions {
|
|
opts.NotBefore = now.Add(time.Hour * 24 * -4) // needs to be before the grace period
|
|
opts.ExpiresAt = now.Add(time.Hour * 24 * -2)
|
|
opts.GraceAt = now.Add(time.Hour * 24 * -3)
|
|
return opts
|
|
}
|
|
|
|
func (opts *LicenseOptions) GracePeriod(now time.Time) *LicenseOptions {
|
|
opts.NotBefore = now.Add(time.Hour * 24 * -2) // needs to be before the grace period
|
|
opts.ExpiresAt = now.Add(time.Hour * 24)
|
|
opts.GraceAt = now.Add(time.Hour * 24 * -1)
|
|
return opts
|
|
}
|
|
|
|
func (opts *LicenseOptions) Valid(now time.Time) *LicenseOptions {
|
|
opts.ExpiresAt = now.Add(time.Hour * 24 * 60)
|
|
opts.GraceAt = now.Add(time.Hour * 24 * 53)
|
|
return opts
|
|
}
|
|
|
|
func (opts *LicenseOptions) FutureTerm(now time.Time) *LicenseOptions {
|
|
opts.NotBefore = now.Add(time.Hour * 24)
|
|
opts.ExpiresAt = now.Add(time.Hour * 24 * 60)
|
|
opts.GraceAt = now.Add(time.Hour * 24 * 53)
|
|
return opts
|
|
}
|
|
|
|
func (opts *LicenseOptions) UserLimit(limit int64) *LicenseOptions {
|
|
return opts.Feature(codersdk.FeatureUserLimit, limit)
|
|
}
|
|
|
|
func (opts *LicenseOptions) ManagedAgentLimit(soft int64, hard int64) *LicenseOptions {
|
|
// These don't use named or exported feature names, see
|
|
// enterprise/coderd/license/license.go.
|
|
opts = opts.Feature(codersdk.FeatureName("managed_agent_limit_soft"), soft)
|
|
opts = opts.Feature(codersdk.FeatureName("managed_agent_limit_hard"), hard)
|
|
return opts
|
|
}
|
|
|
|
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
|
|
if opts.Features == nil {
|
|
opts.Features = license.Features{}
|
|
}
|
|
opts.Features[name] = value
|
|
return opts
|
|
}
|
|
|
|
func (opts *LicenseOptions) Generate(t *testing.T) string {
|
|
return GenerateLicense(t, *opts)
|
|
}
|
|
|
|
// AddFullLicense generates a license with all features enabled.
|
|
func AddFullLicense(t *testing.T, client *codersdk.Client) codersdk.License {
|
|
return AddLicense(t, client, LicenseOptions{AllFeatures: true})
|
|
}
|
|
|
|
// AddLicense generates a new license with the options provided and inserts it.
|
|
func AddLicense(t *testing.T, client *codersdk.Client, options LicenseOptions) codersdk.License {
|
|
l, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
|
|
License: GenerateLicense(t, options),
|
|
})
|
|
require.NoError(t, err)
|
|
return l
|
|
}
|
|
|
|
// GenerateLicense returns a signed JWT using the test key.
|
|
func GenerateLicense(t *testing.T, options LicenseOptions) string {
|
|
t.Helper()
|
|
if options.ExpiresAt.IsZero() {
|
|
options.ExpiresAt = time.Now().Add(time.Hour)
|
|
}
|
|
if options.GraceAt.IsZero() {
|
|
options.GraceAt = time.Now().Add(time.Hour)
|
|
}
|
|
if options.NotBefore.IsZero() {
|
|
options.NotBefore = time.Now().Add(-time.Minute)
|
|
}
|
|
|
|
issuedAt := options.IssuedAt
|
|
if issuedAt.IsZero() {
|
|
issuedAt = time.Now().Add(-time.Minute)
|
|
}
|
|
|
|
c := &license.Claims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ID: uuid.NewString(),
|
|
Issuer: "test@testing.test",
|
|
ExpiresAt: jwt.NewNumericDate(options.ExpiresAt),
|
|
NotBefore: jwt.NewNumericDate(options.NotBefore),
|
|
IssuedAt: jwt.NewNumericDate(issuedAt),
|
|
},
|
|
LicenseExpires: jwt.NewNumericDate(options.GraceAt),
|
|
AccountType: options.AccountType,
|
|
AccountID: options.AccountID,
|
|
DeploymentIDs: options.DeploymentIDs,
|
|
Trial: options.Trial,
|
|
Version: license.CurrentVersion,
|
|
AllFeatures: options.AllFeatures,
|
|
FeatureSet: options.FeatureSet,
|
|
Features: options.Features,
|
|
}
|
|
return GenerateLicenseRaw(t, c)
|
|
}
|
|
|
|
func GenerateLicenseRaw(t *testing.T, claims jwt.Claims) string {
|
|
t.Helper()
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
|
tok.Header[license.HeaderKeyID] = testKeyID
|
|
signedTok, err := tok.SignedString(testPrivateKey)
|
|
require.NoError(t, err)
|
|
return signedTok
|
|
}
|
|
|
|
type nopcloser struct{}
|
|
|
|
func (nopcloser) Close() error { return nil }
|
|
|
|
type CreateOrganizationOptions struct {
|
|
// IncludeProvisionerDaemon will spin up an external provisioner for the organization.
|
|
// This requires enterprise and the feature 'codersdk.FeatureExternalProvisionerDaemons'
|
|
IncludeProvisionerDaemon bool
|
|
}
|
|
|
|
func CreateOrganization(t *testing.T, client *codersdk.Client, opts CreateOrganizationOptions, mutators ...func(*codersdk.CreateOrganizationRequest)) codersdk.Organization {
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
req := codersdk.CreateOrganizationRequest{
|
|
Name: strings.ReplaceAll(strings.ToLower(namesgenerator.GetRandomName(0)), "_", "-"),
|
|
DisplayName: namesgenerator.GetRandomName(1),
|
|
Description: namesgenerator.GetRandomName(1),
|
|
Icon: "",
|
|
}
|
|
for _, mutator := range mutators {
|
|
mutator(&req)
|
|
}
|
|
|
|
org, err := client.CreateOrganization(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
if opts.IncludeProvisionerDaemon {
|
|
closer := NewExternalProvisionerDaemon(t, client, org.ID, map[string]string{})
|
|
t.Cleanup(func() {
|
|
_ = closer.Close()
|
|
})
|
|
}
|
|
|
|
return org
|
|
}
|
|
|
|
// NewExternalProvisionerDaemon runs an external provisioner daemon in a
|
|
// goroutine and returns a closer to stop it. The echo provisioner is used
|
|
// here. This is the default provisioner for tests and should be fine for
|
|
// most use cases. If you need to test terraform-specific behaviors, use
|
|
// NewExternalProvisionerDaemonTerraform instead.
|
|
func NewExternalProvisionerDaemon(t testing.TB, client *codersdk.Client, org uuid.UUID, tags map[string]string) io.Closer {
|
|
t.Helper()
|
|
return newExternalProvisionerDaemon(t, client, org, tags, codersdk.ProvisionerTypeEcho)
|
|
}
|
|
|
|
// NewExternalProvisionerDaemonTerraform runs an external provisioner daemon in
|
|
// a goroutine and returns a closer to stop it. The terraform provisioner is
|
|
// used here. Avoid using this unless you need to test terraform-specific
|
|
// behaviors!
|
|
func NewExternalProvisionerDaemonTerraform(t testing.TB, client *codersdk.Client, org uuid.UUID, tags map[string]string) io.Closer {
|
|
t.Helper()
|
|
return newExternalProvisionerDaemon(t, client, org, tags, codersdk.ProvisionerTypeTerraform)
|
|
}
|
|
|
|
// nolint // This function is a helper for tests and should not be linted.
|
|
func newExternalProvisionerDaemon(t testing.TB, client *codersdk.Client, org uuid.UUID, tags map[string]string, provisionerType codersdk.ProvisionerType) io.Closer {
|
|
t.Helper()
|
|
|
|
entitlements, err := client.Entitlements(context.Background())
|
|
if err != nil {
|
|
t.Errorf("external provisioners requires a license with entitlements. The client failed to fetch the entitlements, is this an enterprise instance of coderd?")
|
|
t.FailNow()
|
|
return nil
|
|
}
|
|
|
|
feature := entitlements.Features[codersdk.FeatureExternalProvisionerDaemons]
|
|
if !feature.Enabled || feature.Entitlement != codersdk.EntitlementEntitled {
|
|
t.Errorf("external provisioner daemons require an entitled license")
|
|
t.FailNow()
|
|
return nil
|
|
}
|
|
|
|
provisionerClient, provisionerSrv := drpcsdk.MemTransportPipe()
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
serveDone := make(chan struct{})
|
|
t.Cleanup(func() {
|
|
_ = provisionerClient.Close()
|
|
_ = provisionerSrv.Close()
|
|
cancelFunc()
|
|
<-serveDone
|
|
})
|
|
|
|
switch provisionerType {
|
|
case codersdk.ProvisionerTypeTerraform:
|
|
// Ensure the Terraform binary is present in the path.
|
|
// If not, we fail this test rather than downloading it.
|
|
terraformPath, err := exec.LookPath("terraform")
|
|
require.NoError(t, err, "terraform binary not found in PATH")
|
|
t.Logf("using Terraform binary at %s", terraformPath)
|
|
|
|
go func() {
|
|
defer close(serveDone)
|
|
assert.NoError(t, terraform.Serve(ctx, &terraform.ServeOptions{
|
|
BinaryPath: terraformPath,
|
|
CachePath: t.TempDir(),
|
|
ServeOptions: &provisionersdk.ServeOptions{
|
|
Listener: provisionerSrv,
|
|
WorkDirectory: t.TempDir(),
|
|
},
|
|
}))
|
|
}()
|
|
case codersdk.ProvisionerTypeEcho:
|
|
go func() {
|
|
defer close(serveDone)
|
|
assert.NoError(t, echo.Serve(ctx, &provisionersdk.ServeOptions{
|
|
Listener: provisionerSrv,
|
|
WorkDirectory: t.TempDir(),
|
|
}))
|
|
}()
|
|
default:
|
|
t.Fatalf("unsupported provisioner type: %s", provisionerType)
|
|
return nil
|
|
}
|
|
|
|
daemon := provisionerd.New(func(ctx context.Context) (provisionerdproto.DRPCProvisionerDaemonClient, error) {
|
|
return client.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{
|
|
Name: testutil.GetRandomName(t),
|
|
Organization: org,
|
|
Provisioners: []codersdk.ProvisionerType{provisionerType},
|
|
Tags: tags,
|
|
})
|
|
}, &provisionerd.Options{
|
|
Logger: testutil.Logger(t).Named("provisionerd").Leveled(slog.LevelDebug),
|
|
UpdateInterval: 250 * time.Millisecond,
|
|
ForceCancelInterval: 5 * time.Second,
|
|
Connector: provisionerd.LocalProvisioners{
|
|
string(provisionerType): sdkproto.NewDRPCProvisionerClient(provisionerClient),
|
|
},
|
|
})
|
|
closer := coderdtest.NewProvisionerDaemonCloser(daemon)
|
|
t.Cleanup(func() {
|
|
_ = closer.Close()
|
|
})
|
|
|
|
return closer
|
|
}
|