Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions cli/src/commands/deploy/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,44 @@ export const deployCommandMeta: CommandMeta = {
negatedName: "no-dev-os",
group: "basic",
},
{
name: "prepare-only",
description:
"Only prepare the update (generate commit token) without performing on-chain operations. For multisig workflows.",
type: "boolean",
target: "prepareOnly",
group: "advanced",
},
{
name: "commit",
description:
"Commit a previously prepared update using a commit token. Requires --token, --compose-hash, and --transaction-hash.",
type: "boolean",
target: "commit",
group: "advanced",
},
{
name: "token",
description: "Commit token from a prepare-only update",
type: "string",
target: "token",
group: "advanced",
},
{
name: "compose-hash",
description: "Compose hash from a prepare-only update",
type: "string",
target: "composeHash",
group: "advanced",
},
{
name: "transaction-hash",
description:
"Transaction hash proving on-chain compose hash registration",
type: "string",
target: "transactionHash",
group: "advanced",
},
{
name: "public-logs",
description: "Make CVM logs publicly accessible (default: true)",
Expand Down Expand Up @@ -273,6 +311,17 @@ export const deployCommandMeta: CommandMeta = {
name: "Update existing CVM to disable logs",
value: "phala deploy --cvm-id app_123 --no-public-logs",
},
// --- Multisig / Prepare-Only Examples ---
{
name: "Prepare update for multisig approval",
value:
"phala deploy --cvm-id app_123 --prepare-only -c docker-compose.yml",
},
{
name: "Commit a prepared update",
value:
"phala deploy --cvm-id app_123 --commit --token <token> --compose-hash 0x... --transaction-hash 0x...",
},
],
};

Expand Down Expand Up @@ -307,6 +356,11 @@ export const deployCommandSchema = z.object({
publicLogs: z.boolean().optional(),
publicSysinfo: z.boolean().optional(),
listed: z.boolean().optional(),
prepareOnly: z.boolean().default(false),
commit: z.boolean().default(false),
token: z.string().optional(),
composeHash: z.string().optional(),
transactionHash: z.string().optional(),
});

export type DeployCommandInput = z.infer<typeof deployCommandSchema>;
198 changes: 198 additions & 0 deletions cli/src/commands/deploy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
safePatchCvm,
safeProvisionCvm,
safeUpdateCvmVisibility,
safeCommitCvmUpdate,
convertToHostname,
isValidHostname,
} from "@phala/cloud";
Expand Down Expand Up @@ -85,6 +86,11 @@ interface Options {
publicLogs?: boolean;
publicSysinfo?: boolean;
listed?: boolean;
prepareOnly?: boolean;
commit?: boolean;
token?: string;
composeHash?: string;
transactionHash?: string;
[key: string]: unknown;
}

Expand Down Expand Up @@ -982,6 +988,11 @@ const updateCvm = async (
patchBody.public_sysinfo = validatedOptions.publicSysinfo;
}

// Add prepareOnly flag if set
if (validatedOptions.prepareOnly) {
patchBody.prepareOnly = true;
}

