#!/usr/bin/env bash set -euo pipefail log() { printf '%s\n' "$*"; } die() { printf 'Error: %s\n' "$*" >&2; exit 1; } # Simple ASCII spinner for waits that would otherwise sit silent (e.g. polling for container # startup) — skipped entirely when stdout isn't a real terminal (piped to a log file, redirected to # a file, etc.), since the carriage-return/escape-code redraw would just show up as garbage there. SPINNER_FRAMES='-\|/' spinner_tick() { local index="$1" label="$2" [[ -t 1 ]] || return 0 local frame="${SPINNER_FRAMES:$(( index % ${#SPINNER_FRAMES} )):1}" printf '\r%s %s' "$frame" "$label" } spinner_clear() { [[ -t 1 ]] || return 0 printf '\r\033[K' } IONIDE_BOT_IMAGE_BASE="${IONIDE_BOT_IMAGE_BASE:-registry.ionide.net/ionide-bot}" IONIDE_REGISTRY_PULL_USER="${IONIDE_REGISTRY_PULL_USER:-ionide-public}" IONIDE_REGISTRY_PULL_PASSWORD="${IONIDE_REGISTRY_PULL_PASSWORD:-ionide-bot-pull-only}" CONTAINER_NAME="ionide-discord-bot" INSTALL_DIR="${IONIDE_BOT_INSTALL_DIR:-/opt/ionide-bot}" RECONFIGURE=false VERSION_TAG="latest" # --version pins a specific release tag (e.g. --version v0.1.5-alpha) instead of the default # :latest. IONIDE_BOT_IMAGE_BASE (registry host/repo, not the tag) is still env-var overridable # separately — the two are orthogonal knobs, not a duplicate of this one. while [[ $# -gt 0 ]]; do case "$1" in --reconfigure) RECONFIGURE=true shift ;; --version) [[ $# -ge 2 ]] || die "--version requires a tag argument, e.g. --version v1.0.0" VERSION_TAG="$2" shift 2 ;; --version=*) VERSION_TAG="${1#--version=}" shift ;; *) die "Unknown argument: $1 (supported: --reconfigure, --version )" ;; esac done IONIDE_BOT_IMAGE="${IONIDE_BOT_IMAGE_BASE}:${VERSION_TAG}" prompt() { local __var="$2" [[ -r /dev/tty ]] || die "This needs an interactive terminal to prompt for input, but none is available. Run it in a real terminal: bash install.sh" read -rp "$1" "$__var" < /dev/tty } cat <<'BANNER' ██╗ ██████╗ ███╗ ██╗██╗██████╗ ███████╗ ██║██╔═══██╗████╗ ██║██║██╔══██╗██╔════╝ ██║██║ ██║██╔██╗ ██║██║██║ ██║█████╗ ██║██║ ██║██║╚██╗██║██║██║ ██║██╔══╝ ██║╚██████╔╝██║ ╚████║██║██████╔╝███████╗ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝╚═════╝ ╚══════╝ BANNER log "" docker_ready() { command -v docker >/dev/null 2>&1 } if ! docker_ready; then log "Docker was not found." prompt "Install Docker now? [y/N]: " INSTALL_DOCKER if [[ "$INSTALL_DOCKER" =~ ^[Yy]$ ]]; then log "Installing Docker via the official convenience script (https://get.docker.com)..." if [[ "$(id -u)" -eq 0 ]]; then curl -fsSL https://get.docker.com | sh else command -v sudo >/dev/null 2>&1 || die "Docker install needs root and 'sudo' isn't available. Re-run this script as root." curl -fsSL https://get.docker.com | sudo sh fi docker_ready || die "Docker install finished but 'docker' still isn't available. Install manually: https://docs.docker.com/engine/install/" log "Docker installed successfully." else die "Docker is required. Install it manually: https://docs.docker.com/engine/install/" fi fi if ! docker info >/dev/null 2>&1; then if [[ "$(id -u)" -eq 0 ]]; then die "Docker is installed, but the daemon isn't reachable even as root — is the service running? Try: sudo systemctl start docker" fi die "Docker is installed, but this user can't reach its daemon (not in the 'docker' group yet). Either re-run this script with sudo (curl -fsSL https://install.ionide.net | sudo bash), or run 'sudo usermod -aG docker \"\$USER\"' and log back in before trying again." fi command -v openssl >/dev/null 2>&1 || die "openssl is required but not found. Needed to generate the bot's secrets-encryption key." if [[ ! -d "$INSTALL_DIR" ]]; then if [[ "$(id -u)" -eq 0 ]]; then mkdir -p "$INSTALL_DIR" else command -v sudo >/dev/null 2>&1 || die "Creating $INSTALL_DIR needs root and 'sudo' isn't available. Re-run this script as root." sudo mkdir -p "$INSTALL_DIR" sudo chown "$(id -u):$(id -g)" "$INSTALL_DIR" fi fi mkdir -p "$INSTALL_DIR/bot-data" chmod 777 "$INSTALL_DIR/bot-data" cd "$INSTALL_DIR" ENV_FILE="$INSTALL_DIR/.env" if [[ -f "$ENV_FILE" && "$RECONFIGURE" == "false" ]]; then log "Existing install found at $INSTALL_DIR — skipping setup, pulling the latest image." BOT_TOKEN="$(grep -m1 '^BOT_TOKEN=' "$ENV_FILE" | cut -d= -f2-)" else log "Setting up ionide-bot in $INSTALL_DIR" log "" log "Before you continue, please read:" log " Terms of Service: https://ionide.net/terms" log " Privacy Policy: https://ionide.net/privacy" log "" prompt "Type y/yes to agree and continue [y/N]: " AGREE_TERMS [[ "$AGREE_TERMS" =~ ^[Yy]([Ee][Ss])?$ ]] || die "You must agree to the Terms of Service and Privacy Policy to continue." log "" log "You'll need a Discord bot application first (if you don't have one yet):" log " 1. https://discord.com/developers/applications -> New Application" log " 2. Bot tab -> Reset Token -> copy it" log "" prompt "Discord bot token: " BOT_TOKEN [[ -n "$BOT_TOKEN" ]] || die "A Discord bot token is required." SECRETS_ENCRYPTION_KEY="$(openssl rand -hex 32)" cat > "$ENV_FILE" </dev/null log "Pulling ${IONIDE_BOT_IMAGE}..." docker pull "$IONIDE_BOT_IMAGE" "$INSTALL_DIR/run-bot.sh" <> "$INSTALL_DIR/run-bot.sh" <<'RUNBOT_BODY' TAG="${1:?usage: run-bot.sh }" docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true docker run -d \ --name "$CONTAINER_NAME" \ --restart unless-stopped \ --memory=128m \ --env-file "$INSTALL_DIR/.env" \ -v "$INSTALL_DIR/bot-data:/app/data:z" \ "$IONIDE_BOT_IMAGE_BASE:$TAG" RUNBOT_BODY chmod +x "$INSTALL_DIR/run-bot.sh" # update-sidecar.sh is a separate always-running container (started below) that watches for # bot-data/update-requested.json (written by the bot's own /update-check -> Approve button flow — # see UpdateSignalWriter) and applies it: pull, stop, snapshot the H2 file, restart via run-bot.sh, # then wait for bot-data/ready (written by ReadinessWriter once the gateway is actually connected — # deliberately not the Docker healthcheck's heartbeat file, which stays fresh even on a broken # boot) before declaring success. On failure it restores the snapshot and restarts pinned to the # previous tag automatically — see docs/01_project/decisions/ionide-bot-auto-update-architecture.md. Idle (a few KB # RAM beyond the base Alpine footprint) whenever no update is in flight; harmless to always run. cat > "$INSTALL_DIR/update-sidecar.sh" <> "$INSTALL_DIR/update-sidecar.sh" <<'SIDECAR_BODY' BOT_DATA_DIR="$INSTALL_DIR/bot-data" SIGNAL_FILE="$BOT_DATA_DIR/update-requested.json" READY_FILE="$BOT_DATA_DIR/ready" BACKUP_DIR="$BOT_DATA_DIR/backups" DB_FILE="$BOT_DATA_DIR/ionide_bot_data.mv.db" CURRENT_TAG_FILE="$BOT_DATA_DIR/current-tag" LAST_FAILURE_FILE="$BOT_DATA_DIR/last-update-failure" # Structured counterpart to LAST_FAILURE_FILE -- UpdateFailureNotifierJob polls this one (JSON, # machine-readable) to DM bot owners about a failed/rolled-back update, then deletes it. Kept # alongside LAST_FAILURE_FILE (still just a human-readable breadcrumb on the host) rather than # replacing it, so nothing that already inspects that file needs to change. FAILURE_MARKER_FILE="$BOT_DATA_DIR/update-failure.json" POLL_INTERVAL_SECONDS=30 READY_TIMEOUT_SECONDS=120 BACKUP_RETENTION=2 log() { echo "[update-sidecar] $*"; } printf '%s' "$IONIDE_REGISTRY_PULL_PASSWORD" | docker login -u "$IONIDE_REGISTRY_PULL_USER" --password-stdin "${IONIDE_BOT_IMAGE_BASE%%/*}" >/dev/null 2>&1 \ || log "docker login failed -- pull attempts below may fail too." extract_target_version() { sed -n 's/.*"targetVersion":"\([^"]*\)".*/\1/p' "$SIGNAL_FILE" | head -1 } wait_for_ready() { i=0 while [ "$i" -lt "$READY_TIMEOUT_SECONDS" ]; do [ -f "$READY_FILE" ] && return 0 running="$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" [ "$running" = "true" ] || return 1 sleep 1 i=$((i + 1)) done return 1 } prune_backups() { ls -1t "$BACKUP_DIR"/*.mv.db 2>/dev/null | tail -n +$((BACKUP_RETENTION + 1)) | while IFS= read -r stale; do rm -f "$stale" done } apply_update() { target_tag="$1" previous_tag="$(cat "$CURRENT_TAG_FILE" 2>/dev/null || echo "")" image="$IONIDE_BOT_IMAGE_BASE:$target_tag" log "Update requested: ${previous_tag:-unknown} -> $target_tag" if ! docker pull "$image"; then log "Pull of $image failed -- aborting, no changes made." rm -f "$SIGNAL_FILE" return fi log "Stopping $CONTAINER_NAME" docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true mkdir -p "$BACKUP_DIR" backup_file="$BACKUP_DIR/pre-update-$(date +%s)-${previous_tag:-unknown}.mv.db" if [ -f "$DB_FILE" ]; then cp "$DB_FILE" "$backup_file" log "Backed up database to $backup_file" fi rm -f "$READY_FILE" sh "$INSTALL_DIR/run-bot.sh" "$target_tag" if wait_for_ready; then log "Update to $target_tag succeeded." echo "$target_tag" > "$CURRENT_TAG_FILE" rm -f "$SIGNAL_FILE" "$LAST_FAILURE_FILE" prune_backups else log "Update to $target_tag failed its readiness check -- rolling back to ${previous_tag:-the previous release}." docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true if [ -f "$backup_file" ]; then cp "$backup_file" "$DB_FILE" fi if [ -n "$previous_tag" ]; then sh "$INSTALL_DIR/run-bot.sh" "$previous_tag" else docker start "$CONTAINER_NAME" >/dev/null 2>&1 || true fi echo "Update to $target_tag failed and was rolled back to ${previous_tag:-the previous release} at $(date -u +%FT%TZ)" > "$LAST_FAILURE_FILE" printf '{"targetVersion":"%s","previousVersion":"%s","timestamp":"%s"}' \ "$target_tag" "$previous_tag" "$(date -u +%FT%TZ)" > "$FAILURE_MARKER_FILE" rm -f "$SIGNAL_FILE" fi } log "Watching $SIGNAL_FILE every ${POLL_INTERVAL_SECONDS}s" while true; do if [ -f "$SIGNAL_FILE" ]; then target_tag="$(extract_target_version)" if [ -n "$target_tag" ]; then apply_update "$target_tag" else log "Signal file present but unparsable -- removing." rm -f "$SIGNAL_FILE" fi fi sleep "$POLL_INTERVAL_SECONDS" done SIDECAR_BODY chmod +x "$INSTALL_DIR/update-sidecar.sh" sh "$INSTALL_DIR/run-bot.sh" "$VERSION_TAG" /dev/null READY_MARKER="Discord bot started." STARTED=false # 90s, not 45s: a real startup failure (e.g. the gateway connection genuinely can't complete) # takes up to 30s to surface on its own before the process exits, so one full crash-then-restart # cycle under --restart unless-stopped can already eat most of a 45s budget. A single transient # "restarting"/"exited" snapshot used to end this loop immediately — but that state is also what a # *successful* one-off restart looks like for the second or two it takes to come back up, and # treating it as fatal produced a false-negative install failure even though the container went on # to start and run fine afterward (confirmed: docker logs showed "Discord bot started." well within # this budget on the very next restart). Bail out early only on a real crash loop — several # restarts already recorded — not a single transient blip. log "Waiting for the bot to report a successful startup..." LOGS_ERROR_STREAK=0 for i in $(seq 1 90); do spinner_tick "$i" "waiting (${i}s)..." if CONTAINER_LOGS="$(docker logs "$CONTAINER_NAME" &1)"; then LOGS_ERROR_STREAK=0 if grep -qF "$READY_MARKER" <<<"$CONTAINER_LOGS"; then STARTED=true break fi else # `docker logs` itself failing (not "no marker yet") is a different problem than a slow # startup — surface it instead of silently spinning for the full 90s indistinguishable from # a genuinely slow bot (this is what made a real prior failure hard to diagnose after the # fact: nothing recorded that the check itself, not the bot, was the thing failing). LOGS_ERROR_STREAK=$((LOGS_ERROR_STREAK + 1)) if [[ "$LOGS_ERROR_STREAK" -eq 5 ]]; then spinner_clear log "Warning: 'docker logs' has failed ${LOGS_ERROR_STREAK} times in a row while waiting for startup — still retrying, but this may be a Docker-side issue rather than the bot being slow." fi fi RESTART_COUNT="$(docker inspect -f '{{.RestartCount}}' "$CONTAINER_NAME" /dev/null || echo 0)" if [[ "$RESTART_COUNT" -ge 5 ]]; then break fi sleep 1 done spinner_clear if [[ "$STARTED" != "true" ]]; then # One last authoritative check before declaring failure: if the marker is actually there now, # the bot did start — the polling loop above just failed to observe it in time (e.g. a run of # `docker logs` errors, confirmed possible above). Treat that as success rather than a false # failure; a genuinely broken container still fails the check below just the same. if docker logs "$CONTAINER_NAME" /dev/null | grep -qF "$READY_MARKER"; then log "" log "Note: the startup check missed this the first time around, but the bot has in fact started." STARTED=true fi fi if [[ "$STARTED" != "true" ]]; then log "" log "The bot did not report a successful startup. First 20 log lines (startup sequence):" docker logs "$CONTAINER_NAME" &1 | head -20 || true log "..." log "Most recent 30 log lines:" docker logs --tail 30 "$CONTAINER_NAME" "$INSTALL_DIR/bot-data/current-tag" # The sidecar needs docker.sock (to control the bot container) and the whole install dir, not just # bot-data (it calls run-bot.sh and needs the same absolute paths run-bot.sh's own --env-file/-v # arguments reference) — see run-bot.sh's "docker-outside-of-docker" note above: bind-mount source # paths always resolve on the host regardless of which container's CLI issued the `docker run`. # Started only after the bot itself is confirmed running, not alongside it — no point running an # updater for a container that never came up. log "Starting the update watcher..." if docker inspect "${CONTAINER_NAME}-updater" /dev/null 2>&1; then docker rm -f "${CONTAINER_NAME}-updater" /dev/null fi docker run -d \ --name "${CONTAINER_NAME}-updater" \ --restart unless-stopped \ --memory=64m \ -v /var/run/docker.sock:/var/run/docker.sock:z \ -v "$INSTALL_DIR:$INSTALL_DIR:z" \ --entrypoint sh \ docker:cli "$INSTALL_DIR/update-sidecar.sh" /dev/null CLIENT_ID_SEGMENT="${BOT_TOKEN%%.*}" PAD=$(( (4 - ${#CLIENT_ID_SEGMENT} % 4) % 4 )) CLIENT_ID_PADDING="" if [[ "$PAD" -gt 0 ]]; then CLIENT_ID_PADDING="$(printf '=%.0s' $(seq 1 "$PAD"))" fi CLIENT_ID_PADDED="${CLIENT_ID_SEGMENT}${CLIENT_ID_PADDING}" CLIENT_ID="$(printf '%s' "$CLIENT_ID_PADDED" | base64 -d 2>/dev/null || true)" # BotApplication logs "ionide-bot starting." as its very first line (see # BotApplication.main, Dockerfile.native) — APP_VERSION is the exact release tag baked in at build # time (build-ionide-bot-release's --build-arg), so this tells you unambiguously which release is # actually running instead of having to cross-reference a pulled image digest against a GitLab # pipeline by hand. RUNNING_VERSION="$(docker logs "$CONTAINER_NAME" 2>/dev/null | grep -F 'ionide-bot ' | grep -F ' starting.' | head -1 | sed -E 's/.*ionide-bot ([^ ]+) starting\..*/\1/')" log "" if [[ -n "$RUNNING_VERSION" ]]; then log "ionide-bot is running (version: ${RUNNING_VERSION})." else log "ionide-bot is running." fi log "" if [[ "$CLIENT_ID" =~ ^[0-9]+$ ]]; then log "Invite it to your server:" log " https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&scope=bot+applications.commands&permissions=93184" else log "Could not derive an invite link from the token automatically." log "Find your Application ID at https://discord.com/developers/applications and use:" log " https://discord.com/oauth2/authorize?client_id=&scope=bot+applications.commands&permissions=93184" fi log "" log "Next steps:" log " 1. Invite the bot using the URL above." log " 2. In any channel, run: /setup" log " This is the guided wizard — it links your account, activates your license, and walks you" log " through exchange API keys, margin mode, and command channel in one flow. No manual" log " /secrets action:set step needed unless you're adding credentials later."