diff --git a/.gitignore b/.gitignore index 403028761..b09e074f7 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ CHANGELOG.md /.workspaces tests/Browser/Screenshots tests/v4/Browser/Screenshots + +# Local generated Lima configs +.dev/lima/*.generated.yaml diff --git a/README.md b/README.md index 378e75ac7..46204ea5e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,25 @@ You can find the installation script source [here](./scripts/install.sh). > Please refer to the [docs](https://coolify.io/docs/installation) for more information about the installation. +## Local dev with coold VM + +For v5 development that needs a packaged `coold` endpoint VM with Corrosion, use: + +```bash +scripts/dev.sh up +``` + +This starts two Lima endpoint VMs by default, configures a WireGuard mesh between them, starts the Docker stack in the background, mints dev host JWTs with `php artisan flux:dev --caps=coold,builder`, installs them into the VMs, starts each VM `coold` agent service with builder capacity, and follows Coolify + VM agent logs by default. Flux runs with the Coolify container, not inside the VM. Set `COOLIFY_COOLD_VM_COUNT=1` for a single endpoint, `COOLIFY_COOLD_VM_ENABLED=false` to skip the VMs, `COOLIFY_COOLD_VM_BUILDER_CAPACITY=0` to disable builder capability, or `COOLIFY_DEV_FOLLOW_LOGS=false` to leave `up` detached. Use `scripts/dev.sh down` to stop the stack and VM agent; set `COOLIFY_COOLD_VM_STOP_ON_DOWN=true` if you also want the VM stopped. The dev entrypoints are intentionally kept to `scripts/dev.sh` for the full stack and `scripts/coold-vm.sh` for VM-only operations. + +Useful Corrosion checks: + +```bash +scripts/dev.sh corrosion check +scripts/dev.sh corrosion containers +scripts/dev.sh corrosion config +scripts/dev.sh corrosion sql 'select count(*) from service_endpoints;' +``` + ## Container roles and Flux The Coolify image can run different process roles with `COOLIFY_CONTAINER_ROLE`. The value can be a single role or a comma-separated list. If `all` is present anywhere in the list, every role starts. diff --git a/app/Console/Commands/FluxDev.php b/app/Console/Commands/FluxDev.php new file mode 100644 index 000000000..2c6cfc84e --- /dev/null +++ b/app/Console/Commands/FluxDev.php @@ -0,0 +1,75 @@ +environment(['local', 'development', 'testing']) && ! $this->option('force')) { + $this->error('This command is intended for development only. Use --force to override.'); + + return self::FAILURE; + } + + $privateKeyPath = config('flux.jwt_private_key_path'); + + if (! is_string($privateKeyPath) || $privateKeyPath === '' || ! File::isReadable($privateKeyPath)) { + $this->error("Flux JWT private key not found at {$privateKeyPath}."); + + return self::FAILURE; + } + + $hostId = (string) $this->argument('host_id'); + $ttl = max(60, (int) $this->option('ttl')); + $now = time(); + $caps = collect(explode(',', (string) $this->option('caps'))) + ->map(fn (string $cap) => trim($cap)) + ->filter() + ->unique() + ->values() + ->all(); + + if ($caps === []) { + $caps = ['coold']; + } + + $token = JWT::encode([ + 'sub' => $hostId, + 'aud' => 'coold', + 'caps' => $caps, + 'iat' => $now, + 'exp' => $now + $ttl, + ], File::get($privateKeyPath), 'ES256'); + + $output = $this->option('output'); + + if (is_string($output) && $output !== '') { + $outputPath = Str::startsWith($output, '/') ? $output : base_path($output); + File::ensureDirectoryExists(dirname($outputPath)); + File::put($outputPath, $token.PHP_EOL); + chmod($outputPath, 0600); + $this->info("Host JWT written to {$outputPath}."); + + return self::SUCCESS; + } + + $this->line($token); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/V5/HomeController.php b/app/Http/Controllers/V5/HomeController.php index b8f9fa6db..116952153 100644 --- a/app/Http/Controllers/V5/HomeController.php +++ b/app/Http/Controllers/V5/HomeController.php @@ -21,6 +21,7 @@ class HomeController extends Controller return Inertia::render('Home', [ 'status' => 'v5-ready', 'flux' => $fluxHealth->check(), + 'cooldHosts' => $this->cooldHosts(), 'currentTeam' => $currentTeam instanceof Team ? [ 'id' => $currentTeam->id, 'name' => $currentTeam->name, @@ -41,4 +42,29 @@ class HomeController extends Controller ]), ]); } + + /** + * @return array, builderEnabled: bool, builderCapacity: int}> + */ + private function cooldHosts(): array + { + $baseId = (string) config('coold.dev_host_id'); + $count = max(0, (int) config('coold.dev_host_count')); + $builderCapacity = (int) config('coold.dev_builder_capacity'); + $builderEnabled = $builderCapacity > 0; + + if ($count === 0 || $baseId === '') { + return []; + } + + return collect(range(1, $count)) + ->map(fn (int $index) => [ + 'id' => $index === 1 ? $baseId : (string) config("coold.dev_host_id_{$index}", "{$baseId}-{$index}"), + 'wireguardIp' => (string) config("coold.dev_wireguard_ip_{$index}") ?: null, + 'capabilities' => $builderEnabled ? ['coold', 'builder'] : ['coold'], + 'builderEnabled' => $builderEnabled, + 'builderCapacity' => $builderCapacity, + ]) + ->all(); + } } diff --git a/config/coold.php b/config/coold.php new file mode 100644 index 000000000..20dac4445 --- /dev/null +++ b/config/coold.php @@ -0,0 +1,11 @@ + (int) env('COOLIFY_COOLD_VM_COUNT', 2), + 'dev_host_id' => env('COOLIFY_COOLD_DEV_HOST_ID', 'coolify-coold-dev'), + 'dev_host_id_2' => env('COOLIFY_COOLD_LIMA_INSTANCE_2', env('COOLIFY_COOLD_DEV_HOST_ID', 'coolify-coold-dev').'-2'), + 'dev_wireguard_ip_1' => env('COOLIFY_COOLD_VM_WG_IP_1', '100.64.0.10'), + 'dev_wireguard_ip_2' => env('COOLIFY_COOLD_VM_WG_IP_2', '100.64.0.11'), + 'dev_builder_capacity' => (int) env('COOLIFY_COOLD_VM_BUILDER_CAPACITY', 2), + 'dev_builder_enabled' => (int) env('COOLIFY_COOLD_VM_BUILDER_CAPACITY', 2) > 0, +]; diff --git a/config/flux.php b/config/flux.php index e82914de0..dea770e47 100644 --- a/config/flux.php +++ b/config/flux.php @@ -2,5 +2,7 @@ return [ 'unix_socket_path' => env('COOLIFY_FLUX_UNIX_SOCKET_PATH', '/run/coolify/flux.sock'), + 'jwt_private_key_path' => env('COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH', storage_path('app/flux/jwt.priv')), + 'jwt_public_key_path' => env('COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH', storage_path('app/flux/jwt.pub')), 'health_timeout_seconds' => (float) env('COOLIFY_FLUX_HEALTH_TIMEOUT_SECONDS', 1.0), ]; diff --git a/dev/lima/coold.yaml b/dev/lima/coold.yaml new file mode 100644 index 000000000..d0354b215 --- /dev/null +++ b/dev/lima/coold.yaml @@ -0,0 +1,125 @@ +# Lima VM for testing Coolify v5 against a packaged coold endpoint. +# Start with: scripts/coold-vm.sh up +# Run endpoint: scripts/coold-vm.sh dev +# +# This file is copied into .dev/lima/coold.generated.yaml by the wrapper script, +# with {{COOLIFY_REPO}}, {{COOLIFY_COOLD_VERSION}}, and {{COOLIFY_CORROSION_VERSION}} replaced before Lima sees it. + +vmType: "vz" +arch: "default" +cpus: 4 +memory: "8GiB" +disk: "100GiB" +containerd: + system: false + user: false + +images: + - location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img" + arch: "x86_64" + - location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img" + arch: "aarch64" + +mountType: "virtiofs" +mounts: + - location: "{{COOLIFY_REPO}}" + mountPoint: "/workspace/coolify" + writable: true + +portForwards: + - guestPort: 5173 + hostPort: 5173 + hostIP: "127.0.0.1" + - guestPort: 3000 + hostPort: 3000 + hostIP: "127.0.0.1" +provision: + - mode: system + script: | + #!/usr/bin/env bash + set -euxo pipefail + export DEBIAN_FRONTEND=noninteractive + echo "[coold-vm] Updating Ubuntu package index..." + apt-get update + echo "[coold-vm] Installing VM dependencies..." + apt-get install -y --no-install-recommends \ + bash \ + buildah \ + ca-certificates \ + clang \ + curl \ + git \ + iproute2 \ + iptables \ + jq \ + libssl-dev \ + mold \ + nftables \ + openssl \ + perl \ + pkg-config \ + podman \ + protobuf-compiler \ + sqlite3 \ + sudo \ + wireguard-tools + echo "[coold-vm] Enabling Podman socket..." + systemctl enable --now podman.socket + cat >/usr/local/bin/rtk <<'SH' + #!/usr/bin/env bash + exec "$@" + SH + chmod +x /usr/local/bin/rtk + mkdir -p /etc/coolify /var/lib/corrosion /run/corrosion /var/lib/coolify-dev /var/lib/coolify-builder/work + chmod 755 /etc/coolify + chmod 777 /var/lib/corrosion /run/corrosion + + install_coolify_binary() { + name="$1" + version="{{COOLIFY_COOLD_VERSION}}" + arch_raw="$(uname -m)" + case "$arch_raw" in + x86_64) arch=amd64 ;; + aarch64) arch=arm64 ;; + *) echo "unsupported arch: $arch_raw" >&2; exit 1 ;; + esac + + url="https://github.com/coollabsio/coold/releases/download/${version}/${name}-linux-${arch}.tar.gz" + echo "[coold-vm] Installing ${name} from ${url}..." + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + curl -fsSL --retry 3 --max-time 120 -o "$tmpdir/${name}.tar.gz" "$url" + tar -xzf "$tmpdir/${name}.tar.gz" -C "$tmpdir" + test -f "$tmpdir/$name" + install -m 0755 "$tmpdir/$name" "/usr/local/bin/${name}.tmp" + mv "/usr/local/bin/${name}.tmp" "/usr/local/bin/${name}" + echo "$version" > "/usr/local/bin/${name}.version" + } + + install_corrosion_binary() { + version="{{COOLIFY_CORROSION_VERSION}}" + arch_raw="$(uname -m)" + case "$arch_raw" in + x86_64) arch=x86_64-unknown-linux-gnu ;; + aarch64) arch=aarch64-unknown-linux-gnu ;; + *) echo "unsupported arch: $arch_raw" >&2; exit 1 ;; + esac + + url="https://github.com/superfly/corrosion/releases/download/${version}/corrosion-${arch}.tar.gz" + echo "[coold-vm] Installing corrosion from ${url}..." + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + curl -fsSL --retry 3 --max-time 180 -o "$tmpdir/corrosion.tar.gz" "$url" + tar -xzf "$tmpdir/corrosion.tar.gz" -C "$tmpdir" + test -f "$tmpdir/corrosion" + install -m 0755 "$tmpdir/corrosion" /usr/local/bin/corrosion.tmp + mv /usr/local/bin/corrosion.tmp /usr/local/bin/corrosion + echo "$version" > /usr/local/bin/corrosion.version + } + + install_coolify_binary coold + install_coolify_binary builder + install_corrosion_binary + echo "[coold-vm] Endpoint binaries installed." + + echo "[coold-vm] Provisioning complete." diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml index bbb483d7a..164b1b338 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -12,8 +12,8 @@ services: - "${APP_PORT:-8000}:8080" environment: AUTORUN_ENABLED: false - PUSHER_HOST: "${PUSHER_HOST}" - PUSHER_PORT: "${PUSHER_PORT}" + PUSHER_HOST: "${PUSHER_HOST:-}" + PUSHER_PORT: "${PUSHER_PORT:-}" PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index bad72a22d..ea095f17f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,14 +8,18 @@ services: args: - USER_ID=${USERID:-1000} - GROUP_ID=${GROUPID:-1000} + - COOLIFY_FLUX_VERSION=${COOLIFY_FLUX_VERSION:-nightly} ports: - "${APP_PORT:-8000}:8080" - "${FORWARD_FLUX_PORT:-6443}:6443" environment: AUTORUN_ENABLED: false COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}" - PUSHER_HOST: "${PUSHER_HOST}" - PUSHER_PORT: "${PUSHER_PORT}" + COOLIFY_COOLD_VERSION: "${COOLIFY_COOLD_VERSION:-nightly}" + COOLIFY_FLUX_VERSION: "${COOLIFY_FLUX_VERSION:-nightly}" + COOLIFY_CORROSION_VERSION: "${COOLIFY_CORROSION_VERSION:-v1.0.0}" + PUSHER_HOST: "${PUSHER_HOST:-}" + PUSHER_PORT: "${PUSHER_PORT:-}" PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" diff --git a/resources/js/v5/Pages/Home.jsx b/resources/js/v5/Pages/Home.jsx index 0fa9a231b..4ed49d968 100644 --- a/resources/js/v5/Pages/Home.jsx +++ b/resources/js/v5/Pages/Home.jsx @@ -1,6 +1,6 @@ import { Head } from '@inertiajs/react'; -export default function Home({ status, currentTeam, teams, flux }) { +export default function Home({ status, currentTeam, teams, flux, cooldHosts }) { return ( <> @@ -27,6 +27,24 @@ export default function Home({ status, currentTeam, teams, flux }) { {flux.socket ?

