From 18c105baec9d3625b56531ec332517fcae1ede59 Mon Sep 17 00:00:00 2001 From: Cody Date: Mon, 9 Sep 2024 21:40:54 -0700 Subject: [PATCH] Add cloudchamber curl command (#6126) * Add cloudchamber curl command * Switch from console.log to logRaw * Cleanup some output related code Switch to using formatLabelledValues from utils. Remove some unneeded conditions in an else block. * Add extra context to json flag description --- .changeset/new-dragons-bow.md | 7 + .../src/__tests__/cloudchamber/curl.test.ts | 318 ++++++++++++++++++ packages/wrangler/src/cloudchamber/curl.ts | 166 +++++++++ packages/wrangler/src/cloudchamber/index.ts | 7 + 4 files changed, 498 insertions(+) create mode 100644 .changeset/new-dragons-bow.md create mode 100644 packages/wrangler/src/__tests__/cloudchamber/curl.test.ts create mode 100644 packages/wrangler/src/cloudchamber/curl.ts diff --git a/.changeset/new-dragons-bow.md b/.changeset/new-dragons-bow.md new file mode 100644 index 000000000000..382c0aac11eb --- /dev/null +++ b/.changeset/new-dragons-bow.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +feature: Add 'cloudchamber curl' command + +Adds a cloudchamber curl command which allows easy access to arbitrary cloudchamber API endpoints. diff --git a/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts b/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts new file mode 100644 index 000000000000..2d6325655665 --- /dev/null +++ b/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts @@ -0,0 +1,318 @@ +import { http, HttpResponse } from "msw"; +import patchConsole from "patch-console"; +import { collectCLIOutput } from "../helpers/collect-cli-output"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { MOCK_DEPLOYMENTS_COMPLEX } from "../helpers/mock-cloudchamber"; +import { 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, setWranglerConfig } from "./utils"; + +describe("cloudchamber curl", () => { + const std = collectCLIOutput(); + const helpStd = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + + mockAccountId(); + mockApiToken(); + runInTempDir(); + const baseRequestUrl: string = + "https://api.cloudflare.com/client/v4/accounts/some-account-id/cloudchamber/"; + beforeEach(mockAccount); + + afterEach(() => { + patchConsole(() => {}); + msw.resetHandlers(); + }); + + it("should help", async () => { + await runWrangler("cloudchamber curl --help"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(helpStd.out).toMatchInlineSnapshot(` + "wrangler cloudchamber curl + + send a request to an arbitrary cloudchamber endpoint + + POSITIONALS + path [string] [required] [default: \\"/\\"] + + GLOBAL FLAGS + -j, --experimental-json-config Experimental: support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean] + + OPTIONS + --json Output json. Use for consistent, machine readable output. [boolean] [default: false] + -H, --header Add headers in the form of --header : [array] + -D, --data Add a JSON body to the request [string] + -X, --method [string] [default: \\"GET\\"] + -s, --silent Only output response [boolean] + -v, --verbose Show version number [boolean] + --use-stdin, --stdin Equivalent of using --data-binary @- in curl [boolean]" + `); + }); + + it("should be able to use data flag", async () => { + setIsTTY(false); + setWranglerConfig({}); + msw.use( + http.post("*/deployments/v2", async ({ request }) => { + // verify we are hitting the expected url + expect(request.url).toEqual(baseRequestUrl + "deployments/v2"); + // and that the request has the expected content + expect(await request.text()).toMatchInlineSnapshot( + `"{\\"image\\":\\"hello:world\\",\\"location\\":\\"sfo06\\",\\"ssh_public_key_ids\\":[],\\"environment_variables\\":[{\\"name\\":\\"HELLO\\",\\"value\\":\\"WORLD\\"},{\\"name\\":\\"YOU\\",\\"value\\":\\"CONQUERED\\"}],\\"vcpu\\":3,\\"memory\\":\\"400GB\\",\\"network\\":{\\"assign_ipv4\\":\\"predefined\\"}}"` + ); + return HttpResponse.json(MOCK_DEPLOYMENTS_COMPLEX[0]); + }) + ); + + // We need to stringify this for cross-platform compatibility + const deployment = JSON.stringify({ + image: "hello:world", + location: "sfo06", + ssh_public_key_ids: [], + environment_variables: [ + { name: "HELLO", value: "WORLD" }, + { name: "YOU", value: "CONQUERED" }, + ], + vcpu: 3, + memory: "400GB", + network: { assign_ipv4: "predefined" }, + }); + + await runWrangler( + "cloudchamber curl /deployments/v2 --json -X POST -D '" + deployment + "'" + ); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "{ + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + } + " + `); + }); + + it("should set headers", async () => { + setIsTTY(false); + setWranglerConfig({}); + msw.use( + http.get("*/test", async ({ request }) => { + // verify we are hitting the expected url + expect(request.url).toEqual(baseRequestUrl + "test"); + // and that we set the expected headers + expect(request.headers.get("something")).toEqual("here"); + expect(request.headers.get("other")).toEqual("thing"); + return HttpResponse.json(`{}`); + }) + ); + await runWrangler( + "cloudchamber curl /test --json --header something:here --header other:thing" + ); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "\\"{}\\" + " + `); + }); + + it("should give response without --json flag set", async () => { + setIsTTY(false); + setWranglerConfig({}); + msw.use( + http.get("*/deployments/v2", async ({ request }) => { + // verify we are hitting the expected url + expect(request.url).toEqual(baseRequestUrl + "deployments/v2"); + // and that the request has the expected content + return HttpResponse.json(MOCK_DEPLOYMENTS_COMPLEX); + }) + ); + + await runWrangler( + "cloudchamber curl /deployments/v2 --header something:here" + ); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "├ Loading account + │ + >> Body + [ + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"2\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"1234\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"version\\": 2, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"ipv4\\": \\"1.1.1.2\\" + }, + \\"current_placement\\": { + \\"deployment_version\\": 2, + \\"status\\": { + \\"health\\": \\"running\\" + }, + \\"deployment_id\\": \\"2\\", + \\"terminate\\": false, + \\"created_at\\": \\"123\\", + \\"id\\": \\"1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"3\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"4\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"1234\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"version\\": 2, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"ipv4\\": \\"1.1.1.2\\" + }, + \\"current_placement\\": { + \\"deployment_version\\": 2, + \\"status\\": { + \\"health\\": \\"running\\" + }, + \\"deployment_id\\": \\"2\\", + \\"terminate\\": false, + \\"created_at\\": \\"123\\", + \\"id\\": \\"1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + } + ] + " + `); + }); + + it("should give a response with headers and request-id when verbose flag is set", async () => { + setIsTTY(false); + setWranglerConfig({}); + msw.use( + http.get("*/deployments/v2", async ({ request }) => { + // verify we are hitting the expected url + expect(request.url).toEqual(baseRequestUrl + "deployments/v2"); + // and that the request has the expected content + return HttpResponse.json(MOCK_DEPLOYMENTS_COMPLEX); + }) + ); + + await runWrangler( + "cloudchamber curl -v /deployments/v2 --header something:here" + ); + expect(std.err).toMatchInlineSnapshot(`""`); + const text = std.out; + // verify we have all the parts of the expected response + expect(text).toContain("Headers"); + expect(text).toContain("Body"); + expect(text).toContain("coordinator-request-id"); + }); + + it("should give a response with headers and request-id when verbose flag is set with --json", async () => { + setIsTTY(false); + setWranglerConfig({}); + msw.use( + http.get("*/deployments/v2", async ({ request }) => { + // verify we are hitting the expected url + expect(request.url).toEqual(baseRequestUrl + "deployments/v2"); + // and that the request has the expected content + return HttpResponse.json(MOCK_DEPLOYMENTS_COMPLEX); + }) + ); + + await runWrangler( + "cloudchamber curl -v --json /deployments/v2 --header something:here" + ); + expect(std.err).toMatchInlineSnapshot(`""`); + const response = JSON.parse(std.out); + expect(response.headers).toHaveProperty("coordinator-request-id"); + expect(response.headers).toHaveProperty("something"); + expect(response.request_id.length).toBeGreaterThan(0); + }); + + it("should catch and report errors", async () => { + setIsTTY(false); + setWranglerConfig({}); + await runWrangler( + "cloudchamber curl /deployments/v2 --header something:here" + ); + expect(std.err).toMatchInlineSnapshot(`""`); + const text = std.out.split("\n").splice(1).join("\n"); + const response = JSON.parse(text); + expect(response.status).toEqual(500); + expect(response.statusText).toEqual("Unhandled Exception"); + }); +}); diff --git a/packages/wrangler/src/cloudchamber/curl.ts b/packages/wrangler/src/cloudchamber/curl.ts new file mode 100644 index 000000000000..d379b99cc987 --- /dev/null +++ b/packages/wrangler/src/cloudchamber/curl.ts @@ -0,0 +1,166 @@ +import { randomUUID } from "crypto"; +import { logRaw } from "@cloudflare/cli"; +import { bold, brandColor, cyanBright, yellow } from "@cloudflare/cli/colors"; +import formatLabelledValues from "../utils/render-labelled-values"; +import { OpenAPI } from "./client"; +import { ApiError } from "./client/core/ApiError"; +import { request } from "./client/core/request"; +import type { Config } from "../config"; +import type { + CommonYargsOptions, + StrictYargsOptionsToInterface, +} from "../yargs-types"; +import type yargs from "yargs"; + +export function yargsCurl(args: yargs.Argv) { + return args + .positional("path", { type: "string", default: "/" }) + .option("header", { + type: "array", + alias: "H", + describe: "Add headers in the form of --header :", + }) + .option("data", { + type: "string", + describe: "Add a JSON body to the request", + alias: "D", + }) + .option("method", { + type: "string", + alias: "X", + default: "GET", + }) + .option("silent", { + describe: "Only output response", + type: "boolean", + alias: "s", + }) + .option("verbose", { + describe: "Print everything, like request id, or headers", + type: "boolean", + alias: "v", + }) + .option("use-stdin", { + describe: "Equivalent of using --data-binary @- in curl", + type: "boolean", + alias: "stdin", + }) + .option("json", { + describe: "Output json. Use for consistent, machine readable output.", + type: "boolean", + default: false, + }); +} + +export async function curlCommand( + args: StrictYargsOptionsToInterface, + config: Config +) { + await requestFromCmd(args, config); +} + +async function read(stream: NodeJS.ReadStream) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +async function requestFromCmd( + args: { + path: string; + method: string; + header: (string | number)[] | undefined; + data?: string; + silent?: boolean; + verbose?: boolean; + useStdin?: boolean; + json?: boolean; + }, + _config: Config +): Promise { + const requestId = `wrangler-${randomUUID()}`; + if (!args.json && args.verbose) { + logRaw(bold(brandColor("Request id: " + requestId))); + } + + if (args.useStdin) { + args.data = await read(process.stdin); + } + try { + const headers: Record = (args.header ?? []).reduce( + (prev, now) => ({ + ...prev, + [now.toString().split(":")[0].trim()]: now + .toString() + .split(":")[1] + .trim(), + }), + { "coordinator-request-id": requestId } + ); + const res = await request(OpenAPI, { + url: args.path, + method: args.method as + | "GET" + | "PUT" + | "POST" + | "DELETE" + | "OPTIONS" + | "HEAD" + | "PATCH", + body: args.data ? JSON.parse(args.data) : undefined, + mediaType: "application/json", + headers: headers, + }); + if (args.json || args.silent) { + logRaw( + JSON.stringify( + !args.verbose + ? res + : { + res, + headers: headers, + request_id: requestId, + }, + null, + 4 + ) + ); + } else { + if (args.verbose) { + logRaw(cyanBright(">> Headers")); + logRaw( + formatLabelledValues(headers, { + indentationCount: 4, + formatLabel: function (label: string): string { + return yellow(label + ":"); + }, + formatValue: yellow, + }) + ); + } + logRaw(cyanBright(">> Body")); + const text = JSON.stringify(res, null, 4); + logRaw( + text + .split("\n") + .map((line) => `${brandColor(line)}`) + .join("\n") + ); + } + return; + } catch (error) { + if (error instanceof ApiError) { + logRaw( + JSON.stringify({ + request: error.request, + status: error.status, + statusText: error.statusText, + }) + ); + } else { + logRaw(String(error)); + } + } +} diff --git a/packages/wrangler/src/cloudchamber/index.ts b/packages/wrangler/src/cloudchamber/index.ts index 8d9dd7e33ed7..0a6d7fdeef24 100644 --- a/packages/wrangler/src/cloudchamber/index.ts +++ b/packages/wrangler/src/cloudchamber/index.ts @@ -1,5 +1,6 @@ import { handleFailure } from "./common"; import { createCommand, createCommandOptionalYargs } from "./create"; +import { curlCommand, yargsCurl } from "./curl"; import { deleteCommand, deleteCommandOptionalYargs } from "./delete"; import { registriesCommand } from "./images/images"; import { listCommand, listDeploymentsYargs } from "./list"; @@ -54,5 +55,11 @@ export const cloudchamber = ( ) .command("registries", "Configure registries via Cloudchamber", (args) => registriesCommand(args).command(subHelp) + ) + .command( + "curl ", + "send a request to an arbitrary cloudchamber endpoint", + (args) => yargsCurl(args), + (args) => handleFailure(curlCommand)(args) ); };