Skip to content

Commit 187e196

Browse files
authored
fix(aws-marketplace): check the container license with CheckoutLicense so AWS's standard buyer policy works (#9981)
* fix(aws-marketplace): check the container license with CheckoutLicense so AWS's standard buyer policy works A buyer who follows AWS's generated launch steps gets the managed AWSLicenseManagerConsumptionPolicy, which does not allow ListReceivedLicenses, so the check failed and Sim stayed on the open-source core. Check out a provisional unit of the entitlement and return it at once, the integration AWS documents for contract-priced containers. License Manager answers a unit held by another replica, or one a crashed replica never returned, the same way as a missing entitlement. Startup retries that answer, and a running Enterprise deployment only restarts without Enterprise once it has held longer than an unreturned unit can last. * fix(aws-marketplace): count only an unbroken run of no-entitlement answers, assert the checkout request, bump the chart * fix(aws-marketplace): check the container license as an uncounted tier entitlement AWS documents a contract that unlocks a tier of features, rather than metering a quantity, as a provisional CheckoutLicense with unit None and no value. That checkout is not counted against the contract, so replicas never compete for a unit, and the check-in, startup retry, and one-hour confirmation window that guarded against a held unit go away. A definite answer that differs from the boot mode restarts; a failed check keeps the current mode.
1 parent 5b4ce4a commit 187e196

5 files changed

