From 3e2c12d73d8e3c03f03b89f8114ff3075d68e2f9 Mon Sep 17 00:00:00 2001 From: Ian Date: Mon, 28 Sep 2026 23:17:45 +0200 Subject: [PATCH] first commit --- .dockerignore | 4 ++ Dockerfile | 75 ++++++++++++++++++++++++++++++ README.md | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++ build.sh | 56 ++++++++++++++++++++++ dev.sh | 103 +++++++++++++++++++++++++++++++++++++++++ entrypoint.sh | 54 ++++++++++++++++++++++ 6 files changed, 417 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 build.sh create mode 100755 dev.sh create mode 100755 entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..15b6b40 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.gitignore +README.md +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa26ad7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +# syntax=docker/dockerfile:1 + +ARG UBUNTU_VERSION=26.04 +FROM ubuntu:${UBUNTU_VERSION} + +ARG OPENCODE_VERSION=1.18.32 +ARG NODE_VERSION=24.21.0 + +ENV DEBIAN_FRONTEND=noninteractive \ + HOME=/home/dev \ + EDITOR=nvim \ + VISUAL=nvim \ + PIP_CACHE_DIR=/home/dev/.cache/pip \ + NPM_CONFIG_CACHE=/home/dev/.npm + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + wget \ + git \ + openssh-client \ + neovim \ + jq \ + less \ + zip \ + unzip \ + tar \ + gzip \ + procps \ + ripgrep \ + fd-find \ + tree \ + build-essential \ + pkg-config \ + python3 \ + python3-pip \ + python3-venv \ + python3-dev \ + openjdk-11-jdk \ + openjdk-17-jdk \ + maven \ + locales \ + xz-utils \ + file \ + util-linux \ + # && rm -rf /var/lib/apt/lists/* \ + && update-alternatives --set java /usr/lib/jvm/java-17-openjdk-amd64/bin/java \ + && update-alternatives --set javac /usr/lib/jvm/java-17-openjdk-amd64/bin/javac \ + && echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen \ + && locale-gen \ + && useradd --create-home --shell /bin/bash dev \ + && mkdir -p /workspace /home/dev/.config/opencode /home/dev/.local/share/opencode \ + /home/dev/.cache/opencode /home/dev/.cache/pip /home/dev/.npm /home/dev/.m2 \ + && chown -R dev:dev /workspace /home/dev + +RUN curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + && tar -xJf "node-v${NODE_VERSION}-linux-x64.tar.xz" -C /usr/local --strip-components=1 \ + && rm "node-v${NODE_VERSION}-linux-x64.tar.xz" \ + && node --version \ + && npm --version \ + && npm install --global "opencode-ai@${OPENCODE_VERSION}" \ + && opencode --version \ + && npm cache clean --force + +COPY entrypoint.sh /usr/local/bin/opencode-dev-entrypoint +RUN chmod +x /usr/local/bin/opencode-dev-entrypoint + +WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/opencode-dev-entrypoint"] +CMD ["bash"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..11b3f72 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# OpenCode Podman Dev Environment + +Podman-based Ubuntu 26.04 development container for OpenCode. + +## Decisions + +- Ubuntu 26.04, amd64/x86_64 +- OpenCode pinned to 1.18.32 +- Node.js 24.21.0 LTS, official Linux x64 binary +- OpenJDK 11 + 17, Java 17 default +- Python 3 from Ubuntu + pip + venv +- Maven, no Gradle +- Neovim installed with no custom configuration +- non-root `dev` user +- rootless Podman with `--userns=keep-id` +- no Podman/Docker socket +- no proxy configuration +- SSH only for Git; host `.ssh` is read-only and host SSH agent is forwarded when available +- host timezone and locale +- custom CA certificates are host-mounted read-only and installed at runtime; invalid certs warn and do not stop startup +- OpenCode config/data/cache and development caches persist on the host +- Bash history is ephemeral + +## Host data + +The scripts create automatically: + +```text +~/dev/opencode-dev/ +├── opencode-config/ +├── opencode-data/ +├── opencode-cache/ +├── m2/ +├── npm-cache/ +├── pip-cache/ +├── certs/ +├── bash/ +└── projects/ +``` + +OpenCode stores provider credentials in `~/.local/share/opencode/auth.json`, which is therefore persisted by the `opencode-data` mount. + +## Build + +```bash +chmod +x build.sh dev.sh opencode.sh +./build.sh +``` + +Clean rebuild: + +```bash +./build.sh --no-cache +``` + +Intentional update rebuild: + +```bash +./build.sh --update +``` + +## Shell + +```bash +./dev.sh +``` + +Start in a project: + +```bash +./dev.sh ~/dev/opencode-dev/projects/my-project +``` + +The same `projects` tree is mounted as `/workspace`. + +Optional additional mount: + +```bash +./dev.sh ~/dev/opencode-dev/projects/my-project \ + --mount "$HOME/another-project:/extra" +``` + +Put custom Bash settings in: + +```text +~/dev/opencode-dev/bash/bashrc +``` + +They are loaded after the image's normal Bash environment. History remains ephemeral. + +## OpenCode + +```bash +./opencode.sh +``` + +Or start in a project: + +```bash +./opencode.sh ~/dev/opencode-dev/projects/my-project +``` + +Arguments are forwarded to OpenCode: + +```bash +./opencode.sh ~/dev/opencode-dev/projects/my-project --help +``` + +## Persistent mounts + +| Host | Container | Mode | +|---|---|---| +| `opencode-config` | `~/.config/opencode` | RW | +| `opencode-data` | `~/.local/share/opencode` | RW | +| `opencode-cache` | `~/.cache/opencode` | RW | +| `m2` | `~/.m2` | RW | +| `npm-cache` | `~/.npm` | RW | +| `pip-cache` | `~/.cache/pip` | RW | +| `certs` | `/host-certs` | RO | +| `~/.gitconfig` | `~/.gitconfig` | RO | +| `~/.ssh` | `~/.ssh` | RO | +| `bash` | `/host-bash` | RO | +| `projects` | `/workspace` | RW | + +No credentials or personal OpenCode state are baked into the image. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..5704917 --- /dev/null +++ b/build.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +UBUNTU_VERSION="${UBUNTU_VERSION:-26.04}" +OPENCODE_VERSION=$(curl -fsSI https://github.com/anomalyco/opencode/releases/latest | sed -n 's/^location:.*\/tag\/v\{0,1\}\(.*\)\r$/\1/ip') +NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/index.json | sed -n 's/.*"version":"v\([^"]*\)".*"lts":".*".*/\1/p' | head -n 1) + +IMAGE_REPO="${IMAGE_REPO:-localhost/opencode-dev}" +IMAGE_TAG="${IMAGE_TAG:-${OPENCODE_VERSION}}" +IMAGE="${IMAGE_REPO}:${IMAGE_TAG}" + +NO_CACHE=() +if [[ "${1:-}" == "--no-cache" ]]; then + NO_CACHE+=(--no-cache) + shift +fi + +UPDATE=0 +if [[ "${1:-}" == "--update" ]]; then + UPDATE=1 + shift +fi + +DRY_RUN=false +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=true + shift +fi + +if (($#)); then + echo "Usage: $0 [--no-cache] [--update] [--dry-run]" >&2 + exit 2 +fi + +if ((UPDATE)); then + echo "Update requested: rebuilding ${IMAGE} with current Ubuntu package metadata." + podman rmi ${IMAGE_REPO} +fi + +if [ DRY_RUN = false ]; then + podman build \ + "${NO_CACHE[@]}" \ + --build-arg "UBUNTU_VERSION=${UBUNTU_VERSION}" \ + --build-arg "OPENCODE_VERSION=${OPENCODE_VERSION}" \ + --build-arg "NODE_VERSION=${NODE_VERSION}" \ + -t "$IMAGE" \ + . + + echo + echo "Built: $IMAGE" +else + echo "running: podman build ${NO_CACHE[@]} --build-arg UBUNTU_VERSION=${UBUNTU_VERSION} --build-arg OPENCODE_VERSION=${OPENCODE_VERSION} --build-arg NODE_VERSION=${NODE_VERSION} -t $IMAGE" +fi diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..570cc35 --- /dev/null +++ b/dev.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_DIR="${OPENCODE_DEV_HOME:-$HOME/dev/opencode-dev}" +PROJECTS_DIR="$BASE_DIR/projects" +IMAGE_REPO="${IMAGE_REPO:-localhost/opencode-dev}" +OPENCODE_VERSION="${OPENCODE_VERSION:-1.18.32}" +IMAGE_TAG="${IMAGE_TAG:-${OPENCODE_VERSION}}" +IMAGE="${IMAGE_REPO}:${IMAGE_TAG}" + +mkdir -p \ + "$BASE_DIR/opencode-config" \ + "$BASE_DIR/opencode-data" \ + "$BASE_DIR/opencode-cache" \ + "$BASE_DIR/m2" \ + "$BASE_DIR/npm-cache" \ + "$BASE_DIR/pip-cache" \ + "$BASE_DIR/certs" \ + "$BASE_DIR/bash" \ + "$PROJECTS_DIR" + +if [[ ! -f "$HOME/.gitconfig" ]]; then + echo "WARNING: $HOME/.gitconfig does not exist; Git will use container defaults." >&2 +fi + +if ! podman image exists "$IMAGE"; then + echo "Image $IMAGE does not exist. Build it first with ./build.sh" >&2 + exit 1 +fi + +START_DIR="${1:-$PROJECTS_DIR}" +shift || true + +if [[ "$START_DIR" = /* ]]; then + if [[ "$START_DIR" == "$PROJECTS_DIR"* ]]; then + REL="${START_DIR#"$PROJECTS_DIR"}" + START_DIR="/workspace${REL}" + else + echo "Absolute paths outside $PROJECTS_DIR require --mount and are not accepted as the initial directory." >&2 + exit 2 + fi +else + START_DIR="/workspace/${START_DIR#./}" +fi + +if [[ ! -d "$PROJECTS_DIR" ]]; then + echo "Project directory missing: $PROJECTS_DIR" >&2 + exit 1 +fi + +ARGS=( + run --rm -it + --name opencode-dev + --userns=keep-id + --security-opt=no-new-privileges + --network=default + --tz=local + -e "HOST_LANG=${LANG:-C.UTF-8}" + -e "HOST_LC_ALL=${LC_ALL:-}" + -e "START_DIR=${START_DIR}" + -v "$BASE_DIR/opencode-config:/home/dev/.config/opencode:rw" + -v "$BASE_DIR/opencode-data:/home/dev/.local/share/opencode:rw" + -v "$BASE_DIR/opencode-cache:/home/dev/.cache/opencode:rw" + -v "$BASE_DIR/m2:/home/dev/.m2:rw" + -v "$BASE_DIR/npm-cache:/home/dev/.npm:rw" + -v "$BASE_DIR/pip-cache:/home/dev/.cache/pip:rw" + -v "$BASE_DIR/certs:/host-certs:ro" + -v "$PROJECTS_DIR:/workspace:rw" + -v "$HOME/.gitconfig:/home/dev/.gitconfig:ro" + -v "$HOME/.ssh:/home/dev/.ssh:ro" + -v "$BASE_DIR/bash:/host-bash:ro" +) + +if [[ -n "${SSH_AUTH_SOCK:-}" && -S "$SSH_AUTH_SOCK" ]]; then + ARGS+=( + -e SSH_AUTH_SOCK=/tmp/ssh-agent.sock + -v "$SSH_AUTH_SOCK:/tmp/ssh-agent.sock" + ) +fi + +# Additional bind mounts: +# ./dev.sh /project --mount "$HOME/other:/extra" +while (($#)); do + case "$1" in + --mount) + [[ $# -ge 2 ]] || { echo "--mount requires host:container[:ro|rw]" >&2; exit 2; } + ARGS+=(-v "$2") + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 2 + ;; + esac +done + +# Load user Bash configuration after the image defaults, without persisting history. +BASH_RC="${BASE_DIR}/bash/bashrc" +if [[ -f "$BASH_RC" ]]; then + ARGS+=(-e BASH_ENV=/host-bash/bashrc) +fi + +exec podman "${ARGS[@]}" "$IMAGE" bash diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..2f80b1d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -euo pipefail + +CERT_DIR="${CERT_DIR:-/host-certs}" + +if [[ -d "$CERT_DIR" ]]; then + shopt -s nullglob + certs=("$CERT_DIR"/*.crt "$CERT_DIR"/*.pem) + if ((${#certs[@]})); then + mkdir -p /usr/local/share/ca-certificates/opencode-host + for cert in "${certs[@]}"; do + target="/usr/local/share/ca-certificates/opencode-host/$(basename "${cert%.*}").crt" + if openssl x509 -in "$cert" -noout >/dev/null 2>&1; then + cp -f "$cert" "$target" + else + echo "WARNING: ignoring invalid CA certificate: $cert" >&2 + fi + done + if update-ca-certificates >/tmp/opencode-ca.log 2>&1; then + : + else + echo "WARNING: update-ca-certificates failed; continuing." >&2 + cat /tmp/opencode-ca.log >&2 || true + fi + fi +fi + +host_lang="${HOST_LANG:-${LANG:-C.UTF-8}}" +host_lc_all="${HOST_LC_ALL:-${LC_ALL:-}}" + +# Generate a host locale when it is a valid UTF-8 locale and not already present. +if [[ "$host_lang" == *.UTF-8 && "$host_lang" != "C.UTF-8" ]]; then + if ! locale -a | grep -Fxqi "${host_lang//UTF-8/utf8}"; then + if [[ "$host_lang" =~ ^([A-Za-z_]+)\.UTF-8$ ]]; then + locale_name="${BASH_REMATCH[1]}.UTF-8" + if grep -qE "^#?[[:space:]]*${locale_name}[[:space:]]+UTF-8" /etc/locale.gen 2>/dev/null; then + sed -i -E "s/^#[[:space:]]*(${locale_name}[[:space:]]+UTF-8)/\1/" /etc/locale.gen + else + printf '%s UTF-8\n' "$locale_name" >> /etc/locale.gen + fi + locale-gen "$locale_name" >/dev/null 2>&1 || \ + echo "WARNING: could not generate locale $host_lang; continuing." >&2 + fi + fi +fi + +export LANG="$host_lang" +if [[ -n "$host_lc_all" ]]; then + export LC_ALL="$host_lc_all" +fi + +# The image entrypoint is root only for one-time runtime setup; the shell/OpenCode +# process itself always runs as the unprivileged dev user. +exec su -s /bin/bash dev -c 'cd "${START_DIR:-/workspace}" && exec "$@"' -- "$@"