Replace docker compose with persistent per-project lifecycle

Move from docker compose run to a Python-managed container lifecycle.
Each project now gets a dedicated container that is started on demand
and stopped when opencode exits, instead of being recreated each time.

Use a shared home directory across projects so configurations presist.

The container are not destroyed, so tools and caches can be installed
specifically for a project by opencode itself once and reused, while
still avoiding long-running containers.
This commit is contained in:
Jeena 2026-01-21 21:33:30 +09:00
parent 57ef6454c6
commit fc2e5b1bca
4 changed files with 168 additions and 49 deletions

View file

@ -10,9 +10,11 @@ RUN pacman -Syu --noconfirm \
ca-certificates \ ca-certificates \
bash \ bash \
less \ less \
ripgrep && \ ripgrep \
sudo && \
groupadd -g ${GID} ${USERNAME} && \ groupadd -g ${GID} ${USERNAME} && \
useradd -m -u ${UID} -g ${GID} -s /bin/bash ${USERNAME} && \ useradd -m -u ${UID} -g ${GID} -s /bin/bash ${USERNAME} && \
echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
pacman -Scc --noconfirm pacman -Scc --noconfirm
WORKDIR /tmp WORKDIR /tmp
@ -20,12 +22,8 @@ USER ${USERNAME}
RUN git clone https://aur.archlinux.org/opencode-bin.git && \ RUN git clone https://aur.archlinux.org/opencode-bin.git && \
cd opencode-bin && \ cd opencode-bin && \
makepkg --noconfirm makepkg --syncdeps --noconfirm --install && \
sudo rm -rf /tmp/opencode-bin && \
sudo pacman -Scc --noconfirm
USER root
RUN pacman -U --noconfirm /tmp/opencode-bin/*.pkg.tar.zst && \
rm -rf /tmp/opencode-bin && \
pacman -Scc --noconfirm
USER ${USERNAME}
WORKDIR /home/${USERNAME} WORKDIR /home/${USERNAME}

View file

@ -1,28 +0,0 @@
services:
opencode:
image: opencode-arch
build:
context: .
dockerfile: Dockerfile
args:
USERNAME: "${USER}"
UID: "${UID}"
GID: "${GID}"
user: "${UID}:${GID}"
working_dir: "${PWD}"
stdin_open: true
tty: true
environment:
UID: "${UID}"
GID: "${GID}"
volumes:
- "${CONTAINER_HOME}:/home/${USER}/"
- "${PWD}:${PWD}"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true

161
opencode-container.py Executable file
View file

@ -0,0 +1,161 @@
#!/usr/bin/env python3
import hashlib
import os
import subprocess
import sys
import time
from pathlib import Path
class OpenCodeContainer:
IMAGE = "opencode-container:latest"
PROJECT_ID_LEN = 12
START_TIMEOUT = 3.0
def __init__(self):
self.project_path = Path.cwd().resolve()
self.project_id = self._project_id(self.project_path)
self.container_name = f"oc-{self.project_path.name}-{self.project_id}"
self.docker_context_dir = Path(__file__).resolve().parent
# =========================
# Public entrypoint
# =========================
def run(self, args: list[str]) -> None:
if not self.image_exists():
self.build_image()
if not self.container_exists():
self.create_container()
if not self.start_container():
print("Recreating container due to failed start")
self.remove_container()
self.create_container()
if not self.start_container():
raise RuntimeError("Container failed to start after recreation")
try:
self.exec_opencode(args)
finally:
self.stop_container()
# =========================
# Image handling
# =========================
def image_exists(self) -> bool:
return subprocess.run(
["docker", "image", "inspect", self.IMAGE],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
def build_image(self) -> None:
print(f"Building image '{self.IMAGE}'")
self._run([
"docker", "build",
"-t", self.IMAGE,
str(self.docker_context_dir),
])
# =========================
# Container lifecycle
# =========================
def create_container(self) -> None:
uid = os.getuid()
gid = os.getgid()
print(f"Creating container '{self.container_name}'")
self._run([
"docker", "create",
"--name", self.container_name,
"--user", f"{uid}:{gid}",
"--volume", f"{self.project_path}:{self.project_path}",
self.IMAGE,
"sh", "-c", "sleep infinity",
])
def start_container(self) -> bool:
subprocess.run(
["docker", "start", self.container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return self.wait_until_running()
def stop_container(self) -> None:
if self.container_running():
self._run(["docker", "stop", "-t", "0", self.container_name])
def remove_container(self) -> None:
subprocess.run(
["docker", "rm", "-f", self.container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# =========================
# Execution
# =========================
def exec_opencode(self, args: list[str]) -> None:
subprocess.run([
"docker", "exec",
"-it",
"-w", str(self.project_path),
self.container_name,
"opencode",
*args,
], check=True)
# =========================
# State checks
# =========================
def container_exists(self) -> bool:
return subprocess.run(
["docker", "inspect", self.container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
def container_running(self) -> bool:
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", self.container_name],
capture_output=True,
text=True,
)
return result.returncode == 0 and result.stdout.strip() == "true"
def wait_until_running(self) -> bool:
deadline = time.time() + self.START_TIMEOUT
while time.time() < deadline:
if self.container_running():
return True
time.sleep(0.1)
return False
# =========================
# Helpers
# =========================
@staticmethod
def _project_id(path: Path) -> str:
return hashlib.sha256(str(path).encode()).hexdigest()[:12]
@staticmethod
def _run(cmd: list[str]) -> None:
subprocess.run(cmd, check=True)
def main() -> None:
OpenCodeContainer().run(sys.argv[1:])
if __name__ == "__main__":
main()

View file

@ -1,17 +1,5 @@
OPENCODE_CONTAINER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" OPENCODE_CONTAINER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
opencode() { opencode() {
local uid=$(id -u) python3 "$OPENCODE_CONTAINER_DIR/opencode-container.py" "$@"
local gid=$(id -g)
local user=$(whoami)
UID="$uid" \
GID="$gid" \
USER="$user" \
CONTAINER_HOME="$OPENCODE_CONTAINER_DIR/container-home" \
docker compose \
-f "$OPENCODE_CONTAINER_DIR/docker-compose.yaml" \
run --rm \
-u "$uid:$gid" \
opencode opencode "$@" \
2> >(grep -v "No services to build" >&2)
} }