diff --git a/Dockerfile b/Dockerfile index a30e8f3..ef2329d 100644 --- a/Dockerfile +++ b/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} diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index df43ac1..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -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 diff --git a/opencode-container.py b/opencode-container.py new file mode 100755 index 0000000..6f95659 --- /dev/null +++ b/opencode-container.py @@ -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() diff --git a/opencode.aliases b/opencode.aliases index b774657..86eaf0e 100644 --- a/opencode.aliases +++ b/opencode.aliases @@ -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" "$@" }