From 0550ff9bc73e8bcfff6722bc9590f38c203e245e Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Sat, 11 Apr 2026 23:34:08 -0400 Subject: [PATCH 1/6] feat: add initial Containerfile, devfiles, scripts, and CI Implement all 8 deliverables from Issue #1: - Containerfile (Fedora 41, Go 1.25, full UF toolchain, non-root dev user) - Containerfile.udi (UDI base for Eclipse Che / Dev Spaces) - devfile.yaml and devfile-dynamic.yaml (Devfile 2.2.0) - scripts: install-uf-tools, entrypoint, extract-changes, connect - podman-compose.yml for headless server mode (Model B) - CI workflow for multi-arch build and push to quay.io - README documenting all 3 deployment models - Full spec artifacts (spec, plan, research, contracts, tasks) --- .github/workflows/build-push.yml | 138 ++++++++ .specify/scripts/bash/check-prerequisites.sh | 0 .specify/scripts/bash/create-new-feature.sh | 0 AGENTS.md | 6 + Containerfile | 109 +++++++ Containerfile.udi | 97 ++++++ README.md | 199 ++++++++++++ devfile-dynamic.yaml | 66 ++++ devfile.yaml | 41 +++ podman-compose.yml | 41 +++ scripts/connect.sh | 149 +++++++++ scripts/entrypoint.sh | 111 +++++++ scripts/extract-changes.sh | 145 +++++++++ scripts/install-uf-tools.sh | 106 +++++++ .../checklists/requirements.md | 37 +++ .../contracts/connect.md | 82 +++++ .../contracts/entrypoint.md | 94 ++++++ .../contracts/extract-changes.md | 86 +++++ .../contracts/install-uf-tools.md | 92 ++++++ specs/001-initial-containerfile/data-model.md | 107 +++++++ specs/001-initial-containerfile/plan.md | 140 +++++++++ specs/001-initial-containerfile/quickstart.md | 149 +++++++++ specs/001-initial-containerfile/research.md | 204 ++++++++++++ specs/001-initial-containerfile/spec.md | 295 ++++++++++++++++++ specs/001-initial-containerfile/tasks.md | 167 ++++++++++ 25 files changed, 2661 insertions(+) create mode 100644 .github/workflows/build-push.yml mode change 100644 => 100755 .specify/scripts/bash/check-prerequisites.sh mode change 100644 => 100755 .specify/scripts/bash/create-new-feature.sh create mode 100644 Containerfile create mode 100644 Containerfile.udi create mode 100644 README.md create mode 100644 devfile-dynamic.yaml create mode 100644 devfile.yaml create mode 100644 podman-compose.yml create mode 100755 scripts/connect.sh create mode 100755 scripts/entrypoint.sh create mode 100755 scripts/extract-changes.sh create mode 100755 scripts/install-uf-tools.sh create mode 100644 specs/001-initial-containerfile/checklists/requirements.md create mode 100644 specs/001-initial-containerfile/contracts/connect.md create mode 100644 specs/001-initial-containerfile/contracts/entrypoint.md create mode 100644 specs/001-initial-containerfile/contracts/extract-changes.md create mode 100644 specs/001-initial-containerfile/contracts/install-uf-tools.md create mode 100644 specs/001-initial-containerfile/data-model.md create mode 100644 specs/001-initial-containerfile/plan.md create mode 100644 specs/001-initial-containerfile/quickstart.md create mode 100644 specs/001-initial-containerfile/research.md create mode 100644 specs/001-initial-containerfile/spec.md create mode 100644 specs/001-initial-containerfile/tasks.md diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml new file mode 100644 index 0000000..52c478e --- /dev/null +++ b/.github/workflows/build-push.yml @@ -0,0 +1,138 @@ +# build-push.yml — Multi-arch build and push to quay.io +# +# Builds the opencode-dev container image for linux/arm64 and +# linux/amd64 using Podman manifest, then pushes to quay.io. +# +# Triggers: +# - Push to main: build + push with "latest" tag +# - Version tag (v*): build + push with version tag +# - Pull request: build only (no push) for validation +# +# Requirements: FR-017 +# Research: R5 (GitHub Actions Multi-Arch Build with Podman) + +name: Build and Push Container Image + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + REGISTRY: quay.io + IMAGE_NAME: unbound-force/opencode-dev + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # --------------------------------------------------------------- + # QEMU for cross-architecture emulation (R5) + # --------------------------------------------------------------- + - name: Install QEMU user-static + run: | + sudo apt-get update + sudo apt-get install -y qemu-user-static + + # --------------------------------------------------------------- + # Determine tags based on trigger + # --------------------------------------------------------------- + - name: Determine image tags + id: tags + run: | + if [[ "${{ github.ref_type }}" == "tag" ]]; then + # Version tag push — tag with version number + VERSION="${{ github.ref_name }}" + echo "tags=${REGISTRY}/${IMAGE_NAME}:${VERSION}" >> "$GITHUB_OUTPUT" + echo "manifest_tag=${VERSION}" >> "$GITHUB_OUTPUT" + elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + # Push to main — tag as latest + echo "tags=${REGISTRY}/${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT" + echo "manifest_tag=latest" >> "$GITHUB_OUTPUT" + else + # Pull request — build only, use PR number for local tag + echo "tags=localhost/${IMAGE_NAME}:pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT" + echo "manifest_tag=pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT" + fi + + # --------------------------------------------------------------- + # Build multi-arch image using podman manifest (R5) + # --------------------------------------------------------------- + - name: Create manifest + run: podman manifest create opencode-dev + + - name: Build for linux/amd64 + run: | + podman build \ + --platform linux/amd64 \ + --manifest opencode-dev \ + -f Containerfile . + + - name: Build for linux/arm64 + run: | + podman build \ + --platform linux/arm64 \ + --manifest opencode-dev \ + -f Containerfile . + + # --------------------------------------------------------------- + # Smoke tests — verify tools are present before pushing + # --------------------------------------------------------------- + - name: Run smoke tests (amd64) + run: | + # Build a local image for smoke testing on the runner arch + podman build -t opencode-dev-test -f Containerfile . + + echo "==> Checking tool versions ..." + podman run --rm opencode-dev-test uf --version + podman run --rm opencode-dev-test opencode --version + podman run --rm opencode-dev-test dewey --version + podman run --rm opencode-dev-test replicator --version + podman run --rm opencode-dev-test gaze --version + podman run --rm opencode-dev-test go version + podman run --rm opencode-dev-test golangci-lint --version + podman run --rm opencode-dev-test govulncheck -version + podman run --rm opencode-dev-test node --version + podman run --rm opencode-dev-test npm --version + podman run --rm opencode-dev-test git --version + podman run --rm opencode-dev-test gh --version + + echo "==> Checking non-root user ..." + USER_OUTPUT=$(podman run --rm opencode-dev-test whoami) + if [ "$USER_OUTPUT" != "dev" ]; then + echo "ERROR: Expected 'dev' but got '$USER_OUTPUT'" + exit 1 + fi + echo "User check passed: $USER_OUTPUT" + + # Clean up test image + podman rmi opencode-dev-test || true + + # --------------------------------------------------------------- + # Push to quay.io (only on main push or version tag) + # --------------------------------------------------------------- + - name: Login to quay.io + if: github.event_name == 'push' + run: | + podman login quay.io \ + -u "${{ secrets.QUAY_USERNAME }}" \ + -p "${{ secrets.QUAY_PASSWORD }}" + + - name: Push manifest to quay.io + if: github.event_name == 'push' + run: | + podman manifest push opencode-dev \ + "docker://${{ steps.tags.outputs.tags }}" + + # Tag as latest on version tag pushes too + - name: Push latest tag (version tag push) + if: github.ref_type == 'tag' + run: | + podman manifest push opencode-dev \ + "docker://${REGISTRY}/${IMAGE_NAME}:latest" diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh old mode 100644 new mode 100755 diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh old mode 100644 new mode 100755 diff --git a/AGENTS.md b/AGENTS.md index 1b026dd..37feeb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,3 +151,9 @@ valid results. - [Discussion #88](https://github.com/orgs/unbound-force/discussions/88) — full architecture and design rationale - [Issue #1](https://github.com/unbound-force/containerfile/issues/1) — implementation issue with all deliverables + +## Active Technologies +- Shell scripts (bash), Containerfile (OCI/Docker syntax), YAML (devfile 2.2.0, compose, GitHub Actions) + Podman, Go 1.24+, Node.js 20+, npm, Git, gh CLI (001-initial-containerfile) + +## Recent Changes +- 001-initial-containerfile: Added initial Containerfile, Containerfile.udi, devfiles, helper scripts, podman-compose, CI workflow, README diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..b7c6abd --- /dev/null +++ b/Containerfile @@ -0,0 +1,109 @@ +# Containerfile — Unbound Force OpenCode Dev Container +# +# Multi-arch OCI image (linux/arm64, linux/amd64) with the full UF +# toolchain for AI-assisted development inside Podman containers. +# +# Base: Fedora 41 (pinned major version per research R1) +# Strategy: Single-stage build — agents need Go + Node.js at runtime (R2) +# User: Non-root "dev" user (R7) +# +# Build: +# podman build -t opencode-dev -f Containerfile . +# +# Smoke test: +# podman run --rm opencode-dev uf --version +# podman run --rm opencode-dev whoami # prints "dev" + +FROM registry.fedoraproject.org/fedora:41 + +# --------------------------------------------------------------------------- +# System packages (as root) — Go is installed separately below because +# Fedora 41 ships Go 1.24 but dewey requires Go 1.25+. +# --------------------------------------------------------------------------- + +RUN dnf install -y \ + nodejs \ + npm \ + git \ + gh \ + curl \ + findutils \ + procps-ng \ + which \ + tar \ + gzip \ + && dnf clean all \ + && rm -rf /var/cache/dnf + +# --------------------------------------------------------------------------- +# Install Go from official tarball (Fedora 41 ships 1.24; dewey needs 1.25+) +# --------------------------------------------------------------------------- + +ARG GO_VERSION=1.25.3 +RUN ARCH="$(uname -m)" \ + && case "$ARCH" in \ + x86_64) GOARCH=amd64 ;; \ + aarch64) GOARCH=arm64 ;; \ + *) echo "Unsupported arch: $ARCH" && exit 1 ;; \ + esac \ + && curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" \ + | tar -C /usr/local -xz \ + && ln -s /usr/local/go/bin/go /usr/local/bin/go \ + && ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt + +# --------------------------------------------------------------------------- +# Non-root user setup (R7) +# +# Create the user early so Go/npm tool installs target the user's paths. +# System packages (dnf) are installed above as root. +# --------------------------------------------------------------------------- + +RUN useradd -m -s /bin/bash dev + +# --------------------------------------------------------------------------- +# Environment — set for all subsequent layers and runtime +# --------------------------------------------------------------------------- + +ENV GOROOT=/usr/local/go \ + GOPATH=/home/dev/go \ + NPM_CONFIG_PREFIX=/home/dev/.npm-global \ + PATH="/usr/local/go/bin:/home/dev/go/bin:/home/dev/.npm-global/bin:/home/dev/.local/bin:$PATH" \ + DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 + +# --------------------------------------------------------------------------- +# Install UF tools as dev user (go install + npm) +# --------------------------------------------------------------------------- + +COPY --chown=dev:dev scripts/install-uf-tools.sh /home/dev/scripts/install-uf-tools.sh +RUN chmod +x /home/dev/scripts/install-uf-tools.sh + +USER dev +RUN /home/dev/scripts/install-uf-tools.sh + +# --------------------------------------------------------------------------- +# Install OpenCode via official curl installer (R6) +# +# The installer detects architecture automatically and places the binary +# in ~/.local/bin (or /usr/local/bin if running as root). We run as dev +# so it goes to ~/.local/bin which is already in PATH. +# --------------------------------------------------------------------------- + +RUN curl -fsSL https://opencode.ai/install | bash + +# --------------------------------------------------------------------------- +# Switch back to root briefly to copy entrypoint to a fixed location +# --------------------------------------------------------------------------- + +USER root +COPY --chown=dev:dev scripts/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY --chown=dev:dev scripts/extract-changes.sh /usr/local/bin/extract-changes.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/extract-changes.sh + +# --------------------------------------------------------------------------- +# Final runtime configuration +# --------------------------------------------------------------------------- + +USER dev +WORKDIR /home/dev + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/Containerfile.udi b/Containerfile.udi new file mode 100644 index 0000000..406860b --- /dev/null +++ b/Containerfile.udi @@ -0,0 +1,97 @@ +# Containerfile.udi — UDI-based variant for Eclipse Che / Dev Spaces +# +# Uses the Universal Developer Image as the base, which already includes +# Go, Node.js, Git, and common developer tools. We install only the +# UF-specific tools on top. +# +# Key differences from the primary Containerfile (per research R3): +# - Base: UDI (not Fedora) — Go, Node.js, Git pre-installed +# - User: "user" UID 1001 (not "dev") — UDI default, do NOT change +# - Home: /home/user (not /home/dev) +# - Accept UDI's Go/Node.js versions for compatibility +# +# Build: +# podman build -t opencode-dev-udi -f Containerfile.udi . +# +# Smoke test: +# podman run --rm opencode-dev-udi uf --version +# podman run --rm opencode-dev-udi whoami # prints "user" + +FROM quay.io/devfile/universal-developer-image:latest + +# --------------------------------------------------------------------------- +# System packages — install as root (gh CLI may not be in UDI) +# --------------------------------------------------------------------------- + +USER 0 + +RUN dnf install -y \ + gh \ + procps-ng \ + && dnf clean all \ + && rm -rf /var/cache/dnf + +# --------------------------------------------------------------------------- +# Upgrade Go — UDI ships Go 1.23/1.24 but dewey requires 1.25+ +# --------------------------------------------------------------------------- + +ARG GO_VERSION=1.25.3 +RUN rm -rf /usr/local/go \ + && ARCH="$(uname -m)" \ + && case "$ARCH" in \ + x86_64) GOARCH=amd64 ;; \ + aarch64) GOARCH=arm64 ;; \ + *) echo "Unsupported arch: $ARCH" && exit 1 ;; \ + esac \ + && curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" \ + | tar -C /usr/local -xz + +# --------------------------------------------------------------------------- +# Environment — set for all subsequent layers and runtime +# +# UDI's Go and Node.js are already in PATH. We add GOPATH/bin for +# tools installed via go install, and set the Ollama endpoint. +# --------------------------------------------------------------------------- + +ENV GOROOT=/usr/local/go \ + GOPATH=/home/user/go \ + NPM_CONFIG_PREFIX=/home/user/.npm-global \ + PATH="/usr/local/go/bin:/home/user/go/bin:/home/user/.npm-global/bin:/home/user/.local/bin:$PATH" \ + DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 + +# --------------------------------------------------------------------------- +# Install UF tools as the UDI user (go install + npm) +# --------------------------------------------------------------------------- + +COPY --chown=1001:0 scripts/install-uf-tools.sh /home/user/scripts/install-uf-tools.sh +RUN chmod +x /home/user/scripts/install-uf-tools.sh + +USER 1001 +RUN /home/user/scripts/install-uf-tools.sh + +# --------------------------------------------------------------------------- +# Install OpenCode via official curl installer (R6) +# +# The installer detects architecture automatically and places the binary +# in ~/.local/bin which is already in PATH. +# --------------------------------------------------------------------------- + +RUN curl -fsSL https://opencode.ai/install | bash + +# --------------------------------------------------------------------------- +# Copy entrypoint and extract-changes scripts +# --------------------------------------------------------------------------- + +USER 0 +COPY --chown=1001:0 scripts/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY --chown=1001:0 scripts/extract-changes.sh /usr/local/bin/extract-changes.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/extract-changes.sh + +# --------------------------------------------------------------------------- +# Final runtime configuration — switch back to UDI user +# --------------------------------------------------------------------------- + +USER 1001 +WORKDIR /home/user + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..846d407 --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +# containerfile + +Container image for running [OpenCode](https://opencode.ai) and the full +[Unbound Force](https://github.com/unbound-force) toolchain inside Podman +containers. Security through isolation. + +## Prerequisites + +- **Podman** installed and configured for rootless operation +- **A project directory** with a git repository to mount into the container + +Optional: + +- **Ollama** running on the host at `localhost:11434` (enables Dewey + semantic embeddings). The container connects via + `DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434`. + Ollama is never installed inside the container. + +## Quick Start + +```bash +# Build the image +podman build -t opencode-dev -f Containerfile . + +# Run interactively with your project mounted +podman run -it --rm \ + --memory 8g --cpus 4 \ + -v ./my-project:/workspace:Z \ + -e DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 \ + opencode-dev +``` + +## Deployment Models + +### Model A: Interactive Development + +Read-write volume mount with shell access. The agent has direct access +to your project files. Use when you trust the agent and want immediate +file changes. + +```bash +podman run -it --rm \ + --memory 8g --cpus 4 \ + -v ./my-project:/workspace:Z \ + -e DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 \ + opencode-dev +``` + +**Security properties**: Agent can read and write host files directly. +Resource limits enforced. No secrets in image. + +### Model B: Headless Server + +Maximum isolation. The source directory is mounted read-only. The agent +works on an internal writable copy. Changes are extracted via +`git format-patch` for human review before applying to the host. + +```bash +# Start the headless server +podman-compose up -d + +# Connect from the host +./scripts/connect.sh + +# After the agent makes changes, extract them +podman exec opencode-server /usr/local/bin/extract-changes.sh + +# Review and apply patches on the host +git am < patches/*.patch + +# Stop the server +podman-compose down +``` + +The `podman-compose.yml` mounts the host project directory as read-only +at `/workspace` and provides a separate writable volume where the agent +operates. The entrypoint creates a working copy from the read-only +source. The host source is never modified directly. + +**Security properties**: Agent cannot modify host files. Read-only source +mount. Changes require human review via format-patch. Resource limits +enforced. No secrets in image. + +### Model C: CDE / Eclipse Che + +Cloud development environment for Eclipse Che or Red Hat Dev Spaces. + +**Option 1 — Custom image (fast startup)**: Use `devfile.yaml`, which +references the pre-built `quay.io/unbound-force/opencode-dev:latest` +image. All tools are immediately available. + +**Option 2 — Dynamic (no custom image)**: Use `devfile-dynamic.yaml`, +which uses the Universal Developer Image and installs tools via +`postStart` commands. Slower startup, but no custom image dependency. + +**Security properties**: Workspace-level isolation managed by Eclipse +Che. Resource limits set in devfile. No secrets in image. + +## Security Model + +These constraints are non-negotiable: + +1. **Podman rootless** — no daemon, user namespace isolation +2. **Non-root user** — container runs as `dev` (or `user` for UDI variant) +3. **No secrets in image** — no SSH keys or git push tokens +4. **Read-only mounts** — headless mode (Model B) mounts source read-only +5. **Resource limits** — `--memory 8g --cpus 4` +6. **SELinux** — volume mounts use `:Z` relabeling on Fedora +7. **Ollama on host** — never installed in the container; connects via + `DEWEY_EMBEDDING_ENDPOINT` + +## Change Extraction + +In headless mode (Model B), the agent cannot write to the host +filesystem. Changes are extracted using `git format-patch`: + +```bash +# Extract patches to stdout +podman exec opencode-server /usr/local/bin/extract-changes.sh + +# Extract patches to a directory +podman exec opencode-server /usr/local/bin/extract-changes.sh /tmp/patches + +# Apply patches on the host after review +git am < patches/*.patch +``` + +The extract script detects uncommitted changes, creates a temporary +commit if needed, and generates standard `git format-patch` output. +In interactive mode (Model A), the agent writes directly to the +mounted project directory — no extraction needed. + +## Smoke Test Suite + +After building any image variant, run the full smoke test: + +```bash +IMAGE=opencode-dev # or opencode-dev-udi for the UDI variant + +# Tool version checks +podman run --rm $IMAGE uf --version +podman run --rm $IMAGE opencode --version +podman run --rm $IMAGE dewey --version +podman run --rm $IMAGE replicator --version +podman run --rm $IMAGE gaze --version + +# Non-root verification +podman run --rm $IMAGE whoami # must print "dev" (or "user" for UDI) + +# Go toolchain +podman run --rm $IMAGE go version +podman run --rm $IMAGE golangci-lint --version +podman run --rm $IMAGE govulncheck -version + +# Node.js toolchain +podman run --rm $IMAGE node --version +podman run --rm $IMAGE npm --version + +# Git and GitHub CLI +podman run --rm $IMAGE git --version +podman run --rm $IMAGE gh --version +``` + +Build the UDI variant: + +```bash +podman build -t opencode-dev-udi -f Containerfile.udi . +``` + +## Repository Structure + +``` +Containerfile Multi-arch OCI image (Fedora base) +Containerfile.udi CDE variant (UDI base) +devfile.yaml Eclipse Che workspace (custom image) +devfile-dynamic.yaml Eclipse Che workspace (UDI + postStart) +podman-compose.yml Headless server orchestration +scripts/ + install-uf-tools.sh Install all UF tools via go install + npm + entrypoint.sh Container entrypoint + extract-changes.sh Git format-patch extraction + connect.sh Host-side attach script +.github/workflows/ + build-push.yml CI: multi-arch build + push to quay.io +``` + +## Troubleshooting + +| Problem | Cause | Solution | +|---------|-------|----------| +| `host.containers.internal` not resolving | Podman version or platform limitation | Set `DEWEY_EMBEDDING_ENDPOINT` to host IP manually | +| SELinux denying volume access | Missing `:Z` relabel | Add `:Z` suffix to volume mount | +| `go install` fails during build | Network access required | Ensure build environment has internet access | +| OpenCode not starting | Port 4096 already in use | Stop conflicting process or change port mapping | +| `whoami` returns `root` | Containerfile `USER` instruction missing | Verify `USER dev` is the final user instruction | + +## License + +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/devfile-dynamic.yaml b/devfile-dynamic.yaml new file mode 100644 index 0000000..6b05568 --- /dev/null +++ b/devfile-dynamic.yaml @@ -0,0 +1,66 @@ +# devfile-dynamic.yaml — Eclipse Che workspace using UDI (no custom image) +# +# Uses the Universal Developer Image as the base. Tools are installed +# via postStart commands at workspace creation — slower startup but +# no custom image to maintain. +# +# Devfile 2.2.0 specification: https://devfile.io/docs/2.2.0/ +# Requirements: FR-010, FR-011 + +schemaVersion: 2.2.0 + +metadata: + name: unbound-force-workspace-dynamic + version: 1.0.0 + +components: + - name: dev-container + container: + image: quay.io/devfile/universal-developer-image:latest + memoryLimit: 8Gi + cpuLimit: "4" + mountSources: true + env: + - name: DEWEY_EMBEDDING_ENDPOINT + value: "http://host.containers.internal:11434" + - name: GOPATH + value: "/home/user/go" + - name: PATH + value: "/home/user/go/bin:/home/user/.local/bin:$PATH" + endpoints: + - name: opencode-server + targetPort: 4096 + exposure: internal + +commands: + - id: install-tools + exec: + component: dev-container + commandLine: | + # Install UF tools via go install + npm + if [ -f /projects/scripts/install-uf-tools.sh ]; then + bash /projects/scripts/install-uf-tools.sh + else + echo "install-uf-tools.sh not found in /projects/scripts/" + echo "Clone the containerfile repo or provide the script." + exit 1 + fi + # Install OpenCode via official installer + curl -fsSL https://opencode.ai/install | bash + workingDir: /projects + + - id: start-server + exec: + component: dev-container + commandLine: "opencode serve --port 4096 --hostname 0.0.0.0" + workingDir: /projects + + - id: init-workspace + exec: + component: dev-container + commandLine: "uf init" + workingDir: /projects + +events: + postStart: + - install-tools diff --git a/devfile.yaml b/devfile.yaml new file mode 100644 index 0000000..677c83a --- /dev/null +++ b/devfile.yaml @@ -0,0 +1,41 @@ +# devfile.yaml — Eclipse Che workspace with custom image +# +# Uses the pre-built opencode-dev image for fast workspace startup. +# All UF tools are baked into the image — no postStart installation. +# +# Devfile 2.2.0 specification: https://devfile.io/docs/2.2.0/ +# Requirements: FR-009, FR-011 + +schemaVersion: 2.2.0 + +metadata: + name: unbound-force-workspace + version: 1.0.0 + +components: + - name: opencode-dev + container: + image: quay.io/unbound-force/opencode-dev:latest + memoryLimit: 8Gi + cpuLimit: "4" + mountSources: true + env: + - name: DEWEY_EMBEDDING_ENDPOINT + value: "http://host.containers.internal:11434" + endpoints: + - name: opencode-server + targetPort: 4096 + exposure: internal + +commands: + - id: start-server + exec: + component: opencode-dev + commandLine: "opencode serve --port 4096 --hostname 0.0.0.0" + workingDir: /projects + + - id: init-workspace + exec: + component: opencode-dev + commandLine: "uf init" + workingDir: /projects diff --git a/podman-compose.yml b/podman-compose.yml new file mode 100644 index 0000000..d76dc6e --- /dev/null +++ b/podman-compose.yml @@ -0,0 +1,41 @@ +# podman-compose.yml — Headless server mode (Model B) +# +# Runs OpenCode as a headless server with read-only source mount. +# The agent works on a writable copy; changes are extracted via +# git format-patch (scripts/extract-changes.sh). +# +# Usage: +# podman-compose up -d # start headless server +# ./scripts/connect.sh # attach from host +# podman-compose down # stop and clean up +# +# Security: +# - Source directory mounted read-only (FR-021) +# - Resource limits enforced (FR-020) +# - No secrets in image (FR-019) + +services: + opencode: + image: quay.io/unbound-force/opencode-dev:latest + container_name: opencode-server + ports: + - "4096:4096" + volumes: + # Read-only source mount — agent cannot modify host files + - ./:/workspace:ro,Z + # Writable work directory — agent operates here + - opencode-workdir:/work + # Persist OpenCode state across restarts + - ~/.local/share/opencode:/home/dev/.local/share/opencode:Z + environment: + - DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-} + command: ["opencode", "serve", "--port", "4096", "--hostname", "0.0.0.0"] + deploy: + resources: + limits: + memory: 8g + cpus: "4" + +volumes: + opencode-workdir: diff --git a/scripts/connect.sh b/scripts/connect.sh new file mode 100755 index 0000000..bace5f1 --- /dev/null +++ b/scripts/connect.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# connect.sh — Start a container and attach via opencode attach. +# +# Host-side convenience script for headless server mode (Model B). +# Handles container lifecycle: start if not running, wait for health, +# then attach. +# +# Usage: +# connect.sh [project-dir] [container-name] +# +# Arguments: +# $1 (optional): Path to the project directory to mount. +# Defaults to the current directory. +# $2 (optional): Container name. Defaults to "opencode-server". +# +# Environment: +# OPENCODE_IMAGE: Image to use (default: quay.io/unbound-force/opencode-dev:latest) +# +# Constraints: +# - Runs on the HOST, not inside the container +# - Uses Podman (not Docker) — Architecture Constraint +# - Resource limits: 8G memory, 4 CPUs (FR-020) +# - SELinux-compatible :Z volume mounts + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +info() { + printf '\033[1;34m[connect]\033[0m %s\n' "$*" +} + +warn() { + printf '\033[1;33m[connect]\033[0m %s\n' "$*" +} + +error() { + printf '\033[1;31m[connect]\033[0m %s\n' "$*" >&2 +} + +# --------------------------------------------------------------------------- +# 1. Pre-flight: verify Podman is installed +# --------------------------------------------------------------------------- + +if ! command -v podman &>/dev/null; then + error "Podman is required but not installed." + error "Install Podman: https://podman.io/getting-started/installation" + exit 1 +fi + +# --------------------------------------------------------------------------- +# 2. Parse arguments and set defaults +# --------------------------------------------------------------------------- + +PROJECT_DIR="${1:-.}" +CONTAINER_NAME="${2:-opencode-server}" +OPENCODE_IMAGE="${OPENCODE_IMAGE:-quay.io/unbound-force/opencode-dev:latest}" +OPENCODE_PORT=4096 +HEALTH_TIMEOUT=30 + +# Resolve to absolute path +PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" + +info "Project directory: $PROJECT_DIR" +info "Container name: $CONTAINER_NAME" +info "Image: $OPENCODE_IMAGE" + +# --------------------------------------------------------------------------- +# 3. Check if container is already running +# --------------------------------------------------------------------------- + +if podman ps --filter "name=^${CONTAINER_NAME}$" --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_NAME}$"; then + info "Container '$CONTAINER_NAME' is already running." +else + info "Container '$CONTAINER_NAME' is not running. Starting ..." + + # Check if a podman-compose.yml exists in the project directory + if [ -f "$PROJECT_DIR/podman-compose.yml" ]; then + info "Found podman-compose.yml — using podman-compose up -d" + if command -v podman-compose &>/dev/null; then + podman-compose -f "$PROJECT_DIR/podman-compose.yml" up -d + else + warn "podman-compose not found. Falling back to podman run." + podman run -d \ + --name "$CONTAINER_NAME" \ + --memory 8g --cpus 4 \ + -p "${OPENCODE_PORT}:${OPENCODE_PORT}" \ + -v "${PROJECT_DIR}:/workspace:ro,Z" \ + -e DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 \ + "$OPENCODE_IMAGE" \ + server + fi + else + info "No podman-compose.yml found — using podman run" + podman run -d \ + --name "$CONTAINER_NAME" \ + --memory 8g --cpus 4 \ + -p "${OPENCODE_PORT}:${OPENCODE_PORT}" \ + -v "${PROJECT_DIR}:/workspace:ro,Z" \ + -e DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 \ + "$OPENCODE_IMAGE" \ + server + fi +fi + +# --------------------------------------------------------------------------- +# 4. Wait for OpenCode health endpoint (30s timeout with retries) +# --------------------------------------------------------------------------- + +info "Waiting for OpenCode server at localhost:${OPENCODE_PORT} (timeout: ${HEALTH_TIMEOUT}s) ..." + +elapsed=0 +while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do + if curl -sf --max-time 2 "http://localhost:${OPENCODE_PORT}" >/dev/null 2>&1; then + info "OpenCode server is ready." + break + fi + sleep 1 + elapsed=$((elapsed + 1)) +done + +if [ "$elapsed" -ge "$HEALTH_TIMEOUT" ]; then + error "OpenCode server did not become ready within ${HEALTH_TIMEOUT}s." + error "Check container logs: podman logs $CONTAINER_NAME" + exit 1 +fi + +# --------------------------------------------------------------------------- +# 5. Attach to the OpenCode server +# --------------------------------------------------------------------------- + +info "Attaching to OpenCode server ..." +opencode attach + +# --------------------------------------------------------------------------- +# 6. Cleanup guidance (printed after detaching) +# --------------------------------------------------------------------------- + +echo "" +info "Detached from OpenCode server." +info "" +info "The container '$CONTAINER_NAME' is still running." +info "To extract changes: podman exec $CONTAINER_NAME /usr/local/bin/extract-changes.sh" +info "To stop: podman stop $CONTAINER_NAME && podman rm $CONTAINER_NAME" +if [ -f "$PROJECT_DIR/podman-compose.yml" ]; then + info "Or: podman-compose -f $PROJECT_DIR/podman-compose.yml down" +fi diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..8c71fc5 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# entrypoint.sh — Container entrypoint for OpenCode dev containers. +# +# Handles first-run initialization and command dispatch: +# - No args or "server": start OpenCode in server mode +# - "bash" or "sh": start an interactive shell +# - Anything else: exec the command directly (pass-through) +# +# Constraints: +# - MUST NOT fail if Ollama is unreachable (Constitution I) +# - MUST NOT fail if workspace has no git repo (Edge Case 2) +# - MUST handle read-only workspace mounts gracefully (Model B) +# - MUST use exec for final process (PID 1 signal handling) + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +info() { + printf '\033[1;34m[entrypoint]\033[0m %s\n' "$*" +} + +warn() { + printf '\033[1;33m[entrypoint]\033[0m %s\n' "$*" +} + +# --------------------------------------------------------------------------- +# 1. Workspace detection +# --------------------------------------------------------------------------- + +WORKSPACE="${WORKSPACE:-/workspace}" +OPENCODE_PORT="${OPENCODE_PORT:-4096}" + +if [ -d "$WORKSPACE" ]; then + info "Workspace: $WORKSPACE" +elif [ -d "/projects" ]; then + # Eclipse Che / Dev Spaces mount sources at /projects + WORKSPACE="/projects" + info "Workspace (Che fallback): $WORKSPACE" +elif [ -d "$HOME" ]; then + WORKSPACE="$HOME" + warn "No /workspace or /projects found. Using \$HOME: $WORKSPACE" +else + WORKSPACE="$HOME" + warn "Falling back to \$HOME: $WORKSPACE" +fi + +cd "$WORKSPACE" || cd "$HOME" + +# --------------------------------------------------------------------------- +# 2. Git repository check + first-run initialization +# --------------------------------------------------------------------------- + +if [ -d "$WORKSPACE/.git" ]; then + info "Git repository detected in $WORKSPACE" + + # First-run: initialize UF workspace if .uf/ doesn't exist yet + if [ ! -d "$WORKSPACE/.uf" ]; then + info "First run — initializing UF workspace with 'uf init' ..." + if uf init 2>/dev/null; then + info "UF workspace initialized." + else + # Graceful degradation: log but don't block startup. + # This handles read-only mounts (Model B) and other failures. + warn "uf init failed (read-only mount or other issue). Continuing without UF workspace." + fi + fi +else + info "No git repository in $WORKSPACE — skipping uf init." +fi + +# --------------------------------------------------------------------------- +# 3. Ollama connectivity check (graceful degradation per Constitution I) +# --------------------------------------------------------------------------- + +if [ -n "${DEWEY_EMBEDDING_ENDPOINT:-}" ]; then + info "Checking Ollama connectivity at $DEWEY_EMBEDDING_ENDPOINT ..." + if curl -sf --max-time 2 "$DEWEY_EMBEDDING_ENDPOINT" >/dev/null 2>&1; then + info "Ollama is reachable." + else + warn "Ollama is not reachable at $DEWEY_EMBEDDING_ENDPOINT. Dewey will run without embeddings." + fi +else + info "DEWEY_EMBEDDING_ENDPOINT not set — skipping Ollama check." +fi + +# --------------------------------------------------------------------------- +# 4. Command dispatch — use exec for proper PID 1 signal handling +# --------------------------------------------------------------------------- + +# Disable strict mode before exec — the executed process handles its own +# error semantics. set -e would cause the shell to exit on non-zero from +# the exec'd process before it can handle signals properly. +set +euo pipefail + +case "${1:-}" in + ""|server) + info "Starting OpenCode server on port $OPENCODE_PORT ..." + exec opencode serve --port "$OPENCODE_PORT" --hostname 0.0.0.0 + ;; + bash|sh) + info "Starting interactive shell ..." + exec "$1" + ;; + *) + info "Executing: $*" + exec "$@" + ;; +esac diff --git a/scripts/extract-changes.sh b/scripts/extract-changes.sh new file mode 100755 index 0000000..70e0ac8 --- /dev/null +++ b/scripts/extract-changes.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# extract-changes.sh — Export agent changes as git format-patch output. +# +# The ONLY approved method for extracting changes from a headless +# (Model B) container where the source directory is mounted read-only. +# The developer reviews the patches on the host before applying them. +# +# Usage: +# extract-changes.sh [output-dir] [base-ref] +# +# Arguments: +# $1 (optional): Output directory for patch files. If omitted, +# patches are written to stdout. +# $2 (optional): Base ref to diff against. Defaults to the branch +# HEAD when the container started. +# +# Constraints: +# - Operates on the container's internal working copy, NOT the +# read-only source mount (FR-014, FR-021) +# - Produces standard git format-patch output (apply with git am) +# - MUST NOT modify the host filesystem (Constitution II) + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +info() { + printf '\033[1;34m[extract-changes]\033[0m %s\n' "$*" +} + +warn() { + printf '\033[1;33m[extract-changes]\033[0m %s\n' "$*" +} + +error() { + printf '\033[1;31m[extract-changes]\033[0m %s\n' "$*" >&2 +} + +# --------------------------------------------------------------------------- +# 1. Working directory check — must be inside a git repository +# --------------------------------------------------------------------------- + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + error "Not inside a git repository. Run this script from a git working tree." + exit 1 +fi + +# Move to the repository root for consistent operation +cd "$(git rev-parse --show-toplevel)" + +# --------------------------------------------------------------------------- +# 2. Parse arguments +# --------------------------------------------------------------------------- + +OUTPUT_DIR="${1:-}" +BASE_REF="${2:-}" + +# If no base ref provided, use the current branch's upstream or initial +# commit. The entrypoint copies the source into the writable area, so +# HEAD at that point is the base. +if [ -z "$BASE_REF" ]; then + # Use the merge-base with the first commit as a fallback — this + # captures all commits made during the container session. + # If there's an upstream tracking branch, diff against that. + if git rev-parse --verify '@{upstream}' >/dev/null 2>&1; then + BASE_REF='@{upstream}' + else + # No upstream — diff against the initial commit that was present + # when the container started. Use the first commit on the branch. + BASE_REF="HEAD" + fi +fi + +# --------------------------------------------------------------------------- +# 3. Set git user defaults if not configured (needed for temporary commits) +# --------------------------------------------------------------------------- + +if ! git config user.name >/dev/null 2>&1; then + git config user.name "OpenCode Agent" +fi + +if ! git config user.email >/dev/null 2>&1; then + git config user.email "agent@opencode.ai" +fi + +# --------------------------------------------------------------------------- +# 4. Detect and handle uncommitted changes +# --------------------------------------------------------------------------- + +TEMP_COMMIT_CREATED=false + +# Check for any uncommitted changes (staged + unstaged + untracked) +if ! git diff --quiet HEAD 2>/dev/null || \ + ! git diff --cached --quiet 2>/dev/null || \ + [ -n "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then + + info "Uncommitted changes detected — creating temporary commit ..." + git add -A + git commit -m "agent: changes from container session" --no-verify >/dev/null 2>&1 + TEMP_COMMIT_CREATED=true +fi + +# --------------------------------------------------------------------------- +# 5. Check if there are any commits to extract +# --------------------------------------------------------------------------- + +# Count commits between base ref and HEAD +if [ "$BASE_REF" = "HEAD" ]; then + # BASE_REF is HEAD — check if the temp commit was created + if [ "$TEMP_COMMIT_CREATED" = true ]; then + # We just created a commit, so diff HEAD~1..HEAD + PATCH_BASE="HEAD~1" + else + info "No changes to extract." + exit 0 + fi +else + PATCH_BASE="$BASE_REF" +fi + +# Verify there are actually commits to extract +COMMIT_COUNT=$(git rev-list --count "${PATCH_BASE}..HEAD" 2>/dev/null || echo "0") + +if [ "$COMMIT_COUNT" -eq 0 ]; then + info "No changes to extract." + exit 0 +fi + +info "Found $COMMIT_COUNT commit(s) to extract." + +# --------------------------------------------------------------------------- +# 6. Generate patches +# --------------------------------------------------------------------------- + +if [ -n "$OUTPUT_DIR" ]; then + # Write patch files to the output directory + mkdir -p "$OUTPUT_DIR" + git format-patch "${PATCH_BASE}..HEAD" -o "$OUTPUT_DIR" + info "Patches written to $OUTPUT_DIR/" +else + # Write patches to stdout (for piping via podman exec) + git format-patch "${PATCH_BASE}..HEAD" --stdout +fi diff --git a/scripts/install-uf-tools.sh b/scripts/install-uf-tools.sh new file mode 100755 index 0000000..69b934c --- /dev/null +++ b/scripts/install-uf-tools.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# install-uf-tools.sh — Install all Unbound Force tools. +# +# Single source of truth for which tools are installed and at what +# versions. Used by both Containerfiles and the dynamic devfile's +# postStart command. +# +# Prerequisites: +# - Go 1.24+ installed, $GOPATH set, $GOPATH/bin in $PATH +# - Node.js 20+ and npm installed +# - Network access +# +# Constraints: +# - MUST NOT use Homebrew or Linuxbrew (FR-003) +# - MUST NOT install Ollama (FR-005, Constitution I) +# - Idempotent — safe to run multiple times +# - Works on both arm64 and amd64 + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +info() { + printf '\033[1;34m==>\033[0m %s\n' "$*" +} + +error() { + printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2 +} + +# --------------------------------------------------------------------------- +# Pre-flight checks +# --------------------------------------------------------------------------- + +if ! command -v go &>/dev/null; then + error "Go is not installed. Go 1.24+ is required." + exit 1 +fi + +if ! command -v node &>/dev/null || ! command -v npm &>/dev/null; then + error "Node.js and npm are required. Node.js 20+ expected." + exit 1 +fi + +GOPATH="${GOPATH:-$HOME/go}" +export GOPATH +export PATH="$GOPATH/bin:$PATH" + +# --------------------------------------------------------------------------- +# Go tools — fail-fast on any install failure (Edge Case 3 from spec) +# --------------------------------------------------------------------------- + +GO_TOOLS=( + "github.com/unbound-force/unbound-force/cmd/unbound-force@latest" + "github.com/unbound-force/dewey@latest" + "github.com/unbound-force/replicator/cmd/replicator@latest" + "github.com/unbound-force/gaze/cmd/gaze@latest" + "github.com/golangci/golangci-lint/cmd/golangci-lint@latest" + "golang.org/x/vuln/cmd/govulncheck@latest" +) + +for tool in "${GO_TOOLS[@]}"; do + info "Installing ${tool} ..." + go install "${tool}" +done + +# Create 'uf' symlink for 'unbound-force' binary +if [ -f "$GOPATH/bin/unbound-force" ] && [ ! -f "$GOPATH/bin/uf" ]; then + info "Creating uf symlink ..." + ln -s "$GOPATH/bin/unbound-force" "$GOPATH/bin/uf" +fi + +# --------------------------------------------------------------------------- +# OpenSpec CLI via npm +# --------------------------------------------------------------------------- + +# Configure npm to install global packages in user home (avoids EACCES on /usr/local) +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +mkdir -p "$NPM_CONFIG_PREFIX" + +info "Installing @fission-ai/openspec via npm ..." +npm install -g @fission-ai/openspec + +# --------------------------------------------------------------------------- +# Version verification — print each tool for build-log visibility +# --------------------------------------------------------------------------- + +info "Verifying installed tools ..." + +echo "" +echo "--- Tool Versions ---" +echo "" + +uf --version || { error "uf verification failed"; exit 1; } +dewey version || { error "dewey verification failed"; exit 1; } +replicator --version || { error "replicator verification failed"; exit 1; } +gaze --version || { error "gaze verification failed"; exit 1; } +golangci-lint --version || { error "golangci-lint verification failed"; exit 1; } +govulncheck -version || { error "govulncheck verification failed"; exit 1; } +openspec --version 2>/dev/null || openspec --help > /dev/null 2>&1 || { error "openspec verification failed"; exit 1; } + +echo "" +info "All tools installed successfully." diff --git a/specs/001-initial-containerfile/checklists/requirements.md b/specs/001-initial-containerfile/checklists/requirements.md new file mode 100644 index 0000000..73e4ee3 --- /dev/null +++ b/specs/001-initial-containerfile/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: Initial Containerfile + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-11 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- 4 user stories covering: local build (P1), headless mode (P2), CDE (P3), CI (P4). +- 21 functional requirements, 7 success criteria, 5 edge cases. +- No clarifications needed — Issue #1 and Discussion #88 provided complete requirements. diff --git a/specs/001-initial-containerfile/contracts/connect.md b/specs/001-initial-containerfile/contracts/connect.md new file mode 100644 index 0000000..561ebc5 --- /dev/null +++ b/specs/001-initial-containerfile/contracts/connect.md @@ -0,0 +1,82 @@ +# Contract: connect.sh + +**Path**: `scripts/connect.sh` +**Runs on**: Host (developer's machine) +**Referenced by**: README.md (documented for developer use) +**Requirements**: FR-015 + +## Purpose + +Convenience script for the developer to start a container and attach +to it via `opencode attach`. This is the host-side companion to the +headless server mode. It handles container lifecycle (start if not +running) and establishes the OpenCode session. + +## Interface + +**Inputs**: +- `$1` (optional): Path to the project directory to mount. Defaults + to the current directory (`.`). +- `$2` (optional): Container name. Defaults to `opencode-server`. +- Environment: `$OPENCODE_IMAGE` (default: + `quay.io/unbound-force/opencode-dev:latest`) — image to use. + +**Outputs**: +- A running container with OpenCode server (if not already running) +- An attached OpenCode session in the terminal +- Exit code from `opencode attach` + +**Side effects**: +- May start a new container via `podman run` or `podman-compose up` +- Attaches to the container's OpenCode server + +## Behavior + +1. **Check if container is running**: Use `podman ps` to check if a + container with the specified name is already running. + +2. **Start if needed**: If the container is not running: + a. If `podman-compose.yml` exists in the project directory, use + `podman-compose up -d`. + b. Otherwise, start with `podman run -d` with appropriate flags + (resource limits, volume mount, Ollama endpoint, port mapping). + +3. **Wait for readiness**: Poll the OpenCode server health endpoint + (port 4096) until it responds or timeout (30 seconds). + +4. **Attach**: Run `opencode attach` to connect to the server. + +5. **Cleanup guidance**: After detaching, print a message about how + to stop the container (`podman-compose down` or `podman stop`). + +## Constraints + +- MUST run on the host, not inside the container. +- MUST use Podman, not Docker (Architecture Constraint). +- MUST apply resource limits (8G memory, 4 CPUs) when starting a + new container (FR-020). +- MUST use `:Z` volume mount suffix for SELinux compatibility + (Architecture Constraint). +- MUST set `DEWEY_EMBEDDING_ENDPOINT` when starting the container. +- MUST use `set -euo pipefail` for strict error handling. +- SHOULD detect if Podman is installed and print a helpful error if + not. + +## Validation + +```bash +# Start and connect (project in current directory): +./scripts/connect.sh +# Should start container and attach to OpenCode + +# Start with explicit project path: +./scripts/connect.sh /path/to/my-project +# Should mount that directory and connect + +# Container already running: +./scripts/connect.sh +# Should skip start and attach directly + +# Podman not installed: +# Should print "Podman is required but not installed" and exit 1 +``` diff --git a/specs/001-initial-containerfile/contracts/entrypoint.md b/specs/001-initial-containerfile/contracts/entrypoint.md new file mode 100644 index 0000000..896d3a5 --- /dev/null +++ b/specs/001-initial-containerfile/contracts/entrypoint.md @@ -0,0 +1,94 @@ +# Contract: entrypoint.sh + +**Path**: `scripts/entrypoint.sh` +**Runs on**: Container (at runtime, as the ENTRYPOINT) +**Referenced by**: `Containerfile`, `Containerfile.udi` +**Requirements**: FR-013 + +## Purpose + +Container entrypoint that handles first-run initialization and starts +OpenCode in server mode when requested. Provides a clean startup +experience whether the container is run interactively or as a headless +server. + +## Interface + +**Inputs**: +- `$1` (optional): Command to execute. If empty or `server`, starts + OpenCode in server mode. If `bash` or `sh`, starts a shell. Any + other value is executed directly via `exec "$@"`. +- Environment: `$WORKSPACE` (default: `/workspace`) — path to the + mounted project directory. +- Environment: `$DEWEY_EMBEDDING_ENDPOINT` — Ollama endpoint URL. +- Environment: `$OPENCODE_PORT` (default: `4096`) — server listen port. + +**Outputs**: +- Running OpenCode server process (server mode), or +- Interactive shell (shell mode), or +- Executed command (pass-through mode) +- Exit code from the executed process + +**Side effects**: +- May run `uf init` on first startup if the workspace has a git repo + but no `.uf/` directory. +- Creates `.uf/` directory in the workspace if `uf init` runs. + +## Behavior + +1. **Workspace detection**: Check if `$WORKSPACE` exists and is a + directory. If not, print a warning and use `$HOME` as fallback. + +2. **Git repository check**: If `$WORKSPACE` contains a `.git` + directory, proceed with initialization. If not, skip `uf init` + (Edge Case 2 from spec — no git repo). + +3. **First-run initialization**: If `$WORKSPACE/.uf/` does not exist + and a git repo is present, run `uf init` to set up the UF + workspace. If `uf init` fails, log the error but continue — do + not block container startup. + +4. **Ollama connectivity check**: If `$DEWEY_EMBEDDING_ENDPOINT` is + set, attempt a health check (curl with 2-second timeout). Log the + result but do NOT fail if Ollama is unreachable (Constitution I — + graceful degradation, Edge Case 1). + +5. **Command dispatch**: + - No arguments or `server`: Start OpenCode in server mode on + `$OPENCODE_PORT`. + - `bash` or `sh`: Start an interactive shell. + - Anything else: `exec "$@"` (pass-through). + +## Constraints + +- MUST NOT fail if Ollama is unreachable (Constitution I). +- MUST NOT fail if the workspace has no git repo (Edge Case 2). +- MUST handle read-only workspace mounts gracefully (Model B). If + `uf init` fails due to read-only filesystem, log and continue. +- MUST use `exec` for the final process to ensure proper signal + handling (PID 1 behavior). +- MUST use `set -euo pipefail` for strict error handling in the + initialization phase, but NOT for the final `exec` (which replaces + the shell process). + +## Validation + +```bash +# Server mode +podman run -d --name test opencode-dev +# Verify OpenCode is listening on port 4096 +podman exec test curl -s http://localhost:4096/health || true +podman rm -f test + +# Shell mode +podman run --rm -it opencode-dev bash +# Should get a bash prompt + +# Pass-through mode +podman run --rm opencode-dev whoami +# Should print "dev" + +# No git repo +podman run --rm -v /tmp/empty:/workspace:Z opencode-dev bash -c "echo ok" +# Should not error about missing git repo +``` diff --git a/specs/001-initial-containerfile/contracts/extract-changes.md b/specs/001-initial-containerfile/contracts/extract-changes.md new file mode 100644 index 0000000..67d100b --- /dev/null +++ b/specs/001-initial-containerfile/contracts/extract-changes.md @@ -0,0 +1,86 @@ +# Contract: extract-changes.sh + +**Path**: `scripts/extract-changes.sh` +**Runs on**: Container (at runtime, invoked by developer) +**Referenced by**: `podman-compose.yml` (documented in README) +**Requirements**: FR-014, FR-021 + +## Purpose + +Export changes made by the AI agent inside the container as +`git format-patch` output. This is the ONLY approved method for +extracting changes from a headless (Model B) container where the +source directory is mounted read-only. The developer reviews the +patches on the host before applying them. + +## Interface + +**Inputs**: +- Working directory: Must be inside a git repository with uncommitted + or committed changes relative to the original mount. +- `$1` (optional): Output directory for patch files. If omitted, + patches are written to stdout. +- `$2` (optional): Base ref to diff against. Defaults to the branch + that was checked out when the container started (typically `HEAD` + of the mounted repo's current branch). + +**Outputs**: +- Patch files in `git format-patch` format, either: + - Written to the specified output directory (one file per commit), or + - Printed to stdout (for piping to the host via `podman exec`) +- Exit code 0 on success, non-zero on failure +- Exit code 0 with informational message if there are no changes + +**Side effects**: +- May create patch files in the output directory +- May create temporary commits from staged/unstaged changes + +## Behavior + +1. **Working directory check**: Verify the current directory is inside + a git repository. If not, print an error and exit 1. + +2. **Detect changes**: Check for uncommitted changes (staged and + unstaged). If changes exist: + a. Stage all changes (`git add -A`). + b. Create a temporary commit with a descriptive message + (e.g., `"agent: changes from container session"`). + +3. **Generate patches**: Run `git format-patch` against the base ref + to produce patch files. + +4. **Output**: Write patches to the output directory or stdout. + +5. **No changes**: If there are no changes (no uncommitted changes and + no new commits since the base ref), print an informational message + and exit 0. + +## Constraints + +- MUST work when the source mount is read-only (the script operates + on the container's internal working copy, not the mounted source). +- MUST NOT modify the host filesystem directly (Constitution II). +- MUST produce standard `git format-patch` output that can be applied + with `git am` on the host. +- MUST use `set -euo pipefail` for strict error handling. +- MUST handle the case where git user.name/user.email are not + configured (set defaults if needed for the temporary commit). + +## Validation + +```bash +# Inside a running container with changes: +/scripts/extract-changes.sh +# Should output patch content to stdout + +/scripts/extract-changes.sh /tmp/patches +# Should create patch files in /tmp/patches/ + +# No changes: +/scripts/extract-changes.sh +# Should print "No changes to extract" and exit 0 + +# Not a git repo: +cd /tmp && /scripts/extract-changes.sh +# Should print error and exit 1 +``` diff --git a/specs/001-initial-containerfile/contracts/install-uf-tools.md b/specs/001-initial-containerfile/contracts/install-uf-tools.md new file mode 100644 index 0000000..da58817 --- /dev/null +++ b/specs/001-initial-containerfile/contracts/install-uf-tools.md @@ -0,0 +1,92 @@ +# Contract: install-uf-tools.sh + +**Path**: `scripts/install-uf-tools.sh` +**Runs on**: Container (during image build or devfile postStart) +**Referenced by**: `Containerfile`, `Containerfile.udi`, `devfile-dynamic.yaml` +**Requirements**: FR-002, FR-003, FR-012 + +## Purpose + +Install all Unbound Force Go tools via `go install`. This script is +the single source of truth for which tools are installed and at what +versions. It is used by both Containerfiles and the dynamic devfile's +postStart command. + +## Interface + +**Inputs**: +- Environment: `$GOPATH` must be set (defaults to `$HOME/go`) +- Environment: `$PATH` must include `$GOPATH/bin` +- Prerequisite: Go 1.24+ must be installed +- Prerequisite: Node.js 20+ and npm must be installed (for OpenSpec CLI) +- Prerequisite: Network access (downloads Go modules and npm packages) + +**Outputs**: +- Binaries in `$GOPATH/bin`: `uf`, `dewey`, `replicator`, `gaze`, + `golangci-lint`, `govulncheck` +- Binary via npm: `openspec` (OpenSpec CLI) +- Exit code 0 on success, non-zero on any failure + +**Side effects**: +- Downloads Go modules to `$GOPATH/pkg/mod` +- Downloads npm packages globally + +## Behavior + +1. Install each Go tool via `go install`: + - `github.com/unbound-force/unbound-force/cmd/uf@latest` + - `github.com/unbound-force/dewey/cmd/dewey@latest` + - `github.com/unbound-force/replicator/cmd/replicator@latest` + - `github.com/unbound-force/gaze/cmd/gaze@latest` + - `github.com/golangci/golangci-lint/cmd/golangci-lint@latest` + - `golang.org/x/vuln/cmd/govulncheck@latest` + +2. Install OpenSpec CLI via npm: + - `npm install -g @openspec/cli` + +3. **Fail-fast**: If any `go install` or `npm install` fails, the + script MUST exit immediately with a non-zero exit code. Partial + tool installation is not acceptable (Edge Case 3 from spec). + +4. Print each tool name and version after successful installation + for build log visibility. + +## Constraints + +- MUST NOT use Homebrew or Linuxbrew (FR-003, Architecture Constraint). +- MUST NOT install Ollama (FR-005, Constitution I). +- MUST be idempotent — safe to run multiple times. +- MUST work on both arm64 and amd64 architectures. +- MUST use `set -euo pipefail` for strict error handling. + +## Version Pinning Trade-Off + +All Go tools use `@latest` rather than pinned versions. This is a +conscious trade-off: UF tools do not yet have a stable release cadence, +and pinning to specific versions would require manual bumps for every +upstream release. The `@latest` approach ensures the container image +always includes the most recent tool versions. This means builds are +not perfectly reproducible across time — two builds on different days +may install different tool versions. This trade-off is acceptable +because: + +1. The smoke test suite (tool `--version` checks) validates that all + tools are present and functional after every build. +2. CI builds are triggered on every push to main, so version drift is + detected quickly. +3. Once UF tools reach v1.0 with stable release cadences, this script + SHOULD be updated to pin specific versions (e.g., + `@v1.2.3` instead of `@latest`). + +## Validation + +```bash +# After running the script: +uf --version # must succeed +dewey --version # must succeed +replicator --version # must succeed +gaze --version # must succeed +golangci-lint --version # must succeed +govulncheck -version # must succeed +openspec --version # must succeed (or openspec --help) +``` diff --git a/specs/001-initial-containerfile/data-model.md b/specs/001-initial-containerfile/data-model.md new file mode 100644 index 0000000..4b5167b --- /dev/null +++ b/specs/001-initial-containerfile/data-model.md @@ -0,0 +1,107 @@ +# Data Model: Initial Containerfile + +**Phase**: 1 — Design & Contracts +**Feature**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) + +> **Note**: This is an infrastructure project, not an application. There +> are no database tables, API schemas, or domain objects. The "data model" +> describes the key entities that the container infrastructure produces +> and consumes. + +## Entities + +### Container Image + +The primary OCI artifact published to quay.io. Two variants exist. + +| Field | Type | Description | +|-------|------|-------------| +| name | string | `opencode-dev` (primary) or `opencode-dev-udi` (CDE) | +| registry | URL | `quay.io/unbound-force/opencode-dev` | +| base_image | string | `registry.fedoraproject.org/fedora:41` (primary) or `quay.io/devfile/universal-developer-image:latest` (UDI) | +| architectures | list | `[linux/arm64, linux/amd64]` | +| user | string | `dev` (primary) or `user` (UDI, inherited) | +| tools | list | `[uf, opencode, dewey, replicator, gaze, go, node, npm, git, gh, golangci-lint, govulncheck, openspec]` | +| env.DEWEY_EMBEDDING_ENDPOINT | URL | `http://host.containers.internal:11434` | +| env.GOPATH | path | `/home/dev/go` (primary) | +| env.PATH | path | Includes `$GOPATH/bin`, `/usr/local/go/bin`, node bin | + +**Invariants**: +- Ollama is NEVER installed in the image. +- No secrets (SSH keys, tokens, API keys) are present. +- The final `USER` instruction is always non-root. +- All tools pass `--version` smoke tests. + +### Devfile + +Eclipse Che workspace definition conforming to Devfile 2.2.0. + +| Field | Type | Description | +|-------|------|-------------| +| schemaVersion | string | `2.2.0` | +| metadata.name | string | `opencode-dev` or `opencode-dev-dynamic` | +| components[].container.image | string | Custom image or UDI | +| components[].container.memoryLimit | string | `8Gi` | +| components[].container.cpuLimit | string | `4` | +| components[].container.mountSources | bool | `true` | +| components[].container.endpoints[] | object | OpenCode server on port 4096 | +| commands[] | object | postStart hooks (dynamic variant only) | +| events.postStart | list | Command IDs to run at workspace start | + +**Variants**: +- `devfile.yaml`: Uses custom image. Fast startup, no postStart install. +- `devfile-dynamic.yaml`: Uses UDI. Slower startup, installs tools via + postStart. No custom image dependency. + +### Deployment Model + +One of three modes for running the container. Each has different +security properties and use cases. + +| Model | Mount | Security | Use Case | +|-------|-------|----------|----------| +| Interactive (Model A) | Read-write (`-v ./project:/workspace:Z`) | Agent can modify host files directly | Local development, trusted agent | +| Headless (Model B) | Read-only (`-v ./project:/workspace:ro,Z`) | Agent cannot modify host files; changes via `git format-patch` | Maximum isolation, untrusted agent | +| CDE (Model C) | Eclipse Che managed | Workspace-level isolation | Cloud development, team environments | + +**Invariants**: +- All models enforce resource limits (8G memory, 4 CPUs). +- All models set `DEWEY_EMBEDDING_ENDPOINT` for host Ollama. +- Model B MUST use read-only source mount. +- Model B extracts changes via `git format-patch` only. + +### Script + +Shell scripts that support container lifecycle operations. + +| Script | Runs On | Purpose | Inputs | Outputs | +|--------|---------|---------|--------|---------| +| `install-uf-tools.sh` | Container (build or postStart) | Install all UF Go tools | None (uses env vars) | Tools in `$GOPATH/bin` | +| `entrypoint.sh` | Container (runtime) | First-run init + OpenCode server start | `$1` (command), env vars | Running OpenCode server or shell | +| `extract-changes.sh` | Container (runtime) | Export agent changes as patches | Working directory with git repo | Patch files on stdout or in output dir | +| `connect.sh` | Host | Start container and attach via OpenCode | Project path argument | Running container + attached session | + +## Relationships + +```text +Container Image +├── uses → install-uf-tools.sh (during build) +├── uses → entrypoint.sh (at runtime) +├── referenced by → devfile.yaml (custom image variant) +├── referenced by → podman-compose.yml (headless mode) +└── published to → quay.io (via CI workflow) + +Devfile +├── devfile.yaml → references Container Image +└── devfile-dynamic.yaml → references UDI + install-uf-tools.sh + +Deployment Model +├── Interactive → podman run (manual) +├── Headless → podman-compose.yml +└── CDE → devfile.yaml or devfile-dynamic.yaml + +CI Workflow +├── builds → Container Image (both architectures) +├── runs → smoke tests +└── pushes → quay.io registry +``` diff --git a/specs/001-initial-containerfile/plan.md b/specs/001-initial-containerfile/plan.md new file mode 100644 index 0000000..c2fb31a --- /dev/null +++ b/specs/001-initial-containerfile/plan.md @@ -0,0 +1,140 @@ +# Implementation Plan: Initial Containerfile, Devfile, and Scripts + +**Branch**: `001-initial-containerfile` | **Date**: 2026-04-11 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/001-initial-containerfile/spec.md` + +## Summary + +Create the complete container infrastructure for running OpenCode and the +full Unbound Force toolchain inside Podman containers. The deliverables are +two Containerfiles (Fedora-based and UDI-based), two devfiles for Eclipse +Che, four helper scripts (install, entrypoint, extract, connect), a +podman-compose orchestration file, a GitHub Actions CI workflow, and a +README documenting all three deployment models. The technical approach uses +Fedora as the base image with `go install` for Go tools, `curl` for +OpenCode, `npm` for OpenSpec CLI, and `dnf` for system packages — no +Homebrew. All images are multi-arch (arm64 + amd64) and run as a non-root +`dev` user. + +## Technical Context + +**Language/Version**: Shell scripts (bash), Containerfile (OCI/Docker syntax), YAML (devfile 2.2.0, compose, GitHub Actions) +**Primary Dependencies**: Podman, Go 1.24+, Node.js 20+, npm, Git, gh CLI +**Storage**: N/A (container images, not database) +**Testing**: `podman build` + smoke test commands (`uf --version`, `opencode --version`, `dewey --version`, `replicator --version`, `gaze --version`, `whoami`). No `go test` or `npm test`. +**Target Platform**: linux/arm64 + linux/amd64 (multi-arch OCI images) +**Project Type**: Infrastructure / container definitions (no application code) +**Performance Goals**: Image build < 15 min per arch, container startup < 30 sec +**Constraints**: Non-root user (`dev`), no secrets in image, multi-arch required, Ollama on host only, Podman rootless only, SELinux `:Z` mounts on Fedora +**Scale/Scope**: 2 Containerfiles, 2 devfiles, 4 scripts, 1 compose file, 1 CI workflow, 1 README + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +### I. Composability First — PASS + +- **Image standalone**: The image delivers its core value (OpenCode + UF + toolchain) when deployed with only Podman and a project directory. No + external services are required for primary operation. +- **Ollama on host**: The container connects via + `DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434`. + Ollama is NOT installed in the container (FR-005). +- **Graceful degradation**: When Ollama is unavailable, the container + starts and operates with reduced capability. Dewey embeddings degrade + but all other tools remain functional (Edge Case 1). +- **Tool independence**: Each tool (uf, OpenCode, Dewey, Replicator, + Gaze) is independently functional. One tool's failure does not break + others. + +### II. Security Through Isolation — PASS + +- **Non-root user**: Container runs as `dev`, not root (FR-004). +- **No secrets**: No SSH keys, git push tokens, or API keys baked into + the image (FR-019). Secrets injected at runtime. +- **Read-only mounts**: Headless mode (Model B) uses read-only source + mounts (FR-021). Changes extracted via `git format-patch` only. +- **Resource limits**: 8G memory, 4 CPUs enforced in compose and devfile + (FR-020). +- **Podman rootless**: Only supported runtime mode. No Docker daemon. + +### III. Reproducible Builds — PASS + +- **Multi-arch**: All images build for both `linux/arm64` and + `linux/amd64` (FR-001). +- **Pinned versions**: Go 1.24+, Node.js 20+, Fedora base image version + pinned. Tool installation via `go install @latest` (latest available + version at build time; see `contracts/install-uf-tools.md` Version + Pinning Trade-Off for rationale). +- **Smoke tests**: Every image passes the smoke test suite from + AGENTS.md (SC-002). +- **CI publishing**: GitHub Actions builds and pushes to + `quay.io/unbound-force/opencode-dev` on every push to main (FR-017). + +### IV. Executable Truth — PASS + +- **Containerfile is truth**: When Containerfile and README conflict, + the README is fixed (Behavioral Constraint). +- **Zero-waste**: Every file serves a purpose traceable to a deliverable. + No unused scripts, dead configuration, or placeholder files. +- **Build verification**: After any Containerfile change, the image is + built locally and smoke tests pass before the task is complete. +- **Script traceability**: Every script is referenced by a Containerfile + or compose file. Unreferenced scripts are removed. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-initial-containerfile/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output (key entities) +├── quickstart.md # Phase 1 output (deployment guides) +├── contracts/ # Phase 1 output (script contracts) +│ ├── install-uf-tools.md +│ ├── entrypoint.md +│ ├── extract-changes.md +│ └── connect.md +├── checklists/ +│ └── requirements.md # Spec quality checklist (already exists) +└── tasks.md # Phase 2 output (/speckit.tasks — NOT created here) +``` + +### Source Code (repository root) + +```text +. +├── Containerfile # Primary multi-arch image (Fedora base) +├── Containerfile.udi # CDE variant (UDI base) +├── devfile.yaml # Eclipse Che workspace (custom image) +├── devfile-dynamic.yaml # Eclipse Che workspace (UDI + postStart) +├── podman-compose.yml # Headless server orchestration +├── scripts/ +│ ├── install-uf-tools.sh # Install all UF tools via go install +│ ├── entrypoint.sh # Container entrypoint +│ ├── extract-changes.sh # Git format-patch extraction +│ └── connect.sh # Host-side attach script +├── .github/ +│ └── workflows/ +│ └── build-push.yml # CI: multi-arch build + push to quay.io +├── README.md # All deployment models, security, prerequisites +├── AGENTS.md # Agent context (already exists) +├── LICENSE # Apache 2.0 (already exists) +└── opencode.json # OpenCode config (already exists) +``` + +**Structure Decision**: Flat layout at repository root. No `src/` or +`tests/` directories — this is an infrastructure project, not an +application. Containerfiles live at the root (OCI convention). Scripts +live in `scripts/` for organization. CI lives in `.github/workflows/` +(GitHub convention). This matches the deliverable table in AGENTS.md. + +## Complexity Tracking + +> No constitution violations. All four principles pass without exception. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| *(none)* | — | — | diff --git a/specs/001-initial-containerfile/quickstart.md b/specs/001-initial-containerfile/quickstart.md new file mode 100644 index 0000000..9551e25 --- /dev/null +++ b/specs/001-initial-containerfile/quickstart.md @@ -0,0 +1,149 @@ +# Quickstart: Initial Containerfile + +**Phase**: 1 — Design & Contracts +**Feature**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) + +> Step-by-step instructions for each deployment model. These will +> inform the README.md content during implementation. + +## Prerequisites + +All models require: +- **Podman** installed and configured for rootless operation +- **A project directory** with a git repository to mount into the container + +Optional (for enhanced functionality): +- **Ollama** running on the host at `localhost:11434` (enables Dewey + semantic embeddings) + +## Model A: Interactive (Read-Write Mount) + +The simplest deployment. The agent has direct read-write access to +your project files. Use when you trust the agent and want immediate +file changes. + +```bash +# 1. Build the image +podman build -t opencode-dev -f Containerfile . + +# 2. Verify the build +podman run --rm opencode-dev uf --version +podman run --rm opencode-dev opencode --version +podman run --rm opencode-dev dewey --version +podman run --rm opencode-dev replicator --version +podman run --rm opencode-dev gaze --version +podman run --rm opencode-dev whoami # should print "dev" + +# 3. Run interactively with your project mounted +podman run -it --rm \ + --memory 8g --cpus 4 \ + -v ./my-project:/workspace:Z \ + -e DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 \ + opencode-dev +``` + +**Security properties**: Agent can read and write host files directly. +Resource limits enforced. No secrets in image. + +## Model B: Headless (Read-Only Mount + Format-Patch) + +Maximum isolation. The source directory is mounted read-only. The agent +works on an internal writable copy. Changes are extracted via +`git format-patch` for human review before applying to the host. + +**How it works**: The `podman-compose.yml` mounts the host project +directory as read-only at `/workspace` and provides a separate writable +volume (the work directory) where the agent operates. The entrypoint +creates a working copy from the read-only source into the writable +area. The agent makes changes in the writable copy, and +`extract-changes.sh` generates patches from that copy. The host source +is never modified directly. + +```bash +# 1. Build the image (same as Model A) +podman build -t opencode-dev -f Containerfile . + +# 2. Start the headless server +podman-compose up -d + +# 3. Connect from the host +./scripts/connect.sh + +# 4. After the agent makes changes, extract them +podman exec opencode-server /scripts/extract-changes.sh + +# 5. Review and apply patches on the host +git am < patches/*.patch + +# 6. Stop the server +podman-compose down +``` + +**Security properties**: Agent CANNOT modify host files. Read-only +source mount. Changes require human review via format-patch. Resource +limits enforced. No secrets in image. + +## Model C: CDE (Eclipse Che / Dev Spaces) + +Cloud development environment. Use when working in Eclipse Che or +Red Hat Dev Spaces. + +### Option 1: Custom Image (fast startup) + +```yaml +# Use devfile.yaml — references the pre-built custom image +# Create workspace in Eclipse Che pointing to this repo +# All tools are immediately available +``` + +### Option 2: Dynamic (UDI + postStart, no custom image) + +```yaml +# Use devfile-dynamic.yaml — uses UDI base image +# Tools are installed via postStart commands at workspace creation +# Slower startup, but no custom image dependency +``` + +**Security properties**: Workspace-level isolation managed by Eclipse +Che. Resource limits set in devfile. No secrets in image. + +## Smoke Test Suite + +After building any image variant, run the full smoke test: + +```bash +IMAGE=opencode-dev # or opencode-dev-udi + +# Tool version checks +podman run --rm $IMAGE uf --version +podman run --rm $IMAGE opencode --version +podman run --rm $IMAGE dewey --version +podman run --rm $IMAGE replicator --version +podman run --rm $IMAGE gaze --version + +# Non-root verification +podman run --rm $IMAGE whoami # must print "dev" (or "user" for UDI) + +# Go toolchain +podman run --rm $IMAGE go version +podman run --rm $IMAGE golangci-lint --version +podman run --rm $IMAGE govulncheck -version + +# Node.js toolchain +podman run --rm $IMAGE node --version +podman run --rm $IMAGE npm --version + +# Git and GitHub CLI +podman run --rm $IMAGE git --version +podman run --rm $IMAGE gh --version +``` + +## Troubleshooting + +| Problem | Cause | Solution | +|---------|-------|----------| +| `host.containers.internal` not resolving | Podman version or platform limitation | Set `DEWEY_EMBEDDING_ENDPOINT` to host IP manually | +| SELinux denying volume access | Missing `:Z` relabel | Add `:Z` suffix to volume mount | +| `go install` fails during build | Network access required | Ensure build environment has internet access | +| OpenCode not starting | Port 4096 already in use | Stop conflicting process or change port mapping | +| `whoami` returns `root` | Containerfile `USER` instruction missing | Verify `USER dev` is the final user instruction | diff --git a/specs/001-initial-containerfile/research.md b/specs/001-initial-containerfile/research.md new file mode 100644 index 0000000..a1efc8f --- /dev/null +++ b/specs/001-initial-containerfile/research.md @@ -0,0 +1,204 @@ +# Research: Initial Containerfile + +**Phase**: 0 — Research +**Feature**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) + +## R1: Fedora Base Image Selection + +**Question**: Which Fedora base image and version strategy to use? + +**Decision**: Use `registry.fedoraproject.org/fedora:41` (pinned major +version). + +**Rationale**: +- Fedora 41 is the current stable release (as of April 2026). +- Pinning the major version (`fedora:41`) provides reproducibility + while still receiving security updates via `dnf update`. +- Using `fedora:latest` would cause unpredictable breakage when Fedora + bumps to the next release. +- Using `fedora:41-YYYYMMDD` (date-pinned) is too rigid — prevents + security patches without manual bumps. +- Fedora provides official multi-arch images for both `linux/arm64` + and `linux/amd64`. + +**Alternatives rejected**: +- `ubuntu:24.04`: Would work, but Fedora aligns with the Red Hat + ecosystem (Eclipse Che, Dev Spaces, UDI). Using `dnf` consistently + across Containerfile and UDI variant simplifies maintenance. +- `alpine:3.20`: Musl libc causes compatibility issues with Go + binaries built with CGO. Would require static builds. +- `fedora:latest`: Non-deterministic. A Fedora version bump could + break the build without any code change. + +## R2: Multi-Stage Build Strategy + +**Question**: Should the Containerfile use multi-stage builds to reduce +image size, or keep Go in the final image? + +**Decision**: Single-stage build. Keep Go, Node.js, and all build tools +in the final image. + +**Rationale**: +- AI agents running inside the container need `go test`, `go build`, + `go vet`, and `golangci-lint` to validate their own code changes. + Stripping Go from the final image would break the core use case. +- Similarly, `npm` is needed for OpenSpec CLI and any Node.js-based + tooling the agent might use. +- Image size is a secondary concern. Developer productivity and tool + availability are primary. The image is pulled once and cached. +- Multi-stage builds add complexity for zero benefit when all build + tools are also runtime tools. + +**Alternatives rejected**: +- Multi-stage with Go in builder only: Breaks `go test` inside the + container. Agents cannot validate Go code changes. +- Multi-stage with selective copy: Same problem. Any tool omitted + from the final stage is unavailable to agents. + +## R3: UDI Base Image Compatibility + +**Question**: How does the UDI (Universal Developer Image) differ from +Fedora, and what installation adjustments are needed? + +**Decision**: The UDI variant (`Containerfile.udi`) uses +`quay.io/devfile/universal-developer-image:latest` as the base and +installs UF tools on top. + +**Key differences**: +- UDI is based on UBI (Universal Base Image) / Fedora, so `dnf` works. +- UDI already includes Go, Node.js, Git, and common developer tools. + The UF-specific tools (uf, dewey, replicator, gaze, opencode) must + be installed on top. +- UDI runs as user `user` (UID 1001) by default, not `root`. The + Containerfile.udi should respect this and NOT switch to root for + tool installation — use `USER 0` temporarily if needed, then switch + back. +- UDI includes `$HOME` at `/home/user`, not `/home/dev`. The + Containerfile.udi should use the UDI's existing user rather than + creating a new `dev` user. +- Go and Node.js versions in UDI may differ from what we pin in the + primary Containerfile. Accept UDI's versions for compatibility. + +**Alternatives rejected**: +- Creating a `dev` user in UDI: Conflicts with Eclipse Che's + expectations. Che expects the UDI's default user. +- Replacing UDI's Go/Node.js: Fragile. UDI's tool versions are tested + together. Overriding them risks breaking the base image. + +## R4: Devfile 2.2.0 Schema Requirements + +**Question**: What are the key schema requirements for Eclipse Che +devfiles? + +**Decision**: Both devfiles conform to Devfile 2.2.0 specification. + +**Key requirements**: +- `schemaVersion: 2.2.0` is mandatory at the top level. +- `metadata.name` is required. +- Container components use `container` type with `image`, `memoryLimit`, + `cpuLimit`, `mountSources`, and `endpoints` fields. +- `endpoints` define exposed ports with `name`, `targetPort`, and + optional `exposure` (public, internal, none). +- `commands` define lifecycle hooks: `postStart` for tool installation + in the dynamic devfile. +- `events.postStart` references command IDs for automatic execution. +- Memory/CPU limits use string format: `"8Gi"` for memory, `"4"` for + CPU cores. +- `mountSources: true` mounts the project source at `/projects`. + +**devfile.yaml** (custom image): +- References `quay.io/unbound-force/opencode-dev:latest`. +- Sets resource limits. +- Defines OpenCode server endpoint on port 4096. + +**devfile-dynamic.yaml** (UDI + postStart): +- Uses `quay.io/devfile/universal-developer-image:latest`. +- Defines a `postStart` command that runs `install-uf-tools.sh`. +- No custom image dependency — tools installed at workspace start. + +## R5: GitHub Actions Multi-Arch Build with Podman + +**Question**: How to build multi-arch OCI images in GitHub Actions +using Podman (not Docker)? + +**Decision**: Use `podman manifest` with QEMU emulation for cross-arch +builds. + +**Approach**: +1. Install QEMU user-static for cross-architecture emulation: + `sudo apt-get install qemu-user-static`. +2. Create a manifest list: `podman manifest create opencode-dev`. +3. Build for each architecture: + `podman build --platform linux/arm64 --manifest opencode-dev .` + `podman build --platform linux/amd64 --manifest opencode-dev .` +4. Push the manifest: `podman manifest push opencode-dev + quay.io/unbound-force/opencode-dev:latest`. +5. Login to quay.io: `podman login quay.io` with secrets. + +**CI triggers**: +- Push to `main`: Build and push with `latest` tag. +- Version tag push (`v*`): Build and push with version tag. +- Pull request: Build only (no push) for validation. + +**Alternatives rejected**: +- Docker Buildx: We standardize on Podman. Using Docker in CI while + requiring Podman locally creates inconsistency. +- GitHub Container Registry (ghcr.io): quay.io is the established + registry for Red Hat ecosystem images. Aligns with UDI and Dev + Spaces conventions. + +## R6: OpenCode Installation Method + +**Question**: How to install OpenCode in the container? + +**Decision**: Use the official curl installer. + +**Approach**: +```bash +curl -fsSL https://opencode.ai/install | bash +``` + +**Rationale**: +- OpenCode is not a Go tool — it cannot be installed via `go install`. +- The curl installer is the official distribution method. +- The installer detects architecture (arm64/amd64) automatically. +- The binary is placed in a standard location (`/usr/local/bin` or + similar). + +**Security consideration**: Piping curl to bash is a known anti-pattern +for security. However, this is during image build (not runtime), the +source is the official OpenCode domain, and the resulting image is +verified via smoke tests. The alternative (manual binary download with +checksum) is more complex and requires maintaining checksum values. + +## R7: Non-Root User Setup in Fedora Containers + +**Question**: How to create and configure a non-root user in the +Fedora-based Containerfile? + +**Decision**: Create a `dev` user with a home directory, add to +necessary groups, and set as the default user. + +**Approach**: +```dockerfile +RUN useradd -m -s /bin/bash dev +# ... install tools as root ... +USER dev +WORKDIR /home/dev +``` + +**Key considerations**: +- User creation happens early in the Containerfile so that `go install` + and other tool installations can target the user's `$GOPATH`. +- However, system packages (`dnf install`) require root. So the + sequence is: create user → install system packages as root → + install Go tools as `dev` → set `USER dev` as the final instruction. +- `$GOPATH/bin` must be in `$PATH` for the `dev` user. +- The `dev` user's home directory (`/home/dev`) is the default + `WORKDIR`. +- File permissions on installed tools must allow execution by the + `dev` user. + +**Alternative approach**: Install everything as root, then `chown` to +`dev`. This is simpler but creates a larger image (duplicate layer +data). Prefer installing as the target user where possible. diff --git a/specs/001-initial-containerfile/spec.md b/specs/001-initial-containerfile/spec.md new file mode 100644 index 0000000..0b50bb2 --- /dev/null +++ b/specs/001-initial-containerfile/spec.md @@ -0,0 +1,295 @@ +# Feature Specification: Initial Containerfile, Devfile, and Scripts + +**Feature Branch**: `001-initial-containerfile` +**Created**: 2026-04-11 +**Status**: In Review +**Input**: Issue #1 and Discussion #88 — multi-arch container image with the full UF toolchain, devfiles for Eclipse Che, helper scripts, orchestration, CI, and documentation. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Build and Run the Container Image Locally (Priority: P1) + +A developer wants to run OpenCode and the full Unbound Force +toolchain inside an isolated container on their local machine. +They build the image from the Containerfile, start a container +with their project directory mounted, and have immediate access +to all tools (uf, OpenCode, Dewey, Replicator, Gaze) without +installing anything on their host besides Podman. + +**Why this priority**: This is the foundational deliverable. +Every other story depends on a working container image. Without +it, nothing else in this repo has value. + +**Independent Test**: Build the image with `podman build`, run +the smoke test commands (tool version checks + `whoami`), and +verify all tools are present and the container runs as non-root. + +**Acceptance Scenarios**: + +1. **Given** a developer with Podman installed, **When** they + run `podman build -t opencode-dev -f Containerfile .`, + **Then** the image builds successfully on both arm64 and + amd64 architectures. +2. **Given** a built image, **When** they run + `podman run --rm opencode-dev uf --version`, **Then** the + command succeeds and prints a version string. Same for + `opencode`, `dewey`, `replicator`, and `gaze`. +3. **Given** a built image, **When** they run + `podman run --rm opencode-dev whoami`, **Then** the output + is `dev` (not `root`). +4. **Given** a built image, **When** they run the container + with a project directory mounted (`-v ./project:/workspace:Z`), + **Then** the tools can read and write files in the mounted + directory. + +--- + +### User Story 2 — Run OpenCode in Headless Server Mode (Priority: P2) + +A developer wants maximum isolation: the host source directory +is mounted read-only, OpenCode runs as a headless server, and +changes are extracted via `git format-patch` only after human +review. This is the "Model B" deployment from Discussion #88. + +**Why this priority**: Headless mode is the primary security +use case — the reason this repo exists. It depends on a +working image (US1) but delivers the core isolation promise. + +**Independent Test**: Start the container in headless mode +with read-only mount, verify OpenCode serves on port 4096, +connect from the host, make a change inside the container, +and extract it via format-patch. + +**Acceptance Scenarios**: + +1. **Given** a built image and a local project, **When** the + developer runs the compose file (`podman-compose up`), + **Then** OpenCode starts as a server on port 4096 with the + source directory mounted read-only. +2. **Given** a running headless container, **When** the + developer connects via `opencode attach`, **Then** they can + interact with the AI agent inside the container. +3. **Given** a headless container with agent-made changes, + **When** the developer runs the extract script, **Then** + changes are exported as `git format-patch` output for human + review before applying to the host. +4. **Given** a headless container, **When** the container + exceeds resource limits (8G memory, 4 CPUs), **Then** the + container runtime enforces the limits. + +--- + +### User Story 3 — Use the CDE Variant in Eclipse Che (Priority: P3) + +A developer working in Eclipse Che or Red Hat Dev Spaces wants +a pre-configured workspace with the full UF toolchain. They +create a workspace from the devfile and immediately have access +to all tools without manual setup. + +**Why this priority**: CDE support expands the audience beyond +local Podman users but is not required for the core isolation +use case. It depends on a working image (US1). + +**Independent Test**: Build the UDI variant, verify tools are +present. Validate both devfiles parse correctly against the +Devfile 2.2.0 schema. + +**Acceptance Scenarios**: + +1. **Given** a developer using Eclipse Che, **When** they + create a workspace from `devfile.yaml`, **Then** the + workspace starts with all UF tools available. +2. **Given** the UDI-based image, **When** it is built with + `podman build -f Containerfile.udi .`, **Then** all UF tools + are present and functional (same smoke tests as US1). +3. **Given** a developer using the dynamic devfile, **When** + they create a workspace from `devfile-dynamic.yaml`, + **Then** tools are installed via postStart commands using + the UDI base image (no custom image required). + +--- + +### User Story 4 — CI Builds and Publishes the Image (Priority: P4) + +An image maintainer wants the container image to be +automatically built and pushed to the registry on every push +to main, so consumers always have access to the latest image +without manual publishing. + +**Why this priority**: Automation is important but can be +deferred until the image definition is stable. Manual builds +work in the interim. + +**Independent Test**: Push a commit to main and verify the +CI workflow builds both architectures and pushes to quay.io. + +**Acceptance Scenarios**: + +1. **Given** a push to the main branch, **When** the CI + workflow runs, **Then** it builds the image for both + `linux/arm64` and `linux/amd64`. +2. **Given** a successful CI build, **When** the workflow + completes, **Then** the image is pushed to + `quay.io/unbound-force/opencode-dev` with `latest` tag. +3. **Given** a version tag push, **When** the CI workflow + runs, **Then** the image is additionally tagged with the + version number. + +--- + +### Edge Cases + +- What happens when Ollama is not running on the host? The + container MUST start successfully; Dewey embedding features + degrade but all other tools remain functional. +- What happens when the mounted project directory has no git + repository? The entrypoint MUST handle this gracefully + (skip `uf init` git operations, still start OpenCode). +- What happens when `go install` fails for one tool during + image build? The build MUST fail immediately — partial + tool installation is not acceptable. +- What happens when the developer is on Fedora with SELinux + enforcing? Volume mounts MUST use `:Z` relabeling to work + correctly. +- What happens when the container runs on a platform where + `host.containers.internal` does not resolve? The container + MUST start; Dewey embeddings are unavailable but the + container remains functional. + +## Requirements *(mandatory)* + +### Functional Requirements + +**Container Image (Containerfile)**: + +- **FR-001**: The Containerfile MUST produce a multi-arch + image that builds for both `linux/arm64` and `linux/amd64`. +- **FR-002**: The image MUST include these tools: `uf`, + OpenCode, Dewey, Replicator, Gaze, Go 1.24+, Node.js 20+, + npm, Git, gh CLI, golangci-lint, govulncheck, OpenSpec CLI. +- **FR-003**: Go tools MUST be installed via `go install`. + No Homebrew or Linuxbrew in the container. +- **FR-004**: The image MUST run as a non-root user named + `dev`. +- **FR-005**: Ollama MUST NOT be installed in the image. +- **FR-006**: The image MUST set + `DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434` + as a default environment variable. + +**CDE Variant (Containerfile.udi)**: + +- **FR-007**: A UDI-based Containerfile variant MUST exist + for Eclipse Che / Dev Spaces, using + `quay.io/devfile/universal-developer-image:latest` as the + base image. +- **FR-008**: The UDI variant MUST install the same set of + UF tools as the primary Containerfile. + +**Devfiles**: + +- **FR-009**: A `devfile.yaml` MUST exist that references the + custom container image with memory limit (8G), CPU limit + (4), source mounting, and an OpenCode server endpoint. +- **FR-010**: A `devfile-dynamic.yaml` MUST exist that uses + the UDI base image with postStart commands to install tools + (no custom image dependency). +- **FR-011**: Both devfiles MUST conform to the Devfile 2.2.0 + specification. + +**Scripts**: + +- **FR-012**: An install script (`scripts/install-uf-tools.sh`) + MUST install all UF Go tools via `go install`, usable in + both Containerfiles and devfile postStart. +- **FR-013**: An entrypoint script (`scripts/entrypoint.sh`) + MUST handle first-run initialization (e.g., `uf init`) and + start OpenCode in server mode when requested. +- **FR-014**: An extraction script + (`scripts/extract-changes.sh`) MUST export container changes + as `git format-patch` output. +- **FR-015**: A connect script (`scripts/connect.sh`) MUST + start the container and attach via `opencode attach`. + +**Orchestration**: + +- **FR-016**: A `podman-compose.yml` MUST define the headless + server mode with read-only source mount, writable work + directory, resource limits (8G memory, 4 CPUs), and Ollama + endpoint configuration. + +**CI**: + +- **FR-017**: A GitHub Actions workflow MUST build the image + for both architectures and push to + `quay.io/unbound-force/opencode-dev` on push to main and + on version tags. + +**Documentation**: + +- **FR-018**: A `README.md` MUST document all three deployment + models (interactive, headless, CDE), the security model, + change extraction methods, and prerequisites. + +**Security (non-negotiable)**: + +- **FR-019**: No SSH keys, git push tokens, API keys, or + other secrets MUST be present in the image. +- **FR-020**: Resource limits MUST be enforced in all + orchestration files. +- **FR-021**: Headless mode MUST use read-only source mounts. + +### Key Entities + +- **Container Image**: The primary OCI artifact published to + quay.io. Two variants: Fedora-based (primary) and UDI-based + (CDE). Contains the full UF toolchain. +- **Devfile**: Eclipse Che workspace definition (Devfile 2.2.0 + schema). Two variants: custom image (fast start) and dynamic + (UDI + postStart, no custom image). +- **Deployment Model**: One of three modes: Interactive (rw + mount), Headless (ro mount + format-patch), CDE (Dev Spaces + workspace). Each has different security properties. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Image builds successfully on both arm64 and + amd64 within 15 minutes per architecture. +- **SC-002**: All 5 tool version checks pass (`uf`, `opencode`, + `dewey`, `replicator`, `gaze`) after building the image. +- **SC-003**: Container starts and OpenCode serves within + 30 seconds of `podman run` or `podman-compose up`. +- **SC-004**: Headless mode with read-only mount prevents + direct host filesystem modification — changes extractable + only via `git format-patch`. +- **SC-005**: The UDI variant passes the same smoke tests + as the primary Containerfile. +- **SC-006**: Both devfiles validate against the Devfile 2.2.0 + schema without errors. +- **SC-007**: CI workflow successfully builds and pushes to + quay.io on the first attempt after merging to main. + +## Assumptions + +- Podman is installed on the developer's machine. Docker is + not a supported runtime. +- Ollama runs on the host and is accessible at + `host.containers.internal:11434`. When unavailable, Dewey + embeddings degrade gracefully. +- The developer has network access during image build to + download Go modules and npm packages. +- quay.io registry credentials are configured in the CI + environment (GitHub Actions secrets). +- All UF tool repos (dewey, gaze, replicator, unbound-force) + have tagged releases available for `go install @latest`. + +## Dependencies + +- [Discussion #88](https://github.com/orgs/unbound-force/discussions/88) — architecture and design rationale +- [Issue #1](https://github.com/unbound-force/containerfile/issues/1) — implementation requirements +- dewey#36 (startup timeout) — closed, fixed +- dewey#40 (relative path fix) — closed, fixed +- replicator#5 (init command) — closed, done +- `uf sandbox` CLI command — tracked separately in the meta + repo; will consume the image this spec produces diff --git a/specs/001-initial-containerfile/tasks.md b/specs/001-initial-containerfile/tasks.md new file mode 100644 index 0000000..138041e --- /dev/null +++ b/specs/001-initial-containerfile/tasks.md @@ -0,0 +1,167 @@ +# Tasks: Initial Containerfile, Devfile, and Scripts + +**Input**: Design documents from `/specs/001-initial-containerfile/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. Validation is `podman build` + smoke test commands — there are no Go tests or npm tests. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3, US4) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup + +**Purpose**: Create the directory structure and verify prerequisites are in place. + +- [x] T001 Create `scripts/` directory at repository root +- [x] T002 Create `.github/workflows/` directory at repository root + +**Checkpoint**: Directory structure matches plan.md project layout. + +--- + +## Phase 2: Foundational (Blocking Prerequisite) + +**Purpose**: The install script is shared by both Containerfiles and the dynamic devfile's postStart. It MUST be complete before any image can be built. + +**CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T003 [US1] Create `scripts/install-uf-tools.sh` per contract `contracts/install-uf-tools.md` — install all UF Go tools via `go install` (uf, dewey, replicator, gaze, golangci-lint, govulncheck) and OpenSpec CLI via `npm install -g @openspec/cli`; must use `set -euo pipefail`, fail-fast on any install failure, print versions after install, be idempotent, work on arm64 and amd64, and MUST NOT use Homebrew or install Ollama (FR-002, FR-003, FR-005, FR-012) + +**Checkpoint**: `install-uf-tools.sh` is executable and syntactically valid (`bash -n scripts/install-uf-tools.sh` passes). Full validation deferred to Phase 3 image build. + +--- + +## Phase 3: User Story 1 — Build and Run Container Image Locally (Priority: P1) + +**Goal**: A developer builds the image from the Containerfile, starts a container with their project mounted, and has immediate access to all tools without installing anything on their host besides Podman. + +**Independent Test**: Build with `podman build`, run smoke tests (tool versions + `whoami`), verify all tools present and container runs as non-root. + +### Implementation + +- [x] T004 [US1] Create `scripts/entrypoint.sh` per contract `contracts/entrypoint.md` — handle workspace detection, git repo check, first-run `uf init`, Ollama connectivity check (graceful degradation), and command dispatch (server/bash/pass-through); must use `set -euo pipefail` for init phase, `exec` for final process, handle read-only mounts gracefully (FR-013) +- [x] T005 [US1] Create `Containerfile` at repository root — Fedora 41 base (`registry.fedoraproject.org/fedora:41`), single-stage build per research R2; install system packages via `dnf` (Go 1.24+, Node.js 20+, npm, Git, gh CLI), create non-root `dev` user, COPY and run `scripts/install-uf-tools.sh`, install OpenCode via `curl -fsSL https://opencode.ai/install | bash`, set `DEWEY_EMBEDDING_ENDPOINT`, set `GOPATH` and `PATH`, set `USER dev`, set `WORKDIR /home/dev`, set ENTRYPOINT to `scripts/entrypoint.sh`; must NOT install Ollama, must NOT contain secrets (FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-019) + +### Verification + +- [x] T006 [US1] Build image and run smoke tests — execute `podman build -t opencode-dev -f Containerfile .`, then verify: `podman run --rm opencode-dev uf --version`, `opencode --version`, `dewey --version`, `replicator --version`, `gaze --version`, `go version`, `golangci-lint --version`, `govulncheck -version`, `node --version`, `npm --version`, `git --version`, `gh --version`; verify `podman run --rm opencode-dev whoami` prints `dev`; verify volume mount read/write: `podman run --rm -v /tmp/uf-smoke:/workspace:Z opencode-dev bash -c "touch /workspace/test-file && rm /workspace/test-file"` (SC-001, SC-002, US1-AC4) + +**Checkpoint**: Image builds successfully. All tool version checks pass. Container runs as `dev`. Volume mount read/write verified. User Story 1 acceptance scenarios 1-4 verified. + +--- + +## Phase 4: User Story 2 — Headless Server Mode (Priority: P2) + +**Goal**: Maximum isolation — host source mounted read-only, OpenCode runs as headless server, changes extracted via `git format-patch` only after human review (Model B). + +**Independent Test**: Start container in headless mode with read-only mount, verify OpenCode serves on port 4096, connect from host, extract changes via format-patch. + +### Implementation + +- [x] T007 [P] [US2] Create `scripts/extract-changes.sh` per contract `contracts/extract-changes.md` — detect git repo, detect uncommitted changes, stage and create temporary commit, generate `git format-patch` output to stdout or output directory, handle no-changes case gracefully, set git user defaults if not configured; must use `set -euo pipefail`, work with read-only source mount (FR-014, FR-021) +- [x] T008 [P] [US2] Create `scripts/connect.sh` per contract `contracts/connect.md` — check if container is running via `podman ps`, start via `podman-compose up -d` or `podman run -d` if not running, wait for OpenCode health endpoint (30s timeout), attach via `opencode attach`, print cleanup guidance; must use Podman (not Docker), apply resource limits (8G/4CPU), use `:Z` volume mounts, set `DEWEY_EMBEDDING_ENDPOINT`; must use `set -euo pipefail` (FR-015, FR-020) +- [x] T009 [US2] Create `podman-compose.yml` at repository root — define headless server mode with read-only source mount (`./project:/workspace:ro,Z`), writable work directory as a named volume or tmpfs at `/work` (the entrypoint copies source here on startup for agent modifications), resource limits (8G memory, 4 CPUs), `DEWEY_EMBEDDING_ENDPOINT` environment variable, port mapping for 4096, container name `opencode-server`, reference `opencode-dev` image (FR-016, FR-020, FR-021) + +### Verification + +- [x] T010 [US2] Verify headless mode — build image (if not already built), run `podman-compose up -d`, verify container starts and OpenCode serves on port 4096, verify source mount is read-only, run `podman-compose down` (SC-003, SC-004) + +**Checkpoint**: Headless mode starts with read-only mount. Connect script attaches successfully. Extract script produces valid format-patch output. User Story 2 acceptance scenarios verified. + +--- + +## Phase 5: User Story 3 — CDE / Eclipse Che (Priority: P3) + +**Goal**: Pre-configured Eclipse Che workspace with the full UF toolchain. Two variants: custom image (fast start) and dynamic (UDI + postStart, no custom image). + +**Independent Test**: Build UDI variant, verify tools present. Validate both devfiles parse against Devfile 2.2.0 schema. + +### Implementation + +- [x] T011 [US3] Create `Containerfile.udi` at repository root — use `quay.io/devfile/universal-developer-image:latest` as base per research R3; install UF tools on top using `scripts/install-uf-tools.sh`, respect UDI's existing `user` user (UID 1001), use `USER 0` temporarily for system installs then switch back, accept UDI's Go/Node.js versions, set `DEWEY_EMBEDDING_ENDPOINT`; must NOT create a `dev` user, must NOT replace UDI's Go/Node.js (FR-007, FR-008) +- [x] T012 [P] [US3] Create `devfile.yaml` at repository root — Devfile 2.2.0 schema, reference `quay.io/unbound-force/opencode-dev:latest` image, set `memoryLimit: 8Gi`, `cpuLimit: "4"`, `mountSources: true`, define OpenCode server endpoint on port 4096 (FR-009, FR-011) +- [x] T013 [P] [US3] Create `devfile-dynamic.yaml` at repository root — Devfile 2.2.0 schema, use `quay.io/devfile/universal-developer-image:latest` as base, define `postStart` command that runs `scripts/install-uf-tools.sh`, set resource limits, define OpenCode endpoint; no custom image dependency (FR-010, FR-011) + +### Verification + +- [x] T014 [US3] Build UDI variant and run smoke tests — execute `podman build -t opencode-dev-udi -f Containerfile.udi .`, then verify same tool version checks as T006; verify `podman run --rm opencode-dev-udi whoami` prints `user` (not `dev`); validate both devfiles are valid YAML (SC-005, SC-006) + +**Checkpoint**: UDI variant builds and passes smoke tests. Both devfiles are valid. User Story 3 acceptance scenarios verified. + +--- + +## Phase 6: User Story 4 — CI Pipeline (Priority: P4) + +**Goal**: Automated multi-arch build and push to quay.io on every push to main and on version tags. + +**Independent Test**: Push a commit to main and verify CI builds both architectures and pushes to quay.io. + +### Implementation + +- [x] T015 [US4] Create `.github/workflows/build-push.yml` — GitHub Actions workflow per research R5; install QEMU user-static for cross-arch emulation, use `podman manifest` for multi-arch builds (linux/arm64 + linux/amd64), login to quay.io via secrets, push to `quay.io/unbound-force/opencode-dev`; triggers: push to `main` (tag `latest`), version tag push `v*` (tag with version), pull request (build only, no push); run smoke tests before push (FR-017) + +**Checkpoint**: CI workflow file is valid YAML and follows GitHub Actions syntax. Full validation requires a push to main (deferred to merge). + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation, AGENTS.md updates, and final validation. + +- [x] T016 [P] Create `README.md` at repository root — document all three deployment models (Interactive/Model A, Headless/Model B, CDE/Model C) per quickstart.md, document security model (non-negotiable constraints), document change extraction methods, document prerequisites (Podman, optional Ollama), include smoke test suite, include troubleshooting table (FR-018) +- [x] T017 [P] Update `AGENTS.md` — verify Active Technologies and Recent Changes sections are current for `001-initial-containerfile` branch +- [x] T018 Validate quickstart.md steps — walk through each model's quickstart commands from `specs/001-initial-containerfile/quickstart.md` against the actual built artifacts, verify all commands work as documented + +**Checkpoint**: README covers all models. AGENTS.md is current. Quickstart steps verified against real artifacts. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: No dependencies — start immediately +- **Phase 2 (Foundational)**: Depends on Phase 1 — BLOCKS all user stories +- **Phase 3 (US1 — P1)**: Depends on Phase 2 — foundational image +- **Phase 4 (US2 — P2)**: Depends on Phase 3 — needs a built image +- **Phase 5 (US3 — P3)**: Depends on Phase 2 — needs install script; independent of US2 +- **Phase 6 (US4 — P4)**: Depends on Phase 3 — needs a working Containerfile +- **Phase 7 (Polish)**: Depends on Phases 3-6 — documents all artifacts + +### Parallel Opportunities + +- **T001 + T002**: Both setup tasks can run in parallel (different directories) +- **T007 + T008**: Extract and connect scripts can be written in parallel (different files, no dependencies) +- **T012 + T013**: Both devfiles can be written in parallel (different files) +- **T016 + T017**: README and AGENTS.md updates can run in parallel +- **Phase 5 (US3) can start after Phase 2**, independent of Phase 4 (US2). If parallelizing across workers, US1 and US3 can overlap after the install script is complete, though US3's Containerfile.udi build verification (T014) benefits from patterns established in US1's Containerfile (T005). + +### Within Each Phase + +- Implementation tasks before verification tasks +- Scripts before Containerfiles (scripts are COPY'd into images) +- Containerfile before compose/devfiles (compose references the image) + +--- + +## Summary + +| Metric | Count | +|--------|-------| +| **Total tasks** | 18 | +| **Phase 1 (Setup)** | 2 | +| **Phase 2 (Foundational)** | 1 | +| **Phase 3 (US1 — P1)** | 3 | +| **Phase 4 (US2 — P2)** | 4 | +| **Phase 5 (US3 — P3)** | 4 | +| **Phase 6 (US4 — P4)** | 1 | +| **Phase 7 (Polish)** | 3 | +| **Parallelizable [P] tasks** | 6 | +| **Files created** | 12 | +| **Files updated** | 1 (AGENTS.md) | + From 72dbe7014d963259c9754b726d41043987e71b68 Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Sun, 12 Apr 2026 09:16:07 -0400 Subject: [PATCH 2/6] fix: add npm install retry for transient CI network errors ECONNRESET during zod download caused CI build failure. Retry npm install up to 3 times with 5s delay between attempts. --- scripts/install-uf-tools.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/install-uf-tools.sh b/scripts/install-uf-tools.sh index 69b934c..c4b78f4 100755 --- a/scripts/install-uf-tools.sh +++ b/scripts/install-uf-tools.sh @@ -82,7 +82,19 @@ export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" mkdir -p "$NPM_CONFIG_PREFIX" info "Installing @fission-ai/openspec via npm ..." -npm install -g @fission-ai/openspec +# Retry npm install up to 3 times — CI runners sometimes hit transient +# network errors (ECONNRESET) when downloading large dependency trees. +for attempt in 1 2 3; do + if npm install -g @fission-ai/openspec; then + break + fi + if [ "$attempt" -eq 3 ]; then + error "npm install failed after 3 attempts" + exit 1 + fi + info "npm install attempt $attempt failed, retrying in 5s ..." + sleep 5 +done # --------------------------------------------------------------------------- # Version verification — print each tool for build-log visibility From 90bce01b23bbb9aab979f0291ac7e34b7d7198ed Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Sun, 12 Apr 2026 09:20:54 -0400 Subject: [PATCH 3/6] fix: restructure CI to avoid redundant builds and fix dewey version flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues addressed: - Fix dewey --version → dewey version in smoke tests - Build native amd64 first, run smoke tests, then add arm64 (was building 3 times: amd64 manifest, arm64 manifest, test) - Reuse native image in manifest via containers-storage instead of rebuilding --- .github/workflows/build-push.yml | 46 +++++++++++++++----------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 52c478e..b8d2476 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -47,52 +47,35 @@ jobs: id: tags run: | if [[ "${{ github.ref_type }}" == "tag" ]]; then - # Version tag push — tag with version number VERSION="${{ github.ref_name }}" echo "tags=${REGISTRY}/${IMAGE_NAME}:${VERSION}" >> "$GITHUB_OUTPUT" echo "manifest_tag=${VERSION}" >> "$GITHUB_OUTPUT" elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then - # Push to main — tag as latest echo "tags=${REGISTRY}/${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT" echo "manifest_tag=latest" >> "$GITHUB_OUTPUT" else - # Pull request — build only, use PR number for local tag echo "tags=localhost/${IMAGE_NAME}:pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT" echo "manifest_tag=pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT" fi # --------------------------------------------------------------- - # Build multi-arch image using podman manifest (R5) + # Build native-arch image first (fast, no QEMU) for smoke tests # --------------------------------------------------------------- - - name: Create manifest - run: podman manifest create opencode-dev - - - name: Build for linux/amd64 + - name: Build for native arch (amd64) run: | podman build \ - --platform linux/amd64 \ - --manifest opencode-dev \ - -f Containerfile . - - - name: Build for linux/arm64 - run: | - podman build \ - --platform linux/arm64 \ - --manifest opencode-dev \ + -t opencode-dev-test \ -f Containerfile . # --------------------------------------------------------------- - # Smoke tests — verify tools are present before pushing + # Smoke tests — verify tools before investing in cross-arch build # --------------------------------------------------------------- - - name: Run smoke tests (amd64) + - name: Run smoke tests run: | - # Build a local image for smoke testing on the runner arch - podman build -t opencode-dev-test -f Containerfile . - echo "==> Checking tool versions ..." podman run --rm opencode-dev-test uf --version podman run --rm opencode-dev-test opencode --version - podman run --rm opencode-dev-test dewey --version + podman run --rm opencode-dev-test dewey version podman run --rm opencode-dev-test replicator --version podman run --rm opencode-dev-test gaze --version podman run --rm opencode-dev-test go version @@ -111,8 +94,21 @@ jobs: fi echo "User check passed: $USER_OUTPUT" - # Clean up test image - podman rmi opencode-dev-test || true + # --------------------------------------------------------------- + # Build multi-arch manifest (only after smoke tests pass) + # --------------------------------------------------------------- + - name: Create manifest + run: podman manifest create opencode-dev + + - name: Add native amd64 image to manifest + run: podman manifest add opencode-dev containers-storage:localhost/opencode-dev-test + + - name: Build and add linux/arm64 to manifest + run: | + podman build \ + --platform linux/arm64 \ + --manifest opencode-dev \ + -f Containerfile . # --------------------------------------------------------------- # Push to quay.io (only on main push or version tag) From 34376ed76ee10fdec6b9236e74e3f595efbed053 Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Sun, 12 Apr 2026 09:37:27 -0400 Subject: [PATCH 4/6] fix: add ~/.opencode/bin to PATH for OpenCode binary The OpenCode curl installer places the binary at ~/.opencode/bin/opencode and updates .bashrc, but .bashrc is not sourced by the container entrypoint. Adding the directory to the ENV PATH makes it available to all execution contexts. --- Containerfile | 2 +- Containerfile.udi | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Containerfile b/Containerfile index b7c6abd..a4d071a 100644 --- a/Containerfile +++ b/Containerfile @@ -67,7 +67,7 @@ RUN useradd -m -s /bin/bash dev ENV GOROOT=/usr/local/go \ GOPATH=/home/dev/go \ NPM_CONFIG_PREFIX=/home/dev/.npm-global \ - PATH="/usr/local/go/bin:/home/dev/go/bin:/home/dev/.npm-global/bin:/home/dev/.local/bin:$PATH" \ + PATH="/usr/local/go/bin:/home/dev/go/bin:/home/dev/.npm-global/bin:/home/dev/.opencode/bin:/home/dev/.local/bin:$PATH" \ DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 # --------------------------------------------------------------------------- diff --git a/Containerfile.udi b/Containerfile.udi index 406860b..62bab1c 100644 --- a/Containerfile.udi +++ b/Containerfile.udi @@ -56,7 +56,7 @@ RUN rm -rf /usr/local/go \ ENV GOROOT=/usr/local/go \ GOPATH=/home/user/go \ NPM_CONFIG_PREFIX=/home/user/.npm-global \ - PATH="/usr/local/go/bin:/home/user/go/bin:/home/user/.npm-global/bin:/home/user/.local/bin:$PATH" \ + PATH="/usr/local/go/bin:/home/user/go/bin:/home/user/.npm-global/bin:/home/user/.opencode/bin:/home/user/.local/bin:$PATH" \ DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 # --------------------------------------------------------------------------- From 411829317e8a8a453e6f6ce223c261be1da33608 Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Sun, 12 Apr 2026 09:46:57 -0400 Subject: [PATCH 5/6] fix: bypass entrypoint for whoami check in CI smoke tests The entrypoint logs (workspace detection, Ollama check) pollute stdout, causing the USER_OUTPUT comparison to fail. Using --entrypoint whoami gets clean output for the user check. --- .github/workflows/build-push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index b8d2476..59e6d72 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -87,7 +87,7 @@ jobs: podman run --rm opencode-dev-test gh --version echo "==> Checking non-root user ..." - USER_OUTPUT=$(podman run --rm opencode-dev-test whoami) + USER_OUTPUT=$(podman run --rm --entrypoint whoami opencode-dev-test) if [ "$USER_OUTPUT" != "dev" ]; then echo "ERROR: Expected 'dev' but got '$USER_OUTPUT'" exit 1 From e2bbf1d76d7a35143c958c2f852ddf6fffb70430 Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Sun, 12 Apr 2026 11:34:12 -0400 Subject: [PATCH 6/6] feat: add base image and native arm64 CI runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate QEMU emulation for cross-arch builds (~40 min → ~10 min target): - Add Containerfile.base (Fedora 41 + Go 1.25 + system packages) - Add build-base.yml CI workflow (weekly schedule + manual dispatch) - Refactor Containerfile to use opencode-base as FROM (109 → 48 lines) - Refactor build-push.yml to use native runners (ubuntu-24.04-arm) - Update spec with FR-022–FR-027, SC-001 (10 min CI target) --- .github/workflows/build-base.yml | 125 ++++++++++++ .github/workflows/build-push.yml | 186 ++++++++++-------- Containerfile | 74 +------ Containerfile.base | 73 +++++++ specs/001-initial-containerfile/plan.md | 240 +++++++++++++++++++++++ specs/001-initial-containerfile/spec.md | 54 ++++- specs/001-initial-containerfile/tasks.md | 29 ++- 7 files changed, 626 insertions(+), 155 deletions(-) create mode 100644 .github/workflows/build-base.yml create mode 100644 Containerfile.base diff --git a/.github/workflows/build-base.yml b/.github/workflows/build-base.yml new file mode 100644 index 0000000..00deb92 --- /dev/null +++ b/.github/workflows/build-base.yml @@ -0,0 +1,125 @@ +# build-base.yml — Multi-arch base image build and push +# +# Builds opencode-base (Fedora 41 + Go + system packages) for +# linux/arm64 and linux/amd64 using native runners (no QEMU). +# +# Triggers: +# - Weekly schedule (Monday 04:00 UTC) +# - Manual dispatch +# +# Requirements: FR-024, FR-027 + +name: Build Base Image + +on: + schedule: + - cron: "0 4 * * 1" + workflow_dispatch: + +env: + REGISTRY: quay.io + IMAGE_NAME: unbound-force/opencode-base + +jobs: + build-amd64: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build base image (amd64) + run: podman build -t opencode-base-amd64 -f Containerfile.base . + + - name: Smoke test (amd64) + run: | + echo "==> Base image smoke tests (amd64) ..." + podman run --rm --entrypoint go opencode-base-amd64 version + podman run --rm --entrypoint node opencode-base-amd64 --version + podman run --rm --entrypoint git opencode-base-amd64 --version + podman run --rm --entrypoint gh opencode-base-amd64 --version + + USER_OUTPUT=$(podman run --rm --entrypoint whoami opencode-base-amd64) + if [ "$USER_OUTPUT" != "dev" ]; then + echo "ERROR: Expected 'dev' but got '$USER_OUTPUT'" + exit 1 + fi + echo "All base image smoke tests passed (amd64)" + + - name: Save image + run: podman save opencode-base-amd64 -o /tmp/opencode-base-amd64.tar + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: opencode-base-amd64 + path: /tmp/opencode-base-amd64.tar + retention-days: 1 + + build-arm64: + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build base image (arm64) + run: podman build -t opencode-base-arm64 -f Containerfile.base . + + - name: Smoke test (arm64) + run: | + echo "==> Base image smoke tests (arm64) ..." + podman run --rm --entrypoint go opencode-base-arm64 version + podman run --rm --entrypoint node opencode-base-arm64 --version + podman run --rm --entrypoint git opencode-base-arm64 --version + podman run --rm --entrypoint gh opencode-base-arm64 --version + + USER_OUTPUT=$(podman run --rm --entrypoint whoami opencode-base-arm64) + if [ "$USER_OUTPUT" != "dev" ]; then + echo "ERROR: Expected 'dev' but got '$USER_OUTPUT'" + exit 1 + fi + echo "All base image smoke tests passed (arm64)" + + - name: Save image + run: podman save opencode-base-arm64 -o /tmp/opencode-base-arm64.tar + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: opencode-base-arm64 + path: /tmp/opencode-base-arm64.tar + retention-days: 1 + + push-manifest: + needs: [build-amd64, build-arm64] + runs-on: ubuntu-latest + steps: + - name: Download amd64 image + uses: actions/download-artifact@v4 + with: + name: opencode-base-amd64 + path: /tmp + + - name: Download arm64 image + uses: actions/download-artifact@v4 + with: + name: opencode-base-arm64 + path: /tmp + + - name: Load images + run: | + podman load -i /tmp/opencode-base-amd64.tar + podman load -i /tmp/opencode-base-arm64.tar + + - name: Login to quay.io + run: | + podman login quay.io \ + -u "${{ secrets.QUAY_USERNAME }}" \ + -p "${{ secrets.QUAY_PASSWORD }}" + + - name: Create and push manifest + run: | + podman manifest create opencode-base + podman manifest add opencode-base containers-storage:localhost/opencode-base-amd64 + podman manifest add opencode-base containers-storage:localhost/opencode-base-arm64 + podman manifest push opencode-base \ + "docker://${REGISTRY}/${IMAGE_NAME}:latest" diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 59e6d72..a1ac443 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -1,15 +1,14 @@ -# build-push.yml — Multi-arch build and push to quay.io +# build-push.yml — Multi-arch dev image build and push # -# Builds the opencode-dev container image for linux/arm64 and -# linux/amd64 using Podman manifest, then pushes to quay.io. +# Builds opencode-dev for linux/arm64 and linux/amd64 using native +# runners (no QEMU). Uses opencode-base as the foundation layer. # # Triggers: # - Push to main: build + push with "latest" tag # - Version tag (v*): build + push with version tag # - Pull request: build only (no push) for validation # -# Requirements: FR-017 -# Research: R5 (GitHub Actions Multi-Arch Build with Podman) +# Requirements: FR-017, FR-026 name: Build and Push Container Image @@ -25,108 +24,135 @@ env: IMAGE_NAME: unbound-force/opencode-dev jobs: - build: + build-amd64: runs-on: ubuntu-latest - steps: - name: Checkout repository uses: actions/checkout@v4 - # --------------------------------------------------------------- - # QEMU for cross-architecture emulation (R5) - # --------------------------------------------------------------- - - name: Install QEMU user-static - run: | - sudo apt-get update - sudo apt-get install -y qemu-user-static + - name: Build dev image (amd64) + run: podman build -t opencode-dev-amd64 -f Containerfile . - # --------------------------------------------------------------- - # Determine tags based on trigger - # --------------------------------------------------------------- - - name: Determine image tags - id: tags + - name: Smoke test (amd64) run: | - if [[ "${{ github.ref_type }}" == "tag" ]]; then - VERSION="${{ github.ref_name }}" - echo "tags=${REGISTRY}/${IMAGE_NAME}:${VERSION}" >> "$GITHUB_OUTPUT" - echo "manifest_tag=${VERSION}" >> "$GITHUB_OUTPUT" - elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then - echo "tags=${REGISTRY}/${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT" - echo "manifest_tag=latest" >> "$GITHUB_OUTPUT" - else - echo "tags=localhost/${IMAGE_NAME}:pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT" - echo "manifest_tag=pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT" + echo "==> Smoke tests (amd64) ..." + podman run --rm opencode-dev-amd64 uf --version + podman run --rm opencode-dev-amd64 opencode --version + podman run --rm opencode-dev-amd64 dewey version + podman run --rm opencode-dev-amd64 replicator --version + podman run --rm opencode-dev-amd64 gaze --version + podman run --rm opencode-dev-amd64 go version + podman run --rm opencode-dev-amd64 golangci-lint --version + podman run --rm opencode-dev-amd64 govulncheck -version + podman run --rm opencode-dev-amd64 node --version + podman run --rm opencode-dev-amd64 npm --version + podman run --rm opencode-dev-amd64 git --version + podman run --rm opencode-dev-amd64 gh --version + + USER_OUTPUT=$(podman run --rm --entrypoint whoami opencode-dev-amd64) + if [ "$USER_OUTPUT" != "dev" ]; then + echo "ERROR: Expected 'dev' but got '$USER_OUTPUT'" + exit 1 fi + echo "All smoke tests passed (amd64)" - # --------------------------------------------------------------- - # Build native-arch image first (fast, no QEMU) for smoke tests - # --------------------------------------------------------------- - - name: Build for native arch (amd64) - run: | - podman build \ - -t opencode-dev-test \ - -f Containerfile . - - # --------------------------------------------------------------- - # Smoke tests — verify tools before investing in cross-arch build - # --------------------------------------------------------------- - - name: Run smoke tests + - name: Save image + run: podman save opencode-dev-amd64 -o /tmp/opencode-dev-amd64.tar + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: opencode-dev-amd64 + path: /tmp/opencode-dev-amd64.tar + retention-days: 1 + + build-arm64: + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build dev image (arm64) + run: podman build -t opencode-dev-arm64 -f Containerfile . + + - name: Smoke test (arm64) run: | - echo "==> Checking tool versions ..." - podman run --rm opencode-dev-test uf --version - podman run --rm opencode-dev-test opencode --version - podman run --rm opencode-dev-test dewey version - podman run --rm opencode-dev-test replicator --version - podman run --rm opencode-dev-test gaze --version - podman run --rm opencode-dev-test go version - podman run --rm opencode-dev-test golangci-lint --version - podman run --rm opencode-dev-test govulncheck -version - podman run --rm opencode-dev-test node --version - podman run --rm opencode-dev-test npm --version - podman run --rm opencode-dev-test git --version - podman run --rm opencode-dev-test gh --version - - echo "==> Checking non-root user ..." - USER_OUTPUT=$(podman run --rm --entrypoint whoami opencode-dev-test) + echo "==> Smoke tests (arm64) ..." + podman run --rm opencode-dev-arm64 uf --version + podman run --rm opencode-dev-arm64 opencode --version + podman run --rm opencode-dev-arm64 dewey version + podman run --rm opencode-dev-arm64 replicator --version + podman run --rm opencode-dev-arm64 gaze --version + podman run --rm opencode-dev-arm64 go version + podman run --rm opencode-dev-arm64 golangci-lint --version + podman run --rm opencode-dev-arm64 govulncheck -version + podman run --rm opencode-dev-arm64 node --version + podman run --rm opencode-dev-arm64 npm --version + podman run --rm opencode-dev-arm64 git --version + podman run --rm opencode-dev-arm64 gh --version + + USER_OUTPUT=$(podman run --rm --entrypoint whoami opencode-dev-arm64) if [ "$USER_OUTPUT" != "dev" ]; then echo "ERROR: Expected 'dev' but got '$USER_OUTPUT'" exit 1 fi - echo "User check passed: $USER_OUTPUT" + echo "All smoke tests passed (arm64)" - # --------------------------------------------------------------- - # Build multi-arch manifest (only after smoke tests pass) - # --------------------------------------------------------------- - - name: Create manifest - run: podman manifest create opencode-dev + - name: Save image + run: podman save opencode-dev-arm64 -o /tmp/opencode-dev-arm64.tar - - name: Add native amd64 image to manifest - run: podman manifest add opencode-dev containers-storage:localhost/opencode-dev-test + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: opencode-dev-arm64 + path: /tmp/opencode-dev-arm64.tar + retention-days: 1 - - name: Build and add linux/arm64 to manifest + push-manifest: + if: github.event_name == 'push' + needs: [build-amd64, build-arm64] + runs-on: ubuntu-latest + steps: + - name: Determine image tag + id: tags run: | - podman build \ - --platform linux/arm64 \ - --manifest opencode-dev \ - -f Containerfile . - - # --------------------------------------------------------------- - # Push to quay.io (only on main push or version tag) - # --------------------------------------------------------------- + if [[ "${{ github.ref_type }}" == "tag" ]]; then + echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + else + echo "tag=latest" >> "$GITHUB_OUTPUT" + fi + + - name: Download amd64 image + uses: actions/download-artifact@v4 + with: + name: opencode-dev-amd64 + path: /tmp + + - name: Download arm64 image + uses: actions/download-artifact@v4 + with: + name: opencode-dev-arm64 + path: /tmp + + - name: Load images + run: | + podman load -i /tmp/opencode-dev-amd64.tar + podman load -i /tmp/opencode-dev-arm64.tar + - name: Login to quay.io - if: github.event_name == 'push' run: | podman login quay.io \ -u "${{ secrets.QUAY_USERNAME }}" \ -p "${{ secrets.QUAY_PASSWORD }}" - - name: Push manifest to quay.io - if: github.event_name == 'push' + - name: Create and push manifest run: | + podman manifest create opencode-dev + podman manifest add opencode-dev containers-storage:localhost/opencode-dev-amd64 + podman manifest add opencode-dev containers-storage:localhost/opencode-dev-arm64 podman manifest push opencode-dev \ - "docker://${{ steps.tags.outputs.tags }}" + "docker://${REGISTRY}/${IMAGE_NAME}:${{ steps.tags.outputs.tag }}" - # Tag as latest on version tag pushes too - name: Push latest tag (version tag push) if: github.ref_type == 'tag' run: | diff --git a/Containerfile b/Containerfile index a4d071a..2267d4e 100644 --- a/Containerfile +++ b/Containerfile @@ -3,72 +3,18 @@ # Multi-arch OCI image (linux/arm64, linux/amd64) with the full UF # toolchain for AI-assisted development inside Podman containers. # -# Base: Fedora 41 (pinned major version per research R1) -# Strategy: Single-stage build — agents need Go + Node.js at runtime (R2) -# User: Non-root "dev" user (R7) +# Base: opencode-base (Fedora 41 + Go 1.25 + system packages + dev user) +# Strategy: Single-stage build — agents need Go + Node.js at runtime +# User: Non-root "dev" user (inherited from base) # # Build: # podman build -t opencode-dev -f Containerfile . # # Smoke test: # podman run --rm opencode-dev uf --version -# podman run --rm opencode-dev whoami # prints "dev" +# podman run --rm --entrypoint whoami opencode-dev # prints "dev" -FROM registry.fedoraproject.org/fedora:41 - -# --------------------------------------------------------------------------- -# System packages (as root) — Go is installed separately below because -# Fedora 41 ships Go 1.24 but dewey requires Go 1.25+. -# --------------------------------------------------------------------------- - -RUN dnf install -y \ - nodejs \ - npm \ - git \ - gh \ - curl \ - findutils \ - procps-ng \ - which \ - tar \ - gzip \ - && dnf clean all \ - && rm -rf /var/cache/dnf - -# --------------------------------------------------------------------------- -# Install Go from official tarball (Fedora 41 ships 1.24; dewey needs 1.25+) -# --------------------------------------------------------------------------- - -ARG GO_VERSION=1.25.3 -RUN ARCH="$(uname -m)" \ - && case "$ARCH" in \ - x86_64) GOARCH=amd64 ;; \ - aarch64) GOARCH=arm64 ;; \ - *) echo "Unsupported arch: $ARCH" && exit 1 ;; \ - esac \ - && curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" \ - | tar -C /usr/local -xz \ - && ln -s /usr/local/go/bin/go /usr/local/bin/go \ - && ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt - -# --------------------------------------------------------------------------- -# Non-root user setup (R7) -# -# Create the user early so Go/npm tool installs target the user's paths. -# System packages (dnf) are installed above as root. -# --------------------------------------------------------------------------- - -RUN useradd -m -s /bin/bash dev - -# --------------------------------------------------------------------------- -# Environment — set for all subsequent layers and runtime -# --------------------------------------------------------------------------- - -ENV GOROOT=/usr/local/go \ - GOPATH=/home/dev/go \ - NPM_CONFIG_PREFIX=/home/dev/.npm-global \ - PATH="/usr/local/go/bin:/home/dev/go/bin:/home/dev/.npm-global/bin:/home/dev/.opencode/bin:/home/dev/.local/bin:$PATH" \ - DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 +FROM quay.io/unbound-force/opencode-base:latest # --------------------------------------------------------------------------- # Install UF tools as dev user (go install + npm) @@ -76,22 +22,16 @@ ENV GOROOT=/usr/local/go \ COPY --chown=dev:dev scripts/install-uf-tools.sh /home/dev/scripts/install-uf-tools.sh RUN chmod +x /home/dev/scripts/install-uf-tools.sh - -USER dev RUN /home/dev/scripts/install-uf-tools.sh # --------------------------------------------------------------------------- -# Install OpenCode via official curl installer (R6) -# -# The installer detects architecture automatically and places the binary -# in ~/.local/bin (or /usr/local/bin if running as root). We run as dev -# so it goes to ~/.local/bin which is already in PATH. +# Install OpenCode via official curl installer # --------------------------------------------------------------------------- RUN curl -fsSL https://opencode.ai/install | bash # --------------------------------------------------------------------------- -# Switch back to root briefly to copy entrypoint to a fixed location +# Copy entrypoint and helper scripts # --------------------------------------------------------------------------- USER root diff --git a/Containerfile.base b/Containerfile.base new file mode 100644 index 0000000..a30159f --- /dev/null +++ b/Containerfile.base @@ -0,0 +1,73 @@ +# Containerfile.base — Multi-arch base image for OpenCode Dev +# +# Foundation layer: Fedora 41 + system packages + Go 1.25 + non-root user. +# Does NOT include UF tools (uf, dewey, replicator, gaze), OpenCode, +# OpenSpec CLI, or any project-specific scripts. +# +# Published to: quay.io/unbound-force/opencode-base:latest +# Rebuilt: weekly via scheduled CI + manual dispatch +# +# The primary Containerfile uses this as its FROM to skip the slow +# dnf + Go download steps on every build. +# +# Build: +# podman build -t opencode-base -f Containerfile.base . +# +# Smoke test: +# podman run --rm --entrypoint whoami opencode-base # prints "dev" +# podman run --rm --entrypoint go opencode-base version + +FROM registry.fedoraproject.org/fedora:41 + +# --------------------------------------------------------------------------- +# System packages (as root) +# --------------------------------------------------------------------------- + +RUN dnf install -y \ + nodejs \ + npm \ + git \ + gh \ + curl \ + findutils \ + procps-ng \ + which \ + tar \ + gzip \ + && dnf clean all \ + && rm -rf /var/cache/dnf + +# --------------------------------------------------------------------------- +# Go from official tarball (Fedora 41 ships 1.24; dewey needs 1.25+) +# --------------------------------------------------------------------------- + +ARG GO_VERSION=1.25.3 +RUN ARCH="$(uname -m)" \ + && case "$ARCH" in \ + x86_64) GOARCH=amd64 ;; \ + aarch64) GOARCH=arm64 ;; \ + *) echo "Unsupported arch: $ARCH" && exit 1 ;; \ + esac \ + && curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" \ + | tar -C /usr/local -xz \ + && ln -s /usr/local/go/bin/go /usr/local/bin/go \ + && ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt + +# --------------------------------------------------------------------------- +# Non-root user +# --------------------------------------------------------------------------- + +RUN useradd -m -s /bin/bash dev + +# --------------------------------------------------------------------------- +# Environment — inherited by all downstream images +# --------------------------------------------------------------------------- + +ENV GOROOT=/usr/local/go \ + GOPATH=/home/dev/go \ + NPM_CONFIG_PREFIX=/home/dev/.npm-global \ + PATH="/usr/local/go/bin:/home/dev/go/bin:/home/dev/.npm-global/bin:/home/dev/.opencode/bin:/home/dev/.local/bin:$PATH" \ + DEWEY_EMBEDDING_ENDPOINT=http://host.containers.internal:11434 + +USER dev +WORKDIR /home/dev diff --git a/specs/001-initial-containerfile/plan.md b/specs/001-initial-containerfile/plan.md index c2fb31a..c9b760d 100644 --- a/specs/001-initial-containerfile/plan.md +++ b/specs/001-initial-containerfile/plan.md @@ -138,3 +138,243 @@ live in `scripts/` for organization. CI lives in `.github/workflows/` | Violation | Why Needed | Simpler Alternative Rejected Because | |-----------|------------|-------------------------------------| | *(none)* | — | — | + +## Addendum: Base Image + Native Runners + +**Date**: 2026-04-12 | **Requirements**: FR-022 through FR-027, SC-001 updated +**Status**: Planned — extends the original plan with 3 new deliverables + +### Motivation + +The current CI workflow builds the full image (system packages + Go +tarball + UF tools) on every push, using QEMU emulation for the arm64 +architecture. This results in ~40 minute CI runs dominated by +QEMU-emulated `go install` commands. Two changes eliminate this: + +1. **Base image layer** — Extract the slow-changing foundation (Fedora + packages, Go tarball, user setup) into a separate base image rebuilt + weekly. The dev image then only installs UF Go tools on top, cutting + build time from ~15 min to ~3 min per arch. + +2. **Native runners** — Replace QEMU emulation with GitHub's native + arm64 runners (`ubuntu-24.04-arm`). Native arm64 builds are 5–10× + faster than QEMU-emulated builds. + +Combined effect: CI wall time drops from ~40 min to ~10 min (SC-001). + +### New Deliverables + +Three new or modified files are added to the repository: + +```text +. +├── Containerfile.base # NEW: Base image definition +├── Containerfile # MODIFIED: FROM opencode-base +├── .github/ +│ └── workflows/ +│ ├── build-base.yml # NEW: Weekly base image CI +│ └── build-push.yml # MODIFIED: Native runners +``` + +#### 1. `Containerfile.base` — Base Image Definition (FR-022, FR-023, FR-025) + +A new Containerfile that produces the `opencode-base` foundation image. +Contains everything that changes infrequently: + +| Layer | Contents | Rationale | +|-------|----------|-----------| +| System packages | `dnf install` — nodejs, npm, git, gh, curl, findutils, procps-ng, which, tar, gzip | Same package set currently in Containerfile lines 24–36 | +| Go 1.25+ | Official tarball, arch-detected (`uname -m`) | Same logic currently in Containerfile lines 42–52 | +| Non-root user | `useradd -m -s /bin/bash dev` | Same as Containerfile line 61 | +| Environment | GOROOT, GOPATH, NPM_CONFIG_PREFIX, PATH | Same as Containerfile lines 67–71 | + +**What it does NOT contain** (FR-023): +- No UF Go tools (uf, dewey, replicator, gaze, golangci-lint, govulncheck) +- No OpenCode binary +- No OpenSpec CLI +- No entrypoint or helper scripts +- No DEWEY_EMBEDDING_ENDPOINT (that's a dev image concern) + +**Image coordinates**: `quay.io/unbound-force/opencode-base:latest` + +**Structure**: +```dockerfile +FROM registry.fedoraproject.org/fedora:41 + +# System packages (dnf) +RUN dnf install -y && dnf clean all + +# Go from official tarball (arch-detected) +ARG GO_VERSION=1.25.3 +RUN + +# Non-root user + environment +RUN useradd -m -s /bin/bash dev +ENV GOROOT=... GOPATH=... NPM_CONFIG_PREFIX=... PATH=... + +USER dev +WORKDIR /home/dev +``` + +#### 2. `.github/workflows/build-base.yml` — Base Image CI (FR-024, FR-027) + +A new workflow that builds and pushes the base image on a weekly +schedule and on manual dispatch. Uses native runners for both +architectures (FR-026). + +**Triggers**: +- `schedule: cron: '0 6 * * 1'` — every Monday at 06:00 UTC +- `workflow_dispatch` — manual trigger for ad-hoc rebuilds + +**Job structure** (3 jobs): + +| Job | Runner | Purpose | +|-----|--------|---------| +| `build-amd64` | `ubuntu-latest` | Build + smoke test amd64 base image | +| `build-arm64` | `ubuntu-24.04-arm` | Build + smoke test arm64 base image | +| `push-manifest` | `ubuntu-latest` | Create manifest list + push to quay.io | + +**Smoke tests for base image** (subset — no UF tools): +```bash +podman run --rm opencode-base go version +podman run --rm opencode-base node --version +podman run --rm opencode-base git --version +podman run --rm opencode-base gh --version +USER_OUTPUT=$(podman run --rm --entrypoint whoami opencode-base) +# Must print "dev" +``` + +**Push strategy**: The `push-manifest` job depends on both build jobs. +It downloads the per-arch images (via artifact upload/download or +registry staging), creates a manifest list, and pushes to +`quay.io/unbound-force/opencode-base:latest`. On manual dispatch, an +optional `tag` input allows pushing a specific version tag. + +#### 3. Updated `.github/workflows/build-push.yml` — Native Runners (FR-026) + +The existing workflow is refactored from a single-job QEMU approach to +a parallel native-runner strategy. + +**Current structure** (single job): +``` +build (ubuntu-latest) + → Install QEMU + → Build amd64 natively + → Smoke test amd64 + → Build arm64 under QEMU + → Create manifest + push +``` + +**New structure** (3 jobs, no QEMU): +``` +build-amd64 (ubuntu-latest) build-arm64 (ubuntu-24.04-arm) + → Checkout → Checkout + → Build from opencode-base → Build from opencode-base + → Smoke test natively → Smoke test natively + → Upload image artifact → Upload image artifact + ↓ ↓ + push-manifest (ubuntu-latest) + → Download both artifacts + → Create manifest list + → Push to quay.io +``` + +**Key changes from current workflow**: + +| Aspect | Current | New | +|--------|---------|-----| +| Runner for arm64 | `ubuntu-latest` + QEMU | `ubuntu-24.04-arm` (native) | +| QEMU step | Required (`apt-get install qemu-user-static`) | Removed entirely | +| Build jobs | 1 sequential job | 2 parallel jobs + 1 manifest job | +| Base image | `registry.fedoraproject.org/fedora:41` | `quay.io/unbound-force/opencode-base:latest` | +| Build time (arm64) | ~25 min (QEMU emulated) | ~3 min (native) | +| Smoke tests | amd64 only | Both architectures natively | +| Triggers | Unchanged | Unchanged (push to main, version tags, PRs) | + +**Artifact transfer**: Each build job uploads its image as a GitHub +Actions artifact (OCI archive via `podman save`). The `push-manifest` +job downloads both, loads them into Podman, creates the manifest list, +and pushes. + +### Containerfile Changes + +The primary `Containerfile` is simplified by rebasing onto `opencode-base`: + +**Lines removed** (moved to `Containerfile.base`): +- Lines 24–36: `dnf install` block +- Lines 42–52: Go tarball download and symlink +- Lines 61: `useradd -m -s /bin/bash dev` +- Lines 67–71: `ENV GOROOT GOPATH NPM_CONFIG_PREFIX PATH` (partially — DEWEY_EMBEDDING_ENDPOINT stays) + +**Lines changed**: +- Line 17: `FROM registry.fedoraproject.org/fedora:41` → `FROM quay.io/unbound-force/opencode-base:latest` + +**Lines kept** (remain in Containerfile): +- `COPY` and `RUN` for `install-uf-tools.sh` +- `USER dev` + `RUN /home/dev/scripts/install-uf-tools.sh` +- OpenCode curl installer +- Entrypoint and extract-changes script copies +- `ENV DEWEY_EMBEDDING_ENDPOINT=...` +- Final `USER dev`, `WORKDIR`, `ENTRYPOINT` + +**Containerfile.udi**: No changes. It continues to use +`quay.io/devfile/universal-developer-image:latest` as its base (per +clarification: UDI base stays for Eclipse Che compatibility). + +### Build Time Analysis + +| Scenario | amd64 | arm64 | Manifest + Push | Total Wall Time | +|----------|-------|-------|-----------------|-----------------| +| **Current** (QEMU) | ~8 min | ~25 min (emulated) | ~2 min | **~35–40 min** | +| **New** (native + base) | ~3 min | ~3 min | ~2 min | **~8 min** | +| **SC-001 target** | — | — | — | **≤ 10 min** | + +The ~3 min per-arch estimate assumes: +- Base image pull: ~30 sec (cached after first run) +- `go install` for 6 UF tools: ~90 sec (native compilation) +- OpenCode curl install: ~10 sec +- Smoke tests: ~30 sec +- Image save + artifact upload: ~30 sec + +### Constitution Re-Check + +The addendum introduces no new constitution violations: + +- **Composability**: Base image is independently useful (Fedora + Go + + Node.js). Dev image layers on top. Each layer is self-contained. +- **Security**: No secrets in base image. Same non-root user model. + Native runners don't change the security posture of the built images. +- **Reproducible Builds**: Weekly base rebuild picks up security patches + while keeping the dev image build fast. Both images are multi-arch. +- **Executable Truth**: `Containerfile.base` is the source of truth for + the base image. `Containerfile` is the source of truth for the dev + image. No duplication between them. + +### Dependency Order + +The new deliverables have a strict dependency chain: + +``` +Containerfile.base + → build-base.yml (needs Containerfile.base to exist) + → opencode-base image pushed to quay.io + → Containerfile (FROM opencode-base:latest) + → build-push.yml (needs opencode-base available in registry) +``` + +**Implementation sequence**: +1. `Containerfile.base` — define the base image +2. `.github/workflows/build-base.yml` — CI to build and push it +3. Build and push the base image (manual dispatch of build-base.yml) +4. Update `Containerfile` — change FROM + remove duplicated layers +5. Update `.github/workflows/build-push.yml` — native runners + matrix +6. Verify: full CI run with native runners pulling from opencode-base + +### Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| `ubuntu-24.04-arm` runner unavailable or slow | arm64 builds fail or regress | Fallback: keep QEMU path as commented-out alternative | +| Base image stale (weekly rebuild missed) | Dev image inherits unpatched Fedora | `workflow_dispatch` allows manual rebuild; CI logs show base image age | +| Base image registry pull fails | Dev image build fails | Pin base image digest in Containerfile for reproducibility; CI retries | +| Artifact transfer between jobs adds overhead | Manifest job slow | OCI archives are compressed; expect ~500MB per arch, ~30 sec transfer | diff --git a/specs/001-initial-containerfile/spec.md b/specs/001-initial-containerfile/spec.md index 0b50bb2..134f464 100644 --- a/specs/001-initial-containerfile/spec.md +++ b/specs/001-initial-containerfile/spec.md @@ -160,12 +160,32 @@ CI workflow builds both architectures and pushes to quay.io. ### Functional Requirements +**Base Image (Containerfile.base)**: + +- **FR-022**: A multi-arch base image MUST be published to + `quay.io/unbound-force/opencode-base` containing Fedora 41, + system packages (Node.js 20+, npm, Git, gh CLI, curl), and + Go 1.25+ installed from the official tarball. +- **FR-023**: The base image MUST NOT include UF-specific Go + tools (uf, dewey, replicator, gaze). These are installed in + the dev image layer to allow tool updates without rebuilding + the base. +- **FR-024**: The base image MUST be rebuilt weekly via a + scheduled CI workflow to pick up Fedora security patches and + Go minor version updates. +- **FR-025**: The base image MUST create the non-root `dev` + user and configure GOPATH, PATH, and NPM_CONFIG_PREFIX + environment variables. + **Container Image (Containerfile)**: - **FR-001**: The Containerfile MUST produce a multi-arch image that builds for both `linux/arm64` and `linux/amd64`. +- **FR-001a**: The Containerfile MUST use + `quay.io/unbound-force/opencode-base:latest` as its base + image (not raw Fedora). - **FR-002**: The image MUST include these tools: `uf`, - OpenCode, Dewey, Replicator, Gaze, Go 1.24+, Node.js 20+, + OpenCode, Dewey, Replicator, Gaze, Go 1.25+, Node.js 20+, npm, Git, gh CLI, golangci-lint, govulncheck, OpenSpec CLI. - **FR-003**: Go tools MUST be installed via `go install`. No Homebrew or Linuxbrew in the container. @@ -223,6 +243,13 @@ CI workflow builds both architectures and pushes to quay.io. for both architectures and push to `quay.io/unbound-force/opencode-dev` on push to main and on version tags. +- **FR-026**: The CI workflow MUST use native runners for each + architecture (`ubuntu-latest` for amd64, + `ubuntu-24.04-arm` for arm64) instead of QEMU emulation. +- **FR-027**: A separate CI workflow MUST build and push the + base image (`opencode-base`) on a weekly schedule and on + manual dispatch. This workflow MUST also use native runners + for each architecture. **Documentation**: @@ -240,9 +267,14 @@ CI workflow builds both architectures and pushes to quay.io. ### Key Entities +- **Base Image**: A multi-arch foundation image + (`quay.io/unbound-force/opencode-base`) containing Fedora 41, + system packages, and Go 1.25+. Rebuilt weekly. Used as the + FROM for the primary Containerfile only (not UDI variant). - **Container Image**: The primary OCI artifact published to - quay.io. Two variants: Fedora-based (primary) and UDI-based - (CDE). Contains the full UF toolchain. + quay.io. Two variants: Fedora-based (primary, built on + opencode-base) and UDI-based (CDE). Contains the full UF + toolchain. - **Devfile**: Eclipse Che workspace definition (Devfile 2.2.0 schema). Two variants: custom image (fast start) and dynamic (UDI + postStart, no custom image). @@ -254,8 +286,10 @@ CI workflow builds both architectures and pushes to quay.io. ### Measurable Outcomes -- **SC-001**: Image builds successfully on both arm64 and - amd64 within 15 minutes per architecture. +- **SC-001**: The dev image builds successfully on both arm64 + and amd64 with a total CI wall time of 10 minutes or less + (both architectures combined, using native runners and the + pre-built base image). - **SC-002**: All 5 tool version checks pass (`uf`, `opencode`, `dewey`, `replicator`, `gaze`) after building the image. - **SC-003**: Container starts and OpenCode serves within @@ -284,6 +318,16 @@ CI workflow builds both architectures and pushes to quay.io. - All UF tool repos (dewey, gaze, replicator, unbound-force) have tagged releases available for `go install @latest`. +## Clarifications + +### Session 2026-04-12 + +- Q: Where should the base image be published? → A: Same quay.io namespace (`quay.io/unbound-force/opencode-base`) +- Q: How should the base image be rebuilt? → A: Weekly scheduled CI workflow +- Q: What should the total CI build time target be? → A: 10 minutes total (both architectures combined) +- Q: What should the base image contain? → A: System packages + Go only (UF tools in dev image layer) +- Q: Should Containerfile.udi also use the base image? → A: No, keep UDI base for Eclipse Che compatibility + ## Dependencies - [Discussion #88](https://github.com/orgs/unbound-force/discussions/88) — architecture and design rationale diff --git a/specs/001-initial-containerfile/tasks.md b/specs/001-initial-containerfile/tasks.md index 138041e..6b0920f 100644 --- a/specs/001-initial-containerfile/tasks.md +++ b/specs/001-initial-containerfile/tasks.md @@ -121,6 +121,27 @@ --- +## Phase 8: Base Image + Native Runners + +**Purpose**: Extract the slow-changing platform foundation into a separate base image rebuilt weekly, and replace QEMU emulation with native arm64 runners. Combined effect: CI wall time drops from ~40 min to ~10 min (SC-001). + +**Dependencies**: Phase 6 (US4) must be complete — this phase refactors the CI workflow created in T015 and the Containerfile created in T005. + +### Implementation (sequential — strict dependency chain) + +- [x] T019 [US4] Create `Containerfile.base` — base image definition: FROM `registry.fedoraproject.org/fedora:41`; `dnf install` system packages (nodejs, npm, git, gh, curl, findutils, procps-ng, which, tar, gzip); Go 1.25.3 tarball install with `ARG GO_VERSION=1.25.3` and arch-detection via `uname -m`; create non-root user `dev` with home at `/home/dev`; set ENV for GOROOT, GOPATH, NPM_CONFIG_PREFIX, PATH (including `.opencode/bin`, `.npm-global/bin`), DEWEY_EMBEDDING_ENDPOINT; must NOT include UF Go tools, OpenCode, OpenSpec CLI, entrypoint, or helper scripts — platform foundation only (FR-022, FR-023, FR-025) +- [x] T020 [US4] Create `.github/workflows/build-base.yml` — base image CI workflow: triggers on `schedule` (weekly, cron `'0 4 * * 1'`) and `workflow_dispatch` (manual); 3 jobs: `build-amd64` (runs-on `ubuntu-latest`), `build-arm64` (runs-on `ubuntu-24.04-arm`), `push-manifest` (needs both build jobs); each build job runs `podman build -t opencode-base-$ARCH -f Containerfile.base .` then smoke tests (`go version`, `node --version`, `git --version`, `--entrypoint whoami` expects `dev`), uploads image as artifact; push job logs in to quay.io, creates manifest list, pushes to `quay.io/unbound-force/opencode-base:latest` (FR-024, FR-027) +- [x] T021 [US4] Refactor `Containerfile` to use base image — change FROM to `quay.io/unbound-force/opencode-base:latest`; remove `dnf install` step, Go tarball download, `ARG GO_VERSION`, `useradd`, and ENV block (GOROOT, GOPATH, NPM_CONFIG_PREFIX, PATH); keep COPY and RUN for `install-uf-tools.sh`, `USER dev`, OpenCode curl installer, script copies, `ENV DEWEY_EMBEDDING_ENDPOINT`, final ENTRYPOINT; resulting Containerfile should be ~30 lines (FR-001a) +- [x] T022 [US4] Refactor `.github/workflows/build-push.yml` to use native runners — replace single QEMU-based job with 3 jobs: `build-amd64` (runs-on `ubuntu-latest`), `build-arm64` (runs-on `ubuntu-24.04-arm`), `push-manifest` (needs both); remove QEMU install step entirely; each build job runs `podman build`, smoke tests natively (tool versions + `--entrypoint whoami` expects `dev`), uploads image artifact; push job creates manifest from both arch images, pushes to `quay.io/unbound-force/opencode-dev`; triggers unchanged (push to main, version tags, PRs) (FR-026) + +### Verification + +- [x] T023 [US4] Verify base image + dev image build chain — build base image locally (`podman build -t opencode-base -f Containerfile.base .`), then build dev image on top (`podman build -t opencode-dev -f Containerfile .`); run full smoke test suite (all tool version checks + `whoami` prints `dev`); verify dev image build is faster than the pre-refactor single-stage build (SC-001, SC-002) + +**Checkpoint**: Base image builds with platform packages + Go only. Dev image builds on top of base with UF tools only. Full smoke test suite passes. Dev image build time is significantly reduced. CI workflows use native runners for both architectures. + +--- + ## Dependencies & Execution Order ### Phase Dependencies @@ -132,6 +153,7 @@ - **Phase 5 (US3 — P3)**: Depends on Phase 2 — needs install script; independent of US2 - **Phase 6 (US4 — P4)**: Depends on Phase 3 — needs a working Containerfile - **Phase 7 (Polish)**: Depends on Phases 3-6 — documents all artifacts +- **Phase 8 (Base Image + Native Runners)**: Depends on Phase 6 — refactors Containerfile (T005) and CI workflow (T015); strict internal dependency chain (T019 → T020 → T021 → T022 → T023) ### Parallel Opportunities @@ -153,7 +175,7 @@ | Metric | Count | |--------|-------| -| **Total tasks** | 18 | +| **Total tasks** | 23 | | **Phase 1 (Setup)** | 2 | | **Phase 2 (Foundational)** | 1 | | **Phase 3 (US1 — P1)** | 3 | @@ -161,7 +183,8 @@ | **Phase 5 (US3 — P3)** | 4 | | **Phase 6 (US4 — P4)** | 1 | | **Phase 7 (Polish)** | 3 | +| **Phase 8 (Base Image + Native Runners)** | 5 | | **Parallelizable [P] tasks** | 6 | -| **Files created** | 12 | -| **Files updated** | 1 (AGENTS.md) | +| **Files created** | 14 | +| **Files modified** | 3 (AGENTS.md, Containerfile, build-push.yml) |