#!/usr/bin/env bash # CoreCI Chat Relay Agent — install script (Wave D Task 4, REQ-010, G-009). # # Modular: detect_os → install_binary → write_systemd_unit → register_target. # Idempotent (re-run upgrades). Aborts cleanly on an unsupported OS with an # actionable error listing the supported OSes (Edge 16). # # Usage: # CORECI_TENANT_TOKEN= CORECI_SAAS_URL=https://chat.coreci.dev \ # curl -fsSL https://chat.coreci.dev/install.sh | sh # # Or with the token pre-embedded by the dashboard's /dashboard/relay page: # curl -fsSL "https://chat.coreci.dev/install.sh?t=" | sh # # The script is designed to be readable + auditable: it never pipes an # unverified binary into a shell. The downloaded binary's SHA256 is verified # against a checksum file before install (R-005 pitfall guard). set -euo pipefail # ─── Defaults / constants ──────────────────────────────────────────────────── readonly BIN_INSTALL_PATH="/usr/local/bin/coreci-relay-agent" readonly UNIT_PATH="/etc/systemd/system/coreci-relay-agent.service" readonly ENV_DIR="/etc/coreci" readonly ENV_FILE="${ENV_DIR}/relay.env" readonly SERVICE_NAME="coreci-relay-agent" # Released by the Gitea release for v0.0.5. Override with CORECI_RELEASE_URL for # self-hosted deploys. The per-arch binary URL pattern is: # ${RELEASE_URL}/coreci-relay-agent-${OS}-${ARCH} readonly DEFAULT_RELEASE_URL="https://git.cloudinit.dev/coreci/coreci-chat/releases/download/v0.0.5" # Agent version this script installs (must match the Go binary's AgentVersion). readonly AGENT_VERSION="0.0.5" # Supported OSes (REQ-010 Edge 16 / G-009). Used in the abort message. readonly SUPPORTED_OS_MSG="Supported: Ubuntu 24.04 LTS, Debian 12+. Detected: ${ID:-unknown} ${VERSION_ID:-unknown}" # Minimum supported VERSION_ID per distro (parsed from /etc/os-release). declare -A MIN_VERSIONS=( ["ubuntu"]="24.04" ["debian"]="12" ) # ─── Logging ───────────────────────────────────────────────────────────────── log() { printf '[coreci-install] %s\n' "$*" >&2; } ok() { printf '[coreci-install] \033[32mok\033[0m %s\n' "$*" >&2; } warn() { printf '[coreci-install] \033[33mwarn\033[0m %s\n' "$*" >&2; } die() { printf '[coreci-install] \033[31merror\033[0m %s\n' "$*" >&2; exit 1; } # ─── detect_os ─────────────────────────────────────────────────────────────── # Parses /etc/os-release (ID + VERSION_ID). Supported: ubuntu >= 24.04, # debian >= 12. Returns 0 + sets OS_NAME/OS_VERSION if supported; returns 1 + # prints the actionable error if not (Edge 16, G-009). # # Exposed for testing: the function only reads OS_NAME/OS_VERSION/ID/ # VERSION_ID globals; tests can source this script and call detect_os with a # fake /etc/os-release by overriding the OS_RELEASE_PATH variable. OS_RELEASE_PATH="${OS_RELEASE_PATH:-/etc/os-release}" OS_NAME="" OS_VERSION="" detect_os() { local release_file="$OS_RELEASE_PATH" if [[ ! -f "$release_file" ]]; then log "Unsupported OS. $SUPPORTED_OS_MSG (no /etc/os-release found)" return 1 fi # Parse ID and VERSION_ID (and PRETTY_NAME for the abort message). # shellcheck disable=SC1090 ID="" VERSION_ID="" PRETTY_NAME="" set +u # Source the file in a subshell so its KEY=value lines populate our locals # without polluting the caller's environment. eval "$(grep -E '^(ID|VERSION_ID|PRETTY_NAME)=' "$release_file" || true)" set -u ID="${ID:-}" VERSION_ID="${VERSION_ID:-}" OS_NAME="$ID" OS_VERSION="$VERSION_ID" local min="${MIN_VERSIONS[$ID]:-}" if [[ -z "$min" ]]; then log "Unsupported OS. $SUPPORTED_OS_MSG" return 1 fi # Version compare: Debian's VERSION_ID is "12" or "12.1"; Ubuntu's is "24.04". # We compare as floating-ish by splitting on '.' and comparing major then minor. if ! version_ge "$VERSION_ID" "$min"; then log "Unsupported OS. $SUPPORTED_OS_MSG" return 1 fi ok "detected supported OS: $ID $VERSION_ID" return 0 } # version_ge returns 0 if $1 >= $2, comparing dotted versions numerically # (24.04 >= 24.04 → true; 24.10 >= 24.04 → true; 22.04 >= 24.04 → false). version_ge() { local a="$1" b="$2" local IFS=. local a_parts=($a) b_parts=($b) local i for i in 0 1 2 3; do local ai="${a_parts[i]:-0}" local bi="${b_parts[i]:-0}" # Strip non-numeric suffixes (e.g., 12.1 → 12, 24.04 LTS → 24.04). ai="${ai//[^0-9]/}" bi="${bi//[^0-9]/}" if (( 10#$ai > 10#$bi )); then return 0; fi if (( 10#$ai < 10#$bi )); then return 1; fi done return 0 } # ─── install_binary ────────────────────────────────────────────────────────── # Detects arch (uname -m → amd64/arm64), downloads the Go binary from the # SaaS release URL, verifies the SHA256 checksum, and installs to # /usr/local/bin/coreci-relay-agent. Idempotent (overwrites on re-run). install_binary() { local arch release_url bin_url checksum_url tmpdir tmp_bin tmp_sum arch="$(uname -m)" case "$arch" in x86_64|amd64) arch="amd64" ;; aarch64|arm64) arch="arm64" ;; *) die "Unsupported architecture: $arch (supported: amd64, arm64)" ;; esac release_url="${CORECI_RELEASE_URL:-$DEFAULT_RELEASE_URL}" bin_url="${release_url}/coreci-relay-agent-linux-${arch}" checksum_url="${release_url}/checksums.txt" log "downloading binary: $bin_url" tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' RETURN tmp_bin="$tmpdir/coreci-relay-agent" tmp_sum="$tmpdir/checksums.txt" if ! curl -fsSL "$bin_url" -o "$tmp_bin"; then die "failed to download binary from $bin_url" fi log "downloading checksum: $checksum_url" if ! curl -fsSL "$checksum_url" -o "$tmp_sum"; then die "failed to download checksum from $checksum_url" fi # Verify: the checksum file has lines like # coreci-relay-agent-linux-amd64 local expected_sha actual_sha match_line match_line="$(grep -E "coreci-relay-agent-linux-${arch}\$" "$tmp_sum" || true)" if [[ -z "$match_line" ]]; then die "no checksum entry for linux-${arch} in $checksum_url" fi expected_sha="$(awk '{print $1}' <<<"$match_line")" actual_sha="$(sha256sum "$tmp_bin" | awk '{print $1}')" if [[ "$expected_sha" != "$actual_sha" ]]; then die "checksum mismatch for $bin_url: expected $expected_sha, got $actual_sha" fi ok "checksum verified ($expected_sha)" install -m 0755 "$tmp_bin" "$BIN_INSTALL_PATH" ok "installed binary to $BIN_INSTALL_PATH" } # ─── write_systemd_unit ────────────────────────────────────────────────────── # Writes /etc/systemd/system/coreci-relay-agent.service and enables + starts it. # Idempotent: overwrites + daemon-reload + restart-if-running on re-run. write_systemd_unit() { cat >"$UNIT_PATH" </dev/null 2>&1 || true ok "enabled $SERVICE_NAME (will start after register_target sets the token)" } # ─── register_target ───────────────────────────────────────────────────────── # Writes /etc/coreci/relay.env with CORECI_TENANT_TOKEN + CORECI_SAAS_URL. # Idempotent: preserves an existing token if the env vars aren't set on this # run (re-run for a binary upgrade should not wipe the registration). register_target() { local token saas_url token="${CORECI_TENANT_TOKEN:-}" saas_url="${CORECI_SAAS_URL:-}" mkdir -p "$ENV_DIR" chmod 0750 "$ENV_DIR" # Preserve existing values if the env var isn't set on this run. if [[ -f "$ENV_FILE" ]]; then if [[ -z "$token" ]]; then token="$(grep -E '^CORECI_TENANT_TOKEN=' "$ENV_FILE" | cut -d= -f2- || true)" fi if [[ -z "$saas_url" ]]; then saas_url="$(grep -E '^CORECI_SAAS_URL=' "$ENV_FILE" | cut -d= -f2- || true)" fi fi if [[ -z "$token" ]]; then die "CORECI_TENANT_TOKEN is required. Get it from the dashboard's /dashboard/relay page (the curl|bash command embeds it)." fi if [[ -z "$saas_url" ]]; then die "CORECI_SAAS_URL is required (e.g. https://chat.coreci.dev)." fi # Write atomically (write to a temp + mv so a crash mid-write doesn't leave a # half-written env file). local tmp_env="${ENV_FILE}.tmp" { echo "CORECI_TENANT_TOKEN=${token}" echo "CORECI_SAAS_URL=${saas_url}" } >"$tmp_env" chmod 0600 "$tmp_env" mv "$tmp_env" "$ENV_FILE" ok "wrote registration to $ENV_FILE (token preserved if already set)" } # ─── main ──────────────────────────────────────────────────────────────────── # Orchestrates: detect_os → install_binary → write_systemd_unit → register_target. # On any failure, exits non-zero with an actionable error (set -e + die()). main() { # Root required: we install to /usr/local/bin + /etc/systemd + /etc/coreci. if [[ $EUID -ne 0 ]]; then die "must run as root (use sudo). The install writes to /usr/local/bin, /etc/systemd, /etc/coreci." fi log "CoreCI Chat Relay Agent installer v$AGENT_VERSION" detect_os || exit 1 install_binary write_systemd_unit register_target # Now that the env file exists, start (or restart) the service. systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME" ok "$SERVICE_NAME started. Check status: systemctl status $SERVICE_NAME" ok "install complete. The agent will connect to the SaaS within 60s." } # Only run main when executed (not when sourced for testing). if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" ]]; then main "$@" fi