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
122 changes: 107 additions & 15 deletions src/commands/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,19 +205,106 @@ function printIntegrationSnippet(t: SandboxTemplate): void {
}
}

// Named shapes a template's default size may reference. Kept in lockstep with
// the sandbox size vocabulary so `templates create --size` and `sandbox create
// --size` accept the same names.
const TEMPLATE_SIZES = ["xs", "small", "medium", "large", "xl"] as const;

function parseTemplateSize(value: string): string {
// PUBLISHED shape contracts. The backend only accepts these exact vCPU/memory
// PAIRS - an off-tier pair (e.g. 4 vCPU / 4 GB) is rejected with a shape
// mismatch. Kept in lockstep with the sandbox size vocabulary so `templates
// create --size` and `sandbox create --size` accept the same names. Disk is a
// floor the platform grows the rootfs to, so it is free-form (>= the shape's
// floor), never a pinned pair member.
const TEMPLATE_SIZE_CONTRACTS = {
xs: { cpu: 1, memory: 2_048, disk: 10_240 },
small: { cpu: 2, memory: 4_096, disk: 10_240 },
medium: { cpu: 4, memory: 8_192, disk: 20_480 },
large: { cpu: 8, memory: 16_384, disk: 40_960 },
xl: { cpu: 16, memory: 32_768, disk: 81_920 },
} as const;

type TemplateSize = keyof typeof TEMPLATE_SIZE_CONTRACTS;

const TEMPLATE_SIZES = Object.keys(TEMPLATE_SIZE_CONTRACTS) as TemplateSize[];

function parseTemplateSize(value: string): TemplateSize {
const size = String(value).trim().toLowerCase();
if ((TEMPLATE_SIZES as readonly string[]).includes(size)) return size;
if (size in TEMPLATE_SIZE_CONTRACTS) return size as TemplateSize;
throw new Error(
`Invalid --size: ${value}. Expected one of: ${TEMPLATE_SIZES.join(", ")}.`,
);
}

// The published shape closest to an off-tier request, preferring an exact vCPU
// match, then the nearest memory. Used only to make the failure message
// actionable ("nearest is medium ...").
function nearestTemplateSize(cpu: number, memory: number): TemplateSize {
return [...TEMPLATE_SIZES].sort((a, b) => {
const ca = TEMPLATE_SIZE_CONTRACTS[a];
const cb = TEMPLATE_SIZE_CONTRACTS[b];
const dCpu = Math.abs(ca.cpu - cpu) - Math.abs(cb.cpu - cpu);
if (dCpu !== 0) return dCpu;
return Math.abs(ca.memory - memory) - Math.abs(cb.memory - memory);
})[0] as TemplateSize;
}

/**
* Resolve the resource fields for `templates create`, refusing off-tier
* vCPU/memory pairs LOCALLY instead of shipping a build the platform will
* reject.
*
* - No --cpu/--memory: forward only what was given (size and/or disk); the
* server picks its own default shape. Never pin a pair from the CLI.
* - With --cpu and/or --memory: fill the missing member from --size (or small),
* then require the resulting pair to be a PUBLISHED shape. A mismatch fails
* fast naming the nearest size; disk stays free-form.
*/
function resolveTemplateResources(opts: {
size?: TemplateSize;
cpu?: number;
memory?: number;
disk?: number;
}): { cpu?: number; memory?: number; disk?: number; size?: TemplateSize } {
const pinned = opts.cpu != null || opts.memory != null;
if (!pinned) {
return {
...(opts.size ? { size: opts.size } : {}),
...(opts.disk != null ? { disk: opts.disk } : {}),
};
}

const base = TEMPLATE_SIZE_CONTRACTS[opts.size ?? "small"];
const cpu = opts.cpu ?? base.cpu;
const memory = opts.memory ?? base.memory;

const match = TEMPLATE_SIZES.find(
(size) =>
TEMPLATE_SIZE_CONTRACTS[size].cpu === cpu &&
TEMPLATE_SIZE_CONTRACTS[size].memory === memory,
);
if (!match) {
const nearest = nearestTemplateSize(cpu, memory);
const near = TEMPLATE_SIZE_CONTRACTS[nearest];
const shapes = TEMPLATE_SIZES.map(
(size) =>
`${size} ${TEMPLATE_SIZE_CONTRACTS[size].cpu}vCPU/${TEMPLATE_SIZE_CONTRACTS[size].memory}MiB`,
).join(", ");
throw new Error(
`cpu/memory ${cpu}/${memory} isn't a supported pair; nearest is ${nearest} ` +
`(${near.cpu} vCPU / ${near.memory} MiB); pass --size ${nearest}. ` +
`Supported pairs: ${shapes}. (Disk is set separately with --disk.)`,
);
}
if (opts.size && opts.size !== match) {
throw new Error(
`--cpu/--memory ${cpu}/${memory} match ${match}, not --size ${opts.size}.`,
);
}

return {
cpu,
memory,
size: match,
...(opts.disk != null ? { disk: opts.disk } : {}),
};
}

function parseCpuCount(value: string): number {
const n = Number.parseInt(String(value).trim(), 10);
if (!Number.isInteger(n) || n <= 0) {
Expand Down Expand Up @@ -647,13 +734,18 @@ export function register(program: Command): void {
cpu?: number;
memory?: number;
disk?: number;
size?: string;
size?: TemplateSize;
json?: boolean;
}) => {
// Resolve JSON mode once. In JSON mode NOTHING but the single JSON
// payload may reach stdout - no spinner, no status lines, no hints.
const json = isJsonMode(opts);
try {
// Validate resource flags LOCALLY first: an off-tier vCPU/memory pair
// fails fast here with an actionable message instead of becoming a
// backend shape-mismatch 500 after a Dockerfile upload.
const resources = resolveTemplateResources(opts);

let dockerfileContent: string;
try {
dockerfileContent = readFileSync(opts.dockerfile, "utf8");
Expand All @@ -674,17 +766,17 @@ export function register(program: Command): void {
const client = new MiosaClient(config);

// Only forward resource fields the user actually set. When none are
// given we omit them entirely so the server picks its own default,
// rather than the CLI pinning an unusable shape. vCPU and memory go
// as raw MiB/count integers; disk is a floor the platform grows to.
// given they are omitted so the server picks its own default, rather
// than the CLI pinning an unusable shape. vCPU/memory are a validated
// published pair; disk is a raw MiB floor the platform grows to.
const body: Record<string, unknown> = {
name: opts.name,
dockerfile: dockerfileContent,
};
if (opts.cpu != null) body["cpu_count"] = opts.cpu;
if (opts.memory != null) body["memory_mb"] = opts.memory;
if (opts.disk != null) body["disk_size_mb"] = opts.disk;
if (opts.size) body["size"] = opts.size;
if (resources.cpu != null) body["cpu_count"] = resources.cpu;
if (resources.memory != null) body["memory_mb"] = resources.memory;
if (resources.disk != null) body["disk_size_mb"] = resources.disk;
if (resources.size) body["size"] = resources.size;

const spinner = json ? null : spin(`Creating template ${opts.name}...`);
let tmpl: SandboxTemplate;
Expand Down
47 changes: 44 additions & 3 deletions test/commands/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,13 @@ describe("miosa templates create", () => {
mock.disableNetConnect();
setGlobalDispatcher(mock);

// memory 4gb → 4096 MiB, disk 30gb → 30720 MiB, cpu as a raw integer.
// 4 vCPU / 8 GB is the published `medium` pair; memory 8gb → 8192 MiB,
// disk 30gb → 30720 MiB, cpu as a raw integer.
const expectedBody = JSON.stringify({
name: "cpu-test",
dockerfile: "FROM alpine\n",
cpu_count: 4,
memory_mb: 4096,
memory_mb: 8192,
disk_size_mb: 30720,
size: "medium",
});
Expand Down Expand Up @@ -168,7 +169,7 @@ describe("miosa templates create", () => {
"--cpu",
"4",
"--memory",
"4gb",
"8gb",
"--disk",
"30gb",
"--size",
Expand Down Expand Up @@ -229,4 +230,44 @@ describe("miosa templates create", () => {

expect(mock.pendingInterceptors()).toEqual([]);
});

it("rejects an off-tier cpu/memory pair before calling the API", async () => {
const dockerfile = writeDockerfile("FROM alpine\n");
const mock = new MockAgent();
mock.disableNetConnect();
setGlobalDispatcher(mock);
// No interceptor is registered on purpose: the CLI must fail locally and
// never POST a shape the platform would reject with a 500.

const output: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
output.push(args.map(String).join(" "));
});

// 4 vCPU / 4 GB is not a published pair (medium is 4 vCPU / 8 GB).
await program().parseAsync([
"node",
"miosa",
"templates",
"create",
"--name",
"off-tier",
"--dockerfile",
dockerfile,
"--cpu",
"4",
"--memory",
"4gb",
"--json",
]);

const payload = JSON.parse(output.join("\n")) as {
ok: boolean;
error: { message: string };
};
expect(payload.ok).toBe(false);
// Naming the message proves it failed at local validation, not the network.
expect(payload.error.message).toContain("isn't a supported pair");
expect(payload.error.message).toContain("medium");
});
});
Loading