diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index 51d4597cc45..ceb36948e0a 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -3,7 +3,7 @@ import { subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { APIError } from 'better-auth/api' import { and, eq, isNotNull } from 'drizzle-orm' -import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' +import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config' import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { @@ -64,8 +64,9 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise * license lookup fails. */ async function assertNotBilledThroughMarketplace(referenceId: string): Promise { - if (!isAwsMarketplaceEnabled()) return - if (!(await holdsMarketplaceLicense(referenceId))) return + const products = getAwsMarketplaceProducts() + if (products.size === 0) return + if (!(await holdsMarketplaceLicense(products, referenceId))) return logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId }) throw new APIError('CONFLICT', { diff --git a/apps/sim/lib/billing/aws-marketplace/application/admin.ts b/apps/sim/lib/billing/aws-marketplace/application/admin.ts index 4154364c62a..ad373adbb8f 100644 --- a/apps/sim/lib/billing/aws-marketplace/application/admin.ts +++ b/apps/sim/lib/billing/aws-marketplace/application/admin.ts @@ -1,9 +1,10 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { AuditAction, recordAudit } from '@sim/audit' import { db } from '@sim/db' import type { AdminMutationActor } from '@/lib/admin/dashboard' import { - licenseAuditMetadata, + licenseAuditEntry, requireLinkableTo, + requireNotBilledThroughStripe, } from '@/lib/billing/aws-marketplace/application/use-cases' import { countUnlinkedLicenses, @@ -57,6 +58,7 @@ export async function linkAwsMarketplaceLicenseAsAdmin( if (locked.organizationId === input.organizationId) { return { license: locked, newlyLinked: false } } + await requireNotBilledThroughStripe(tx, input.organizationId) return { license: await linkLicenseForTeam(tx, input.licenseId, input.organizationId), newlyLinked: true, @@ -67,12 +69,12 @@ export async function linkAwsMarketplaceLicenseAsAdmin( actorId: actor.id, actorName: actor.name, actorEmail: actor.email, - action: AuditAction.AWS_MARKETPLACE_LICENSE_LINKED, - resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, - resourceId: license.id, - resourceName: license.dimension ?? 'AWS Marketplace license', - description: 'Admin linked an AWS Marketplace subscription to an organization', - metadata: { ...licenseAuditMetadata(license), organizationId: input.organizationId }, + ...licenseAuditEntry( + license, + AuditAction.AWS_MARKETPLACE_LICENSE_LINKED, + 'Admin linked an AWS Marketplace subscription to an organization', + { organizationId: input.organizationId } + ), }) } return { id: license.id, organizationId: input.organizationId } diff --git a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts index 33ff2edc216..be2f5774365 100644 --- a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts +++ b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts @@ -1,4 +1,4 @@ -import { AuditAction, AuditResourceType } from '@sim/audit' +import { AuditAction, type AuditActionType, AuditResourceType } from '@sim/audit' import { db } from '@sim/db' import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace' import { awsMarketplaceOperations } from '@/lib/billing/aws-marketplace/application/operations' @@ -10,6 +10,7 @@ import { import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements' import { type AwsMarketplaceLicenseRecord, + type BuyerContact, consumeLicenseClaim, findLicenseByClaimHash, isNewerRead, @@ -17,6 +18,7 @@ import { listAdministeredOrganizations, listOrganizationLicenses, lockLicense, + paysForSelfServePlan, recordLicenseVerification, saveLicenseContact, } from '@/lib/billing/aws-marketplace/repository' @@ -28,6 +30,7 @@ import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/autho import { assertOperationPrincipal, type OperationUseCase } from '@/lib/core/application/operation' import { requireOrganizationSubjectMembership } from '@/lib/core/application/organization-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' /** The same answer as an unknown route, so a deployment that does not sell on AWS shows nothing. */ function requireMarketplaceEnabled(): void { @@ -93,6 +96,22 @@ export function requireLinkableTo( } } +/** + * AWS bills a linked organization for the listing, so one already paying Stripe for a plan would + * pay twice. Its subscription is cancelled first, by its owner or by support. + */ +export async function requireNotBilledThroughStripe( + tx: DbTransaction, + organizationId: string +): Promise { + if (await paysForSelfServePlan(tx, organizationId)) { + throw new OrchestrationError( + 'conflict', + 'This organization already pays for a Sim plan. Cancel that subscription, or contact support, before linking an AWS Marketplace subscription to it.' + ) + } +} + /** Enough for a buyer to recognize their account, and no more for someone holding their claim. */ function maskAwsAccount(accountId: string): string { return `••••${accountId.slice(-4)}` @@ -149,12 +168,6 @@ interface ClaimInput { claimToken: string | null } -interface BuyerContact { - /** Whom to contact about provisioning; AWS asks the registration page to collect it. */ - contactEmail: string - companyName: string | null -} - type TakeInput = ClaimInput & BuyerContact interface LinkLicenseInput extends TakeInput { @@ -167,8 +180,20 @@ interface LinkedLicense { newlyLinked: boolean } -function contactOf(input: BuyerContact) { - return { email: input.contactEmail, companyName: input.companyName } +/** The license this browser's claim names, refused unless the claim is still open. */ +async function findOpenClaim(input: ClaimInput) { + const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null + const claimed = requireOpenClaim( + claimHash ? await findLicenseByClaimHash(claimHash) : null, + input.licenseId, + claimHash + ) + return { claimHash, claimed } +} + +/** Locks the claimed license and checks the claim again, so two tabs racing it take it once. */ +async function lockOpenClaim(tx: DbTransaction, input: ClaimInput, claimHash: string | null) { + return requireOpenClaim(await lockLicense(tx, input.licenseId), input.licenseId, claimHash) } /** @@ -178,12 +203,7 @@ function contactOf(input: BuyerContact) { */ async function verifyClaimedLicense(input: ClaimInput, organizationId: string | null) { requireMarketplaceEnabled() - const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null - const claimed = requireOpenClaim( - claimHash ? await findLicenseByClaimHash(claimHash) : null, - input.licenseId, - claimHash - ) + const { claimHash, claimed } = await findOpenClaim(input) requireLinkableTo(claimed, organizationId) const verifiedAt = new Date() @@ -205,11 +225,25 @@ async function verifyClaimedLicense(input: ClaimInput, organizationId: string | return { claimHash, verification } } -export function licenseAuditMetadata(license: AwsMarketplaceLicenseRecord) { +/** The audit entry for a license changing hands; `metadata` adds to its terms. */ +export function licenseAuditEntry( + license: AwsMarketplaceLicenseRecord, + action: AuditActionType, + description: string, + metadata: Record = {} +) { return { - dimension: license.dimension, - quantity: license.quantity, - expiresAt: license.expiresAt?.toISOString() ?? null, + action, + resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, + resourceId: license.id, + resourceName: license.dimension ?? 'AWS Marketplace license', + description, + metadata: { + dimension: license.dimension, + quantity: license.quantity, + expiresAt: license.expiresAt?.toISOString() ?? null, + ...metadata, + }, } } @@ -226,11 +260,7 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({ }: OrganizationUseCaseContext): Promise { const { claimHash, verification } = await verifyClaimedLicense(input, input.organizationId) return db.transaction(async (tx) => { - const locked = requireOpenClaim( - await lockLicense(tx, input.licenseId), - input.licenseId, - claimHash - ) + const locked = await lockOpenClaim(tx, input, claimHash) requireLinkableTo(locked, input.organizationId) requireNotOverruled(locked, verification.verifiedAt) // Holds the admin's membership until commit, so a concurrent demotion waits for the link. @@ -243,9 +273,10 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({ { executor: tx, forShare: true } ) const newlyLinked = locked.organizationId !== input.organizationId + if (newlyLinked) await requireNotBilledThroughStripe(tx, input.organizationId) const license = await consumeLicenseClaim(tx, locked, { link: newlyLinked ? { organizationId: input.organizationId, userId: context.userId } : null, - contact: contactOf(input), + contact: input, verification, }) return { license, newlyLinked } @@ -253,14 +284,11 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({ }, projectAudit: ({ result }) => result.newlyLinked - ? { - action: AuditAction.AWS_MARKETPLACE_LICENSE_LINKED, - resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, - resourceId: result.license.id, - resourceName: result.license.dimension ?? 'AWS Marketplace license', - description: 'Linked an AWS Marketplace subscription to the organization', - metadata: licenseAuditMetadata(result.license), - } + ? licenseAuditEntry( + result.license, + AuditAction.AWS_MARKETPLACE_LICENSE_LINKED, + 'Linked an AWS Marketplace subscription to the organization' + ) : [], }) @@ -280,17 +308,13 @@ export const registerAwsMarketplaceLicenseToAccount: OperationUseCase< assertOperationPrincipal(principal, awsMarketplaceOperations.registerToAccount) const { claimHash, verification } = await verifyClaimedLicense(input, null) const { license, newlyRegistered } = await db.transaction(async (tx) => { - const locked = requireOpenClaim( - await lockLicense(tx, input.licenseId), - input.licenseId, - claimHash - ) + const locked = await lockOpenClaim(tx, input, claimHash) requireLinkableTo(locked, null) requireNotOverruled(locked, verification.verifiedAt) const newlyRegistered = locked.linkedByUserId !== principal.userId const consumed = await consumeLicenseClaim(tx, locked, { link: newlyRegistered ? { organizationId: null, userId: principal.userId } : null, - contact: contactOf(input), + contact: input, verification, }) return { license: consumed, newlyRegistered } @@ -302,14 +326,11 @@ export const registerAwsMarketplaceLicenseToAccount: OperationUseCase< principal, request, [ - { - action: AuditAction.AWS_MARKETPLACE_LICENSE_REGISTERED, - resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, - resourceId: license.id, - resourceName: license.dimension ?? 'AWS Marketplace license', - description: 'Registered an AWS Marketplace subscription to an account', - metadata: licenseAuditMetadata(license), - }, + licenseAuditEntry( + license, + AuditAction.AWS_MARKETPLACE_LICENSE_REGISTERED, + 'Registered an AWS Marketplace subscription to an account' + ), ] ) } @@ -330,22 +351,13 @@ export const saveAwsMarketplaceClaimContact: OperationUseCase< async execute({ principal, input }) { assertOperationPrincipal(principal, awsMarketplaceOperations.saveClaimContact) requireMarketplaceEnabled() - const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null - const claimed = requireOpenClaim( - claimHash ? await findLicenseByClaimHash(claimHash) : null, - input.licenseId, - claimHash - ) + const { claimHash, claimed } = await findOpenClaim(input) const verifiedAt = new Date() const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn) return db.transaction(async (tx) => { - const locked = requireOpenClaim( - await lockLicense(tx, input.licenseId), - input.licenseId, - claimHash - ) + const locked = await lockOpenClaim(tx, input, claimHash) const license = await saveLicenseContact(tx, locked, { - contact: contactOf(input), + contact: input, verification: { summary, verifiedAt }, }) return { status: license.status } diff --git a/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts b/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts index e871acd212a..b69b1df157f 100644 --- a/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts +++ b/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts @@ -4,15 +4,20 @@ import { generateId } from '@sim/utils/id' import { inArray } from 'drizzle-orm' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' -const PRODUCT = 'product-placeholder' const ACCOUNT = '000000000000' const TEAM_PLAN = 'team_6000' +const RETIRED_PRODUCT = 'retired-product-placeholder' -const marketplace = vi.hoisted(() => ({ enabled: true })) +const marketplace = vi.hoisted(() => ({ enabled: true, product: 'product-placeholder' })) +const PRODUCT = marketplace.product -vi.mock('@/lib/billing/aws-marketplace/config', () => ({ - isAwsMarketplaceEnabled: () => marketplace.enabled, -})) +vi.mock('@/lib/billing/aws-marketplace/config', async () => { + const { parseAwsMarketplaceProducts } = await vi.importActual< + typeof import('@/lib/billing/aws-marketplace/products') + >('@/lib/billing/aws-marketplace/products') + const sold = parseAwsMarketplaceProducts(`${marketplace.product}=contract`) + return { getAwsMarketplaceProducts: () => (marketplace.enabled ? sold : new Map()) } +}) import { assertPersonalCheckoutAllowed, @@ -61,12 +66,13 @@ async function insertLicense(link: { organizationId: string | null linkedByUserId: string status: LicenseStatus + productCode?: string }) { const now = new Date() await db.insert(awsMarketplaceLicense).values({ id: generateId(), licenseArn: `license-${generateId()}`, - productCode: PRODUCT, + productCode: link.productCode ?? PRODUCT, customerAwsAccountId: ACCOUNT, dimension: 'enterprise', quantity: 25, @@ -135,6 +141,21 @@ describe('Stripe checkout for an AWS Marketplace-billed reference', () => { ).resolves.toBe(true) }) + it('allows an organization whose only license is on a listing this deployment no longer sells', async () => { + const ownerId = await createUser() + const organizationId = await createOwnedOrganization(ownerId) + await insertLicense({ + organizationId, + linkedByUserId: ownerId, + status: 'active', + productCode: RETIRED_PRODUCT, + }) + + await expect( + authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN) + ).resolves.toBe(true) + }) + it('allows an organization with no license', async () => { const { ownerId, organizationId } = await organizationWithLicense(null) diff --git a/apps/sim/lib/billing/aws-marketplace/client.ts b/apps/sim/lib/billing/aws-marketplace/client.ts index 1e3ec4ee87d..0ff3a712e1a 100644 --- a/apps/sim/lib/billing/aws-marketplace/client.ts +++ b/apps/sim/lib/billing/aws-marketplace/client.ts @@ -29,10 +29,10 @@ let queueClient: SQSClient | null = null /** * Credentials for the Marketplace and event-queue clients only. On ECS the app's env also carries * a static IAM user's keys, which the SDK's default chain prefers and which Sim's general AWS - * clients (`getAwsCredentials()`) sign with. Infra grants ResolveCustomer, GetEntitlements, and the - * queue permissions to the ECS task role alone, and AWS asks for temporary role credentials here, - * so whenever the container credential endpoint is present these clients sign as the task role. - * Elsewhere (local development, tests) they keep the default chain. + * clients (`getAwsCredentials()`) sign with. Infra grants ResolveCustomer, GetEntitlements, + * BatchMeterUsage, and the queue permissions to the ECS task role alone, and AWS asks for + * temporary role credentials here, so whenever the container credential endpoint is present these + * clients sign as the task role. Elsewhere (local development, tests) they keep the default chain. */ function marketplaceClientConfig() { const relativeUri = env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI diff --git a/apps/sim/lib/billing/aws-marketplace/config.ts b/apps/sim/lib/billing/aws-marketplace/config.ts index 9a3ff092924..0239467d436 100644 --- a/apps/sim/lib/billing/aws-marketplace/config.ts +++ b/apps/sim/lib/billing/aws-marketplace/config.ts @@ -1,17 +1,18 @@ import { type AwsMarketplaceProduct, + type AwsMarketplaceProducts, parseAwsMarketplaceProducts, } from '@/lib/billing/aws-marketplace/products' import { env } from '@/lib/core/config/env' -let products: ReadonlyMap | undefined +let products: AwsMarketplaceProducts | undefined /** * The products this deployment sells through AWS Marketplace, by product code. Empty on * self-hosted installs and every environment but production, which turns the Marketplace surfaces * off. The container entrypoint refuses to start on a malformed setting. */ -export function getAwsMarketplaceProducts(): ReadonlyMap { +export function getAwsMarketplaceProducts(): AwsMarketplaceProducts { products ??= env.AWS_MARKETPLACE_PRODUCTS ? parseAwsMarketplaceProducts(env.AWS_MARKETPLACE_PRODUCTS) : new Map() diff --git a/apps/sim/lib/billing/aws-marketplace/container-license.ts b/apps/sim/lib/billing/aws-marketplace/container-license.ts index 9701df401e5..fa42f646256 100644 --- a/apps/sim/lib/billing/aws-marketplace/container-license.ts +++ b/apps/sim/lib/billing/aws-marketplace/container-license.ts @@ -5,6 +5,7 @@ import { } from '@aws-sdk/client-license-manager' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { randomInt } from '@sim/utils/random' import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' /** @@ -47,6 +48,19 @@ const ENTERPRISE_ENTITLEMENT = 'enterprise_license' /** AWS recommends re-checking every 15 minutes to pick up renewals, upgrades, and cancellations. */ const RECHECK_INTERVAL_MS = 15 * 60 * 1000 +/** + * Replicas start together, so their re-checks line up; each waits a random part of this before + * restarting, so a renewal or lapse never takes every replica down at once. + */ +const RESTART_SPREAD_MS = 10 * 60 * 1000 + +/** + * How the buyer's account holds the license. A delegate grant only shows licenses distributed to + * other accounts, and a grant still awaiting acceptance, or rejected, disabled, or deleted by the + * license administrator, entitles nothing. No status at all is a license the account bought itself. + */ +const USABLE_RECEIVED_STATUSES = new Set(['ACTIVE', 'WORKFLOW_COMPLETED']) + const ENTERPRISE_FLAGS = ['ENTERPRISE_ENABLED', 'NEXT_PUBLIC_ENTERPRISE_ENABLED'] as const /** `unknown` is a failed check (network, permissions, throttling), never a verdict on the license. */ @@ -64,14 +78,21 @@ function createClient(): LicenseManagerClient { }) } +function isUsableGrant({ ReceivedMetadata: received }: GrantedLicense): boolean { + if (received?.GrantType === 'DelegateGrant') return false + return !received?.ReceivedStatus || USABLE_RECEIVED_STATUSES.has(received.ReceivedStatus) +} + /** * An AWS Marketplace license this product's buyer holds is current when License Manager reports it - * `AVAILABLE`, its issuer is AWS Marketplace, and it carries the Enterprise entitlement. License - * Manager moves an ended, cancelled, or suspended license out of `AVAILABLE` itself. + * `AVAILABLE`, its issuer is AWS Marketplace, it carries the Enterprise entitlement, and the account + * holds it through a grant it may use. License Manager moves an ended, cancelled, or suspended + * license out of `AVAILABLE` itself. */ function isActiveEnterpriseLicense(license: GrantedLicense): boolean { return ( license.Status === 'AVAILABLE' && + isUsableGrant(license) && license.Issuer?.KeyFingerprint === MARKETPLACE_KEY_FINGERPRINT && (license.Entitlements ?? []).some((entitlement) => entitlement.Name === ENTERPRISE_ENTITLEMENT) ) @@ -133,9 +154,13 @@ export async function applyAwsMarketplaceContainerLicense(): Promise { const state = await checkLicense(client, productSku) const entitledNow = state === 'entitled' if (state === 'unknown' || entitledNow === enterpriseAtBoot) return - logger.info('AWS Marketplace Enterprise license changed; restarting to apply it', { state }) clearInterval(recheck) - process.kill(process.pid, 'SIGTERM') + const delayMs = randomInt(0, RESTART_SPREAD_MS) + logger.info('AWS Marketplace Enterprise license changed; restarting to apply it', { + state, + delayMs, + }) + setTimeout(() => process.kill(process.pid, 'SIGTERM'), delayMs) }, RECHECK_INTERVAL_MS) recheck.unref() } diff --git a/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts b/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts index f9e8d3141f5..a10924df7dd 100644 --- a/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts +++ b/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts @@ -6,6 +6,7 @@ import { member, organization, outboxEvent, + subscription, user, } from '@sim/db/schema' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' @@ -352,6 +353,7 @@ afterAll(async () => { .delete(awsMarketplaceLicense) .where(inArray(awsMarketplaceLicense.licenseArn, licenseArns)) if (organizationIds.length) { + await db.delete(subscription).where(inArray(subscription.referenceId, organizationIds)) await db.delete(organization).where(inArray(organization.id, organizationIds)) } if (userIds.length) { @@ -779,6 +781,72 @@ describe('linking a claimed license', () => { expect((await readLicense(license.licenseArn))?.organizationId).toBe(otherOrganizationId) }) + it('refuses an organization with a live Stripe self-serve subscription, but not Enterprise or an abandoned checkout', async () => { + async function organizationWith(sub: { plan: string; status: string; bound: boolean }) { + const planOwnerId = await createUser() + const id = await createOrganization([{ userId: planOwnerId, role: 'owner' }]) + await db.insert(subscription).values({ + id: generateId(), + plan: sub.plan, + referenceId: id, + status: sub.status, + stripeSubscriptionId: sub.bound ? `sub_${generateId()}` : null, + metadata: { plan: sub.plan, referenceId: id, seats: 10, monthlyPrice: 100 }, + }) + return { id, planOwnerId } + } + async function linkTo(organization: { id: string; planOwnerId: string }) { + const license = await createClaimedLicense() + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + const refusal = await linkAwsMarketplaceLicense + .execute({ + principal: createSessionPrincipal({ userId: organization.planOwnerId }), + input: { + ...CONTACT, + organizationId: organization.id, + licenseId: license.id, + claimToken: license.token, + }, + }) + .then( + () => null, + (error: unknown) => error + ) + return { license, refusal } + } + + const trial = await linkTo( + await organizationWith({ plan: 'team_6000', status: 'trialing', bound: true }) + ) + const onEnterprise = await organizationWith({ + plan: 'enterprise', + status: 'active', + bound: true, + }) + const enterprise = await linkTo(onEnterprise) + const abandoned = await organizationWith({ + plan: 'team_6000', + status: 'incomplete', + bound: false, + }) + const afterAbandoned = await linkTo(abandoned) + + expect(trial.refusal).toMatchObject({ + code: 'conflict', + message: expect.stringMatching(/already pays/), + }) + expect(await readLicense(trial.license.licenseArn)).toMatchObject({ + organizationId: null, + claimTokenHash: hashClaimToken(trial.license.token), + }) + expect(enterprise.refusal).toBeNull() + expect(afterAbandoned.refusal).toBeNull() + expect((await readLicense(enterprise.license.licenseArn))?.organizationId).toBe(onEnterprise.id) + expect((await readLicense(afterAbandoned.license.licenseArn))?.organizationId).toBe( + abandoned.id + ) + }) + it('links under a newer active read without overwriting it with its own older one', async () => { const license = await createClaimedLicense({ lastVerifiedAt: new Date(Date.now() + 60_000) }) entitlementsByLicense.set(license.licenseArn, [ diff --git a/apps/sim/lib/billing/aws-marketplace/metering.ts b/apps/sim/lib/billing/aws-marketplace/metering.ts index 0f1094d9994..4c5468f4af5 100644 --- a/apps/sim/lib/billing/aws-marketplace/metering.ts +++ b/apps/sim/lib/billing/aws-marketplace/metering.ts @@ -317,7 +317,7 @@ async function chargeOrganization(licenses: MeteredLicense[], now: number): Prom ) const units = await countNewUnits(tx, license.organizationId, usage, countFrom, now) if (units <= 0) return 0 - await tx + const [written] = await tx .insert(awsMarketplaceUsageReport) .values({ id: generateId(), @@ -337,7 +337,12 @@ async function chargeOrganization(licenses: MeteredLicense[], now: number): Prom quantity: sql`${awsMarketplaceUsageReport.quantity} + ${units}`, updatedAt: new Date(), }, + setWhere: isNull(awsMarketplaceUsageReport.sentAt), }) + .returning({ id: awsMarketplaceUsageReport.id }) + // A row frozen under another organization's lock (relinked after its organization was deleted) + // keeps its quantity; rolling back leaves these units for the next record. + if (!written) throw new Error('AWS Marketplace usage record was sent while it was charged') return units }) } @@ -536,7 +541,9 @@ async function sendPendingReports(now: number, summary: AwsMarketplaceMeteringSu const byLicense = new Map() for (const row of pending) { - byLicense.set(row.licenseArn, [...(byLicense.get(row.licenseArn) ?? []), row]) + const rows = byLicense.get(row.licenseArn) + if (rows) rows.push(row) + else byLicense.set(row.licenseArn, [row]) } for (const rows of byLicense.values()) { for (const chunk of chunkArray(rows, RECORDS_PER_CALL)) { @@ -569,7 +576,9 @@ export async function meterAwsMarketplaceUsage( const licensesByOrganization = new Map() for (const license of await listMeteredLicenses(usageByProduct, now)) { const key = `${license.organizationId}:${license.usage.dimension}` - licensesByOrganization.set(key, [...(licensesByOrganization.get(key) ?? []), license]) + const licenses = licensesByOrganization.get(key) + if (licenses) licenses.push(license) + else licensesByOrganization.set(key, [license]) } for (const licenses of licensesByOrganization.values()) { summary.charged += await chargeOrganization(licenses, now) diff --git a/apps/sim/lib/billing/aws-marketplace/products.ts b/apps/sim/lib/billing/aws-marketplace/products.ts index 80de4fd729b..e5676683e21 100644 --- a/apps/sim/lib/billing/aws-marketplace/products.ts +++ b/apps/sim/lib/billing/aws-marketplace/products.ts @@ -53,14 +53,15 @@ export interface AwsMarketplaceProduct { readonly plan: AwsMarketplacePlan } +/** The products a deployment sells, by product code. */ +export type AwsMarketplaceProducts = ReadonlyMap + /** * Parses `AWS_MARKETPLACE_PRODUCTS`: comma-separated `=` pairs, for example * `abc123=contract,def456=platform-with-usage`. Throws on a malformed entry, an unknown plan, or a * repeated code. */ -export function parseAwsMarketplaceProducts( - value: string -): ReadonlyMap { +export function parseAwsMarketplaceProducts(value: string): AwsMarketplaceProducts { const products = new Map() for (const entry of value.split(',')) { const [productCode, planId, ...rest] = entry.split('=').map((part) => part.trim()) diff --git a/apps/sim/lib/billing/aws-marketplace/reconcile.ts b/apps/sim/lib/billing/aws-marketplace/reconcile.ts index 98f19e34719..8a58828a22b 100644 --- a/apps/sim/lib/billing/aws-marketplace/reconcile.ts +++ b/apps/sim/lib/billing/aws-marketplace/reconcile.ts @@ -15,7 +15,10 @@ import { isInvalidEntitlementRequest, verifyLicense, } from '@/lib/billing/aws-marketplace/entitlements' -import type { AwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/products' +import type { + AwsMarketplaceProduct, + AwsMarketplaceProducts, +} from '@/lib/billing/aws-marketplace/products' import { applyAgreementRenewal, enqueueAgreementAdvisory, @@ -82,7 +85,7 @@ type BudgetCheck = (workMs: number) => boolean interface DrainContext { /** This run's configured products, by code; an event for any other product is not ours. */ - products: ReadonlyMap + products: AwsMarketplaceProducts queueUrl: string summary: AwsMarketplaceReconcileSummary fits: BudgetCheck @@ -163,12 +166,10 @@ type MessageOutcome = 'applied' | 'discarded' | 'skipped' | 'retained' async function reverifyLicense( product: AwsMarketplaceProduct, licenseArn: string, - options: { - ended?: boolean - agreement?: { agreementId: string | null; seenAt: Date | null } - fallbackAccountId?: string | null - licenseEventReceivedAt?: Date - } = {} + options: Pick< + Parameters[0], + 'agreement' | 'fallbackAccountId' | 'licenseEventReceivedAt' + > & { ended?: boolean } = {} ) { const verifiedAt = new Date() await markVerificationAttempted(licenseArn, verifiedAt) @@ -300,6 +301,29 @@ async function applyLicenseEvent( return 'applied' } +/** Whether a stored license was involved in an advisory or a renewal. */ +async function applyAgreementEvent( + products: AwsMarketplaceProducts, + event: Extract, + messageId: string | undefined +): Promise { + if (event.kind === 'advisory') { + return enqueueAgreementAdvisory({ + products, + agreementId: event.agreementId, + state: event.state, + detail: event.detail, + messageId: messageId ?? event.agreementId, + }) + } + if (!event.previousAgreementId) return false + return applyAgreementRenewal({ + products, + previousAgreementId: event.previousAgreementId, + agreementId: event.agreementId, + }) +} + async function applyEvent( context: DrainContext, event: MarketplaceEvent, @@ -311,23 +335,7 @@ async function applyEvent( if (event.kind === 'agreement-ended') { return applyAgreementEnded(context, event.agreementId, message) } - const involved = - event.kind === 'advisory' - ? await enqueueAgreementAdvisory({ - products, - agreementId: event.agreementId, - state: event.state, - detail: event.detail, - messageId: messageId ?? event.agreementId, - }) - : event.previousAgreementId - ? await applyAgreementRenewal({ - products, - previousAgreementId: event.previousAgreementId, - agreementId: event.agreementId, - }) - : false - if (involved) return 'applied' + if (await applyAgreementEvent(products, event, messageId)) return 'applied' logger.info('Ignoring an AWS Marketplace agreement event for no stored license', { messageId }) return 'discarded' } @@ -414,7 +422,7 @@ async function drainEvents(context: DrainContext): Promise { } async function reverifyStaleLicenses( - products: ReadonlyMap, + products: AwsMarketplaceProducts, summary: AwsMarketplaceReconcileSummary, fits: BudgetCheck ): Promise { diff --git a/apps/sim/lib/billing/aws-marketplace/repository.ts b/apps/sim/lib/billing/aws-marketplace/repository.ts index f319f129b95..15636142926 100644 --- a/apps/sim/lib/billing/aws-marketplace/repository.ts +++ b/apps/sim/lib/billing/aws-marketplace/repository.ts @@ -1,7 +1,27 @@ import { db } from '@sim/db' -import { awsMarketplaceLicense, member, organization, outboxEvent, user } from '@sim/db/schema' +import { + awsMarketplaceLicense, + member, + organization, + outboxEvent, + subscription, + user, +} from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, asc, count, desc, eq, inArray, isNotNull, isNull, lt, ne, or } from 'drizzle-orm' +import { + and, + asc, + count, + desc, + eq, + inArray, + isNotNull, + isNull, + lt, + ne, + notInArray, + or, +} from 'drizzle-orm' import type { EntitlementSummary } from '@/lib/billing/aws-marketplace/entitlements' import { AWS_MARKETPLACE_BUYER_NOTICE_EVENT, @@ -10,13 +30,14 @@ import { type AwsMarketplaceNoticeTerms, type AwsMarketplaceTeamNotice, } from '@/lib/billing/aws-marketplace/notice-events' -import type { AwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/products' +import type { + AwsMarketplaceProduct, + AwsMarketplaceProducts, +} from '@/lib/billing/aws-marketplace/products' +import { TERMINAL_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import type { DbTransaction } from '@/lib/db/types' -/** Configured products by product code, as `getAwsMarketplaceProducts` returns them. */ -type AwsMarketplaceProducts = ReadonlyMap - export type AwsMarketplaceLicenseRecord = typeof awsMarketplaceLicense.$inferSelect type AwsMarketplaceLicenseStatus = AwsMarketplaceLicenseRecord['status'] @@ -44,6 +65,20 @@ function enqueueTeamNotice(tx: DbTransaction, notice: AwsMarketplaceTeamNotice, return enqueueOutboxEvent(tx, AWS_MARKETPLACE_TEAM_NOTICE_EVENT, notice, { id }) } +/** Enqueues a team notice under a fixed id unless one with that id was already sent. */ +async function enqueueTeamNoticeOnce( + tx: DbTransaction, + notice: AwsMarketplaceTeamNotice, + id: string +): Promise { + const [sent] = await tx + .select({ id: outboxEvent.id }) + .from(outboxEvent) + .where(eq(outboxEvent.id, id)) + .limit(1) + if (!sent) await enqueueTeamNotice(tx, notice, id) +} + function enqueueBuyerNotice(tx: DbTransaction, notice: AwsMarketplaceBuyerNotice) { return enqueueOutboxEvent(tx, AWS_MARKETPLACE_BUYER_NOTICE_EVENT, notice) } @@ -68,8 +103,8 @@ function statusFromRead( /** * The columns a read writes. With no answer about access the last-known dimension, quantity, and - * expiry stay as they were, and only the status can change. An inactive license has nothing to claim, - * and the read that first finds it inactive records when it ended. + * expiry stay as they were, and only the status can change. The read that first finds a license + * inactive records when it ended. */ function verificationFields( prior: AwsMarketplaceLicenseStatus | null, @@ -90,10 +125,12 @@ function verificationFields( quantity: summary.quantity, expiresAt: summary.expiresAt, }), - ...(status === 'inactive' && { claimTokenHash: null, claimExpiresAt: null }), } } +/** An inactive license has nothing to claim, whatever else the writer sets. */ +const NO_CLAIM = { claimTokenHash: null, claimExpiresAt: null } + function termsOf(license: AwsMarketplaceLicenseRecord): AwsMarketplaceNoticeTerms { return { status: license.status, @@ -115,7 +152,8 @@ function termsDiffer(before: AwsMarketplaceNoticeTerms, after: AwsMarketplaceNot /** * The one place a read reaches a stored license, for every writer: under the caller's row lock it * applies the read unless a newer one is stored, and when that moves the terms, enqueues the team's - * notice in the same transaction. `alongside` is written either way. + * notice in the same transaction. `alongside` is written either way, except a claim on a license + * that ends up inactive. */ async function applyVerification( tx: DbTransaction, @@ -124,11 +162,13 @@ async function applyVerification( alongside: Partial = {} ): Promise { const fresh = isNewerRead(locked, verification.verifiedAt) + const status = fresh ? statusFromRead(locked.status, verification) : locked.status const [license] = await tx .update(awsMarketplaceLicense) .set({ ...(fresh ? verificationFields(locked.status, verification) : { updatedAt: new Date() }), ...alongside, + ...(status === 'inactive' && NO_CLAIM), }) .where(eq(awsMarketplaceLicense.id, locked.id)) .returning() @@ -201,6 +241,43 @@ async function inheritFromRenewedLicense( return inherited } +/** + * Creates the row for a license nobody stored yet, carrying on a renewed license's owner, and tells + * the team. Null when the ARN already has a row, which the caller then updates under its lock. + */ +async function insertNewLicense( + tx: DbTransaction, + input: { + licenseArn: string + productCode: string + customerAwsAccountId: string + seen: AgreementObservation + verification: LicenseVerification + alongside: Partial + }, + source: 'registration' | 'event' +): Promise { + const status = statusFromRead(null, input.verification) + const [created] = await tx + .insert(awsMarketplaceLicense) + .values({ + id: generateId(), + licenseArn: input.licenseArn, + productCode: input.productCode, + customerAwsAccountId: input.customerAwsAccountId, + ...agreementFields(null, input.seen), + ...input.alongside, + ...verificationFields(null, input.verification), + ...(status === 'inactive' && NO_CLAIM), + }) + .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn }) + .returning() + if (!created) return null + const license = await inheritFromRenewedLicense(tx, created) + await enqueueTeamNotice(tx, { kind: 'new-license', licenseId: license.id, source }) + return license +} + /** * Records a registration: the license row keyed by its ARN, with `claim` unless the newest answer * is inactive. A new row tells the team at once, so a buyer who pays and abandons setup is seen. @@ -217,45 +294,25 @@ export async function saveRegisteredLicense(input: { const claimFields = { claimTokenHash: claim.hash, claimExpiresAt: claim.expiresAt } const seen = { agreementId: input.agreementId, seenAt: verification.verifiedAt } return db.transaction(async (tx) => { - const status = statusFromRead(null, verification) - const [created] = await tx - .insert(awsMarketplaceLicense) - .values({ - id: generateId(), - licenseArn: input.licenseArn, - productCode: input.productCode, - customerAwsAccountId: input.customerAwsAccountId, - ...agreementFields(null, seen), - ...(status === 'inactive' ? {} : claimFields), - ...verificationFields(null, verification), - }) - .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn }) - .returning() - if (created) { - const license = await inheritFromRenewedLicense(tx, created) - await enqueueTeamNotice(tx, { - kind: 'new-license', - licenseId: license.id, - source: 'registration', - }) - return { id: license.id, status: license.status } - } + const created = await insertNewLicense( + tx, + { ...input, seen, alongside: claimFields }, + 'registration' + ) + if (created) return { id: created.id, status: created.status } const locked = await lockByArn(tx, input.licenseArn) if (!locked) throw new Error('AWS Marketplace license vanished during registration') - const effective = isNewerRead(locked, verification.verifiedAt) - ? statusFromRead(locked.status, verification) - : locked.status const written = await applyVerification(tx, locked, verification, { ...agreementFields(locked, seen), - ...(effective === 'inactive' ? {} : claimFields), + ...claimFields, }) const license = await inheritFromRenewedLicense(tx, written) return { id: license.id, status: license.status } }) } -export type LicenseVerificationOutcome = +type LicenseVerificationOutcome = | { kind: 'created' | 'updated'; license: AwsMarketplaceLicenseRecord } /** No row, and AWS described nothing to create one from. */ | { kind: 'skipped' } @@ -282,24 +339,19 @@ export async function recordLicenseVerification(input: { return db.transaction(async (tx) => { const accountId = verification.summary.customerAwsAccountId ?? input.fallbackAccountId if (accountId && verification.summary.licenseSeen) { - const [created] = await tx - .insert(awsMarketplaceLicense) - .values({ - id: generateId(), + const created = await insertNewLicense( + tx, + { licenseArn: input.licenseArn, productCode: input.productCode, customerAwsAccountId: accountId, - ...agreementFields(null, seen), - ...stamp, - ...verificationFields(null, verification), - }) - .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn }) - .returning() - if (created) { - const license = await inheritFromRenewedLicense(tx, created) - await enqueueTeamNotice(tx, { kind: 'new-license', licenseId: license.id, source: 'event' }) - return { kind: 'created', license } - } + seen, + verification, + alongside: stamp, + }, + 'event' + ) + if (created) return { kind: 'created', license: created } } const locked = await lockByArn(tx, input.licenseArn) if (!locked) return { kind: 'skipped' } @@ -340,15 +392,7 @@ export async function applyAgreementRenewal(input: { const predecessors = await tx .select() .from(awsMarketplaceLicense) - .where( - and( - ofProducts(products), - or( - eq(awsMarketplaceLicense.agreementId, previousAgreementId), - eq(awsMarketplaceLicense.renewedAgreementId, previousAgreementId) - ) - ) - ) + .where(belongsToAgreement(products, previousAgreementId)) .for('update') const accepted = predecessors.filter( (row) => row.renewedAgreementId === null || row.renewedAgreementId === previousAgreementId @@ -373,19 +417,11 @@ export async function applyAgreementRenewal(input: { const licenseIds = [...accepted, ...successors].map((row) => row.id) if (licenseIds.length === 0) return false - const noticeId = `aws-marketplace-renewal-${agreementId}` - const [sent] = await tx - .select({ id: outboxEvent.id }) - .from(outboxEvent) - .where(eq(outboxEvent.id, noticeId)) - .limit(1) - if (!sent) { - await enqueueTeamNotice( - tx, - { kind: 'renewed', licenseIds, previousAgreementId, agreementId }, - noticeId - ) - } + await enqueueTeamNoticeOnce( + tx, + { kind: 'renewed', licenseIds, previousAgreementId, agreementId }, + `aws-marketplace-renewal-${agreementId}` + ) return true }) } @@ -405,25 +441,17 @@ export async function enqueueAgreementAdvisory(input: { .where(belongsToAgreement(input.products, input.agreementId)) .limit(MAX_LICENSES_PER_AGREEMENT) if (rows.length === 0) return false - const noticeId = `aws-marketplace-advisory-${input.messageId}` - const [sent] = await tx - .select({ id: outboxEvent.id }) - .from(outboxEvent) - .where(eq(outboxEvent.id, noticeId)) - .limit(1) - if (!sent) { - await enqueueTeamNotice( - tx, - { - kind: 'advisory', - licenseIds: rows.map((row) => row.id), - agreementId: input.agreementId, - state: input.state, - detail: input.detail, - }, - noticeId - ) - } + await enqueueTeamNoticeOnce( + tx, + { + kind: 'advisory', + licenseIds: rows.map((row) => row.id), + agreementId: input.agreementId, + state: input.state, + detail: input.detail, + }, + `aws-marketplace-advisory-${input.messageId}` + ) return true }) } @@ -442,12 +470,8 @@ function belongsToAgreement(products: AwsMarketplaceProducts, agreementId: strin ) } -/** - * The licenses an agreement covers, least recently attempted first, so a redelivered message moves - * on to the ones an earlier delivery did not reach. - */ /** A license to re-read, with the product it was sold under. */ -export interface LicenseToVerify { +interface LicenseToVerify { licenseArn: string product: AwsMarketplaceProduct } @@ -477,6 +501,10 @@ function withProducts( }) } +/** + * The licenses an agreement covers, least recently attempted first, so a redelivered message moves + * on to the ones an earlier delivery did not reach. + */ export async function listLicensesForAgreement( products: AwsMarketplaceProducts, agreementId: string @@ -537,13 +565,14 @@ export async function lockLicense( return row ?? null } -interface BuyerContact { - email: string +/** Whom to contact about provisioning; AWS asks the registration page to collect it. */ +export interface BuyerContact { + contactEmail: string companyName: string | null } function contactChanged(locked: AwsMarketplaceLicenseRecord, contact: BuyerContact): boolean { - return locked.contactEmail !== contact.email || locked.companyName !== contact.companyName + return locked.contactEmail !== contact.contactEmail || locked.companyName !== contact.companyName } /** @@ -562,7 +591,7 @@ export async function consumeLicenseClaim( } ): Promise { const license = await applyVerification(tx, locked, input.verification, { - contactEmail: input.contact.email, + contactEmail: input.contact.contactEmail, companyName: input.contact.companyName, ...(input.link && { ...organizationLink(input.link.organizationId), @@ -594,7 +623,7 @@ export async function saveLicenseContact( ): Promise { const changed = contactChanged(locked, input.contact) const license = await applyVerification(tx, locked, input.verification, { - contactEmail: input.contact.email, + contactEmail: input.contact.contactEmail, companyName: input.contact.companyName, }) if (changed) await enqueueTeamNotice(tx, { kind: 'contact', licenseId: license.id }) @@ -646,15 +675,20 @@ export async function listAccountLicenses(userId: string) { } /** - * Whether AWS Marketplace bills this billing reference: a license that is not inactive, linked to - * the organization, or registered to the user's account with no organization yet. + * Whether AWS Marketplace bills this billing reference: a license of a listing this deployment + * sells that is not inactive, linked to the organization, or registered to the user's account with + * no organization yet. */ -export async function holdsMarketplaceLicense(referenceId: string): Promise { +export async function holdsMarketplaceLicense( + products: AwsMarketplaceProducts, + referenceId: string +): Promise { const [row] = await db .select({ id: awsMarketplaceLicense.id }) .from(awsMarketplaceLicense) .where( and( + ofProducts(products), ne(awsMarketplaceLicense.status, 'inactive'), or( eq(awsMarketplaceLicense.organizationId, referenceId), @@ -710,6 +744,30 @@ export async function linkLicenseForTeam( return license } +/** + * Whether the organization has a live Stripe subscription to a self-serve plan: any it is bound to + * that has not finished, since a trial or a payment still pending turns into a charge. Enterprise + * is provisioned by the team, who pause its collection for a Marketplace customer. + */ +export async function paysForSelfServePlan( + tx: DbTransaction, + organizationId: string +): Promise { + const [row] = await tx + .select({ id: subscription.id }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, organizationId), + isNotNull(subscription.stripeSubscriptionId), + notInArray(subscription.status, [...TERMINAL_SUBSCRIPTION_STATUSES]), + ne(subscription.plan, 'enterprise') + ) + ) + .limit(1) + return Boolean(row) +} + export async function organizationExists(tx: DbTransaction, organizationId: string) { const [row] = await tx .select({ id: organization.id }) diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index 258c1a392fc..f98dd9f91f3 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: sim description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents type: application -version: 1.11.15 +version: 1.11.16 appVersion: "v0.8.26" kubeVersion: ">=1.25.0-0" home: https://sim.ai diff --git a/helm/sim/examples/aws-marketplace-prerequisites.yaml b/helm/sim/examples/aws-marketplace-prerequisites.yaml index 5cf0d9c885d..2754ff4b49c 100644 --- a/helm/sim/examples/aws-marketplace-prerequisites.yaml +++ b/helm/sim/examples/aws-marketplace-prerequisites.yaml @@ -66,6 +66,16 @@ Resources: DBSubnetGroupDescription: Sim PostgreSQL subnets SubnetIds: !Ref PrivateSubnetIds + # A fixed, URL-safe password: the chart puts it into the connection URL as is, and copies it into + # a Kubernetes Secret once, so an RDS-managed password that rotates would lock Sim out. + DatabasePassword: + Type: AWS::SecretsManager::Secret + Properties: + Description: Master password for the Sim PostgreSQL instance + GenerateSecretString: + PasswordLength: 40 + ExcludePunctuation: true + Database: Type: AWS::RDS::DBInstance DeletionPolicy: Snapshot @@ -76,8 +86,7 @@ Resources: DBInstanceClass: !Ref DatabaseInstanceClass DBName: sim MasterUsername: sim - # RDS generates the password and keeps it in Secrets Manager (output DatabaseSecretArn). - ManageMasterUserPassword: true + MasterUserPassword: !Sub '{{resolve:secretsmanager:${DatabasePassword}}}' AllocatedStorage: '100' StorageType: gp3 StorageEncrypted: true @@ -159,9 +168,9 @@ Outputs: DatabaseEndpoint: Description: externalDatabase.host in values.yaml Value: !GetAtt Database.Endpoint.Address - DatabaseSecretArn: + DatabasePasswordArn: Description: Secrets Manager secret holding the RDS master password (EXTERNAL_DB_PASSWORD) - Value: !GetAtt Database.MasterUserSecret.SecretArn + Value: !Ref DatabasePassword CachePrimaryEndpoint: Description: Host for REDIS_URL (rediss://:@:6379) Value: !GetAtt Cache.PrimaryEndPoint.Address diff --git a/helm/sim/examples/values-aws-marketplace.yaml b/helm/sim/examples/values-aws-marketplace.yaml index d368558bf55..65b3ae6ae93 100644 --- a/helm/sim/examples/values-aws-marketplace.yaml +++ b/helm/sim/examples/values-aws-marketplace.yaml @@ -24,7 +24,10 @@ # 3. An IAM role for the ServiceAccount below that allows license-manager:ListReceivedLicenses # (the CloudFormation template creates it). # 4. An ACM certificate for the two hostnames below. -# 5. Two Secrets in the install namespace (no secret ever goes in this file): +# 5. Two Secrets in the install namespace (no secret ever goes in this file). The template's +# DatabasePasswordArn and CacheAuthTokenArn outputs are the ARNs of the Secrets Manager +# secrets holding the two passwords; read each password with: +# aws secretsmanager get-secret-value --secret-id --query SecretString --output text # kubectl create secret generic sim-app-secrets --namespace sim \ # --from-literal=BETTER_AUTH_SECRET=$(openssl rand -hex 32) \ # --from-literal=ENCRYPTION_KEY=$(openssl rand -hex 32) \ @@ -48,8 +51,9 @@ app: NEXT_PUBLIC_APP_URL: "https://sim.example.com" # EDIT: your public URL BETTER_AUTH_URL: "https://sim.example.com" # EDIT: same as NEXT_PUBLIC_APP_URL NEXT_PUBLIC_SOCKET_URL: "wss://sim-ws.example.com" # EDIT: the realtime hostname below - # The listing's product ID. Its presence turns on the AWS License Manager check. - AWS_MARKETPLACE_LICENSE_PRODUCT_SKU: "prod-ptrlmodsen2jg" + # The listing's product ID, which the Marketplace chart sets; rendered from this repository, + # set it to the product ID shown on the listing. Its presence turns on the License Manager check. + AWS_MARKETPLACE_LICENSE_PRODUCT_SKU: "prod-xxxxxxxxxxxxx" # EDIT realtime: replicaCount: 2