From 9550737f79a82148c8818e5e291b6e4635165e07 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. One of the biggest TODOs here is proper error rendering. We hope to improve that overtime, and pinpoint to the user in the wrangler.toml what went wrong. --- .changeset/angry-apricots-swim.md | 5 + packages/wrangler/package.json | 2 + .../src/__tests__/cloudchamber/apply.test.ts | 594 ++++++++++++++++++ .../src/__tests__/configuration.test.ts | 1 + .../src/__tests__/helpers/mock-console.ts | 37 +- .../src/__tests__/type-generation.test.ts | 1 + packages/wrangler/src/cloudchamber/apply.ts | 565 +++++++++++++++++ .../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 | 69 ++ pnpm-lock.yaml | 17 + 13 files changed, 1335 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 dac8bf38a2d8..5310dc90d156 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -76,6 +76,7 @@ "blake3-wasm": "^2.1.5", "chokidar": "^4.0.1", "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..b7338380ebd6 --- /dev/null +++ b/packages/wrangler/src/__tests__/cloudchamber/apply.test.ts @@ -0,0 +1,594 @@ +import * as fs from "node:fs"; +import * as TOML from "@iarna/toml"; +import { http, HttpResponse } from "msw"; +import patchConsole from "patch-console"; +import { SchedulingPolicy, SecretAccessType } from "../../cloudchamber/client"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { mockCLIOutput } 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"; +import type { + Application, + CreateApplicationRequest, + ModifyApplicationRequestBody, +} from "../../cloudchamber/client"; +import type { ContainerApp } from "../../config/environment"; + +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()) as ModifyApplicationRequestBody; + 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); + } + console.log(json); + expect((json as CreateApplicationRequest).name).toBeUndefined(); + 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"); + /* eslint-disable */ + 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(`""`); + /* eslint-enable */ + }); + + 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"); + /* eslint-disable */ + 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(`""`); + /* eslint-enable */ + }); + + 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"); + /* eslint-disable */ + 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(`""`); + /* eslint-enable */ + }); + + 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"); + /* eslint-disable */ + 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(`""`); + /* eslint-enable */ + }); + + 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"); + /* eslint-disable */ + 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(`""`); + /* eslint-enable */ + }); + + 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"); + /* eslint-disable */ + 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(`""`); + /* eslint-enable */ + }); +}); diff --git a/packages/wrangler/src/__tests__/configuration.test.ts b/packages/wrangler/src/__tests__/configuration.test.ts index 6979789b260c..ff2fd87139c3 100644 --- a/packages/wrangler/src/__tests__/configuration.test.ts +++ b/packages/wrangler/src/__tests__/configuration.test.ts @@ -72,6 +72,7 @@ describe("normalizeAndValidateConfig()", () => { upstream_protocol: "http", host: undefined, }, + container_app: [], cloudchamber: {}, durable_objects: { bindings: [], diff --git a/packages/wrangler/src/__tests__/helpers/mock-console.ts b/packages/wrangler/src/__tests__/helpers/mock-console.ts index 3e559f5c0dab..8898a901f90c 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,31 @@ 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(() => true); + errSpy = vi + .spyOn(streams.stderr, "write") + .mockImplementationOnce(() => true); + }); + + afterEach(() => { + outSpy.mockRestore(); + errSpy.mockRestore(); + }); + + return process; +} diff --git a/packages/wrangler/src/__tests__/type-generation.test.ts b/packages/wrangler/src/__tests__/type-generation.test.ts index 94665fa07c26..e79a8b6f8afd 100644 --- a/packages/wrangler/src/__tests__/type-generation.test.ts +++ b/packages/wrangler/src/__tests__/type-generation.test.ts @@ -165,6 +165,7 @@ const bindingsConfigMock: Omit< ], }, workflows: [], + container_app: [], r2_buckets: [ { binding: "R2_BUCKET_BINDING", diff --git a/packages/wrangler/src/cloudchamber/apply.ts b/packages/wrangler/src/cloudchamber/apply.ts new file mode 100644 index 000000000000..9204706f989c --- /dev/null +++ b/packages/wrangler/src/cloudchamber/apply.ts @@ -0,0 +1,565 @@ +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 from "@iarna/toml"; +import Diff from "diff"; +import { + ApiError, + ApplicationsService, + DeploymentMutationError, + SchedulingPolicy, +} from "./client"; +import { promiseSpinner } from "./common"; +import { wrap } from "./helpers/wrap"; +import type { Config } from "../config"; +import type { ContainerApp } from "../config/environment"; +import type { + CommonYargsArgvJSON, + StrictYargsOptionsToInterfaceJSON, +} from "../yargs-types"; +import type { + Application, + ApplicationID, + ApplicationName, + CreateApplicationRequest, + ModifyApplicationRequestBody, +} from "./client"; +import type { JsonMap } from "@iarna/toml"; + +export function applyCommandOptionalYargs(yargs: CommonYargsArgvJSON) { + return yargs.option("skip-defaults", { + requiresArg: true, + type: "boolean", + demandOption: false, + describe: "Skips recommended defaults added by apply", + }); +} + +function createApplicationToModifyApplication( + req: CreateApplicationRequest +): ModifyApplicationRequestBody { + return { + configuration: req.configuration, + instances: req.instances, + constraints: req.constraints, + affinities: req.affinities, + scheduling_policy: req.scheduling_policy, + }; +} + +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, + skipDefaults = false +): CreateApplicationRequest { + return { + ...containerApp, + scheduling_policy: + containerApp.scheduling_policy ?? SchedulingPolicy.REGIONAL, + constraints: { + ...(containerApp.constraints ?? (skipDefaults ? { tier: 1 } : undefined)), + 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"; + } + + break; + 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 (const [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/hello-world:1.0", + }, + 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, + args.skipDefaults + ); + 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: createApplicationToModifyApplication(application), + 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 6cc0bfde2707..2dc5f2ebeb94 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 eb85f4e12e83..e7c7a8911e17 100644 --- a/packages/wrangler/src/config/environment.ts +++ b/packages/wrangler/src/config/environment.ts @@ -1,3 +1,4 @@ +import type { 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 7437cb9eda08..e3cc430d0b9f 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -31,6 +31,10 @@ import { validateTypedArray, } from "./validation-helpers"; import { configFileName, formatConfigSnippet, friendlyBindingNames } from "."; +import type { + CreateApplicationRequest, + UserDeploymentConfiguration, +} from "../cloudchamber/client"; import type { CfWorkerInit } from "../deployment-bundle/worker"; import type { Config, DevConfig, RawConfig, RawDevConfig } from "./config"; import type { @@ -1328,6 +1332,16 @@ function normalizeAndValidateEnvironment( validateCloudchamberConfig, {} ), + container_app: notInheritable( + diagnostics, + topLevelEnv, + rawConfig, + rawEnv, + envName, + "container_app", + validateContainerAppConfig, + [] + ), send_email: notInheritable( diagnostics, topLevelEnv, @@ -2362,6 +2376,61 @@ 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 85e535c29c00..fbbbcd755e37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1708,6 +1708,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 @@ -1791,6 +1794,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 @@ -3603,6 +3609,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==} @@ -4798,6 +4807,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'} @@ -10391,6 +10404,8 @@ snapshots: '@types/degit@2.8.6': {} + '@types/diff@6.0.0': {} + '@types/dns2@2.0.3': dependencies: '@types/node': 18.19.59 @@ -11746,6 +11761,8 @@ snapshots: devtools-protocol@0.0.1182435: {} + diff@1.4.0: {} + diff@4.0.2: {} dir-glob@3.0.1: