import { Account, errors, Notification } from "@kumori/aurora-interfaces"; const CREDENTIAL_ERROR_CODES = new Set([ "_error_retrieving_credentials_", "_invalid_account_credentials_", "_unsupported_region_", ]); /** * All statuses that mean the account has reached a final error state. * Pollers and status-strategy consumers should treat these as terminal. */ export const ACCOUNT_ERROR_STATUSES = new Set([ "error", "invalid_credentials", "failed", ]); /** * Map a raw backend error code to the normalized account status * we write to accountsMap / the Account object. */ export const resolveErrorStatus = ( code: string, ): "invalid_credentials" | "error" => CREDENTIAL_ERROR_CODES.has(code) ? "invalid_credentials" : "error"; interface HandleAccountEventParams { entityId: string; eventData: any; parentParts: { [entity: string]: string }; accountsMap: Map; } interface HandleAccountEventResult { account: Account; isDeleted: boolean; } interface HandleAccountOperationSuccessParams { action: string; entityName: string; originalData: Account; } interface HandleAccountOperationSuccessResult { updatedAccount: Account | null; shouldDelete: boolean; notification: Notification; eventType: "created" | "updated" | "deleted" | null; } interface HandleAccountOperationErrorParams { action: string; entityName: string; originalData: any; error: any; } interface HandleAccountOperationErrorResult { updatedAccount: Account | null; shouldDelete: boolean; notification: Notification; eventType: "creationError" | "updateError" | "deletionError"; } /** * Extract cloud provider credentials from event data. */ const extractCloudProviderCredentials = ( eventData: any, ): { providerType: string; credentials: any } => { let providerType = ""; let credentials = {}; if (eventData.spec.credentials.openstack) { providerType = "ovh"; credentials = { region: eventData.spec.credentials.openstack?.region_name || "", interface: eventData.spec.credentials.openstack?.interface || "", apiVersion: eventData.spec.credentials.openstack?.identity_api_version || "", authType: eventData.spec.credentials.openstack?.auth_type || "", authUrl: eventData.spec.credentials.openstack?.auth?.auth_url || "", credentialId: eventData.spec.credentials.openstack?.auth?.application_credential_id || "", credentialSecret: eventData.spec.credentials.openstack?.auth ?.application_credential_secret || "", }; } else if (eventData.spec.credentials.aws) { providerType = "aws"; credentials = { region: eventData.spec.credentials.aws?.region || "", credentialId: eventData.spec.credentials.aws?.aws_access_key_id || "", credentialSecret: eventData.spec.credentials.aws?.aws_secret_access_key || "", }; } else if (eventData.spec.credentials.azure) { providerType = "azure"; credentials = { region: eventData.spec.credentials.azure?.region || "", subscriptionId: eventData.spec.credentials.azure?.subscription_id || "", tenantId: eventData.spec.credentials.azure?.tenant_id || "", clientId: eventData.spec.credentials.azure?.client_id || "", clientSecret: eventData.spec.credentials.azure?.client_secret || "", }; } else if (eventData.spec.credentials.opennebula) { providerType = "opennebula"; credentials = { username: eventData.spec.credentials.opennebula?.user || "", password: eventData.spec.credentials.opennebula?.password || "", endpoint: eventData.spec.credentials.opennebula?.xmlrpc || "", }; } return { providerType, credentials }; }; /** * Determine the canonical account status from a WebSocket event. * * Priority order: * 1. Soft-deleted accounts → 'deleting' * 2. validCredentials status reported by the backend (covers both * success and the async credential-validation path) * 3. Keep the existing status so we never regress a terminal state * (e.g. don't overwrite 'invalid_credentials' with 'pending') * 4. Default to 'pending' for brand-new accounts */ const determineAccountStatus = ( eventData: any, existingAccount: Account | undefined, ): string => { if (eventData.meta?.deleted) { return "deleting"; } const rawStatus: string | undefined = eventData.status?.validCredentials?.status; if (rawStatus) { if (CREDENTIAL_ERROR_CODES.has(rawStatus)) { return "invalid_credentials"; } return rawStatus; } if (existingAccount?.status && existingAccount.status !== "pending") { return existingAccount.status; } return "pending"; }; /** * Translates account error notifications → account.status on accountsMap * so that waitForAccountStatus only needs to poll account.status, not notifications. * * Called from the websocket-manager's `user` event case immediately after * handleUserEvent so the status is always written in the same event-loop tick. */ export const syncAccountStatusFromNotifications = ( notifications: any[] = [], accountsMap: Map, ): void => { for (const notification of notifications) { if (notification.type !== "error") continue; if ( notification.subtype !== "account-creation-error" && notification.subtype !== "account-update-error" ) continue; const accountName: string = notification.data?.account; if (!accountName) continue; const accountTenant: string = notification.data?.tenant ?? ""; const accountKey = accountTenant ? `${accountTenant}/${accountName}` : accountName; const account = accountsMap.get(accountKey); if (!account || ACCOUNT_ERROR_STATUSES.has(account.status)) continue; const code: string = notification.info_content?.code ?? ""; accountsMap.set(accountKey, { ...account, status: resolveErrorStatus(code), }); } }; /** * Handles the "account" kind event from WebSocket messages. */ export const handleAccountEvent = ({ entityId, eventData, parentParts, accountsMap, }: HandleAccountEventParams): HandleAccountEventResult => { const accountTenantId = parentParts.tenant; const { providerType, credentials } = extractCloudProviderCredentials(eventData); const accountLabels: Record = eventData.meta.labels; const hasCredentials = "__axebow::managedCredentials" in accountLabels; const accountKey = accountTenantId ? `${accountTenantId}/${entityId}` : entityId; const existingAccount = accountsMap.get(accountKey); const accountStatus = determineAccountStatus(eventData, existingAccount); const newAccount: Account = { id: entityId, name: entityId, tenant: accountTenantId, cloudProvider: { name: eventData.spec.api, ...credentials, }, logo: "", environments: [], services: [], domains: [], status: accountStatus, usage: { current: { cpu: eventData.status.marks.vcpu.current / 1000, memory: eventData.status.marks.memory.current / 1000, storage: eventData.status.marks.storage.current / 1000, volatileStorage: eventData.status.marks.vstorage.current / 1000, nonReplicatedStorage: eventData.status.marks.nrstorage.current / 1000, persistentStorage: eventData.status.marks.rstorage.current / 1000, cpuConsuption: existingAccount?.usage.current.cpuConsuption || [], memoryConsuption: existingAccount?.usage.current.memoryConsuption || [], }, limit: { cpu: { max: eventData.spec.marks.vcpu.highmark / 1000, min: eventData.spec.marks.vcpu.lowmark / 1000, }, memory: { max: eventData.spec.marks.memory.highmark / 1000, min: eventData.spec.marks.memory.lowmark / 1000, }, storage: { max: eventData.spec.marks.storage.highmark / 1000, min: eventData.spec.marks.storage.lowmark / 1000, }, volatileStorage: { max: eventData.spec.marks.vstorage.highmark / 1000, min: eventData.spec.marks.vstorage.lowmark / 1000, }, nonReplicatedStorage: { max: eventData.spec.marks.nrstorage.highmark / 1000, min: eventData.spec.marks.nrstorage.lowmark / 1000, }, persistentStorage: { max: eventData.spec.marks.rstorage.highmark / 1000, min: eventData.spec.marks.rstorage.lowmark / 1000, }, }, cost: 0, }, flavors: { small: [eventData.spec.iaasconfig.smallVMFlavor || ""], medium: [eventData.spec.iaasconfig.mediumVMFlavor || ""], large: [eventData.spec.iaasconfig.largeVMFlavor || ""], volatile: [eventData.spec.iaasconfig.volatile || ""], nonReplicated: [eventData.spec.iaasconfig.nonreplicated || ""], persistent: [eventData.spec.iaasconfig.persistent || ""], }, organization: "", nodes: { max: eventData.spec.marks.nodes.highmark, min: 0, }, credentials: hasCredentials, }; return { account: newAccount, isDeleted: false, }; }; /** * Handles successful account operations (CREATE, UPDATE, DELETE). */ export const handleAccountOperationSuccess = ({ action, entityName, originalData, }: HandleAccountOperationSuccessParams): HandleAccountOperationSuccessResult => { if (action === "DELETE") { return { updatedAccount: null, shouldDelete: true, notification: { type: "success", subtype: errors.account.deleted.subtype, date: Date.now().toString(), status: "unread", callToAction: false, data: { account: originalData.name, tenant: originalData.tenant }, }, eventType: "deleted", }; } if (originalData) { const updatedAccount: Account = { ...originalData, status: "active" }; const isCreate = action === "CREATE"; return { updatedAccount, shouldDelete: false, notification: { type: "success", subtype: isCreate ? errors.account.created.subtype : errors.account.updated.subtype, date: Date.now().toString(), status: "unread", callToAction: false, data: { account: updatedAccount.name, tenant: updatedAccount.tenant }, }, eventType: isCreate ? "created" : "updated", }; } return { updatedAccount: null, shouldDelete: false, notification: { type: "success", subtype: "account-unknown", date: Date.now().toString(), status: "unread", callToAction: false, data: {}, }, eventType: null, }; }; /** * Handles failed account operations (CREATE, UPDATE, DELETE). * * For CREATE failures the account is removed from the map (shouldDelete = true). * For UPDATE failures the existing account is kept but stamped with the * resolved error status so waitForAccountStatus can terminate cleanly. */ export const handleAccountOperationError = ({ action, entityName, originalData, error, }: HandleAccountOperationErrorParams): HandleAccountOperationErrorResult => { const isCreate = action === "CREATE"; const isUpdate = action === "UPDATE"; const eventType: "creationError" | "updateError" | "deletionError" = isCreate ? "creationError" : isUpdate ? "updateError" : "deletionError"; const errorCode: string = error?.error?.code ?? error?.code ?? ""; const errorStatus = resolveErrorStatus(errorCode); const errEntry = isCreate ? errors.account.creationError : isUpdate ? errors.account.updateError : errors.account.deletionError; const notification: Notification = { type: "error", subtype: errEntry.subtype, date: Date.now().toString(), status: "unread", info_content: { code: errorCode || "UNKNOWN_ERROR", message: error?.error?.content ?? error?.error?.message ?? "Unknown error", timestamp: error?.error?.timestamp ?? Date.now().toString(), }, callToAction: false, data: { account: entityName || originalData?.name || originalData?.account || "unknown", tenant: originalData?.tenant ?? "unknown", }, userError: true, }; if (isCreate) { return { updatedAccount: null, shouldDelete: true, notification, eventType, }; } const updatedAccount: Account | null = originalData ? { ...originalData, status: errorStatus } : null; return { updatedAccount, shouldDelete: false, notification, eventType }; };