From 8a5961a573c6f31d3e7a8e5ced7fd3d314e43d1d Mon Sep 17 00:00:00 2001 From: ksteffe Date: Tue, 6 Oct 2026 21:02:17 -0700 Subject: [PATCH] feat: add PADE module under after-certainty namespace --- .icons/pade.svg | 21 + registry/after-certainty/.images/avatar.svg | 4 + registry/after-certainty/README.md | 11 + .../after-certainty/modules/pade/README.md | 88 ++++ .../after-certainty/modules/pade/main.test.ts | 439 ++++++++++++++++++ registry/after-certainty/modules/pade/main.tf | 123 +++++ .../modules/pade/main.tftest.hcl | 241 ++++++++++ .../modules/pade/scripts/install.sh.tftpl | 88 ++++ 8 files changed, 1015 insertions(+) create mode 100644 .icons/pade.svg create mode 100644 registry/after-certainty/.images/avatar.svg create mode 100644 registry/after-certainty/README.md create mode 100644 registry/after-certainty/modules/pade/README.md create mode 100644 registry/after-certainty/modules/pade/main.test.ts create mode 100644 registry/after-certainty/modules/pade/main.tf create mode 100644 registry/after-certainty/modules/pade/main.tftest.hcl create mode 100644 registry/after-certainty/modules/pade/scripts/install.sh.tftpl diff --git a/.icons/pade.svg b/.icons/pade.svg new file mode 100644 index 000000000..3d6ace0a4 --- /dev/null +++ b/.icons/pade.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/registry/after-certainty/.images/avatar.svg b/registry/after-certainty/.images/avatar.svg new file mode 100644 index 000000000..e3e19946b --- /dev/null +++ b/registry/after-certainty/.images/avatar.svg @@ -0,0 +1,4 @@ + + + AC + diff --git a/registry/after-certainty/README.md b/registry/after-certainty/README.md new file mode 100644 index 000000000..6be8aa228 --- /dev/null +++ b/registry/after-certainty/README.md @@ -0,0 +1,11 @@ +--- +display_name: "After Certainty" +bio: "Open-source tools exploring portable development environments, runtime capability intent, and related infrastructure." +avatar: "./.images/avatar.svg" +github: "After-Certainty" +status: "community" +--- + +# After Certainty + +Open-source tools exploring portable development environments, runtime capability intent, and related infrastructure. diff --git a/registry/after-certainty/modules/pade/README.md b/registry/after-certainty/modules/pade/README.md new file mode 100644 index 000000000..f62690f43 --- /dev/null +++ b/registry/after-certainty/modules/pade/README.md @@ -0,0 +1,88 @@ +--- +display_name: PADE +description: Install the PADE CLI and bind a workspace to an existing PADE broker using runtime workload identity +icon: ../../../../.icons/pade.svg +verified: false +tags: [helper, integration, identity, security] +--- + +# PADE + +Installs the [PADE](https://github.com/After-Certainty/pade) CLI in a Coder workspace and writes broker-only capability bindings, so tools in the workspace can resolve explicitly configured capabilities through an existing PADE broker without durable provider credentials. + +```tf +module "pade" { + source = "registry.coder.com/after-certainty/pade/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + broker_endpoint = "https://pade-broker.example.com" + broker_capabilities = ["github.repo.read"] +} +``` + +## What it does + +On workspace start, the module: + +1. downloads the released PADE CLI for the workspace architecture; +2. verifies the archive against the release `SHA256SUMS`; +3. installs `pade` to `~/.local/bin`; +4. writes `~/.config/pade/coder-bindings.yaml` (mode `0600`) containing only the capabilities listed in `broker_capabilities`; +5. adds `~/.local/bin` to `PATH` and sets `PADE_BINDINGS` in normal shell startup files; +6. runs `pade --version` to confirm the install. + +Coder script lifecycle, synchronization, and logs are handled by `coder-utils`. It materializes the installer at `~/.coder-modules/after-certainty/pade/scripts/install.sh` and writes its output to `~/.coder-modules/after-certainty/pade/logs/install.log`. PADE's binary and bindings retain their tool-specific locations. + +`coder-utils` does not block login while installation runs. Wait for the PADE install script to finish before using `pade`. Dependent Coder scripts can use `coder exp sync want ${join(" ", module.pade.scripts)}` to wait for the install pipeline. + +Persistent home storage keeps the binary and bindings between starts; ephemeral home storage simply causes them to be recreated. + +It does not deploy a PADE broker, define broker-side authorization, or place provider credentials in the workspace or in Terraform state. The bindings file contains only the broker endpoint, audience, identity adapter, and capability names. + +`coder-utils` also reads Coder workspace-owner metadata. Its Terraform data-source state can include existing Coder control-plane credentials supplied by the runtime; these are not passed to PADE or used as workload identity. Keep Terraform state protected as for the parent Coder template. + +The module-managed bindings file is separate from PADE's default `~/.config/pade/bindings.yaml`, so user-managed bindings are not overwritten. + +## Prerequisites + +- A Linux workspace with `curl`, `tar`, `sha256sum`, `base64`, `awk`, and `find`. +- An existing PADE broker reachable over HTTPS that authorizes the workspace's workload identity. +- A workspace runtime that provides the selected workload identity (for `gce`, the GCE metadata server with an attached service account). + +## Proven vs. accepted + +Only the GCE path below has been validated end to end through this module. The [pade-coder integration evidence](https://github.com/After-Certainty/pade-coder/tree/main/experiments/001-gce-broker#live-result-after-coder-utils-migration-2026-10-06) records the successful dogfood run after the coder-utils migration: + +| Dimension | Proven through this module | Accepted, not yet validated here | +| ------------------- | -------------------------- | -------------------------------- | +| Workload identity | `gce` (GCE metadata) | `cursor` | +| Workspace OS / arch | Linux `amd64` | Linux `arm64` | +| PADE version | `v0.3.0` | other released versions | +| Capability | `github.repo.read` | other broker-authorized names | + +Accepting `broker_identity = "cursor"` reflects what PADE supports; it does not mean a Cursor-on-Coder path has been validated. + +## Identity boundary + +Coder owns the workspace lifecycle. The underlying runtime supplies workload identity. PADE consumes that identity and mediates authorized capabilities. The module does not treat Coder control-plane authentication as PADE identity. PADE is not a complete agent security solution. + +## Examples + +### Explicit broker audience + +```tf +module "pade" { + source = "registry.coder.com/after-certainty/pade/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + pade_version = "v0.3.0" + broker_endpoint = "https://pade-broker.example.com" + broker_audience = "pade-broker" + broker_identity = "gce" + broker_capabilities = ["github.repo.read"] +} +``` + +### Using the bindings + +New shells pick up `PADE_BINDINGS` automatically. Processes that do not source shell startup files can set `PADE_BINDINGS` to the `bindings_path` output or pass that path to `pade` with `--bindings`. diff --git a/registry/after-certainty/modules/pade/main.test.ts b/registry/after-certainty/modules/pade/main.test.ts new file mode 100644 index 000000000..a0e4505a1 --- /dev/null +++ b/registry/after-certainty/modules/pade/main.test.ts @@ -0,0 +1,439 @@ +import { readFile } from "node:fs/promises"; +import { + afterEach, + beforeAll, + describe, + expect, + it, + setDefaultTimeout, +} from "bun:test"; +import { + execContainer, + findResourceInstance, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +setDefaultTimeout(120_000); + +const IMAGE = "debian:bookworm-slim"; +const STUB_PATH = + "/stubs:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const RELEASE_URL = + "https://github.com/After-Certainty/pade/releases/download/v0.3.0"; +const MODULE_DIR = "/root/.coder-modules/after-certainty/pade"; +const SYNC_NAME = "after-certainty-pade-install_script"; +const BINDINGS_PATH = "/root/.config/pade/coder-bindings.yaml"; + +const requiredVars = { + agent_id: "foo", + broker_endpoint: "https://broker.example.com", + broker_capabilities: '["github.repo.read"]', +}; + +const CODER_STUB = `#!/bin/sh +[ "$1 $2" = "exp sync" ] || exit 1 +case "$3" in start|complete) ;; *) exit 1 ;; esac +[ "$4" = "${SYNC_NAME}" ] || exit 1 +echo "$@" >> /tmp/coder.log +`; + +const renderedInstaller = (script: string) => { + const encoded = script.match( + /echo -n '([^']+)' \| base64 -d > .*\/scripts\/install\.sh/, + ); + expect(encoded).not.toBeNull(); + return Buffer.from(encoded![1], "base64").toString(); +}; + +// Serves release assets from /fixtures and records every requested URL. +const CURL_STUB = `#!/bin/sh +out="" +url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + --retry|--connect-timeout|--max-time) shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +echo "$url" >> /tmp/curl.log +src="/fixtures/$(basename "$url")" +[ -f "$src" ] || { echo "curl: (22) not found: $url" >&2; exit 22; } +cp "$src" "$out" +`; + +const UNAME_STUB = `#!/bin/sh +case "$1" in + -s) echo "\${FAKE_UNAME_S:-Linux}" ;; + -m) echo "\${FAKE_UNAME_M:-x86_64}" ;; + *) exec /usr/bin/uname "$@" ;; +esac +`; + +const FIXTURES = `mkdir -p /fixtures +for arch in amd64 arm64; do + dir="/build/$arch/pade-v0.3.0-linux-$arch" + mkdir -p "$dir" + cat > "$dir/pade" <<'EOF' +#!/bin/sh +echo "$@" >> /tmp/pade.log +echo "pade v0.3.0" +EOF + chmod 0755 "$dir/pade" + tar -czf "/fixtures/pade-v0.3.0-linux-$arch.tar.gz" -C "/build/$arch" . +done +cd /fixtures && sha256sum pade-*.tar.gz > SHA256SUMS +`; + +let cleanupFunctions: (() => Promise)[] = []; +afterEach(async () => { + const cleanups = cleanupFunctions.reverse(); + cleanupFunctions = []; + for (const cleanup of cleanups) { + await cleanup(); + } +}); + +const setup = async ( + vars: Record = {}, + customEnv: Record = {}, +) => { + const state = await runTerraformApply( + import.meta.dir, + { + ...requiredVars, + ...vars, + }, + customEnv, + ); + const instance = findResourceInstance( + state, + "coder_script", + "install_script", + ); + const id = await runContainer(IMAGE); + cleanupFunctions.push(() => removeContainer(id)); + + // Each docker exec is slow on some hosts, so prepare the container in one call. + const writeFile = (path: string, content: string, mode: string) => + `echo '${Buffer.from(content).toString("base64")}' | base64 -d > ${path} && chmod ${mode} ${path}`; + const bootstrap = await execContainer(id, [ + "sh", + "-c", + [ + "set -eu", + "mkdir -p /stubs", + writeFile("/stubs/curl", CURL_STUB, "0755"), + writeFile("/stubs/uname", UNAME_STUB, "0755"), + writeFile("/stubs/coder", CODER_STUB, "0755"), + writeFile("/tmp/run.sh", instance.script, "0644"), + FIXTURES, + ].join("\n"), + ]); + expect(bootstrap.stderr).toBe(""); + expect(bootstrap.exitCode).toBe(0); + + const run = (env: Record = {}) => + execContainer( + id, + ["bash", "/tmp/run.sh"], + [ + "-e", + `PATH=${STUB_PATH}`, + ...Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]), + ], + ); + const sh = (command: string) => execContainer(id, ["sh", "-c", command]); + + return { state, instance, id, run, sh }; +}; + +describe("pade", () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + testRequiredVariables(import.meta.dir, requiredVars); + + it("uses only coder-utils install orchestration and preserves the rendered installer", async () => { + const { state, instance, id, run, sh } = await setup(); + expect( + state.resources + .filter((resource) => resource.type === "coder_script") + .map((resource) => [resource.module, resource.name]), + ).toEqual([["module.coder_utils", "install_script"]]); + expect(instance.agent_id).toBe(requiredVars.agent_id); + expect(state.outputs.scripts.value).toEqual([SYNC_NAME]); + const attributes = state.resources.find( + (resource) => resource.type === "coder_script", + )!.instances[0].attributes; + expect(attributes.display_name).toBe("PADE: Install Script"); + expect(attributes.run_on_start).toBe(true); + expect(attributes.start_blocks_login).toBe(false); + const template = await readFile( + `${import.meta.dir}/scripts/install.sh.tftpl`, + "utf8", + ); + const bindings = Buffer.from( + renderedInstaller(instance.script).match(/^BINDINGS_B64='([^']*)'$/m)![1], + "base64", + ).toString(); + expect(renderedInstaller(instance.script)).toBe( + template + .replace("${PADE_VERSION}", "v0.3.0") + .replace("${BINDINGS_B64}", Buffer.from(bindings).toString("base64")), + ); + const result = await run(); + expect(result.exitCode).toBe(0); + expect( + await readFileContainer(id, `${MODULE_DIR}/scripts/install.sh`), + ).toBe(renderedInstaller(instance.script)); + expect(await readFileContainer(id, `${MODULE_DIR}/logs/install.log`)).toBe( + result.stdout, + ); + expect((await sh("cat /tmp/coder.log")).stdout.trim().split("\n")).toEqual([ + `exp sync start ${SYNC_NAME}`, + `exp sync complete ${SYNC_NAME}`, + ]); + }); + + it("installs the amd64 release and runs pade --version", async () => { + const { run, sh } = await setup(); + const result = await run(); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("installing PADE v0.3.0 (linux/amd64)"); + expect(result.stdout).toContain("pade v0.3.0"); + + const requested = await sh("cat /tmp/curl.log"); + expect(requested.stdout.trim().split("\n")).toEqual([ + `${RELEASE_URL}/pade-v0.3.0-linux-amd64.tar.gz`, + `${RELEASE_URL}/SHA256SUMS`, + ]); + + const invocations = await sh("cat /tmp/pade.log"); + expect(invocations.stdout.trim()).toBe("--version"); + + const mode = await sh("stat -c %a /root/.local/bin/pade"); + expect(mode.stdout.trim()).toBe("755"); + }); + + it("selects the arm64 release on aarch64", async () => { + const { run, sh } = await setup(); + const result = await run({ FAKE_UNAME_M: "aarch64" }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("(linux/arm64)"); + + const requested = await sh("head -n 1 /tmp/curl.log"); + expect(requested.stdout.trim()).toBe( + `${RELEASE_URL}/pade-v0.3.0-linux-arm64.tar.gz`, + ); + }); + + it("installs the requested pade_version", async () => { + const { run, sh } = await setup({ pade_version: "0.4.0" }); + const fixtures = await sh(`cd /fixtures +cp pade-v0.3.0-linux-amd64.tar.gz pade-v0.4.0-linux-amd64.tar.gz +sha256sum pade-v0.4.0-linux-amd64.tar.gz >> SHA256SUMS`); + expect(fixtures.exitCode).toBe(0); + const result = await run(); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("installing PADE v0.4.0 (linux/amd64)"); + + const requested = await sh("cat /tmp/curl.log"); + expect(requested.stdout.trim().split("\n")).toEqual([ + "https://github.com/After-Certainty/pade/releases/download/v0.4.0/pade-v0.4.0-linux-amd64.tar.gz", + "https://github.com/After-Certainty/pade/releases/download/v0.4.0/SHA256SUMS", + ]); + }); + + it("rejects unsupported architectures before downloading", async () => { + const { run, sh } = await setup(); + const result = await run({ FAKE_UNAME_M: "riscv64" }); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain( + "unsupported workspace architecture: riscv64", + ); + expect((await sh("test -e /tmp/curl.log")).exitCode).not.toBe(0); + }); + + it("rejects non-Linux workspaces before downloading", async () => { + const { run, sh } = await setup(); + const result = await run({ FAKE_UNAME_S: "Darwin" }); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain( + "Linux workspaces are required", + ); + expect((await sh("test -e /tmp/curl.log")).exitCode).not.toBe(0); + }); + + it("fails on a checksum mismatch without installing", async () => { + const { run, sh } = await setup(); + await sh( + "sed -i 's/^[0-9a-f]*\\( pade-v0.3.0-linux-amd64\\)/0000000000000000000000000000000000000000000000000000000000000000\\1/' /fixtures/SHA256SUMS", + ); + const result = await run(); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain("FAILED"); + expect((await sh("test -e /root/.local/bin/pade")).exitCode).not.toBe(0); + expect((await sh(`test -e ${BINDINGS_PATH}`)).exitCode).not.toBe(0); + }); + + it("fails when the asset is missing from SHA256SUMS", async () => { + const { run, sh } = await setup(); + await sh("sed -i '/linux-amd64/d' /fixtures/SHA256SUMS"); + const result = await run(); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain( + "release checksum does not contain pade-v0.3.0-linux-amd64.tar.gz", + ); + expect((await sh("test -e /root/.local/bin/pade")).exitCode).not.toBe(0); + }); + + it("writes only the requested broker bindings", async () => { + const { id, run, sh } = await setup({ + broker_endpoint: "https://other-broker.example.com", + broker_audience: "pade-broker", + broker_identity: "cursor", + broker_capabilities: '["github.repo.read", "example.capability"]', + }); + const result = await run(); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + `broker bindings written to ${BINDINGS_PATH}`, + ); + + const mode = await sh(`stat -c %a ${BINDINGS_PATH}`); + expect(mode.stdout.trim()).toBe("600"); + + const broker = { + endpoint: "https://other-broker.example.com", + audience: "pade-broker", + identity: "cursor", + }; + const bindings = Bun.YAML.parse(await readFileContainer(id, BINDINGS_PATH)); + expect(bindings).toEqual({ + version: "0.1", + capabilities: { + "github.repo.read": { provider: "broker", broker }, + "example.capability": { provider: "broker", broker }, + }, + }); + }); + + it("configures shell startup files idempotently", async () => { + const { run, sh } = await setup(); + await sh( + "mkdir -p /root/.config/fish && touch /root/.bashrc /root/.zshrc /root/.config/fish/config.fish && rm -f /root/.bash_profile /root/.zprofile", + ); + expect((await run()).exitCode).toBe(0); + expect((await run()).exitCode).toBe(0); + + const counts = await sh(`cd /root +for f in .profile .bashrc .zshrc; do + echo "$f path $(grep -cxF 'export PATH="$HOME/.local/bin:$PATH"' "$f")" + echo "$f bindings $(grep -cxF 'export PADE_BINDINGS="$HOME/.config/pade/coder-bindings.yaml"' "$f")" +done +f=.config/fish/config.fish +echo "fish path $(grep -cxF 'fish_add_path $HOME/.local/bin' "$f")" +echo "fish bindings $(grep -cxF 'set -gx PADE_BINDINGS $HOME/.config/pade/coder-bindings.yaml' "$f")" +for f in .bash_profile .zprofile; do + if [ -e "$f" ]; then echo "$f created"; fi +done`); + expect(counts.stdout.trim().split("\n")).toEqual([ + ".profile path 1", + ".profile bindings 1", + ".bashrc path 1", + ".bashrc bindings 1", + ".zshrc path 1", + ".zshrc bindings 1", + "fish path 1", + "fish bindings 1", + ]); + + const login = await sh( + "env -i HOME=/root PATH=/usr/bin:/bin bash -lc 'command -v pade && echo \"$PADE_BINDINGS\"'", + ); + expect(login.stdout.trim().split("\n")).toEqual([ + "/root/.local/bin/pade", + BINDINGS_PATH, + ]); + }); + + it("keeps credential material out of state and the installer", async () => { + const coderSession = "test-only-coder-session-token"; + const { state, instance, id, run } = await setup( + {}, + { + CODER_WORKSPACE_OWNER_SESSION_TOKEN: coderSession, + }, + ); + const credentialPattern = + /token|secret|password|private key|ghp_|github_pat_|gho_|ghs_/i; + const installer = renderedInstaller(instance.script); + expect(instance.script).not.toMatch(credentialPattern); + expect(installer).not.toMatch(credentialPattern); + // Upstream coder-utils reads owner metadata, including Coder's existing + // control-plane session token. Allow only that exact fixture in that field; + // it must never reach the PADE script, bindings, logs, or other state. + const checkValues = (value: unknown): void => { + if (typeof value === "string") + expect(value).not.toMatch(credentialPattern); + else if (Array.isArray(value)) value.forEach(checkValues); + else if (value && typeof value === "object") + Object.values(value).forEach(checkValues); + }; + const owner = state.resources.find( + (resource) => + resource.module === "module.coder_utils" && + resource.mode === "data" && + resource.type === "coder_workspace_owner", + ); + expect(owner).toBeDefined(); + expect(owner!.instances[0].attributes.session_token).toBe(coderSession); + for (const resource of state.resources) { + for (const instance of resource.instances) { + const { session_token, ...attributes } = instance.attributes; + if (resource === owner) { + expect(session_token).toBe(coderSession); + expect(attributes.ssh_private_key).toBe(""); + if ("oidc_access_token" in attributes) + expect(attributes.oidc_access_token).toBe(""); + } else checkValues(session_token); + checkValues(attributes); + } + } + checkValues(state.outputs); + expect((await run()).exitCode).toBe(0); + expect( + await readFileContainer(id, `${MODULE_DIR}/scripts/install.sh`), + ).toBe(installer); + expect( + await readFileContainer(id, `${MODULE_DIR}/logs/install.log`), + ).not.toMatch(credentialPattern); + + const embedded = installer.match(/^BINDINGS_B64='([^']*)'$/m); + expect(embedded).not.toBeNull(); + const bindings = Bun.YAML.parse( + Buffer.from(embedded![1], "base64").toString(), + ); + expect(bindings).toEqual({ + version: "0.1", + capabilities: { + "github.repo.read": { + provider: "broker", + broker: { + endpoint: "https://broker.example.com", + audience: "https://broker.example.com", + identity: "gce", + }, + }, + }, + }); + }); +}); diff --git a/registry/after-certainty/modules/pade/main.tf b/registry/after-certainty/modules/pade/main.tf new file mode 100644 index 000000000..cdb68b317 --- /dev/null +++ b/registry/after-certainty/modules/pade/main.tf @@ -0,0 +1,123 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.13" + } + } +} + +variable "agent_id" { + description = "The ID of the Coder agent for the workspace that will consume PADE." + type = string +} + +variable "pade_version" { + description = "PADE release version to install." + type = string + default = "v0.3.0" + + validation { + condition = can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+$", var.pade_version)) + error_message = "pade_version must be a semantic version such as v0.3.0 or 0.3.0." + } +} + +variable "broker_endpoint" { + description = "The HTTPS endpoint of an existing PADE broker." + type = string + + validation { + condition = can(regex("^https://", var.broker_endpoint)) + error_message = "broker_endpoint must be an HTTPS URL." + } +} + +variable "broker_audience" { + description = "OIDC audience requested for the PADE broker. Defaults to broker_endpoint." + type = string + default = null + nullable = true + + validation { + condition = var.broker_audience == null ? true : trimspace(var.broker_audience) != "" + error_message = "broker_audience must be null or a non-empty string." + } +} + +variable "broker_identity" { + description = "PADE workload identity adapter. The GCE path is the integration proven by Experiment 001." + type = string + default = "gce" + + validation { + condition = contains(["gce", "cursor"], var.broker_identity) + error_message = "broker_identity must be one of the identity adapters supported by the current PADE Consumer: gce or cursor." + } +} + +variable "broker_capabilities" { + description = "Capability names that this workspace should resolve through the configured PADE broker." + type = set(string) + + validation { + condition = ( + length(var.broker_capabilities) > 0 && + alltrue([for capability in var.broker_capabilities : trimspace(capability) != ""]) + ) + error_message = "broker_capabilities must contain at least one non-empty capability name." + } +} + +locals { + pade_version = startswith(var.pade_version, "v") ? var.pade_version : "v${var.pade_version}" + broker_audience = var.broker_audience != null ? var.broker_audience : var.broker_endpoint + + bindings = yamlencode({ + version = "0.1" + capabilities = { + for capability in var.broker_capabilities : capability => { + provider = "broker" + broker = { + endpoint = var.broker_endpoint + audience = local.broker_audience + identity = var.broker_identity + } + } + } + }) +} + +locals { + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + PADE_VERSION = local.pade_version + BINDINGS_B64 = base64encode(local.bindings) + }) +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.2" + + agent_id = var.agent_id + module_directory = "$HOME/.coder-modules/after-certainty/pade" + display_name_prefix = "PADE" + install_script = local.install_script +} + +output "bindings_path" { + description = "Workspace-local path written by this module. Set PADE_BINDINGS to this path or pass it with --bindings." + value = "~/.config/pade/coder-bindings.yaml" +} + +output "pade_version" { + description = "PADE version installed by the module." + value = local.pade_version +} + +output "scripts" { + description = "Ordered list of coder exp sync names produced by this module, in run order." + value = module.coder_utils.scripts +} diff --git a/registry/after-certainty/modules/pade/main.tftest.hcl b/registry/after-certainty/modules/pade/main.tftest.hcl new file mode 100644 index 000000000..0912174c0 --- /dev/null +++ b/registry/after-certainty/modules/pade/main.tftest.hcl @@ -0,0 +1,241 @@ +mock_provider "coder" {} + +variables { + agent_id = "test-agent-id" + broker_endpoint = "https://broker.example.com" + broker_capabilities = ["github.repo.read"] +} + +run "defaults" { + command = plan + + assert { + condition = output.pade_version == "v0.3.0" + error_message = "pade_version should default to v0.3.0" + } + + assert { + condition = output.bindings_path == "~/.config/pade/coder-bindings.yaml" + error_message = "bindings_path output should be stable" + } + + assert { + condition = local.broker_audience == "https://broker.example.com" + error_message = "broker_audience should default to broker_endpoint" + } + + assert { + condition = yamldecode(local.bindings) == { + version = "0.1" + capabilities = { + "github.repo.read" = { + provider = "broker" + broker = { + endpoint = "https://broker.example.com" + audience = "https://broker.example.com" + identity = "gce" + } + } + } + } + error_message = "bindings should contain only the requested capability with default broker coordinates" + } + + assert { + condition = output.scripts == ["after-certainty-pade-install_script"] + error_message = "only the install synchronization name should be exposed" + } + + assert { + condition = strcontains(local.install_script, "PADE_VERSION='v0.3.0'") + error_message = "installer should be rendered with the normalized PADE version" + } + + assert { + condition = strcontains(local.install_script, "BINDINGS_B64='${base64encode(local.bindings)}'") + error_message = "installer should embed the generated bindings" + } +} + +run "version_normalization" { + command = plan + + variables { + pade_version = "0.4.1" + } + + assert { + condition = output.pade_version == "v0.4.1" + error_message = "pade_version without a leading v should be normalized" + } + + assert { + condition = strcontains(local.install_script, "PADE_VERSION='v0.4.1'") + error_message = "installer should receive the normalized version" + } +} + +run "version_with_prefix_unchanged" { + command = plan + + variables { + pade_version = "v0.4.1" + } + + assert { + condition = output.pade_version == "v0.4.1" + error_message = "pade_version with a leading v should be preserved" + } +} + +run "invalid_version" { + command = plan + + variables { + pade_version = "latest" + } + + expect_failures = [var.pade_version] +} + +run "non_https_endpoint" { + command = plan + + variables { + broker_endpoint = "http://broker.example.com" + } + + expect_failures = [var.broker_endpoint] +} + +run "explicit_audience" { + command = plan + + variables { + broker_audience = "pade-broker" + } + + assert { + condition = yamldecode(local.bindings).capabilities["github.repo.read"].broker.audience == "pade-broker" + error_message = "explicit broker_audience should be written to bindings" + } + + assert { + condition = yamldecode(local.bindings).capabilities["github.repo.read"].broker.endpoint == "https://broker.example.com" + error_message = "explicit audience should not change the endpoint" + } +} + +run "empty_audience" { + command = plan + + variables { + broker_audience = " " + } + + expect_failures = [var.broker_audience] +} + +run "identity_gce" { + command = plan + + variables { + broker_identity = "gce" + } + + assert { + condition = yamldecode(local.bindings).capabilities["github.repo.read"].broker.identity == "gce" + error_message = "gce identity should be written to bindings" + } +} + +run "identity_cursor" { + command = plan + + variables { + broker_identity = "cursor" + } + + assert { + condition = yamldecode(local.bindings).capabilities["github.repo.read"].broker.identity == "cursor" + error_message = "cursor identity should be accepted and written to bindings" + } +} + +run "identity_unsupported" { + command = plan + + variables { + broker_identity = "aws" + } + + expect_failures = [var.broker_identity] +} + +run "empty_capabilities" { + command = plan + + variables { + broker_capabilities = [] + } + + expect_failures = [var.broker_capabilities] +} + +run "blank_capability" { + command = plan + + variables { + broker_capabilities = ["github.repo.read", " "] + } + + expect_failures = [var.broker_capabilities] +} + +run "multiple_capabilities" { + command = plan + + variables { + broker_endpoint = "https://other-broker.example.com" + broker_audience = "aud" + broker_identity = "cursor" + broker_capabilities = ["github.repo.read", "example.capability"] + } + + assert { + condition = toset(keys(yamldecode(local.bindings).capabilities)) == toset(["example.capability", "github.repo.read"]) + error_message = "bindings should contain exactly the requested capabilities" + } + + assert { + condition = alltrue([ + for capability in values(yamldecode(local.bindings).capabilities) : + capability == { + provider = "broker" + broker = { + endpoint = "https://other-broker.example.com" + audience = "aud" + identity = "cursor" + } + } + ]) + error_message = "every binding should use the broker provider with the configured endpoint, audience, and identity" + } + + assert { + condition = toset(keys(yamldecode(local.bindings))) == toset(["capabilities", "version"]) + error_message = "bindings should contain only version and capabilities" + } +} + +run "no_credential_material" { + command = plan + + assert { + condition = !can(regex( + "(?i)(token|secret|password|private key|ghp_|github_pat_|gho_|ghs_)", + "${local.install_script}\n${local.bindings}", + )) + error_message = "installer and bindings must not contain credential material" + } +} diff --git a/registry/after-certainty/modules/pade/scripts/install.sh.tftpl b/registry/after-certainty/modules/pade/scripts/install.sh.tftpl new file mode 100644 index 000000000..eed02c531 --- /dev/null +++ b/registry/after-certainty/modules/pade/scripts/install.sh.tftpl @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +PADE_VERSION='${PADE_VERSION}' +BINDINGS_B64='${BINDINGS_B64}' +INSTALL_DIR="$HOME/.local/bin" +BINDINGS_PATH="$HOME/.config/pade/coder-bindings.yaml" + +die() { + echo "pade-coder: $*" >&2 + exit 1 +} + +for command in curl tar sha256sum base64 awk find; do + command -v "$command" > /dev/null 2>&1 || die "required command not found: $command" +done + +case "$(uname -s)" in + Linux) os="linux" ;; + *) die "Linux workspaces are required" ;; +esac + +case "$(uname -m)" in + x86_64 | amd64) arch="amd64" ;; + aarch64 | arm64) arch="arm64" ;; + *) die "unsupported workspace architecture: $(uname -m)" ;; +esac + +asset="pade-$PADE_VERSION-$os-$arch.tar.gz" +base_url="https://github.com/After-Certainty/pade/releases/download/$PADE_VERSION" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "pade-coder: installing PADE $PADE_VERSION ($os/$arch)" + +curl --fail --silent --show-error --location --retry 2 \ + --connect-timeout 10 --max-time 300 \ + -o "$tmp/$asset" "$base_url/$asset" +curl --fail --silent --show-error --location --retry 2 \ + --connect-timeout 10 --max-time 60 \ + -o "$tmp/SHA256SUMS" "$base_url/SHA256SUMS" + +awk -v asset="$asset" '$2 == asset { print }' "$tmp/SHA256SUMS" > "$tmp/SHA256SUMS.asset" +test -s "$tmp/SHA256SUMS.asset" || die "release checksum does not contain $asset" +(cd "$tmp" && sha256sum -c SHA256SUMS.asset) + +mkdir -p "$tmp/extract" "$INSTALL_DIR" "$(dirname "$BINDINGS_PATH")" +tar -xzf "$tmp/$asset" -C "$tmp/extract" +pade_binary="$(find "$tmp/extract" -type f -name pade -print -quit)" +test -n "$pade_binary" || die "release archive did not contain the pade binary" + +cp "$pade_binary" "$INSTALL_DIR/pade" +chmod 0755 "$INSTALL_DIR/pade" + +bindings_tmp="$(mktemp "$BINDINGS_PATH.XXXXXX")" +printf '%s' "$BINDINGS_B64" | base64 -d > "$bindings_tmp" +chmod 0600 "$bindings_tmp" +mv "$bindings_tmp" "$BINDINGS_PATH" + +add_shell_exports() { + profile="$1" + touch "$profile" + grep -qF 'export PATH="$HOME/.local/bin:$PATH"' "$profile" \ + || printf '\n# pade-coder\nexport PATH="$HOME/.local/bin:$PATH"\n' >> "$profile" + grep -qF 'export PADE_BINDINGS="$HOME/.config/pade/coder-bindings.yaml"' "$profile" \ + || printf 'export PADE_BINDINGS="$HOME/.config/pade/coder-bindings.yaml"\n' >> "$profile" +} + +add_shell_exports "$HOME/.profile" +for profile in "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do + if [ -f "$profile" ]; then + add_shell_exports "$profile" + fi +done + +if [ -f "$HOME/.config/fish/config.fish" ]; then + grep -qF 'fish_add_path $HOME/.local/bin' "$HOME/.config/fish/config.fish" \ + || printf '\n# pade-coder\nfish_add_path $HOME/.local/bin\n' >> "$HOME/.config/fish/config.fish" + grep -qF 'set -gx PADE_BINDINGS $HOME/.config/pade/coder-bindings.yaml' "$HOME/.config/fish/config.fish" \ + || printf 'set -gx PADE_BINDINGS $HOME/.config/pade/coder-bindings.yaml\n' >> "$HOME/.config/fish/config.fish" +fi + +export PATH="$INSTALL_DIR:$PATH" +export PADE_BINDINGS="$BINDINGS_PATH" + +"$INSTALL_DIR/pade" --version +echo "pade-coder: broker bindings written to $BINDINGS_PATH" +echo "pade-coder: Coder owns workspace lifecycle; workload identity comes from the workspace runtime"