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
13 changes: 12 additions & 1 deletion cli/src/commands/deploy/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(),
Expand Down
89 changes: 89 additions & 0 deletions cli/src/commands/deploy/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const composeFile = body.compose_file as Record<string, unknown>;
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<string, unknown>;
const composeFile = body.compose_file as Record<string, unknown>;
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 });

Expand Down
98 changes: 88 additions & 10 deletions cli/src/commands/deploy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -176,6 +181,45 @@ const CREATE_ONLY_FLAGS: ReadonlyArray<readonly [keyof Options, string]> = [
["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) => fs.readFileSync(filePath, "utf8"));
}

export function assertUpdatableOptions(options: Options): void {
const flags = CREATE_ONLY_FLAGS.filter(
([key]) => options[key] !== undefined,
Expand All @@ -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.`,
);
}
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -783,6 +850,7 @@ function buildComposeFile(
envs: EnvVar[],
privacySettings: PrivacySettings,
preLaunchScriptContent?: string,
initScriptContents?: string[],
): Record<string, unknown> {
const composeFile: Record<string, unknown> = {
name: "", // Required by backend schema, defaults to empty string
Expand Down Expand Up @@ -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;
}

Expand All @@ -832,6 +903,7 @@ export const buildProvisionPayload = (
privacySettings: PrivacySettings,
preResolvedKmsSelection?: ReturnType<typeof resolveKmsSelection>,
preLaunchScriptContent?: string,
initScriptContents?: string[],
) => {
const payload: ProvisionCvmRequest = options.appCompose
? // The file is the whole app compose; the API parses it.
Expand All @@ -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;
Expand Down Expand Up @@ -991,6 +1065,7 @@ const deployNewCvm = async (
stderr: NodeJS.WriteStream,
projectConfig: PrivacyConfig | undefined,
preLaunchScriptContent: string | undefined,
initScriptContents: string[],
context: Pick<CommandContext, "env" | "projectConfig" | "globalOptions">,
) => {
// Resolve KMS selection once at the start to avoid duplicate calls and warnings
Expand Down Expand Up @@ -1022,6 +1097,7 @@ const deployNewCvm = async (
privacySettings,
kmsSelection,
preLaunchScriptContent,
initScriptContents,
);

logger.info(`Provisioning CVM ${name}...`);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1752,6 +1829,7 @@ export async function runDeploy(
context.stderr,
context.projectConfig,
preLaunchScriptContent,
initScriptContents,
context,
);
}
Expand Down
39 changes: 39 additions & 0 deletions cli/src/commands/deploy/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions js/src/actions/cvms/provision_cvm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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",
Expand Down
2 changes: 2 additions & 0 deletions js/src/actions/cvms/provision_cvm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading