Infrastructure pass: CI smoke tests, floating versions, chown sentinel, generate-config script
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).
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Launcher for the MemPalace MCP server.
|
||||
#
|
||||
# MemPalace is installed via `uv tool install` into an isolated venv
|
||||
# under /opt/uv-tools/. System python3 cannot import mempalace directly,
|
||||
# so this wrapper exec's the venv's python with the mcp_server module.
|
||||
#
|
||||
# Used by opencode.json:
|
||||
# "command": ["mempalace-mcp-server"]
|
||||
exec /opt/uv-tools/mempalace/bin/python -m mempalace.mcp_server "$@"
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate opencode.json from environment variables on first container start.
|
||||
|
||||
Safety guarantees:
|
||||
- NEVER overwrites an existing opencode.json. If the file is present
|
||||
(whether bind-mounted from the host, persisted in a named volume, or
|
||||
previously generated), this script exits immediately without writing.
|
||||
- Requires OPENCODE_PROVIDER to be set. Without it, no file is written.
|
||||
|
||||
Environment variables:
|
||||
OPENCODE_PROVIDER Required. One of: anthropic, openai, amazon-bedrock.
|
||||
OPENCODE_MODEL Optional. Overrides the provider default model.
|
||||
AWS_REGION Bedrock only. Default: us-east-1.
|
||||
AWS_PROFILE Bedrock only. Default: default.
|
||||
|
||||
MCP servers are auto-registered for tools detected on PATH:
|
||||
- mempalace (if installed) — enabled
|
||||
- gitea-mcp (if installed) — registered but disabled by default
|
||||
|
||||
Output path: $HOME/.config/opencode/opencode.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Default model per provider. Update here when upstream changes.
|
||||
DEFAULT_MODELS: dict[str, str] = {
|
||||
"anthropic": "anthropic/claude-sonnet-4-6",
|
||||
"openai": "openai/gpt-5.4",
|
||||
"amazon-bedrock": (
|
||||
"amazon-bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
),
|
||||
}
|
||||
|
||||
# Fallback when OPENCODE_PROVIDER is set but not recognized.
|
||||
FALLBACK_MODEL = DEFAULT_MODELS["anthropic"]
|
||||
|
||||
SCHEMA_URL = "https://opencode.ai/config.json"
|
||||
|
||||
|
||||
def build_config(provider: str, model: str) -> dict:
|
||||
"""Build the base opencode.json structure for a provider."""
|
||||
config: dict = {
|
||||
"$schema": SCHEMA_URL,
|
||||
"model": model,
|
||||
"share": "disabled",
|
||||
"autoupdate": False,
|
||||
}
|
||||
|
||||
if provider == "amazon-bedrock":
|
||||
config["provider"] = {
|
||||
"amazon-bedrock": {
|
||||
"options": {
|
||||
"region": os.environ.get("AWS_REGION", "us-east-1"),
|
||||
"profile": os.environ.get("AWS_PROFILE", "default"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def register_mcp_servers(config: dict) -> list[str]:
|
||||
"""Auto-register MCP servers for tools detected on PATH.
|
||||
|
||||
Returns the list of server names that were added. The "mcp" key
|
||||
is only added to the config when at least one server is registered.
|
||||
"""
|
||||
servers: dict[str, dict] = {}
|
||||
|
||||
# MemPalace — local-first AI memory (if installed).
|
||||
# Uses the mempalace-mcp-server wrapper rather than invoking
|
||||
# `python3 -m mempalace.mcp_server` directly, because mempalace
|
||||
# lives in an isolated uv tool venv that system python3 cannot
|
||||
# import from. The wrapper exec's the right interpreter.
|
||||
if shutil.which("mempalace") and shutil.which("mempalace-mcp-server"):
|
||||
servers["mempalace"] = {
|
||||
"type": "local",
|
||||
"command": ["mempalace-mcp-server"],
|
||||
}
|
||||
|
||||
# Gitea — self-hosted Git forge API (if installed).
|
||||
# Disabled by default; user must set GITEA_ACCESS_TOKEN + GITEA_HOST
|
||||
# and flip enabled=true in their config.
|
||||
if shutil.which("gitea-mcp"):
|
||||
servers["gitea"] = {
|
||||
"type": "local",
|
||||
"command": ["gitea-mcp", "-t", "stdio"],
|
||||
"enabled": False,
|
||||
}
|
||||
|
||||
if servers:
|
||||
config["mcp"] = servers
|
||||
|
||||
return list(servers.keys())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
|
||||
if not provider:
|
||||
# No provider set — nothing to do. Not an error.
|
||||
return 0
|
||||
|
||||
home = Path(os.environ.get("HOME", "/home/developer"))
|
||||
config_dir = home / ".config" / "opencode"
|
||||
config_file = config_dir / "opencode.json"
|
||||
|
||||
# CRITICAL: never overwrite an existing config. Users may have
|
||||
# bind-mounted their host config directory, or their config may be
|
||||
# persisted in a named volume from a previous run.
|
||||
if config_file.exists():
|
||||
print(
|
||||
f"Existing opencode.json found at {config_file} — "
|
||||
"skipping generation.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
if provider not in DEFAULT_MODELS:
|
||||
print(
|
||||
f"WARNING: unknown OPENCODE_PROVIDER={provider!r}, "
|
||||
f"falling back to default model {FALLBACK_MODEL!r}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
model = os.environ.get("OPENCODE_MODEL", "").strip() or DEFAULT_MODELS.get(
|
||||
provider, FALLBACK_MODEL
|
||||
)
|
||||
|
||||
print(f"Generating opencode config for provider: {provider}", file=sys.stderr)
|
||||
|
||||
config = build_config(provider, model)
|
||||
added = register_mcp_servers(config)
|
||||
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with config_file.open("w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
if added:
|
||||
print(
|
||||
f"MCP servers registered in opencode config: {', '.join(added)}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user