Lines changed: 209 additions & 80 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn() }))
4+
5+
vi.mock('@aws-sdk/client-license-manager', () => ({
6+
LicenseManagerClient: class {
7+
send = mockSend
8+
},
9+
CheckoutLicenseCommand: class {
10+
constructor(public input: { ProductSKU: string }) {}
11+
},
12+
}))
13+
vi.mock('@/lib/core/network/proxy-agent.server', () => ({ awsRequestHandler: () => undefined }))
14+
vi.mock('@sim/utils/random', () => ({ randomInt: (min: number) => min }))
15+
16+
import { applyAwsMarketplaceContainerLicense } from '@/lib/billing/aws-marketplace/container-license'
17+
18+
const MINUTE = 60 * 1000
19+
20+
type Answer =
21+
| 'entitled'
22+
| 'NoEntitlementsAllowedException'
23+
| 'ResourceNotFoundException'
24+
| 'AccessDeniedException'
25+
| 'ServerInternalException'
26+
27+
/** License Manager's checkout answers in order; the last one repeats once the list runs out. */
28+
let answers: Answer[] = []
29+
const checkoutRequests: unknown[] = []
30+
31+
function awsError(name: string): Error {
32+
return Object.assign(new Error(name), { name })
33+
}
34+
35+
function enterpriseEnabled(): boolean {
36+
return process.env.ENTERPRISE_ENABLED === 'true'
37+
}
38+
39+
describe('applyAwsMarketplaceContainerLicense', () => {
40+
let kill: ReturnType<typeof vi.spyOn>
41+
42+
beforeEach(() => {
43+
vi.useFakeTimers({ toFake: ['setTimeout'] })
44+
answers = []
45+
checkoutRequests.length = 0
46+
vi.stubEnv('AWS_MARKETPLACE_LICENSE_PRODUCT_SKU', 'product-placeholder')
47+
kill = vi.spyOn(process, 'kill').mockImplementation(() => true)
48+
mockSend.mockReset()
49+
mockSend.mockImplementation(async (command: { input: unknown }) => {
50+
checkoutRequests.push(command.input)
51+
const answer = answers.length > 1 ? answers.shift() : answers[0]
52+
if (answer !== 'entitled') throw awsError(answer ?? 'ServerInternalException')
53+
return { EntitlementsAllowed: [{ Name: 'enterprise_license', Unit: 'None' }] }
54+
})
55+
})
56+
57+
afterEach(() => {
58+
vi.clearAllTimers()
59+
vi.useRealTimers()
60+
kill.mockRestore()
61+
Reflect.deleteProperty(process.env, 'ENTERPRISE_ENABLED')
62+
Reflect.deleteProperty(process.env, 'NEXT_PUBLIC_ENTERPRISE_ENABLED')
63+
})
64+
65+
it('does nothing outside a Marketplace install', async () => {
66+
vi.stubEnv('AWS_MARKETPLACE_LICENSE_PRODUCT_SKU', undefined)
67+
await applyAwsMarketplaceContainerLicense()
68+
expect(mockSend).not.toHaveBeenCalled()
69+
})
70+
71+
it('turns Enterprise on with an uncounted checkout of the entitlement', async () => {
72+
answers = ['entitled']
73+
await applyAwsMarketplaceContainerLicense()
74+
expect(enterpriseEnabled()).toBe(true)
75+
expect(checkoutRequests).toEqual([
76+
expect.objectContaining({
77+
ProductSKU: 'product-placeholder',
78+
CheckoutType: 'PROVISIONAL',
79+
KeyFingerprint: 'aws:294406891311:AWS/Marketplace:issuer-fingerprint',
80+
Entitlements: [{ Name: 'enterprise_license', Unit: 'None' }],
81+
}),
82+
])
83+
})
84+
85+
it('runs without Enterprise when no license grants the entitlement', async () => {
86+
answers = ['NoEntitlementsAllowedException']
87+
await applyAwsMarketplaceContainerLicense()
88+
expect(enterpriseEnabled()).toBe(false)
89+
})
90+
91+
it('runs without Enterprise when its IAM role cannot check out', async () => {
92+
answers = ['AccessDeniedException']
93+
await applyAwsMarketplaceContainerLicense()
94+
expect(enterpriseEnabled()).toBe(false)
95+
})
96+
97+
it.each<Answer>(['NoEntitlementsAllowedException', 'ResourceNotFoundException'])(
98+
'restarts without Enterprise once the license ends (%s)',
99+
async (answer) => {
100+
answers = ['entitled', answer]
101+
await applyAwsMarketplaceContainerLicense()
102+
await vi.advanceTimersByTimeAsync(16 * MINUTE)
103+
expect(kill).toHaveBeenCalledWith(process.pid, 'SIGTERM')
104+
}
105+
)
106+
107+
it('restarts into Enterprise once the license becomes active', async () => {
108+
answers = ['NoEntitlementsAllowedException', 'entitled']
109+
await applyAwsMarketplaceContainerLicense()
110+
expect(enterpriseEnabled()).toBe(false)
111+
await vi.advanceTimersByTimeAsync(16 * MINUTE)
112+
expect(kill).toHaveBeenCalledWith(process.pid, 'SIGTERM')
113+
})
114+
115+
it.each<Answer>(['ServerInternalException', 'AccessDeniedException'])(
116+
'never restarts a running Enterprise deployment over a failed check (%s)',
117+
async (answer) => {
118+
answers = ['entitled', answer]
119+
await applyAwsMarketplaceContainerLicense()
120+
await vi.advanceTimersByTimeAsync(6 * 60 * MINUTE)
121+
expect(kill).not.toHaveBeenCalled()
122+
}
123+
)
124+
})
Lines changed: 77 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,21 @@
1-
import {
2-
type GrantedLicense,
3-
LicenseManagerClient,
4-
ListReceivedLicensesCommand,
5-
} from '@aws-sdk/client-license-manager'
1+
import { CheckoutLicenseCommand, LicenseManagerClient } from '@aws-sdk/client-license-manager'
62
import { createLogger } from '@sim/logger'
73
import { getErrorMessage } from '@sim/utils/errors'
4+
import { generateId } from '@sim/utils/id'
85
import { randomInt } from '@sim/utils/random'
96
import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server'
107

