diff --git a/.bashrc b/.bashrc deleted file mode 100644 index 25b758c..0000000 --- a/.bashrc +++ /dev/null @@ -1,14 +0,0 @@ -# SDKMAN for interactive use -source /opt/sdkman/bin/sdkman-init.sh - -# nvm for interactive use -source /opt/nvm/nvm.sh - -# AI CLI tool aliases -alias claude="claude --dangerously-skip-permissions" -alias codex="codex --no-safety" - -# General aliases -alias l="ls -ltrha" -alias ll="ls -lh" -alias la="ls -A" \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index 9395f3f..b276b6e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,9 @@ -# Exclude everything +# Only the build needs sources + Gradle metadata; ignore everything else. * -!entrypoint.sh -!.bashrc -!tmux.conf + +!gradlew +!settings.gradle.kts +!build.gradle.kts +!gradle.properties +!gradle +!src diff --git a/.env.example b/.env.example deleted file mode 100644 index 8639814..0000000 --- a/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -########################################################## -# -# run.sh would read these variables from folder where run.sh is located -# and from folder from which run.sh is called -# -# call path takes precedence over folder path, -# and explicit arguments take precedence over environment variables -# -########################################################## - -# Anthropic Claude API Key -KODKOD_ANTHROPIC_API_KEY= - -# OpenAI API Key -KODKOD_OPENAI_API_KEY= - -# Google Gemini API Key -KODKOD_GEMINI_API_KEY= -KODKOD_GOOGLE_API_KEY= - -# JDK version (17, 21, or 25) -KODKOD_JDK_VERSION=25 - -# Use local "kodkod:latest" image instead of ghcr.io -KODKOD_LOCAL=false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b049884..d55b1e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: "Build and Push Multi-JDK Docker Image" +name: "Build" on: push: @@ -11,28 +11,39 @@ env: REGISTRY: ghcr.io jobs: - build: + gradle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: liberica + java-version: "25" + - name: "Build" + run: ./gradlew --no-daemon build + + docker: + needs: gradle runs-on: ubuntu-latest permissions: contents: read packages: write - steps: - uses: actions/checkout@v6 - name: "Set lowercase image name" run: echo "IMAGE_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 - name: "Log in to GHCR" if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: "Docker meta" id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -40,22 +51,25 @@ jobs: type=semver,pattern={{version}} type=sha - name: "Build and push" - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . - platforms: linux/amd64,linux/arm64 + # Multi-arch only when actually pushing. The docker daemon can't `--load` a + # manifest list, so on PRs we build a single arch and load it for the smoke test. + platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} push: ${{ github.event_name != 'pull_request' }} + load: ${{ github.event_name == 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - load: ${{ github.event_name == 'pull_request' }} - - name: "Smoke tests" + - name: "Smoke test (no socket needed)" + if: github.event_name == 'pull_request' run: | set -euo pipefail image="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}" - for cmd in "java -version" "gradle --version" "node --version" "rg --version" "fd --version"; do - echo "==> $cmd" - docker run --rm "$image" bash -c "source /opt/sdkman/bin/sdkman-init.sh && $cmd" - done + # With both jobs disabled the daemon logs config, finds nothing to do, and exits 0. + out=$(docker run --rm -e KODKOD_AUTOHEAL_ENABLED=false -e KODKOD_UPDATE_ENABLED=false "$image") + echo "$out" + echo "$out" | grep -q "nothing to do" diff --git a/.gitignore b/.gitignore index e03db0a..2da0eaa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ .idea .DS_Store .env + +# Gradle / build output +.gradle +.kotlin +build diff --git a/CHANGELOG.md b/CHANGELOG.md index d97a1cb..0360bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,21 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -### Added -- OCI standard labels in Dockerfile for image metadata -- CI smoke tests verifying key tools (java, gradle, node, rg, fd) after build -- CHANGELOG.md to track project changes - -### Changed -- Tightened `/.kodkod` base directory permissions from 777 to 755; only cache subdirectories (m2, gradle, npm, pip, uv, config/*) retain 777 for write access - -## [0.1.0] - 2026-02-02 +## [0.2.0] - 2026-06-03 ### Added -- Initial Dockerfile based on Amazon Linux 2023 -- JDK 17, 21, 25 via SDKMAN with Gradle 9.3.1 and Kotlin 2.3.0 -- Node.js 24, ripgrep, fd, uv, ralphex -- AI CLI tools: claude-code, codex, gemini-cli -- `run.sh` launcher script and README -- GitHub Actions CI/CD workflow with multi-arch builds (amd64/arm64) -- Apache 2.0 license +- Kotlin 2.4.0 application (Gradle 9.5.1) talking to the Docker Engine API directly + over the unix socket, with a single runtime dependency (kotlinx-serialization-json). +- Autoheal loop: restarts `unhealthy` containers (labels `kodkod.autoheal.enable`, + `kodkod.stop.timeout`). +- Update loop: pulls a container's image tag and recreates it — preserving config, env, + labels and networks — when the image changes (label `kodkod.update.enable`). +- Lightweight multi-stage Dockerfile on BellSoft Liberica (JDK build → JRE Alpine runtime). diff --git a/Dockerfile b/Dockerfile index ef62018..8c6ef56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,149 +1,28 @@ -# AI Agent Development Environment -# Amazon Linux 2023 + JDK, Gradle, Kotlin via SDKMAN +# syntax=docker/dockerfile:1 -# --- Stage 1: SDKMAN + JDKs (isolated layer, rebuilds only when SDK versions change) --- -FROM amazonlinux:2023 AS sdkman +# ---- build stage: full JDK to run Gradle ---- +FROM bellsoft/liberica-openjdk-alpine:25 AS build +WORKDIR /app -ENV SDKMAN_DIR="/opt/sdkman" +# Resolve dependencies first so this layer is cached across source-only changes. +COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./ +COPY gradle ./gradle +RUN ./gradlew --no-daemon dependencies > /dev/null 2>&1 || true -RUN --mount=type=cache,id=dnf-sdkman,target=/var/cache/dnf \ - dnf update -y && \ - dnf install -y --allowerasing curl unzip zip tar gzip findutils which -RUN curl -s "https://get.sdkman.io" | bash -RUN bash -c "source $SDKMAN_DIR/bin/sdkman-init.sh && \ - sdk install java 21.0.10-librca && \ - sdk install java 17.0.18-librca && \ - sdk install java 25.0.2-librca && \ - sdk default java 25.0.2-librca && \ - sdk install gradle 9.3.1 && \ - sdk install kotlin 2.3.0" -RUN chmod -R 755 $SDKMAN_DIR -RUN rm -rf $SDKMAN_DIR/tmp +# Build the runnable distribution (build/install/kodkod). +COPY src ./src +RUN ./gradlew --no-daemon installDist -# --- Stage 2: CLI tools (fd, rg, ralphex) --- -FROM amazonlinux:2023 AS tools +# ---- runtime stage: lightweight Liberica JRE on Alpine ---- +FROM bellsoft/liberica-openjre-alpine:25 AS runtime -ENV RIPGREP_VERSION=15.1.0 \ - FD_VERSION=10.3.0 \ - RALPHEX_VERSION=0.6.0 - -RUN --mount=type=cache,id=dnf-tools,target=/var/cache/dnf \ - dnf update -y && \ - dnf install -y --allowerasing curl tar gzip - -# Install ripgrep (multi-arch) -RUN ARCH=$(uname -m) && \ - if [ "$ARCH" = "aarch64" ]; then RG_ARCH="aarch64-unknown-linux-gnu"; else RG_ARCH="x86_64-unknown-linux-musl"; fi && \ - curl -L https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/ripgrep-${RIPGREP_VERSION}-${RG_ARCH}.tar.gz -o /tmp/ripgrep.tar.gz && \ - tar -xzf /tmp/ripgrep.tar.gz -C /tmp && \ - cp /tmp/ripgrep-${RIPGREP_VERSION}-${RG_ARCH}/rg /usr/local/bin/ && \ - chmod +x /usr/local/bin/rg - -# Install fd (multi-arch) -RUN ARCH=$(uname -m) && \ - if [ "$ARCH" = "aarch64" ]; then FD_ARCH="aarch64-unknown-linux-gnu"; else FD_ARCH="x86_64-unknown-linux-musl"; fi && \ - curl -L https://github.com/sharkdp/fd/releases/download/v${FD_VERSION}/fd-v${FD_VERSION}-${FD_ARCH}.tar.gz -o /tmp/fd.tar.gz && \ - tar -xzf /tmp/fd.tar.gz -C /tmp && \ - cp /tmp/fd-v${FD_VERSION}-${FD_ARCH}/fd /usr/local/bin/ && \ - chmod +x /usr/local/bin/fd - -# Install ralphex (multi-arch) -RUN ARCH=$(uname -m) && \ - if [ "$ARCH" = "aarch64" ]; then RX_ARCH="arm64"; else RX_ARCH="amd64"; fi && \ - curl -L https://github.com/umputun/ralphex/releases/download/v${RALPHEX_VERSION}/ralphex_${RALPHEX_VERSION}_linux_${RX_ARCH}.tar.gz -o /tmp/ralphex.tar.gz && \ - tar -xzf /tmp/ralphex.tar.gz -C /tmp && \ - mv /tmp/ralphex /usr/local/bin/ralphex && \ - chmod +x /usr/local/bin/ralphex - -# --- Stage 3: Node.js via nvm --- -FROM amazonlinux:2023 AS nodejs - -ENV NVM_DIR="/opt/nvm" \ - NODE_VERSION=24 - -RUN --mount=type=cache,id=dnf-nvm,target=/var/cache/dnf \ - dnf update -y && \ - dnf install -y --allowerasing curl tar gzip && \ - mkdir -p $NVM_DIR && \ - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash && \ - bash -c "source $NVM_DIR/nvm.sh && NVM_SYMLINK_CURRENT=true nvm install $NODE_VERSION" - -# --- Stage 4: Final image --- -FROM amazonlinux:2023 - -LABEL org.opencontainers.image.source="https://github.com/umputun/kodkod" \ - org.opencontainers.image.description="AI Agent Development Environment — Amazon Linux 2023 with JDK, Gradle, Kotlin, Node.js, and AI CLI tools" \ +LABEL org.opencontainers.image.title="kodkod" \ + org.opencontainers.image.description="docker-compose companion that restarts unhealthy containers and auto-updates images" \ + org.opencontainers.image.source="https://github.com/Heapy/kodkod" \ org.opencontainers.image.licenses="Apache-2.0" -ENV SDKMAN_DIR="/opt/sdkman" \ - NVM_DIR="/opt/nvm" - -# Install system dependencies -RUN --mount=type=cache,id=dnf-final,target=/var/cache/dnf \ - dnf update -y && \ - dnf install -y --allowerasing \ - git \ - curl \ - wget \ - tar \ - gzip \ - unzip \ - which \ - sudo \ - jq \ - python3 \ - python3-pip \ - gcc \ - make \ - tmux \ - zip \ - findutils \ - vim-minimal \ - procps-ng \ - less - -# Copy pre-built SDKMAN with all SDKs from builder stage -COPY --from=sdkman /opt/sdkman /opt/sdkman - -ENV JAVA_HOME="$SDKMAN_DIR/candidates/java/current" \ - GRADLE_HOME="$SDKMAN_DIR/candidates/gradle/current" \ - KOTLIN_HOME="$SDKMAN_DIR/candidates/kotlin/current" \ - PATH="$NVM_DIR/current/bin:$SDKMAN_DIR/candidates/java/current/bin:$SDKMAN_DIR/candidates/gradle/current/bin:$SDKMAN_DIR/candidates/kotlin/current/bin:$PATH" - -# Copy pre-built CLI tools (rg, fd, ralphex) from tools stage -COPY --from=tools /usr/local/bin/rg /usr/local/bin/fd /usr/local/bin/ralphex /usr/local/bin/ - -# Copy pre-built Node.js via nvm from builder stage -COPY --from=nodejs /opt/nvm /opt/nvm - -# Install uv (Python package manager) -COPY --from=ghcr.io/astral-sh/uv:0.9.28 /uv /uvx /usr/local/bin/ - -# Install AI CLI tools globally -RUN --mount=type=cache,target=/root/.npm \ - npm install -g @anthropic-ai/claude-code || true && \ - npm install -g @openai/codex || true && \ - npm install -g @google/gemini-cli || true - -# Create cache directories that will be used at runtime -# Create cache directories under /.kodkod (mounted from ~/.kodkod on host) -RUN mkdir -p /.kodkod/m2 /.kodkod/gradle /.kodkod/npm /.kodkod/pip /.kodkod/uv \ - /.kodkod/config/claude /.kodkod/config/codex /.kodkod/config/gemini-cli && \ - chmod -R 755 /.kodkod && \ - chmod -R 777 /.kodkod/m2 /.kodkod/gradle /.kodkod/npm /.kodkod/pip /.kodkod/uv \ - /.kodkod/config/claude /.kodkod/config/codex /.kodkod/config/gemini-cli - -COPY tmux.conf /etc/tmux.conf - -# Pre-create home directory for non-root users and allow passwd/group updates at runtime -RUN mkdir -p /home/kodkod && chmod 777 /home/kodkod && \ - chmod 666 /etc/passwd /etc/group - -COPY .bashrc /home/kodkod/.bashrc -COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - -WORKDIR /workspace +WORKDIR /app +COPY --from=build /app/build/install/kodkod/ ./ -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -CMD ["/bin/bash"] +# Talks to the Docker Engine API over the mounted unix socket; no extra tooling needed. +ENTRYPOINT ["/app/bin/kodkod"] diff --git a/README.md b/README.md index f092ff0..c7e66f0 100644 --- a/README.md +++ b/README.md @@ -4,363 +4,109 @@ # Kodkod -Containerize AI agents using Docker to run full-accept mode without compromising security. +A tiny **docker-compose companion** that does two things and nothing else: -## Features +1. **Restarts unhealthy containers** — like [`docker-autoheal`](https://github.com/tmknight/docker-autoheal), it watches container health and restarts containers that go `unhealthy`. +2. **Auto-updates containers** — like Watchtower, it pulls the image tag a container runs and recreates the container (preserving its config, env, labels and networks) when a newer image is published. -A complete AI agent development environment with: +Both jobs talk **directly to the Docker Engine API over the unix socket** — no `docker` CLI, no compose CLI, no extra tooling in the image. It is written in Kotlin with a single runtime dependency (a JSON library) and ships as a small Liberica JRE Alpine image. -- **Multi-JDK Support**: JDK 17, 21, and 25 -- **Build Tools**: Gradle 9.3.1, Kotlin 2.3.0 -- **Languages**: Python 3, Node.js 24 -- **System Tools**: jq, curl, ripgrep, git, fd, uv -- **AI CLI Tools**: claude-code, codex, gemini-cli, ralphex -- **Base Image**: Amazon Corretto (AWS-supported OpenJDK) -- **Runtime User Mapping**: Run as host user for proper file permissions +Everything is **opt-in per container via labels**, so kodkod only ever touches the containers you mark. -## Quick Start +## Quick start -### Build the Docker Image +Add kodkod to your `docker-compose.yml` and label the services you want it to manage: -Build a single image that contains all JDK versions (17, 21, and 25): +```yaml +services: + app: + image: ghcr.io/you/app:latest + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 10s + retries: 3 + labels: + kodkod.autoheal.enable: "true" # restart this container when it goes unhealthy + kodkod.update.enable: "true" # recreate it when ghcr.io/you/app:latest changes -```bash -docker build -t kodkod:latest . + kodkod: + image: ghcr.io/heapy/kodkod:latest + restart: always + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # optional: match host time in logs + - /etc/localtime:/etc/localtime:ro ``` -The image includes all JDK versions managed by SDKMAN. You select which version to use at runtime with the `--jdk` flag. +> **Autoheal requires a `HEALTHCHECK`** on the target image/service — kodkod can only restart what Docker reports as `unhealthy`. See the [Docker docs](https://docs.docker.com/reference/dockerfile/#healthcheck). -### Setup the Kodkod Alias +## Labels -Add the `kodkod` alias to your shell for easy access: +Labels live under a namespace (default `kodkod`, configurable via `KODKOD_LABEL_NAMESPACE`). -```bash -# Automatic setup (recommended) -./setup-alias.sh +| Label | Default | Description | +|--------------------------|---------|---------------------------------------------------------------------------------------------| +| `kodkod.autoheal.enable` | `false` | Restart this container when it becomes `unhealthy`. | +| `kodkod.update.enable` | `false` | Pull its image tag and recreate the container when the image changes. | +| `kodkod.stop.timeout` | — | Per-container stop timeout (seconds) for restart/recreate. Overrides `KODKOD_STOP_TIMEOUT`. | -# Or add manually to ~/.bashrc or ~/.zshrc: -alias kodkod='/path/to/kodkod/run.sh' +When `*_MONITOR_ALL` is enabled (see below) the relevant feature applies to **all** containers and the label flips to an opt-**out** (`...enable=false` to exclude). -# Then reload your shell -source ~/.bashrc # or ~/.zshrc -``` +## Configuration -### Using Kodkod +All configuration is via environment variables: -```bash -# Create or attach to container (default JDK 25) -kodkod +| Variable | Default | Description | +|--------------------------------|--------------------------|------------------------------------------------------------------------| +| `KODKOD_DOCKER_SOCKET` | `/var/run/docker.sock` | Path to the Docker Engine unix socket. | +| `KODKOD_LABEL_NAMESPACE` | `kodkod` | Prefix for all labels (e.g. `kodkod.autoheal.enable`). | +| `KODKOD_STOP_TIMEOUT` | `10` | Default stop timeout (seconds) for restart/recreate. | +| `KODKOD_AUTOHEAL_ENABLED` | `true` | Enable the autoheal loop. | +| `KODKOD_AUTOHEAL_INTERVAL` | `30` | Seconds between unhealthy-container checks. | +| `KODKOD_AUTOHEAL_START_PERIOD` | `0` | Seconds to wait before the first autoheal check. | +| `KODKOD_AUTOHEAL_MONITOR_ALL` | `false` | Heal **all** containers with a healthcheck (label becomes opt-out). | +| `KODKOD_UPDATE_ENABLED` | `true` | Enable the auto-update loop. | +| `KODKOD_UPDATE_INTERVAL` | `3600` | Seconds between image-update checks. | +| `KODKOD_UPDATE_START_PERIOD` | `0` | Seconds to wait before the first update check. | +| `KODKOD_UPDATE_MONITOR_ALL` | `false` | Update **all** running containers (label becomes opt-out). | +| `KODKOD_UPDATE_CLEANUP` | `true` | Remove the previous image after a successful update (best-effort). | +| `KODKOD_REGISTRY_AUTH` | — | Base64 `X-Registry-Auth` value for pulling from private registries. | -# Use specific JDK version -kodkod --jdk=21 -kodkod --jdk=17 +## How updates work -# Recreate container from scratch -kodkod --recreate +For each container marked for updates, kodkod: -# Recreate with different JDK version -kodkod --recreate --jdk=21 +1. reads its image reference (e.g. `nginx:1.27`) — containers pinned to a digest (`image@sha256:...`) are skipped; +2. pulls that repo/tag from the registry; +3. compares the freshly-pulled image id with the container's current image id; +4. if they differ, **recreates** the container from its existing configuration against the new image: + stop → rename old → create new (same `Config` + `HostConfig` + networks) → start → remove old. -# Use locally built image instead of ghcr.io -kodkod --local -``` +If create or start fails, kodkod rolls back to the original container. The kodkod container never updates or restarts **itself**. -### Defaults via .env +## Build from source -You can create a `.env` file in the same directory as `run.sh` to set default values: +Requires JDK 25+ (the Gradle 9.5.1 wrapper and Kotlin 2.4.0 are pinned): ```bash -# .env -KODKOD_JDK_VERSION=21 # Default JDK version (17, 21, or 25). Default: 25 -KODKOD_LOCAL=true # Use local "kodkod:latest" image instead of ghcr.io. Default: false -``` - -Command-line flags always override `.env` defaults. For example, `kodkod --jdk=17` will use JDK 17 even if `KODKOD_JDK_VERSION=21` is set in `.env`. - -## Persistent Container Management - -Kodkod creates one persistent container per project directory. This allows you to maintain long-running development sessions without losing state. - -### How It Works - -- **One container per project**: Each project directory gets its own container -- **Container naming**: `kodkod--` where: - - `dirname` = sanitized basename of current directory - - `hash` = first 5 characters of SHA256 hash of full path -- **State preservation**: Containers persist across sessions -- **Fast startup**: Reusing containers is much faster than creating new ones - -Example: Working in `/Users/tim/dev/kodkod` creates container named `kodkod-kodkod-a1b2c` - -### Container Behavior +./gradlew installDist # build/install/kodkod/bin/kodkod +./gradlew build # compile + checks -**First run in a directory:** -- Creates a new container with the specified JDK version -- Starts and attaches to it - -**Subsequent runs:** -- If container is stopped: Starts it and attaches -- If container is running: Attaches to it (exec into it) -- If container doesn't exist: Creates a new one - -**With --recreate flag:** -- Stops and removes existing container -- Creates a fresh container - -### Multiple Sessions with tmux - -Each container has tmux installed for running multiple Claude sessions simultaneously: - -```bash -# Start kodkod container -kodkod - -# Inside container, start tmux -tmux - -# tmux Quick Reference (prefix key: Ctrl-a): -# Ctrl-a c - Create new window -# Ctrl-a n - Next window -# Ctrl-a p - Previous window -# Ctrl-a 0-9 - Switch to window number -# Ctrl-a % - Split pane vertically -# Ctrl-a " - Split pane horizontally -# Ctrl-a arrow - Switch between panes -# Ctrl-a d - Detach from session (container keeps running) -# tmux attach - Reattach to session - -# Example: Run different Claude commands in different windows/panes -# Window 1: claude code . -# Window 2: claude chat -# Window 3: gradle build --watch +docker build -t kodkod . # build the Liberica JRE image ``` -**Why tmux over multiple containers?** -- Lighter weight (one container vs multiple) -- Shared filesystem state - all sessions see changes instantly -- Easy session management with keyboard shortcuts -- Container state persists even when detached - -### Managing Containers - -```bash -# List all kodkod containers -docker ps -a --filter name=kodkod- +## Security notes -# Stop a container -docker stop kodkod-myproject-a1b2c +- kodkod needs read/write access to the Docker socket to inspect, restart and recreate containers — this is effectively root on the host, so run it only where you trust the workloads. +- Mount the socket and run nothing else in the container; the image contains only a JRE and kodkod. +- Keep `KODKOD_*_MONITOR_ALL=false` (the default) and use per-container labels to keep the blast radius small. -# Remove a container -docker rm kodkod-myproject-a1b2c +## Credits -# Remove all stopped kodkod containers -docker container prune --filter label=kodkod - -# View container labels -docker inspect kodkod-myproject-a1b2c --format '{{.Config.Labels}}' -``` - -## JDK Version Management with SDKMAN - -Kodkod uses a single Docker image containing all JDK versions (17, 21, and 25) managed by SDKMAN. When you specify a JDK version with the `--jdk` flag, the container is configured to use that specific version by setting the `JAVA_HOME` environment variable. - -### How It Works - -- **Single Image**: One image contains all three JDK versions installed in `/opt/sdkman/candidates/java/` -- **Runtime Selection**: The `--jdk` flag sets `JAVA_HOME` to point to the requested version -- **No Rebuild Needed**: Switch between JDK versions without rebuilding the image -- **Container Labels**: Each container is labeled with its JDK version for easy identification - -### JDK Versions Included - -- **JDK 17**: 17.0.18-librca -- **JDK 21**: 21.0.10-librca -- **JDK 25**: 25.0.2-librca (default) - -### Switching JDK Versions - -To use a different JDK version for an existing project: - -```bash -# Recreate the container with a different JDK version -kodkod --recreate --jdk=21 -``` - -The JDK version is set at container creation time and persists for that container. To change versions, recreate the container with the `--recreate` flag. - -## Volume Mounts and Caching - -The container supports mounting cache directories for faster dependency resolution: - -All caches and configs are stored under a single `~/.kodkod` directory on the host, mounted to `/.kodkod` in the container: - -- `~/.kodkod/m2` - Maven dependency cache -- `~/.kodkod/gradle` - Gradle dependency cache -- `~/.kodkod/npm` - Node.js package cache -- `~/.kodkod/pip` - Python pip cache -- `~/.kodkod/uv` - uv package cache -- `~/.kodkod/config/claude` - Claude Code config -- `~/.kodkod/config/codex` - Codex config -- `~/.kodkod/config/gemini-cli` - Gemini CLI config - -### Project Source -- `.` → `/workspace` - Working directory for your code - -## API Keys Configuration - -### Option 1: Export Environment Variables - -```bash -export KODKOD_ANTHROPIC_API_KEY="sk-ant-xxxxx" -export KODKOD_OPENAI_API_KEY="sk-xxxxx" -export KODKOD_GEMINI_API_KEY="xxxxx" -./run.sh -``` - -The `KODKOD_` prefixed variables take priority. Non-prefixed variables (e.g. `ANTHROPIC_API_KEY`) are also supported as a fallback. - -### Option 2: Pass Directly to Docker - -```bash -docker run --rm -it \ - -e ANTHROPIC_API_KEY="sk-ant-xxxxx" \ - -e OPENAI_API_KEY="sk-xxxxx" \ - -e GEMINI_API_KEY="xxxxx" \ - kodkod:latest -``` - -### Option 3: Use .env file - -```bash -cp .env.example .env -# Edit .env and add your API keys -source .env -./run.sh -``` - -## AI CLI Tools - -The following AI CLI tools are pre-installed: - -- **claude-code**: Anthropic's Claude Code CLI tool -- **codex**: OpenAI's Codex CLI tool -- **gemini-cli**: Google's Gemini CLI tool -- **ralphex**: AI agent CLI tool - -### Pre-configured Aliases - -The container includes bash aliases for enhanced AI agent development: - -```bash -# Claude runs with sandbox disabled by default -alias claude="claude --dangerously-skip-permissions" - -# Codex runs with safety checks disabled -alias codex="codex --no-safety" -``` - -This allows AI agents to run in full-accept mode without compromising your host system security (since they're containerized). - -### Usage inside container - -```bash -# Use the aliases (recommended in container) -claude --help -codex --help -gemini-cli --help -ralphex --help - -# Or call directly without aliases -/usr/local/bin/claude --help -``` - -## Runtime User Mapping - -The image is built generically without hardcoded user IDs. The `kodkod` script automatically runs containers as your host user: - -```bash -kodkod # Automatically uses --user=$(id -u):$(id -g) -``` - -This ensures: -- Files created in the container have correct ownership on the host -- No permission issues with mounted volumes -- Same image works for any user -- Perfect for publishing to container registries - -## Advanced Usage - -### Using docker run directly (not recommended) - -If you prefer not to use the persistent container system: - -```bash -docker run --rm -it \ - --user=$(id -u):$(id -g) \ - -v $(pwd):/workspace \ - -v ~/.kodkod:/.kodkod \ - -e GRADLE_USER_HOME="/.kodkod/gradle" \ - -e MAVEN_HOME="/.kodkod/m2" \ - -e NPM_CONFIG_CACHE="/.kodkod/npm" \ - -e PIP_CACHE_DIR="/.kodkod/pip" \ - -e UV_CACHE_DIR="/.kodkod/uv" \ - -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ - -e OPENAI_API_KEY=$OPENAI_API_KEY \ - -e GEMINI_API_KEY=$GEMINI_API_KEY \ - kodkod:latest -``` - -Note: This creates ephemeral containers that are removed after exit. - -## Installed Versions - -- **Gradle**: 9.3.1 -- **Kotlin**: 2.3.0 -- **Node.js**: 24.x -- **Python**: 3.x (via Amazon Linux 2) -- **uv**: 0.9.28 -- **ripgrep**: 15.1.0 -- **fd**: 10.3.0 -- **ralphex**: 0.6.0 -- **tmux**: Latest (via yum) - -## Verification - -### Verify all tools are installed - -```bash -docker run --rm ghcr.io/heapy/kodkod:latest bash -c " - echo '=== JDK ===' && - java -version && - echo '=== Build Tools ===' && - gradle --version && - kotlin -version && - echo '=== Languages ===' && - python3 --version && - node --version && - echo '=== System Tools ===' && - jq --version && - curl --version && - rg --version && - git --version && - fd --version && - echo '=== AI Tools ===' && - ralphex --version -" -``` - -### Test user permissions - -```bash -docker run --rm --user=$(id -u):$(id -g) -v $(pwd):/workspace kodkod:latest bash -c " - id && - touch /workspace/test.txt && - ls -l /workspace/test.txt -" -# Verify test.txt is owned by your host user -ls -l test.txt -``` +The autoheal behaviour mirrors the excellent +[`docker-autoheal`](https://github.com/tmknight/docker-autoheal) (Rust) and +[`willfarrell/docker-autoheal`](https://github.com/willfarrell/docker-autoheal) (shell) projects. ## License -See LICENSE file for details. \ No newline at end of file +Apache-2.0 — see [LICENSE](LICENSE). diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7d81aba --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,34 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm").version("2.4.0") + kotlin("plugin.serialization").version("2.4.0") + application +} + +group = "io.heapy" +version = "1.0.0" + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_25 + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +application { + applicationName = "kodkod" + mainClass = "io.heapy.kodkod.MainKt" +} diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index da216f0..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -# Create /etc/passwd and /etc/group entries for the container user if missing. -# This avoids "id: cannot find name for user ID ..." warnings when running -# with --user=UID:GID that doesn't exist inside the image. - -CUR_UID=$(id -u) -CUR_GID=$(id -g) - -if ! getent passwd "$CUR_UID" >/dev/null 2>&1; then - if ! echo "kodkod:x:${CUR_UID}:${CUR_GID}::/home/kodkod:/bin/bash" >> /etc/passwd; then - echo "entrypoint: warning: failed to add passwd entry for UID ${CUR_UID}" >&2 - fi -fi - -if ! getent group "$CUR_GID" >/dev/null 2>&1; then - if ! echo "kodkod:x:${CUR_GID}:" >> /etc/group; then - echo "entrypoint: warning: failed to add group entry for GID ${CUR_GID}" >&2 - fi -fi - -export HOME=/home/kodkod -if [ ! -d "$HOME" ]; then - if ! mkdir -p "$HOME"; then - echo "entrypoint: warning: failed to create home directory ${HOME}" >&2 - fi -fi - -exec "$@" diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..6e1e412 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.caching=true +org.gradle.parallel=true +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bd82f36 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/run.sh b/run.sh deleted file mode 100755 index 410d0cc..0000000 --- a/run.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -set -e - -# === Load .env defaults === -# Script directory .env first, then call directory .env (call path takes precedence) -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [ -f "$SCRIPT_DIR/.env" ]; then - # shellcheck disable=SC1091 - source "$SCRIPT_DIR/.env" -fi -if [ -f "$PWD/.env" ] && [ "$PWD/.env" != "$SCRIPT_DIR/.env" ]; then - # shellcheck disable=SC1091 - source "$PWD/.env" -fi - -# === Configuration === -DEFAULT_JDK_VERSION="${KODKOD_JDK_VERSION:-25}" -JDK_VERSION=$DEFAULT_JDK_VERSION -RECREATE_FLAG=false -LOCAL_FLAG="${KODKOD_LOCAL:-false}" - -# === Parse Arguments === -while [[ $# -gt 0 ]]; do - case $1 in - --jdk=*) - JDK_VERSION="${1#*=}" - shift - ;; - --local) - LOCAL_FLAG=true - shift - ;; - --recreate) - RECREATE_FLAG=true - shift - ;; - --help) - echo "Usage: kodkod [--jdk=17|21|25] [--recreate] [--local]" - echo "" - echo "Options:" - echo " --jdk=VERSION Use specific JDK version (17, 21, or 25). Default: 25" - echo " --recreate Stop and remove existing container, then create new one" - echo " --local Use local 'kodkod:latest' image instead of ghcr.io" - echo " --help Show this help message" - echo "" - echo "Examples:" - echo " kodkod # Create or reuse container with JDK 25" - echo " kodkod --jdk=21 # Create or reuse container with JDK 21" - echo " kodkod --recreate # Recreate container with default JDK" - echo " kodkod --recreate --jdk=17 # Recreate container with JDK 17" - echo " kodkod --local # Use locally built image" - exit 0 - ;; - *) - echo "Unknown option: $1" - echo "Usage: kodkod [--jdk=17|21|25] [--recreate] [--local]" - echo "Run 'kodkod --help' for more information" - exit 1 - ;; - esac -done - -# === Validate JDK Version === -if [[ ! "$JDK_VERSION" =~ ^(17|21|25)$ ]]; then - echo "Error: Invalid JDK version '$JDK_VERSION'" - echo "Supported versions: 17, 21, 25" - exit 1 -fi - -# === Map JDK Version to Full Version String === -case $JDK_VERSION in - 17) JAVA_FULL_VERSION="17.0.18-librca" ;; - 21) JAVA_FULL_VERSION="21.0.10-librca" ;; - 25) JAVA_FULL_VERSION="25.0.2-librca" ;; -esac - -# === Generate Container Name === -DIR_NAME=$(basename "$PWD" | tr -cd '[:alnum:]-_' | tr '[:upper:]' '[:lower:]') -DIR_PATH=$(pwd) -PATH_HASH=$(echo -n "$DIR_PATH" | shasum -a 256 | cut -c1-5) -CONTAINER_NAME="kodkod-${DIR_NAME}-${PATH_HASH}" -if [ "$LOCAL_FLAG" = true ]; then - IMAGE_TAG="kodkod:latest" -else - IMAGE_TAG="ghcr.io/heapy/kodkod:latest" -fi - -# === Detect Host User === -HOST_UID=$(id -u) -HOST_GID=$(id -g) - -# === Check if Image Exists === -if ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then - echo "Error: Docker image '$IMAGE_TAG' not found" - echo "" - echo "Please build the image first:" - echo " docker build -t $IMAGE_TAG ." - echo "" - echo "The image contains all JDK versions (17, 21, 25)." - echo "Use --jdk flag to select which version to use at runtime." - exit 1 -fi - -# === Recreate Container if Requested === -if [ "$RECREATE_FLAG" = true ]; then - echo "Recreating container: $CONTAINER_NAME" - docker stop "$CONTAINER_NAME" 2>/dev/null || true - docker rm "$CONTAINER_NAME" 2>/dev/null || true -fi - -# === Check if Container Exists === -EXISTING_CONTAINER=$(docker ps -a --filter "name=^${CONTAINER_NAME}$" --format '{{.Names}}') - -if [ -n "$EXISTING_CONTAINER" ]; then - # Container exists, check if running - RUNNING_CONTAINER=$(docker ps --filter "name=^${CONTAINER_NAME}$" --format '{{.Names}}') - - if [ -n "$RUNNING_CONTAINER" ]; then - # Container is running, exec into it - echo "Attaching to running container: $CONTAINER_NAME" - docker exec -it "$CONTAINER_NAME" bash - else - # Container exists but stopped, start and attach - echo "Starting stopped container: $CONTAINER_NAME" - docker start -ai "$CONTAINER_NAME" - fi -else - # Container doesn't exist, create it - echo "Creating new container: $CONTAINER_NAME (JDK $JDK_VERSION)" - - # Ensure ~/.kodkod cache directory exists - mkdir -p "${HOME}/.kodkod" - - docker create -it \ - --name "$CONTAINER_NAME" \ - --user="${HOST_UID}:${HOST_GID}" \ - --label kodkod=true \ - --label kodkod.project.path="$PWD" \ - --label kodkod.jdk.version="$JDK_VERSION" \ - -v "${PWD}:/workspace" \ - -v "${HOME}/.kodkod:/.kodkod" \ - -e ANTHROPIC_API_KEY="${KODKOD_ANTHROPIC_API_KEY:-${ANTHROPIC_API_KEY:-}}" \ - -e OPENAI_API_KEY="${KODKOD_OPENAI_API_KEY:-${OPENAI_API_KEY:-}}" \ - -e GEMINI_API_KEY="${KODKOD_GEMINI_API_KEY:-${GEMINI_API_KEY:-}}" \ - -e GOOGLE_API_KEY="${KODKOD_GOOGLE_API_KEY:-${GOOGLE_API_KEY:-}}" \ - -e JAVA_HOME="/opt/sdkman/candidates/java/${JAVA_FULL_VERSION}" \ - -e JAVA_TOOL_OPTIONS="-Duser.home=/home/kodkod" \ - -e GRADLE_USER_HOME="/.kodkod/gradle" \ - -e MAVEN_HOME="/.kodkod/m2" \ - -e NPM_CONFIG_CACHE="/.kodkod/npm" \ - -e PIP_CACHE_DIR="/.kodkod/pip" \ - -e UV_CACHE_DIR="/.kodkod/uv" \ - -e CLAUDE_CONFIG_DIR="/.kodkod/config/claude" \ - -e PATH="/opt/sdkman/candidates/java/${JAVA_FULL_VERSION}/bin:/opt/sdkman/candidates/gradle/current/bin:/opt/sdkman/candidates/kotlin/current/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ - -w /workspace \ - "$IMAGE_TAG" \ - bash - - # Start and attach to the new container - docker start -ai "$CONTAINER_NAME" -fi diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..c539f22 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "kodkod" diff --git a/setup-alias.sh b/setup-alias.sh deleted file mode 100755 index 5f76afd..0000000 --- a/setup-alias.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -KODKOD_SCRIPT="${SCRIPT_DIR}/run.sh" - -# Detect user's login shell -case "$(basename "$SHELL")" in - zsh) SHELL_RC="$HOME/.zshrc" ;; - bash) SHELL_RC="$HOME/.bashrc" ;; - *) - echo "Unknown shell: $SHELL. Please add this alias manually:" - echo "alias kodkod='${KODKOD_SCRIPT}'" - exit 1 - ;; -esac - -# Check if alias already exists -if grep -q "alias kodkod=" "$SHELL_RC" 2>/dev/null; then - echo "Alias 'kodkod' already exists in $SHELL_RC" - echo "Current definition:" - grep "alias kodkod=" "$SHELL_RC" - read -p "Do you want to update it? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Keeping existing alias" - exit 0 - fi - # Remove old alias - sed -i.bak '/alias kodkod=/d' "$SHELL_RC" -fi - -# Add alias -echo "alias kodkod='${KODKOD_SCRIPT}'" >> "$SHELL_RC" - -echo "✓ Alias 'kodkod' added to $SHELL_RC" -echo "Run: source $SHELL_RC" -echo "Or restart your terminal" diff --git a/src/main/kotlin/io/heapy/kodkod/Autoheal.kt b/src/main/kotlin/io/heapy/kodkod/Autoheal.kt new file mode 100644 index 0000000..6c0f849 --- /dev/null +++ b/src/main/kotlin/io/heapy/kodkod/Autoheal.kt @@ -0,0 +1,52 @@ +package io.heapy.kodkod + +import kotlinx.serialization.json.jsonObject + +/** + * Restarts unhealthy containers — the same job as the reference `docker-autoheal` tools, driven + * by labels under the configured namespace: + * + * - `.autoheal.enable=true|false` — opt in/out (default follows [Config.autohealMonitorAll]) + * - `.stop.timeout=` — per-container stop timeout override + */ +class Autoheal( + private val api: DockerApi, + private val config: Config, + private val selfId: String?, +) { + private val ns = config.labelNamespace + + fun runOnce() { + val filters = linkedMapOf("health" to listOf("unhealthy")) + // When not monitoring everything, let Docker pre-filter to labelled containers. + if (!config.autohealMonitorAll) filters["label"] = listOf("$ns.autoheal.enable") + + val containers = api.listContainers(all = false, filters = filters) + for (element in containers) { + val container = element.jsonObject + val id = container.str("Id") ?: continue + if (isSelf(id)) continue + + val short = id.take(12) + val name = container.arr("Names").firstString()?.trimStart('/') ?: short + val labels = container.obj("Labels") + if (!labelTruthy(labels, "$ns.autoheal.enable", config.autohealMonitorAll)) continue + + if (container.str("State") == "restarting") { + Log.info("[$name ($short)] already restarting — skipping") + continue + } + + val timeout = labels.label("$ns.stop.timeout")?.toIntOrNull() ?: config.defaultStopTimeout + Log.warn("[$name ($short)] unhealthy — restarting with ${timeout}s timeout") + try { + api.restart(id, timeout) + Log.info("[$name ($short)] restart successful") + } catch (e: Exception) { + Log.error("[$name ($short)] restart failed: ${e.message}") + } + } + } + + private fun isSelf(id: String) = selfId != null && id.startsWith(selfId) +} diff --git a/src/main/kotlin/io/heapy/kodkod/Config.kt b/src/main/kotlin/io/heapy/kodkod/Config.kt new file mode 100644 index 0000000..a7cb0b1 --- /dev/null +++ b/src/main/kotlin/io/heapy/kodkod/Config.kt @@ -0,0 +1,49 @@ +package io.heapy.kodkod + +/** + * Runtime configuration, sourced entirely from environment variables so the daemon + * can be configured the usual docker-compose way (`environment:` / `.env`). + */ +data class Config( + val dockerSocket: String, + val labelNamespace: String, + val defaultStopTimeout: Int, + // Autoheal — restart unhealthy containers + val autohealEnabled: Boolean, + val autohealInterval: Long, + val autohealStartPeriod: Long, + val autohealMonitorAll: Boolean, + // Update — pull newer images and recreate containers + val updateEnabled: Boolean, + val updateInterval: Long, + val updateStartPeriod: Long, + val updateMonitorAll: Boolean, + val updateCleanup: Boolean, + val registryAuth: String?, +) { + companion object { + fun fromEnv(get: (String) -> String? = System::getenv): Config { + fun str(key: String, default: String) = get(key)?.takeIf { it.isNotBlank() } ?: default + fun long(key: String, default: Long) = get(key)?.trim()?.toLongOrNull() ?: default + fun int(key: String, default: Int) = get(key)?.trim()?.toIntOrNull() ?: default + fun bool(key: String, default: Boolean) = + get(key)?.trim()?.lowercase()?.let { it in TRUTHY } ?: default + + return Config( + dockerSocket = str("KODKOD_DOCKER_SOCKET", "/var/run/docker.sock"), + labelNamespace = str("KODKOD_LABEL_NAMESPACE", "kodkod"), + defaultStopTimeout = int("KODKOD_STOP_TIMEOUT", 10), + autohealEnabled = bool("KODKOD_AUTOHEAL_ENABLED", true), + autohealInterval = long("KODKOD_AUTOHEAL_INTERVAL", 30), + autohealStartPeriod = long("KODKOD_AUTOHEAL_START_PERIOD", 0), + autohealMonitorAll = bool("KODKOD_AUTOHEAL_MONITOR_ALL", false), + updateEnabled = bool("KODKOD_UPDATE_ENABLED", true), + updateInterval = long("KODKOD_UPDATE_INTERVAL", 3600), + updateStartPeriod = long("KODKOD_UPDATE_START_PERIOD", 0), + updateMonitorAll = bool("KODKOD_UPDATE_MONITOR_ALL", false), + updateCleanup = bool("KODKOD_UPDATE_CLEANUP", true), + registryAuth = get("KODKOD_REGISTRY_AUTH")?.takeIf { it.isNotBlank() }, + ) + } + } +} diff --git a/src/main/kotlin/io/heapy/kodkod/DockerApi.kt b/src/main/kotlin/io/heapy/kodkod/DockerApi.kt new file mode 100644 index 0000000..bdd7def --- /dev/null +++ b/src/main/kotlin/io/heapy/kodkod/DockerApi.kt @@ -0,0 +1,233 @@ +package io.heapy.kodkod + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.putJsonArray +import java.io.ByteArrayOutputStream +import java.net.StandardProtocolFamily +import java.net.URLEncoder +import java.net.UnixDomainSocketAddress +import java.nio.ByteBuffer +import java.nio.channels.SelectionKey +import java.nio.channels.Selector +import java.nio.channels.SocketChannel +import java.nio.charset.StandardCharsets + +/** Raised for non-2xx Docker API responses (or transport errors). */ +class DockerException(val status: Int, message: String) : RuntimeException("docker api error ($status): $message") + +/** + * A tiny Docker Engine API client that speaks HTTP/1.1 directly over the unix domain socket + * (`/var/run/docker.sock`) using only the JDK. Each call opens a fresh connection and sends + * `Connection: close`, so we never have to manage keep-alive state; the response body is either + * `Transfer-Encoding: chunked` (decoded here) or simply delimited by the socket close. + */ +class DockerApi(private val socketPath: String) { + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + // --- High level helpers --------------------------------------------------------------- + + fun version(): JsonObject = getJson("/version").jsonObject + + fun listContainers(all: Boolean, filters: Map>): JsonArray { + val filterJson = buildJsonObject { + filters.forEach { (key, values) -> putJsonArray(key) { values.forEach { add(it) } } } + }.toString() + val query = "all=$all&filters=${enc(filterJson)}" + return getJson("/containers/json?$query").jsonArray + } + + fun inspectContainer(id: String): JsonObject = getJson("/containers/${enc(id)}/json").jsonObject + + fun restart(id: String, timeout: Int) { + ok(request("POST", "/containers/${enc(id)}/restart?t=$timeout")) + } + + fun stop(id: String, timeout: Int) { + ok(request("POST", "/containers/${enc(id)}/stop?t=$timeout"), 304) + } + + fun start(id: String) { + ok(request("POST", "/containers/${enc(id)}/start"), 304) + } + + fun rename(id: String, name: String) { + ok(request("POST", "/containers/${enc(id)}/rename?name=${enc(name)}")) + } + + fun remove(id: String, force: Boolean) { + ok(request("DELETE", "/containers/${enc(id)}?force=$force&v=false"), 404) + } + + fun create(name: String, body: JsonObject): String { + val response = request( + method = "POST", + path = "/containers/create?name=${enc(name)}", + body = body.toString().toByteArray(StandardCharsets.UTF_8), + headers = mapOf("Content-Type" to "application/json"), + ) + ok(response) + return json.parseToJsonElement(response.bodyText).jsonObject["Id"]!!.jsonPrimitive.content + } + + /** `GET /images/{ref}/json` — note the ref (repo/tag) is kept raw; its slashes/colons are valid path chars. */ + fun inspectImage(ref: String): JsonObject = getJson("/images/$ref/json").jsonObject + + fun removeImage(ref: String) { + ok(request("DELETE", "/images/$ref?force=false&noprune=false"), 404, 409) + } + + /** + * `POST /images/create` — pull an image. Docker answers 200 and streams newline-delimited + * JSON progress objects; a failed pull surfaces an `error` field in the stream, so we scan for it. + */ + fun pull(fromImage: String, tag: String, registryAuth: String?) { + val headers = buildMap { + if (registryAuth != null) put("X-Registry-Auth", registryAuth) + } + val response = request( + method = "POST", + path = "/images/create?fromImage=${enc(fromImage)}&tag=${enc(tag)}", + headers = headers, + readTimeoutMs = 600_000, // pulls can be slow; allow up to 10 minutes of idle gap + ) + ok(response) + for (rawLine in response.bodyText.split('\n')) { + val line = rawLine.trim() + if (line.isEmpty()) continue + val element = runCatching { json.parseToJsonElement(line) }.getOrNull() ?: continue + (element as? JsonObject)?.str("error")?.let { throw DockerException(-1, "pull failed: $it") } + } + } + + // --- Response handling ---------------------------------------------------------------- + + private fun getJson(path: String) = request("GET", path).let { ok(it); json.parseToJsonElement(it.bodyText) } + + private fun ok(response: HttpResponse, vararg allowed: Int) { + if (response.status in 200..299 || response.status in allowed) return + throw DockerException(response.status, response.bodyText.take(500)) + } + + private fun enc(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + + // --- Transport: HTTP/1.1 over a unix domain socket ------------------------------------ + + private class HttpResponse(val status: Int, val body: ByteArray) { + val bodyText: String get() = String(body, StandardCharsets.UTF_8) + } + + private fun request( + method: String, + path: String, + body: ByteArray? = null, + headers: Map = emptyMap(), + readTimeoutMs: Long = 60_000, + ): HttpResponse { + SocketChannel.open(StandardProtocolFamily.UNIX).use { channel -> + channel.connect(UnixDomainSocketAddress.of(socketPath)) + + val head = StringBuilder() + .append(method).append(' ').append(path).append(" HTTP/1.1\r\n") + .append("Host: localhost\r\n") + .apply { headers.forEach { (k, v) -> append(k).append(": ").append(v).append("\r\n") } } + .apply { + if (body != null || (method != "GET" && method != "HEAD")) { + append("Content-Length: ").append(body?.size ?: 0).append("\r\n") + } + } + .append("Connection: close\r\n\r\n") + .toString() + .toByteArray(StandardCharsets.US_ASCII) + + channel.configureBlocking(true) + writeFully(channel, ByteBuffer.wrap(head)) + if (body != null && body.isNotEmpty()) writeFully(channel, ByteBuffer.wrap(body)) + + return parse(readUntilClose(channel, readTimeoutMs)) + } + } + + private fun writeFully(channel: SocketChannel, buffer: ByteBuffer) { + while (buffer.hasRemaining()) channel.write(buffer) + } + + private fun readUntilClose(channel: SocketChannel, readTimeoutMs: Long): ByteArray { + channel.configureBlocking(false) + Selector.open().use { selector -> + channel.register(selector, SelectionKey.OP_READ) + val out = ByteArrayOutputStream() + val buffer = ByteBuffer.allocate(16 * 1024) + while (true) { + if (selector.select(readTimeoutMs) == 0) { + throw DockerException(-1, "read timed out after ${readTimeoutMs}ms") + } + selector.selectedKeys().clear() + buffer.clear() + val read = channel.read(buffer) + if (read < 0) break + if (read > 0) out.write(buffer.array(), 0, read) + } + return out.toByteArray() + } + } + + private fun parse(raw: ByteArray): HttpResponse { + val separator = indexOf(raw, CRLF_CRLF, 0) + if (separator < 0) throw DockerException(-1, "malformed http response (no header terminator)") + + val headerText = String(raw, 0, separator, StandardCharsets.US_ASCII) + val lines = headerText.split("\r\n") + val status = lines.first().split(' ').getOrNull(1)?.toIntOrNull() + ?: throw DockerException(-1, "malformed status line: ${lines.first()}") + + val chunked = lines.drop(1).any { + val colon = it.indexOf(':') + colon > 0 && + it.substring(0, colon).trim().equals("Transfer-Encoding", ignoreCase = true) && + it.substring(colon + 1).trim().equals("chunked", ignoreCase = true) + } + + val bodyStart = separator + CRLF_CRLF.size + val body = raw.copyOfRange(bodyStart, raw.size) + return HttpResponse(status, if (chunked) dechunk(body) else body) + } + + private fun dechunk(data: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + var pos = 0 + while (pos < data.size) { + val lineEnd = indexOf(data, CRLF, pos) + if (lineEnd < 0) break + val sizeToken = String(data, pos, lineEnd - pos, StandardCharsets.US_ASCII) + .substringBefore(';').trim() + val size = sizeToken.toIntOrNull(16) ?: break + pos = lineEnd + CRLF.size + if (size == 0) break + if (pos + size > data.size) break + out.write(data, pos, size) + pos += size + CRLF.size // skip the chunk data plus its trailing CRLF + } + return out.toByteArray() + } + + private fun indexOf(haystack: ByteArray, needle: ByteArray, from: Int): Int { + outer@ for (i in from..haystack.size - needle.size) { + for (j in needle.indices) if (haystack[i + j] != needle[j]) continue@outer + return i + } + return -1 + } + + private companion object { + val CRLF = "\r\n".toByteArray(StandardCharsets.US_ASCII) + val CRLF_CRLF = "\r\n\r\n".toByteArray(StandardCharsets.US_ASCII) + } +} diff --git a/src/main/kotlin/io/heapy/kodkod/Json.kt b/src/main/kotlin/io/heapy/kodkod/Json.kt new file mode 100644 index 0000000..701f8f4 --- /dev/null +++ b/src/main/kotlin/io/heapy/kodkod/Json.kt @@ -0,0 +1,28 @@ +package io.heapy.kodkod + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** Values that are treated as a truthy label/env value. */ +val TRUTHY = setOf("true", "1", "yes", "on") + +val EMPTY_OBJECT = JsonObject(emptyMap()) + +/** Convenience accessors over the loosely-typed JSON we get back from the Docker Engine API. */ +fun JsonObject.str(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull +fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject +fun JsonObject.arr(key: String): JsonArray? = this[key] as? JsonArray + +/** Read a container label, tolerating a missing `Labels` map. */ +fun JsonObject?.label(key: String): String? = this?.get(key)?.jsonPrimitive?.contentOrNull + +/** First entry of a JSON string array, or null. */ +fun JsonArray?.firstString(): String? = this?.firstOrNull()?.jsonPrimitive?.contentOrNull + +/** Interpret a boolean label, falling back to [default] when the label is absent. */ +fun labelTruthy(labels: JsonObject?, key: String, default: Boolean): Boolean { + val value = labels.label(key) ?: return default + return value.trim().lowercase() in TRUTHY +} diff --git a/src/main/kotlin/io/heapy/kodkod/Log.kt b/src/main/kotlin/io/heapy/kodkod/Log.kt new file mode 100644 index 0000000..539e97c --- /dev/null +++ b/src/main/kotlin/io/heapy/kodkod/Log.kt @@ -0,0 +1,21 @@ +package io.heapy.kodkod + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * Minimal stdout logger — avoids pulling in SLF4J/Logback for a single-purpose daemon. + * Lines look like: `2026-06-03 21:15:00 [WARN ] message`. + */ +object Log { + private val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + private fun emit(level: String, message: String) { + val line = "${LocalDateTime.now().format(format)} [$level] $message" + synchronized(this) { println(line) } + } + + fun info(message: String) = emit("INFO ", message) + fun warn(message: String) = emit("WARN ", message) + fun error(message: String) = emit("ERROR", message) +} diff --git a/src/main/kotlin/io/heapy/kodkod/Main.kt b/src/main/kotlin/io/heapy/kodkod/Main.kt new file mode 100644 index 0000000..de807da --- /dev/null +++ b/src/main/kotlin/io/heapy/kodkod/Main.kt @@ -0,0 +1,86 @@ +package io.heapy.kodkod + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.system.exitProcess + +/** + * kodkod — a small docker-compose companion that + * 1. restarts unhealthy containers, and + * 2. updates containers when a newer image is published. + * + * Both jobs talk straight to the Docker Engine API over the unix socket and are opt-in per + * container via labels (see [Config] and the README). + */ +fun main() { + val config = Config.fromEnv() + Log.info("kodkod starting") + Log.info("docker socket : ${config.dockerSocket}") + Log.info("label namespace : ${config.labelNamespace}") + Log.info( + "autoheal : enabled=${config.autohealEnabled} interval=${config.autohealInterval}s " + + "monitorAll=${config.autohealMonitorAll}", + ) + Log.info( + "update : enabled=${config.updateEnabled} interval=${config.updateInterval}s " + + "monitorAll=${config.updateMonitorAll} cleanup=${config.updateCleanup}", + ) + + if (!config.autohealEnabled && !config.updateEnabled) { + Log.warn("both autoheal and update are disabled — nothing to do, exiting") + return + } + + val api = DockerApi(config.dockerSocket) + try { + val version = api.version() + Log.info("connected to docker engine (version ${version.str("Version")}, API ${version.str("ApiVersion")})") + } catch (e: Exception) { + Log.error("cannot reach docker at ${config.dockerSocket}: ${e.message}") + exitProcess(1) + } + + // Docker sets HOSTNAME to the container's short id; used to avoid acting on ourselves. + val selfId = System.getenv("HOSTNAME")?.takeIf { it.isNotBlank() } + val autoheal = Autoheal(api, config, selfId) + val updater = Updater(api, config, selfId) + + val scheduler = Executors.newScheduledThreadPool(2) { runnable -> + Thread(runnable, "kodkod-worker").apply { isDaemon = true } + } + + // scheduleWithFixedDelay => the next cycle starts only after the previous one finishes, + // so cycles never overlap even if a pull/restart runs long. + if (config.autohealEnabled) { + scheduler.scheduleWithFixedDelay( + guarded("autoheal", autoheal::runOnce), + config.autohealStartPeriod, config.autohealInterval, TimeUnit.SECONDS, + ) + } + if (config.updateEnabled) { + scheduler.scheduleWithFixedDelay( + guarded("update", updater::runOnce), + config.updateStartPeriod, config.updateInterval, TimeUnit.SECONDS, + ) + } + + val shutdown = CountDownLatch(1) + Runtime.getRuntime().addShutdownHook( + Thread { + Log.info("kodkod stopping") + scheduler.shutdownNow() + shutdown.countDown() + }, + ) + shutdown.await() +} + +/** Wrap a cycle so a thrown exception is logged instead of cancelling the scheduled task. */ +private fun guarded(name: String, task: () -> Unit): Runnable = Runnable { + try { + task() + } catch (e: Throwable) { + Log.error("[$name] cycle failed: ${e.message}") + } +} diff --git a/src/main/kotlin/io/heapy/kodkod/Updater.kt b/src/main/kotlin/io/heapy/kodkod/Updater.kt new file mode 100644 index 0000000..7d775c1 --- /dev/null +++ b/src/main/kotlin/io/heapy/kodkod/Updater.kt @@ -0,0 +1,190 @@ +package io.heapy.kodkod + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.putJsonObject + +/** + * Keeps containers up to date by pulling their image tag and, when the resolved image id changes, + * recreating the container from its existing configuration against the new image. Driven by labels: + * + * - `.update.enable=true|false` — opt in/out (default follows [Config.updateMonitorAll]) + * - `.stop.timeout=` — stop timeout used while recreating + * + * Containers pinned to a digest (`image@sha256:...`) are skipped — there is nothing to update. + */ +class Updater( + private val api: DockerApi, + private val config: Config, + private val selfId: String?, +) { + private val ns = config.labelNamespace + + fun runOnce() { + val filters = linkedMapOf("status" to listOf("running")) + if (!config.updateMonitorAll) filters["label"] = listOf("$ns.update.enable") + + val containers = api.listContainers(all = false, filters = filters) + for (element in containers) { + val container = element.jsonObject + val id = container.str("Id") ?: continue + if (isSelf(id)) continue + if (!labelTruthy(container.obj("Labels"), "$ns.update.enable", config.updateMonitorAll)) continue + + try { + updateOne(id) + } catch (e: Exception) { + Log.error("[${id.take(12)}] update failed: ${e.message}") + } + } + } + + private fun updateOne(id: String) { + val inspect = api.inspectContainer(id) + val name = (inspect.str("Name") ?: id).trimStart('/') + val imageRef = inspect.obj("Config")?.str("Image") + ?: throw IllegalStateException("container has no image reference") + + if (imageRef.contains('@')) { + Log.info("[$name] image is digest-pinned ($imageRef) — skipping") + return + } + + val currentImageId = inspect.str("Image").orEmpty() + val (repo, tag) = splitImageRef(imageRef) + Log.info("[$name] checking $imageRef for updates") + api.pull(repo, tag, config.registryAuth) + + val newImageId = api.inspectImage(imageRef).str("Id") + if (newImageId == null) { + Log.warn("[$name] could not inspect pulled image $imageRef — skipping") + return + } + if (newImageId == currentImageId) { + Log.info("[$name] already up to date") + return + } + + Log.warn("[$name] update available (${currentImageId.short()} -> ${newImageId.short()}) — recreating") + recreate(id, name, inspect, imageRef) + Log.info("[$name] update complete") + + if (config.updateCleanup && currentImageId.isNotEmpty()) { + // Best-effort: the old image is often still referenced; ignore failures. + try { + api.removeImage(currentImageId) + } catch (_: Exception) { + } + } + } + + private fun recreate(oldId: String, name: String, inspect: JsonObject, imageRef: String) { + val labels = inspect.obj("Config")?.obj("Labels") + val stopTimeout = labels.label("$ns.stop.timeout")?.toIntOrNull() ?: config.defaultStopTimeout + val backupName = "${name}_kodkod_old" + val body = buildCreateBody(inspect, imageRef, oldId) + + api.stop(oldId, stopTimeout) + api.rename(oldId, backupName) + + val newId = try { + api.create(name, body) + } catch (e: Exception) { + Log.error("[$name] create failed — rolling back: ${e.message}") + rollback(oldId, name) + throw e + } + + try { + api.start(newId) + } catch (e: Exception) { + Log.error("[$name] start failed — rolling back: ${e.message}") + runCatching { api.remove(newId, force = true) } + rollback(oldId, name) + throw e + } + + runCatching { api.remove(oldId, force = true) } + } + + private fun rollback(oldId: String, name: String) { + runCatching { api.rename(oldId, name) } + runCatching { api.start(oldId) } + } + + /** + * Build the `/containers/create` body from the existing container's inspect output: reuse the + * full `Config` (with `Image` swapped for the new ref), the `HostConfig`, and the networks the + * container is attached to. Treating these as opaque JSON preserves every setting without + * mapping each field by hand. + */ + private fun buildCreateBody(inspect: JsonObject, imageRef: String, oldId: String): JsonObject { + val containerConfig = inspect.obj("Config") ?: EMPTY_OBJECT + val hostConfig = inspect.obj("HostConfig") + val networks = inspect.obj("NetworkSettings")?.obj("Networks") + val networkMode = hostConfig?.str("NetworkMode").orEmpty() + + // EndpointsConfig is only meaningful for user-defined/bridge networks; skip it for + // host/none/container-share modes where HostConfig.NetworkMode is authoritative. + val emitNetworks = networks != null && networks.isNotEmpty() && + networkMode != "host" && networkMode != "none" && !networkMode.startsWith("container:") + + // Docker auto-assigns Config.Hostname to the container's own short id when none was set; + // carrying that over would give the replacement a stale hostname, so drop it in that case. + val autoHostname = containerConfig.str("Hostname") == oldId.take(12) + + return buildJsonObject { + containerConfig.forEach { (key, value) -> if (!(autoHostname && key == "Hostname")) put(key, value) } + put("Image", JsonPrimitive(imageRef)) + if (hostConfig != null) put("HostConfig", hostConfig) + if (emitNetworks) { + putJsonObject("NetworkingConfig") { + putJsonObject("EndpointsConfig") { + networks.forEach { (netName, endpoint) -> + put(netName, cleanEndpoint(endpoint.jsonObject, oldId)) + } + } + } + } + } + } + + /** Keep only the create-relevant endpoint fields and drop the auto-generated container-id alias. */ + private fun cleanEndpoint(endpoint: JsonObject, oldId: String): JsonObject { + val short = oldId.take(12) + return buildJsonObject { + endpoint.arr("Aliases")?.let { aliases -> + val kept = aliases.filter { + val alias = it.jsonPrimitive.content + alias != short && alias != oldId + } + if (kept.isNotEmpty()) put("Aliases", JsonArray(kept)) + } + endpoint.obj("IPAMConfig")?.let { put("IPAMConfig", it) } + endpoint.arr("Links")?.let { put("Links", it) } + endpoint.obj("DriverOpts")?.let { put("DriverOpts", it) } + } + } + + private fun isSelf(id: String) = selfId != null && id.startsWith(selfId) + + companion object { + /** Split `registry:5000/repo:tag` into (`registry:5000/repo`, `tag`), defaulting tag to `latest`. */ + fun splitImageRef(ref: String): Pair { + val lastSlash = ref.lastIndexOf('/') + val lastColon = ref.lastIndexOf(':') + return if (lastColon > lastSlash) { + ref.substring(0, lastColon) to ref.substring(lastColon + 1) + } else { + ref to "latest" + } + } + + private fun String.short(): String = + removePrefix("sha256:").take(12).ifEmpty { "" } + } +} diff --git a/tmux.conf b/tmux.conf deleted file mode 100644 index b9be85f..0000000 --- a/tmux.conf +++ /dev/null @@ -1,8 +0,0 @@ -# Basic tmux configuration -set -g mouse on -set -g base-index 1 -setw -g pane-base-index 1 -set -g history-limit 10000 -unbind C-b -set -g prefix C-a -bind C-a send-prefix