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:
parent
57ef6454c6
commit
fc2e5b1bca
4 changed files with 168 additions and 49 deletions
14
Dockerfile
14
Dockerfile
|
|
@ -10,9 +10,11 @@ RUN pacman -Syu --noconfirm \
|
|||
ca-certificates \
|
||||
bash \
|
||||
less \
|
||||
ripgrep && \
|
||||
ripgrep \
|
||||
sudo && \
|
||||
groupadd -g ${GID} ${USERNAME} && \
|
||||
useradd -m -u ${UID} -g ${GID} -s /bin/bash ${USERNAME} && \
|
||||
echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
WORKDIR /tmp
|
||||
|
|
@ -20,12 +22,8 @@ USER ${USERNAME}
|
|||
|
||||
RUN git clone https://aur.archlinux.org/opencode-bin.git && \
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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
161
opencode-container.py
Executable 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()
|
||||
|
|
@ -1,17 +1,5 @@
|
|||
OPENCODE_CONTAINER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
|
||||
opencode() {
|
||||
local uid=$(id -u)
|
||||
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)
|
||||
python3 "$OPENCODE_CONTAINER_DIR/opencode-container.py" "$@"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue