diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..8117c47 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +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 + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - run: bun fmt:ci \ No newline at end of file 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/CONTRIBUTING.md b/CONTRIBUTING.md index eb8d484..8311b04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,37 +6,13 @@ To create a new module, clone this repository and run: ./new.sh MOUDLE_NAME ``` -Test a module by running an instance of Coder on your local machine: - -```shell -coder server --in-memory -``` - -This will create a new module in the modules directory with the given name and scaffolding. -Edit the files, adding your module's implementation, documentation and screenshots. - ## Testing a Module -Create a template and edit it to include your development module: +A suite of test-helpers exists to run `terraform apply` on modules with variables, and test script output against containers. -> [!NOTE] -> The Docker starter template is recommended for quick-iteration! +Reference existing `*.test.ts` files for implementation. -```hcl -module "MOUDLE_NAME" { - source = "/home/user/coder/modules/MOUDLE_NAME" -} +```sh +# Run tests for a specific module! +$ bun test -t '' ``` - -You can also test your module by specifying the source as a git repository: - -```hcl -module "MOUDLE_NAME" { - source = "git::https://github.com//.git//?ref=" -} -``` - -Build a workspace and your module will be consumed! 🥳 - -Open a pull-request with your module, a member of the Coder team will -manually test it, and after-merge it will appear on the Registry. diff --git a/aws-region/main.test.ts b/aws-region/main.test.ts new file mode 100644 index 0000000..f943f94 --- /dev/null +++ b/aws-region/main.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("aws-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe("us-east-1"); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "us-west-2", + }); + expect(state.outputs.value.value).toBe("us-west-2"); + }); +}); diff --git a/azure-region/main.test.ts b/azure-region/main.test.ts new file mode 100644 index 0000000..26a522d --- /dev/null +++ b/azure-region/main.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("azure-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe("eastus"); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "westus", + }); + expect(state.outputs.value.value).toBe("westus"); + }); +}); diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..dfed919 Binary files /dev/null and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..7bb903b --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./setup.ts"] \ No newline at end of file diff --git a/fly-region/README.md b/fly-region/README.md index 96babc3..7c14f53 100644 --- a/fly-region/README.md +++ b/fly-region/README.md @@ -13,4 +13,4 @@ A parameter with all fly.io regions. This allows developers to select the region ## Examples -TODO \ No newline at end of file +TODO diff --git a/gcp-region/README.md b/gcp-region/README.md index 553a8fd..cfa1370 100644 --- a/gcp-region/README.md +++ b/gcp-region/README.md @@ -6,6 +6,7 @@ maintainer_github: coder verified: true tags: [gcp, regions, parameter, helper] --- + # Google Cloud Platform Regions This module adds Google Cloud Platform regions to your Coder template. @@ -16,32 +17,32 @@ This module adds Google Cloud Platform regions to your Coder template. 1. Add only GPU zones in the US West 1 region: - ```hcl - module "gcp_region" { - source = "https://registry.coder.com/modules/gcp-region" - default = ["us-west1-a"] - regions = ["us-west1"] - gpu_only = false - } - ``` + ```hcl + module "gcp_region" { + source = "https://registry.coder.com/modules/gcp-region" + default = ["us-west1-a"] + regions = ["us-west1"] + gpu_only = false + } + ``` 2. Add all zones in the Europe West region: - ```hcl - module "gcp_region" { - source = "https://registry.coder.com/modules/gcp-region" - regions = ["europe-west"] - single_zone_per_region = false - } - ``` + ```hcl + module "gcp_region" { + source = "https://registry.coder.com/modules/gcp-region" + regions = ["europe-west"] + single_zone_per_region = false + } + ``` 3. Add a single zone from each region in US and Europe that laos has GPUs - ```hcl - module "gcp_region" { - source = "https://registry.coder.com/modules/gcp-region" - regions = ["us", "europe"] - gpu_only = true - single_zone_per_region = true - } - ``` + ```hcl + module "gcp_region" { + source = "https://registry.coder.com/modules/gcp-region" + regions = ["us", "europe"] + gpu_only = true + single_zone_per_region = true + } + ``` diff --git a/git-clone/README.md b/git-clone/README.md index b89c47e..40ed231 100644 --- a/git-clone/README.md +++ b/git-clone/README.md @@ -6,6 +6,7 @@ maintainer_github: coder verified: true tags: [git, helper] --- + # Git Clone This module allows you to automatically clone a repository by URL and skip if it exists in the path provided. diff --git a/git-clone/main.test.ts b/git-clone/main.test.ts new file mode 100644 index 0000000..0c3dd54 --- /dev/null +++ b/git-clone/main.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("git-clone", async () => { + 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..c42b502 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "modules", + "scripts": { + "test": "bun test", + "fmt": "bun x prettier -w **/*.ts **/*.md *.md && terraform fmt **/*.tf", + "fmt:ci": "bun x prettier --check **/*.ts **/*.md *.md && terraform fmt -check **/*.tf" + }, + "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..b4c384f --- /dev/null +++ b/test.ts @@ -0,0 +1,212 @@ +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 { + outputs: { + [key: string]: { + type: string; + value: any; + }; + } + 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"] + } +}