Merge pull request 'fix: Containerize full stack with docker-compose (#618)' (#625) from fix/issue-618 into main

This commit is contained in:
johba 2026-03-24 21:43:39 +01:00
commit 9a9b82eea5
4 changed files with 331 additions and 21 deletions

View file

@ -4,12 +4,19 @@
#
# Commands:
# disinto init <repo-url> [options] Bootstrap a new project
# disinto up Start the full stack (docker compose)
# disinto down Stop the full stack
# disinto logs [service] Tail service logs
# disinto shell Shell into the agent container
# disinto status Show factory status
# disinto secrets <edit|show|migrate> Manage encrypted secrets
#
# Usage:
# disinto init https://github.com/user/repo
# disinto init user/repo --branch main --ci-id 3
# disinto init user/repo --bare (bare-metal, no compose)
# disinto up
# disinto down
# disinto status
# =============================================================================
set -euo pipefail
@ -25,6 +32,10 @@ disinto — autonomous code factory CLI
Usage:
disinto init <repo-url> [options] Bootstrap a new project
disinto up Start the full stack (docker compose)
disinto down Stop the full stack
disinto logs [service] Tail service logs
disinto shell Shell into the agent container
disinto status Show factory status
disinto secrets <edit|show|migrate> Manage encrypted secrets (.env.enc)
@ -33,6 +44,7 @@ Init options:
--repo-root <path> Local clone path (default: ~/name)
--ci-id <n> Woodpecker CI repo ID (default: 0 = no CI)
--forge-url <url> Forge base URL (default: http://localhost:3000)
--bare Skip compose generation (bare-metal setup)
--yes Skip confirmation prompts
EOF
exit 1
@ -136,15 +148,128 @@ write_secrets_encrypted() {
FORGEJO_DATA_DIR="${HOME}/.disinto/forgejo"
# Generate docker-compose.yml in the factory root.
generate_compose() {
local forge_port="${1:-3000}"
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
cat > "$compose_file" <<'COMPOSEEOF'
# docker-compose.yml — generated by disinto init
# Brings up Forgejo, Woodpecker, and the agent runtime.
services:
forgejo:
image: forgejo/forgejo:latest
restart: unless-stopped
volumes:
- forgejo-data:/data
environment:
FORGEJO__database__DB_TYPE: sqlite3
FORGEJO__server__ROOT_URL: http://forgejo:3000/
FORGEJO__server__HTTP_PORT: "3000"
FORGEJO__service__DISABLE_REGISTRATION: "true"
networks:
- disinto-net
woodpecker:
image: woodpeckerci/woodpecker-server:latest
restart: unless-stopped
volumes:
- woodpecker-data:/var/lib/woodpecker
environment:
WOODPECKER_FORGEJO: "true"
WOODPECKER_FORGEJO_URL: http://forgejo:3000
WOODPECKER_FORGEJO_CLIENT: ${WP_FORGEJO_CLIENT:-}
WOODPECKER_FORGEJO_SECRET: ${WP_FORGEJO_SECRET:-}
WOODPECKER_HOST: http://woodpecker:8000
WOODPECKER_DATABASE_DRIVER: sqlite3
WOODPECKER_DATABASE_DATASOURCE: /var/lib/woodpecker/woodpecker.sqlite
depends_on:
- forgejo
networks:
- disinto-net
agents:
build: ./docker/agents
restart: unless-stopped
volumes:
- agent-data:/home/agent/data
- project-repos:/home/agent/repos
- ./:/home/agent/disinto:ro
- claude-auth:/home/agent/.claude:ro
environment:
FORGE_URL: http://forgejo:3000
WOODPECKER_SERVER: http://woodpecker:8000
DISINTO_CONTAINER: "1"
env_file:
- .env
depends_on:
- forgejo
- woodpecker
networks:
- disinto-net
volumes:
forgejo-data:
woodpecker-data:
agent-data:
project-repos:
claude-auth:
networks:
disinto-net:
driver: bridge
COMPOSEEOF
# Patch the forgejo port mapping into the file if non-default
if [ "$forge_port" != "3000" ]; then
# Add port mapping to forgejo service so it's reachable from host during init
sed -i "/image: forgejo\/forgejo:latest/a\\ ports:\\n - \"${forge_port}:3000\"" "$compose_file"
else
sed -i "/image: forgejo\/forgejo:latest/a\\ ports:\\n - \"3000:3000\"" "$compose_file"
fi
echo "Created: ${compose_file}"
}
# Generate docker/agents/ files if they don't already exist.
generate_agent_docker() {
local docker_dir="${FACTORY_ROOT}/docker/agents"
mkdir -p "$docker_dir"
if [ ! -f "${docker_dir}/Dockerfile" ]; then
echo "Warning: docker/agents/Dockerfile not found — expected in repo" >&2
fi
if [ ! -f "${docker_dir}/entrypoint.sh" ]; then
echo "Warning: docker/agents/entrypoint.sh not found — expected in repo" >&2
fi
}
# Check whether compose mode is active (docker-compose.yml exists).
is_compose_mode() {
[ -f "${FACTORY_ROOT}/docker-compose.yml" ]
}
# Provision or connect to a local Forgejo instance.
# Creates admin + bot users, generates API tokens, stores in .env.
# When $DISINTO_BARE is set, uses standalone docker run; otherwise uses compose.
setup_forge() {
local forge_url="$1"
local repo_slug="$2"
local use_bare="${DISINTO_BARE:-false}"
echo ""
echo "── Forge setup ────────────────────────────────────────"
# Helper: run a command inside the Forgejo container
_forgejo_exec() {
if [ "$use_bare" = true ]; then
docker exec disinto-forgejo "$@"
else
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T forgejo "$@"
fi
}
# Check if Forgejo is already running
if curl -sf --max-time 5 "${forge_url}/api/v1/version" >/dev/null 2>&1; then
echo "Forgejo: ${forge_url} (already running)"
@ -158,29 +283,33 @@ setup_forge() {
exit 1
fi
# Create data directory
mkdir -p "${FORGEJO_DATA_DIR}"
# Extract port from forge_url
local forge_port
forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|')
forge_port="${forge_port:-3000}"
# Start Forgejo container
if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then
docker start disinto-forgejo >/dev/null 2>&1 || true
if [ "$use_bare" = true ]; then
# Bare-metal mode: standalone docker run
mkdir -p "${FORGEJO_DATA_DIR}"
if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then
docker start disinto-forgejo >/dev/null 2>&1 || true
else
docker run -d \
--name disinto-forgejo \
--restart unless-stopped \
-p "${forge_port}:3000" \
-p 2222:22 \
-v "${FORGEJO_DATA_DIR}:/data" \
-e "FORGEJO__database__DB_TYPE=sqlite3" \
-e "FORGEJO__server__ROOT_URL=${forge_url}/" \
-e "FORGEJO__server__HTTP_PORT=3000" \
-e "FORGEJO__service__DISABLE_REGISTRATION=true" \
forgejo/forgejo:latest
fi
else
docker run -d \
--name disinto-forgejo \
--restart unless-stopped \
-p "${forge_port}:3000" \
-p 2222:22 \
-v "${FORGEJO_DATA_DIR}:/data" \
-e "FORGEJO__database__DB_TYPE=sqlite3" \
-e "FORGEJO__server__ROOT_URL=${forge_url}/" \
-e "FORGEJO__server__HTTP_PORT=3000" \
-e "FORGEJO__service__DISABLE_REGISTRATION=true" \
forgejo/forgejo:latest
# Compose mode: start Forgejo via docker compose
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d forgejo
fi
# Wait for Forgejo to become healthy
@ -206,7 +335,7 @@ setup_forge() {
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then
echo "Creating admin user: ${admin_user}"
docker exec disinto-forgejo forgejo admin user create \
_forgejo_exec forgejo admin user create \
--admin \
--username "${admin_user}" \
--password "${admin_pass}" \
@ -241,7 +370,7 @@ setup_forge() {
-H "Authorization: token ${admin_token}" \
"${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then
echo "Creating bot user: ${bot_user}"
docker exec disinto-forgejo forgejo admin user create \
_forgejo_exec forgejo admin user create \
--username "${bot_user}" \
--password "${bot_pass}" \
--email "${bot_user}@disinto.local" \
@ -748,18 +877,22 @@ disinto_init() {
shift
# Parse flags
local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag=""
local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false
while [ $# -gt 0 ]; do
case "$1" in
--branch) branch="$2"; shift 2 ;;
--repo-root) repo_root="$2"; shift 2 ;;
--ci-id) ci_id="$2"; shift 2 ;;
--forge-url) forge_url_flag="$2"; shift 2 ;;
--bare) bare=true; shift ;;
--yes) auto_yes=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
# Export bare-metal flag for setup_forge
export DISINTO_BARE="$bare"
# Extract org/repo slug
local forge_repo
forge_repo=$(parse_repo_slug "$repo_url")
@ -829,6 +962,15 @@ p.write_text(text)
fi
fi
# Generate compose files (unless --bare)
if [ "$bare" = false ]; then
local forge_port
forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|')
forge_port="${forge_port:-3000}"
generate_compose "$forge_port"
generate_agent_docker
fi
# Set up local Forgejo instance (provision if needed, create users/tokens/repo)
setup_forge "$forge_url" "$forge_repo"
@ -919,11 +1061,24 @@ p.write_text(text)
# Encrypt secrets if SOPS + age are available
write_secrets_encrypted
# Bring up the full stack (compose mode only)
if [ "$bare" = false ] && [ -f "${FACTORY_ROOT}/docker-compose.yml" ]; then
echo ""
echo "── Starting full stack ────────────────────────────────"
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d
echo "Stack: running (forgejo + woodpecker + agents)"
fi
echo ""
echo "Done. Project ${project_name} is ready."
echo " Config: ${toml_path}"
echo " Clone: ${repo_root}"
echo " Forge: ${forge_url}/${forge_repo}"
if [ "$bare" = false ]; then
echo " Stack: docker compose (use 'disinto up/down/logs/shell')"
else
echo " Mode: bare-metal"
fi
echo " Run 'disinto status' to verify."
}
@ -1054,10 +1209,79 @@ disinto_secrets() {
esac
}
# ── up command ────────────────────────────────────────────────────────────────
disinto_up() {
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
if [ ! -f "$compose_file" ]; then
echo "Error: docker-compose.yml not found" >&2
echo " Run 'disinto init <repo-url>' first (without --bare)" >&2
exit 1
fi
# Decrypt secrets to temp .env if SOPS available and .env.enc exists
local tmp_env=""
local enc_file="${FACTORY_ROOT}/.env.enc"
local env_file="${FACTORY_ROOT}/.env"
if [ -f "$enc_file" ] && command -v sops &>/dev/null && [ ! -f "$env_file" ]; then
tmp_env="${env_file}"
sops -d --output-type dotenv "$enc_file" > "$tmp_env"
trap '[ -n "${tmp_env:-}" ] && rm -f "$tmp_env"' EXIT
echo "Decrypted secrets for compose"
fi
docker compose -f "$compose_file" up -d "$@"
echo "Stack is up"
# Clean up temp .env (also handled by EXIT trap if compose fails)
if [ -n "$tmp_env" ] && [ -f "$tmp_env" ]; then
rm -f "$tmp_env"
echo "Removed temporary .env"
fi
}
# ── down command ──────────────────────────────────────────────────────────────
disinto_down() {
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
if [ ! -f "$compose_file" ]; then
echo "Error: docker-compose.yml not found" >&2
exit 1
fi
docker compose -f "$compose_file" down "$@"
echo "Stack is down"
}
# ── logs command ──────────────────────────────────────────────────────────────
disinto_logs() {
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
if [ ! -f "$compose_file" ]; then
echo "Error: docker-compose.yml not found" >&2
exit 1
fi
docker compose -f "$compose_file" logs -f "$@"
}
# ── shell command ─────────────────────────────────────────────────────────────
disinto_shell() {
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
if [ ! -f "$compose_file" ]; then
echo "Error: docker-compose.yml not found" >&2
exit 1
fi
docker compose -f "$compose_file" exec agents bash
}
# ── Main dispatch ────────────────────────────────────────────────────────────
case "${1:-}" in
init) shift; disinto_init "$@" ;;
up) shift; disinto_up "$@" ;;
down) shift; disinto_down "$@" ;;
logs) shift; disinto_logs "$@" ;;
shell) shift; disinto_shell ;;
status) shift; disinto_status "$@" ;;
secrets) shift; disinto_secrets "$@" ;;
-h|--help) usage ;;