logger.info(`Updating CVM ${validatedOptions.uuid}...`);
// biome-ignore lint/suspicious/noExplicitAny: dynamic patch body
const patchResult = await safePatchCvm(client, patchBody as any);
Expand All @@ -996,8 +1007,112 @@ const updateCvm = async (

const result = patchResult.data;

// --prepare-only on a CVM that doesn't require on-chain hash
if (validatedOptions.prepareOnly && !result.requiresOnChainHash) {
const msg =
"--prepare-only has no effect on this CVM: it does not use on-chain KMS. The update was applied directly.";
if (validatedOptions.json !== false) {
stdout.write(
`${JSON.stringify({ success: true, prepare_only: false, message: msg }, null, 2)}\n`,
);
} else {
logger.warn(msg);
}
return;
}

// Two-phase flow: on-chain KMS requires compose hash registration
if (result.requiresOnChainHash) {
// --prepare-only mode: output commit info and stop
if (validatedOptions.prepareOnly) {
// Build explorer link for the contract address
const chainId = result.kmsInfo?.chain_id;
const chain = result.kmsInfo?.chain as
| { name?: string; blockExplorers?: { default?: { url?: string } } }
| undefined;
const contractAddress = cvm.app_id;
const explorerUrl = chain?.blockExplorers?.default?.url;
const contractExplorerUrl =
explorerUrl && contractAddress
? `${explorerUrl}/address/${contractAddress.startsWith("0x") ? contractAddress : `0x${contractAddress}`}`
: undefined;

const composeHashHex = result.composeHash.startsWith("0x")
? result.composeHash
: `0x${result.composeHash}`;

const onchain = result.onchainStatus;
const output = {
success: true,
prepare_only: true,
compose_hash: composeHashHex,
app_id: cvm.app_id,
device_id: result.deviceId,
kms_info: result.kmsInfo,
chain_id: chainId,
contract_explorer_url: contractExplorerUrl,
onchain_status: onchain,
commit_token: result.commitToken,
commit_url: result.commitUrl,
api_commit_url: result.apiCommitUrl,
};

if (validatedOptions.json !== false) {
stdout.write(`${JSON.stringify(output, null, 2)}\n`);
} else {
const lines = [
"CVM update prepared successfully (pending on-chain approval).",
"",
`Compose Hash: ${composeHashHex}`,
`App ID: ${cvm.app_id}`,
`Device ID: ${result.deviceId}`,
];
if (chainId) {
lines.push(
`Chain: ${chain?.name || "Unknown"} (ID: ${chainId})`,
);
}
if (contractExplorerUrl) {
lines.push(`Contract: ${contractExplorerUrl}`);
}
lines.push(
`Commit Token: ${result.commitToken || "N/A"}`,
`Commit URL: ${result.commitUrl || "N/A"}`,
`API Commit URL: ${result.apiCommitUrl || "N/A"} (POST)`,
);
if (onchain) {
const hashStatus = onchain.compose_hash_allowed
? "registered"
: "NOT registered";
const deviceStatus = onchain.device_id_allowed
? "registered"
: "NOT registered";
lines.push(
"",
"On-chain Status:",
` Compose Hash: ${hashStatus}`,
` Device ID: ${deviceStatus}`,
);
if (onchain.is_allowed) {
lines.push(
" All prerequisites met. You can commit with --transaction-hash already-registered.",
);
}
}
lines.push(
"",
"To complete the update after on-chain approval:",
` phala deploy --cvm-id ${validatedOptions.uuid} \\`,
" --commit \\",
` --token ${result.commitToken || "<token>"} \\`,
` --compose-hash ${composeHashHex} \\`,
" --transaction-hash <tx-hash>",
);
stdout.write(`${lines.join("\n")}\n`);
}
return;
}

if (!validatedOptions.privateKey) {
throw new Error("Private key is required for contract DstackApp");
}
Expand Down Expand Up @@ -1139,11 +1254,94 @@ const updateCvm = async (
}
};

/**
* Commit a previously prepared CVM update using a commit token.
* Skips compose file reading and env processing — goes straight to the API.
*/
const commitCvmUpdate = async (
validatedOptions: Options,
client: Client<typeof API_VERSION>,
stdout: NodeJS.WriteStream,
) => {
if (!validatedOptions.token) {
throw new Error("--token is required for --commit mode");
}
if (!validatedOptions.uuid) {
throw new Error("--cvm-id is required for --commit mode");
}
if (!validatedOptions.transactionHash) {
logger.info(
"No --transaction-hash provided, using 'already-registered' (state-only check)",
);
}

logger.info(`Committing CVM update for ${validatedOptions.uuid}...`);

const commitResult = await safeCommitCvmUpdate(client, {
id: validatedOptions.uuid,
token: validatedOptions.token,
composeHash: validatedOptions.composeHash || "",
transactionHash: validatedOptions.transactionHash || "",
});

if (!commitResult.success) {
const errMsg =
commitResult.error instanceof Error
? commitResult.error.message
: String(commitResult.error);
const isExpired = errMsg.includes("expired") || errMsg.includes("Invalid");
const hint = isExpired
? " Run --prepare-only again to get a new commit token."
: "";
throw new Error(`Failed to commit CVM update: ${errMsg}${hint}`);
}

if (validatedOptions.json !== false) {
stdout.write(
`${JSON.stringify(
{
success: true,
vm_uuid: validatedOptions.uuid,
correlation_id: commitResult.data.correlationId,
status: commitResult.data.status,
},
null,
2,
)}\n`,
);
} else {
stdout.write(
`CVM update committed successfully! Correlation ID: ${commitResult.data.correlationId}\n`,
);
}
};

export async function runDeploy(
input: DeployCommandInput,
context: CommandContext,
): Promise<void> {
try {
// Handle --commit mode: skip compose file reading entirely
// commit-update endpoint is token-based (no API key required),
// but we still need a client with the correct base URL.
if (input.commit) {
const resolved = resolveAuthForContext(undefined, {
apiToken: input.apiToken,
});
const client = createClient({
apiKey: resolved.apiKey,
baseURL: resolved.baseURL,
version: API_VERSION,
});

const uuid = context.cvmId
? CvmIdSchema.parse(context.cvmId).cvmId
: undefined;

await commitCvmUpdate({ ...input, uuid }, client, context.stdout);
return;
}

// Use positional argument if provided, otherwise use the --compose option
// Fallback to phala.toml compose_file if not specified
const dockerComposePath =
Expand Down
79 changes: 79 additions & 0 deletions js/src/actions/cvms/commit_cvm_update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { z } from "zod";
import { CvmIdObjectSchema, CvmIdSchema, refineCvmId } from "../../types/cvm_id";
import { defineAction } from "../../utils/define-action";

/**
* Commit CVM update (token-based, no auth required)
*
* Completes a two-phase CVM update using a one-time commit token generated
* during a prepare-only PATCH request. This enables multisig workflows where
* the on-chain signer is different from the original API caller.
*
* @example
* ```typescript
* import { createClient, patchCvm, commitCvmUpdate } from '@phala/cloud'
*
* const client = createClient()
*
* // Phase 1: prepare-only
* const result = await patchCvm(client, {
* id: 'my-cvm',
* docker_compose_file: newComposeYaml,
* prepareOnly: true,
* })
*
* if (result.requiresOnChainHash && result.commitToken) {
* // ... multisig approval happens externally ...
*
* // Phase 2: commit with token
* const committed = await commitCvmUpdate(client, {
* id: 'my-cvm',
* token: result.commitToken,
* composeHash: result.composeHash,
* transactionHash: '0x...',
* })
* console.log(`Update started: ${committed.correlationId}`)
* }
* ```
*/

export const CommitCvmUpdateRequestSchema = refineCvmId(
CvmIdObjectSchema.extend({
token: z.string().describe("One-time commit token from prepare-only flow"),
composeHash: z.string().describe("Compose hash from Phase 1 response"),
transactionHash: z.string().describe("Transaction hash proving on-chain registration"),
}),
);

export type CommitCvmUpdateRequest = z.input<typeof CommitCvmUpdateRequestSchema>;

const CommitCvmUpdateResultSchema = z.object({
correlationId: z.string(),
status: z.string(),
});

export type CommitCvmUpdateResult = z.infer<typeof CommitCvmUpdateResultSchema>;

const { action: commitCvmUpdate, safeAction: safeCommitCvmUpdate } = defineAction<
CommitCvmUpdateRequest,
typeof CommitCvmUpdateResultSchema
>(CommitCvmUpdateResultSchema, async (client, request) => {
const parsed = CommitCvmUpdateRequestSchema.parse(request);
const { cvmId } = CvmIdSchema.parse(parsed);

const response = await client.post<{
correlation_id: string;
status: string;
}>(`/cvms/${cvmId}/commit-update`, {
token: parsed.token,
compose_hash: parsed.composeHash,
transaction_hash: parsed.transactionHash,
});

return {
correlationId: response.correlation_id,
status: response.status,
};
});

export { commitCvmUpdate, safeCommitCvmUpdate };
Loading
Loading