Socket: {flux.socket}

: null} +
+

coold host

+ + +
+

Current team

{currentTeam ? ( diff --git a/scripts/coold-vm.sh b/scripts/coold-vm.sh new file mode 100755 index 000000000..627717181 --- /dev/null +++ b/scripts/coold-vm.sh @@ -0,0 +1,591 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +read_coolify_env() { + key="$1" + default_value="$2" + current_value="${!key:-}" + + if [ -n "$current_value" ]; then + printf '%s\n' "$current_value" + return + fi + + if [ -f "$ROOT/.env" ]; then + env_value="$(grep -E "^${key}=" "$ROOT/.env" 2>/dev/null | tail -n1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]$//")" + if [ -n "$env_value" ]; then + printf '%s\n' "$env_value" + return + fi + fi + + printf '%s\n' "$default_value" +} + +INSTANCE="$(read_coolify_env COOLIFY_COOLD_LIMA_INSTANCE coold-dev)" +VERSION="$(read_coolify_env COOLIFY_COOLD_VERSION nightly)" +CORROSION_VERSION="$(read_coolify_env COOLIFY_CORROSION_VERSION v1.0.0)" +FLUX_URL="$(read_coolify_env COOLIFY_COOLD_VM_FLUX_URL http://host.lima.internal:6443)" +BUILDER_CAPACITY="$(read_coolify_env COOLIFY_COOLD_VM_BUILDER_CAPACITY 2)" +WG_IP="$(read_coolify_env COOLIFY_COOLD_VM_WG_IP "")" +WG_PEER_IP="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_IP "")" +WG_PEER_ENDPOINT="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_ENDPOINT "")" +WG_PEER_PUBLIC_KEY="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_PUBLIC_KEY "")" +CONTAINER_SUBNET="$(read_coolify_env COOLIFY_COOLD_VM_CONTAINER_SUBNET 10.210.0.0/24)" +CONTAINER_GATEWAY="$(read_coolify_env COOLIFY_COOLD_VM_CONTAINER_GATEWAY 10.210.0.1)" +BUILDER_ENABLED="true" +if [ "$BUILDER_CAPACITY" = "0" ]; then + BUILDER_ENABLED="false" +fi +TEMPLATE="$ROOT/dev/lima/coold.yaml" +GENERATED="$ROOT/.dev/lima/coold.generated.yaml" +GUEST_COOLIFY_ROOT="/workspace/coolify" + +usage() { + cat < + +Commands: + up Create/start the Lima VM and install packaged coold endpoint binaries + dev Start packaged coold + Corrosion inside the VM + start-agent + Start production-like coold.service + corrosion.service inside the VM + stop-agent + Stop the VM coold.service + corrosion.service + logs-agent + Follow the VM coold.service + corrosion.service logs + install-host-jwt [token] + Install a Flux host JWT into the VM at /etc/coolify/host-jwt + shell Open a shell inside the VM + status Show Lima instance status + stop Stop the VM + delete Delete the VM and all VM-local runtime state + +Environment: + COOLIFY_COOLD_LIMA_INSTANCE Override Lima instance name (default: coold-dev) + COOLIFY_COOLD_VERSION coold release tag to install (default: nightly) + COOLIFY_CORROSION_VERSION corrosion release tag to install (default: v1.0.0) + COOLIFY_COOLD_VM_FLUX_URL Flux gRPC URL visible from the VM (default: http://host.lima.internal:6443) + COOLIFY_COOLD_VM_BUILDER_CAPACITY VM builder capacity to advertise (default: 2; set 0 to disable) + COOLIFY_COOLD_VM_WG_IP Optional WireGuard mgmt IP for this host + COOLIFY_COOLD_VM_CONTAINER_SUBNET Podman mesh subnet for this host + COOLIFY_COOLD_VM_CONTAINER_GATEWAY Podman mesh gateway for this host + +Guest mounts: + $ROOT -> $GUEST_COOLIFY_ROOT + +Installed from: + https://github.com/coollabsio/coold/releases/tag/$VERSION + https://github.com/superfly/corrosion/releases/tag/$CORROSION_VERSION +USAGE +} + +require_lima() { + command -v limactl >/dev/null 2>&1 || { + echo "limactl is required. Install Lima first: brew install lima" >&2 + exit 1 + } +} + +instance_exists() { + limactl list 2>/dev/null | awk 'NR > 1 {print $1}' | grep -qx "$INSTANCE" +} + +instance_running() { + limactl list 2>/dev/null | awk -v name="$INSTANCE" 'NR > 1 && $1 == name {print $2}' | grep -qx Running +} + +lima_shell() { + (cd /tmp && limactl shell "$INSTANCE" -- "$@") +} + +vm_primary_ip() { + lima_shell sh -lc "ip -4 route get 1.1.1.1 | awk '{print \$7; exit}'" +} + +wireguard_public_key() { + lima_shell sudo sh -lc 'install -d -m 0700 /etc/wireguard; if [ ! -s /etc/wireguard/privatekey ]; then wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey; chmod 600 /etc/wireguard/privatekey; fi; cat /etc/wireguard/publickey' +} + +setup_wireguard() { + local ip="${1:-$WG_IP}" + local peer_ip="${2:-$WG_PEER_IP}" + local peer_endpoint="${3:-$WG_PEER_ENDPOINT}" + local peer_public_key="${4:-$WG_PEER_PUBLIC_KEY}" + local listen_port="${5:-51820}" + local peer_port="${6:-51820}" + local peer_subnet="${7:-}" + + if [ -z "$ip" ]; then + echo "ERROR: WireGuard IP is required." >&2 + exit 1 + fi + + wireguard_public_key >/dev/null + + if [ -n "$peer_ip" ] && [ -n "$peer_endpoint" ] && [ -n "$peer_public_key" ]; then + lima_shell sudo sh -lc "cat >/etc/wireguard/wg0.conf.tmp </dev/null 2>&1 || true; wg-quick up wg0" + else + lima_shell sudo sh -lc "cat >/etc/wireguard/wg0.conf.tmp </dev/null 2>&1 || true; wg-quick up wg0" + fi +} + +install_host_jwt() { + token="${1:-}" + + if [ -z "$token" ]; then + token="$(cat)" + fi + + if [ -z "$token" ]; then + echo "ERROR: host JWT is empty." >&2 + exit 1 + fi + + printf '%s\n' "$token" | lima_shell sudo sh -c 'install -d -m 0755 /etc/coolify && cat > /tmp/coolify-host-jwt && install -m 0600 /tmp/coolify-host-jwt /etc/coolify/host-jwt && rm -f /tmp/coolify-host-jwt' +} + + +stop_agent_processes() { + lima_shell sudo systemctl stop coold.service corrosion.service coold-dev-agent.service >/dev/null 2>&1 || true + lima_shell sudo pkill -x coold >/dev/null 2>&1 || true + lima_shell sudo pkill -x corrosion >/dev/null 2>&1 || true +} + +ensure_podman_networks() { + local current_subnet + current_subnet="$(lima_shell sudo podman network inspect coolify-default-mesh --format '{{range .Subnets}}{{.Subnet}}{{end}}' 2>/dev/null || true)" + + if [ -n "$current_subnet" ] && [ "$current_subnet" != "$CONTAINER_SUBNET" ]; then + lima_shell sudo podman network rm coolify-default-mesh >/dev/null + current_subnet="" + fi + + if [ -z "$current_subnet" ]; then + lima_shell sudo podman network create --subnet "$CONTAINER_SUBNET" --gateway "$CONTAINER_GATEWAY" coolify-default-mesh >/dev/null + fi +} + +write_runtime_config() { + local gossip_addr="127.0.0.1:8787" + local bootstrap="" + + if [ -n "$WG_IP" ]; then + gossip_addr="$WG_IP:8787" + fi + + if [ -n "$WG_PEER_IP" ]; then + bootstrap="\"$WG_PEER_IP:8787\"" + fi + + lima_shell sudo install -d -m 0755 /etc/corrosion/schemas /etc/coolify /run/coolify /var/lib/corrosion /var/run/corrosion /var/lib/coolify-dev /var/lib/coolify-builder/work + + lima_shell sudo tee /etc/corrosion/schemas/coolify.sql >/dev/null <<'SQL' +CREATE TABLE service_endpoints ( + container_id TEXT NOT NULL DEFAULT '' PRIMARY KEY, + container_name TEXT NOT NULL DEFAULT '', + namespace TEXT NOT NULL DEFAULT '', + host_mgmt_ip TEXT NOT NULL DEFAULT '', + container_ip TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', + health TEXT NOT NULL DEFAULT 'unknown', + updated_at INTEGER NOT NULL DEFAULT 0 +); +SQL + + lima_shell sudo tee /etc/corrosion/config.toml >/dev/null </dev/null || cat /usr/local/bin/corrosion.version)" +echo "starting packaged coold endpoint with corrosion in local mode" + +cleanup() { + jobs -pr | xargs -r kill || true +} +trap cleanup EXIT INT TERM + +/usr/local/bin/corrosion agent --config /etc/corrosion/config.toml & + +COOLIFY_COOLD_HOST_MGMT_IP="${COOLIFY_COOLD_HOST_MGMT_IP:-127.0.0.1}" \ +COOLIFY_COOLD_PODMAN_SOCKET="${COOLIFY_COOLD_PODMAN_SOCKET:-/run/podman/podman.sock}" \ +COOLIFY_COOLD_CORROSION_URL="${COOLIFY_COOLD_CORROSION_URL:-http://127.0.0.1:8080}" \ +COOLIFY_COOLD_NAMESPACES="${COOLIFY_COOLD_NAMESPACES:-default:coolify-default-mesh:$CONTAINER_GATEWAY}" \ +COOLIFY_COOLD_DNS_ZONE="${COOLIFY_COOLD_DNS_ZONE:-coolify.internal}" \ +COOLIFY_COOLD_API_BIND="${COOLIFY_COOLD_API_BIND:-${WG_IP:-127.0.0.1}:8443}" \ +COOLIFY_COOLD_API_TOKEN_FILE="${COOLIFY_COOLD_API_TOKEN_FILE:-/etc/coolify/api-token}" \ +COOLIFY_COOLD_FLUX_URL="${COOLIFY_COOLD_FLUX_URL:-http://host.lima.internal:6443}" \ +COOLIFY_COOLD_HOST_JWT_PATH="${COOLIFY_COOLD_HOST_JWT_PATH:-/etc/coolify/host-jwt}" \ +COOLIFY_COOLD_BUILDER_ENABLED="${COOLIFY_COOLD_BUILDER_ENABLED:-true}" \ +COOLIFY_COOLD_BUILDER_CAPACITY="${COOLIFY_COOLD_BUILDER_CAPACITY:-2}" \ +COOLIFY_COOLD_BUILDER_BIN="${COOLIFY_COOLD_BUILDER_BIN:-/usr/local/bin/builder}" \ +COOLIFY_COOLD_BUILDER_WORK_DIR="${COOLIFY_COOLD_BUILDER_WORK_DIR:-/var/lib/coolify-builder/work}" \ +/usr/local/bin/coold & + +wait +RUNNER +} + +install_mesh_firewall() { + lima_shell sudo install -d -m 0755 /etc/coolify + lima_shell sudo touch /etc/coolify/allow.rules /etc/coolify/allow.nft + + lima_shell sudo tee /etc/coolify/bridge-fw.nft >/dev/null </dev/null </dev/null || /usr/sbin/iptables -t nat -I POSTROUTING -s $CONTAINER_SUBNET -o wg0 -j RETURN" +ExecStart=/bin/sh -c "/usr/sbin/iptables -D FORWARD -s $CONTAINER_SUBNET -j ACCEPT 2>/dev/null || true" +ExecStart=/bin/sh -c "/usr/sbin/iptables -D FORWARD -d $CONTAINER_SUBNET -j ACCEPT 2>/dev/null || true" +ExecStart=/bin/sh -c "/usr/sbin/iptables -N COOLIFY-ALLOW 2>/dev/null || true" +ExecStart=/bin/sh -c "/usr/sbin/iptables -N COOLIFY-INTRA 2>/dev/null || true" +ExecStart=/usr/sbin/iptables -F COOLIFY-ALLOW +ExecStart=/usr/sbin/iptables -F COOLIFY-INTRA +ExecStart=/usr/sbin/iptables -A COOLIFY-INTRA -j COOLIFY-ALLOW +ExecStart=/usr/sbin/iptables -A COOLIFY-INTRA -j DROP +ExecStart=/bin/sh -c "/usr/sbin/iptables -C FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || /usr/sbin/iptables -I FORWARD 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT" +ExecStart=/bin/sh -c "/usr/sbin/iptables -C FORWARD -d $CONTAINER_SUBNET -j COOLIFY-INTRA 2>/dev/null || /usr/sbin/iptables -A FORWARD -d $CONTAINER_SUBNET -j COOLIFY-INTRA" +ExecStart=/bin/sh -c "/usr/sbin/iptables -C FORWARD -s $CONTAINER_SUBNET -j COOLIFY-INTRA 2>/dev/null || /usr/sbin/iptables -A FORWARD -s $CONTAINER_SUBNET -j COOLIFY-INTRA" +ExecStart=/bin/sh -c "nft delete table bridge coolify_bridge 2>/dev/null || true" +ExecStart=/bin/sh -c "nft -f /etc/coolify/bridge-fw.nft" +ExecStart=/bin/sh -c "[ -s /etc/coolify/allow.nft ] && nft -f /etc/coolify/allow.nft || true" + +[Install] +WantedBy=multi-user.target +UNIT + + lima_shell sudo systemctl daemon-reload + lima_shell sudo systemctl enable --now coolify-mesh-fw.service + lima_shell sudo systemctl restart coolify-mesh-fw.service +} + +start_agent() { + stop_agent_processes + write_runtime_config + ensure_podman_networks + install_mesh_firewall + lima_shell sudo sh -c 'if [ ! -s /etc/coolify/api-token ]; then openssl rand -hex 32 > /etc/coolify/api-token.tmp && chmod 600 /etc/coolify/api-token.tmp && mv /etc/coolify/api-token.tmp /etc/coolify/api-token; fi' + + lima_shell sudo tee /etc/systemd/system/corrosion.service >/dev/null <<'UNIT' +[Unit] +Description=Corrosion local state store +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/local/bin/corrosion agent --config /etc/corrosion/config.toml +Restart=on-failure +RestartSec=2s + +[Install] +WantedBy=multi-user.target +UNIT + + lima_shell sudo tee /etc/systemd/system/coold.service >/dev/null < "$GENERATED" +} + +start_vm() { + generate_yaml + + if instance_running; then + return + fi + + if instance_exists; then + limactl start --tty=false "$INSTANCE" + else + limactl start --tty=false --name="$INSTANCE" "$GENERATED" + fi +} + +latest_lima_message() { + log_file="$HOME/.lima/$INSTANCE/ha.stderr.log" + + if [ ! -f "$log_file" ]; then + echo "creating Lima instance directory" + return 0 + fi + + grep -E '"msg":"(Starting VZ|Waiting for|The essential requirement|Executing /mnt/lima|SSH Local Port|Port is available|Attempting|Downloaded|Using the existing instance|The instance)' "$log_file" | tail -n 1 | sed -E 's/^.*"msg":"(.*)","time":.*$/\1/' | sed 's/\\"/"/g' || true +} + +wait_for_lima_start() { + start_vm & + start_pid=$! + elapsed=0 + + while kill -0 "$start_pid" 2>/dev/null; do + if instance_exists && lima_shell true >/dev/null 2>&1; then + status="$(lima_shell cloud-init status 2>/dev/null || true)" + printf '==> [%3ss] Lima start: guest SSH ready, cloud-init %s +' "$elapsed" "${status:-unknown}" + lima_shell sudo sh -c 'test -f /var/log/cloud-init-output.log && tail -n 12 /var/log/cloud-init-output.log || true' 2>/dev/null | awk '{ print "[guest] " $0; fflush(); }' || true + + if ! printf '%s' "$status" | grep -q running; then + kill "$start_pid" >/dev/null 2>&1 || true + break + fi + else + message="$(latest_lima_message)" + printf '==> [%3ss] Lima start: %s +' "$elapsed" "${message:-booting}" + fi + + sleep 5 + elapsed=$((elapsed + 5)) + done + + wait "$start_pid" 2>/dev/null || true +} + +wait_for_guest_provisioning() { + echo "==> Waiting for guest SSH..." + until instance_exists && lima_shell true >/dev/null 2>&1; do + message="$(latest_lima_message)" + printf '==> Waiting for guest SSH: %s +' "${message:-booting}" + sleep 5 + done + + status="$(lima_shell cloud-init status 2>/dev/null || true)" + + if printf '%s' "$status" | grep -q running; then + echo "==> Guest SSH is ready; streaming cloud-init output until provisioning completes..." + ( + lima_shell sudo sh -c 'touch /var/log/cloud-init-output.log; tail -n 40 -F /var/log/cloud-init-output.log' 2>/dev/null | awk '{ print "[guest] " $0; fflush(); }' + ) & + tail_pid=$! + + while true; do + status="$(lima_shell cloud-init status 2>/dev/null || true)" + printf '==> Guest cloud-init: %s +' "${status:-unknown}" + + if ! printf '%s' "$status" | grep -q running; then + break + fi + + sleep 5 + done + + kill "$tail_pid" >/dev/null 2>&1 || true + wait "$tail_pid" 2>/dev/null || true + else + printf '==> Guest cloud-init: %s +' "${status:-unknown}" + fi + + echo "==> Final guest provisioning status:" + lima_shell bash -lc 'cloud-init status 2>/dev/null || true; if command -v coold >/dev/null; then coold --version; else echo "coold not installed yet"; fi; if command -v corrosion >/dev/null; then echo "corrosion installed"; else echo "corrosion not installed yet"; fi; if command -v builder >/dev/null; then echo "builder installed"; else echo "builder not installed yet"; fi; true' | awk '{ print "[guest] " $0; fflush(); }' || true + + if ! lima_shell bash -lc 'command -v coold >/dev/null && command -v corrosion >/dev/null && command -v builder >/dev/null' >/dev/null 2>&1; then + echo "ERROR: VM provisioning finished but coold, corrosion, or builder is missing." >&2 + echo "Check with: scripts/coold-vm.sh shell" >&2 + exit 1 + fi +} + +up_with_logs() { + echo "==> Coolify coold VM: $INSTANCE" + echo "==> coold package tag: $VERSION" + echo "==> corrosion package tag: $CORROSION_VERSION" + echo "==> Lima config: $GENERATED" + + wait_for_lima_start + wait_for_guest_provisioning + + echo "==> VM is ready. Run: scripts/coold-vm.sh dev" +} + +cmd="${1:-}" +case "$cmd" in + up) + require_lima + up_with_logs + ;; + dev) + require_lima + start_vm >/dev/null + run_foreground + ;; + start-agent) + require_lima + start_vm >/dev/null + start_agent + ;; + stop-agent) + require_lima + if instance_running; then + stop_agent_processes + fi + ;; + logs-agent) + require_lima + start_vm >/dev/null + exec bash -lc "cd /tmp && limactl shell '$INSTANCE' -- sudo journalctl -u coold.service -u corrosion.service -f -n 100" + ;; + install-host-jwt) + require_lima + start_vm >/dev/null + install_host_jwt "${2:-}" + ;; + wg-public-key) + require_lima + start_vm >/dev/null + wireguard_public_key + ;; + vm-ip) + require_lima + start_vm >/dev/null + vm_primary_ip + ;; + setup-wireguard) + require_lima + start_vm >/dev/null + setup_wireguard "${2:-}" "${3:-}" "${4:-}" "${5:-}" "${6:-}" "${7:-}" "${8:-}" + ;; + shell) + require_lima + start_vm >/dev/null + exec bash -lc "cd /tmp && limactl shell '$INSTANCE' -- env TERM=xterm-256color SYSTEMD_PAGER=cat SYSTEMD_LESS=FRXMK bash -l" + ;; + status) + require_lima + if instance_exists; then + exec limactl list "$INSTANCE" + fi + + echo "No Lima instance named '$INSTANCE' exists yet." + echo "Run: scripts/coold-vm.sh up" + ;; + stop) + require_lima + exec limactl stop "$INSTANCE" + ;; + delete|destroy) + require_lima + exec limactl delete --force --tty=false "$INSTANCE" + ;; + -h|--help|help|"") + usage + ;; + *) + echo "unknown command: $cmd" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 000000000..f361a0fac --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,680 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +read_coolify_env() { + key="$1" + default_value="$2" + current_value="${!key:-}" + + if [ -n "$current_value" ]; then + printf '%s\n' "$current_value" + return + fi + + if [ -f .env ]; then + env_value="$(grep -E "^${key}=" .env 2>/dev/null | tail -n1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]$//")" + if [ -n "$env_value" ]; then + printf '%s\n' "$env_value" + return + fi + fi + + printf '%s\n' "$default_value" +} + +coold_vm_count() { + local count + count="$(read_coolify_env COOLIFY_COOLD_VM_COUNT 2)" + + if [ "$count" != "1" ] && [ "$count" != "2" ]; then + echo "ERROR: COOLIFY_COOLD_VM_COUNT supports 1 or 2 for now." >&2 + exit 1 + fi + + printf '%s\n' "$count" +} + +coold_vm_instance() { + local index="$1" + local base + base="$(read_coolify_env COOLIFY_COOLD_LIMA_INSTANCE coold-dev)" + + if [ "$index" = "1" ]; then + printf '%s\n' "$base" + return + fi + + read_coolify_env "COOLIFY_COOLD_LIMA_INSTANCE_${index}" "${base}-${index}" +} + +coold_vm_wg_ip() { + local index="$1" + read_coolify_env "COOLIFY_COOLD_VM_WG_IP_${index}" "100.64.0.$((9 + index))" +} + +coold_vm_wg_port() { + local index="$1" + read_coolify_env "COOLIFY_COOLD_VM_WG_PORT_${index}" "$((51820 + index))" +} + +coold_vm_container_subnet() { + local index="$1" + read_coolify_env "COOLIFY_COOLD_VM_CONTAINER_SUBNET_${index}" "10.210.$((index - 1)).0/24" +} + +coold_vm_container_gateway() { + local index="$1" + read_coolify_env "COOLIFY_COOLD_VM_CONTAINER_GATEWAY_${index}" "10.210.$((index - 1)).1" +} + +coold_vm() { + local index="$1" + shift + COOLIFY_COOLD_LIMA_INSTANCE="$(coold_vm_instance "$index")" \ + COOLIFY_COOLD_VM_WG_IP="$(coold_vm_wg_ip "$index")" \ + COOLIFY_COOLD_VM_CONTAINER_SUBNET="$(coold_vm_container_subnet "$index")" \ + COOLIFY_COOLD_VM_CONTAINER_GATEWAY="$(coold_vm_container_gateway "$index")" \ + scripts/coold-vm.sh "$@" +} + +mint_host_jwt_for_host() { + local host_id="$1" + local attempts=60 + local output + local caps + local builder_capacity + + builder_capacity="$(read_coolify_env COOLIFY_COOLD_VM_BUILDER_CAPACITY 2)" + caps="coold" + if [ "$builder_capacity" != "0" ]; then + caps="coold,builder" + fi + + for attempt in $(seq 1 "$attempts"); do + if output="$(spin exec -T coolify php artisan flux:dev "$host_id" --caps="$caps" 2>&1)"; then + printf '%s\n' "$output" | tail -n 1 + return 0 + fi + + echo "==> Waiting for Flux dev JWT key for ${host_id} (${attempt}/${attempts})..." >&2 + printf '%s\n' "$output" | tail -n 3 | sed 's/^/[coolify] /' >&2 + sleep 2 + done + + echo "ERROR: Could not mint Flux dev host JWT for ${host_id}." >&2 + return 1 +} + +setup_wireguard_mesh() { + local count="$1" + + if [ "$count" -lt 2 ]; then + return + fi + + echo "==> Configuring WireGuard mesh for ${count} coold VMs..." + + local pub1 pub2 ip1 ip2 port1 port2 subnet1 subnet2 + pub1="$(coold_vm 1 wg-public-key)" + pub2="$(coold_vm 2 wg-public-key)" + ip1="$(coold_vm_wg_ip 1)" + ip2="$(coold_vm_wg_ip 2)" + port1="$(coold_vm_wg_port 1)" + port2="$(coold_vm_wg_port 2)" + subnet1="$(coold_vm_container_subnet 1)" + subnet2="$(coold_vm_container_subnet 2)" + + coold_vm 1 setup-wireguard "$ip1" "$ip2" "host.lima.internal" "$pub2" "$port1" "$port2" "$subnet2" + coold_vm 2 setup-wireguard "$ip2" "$ip1" "host.lima.internal" "$pub1" "$port2" "$port1" "$subnet1" +} + +follow_logs() { + local coold_vm_enabled="$1" + local count + local vm_logs_pids="" + count="$(coold_vm_count)" + + echo "==> Following dev logs. Press Ctrl-C to stop the dev environment." + + cleanup_logs() { + for pid in $vm_logs_pids; do + kill "$pid" >/dev/null 2>&1 || true + done + } + + stop_from_signal() { + trap - INT TERM EXIT + cleanup_logs + echo + echo "==> Ctrl-C received; stopping dev environment..." + down + exit 130 + } + + trap cleanup_logs EXIT + trap stop_from_signal INT TERM + + if [ "$coold_vm_enabled" != "false" ]; then + for index in $(seq 1 "$count"); do + instance="$(coold_vm_instance "$index")" + coold_vm "$index" logs-agent | sed "s/^/[${instance}] /" & + vm_logs_pids="$vm_logs_pids $!" + done + fi + + spin logs -f +} + +up() { + local coold_vm_enabled + local follow_dev_logs + local count + coold_vm_enabled="$(read_coolify_env COOLIFY_COOLD_VM_ENABLED true)" + follow_dev_logs="$(read_coolify_env COOLIFY_DEV_FOLLOW_LOGS true)" + count="$(coold_vm_count)" + + if [ "$coold_vm_enabled" != "false" ]; then + echo "==> Starting ${count} Coolify coold VM(s) before Spin..." + for index in $(seq 1 "$count"); do + coold_vm "$index" up + done + setup_wireguard_mesh "$count" + else + echo "==> COOLIFY_COOLD_VM_ENABLED=false; skipping coold VM." + fi + + echo "==> Starting Coolify Docker stack with Spin..." + spin up -d "$@" + + if [ "$coold_vm_enabled" != "false" ]; then + for index in $(seq 1 "$count"); do + host_id="$(coold_vm_instance "$index")" + echo "==> Minting Flux dev host JWT for ${host_id}..." + host_jwt="$(mint_host_jwt_for_host "$host_id")" + echo "==> Installing host JWT into ${host_id}..." + coold_vm "$index" install-host-jwt "$host_jwt" + done + + for index in $(seq 1 "$count"); do + echo "==> Starting coold VM agent service on $(coold_vm_instance "$index")..." + if [ "$count" -ge 2 ]; then + peer_index=1 + if [ "$index" = "1" ]; then + peer_index=2 + fi + COOLIFY_COOLD_VM_WG_PEER_IP="$(coold_vm_wg_ip "$peer_index")" coold_vm "$index" start-agent + else + coold_vm "$index" start-agent + fi + done + fi + + if [ "$follow_dev_logs" = "false" ]; then + echo "==> Dev environment is ready. Use 'spin logs -f' or 'scripts/coold-vm.sh logs-agent' to follow logs." + return + fi + + follow_logs "$coold_vm_enabled" +} + +down() { + local coold_vm_enabled + local stop_coold_vm + coold_vm_enabled="$(read_coolify_env COOLIFY_COOLD_VM_ENABLED true)" + stop_coold_vm="$(read_coolify_env COOLIFY_COOLD_VM_STOP_ON_DOWN false)" + + if [ "$coold_vm_enabled" != "false" ]; then + for index in $(seq 1 "$(coold_vm_count)"); do + echo "==> Stopping coold VM agent service on $(coold_vm_instance "$index")..." + coold_vm "$index" stop-agent || true + done + fi + + echo "==> Stopping Coolify Docker stack with Spin..." + spin down "$@" + + if [ "$stop_coold_vm" = "true" ]; then + echo "==> Stopping Coolify coold VM..." + for index in $(seq 1 "$(coold_vm_count)"); do + coold_vm "$index" stop + done + fi +} + +corrosion_for_each_vm() { + local label="$1" + shift + local count + local script + count="$(coold_vm_count)" + script="$(cat)" + + for index in $(seq 1 "$count"); do + instance="$(coold_vm_instance "$index")" + echo "--- ${instance}: ${label} ---" + printf '%s\n' "$script" | COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell "$@" + done +} + +corrosion_check() { + local count + count="$(coold_vm_count)" + + for index in $(seq 1 "$count"); do + instance="$(coold_vm_instance "$index")" + peer_index=1 + if [ "$index" = "1" ]; then + peer_index=2 + fi + peer_ip="$(coold_vm_wg_ip "$peer_index")" + + echo "--- ${instance}: check ---" + COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell </dev/null 2>&1 && echo active || echo unavailable +printf 'peer ping (${peer_ip}): ' +ping -c 1 -W 2 ${peer_ip} >/dev/null 2>&1 && echo ok || echo failed +printf 'gossip: ' +awk '/^\[gossip\]/{section=1; next} /^\[/{section=0} section && /^addr =|^bootstrap =/ {printf "%s ", \$0} END {print ""}' /etc/corrosion/config.toml +printf 'registered containers: ' +sudo sqlite3 /var/lib/corrosion/corrosion.db 'select count(*) from service_endpoints;' 2>/dev/null || echo unavailable +SH + done +} + +corrosion_containers() { + corrosion_for_each_vm containers <<'SH' +echo '[corrosion service_endpoints]' +sudo sqlite3 -header -column /var/lib/corrosion/corrosion.db \ + 'select container_id, container_name, namespace, host_mgmt_ip, container_ip, state, health, updated_at from service_endpoints order by updated_at desc;' 2>/dev/null \ + || echo 'service_endpoints table unavailable' + +echo +echo '[rootful podman]' +sudo podman ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null \ + || echo 'rootful podman unavailable' + +echo +echo '[rootless podman]' +podman ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null \ + || echo 'rootless podman unavailable' +SH +} + +corrosion_config() { + corrosion_for_each_vm config <<'SH' +sudo cat /etc/corrosion/config.toml +SH +} + +corrosion_logs() { + local index="${1:-1}" + coold_vm "$index" logs-agent +} + +corrosion_sql() { + local query="$*" + + if [ -z "$query" ]; then + echo "Usage: scripts/dev.sh corrosion sql " >&2 + exit 1 + fi + + corrosion_for_each_vm sql < + +Commands: + check Show Corrosion service, WireGuard, gossip, and row-count state + containers List registered service_endpoints rows on each VM + config Print /etc/corrosion/config.toml from each VM + logs [n] Follow coold/corrosion logs for VM n (default: 1) + sql Run a read-only sqlite query against each Corrosion DB +USAGE + ;; + *) + echo "unknown corrosion command: $command" >&2 + echo "Run: scripts/dev.sh corrosion help" >&2 + exit 1 + ;; + esac +} + +example_nginx_name() { + local index="$1" + + if [ "$index" = "1" ]; then + printf '%s\n' coolify-example-nginx + return + fi + + printf 'coolify-example-nginx-%s\n' "$index" +} + +example_nginx_up() { + local count + count="$(coold_vm_count)" + + for index in $(seq 1 "$count"); do + instance="$(coold_vm_instance "$index")" + name="$(example_nginx_name "$index")" + gateway="$(coold_vm_container_gateway "$index")" + + echo "--- ${instance}: starting ${name} with coold DNS (${gateway}) ---" + COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell </dev/null 2>&1 || true +sudo podman run -d \\ + --name ${name} \\ + --network coolify-default-mesh \\ + --dns ${gateway} \\ + --dns-search default.coolify.internal \\ + docker.io/library/nginx:alpine +SH + done +} + +example_nginx_down() { + local count + count="$(coold_vm_count)" + + for index in $(seq 1 "$count"); do + instance="$(coold_vm_instance "$index")" + name="$(example_nginx_name "$index")" + + echo "--- ${instance}: removing ${name} ---" + COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell </dev/null 2>&1 || true +SH + done +} + +example_nginx_check_dns() { + COOLIFY_COOLD_LIMA_INSTANCE="$(coold_vm_instance 1)" scripts/coold-vm.sh shell <<'SH' +set -e +echo '--- resolv.conf ---' +sudo podman exec coolify-example-nginx cat /etc/resolv.conf +echo +echo '--- coold DNS lookup through search domain ---' +sudo podman exec coolify-example-nginx nslookup coolify-example-nginx-2 +echo +echo '--- coold DNS lookup by full name ---' +sudo podman exec coolify-example-nginx nslookup coolify-example-nginx-2.default.coolify.internal +SH +} + +example_nginx_help() { + cat <<'USAGE' +Usage: scripts/dev.sh example-nginx + +Commands: + up Start one nginx container on each coold VM with coold DNS configured + down Remove the example nginx containers + check-dns Verify host 1 nginx can resolve host 2 nginx through coold DNS +USAGE +} + +example_nginx() { + local command="${1:-help}" + if [ $# -gt 0 ]; then + shift + fi + + case "$command" in + up) + example_nginx_up + ;; + down) + example_nginx_down + ;; + check-dns) + example_nginx_check_dns + ;; + -h|--help|help) + example_nginx_help + ;; + *) + echo "unknown example-nginx command: $command" >&2 + echo "Run: scripts/dev.sh example-nginx help" >&2 + exit 1 + ;; + esac +} + +firewall_help() { + cat <<'USAGE' +Usage: scripts/dev.sh firewall + +Commands: + allow [proto] [port] Allow traffic on every coold VM (proto/port optional) + revoke [id|src] [dst] [proto] [port] + Remove an allow rule from every coold VM + list List allow rules on every coold VM + reconcile Re-apply firewall snapshot on every coold VM + +Examples: + scripts/dev.sh firewall allow 10.210.0.2 10.210.1.2 tcp 80 + scripts/dev.sh firewall revoke + scripts/dev.sh firewall revoke 10.210.0.2 10.210.1.2 tcp 80 + scripts/dev.sh firewall revoke 3ba6e0c235a6 + scripts/dev.sh firewall list +USAGE +} + +firewall_api_for_each_vm() { + local label="$1" + local method="$2" + local path="$3" + local body="${4:-}" + local count + count="$(coold_vm_count)" + + for index in $(seq 1 "$count"); do + instance="$(coold_vm_instance "$index")" + api_ip="$(coold_vm_wg_ip "$index")" + echo "--- ${instance}: ${label} ---" + COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell <&2 + exit 1 + fi + + if [ -n "$port" ] && [ -z "$proto" ]; then + echo "ERROR: port requires proto (tcp or udp)." >&2 + exit 1 + fi + + body="$(printf '{"namespace":"default","src":"%s","dst":"%s"' "$src" "$dst")" + if [ -n "$proto" ]; then + body="${body}$(printf ',"proto":"%s"' "$proto")" + fi + if [ -n "$port" ]; then + body="${body}$(printf ',"port":%s' "$port")" + fi + body="${body}}" + + firewall_api_for_each_vm "allow ${src} -> ${dst}" POST /api/v1/firewall/allow "$body" +} + +firewall_rule_id() { + local src="$1" + local dst="$2" + local proto="${3:-}" + local port="${4:-0}" + + if [ -z "$proto" ]; then + port=0 + fi + + printf 'default|%s|%s|%s|%s' "$src" "$dst" "$proto" "$port" \ + | shasum -a 256 \ + | awk '{print substr($1, 1, 12)}' +} + +firewall_revoke() { + local id_or_src="${1:-}" + local dst="${2:-}" + local proto="${3:-}" + local port="${4:-}" + local id + + if [ -z "$id_or_src" ]; then + echo "Current firewall allow rule IDs:" + firewall_list + echo + echo "Revoke one with: scripts/dev.sh firewall revoke " + return + fi + + if [ -z "$dst" ]; then + id="$id_or_src" + else + id="$(firewall_rule_id "$id_or_src" "$dst" "$proto" "$port")" + fi + + firewall_api_for_each_vm "revoke ${id}" DELETE "/api/v1/firewall/allow/${id}" +} + +firewall_list() { + firewall_api_for_each_vm list GET '/api/v1/firewall/allow?namespace=default' +} + +firewall_reconcile() { + firewall_api_for_each_vm reconcile POST /api/v1/firewall/reconcile +} + +firewall() { + local command="${1:-help}" + if [ $# -gt 0 ]; then + shift + fi + + case "$command" in + allow) + firewall_allow "$@" + ;; + revoke|remove|delete|deny) + firewall_revoke "$@" + ;; + list) + firewall_list + ;; + reconcile) + firewall_reconcile + ;; + -h|--help|help) + firewall_help + ;; + *) + echo "unknown firewall command: $command" >&2 + echo "Run: scripts/dev.sh firewall help" >&2 + exit 1 + ;; + esac +} + +usage() { + cat <<'USAGE' +Usage: scripts/dev.sh [spin args] + +Commands: + up Start the coold VM, Spin stack, and dev coold agent + down Stop the dev coold agent and Spin stack + shell [n] Open a shell inside coold VM n (default: 1) + list Show Lima instances + corrosion Inspect Corrosion state, config, logs, and registered containers + firewall Manage dev coold firewall allow rules + example-nginx Start/check example nginx containers with coold DNS +USAGE +} + +cmd="${1:-}" +if [ $# -gt 0 ]; then + shift +fi + +case "$cmd" in + up) + up "$@" + ;; + down) + down "$@" + ;; + shell) + coold_vm "${1:-1}" shell + ;; + list) + limactl list + ;; + corrosion) + corrosion "$@" + ;; + firewall) + firewall "$@" + ;; + example-nginx) + example_nginx "$@" + ;; + -h|--help|help|"") + usage + ;; + *) + echo "unknown command: $cmd" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/tests/Feature/DevEnvironmentExampleTest.php b/tests/Feature/DevEnvironmentExampleTest.php new file mode 100644 index 000000000..d5f2320cd --- /dev/null +++ b/tests/Feature/DevEnvironmentExampleTest.php @@ -0,0 +1,31 @@ +not->toContain($variable.'='); +})->with([ + 'coold package version default' => 'COOLIFY_COOLD_VERSION', + 'flux package version default' => 'COOLIFY_FLUX_VERSION', + 'corrosion package version default' => 'COOLIFY_CORROSION_VERSION', + 'coold VM count default' => 'COOLIFY_COOLD_VM_COUNT', + 'coold VM flux URL default' => 'COOLIFY_COOLD_VM_FLUX_URL', + 'coold VM WireGuard IP 1 default' => 'COOLIFY_COOLD_VM_WG_IP_1', + 'coold VM WireGuard IP 2 default' => 'COOLIFY_COOLD_VM_WG_IP_2', + 'coold VM WireGuard port 1 default' => 'COOLIFY_COOLD_VM_WG_PORT_1', + 'coold VM WireGuard port 2 default' => 'COOLIFY_COOLD_VM_WG_PORT_2', + 'coold VM builder capacity default' => 'COOLIFY_COOLD_VM_BUILDER_CAPACITY', + 'coold VM enabled default' => 'COOLIFY_COOLD_VM_ENABLED', + 'coold VM stop on down default' => 'COOLIFY_COOLD_VM_STOP_ON_DOWN', + 'dev follow logs default' => 'COOLIFY_DEV_FOLLOW_LOGS', +]); + +it('defaults coold dev VM settings in Laravel config', function () { + expect(config('coold.dev_host_count'))->toBe(2) + ->and(config('coold.dev_host_id'))->toBe('coolify-coold-dev') + ->and(config('coold.dev_host_id_2'))->toBe('coolify-coold-dev-2') + ->and(config('coold.dev_wireguard_ip_1'))->toBe('100.64.0.10') + ->and(config('coold.dev_wireguard_ip_2'))->toBe('100.64.0.11') + ->and(config('coold.dev_builder_capacity'))->toBe(2) + ->and(config('coold.dev_builder_enabled'))->toBeTrue(); +}); diff --git a/tests/Feature/FluxDevCommandTest.php b/tests/Feature/FluxDevCommandTest.php new file mode 100644 index 000000000..6c9a3c810 --- /dev/null +++ b/tests/Feature/FluxDevCommandTest.php @@ -0,0 +1,65 @@ + 'coold-dev', + '--caps' => 'coold,builder', + '--ttl' => '600', + ]); + + expect($exitCode)->toBe(0); + + $token = trim(Artisan::output()); + $claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256')); + + expect($claims->sub)->toBe('coold-dev') + ->and($claims->aud)->toBe('coold') + ->and($claims->caps)->toBe(['coold', 'builder']) + ->and($claims->exp)->toBeGreaterThan(time()); +}); + +it('writes the host jwt to an output path with owner-only permissions', function () { + [$privateKeyPath] = createFluxJwtKeypair(); + $outputPath = storage_path('framework/testing/host-jwt'); + + Config::set('flux.jwt_private_key_path', $privateKeyPath); + + $exitCode = Artisan::call('flux:dev', [ + 'host_id' => 'coold-dev', + '--output' => $outputPath, + ]); + + expect($exitCode)->toBe(0); + + expect($outputPath)->toBeFile() + ->and(substr(sprintf('%o', fileperms($outputPath)), -4))->toBe('0600'); +}); + +/** + * @return array{0: string, 1: string} + */ +function createFluxJwtKeypair(): array +{ + $directory = storage_path('framework/testing/flux-keys-'.bin2hex(random_bytes(4))); + mkdir($directory, 0777, true); + + $privateKeyPath = $directory.'/jwt.priv'; + $publicKeyPath = $directory.'/jwt.pub'; + + exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $output, $exitCode); + expect($exitCode)->toBe(0); + + exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $output, $exitCode); + expect($exitCode)->toBe(0); + + return [$privateKeyPath, $publicKeyPath]; +} diff --git a/tests/Feature/V5/HomeTest.php b/tests/Feature/V5/HomeTest.php index c37b47496..d4b4b96dc 100644 --- a/tests/Feature/V5/HomeTest.php +++ b/tests/Feature/V5/HomeTest.php @@ -105,6 +105,12 @@ it('serves the v5 inertia shell', function () { ->assertSee('v5-ready', false) ->assertSee('Running') ->assertSee('Flux is running.') + ->assertSee('coolify-coold-dev') + ->assertSee('coolify-coold-dev-2') + ->assertSee('100.64.0.10') + ->assertSee('100.64.0.11') + ->assertSee('builder') + ->assertSee('builderCapacity') ->assertSee('V5 Shared Team') ->assertSee('Shared team details') ->assertSee('owner')