22
docker/agents/Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
bash curl git jq tmux cron python3 openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Claude CLI — install and verify
RUN curl -fsSL https://cli.anthropic.com/install.sh | sh \
&& cp "$(find /root -name claude -type f 2>/dev/null | head -1)" /usr/local/bin/claude \
&& claude --version
# Non-root user
RUN useradd -m -u 1000 -s /bin/bash agent
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Entrypoint runs as root to start the cron daemon;
# cron jobs execute as the agent user (crontab -u agent).
WORKDIR /home/agent
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
# entrypoint.sh — Start agent container with cron in foreground
#
# Runs as root inside the container. Installs crontab entries for the
# agent user from project TOMLs, then starts cron in the foreground.
# All cron jobs execute as the agent user (UID 1000).
DISINTO_DIR="/home/agent/disinto"
LOGFILE="/home/agent/data/agent-entrypoint.log"
mkdir -p /home/agent/data
chown agent:agent /home/agent/data
log() {
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" | tee -a "$LOGFILE"
}
# Build crontab from project TOMLs and install for the agent user.
install_project_crons() {
local cron_lines=""
for toml in "${DISINTO_DIR}"/projects/*.toml; do
[ -f "$toml" ] || continue
local pname
pname=$(python3 -c "
import sys, tomllib
with open(sys.argv[1], 'rb') as f:
print(tomllib.load(f)['name'])
" "$toml" 2>/dev/null) || continue
cron_lines="${cron_lines}
# disinto: ${pname}
2,7,12,17,22,27,32,37,42,47,52,57 * * * * ${DISINTO_DIR}/review/review-poll.sh ${toml} >/dev/null 2>&1
4,9,14,19,24,29,34,39,44,49,54,59 * * * * ${DISINTO_DIR}/dev/dev-poll.sh ${toml} >/dev/null 2>&1
0 0,6,12,18 * * * cd ${DISINTO_DIR} && bash gardener/gardener-run.sh ${toml} >/dev/null 2>&1"
done
if [ -n "$cron_lines" ]; then
printf '%s\n' "$cron_lines" | crontab -u agent -
log "Installed crontab for agent user"
else
log "No project TOMLs found — crontab empty"
fi
}
log "Agent container starting"
install_project_crons
# Run cron in the foreground. Cron jobs execute as the agent user.
log "Starting cron daemon"
exec cron -f

View file

@ -7,6 +7,14 @@ set -euo pipefail
# Resolve script root (parent of lib/)
FACTORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Container detection: when running inside the agent container, DISINTO_CONTAINER
# is set by docker-compose.yml. Adjust paths so phase files, logs, and thread
# maps land on the persistent volume instead of /tmp (which is ephemeral).
if [ "${DISINTO_CONTAINER:-}" = "1" ]; then
DISINTO_DATA_DIR="${HOME}/data"
mkdir -p "${DISINTO_DATA_DIR}"
fi
# Load secrets: prefer .env.enc (SOPS-encrypted), fall back to plaintext .env
if [ -f "$FACTORY_ROOT/.env.enc" ] && command -v sops &>/dev/null; then
set -a
@ -123,7 +131,12 @@ wpdb() {
# Matrix messaging helper — usage: matrix_send <prefix> <message> [thread_event_id] [context_tag]
# Returns event_id on stdout. Registers threads for listener dispatch.
# context_tag is stored in the thread map (e.g. issue number) for routing replies.
MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}"
# Thread map: use persistent data dir inside container, /tmp on bare metal
if [ "${DISINTO_CONTAINER:-}" = "1" ]; then
MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-${DISINTO_DATA_DIR}/matrix-thread-map}"
else
MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}"
fi
matrix_send() {
[ -z "${MATRIX_TOKEN:-}" ] && return 0
local prefix="$1" msg="$2" thread_id="${3:-}" ctx_tag="${4:-}"