mirror of
https://github.com/coder/registry.git
synced 2026-06-02 20:48:14 +00:00
047a5d654b
The agentapi module is a building block for modules that need to run an agentapi server. It's not meant for end users of Coder. The agentapi-specific logic is mostly extracted from [the claude-code module](https://github.com/coder/registry/tree/c0f2d945c5808c1962b15b5c2e2a5269ea0f4339/registry/coder/modules/claude-code). You can see this module in action in [the goose 2.0 PR](https://github.com/coder/registry/pull/178).
337 lines
8.1 KiB
TypeScript
337 lines
8.1 KiB
TypeScript
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<string> => {
|
|
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();
|
|
};
|
|
|
|
export const removeContainer = async (id: string) => {
|
|
const proc = spawn(["docker", "rm", "-f", id], {
|
|
stderr: "pipe",
|
|
stdout: "pipe",
|
|
});
|
|
const exitCode = await proc.exited;
|
|
const [stderr, stdout] = await Promise.all([
|
|
readableStreamToText(proc.stderr ?? new ReadableStream()),
|
|
readableStreamToText(proc.stdout ?? new ReadableStream()),
|
|
]);
|
|
if (exitCode !== 0) {
|
|
throw new Error(`${stderr}\n${stdout}`);
|
|
}
|
|
};
|
|
|
|
export interface scriptOutput {
|
|
exitCode: number;
|
|
stdout: string[];
|
|
stderr: string[];
|
|
}
|
|
|
|
/**
|
|
* 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",
|
|
before?: string,
|
|
): Promise<scriptOutput> => {
|
|
const instance = findResourceInstance(state, "coder_script");
|
|
const id = await runContainer(image);
|
|
|
|
if (before) {
|
|
await execContainer(id, [shell, "-c", before]);
|
|
}
|
|
|
|
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[],
|
|
args?: string[],
|
|
): Promise<{
|
|
exitCode: number;
|
|
stderr: string;
|
|
stdout: string;
|
|
}> => {
|
|
const proc = spawn(["docker", "exec", ...(args ?? []), 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<string, JsonValue>;
|
|
},
|
|
];
|
|
};
|
|
|
|
type TerraformOutput = {
|
|
type: string;
|
|
value: JsonValue;
|
|
};
|
|
|
|
export interface TerraformState {
|
|
outputs: Record<string, TerraformOutput>;
|
|
resources: [TerraformStateResource, ...TerraformStateResource[]];
|
|
}
|
|
|
|
type TerraformVariables = Record<string, JsonValue>;
|
|
|
|
export interface CoderScriptAttributes {
|
|
script: string;
|
|
agent_id: string;
|
|
url: string;
|
|
}
|
|
|
|
export type ResourceInstance<T extends string = string> =
|
|
T extends "coder_script" ? CoderScriptAttributes : Record<string, string>;
|
|
|
|
/**
|
|
* 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 string>(
|
|
state: TerraformState,
|
|
type: T,
|
|
name?: string,
|
|
): ResourceInstance<T> => {
|
|
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<T>;
|
|
};
|
|
|
|
/**
|
|
* Creates a test-case for each variable provided and ensures that the apply
|
|
* fails without it.
|
|
*/
|
|
export const testRequiredVariables = <TVars extends TerraformVariables>(
|
|
dir: string,
|
|
vars: Readonly<TVars>,
|
|
) => {
|
|
// 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 <TVars extends TerraformVariables>(
|
|
dir: string,
|
|
vars: Readonly<TVars>,
|
|
customEnv?: Record<string, string>,
|
|
): Promise<TerraformState> => {
|
|
const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`;
|
|
|
|
const childEnv: Record<string, string | undefined> = {
|
|
...process.env,
|
|
...(customEnv ?? {}),
|
|
};
|
|
|
|
// This is a fix for when you try to run the tests from a Coder workspace.
|
|
// When process.env is destructured into the object, it can sometimes have
|
|
// workspace-specific values, which causes the resulting URL to be different
|
|
// from what the tests have classically expected.
|
|
childEnv.CODER_AGENT_URL = undefined;
|
|
childEnv.CODER_WORKSPACE_NAME = undefined;
|
|
|
|
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) => {
|
|
await writeFileContainer(id, "/usr/bin/coder", script, {
|
|
user: "root",
|
|
});
|
|
const execResult = await execContainer(
|
|
id,
|
|
["chmod", "755", "/usr/bin/coder"],
|
|
["--user", "root"],
|
|
);
|
|
expect(execResult.exitCode).toBe(0);
|
|
};
|
|
|
|
export const writeFileContainer = async (
|
|
id: string,
|
|
path: string,
|
|
content: string,
|
|
options?: {
|
|
user?: string;
|
|
},
|
|
) => {
|
|
const contentBase64 = Buffer.from(content).toString("base64");
|
|
const proc = await execContainer(
|
|
id,
|
|
["sh", "-c", `echo '${contentBase64}' | base64 -d > '${path}'`],
|
|
options?.user ? ["--user", options.user] : undefined,
|
|
);
|
|
if (proc.exitCode !== 0) {
|
|
throw new Error(`Failed to write file: ${proc.stderr}`);
|
|
}
|
|
expect(proc.exitCode).toBe(0);
|
|
};
|
|
|
|
export const readFileContainer = async (id: string, path: string) => {
|
|
const proc = await execContainer(id, ["cat", path], ["--user", "root"]);
|
|
if (proc.exitCode !== 0) {
|
|
console.log(proc.stderr);
|
|
console.log(proc.stdout);
|
|
}
|
|
expect(proc.exitCode).toBe(0);
|
|
return proc.stdout;
|
|
};
|