diff --git a/.gitignore b/.gitignore index 8d1b05c91..fa7b1f931 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,4 @@ gha-creds-*.json # IDEs .idea +.serena diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index e7a9869fb..3b8364bd9 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI ```tf module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.1.1" + version = "2.2.0" agent_id = var.agent_id web_app_slug = local.app_slug @@ -62,6 +62,19 @@ module "agentapi" { } ``` +## Caching the AgentAPI binary + +When `agentapi_cache_dir` is set, the AgentAPI binary is cached to that directory after the first download. On subsequent workspace starts, the cached binary is used instead of re-downloading from GitHub. This is particularly useful when workspaces use a persistent volume. + +```tf +module "agentapi" { + # ... other config + agentapi_cache_dir = "/home/coder/.cache/agentapi" +} +``` + +The cached binary is stored with a name that includes the architecture and version (e.g., `agentapi-linux-amd64-v0.10.0`) so different versions can coexist in the same cache directory. + ## For module developers For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf). diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 20b47b1a0..6bec30926 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -149,6 +149,84 @@ describe("agentapi", async () => { expect(respAgentAPI.exitCode).toBe(0); }); + test("cache-dir-uses-cached-binary", async () => { + // Verify that when a cached binary exists in the cache dir, it is used + // instead of downloading. Use a pinned version so the cache filename is + // deterministic (resolving "latest" requires a network call). + const cacheDir = "/home/coder/.agentapi-cache"; + const pinnedVersion = "v0.10.0"; + const { id } = await setup({ + moduleVariables: { + install_agentapi: "true", + agentapi_cache_dir: cacheDir, + agentapi_version: pinnedVersion, + }, + }); + + // Derive the binary name from the container's architecture so the test is + // portable across amd64 and arm64 hosts. + const archResult = await execContainer(id, ["uname", "-m"]); + expect(archResult.exitCode).toBe(0); + const agentArch = + archResult.stdout.trim() === "aarch64" ? "linux-arm64" : "linux-amd64"; + + // Pre-populate the cache directory with a fake agentapi binary. + const cachedBinary = `${cacheDir}/agentapi-${agentArch}-${pinnedVersion}`; + await execContainer(id, [ + "bash", + "-c", + `mkdir -p ${cacheDir} && cp /usr/bin/agentapi ${cachedBinary}`, + ]); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + expect(respModuleScript.stdout).toContain( + `Using cached AgentAPI binary from ${cachedBinary}`, + ); + + await expectAgentAPIStarted(id); + }); + + test("cache-dir-saves-binary-after-download", async () => { + // Verify that after downloading agentapi, the binary is saved to the cache + // dir under the resolved version name. Use a pinned version so the cache + // filename is deterministic. + const cacheDir = "/home/coder/.agentapi-cache"; + const pinnedVersion = "v0.10.0"; + const { id } = await setup({ + skipAgentAPIMock: true, + moduleVariables: { + agentapi_cache_dir: cacheDir, + agentapi_version: pinnedVersion, + }, + }); + + // Derive the binary name from the container's architecture so the test is + // portable across amd64 and arm64 hosts. + const archResult = await execContainer(id, ["uname", "-m"]); + expect(archResult.exitCode).toBe(0); + const agentArch = + archResult.stdout.trim() === "aarch64" ? "linux-arm64" : "linux-amd64"; + + const cachedBinary = `${cacheDir}/agentapi-${agentArch}-${pinnedVersion}`; + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + expect(respModuleScript.stdout).toContain( + `Caching AgentAPI binary to ${cachedBinary}`, + ); + + await expectAgentAPIStarted(id); + + // Verify the binary was saved to the cache directory. + const respCacheCheck = await execContainer(id, [ + "bash", + "-c", + `test -f ${cachedBinary} && echo "cached"`, + ]); + expect(respCacheCheck.exitCode).toBe(0); + expect(respCacheCheck.stdout).toContain("cached"); + }); + test("no-subdomain-base-path", async () => { const { id } = await setup({ moduleVariables: { diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 6914be779..ff6fca847 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -164,6 +164,11 @@ variable "module_dir_name" { description = "Name of the subdirectory in the home directory for module files." } +variable "agentapi_cache_dir" { + type = string + description = "Path to a directory where the AgentAPI binary will be cached after download. On subsequent workspace starts, the cached binary is used instead of downloading again. Useful with persistent volumes to avoid repeated downloads." + default = "" +} locals { # we always trim the slash for consistency @@ -209,6 +214,7 @@ resource "coder_script" "agentapi" { ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \ ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ + ARG_CACHE_DIR="$(echo -n '${base64encode(var.agentapi_cache_dir)}' | base64 -d)" \ /tmp/main.sh EOT run_on_start = true diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 63e013eb9..0036901a3 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -16,6 +16,7 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" +CACHE_DIR="${ARG_CACHE_DIR:-}" set +o nounset command_exists() { @@ -62,6 +63,7 @@ if [ "${INSTALL_AGENTAPI}" = "true" ]; then echo "Error: Unsupported architecture: $arch" exit 1 fi + if [ "${AGENTAPI_VERSION}" = "latest" ]; then # for the latest release the download URL pattern is different than for tagged releases # https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases @@ -69,17 +71,70 @@ if [ "${INSTALL_AGENTAPI}" = "true" ]; then else download_url="https://github.com/coder/agentapi/releases/download/${AGENTAPI_VERSION}/$binary_name" fi - curl \ - --retry 5 \ - --retry-delay 5 \ - --fail \ - --retry-all-errors \ - -L \ - -C - \ - -o agentapi \ - "$download_url" - chmod +x agentapi - sudo mv agentapi /usr/local/bin/agentapi + + cached_binary="" + if [ -n "${CACHE_DIR}" ]; then + resolved_version="${AGENTAPI_VERSION}" + if [ "${AGENTAPI_VERSION}" = "latest" ]; then + # Resolve the actual version tag so the cache key is stable (e.g. v0.10.0, not "latest"). + # GitHub redirects /releases/latest to /releases/tag/vX.Y.Z; we extract the tag from that URL. + resolved_version=$(curl \ + --retry 3 \ + --retry-delay 3 \ + --fail \ + --retry-all-errors \ + -Ls \ + -o /dev/null \ + -w '%{url_effective}' \ + "https://github.com/coder/agentapi/releases/latest" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || true) + if [ -z "${resolved_version}" ]; then + echo "Warning: Failed to resolve latest AgentAPI version tag; proceeding without cache for this run." + else + echo "Resolved AgentAPI latest version to: ${resolved_version}" + fi + fi + if [ -n "${resolved_version}" ]; then + # Sanitize the version so it is safe to use as a filename component. + # Allow only alphanumerics, dots, underscores, and hyphens; replace others with '_'. + safe_version=$(printf '%s' "${resolved_version}" | tr -c 'A-Za-z0-9._-' '_') + cached_binary="${CACHE_DIR}/${binary_name}-${safe_version}" + fi + fi + + if [ -n "${cached_binary}" ] && [ -f "${cached_binary}" ]; then + echo "Using cached AgentAPI binary from ${cached_binary}" + cp "${cached_binary}" agentapi + chmod +x agentapi + sudo mv agentapi /usr/local/bin/agentapi + else + curl \ + --retry 5 \ + --retry-delay 5 \ + --fail \ + --retry-all-errors \ + -L \ + -C - \ + -o agentapi \ + "$download_url" + chmod +x agentapi + + if [ -n "${cached_binary}" ]; then + echo "Caching AgentAPI binary to ${cached_binary}" + # Write atomically via a temp file so concurrent workspace starts on a shared + # volume never observe a partially-written binary. + if ! mkdir -p "${CACHE_DIR}"; then + echo "Warning: Failed to create cache directory ${CACHE_DIR}. Continuing without caching." + else + tmp_cached_binary="${cached_binary}.$$" + if ! cp agentapi "${tmp_cached_binary}" || ! mv -f "${tmp_cached_binary}" "${cached_binary}"; then + rm -f "${tmp_cached_binary}" + echo "Warning: Failed to cache AgentAPI binary to ${cached_binary}. Continuing without caching." + fi + fi + fi + + sudo mv agentapi /usr/local/bin/agentapi + fi fi if ! command_exists agentapi; then echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."