Skip to content

Commit e518dd0

Browse files
committed
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 a0b6763 commit e518dd0

4 files changed

Lines changed: 62 additions & 174 deletions

File tree

‎apps/sim/lib/billing/aws-marketplace/container-license.test.ts‎

Lines changed: 33 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,24 @@ vi.mock('@aws-sdk/client-license-manager', () => ({
99
CheckoutLicenseCommand: class {
1010
constructor(public input: { ProductSKU: string }) {}
1111
},
12-
CheckInLicenseCommand: class {
13-
constructor(public input: { LicenseConsumptionToken: string }) {}
14-
},
1512
}))
1613
vi.mock('@/lib/core/network/proxy-agent.server', () => ({ awsRequestHandler: () => undefined }))
1714
vi.mock('@sim/utils/random', () => ({ randomInt: (min: number) => min }))
1815

19-
import { CheckInLicenseCommand } from '@aws-sdk/client-license-manager'
2016
import { applyAwsMarketplaceContainerLicense } from '@/lib/billing/aws-marketplace/container-license'
2117

2218
const MINUTE = 60 * 1000
23-
const TOKEN = 'consumption-token-placeholder'
2419

2520
type Answer =
2621
| 'entitled'
2722
| 'NoEntitlementsAllowedException'
23+
| 'ResourceNotFoundException'
2824
| 'AccessDeniedException'
2925
| 'ServerInternalException'
3026

3127
/** License Manager's checkout answers in order; the last one repeats once the list runs out. */
3228
let answers: Answer[] = []
33-
let checkInFails = false
3429
const checkoutRequests: unknown[] = []
35-
const checkIns: Array<string | undefined> = []
3630

