import { sha256Base64 } from '../signing/hashing.js'; import { ApiTriggerType } from '../types/api/index.js'; import type { DatabaseEngine } from './capacityPolicy.js'; export const LIFECYCLE_TERMINAL_STATES = ['ready', 'failed', 'cancelled'] as const; export const LIFECYCLE_NON_TERMINAL_STATES = ['pending', 'provisioning', 'migrating', 'retiring'] as const; export const LIFECYCLE_STATES = [...LIFECYCLE_NON_TERMINAL_STATES, ...LIFECYCLE_TERMINAL_STATES] as const; export const LIFECYCLE_MIGRATION_STATES = ['pending', 'migrated', 'failed'] as const; // A single `ensure_database` covers what used to be split into // ensure_dev_database / ensure_prod_database. `migrate_schema` and // `retire_database` were always environment-agnostic. // `ensure_physical_database_instance` is claimable pool-level work (wizard / // eager provisioning). It is not bound to a database_binding row; the claim // payload carries provisioningId instead of binding identity. Appended last // because Postgres `ALTER TYPE ... ADD VALUE` appends, and TypeORM's // entity-vs-schema check requires this array to match that live order. export const LIFECYCLE_OPERATIONS = ['ensure_database', 'migrate_schema', 'retire_database', 'ensure_physical_database_instance'] as const; export const DATABASE_LIFECYCLE_MANAGED_BY = 'database_lifecycle'; export const NATIVE_DB_AUTH_DESCRIPTOR_VERSION = 3 as const; export const NATIVE_DB_CONNECTOR_ROLE_NAME_PREFIX = 'superblocks-native-db-connector'; export const NATIVE_DB_IAM_AUTH_MODE = 'aws_iam_role' as const; export const NATIVE_DB_IDENTIFIER_HASH_DOMAIN = 'superblocks-native-db:v1'; export const DATABASE_LIFECYCLE_CAPABILITY_MANAGED_IAM_V1 = 'managed-IAM-v1'; export function isLifecycleManagedIntegrationAllowedForApiTrigger( triggerType: ApiTriggerType, managedBy: string | null | undefined ): boolean { if (managedBy !== DATABASE_LIFECYCLE_MANAGED_BY) { return true; } return triggerType === ApiTriggerType.UI; } // Agent capability tag keys. A lifecycle worker publishes these in the // `tags` map of its agent registration (merged into — never replacing — the // tag map the agent already publishes). The existing `profile` tag carries // datatag coverage, same meaning it has for execution routing, and is the // sole lifecycle routing key. The server matches pending lifecycle requests // against these at poll/claim time and gates task creation on "some active // org agent supports this". export const DATABASE_LIFECYCLE_TAG_OPERATIONS = 'databaseLifecycle:operations'; export const DATABASE_LIFECYCLE_TAG_ENGINES = 'databaseLifecycle:engines'; export const DATABASE_LIFECYCLE_TAG_CAPABILITIES = 'databaseLifecycle:capabilities'; export { CapacityPolicyValidationError, DATABASE_ENGINES, DATABASE_LIFECYCLE_TAG_CAPACITY_POLICIES, PHYSICAL_DATABASE_CAPACITY_THRESHOLD_TYPES, computePhysicalDatabasePoolKey, encodeCapacityPolicyTag, parseCapacityPolicyTag, parseCapacityPolicyTags, validateCapacityPolicyCompatibility, type DatabaseEngine, type PhysicalDatabaseCapacityPolicy, type PhysicalDatabaseCapacityThresholdType, type PhysicalDatabasePoolKey } from './capacityPolicy.js'; // The stable, customer-managed name of the deployment a worker belongs to. It // says which physical pool that worker owns, which is what keeps one // deployment from being handed work meant for another. The claim path also // treats it as the binding-ownership authority, so a client-supplied query // value cannot bypass it. export { DATABASE_LIFECYCLE_TAG_AGENT_NAME, PhysicalPoolIdentityError, parsePhysicalPoolAgentNameTag } from './physicalPoolIdentity.js'; export type LifecycleOperation = (typeof LIFECYCLE_OPERATIONS)[number]; export type LifecycleTerminalState = (typeof LIFECYCLE_TERMINAL_STATES)[number]; export type LifecycleNonTerminalState = (typeof LIFECYCLE_NON_TERMINAL_STATES)[number]; export type LifecycleState = (typeof LIFECYCLE_STATES)[number]; export type LifecycleMigrationState = (typeof LIFECYCLE_MIGRATION_STATES)[number]; export const LIFECYCLE_ERROR_CODES = [ 'unsupported_provider_capability', 'backend_locked', 'policy_blocked', 'credential_resolution_failed', 'terraform_failed', 'migration_failed', 'callback_failed', // Server-side classification for a terminal callback that arrived for a // request whose desiredSpecHash no longer matches the binding (binding has // moved to a newer spec). The callback's metadata/state is discarded and // the request is cancelled rather than applied to the current binding. 'stale_dispatch' ] as const; export type LifecycleErrorCode = (typeof LIFECYCLE_ERROR_CODES)[number]; export type PhysicalMasterCredentialRef = { resolver: 'aws_secrets_manager'; ref: string; field?: string; }; export type NativeDbIamDescriptor = { application_id: string; auth_descriptor_version: typeof NATIVE_DB_AUTH_DESCRIPTOR_VERSION; auth_mode: typeof NATIVE_DB_IAM_AUTH_MODE; aws_account_id: string; binding_id: string; cluster_resource_id: string; connector_role_arn: string; database: string; host: string; port: number; region: string; username: string; }; export type DatabaseRequirement = { logicalName: string; engine: DatabaseEngine; version?: string; sizing?: Record; extensions?: readonly string[]; replicaCount?: number; migrationDirectory?: string; }; type DatabaseBindingBase = { id: string; bindingKey: string; requirementKey: string; logicalName: string; applicationId: string; // The datatag key (Profile.key, e.g. 'staging' / 'production') this // binding serves, and the only axis that scopes it. One profile is one // logical database with one DB user. profile: string; desiredSpecHash: string; migrationState: LifecycleMigrationState; }; // A binding that has completed IAM provisioning. export type ReadyDatabaseBinding = DatabaseBindingBase & { lifecycleState: 'ready'; connectionMetadata: Record; }; // A binding in any non-ready state. Connection metadata may be present from a // prior `ready` transition or absent if the binding never reached `ready`. export type IncompleteDatabaseBinding = DatabaseBindingBase & { lifecycleState: Exclude; connectionMetadata?: Record; }; export type DatabaseBinding = ReadyDatabaseBinding | IncompleteDatabaseBinding; export function isReadyDatabaseBinding(binding: DatabaseBinding): binding is ReadyDatabaseBinding { return binding.lifecycleState === 'ready'; } export type LifecycleRequest = { id: string; operation: LifecycleOperation; bindingKey: string; requirementKey: string; desiredSpecHash: string; state: LifecycleState; }; // Physical database instances back the M2 shared-RDS allocation pattern: dev-DB // provisioning issues `CREATE DATABASE`/`CREATE ROLE` against a pre-existing // physical database instance instead of spinning up a fresh RDS per binding. // The control plane is the dumb org-scoped state store (registry + atomic // capacity counter); ALL selection and provisioning logic lives in the worker. export const PHYSICAL_DATABASE_INSTANCE_STATUSES = ['active', 'draining', 'retired'] as const; export type PhysicalDatabaseInstanceStatus = (typeof PHYSICAL_DATABASE_INSTANCE_STATUSES)[number]; export type PhysicalDatabaseInstance = { id: string; organizationId: string; region: string; engine: DatabaseEngine; // postgres-only in V1 provisionResourceKey: string; endpoint: string; masterCredentialRef: PhysicalMasterCredentialRef; capacityMax: number; capacityUsed: number; status: PhysicalDatabaseInstanceStatus; // Stable customer OPA deployment identity that owns this physical pool. // Absent on legacy rows (no backfill); agent-scoped lookups skip those. agentName?: string; metadata: Record; created?: Date; updated?: Date; }; // One forward-only SQL migration the server attaches to a dispatch // payload so the lifecycle worker's migration runner can apply it after // provisioning succeeds. `version` is the sort key + the primary key in // the worker's in-DB `superblocks_schema_migrations` ledger; `filename` // is recorded for diagnostics; `sql` is the raw multi-statement SQL. // Matches `orchestrator/pkg/databaselifecycle/migrations.Migration`. export type LifecycleMigration = { version: string; filename: string; sql: string; }; export { analyzeDestructiveMigrations, DESTRUCTIVE_MIGRATION_ACK_REQUIRED, DESTRUCTIVE_MIGRATION_ACK_REQUIRED_MESSAGE, DESTRUCTIVE_MIGRATION_SCAN_UNAVAILABLE } from './destructiveMigrations.js'; export type { DestructiveMigrationFile, DestructiveMigrationFinding, DestructiveMigrationKind } from './destructiveMigrations.js'; export type LifecycleDispatchContinuation = { currentState?: string; physicalInstanceId?: string; physicalTerraformResourceKey?: string; reservationId?: string; // An operator's assertion that the worker holding this binding's Terraform // state lock is gone, which is the only thing that lets the next worker // break that lock. Set by the admin retry endpoint and cleared by anything // that reopens the request on a timer, which cannot tell a dead worker from // a slow one. stateLockRecoveryAuthorized?: boolean; }; // Wizard-facing view of a physical pool provisioning run. Narrower than // `LIFECYCLE_STATES` on purpose: the UI only distinguishes "nothing has run" // from in-progress, done, and failed. `not_started` is the absence of a row; // queued and running both read as `provisioning`. export const PHYSICAL_POOL_PROVISIONING_STATUSES = ['not_started', 'provisioning', 'active', 'failed'] as const; export type PhysicalPoolProvisioningStatus = (typeof PHYSICAL_POOL_PROVISIONING_STATUSES)[number]; export type PhysicalPoolProvisioningActor = { displayName: string; id: string; }; export type PhysicalPoolProvisioningError = { code: string; message: string; }; // Stable wizard/admin response for POST/GET physical-pool-provisioning. export type PhysicalPoolProvisioningState = { alreadyInProgress?: boolean; completedAt?: string; error?: PhysicalPoolProvisioningError; physicalDatabaseInstanceId?: string; provisioningId?: string; startedAt?: string; startedBy?: PhysicalPoolProvisioningActor; status: PhysicalPoolProvisioningStatus; }; // One physical instance as the admin surface reports it. Narrower than // `PhysicalDatabaseInstance`: credentials and provider metadata are not part of // an admin listing. export type PhysicalPoolInstanceSummary = { capacityMax: number; capacityUsed: number; endpoint: string; id: string; region: string; status: PhysicalDatabaseInstanceStatus; }; // Every pool an organization has, whether it finished provisioning or not. // A pool with no instances is one that is still running or that failed; a pool // with instances and no run was provisioned before the wizard existed. export type PhysicalPoolSummary = { agentName: string; engine: DatabaseEngine; instances: PhysicalPoolInstanceSummary[]; provisioning: PhysicalPoolProvisioningState; }; // Canonical wire payload for a lifecycle dispatch sent from the server to a // lifecycle worker. The worker's `DispatchPayload` struct in // orchestrator/pkg/databaselifecycle/dispatch.go decodes this JSON shape; // keys and order here are intentional. // // The server describes WHAT (binding identity, desired spec, migrations, // connection/credential context); the worker owns HOW (Terraform modules, // state backends, credential resolvers, shared physical database instances — all resolved from // the worker's single local lifecycle config, selected by the payload's profile). // // `migrations` is present iff the operation should consider migration // state — `ensure_database` / `migrate_schema` dispatches carry an array // (possibly empty); `retire_database` omits it. An omitted slice keeps the // worker's `MigrationState` at the default "pending"; an empty slice means // "vacuous truth, mark migrated"; a non-empty slice triggers the runner. // // For `ensure_physical_database_instance`, binding identity fields are empty // placeholders (the OPA keys the lock on provisioningId) and `provisioningId` // is required. export type LifecycleDispatchPayload = { applicationId: string; bindingId: string; bindingKey: string; connectionMetadata?: Record; continuation?: LifecycleDispatchContinuation; desiredSpec: DatabaseRequirement; desiredSpecHash: string; // Emitted only for `ensure_physical_database_instance`, and named to match // the worker's `environment` field. The control plane no longer routes on an // environment, but the worker still validates one before resolving pool // topology and deriving its Terraform state key. It is a fixed value the // server supplies rather than anything a caller picks, and it goes away with // the worker's remaining shims in ENG-5127. environment?: string; migrations?: LifecycleMigration[]; operation: LifecycleOperation; profile: string; provisioningId?: string; requestId: string; resourceKey: string; }; export function physicalPoolProvisioningResourceKey(provisioningId: string): string { return `physical-pool-provisioning:${provisioningId}`; } export function computeRequirementKey(requirement: Pick): string { return `${slugify(requirement.logicalName)}~${encodeURIComponent(requirement.logicalName)}:${requirement.engine}`; } // binding_key is the product identity of a database in the control plane, and // is deliberately scoped to (organization, application, profile) only — NOT the // logical name. This is what makes "one database per profile" enforceable by the // plain UNIQUE (binding_key) constraint: asking for another database under a // different logical name resolves to the same binding_key instead of minting a // new one. The logical name still lives on the binding row and in resource_key // (physical identity); it just doesn't grant a second slot. export function computeBindingKey(input: { organizationId: string; applicationId: string; profile: string }): string { return [input.organizationId, ...bindingKeySegments(input)].join(':'); } export function computeLegacyBindingKeyWithoutOrganization(input: { applicationId: string; profile: string }): string { return bindingKeySegments(input).join(':'); } function bindingKeySegments(input: { applicationId: string; profile: string }): string[] { return [input.applicationId, `${slugify(input.profile)}~${encodeURIComponent(input.profile)}`]; } // Hashes only the infrastructure spec. migrationDirectory is deliberately // excluded: it says where the SQL files live, not what database to provision, // so changing it must not make a ready binding look drifted and re-dispatch // ensure_database. Callers that persist the requirement must still rewrite // desiredSpec when migrationDirectory changes (same hash is not "no-op"). // Specs without a migrationDirectory keep their existing hash. export async function computeDesiredSpecHash(requirement: DatabaseRequirement): Promise { const infrastructureSpec = { ...requirement }; delete infrastructureSpec.migrationDirectory; return await sha256Base64(JSON.stringify(canonicalize(infrastructureSpec))); } export function nativeDbIamSessionPolicy(descriptor: NativeDbIamDescriptor): string { return JSON.stringify({ Version: '2012-10-17', Statement: [ { Sid: 'ConnectToThisNativeDatabaseUser', Effect: 'Allow', Action: 'rds-db:connect', Resource: `arn:aws:rds-db:${descriptor.region}:${descriptor.aws_account_id}:dbuser:${descriptor.cluster_resource_id}/${descriptor.username}` } ] }); } export function parseNativeDbIamDescriptor(value: unknown): NativeDbIamDescriptor | undefined { if (!isUnknownRecord(value)) { return undefined; } const databaseIdentifier = typeof value.database === 'string' ? /^sbndb_([0-9a-f]{16})_([0-9a-f]{24})$/.exec(value.database) : null; const usernameIdentifier = typeof value.username === 'string' ? /^sbndb_([0-9a-f]{16})_([0-9a-f]{24})_runtime$/.exec(value.username) : null; const connectorRoleArn = typeof value.connector_role_arn === 'string' ? /^arn:aws:iam::(\d{12}):role\/(?:[\w+=,.@-]+\/)*[\w+=,.@-]+$/.exec(value.connector_role_arn) : null; if ( !isNonEmptyString(value.application_id) || !isNativeDbAuthDescriptorVersion(value.auth_descriptor_version) || value.auth_mode !== NATIVE_DB_IAM_AUTH_MODE || !isNonEmptyString(value.aws_account_id) || !/^\d{12}$/.test(value.aws_account_id) || !isNonEmptyString(value.binding_id) || !isNonEmptyString(value.cluster_resource_id) || !/^(cluster|db)-[A-Za-z0-9-]+$/.test(value.cluster_resource_id) || !connectorRoleArn || connectorRoleArn[1] !== value.aws_account_id || !databaseIdentifier || !usernameIdentifier || databaseIdentifier[1] !== usernameIdentifier[1] || usernameIdentifier[0] !== databaseIdentifier[0] + '_runtime' || !isValidNativeDbRdsHostname(value.host, value.region) || typeof value.port !== 'number' || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || !isCommercialAwsRegion(value.region) ) { return undefined; } return { application_id: value.application_id, auth_descriptor_version: value.auth_descriptor_version, auth_mode: NATIVE_DB_IAM_AUTH_MODE, aws_account_id: value.aws_account_id, binding_id: value.binding_id, cluster_resource_id: value.cluster_resource_id, connector_role_arn: connectorRoleArn[0], database: databaseIdentifier[0], host: value.host, port: value.port, region: value.region, username: usernameIdentifier[0] }; } export async function computeNativeDbApplicationToken(trustedApplicationId: string): Promise { return (await computeNativeDbIdentifierToken('application', trustedApplicationId)).slice(0, 24); } export async function computeNativeDbProfileToken(profile: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(profile.toLowerCase())); return Array.from(new Uint8Array(digest)) .slice(0, 8) .map((byte) => byte.toString(16).padStart(2, '0')) .join(''); } async function computeNativeDbIdentifierToken(kind: 'application', trustedId: string): Promise { if (!isNonEmptyString(trustedId)) { throw new Error(`Trusted native database ${kind} ID must be a non-empty string`); } const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(`${NATIVE_DB_IDENTIFIER_HASH_DOMAIN}:${kind}:${trustedId}`) ); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, '0')) .join(''); } // resource_key identifies the physical resource a binding maps to in // customer infrastructure, and is the unit of locking inside the lifecycle // worker. Distinct from binding_key (product identity in the control plane) // because the worker derives infrastructure identity from it — shape // mirrors planning doc §15. export function computeResourceKey(input: { organizationId: string; applicationId: string; requirementKey: string; profile: string; actorScope?: string; }): string { return [ input.organizationId, input.applicationId, input.requirementKey, `${slugify(input.profile)}~${encodeURIComponent(input.profile)}`, input.actorScope ?? 'default' ].join('/'); } export function isTerminalLifecycleState(state: LifecycleState): state is LifecycleTerminalState { return (LIFECYCLE_TERMINAL_STATES as readonly string[]).includes(state); } export function redactLifecycleSecrets(value: T): T { return redact(value) as T; } function slugify(value: string): string { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); } function canonicalize(value: unknown): unknown { if (Array.isArray(value)) { return value.map(canonicalize).sort((left, right) => codepointCompare(JSON.stringify(left), JSON.stringify(right))); } if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value) .filter(([, entryValue]) => entryValue !== undefined) .sort(([left], [right]) => codepointCompare(left, right)) .map(([key, entryValue]) => [key, canonicalize(entryValue)]) ); } return value; } function codepointCompare(left: string, right: string): number { if (left < right) { return -1; } if (left > right) { return 1; } return 0; } function isCommercialAwsRegion(value: unknown): value is string { return typeof value === 'string' && /^[a-z]{2}-[a-z]+-[0-9]+$/.test(value) && !value.startsWith('cn-') && !value.startsWith('us-gov-'); } function isNativeDbAuthDescriptorVersion(value: unknown): value is NativeDbIamDescriptor['auth_descriptor_version'] { return value === NATIVE_DB_AUTH_DESCRIPTOR_VERSION; } function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } function isUnknownRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function isValidNativeDbRdsHostname(host: unknown, region: unknown): host is string { if (typeof host !== 'string' || typeof region !== 'string') { return false; } const suffix = `.${region}.rds.amazonaws.com`; const prefix = host.endsWith(suffix) ? host.slice(0, -suffix.length) : ''; const validLabel = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; return host.length <= 253 && host === host.toLowerCase() && prefix !== '' && host.split('.').every((label) => validLabel.test(label)); } function redact(value: unknown, preservePhysicalMasterCredentialRef = false): unknown { if (Array.isArray(value)) { return value.map((entry) => redact(entry, preservePhysicalMasterCredentialRef)); } if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([key, entryValue]) => { if (isPhysicalMasterCredentialRefKey(key)) { return [key, redact(entryValue, true)]; } if (!preservePhysicalMasterCredentialRef && SECRET_KEY_PATTERN.test(key)) { return [key, '[REDACTED]']; } return [key, redact(entryValue, preservePhysicalMasterCredentialRef)]; }) ); } return value; } // Shared secret-key tripwire: keys whose name strongly suggests a raw secret // value. Used by `redactLifecycleSecrets` to scrub logs/error reporting and by // the server-side `assertNoRawCredentialMaterial` guard to refuse callbacks. // NOT a security boundary -- callers must still send primitive // `connectionMetadata`; this catches obvious misuse only. export const SECRET_KEY_PATTERN = /password|secret|token|private[_-]?key|dsn/i; // Schemes whose URI form embeds credentials before `@`. Used as a value-side // tripwire alongside `SECRET_KEY_PATTERN` -- callers should never send // connection strings; the typed `connectionMetadata` shape is the contract. export const CREDENTIAL_BEARING_DSN_SCHEMES = [ 'postgres', 'postgresql', 'mysql', 'mariadb', 'mongodb', 'mongodb+srv', 'redis', 'rediss', 'amqp', 'amqps', 'kafka' ] as const; export function isSecretKey(key: string): boolean { return SECRET_KEY_PATTERN.test(key); } export function containsCredentialMaterial(value: string): boolean { if (DSN_WITH_INLINE_AUTH.test(value)) { return true; } if (CREDENTIAL_KV_PATTERN.test(value)) { return true; } if (JSON_ENCODED_SECRET_PATTERN.test(value)) { return true; } return false; } const DSN_SCHEME_GROUP = CREDENTIAL_BEARING_DSN_SCHEMES.join('|').replace(/\+/g, '\\+'); const DSN_WITH_INLINE_AUTH = new RegExp(`(?:${DSN_SCHEME_GROUP}):\\/\\/[^/@\\s:]+:[^/@\\s]+@`, 'i'); const CREDENTIAL_KV_PATTERN = /(?:password|pwd|token|secret|private[_-]?key|api[_-]?key)\s*=\s*[^;&\s"]+/i; const JSON_ENCODED_SECRET_PATTERN = /["']\s*(?:password|secret|token|private[_-]?key|dsn)\s*["']\s*:/i; function isPhysicalMasterCredentialRefKey(key: string): boolean { return /^master[_-]?credential[_-]?ref$/i.test(key); }