Merge pull request 'fix: Containerize full stack with docker-compose (#618)' (#625) from fix/issue-618 into main
This commit is contained in:
commit
9a9b82eea5
4 changed files with 331 additions and 21 deletions
264
bin/disinto
264
bin/disinto
|
|
@ -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
22
docker/agents/Dockerfile
Normal 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"]
|
||||
51
docker/agents/entrypoint.sh
Normal file
51
docker/agents/entrypoint.sh
Normal 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
|
||||
15
lib/env.sh
15
lib/env.sh
|
|
@ -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:-}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue