Skip to content

Commit 20a1652

Browse files
authored
Merge pull request #217 from Phala-Network/feat/two-phase-deploy-multisig
feat: two-phase CVM deploy support (prepareOnly + commitCvmUpdate)
2 parents f6c8490 + 79e695f commit 20a1652

File tree

6 files changed

+610
-3
lines changed

6 files changed

+610
-3
lines changed

cli/src/commands/deploy/command.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,44 @@ export const deployCommandMeta: CommandMeta = {
194194
negatedName: "no-dev-os",
195195
group: "basic",
196196
},
197+
{
198+
name: "prepare-only",
199+
description:
200+
"Only prepare the update (generate commit token) without performing on-chain operations. For multisig workflows.",
201+
type: "boolean",
202+
target: "prepareOnly",
203+
group: "advanced",
204+
},
205+
{
206+
name: "commit",
207+
description:
208+
"Commit a previously prepared update using a commit token. Requires --token, --compose-hash, and --transaction-hash.",
209+
type: "boolean",
210+
target: "commit",
211+
group: "advanced",
212+
},
213+
{
214+
name: "token",
215+
description: "Commit token from a prepare-only update",
216+
type: "string",
217+
target: "token",
218+
group: "advanced",
219+
},
220+
{
221+
name: "compose-hash",
222+
description: "Compose hash from a prepare-only update",
223+
type: "string",
224+
target: "composeHash",
225+
group: "advanced",
226+
},
227+
{
228+
name: "transaction-hash",
229+
description:
230+
"Transaction hash proving on-chain compose hash registration",
231+
type: "string",
232+
target: "transactionHash",
233+
group: "advanced",
234+
},
197235
{
198236
name: "public-logs",
199237
description: "Make CVM logs publicly accessible (default: true)",
@@ -273,6 +311,17 @@ export const deployCommandMeta: CommandMeta = {
273311
name: "Update existing CVM to disable logs",
274312
value: "phala deploy --cvm-id app_123 --no-public-logs",
275313
},
314+
// --- Multisig / Prepare-Only Examples ---
315+
{
316+
name: "Prepare update for multisig approval",
317+
value:
318+
"phala deploy --cvm-id app_123 --prepare-only -c docker-compose.yml",
319+
},
320+
{
321+
name: "Commit a prepared update",
322+
value:
323+
"phala deploy --cvm-id app_123 --commit --token <token> --compose-hash 0x... --transaction-hash 0x...",
324+
},
276325
],
277326
};
278327

@@ -307,6 +356,11 @@ export const deployCommandSchema = z.object({
307356
publicLogs: z.boolean().optional(),
308357
publicSysinfo: z.boolean().optional(),
309358
listed: z.boolean().optional(),
359+
prepareOnly: z.boolean().default(false),
360+
commit: z.boolean().default(false),
361+
token: z.string().optional(),
362+
composeHash: z.string().optional(),
363+
transactionHash: z.string().optional(),
310364
});
311365

312366
export type DeployCommandInput = z.infer<typeof deployCommandSchema>;

cli/src/commands/deploy/handler.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
safePatchCvm,
4040
safeProvisionCvm,
4141
safeUpdateCvmVisibility,
42+
safeCommitCvmUpdate,
4243
convertToHostname,
4344
isValidHostname,
4445
} from "@phala/cloud";
@@ -85,6 +86,11 @@ interface Options {
8586
publicLogs?: boolean;
8687
publicSysinfo?: boolean;
8788
listed?: boolean;
89+
prepareOnly?: boolean;
90+
commit?: boolean;
91+
token?: string;
92+
composeHash?: string;
93+
transactionHash?: string;
8894
[key: string]: unknown;
8995
}
9096

@@ -982,6 +988,11 @@ const updateCvm = async (
982988
patchBody.public_sysinfo = validatedOptions.publicSysinfo;
983989
}
984990

