feat: add aibridge-proxy module for AI Bridge Proxy workspace setup (#721)
## Description Add `aibridge-proxy` module that configures workspaces to use AI Bridge Proxy. Downloads the proxy's CA certificate and exposes `proxy_auth_url` and `cert_path` outputs for tool-specific modules to configure the proxy scoped to their process. The module does not set proxy environment variables globally in the workspace. ## Type of Change - [x] New module - [ ] New template - [ ] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information <!-- Delete this section if not applicable --> **Path:** `registry/coder/modules/aibridge-proxy` **New version:** `v1.0.0` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues Closes: https://github.com/coder/internal/issues/1187
This commit is contained in:
parent
ac49e6eef5
commit
b6c2998eb3
89
registry/coder/modules/aibridge-proxy/README.md
Normal file
89
registry/coder/modules/aibridge-proxy/README.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
display_name: AI Bridge Proxy
|
||||||
|
description: Configure a workspace to route AI tool traffic through AI Bridge via AI Bridge Proxy.
|
||||||
|
icon: ../../../../.icons/coder.svg
|
||||||
|
verified: true
|
||||||
|
tags: [helper, aibridge]
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI Bridge Proxy
|
||||||
|
|
||||||
|
This module configures a Coder workspace to use [AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy).
|
||||||
|
It downloads the proxy's CA certificate from the Coder deployment and provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules can use to route their traffic through the proxy.
|
||||||
|
|
||||||
|
```tf
|
||||||
|
module "aibridge-proxy" {
|
||||||
|
source = "registry.coder.com/coder/aibridge-proxy/coder"
|
||||||
|
version = "1.0.0"
|
||||||
|
agent_id = coder_agent.main.id
|
||||||
|
proxy_url = "https://aiproxy.example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance).
|
||||||
|
> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) is an HTTP proxy that intercepts traffic to AI providers and forwards it through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge), enabling centralized LLM management, governance, and cost tracking.
|
||||||
|
Any process with the proxy environment variables set will route **all** its traffic through the proxy.
|
||||||
|
|
||||||
|
This module **does not** set proxy environment variables globally on the workspace.
|
||||||
|
Instead, it provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules consume to configure proxy routing.
|
||||||
|
See the [Copilot module](https://registry.coder.com/modules/coder-labs/copilot) for a working integration example.
|
||||||
|
|
||||||
|
It is recommended that tool modules scope the proxy environment variables to their own process rather than setting them globally on the workspace, to avoid routing unnecessary traffic through the proxy.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> If the setup script fails (e.g. the proxy is unreachable), the workspace will still start but the agent will report a startup script error.
|
||||||
|
> Tools that depend on the proxy will not work until the issue is resolved. Check the workspace build logs for details.
|
||||||
|
|
||||||
|
## Startup Coordination
|
||||||
|
|
||||||
|
When used with tool-specific modules (e.g. [Copilot](https://registry.coder.com/modules/coder-labs/copilot)),
|
||||||
|
the setup script signals completion via [`coder exp sync`](https://coder.com/docs/admin/templates/startup-coordination) so dependent modules can wait for the `aibridge-proxy` module to complete before starting.
|
||||||
|
|
||||||
|
Dependent modules are unblocked once the setup script finishes, regardless of success or failure.
|
||||||
|
If the setup fails, dependent modules are expected to detect the failure and handle the error accordingly.
|
||||||
|
|
||||||
|
To enable startup coordination, set `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
env = [
|
||||||
|
"CODER_AGENT_TOKEN=${coder_agent.main.token}",
|
||||||
|
"CODER_AGENT_SOCKET_SERVER_ENABLED=true",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> [Startup coordination](https://coder.com/docs/admin/templates/startup-coordination) requires Coder >= v2.30.
|
||||||
|
> Without it, the sync calls are skipped gracefully but dependent modules may fail to start if the `aibridge-proxy` setup has not completed in time.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Custom certificate path
|
||||||
|
|
||||||
|
```tf
|
||||||
|
module "aibridge-proxy" {
|
||||||
|
source = "registry.coder.com/coder/aibridge-proxy/coder"
|
||||||
|
version = "1.0.0"
|
||||||
|
agent_id = coder_agent.main.id
|
||||||
|
proxy_url = "https://aiproxy.example.com"
|
||||||
|
cert_path = "/home/coder/.certs/aibridge-proxy-ca.pem"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proxy with custom port
|
||||||
|
|
||||||
|
For deployments where the proxy is accessed directly on a configured port.
|
||||||
|
See [security considerations](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup#security-considerations) for network access guidelines.
|
||||||
|
|
||||||
|
```tf
|
||||||
|
module "aibridge-proxy" {
|
||||||
|
source = "registry.coder.com/coder/aibridge-proxy/coder"
|
||||||
|
version = "1.0.0"
|
||||||
|
agent_id = coder_agent.main.id
|
||||||
|
proxy_url = "http://internal-proxy:8888"
|
||||||
|
}
|
||||||
|
```
|
||||||
254
registry/coder/modules/aibridge-proxy/main.test.ts
Normal file
254
registry/coder/modules/aibridge-proxy/main.test.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import { serve } from "bun";
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeAll,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
setDefaultTimeout,
|
||||||
|
} from "bun:test";
|
||||||
|
import {
|
||||||
|
execContainer,
|
||||||
|
findResourceInstance,
|
||||||
|
removeContainer,
|
||||||
|
runContainer,
|
||||||
|
runTerraformApply,
|
||||||
|
runTerraformInit,
|
||||||
|
testRequiredVariables,
|
||||||
|
} from "~test";
|
||||||
|
|
||||||
|
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||||
|
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||||
|
cleanupFunctions.push(cleanup);
|
||||||
|
};
|
||||||
|
afterEach(async () => {
|
||||||
|
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||||
|
cleanupFunctions = [];
|
||||||
|
for (const cleanup of cleanupFnsCopy) {
|
||||||
|
try {
|
||||||
|
await cleanup();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during cleanup:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const FAKE_CERT =
|
||||||
|
"-----BEGIN CERTIFICATE-----\nMIIBfakecert\n-----END CERTIFICATE-----\n";
|
||||||
|
|
||||||
|
// Runs terraform apply to render the setup script, then starts a Docker
|
||||||
|
// container where we can execute it against a mock server.
|
||||||
|
const setupContainer = async (vars: Record<string, string> = {}) => {
|
||||||
|
const state = await runTerraformApply(import.meta.dir, {
|
||||||
|
agent_id: "foo",
|
||||||
|
proxy_url: "https://aiproxy.example.com",
|
||||||
|
...vars,
|
||||||
|
});
|
||||||
|
const instance = findResourceInstance(state, "coder_script");
|
||||||
|
const id = await runContainer("lorello/alpine-bash");
|
||||||
|
|
||||||
|
registerCleanup(async () => {
|
||||||
|
await removeContainer(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id, instance };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Starts a mock HTTP server that simulates the Coder API certificate endpoint.
|
||||||
|
// Returns the server and its base URL.
|
||||||
|
const setupServer = (handler: (req: Request) => Response) => {
|
||||||
|
const server = serve({
|
||||||
|
fetch: handler,
|
||||||
|
port: 0,
|
||||||
|
});
|
||||||
|
registerCleanup(async () => {
|
||||||
|
server.stop();
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
// Base URL without trailing slash
|
||||||
|
url: server.url.toString().slice(0, -1),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setDefaultTimeout(30 * 1000);
|
||||||
|
|
||||||
|
describe("aibridge-proxy", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await runTerraformInit(import.meta.dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that agent_id and proxy_url are required.
|
||||||
|
testRequiredVariables(import.meta.dir, {
|
||||||
|
agent_id: "foo",
|
||||||
|
proxy_url: "https://aiproxy.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downloads the CA certificate successfully", async () => {
|
||||||
|
let receivedToken = "";
|
||||||
|
const { url } = setupServer((req) => {
|
||||||
|
const reqUrl = new URL(req.url);
|
||||||
|
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
|
||||||
|
receivedToken = req.headers.get("Coder-Session-Token") || "";
|
||||||
|
return new Response(FAKE_CERT, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/x-pem-file" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id, instance } = await setupContainer();
|
||||||
|
|
||||||
|
// Override ACCESS_URL and SESSION_TOKEN at runtime to point at the mock server.
|
||||||
|
const exec = await execContainer(id, [
|
||||||
|
"env",
|
||||||
|
`ACCESS_URL=${url}`,
|
||||||
|
"SESSION_TOKEN=test-session-token-123",
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
instance.script,
|
||||||
|
]);
|
||||||
|
expect(exec.exitCode).toBe(0);
|
||||||
|
expect(exec.stdout).toContain(
|
||||||
|
"AI Bridge Proxy CA certificate saved to /tmp/aibridge-proxy/ca-cert.pem",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the cert was written to the default path.
|
||||||
|
const certContent = await execContainer(id, [
|
||||||
|
"cat",
|
||||||
|
"/tmp/aibridge-proxy/ca-cert.pem",
|
||||||
|
]);
|
||||||
|
expect(certContent.stdout).toContain("BEGIN CERTIFICATE");
|
||||||
|
|
||||||
|
// Verify the session token was sent in the request header.
|
||||||
|
expect(receivedToken).toBe("test-session-token-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the server is unreachable", async () => {
|
||||||
|
const { id, instance } = await setupContainer();
|
||||||
|
|
||||||
|
// Port 9999 has nothing listening, so curl will fail to connect.
|
||||||
|
const exec = await execContainer(id, [
|
||||||
|
"env",
|
||||||
|
"ACCESS_URL=http://localhost:9999",
|
||||||
|
"SESSION_TOKEN=mock-token",
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
instance.script,
|
||||||
|
]);
|
||||||
|
expect(exec.exitCode).not.toBe(0);
|
||||||
|
expect(exec.stdout).toContain(
|
||||||
|
"AI Bridge Proxy setup failed: could not connect to",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the server returns a non-200 status", async () => {
|
||||||
|
const { url } = setupServer(() => {
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id, instance } = await setupContainer();
|
||||||
|
|
||||||
|
const exec = await execContainer(id, [
|
||||||
|
"env",
|
||||||
|
`ACCESS_URL=${url}`,
|
||||||
|
"SESSION_TOKEN=mock-token",
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
instance.script,
|
||||||
|
]);
|
||||||
|
expect(exec.exitCode).not.toBe(0);
|
||||||
|
expect(exec.stdout).toContain(
|
||||||
|
"AI Bridge Proxy setup failed: unexpected response",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the server returns an empty response", async () => {
|
||||||
|
const { url } = setupServer((req) => {
|
||||||
|
const reqUrl = new URL(req.url);
|
||||||
|
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
|
||||||
|
return new Response("", { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id, instance } = await setupContainer();
|
||||||
|
|
||||||
|
const exec = await execContainer(id, [
|
||||||
|
"env",
|
||||||
|
`ACCESS_URL=${url}`,
|
||||||
|
"SESSION_TOKEN=mock-token",
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
instance.script,
|
||||||
|
]);
|
||||||
|
expect(exec.exitCode).not.toBe(0);
|
||||||
|
expect(exec.stdout).toContain(
|
||||||
|
"AI Bridge Proxy setup failed: downloaded certificate is empty.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the certificate to a custom path", async () => {
|
||||||
|
const { url } = setupServer((req) => {
|
||||||
|
const reqUrl = new URL(req.url);
|
||||||
|
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
|
||||||
|
return new Response(FAKE_CERT, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/x-pem-file" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass a custom cert_path to terraform apply so the script uses it.
|
||||||
|
const { id, instance } = await setupContainer({
|
||||||
|
cert_path: "/tmp/custom/certs/proxy-ca.pem",
|
||||||
|
});
|
||||||
|
|
||||||
|
const exec = await execContainer(id, [
|
||||||
|
"env",
|
||||||
|
`ACCESS_URL=${url}`,
|
||||||
|
"SESSION_TOKEN=mock-token",
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
instance.script,
|
||||||
|
]);
|
||||||
|
expect(exec.exitCode).toBe(0);
|
||||||
|
expect(exec.stdout).toContain(
|
||||||
|
"AI Bridge Proxy CA certificate saved to /tmp/custom/certs/proxy-ca.pem",
|
||||||
|
);
|
||||||
|
|
||||||
|
const certContent = await execContainer(id, [
|
||||||
|
"cat",
|
||||||
|
"/tmp/custom/certs/proxy-ca.pem",
|
||||||
|
]);
|
||||||
|
expect(certContent.stdout).toContain("BEGIN CERTIFICATE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create global proxy env vars via coder_env", async () => {
|
||||||
|
const state = await runTerraformApply(import.meta.dir, {
|
||||||
|
agent_id: "foo",
|
||||||
|
proxy_url: "https://aiproxy.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proxy env vars should NOT be set globally via coder_env.
|
||||||
|
// They are intended to be scoped to specific tool processes.
|
||||||
|
const proxyEnvVarNames = [
|
||||||
|
"HTTP_PROXY",
|
||||||
|
"HTTPS_PROXY",
|
||||||
|
"NODE_EXTRA_CA_CERTS",
|
||||||
|
"SSL_CERT_FILE",
|
||||||
|
"REQUESTS_CA_BUNDLE",
|
||||||
|
"CURL_CA_BUNDLE",
|
||||||
|
];
|
||||||
|
const proxyEnvVars = state.resources.filter(
|
||||||
|
(r) =>
|
||||||
|
r.type === "coder_env" &&
|
||||||
|
r.instances.some((i) =>
|
||||||
|
proxyEnvVarNames.includes(i.attributes.name as string),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(proxyEnvVars.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
registry/coder/modules/aibridge-proxy/main.tf
Normal file
81
registry/coder/modules/aibridge-proxy/main.tf
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.9"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
coder = {
|
||||||
|
source = "coder/coder"
|
||||||
|
version = ">= 2.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "agent_id" {
|
||||||
|
type = string
|
||||||
|
description = "The ID of a Coder agent."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "proxy_url" {
|
||||||
|
type = string
|
||||||
|
description = "The full URL of the AI Bridge Proxy. Include the port if not using standard ports (e.g. https://aiproxy.example.com or http://internal-proxy:8888)."
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = can(regex("^https?://", var.proxy_url))
|
||||||
|
error_message = "proxy_url must start with http:// or https://."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "cert_path" {
|
||||||
|
type = string
|
||||||
|
description = "Absolute path where the AI Bridge Proxy CA certificate will be saved."
|
||||||
|
default = "/tmp/aibridge-proxy/ca-cert.pem"
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = startswith(var.cert_path, "/")
|
||||||
|
error_message = "cert_path must be an absolute path."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "coder_workspace" "me" {}
|
||||||
|
|
||||||
|
data "coder_workspace_owner" "me" {}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
# Build the proxy URL with Coder authentication embedded.
|
||||||
|
# AI Bridge Proxy expects the Coder session token as the password
|
||||||
|
# in basic auth: http://coder:<token>@host:port
|
||||||
|
proxy_auth_url = replace(
|
||||||
|
var.proxy_url,
|
||||||
|
"://",
|
||||||
|
"://coder:${data.coder_workspace_owner.me.session_token}@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# These outputs are intended to be consumed by tool-specific modules,
|
||||||
|
# to set proxy environment variables scoped to their process, rather than globally.
|
||||||
|
output "proxy_auth_url" {
|
||||||
|
description = "The AI Bridge Proxy URL with Coder authentication embedded (http://coder:<token>@host:port)."
|
||||||
|
value = local.proxy_auth_url
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cert_path" {
|
||||||
|
description = "Path to the downloaded AI Bridge Proxy CA certificate."
|
||||||
|
value = var.cert_path
|
||||||
|
}
|
||||||
|
|
||||||
|
# Downloads the CA certificate from the Coder deployment.
|
||||||
|
# This runs on workspace start but does not block login, if the script
|
||||||
|
# fails, the workspace remains usable and the error is visible in the build logs.
|
||||||
|
# Tools that depend on the proxy will fail until the certificate is available.
|
||||||
|
resource "coder_script" "aibridge_proxy_setup" {
|
||||||
|
agent_id = var.agent_id
|
||||||
|
display_name = "AI Bridge Proxy Setup"
|
||||||
|
icon = "/icon/coder.svg"
|
||||||
|
run_on_start = true
|
||||||
|
start_blocks_login = false
|
||||||
|
script = templatefile("${path.module}/scripts/setup.sh", {
|
||||||
|
CERT_PATH = var.cert_path,
|
||||||
|
ACCESS_URL = data.coder_workspace.me.access_url,
|
||||||
|
SESSION_TOKEN = data.coder_workspace_owner.me.session_token,
|
||||||
|
})
|
||||||
|
}
|
||||||
210
registry/coder/modules/aibridge-proxy/main.tftest.hcl
Normal file
210
registry/coder/modules/aibridge-proxy/main.tftest.hcl
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
run "test_aibridge_proxy_basic" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "https://aiproxy.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = var.agent_id == "test-agent-id"
|
||||||
|
error_message = "Agent ID should match the input variable"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = var.proxy_url == "https://aiproxy.example.com"
|
||||||
|
error_message = "Proxy URL should match the input variable"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = var.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
|
||||||
|
error_message = "cert_path should default to /tmp/aibridge-proxy/ca-cert.pem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_empty_url_validation" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_failures = [
|
||||||
|
var.proxy_url,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_invalid_url_validation" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "aiproxy.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_failures = [
|
||||||
|
var.proxy_url,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_url_formats" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "https://aiproxy.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = can(regex("^https?://", var.proxy_url))
|
||||||
|
error_message = "Proxy URL should be a valid URL with scheme"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_https_with_port" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "https://aiproxy.example.com:8443"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = can(regex("^https?://", var.proxy_url))
|
||||||
|
error_message = "Proxy URL should support HTTPS with custom port"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_http_with_port" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "http://internal-proxy:8888"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = can(regex("^https?://", var.proxy_url))
|
||||||
|
error_message = "Proxy URL should support HTTP with custom port"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_empty_cert_path_validation" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "https://aiproxy.example.com"
|
||||||
|
cert_path = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_failures = [
|
||||||
|
var.cert_path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_relative_cert_path_validation" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "https://aiproxy.example.com"
|
||||||
|
cert_path = "relative/path/ca-cert.pem"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_failures = [
|
||||||
|
var.cert_path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_custom_cert_path" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "https://aiproxy.example.com"
|
||||||
|
cert_path = "/home/coder/.certs/ca-cert.pem"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = var.cert_path == "/home/coder/.certs/ca-cert.pem"
|
||||||
|
error_message = "cert_path should match the input variable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_script" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "https://aiproxy.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = coder_script.aibridge_proxy_setup.run_on_start == true
|
||||||
|
error_message = "Script should run on start"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = coder_script.aibridge_proxy_setup.start_blocks_login == false
|
||||||
|
error_message = "Script should not block login"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = coder_script.aibridge_proxy_setup.display_name == "AI Bridge Proxy Setup"
|
||||||
|
error_message = "Script display name should be 'AI Bridge Proxy Setup'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_auth_url_https" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "https://aiproxy.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
override_data {
|
||||||
|
target = data.coder_workspace_owner.me
|
||||||
|
values = {
|
||||||
|
session_token = "mock-session-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = output.proxy_auth_url == "https://coder:mock-session-token@aiproxy.example.com"
|
||||||
|
error_message = "proxy_auth_url should contain the mocked session token"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
|
||||||
|
error_message = "cert_path output should match the default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run "test_aibridge_proxy_auth_url_http_with_port" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "test-agent-id"
|
||||||
|
proxy_url = "http://internal-proxy:8888"
|
||||||
|
}
|
||||||
|
|
||||||
|
override_data {
|
||||||
|
target = data.coder_workspace_owner.me
|
||||||
|
values = {
|
||||||
|
session_token = "mock-session-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = output.proxy_auth_url == "http://coder:mock-session-token@internal-proxy:8888"
|
||||||
|
error_message = "proxy_auth_url should preserve the port"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
|
||||||
|
error_message = "cert_path output should match the default"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
registry/coder/modules/aibridge-proxy/scripts/setup.sh
Normal file
79
registry/coder/modules/aibridge-proxy/scripts/setup.sh
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
if [ -z "$CERT_PATH" ]; then
|
||||||
|
CERT_PATH="${CERT_PATH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ACCESS_URL" ]; then
|
||||||
|
ACCESS_URL="${ACCESS_URL}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SESSION_TOKEN" ]; then
|
||||||
|
SESSION_TOKEN="${SESSION_TOKEN}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Signal startup coordination.
|
||||||
|
# The trap ensures 'complete' is always called (even on failure) so dependent
|
||||||
|
# scripts unblock promptly and can check for the certificate themselves.
|
||||||
|
if command -v coder > /dev/null 2>&1; then
|
||||||
|
coder exp sync start "aibridge-proxy-setup" > /dev/null 2>&1 || true
|
||||||
|
trap 'coder exp sync complete "aibridge-proxy-setup" > /dev/null 2>&1 || true' EXIT
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ACCESS_URL" ]; then
|
||||||
|
echo "Error: Coder access URL is not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SESSION_TOKEN" ]; then
|
||||||
|
echo "Error: Coder session token is not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v curl > /dev/null; then
|
||||||
|
echo "Error: curl is not installed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--------------------------------"
|
||||||
|
echo "AI Bridge Proxy Setup"
|
||||||
|
printf "Certificate path: %s\n" "$CERT_PATH"
|
||||||
|
printf "Access URL: %s\n" "$ACCESS_URL"
|
||||||
|
echo "--------------------------------"
|
||||||
|
|
||||||
|
CERT_DIR=$(dirname "$CERT_PATH")
|
||||||
|
mkdir -p "$CERT_DIR"
|
||||||
|
|
||||||
|
CERT_URL="$ACCESS_URL/api/v2/aibridge/proxy/ca-cert.pem"
|
||||||
|
echo "Downloading AI Bridge Proxy CA certificate from $CERT_URL..."
|
||||||
|
|
||||||
|
# Download the certificate with a 5s connection timeout and 10s total timeout
|
||||||
|
# to avoid the script hanging indefinitely.
|
||||||
|
if ! HTTP_STATUS=$(curl -s -o "$CERT_PATH" -w "%%{http_code}" \
|
||||||
|
--connect-timeout 5 \
|
||||||
|
--max-time 10 \
|
||||||
|
-H "Coder-Session-Token: $SESSION_TOKEN" \
|
||||||
|
"$CERT_URL"); then
|
||||||
|
echo "❌ AI Bridge Proxy setup failed: could not connect to $CERT_URL."
|
||||||
|
echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace."
|
||||||
|
rm -f "$CERT_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HTTP_STATUS" -ne 200 ]; then
|
||||||
|
echo "❌ AI Bridge Proxy setup failed: unexpected response (HTTP $HTTP_STATUS)."
|
||||||
|
echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace."
|
||||||
|
rm -f "$CERT_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -s "$CERT_PATH" ]; then
|
||||||
|
echo "❌ AI Bridge Proxy setup failed: downloaded certificate is empty."
|
||||||
|
rm -f "$CERT_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "AI Bridge Proxy CA certificate saved to $CERT_PATH"
|
||||||
|
echo "✅ AI Bridge Proxy setup complete."
|
||||||
Loading…
x
Reference in New Issue
Block a user