#!/usr/bin/env bash # # ANT server 2.38.0.534-LTS-Candidate — installs itself into ~/ant-server/2.38.0.534-LTS-Candidate and # prints how to start. Linux and macOS. Nothing is installed system-wide, no # sudo is used, and nothing outside that directory is written. # # bash install-antserver-2.38.sh [options] # curl -fsSL /install-antserver-2.38.sh | bash # curl -fsSL /install-antserver-2.38.sh | bash -s -- --start # # The compose file, this version's configuration and the database seed are not # in this script: they are pulled from git.djdadi.com/bav # as a container image, pinned below by digest. The server images come from the # same place, so one sign-in covers both and the machine needs to reach that # host once. # # Generated by scripts/export-bundle.sh --bootstrap. This file holds no vendor # material and is safe to publish; what it downloads is not. # set -eu ANT_VERSION="2.38.0.534-LTS-Candidate" ANT_TAG="2.38" ANT_SLUG="2-38-0-534-lts-candidate" ANT_IMAGE="git.djdadi.com/bav/antserver:2.38" REGISTRY_HOST="git.djdadi.com" ANT_PG_IMAGE="postgres:17.10" ANT_BOOTSTRAP="bootstrap-simul.json" ANT_COMPUTERNAME="antserver-2-38-0-534-lts-candidate" ANT_HEAP="4g" ANT_JRE_TAG="25-jre-noble" HAS_SIM=1 # By digest, not by tag: whoever runs this gets the bundle that was published # with it, even if the tag it was built from — git.djdadi.com/bav/antserver-bundle:2.38 — # has since been moved on to a later build. BUNDLE_IMAGE="git.djdadi.com/bav/antserver-bundle@sha256:b20d40d28fc7c9c2eefbdb25786524bc4b9745f22abfe302c680d27fba83c6fc" # Published ports are these bases plus the block this installer picks. The # bases are 100 apart in the residues that matter, so any offset that is a # multiple of 100 keeps two ANT versions off each other's ports. BASE_MONITOR=8081 BASE_NAV=9000 BASE_SSE=8101 BASE_PG=5433 BASE_SIM=6080 PORT_BASES="8081 9000 8101 5433 6080" # Empty until --dir or $HOME resolves it, so that a machine with no HOME set # fails with a sentence rather than with `set -u`'s unbound-variable abort. DIR="" OPT_OFFSET="" DO_START=0 FORCE=0 say() { printf '%s\n' "$*"; } warn() { printf 'WARNING: %s\n' "$1" >&2; shift || true for _l in "$@"; do printf ' %s\n' "$_l" >&2; done; } die() { printf 'FATAL: %s\n' "$1" >&2; shift || true for _l in "$@"; do printf ' %s\n' "$_l" >&2; done; exit 1; } usage() { cat <&2; usage >&2; exit 1 ;; esac done } env_get() { # file key [ -f "$1" ] || return 0 sed -n "s/^$2=//p" "$1" | tail -1 } # --- Preconditions ----------------------------------------------------------- preflight() { command -v docker >/dev/null 2>&1 || die \ "Docker is not installed." \ "Mac: Docker Desktop, https://www.docker.com/products/docker-desktop" \ "Linux: Docker Engine, https://docs.docker.com/engine/install/" \ "Then run this again." docker info >/dev/null 2>&1 || die \ "the Docker daemon is not responding." \ "Mac: start Docker Desktop and wait for the whale to stop animating." \ "Linux: sudo systemctl start docker" \ " If Docker runs but only under sudo, add yourself to the 'docker'" \ " group and log out and back in." docker compose version >/dev/null 2>&1 \ || command -v docker-compose >/dev/null 2>&1 \ || die "Docker is installed but Compose is not." \ "Mac: Docker Desktop includes it — update to a current version." \ "Linux: install the docker-compose-plugin package." } # Whether this machine may fetch the images at all — asked with a manifest # lookup rather than a pull, because 500 MB is a slow way to discover a 401. # Not being able to answer is not fatal: the files are still worth writing, and # start.sh reports the same problem later with the same wording. registry_check() { docker image inspect "$ANT_IMAGE" >/dev/null 2>&1 && return 0 out="" if out="$(docker manifest inspect "$ANT_IMAGE" 2>&1)"; then return 0; fi if docker buildx imagetools inspect "$ANT_IMAGE" >/dev/null 2>&1; then return 0; fi case "$out" in *nauthorized*|*uthentication*|*enied*|*orbidden*|*401*|*403*) die "this machine is not signed in to $REGISTRY_HOST," \ "so the ANT server images cannot be downloaded." \ "" \ " docker login $REGISTRY_HOST" \ "" \ "then run this installer again. Ask whoever sent you this for the" \ "username and access token." ;; esac detail="$(printf '%s' "$out" | head -1)" if [ -n "$detail" ]; then warn "could not reach $REGISTRY_HOST to check the images." \ "Installing anyway — but the first start needs that connection." \ "$detail" else warn "could not reach $REGISTRY_HOST to check the images." \ "Installing anyway — but the first start needs that connection." fi } # --- Host port block --------------------------------------------------------- # Nothing here is authoritative about what a *stopped* stack owns, which is why # both questions get asked: what another install under $DIR has claimed, and # what is listening right now. port_busy() { ( exec 3<>"/dev/tcp/127.0.0.1/$1" ) >/dev/null 2>&1; } block_busy() { for _b in $PORT_BASES; do if port_busy $((_b + $1)); then return 0; fi done return 1 } block_claimed() { for _f in "$DIR"/*/.env; do [ -f "$_f" ] || continue [ "$_f" = "$INSTALL_DIR/.env" ] && continue if [ "$(env_get "$_f" ANT_PORT_OFFSET)" = "$1" ]; then return 0; fi done return 1 } choose_offset() { # An install that is already here keeps its ports, so its URLs, bookmarks and # any project pointing at them survive a re-run of this installer. KEPT_ENV=0 if [ -f "$INSTALL_DIR/.env" ] && [ "$FORCE" = 0 ]; then OFFSET="$(env_get "$INSTALL_DIR/.env" ANT_PORT_OFFSET)" if [ -n "$OFFSET" ]; then KEPT_ENV=1; return 0; fi fi if [ -n "$OPT_OFFSET" ]; then case "$OPT_OFFSET" in ''|*[!0-9]*) die "--port-offset must be a whole number." ;; esac [ $((OPT_OFFSET % 100)) -eq 0 ] || die \ "--port-offset must be a multiple of 100." \ "The base ports are spaced so that any multiple of 100 keeps two ANT" \ "versions off each other's ports; anything else breaks that." OFFSET="$OPT_OFFSET" # A block another install already holds is a mistake even when it was asked # for by name: it means two stacks that cannot both be up. if block_claimed "$OFFSET"; then die "port block +$OFFSET is already held by another install under $DIR." \ "Pick a different one, or let this installer choose by leaving" \ "--port-offset off." fi return 0 fi OFFSET=0 while [ "$OFFSET" -le 900 ]; do if ! block_claimed "$OFFSET" && ! block_busy "$OFFSET"; then return 0; fi OFFSET=$((OFFSET + 100)) done die "every port block from +0 to +900 is taken or in use." \ "Free one, or pass --port-offset with a block you know is clear." } # --- Payload ----------------------------------------------------------------- # The bundle travels as a container image rather than inside this file, which is # what lets this script be published while its contents stay behind the same # login as the server images. The image is FROM scratch and holds only files: # nothing in it is ever executed, and the container created below exists purely # to give `docker cp` something to read from. fetch_bundle() { say "==> Downloading the bundle" if out="$(docker pull "$BUNDLE_IMAGE" 2>&1)"; then return 0; fi case "$out" in *nauthorized*|*uthentication*|*enied*|*orbidden*|*401*|*403*) die "this machine is not signed in to $REGISTRY_HOST," \ "so the bundle cannot be downloaded." \ "" \ " docker login $REGISTRY_HOST" \ "" \ "then run this installer again. Ask whoever sent you this for the" \ "username and access token." ;; esac die "could not download the bundle from $REGISTRY_HOST." \ "$(printf '%s' "$out" | tail -1)" } unpack() { fetch_bundle cid="$(docker create "$BUNDLE_IMAGE" /bundle 2>/dev/null)" || die \ "could not open the bundle image." mkdir -p "$TMPD/stage" if ! docker cp "$cid:/bundle/." "$TMPD/stage" >/dev/null 2>&1; then docker rm -f "$cid" >/dev/null 2>&1 || true die "could not read the bundle contents." fi docker rm -f "$cid" >/dev/null 2>&1 || true # A digest cannot point at the wrong bytes, so this is not an integrity check # — it catches a bundle image built from something that was never a bundle. [ -f "$TMPD/stage/compose.yaml" ] || die \ "the downloaded bundle has no compose.yaml in it." \ "Ask whoever sent you this for a current copy." } # --- Install ----------------------------------------------------------------- # The scaffolding — compose, start/stop, the seed — is always refreshed, so a # re-run is how you take a fix. The two things someone tunes by hand, .env and # configuration/, are kept unless --force says otherwise. install_files() { src="$TMPD/stage" mkdir -p "$INSTALL_DIR" for f in compose.yaml start.sh stop.sh README.txt \ "START HERE (Mac).command" "STOP (Mac).command"; do if [ -e "$src/$f" ]; then cp "$src/$f" "$INSTALL_DIR/$f"; fi done chmod +x "$INSTALL_DIR/start.sh" "$INSTALL_DIR/stop.sh" \ "$INSTALL_DIR/START HERE (Mac).command" \ "$INSTALL_DIR/STOP (Mac).command" 2>/dev/null || true # Only ever read, and only by a database being created for the first time. rm -rf "$INSTALL_DIR/initdb" cp -R "$src/initdb" "$INSTALL_DIR/initdb" KEPT_CONFIG=0 if [ -d "$INSTALL_DIR/configuration" ] && [ "$FORCE" = 0 ]; then KEPT_CONFIG=1 else rm -rf "$INSTALL_DIR/configuration" cp -R "$src/configuration" "$INSTALL_DIR/configuration" fi } write_env() { [ "$KEPT_ENV" = 0 ] || return 0 cat > "$INSTALL_DIR/.env" < "$TMPD/README.txt" cp "$TMPD/README.txt" "$INSTALL_DIR/README.txt" } summary() { say say "==================================================================" say " ANT server $ANT_VERSION is installed" say "==================================================================" say printf ' %-12s %s\n' "Folder" "$INSTALL_DIR" # Read back from .env rather than recomputed, so this says what Compose will # actually publish — including on a re-run, where .env is a file this install # deliberately did not rewrite. _e="$INSTALL_DIR/.env" _ports="monitor $(env_get "$_e" ANT_PORT_MONITOR)" _ports="$_ports, nav $(env_get "$_e" ANT_PORT_NAV)" _ports="$_ports, SSE $(env_get "$_e" ANT_PORT_SSE)" _ports="$_ports, db $(env_get "$_e" ANT_PORT_PG)" [ "$HAS_SIM" = 1 ] && _ports="$_ports, simulator $(env_get "$_e" ANT_PORT_SIM)" printf ' %-12s %s\n' "Ports" "$_ports" [ "$KEPT_ENV" = 1 ] && printf ' %-12s %s\n' "Kept" ".env — including the ports above" [ "$KEPT_CONFIG" = 1 ] && printf ' %-12s %s\n' "Kept" "configuration/ — --force replaces it" say say " Start it:" say " $INSTALL_DIR/start.sh" say say " Stop it (keeps the database):" say " $INSTALL_DIR/stop.sh" say printf ' %-18s %s\n' "ANT monitor" "$MONITOR_URL" [ -n "$SIM_URL" ] && printf ' %-18s %s\n' "Vehicle Simulator" "$SIM_URL" say say " The first start downloads about 500 MB and takes a few minutes." say " Everything else is in $INSTALL_DIR/README.txt." say } main() { if [ -z "$DIR" ]; then [ -n "${HOME:-}" ] || die \ "HOME is not set, so there is no default place to install." \ "Pass --dir DIR." DIR="$HOME/ant-server" fi INSTALL_DIR="$DIR/$ANT_VERSION" # Piped from curl this is the first thing anyone sees, and the checks below # can sit for a few seconds on a slow network. say "==> ANT server $ANT_VERSION into $INSTALL_DIR" preflight registry_check choose_offset unpack install_files write_env # One source of truth for the URLs: whatever .env ended up saying, including # when that is a file this run deliberately did not touch. MONITOR_URL="http://localhost:$(env_get "$INSTALL_DIR/.env" ANT_PORT_MONITOR)/wms/monitor/index.html" SIM_URL="" if [ "$HAS_SIM" = 1 ]; then SIM_URL="http://localhost:$(env_get "$INSTALL_DIR/.env" ANT_PORT_SIM)/vnc.html" fi patch_readme summary if [ "$DO_START" = 1 ]; then say "==> --start given, starting now" say exec "$INSTALL_DIR/start.sh" fi } parse_args "$@" TMPD="$(mktemp -d 2>/dev/null || mktemp -d -t antserver)" trap 'rm -rf "$TMPD"' EXIT INT TERM main