diff --git a/cli/src/api/cvms.ts b/cli/src/api/cvms.ts index 85b6bd82..97230536 100644 --- a/cli/src/api/cvms.ts +++ b/cli/src/api/cvms.ts @@ -1,7 +1,6 @@ import { safeGetCvmList, safeGetCvmInfo, - safeGetCvmComposeFile, type Client, type CvmInfoDetailV20260121, } from "@phala/cloud"; @@ -20,7 +19,6 @@ import type { GetCvmNetworkResponse, TeepodResponse, PubkeyResponse, - CvmComposeConfigResponse, UpgradeResponse, } from "./types"; import inquirer from "inquirer"; @@ -45,22 +43,6 @@ export async function getCvmByAppId( return result.data; } -/** - * Get CVM compose configuration - */ -export async function getCvmComposeConfig( - cvmId: string, -): Promise { - const client = await getClient(); - const result = await safeGetCvmComposeFile(client, { id: cvmId }); - - if (!result.success) { - throw new Error(result.error.message); - } - - return result.data as CvmComposeConfigResponse; -} - /** * Get CVM network information * @param appId App ID (with or without app_ prefix) @@ -168,11 +150,13 @@ export interface ResizeCvmPayload { } /** - * Replicate a CVM + * Replicate a CVM instance * @param appId App ID (with or without app_ prefix) + * @param vmUuid Source CVM UUID (with or without dashes) */ export async function replicateCvm( appId: string, + vmUuid: string, payload: { teepod_id?: number; encrypted_env?: string; @@ -181,7 +165,7 @@ export async function replicateCvm( const client = await getClient(); const cleanAppId = appId.replace(/^app_/, ""); const response = await client.post( - `cvms/app_${cleanAppId}/replicas`, + `apps/${cleanAppId}/cvms/${vmUuid}/replicas`, payload, ); return replicateCvmResponseSchema.parse(response); diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 7dc0f192..a247cc1d 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -148,22 +148,22 @@ export const replicateCvmResponseSchema = z.object({ id: z.number(), name: z.string(), }), - user_id: z.number().nullable(), + user_id: z.number().nullable().optional(), app_id: z.string(), - vm_uuid: z.string().nullable(), - instance_id: z.string().nullable(), - app_url: z.string().nullable(), - base_image: z.string().nullable(), + vm_uuid: z.string().nullable().optional(), + instance_id: z.string().nullable().optional(), + app_url: z.string().nullable().optional(), + base_image: z.string().nullable().optional(), vcpu: z.number(), memory: z.number(), disk_size: z.number(), - manifest_version: z.number().nullable(), - version: z.string().nullable(), - runner: z.string().nullable(), - docker_compose_file: z.string().nullable(), - features: z.array(z.string()).nullable(), + manifest_version: z.number().nullable().optional(), + version: z.string().nullable().optional(), + runner: z.string().nullable().optional(), + docker_compose_file: z.string().nullable().optional(), + features: z.array(z.string()).nullable().optional(), created_at: z.string(), - encrypted_env_pubkey: z.string().nullable(), + encrypted_env_pubkey: z.string().nullable().optional(), }); export type ReplicateCvmResponse = z.infer; @@ -309,9 +309,3 @@ export interface AvailableNodesResponse { nodes: TEEPod[]; kms_list?: KmsListItem[]; } - -// CVM Compose Config Response -export interface CvmComposeConfigResponse { - env_pubkey: string; - [key: string]: unknown; -} diff --git a/cli/src/commands/allow-devices/index.ts b/cli/src/commands/allow-devices/index.ts index d66a28cf..e26b4d52 100644 --- a/cli/src/commands/allow-devices/index.ts +++ b/cli/src/commands/allow-devices/index.ts @@ -1,14 +1,5 @@ import chalk from "chalk"; import inquirer from "inquirer"; -import { - type Chain, - type PublicClient, - type WalletClient, - createPublicClient, - createWalletClient, - http, -} from "viem"; -import { privateKeyToAccount, nonceManager } from "viem/accounts"; import { safeGetCvmInfo, safeGetAppDeviceAllowlist, @@ -163,24 +154,6 @@ function resolvePrivateKey(input: { privateKey?: string }): `0x${string}` { return (key.startsWith("0x") ? key : `0x${key}`) as `0x${string}`; } -function createSharedClients( - chain: Chain, - privateKey: `0x${string}`, - rpcUrl?: string, -) { - const account = privateKeyToAccount(privateKey, { nonceManager }); - const publicClient = createPublicClient({ - chain, - transport: http(rpcUrl), - }) as unknown as PublicClient; - const walletClient = createWalletClient({ - account, - chain, - transport: http(rpcUrl), - }) as unknown as WalletClient; - return { publicClient, walletClient }; -} - async function resolveDeviceIdOrNodeName( deviceInput: string, ): Promise<`0x${string}`> { @@ -429,12 +402,6 @@ async function runAdd( return 1; } - const { publicClient, walletClient } = createSharedClients( - chain, - privateKey, - input.rpcUrl, - ); - const results: { deviceId: string; txHash: string; @@ -444,10 +411,10 @@ async function runAdd( for (const deviceId of deviceIds) { const result = await safeAddDevice({ chain, + rpcUrl: input.rpcUrl, appAddress: appContractAddress, deviceId, - walletClient, - publicClient, + privateKey, skipPrerequisiteChecks: true, }); @@ -574,12 +541,6 @@ async function runRemove( return 1; } - const { publicClient, walletClient } = createSharedClients( - chain, - privateKey, - input.rpcUrl, - ); - const results: { deviceId: string; txHash: string; @@ -589,10 +550,10 @@ async function runRemove( for (const deviceId of deviceIds) { const result = await safeRemoveDevice({ chain, + rpcUrl: input.rpcUrl, appAddress: appContractAddress, deviceId, - walletClient, - publicClient, + privateKey, skipPrerequisiteChecks: true, }); diff --git a/cli/src/commands/cvms/replicate/command.ts b/cli/src/commands/cvms/replicate/command.ts index 4c41dba5..2836979d 100644 --- a/cli/src/commands/cvms/replicate/command.ts +++ b/cli/src/commands/cvms/replicate/command.ts @@ -9,10 +9,17 @@ export const cvmsReplicateCommandMeta: CommandMeta = { arguments: [cvmIdArgument], options: [ { - name: "teepod-id", - description: "TEEPod ID for replica", + name: "node-id", + description: "Node ID for replica", type: "string", - target: "teepodId", + target: "nodeId", + }, + { + name: "compose-hash", + description: + "Explicit compose hash to replicate. Required when the source app has multiple live instances", + type: "string", + target: "composeHash", }, { name: "env-file", @@ -21,20 +28,82 @@ export const cvmsReplicateCommandMeta: CommandMeta = { type: "string", target: "envFile", }, + { + name: "private-key", + description: "Private key for signing transactions.", + type: "string", + target: "privateKey", + group: "advanced", + }, + { + name: "rpc-url", + description: "RPC URL for the blockchain.", + type: "string", + target: "rpcUrl", + group: "advanced", + }, + { + name: "prepare-only", + description: + "Only prepare the replica (generate commit token) without performing on-chain operations.", + type: "boolean", + target: "prepareOnly", + group: "advanced", + }, + { + name: "commit", + description: + "Commit a previously prepared replica using a commit token. Requires --token and --compose-hash.", + type: "boolean", + target: "commit", + group: "advanced", + }, + { + name: "token", + description: "Commit token from a prepare-only replica request", + type: "string", + target: "token", + group: "advanced", + }, + { + name: "transaction-hash", + description: + "Transaction hash proving on-chain compose hash registration. Use already-registered for state-only verification.", + type: "string", + target: "transactionHash", + group: "advanced", + }, interactiveOption, ], examples: [ { name: "Replicate a CVM", - value: "phala cvms replicate 1234 --teepod-id 5", + value: "phala cvms replicate 1234 --node-id 5", + }, + { + name: "Prepare a replica for multisig approval", + value: + "phala cvms replicate 1234 --node-id 5 --compose-hash --prepare-only", + }, + { + name: "Commit a prepared replica", + value: + "phala cvms replicate 1234 --commit --token --compose-hash --transaction-hash ", }, ], }; export const cvmsReplicateCommandSchema = z.object({ cvmId: z.string().optional(), - teepodId: z.string().optional(), + nodeId: z.string().optional(), + composeHash: z.string().optional(), envFile: z.string().optional(), + privateKey: z.string().optional(), + rpcUrl: z.string().optional(), + prepareOnly: z.boolean().default(false), + commit: z.boolean().default(false), + token: z.string().optional(), + transactionHash: z.string().optional(), interactive: z.boolean().default(false), }); diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index f87e4e30..d7c1fa96 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -1,10 +1,26 @@ import fs from "node:fs"; import path from "node:path"; -import { CvmIdSchema, encryptEnvVars } from "@phala/cloud"; -import { getCvmComposeConfig, replicateCvm } from "@/src/api/cvms"; +import { + type PhalaCloudError, + ResourceError, + type Client, + type ErrorLink, + type EnvVar, + safeAddComposeHash, + safeAddDevice, + safeCheckOnChainPrerequisites, + safeGetAvailableNodes, + safeGetCvmInfo, + encryptEnvVars, + formatErrorMessage, + formatStructuredError, +} from "@phala/cloud"; +import { getClient } from "@/src/lib/client"; +import { getEncryptPubkey } from "@/src/commands/envs/get-encrypt-pubkey"; import { defineCommand } from "@/src/core/define-command"; +import { isInJsonMode } from "@/src/core/json-mode"; import type { CommandContext } from "@/src/core/types"; - +import { CLOUD_URL } from "@/src/utils/constants"; import { logger } from "@/src/utils/logger"; import { cvmsReplicateCommandMeta, @@ -12,7 +28,22 @@ import { type CvmsReplicateCommandInput, } from "./command"; -function parseEnvFile(filePath: string): { key: string; value: string }[] { +interface ReplicaPreparePayload { + composeHash: string; + appId: string; + deviceId: string; + kmsInfo?: unknown; + commitToken?: string; + commitUrl?: string; + apiCommitUrl?: string; + onchainStatus?: { + compose_hash_allowed: boolean; + device_id_allowed: boolean; + is_allowed: boolean; + }; +} + +function parseEnvFile(filePath: string): EnvVar[] { const envContent = fs.readFileSync(filePath, "utf-8"); return envContent .split("\n") @@ -26,6 +57,229 @@ function parseEnvFile(filePath: string): { key: string; value: string }[] { }); } +function extractDetailsMap(error: ResourceError): Record { + const map: Record = {}; + const details = (error.structuredDetails ?? []) as Array<{ + field?: string; + value?: unknown; + }>; + for (const item of details) { + if (item.field) { + map[item.field] = item.value; + } + } + return map; +} + +function getPreparePayload(error: ResourceError): ReplicaPreparePayload | null { + const status = (error as unknown as { status?: number }).status; + if (status !== 465) { + return null; + } + const details = extractDetailsMap(error); + return { + composeHash: String(details.compose_hash ?? ""), + appId: String(details.app_id ?? ""), + deviceId: String(details.device_id ?? ""), + kmsInfo: details.kms_info, + commitToken: details.commit_token as string | undefined, + commitUrl: details.commit_url as string | undefined, + apiCommitUrl: details.api_commit_url as string | undefined, + onchainStatus: details.onchain_status as + | ReplicaPreparePayload["onchainStatus"] + | undefined, + }; +} + +function readPrivateKey(input: CvmsReplicateCommandInput): `0x${string}` { + const privateKey = input.privateKey || process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error( + "Private key is required for on-chain replica approval. Pass --private-key or set PRIVATE_KEY.", + ); + } + return privateKey as `0x${string}`; +} + +async function loadEncryptedEnv( + client: Client<"2026-01-21">, + sourceCvm: Parameters[1], + envFile?: string, +): Promise { + if (!envFile) { + return undefined; + } + const envPath = path.resolve(process.cwd(), envFile); + if (!fs.existsSync(envPath)) { + throw new Error(`Environment file not found: ${envPath}`); + } + const envVars = parseEnvFile(envPath); + const pubkey = await getEncryptPubkey(client, sourceCvm); + return encryptEnvVars(envVars, pubkey); +} + +async function resolveNodeId( + client: Client<"2026-01-21">, + nodeInput?: string, +): Promise { + if (!nodeInput) { + return undefined; + } + + const trimmed = nodeInput.trim(); + if (/^\d+$/.test(trimmed)) { + return Number.parseInt(trimmed, 10); + } + + const nodesResult = await safeGetAvailableNodes(client); + if (!nodesResult.success) { + const nodesError = "error" in nodesResult ? nodesResult.error : undefined; + throw new Error( + nodesError?.message || `Failed to resolve node name \"${trimmed}\"`, + ); + } + + const matches = nodesResult.data.nodes.filter( + (node) => node.name.toLowerCase() === trimmed.toLowerCase(), + ); + if (matches.length === 0) { + throw new Error(`Node \"${trimmed}\" not found.`); + } + if (matches.length > 1) { + throw new Error( + `Node name \"${trimmed}\" is ambiguous (${matches.length} matches). Use an explicit node ID.`, + ); + } + return matches[0].teepod_id; +} + +function formatReplicaOutput( + replica: Record, + context: CommandContext, + sourceCvm?: { + workspace?: { slug?: string | null; name?: string } | null; + kms_type?: string | null; + }, +): void { + if (isInJsonMode()) { + context.success(replica); + return; + } + + const vmUuid = typeof replica.vm_uuid === "string" ? replica.vm_uuid : ""; + const workspaceSlug = + sourceCvm?.workspace?.slug || context.projectConfig.slug || ""; + const workspaceName = sourceCvm?.workspace?.name || workspaceSlug || "-"; + const teamLabel = + workspaceSlug && workspaceSlug !== workspaceName + ? `${workspaceName} (${workspaceSlug})` + : workspaceSlug || workspaceName; + const vmUuidCompact = vmUuid.replace(/-/g, ""); + const appUrl = + workspaceSlug && vmUuidCompact && typeof replica.app_id === "string" + ? `${CLOUD_URL}/${workspaceSlug}/apps/${replica.app_id}/instances/${vmUuidCompact}` + : typeof replica.app_url === "string" + ? replica.app_url + : "-"; + const teepodName = + typeof replica.teepod === "object" && + replica.teepod !== null && + "name" in replica.teepod && + typeof replica.teepod.name === "string" + ? replica.teepod.name + : typeof replica.teepod_name === "string" + ? replica.teepod_name + : "-"; + const kmsType = sourceCvm?.kms_type || "-"; + const lines = [ + `Source CVM ID: ${context.cvmId?.id || replica.source_cvm_id || "-"}`, + `Team: ${teamLabel}`, + `CVM UUID: ${vmUuid || "-"}`, + `App ID: ${replica.app_id}`, + `Name: ${replica.name}`, + `Status: ${replica.status}`, + `KMS: ${kmsType}`, + `Node: ${teepodName} (ID: ${replica.teepod_id})`, + `vCPUs: ${replica.vcpu}`, + `Memory: ${replica.memory} MB`, + `Disk Size: ${replica.disk_size} GB`, + `App URL: ${appUrl}`, + ]; + context.stdout.write(`${lines.join("\n")}\n`); +} + +function formatPrepareOutput( + payload: ReplicaPreparePayload, + context: CommandContext, +): void { + if (isInJsonMode()) { + context.success({ success: true, prepare_only: true, ...payload }); + return; + } + + const ensureHex = (v: string) => (v && !v.startsWith("0x") ? `0x${v}` : v); + const lines = [ + "CVM replica prepared successfully (pending on-chain approval).", + "", + `Compose Hash: ${ensureHex(payload.composeHash)}`, + `App ID: ${ensureHex(payload.appId)}`, + `Device ID: ${ensureHex(payload.deviceId)}`, + ]; + if (payload.commitToken) { + lines.push(`Commit Token: ${payload.commitToken}`); + } + if (payload.commitUrl) { + lines.push(`Commit URL: ${payload.commitUrl}`); + } + if (payload.apiCommitUrl) { + lines.push(`API Commit URL: ${payload.apiCommitUrl}`); + } + if (payload.onchainStatus) { + lines.push( + "", + "On-chain Status:", + ` Compose Hash: ${payload.onchainStatus.compose_hash_allowed ? "registered" : "NOT registered"}`, + ` Device ID: ${payload.onchainStatus.device_id_allowed ? "registered" : "NOT registered"}`, + ); + if (payload.onchainStatus.is_allowed) { + lines.push( + " All prerequisites met. You can commit with --transaction-hash already-registered.", + ); + } + } + if (payload.commitToken) { + const composeHashHex = payload.composeHash.startsWith("0x") + ? payload.composeHash + : `0x${payload.composeHash}`; + lines.push( + "", + "To complete the replica after on-chain approval:", + " phala cvms replicate \\", + " --commit \\", + ` --token ${payload.commitToken} \\`, + ` --compose-hash ${composeHashHex} \\`, + " --transaction-hash ", + ); + } + context.stdout.write(`${lines.join("\n")}\n`); +} + +async function commitReplica( + client: Client<"2026-01-21">, + sourceVmUuid: string, + payload: { + token: string; + composeHash: string; + transactionHash: string; + }, +): Promise> { + return client.post(`/cvms/${sourceVmUuid}/commit-replica`, { + token: payload.token, + compose_hash: payload.composeHash, + transaction_hash: payload.transactionHash, + }); +} + async function runCvmsReplicateCommand( input: CvmsReplicateCommandInput, context: CommandContext, @@ -38,61 +292,171 @@ async function runCvmsReplicateCommand( return 1; } - const { cvmId: normalizedCvmId } = CvmIdSchema.parse(context.cvmId); - let encryptedEnv: string | undefined; - - if (input.envFile) { - const envPath = path.resolve(process.cwd(), input.envFile); - if (!fs.existsSync(envPath)) { - throw new Error(`Environment file not found: ${envPath}`); - } - - const envVars = parseEnvFile(envPath); - const cvmConfig = await getCvmComposeConfig(normalizedCvmId); + const client = await getClient(context); + const cvmResult = await safeGetCvmInfo(client, context.cvmId); + if (!cvmResult.success) { + throw cvmResult.error; + } + const sourceCvm = cvmResult.data; - logger.info("Encrypting environment variables..."); - encryptedEnv = await encryptEnvVars(envVars, cvmConfig.env_pubkey); + if (!sourceCvm.vm_uuid) { + throw new Error("Source CVM has no vm_uuid"); } - const requestBody: { teepod_id?: number; encrypted_env?: string } = {}; + if (input.commit) { + if (!input.token) { + throw new Error("--token is required for --commit mode"); + } + if (!input.composeHash) { + throw new Error("--compose-hash is required for --commit mode"); + } + const replica = (await commitReplica(client, sourceCvm.vm_uuid, { + token: input.token, + composeHash: input.composeHash, + transactionHash: input.transactionHash || "already-registered", + })) as Record; + formatReplicaOutput(replica, context, sourceCvm); + return 0; + } - if (input.teepodId) { - requestBody.teepod_id = Number.parseInt(input.teepodId, 10); + const encryptedEnv = await loadEncryptedEnv( + client, + sourceCvm, + input.envFile, + ); + const resolvedNodeId = await resolveNodeId(client, input.nodeId); + const requestBody: Record = {}; + if (resolvedNodeId !== undefined) { + requestBody.node_id = resolvedNodeId; } if (encryptedEnv) { requestBody.encrypted_env = encryptedEnv; } + if (input.composeHash) { + requestBody.compose_hash = input.composeHash; + } - const replica = await replicateCvm(normalizedCvmId, requestBody); + try { + const replica = (await client.post( + `/cvms/${sourceCvm.vm_uuid}/replicas`, + requestBody, + { + headers: input.prepareOnly ? { "X-Prepare-Only": "true" } : undefined, + }, + )) as Record; + formatReplicaOutput(replica, context, sourceCvm); + return 0; + } catch (error) { + if (!(error instanceof ResourceError)) { + throw error; + } - logger.success( - `Successfully created replica of CVM UUID: ${normalizedCvmId} with App ID: ${replica.app_id}`, - ); + const preparePayload = getPreparePayload(error); + if (!preparePayload) { + throw error; + } - logger.keyValueTable( - { - "CVM UUID": replica.vm_uuid.replace(/-/g, ""), - "App ID": replica.app_id, - Name: replica.name, - Status: replica.status, - TEEPod: `${replica.teepod.name} (ID: ${replica.teepod_id})`, - vCPUs: replica.vcpu, - Memory: `${replica.memory} MB`, - "Disk Size": `${replica.disk_size} GB`, - "App URL": - replica.app_url || - `${process.env.CLOUD_URL || "https://cloud.phala.com"}/dashboard/cvms/${replica.vm_uuid.replace(/-/g, "")}`, - }, - { borderStyle: "rounded" }, - ); + if (input.prepareOnly) { + formatPrepareOutput(preparePayload, context); + return 0; + } - logger.success( - `Your CVM replica is being created. You can check its status with: -phala cvms get ${replica.app_id}`, - ); - return 0; + if (!preparePayload.commitToken) { + throw new Error( + "Replica prepare response did not include a commit token", + ); + } + const kmsInfo = (preparePayload.kmsInfo ?? {}) as { + chain?: Parameters[0]["chain"]; + }; + if (!kmsInfo.chain) { + throw new Error( + "Replica prepare response is missing chain info required for on-chain approval", + ); + } + if (!sourceCvm.app_id) { + throw new Error( + "Source CVM is missing app_id required for on-chain approval", + ); + } + + const prereqs = await safeCheckOnChainPrerequisites({ + chain: kmsInfo.chain, + rpcUrl: input.rpcUrl, + appAddress: sourceCvm.app_id as `0x${string}`, + deviceId: preparePayload.deviceId, + composeHash: preparePayload.composeHash, + }); + if (!prereqs.success) { + const prereqError = "error" in prereqs ? prereqs.error : undefined; + throw new Error( + prereqError?.message || "Failed to check on-chain prerequisites", + ); + } + + let transactionHash = input.transactionHash || "already-registered"; + if (!prereqs.data.deviceAllowed) { + const deviceResult = await safeAddDevice({ + chain: kmsInfo.chain, + rpcUrl: input.rpcUrl, + appAddress: sourceCvm.app_id as `0x${string}`, + deviceId: preparePayload.deviceId, + privateKey: readPrivateKey(input), + }); + if (!deviceResult.success) { + const deviceError = + "error" in deviceResult ? deviceResult.error : undefined; + throw new Error( + deviceError?.message || "Failed to register device on-chain", + ); + } + } + + if (!prereqs.data.composeHashAllowed) { + const receiptResult = await safeAddComposeHash({ + chain: kmsInfo.chain, + rpcUrl: input.rpcUrl, + appId: sourceCvm.app_id as `0x${string}`, + composeHash: preparePayload.composeHash, + privateKey: readPrivateKey(input), + }); + if (!receiptResult.success) { + const receiptError = + "error" in receiptResult ? receiptResult.error : undefined; + throw new Error( + receiptError?.message || "Failed to register compose hash on-chain", + ); + } + transactionHash = String( + (receiptResult.data as { transactionHash?: string }) + .transactionHash || "already-registered", + ); + } + + const replica = (await commitReplica(client, sourceCvm.vm_uuid, { + token: preparePayload.commitToken, + composeHash: preparePayload.composeHash, + transactionHash, + })) as Record; + formatReplicaOutput(replica, context, sourceCvm); + return 0; + } } catch (error) { logger.error("Failed to create CVM replica"); + if (error instanceof ResourceError) { + process.stderr.write(`${formatStructuredError(error)}\n`); + const links = error.links as ErrorLink[] | undefined; + if (links && links.length > 0) { + for (const link of links) { + process.stderr.write(` ${link.label}: ${link.url}\n`); + } + } + return 1; + } + if (error instanceof Error) { + process.stderr.write(`${formatErrorMessage(error as PhalaCloudError)}\n`); + return 1; + } logger.logDetailedError(error); return 1; } diff --git a/js/src/actions/cvms/update_cvm_envs.test.ts b/js/src/actions/cvms/update_cvm_envs.test.ts new file mode 100644 index 00000000..eb4b89ca --- /dev/null +++ b/js/src/actions/cvms/update_cvm_envs.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { updateCvmEnvs, safeUpdateCvmEnvs } from "./update_cvm_envs"; +import { PhalaCloudError } from "../../utils/errors"; +import type { Client } from "../../client"; + +function make465Error() { + return new PhalaCloudError("Compose hash registration required", { + status: 465, + statusText: "Hash Registration Required", + detail: { + error_code: "ERR-01-005", + message: "Compose hash registration required on-chain", + details: [ + { field: "compose_hash", value: "0xhash123" }, + { field: "app_id", value: "0xapp456" }, + { field: "device_id", value: "0xdevice789" }, + { + field: "kms_info", + value: { + id: "kms_test", + slug: "kms-base-prod9", + url: "https://kms.example.com", + version: "v0.5.7", + chain_id: 8453, + kms_contract_address: "0xkms123", + gateway_app_id: "0xgateway456", + }, + }, + ], + }, + }); +} + +describe("updateCvmEnvs", () => { + let mockClient: Client; + + beforeEach(() => { + mockClient = { + patch: vi.fn(), + } as unknown as Client; + }); + + it("should return in_progress on success", async () => { + vi.mocked(mockClient.patch).mockResolvedValue({ + status: "in_progress", + message: "Update initiated", + correlation_id: "corr-123", + allowed_envs_changed: false, + }); + + const result = await updateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + env_keys: ["KEY1"], + }); + + expect(result.status).toBe("in_progress"); + if (result.status === "in_progress") { + expect(result.correlation_id).toBe("corr-123"); + } + }); + + it("should return precondition_required on 465", async () => { + vi.mocked(mockClient.patch).mockRejectedValue(make465Error()); + + const result = await updateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + env_keys: ["KEY1"], + }); + + expect(result.status).toBe("precondition_required"); + if (result.status === "precondition_required") { + expect(result.compose_hash).toBe("0xhash123"); + expect(result.app_id).toBe("0xapp456"); + expect(result.device_id).toBe("0xdevice789"); + expect(result.kms_info).toBeDefined(); + } + }); + + it("should throw on non-465 errors", async () => { + const error = new PhalaCloudError("Not found", { + status: 404, + statusText: "Not Found", + detail: "CVM not found", + }); + vi.mocked(mockClient.patch).mockRejectedValue(error); + + await expect( + updateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + }), + ).rejects.toThrow(); + }); +}); + +describe("safeUpdateCvmEnvs", () => { + let mockClient: Client; + + beforeEach(() => { + mockClient = { + patch: vi.fn(), + } as unknown as Client; + }); + + it("should return success for in_progress result", async () => { + vi.mocked(mockClient.patch).mockResolvedValue({ + status: "in_progress", + message: "Update initiated", + correlation_id: "corr-123", + allowed_envs_changed: false, + }); + + const result = await safeUpdateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe("in_progress"); + } + }); + + it("should return success for 465 precondition_required result", async () => { + vi.mocked(mockClient.patch).mockRejectedValue(make465Error()); + + const result = await safeUpdateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe("precondition_required"); + } + }); + + it("should return error for non-465 failures", async () => { + const error = new PhalaCloudError("Server error", { + status: 500, + statusText: "Internal Server Error", + detail: "Unexpected error", + }); + vi.mocked(mockClient.patch).mockRejectedValue(error); + + const result = await safeUpdateCvmEnvs(mockClient, { + id: "test-cvm-id", + encrypted_env: "encrypted-data", + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/js/src/actions/cvms/update_cvm_envs.ts b/js/src/actions/cvms/update_cvm_envs.ts index 2e945e47..6690d99a 100644 --- a/js/src/actions/cvms/update_cvm_envs.ts +++ b/js/src/actions/cvms/update_cvm_envs.ts @@ -214,15 +214,31 @@ const { action: updateCvmEnvs, safeAction: safeUpdateCvmEnvs } = defineAction< if (detail && typeof detail === "object") { const detailObj = detail as Record; - // Return the 465 data as a successful response with status: "precondition_required" - return { - status: "precondition_required" as const, - message: (detailObj.message as string) || "Compose hash verification required", - compose_hash: detailObj.compose_hash as string, - app_id: detailObj.app_id as string, - device_id: detailObj.device_id as string, - kms_info: detailObj.kms_info, - }; + // Backend returns StructuredError format with fields in a details array: + // { error_code, message, details: [{ field, value }, ...], suggestions, links } + // Extract field values from the details array + const detailsArray = detailObj.details as + | Array<{ field: string; value: unknown }> + | undefined; + + if (detailsArray && Array.isArray(detailsArray)) { + const fieldMap = new Map(detailsArray.map((d) => [d.field, d.value])); + const composeHash = fieldMap.get("compose_hash"); + const appId = fieldMap.get("app_id"); + const deviceId = fieldMap.get("device_id"); + const kmsInfo = fieldMap.get("kms_info"); + + if (composeHash && appId) { + return { + status: "precondition_required" as const, + message: (detailObj.message as string) || "Compose hash verification required", + compose_hash: composeHash as string, + app_id: appId as string, + device_id: (deviceId as string) || "", + kms_info: kmsInfo, + }; + } + } } } diff --git a/js/src/actions/cvms/update_docker_compose.ts b/js/src/actions/cvms/update_docker_compose.ts index af576d2c..e10d26ef 100644 --- a/js/src/actions/cvms/update_docker_compose.ts +++ b/js/src/actions/cvms/update_docker_compose.ts @@ -229,15 +229,30 @@ const { action: updateDockerCompose, safeAction: safeUpdateDockerCompose } = def if (detail && typeof detail === "object") { const detailObj = detail as Record; - // Return the 465 data as a successful response with status: "precondition_required" - return { - status: "precondition_required" as const, - message: (detailObj.message as string) || "Compose hash verification required", - compose_hash: detailObj.compose_hash as string, - app_id: detailObj.app_id as string, - device_id: detailObj.device_id as string, - kms_info: detailObj.kms_info, - }; + // Backend returns StructuredError format with fields in a details array: + // { error_code, message, details: [{ field, value }, ...], suggestions, links } + const detailsArray = detailObj.details as + | Array<{ field: string; value: unknown }> + | undefined; + + if (detailsArray && Array.isArray(detailsArray)) { + const fieldMap = new Map(detailsArray.map((d) => [d.field, d.value])); + const composeHash = fieldMap.get("compose_hash"); + const appId = fieldMap.get("app_id"); + const deviceId = fieldMap.get("device_id"); + const kmsInfo = fieldMap.get("kms_info"); + + if (composeHash && appId) { + return { + status: "precondition_required" as const, + message: (detailObj.message as string) || "Compose hash verification required", + compose_hash: composeHash as string, + app_id: appId as string, + device_id: (deviceId as string) || "", + kms_info: kmsInfo, + }; + } + } } } diff --git a/js/src/actions/cvms/update_prelaunch_script.ts b/js/src/actions/cvms/update_prelaunch_script.ts index 2af1dbcd..4cb0a2dd 100644 --- a/js/src/actions/cvms/update_prelaunch_script.ts +++ b/js/src/actions/cvms/update_prelaunch_script.ts @@ -226,15 +226,30 @@ const { action: updatePreLaunchScript, safeAction: safeUpdatePreLaunchScript } = if (detail && typeof detail === "object") { const detailObj = detail as Record; - // Return the 465 data as a successful response with status: "precondition_required" - return { - status: "precondition_required" as const, - message: (detailObj.message as string) || "Compose hash verification required", - compose_hash: detailObj.compose_hash as string, - app_id: detailObj.app_id as string, - device_id: detailObj.device_id as string, - kms_info: detailObj.kms_info, - }; + // Backend returns StructuredError format with fields in a details array: + // { error_code, message, details: [{ field, value }, ...], suggestions, links } + const detailsArray = detailObj.details as + | Array<{ field: string; value: unknown }> + | undefined; + + if (detailsArray && Array.isArray(detailsArray)) { + const fieldMap = new Map(detailsArray.map((d) => [d.field, d.value])); + const composeHash = fieldMap.get("compose_hash"); + const appId = fieldMap.get("app_id"); + const deviceId = fieldMap.get("device_id"); + const kmsInfo = fieldMap.get("kms_info"); + + if (composeHash && appId) { + return { + status: "precondition_required" as const, + message: (detailObj.message as string) || "Compose hash verification required", + compose_hash: composeHash as string, + app_id: appId as string, + device_id: (deviceId as string) || "", + kms_info: kmsInfo, + }; + } + } } } diff --git a/js/src/utils/errors.test.ts b/js/src/utils/errors.test.ts index 6c2ab959..2c0dfb00 100644 --- a/js/src/utils/errors.test.ts +++ b/js/src/utils/errors.test.ts @@ -3,6 +3,7 @@ import { parseApiError, PhalaCloudError, RequestError, + ResourceError, ValidationError, AuthError, BusinessError, @@ -459,3 +460,71 @@ describe("Error type discriminator properties", () => { expect(error.isUnknownError).toBeUndefined(); }); }); + +describe("RequestError.fromFetchError with StructuredError responses", () => { + function makeStructuredFetchError(status: number) { + return { + message: `[PATCH] "/api/cvms/abc123/envs": ${status} `, + status, + statusText: "", + data: { + error_code: "ERR-01-005", + message: "Compose hash registration required on-chain", + details: [ + { field: "compose_hash", value: "0xhash123", message: null }, + { field: "app_id", value: "0xapp456", message: null }, + { field: "device_id", value: "0xdevice789", message: null }, + { + field: "kms_info", + value: { + id: "kms_test", + slug: "kms-base-prod9", + url: "https://kms.example.com", + version: "v0.5.7", + chain_id: 8453, + kms_contract_address: "0xkms123", + gateway_app_id: "0xgateway456", + }, + message: null, + }, + ], + suggestions: ["Register the compose hash on-chain"], + links: [{ url: "https://docs.example.com", label: "Docs" }], + }, + request: "/api/cvms/abc123/envs", + response: {} as Response, + } as unknown; + } + + it("should preserve StructuredError data as detail when ApiErrorSchema.detail is undefined", () => { + const fetchError = makeStructuredFetchError(465); + const requestError = RequestError.fromFetchError(fetchError as never); + + // detail should be the full StructuredError object, not undefined or a string + expect(requestError.detail).toBeDefined(); + expect(typeof requestError.detail).toBe("object"); + + const detail = requestError.detail as Record; + expect(detail.error_code).toBe("ERR-01-005"); + expect(detail.details).toBeDefined(); + expect(Array.isArray(detail.details)).toBe(true); + }); + + it("should produce ResourceError when parsed through parseApiError", () => { + const fetchError = makeStructuredFetchError(465); + const requestError = RequestError.fromFetchError(fetchError as never); + const error = parseApiError(requestError); + + expect(error).toBeInstanceOf(ResourceError); + expect(error).toBeInstanceOf(BusinessError); + expect(error).toBeInstanceOf(PhalaCloudError); + expect(error.status).toBe(465); + + // detail should still contain the StructuredError object + expect(error.detail).toBeDefined(); + expect(typeof error.detail).toBe("object"); + const detail = error.detail as Record; + expect(detail.error_code).toBe("ERR-01-005"); + expect(Array.isArray(detail.details)).toBe(true); + }); +}); diff --git a/js/src/utils/errors.ts b/js/src/utils/errors.ts index d99e0bae..6176706d 100644 --- a/js/src/utils/errors.ts +++ b/js/src/utils/errors.ts @@ -135,13 +135,22 @@ export class RequestError extends PhalaCloudError implements ApiError { const parseResult = ApiErrorSchema.safeParse(error.data); if (parseResult.success) { + // If ApiErrorSchema matched but detail is undefined, the response may use + // StructuredError format (error_code, details array, etc). Fall back to + // the raw response data so parseStructuredError can handle it downstream. + const detail = + parseResult.data.detail ?? + (error.data && typeof error.data === "object" && "error_code" in error.data + ? error.data + : undefined); + return new RequestError(error.message, { status: error.status ?? undefined, statusText: error.statusText ?? undefined, data: error.data, request: error.request ?? undefined, response: error.response ?? undefined, - detail: parseResult.data.detail as + detail: detail as | string | Record | Array<{