#!/usr/bin/env bash set -euo pipefail SCRIPT_NAME="$(basename "$0")" DEFAULT_CONTROL_PLANE_URL="https://app.turbov.io/api" DEFAULT_SERVER_URL="${DEFAULT_CONTROL_PLANE_URL}/agents" DEFAULT_MANIFEST_URL="${DEFAULT_CONTROL_PLANE_URL}/agents/updates/manifest" DEFAULT_CHANNEL="stable" SERVER_URL="${SERVER_URL:-$DEFAULT_SERVER_URL}" MANIFEST_URL="${MANIFEST_URL:-$DEFAULT_MANIFEST_URL}" CHANNEL="${CHANNEL:-$DEFAULT_CHANNEL}" TOKEN="" usage() { cat < [options] $SCRIPT_NAME --token [options] Options: --server-url Agent API base URL (default: $DEFAULT_SERVER_URL) --manifest-url Update manifest URL (default: $DEFAULT_MANIFEST_URL) --channel Update channel (default: $DEFAULT_CHANNEL) -h, --help Show this help Example: curl -fsSL https://turbov.io/install.sh | sudo bash -s -- tk_abc123 EOF } log() { echo "[install] $*" } fail() { echo "[install] ERROR: $*" >&2 exit 1 } need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then fail "Required command not found: $1" fi } calc_sha256() { local file="$1" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$file" | awk '{print $1}' return fi if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$file" | awk '{print $1}' return fi fail "Neither sha256sum nor shasum is available" } parse_args() { while (($#)); do case "$1" in --token) [[ $# -ge 2 ]] || fail "Missing value for --token" TOKEN="$2" shift 2 ;; --token=*) TOKEN="${1#*=}" shift ;; --server-url) [[ $# -ge 2 ]] || fail "Missing value for --server-url" SERVER_URL="$2" shift 2 ;; --server-url=*) SERVER_URL="${1#*=}" shift ;; --manifest-url) [[ $# -ge 2 ]] || fail "Missing value for --manifest-url" MANIFEST_URL="$2" shift 2 ;; --manifest-url=*) MANIFEST_URL="${1#*=}" shift ;; --channel) [[ $# -ge 2 ]] || fail "Missing value for --channel" CHANNEL="$2" shift 2 ;; --channel=*) CHANNEL="${1#*=}" shift ;; -h|--help) usage exit 0 ;; -*) fail "Unknown option: $1" ;; *) if [[ -z "$TOKEN" ]]; then TOKEN="$1" else fail "Unexpected argument: $1" fi shift ;; esac done [[ -n "$TOKEN" ]] || fail "Registration token is required" } detect_platform() { local os arch os="$(uname -s | tr '[:upper:]' '[:lower:]')" arch="$(uname -m)" case "$os" in linux) ;; *) fail "Unsupported OS: $os (Linux + systemd required)" ;; esac case "$arch" in x86_64|amd64) AGENT_ARCH="x64" ;; aarch64|arm64) AGENT_ARCH="arm64" ;; *) fail "Unsupported architecture: $arch (expected amd64/x86_64 or arm64/aarch64)" ;; esac } parse_manifest_payload() { local raw_input="$1" python3 - "$raw_input" <<'PY' import base64 import json import sys raw = sys.argv[1] if not raw.strip(): raise SystemExit("Manifest response was empty") try: doc = json.loads(raw) except json.JSONDecodeError: snippet = raw[:200].replace("\n", " ").strip() raise SystemExit( f"Manifest endpoint did not return valid JSON. First bytes: {snippet!r}" ) if isinstance(doc, dict) and isinstance(doc.get("manifest"), dict): doc = doc["manifest"] payload_b64 = doc.get("payload") if not payload_b64: raise SystemExit("Manifest missing payload") payload_b64 += "=" * (-len(payload_b64) % 4) try: decoded_payload = base64.b64decode(payload_b64).decode("utf-8") except Exception: raise SystemExit("Manifest payload is not valid base64-encoded UTF-8 JSON") try: payload = json.loads(decoded_payload) except json.JSONDecodeError: raise SystemExit("Manifest payload is not valid JSON") artifact = payload.get("artifact") or {} version = payload.get("version") url = artifact.get("url") sha = artifact.get("sha256") size = artifact.get("sizeBytes") if not version or not url or not sha: raise SystemExit("Manifest payload is missing required artifact fields") print(version) print(url) print(sha) print("" if size is None else str(size)) PY } write_env_file() { install -d -m 0755 /etc/turbov /var/lib/turbov /var/log/turbov /opt/turbov cat >/etc/turbov/agent.env </etc/systemd/system/turbov-agent.service <<'EOF' [Unit] Description=Turbov Agent After=network-online.target Wants=network-online.target [Service] Type=simple EnvironmentFile=-/etc/turbov/agent.env WorkingDirectory=/var/lib/turbov ExecStart=/usr/local/bin/turbov Restart=always RestartSec=3 SuccessExitStatus=23 User=root Group=root LimitNOFILE=65536 [Install] WantedBy=multi-user.target EOF } register_agent() { local status_output status_output="$(SERVER_URL="$SERVER_URL" /usr/local/bin/turbov status 2>/dev/null || true)" if grep -q "Status: REGISTERED" <<<"$status_output"; then log "Existing agent registration detected, running re-register" SERVER_URL="$SERVER_URL" REGISTRATION_TOKEN="$TOKEN" \ /usr/local/bin/turbov re-register --token "$TOKEN" else log "Registering agent" SERVER_URL="$SERVER_URL" REGISTRATION_TOKEN="$TOKEN" \ /usr/local/bin/turbov register --token "$TOKEN" fi } main() { parse_args "$@" if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then fail "Run as root (example: curl ... | sudo bash -s -- )" fi need_cmd curl need_cmd tar need_cmd python3 need_cmd systemctl need_cmd install need_cmd mktemp detect_platform log "Fetching latest manifest from $MANIFEST_URL" local manifest_url manifest_url="${MANIFEST_URL}?channel=${CHANNEL}&os=linux&arch=${AGENT_ARCH}¤tVersion=0.0.0" local manifest_json manifest_json="$(curl -fsSL "$manifest_url")" local manifest_parsed manifest_parsed="$(parse_manifest_payload "$manifest_json")" local -a manifest_fields mapfile -t manifest_fields <<<"$manifest_parsed" [[ ${#manifest_fields[@]} -ge 4 ]] || fail "Failed to parse update manifest" local release_version artifact_url artifact_sha artifact_size release_version="${manifest_fields[0]}" artifact_url="${manifest_fields[1]}" artifact_sha="${manifest_fields[2]}" artifact_size="${manifest_fields[3]}" log "Resolved release version: $release_version" local tmp_dir artifact_archive tmp_dir="$(mktemp -d)" artifact_archive="$tmp_dir/agent-bundle.tar.gz" trap '[ -n "${tmp_dir:-}" ] && rm -rf "$tmp_dir"' EXIT log "Downloading agent artifact" curl -fsSL "$artifact_url" -o "$artifact_archive" if [[ -n "$artifact_size" ]]; then local downloaded_size downloaded_size="$(wc -c <"$artifact_archive" | tr -d ' ')" [[ "$downloaded_size" == "$artifact_size" ]] || \ fail "Artifact size mismatch (expected $artifact_size, got $downloaded_size)" fi local actual_sha actual_sha="$(calc_sha256 "$artifact_archive")" [[ "${actual_sha,,}" == "${artifact_sha,,}" ]] || \ fail "Artifact checksum mismatch" local active_slot_file="/var/lib/turbov/updates/active-slot" local slot_a_dir="/opt/turbov/slot-a" local slot_a_bin="$slot_a_dir/agent.bin" local slot_a_wrapper="$slot_a_dir/wrapper.sh" install -d -m 0755 "$slot_a_dir" /opt/turbov/slot-b /var/lib/turbov/updates # Prime the active slot to A echo "A" > "$active_slot_file" log "Installing bundle to $slot_a_dir" tar -xzf "$artifact_archive" -C "$slot_a_dir" agent.bin wrapper.sh chmod 0755 "$slot_a_bin" "$slot_a_wrapper" log "Writing wrapper script to /usr/local/bin/turbov" cat >/usr/local/bin/turbov <<'EOF' #!/usr/bin/env bash SLOTS_DIR="/opt/turbov" ACTIVE_SLOT_FILE="/var/lib/turbov/updates/active-slot" ACTIVE_SLOT="A" if [ -f "$ACTIVE_SLOT_FILE" ]; then ACTIVE_SLOT=$(cat "$ACTIVE_SLOT_FILE" | tr -d '[:space:]') fi if [ "$ACTIVE_SLOT" = "B" ]; then WRAPPER_PATH="$SLOTS_DIR/slot-b/wrapper.sh" else WRAPPER_PATH="$SLOTS_DIR/slot-a/wrapper.sh" fi if [ ! -x "$WRAPPER_PATH" ]; then echo "Error: Wrapper not found or not executable at $WRAPPER_PATH" >&2 exit 1 fi exec "$WRAPPER_PATH" "$@" EOF chmod 0755 /usr/local/bin/turbov write_env_file write_systemd_unit log "Reloading systemd" systemctl daemon-reload systemctl stop turbov-agent >/dev/null 2>&1 || true register_agent log "Enabling and starting turbov-agent.service" systemctl enable --now turbov-agent if ! systemctl is-active --quiet turbov-agent; then journalctl -u turbov-agent -n 100 --no-pager || true fail "turbov-agent failed to start" fi log "Install complete" SERVER_URL="$SERVER_URL" /usr/local/bin/turbov status || true log "View logs: journalctl -u turbov-agent -f" } main "$@"