3731
function awsError(name: string): Error {
3832
return Object.assign(new Error(name), { name })
@@ -42,37 +36,21 @@ function enterpriseEnabled(): boolean {
4236
return process.env.ENTERPRISE_ENABLED === 'true'
4337
}
4438

45-
async function boot(): Promise<void> {
46-
const started = applyAwsMarketplaceContainerLicense()
47-
await vi.advanceTimersByTimeAsync(30_000)
48-
await started
49-
}
50-
5139
describe('applyAwsMarketplaceContainerLicense', () => {
5240
let kill: ReturnType<typeof vi.spyOn>
5341

5442
beforeEach(() => {
55-
vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] })
43+
vi.useFakeTimers({ toFake: ['setTimeout'] })
5644
answers = []
57-
checkInFails = false
58-
checkIns.length = 0
5945
checkoutRequests.length = 0
6046
vi.stubEnv('AWS_MARKETPLACE_LICENSE_PRODUCT_SKU', 'product-placeholder')
6147
kill = vi.spyOn(process, 'kill').mockImplementation(() => true)
6248
mockSend.mockReset()
63-
mockSend.mockImplementation(async (command: unknown) => {
64-
if (command instanceof CheckInLicenseCommand) {
65-
checkIns.push(command.input.LicenseConsumptionToken)
66-
if (checkInFails) throw awsError('ServerInternalException')
67-
return {}
68-
}
69-
checkoutRequests.push((command as { input: unknown }).input)
49+
mockSend.mockImplementation(async (command: { input: unknown }) => {
50+
checkoutRequests.push(command.input)
7051
const answer = answers.length > 1 ? answers.shift() : answers[0]
7152
if (answer !== 'entitled') throw awsError(answer ?? 'ServerInternalException')
72-
return {
73-
LicenseConsumptionToken: TOKEN,
74-
EntitlementsAllowed: [{ Name: 'enterprise_license', Unit: 'Count', Value: '1' }],
75-
}
53+
return { EntitlementsAllowed: [{ Name: 'enterprise_license', Unit: 'None' }] }
7654
})
7755
})
7856

@@ -86,91 +64,61 @@ describe('applyAwsMarketplaceContainerLicense', () => {
8664

8765
it('does nothing outside a Marketplace install', async () => {
8866
vi.stubEnv('AWS_MARKETPLACE_LICENSE_PRODUCT_SKU', undefined)
89-
await boot()
67+
await applyAwsMarketplaceContainerLicense()
9068
expect(mockSend).not.toHaveBeenCalled()
9169
})
9270

93-
it('turns Enterprise on and returns the unit it checked out', async () => {
71+
it('turns Enterprise on with an uncounted checkout of the entitlement', async () => {
9472
answers = ['entitled']
95-
await boot()
73+
await applyAwsMarketplaceContainerLicense()
9674
expect(enterpriseEnabled()).toBe(true)
9775
expect(checkoutRequests).toEqual([
9876
expect.objectContaining({
9977
ProductSKU: 'product-placeholder',
10078
CheckoutType: 'PROVISIONAL',
10179
KeyFingerprint: 'aws:294406891311:AWS/Marketplace:issuer-fingerprint',
102-
Entitlements: [{ Name: 'enterprise_license', Value: '1', Unit: 'Count' }],
80+
Entitlements: [{ Name: 'enterprise_license', Unit: 'None' }],
10381
}),
10482
])
105-
expect(checkIns).toEqual([TOKEN])
106-
})
107-
108-
it('keeps an active license when returning the unit fails', async () => {
109-
answers = ['entitled']
110-
checkInFails = true
111-
await boot()
112-
expect(enterpriseEnabled()).toBe(true)
113-
})
114-
115-
it('retries a unit another replica is holding at startup', async () => {
116-
answers = ['NoEntitlementsAllowedException', 'entitled']
117-
await boot()
118-
expect(enterpriseEnabled()).toBe(true)
11983
})
12084

121-
it('runs without Enterprise when no unit is allowed at startup', async () => {
85+
it('runs without Enterprise when no license grants the entitlement', async () => {
12286
answers = ['NoEntitlementsAllowedException']
123-
await boot()
87+
await applyAwsMarketplaceContainerLicense()
12488
expect(enterpriseEnabled()).toBe(false)
12589
})
12690

12791
it('runs without Enterprise when its IAM role cannot check out', async () => {
12892
answers = ['AccessDeniedException']
129-
await boot()
93+
await applyAwsMarketplaceContainerLicense()
13094
expect(enterpriseEnabled()).toBe(false)
13195
})
13296

133-
it('keeps Enterprise on through a unit held elsewhere for up to an hour', async () => {
134-
answers = ['entitled', ...Array<Answer>(5).fill('NoEntitlementsAllowedException'), 'entitled']
135-
await boot()
136-
await vi.advanceTimersByTimeAsync(180 * MINUTE)
137-
expect(kill).not.toHaveBeenCalled()
138-
})
139-
140-
it('restarts without Enterprise once no entitlement outlasts any held unit', async () => {
141-
answers = ['entitled', 'NoEntitlementsAllowedException']
142-
await boot()
143-
await vi.advanceTimersByTimeAsync(85 * MINUTE)
144-
expect(kill).not.toHaveBeenCalled()
145-
await vi.advanceTimersByTimeAsync(10 * MINUTE)
146-
expect(kill).toHaveBeenCalledWith(process.pid, 'SIGTERM')
147-
})
148-
149-
it('does not count failed checks toward switching Enterprise off', async () => {
150-
answers = [
151-
'entitled',
152-
'NoEntitlementsAllowedException',
153-
...Array<Answer>(6).fill('ServerInternalException'),
154-
'NoEntitlementsAllowedException',
155-
'entitled',
156-
]
157-
await boot()
158-
await vi.advanceTimersByTimeAsync(130 * MINUTE)
159-
expect(kill).not.toHaveBeenCalled()
160-
})
161-
162-
it('never restarts a running Enterprise deployment over a permission failure', async () => {
163-
answers = ['entitled', 'AccessDeniedException']
164-
await boot()
165-
await vi.advanceTimersByTimeAsync(6 * 60 * MINUTE)
166-
expect(kill).not.toHaveBeenCalled()
167-
})
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+
)
168106

169107
it('restarts into Enterprise once the license becomes active', async () => {
170-
answers = [...Array<Answer>(3).fill('NoEntitlementsAllowedException'), 'entitled']
171-
await boot()
108+
answers = ['NoEntitlementsAllowedException', 'entitled']
109+
await applyAwsMarketplaceContainerLicense()
172110
expect(enterpriseEnabled()).toBe(false)
173111
await vi.advanceTimersByTimeAsync(16 * MINUTE)
174112
expect(kill).toHaveBeenCalledWith(process.pid, 'SIGTERM')
175113
})
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+
)
176124
})

‎apps/sim/lib/billing/aws-marketplace/container-license.ts‎

Lines changed: 22 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
1-
import {
2-
CheckInLicenseCommand,
3-
CheckoutLicenseCommand,
4-
LicenseManagerClient,
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'
8-
import { sleep } from '@sim/utils/helpers'
94
import { generateId } from '@sim/utils/id'
105
import { randomInt } from '@sim/utils/random'
116
import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server'
@@ -15,18 +10,12 @@ import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server'
1510
*
1611
* The listing sells an annual contract whose one dimension is `enterprise_license`. AWS Marketplace
1712
* grants the buyer a license in AWS License Manager, and the running product checks it the way AWS
18-
* documents for contract-priced containers: a provisional `CheckoutLicense` of the entitlement,
19-
* which needs only permissions in AWS's managed `AWSLicenseManagerConsumptionPolicy`. While the
20-
* license is active, the Enterprise suite is on (`ENTERPRISE_ENABLED`). Without one, Sim runs as
21-
* the open-source core, so a lapsed renewal or an unreachable License Manager never locks a buyer
22-
* out of their deployment.
23-
*
24-
* A contract usually covers the deployment with one unit, which every replica checks out. Each
25-
* check returns its unit at once (`CheckInLicense`), but two replicas checking together, or a unit
26-
* a crashed replica never returned (License Manager takes it back after an hour), answer exactly
27-
* like a license without the entitlement: AWS reports both as `NoEntitlementsAllowedException`. So
28-
* that answer switches a running deployment's Enterprise off only after it has held for longer
29-
* than a held unit can last.
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.
3019
*
3120
* Opt-in: nothing here runs unless {@link PRODUCT_SKU_ENV} is set, which only the Marketplace
3221
* Helm profile does. Enterprise flags are read once at startup by both the server and the
@@ -57,21 +46,6 @@ const RECHECK_INTERVAL_MS = 15 * 60 * 1000
5746
/** Replicas start together; a random part of this added to each re-check keeps them apart. */
5847
const RECHECK_JITTER_MS = 2 * 60 * 1000
5948

60-
/** License Manager takes back a unit nobody checked in after an hour. */
61-
const UNRETURNED_UNIT_MS = 60 * 60 * 1000
62-
63-
/**
64-
* How long "no entitlement" must hold, with no other answer in between, before a running Enterprise
65-
* deployment believes it: longer than an unreturned unit lasts, plus one re-check, so a unit held
66-
* elsewhere can never cause it.
67-
*/
68-
const NO_ENTITLEMENT_CONFIRMATION_MS = UNRETURNED_UNIT_MS + RECHECK_INTERVAL_MS
69-
70-
/** Replicas rolling out together can meet on the unit, so startup retries that answer. */
71-
const STARTUP_ATTEMPTS = 3
72-
const STARTUP_RETRY_MIN_MS = 1_000
73-
const STARTUP_RETRY_MAX_MS = 5_000
74-
7549
/**
7650
* Replicas see a change together; each waits a random part of this before restarting, so a renewal
7751
* or lapse never takes every replica down at once.
@@ -80,7 +54,7 @@ const RESTART_SPREAD_MS = 10 * 60 * 1000
8054

8155
const ENTERPRISE_FLAGS = ['ENTERPRISE_ENABLED', 'NEXT_PUBLIC_ENTERPRISE_ENABLED'] as const
8256

83-
/** License Manager's answers when the account holds no unit of the entitlement it can use now. */
57+
/** License Manager's answers when the account holds no active license with the entitlement. */
8458
const NO_ENTITLEMENT_ERRORS = new Set([
8559
'NoEntitlementsAllowedException',
8660
'ResourceNotFoundException',
@@ -89,11 +63,8 @@ const NO_ENTITLEMENT_ERRORS = new Set([
8963
/** The ServiceAccount's IAM role does not allow the check: a setup problem, never a verdict. */
9064
const PERMISSION_ERRORS = new Set(['AccessDeniedException', 'AuthorizationException'])
9165

92-
/**
93-
* `no-entitlement` is AWS's answer for both an unlicensed account and a unit held elsewhere.
94-
* `unknown` is a failed check (network, permissions, throttling), never a verdict on the license.
95-
*/
96-
type LicenseState = 'entitled' | 'no-entitlement' | 'unknown'
66+
/** `unknown` is a failed check (network, permissions, throttling), never a verdict on the license. */
67+
type LicenseState = 'entitled' | 'not-entitled' | 'unknown'
9768

9869
function createClient(): LicenseManagerClient {
9970
const region =
@@ -107,25 +78,15 @@ function createClient(): LicenseManagerClient {
10778
})
10879
}
10980

110-
/** A unit left out returns to the pool within the hour, so a failed check-in is only logged. */
111-
async function checkIn(client: LicenseManagerClient, licenseConsumptionToken: string) {
112-
try {
113-
await client.send(
114-
new CheckInLicenseCommand({ LicenseConsumptionToken: licenseConsumptionToken })
115-
)
116-
} catch (error) {
117-
logger.warn('Could not return the AWS Marketplace license unit; License Manager reclaims it', {
118-
message: getErrorMessage(error, 'Unknown error'),
119-
})
120-
}
121-
}
122-
12381
function classifyFailure(error: unknown): LicenseState {
12482
const name = error instanceof Error ? error.name : undefined
125-
if (name && NO_ENTITLEMENT_ERRORS.has(name)) return 'no-entitlement'
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+
}
12687
if (name && PERMISSION_ERRORS.has(name)) {
12788
logger.error(
128-
'The IAM role cannot check the AWS Marketplace license; allow license-manager:CheckoutLicense and license-manager:CheckInLicense',
89+
'The IAM role cannot check the AWS Marketplace license; allow license-manager:CheckoutLicense',
12990
{ error: name }
13091
)
13192
return 'unknown'
@@ -147,32 +108,19 @@ async function checkLicense(
147108
ProductSKU: productSku,
148109
CheckoutType: 'PROVISIONAL',
149110
KeyFingerprint: MARKETPLACE_KEY_FINGERPRINT,
150-
Entitlements: [{ Name: ENTERPRISE_ENTITLEMENT, Value: '1', Unit: 'Count' }],
111+
Entitlements: [{ Name: ENTERPRISE_ENTITLEMENT, Unit: 'None' }],
151112
ClientToken: generateId(),
152113
})
153114
)
154-
if (checkout.LicenseConsumptionToken) await checkIn(client, checkout.LicenseConsumptionToken)
155115
const allowed = (checkout.EntitlementsAllowed ?? []).some(
156116
(entitlement) => entitlement.Name === ENTERPRISE_ENTITLEMENT
157117
)
158-
return allowed ? 'entitled' : 'no-entitlement'
118+
return allowed ? 'entitled' : 'not-entitled'
159119
} catch (error) {
160120
return classifyFailure(error)
161121
}
162122
}
163123

164-
async function checkAtStartup(
165-
client: LicenseManagerClient,
166-
productSku: string
167-
): Promise<LicenseState> {
168-
let state = await checkLicense(client, productSku)
169-
for (let attempt = 1; state === 'no-entitlement' && attempt < STARTUP_ATTEMPTS; attempt++) {
170-
await sleep(randomInt(STARTUP_RETRY_MIN_MS, STARTUP_RETRY_MAX_MS))
171-
state = await checkLicense(client, productSku)
172-
}
173-
return state
174-
}
175-
176124
function setEnterpriseFlags(enabled: boolean): void {
177125
for (const flag of ENTERPRISE_FLAGS) process.env[flag] = enabled ? 'true' : 'false'
178126
}
@@ -196,7 +144,7 @@ export async function applyAwsMarketplaceContainerLicense(): Promise<void> {
196144
if (!productSku) return
197145

198146
const client = createClient()
199-
const bootState = await checkAtStartup(client, productSku)
147+
const bootState = await checkLicense(client, productSku)
200148
const enterpriseAtBoot = bootState === 'entitled'
201149
setEnterpriseFlags(enterpriseAtBoot)
202150
logger.info(
@@ -206,21 +154,15 @@ export async function applyAwsMarketplaceContainerLicense(): Promise<void> {
206154
{ state: bootState }
207155
)
208156

209-
let noEntitlementSince: number | null = null
210157
const scheduleRecheck = () => {
211158
setTimeout(recheck, RECHECK_INTERVAL_MS + randomInt(0, RECHECK_JITTER_MS)).unref()
212159
}
213160
const recheck = async () => {
214161
const state = await checkLicense(client, productSku)
215-
// Only an unbroken run of "no entitlement" answers counts toward switching Enterprise off.
216-
if (state !== 'no-entitlement') noEntitlementSince = null
217-
if (state === 'entitled' && !enterpriseAtBoot) return restartToApply(state)
218-
if (state === 'no-entitlement' && enterpriseAtBoot) {
219-
noEntitlementSince ??= Date.now()
220-
if (Date.now() - noEntitlementSince >= NO_ENTITLEMENT_CONFIRMATION_MS) {
221-
return restartToApply(state)
222-
}
223-
}
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)
224166
scheduleRecheck()
225167
}
226168
scheduleRecheck()

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,10 @@ Resources:
159159
Version: '2012-10-17'
160160
Statement:
161161
- Effect: Allow
162-
# Sim checks a unit of the license out and returns it at once, the two actions
163-
# AWS's managed AWSLicenseManagerConsumptionPolicy also grants. Neither has
164-
# resource-level permissions, so they cannot be scoped below '*'.
165-
Action:
166-
- license-manager:CheckoutLicense
167-
- license-manager:CheckInLicense
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
168166
Resource: '*'
169167

170168
Outputs:

0 commit comments

Comments
 (0)