From 3b69eba39b1b8f327907cefd51fb4caba6e1408a Mon Sep 17 00:00:00 2001 From: core-devops Date: Sat, 6 Jun 2026 15:53:33 -0700 Subject: [PATCH] feat(platform-agent): dedicated molecule-platform-agent image build (Phase 3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC molecule-core docs/design/rfc-platform-agent.md §5.7. Multi-stage image FROM workspace-template-claude-code that bakes molecule-mcp-server at /opt/molecule-mcp-server/dist/index.js — the exact path the platform agent's config.yaml mcp_servers entry references (template _apply_extra_mcp_servers, PR #91) — and pins Node 20 (slim base ships 18; the org MCP needs >=20). Keeps the org-admin MCP out of ordinary workspace images. publish-platform-agent-image.yml mirrors the template's proven ECR publish (op-host [publish,release] runners, build->smoke->push, promote-pin), adding a cross-repo checkout of molecule-mcp-server and a Node20+baked-entry smoke. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../publish-platform-agent-image.yml | 211 ++++++++++++++++++ images/platform-agent/Dockerfile | 74 ++++++ 2 files changed, 285 insertions(+) create mode 100644 .github/workflows/publish-platform-agent-image.yml create mode 100644 images/platform-agent/Dockerfile diff --git a/.github/workflows/publish-platform-agent-image.yml b/.github/workflows/publish-platform-agent-image.yml new file mode 100644 index 0000000..7851125 --- /dev/null +++ b/.github/workflows/publish-platform-agent-image.yml @@ -0,0 +1,211 @@ +name: publish-platform-agent-image + +# Builds the dedicated molecule-platform-agent image (images/platform-agent/Dockerfile) +# and pushes it to ECR as `/molecule-ai/molecule-platform-agent:latest` + `:sha-<7>`. +# RFC: molecule-core docs/design/rfc-platform-agent.md §5.7. +# +# The image is FROM workspace-template-claude-code and bakes molecule-mcp-server +# at /opt/molecule-mcp-server/dist/index.js (the path the platform agent's +# config.yaml mcp_servers entry references). Because it is a multi-stage build +# of a SECOND repo (molecule-mcp-server), this workflow checks that repo out into +# ./images/platform-agent/mcp-server before building. +# +# Mirrors molecule-ai-workspace-template-claude-code/.gitea/workflows/publish-image.yml +# (the proven ECR pattern) including the hard-won runner placement: +# - runs-on: [publish, release] pins to op-host molecule-runner-publish-{1,2}; +# `ubuntu-latest`/`publish`-only would non-deterministically land on a +# Windows self-hosted runner where `docker login --password-stdin` fails. +# - GITHUB_SERVER_URL pinned at workflow level (act_runner regen safety). +# +# REQUIRED repo secrets (provision on molecule-ai/molecule-ci before this is green): +# - AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (ECR push, acct 153263036946) +# - MCP_CHECKOUT_TOKEN (read token for cross-repo +# checkout of molecule-mcp-server) +# - CP_ADMIN_API_TOKEN / CP_ADMIN_API_TOKEN_STAGING (promote-pin; requires the +# CP to accept template_name +# "platform-agent" in +# runtime_image_pins — Phase 3c) + +on: + push: + branches: [main] + workflow_dispatch: + +env: + GITHUB_SERVER_URL: https://git.moleculesai.app + ECR_REGISTRY: 153263036946.dkr.ecr.us-east-2.amazonaws.com + IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/molecule-platform-agent + AWS_DEFAULT_REGION: us-east-2 + # Pin the base workspace image. :latest tracks the current claude-code + # template; bump to a :sha-<7> tag for a fully reproducible build. + BASE_IMAGE: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/workspace-template-claude-code:latest + +permissions: + contents: read + +jobs: + resolve-version: + name: Resolve sha + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + sha: ${{ steps.read.outputs.sha }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - id: read + shell: bash + run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + publish: + name: Build & push molecule-platform-agent image + runs-on: [publish, release] + timeout-minutes: 30 + needs: resolve-version + outputs: + digest: ${{ steps.push.outputs.digest }} + steps: + - name: Checkout molecule-ci + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Checkout molecule-mcp-server into build context + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: molecule-ai/molecule-mcp-server + ref: main + token: ${{ secrets.MCP_CHECKOUT_TOKEN }} + path: images/platform-agent/mcp-server + + - name: Log in to ECR + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: | + set -euo pipefail + aws ecr get-login-password --region us-east-2 | \ + docker login --username AWS --password-stdin "${ECR_REGISTRY}" + + - name: Verify Docker daemon access + run: | + set -euo pipefail + docker info >/dev/null 2>&1 || { echo "::error::Docker daemon not accessible"; exit 1; } + echo "Docker daemon OK" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Ensure ECR repository exists + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: | + set -euo pipefail + repo_path="${IMAGE_NAME#*/}" + repo_path="${repo_path#*/}" # → molecule-ai/molecule-platform-agent + if ! aws ecr describe-repositories --repository-names "${repo_path}" --region us-east-2 >/dev/null 2>&1; then + aws ecr create-repository --repository-name "${repo_path}" \ + --image-scanning-configuration scanOnPush=true --region us-east-2 >/dev/null + echo "::notice::created ECR repository ${repo_path}" + else + echo "ECR repository ${repo_path} already exists" + fi + + - name: Build image (load for smoke test, do not push yet) + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ./images/platform-agent + file: ./images/platform-agent/Dockerfile + platforms: linux/amd64 + load: true + push: false + tags: ${{ env.IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }} + build-args: | + BASE_IMAGE=${{ env.BASE_IMAGE }} + labels: | + org.opencontainers.image.source=https://git.moleculesai.app/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.description=Molecule AI org-level platform agent (claude-code + org-management MCP) + + - name: Smoke test — Node 20 + baked MCP entry present and parses + shell: bash + env: + IMAGE: ${{ env.IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }} + run: | + set -eu + docker run --rm --entrypoint sh "${IMAGE}" -c ' + set -e + node --version | grep -E "^v20\." || { echo "::error::Node is not v20"; exit 1; } + test -f /opt/molecule-mcp-server/dist/index.js + node --check /opt/molecule-mcp-server/dist/index.js + test -d /opt/molecule-mcp-server/node_modules/@modelcontextprotocol + echo "platform-agent image smoke OK: node20 + baked MCP entry parses" + ' + + - name: Push image to ECR (post-smoke) + id: push + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ./images/platform-agent + file: ./images/platform-agent/Dockerfile + platforms: linux/amd64 + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }} + build-args: | + BASE_IMAGE=${{ env.BASE_IMAGE }} + labels: | + org.opencontainers.image.source=https://git.moleculesai.app/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.description=Molecule AI org-level platform agent (claude-code + org-management MCP) + + promote-pin: + name: Promote runtime_image_pins (CP admin) + runs-on: ubuntu-latest + timeout-minutes: 2 + needs: [resolve-version, publish] + if: ${{ success() && github.ref == 'refs/heads/main' }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - env_name: prod + cp_host: api.moleculesai.app + - env_name: staging + cp_host: staging-api.moleculesai.app + steps: + - name: POST /cp/admin/runtime-image/promote (${{ matrix.env_name }}) + env: + PROD_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }} + STAGING_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN_STAGING }} + ENV_NAME: ${{ matrix.env_name }} + CP_HOST: ${{ matrix.cp_host }} + TEMPLATE_NAME: platform-agent + IMAGE_DIGEST: ${{ needs.publish.outputs.digest }} + GIT_SHA: ${{ needs.resolve-version.outputs.sha }} + run: | + set -euo pipefail + if [ "${ENV_NAME}" = "prod" ]; then + CP_ADMIN_API_TOKEN="${PROD_TOKEN}"; TOKEN_SECRET_NAME="CP_ADMIN_API_TOKEN" + else + CP_ADMIN_API_TOKEN="${STAGING_TOKEN}"; TOKEN_SECRET_NAME="CP_ADMIN_API_TOKEN_STAGING" + fi + if [ -z "${CP_ADMIN_API_TOKEN}" ]; then + echo "::error::${TOKEN_SECRET_NAME} not configured — cannot promote ${ENV_NAME} pin"; exit 1 + fi + if [ -z "${IMAGE_DIGEST}" ]; then + echo "::error::needs.publish.outputs.digest empty — Push step did not expose digest"; exit 1 + fi + body=$(printf '{"template_name":"%s","image_digest":"%s","git_sha":"%s","notes":"auto-promote via publish-platform-agent-image.yml -> %s"}' \ + "${TEMPLATE_NAME}" "${IMAGE_DIGEST}" "${GIT_SHA}" "${ENV_NAME}") + resp=$(mktemp) + code=$(curl -sS -o "${resp}" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer ${CP_ADMIN_API_TOKEN}" \ + -H "Content-Type: application/json" -d "${body}" \ + "https://${CP_HOST}/cp/admin/runtime-image/promote") + echo "HTTP ${code}"; cat "${resp}"; echo + if [ "${code}" != "200" ] && [ "${code}" != "201" ]; then + echo "::error::${ENV_NAME} promote failed (HTTP ${code})"; exit 1 + fi + echo "::notice::${ENV_NAME} runtime_image_pins.${TEMPLATE_NAME} bumped to ${IMAGE_DIGEST}" diff --git a/images/platform-agent/Dockerfile b/images/platform-agent/Dockerfile new file mode 100644 index 0000000..78ced49 --- /dev/null +++ b/images/platform-agent/Dockerfile @@ -0,0 +1,74 @@ +# molecule-platform-agent image +# RFC: molecule-core docs/design/rfc-platform-agent.md §5.7 +# +# A DEDICATED image, FROM the claude-code workspace template, that bakes in the +# org-management MCP server (molecule-mcp-server) so the org-level platform +# agent can drive the org alongside the always-on a2a MCP. It: +# +# - bakes the MCP at the exact path the platform agent's config.yaml +# references — `mcp_servers: [{name: platform, command: node, +# args: [/opt/molecule-mcp-server/dist/index.js]}]` (Phase 2, +# molecule-ai-workspace-template-claude-code claude_sdk_executor.py +# _apply_extra_mcp_servers); +# - pins Node 20 (the debian-slim base ships Node 18 via apt; the MCP's +# @modelcontextprotocol/sdk expects >=20); +# - keeps the org-admin MCP OUT of ordinary workspace images (security +# hygiene) — ordinary workspaces declare no extra mcp_servers, so the +# org-management surface only exists on this image. +# +# Build context expects the molecule-mcp-server source at ./mcp-server +# (the publish workflow checks it out there). + +# ---- Stage 1: build the MCP server (dist + production node_modules) ---- +ARG MCP_BUILDER=node:20-bookworm-slim +FROM ${MCP_BUILDER} AS mcpbuild +WORKDIR /build +# Install deps against the lockfile first for layer caching. +COPY mcp-server/package.json mcp-server/package-lock.json ./ +RUN npm ci +# Build (tsc -> dist/), then drop devDependencies so only the runtime closure +# (@modelcontextprotocol/sdk, pino, zod, …) is carried into the final image. +COPY mcp-server/ ./ +RUN npm run build \ + && npm prune --omit=dev \ + && test -f dist/index.js + +# ---- Stage 2: the platform-agent runtime ---- +# Pin to a specific workspace-template-claude-code digest/tag at build time via +# --build-arg BASE_IMAGE=...:sha-<7>; defaults to :latest for local builds. +ARG BASE_IMAGE=153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/workspace-template-claude-code:latest +FROM ${BASE_IMAGE} + +# Pin Node 20. The base apt-installs `nodejs` (Node 18 on bookworm-slim); the +# org MCP needs >=20. Install Node 20 from NodeSource, replacing the apt nodejs +# so `node` resolves to 20 for both claude-code and the baked MCP. +USER root +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl gnupg; \ + mkdir -p /etc/apt/keyrings; \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list; \ + apt-get update; \ + apt-get install -y --no-install-recommends nodejs; \ + node --version | grep -E '^v20\.'; \ + rm -rf /var/lib/apt/lists/* + +# Bake the org-management MCP at the config-referenced path. +COPY --from=mcpbuild /build/dist /opt/molecule-mcp-server/dist +COPY --from=mcpbuild /build/node_modules /opt/molecule-mcp-server/node_modules +COPY --from=mcpbuild /build/package.json /opt/molecule-mcp-server/package.json + +# Smoke: the baked entry parses under Node 20 (syntax-only; does not start the +# stdio server). Catches an ESM/Node-version mismatch at build time. +RUN node --check /opt/molecule-mcp-server/dist/index.js \ + && test -d /opt/molecule-mcp-server/node_modules/@modelcontextprotocol + +# Marker so the runtime/provisioner can assert it is on the platform-agent +# image (and not an ordinary workspace image) before declaring the platform MCP. +ENV MOLECULE_PLATFORM_AGENT_IMAGE_BAKED=1 + +# Return to the agent uid the base image runs as. +USER agent -- 2.52.0