From 030404939f4783901daa5cccc3ea9c0d7f638608 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 25 Sep 2023 22:59:38 -0500 Subject: [PATCH] Add test framework --- .github/workflows/ci.yaml | 23 ++++ .gitignore | 5 +- .vscode/settings.json | 6 ++ bun.lockb | Bin 0 -> 1269 bytes bunfig.toml | 2 + fly-region/README.md | 2 +- gcp-region/README.md | 47 ++++---- git-clone/README.md | 1 + git-clone/main.test.ts | 39 +++++++ jetbrains-gateway/README.md | 1 + jfrog/README.md | 2 +- package.json | 13 +++ setup.ts | 47 ++++++++ test.ts | 206 ++++++++++++++++++++++++++++++++++++ tsconfig.json | 7 ++ 15 files changed, 375 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 .vscode/settings.json create mode 100755 bun.lockb create mode 100644 bunfig.toml create mode 100644 git-clone/main.test.ts create mode 100644 package.json create mode 100644 setup.ts create mode 100644 test.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..eb44ad4 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,23 @@ +name: ci + +on: + push: + branches: + - main + + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - run: bun test diff --git a/.gitignore b/.gitignore index 66df410..6d6f5a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -.terraform* \ No newline at end of file +.terraform* +node_modules +*.tfstate +*.tfstate.lock.info \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1c5485b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "files.exclude": { + "**/terraform.tfstate": true, + "**/.terraform": true + } +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..dfed91948022558cca9901420988b9f7b8ce4291 GIT binary patch literal 1269 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p+vrLKXi zJswMWd=&m(x}P)um;5aTchyRJX^k4AgQn*$r?LPQ0Rc!Y2yj3sINbo{SHTo8 zU7^>QWY?W~^6`e`8NQHL$ArI~nX_f*b-m9byf+zGKXvlW^<5lpqB_~{`2&UiU+W%M zzxQ6qSe59M^FH|3w^}5JeDH*sJZCdZIO28OumArU7@&~|6DEp=nMRaiu%ZLdAiV;m zxonDylJzq4ic1o6a`a#cP%oq?HPueR$Uvbuvnn+|O-I2*Au%U2Jug2Elw`o*-+u@I zalr0?MlK7K=CUa@HnIb1#9{xnipi!HSPI@Jtrk0Gj4 zfevn%uiBua-I|&tz!+z$XP{@m08ja_6wd)qYR{l%!)VkHDo)KUOD)oKttd$?%1g`% zE-A{)OSe-nL|AVDw_X4mrLj;N7$QKI8e(y1No7H5adJ^+K?$fB0P`|RN(zdt^!1BU p(=&@piYoQ;3UafG_413-_2FvtbrEb`L!ifu^-9vKKoK$+0RW`X { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + url: "foo", + }); + + it("fails without git", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "some-url", + }); + const output = await executeScriptInContainer(state, "alpine"); + expect(output.exitCode).toBe(1); + expect(output.stdout).toEqual(["Git is not installed!"]); + }); + + it("runs with git", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "fake-url", + }); + const output = await executeScriptInContainer(state, "alpine/git"); + expect(output.exitCode).toBe(128); + expect(output.stdout).toEqual([ + "Creating directory ~/fake-url...", + "Cloning fake-url to ~/fake-url...", + ]); + }); +}); diff --git a/jetbrains-gateway/README.md b/jetbrains-gateway/README.md index 7abe4d2..d7fdfb0 100644 --- a/jetbrains-gateway/README.md +++ b/jetbrains-gateway/README.md @@ -6,6 +6,7 @@ maintainer_github: coder verified: true tags: [ide, jetbrains, helper, parameter] --- + # JetBrains Gateway This module adds a JetBrains Gateway Button to open any workspace with a single click. diff --git a/jfrog/README.md b/jfrog/README.md index 755d5c8..f5f9670 100644 --- a/jfrog/README.md +++ b/jfrog/README.md @@ -10,4 +10,4 @@ tags: [integration] # JFrog -TODO \ No newline at end of file +TODO diff --git a/package.json b/package.json new file mode 100644 index 0000000..2666697 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "modules", + "scripts": { + "test": "bun test", + "fmt": "bun x prettier -w **/*.ts **/*.md" + }, + "devDependencies": { + "bun-types": "^1.0.3" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/setup.ts b/setup.ts new file mode 100644 index 0000000..c55df43 --- /dev/null +++ b/setup.ts @@ -0,0 +1,47 @@ +import { readableStreamToText, spawn } from "bun"; +import { afterAll, beforeAll } from "bun:test"; + +const removeStatefiles = async () => { + const proc = spawn([ + "find", + ".", + "-type", + "f", + "-name", + "*.tfstate", + "-name", + "*.tfstate.lock.info", + "-delete", + ]); + await proc.exited; +}; + +const removeOldContainers = async () => { + let proc = spawn([ + "docker", + "ps", + "-a", + "-q", + "--filter", + `label=modules-test`, + ]); + let containerIDsRaw = await readableStreamToText(proc.stdout); + let exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(containerIDsRaw); + } + containerIDsRaw = containerIDsRaw.trim(); + if (containerIDsRaw === "") { + return; + } + proc = spawn(["docker", "rm", "-f", ...containerIDsRaw.split("\n")]); + const stdout = await readableStreamToText(proc.stdout); + exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(stdout); + } +}; + +afterAll(async () => { + await Promise.all([removeStatefiles(), removeOldContainers()]); +}); diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..cc43018 --- /dev/null +++ b/test.ts @@ -0,0 +1,206 @@ +import { readableStreamToText, spawn } from "bun"; +import { afterEach, expect, it } from "bun:test"; +import { readFile, unlink } from "fs/promises"; + +export const runContainer = async ( + image: string, + init = "sleep infinity", +): Promise => { + const proc = spawn([ + "docker", + "run", + "--rm", + "-d", + "--label", + "modules-test=true", + "--entrypoint", + "sh", + image, + "-c", + init, + ]); + let containerID = await readableStreamToText(proc.stdout); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(containerID); + } + return containerID.trim(); +}; + +// executeScriptInContainer finds the only "coder_script" +// resource in the given state and runs it in a container. +export const executeScriptInContainer = async ( + state: TerraformState, + image: string, +): Promise<{ + exitCode: number; + stdout: string[]; + stderr: string[]; +}> => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + const resp = await execContainer(id, ["sh", "-c", instance.script]); + const stdout = resp.stdout.trim().split("\n"); + const stderr = resp.stderr.trim().split("\n"); + return { + exitCode: resp.exitCode, + stdout, + stderr, + }; +}; + +export const execContainer = async ( + id: string, + cmd: string[], +): Promise<{ + exitCode: number; + stderr: string; + stdout: string; +}> => { + const proc = spawn(["docker", "exec", id, ...cmd], { + stderr: "pipe", + stdout: "pipe", + }); + const [stderr, stdout] = await Promise.all([ + readableStreamToText(proc.stderr), + readableStreamToText(proc.stdout), + ]); + const exitCode = await proc.exited; + return { + exitCode, + stderr, + stdout, + }; +}; + +export interface TerraformState { + resources: [ + { + type: string; + name: string; + provider: string; + instances: [ + { + attributes: { + [key: string]: any; + }; + }, + ]; + }, + ]; +} + +export interface CoderScriptAttributes { + script: string; + agent_id: string; + url: string; +} + +// findResourceInstance finds the first instance of the given resource +// type in the given state. If name is specified, it will only find +// the instance with the given name. +export const findResourceInstance = ( + state: TerraformState, + type: T, + name?: string, + // if type is "coder_script" return CoderScriptAttributes +): T extends "coder_script" + ? CoderScriptAttributes + : Record => { + const resource = state.resources.find( + (resource) => + resource.type === type && (name ? resource.name === name : true), + ); + if (!resource) { + throw new Error(`Resource ${type} not found`); + } + if (resource.instances.length !== 1) { + throw new Error( + `Resource ${type} has ${resource.instances.length} instances`, + ); + } + return resource.instances[0].attributes as any; +}; + +// assertRequiredVariables creates a test-case +// for each variable provided and ensures that +// the apply fails without it. +export const testRequiredVariables = ( + dir: string, + vars: Record, +) => { + // Ensures that all required variables are provided. + it("required variables", async () => { + await runTerraformApply(dir, vars); + }); + const varNames = Object.keys(vars); + varNames.forEach((varName) => { + // Ensures that every variable provided is required! + it("missing variable " + varName, async () => { + const localVars = {}; + varNames.forEach((otherVarName) => { + if (otherVarName !== varName) { + localVars[otherVarName] = vars[otherVarName]; + } + }); + try { + await runTerraformApply(dir, localVars); + } catch (ex) { + expect(ex.message).toContain( + `input variable \"${varName}\" is not set, and has no default`, + ); + return; + } + throw new Error(`${varName} is not a required variable!`); + }); + }); +}; + +// runTerraformApply runs terraform apply in the given directory +// with the given variables. It is fine to run in parallel with +// other instances of this function, as it uses a random state file. +export const runTerraformApply = async ( + dir: string, + vars: Record, +): Promise => { + const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`; + const env = {}; + Object.keys(vars).forEach((key) => (env[`TF_VAR_${key}`] = vars[key])); + const proc = spawn( + [ + "terraform", + "apply", + "-compact-warnings", + "-input=false", + "-auto-approve", + "-state", + stateFile, + ], + { + cwd: dir, + env, + stderr: "pipe", + stdout: "pipe", + }, + ); + const text = await readableStreamToText(proc.stderr); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(text); + } + const content = await readFile(stateFile, "utf8"); + await unlink(stateFile); + return JSON.parse(content); +}; + +// runTerraformInit runs terraform init in the given directory. +export const runTerraformInit = async (dir: string) => { + const proc = spawn(["terraform", "init"], { + cwd: dir, + }); + const text = await readableStreamToText(proc.stdout); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(text); + } +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..86140a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "types": ["bun-types"] + } +}