From 21abdd5219902c914e7af61c7fdc0d2ed253cdad Mon Sep 17 00:00:00 2001 From: Gabi Villalonga Simon Date: Tue, 5 Nov 2024 02:24:30 +0000 Subject: [PATCH] cloudchamber: Start `cloudchamber apply`, the new command to deploy container app changes This command is able to take the [[container-app]] configurations, and deploy them to Cloudchamber. To render the differences, we are introducing a new dependency with "diff". This was already included in the pnpm lock, however we could consider not rolling out a new dependency into wrangler unless absolutely necessary. The command is designed to be CI friendly. In the tests there is some example command renders from different kind of configurations. --- .changeset/angry-apricots-swim.md | 5 + packages/wrangler/package.json | 2 + .../src/__tests__/cloudchamber/apply.test.ts | 576 ++++++++++++++++++ .../src/__tests__/helpers/mock-console.ts | 35 +- packages/wrangler/src/cloudchamber/apply.ts | 533 ++++++++++++++++ .../models/ModifyApplicationRequestBody.ts | 18 +- packages/wrangler/src/cloudchamber/index.ts | 7 + packages/wrangler/src/config/config.ts | 1 + packages/wrangler/src/config/environment.ts | 23 + packages/wrangler/src/config/validation.ts | 67 ++ pnpm-lock.yaml | 23 + 11 files changed, 1285 insertions(+), 5 deletions(-) create mode 100644 .changeset/angry-apricots-swim.md create mode 100644 packages/wrangler/src/__tests__/cloudchamber/apply.test.ts create mode 100644 packages/wrangler/src/cloudchamber/apply.ts diff --git a/.changeset/angry-apricots-swim.md b/.changeset/angry-apricots-swim.md new file mode 100644 index 000000000000..f641f7b8a6c2 --- /dev/null +++ b/.changeset/angry-apricots-swim.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +introduce a new cloudchamber command `apply`, which will be used by customers to deploy container-apps diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 16a99b8f7794..5297ea9fa434 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -76,6 +76,7 @@ "blake3-wasm": "^2.1.5", "chokidar": "^3.5.3", "date-fns": "^4.1.0", + "diff": "^1.1.1", "esbuild": "0.17.19", "itty-time": "^1.0.6", "miniflare": "workspace:*", @@ -104,6 +105,7 @@ "@sentry/utils": "^7.86.0", "@types/body-parser": "^1.19.2", "@types/command-exists": "^1.2.0", + "@types/diff": "^6.0.0", "@types/express": "^4.17.13", "@types/glob-to-regexp": "^0.4.1", "@types/is-ci": "^3.0.0", diff --git a/packages/wrangler/src/__tests__/cloudchamber/apply.test.ts b/packages/wrangler/src/__tests__/cloudchamber/apply.test.ts new file mode 100644 index 000000000000..6d3c20ffc1f8 --- /dev/null +++ b/packages/wrangler/src/__tests__/cloudchamber/apply.test.ts @@ -0,0 +1,576 @@ +import * as fs from "node:fs"; +import * as TOML from "@iarna/toml"; +import { http, HttpResponse } from "msw"; +import patchConsole from "patch-console"; +import { completion } from "yargs"; +import { + Application, + SchedulingPolicy, + SecretAccessType, +} from "../../cloudchamber/client"; +import { ContainerApp } from "../../config/environment"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { mockCLIOutput, mockConsoleMethods } from "../helpers/mock-console"; +import { useMockIsTTY } from "../helpers/mock-istty"; +import { msw } from "../helpers/msw"; +import { runInTempDir } from "../helpers/run-in-tmp"; +import { runWrangler } from "../helpers/run-wrangler"; +import { mockAccount } from "./utils"; + +function writeAppConfiguration(...app: ContainerApp[]) { + fs.writeFileSync( + "./wrangler.toml", + TOML.stringify({ + name: "my-container", + container_app: app, + }), + + "utf-8" + ); +} + +function mockGetApplications(applications: Application[]) { + msw.use( + http.get( + "*/applications", + async () => { + return HttpResponse.json(applications); + }, + { once: true } + ) + ); +} + +function mockCreateApplication(expected?: Application) { + msw.use( + http.post( + "*/applications", + async ({ request }) => { + const json = await request.json(); + if (expected !== undefined) expect(json).toEqual(expected); + return HttpResponse.json(json); + }, + { once: true } + ) + ); +} + +function mockModifyApplication(expected?: Application) { + msw.use( + http.patch( + "*/applications/:id", + async ({ request }) => { + const json = await request.json(); + if (expected !== undefined) expect(json).toEqual(expected); + return HttpResponse.json(json); + }, + { once: true } + ) + ); +} + +describe("cloudchamber apply", () => { + const { setIsTTY } = useMockIsTTY(); + const std = mockCLIOutput(); + + mockAccountId(); + mockApiToken(); + beforeEach(mockAccount); + runInTempDir(); + afterEach(() => { + patchConsole(() => {}); + msw.resetHandlers(); + }); + + test("can apply a simple application", async () => { + setIsTTY(false); + writeAppConfiguration({ + name: "my-container-app", + instances: 3, + configuration: { + image: "./Dockerfile", + }, + }); + mockGetApplications([]); + mockCreateApplication(); + await runWrangler("cloudchamber apply --json"); + expect(std.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ NEW my-container-app + │ + │ [[container_app]] + │ name = \\"my-container-app\\" + │ instances = 3 + │ scheduling_policy = \\"regional\\" + │ + │ [container_app.configuration] + │ image = \\"./Dockerfile\\" + │ + │ [container_app.constraints] + │ tier = 1 + │ + ├ Do you want to apply these changes? + │ yes + │ + │ + │  SUCCESS  Created application my-container-app + │ + ╰ Applied changes + + " + `); + expect(std.stderr).toMatchInlineSnapshot(`""`); + }); + + test("can apply a simple existing application", async () => { + setIsTTY(false); + writeAppConfiguration({ + name: "my-container-app", + instances: 4, + configuration: { + image: "./Dockerfile", + }, + }); + mockGetApplications([ + { + id: "abc", + name: "my-container-app", + instances: 3, + created_at: new Date().toString(), + account_id: "1", + scheduling_policy: SchedulingPolicy.REGIONAL, + configuration: { + image: "./Dockerfile", + }, + constraints: { + tier: 1, + }, + }, + ]); + mockModifyApplication(); + await runWrangler("cloudchamber apply --json"); + expect(std.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ EDIT my-container-app + │ + │ [[container_app]] + │ - instances = 3 + │ + instances = 4 + │ name = \\"my-container-app\\" + │ + ├ Do you want to apply these changes? + │ yes + │ + │ + │  SUCCESS  Modified application my-container-app + │ + ╰ Applied changes + + " + `); + expect(std.stderr).toMatchInlineSnapshot(`""`); + }); + + test("can apply a simple existing application and create other", async () => { + setIsTTY(false); + writeAppConfiguration( + { + name: "my-container-app", + instances: 4, + configuration: { + image: "./Dockerfile", + }, + }, + { + name: "my-container-app-2", + instances: 1, + configuration: { + image: "other-app/Dockerfile", + }, + } + ); + mockGetApplications([ + { + id: "abc", + name: "my-container-app", + instances: 3, + created_at: new Date().toString(), + account_id: "1", + scheduling_policy: SchedulingPolicy.REGIONAL, + configuration: { + image: "./Dockerfile", + }, + constraints: { + tier: 1, + }, + }, + ]); + mockModifyApplication(); + mockCreateApplication(); + await runWrangler("cloudchamber apply --json"); + expect(std.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ EDIT my-container-app + │ + │ [[container_app]] + │ - instances = 3 + │ + instances = 4 + │ name = \\"my-container-app\\" + │ + ├ NEW my-container-app-2 + │ + │ [[container_app]] + │ name = \\"my-container-app-2\\" + │ instances = 1 + │ scheduling_policy = \\"regional\\" + │ + │ [container_app.configuration] + │ image = \\"other-app/Dockerfile\\" + │ + │ [container_app.constraints] + │ tier = 1 + │ + ├ Do you want to apply these changes? + │ yes + │ + │ + │  SUCCESS  Modified application my-container-app + │ + │ + │  SUCCESS  Created application my-container-app-2 + │ + ╰ Applied changes + + " + `); + expect(std.stderr).toMatchInlineSnapshot(`""`); + }); + + test("can apply a simple existing application (labels)", async () => { + setIsTTY(false); + writeAppConfiguration({ + name: "my-container-app", + instances: 4, + configuration: { + image: "./Dockerfile", + labels: [ + { + name: "name", + value: "value", + }, + { + name: "name-1", + value: "value-1", + }, + { + name: "name-2", + value: "value-2", + }, + ], + secrets: [ + { + name: "MY_SECRET", + type: SecretAccessType.ENV, + secret: "SECRET_NAME", + }, + { + name: "MY_SECRET_2", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_2", + }, + ], + }, + }); + mockGetApplications([ + { + id: "abc", + name: "my-container-app", + instances: 3, + created_at: new Date().toString(), + account_id: "1", + scheduling_policy: SchedulingPolicy.REGIONAL, + configuration: { + image: "./Dockerfile", + labels: [ + { + name: "name", + value: "value", + }, + { + name: "name-2", + value: "value-2", + }, + ], + secrets: [ + { + name: "MY_SECRET", + type: SecretAccessType.ENV, + secret: "SECRET_NAME", + }, + { + name: "MY_SECRET_1", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_1", + }, + { + name: "MY_SECRET_2", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_2", + }, + ], + }, + constraints: { + tier: 1, + }, + }, + ]); + mockModifyApplication(); + await runWrangler("cloudchamber apply --json"); + expect(std.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ EDIT my-container-app + │ + │ [[container_app]] + │ - instances = 3 + │ + instances = 4 + │ name = \\"my-container-app\\" + │ + │ [[container_app.configuration.labels]] + │ + name = \\"name-1\\" + │ + value = \\"value-1\\" + │ + │ + [[container_app.configuration.labels]] + │ name = \\"name-2\\" + │ + │ [[container_app.configuration.secrets]] + │ - name = \\"MY_SECRET_1\\" + │ - secret = \\"SECRET_NAME_1\\" + │ - type = \\"env\\" + │ + │ - [[container_app.configuration.secrets]] + │ name = \\"MY_SECRET_2\\" + │ + ├ Do you want to apply these changes? + │ yes + │ + │ + │  SUCCESS  Modified application my-container-app + │ + ╰ Applied changes + + " + `); + expect(std.stderr).toMatchInlineSnapshot(`""`); + }); + + test("can apply an application, and there is no changes", async () => { + setIsTTY(false); + writeAppConfiguration({ + name: "my-container-app", + instances: 3, + configuration: { + image: "./Dockerfile", + labels: [ + { + name: "name", + value: "value", + }, + { + name: "name-2", + value: "value-2", + }, + ], + secrets: [ + { + name: "MY_SECRET", + type: SecretAccessType.ENV, + secret: "SECRET_NAME", + }, + { + name: "MY_SECRET_1", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_1", + }, + { + name: "MY_SECRET_2", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_2", + }, + ], + }, + }); + mockGetApplications([ + { + id: "abc", + name: "my-container-app", + instances: 3, + created_at: new Date().toString(), + account_id: "1", + scheduling_policy: SchedulingPolicy.REGIONAL, + configuration: { + image: "./Dockerfile", + labels: [ + { + name: "name", + value: "value", + }, + { + name: "name-2", + value: "value-2", + }, + ], + secrets: [ + { + name: "MY_SECRET", + type: SecretAccessType.ENV, + secret: "SECRET_NAME", + }, + { + name: "MY_SECRET_1", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_1", + }, + { + name: "MY_SECRET_2", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_2", + }, + ], + }, + + constraints: { + tier: 1, + }, + }, + ]); + mockModifyApplication(); + await runWrangler("cloudchamber apply --json"); + expect(std.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ no changes my-container-app + │ + ╰ No changes to be made + + " + `); + expect(std.stderr).toMatchInlineSnapshot(`""`); + }); + + test("can apply an application, and there is no changes (two applications)", async () => { + setIsTTY(false); + const app = { + name: "my-container-app", + instances: 3, + configuration: { + image: "./Dockerfile", + labels: [ + { + name: "name", + value: "value", + }, + { + name: "name-2", + value: "value-2", + }, + ], + secrets: [ + { + name: "MY_SECRET", + type: SecretAccessType.ENV, + secret: "SECRET_NAME", + }, + { + name: "MY_SECRET_1", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_1", + }, + { + name: "MY_SECRET_2", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_2", + }, + ], + }, + }; + writeAppConfiguration(app, { ...app, name: "my-container-app-2" }); + + const completeApp = { + id: "abc", + name: "my-container-app", + instances: 3, + created_at: new Date().toString(), + account_id: "1", + scheduling_policy: SchedulingPolicy.REGIONAL, + configuration: { + image: "./Dockerfile", + labels: [ + { + name: "name", + value: "value", + }, + { + name: "name-2", + value: "value-2", + }, + ], + secrets: [ + { + name: "MY_SECRET", + type: SecretAccessType.ENV, + secret: "SECRET_NAME", + }, + { + name: "MY_SECRET_1", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_1", + }, + { + name: "MY_SECRET_2", + type: SecretAccessType.ENV, + secret: "SECRET_NAME_2", + }, + ], + }, + + constraints: { + tier: 1, + }, + }; + + mockGetApplications([ + completeApp, + { ...completeApp, name: "my-container-app-2", id: "abc2" }, + ]); + mockModifyApplication(); + await runWrangler("cloudchamber apply --json"); + expect(std.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ no changes my-container-app + │ + ├ no changes my-container-app-2 + │ + ╰ No changes to be made + + " + `); + expect(std.stderr).toMatchInlineSnapshot(`""`); + }); +}); diff --git a/packages/wrangler/src/__tests__/helpers/mock-console.ts b/packages/wrangler/src/__tests__/helpers/mock-console.ts index 3e559f5c0dab..5139e34f9f4a 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-console.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-console.ts @@ -1,4 +1,5 @@ import * as util from "node:util"; +import * as streams from "@cloudflare/cli/streams"; import { afterEach, beforeEach, vi } from "vitest"; import { logger } from "../../logger"; import { normalizeString } from "./normalize"; @@ -33,14 +34,14 @@ const std = { }, }; -function normalizeOutput(spy: MockInstance): string { - return normalizeString(captureCalls(spy)); +function normalizeOutput(spy: MockInstance, join = "\n"): string { + return normalizeString(captureCalls(spy, join)); } -function captureCalls(spy: MockInstance): string { +function captureCalls(spy: MockInstance, join = "\n"): string { return spy.mock.calls .map((args: unknown[]) => util.format("%s", ...args)) - .join("\n"); + .join(join); } export function mockConsoleMethods() { @@ -61,3 +62,29 @@ export function mockConsoleMethods() { }); return std; } + +let outSpy: MockInstance, errSpy: MockInstance; + +const process = { + get stdout() { + return normalizeOutput(outSpy, ""); + }, + + get stderr() { + return normalizeOutput(errSpy, ""); + }, +}; + +export function mockCLIOutput() { + beforeEach(() => { + outSpy = vi.spyOn(streams.stdout, "write").mockImplementation(() => {}); + errSpy = vi.spyOn(streams.stderr, "write").mockImplementationOnce(() => {}); + }); + + afterEach(() => { + outSpy.mockRestore(); + errSpy.mockRestore(); + }); + + return process; +} diff --git a/packages/wrangler/src/cloudchamber/apply.ts b/packages/wrangler/src/cloudchamber/apply.ts new file mode 100644 index 000000000000..bae71caa04a9 --- /dev/null +++ b/packages/wrangler/src/cloudchamber/apply.ts @@ -0,0 +1,533 @@ +import { + cancel, + crash, + endSection, + log, + logRaw, + shapes, + startSection, + success, + updateStatus, +} from "@cloudflare/cli"; +import { processArgument } from "@cloudflare/cli/args"; +import { bold, brandColor, dim, green, red } from "@cloudflare/cli/colors"; +import TOML, { JsonMap } from "@iarna/toml"; +import Diff from "diff"; +import { Config } from "../config"; +import { ContainerApp } from "../config/environment"; +import { + CommonYargsArgvJSON, + StrictYargsOptionsToInterfaceJSON, +} from "../yargs-types"; +import { + ApiError, + Application, + ApplicationID, + ApplicationName, + ApplicationsService, + CreateApplicationRequest, + DeploymentMutationError, + ModifyApplicationRequestBody, + SchedulingPolicy, +} from "./client"; +import { promiseSpinner } from "./common"; +import { wrap } from "./helpers/wrap"; + +export function applyCommandOptionalYargs(yargs: CommonYargsArgvJSON) { + return yargs; +} + +function applicationToCreateApplication( + application: Application +): CreateApplicationRequest { + return { + configuration: application.configuration, + constraints: application.constraints, + name: application.name, + scheduling_policy: application.scheduling_policy, + affinities: application.affinities, + instances: application.instances, + jobs: application.jobs ? true : undefined, + }; +} + +function containerAppToCreateApplication( + containerApp: ContainerApp +): CreateApplicationRequest { + return { + ...containerApp, + scheduling_policy: + containerApp.scheduling_policy ?? SchedulingPolicy.REGIONAL, + constraints: { + ...(containerApp.constraints ?? { tier: 1 }), + cities: containerApp.constraints?.cities?.map((city) => + city.toLowerCase() + ), + regions: containerApp.constraints?.regions?.map((region) => + region.toUpperCase() + ), + }, + }; +} + +function isNumber(c: string | number) { + if (typeof c === "number") return true; + const code = c.charCodeAt(0); + const zero = "0".charCodeAt(0); + const nine = "9".charCodeAt(0); + return code >= zero && code <= nine; +} + +/** + * createLine takes a string and goes through each character, rendering possibly syntax highlighting. + * Useful to render TOML files. + */ +function createLine(el: string, startWith = ""): string { + let line = startWith; + let lastAdded = 0; + const addToLine = (i: number, color = (s: string) => s) => { + line += color(el.slice(lastAdded, i)); + lastAdded = i; + }; + + const state = { + render: "left" as "quotes" | "number" | "left" | "right" | "section", + }; + for (let i = 0; i < el.length; i++) { + const current = el[i]; + const peek = i + 1 < el.length ? el[i + 1] : null; + const prev = i === 0 ? null : el[i - 1]; + + switch (state.render) { + case "left": + if (current === "=") { + state.render = "right"; + } + + break; + case "right": + if (current === '"') { + addToLine(i); + state.render = "quotes"; + break; + } + + if (isNumber(current)) { + addToLine(i); + state.render = "number"; + break; + } + + if (current === "[" && peek === "[") { + state.render = "section"; + } + case "quotes": + if (current === '"') { + addToLine(i + 1, brandColor); + state.render = "right"; + } + + break; + case "number": + if (!isNumber(el)) { + addToLine(i, red); + state.render = "right"; + } + + break; + case "section": + if (current === "]" && prev === "]") { + addToLine(i + 1); + state.render = "right"; + } + } + } + + switch (state.render) { + case "left": + addToLine(el.length); + break; + case "right": + addToLine(el.length); + break; + case "quotes": + addToLine(el.length, brandColor); + break; + case "number": + addToLine(el.length, red); + break; + case "section": + // might be unreachable + addToLine(el.length, bold); + break; + } + + return line; +} + +/** + * printLine takes a line and prints it by using createLine and use printFunc + */ +function printLine(el: string, startWith = "", printFunc = log) { + printFunc(createLine(el, startWith)); +} + +/** + * Removes from the object every undefined property + */ +function stripUndefined>(r: T): T { + for (const k in r) { + if (r[k] === undefined) { + delete r[k]; + } + } + + return r; +} + +/** + * Take an object and sort its keys in alphabetical order. + */ +function sortObjectKeys(unordered: Record) { + if (Array.isArray(unordered)) { + return unordered; + } + + return Object.keys(unordered) + .sort() + .reduce( + (obj, key) => { + obj[key] = unordered[key]; + return obj; + }, + {} as Record + ); +} + +/** + * Take an object and sort its keys in alphabetical order recursively. + * Useful to normalize objects so they can be compared when rendered. + * It will copy the object and not mutate it. + */ +function sortObjectRecursive>( + object: Record | Record[] +): T { + if (typeof object !== "object") { + return object; + } + + if (Array.isArray(object)) { + return object.map((obj) => sortObjectRecursive(obj)) as T; + } + + const objectCopy: Record = { ...object }; + for (let [key, value] of Object.entries(object)) { + if (typeof value === "object") { + if (value === null) continue; + objectCopy[key] = sortObjectRecursive( + value as Record + ) as unknown; + } + } + + return sortObjectKeys(objectCopy) as T; +} + +/** + * applyCommand is able to take the wrangler.toml file and render the changes that it + * detects. + */ +export async function applyCommand( + args: StrictYargsOptionsToInterfaceJSON, + config: Config +) { + startSection( + "Deploy a container application", + "deploy changes to your application" + ); + + if (config.container_app.length === 0) { + endSection( + "You don't have any container applications defined in your wrangler.toml", + "You can set the following configuration in your wrangler.toml" + ); + const configuration = { + configuration: { + image: "docker.io/cloudflare/containers:getting-started", + }, + instances: 2, + name: config.name ?? "my-containers-application", + }; + const endConfig: JsonMap = + args.env !== undefined + ? { + env: { [args.env]: { container_app: [configuration] } }, + } + : { container_app: [configuration] }; + TOML.stringify(endConfig) + .split("\n") + .map((el) => el.trim()) + .forEach((el) => { + printLine(el, " ", logRaw); + }); + return; + } + + const applications = await promiseSpinner( + ApplicationsService.listApplications(), + { json: args.json, message: "Loading applications" } + ); + const applicationByNames: Record = {}; + // TODO: this is not correct right now as there can be multiple applications + // with the same name. + for (const application of applications) { + applicationByNames[application.name] = application; + } + + const actions: ( + | { action: "create"; application: CreateApplicationRequest } + | { + action: "modify"; + application: ModifyApplicationRequestBody; + id: ApplicationID; + name: ApplicationName; + } + )[] = []; + + log(dim("Container application changes\n")); + + for (const appConfigNoDefaults of config.container_app) { + const appConfig = containerAppToCreateApplication(appConfigNoDefaults); + const application = applicationByNames[appConfig.name]; + if (application !== undefined && application !== null) { + // we need to sort the objects (by key) because the diff algorithm works with + // lines + const prevApp = sortObjectRecursive( + stripUndefined(applicationToCreateApplication(application)) + ); + + const prev = TOML.stringify({ container_app: [prevApp] }); + const now = TOML.stringify({ + container_app: [ + sortObjectRecursive(appConfig), + ], + }); + const results = Diff.diffLines(prev, now); + + const changes = results.find((l) => l.added || l.removed) !== undefined; + if (!changes) { + updateStatus(`no changes ${brandColor(application.name)}`); + continue; + } + + updateStatus( + `${brandColor.underline("EDIT")} ${application.name}`, + false + ); + + let printedLines: string[] = []; + let printedDiff = false; + // prints the lines we accumulated to bring context to the edited line + const printContext = () => { + let index = 0; + for (let i = printedLines.length - 1; i >= 0; i--) { + if (printedLines[i].trim().startsWith("[")) { + log(""); + index = i; + break; + } + } + + for (let i = index; i < printedLines.length; i++) { + log(printedLines[i]); + if (printedLines.length - i > 2) { + i = printedLines.length - 2; + printLine(dim("..."), " "); + } + } + + printedLines = []; + }; + + // go line by line and print diff results + for (const lines of results) { + const trimmedLines = lines.value + .split("\n") + .map((e) => e.trim()) + .filter((e) => e !== ""); + + for (const l of trimmedLines) { + if (lines.added) { + printContext(); + if (l.startsWith("[")) { + printLine(""); + } + + printedDiff = true; + printLine(l, green("+ ")); + } else if (lines.removed) { + printContext(); + if (l.startsWith("[")) { + printLine(""); + } + + printedDiff = true; + printLine(l, red("- ")); + } else { + // if we had printed a diff before this line, print a little bit more + // so the user has a bit more context on where the edit happens + if (printedDiff) { + let printDots = false; + if (l.startsWith("[")) { + printLine(""); + printDots = true; + } + + printedDiff = false; + printLine(l, " "); + if (printDots) printLine(dim("..."), " "); + continue; + } + + printedLines.push(createLine(l, " ")); + } + } + } + + actions.push({ + action: "modify", + application: appConfig, + id: application.id, + name: application.name, + }); + + printLine(""); + continue; + } + + // print the header of the app + updateStatus(bold.underline(green.underline("NEW")) + ` ${appConfig.name}`); + + const s = TOML.stringify({ container_app: [appConfig] }); + + // go line by line and pretty print it + s.split("\n") + .map((line) => line.trim()) + .forEach((el) => { + printLine(el, " "); + }); + + // add to the actions array to create the app later + actions.push({ + action: "create", + application: appConfig, + }); + } + + if (actions.length == 0) { + endSection("No changes to be made"); + return; + } + + const yes = await processArgument( + { confirm: args.json ? true : undefined }, + "confirm", + { + type: "confirm", + question: "Do you want to apply these changes?", + label: "", + } + ); + if (!yes) { + cancel("Not applying changes"); + return; + } + + function formatError(err: ApiError): string { + // TODO: this is bad bad. Please fix like we do in create.ts. + // On Cloudchamber API side, we have to improve as well the object validation errors, + // so we can detect them here better and pinpoint to the user what's going on. + if ( + err.body.error === DeploymentMutationError.VALIDATE_INPUT && + err.body.details !== undefined + ) { + let message = ""; + for (const key in err.body.details) { + message += ` ${brandColor(key)} ${err.body.details[key]}\n`; + } + + return message; + } + + return ` ${err.body.error}`; + } + + for (const action of actions) { + if (action.action === "create") { + const [_result, err] = await wrap( + promiseSpinner( + ApplicationsService.createApplication(action.application), + { json: args.json, message: `creating ${action.application.name}` } + ) + ); + if (err !== null) { + if (!(err instanceof ApiError)) { + crash(`Unexpected error creating application: ${err.message}`); + } + + if (err.status === 400) { + crash( + `Error creating application due to a misconfiguration\n${formatError(err)}` + ); + } + + crash( + `Error creating application due to an internal error (request id: ${err.body.request_id}): ${formatError(err)}` + ); + } + + success(`Created application ${brandColor(action.application.name)}`, { + shape: shapes.bar, + }); + printLine(""); + continue; + } + + if (action.action === "modify") { + const [_result, err] = await wrap( + promiseSpinner( + ApplicationsService.modifyApplication(action.id, action.application), + { + json: args.json, + message: `modifying application ${action.name}`, + } + ) + ); + if (err !== null) { + if (!(err instanceof ApiError)) { + crash( + `Unexpected error modifying application ${action.name}: ${err.message}` + ); + } + + if (err.status === 400) { + crash( + `Error modifying application ${action.name} due to a ${brandColor.underline("misconfiguration")}\n\n\t${formatError(err)}` + ); + } + + crash( + `Error modifying application ${action.name} due to an internal error (request id: ${err.body.request_id}): ${formatError(err)}` + ); + } + + success(`Modified application ${brandColor(action.name)}`, { + shape: shapes.bar, + }); + printLine(""); + continue; + } + } + + endSection("Applied changes"); +} diff --git a/packages/wrangler/src/cloudchamber/client/models/ModifyApplicationRequestBody.ts b/packages/wrangler/src/cloudchamber/client/models/ModifyApplicationRequestBody.ts index 78741a9c7213..139ff330dd0a 100644 --- a/packages/wrangler/src/cloudchamber/client/models/ModifyApplicationRequestBody.ts +++ b/packages/wrangler/src/cloudchamber/client/models/ModifyApplicationRequestBody.ts @@ -2,6 +2,11 @@ /* tslint:disable */ /* eslint-disable */ +import type { ApplicationAffinities } from "./ApplicationAffinities"; +import type { ApplicationConstraints } from "./ApplicationConstraints"; +import type { SchedulingPolicy } from "./SchedulingPolicy"; +import type { UserDeploymentConfiguration } from "./UserDeploymentConfiguration"; + /** * Request body for modifying an application */ @@ -9,5 +14,16 @@ export type ModifyApplicationRequestBody = { /** * Number of deployments to maintain within this applicaiton. This can be used to scale the appliation up/down. */ - instances: number; + instances?: number; + affinities?: ApplicationAffinities; + scheduling_policy?: SchedulingPolicy; + constraints?: ApplicationConstraints; + /** + * The deployment configuration of all deployments created by this application. + * Right now, if you modify the application configuration, only new deployments + * created will have the new configuration. You can delete old deployments to + * release new instances. + * + */ + configuration?: UserDeploymentConfiguration; }; diff --git a/packages/wrangler/src/cloudchamber/index.ts b/packages/wrangler/src/cloudchamber/index.ts index 0a6d7fdeef24..ab7084edeaf7 100644 --- a/packages/wrangler/src/cloudchamber/index.ts +++ b/packages/wrangler/src/cloudchamber/index.ts @@ -1,3 +1,4 @@ +import { applyCommand, applyCommandOptionalYargs } from "./apply"; import { handleFailure } from "./common"; import { createCommand, createCommandOptionalYargs } from "./create"; import { curlCommand, yargsCurl } from "./curl"; @@ -61,5 +62,11 @@ export const cloudchamber = ( "send a request to an arbitrary cloudchamber endpoint", (args) => yargsCurl(args), (args) => handleFailure(curlCommand)(args) + ) + .command( + "apply", + "apply the changes in the container applications to deploy", + (args) => applyCommandOptionalYargs(args), + (args) => handleFailure(applyCommand)(args) ); }; diff --git a/packages/wrangler/src/config/config.ts b/packages/wrangler/src/config/config.ts index fe991c1c49d2..035c3d9ca791 100644 --- a/packages/wrangler/src/config/config.ts +++ b/packages/wrangler/src/config/config.ts @@ -382,6 +382,7 @@ export const defaultWranglerConfig: Config = { /** NON-INHERITABLE ENVIRONMENT FIELDS **/ define: {}, cloudchamber: {}, + container_app: [], send_email: [], browser: undefined, unsafe: { diff --git a/packages/wrangler/src/config/environment.ts b/packages/wrangler/src/config/environment.ts index d5663db5ad22..d10a76b1cfc8 100644 --- a/packages/wrangler/src/config/environment.ts +++ b/packages/wrangler/src/config/environment.ts @@ -1,3 +1,4 @@ +import { CreateApplicationRequest } from "../cloudchamber/client"; import type { Json } from "miniflare"; /** @@ -39,6 +40,17 @@ export type CloudchamberConfig = { ipv4?: boolean; }; +type Omit = Pick>; +type PartialBy = Omit & Partial>; + +/** + * Configuration for a container application + */ +export type ContainerApp = PartialBy< + CreateApplicationRequest, + "scheduling_policy" +>; + /** * Configuration in wrangler for Durable Object Migrations */ @@ -450,6 +462,17 @@ export interface EnvironmentNonInheritable { */ cloudchamber: CloudchamberConfig; + /** + * Container app configuration + * + * NOTE: This field is not automatically inherited from the top level environment, + * and so must be specified in every named environment. + * + * @default `{}` + * @nonInheritable + */ + container_app: ContainerApp[]; + /** * These specify any Workers KV Namespaces you want to * access from inside your Worker. diff --git a/packages/wrangler/src/config/validation.ts b/packages/wrangler/src/config/validation.ts index ebd47750cbce..19a180d66c3f 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -2,6 +2,10 @@ import assert from "node:assert"; import path from "node:path"; import TOML from "@iarna/toml"; import { dedent } from "ts-dedent"; +import { + CreateApplicationRequest, + UserDeploymentConfiguration, +} from "../cloudchamber/client"; import { Diagnostics } from "./diagnostics"; import { all, @@ -1326,6 +1330,16 @@ function normalizeAndValidateEnvironment( validateCloudchamberConfig, {} ), + container_app: notInheritable( + diagnostics, + topLevelEnv, + rawConfig, + rawEnv, + envName, + "container_app", + validateContainerAppConfig, + [] + ), send_email: notInheritable( diagnostics, topLevelEnv, @@ -2358,6 +2372,59 @@ const validateBindingArray = return isValid; }; +const validateContainerAppConfig: ValidatorFn = ( + diagnostics, + _field, + value +) => { + if (!Array.isArray(value)) { + diagnostics.errors.push( + `"container_app" should be an array, but got ${JSON.stringify(value)}` + ); + return false; + } + + for (const containerApp of value) { + const containerAppOptional = + containerApp as Partial; + if (!isRequiredProperty(containerAppOptional, "instances", "number")) { + diagnostics.errors.push( + `"container_app.instances" should be defined and an integer` + ); + } + + if (!isRequiredProperty(containerAppOptional, "name", "string")) { + diagnostics.errors.push( + `"container_app.name" should be defined and a string` + ); + } + + if (!("configuration" in containerAppOptional)) { + diagnostics.errors.push( + `"container_app.configuration" should be defined` + ); + } else if (Array.isArray(containerAppOptional.configuration)) { + diagnostics.errors.push( + `"container_app.configuration" is defined as an array, it should be an object` + ); + } else if ( + !isRequiredProperty( + containerAppOptional.configuration as UserDeploymentConfiguration, + "image", + "string" + ) + ) { + diagnostics.errors.push( + `"container_app.configuration.image" should be defined and a string` + ); + } + } + + if (diagnostics.errors.length > 0) return false; + + return true; +}; + const validateCloudchamberConfig: ValidatorFn = (diagnostics, field, value) => { if (typeof value !== "object" || value === null || Array.isArray(value)) { diagnostics.errors.push( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 722bda5bc7f3..3a721e52fdec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,12 @@ settings: catalogs: default: + '@vitest/runner': + specifier: ~2.1.3 + version: 2.1.3 + '@vitest/snapshot': + specifier: ~2.1.3 + version: 2.1.3 '@vitest/ui': specifier: ~2.1.3 version: 2.1.3 @@ -1696,6 +1702,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + diff: + specifier: ^1.1.1 + version: 1.4.0 esbuild: specifier: 0.17.19 version: 0.17.19 @@ -1779,6 +1788,9 @@ importers: '@types/command-exists': specifier: ^1.2.0 version: 1.2.0 + '@types/diff': + specifier: ^6.0.0 + version: 6.0.0 '@types/express': specifier: ^4.17.13 version: 4.17.13 @@ -3624,6 +3636,9 @@ packages: '@types/degit@2.8.6': resolution: {integrity: sha512-y0M7sqzsnHB6cvAeTCBPrCQNQiZe8U4qdzf8uBVmOWYap5MMTN/gB2iEqrIqFiYcsyvP74GnGD5tgsHttielFw==} + '@types/diff@6.0.0': + resolution: {integrity: sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==} + '@types/dns2@2.0.3': resolution: {integrity: sha512-sO14jUYelc2DzwHcCbwp7tZsZfB2x17/zIdHCAeUBINAz2cc36iVFLqCPCB7rn73CzoyoCmpkEnh1rA8C0puPw==} @@ -4865,6 +4880,10 @@ packages: devtools-protocol@0.0.1182435: resolution: {integrity: sha512-EmlkWb62wSbQNE1gRZZsi4KZYRaF5Skpp183LhRU7+sadKR06O1dHCjZmFSEG6Kv7P6S/UYLxcY3NlYwqKM99w==} + diff@1.4.0: + resolution: {integrity: sha512-VzVc42hMZbYU9Sx/ltb7KYuQ6pqAw+cbFWVy4XKdkuEL2CFaRLGEnISPs7YdzaUGpi+CpIqvRmu7hPQ4T7EQ5w==} + engines: {node: '>=0.3.1'} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -10513,6 +10532,8 @@ snapshots: '@types/degit@2.8.6': {} + '@types/diff@6.0.0': {} + '@types/dns2@2.0.3': dependencies: '@types/node': 18.19.59 @@ -11912,6 +11933,8 @@ snapshots: devtools-protocol@0.0.1182435: {} + diff@1.4.0: {} + diff@4.0.2: {} dir-glob@3.0.1: