#!/bin/sh # Omni core one-line installer — TEMPLATE. # # The omnigto.dev / develop / get.omnigto.dev placeholders are # baked at deploy time by the deploy workflow (from the server's .env), and # @@COMPOSE@@ is replaced with docker/compose.core.yaml (comments stripped), so # the embedded compose is never a hand-kept copy. This produces the concrete # install.sh that Caddy serves at https://get.omnigto.dev (root; /install.sh stays # as an alias). The served command is: # # curl -fsSL https://get.omnigto.dev | sudo sh # # Installs a single Omni solver core on this host: writes /opt/omni/{compose.yaml,.env}, # then brings the core up via Docker Compose. The core dials the Omni relay OUTBOUND # (no inbound ports) and pairs with your browser UI by the account id you enter below. # Re-running on a host that already has a core offers update / reconfigure / # uninstall, so the same one-liner is the whole lifecycle: there is no second # script to find, and no hand-written "docker compose down" for the user to get # wrong (a plain `docker rm` would kill a running solve before it checkpoints). set -eu OMNI_DOMAIN="omnigto.dev" # root domain; app./registry. derive from it IMAGE_TAG="develop" INSTALL_DIR="/opt/omni" err() { echo "Error: $*" >&2; exit 1; } # Ask one question on the terminal and leave the answer in $reply, stripped of # control characters and lowercased. A terminal hands us bytes the user never # typed: mouse reports when mouse reporting is left on, or type-ahead from # before the prompt drew. They land on the same line as the answer, which is # why every caller below matches the WHOLE reply and never a prefix. Prefix # matching is how a stray escape sequence in front of "q" once silently became # an update, and how a mistyped "xyz" would have reached Uninstall. ask() { printf '%s' "$1" > /dev/tty IFS= read -r reply < /dev/tty || err "could not read a reply." reply=$(printf '%s' "$reply" | tr -d '[:cntrl:][:space:]' | tr '[:upper:]' '[:lower:]') } # --- prerequisites ----------------------------------------------------------- [ "$(id -u)" -eq 0 ] || err "run as root, e.g. curl -fsSL https://get.omnigto.dev | sudo sh" # We prompt (Docker install consent + account token) on the terminal, since stdin # is the curl pipe. No tty = no way to ask, so bail early with guidance. [ -r /dev/tty ] || err "no terminal to prompt on; download the script and run it directly instead of piping." # Omni ships x86_64 images only. On an ARM VPS (the cheapest Hetzner / Oracle # free tier — exactly what a cost-conscious user picks) `docker compose pull` # would otherwise fail with a cryptic "no matching manifest for linux/arm64", # AFTER the user already installed Docker and entered their token. Fail fast, in # plain language, up front. arch=$(uname -m) case "$arch" in x86_64 | amd64) : ;; *) err "Omni needs an x86_64 (Intel/AMD) server; yours is $arch (e.g. ARM). It cannot run here." ;; esac # On a host that already has an install, re-running is either an update (the # common case — refresh the embedded compose.yaml and pull a newer image) or a # reconfigure (enter a new token/name, rewriting .env). Ask which; default to a # non-destructive update so the account token and OMNI_CORE_ID are never silently # rewritten. A fresh host (no .env) is a plain install. MODE=install if [ -e "$INSTALL_DIR/.env" ]; then printf 'Found an existing Omni core at %s.\n [U] Update — get the latest Omni, keep your current token (default).\n Restarts the core. A running solve is gracefully saved and reloaded if possible\n [R] Reconfigure — enter a new account token or core name\n [X] Uninstall — stop and remove this core\n [Q] Quit\n' "$INSTALL_DIR" > /dev/tty # Only an empty reply is the default. Anything unrecognized asks again # rather than falling through to a mode the user did not pick. while :; do ask 'Choose [U/r/x/q]: ' case "$reply" in '' | u | update) MODE=update ; break ;; r | reconfigure) MODE=reconfigure ; break ;; x | uninstall) MODE=uninstall ; break ;; q | quit) echo "Nothing changed. The existing core is untouched."; exit 0 ;; *) printf 'Sorry, "%s" is not one of U, R, X or Q.\n' "$reply" > /dev/tty ;; esac done fi # --- uninstall --------------------------------------------------------------- # Runs before the Docker/token machinery below: nothing here needs a registry # login or a token, and an uninstall must work even for a license that has since # expired. Ends the script either way. if [ "$MODE" = uninstall ]; then command -v docker >/dev/null 2>&1 || err "Docker is gone, so nothing can be removed cleanly. Delete $INSTALL_DIR by hand." [ -f "$INSTALL_DIR/compose.yaml" ] || \ err "$INSTALL_DIR/compose.yaml is missing, so the core cannot be stopped from here. Re-run and choose Update to restore it, then uninstall." cd "$INSTALL_DIR" # Note the image before the container goes away — `compose images` needs the # container to resolve it, and this is what scopes the removal to OUR image # instead of anything else on the host. core_image=$(docker compose images -q core 2>/dev/null || true) # A running solve serializes its full CFR state on SIGTERM, which for a big # Omaha sim is many GB of disk I/O. `compose down` honours the compose file's # stop_grace_period (5m), so the checkpoint completes and the data volume # stays resumable if the user reinstalls later. Never shorten this, and never # reach for `docker rm -f`. echo "Stopping the core (waiting for it to checkpoint a running solve, this can take a few minutes)..." docker compose down --remove-orphans # The data volume is the one irreversible part: saved solutions, solve # checkpoints and the abstraction cache, often hundreds of GB. Keeping it is # the default — a user who uninstalls to move the core to another host, or to # switch account tokens, would otherwise silently lose their solves. Show the # size so "keep" is an informed choice rather than a blind Enter. if docker volume inspect omni >/dev/null 2>&1; then mount=$(docker volume inspect omni --format '{{.Mountpoint}}' 2>/dev/null || true) size=$([ -n "$mount" ] && [ -d "$mount" ] && du -sh "$mount" 2>/dev/null | cut -f1 || true) prompt=$(printf 'Also delete the solver data volume "omni"%s? Saved solutions, solve checkpoints and the abstraction cache. This cannot be undone. [y/N] ' \ "${size:+ ($size)}") while :; do ask "$prompt" case "$reply" in y | yes) if docker volume rm omni >/dev/null 2>&1; then echo "Deleted the data volume." else echo "Could not delete the data volume 'omni' (something else may still use it)."; fi break ;; '' | n | no) KEPT_VOLUME=1 ; break ;; *) printf 'Sorry, "%s" is not one of Y or N.\n' "$reply" > /dev/tty ;; esac done fi [ -n "$core_image" ] && docker image rm "$core_image" >/dev/null 2>&1 || true docker logout "registry.$OMNI_DOMAIN" >/dev/null 2>&1 || true cd / rm -rf "$INSTALL_DIR" echo echo "Done. The Omni core is uninstalled and will stop appearing in your panel." if [ "${KEPT_VOLUME:-}" = 1 ]; then echo "Kept your solver data in the Docker volume 'omni'. Reinstalling picks it up again;" echo "to delete it later: docker volume rm omni" fi echo "Reinstall any time with the same command from https://app.$OMNI_DOMAIN" exit 0 fi # Docker Engine + the Compose v2 plugin. If Docker is missing, offer to install # it via Docker's official convenience script (get.docker.com): it covers the # mainstream server distros (Debian/Ubuntu, RHEL/CentOS/Fedora/Rocky/Alma, SLES) # and installs the compose plugin too. Ask first — it adds a system repo + root # daemon — but default to yes so a plain `curl … | sh` stays one Enter away. if ! command -v docker >/dev/null 2>&1; then while :; do ask 'Docker is not installed. Install it now via get.docker.com? [Y/n] ' case "$reply" in '' | y | yes) echo "Installing Docker via get.docker.com..." curl -fsSL https://get.docker.com | sh || err "Docker install failed; install it manually and re-run." break ;; n | no) err "Docker is required. Install it (https://docs.docker.com/engine/install/) and re-run." ;; *) printf 'Sorry, "%s" is not one of Y or N.\n' "$reply" > /dev/tty ;; esac done fi docker compose version >/dev/null 2>&1 || \ err "Docker Compose v2 plugin is missing; install it (https://docs.docker.com/compose/install/) and re-run." # --- account token (interactive; stdin is the curl pipe, so read the terminal) -- if [ "$MODE" = update ]; then # An update reuses the existing credentials — never re-prompts. Pull the # token out of .env for the registry login below. ACCOUNT_TOKEN=$(sed -n 's/^OMNI_ACCOUNT_TOKEN=//p' "$INSTALL_DIR/.env") [ -n "$ACCOUNT_TOKEN" ] || err "no OMNI_ACCOUNT_TOKEN in $INSTALL_DIR/.env; re-run and choose Reconfigure." else # The token pairs this core with the owner's browser panel: the panel's # entitlement token carries the same token in its `sub` claim (see WEB.md). printf 'Enter your Omni account token (omni_..., shown on your account page): ' > /dev/tty IFS= read -r ACCOUNT_TOKEN < /dev/tty || err "could not read the account token." ACCOUNT_TOKEN=$(printf '%s' "$ACCOUNT_TOKEN" | tr -d '[:space:]') [ -n "$ACCOUNT_TOKEN" ] || err "account token must not be empty." case "$ACCOUNT_TOKEN" in omni_?*) : ;; *) err "that does not look like an Omni account token (expected omni_...): $ACCOUNT_TOKEN" ;; esac # Name shown for this core in the panel. Without it the core falls back to # gethostname() INSIDE the container — a random 12-hex container id — so the # user's first sight of the panel is a core named `a1b2c3d4e5f6`. Default to the # host's real name; let the user override. default_core_id=$(hostname 2>/dev/null || uname -n) printf 'Name for this core [%s]: ' "$default_core_id" > /dev/tty IFS= read -r CORE_ID < /dev/tty || err "could not read a reply." CORE_ID=$(printf '%s' "$CORE_ID" | tr -d '[:space:]') [ -n "$CORE_ID" ] || CORE_ID=$default_core_id fi # --- validate the token, THEN write files ------------------------------------ # Log in to the license-gated registry BEFORE touching the working install: on a # Reconfigure a bad token (or a token for the wrong environment) must abort here, not # after .env has already been overwritten — otherwise a failed switch leaves the # core pointed at the new environment with no image pulled and the previous # working config gone. docker stores the credential, so background # `docker compose pull` (updates) keeps working; the registry re-checks the # license on every pull, so an expired license simply stops delivering images. echo "Logging in to the image registry..." printf '%s' "$ACCOUNT_TOKEN" | docker login "registry.$OMNI_DOMAIN" -u omni --password-stdin \ || err "registry login failed — check the account token and that your license is active." mkdir -p "$INSTALL_DIR" # compose.yaml is (re)written from the embedded @@COMPOSE@@ in every mode — this # is how an update picks up compose changes (new env, volumes, services), not # just a newer image. .env is left untouched on update so the token/name survive. cat > "$INSTALL_DIR/compose.yaml" <<'COMPOSE' name: omni-core services: core: image: registry.${OMNI_DOMAIN}/omnigto/omni-core:${TAG:-latest} environment: OMNI_RELAY_URL: "wss://app.${OMNI_DOMAIN}/core" OMNI_ACCOUNT_TOKEN: "${OMNI_ACCOUNT_TOKEN:?OMNI_ACCOUNT_TOKEN is missing}" OMNI_CORE_ID: "${OMNI_CORE_ID:-}" volumes: - omni:/omni restart: unless-stopped stop_grace_period: 5m volumes: omni: name: omni COMPOSE if [ "$MODE" != update ]; then cat > "$INSTALL_DIR/.env" </dev/null || true) # The container id, not the image id, is what says whether this update actually # changed anything: compose.yaml is rewritten on every update, so a compose-only # change recreates the container while the image id stays put. An unchanged id # means `up -d` was a no-op and a running solve was never interrupted, which is # worth saying out loud — `compose pull` prints "Pulled" either way, and a user # who re-ran the one-liner out of curiosity should not be left thinking their # core just restarted. old_container=$(docker compose ps -q core 2>/dev/null || true) docker compose up -d new_core_image=$(docker compose images -q core 2>/dev/null || true) new_container=$(docker compose ps -q core 2>/dev/null || true) if [ -n "$old_core_image" ] && [ "$old_core_image" != "$new_core_image" ]; then docker image rm "$old_core_image" >/dev/null 2>&1 || true fi echo if [ "$MODE" = update ]; then if [ -n "$old_container" ] && [ "$old_container" = "$new_container" ]; then echo "Already the latest Omni. Nothing changed and the core was not restarted." else echo "Done. The core is updated and running." fi else echo "Done. The core '$CORE_ID' is running." fi echo "Logs: cd $INSTALL_DIR && docker compose logs -f" echo "Update: re-run the same install command and choose Update." echo "Uninstall: re-run the same install command and choose Uninstall." echo "Manage this core at https://app.$OMNI_DOMAIN"