118
/**
129
* Enterprise entitlement for Sim installed from the AWS Marketplace container listing.
1310
*
1411
* The listing sells an annual contract whose one dimension is `enterprise_license`. AWS Marketplace
15-
* grants the buyer a license in AWS License Manager, and the running product reads it. While the
16-
* license is active, the Enterprise suite is on (`ENTERPRISE_ENABLED`). Without one, Sim runs as the
17-
* open-source core, so a lapsed renewal or an unreachable License Manager never locks a buyer out
18-
* of their deployment.
19-
*
20-
* The check reads the license rather than checking a unit out of it. A contract usually covers the
21-
* whole deployment with one unit, so checking it out from every replica would make them compete
22-
* for it, and a unit left checked out (a failed check-in holds it for an hour) would read exactly
23-
* like an expired license.
12+
* grants the buyer a license in AWS License Manager, and the running product checks it the way AWS
13+
* documents for a contract that unlocks a tier of features rather than metering a quantity: a
14+
* provisional `CheckoutLicense` of the entitlement with unit `None`. Such a checkout is not counted
15+
* against the contract, so every replica can check at once, and it needs only the permission AWS's
16+
* managed `AWSLicenseManagerConsumptionPolicy` grants. While the license is active, the Enterprise
17+
* suite is on (`ENTERPRISE_ENABLED`). Without one, Sim runs as the open-source core, so a lapsed
18+
* renewal or an unreachable License Manager never locks a buyer out of their deployment.
2419
*
2520
* Opt-in: nothing here runs unless {@link PRODUCT_SKU_ENV} is set, which only the Marketplace
2621
* Helm profile does. Enterprise flags are read once at startup by both the server and the
@@ -34,8 +29,8 @@ const logger = createLogger('AwsMarketplaceContainerLicense')
3429
const PRODUCT_SKU_ENV = 'AWS_MARKETPLACE_LICENSE_PRODUCT_SKU'
3530

3631
/**
37-
* Optional override for where the license is read. License Manager replicates a granted license to
38-
* other Regions, so the default is the pod's own Region, the same chain AWS's EKS walkthrough uses.
32+
* Optional override for where the license is checked. License Manager replicates a granted license
33+
* to other Regions, so the default is the pod's own Region, the same chain AWS's EKS walkthrough uses.
3934
*/
4035
const LICENSE_REGION_ENV = 'AWS_MARKETPLACE_LICENSE_REGION'
4136

@@ -48,21 +43,26 @@ const ENTERPRISE_ENTITLEMENT = 'enterprise_license'
4843
/** AWS recommends re-checking every 15 minutes to pick up renewals, upgrades, and cancellations. */
4944
const RECHECK_INTERVAL_MS = 15 * 60 * 1000
5045

51-
/**
52-
* Replicas start together, so their re-checks line up; each waits a random part of this before
53-
* restarting, so a renewal or lapse never takes every replica down at once.
54-
*/
55-
const RESTART_SPREAD_MS = 10 * 60 * 1000
46+
/** Replicas start together; a random part of this added to each re-check keeps them apart. */
47+
const RECHECK_JITTER_MS = 2 * 60 * 1000
5648

5749
/**
58-
* How the buyer's account holds the license. A delegate grant only shows licenses distributed to
59-
* other accounts, and a grant still awaiting acceptance, or rejected, disabled, or deleted by the
60-
* license administrator, entitles nothing. No status at all is a license the account bought itself.
50+
* Replicas see a change together; each waits a random part of this before restarting, so a renewal
51+
* or lapse never takes every replica down at once.
6152
*/
62-
const USABLE_RECEIVED_STATUSES = new Set(['ACTIVE', 'WORKFLOW_COMPLETED'])
53+
const RESTART_SPREAD_MS = 10 * 60 * 1000
6354

6455
const ENTERPRISE_FLAGS = ['ENTERPRISE_ENABLED', 'NEXT_PUBLIC_ENTERPRISE_ENABLED'] as const
6556

57+
/** License Manager's answers when the account holds no active license with the entitlement. */
58+
const NO_ENTITLEMENT_ERRORS = new Set([
59+
'NoEntitlementsAllowedException',
60+
'ResourceNotFoundException',
61+
])
62+
63+
/** The ServiceAccount's IAM role does not allow the check: a setup problem, never a verdict. */
64+
const PERMISSION_ERRORS = new Set(['AccessDeniedException', 'AuthorizationException'])
65+
6666
/** `unknown` is a failed check (network, permissions, throttling), never a verdict on the license. */
6767
type LicenseState = 'entitled' | 'not-entitled' | 'unknown'
6868

