113c9f0bb0
Main changes: - Extract opencode.json generation from entrypoint-user.sh into a standalone Python script (rootfs/usr/local/lib/opencode-devbox/ generate-config.py). Preserves the never-overwrite-existing-config guarantee. Cuts entrypoint-user.sh from 176 to 97 lines. - Install MemPalace via 'uv tool install' into an isolated venv at /opt/uv-tools/mempalace/ with a /usr/local/bin/mempalace-mcp-server wrapper, replacing the 'pip install --break-system-packages' escape hatch. The wrapper is what generate-config.py references in the auto-generated opencode.json. Also fix 'mempalace init' in entrypoint-user.sh to use --yes so first-start initialization isn't interactive (this used to hang or print prompts into the user's terminal). Gated by INSTALL_MEMPALACE build arg (default true) so users who don't need AI memory can shave ~300 MB. - Sentinel-file pattern in entrypoint.sh volume-ownership loop: write .devbox-owner after a successful chown -R, skip the recursive walk on subsequent starts when the sentinel matches FINAL_UID:FINAL_GID. Cuts multi-second startup costs to milliseconds on large volumes (nvim plugins, palace data). UID changes still trigger a full chown. - Float all GitHub/Gitea-hosted binary versions: gosu, fzf, git-lfs, neovim, bat, eza, zoxide, uv, gitea-mcp now default to 'latest' and resolve the newest upstream release at build time via the /releases/ latest redirect. Go (go.dev JSON feed) and oh-my-opencode-slim (npm @latest) likewise. Intentional pins still in place: OPENCODE_VERSION, NODE_VERSION=22, DEBIAN_VERSION=trixie-slim. Each *_VERSION ARG accepts an explicit value to lock a specific version when needed. - New scripts/smoke-test.sh verifies binary presence, opencode startup, entrypoint user drop, generate-config idempotency, bun's presence- per-variant, and image size against thresholds (2500 MB base, 3000 MB OMOS). Prints resolved component versions as its first step so CI logs always record what got baked into a given image. - New .gitea/workflows/validate.yml runs on push to main and PRs: single-arch amd64 build, smoke test, DOCKER_HUB.md sync check. Tag- triggered docker-publish.yml now smoke-tests each variant on amd64 before the full multi-arch push. - scripts/generate-dockerhub-md.py auto-generates DOCKER_HUB.md from README.md using explicit SECTION_RULES. --check mode fails CI when the committed file is out of sync. Enforces the 25 kB Docker Hub limit. Adding a new README section forces an explicit keep/drop/ replace decision. - Remove dead INSTALL_PYTHON build arg (was a no-op since mempalace added python3 unconditionally).
218 lines
7.7 KiB
Bash
Executable File
218 lines
7.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Smoke-test a freshly-built opencode-devbox image.
|
|
#
|
|
# Verifies:
|
|
# - Core binaries are on PATH and runnable
|
|
# - opencode itself starts and prints a version
|
|
# - Entrypoint runs cleanly as non-root after UID adjustment
|
|
# - Generated opencode.json has the expected shape
|
|
# - MCP wrapper works (when mempalace is installed)
|
|
#
|
|
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos]
|
|
#
|
|
# Exit codes:
|
|
# 0 all checks passed
|
|
# 1 one or more checks failed
|
|
|
|
set -euo pipefail
|
|
|
|
IMAGE="${1:-}"
|
|
VARIANT="base"
|
|
if [ "${2:-}" = "--variant" ]; then
|
|
VARIANT="${3:-base}"
|
|
fi
|
|
|
|
if [ -z "$IMAGE" ]; then
|
|
echo "usage: $0 <image> [--variant base|omos]" >&2
|
|
exit 2
|
|
fi
|
|
|
|
FAILED=0
|
|
pass() { echo " ✓ $1"; }
|
|
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
|
|
|
run() {
|
|
# Run a command inside the image and capture its output.
|
|
# First arg is a label, rest is the shell command.
|
|
local label="$1"; shift
|
|
local out
|
|
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$*" 2>&1); then
|
|
pass "$label ($(echo "$out" | head -1))"
|
|
else
|
|
fail "$label: $out"
|
|
fi
|
|
}
|
|
|
|
echo "=== Smoke test: $IMAGE (variant: $VARIANT) ==="
|
|
echo
|
|
echo "-- Resolved component versions --"
|
|
# Prints the actual version of every floating component so CI logs
|
|
# always record what got baked into this image, even when Dockerfile
|
|
# ARGs default to "latest".
|
|
docker run --rm --entrypoint="" "$IMAGE" sh -c '
|
|
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
|
|
printf " %-15s %s\n" "node" "$(node --version)"
|
|
printf " %-15s %s\n" "npm" "$(npm --version)"
|
|
printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)"
|
|
printf " %-15s %s\n" "bat" "$(bat --version)"
|
|
printf " %-15s %s\n" "eza" "$(eza --version | head -2 | tail -1)"
|
|
printf " %-15s %s\n" "zoxide" "$(zoxide --version)"
|
|
printf " %-15s %s\n" "uv" "$(uv --version)"
|
|
printf " %-15s %s\n" "fzf" "$(fzf --version)"
|
|
printf " %-15s %s\n" "fd" "$(fd --version)"
|
|
printf " %-15s %s\n" "rg" "$(rg --version | head -1)"
|
|
printf " %-15s %s\n" "gosu" "$(gosu --version)"
|
|
printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)"
|
|
printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)"
|
|
printf " %-15s %s\n" "aws" "$(aws --version 2>&1)"
|
|
if command -v bun >/dev/null 2>&1; then
|
|
printf " %-15s %s\n" "bun" "$(bun --version)"
|
|
fi
|
|
if command -v mempalace >/dev/null 2>&1; then
|
|
printf " %-15s %s\n" "mempalace" "$(mempalace --version 2>&1 | head -1 || echo installed)"
|
|
fi
|
|
'
|
|
echo
|
|
echo "-- Core binaries --"
|
|
run "opencode" "opencode --version"
|
|
run "node" "node --version"
|
|
run "npm" "npm --version"
|
|
run "git" "git --version"
|
|
run "nvim" "nvim --version | head -1"
|
|
run "bat" "bat --version"
|
|
run "eza" "eza --version | head -1"
|
|
run "zoxide" "zoxide --version"
|
|
run "uv" "uv --version"
|
|
run "uvx" "uvx --version"
|
|
run "rustup-init" "rustup-init --version"
|
|
run "fzf" "fzf --version"
|
|
run "fd" "fd --version"
|
|
run "rg" "rg --version | head -1"
|
|
run "jq" "jq --version"
|
|
run "aws" "aws --version"
|
|
run "gitea-mcp" "gitea-mcp --version"
|
|
run "gosu" "gosu --version"
|
|
run "tmux" "tmux -V"
|
|
|
|
echo
|
|
echo "-- Optional / variant-gated --"
|
|
# mempalace: present unless built with INSTALL_MEMPALACE=false
|
|
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then
|
|
run "mempalace" "mempalace --help | head -1"
|
|
run "mempalace-mcp-server" "test -x /usr/local/bin/mempalace-mcp-server && echo wrapper-present"
|
|
else
|
|
echo " - mempalace not installed (INSTALL_MEMPALACE=false)"
|
|
fi
|
|
|
|
# bun: only in the omos variant
|
|
if [ "$VARIANT" = "omos" ]; then
|
|
run "bun (omos)" "bun --version"
|
|
# oh-my-opencode-slim is npm-installed globally (not a bun install);
|
|
# verify it shows up in the global module list.
|
|
run "oh-my-opencode-slim" "npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim"
|
|
else
|
|
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then
|
|
fail "bun should NOT be in base image but was found"
|
|
else
|
|
pass "bun correctly absent from base image"
|
|
fi
|
|
fi
|
|
|
|
echo
|
|
echo "-- Entrypoint behaviour --"
|
|
|
|
# Generate-config script exists and has valid syntax.
|
|
run "generate-config.py exists" \
|
|
"test -x /usr/local/lib/opencode-devbox/generate-config.py && python3 -m py_compile /usr/local/lib/opencode-devbox/generate-config.py && echo ok"
|
|
|
|
# Entrypoint drops to developer user and runs a trivial command.
|
|
# Writes the result to a file inside the container so we don't have to
|
|
# disentangle entrypoint log output from command stdout on the host.
|
|
label="entrypoint drops to developer"
|
|
tmpout=$(mktemp)
|
|
if docker run --rm -e OPENCODE_PROVIDER= "$IMAGE" \
|
|
sh -c 'whoami > /tmp/who && cat /tmp/who' > "$tmpout" 2>/dev/null; then
|
|
# The last line of stdout is the whoami output. Entrypoint log lines
|
|
# (MemPalace init, "Adjusted developer UID", etc.) go to stderr or
|
|
# get printed before our sh command runs.
|
|
actual=$(tail -1 "$tmpout" | tr -d '[:space:]')
|
|
if [ "$actual" = "developer" ]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label: expected 'developer', got '$actual' (full output: $(cat "$tmpout"))"
|
|
fi
|
|
else
|
|
fail "$label: container failed"
|
|
fi
|
|
rm -f "$tmpout"
|
|
|
|
# Config generation with anthropic provider writes valid JSON with the
|
|
# expected shape. The script's log message goes to stderr (line 1 of
|
|
# generate-config.py uses file=sys.stderr) so capturing only stdout
|
|
# gives us clean JSON.
|
|
label="generate-config produces valid opencode.json"
|
|
tmp=$(mktemp -d)
|
|
if docker run --rm \
|
|
-e OPENCODE_PROVIDER=anthropic \
|
|
-e HOME=/tmp/home \
|
|
--entrypoint="" \
|
|
"$IMAGE" sh -c '
|
|
mkdir -p /tmp/home
|
|
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
|
|
cat /tmp/home/.config/opencode/opencode.json
|
|
' > "$tmp/out.json" 2>/dev/null; then
|
|
if python3 -c "
|
|
import json, sys
|
|
c = json.load(open('$tmp/out.json'))
|
|
assert c['model'].startswith('anthropic/'), c
|
|
assert c['autoupdate'] is False
|
|
assert c['share'] == 'disabled'
|
|
" 2>&1; then
|
|
pass "$label"
|
|
else
|
|
fail "$label: output doesn't match expected shape: $(cat "$tmp/out.json")"
|
|
fi
|
|
else
|
|
fail "$label: container failed: $(cat "$tmp/out.json")"
|
|
fi
|
|
|
|
# Config generation is idempotent — running twice must not overwrite.
|
|
label="generate-config never overwrites existing config"
|
|
if docker run --rm \
|
|
-e OPENCODE_PROVIDER=anthropic \
|
|
-e HOME=/tmp/home \
|
|
--entrypoint="" \
|
|
"$IMAGE" sh -c '
|
|
mkdir -p /tmp/home/.config/opencode
|
|
echo "{\"sentinel\": \"user-config\"}" > /tmp/home/.config/opencode/opencode.json
|
|
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
|
|
cat /tmp/home/.config/opencode/opencode.json
|
|
' 2>/dev/null | grep -q '"sentinel": "user-config"'; then
|
|
pass "$label"
|
|
else
|
|
fail "$label: existing config was modified!"
|
|
fi
|
|
rm -rf "$tmp"
|
|
|
|
echo
|
|
echo "-- Image size --"
|
|
SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE")
|
|
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
|
|
echo " Uncompressed size: ${SIZE_MB} MB"
|
|
|
|
# Thresholds (uncompressed): base 2500 MB, omos 3000 MB. Adjust as image content evolves.
|
|
THRESHOLD=2500
|
|
[ "$VARIANT" = "omos" ] && THRESHOLD=3000
|
|
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
|
|
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
|
|
else
|
|
pass "image size ${SIZE_MB} MB within threshold ${THRESHOLD} MB"
|
|
fi
|
|
|
|
echo
|
|
if [ "$FAILED" -gt 0 ]; then
|
|
echo "=== FAILED: $FAILED check(s) ===" >&2
|
|
exit 1
|
|
fi
|
|
echo "=== PASSED ==="
|