Add test framework
							parent
							
								
									84bad159a3
								
							
						
					
					
						commit
						030404939f
					
				@ -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
 | 
			
		||||
@ -1 +1,4 @@
 | 
			
		||||
.terraform*
 | 
			
		||||
.terraform*
 | 
			
		||||
node_modules
 | 
			
		||||
*.tfstate
 | 
			
		||||
*.tfstate.lock.info
 | 
			
		||||
@ -0,0 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "files.exclude": {
 | 
			
		||||
    "**/terraform.tfstate": true,
 | 
			
		||||
    "**/.terraform": true
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,2 @@
 | 
			
		||||
[test]
 | 
			
		||||
preload = ["./setup.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...",
 | 
			
		||||
    ]);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@ -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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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()]);
 | 
			
		||||
});
 | 
			
		||||
@ -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<string> => {
 | 
			
		||||
  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 = <T extends "coder_script" | string>(
 | 
			
		||||
  state: TerraformState,
 | 
			
		||||
  type: T,
 | 
			
		||||
  name?: string,
 | 
			
		||||
  // if type is "coder_script" return CoderScriptAttributes
 | 
			
		||||
): T extends "coder_script"
 | 
			
		||||
  ? CoderScriptAttributes
 | 
			
		||||
  : Record<string, string> => {
 | 
			
		||||
  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<string, string>,
 | 
			
		||||
) => {
 | 
			
		||||
  // 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<string, string>,
 | 
			
		||||
): Promise<TerraformState> => {
 | 
			
		||||
  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);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "target": "esnext",
 | 
			
		||||
    "module": "esnext",
 | 
			
		||||
    "types": ["bun-types"]
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
					Loading…
					
					
				
		Reference in New Issue