@@ -78,58 +78,62 @@ function createClient(): LicenseManagerClient {
7878
})
7979
}
8080

81-
function isUsableGrant({ ReceivedMetadata: received }: GrantedLicense): boolean {
82-
if (received?.GrantType === 'DelegateGrant') return false
83-
return !received?.ReceivedStatus || USABLE_RECEIVED_STATUSES.has(received.ReceivedStatus)
84-
}
85-
86-
/**
87-
* An AWS Marketplace license this product's buyer holds is current when License Manager reports it
88-
* `AVAILABLE`, its issuer is AWS Marketplace, it carries the Enterprise entitlement, and the account
89-
* holds it through a grant it may use. License Manager moves an ended, cancelled, or suspended
90-
* license out of `AVAILABLE` itself.
91-
*/
92-
function isActiveEnterpriseLicense(license: GrantedLicense): boolean {
93-
return (
94-
license.Status === 'AVAILABLE' &&
95-
isUsableGrant(license) &&
96-
license.Issuer?.KeyFingerprint === MARKETPLACE_KEY_FINGERPRINT &&
97-
(license.Entitlements ?? []).some((entitlement) => entitlement.Name === ENTERPRISE_ENTITLEMENT)
98-
)
81+
function classifyFailure(error: unknown): LicenseState {
82+
const name = error instanceof Error ? error.name : undefined
83+
if (name && NO_ENTITLEMENT_ERRORS.has(name)) {
84+
logger.info('AWS License Manager reports no active Enterprise entitlement', { error: name })
85+
return 'not-entitled'
86+
}
87+
if (name && PERMISSION_ERRORS.has(name)) {
88+
logger.error(
89+
'The IAM role cannot check the AWS Marketplace license; allow license-manager:CheckoutLicense',
90+
{ error: name }
91+
)
92+
return 'unknown'
93+
}
94+
logger.warn('Could not check the AWS Marketplace Enterprise license', {
95+
error: name,
96+
message: getErrorMessage(error, 'Unknown error'),
97+
})
98+
return 'unknown'
9999
}
100100

101101
async function checkLicense(
102102
client: LicenseManagerClient,
103103
productSku: string
104104
): Promise<LicenseState> {
105105
try {
106-
let nextToken: string | undefined
107-
do {
108-
const page = await client.send(
109-
new ListReceivedLicensesCommand({
110-
Filters: [
111-
{ Name: 'ProductSKU', Values: [productSku] },
112-
{ Name: 'Fingerprint', Values: [MARKETPLACE_KEY_FINGERPRINT] },
113-
],
114-
NextToken: nextToken,
115-
})
116-
)
117-
if ((page.Licenses ?? []).some(isActiveEnterpriseLicense)) return 'entitled'
118-
nextToken = page.NextToken
119-
} while (nextToken)
120-
return 'not-entitled'
106+
const checkout = await client.send(
107+
new CheckoutLicenseCommand({
108+
ProductSKU: productSku,
109+
CheckoutType: 'PROVISIONAL',
110+
KeyFingerprint: MARKETPLACE_KEY_FINGERPRINT,
111+
Entitlements: [{ Name: ENTERPRISE_ENTITLEMENT, Unit: 'None' }],
112+
ClientToken: generateId(),
113+
})
114+
)
115+
const allowed = (checkout.EntitlementsAllowed ?? []).some(
116+
(entitlement) => entitlement.Name === ENTERPRISE_ENTITLEMENT
117+
)
118+
return allowed ? 'entitled' : 'not-entitled'
121119
} catch (error) {
122-
logger.warn('Could not check the AWS Marketplace Enterprise license', {
123-
message: getErrorMessage(error, 'Unknown error'),
124-
})
125-
return 'unknown'
120+
return classifyFailure(error)
126121
}
127122
}
128123

129124
function setEnterpriseFlags(enabled: boolean): void {
130125
for (const flag of ENTERPRISE_FLAGS) process.env[flag] = enabled ? 'true' : 'false'
131126
}
132127

128+
function restartToApply(state: LicenseState): void {
129+
const delayMs = randomInt(0, RESTART_SPREAD_MS)
130+
logger.info('AWS Marketplace Enterprise license changed; restarting to apply it', {
131+
state,
132+
delayMs,
133+
})
134+
setTimeout(() => process.kill(process.pid, 'SIGTERM'), delayMs)
135+
}
136+
133137
/**
134138
* Resolves the Enterprise license before the server loads, so every Enterprise gate reads the
135139
* answer through `ENTERPRISE_ENABLED`, then keeps re-checking. Returns without doing anything on a
@@ -150,17 +154,16 @@ export async function applyAwsMarketplaceContainerLicense(): Promise<void> {
150154
{ state: bootState }
151155
)
152156

153-
const recheck = setInterval(async () => {
157+
const scheduleRecheck = () => {
158+
setTimeout(recheck, RECHECK_INTERVAL_MS + randomInt(0, RECHECK_JITTER_MS)).unref()
159+
}
160+
const recheck = async () => {
154161
const state = await checkLicense(client, productSku)
155-
const entitledNow = state === 'entitled'
156-
if (state === 'unknown' || entitledNow === enterpriseAtBoot) return
157-
clearInterval(recheck)
158-
const delayMs = randomInt(0, RESTART_SPREAD_MS)
159-
logger.info('AWS Marketplace Enterprise license changed; restarting to apply it', {
160-
state,
161-
delayMs,
162-
})
163-
setTimeout(() => process.kill(process.pid, 'SIGTERM'), delayMs)
164-
}, RECHECK_INTERVAL_MS)
165-
recheck.unref()
162+
// A failed check keeps the current mode; only a definite answer that differs restarts.
163+
const changed =
164+
(state === 'entitled' && !enterpriseAtBoot) || (state === 'not-entitled' && enterpriseAtBoot)
165+
if (changed) return restartToApply(state)
166+
scheduleRecheck()
167+
}
168+
scheduleRecheck()
166169
}

‎helm/sim/Chart.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ apiVersion: v2
22
name: sim
33
description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents
44
type: application
5-
version: 1.11.16
5+
version: 1.11.17
66
appVersion: "v0.8.26"
77
kubeVersion: ">=1.25.0-0"
88
home: https://sim.ai

‎helm/sim/examples/aws-marketplace-prerequisites.yaml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,10 @@ Resources:
159159
Version: '2012-10-17'
160160
Statement:
161161
- Effect: Allow
162-
# Sim only reads the license. ListReceivedLicenses has no resource-level
163-
# permissions, so it cannot be scoped below '*'.
164-
Action: license-manager:ListReceivedLicenses
162+
# Sim checks the license with an uncounted checkout, which AWS's managed
163+
# AWSLicenseManagerConsumptionPolicy also grants. CheckoutLicense has no
164+
# resource-level permissions, so it cannot be scoped below '*'.
165+
Action: license-manager:CheckoutLicense
165166
Resource: '*'
166167

167168
Outputs:

‎helm/sim/examples/values-aws-marketplace.yaml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
# provider (for IRSA).
2222
# 2. RDS for PostgreSQL 17 and ElastiCache reachable from the cluster. The listing's
2323
# CloudFormation template creates both in your VPC.
24-
# 3. An IAM role for the ServiceAccount below that allows license-manager:ListReceivedLicenses
25-
# (the CloudFormation template creates it).
24+
# 3. An IAM role for the ServiceAccount below that allows license-manager:CheckoutLicense (the
25+
# CloudFormation template creates it; AWS's managed AWSLicenseManagerConsumptionPolicy
26+
# grants it too).
2627
# 4. An ACM certificate for the two hostnames below.
2728
# 5. Two Secrets in the install namespace (no secret ever goes in this file). The template's
2829
# DatabasePasswordArn and CacheAuthTokenArn outputs are the ARNs of the Secrets Manager

0 commit comments

Comments
 (0)