991+
// Add prepareOnly flag if set
992+
if (validatedOptions.prepareOnly) {
993+
patchBody.prepareOnly = true;
994+
}
995+
985996
logger.info(`Updating CVM ${validatedOptions.uuid}...`);
986997
// biome-ignore lint/suspicious/noExplicitAny: dynamic patch body
987998
const patchResult = await safePatchCvm(client, patchBody as any);
@@ -996,8 +1007,112 @@ const updateCvm = async (
9961007

9971008
const result = patchResult.data;
9981009

1010+
// --prepare-only on a CVM that doesn't require on-chain hash
1011+
if (validatedOptions.prepareOnly && !result.requiresOnChainHash) {
1012+
const msg =
1013+
"--prepare-only has no effect on this CVM: it does not use on-chain KMS. The update was applied directly.";
1014+
if (validatedOptions.json !== false) {
1015+
stdout.write(
1016+
`${JSON.stringify({ success: true, prepare_only: false, message: msg }, null, 2)}\n`,
1017+
);
1018+
} else {
1019+
logger.warn(msg);
1020+
}
1021+
return;
1022+
}
1023+
9991024
// Two-phase flow: on-chain KMS requires compose hash registration
10001025
if (result.requiresOnChainHash) {
1026+
// --prepare-only mode: output commit info and stop
1027+
if (validatedOptions.prepareOnly) {
1028+
// Build explorer link for the contract address
1029+
const chainId = result.kmsInfo?.chain_id;
1030+
const chain = result.kmsInfo?.chain as
1031+
| { name?: string; blockExplorers?: { default?: { url?: string } } }
1032+
| undefined;
1033+
const contractAddress = cvm.app_id;
1034+
const explorerUrl = chain?.blockExplorers?.default?.url;
1035+
const contractExplorerUrl =
1036+
explorerUrl && contractAddress
1037+
? `${explorerUrl}/address/${contractAddress.startsWith("0x") ? contractAddress : `0x${contractAddress}`}`
1038+
: undefined;
1039+
1040+
const composeHashHex = result.composeHash.startsWith("0x")
1041+
? result.composeHash
1042+
: `0x${result.composeHash}`;
1043+
1044+
const onchain = result.onchainStatus;
1045+
const output = {
1046+
success: true,
1047+
prepare_only: true,
1048+
compose_hash: composeHashHex,
1049+
app_id: cvm.app_id,
1050+
device_id: result.deviceId,
1051+
kms_info: result.kmsInfo,
1052+
chain_id: chainId,
1053+
contract_explorer_url: contractExplorerUrl,
1054+
onchain_status: onchain,
1055+
commit_token: result.commitToken,
1056+
commit_url: result.commitUrl,
1057+
api_commit_url: result.apiCommitUrl,
1058+
};
1059+
1060+
if (validatedOptions.json !== false) {
1061+
stdout.write(`${JSON.stringify(output, null, 2)}\n`);
1062+
} else {
1063+
const lines = [
1064+
"CVM update prepared successfully (pending on-chain approval).",
1065+
"",
1066+
`Compose Hash: ${composeHashHex}`,
1067+
`App ID: ${cvm.app_id}`,
1068+
`Device ID: ${result.deviceId}`,
1069+
];
1070+
if (chainId) {
1071+
lines.push(
1072+
`Chain: ${chain?.name || "Unknown"} (ID: ${chainId})`,
1073+
);
1074+
}
1075+
if (contractExplorerUrl) {
1076+
lines.push(`Contract: ${contractExplorerUrl}`);
1077+
}
1078+
lines.push(
1079+
`Commit Token: ${result.commitToken || "N/A"}`,
1080+
`Commit URL: ${result.commitUrl || "N/A"}`,
1081+
`API Commit URL: ${result.apiCommitUrl || "N/A"} (POST)`,
1082+
);
1083+
if (onchain) {
1084+
const hashStatus = onchain.compose_hash_allowed
1085+
? "registered"
1086+
: "NOT registered";
1087+
const deviceStatus = onchain.device_id_allowed
1088+
? "registered"
1089+
: "NOT registered";
1090+
lines.push(
1091+
"",
1092+
"On-chain Status:",
1093+
` Compose Hash: ${hashStatus}`,
1094+
` Device ID: ${deviceStatus}`,
1095+
);
1096+
if (onchain.is_allowed) {
1097+
lines.push(
1098+
" All prerequisites met. You can commit with --transaction-hash already-registered.",
1099+
);
1100+
}
1101+
}
1102+
lines.push(
1103+
"",
1104+
"To complete the update after on-chain approval:",
1105+
` phala deploy --cvm-id ${validatedOptions.uuid} \\`,
1106+
" --commit \\",
1107+
` --token ${result.commitToken || "<token>"} \\`,
1108+
` --compose-hash ${composeHashHex} \\`,
1109+
" --transaction-hash <tx-hash>",
1110+
);
1111+
stdout.write(`${lines.join("\n")}\n`);
1112+
}
1113+
return;
1114+
}
1115+
10011116
if (!validatedOptions.privateKey) {
10021117
throw new Error("Private key is required for contract DstackApp");
10031118
}
@@ -1139,11 +1254,94 @@ const updateCvm = async (
11391254
}
11401255
};
11411256

1257+
/**
1258+
* Commit a previously prepared CVM update using a commit token.
1259+
* Skips compose file reading and env processing — goes straight to the API.
1260+
*/
1261+
const commitCvmUpdate = async (
1262+
validatedOptions: Options,
1263+
client: Client<typeof API_VERSION>,
1264+
stdout: NodeJS.WriteStream,
1265+
) => {
1266+
if (!validatedOptions.token) {
1267+
throw new Error("--token is required for --commit mode");
1268+
}
1269+
if (!validatedOptions.uuid) {
1270+
throw new Error("--cvm-id is required for --commit mode");
1271+
}
1272+
if (!validatedOptions.transactionHash) {
1273+
logger.info(
1274+
"No --transaction-hash provided, using 'already-registered' (state-only check)",
1275+
);
1276+
}
1277+
1278+
logger.info(`Committing CVM update for ${validatedOptions.uuid}...`);
1279+
1280+
const commitResult = await safeCommitCvmUpdate(client, {
1281+
id: validatedOptions.uuid,
1282+
token: validatedOptions.token,
1283+
composeHash: validatedOptions.composeHash || "",
1284+
transactionHash: validatedOptions.transactionHash || "",
1285+
});
1286+
1287+
if (!commitResult.success) {
1288+
const errMsg =
1289+
commitResult.error instanceof Error
1290+
? commitResult.error.message
1291+
: String(commitResult.error);
1292+
const isExpired = errMsg.includes("expired") || errMsg.includes("Invalid");
1293+
const hint = isExpired
1294+
? " Run --prepare-only again to get a new commit token."
1295+
: "";
1296+
throw new Error(`Failed to commit CVM update: ${errMsg}${hint}`);
1297+
}
1298+
1299+
if (validatedOptions.json !== false) {
1300+
stdout.write(
1301+
`${JSON.stringify(
1302+
{
1303+
success: true,
1304+
vm_uuid: validatedOptions.uuid,
1305+
correlation_id: commitResult.data.correlationId,
1306+
status: commitResult.data.status,
1307+
},
1308+
null,
1309+
2,
1310+
)}\n`,
1311+
);
1312+
} else {
1313+
stdout.write(
1314+
`CVM update committed successfully! Correlation ID: ${commitResult.data.correlationId}\n`,
1315+
);
1316+
}
1317+
};
1318+
11421319
export async function runDeploy(
11431320
input: DeployCommandInput,
11441321
context: CommandContext,
11451322
): Promise<void> {
11461323
try {
1324+
// Handle --commit mode: skip compose file reading entirely
1325+
// commit-update endpoint is token-based (no API key required),
1326+
// but we still need a client with the correct base URL.
1327+
if (input.commit) {
1328+
const resolved = resolveAuthForContext(undefined, {
1329+
apiToken: input.apiToken,
1330+
});
1331+
const client = createClient({
1332+
apiKey: resolved.apiKey,
1333+
baseURL: resolved.baseURL,
1334+
version: API_VERSION,
1335+
});
1336+
1337+
const uuid = context.cvmId
1338+
? CvmIdSchema.parse(context.cvmId).cvmId
1339+
: undefined;
1340+
1341+
await commitCvmUpdate({ ...input, uuid }, client, context.stdout);
1342+
return;
1343+
}
1344+
11471345
// Use positional argument if provided, otherwise use the --compose option
11481346
// Fallback to phala.toml compose_file if not specified
11491347
const dockerComposePath =
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { z } from "zod";
2+
import { CvmIdObjectSchema, CvmIdSchema, refineCvmId } from "../../types/cvm_id";
3+
import { defineAction } from "../../utils/define-action";
4+
5+
/**
6+
* Commit CVM update (token-based, no auth required)
7+
*
8+
* Completes a two-phase CVM update using a one-time commit token generated
9+
* during a prepare-only PATCH request. This enables multisig workflows where
10+
* the on-chain signer is different from the original API caller.
11+
*
12+
* @example
13+
* ```typescript
14+
* import { createClient, patchCvm, commitCvmUpdate } from '@phala/cloud'
15+
*
16+
* const client = createClient()
17+
*
18+
* // Phase 1: prepare-only
19+
* const result = await patchCvm(client, {
20+
* id: 'my-cvm',
21+
* docker_compose_file: newComposeYaml,
22+
* prepareOnly: true,
23+
* })
24+
*
25+
* if (result.requiresOnChainHash && result.commitToken) {
26+
* // ... multisig approval happens externally ...
27+
*
28+
* // Phase 2: commit with token
29+
* const committed = await commitCvmUpdate(client, {
30+
* id: 'my-cvm',
31+
* token: result.commitToken,
32+
* composeHash: result.composeHash,
33+
* transactionHash: '0x...',
34+
* })
35+
* console.log(`Update started: ${committed.correlationId}`)
36+
* }
37+
* ```
38+
*/
39+
40+
export const CommitCvmUpdateRequestSchema = refineCvmId(
41+
CvmIdObjectSchema.extend({
42+
token: z.string().describe("One-time commit token from prepare-only flow"),
43+
composeHash: z.string().describe("Compose hash from Phase 1 response"),
44+
transactionHash: z.string().describe("Transaction hash proving on-chain registration"),
45+
}),
46+
);
47+
48+
export type CommitCvmUpdateRequest = z.input<typeof CommitCvmUpdateRequestSchema>;
49+
50+
const CommitCvmUpdateResultSchema = z.object({
51+
correlationId: z.string(),
52+
status: z.string(),
53+
});
54+
55+
export type CommitCvmUpdateResult = z.infer<typeof CommitCvmUpdateResultSchema>;
56+
57+
const { action: commitCvmUpdate, safeAction: safeCommitCvmUpdate } = defineAction<
58+
CommitCvmUpdateRequest,
59+
typeof CommitCvmUpdateResultSchema
60+
>(CommitCvmUpdateResultSchema, async (client, request) => {
61+
const parsed = CommitCvmUpdateRequestSchema.parse(request);
62+
const { cvmId } = CvmIdSchema.parse(parsed);
63+
64+
const response = await client.post<{
65+
correlation_id: string;
66+
status: string;
67+
}>(`/cvms/${cvmId}/commit-update`, {
68+
token: parsed.token,
69+
compose_hash: parsed.composeHash,
70+
transaction_hash: parsed.transactionHash,
71+
});
72+
73+
return {
74+
correlationId: response.correlation_id,
75+
status: response.status,
76+
};
77+
});
78+
79+
export { commitCvmUpdate, safeCommitCvmUpdate };

0 commit comments

Comments
 (0)