From 1153e1c90771a98038e2af9fefb379309c7b181f Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Mon, 9 Nov 2026 19:19:23 +0800 Subject: [PATCH 1/2] feat(cli): accept init-script files on create --init-script still takes platform slugs (docker_cleanup, env_merge) for create and update. Other values are script files whose contents are sent as compose_file.init_script on create, in argument order. A value that matches a slug is always a slug; use ./docker_cleanup for a same-named file. Mixing slugs and files is rejected. File paths cannot be used with --cvm-id. At most five files. A mistyped slug is reported as unknown before mix or count errors. Help still describes slugs only. --- cli/src/commands/deploy/command.ts | 13 ++- cli/src/commands/deploy/create.test.ts | 89 ++++++++++++++++++++ cli/src/commands/deploy/handler.ts | 98 ++++++++++++++++++++--- cli/src/commands/deploy/update.test.ts | 39 +++++++++ js/src/actions/cvms/provision_cvm.test.ts | 14 ++++ js/src/actions/cvms/provision_cvm.ts | 2 + 6 files changed, 244 insertions(+), 11 deletions(-) diff --git a/cli/src/commands/deploy/command.ts b/cli/src/commands/deploy/command.ts index c567d59e5..dc8bc6b24 100644 --- a/cli/src/commands/deploy/command.ts +++ b/cli/src/commands/deploy/command.ts @@ -14,6 +14,17 @@ import { } from "@/src/utils/constants"; import { z } from "zod"; +// Platform slugs accepted by --init-script. A value equal to one of these is +// always a slug, even if a file of the same name exists; pass ./docker_cleanup +// to send a file. Other values are script file paths (create only, admin only: +// Phala Cloud ignores raw scripts for non-admin accounts; at most 5 files). +export const INIT_SCRIPT_SLUGS = ["docker_cleanup", "env_merge"] as const; +export type InitScriptSlug = (typeof INIT_SCRIPT_SLUGS)[number]; + +export function isInitScriptSlug(value: string): value is InitScriptSlug { + return (INIT_SCRIPT_SLUGS as readonly string[]).includes(value); +} + export const deployCommandMeta: CommandMeta = { name: "deploy", category: "deploy", @@ -540,7 +551,7 @@ export const deployCommandSchema = z transactionHash: z.string().optional(), runner: z.enum(["docker-compose", "nerdctl-compose", "bash"]).optional(), appCompose: z.string().optional(), - initScript: z.array(z.enum(["docker_cleanup", "env_merge"])).optional(), + initScript: z.array(z.string()).optional(), clearInitScripts: z.boolean().optional(), launchToken: z.string().optional(), launchTokenHash: z.string().optional(), diff --git a/cli/src/commands/deploy/create.test.ts b/cli/src/commands/deploy/create.test.ts index e041ff531..a6db9f654 100644 --- a/cli/src/commands/deploy/create.test.ts +++ b/cli/src/commands/deploy/create.test.ts @@ -187,6 +187,95 @@ describe("phala deploy (create)", () => { expect(body.launch_token).toBe(LAUNCH_TOKEN); }); + test("sends --init-script file contents as compose_file.init_script in order", async () => { + const first = path.join(tmpDir, "init-a.sh"); + const second = path.join(tmpDir, "init-b.sh"); + await fs.writeFile(first, "echo first\n"); + await fs.writeFile(second, "echo second\n"); + + const { exitCode, failure } = await deploy({ + compose: composePath, + initScript: [first, second], + }); + + expect(failure).toBeUndefined(); + expect(exitCode).toBe(0); + const body = prepareRequest().body as Record; + const composeFile = body.compose_file as Record; + expect(composeFile.init_script).toEqual(["echo first\n", "echo second\n"]); + expect("init_scripts" in body).toBe(false); + }); + + test("omits compose_file.init_script when --init-script is not passed", async () => { + const { exitCode } = await deploy({ compose: composePath }); + + expect(exitCode).toBe(0); + const body = prepareRequest().body as Record; + const composeFile = body.compose_file as Record; + expect("init_script" in composeFile).toBe(false); + expect("init_scripts" in body).toBe(false); + }); + + test("rejects a non-slug --init-script that is not a file before any request", async () => { + const { exitCode, failure } = await deploy({ + compose: composePath, + initScript: ["not-a-slug-or-file"], + }); + + expect(exitCode).toBe(1); + expect((failure as Error).message).toContain( + "not a known init script slug", + ); + expect((failure as Error).message).toContain("no such file"); + expect(requests).toEqual([]); + }); + + test("rejects a mistyped slug on create before mix or file checks", async () => { + const { exitCode, failure } = await deploy({ + compose: composePath, + initScript: ["docker_cleanup", "docker_clenaup"], + }); + + expect(exitCode).toBe(1); + expect((failure as Error).message).toContain( + "not a known init script slug", + ); + expect((failure as Error).message).toContain("docker_cleanup, env_merge"); + expect((failure as Error).message).toContain("no such file"); + expect((failure as Error).message).not.toContain("not both"); + expect(requests).toEqual([]); + }); + + test("rejects mixing init script slugs and files before any request", async () => { + const script = path.join(tmpDir, "init-mix.sh"); + await fs.writeFile(script, "echo mix\n"); + const { exitCode, failure } = await deploy({ + compose: composePath, + initScript: ["docker_cleanup", script], + }); + + expect(exitCode).toBe(1); + expect((failure as Error).message).toContain("not both"); + expect(requests).toEqual([]); + }); + + test("rejects more than 5 init script files before any request", async () => { + const files: string[] = []; + for (const name of ["a", "b", "c", "d", "e", "f"]) { + const filePath = path.join(tmpDir, `init-${name}.sh`); + await fs.writeFile(filePath, `echo ${name}\n`); + files.push(filePath); + } + const { exitCode, failure } = await deploy({ + compose: composePath, + initScript: files, + }); + + expect(exitCode).toBe(1); + expect((failure as Error).message).toContain("at most 5"); + expect(requests).toEqual([]); + }); + test("--json writes exactly one JSON object to stdout", async () => { const { exitCode, failure } = await deploy({ compose: composePath }); diff --git a/cli/src/commands/deploy/handler.ts b/cli/src/commands/deploy/handler.ts index 7fd0febd5..4c5f1205e 100644 --- a/cli/src/commands/deploy/handler.ts +++ b/cli/src/commands/deploy/handler.ts @@ -65,7 +65,12 @@ import { import dedent from "dedent"; import fs from "fs-extra"; import inquirer from "inquirer"; -import type { DeployCommandInput } from "./command"; +import { + INIT_SCRIPT_SLUGS, + isInitScriptSlug, + type DeployCommandInput, + type InitScriptSlug, +} from "./command"; type PrivacyConfig = Pick< RuntimeProjectConfig, @@ -120,7 +125,7 @@ interface Options { transactionHash?: string; runner?: "docker-compose" | "nerdctl-compose" | "bash"; appCompose?: string; - initScript?: Array<"docker_cleanup" | "env_merge">; + initScript?: string[]; clearInitScripts?: boolean; launchToken?: string; launchTokenHash?: string; @@ -176,6 +181,45 @@ const CREATE_ONLY_FLAGS: ReadonlyArray = [ ["generateLaunchToken", "--generate-launch-token"], ]; +function partitionInitScriptValues(values: string[] | undefined): { + slugs: InitScriptSlug[]; + files: string[]; +} { + const slugs: InitScriptSlug[] = []; + const files: string[] = []; + for (const value of values ?? []) { + if (isInitScriptSlug(value)) slugs.push(value); + else files.push(value); + } + return { slugs, files }; +} + +function readScriptFile(filePath: string, missingMessage: string): string { + if (!fs.existsSync(filePath)) { + throw new Error(missingMessage); + } + return fs.readFileSync(filePath, "utf8"); +} + +function unknownInitScriptSlugMessage(value: string): string { + return `${value} is not a known init script slug (${INIT_SCRIPT_SLUGS.join(", ")})`; +} + +function assertInitScriptFilesExist(paths: string[]): void { + for (const filePath of paths) { + if (!fs.existsSync(filePath)) { + throw new Error( + `${unknownInitScriptSlugMessage(filePath)}, and no such file was found.`, + ); + } + } +} + +function readInitScriptFiles(paths: string[]): string[] { + assertInitScriptFilesExist(paths); + return paths.map((filePath) => readScriptFile(filePath, filePath)); +} + export function assertUpdatableOptions(options: Options): void { const flags = CREATE_ONLY_FLAGS.filter( ([key]) => options[key] !== undefined, @@ -190,6 +234,14 @@ export function assertUpdatableOptions(options: Options): void { "--clear-init-scripts cannot be combined with --init-script.", ); } + const unknownInitScripts = partitionInitScriptValues( + options.initScript, + ).files; + if (unknownInitScripts.length > 0) { + throw new Error( + `${unknownInitScriptSlugMessage(unknownInitScripts[0] as string)}. File paths with --init-script can only be used when creating a CVM, not with --cvm-id.`, + ); + } } /** @@ -224,6 +276,21 @@ export function assertCreateOptions(options: Options): void { ); } } + const initScripts = partitionInitScriptValues(options.initScript); + assertInitScriptFilesExist(initScripts.files); + if (initScripts.slugs.length > 0 && initScripts.files.length > 0) { + throw new Error( + "Send either init script slugs or init script files with --init-script, not both.", + ); + } + if (initScripts.files.length > 5) { + throw new Error("--init-script accepts at most 5 script files."); + } + if (options.appCompose !== undefined && initScripts.files.length > 0) { + throw new Error( + "--init-script file paths cannot be used with --app-compose. Set them in the app-compose file.", + ); + } if (options.appCompose === undefined) return; const flags = COMPOSE_SHAPING_FLAGS.filter( ([key]) => options[key] !== undefined, @@ -783,6 +850,7 @@ function buildComposeFile( envs: EnvVar[], privacySettings: PrivacySettings, preLaunchScriptContent?: string, + initScriptContents?: string[], ): Record { const composeFile: Record = { name: "", // Required by backend schema, defaults to empty string @@ -816,6 +884,9 @@ function buildComposeFile( if (options.experimentalKeyProviderType) { composeFile.key_provider = options.experimentalKeyProviderType; } + if (initScriptContents && initScriptContents.length > 0) { + composeFile.init_script = initScriptContents; + } return composeFile; } @@ -832,6 +903,7 @@ export const buildProvisionPayload = ( privacySettings: PrivacySettings, preResolvedKmsSelection?: ReturnType, preLaunchScriptContent?: string, + initScriptContents?: string[], ) => { const payload: ProvisionCvmRequest = options.appCompose ? // The file is the whole app compose; the API parses it. @@ -848,12 +920,14 @@ export const buildProvisionPayload = ( envs, privacySettings, preLaunchScriptContent, + initScriptContents, ), listed: privacySettings.listed, }; - if (options.initScript !== undefined) { - payload.init_scripts = options.initScript; + const initScripts = partitionInitScriptValues(options.initScript); + if (initScripts.slugs.length > 0 && initScripts.files.length === 0) { + payload.init_scripts = initScripts.slugs; } if (options.launchToken !== undefined) { payload.launch_token = options.launchToken; @@ -991,6 +1065,7 @@ const deployNewCvm = async ( stderr: NodeJS.WriteStream, projectConfig: PrivacyConfig | undefined, preLaunchScriptContent: string | undefined, + initScriptContents: string[], context: Pick, ) => { // Resolve KMS selection once at the start to avoid duplicate calls and warnings @@ -1022,6 +1097,7 @@ const deployNewCvm = async ( privacySettings, kmsSelection, preLaunchScriptContent, + initScriptContents, ); logger.info(`Provisioning CVM ${name}...`); @@ -1671,13 +1747,14 @@ export async function runDeploy( // Read pre-launch script file if provided let preLaunchScriptContent: string | undefined; if (input.preLaunchScript) { - if (!fs.existsSync(input.preLaunchScript)) { - throw new Error( - `Pre-launch script file not found: ${input.preLaunchScript}`, - ); - } - preLaunchScriptContent = fs.readFileSync(input.preLaunchScript, "utf8"); + preLaunchScriptContent = readScriptFile( + input.preLaunchScript, + `Pre-launch script file not found: ${input.preLaunchScript}`, + ); } + const initScriptContents = readInitScriptFiles( + partitionInitScriptValues(input.initScript).files, + ); // Early size check for compose payload (the SDK schema validates the // combined size too, but checking here gives users a faster, friendlier @@ -1752,6 +1829,7 @@ export async function runDeploy( context.stderr, context.projectConfig, preLaunchScriptContent, + initScriptContents, context, ); } diff --git a/cli/src/commands/deploy/update.test.ts b/cli/src/commands/deploy/update.test.ts index 444b6d39e..8c762754f 100644 --- a/cli/src/commands/deploy/update.test.ts +++ b/cli/src/commands/deploy/update.test.ts @@ -251,6 +251,21 @@ describe("phala deploy --help", () => { "stays stopped if it was not running before the update", ); }); + + test("describes --init-script as slugs only", async () => { + const { code, stdout } = await runCli(["deploy", "--help"]); + + expect(code).toBe(0); + const option = stdout + .split("\n") + .find( + (line) => + line.includes("--init-script") && line.includes("docker_cleanup"), + ); + expect(option).toBeDefined(); + expect(option).not.toMatch(/file/i); + expect(option).not.toMatch(/admin/i); + }); }); describe("phala deploy --cvm-id --clear-init-scripts (parsed argv)", () => { @@ -315,6 +330,30 @@ describe("phala deploy --cvm-id", () => { }); }); + test("rejects --init-script file paths on update before any request", async () => { + const { exitCode, failure } = await deploy({ + initScript: [path.join(tmpDir, "init.sh")], + }); + + expect(exitCode).toBe(1); + expect((failure as Error).message).toContain("creating a CVM"); + expect(requests).toEqual([]); + }); + + test("rejects a mistyped init script slug on update as unknown slug, not only as a file", async () => { + const { exitCode, failure } = await deploy({ + initScript: ["docker_clenaup"], + }); + + expect(exitCode).toBe(1); + expect((failure as Error).message).toContain( + "not a known init script slug", + ); + expect((failure as Error).message).toContain("docker_cleanup, env_merge"); + expect((failure as Error).message).toContain("creating a CVM"); + expect(requests).toEqual([]); + }); + test("sends compose flags as a merge patch, with init scripts and the data-wipe confirmation", async () => { const { exitCode, failure } = await deploy({ secureTime: true, diff --git a/js/src/actions/cvms/provision_cvm.test.ts b/js/src/actions/cvms/provision_cvm.test.ts index 606bd41aa..954120367 100644 --- a/js/src/actions/cvms/provision_cvm.test.ts +++ b/js/src/actions/cvms/provision_cvm.test.ts @@ -355,6 +355,20 @@ describe("provisionCvm (wire-level)", () => { expect("compose_file" in body).toBe(false); }); + it("keeps compose_file.init_script in the provision body", async () => { + await provisionCvm(mockClient, { + name: "test-app", + instance_type: "tdx.small", + compose_file: { + docker_compose_file: "services: {}", + init_script: ["echo one", "echo two"], + }, + }); + + const composeFile = getRequestBody().compose_file as Record; + expect(composeFile.init_script).toEqual(["echo one", "echo two"]); + }); + it("sends the create-time compose and app fields", async () => { await provisionCvm(mockClient, { name: "test-app", diff --git a/js/src/actions/cvms/provision_cvm.ts b/js/src/actions/cvms/provision_cvm.ts index dee7faca2..02d421003 100644 --- a/js/src/actions/cvms/provision_cvm.ts +++ b/js/src/actions/cvms/provision_cvm.ts @@ -248,6 +248,8 @@ export const ProvisionCvmRequestSchema = z .optional(), snapshotter: z.enum(["overlayfs", "stargz"]).nullable().optional(), verity_volumes: z.array(VerityVolumeSchema).nullable().optional(), + // Raw guest init scripts. Platform slugs use init_scripts instead. + init_script: z.union([z.string(), z.array(z.string())]).optional(), }) // Keep compose_file open to newer backend fields. Without passthrough, // zod's default strip mode drops every unknown key at parse time and From 30237621c816258496b4c06d7d3378c0c15eb055 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Mon, 9 Nov 2026 19:20:52 +0800 Subject: [PATCH 2/2] refactor(cli): read init-script files after the existence check readInitScriptFiles already asserts each path exists. Read the bytes with readFileSync instead of calling readScriptFile again (which re-checked existence and used the path as a dummy error message). readScriptFile remains the helper for --pre-launch-script. --- cli/src/commands/deploy/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/deploy/handler.ts b/cli/src/commands/deploy/handler.ts index 4c5f1205e..713ab694a 100644 --- a/cli/src/commands/deploy/handler.ts +++ b/cli/src/commands/deploy/handler.ts @@ -217,7 +217,7 @@ function assertInitScriptFilesExist(paths: string[]): void { function readInitScriptFiles(paths: string[]): string[] { assertInitScriptFilesExist(paths); - return paths.map((filePath) => readScriptFile(filePath, filePath)); + return paths.map((filePath) => fs.readFileSync(filePath, "utf8")); } export function assertUpdatableOptions(options: Options): void {