Files
coolify/scripts/dev
T
Andras BacsaiandClaude Opus 5.5 338f1f837f feat(dev): run one isolated dev instance per git branch
Merge scripts/dev-instances into scripts/dev. Each branch gets its own
Compose project, container, volumes, port block, libvirt network, and
KVM VMs, so stop and start reuse the same data and worktrees do not
collide.

- Name instances after the branch; keep slots and APP_KEYs in the main
  checkout's .dev-instances/ so they survive worktree removal
- Reuse existing QEMU VMs instead of recreating them; add dev:qemu --fresh
- Give each instance an isolated libvirt network (10.221.<slot>.0/24)
  and VM names coolify-dev-<branch>--<profile>
- Publish per-instance browser ports and publish them with tailscale
  serve when APP_URL is a tailnet host
- Add urls, exec, logs, container, destroy, and teardown commands; run
  teardown from jean.json before Jean deletes a worktree
- Fix Reverb/terminal browser ports and the testing-host alias in the
  instance compose file
- Keep fresh worktree instances working: pre-create laravel.log, log the
  host dev:qemu call to stderr, and stop Vite from watching vendor/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:55:49 +02:00

427 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# Manage the development instance of the current git branch.
#
# Usage:
# ./scripts/dev start [qemu-profile] # start this branch's instance (KVM VM or testing-host)
# ./scripts/dev stop [qemu-profile] # stop containers and VMs; data is kept
# ./scripts/dev run [qemu-profile] # start, follow logs, stop on exit (Jean)
# ./scripts/dev urls # all instances, ports, and state
# ./scripts/dev ps | logs | exec <cmd...> | container
# ./scripts/dev destroy <name> # delete containers, volumes, VMs, and the port slot
# ./scripts/dev teardown # destroy this worktree's instance if it exists (Jean)
#
# Each branch is one instance: Compose project, container, volumes, and KVM VMs are
# named after it, so stop + start keeps the same data. Detached HEAD uses the
# checkout directory name. Set COOLIFY_DEV_INSTANCE=<name> to override.
#
# Ports: the main checkout uses 8000 (Reverb 6001, terminal 6002, db 5432, redis 6379,
# vite 5173). Worktrees use 20000 + slot*10: app +0, Reverb +1, terminal +2, db +3,
# redis +4, vite +5, mailpit +6/+7, minio +8/+9.
#
# Env: COOLIFY_DEV_SERVER_BACKEND=auto|testing-host, COOLIFY_DEV_KVM_PROFILE=<profile>
#
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT="$(pwd -P)"
COMPOSE_FILE="docker-compose.dev-multi.yml"
GIT_COMMON_DIR="$(git rev-parse --path-format=absolute --git-common-dir)"
# Shared by all worktrees, so slots, APP_KEYs, and data survive worktree removal.
ENV_DIR="$(dirname "$GIT_COMMON_DIR")/.dev-instances"
SLOTS_FILE="$ENV_DIR/slots"
QEMU_STORAGE="${DEVELOPMENT_QEMU_STORAGE_PATH:-/var/lib/libvirt/images/coolify-development}"
LEGACY_CONTAINERS=(coolify coolify-db coolify-redis coolify-vite coolify-mail coolify-minio coolify-minio-init coolify-testing-host)
usage() {
sed -n '2,22p' "$0" | sed 's/^# \?//'
}
normalize_name() {
local name
name="$(tr '[:upper:]' '[:lower:]' <<<"$1" | sed -E 's/[^a-z0-9_]+/-/g; s/^-+//; s/-+$//')"
if [[ ! "$name" =~ ^[a-z0-9_]+(-[a-z0-9_]+)*$ ]]; then
echo "Invalid instance name '$1'" >&2
exit 1
fi
echo "$name"
}
is_linked_worktree() {
[[ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ]]
}
current_instance() {
if [[ -n "${COOLIFY_DEV_INSTANCE:-}" ]]; then
normalize_name "$COOLIFY_DEV_INSTANCE"
return
fi
normalize_name "$(git symbolic-ref --quiet --short HEAD || basename "$ROOT")"
}
project_name() { echo "coolify-dev-$1"; }
env_file() { echo "$ENV_DIR/$1.env"; }
env_value() { grep -E "^$2=" "$(env_file "$1")" | cut -d= -f2-; }
# Lowest free slot (1..254), stable per instance name. It selects the port block
# and the libvirt network (10.221.<slot>.0/24) of the instance.
instance_slot() {
local name="$1" slot
mkdir -p "$ENV_DIR"
touch "$SLOTS_FILE"
exec 9>"$SLOTS_FILE.lock"
if command -v flock >/dev/null; then
flock 9
fi
slot="$(awk -v n="$name" '$1 == n { print $2 }' "$SLOTS_FILE")"
if [[ -z "$slot" ]]; then
slot=1
while awk -v s="$slot" '$2 == s { found = 1 } END { exit !found }' "$SLOTS_FILE"; do
slot=$((slot + 1))
done
if ((slot > 254)); then
echo 'No free dev instance slot. Destroy unused instances first.' >&2
exit 1
fi
echo "$name $slot" >>"$SLOTS_FILE"
fi
exec 9>&-
echo "$slot"
}
# Keep the scheme and host of APP_URL in .env (for example a Tailscale name) and use the instance port.
app_url() {
local port="$1" url=""
if [[ -f .env ]]; then
url="$(grep -E '^APP_URL=' .env | tail -n1 | cut -d= -f2- | tr -d "\"'" || true)"
fi
if [[ "$url" =~ ^(https?)://([^/:]+) ]]; then
echo "${BASH_REMATCH[1]}://${BASH_REMATCH[2]}:${port}"
else
echo "http://localhost:${port}"
fi
}
ensure_env() {
local name="$1" slot env_file existing_key base
local app reverb terminal db redis vite mail mail_ui minio minio_ui
slot="$(instance_slot "$name")"
env_file="$(env_file "$name")"
if ! is_linked_worktree && [[ -z "${COOLIFY_DEV_INSTANCE:-}" ]]; then
app=8000 reverb=6001 terminal=6002 db=5432 redis=6379 vite=5173
mail=1025 mail_ui=8025 minio=9000 minio_ui=9001
else
base=$((20000 + slot * 10))
app=$base reverb=$((base + 1)) terminal=$((base + 2)) db=$((base + 3)) redis=$((base + 4))
vite=$((base + 5)) mail=$((base + 6)) mail_ui=$((base + 7)) minio=$((base + 8)) minio_ui=$((base + 9))
fi
# Never rotate APP_KEY once set: encrypted DB columns (private keys, secrets)
# become unreadable ("The MAC is invalid") if APP_KEY changes while volumes persist.
existing_key=""
if [[ -f "$env_file" ]]; then
existing_key="$(grep -E '^APP_KEY=base64:' "$env_file" | tail -n1 | cut -d= -f2- || true)"
fi
if [[ -z "$existing_key" && -f "$ENV_DIR/$name.appkey" ]]; then
existing_key="$(cat "$ENV_DIR/$name.appkey")"
fi
if [[ -z "$existing_key" ]]; then
existing_key="base64:$(openssl rand -base64 32)"
fi
printf '%s\n' "$existing_key" >"$ENV_DIR/$name.appkey"
cat >"$env_file" <<EOF
# Generated by scripts/dev for instance ${name} — do not commit
COMPOSE_PROJECT_NAME=$(project_name "$name")
DEV_CHECKOUT=${ROOT}
DEVELOPMENT_QEMU_INSTANCE=${name}
DEVELOPMENT_QEMU_SLOT=${slot}
APP_NAME=Coolify-${name}
APP_URL=$(app_url "$app")
APP_KEY=${existing_key}
APP_PORT=${app}
FORWARD_PUSHER_PORT=${reverb}
FORWARD_TERMINAL_PORT=${terminal}
FORWARD_DB_PORT=${db}
FORWARD_REDIS_PORT=${redis}
VITE_PORT=${vite}
FORWARD_MAILPIT_PORT=${mail}
FORWARD_MAILPIT_DASHBOARD_PORT=${mail_ui}
FORWARD_MINIO_PORT=${minio}
FORWARD_MINIO_PORT_CONSOLE=${minio_ui}
PUSHER_APP_ID=coolify-${name}
PUSHER_APP_KEY=coolify-${name}
PUSHER_APP_SECRET=coolify-${name}
EOF
}
# compose <name> <args...>: .env of the checkout first, generated instance env wins.
compose() {
local name="$1"
shift
local -a env_files=()
if [[ -f .env ]]; then
env_files+=(--env-file .env)
fi
docker compose -p "$(project_name "$name")" -f "$COMPOSE_FILE" \
"${env_files[@]}" --env-file "$(env_file "$name")" "${profiles[@]}" "$@"
}
select_backend() {
backend="${COOLIFY_DEV_SERVER_BACKEND:-auto}"
if [[ "$backend" != "auto" && "$backend" != "testing-host" ]]; then
echo 'COOLIFY_DEV_SERVER_BACKEND must be auto or testing-host.' >&2
exit 2
fi
privileged=()
if [[ $EUID -ne 0 ]]; then
privileged=(sudo -n -E)
fi
if [[ "$backend" == "auto" && -c /dev/kvm && -r /dev/kvm && -w /dev/kvm ]] &&
{ [[ $EUID -eq 0 ]] || { command -v sudo >/dev/null && sudo -n -E true; }; }; then
backend=kvm
else
backend=testing-host
fi
profiles=(--profile vite --profile mailpit --profile minio)
if [[ "$backend" == "testing-host" ]]; then
profiles+=(--profile testing-host)
fi
}
wait_for_coolify() {
local container attempt status
container="$(project_name "$instance")"
for ((attempt = 0; attempt < 150; attempt++)); do
status="$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true)"
if [[ "$status" == 'running healthy' ]]; then
return 0
fi
if [[ "$status" == exited* || "$status" == dead* ]]; then
break
fi
sleep 2
done
echo 'Coolify did not become healthy within 5 minutes.' >&2
return 1
}
# Only one instance of a checkout runs at a time: they share its ports (main checkout)
# and its public/hot Vite file. The legacy fixed-name stack also holds the main ports.
stop_other_checkout_instances() {
local file other
for file in "$ENV_DIR"/*.env; do
[[ -f "$file" ]] || continue
other="$(basename "$file" .env)"
if [[ "$other" != "$instance" ]] && grep -qxF "DEV_CHECKOUT=$ROOT" "$file" &&
[[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$(project_name "$other")")" ]]; then
echo "Stopping instance ${other} (same checkout)"
docker compose -p "$(project_name "$other")" stop
fi
done
if ! is_linked_worktree; then
docker stop "${LEGACY_CONTAINERS[@]}" >/dev/null 2>&1 || true
fi
}
# Publish the browser-facing ports over Tailscale HTTPS when APP_URL uses a tailnet name.
tailscale_serve() {
local action="$1" port
[[ "$(env_value "$instance" APP_URL)" == https://*.ts.net:* ]] || return 0
command -v tailscale >/dev/null || return 0
for port in APP_PORT FORWARD_PUSHER_PORT FORWARD_TERMINAL_PORT VITE_PORT; do
port="$(env_value "$instance" "$port")"
if [[ "$action" == on ]]; then
tailscale serve --bg --https="$port" "http://127.0.0.1:$port" >/dev/null || true
else
tailscale serve --https="$port" off >/dev/null 2>&1 || true
fi
done
}
vm_domains() {
command -v virsh >/dev/null || return 0
"${privileged[@]}" virsh --connect qemu:///system list "$@" --name 2>/dev/null |
grep -E "^coolify-dev-${instance}--" || true
}
start_backend() {
stop_other_checkout_instances
echo "==> Instance ${instance} → $(env_value "$instance" APP_URL) (backend: ${backend})"
compose "$instance" up -d --build
wait_for_coolify
tailscale_serve on
if [[ "$backend" == "kvm" ]]; then
local host="${DEV_BIND_ADDRESS:-127.0.0.1}"
[[ "$host" == 0.0.0.0 ]] && host=127.0.0.1
echo "Using KVM profile: $profile"
# The host command must reach this instance's database, not the one in .env, and must
# not create root-owned files in storage/logs.
LOG_CHANNEL=stderr DB_HOST="$host" DB_PORT="$(env_value "$instance" FORWARD_DB_PORT)" \
REDIS_HOST="$host" REDIS_PORT="$(env_value "$instance" FORWARD_REDIS_PORT)" \
DEVELOPMENT_QEMU_INSTANCE="$instance" \
DEVELOPMENT_QEMU_SLOT="$(env_value "$instance" DEVELOPMENT_QEMU_SLOT)" \
DEVELOPMENT_QEMU_DOCKER_NETWORK="$(project_name "$instance")" \
DEVELOPMENT_QEMU_COOLIFY_CONTAINER="$(project_name "$instance")" \
"${privileged[@]}" php artisan dev:qemu "$profile" --as-localhost
else
compose "$instance" exec -T coolify php artisan db:seed --class=ServerSeeder --force
fi
}
stop_backend() {
local domain
if [[ "$backend" == "kvm" ]]; then
for domain in $(vm_domains --state-running); do
if [[ -z "$profile_arg" || "$domain" == "coolify-dev-${instance}--${profile_arg}" ]]; then
"${privileged[@]}" virsh --connect qemu:///system shutdown "$domain"
fi
done
fi
compose "$instance" stop
}
cmd_urls() {
local name file state
printf '%-40s %-44s %-7s %-7s %-6s %s\n' NAME URL STATE DB REDIS CHECKOUT
for file in "$ENV_DIR"/*.env; do
[[ -f "$file" ]] || continue
name="$(basename "$file" .env)"
state="$(docker inspect --format '{{.State.Status}}' "$(project_name "$name")" 2>/dev/null || echo stopped)"
printf '%-40s %-44s %-7s %-7s %-6s %s\n' "$name" "$(env_value "$name" APP_URL)" "$state" \
"$(env_value "$name" FORWARD_DB_PORT)" "$(env_value "$name" FORWARD_REDIS_PORT)" \
"$(env_value "$name" DEV_CHECKOUT)"
done
}
cmd_destroy() {
local name="$1" slot domain subnet
instance="$name"
[[ -f "$(env_file "$name")" ]] || {
echo "Unknown instance '$name'. See ./scripts/dev urls" >&2
exit 1
}
slot="$(env_value "$name" DEVELOPMENT_QEMU_SLOT)"
echo "==> Destroying ${name} (containers, volumes, VMs, port slot)"
tailscale_serve off
subnet="$(docker network inspect "$(project_name "$name")" --format '{{(index .IPAM.Config 0).Subnet}}' 2>/dev/null || true)"
compose "$name" --profile '*' down --remove-orphans --volumes
if command -v virsh >/dev/null; then
for domain in $(vm_domains --all); do
"${privileged[@]}" virsh --connect qemu:///system destroy "$domain" >/dev/null 2>&1 || true
"${privileged[@]}" virsh --connect qemu:///system undefine "$domain" >/dev/null
done
"${privileged[@]}" rm -f "$QEMU_STORAGE/coolify-dev-${name}--"*
if "${privileged[@]}" virsh --connect qemu:///system net-info "coolify-dev-${slot}" >/dev/null 2>&1; then
"${privileged[@]}" virsh --connect qemu:///system net-destroy "coolify-dev-${slot}" >/dev/null 2>&1 || true
"${privileged[@]}" virsh --connect qemu:///system net-undefine "coolify-dev-${slot}" >/dev/null
fi
if [[ -n "$subnet" ]]; then
"${privileged[@]}" iptables -D LIBVIRT_FWI -s "$subnet" -d "10.221.${slot}.0/24" -o "cdevbr${slot}" -j ACCEPT >/dev/null 2>&1 || true
fi
fi
rm -f "$(env_file "$name")" "$ENV_DIR/$name.appkey"
awk -v n="$name" '$1 != n' "$SLOTS_FILE" >"$SLOTS_FILE.tmp"
mv "$SLOTS_FILE.tmp" "$SLOTS_FILE"
}
command="${1:-start}"
shift || true
case "$command" in
--help | -h | help)
usage
exit 0
;;
start | stop | run)
if [[ $# -gt 1 ]]; then
usage >&2
exit 2
fi
;;
destroy)
if [[ $# -ne 1 ]]; then
echo 'Usage: ./scripts/dev destroy <name>' >&2
exit 2
fi
;;
urls | ls | ps | logs | exec | container | teardown) ;;
*)
usage >&2
exit 2
;;
esac
profile_arg="${1:-}"
profile="${profile_arg:-${COOLIFY_DEV_KVM_PROFILE:-ubuntu-root}}"
select_backend
case "$command" in
urls | ls)
cmd_urls
exit 0
;;
container)
project_name "$(current_instance)"
exit 0
;;
destroy)
cmd_destroy "$(normalize_name "$1")"
exit 0
;;
teardown)
# Jean runs this before it deletes a worktree; a failure blocks the deletion.
if ! is_linked_worktree; then
echo 'teardown only destroys the instance of a linked worktree.' >&2
exit 1
fi
instance="$(current_instance)"
if [[ ! -f "$(env_file "$instance")" ]]; then
echo "No dev instance for ${instance}; nothing to destroy."
exit 0
fi
cmd_destroy "$instance"
exit 0
;;
esac
instance="$(current_instance)"
ensure_env "$instance"
case "$command" in
start)
start_backend
;;
stop)
stop_backend
;;
run)
cleanup() {
status=$?
trap - EXIT INT TERM
stop_backend || true
compose "$instance" down --remove-orphans || true
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
start_backend
compose "$instance" logs -f --tail=100
;;
ps)
compose "$instance" ps
;;
logs)
compose "$instance" logs -f "$@"
;;
exec)
[[ $# -gt 0 ]] || {
usage >&2
exit 2
}
compose "$instance" exec coolify "$@"
;;
esac