diff --git a/README.md b/README.md index debc8c66..58621bac 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # hub + Publish Coder modules and templates for other developers to use. diff --git a/package.json b/package.json new file mode 100644 index 00000000..25060aa3 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "modules", + "scripts": { + "test": "bun test", + "fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf", + "fmt:ci": "bun x prettier --check **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt -check **/*.tf .sample/main.tf", + "lint": "bun run lint.ts && ./terraform_validate.sh", + "update-version": "./update-version.sh" + }, + "devDependencies": { + "@types/bun": "^1.2.9", + "bun-types": "^1.1.23", + "gray-matter": "^4.0.3", + "marked": "^12.0.2", + "prettier": "^3.3.3", + "prettier-plugin-sh": "^0.13.1", + "prettier-plugin-terraform-formatter": "^1.2.1" + }, + "peerDependencies": { + "typescript": "^5.5.4" + }, + "prettier": { + "plugins": [ + "prettier-plugin-sh", + "prettier-plugin-terraform-formatter" + ] + } +} diff --git a/registry/coder/modules/aws-region/main.test.ts b/registry/coder/modules/aws-region/main.test.ts index 06f8e56e..b7768cf2 100644 --- a/registry/coder/modules/aws-region/main.test.ts +++ b/registry/coder/modules/aws-region/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("aws-region", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/azure-region/main.test.ts b/registry/coder/modules/azure-region/main.test.ts index 8adbb48b..a20f8d43 100644 --- a/registry/coder/modules/azure-region/main.test.ts +++ b/registry/coder/modules/azure-region/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("azure-region", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/code-server/main.test.ts b/registry/coder/modules/code-server/main.test.ts index 1d6da5e5..01e80883 100644 --- a/registry/coder/modules/code-server/main.test.ts +++ b/registry/coder/modules/code-server/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("code-server", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/coder-login/main.test.ts b/registry/coder/modules/coder-login/main.test.ts index aca43216..c7ecef15 100644 --- a/registry/coder/modules/coder-login/main.test.ts +++ b/registry/coder/modules/coder-login/main.test.ts @@ -1,5 +1,5 @@ import { describe } from "bun:test"; -import { runTerraformInit, testRequiredVariables } from "../test"; +import { runTerraformInit, testRequiredVariables } from "~test"; describe("coder-login", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/cursor/main.test.ts b/registry/coder/modules/cursor/main.test.ts index 3c164698..ed92b9c9 100644 --- a/registry/coder/modules/cursor/main.test.ts +++ b/registry/coder/modules/cursor/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("cursor", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/dotfiles/main.test.ts b/registry/coder/modules/dotfiles/main.test.ts index 60267195..8c82cd1e 100644 --- a/registry/coder/modules/dotfiles/main.test.ts +++ b/registry/coder/modules/dotfiles/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("dotfiles", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/fly-region/main.test.ts b/registry/coder/modules/fly-region/main.test.ts index 7e72586f..4882fd0f 100644 --- a/registry/coder/modules/fly-region/main.test.ts +++ b/registry/coder/modules/fly-region/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("fly-region", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/gcp-region/main.test.ts b/registry/coder/modules/gcp-region/main.test.ts index bf01c2bc..3acfaa21 100644 --- a/registry/coder/modules/gcp-region/main.test.ts +++ b/registry/coder/modules/gcp-region/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("gcp-region", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/git-clone/main.test.ts b/registry/coder/modules/git-clone/main.test.ts index 9fbd2022..1e074fc0 100644 --- a/registry/coder/modules/git-clone/main.test.ts +++ b/registry/coder/modules/git-clone/main.test.ts @@ -4,7 +4,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("git-clone", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/git-config/main.test.ts b/registry/coder/modules/git-config/main.test.ts index e702c6e6..90f48c07 100644 --- a/registry/coder/modules/git-config/main.test.ts +++ b/registry/coder/modules/git-config/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("git-config", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/github-upload-public-key/main.test.ts b/registry/coder/modules/github-upload-public-key/main.test.ts index 6ce16d82..467d6b95 100644 --- a/registry/coder/modules/github-upload-public-key/main.test.ts +++ b/registry/coder/modules/github-upload-public-key/main.test.ts @@ -9,7 +9,7 @@ import { runTerraformInit, testRequiredVariables, writeCoder, -} from "../test"; +} from "~test"; describe("github-upload-public-key", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/jetbrains-gateway/main.test.ts b/registry/coder/modules/jetbrains-gateway/main.test.ts index ea04a77d..764170e3 100644 --- a/registry/coder/modules/jetbrains-gateway/main.test.ts +++ b/registry/coder/modules/jetbrains-gateway/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformInit, testRequiredVariables, runTerraformApply, -} from "../test"; +} from "~test"; describe("jetbrains-gateway", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/jfrog-oauth/main.test.ts b/registry/coder/modules/jfrog-oauth/main.test.ts index 7b0c1a5f..20ace697 100644 --- a/registry/coder/modules/jfrog-oauth/main.test.ts +++ b/registry/coder/modules/jfrog-oauth/main.test.ts @@ -4,7 +4,7 @@ import { runTerraformInit, runTerraformApply, testRequiredVariables, -} from "../test"; +} from "~test"; describe("jfrog-oauth", async () => { type TestVariables = { diff --git a/registry/coder/modules/jfrog-token/main.test.ts b/registry/coder/modules/jfrog-token/main.test.ts index 4ba2f52d..4aeaba35 100644 --- a/registry/coder/modules/jfrog-token/main.test.ts +++ b/registry/coder/modules/jfrog-token/main.test.ts @@ -6,7 +6,7 @@ import { runTerraformInit, runTerraformApply, testRequiredVariables, -} from "../test"; +} from "~test"; describe("jfrog-token", async () => { type TestVariables = { diff --git a/registry/coder/modules/jupyterlab/main.test.ts b/registry/coder/modules/jupyterlab/main.test.ts index a9789c39..4ef7fa02 100644 --- a/registry/coder/modules/jupyterlab/main.test.ts +++ b/registry/coder/modules/jupyterlab/main.test.ts @@ -8,7 +8,7 @@ import { runTerraformInit, testRequiredVariables, type TerraformState, -} from "../test"; +} from "~test"; // executes the coder script after installing pip const executeScriptInContainerWithPip = async ( diff --git a/registry/coder/modules/kasmvnc/main.test.ts b/registry/coder/modules/kasmvnc/main.test.ts index 0116d053..8ec5721b 100644 --- a/registry/coder/modules/kasmvnc/main.test.ts +++ b/registry/coder/modules/kasmvnc/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; const allowedDesktopEnvs = ["xfce", "kde", "gnome", "lxde", "lxqt"] as const; type AllowedDesktopEnv = (typeof allowedDesktopEnvs)[number]; diff --git a/registry/coder/modules/personalize/main.test.ts b/registry/coder/modules/personalize/main.test.ts index b499a0b7..98ed11db 100644 --- a/registry/coder/modules/personalize/main.test.ts +++ b/registry/coder/modules/personalize/main.test.ts @@ -4,7 +4,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("personalize", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/slackme/main.test.ts b/registry/coder/modules/slackme/main.test.ts index d8d06242..9c4a467e 100644 --- a/registry/coder/modules/slackme/main.test.ts +++ b/registry/coder/modules/slackme/main.test.ts @@ -9,7 +9,7 @@ import { runTerraformInit, testRequiredVariables, writeCoder, -} from "../test"; +} from "~test"; describe("slackme", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/vault-github/main.test.ts b/registry/coder/modules/vault-github/main.test.ts index 25934c85..2a2af938 100644 --- a/registry/coder/modules/vault-github/main.test.ts +++ b/registry/coder/modules/vault-github/main.test.ts @@ -1,5 +1,5 @@ import { describe } from "bun:test"; -import { runTerraformInit, testRequiredVariables } from "../test"; +import { runTerraformInit, testRequiredVariables } from "~test"; describe("vault-github", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/vault-jwt/main.test.ts b/registry/coder/modules/vault-jwt/main.test.ts index 2fda3d7c..50211512 100644 --- a/registry/coder/modules/vault-jwt/main.test.ts +++ b/registry/coder/modules/vault-jwt/main.test.ts @@ -1,5 +1,5 @@ import { describe } from "bun:test"; -import { runTerraformInit, testRequiredVariables } from "../test"; +import { runTerraformInit, testRequiredVariables } from "~test"; describe("vault-jwt", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/vault-token/main.test.ts b/registry/coder/modules/vault-token/main.test.ts index d5134252..cf876ac0 100644 --- a/registry/coder/modules/vault-token/main.test.ts +++ b/registry/coder/modules/vault-token/main.test.ts @@ -1,5 +1,5 @@ import { describe } from "bun:test"; -import { runTerraformInit, testRequiredVariables } from "../test"; +import { runTerraformInit, testRequiredVariables } from "~test"; describe("vault-token", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/vscode-desktop/main.test.ts b/registry/coder/modules/vscode-desktop/main.test.ts index 7aa144ec..b59ef5dc 100644 --- a/registry/coder/modules/vscode-desktop/main.test.ts +++ b/registry/coder/modules/vscode-desktop/main.test.ts @@ -4,7 +4,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("vscode-desktop", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/vscode-web/main.test.ts b/registry/coder/modules/vscode-web/main.test.ts index d8e0e68e..860fc176 100644 --- a/registry/coder/modules/vscode-web/main.test.ts +++ b/registry/coder/modules/vscode-web/main.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { runTerraformApply, runTerraformInit } from "../test"; +import { runTerraformApply, runTerraformInit } from "~test"; describe("vscode-web", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/coder/modules/windows-rdp/main.test.ts b/registry/coder/modules/windows-rdp/main.test.ts index ba5e21a5..01a7e46d 100644 --- a/registry/coder/modules/windows-rdp/main.test.ts +++ b/registry/coder/modules/windows-rdp/main.test.ts @@ -4,7 +4,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; type TestVariables = Readonly<{ agent_id: string; diff --git a/registry/thezoker/modules/nodejs/README.md b/registry/thezoker/modules/nodejs/README.md index b4420c1d..9c199020 100644 --- a/registry/thezoker/modules/nodejs/README.md +++ b/registry/thezoker/modules/nodejs/README.md @@ -20,7 +20,7 @@ module "nodejs" { } ``` -### Install multiple versions +## Install multiple versions This installs multiple versions of Node.js: diff --git a/registry/thezoker/modules/nodejs/main.test.ts b/registry/thezoker/modules/nodejs/main.test.ts index 39e48f49..14f8a066 100644 --- a/registry/thezoker/modules/nodejs/main.test.ts +++ b/registry/thezoker/modules/nodejs/main.test.ts @@ -1,5 +1,5 @@ import { describe } from "bun:test"; -import { runTerraformInit, testRequiredVariables } from "../test"; +import { runTerraformInit, testRequiredVariables } from "~test"; describe("nodejs", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/whizus/modules/exoscale-instance-type/main.test.ts b/registry/whizus/modules/exoscale-instance-type/main.test.ts index e4b998bc..8a63cbfa 100644 --- a/registry/whizus/modules/exoscale-instance-type/main.test.ts +++ b/registry/whizus/modules/exoscale-instance-type/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("exoscale-instance-type", async () => { await runTerraformInit(import.meta.dir); diff --git a/registry/whizus/modules/exoscale-zone/main.test.ts b/registry/whizus/modules/exoscale-zone/main.test.ts index 1751cb14..236b6e4c 100644 --- a/registry/whizus/modules/exoscale-zone/main.test.ts +++ b/registry/whizus/modules/exoscale-zone/main.test.ts @@ -3,7 +3,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, -} from "../test"; +} from "~test"; describe("exoscale-zone", async () => { await runTerraformInit(import.meta.dir); diff --git a/test/test.ts b/test/test.ts new file mode 100644 index 00000000..0c48ee99 --- /dev/null +++ b/test/test.ts @@ -0,0 +1,271 @@ +import { readableStreamToText, spawn } from "bun"; +import { expect, it } from "bun:test"; +import { readFile, unlink } from "node:fs/promises"; + +export const runContainer = async ( + image: string, + init = "sleep infinity", +): Promise => { + const proc = spawn([ + "docker", + "run", + "--rm", + "-d", + "--label", + "modules-test=true", + "--network", + "host", + "--entrypoint", + "sh", + image, + "-c", + init, + ]); + + const containerID = await readableStreamToText(proc.stdout); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(containerID); + } + return containerID.trim(); +}; + +/** + * Finds the only "coder_script" resource in the given state and runs it in a + * container. + */ +export const executeScriptInContainer = async ( + state: TerraformState, + image: string, + shell = "sh", +): Promise<{ + exitCode: number; + stdout: string[]; + stderr: string[]; +}> => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + const resp = await execContainer(id, [shell, "-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, + }; +}; + +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +type TerraformStateResource = { + type: string; + name: string; + provider: string; + + instances: [ + { + attributes: Record; + }, + ]; +}; + +type TerraformOutput = { + type: string; + value: JsonValue; +}; + +export interface TerraformState { + outputs: Record; + resources: [TerraformStateResource, ...TerraformStateResource[]]; +} + +type TerraformVariables = Record; + +export interface CoderScriptAttributes { + script: string; + agent_id: string; + url: string; +} + +export type ResourceInstance = + T extends "coder_script" ? CoderScriptAttributes : Record; + +/** + * 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, +): ResourceInstance => { + 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 ResourceInstance; +}; + +/** + * Creates a test-case for each variable provided and ensures that the apply + * fails without it. + */ +export const testRequiredVariables = ( + dir: string, + vars: Readonly, +) => { + // Ensures that all required variables are provided. + it("required variables", async () => { + await runTerraformApply(dir, vars); + }); + + const varNames = Object.keys(vars); + for (const varName of varNames) { + // Ensures that every variable provided is required! + it(`missing variable: ${varName}`, async () => { + const localVars: TerraformVariables = {}; + for (const otherVarName of varNames) { + if (otherVarName !== varName) { + localVars[otherVarName] = vars[otherVarName]; + } + } + + try { + await runTerraformApply(dir, localVars); + } catch (ex) { + if (!(ex instanceof Error)) { + throw new Error("Unknown error generated"); + } + + expect(ex.message).toContain( + `input variable \"${varName}\" is not set`, + ); + return; + } + throw new Error(`${varName} is not a required variable!`); + }); + } +}; + +/** + * 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: Readonly, + customEnv?: Record, +): Promise => { + const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`; + + const childEnv: Record = { + ...process.env, + ...(customEnv ?? {}), + }; + for (const [key, value] of Object.entries(vars) as [string, JsonValue][]) { + if (value !== null) { + childEnv[`TF_VAR_${key}`] = String(value); + } + } + + const proc = spawn( + [ + "terraform", + "apply", + "-compact-warnings", + "-input=false", + "-auto-approve", + "-state", + "-no-color", + stateFile, + ], + { + cwd: dir, + env: childEnv, + 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); +}; + +/** + * 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); + } +}; + +export const createJSONResponse = (obj: object, statusCode = 200): Response => { + return new Response(JSON.stringify(obj), { + headers: { + "Content-Type": "application/json", + }, + status: statusCode, + }); +}; + +export const writeCoder = async (id: string, script: string) => { + const exec = await execContainer(id, [ + "sh", + "-c", + `echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`, + ]); + expect(exec.exitCode).toBe(0); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..415b48f7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + // If we were just compiling for the tests, we could safely target ESNext at + // all times, but just because we've been starting to add more runtime logic + // files to some of the modules, erring on the side of caution by having a + // older compilation target + "target": "ES2024", + "module": "esnext", + "strict": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "types": ["bun-types"], + + "paths": { + // Not the biggest fan of relative paths in TypeScript projects, but it + // does make things easier for non-Coder contributors to get tests + // imported and set up + "~test": ["./test/test.ts"] + } + } +}