#!/usr/bin/env bash # Bash 3.2+ (including macOS). Do not source this file or .env. set -Eeuo pipefail umask 077 TARGET_VERSION="0.1.19" ARTIFACT_BASE_URL="https://updates.putex.org/ristu/core/releases/0.1.19" UPDATE_MANIFEST_URL="https://updates.putex.org/ristu/core/stable.json" EXPECTED_COMPOSE_SHA256="e00b4e62896c40c5b52dc678c2d498ec53ebb0aa9f348d6554f53ee067411a9c" EXPECTED_ENV_SHA256="0143cee544b4fbdc11e8abb64f241c9627d694bc80089397066dddf982c52c61" log() { printf 'Ristu: %s\n' "$*"; } fail() { printf 'Ristu: błąd: %s\n' "$*" >&2; exit 1; } ENGINE=''; INSTALL_MODE=''; HOST_NAME=''; SERVER_IP=''; ADMIN_IP='' HTTPS_PORT=''; ADMIN_PORT=''; PROJECT=''; YES=0; NONINTERACTIVE=0; RESUME=0 WAIT_TIMEOUT=300 usage() { cat <<'HELP' Instalacja/aktualizacja Ristu w bieżącym katalogu (Docker lub Podman + Compose). --engine docker|podman --mode workstation|server --host localhost|IPv4|DNS --server-ip IPv4 --admin-ip IPv4 --https-port 8443 --admin-port 8445 --project ristu-fresh --yes potwierdzenie instalacji/przerwy na aktualizację --non-interactive bez pytań; nowa instalacja wymaga engine/mode/host --wait-timeout 300 limit oczekiwania na gotowość (sekundy) --resume ponów to samo wydanie po błędzie migracji/startu Serwer: lokalny Linux, rootful engine zachowujący źródłowy adres klienta. Aktualizacja zachowuje adresy, sekrety, silnik, projekt, wolumeny i certyfikat. HELP } while [[ $# -gt 0 ]]; do case "$1" in --engine|--mode|--host|--server-ip|--admin-ip|--https-port|--admin-port|--project|--wait-timeout) [[ $# -ge 2 && -n "$2" ]] || fail "brak wartości dla $1" case "$1" in --engine) ENGINE="$2";; --mode) INSTALL_MODE="$2";; --host) HOST_NAME="$2";; --server-ip) SERVER_IP="$2";; --admin-ip) ADMIN_IP="$2";; --https-port) HTTPS_PORT="$2";; --admin-port) ADMIN_PORT="$2";; --project) PROJECT="$2";; --wait-timeout) WAIT_TIMEOUT="$2";; esac shift 2;; --yes) YES=1; shift;; --non-interactive) NONINTERACTIVE=1; shift;; --resume) RESUME=1; shift;; --help|-h) usage; exit 0;; *) fail "nieznany argument: $1";; esac done [[ "$WAIT_TIMEOUT" =~ ^[0-9]+$ && ${#WAIT_TIMEOUT} -le 5 && "$WAIT_TIMEOUT" -ge 10 && "$WAIT_TIMEOUT" -le 86400 ]] || fail "nieprawidłowy wait-timeout" TARGET_DIR="$(pwd -P)" [[ "$PWD" == "$TARGET_DIR" ]] || fail "katalog instalacji nie może prowadzić przez dowiązanie symboliczne" ENV_FILE="$TARGET_DIR/.env"; COMPOSE_FILE="$TARGET_DIR/docker-compose.yml" STATE_FILE="$TARGET_DIR/.ristu-installation"; RECOVERY_FILE="$TARGET_DIR/.ristu-recovery" LOCK_DIR="$TARGET_DIR/.ristu-installer.lock"; TEMP_DIR=''; BACKUP_DIR=''; PHASE=preflight WRITER_IDS=(); CONTAINER_IDS=(); LOCKED=0 SERVICES=(ristu-db orthanc-db orthanc-auth-service orthanc ristu-viewer ristu-api ristu-web) SECRET_KEYS=(RISTU_DB_PASSWORD ORTHANC_DB_PASSWORD ORTHANC_AUTH_SERVICE_PASSWORD ORTHANC_AUTH_SECRET RISTU_PROXY_SESSION_SECRET RISTU_SETTINGS_SECRET) VOLUMES=(ristu_db orthanc_db orthanc_dicom ristu_api_runtime ristu_web_tls ristu_dicomweb_cache) plain_file() { [[ -f "$1" && ! -L "$1" ]] || fail "oczekiwano zwykłego pliku bez dowiązań: $(basename "$1")"; } for path in .env docker-compose.yml .env.example .ristu-installation .ristu-recovery ristu-certificate.crt TRUST-CERTIFICATE.txt; do [[ ! -e "$TARGET_DIR/$path" && ! -L "$TARGET_DIR/$path" ]] || plain_file "$TARGET_DIR/$path" done for path in .site-overlay quadlet-site docker-compose.override.yml docker-compose.override.yaml compose.override.yml compose.override.yaml; do [[ ! -e "$TARGET_DIR/$path" && ! -L "$TARGET_DIR/$path" ]] || fail "wykryto $path; instalacja niestandardowa/Quadlet wymaga własnej procedury aktualizacji" done [[ ! -L "$TARGET_DIR/.ristu-backups" ]] || fail "katalog backupu nie może być dowiązaniem" if [[ -f "$RECOVERY_FILE" ]] && grep -q '^phase=before-promotion$' "$RECOVERY_FILE"; then fail "przerwano podmianę konfiguracji przed migracją; odzyskaj pliki z kopii wskazanej w .ristu-recovery według instrukcji odzyskiwania przed ponowieniem" fi if [[ -f "$ENV_FILE" ]]; then ACTION=update; plain_file "$COMPOSE_FILE" else ACTION=install shopt -s nullglob dotglob; entries=("$TARGET_DIR"/*); shopt -u nullglob dotglob [[ ${#entries[@]} -eq 0 ]] || fail "nową instalację uruchom w całkowicie pustym katalogu; istniejącą w katalogu z .env" fi if ! mkdir "$LOCK_DIR" 2>/dev/null; then fail "instalator już działa lub pozostała blokada .ristu-installer.lock; sprawdź poprzedni proces przed jej ręcznym usunięciem"; fi LOCKED=1 printf '%s\n' "$$" > "$LOCK_DIR/pid" cleanup() { local status=$? restored=1 trap - EXIT INT TERM if [[ $status -ne 0 ]]; then if [[ "$PHASE" == stopped || "$PHASE" == files ]]; then if [[ "$PHASE" == files && -n "$BACKUP_DIR" ]]; then [[ "$ACTION" != install ]] || restored=0 for file in .env docker-compose.yml .env.example .ristu-installation; do if [[ -f "$BACKUP_DIR/$file" ]]; then cp -p "$BACKUP_DIR/$file" "$TARGET_DIR/$file" || restored=0 elif [[ "$file" == .ristu-installation || "$file" == .env.example ]]; then rm -f "$TARGET_DIR/$file" || restored=0 else restored=0; fi done fi if [[ "$restored" -eq 0 ]]; then log "nie ukończono przywracania konfiguracji; zachowano .ristu-recovery i kopię $BACKUP_DIR. Odzyskaj pliki według instrukcji odzyskiwania" elif [[ ${#WRITER_IDS[@]} -gt 0 ]]; then if "$ENGINE" start "${WRITER_IDS[@]}" >/dev/null 2>&1; then rm -f "$RECOVERY_FILE" log "przywrócono poprzednie kontenery; migracja nie została uruchomiona" else printf 'phase=before-migration\nversion=%s\nbackup=%s\n' "$TARGET_VERSION" "${BACKUP_DIR##*/}" > "$RECOVERY_FILE" log "nie udało się uruchomić poprzednich kontenerów; sprawdź .ristu-recovery" fi else rm -f "$RECOVERY_FILE" fi elif [[ "$PHASE" == migration ]]; then if compose "$ENV_FILE" "$COMPOSE_FILE" stop ristu-web ristu-api orthanc >/dev/null 2>&1; then log "start/migracja nie powiodły się; usługi zapisujące dane zatrzymano. Backup: $BACKUP_DIR" else log "NIE UDAŁO SIĘ zatrzymać usług zapisujących dane; sprawdź stan silnika i zatrzymaj ristu-web, ristu-api i orthanc przed odzyskiwaniem. Backup: $BACKUP_DIR" fi log "Nie uruchamiaj starego kodu na zmienionej bazie. Usuń przyczynę i ponów to samo wydanie z --resume; stan: .ristu-recovery." fi fi if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" && "$TEMP_DIR" == "$TARGET_DIR"/.ristu-bootstrap.* ]]; then rm -rf -- "$TEMP_DIR"; fi [[ "$LOCKED" -eq 0 ]] || rm -rf -- "$LOCK_DIR" exit "$status" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM TEMP_DIR="$(mktemp -d "$TARGET_DIR/.ristu-bootstrap.XXXXXX")" CANDIDATE_ENV="$TEMP_DIR/.env"; CANDIDATE_COMPOSE="$TEMP_DIR/docker-compose.yml" CANDIDATE_EXAMPLE="$TEMP_DIR/.env.example" # Only a deliberately small, literal dotenv grammar is accepted. No source/eval, # substitutions or duplicate keys; quoted simple values are allowed. validate_env() { awk ' /^[[:space:]]*(#|$)/ { next } !/^[A-Za-z_][A-Za-z0-9_]*=/ { exit 1 } { key=$0; sub(/=.*/, "", key); if (seen[key]++) exit 1 value=substr($0,length(key)+2); sub(/\r$/, "",value) if (value ~ /[\\$`]/) exit 1 if (value ~ /^\047.*\047$/ || value ~ /^".*"$/) value=substr(value,2,length(value)-2) if (value ~ /[[:space:]"\047]/) exit 1 if (key ~ /^COMPOSE_/ && key != "COMPOSE_PROJECT_NAME") exit 1 }' "$1" || fail "niejednoznaczny .env: wymagane unikalne KEY=VALUE bez interpolacji, odstępów i kodu" } get_value() { awk -v key="$2" 'index($0,key "=")==1 {v=substr($0,length(key)+2);sub(/\r$/,"",v);if(v~/^\047.*\047$/||v~/^".*"$/)v=substr(v,2,length(v)-2);print v;exit}' "$1" } set_value() { awk -v key="$2" -v value="$3" 'index($0,key "=")==1 {print key "=" value;found=1;next} {print} END {if(!found)print key "=" value}' "$1" > "$TEMP_DIR/env.new" mv "$TEMP_DIR/env.new" "$1" } ask() { local prompt="$1" default="$2" answer [[ "$NONINTERACTIVE" -eq 0 ]] || fail "brak parametru: $prompt (użyj --help)" if ! { exec 3<>/dev/tty; } 2>/dev/null; then fail "brak terminala do kreatora; użyj jawnych parametrów i --yes --non-interactive"; fi printf 'Ristu: %s [%s]: ' "$prompt" "$default" >&3 IFS= read -r answer <&3 || fail "przerwano odpowiedź w terminalu" exec 3>&- REPLY="${answer:-$default}" } sha256_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else openssl dgst -sha256 "$1" | awk '{print $NF}'; fi } verify_sha256() { [[ "$(sha256_file "$1")" == "$2" ]] || fail "pobrany plik $(basename "$1") ma nieprawidłową sumę SHA-256"; } download() { curl --proto '=https' --proto-redir '=https' --tlsv1.2 --fail --silent --show-error --location \ --connect-timeout 15 --max-time 120 --output "$2" "$1" } random_secret() { LC_ALL=C od -An -N32 -tx1 /dev/urandom | tr -d '[:space:]'; } write_recovery() { printf 'phase=%s\nversion=%s\nbackup=%s\n' "$1" "$TARGET_VERSION" "${BACKUP_DIR##*/}" > "$TEMP_DIR/recovery" mv "$TEMP_DIR/recovery" "$RECOVERY_FILE" } check_not_downgrade() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 0 awk -v old="$1" -v target="$TARGET_VERSION" 'BEGIN {split(old,a,".");split(target,b,".");for(i=1;i<=3;i++){if(b[i]+0>a[i]+0)exit 0;if(b[i]+0/dev/null 2>&1 || fail "brak $tool; zainstaluj narzędzia systemowe i uruchom ponownie"; done if [[ "$ACTION" == update ]]; then validate_env "$ENV_FILE" [[ -z "$(get_value "$ENV_FILE" ENABLED_MODULES)" ]] || fail "moduły instalacji wymagają własnej procedury aktualizacji" for key in "${SECRET_KEYS[@]}"; do value="$(get_value "$ENV_FILE" "$key")" [[ ${#value} -ge 16 && "$value" != *HASLO_ZMIEN_MNIE* ]] || fail "brak/pusty lub przykładowy sekret $key; odzyskaj pierwotny .env, nie generuj nowego" done if [[ -f "$STATE_FILE" ]]; then validate_env "$STATE_FILE" stored_engine="$(get_value "$STATE_FILE" engine)" [[ -z "$ENGINE" || "$ENGINE" == "$stored_engine" ]] || fail "zmiana silnika przeniosłaby instalację do innego magazynu danych" ENGINE="$stored_engine" stored_project="$(get_value "$STATE_FILE" project)" [[ -z "$PROJECT" || "$PROJECT" == "$stored_project" ]] || fail "projekt różni się od zapisanej instalacji" PROJECT="$stored_project" fi fi if [[ -z "$ENGINE" ]]; then available=() for candidate in docker podman; do if command -v "$candidate" >/dev/null 2>&1 && "$candidate" info >/dev/null 2>&1; then available+=("$candidate"); fi done if [[ ${#available[@]} -eq 1 ]]; then ENGINE="${available[0]}" elif [[ ${#available[@]} -eq 0 ]]; then fail "zainstaluj i uruchom Docker Desktop/Engine lub Podman oraz provider Compose, a następnie ponów polecenie" else ask 'Silnik (docker/podman)' docker; ENGINE="$REPLY"; fi fi [[ "$ENGINE" == docker || "$ENGINE" == podman ]] || fail "silnik musi mieć wartość docker lub podman" command -v "$ENGINE" >/dev/null 2>&1 || fail "brak $ENGINE; zainstaluj i uruchom silnik oraz Compose" "$ENGINE" info >/dev/null 2>&1 || fail "silnik $ENGINE nie jest gotowy" "$ENGINE" compose version >/dev/null 2>&1 || fail "brak providera Compose; Docker wymaga docker compose, Podman podman compose" if [[ "$ENGINE" == docker ]]; then endpoint="$(docker context inspect --format '{{.Endpoints.docker.Host}}')" [[ -z "${DOCKER_HOST:-}" || "$DOCKER_HOST" == "$endpoint" ]] || fail "DOCKER_HOST zmienia zapisany endpoint; wybierz lokalny kontekst silnika" [[ "$endpoint" == unix://* ]] || fail "wymagany lokalny silnik; zdalny kontekst Docker nie jest obsługiwany" engine_info="$(docker info --format '{{.ID}}|{{.OSType}}|{{.Architecture}}|{{.OperatingSystem}}|{{json .SecurityOptions}}')" engine_identity="$(docker info --format '{{.ID}}')|$endpoint" else [[ -z "${CONTAINER_HOST:-}" && -z "${CONTAINER_CONNECTION:-}" ]] || fail "zdalny endpoint Podmana w środowisku; użyj lokalnego silnika/machine" engine_info="$(podman info --format '{{.Store.GraphRoot}}|{{.Host.Hostname}}|{{.Host.Arch}}|{{.Host.Security.Rootless}}')" engine_identity="$(podman info --format '{{.Store.GraphRoot}}|{{.Host.Security.Rootless}}')" fi printf '%s\n%s\n' "$ENGINE" "$engine_identity" > "$TEMP_DIR/engine-identity" ENGINE_ID="$(sha256_file "$TEMP_DIR/engine-identity")" printf '%s' "$TARGET_DIR" > "$TEMP_DIR/directory-identity"; DIRECTORY_ID="$(sha256_file "$TEMP_DIR/directory-identity")" if [[ -f "$STATE_FILE" ]]; then [[ "$(get_value "$STATE_FILE" engine_id)" == "$ENGINE_ID" && "$(get_value "$STATE_FILE" directory_id)" == "$DIRECTORY_ID" ]] || fail "inny silnik, magazyn danych lub katalog niż zapisany w .ristu-installation" fi [[ -n "$PROJECT" ]] || PROJECT="$(get_value "$ENV_FILE" COMPOSE_PROJECT_NAME 2>/dev/null || true)" PROJECT="${PROJECT:-ristu-fresh}" [[ "$PROJECT" =~ ^[a-z0-9][a-z0-9_-]*$ && ${#PROJECT} -le 63 ]] || fail "nieprawidłowa nazwa projektu" if [[ "$ACTION" == install ]]; then existing_names="$("$ENGINE" ps -a --format '{{.Names}}')" while IFS= read -r name; do case "$name" in ristu-db|orthanc-db|orthanc-auth-service|orthanc|ohif|ristu-viewer|ristu-api|ristu-web) fail "silnik zawiera już kontenery Ristu; odzyskaj katalog .env zamiast nowej instalacji";; esac done <<< "$existing_names" existing_volumes="$("$ENGINE" volume ls --format '{{.Name}}')" while IFS= read -r name; do case "$name" in *_ristu_db|*_orthanc_db|*_orthanc_dicom|*_ristu_api_runtime|*_ristu_web_tls|*_ristu_dicomweb_cache) fail "silnik zawiera już wolumeny Ristu; odzyskaj plik .env zamiast tworzyć nowe sekrety";; esac done <<< "$existing_volumes" [[ -n "$INSTALL_MODE" ]] || { ask 'Tryb (workstation/serwer: server)' workstation; INSTALL_MODE="$REPLY"; } [[ -n "$HOST_NAME" ]] || { ask 'Adres aplikacji (IPv4 lub DNS, bez https://)' localhost; HOST_NAME="$REPLY"; } if [[ "$NONINTERACTIVE" -eq 0 ]]; then [[ -n "$HTTPS_PORT" ]] || { ask 'Port HTTPS aplikacji' 8443; HTTPS_PORT="$REPLY"; } [[ -n "$ADMIN_PORT" ]] || { ask 'Port HTTPS administratora' 8445; ADMIN_PORT="$REPLY"; } fi HTTPS_PORT="${HTTPS_PORT:-8443}"; ADMIN_PORT="${ADMIN_PORT:-8445}" if [[ "$INSTALL_MODE" == server ]]; then [[ -n "$SERVER_IP" ]] || { ask 'Lokalny IPv4 serwera' ''; SERVER_IP="$REPLY"; } [[ -n "$ADMIN_IP" ]] || { ask 'IPv4 jednej stacji administratora' ''; ADMIN_IP="$REPLY"; } else SERVER_IP=127.0.0.1; ADMIN_IP=127.0.0.1; fi else current_bind="$(get_value "$ENV_FILE" RISTU_LOCAL_ADMIN_BIND_IP)" current_mode=server; [[ "${current_bind:-127.0.0.1}" != 127.0.0.1 ]] || current_mode=workstation [[ -z "$INSTALL_MODE" || "$INSTALL_MODE" == "$current_mode" ]] || fail "zmiana trybu wymaga oddzielnej konfiguracji dostępu i certyfikatu" INSTALL_MODE="$current_mode" for pair in 'HTTPS_PORT RISTU_HTTPS_PORT' 'ADMIN_PORT RISTU_LOCAL_ADMIN_HTTPS_PORT' 'SERVER_IP RISTU_LOCAL_ADMIN_BIND_IP' 'ADMIN_IP RISTU_LOCAL_ADMIN_ALLOWED_IP'; do read -r option key <<< "$pair"; current="$(get_value "$ENV_FILE" "$key")" [[ "$key" != RISTU_LOCAL_ADMIN_BIND_IP || -n "$current" ]] || current=127.0.0.1 case "$option" in HTTPS_PORT) [[ -z "$HTTPS_PORT" || "$HTTPS_PORT" == "$current" ]] || fail "aktualizacja nie zmienia portów"; HTTPS_PORT="$current";; ADMIN_PORT) [[ -z "$ADMIN_PORT" || "$ADMIN_PORT" == "$current" ]] || fail "aktualizacja nie zmienia portów"; ADMIN_PORT="$current";; SERVER_IP) [[ -z "$SERVER_IP" || "$SERVER_IP" == "$current" ]] || fail "aktualizacja nie zmienia adresów"; SERVER_IP="$current";; ADMIN_IP) [[ -z "$ADMIN_IP" || "$ADMIN_IP" == "$current" ]] || fail "aktualizacja nie zmienia adresów"; ADMIN_IP="$current";; esac done if [[ -f "$STATE_FILE" ]]; then current_host="$(get_value "$STATE_FILE" host)" elif [[ "$INSTALL_MODE" == workstation ]]; then current_host=localhost else current_host="$(get_value "$ENV_FILE" RISTU_TLS_SAN_DNS | cut -d, -f1)"; [[ -n "$current_host" && "$current_host" != localhost ]] || current_host="$SERVER_IP"; fi [[ -z "$HOST_NAME" || "$HOST_NAME" == "$current_host" ]] || fail "aktualizacja zachowuje adres i zaufany certyfikat" HOST_NAME="$current_host" fi [[ "$INSTALL_MODE" == workstation || "$INSTALL_MODE" == server ]] || fail "tryb musi mieć wartość workstation lub server" valid_host "$HOST_NAME" || fail "nieprawidłowy adres aplikacji (IPv4 lub DNS)" valid_port "$HTTPS_PORT" && valid_port "$ADMIN_PORT" && [[ "$HTTPS_PORT" != "$ADMIN_PORT" ]] || fail "porty muszą być różne i należeć do 1–65535" ipv4 "$SERVER_IP" && ipv4 "$ADMIN_IP" || fail "nieprawidłowy IPv4 serwera/stacji administratora" if [[ "$INSTALL_MODE" == server ]]; then [[ "$(uname -s)" == Linux && "$engine_info" != *Desktop* && "$engine_info" != *rootless* ]] || fail "serwer wymaga natywnego Linuxa z zachowaniem IP klienta; Desktop/rootless obsługuje tryb workstation" [[ "$ENGINE" != podman || "$engine_info" == *'|false' ]] || fail "Podman rootless nie gwarantuje IP klienta; użyj workstation albo rootful Linux" [[ "$SERVER_IP" != 127.* && "$SERVER_IP" != 0.* && "$ADMIN_IP" != 127.* && "$ADMIN_IP" != 0.* && "$SERVER_IP" != "$ADMIN_IP" ]] || fail "serwer i zdalna stacja wymagają dwóch różnych, routowalnych IPv4" [[ "$HOST_NAME" != localhost && "$HOST_NAME" != 127.* ]] || fail "tryb server wymaga nazwy/adresu serwera dostępnego ze stacji" fi case "$engine_info" in *aarch64*|*arm64*) log "obrazy Ristu są linux/amd64; wymagają działającej emulacji amd64 w silniku";; esac log "tryb $ACTION ($INSTALL_MODE), $ENGINE, projekt $PROJECT; pobieranie wydania $TARGET_VERSION" download "$ARTIFACT_BASE_URL/docker-compose.yml" "$CANDIDATE_COMPOSE" download "$ARTIFACT_BASE_URL/env.example" "$CANDIDATE_EXAMPLE" verify_sha256 "$CANDIDATE_COMPOSE" "$EXPECTED_COMPOSE_SHA256" verify_sha256 "$CANDIDATE_EXAMPLE" "$EXPECTED_ENV_SHA256" validate_env "$CANDIDATE_EXAMPLE" if [[ "$ACTION" == install ]]; then cp "$CANDIDATE_EXAMPLE" "$CANDIDATE_ENV" for key in "${SECRET_KEYS[@]}"; do set_value "$CANDIDATE_ENV" "$key" "$(random_secret)"; done set_value "$CANDIDATE_ENV" RISTU_LOCAL_ADMIN_BIND_IP "$SERVER_IP" set_value "$CANDIDATE_ENV" RISTU_LOCAL_ADMIN_ALLOWED_IP "$ADMIN_IP" set_value "$CANDIDATE_ENV" RISTU_HTTPS_PORT "$HTTPS_PORT" set_value "$CANDIDATE_ENV" RISTU_LOCAL_ADMIN_HTTPS_PORT "$ADMIN_PORT" if ipv4 "$HOST_NAME"; then set_value "$CANDIDATE_ENV" RISTU_TLS_SAN_IPS "127.0.0.1,$HOST_NAME" else set_value "$CANDIDATE_ENV" RISTU_TLS_SAN_DNS "localhost,$HOST_NAME"; fi else cp "$ENV_FILE" "$CANDIDATE_ENV" while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)= ]]; then key="${BASH_REMATCH[1]}" if ! awk -v key="$key" 'index($0,key "=")==1 {found=1} END {exit !found}' "$CANDIDATE_ENV"; then printf '%s\n' "$line" >> "$CANDIDATE_ENV"; fi fi done < "$CANDIDATE_EXAMPLE" fi set_value "$CANDIDATE_ENV" RISTU_IMAGE_TAG "$TARGET_VERSION" [[ -n "$(get_value "$CANDIDATE_ENV" RISTU_UPDATE_MANIFEST_URL)" ]] || set_value "$CANDIDATE_ENV" RISTU_UPDATE_MANIFEST_URL "$UPDATE_MANIFEST_URL" compose "$CANDIDATE_ENV" "$CANDIDATE_COMPOSE" config --quiet > "$TEMP_DIR/config.log" 2>&1 || fail "Compose odrzucił konfigurację; nie zmieniono usług" # A first startup can fail before Compose has created all seven containers. # Its persisted .env is reused; missing resources may only be created while # explicitly resuming that same initial installation. if [[ -f "$RECOVERY_FILE" && -f "$STATE_FILE" && "$RESUME" -eq 1 && "$(get_value "$RECOVERY_FILE" backup)" == initial && "$(get_value "$STATE_FILE" volumes_sha256)" == pending ]]; then ACTION=install-resume fi if [[ "$ACTION" == update ]]; then installed_version="$(get_value "$STATE_FILE" version 2>/dev/null || get_value "$ENV_FILE" RISTU_IMAGE_TAG)" check_not_downgrade "$installed_version" if [[ -f "$STATE_FILE" ]]; then [[ "$(sha256_file "$COMPOSE_FILE")" == "$(get_value "$STATE_FILE" compose_sha256)" ]] || fail "lokalny Compose zmieniono; wymagana aktualizacja dostosowanej instalacji" else old_version="$(get_value "$ENV_FILE" RISTU_IMAGE_TAG)" if [[ "$old_version" == latest ]]; then api_id="$(container_id ristu-api)"; [[ -n "$api_id" ]] || fail "brak istniejącego API do identyfikacji wersji latest" image_id="$("$ENGINE" inspect "$api_id" --format '{{.Image}}')" old_version="$("$ENGINE" image inspect "$image_id" --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" fi [[ "$old_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "nie można ustalić opublikowanej wersji starej instalacji" check_not_downgrade "$old_version" download "${ARTIFACT_BASE_URL%/releases/*}/releases/$old_version/docker-compose.yml" "$TEMP_DIR/old-official.yml" [[ "$(sha256_file "$COMPOSE_FILE")" == "$(sha256_file "$TEMP_DIR/old-official.yml")" ]] || fail "istniejący Compose różni się od neutralnego wydania; własna konfiguracja wymaga własnej procedury" fi for service in "${SERVICES[@]}"; do cid="$(container_id "$service")" [[ -n "$cid" && "$cid" != *$'\n'* ]] || fail "brak/jednoznaczność kontenera $service; odzyskaj istniejącą instalację" labels="$("$ENGINE" inspect "$cid" --format '{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.service"}}')" [[ "$labels" == "$PROJECT|$service" ]] || fail "kontener $service należy do innego projektu/usługi" CONTAINER_IDS+=("$cid") case "$service" in ristu-web|ristu-api|orthanc) [[ "$("$ENGINE" inspect "$cid" --format '{{.State.Running}}')" != true ]] || WRITER_IDS+=("$cid");; esac done # Verify that this .env belongs to these data containers, without exposing it. for record in 'ristu-db POSTGRES_PASSWORD RISTU_DB_PASSWORD' 'orthanc-db POSTGRES_PASSWORD ORTHANC_DB_PASSWORD' 'orthanc-auth-service SECRET_KEY ORTHANC_AUTH_SECRET' 'ristu-api PROXY_SESSION_SECRET RISTU_PROXY_SESSION_SECRET' 'ristu-api AI_SETTINGS_SECRET RISTU_SETTINGS_SECRET' 'ristu-api ORTHANC_AUTH_SERVICE_PASSWORD ORTHANC_AUTH_SERVICE_PASSWORD'; do read -r service container_key env_key <<< "$record" "$ENGINE" inspect "$(container_id "$service")" --format '{{range .Config.Env}}{{println .}}{{end}}' > "$TEMP_DIR/container.env" [[ "$(get_value "$TEMP_DIR/container.env" "$container_key")" == "$(get_value "$ENV_FILE" "$env_key")" ]] || fail "sekret $env_key nie odpowiada istniejącemu kontenerowi; odzyskaj właściwy .env" done for record in 'ristu-db /var/lib/postgresql/data ristu_db' 'orthanc-db /var/lib/postgresql/data orthanc_db' 'orthanc /var/lib/orthanc/db orthanc_dicom' 'ristu-api /runtime ristu_api_runtime' 'ristu-web /etc/nginx/tls ristu_web_tls' 'ristu-api /var/cache/ristu/dicomweb ristu_dicomweb_cache'; do read -r service destination volume <<< "$record" actual="$("$ENGINE" inspect "$(container_id "$service")" --format "{{range .Mounts}}{{if eq .Destination \"$destination\"}}{{.Name}}{{end}}{{end}}")" [[ "$actual" == "${PROJECT}_$volume" ]] || fail "niezgodny wolumen $volume; nie wolno podłączyć pustego magazynu danych" done # Changes to data-engine binaries need their own migration/restore procedure. compose "$ENV_FILE" "$COMPOSE_FILE" config --images | sort > "$TEMP_DIR/old-images" compose "$CANDIDATE_ENV" "$CANDIDATE_COMPOSE" config --images | sort > "$TEMP_DIR/new-images" sed -n '/\/postgres[:@]/p; /\/orthanc[:@]/p; /\/orthanc-pre-release[:@]/p' "$TEMP_DIR/old-images" > "$TEMP_DIR/old-data-images" sed -n '/\/postgres[:@]/p; /\/orthanc[:@]/p; /\/orthanc-pre-release[:@]/p' "$TEMP_DIR/new-images" > "$TEMP_DIR/new-data-images" cmp -s "$TEMP_DIR/old-data-images" "$TEMP_DIR/new-data-images" || fail "wydanie zmienia silnik PostgreSQL/Orthanc; potrzebna osobna procedura migracji i kopii DICOM" fi volume_identity() { local volume for volume in "${VOLUMES[@]}"; do "$ENGINE" volume inspect "${PROJECT}_$volume" --format '{{.Name}}|{{.CreatedAt}}' done } if [[ "$ACTION" == update ]]; then volume_identity > "$TEMP_DIR/volumes.before" VOLUME_ID="$(sha256_file "$TEMP_DIR/volumes.before")" [[ ! -f "$STATE_FILE" || "$(get_value "$STATE_FILE" volumes_sha256)" == "$VOLUME_ID" ]] || fail "zmieniono tożsamość wolumenów od poprzedniego uruchomienia" fi if [[ -f "$RECOVERY_FILE" ]]; then validate_env "$RECOVERY_FILE" [[ "$RESUME" -eq 1 && "$(get_value "$RECOVERY_FILE" version)" == "$TARGET_VERSION" && "$(get_value "$RECOVERY_FILE" phase)" == migration ]] || fail "niedokończona operacja; przeczytaj .ristu-recovery. Po usunięciu przyczyny użyj tego samego wydania i --resume" backup_name="$(get_value "$RECOVERY_FILE" backup)" [[ "$backup_name" =~ ^[A-Za-z0-9_-]+$ ]] || fail "nieprawidłowy katalog kopii we wskaźniku odzyskiwania" BACKUP_DIR="$TARGET_DIR/.ristu-backups/$backup_name" [[ "$BACKUP_DIR" == "$TARGET_DIR/.ristu-backups/"* && -f "$BACKUP_DIR/COMPLETE" && ! -L "$BACKUP_DIR" ]] || fail "brak potwierdzonej kopii do wznowienia" elif [[ "$RESUME" -eq 1 ]]; then fail "brak niedokończonej operacji do wznowienia"; fi log "pobieranie obrazów przypiętych do $TARGET_VERSION (przed przerwą serwisową)" compose "$CANDIDATE_ENV" "$CANDIDATE_COMPOSE" pull > "$TEMP_DIR/pull.log" 2>&1 || fail "pobieranie obrazów nie powiodło się; usługi pozostają bez zmian" if [[ "$YES" -eq 0 ]]; then ask 'Uruchomić instalację/aktualizację? Aktualizacja zatrzyma zapis na czas kopii baz i danych runtime (tak/nie)' nie [[ "$REPLY" == tak || "$REPLY" == yes ]] || fail "anulowano bez zmiany usług" fi if [[ "$ACTION" == update && "$RESUME" -eq 0 ]]; then BACKUP_DIR="$TARGET_DIR/.ristu-backups/$(date -u +%Y%m%dT%H%M%SZ)-$$" mkdir -p "$BACKUP_DIR"; chmod 700 "$TARGET_DIR/.ristu-backups" "$BACKUP_DIR" for file in .env docker-compose.yml .env.example .ristu-installation; do [[ ! -f "$TARGET_DIR/$file" ]] || cp -p "$TARGET_DIR/$file" "$BACKUP_DIR/$file"; done cp "$TEMP_DIR/volumes.before" "$BACKUP_DIR/volumes.txt" for cid in "${CONTAINER_IDS[@]}"; do "$ENGINE" inspect "$cid" --format '{{.Id}} {{.Image}} {{.State.Running}}' >> "$BACKUP_DIR/containers.txt"; done # Writers are stopped first; PostgreSQL remains running for consistent dumps. write_recovery before-migration PHASE=stopped compose "$ENV_FILE" "$COMPOSE_FILE" stop ristu-web ristu-api orthanc > "$TEMP_DIR/stop.log" 2>&1 || fail "nie udało się zatrzymać zapisu" log "kopia obu baz, runtime i TLS; wolumen DICOM pozostaje na miejscu (nie jest kopiowany)" for service in ristu-db orthanc-db; do database=orthanc_ris; [[ "$service" != orthanc-db ]] || database=orthanc cid="$(container_id "$service")"; dump_path="/tmp/ristu-installer-$$.dump" # Byte-safe also in PowerShell 5.1: database produces a file, engine copies it. if ! "$ENGINE" exec "$cid" pg_dump -U "$database" -d "$database" --format=custom --file="$dump_path"; then "$ENGINE" exec "$cid" rm -f "$dump_path" >/dev/null 2>&1 || true; fail "pg_dump $service nie powiódł się" fi if ! "$ENGINE" exec "$cid" pg_restore --list "$dump_path" > "$TEMP_DIR/$service.list"; then "$ENGINE" exec "$cid" rm -f "$dump_path" >/dev/null 2>&1 || true; fail "nieprawidłowe archiwum $service" fi if ! dump_sha="$("$ENGINE" exec "$cid" sha256sum "$dump_path" | awk '{print $1}')"; then "$ENGINE" exec "$cid" rm -f "$dump_path" >/dev/null 2>&1 || true; fail "nie można sprawdzić sumy kopii $service" fi if ! "$ENGINE" cp "$cid:$dump_path" "$BACKUP_DIR/$service.dump"; then "$ENGINE" exec "$cid" rm -f "$dump_path" >/dev/null 2>&1 || true; fail "nie można zapisać kopii $service" fi "$ENGINE" exec "$cid" rm -f "$dump_path" [[ -s "$BACKUP_DIR/$service.dump" && "$dump_sha" =~ ^[a-f0-9]{64}$ && "$(sha256_file "$BACKUP_DIR/$service.dump")" == "$dump_sha" ]] || fail "pusta lub uszkodzona kopia $service po przeniesieniu na hosta" done for record in 'ristu-api /runtime runtime' 'ristu-web /etc/nginx/tls tls'; do read -r service source name <<< "$record" # Engine tar preserves Linux UID/mode, including licensing 10001/0700/0600. # Repacking host files loses this metadata, especially on macOS/Windows. "$ENGINE" cp "$(container_id "$service"):$source/." - > "$BACKUP_DIR/$name.tar" tar -tf "$BACKUP_DIR/$name.tar" > /dev/null || fail "nie można odczytać archiwum $name" done (cd "$BACKUP_DIR"; for file in .env docker-compose.yml ristu-db.dump orthanc-db.dump runtime.tar tls.tar volumes.txt containers.txt; do printf '%s %s\n' "$(sha256_file "$file")" "$file"; done) > "$BACKUP_DIR/SHA256SUMS" printf 'Backup baz PostgreSQL, runtime, TLS i konfiguracji. DICOM pozostaje w oryginalnym wolumenie; to nie jest pełna kopia DICOM.\n' > "$BACKUP_DIR/README.txt" printf 'verified\n' > "$BACKUP_DIR/COMPLETE" fi if [[ "$ACTION" == install ]]; then # A recoverable first startup has no previous databases to back up. BACKUP_DIR="$TARGET_DIR/.ristu-backups/initial" mkdir -p "$BACKUP_DIR"; printf 'initial installation; no previous data\n' > "$BACKUP_DIR/COMPLETE" fi PHASE=files write_recovery before-promotion mv "$CANDIDATE_ENV" "$ENV_FILE"; mv "$CANDIDATE_COMPOSE" "$COMPOSE_FILE"; mv "$CANDIDATE_EXAMPLE" "$TARGET_DIR/.env.example" chmod 600 "$ENV_FILE"; chmod 644 "$COMPOSE_FILE" "$TARGET_DIR/.env.example" write_state() { printf 'schema=1\nengine=%s\nengine_id=%s\ndirectory_id=%s\nproject=%s\nmode=%s\nhost=%s\nversion=%s\ncompose_sha256=%s\nvolumes_sha256=%s\n' \ "$ENGINE" "$ENGINE_ID" "$DIRECTORY_ID" "$PROJECT" "$INSTALL_MODE" "$HOST_NAME" "$TARGET_VERSION" "$EXPECTED_COMPOSE_SHA256" "${VOLUME_ID:-pending}" > "$TEMP_DIR/state" mv "$TEMP_DIR/state" "$STATE_FILE" } write_state write_recovery migration PHASE=migration log "uruchamianie $TARGET_VERSION; oczekiwanie na siedem zdrowych usług" compose "$ENV_FILE" "$COMPOSE_FILE" up -d > "$TEMP_DIR/up.log" 2>&1 || fail "Compose nie uruchomił wydania" start_seconds=$SECONDS while :; do healthy=0 for service in "${SERVICES[@]}"; do cid="$(container_id "$service")" [[ -n "$cid" ]] || continue status="$("$ENGINE" inspect "$cid" --format '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}')" [[ "$status" != 'running|healthy' ]] || healthy=$((healthy + 1)) done [[ "$healthy" -ne 7 ]] || break [[ $((SECONDS - start_seconds)) -lt "$WAIT_TIMEOUT" ]] || fail "przekroczono czas oczekiwania na zdrowie wszystkich usług ($healthy/7)" sleep 2 done volume_identity > "$TEMP_DIR/volumes.after" if [[ "$ACTION" == update ]]; then cmp -s "$TEMP_DIR/volumes.before" "$TEMP_DIR/volumes.after" || fail "zmieniła się tożsamość wolumenów"; fi VOLUME_ID="$(sha256_file "$TEMP_DIR/volumes.after")"; write_state web_id="$(container_id ristu-web)" "$ENGINE" cp "$web_id:/etc/nginx/tls/fresh.crt" "$TEMP_DIR/certificate.crt" "$ENGINE" exec "$web_id" openssl x509 -in /etc/nginx/tls/fresh.crt -noout -sha256 -fingerprint > "$TEMP_DIR/fingerprint" certificate_check=-checkhost; ipv4 "$HOST_NAME" && certificate_check=-checkip certificate_result="$("$ENGINE" exec "$web_id" openssl x509 -in /etc/nginx/tls/fresh.crt -noout "$certificate_check" "$HOST_NAME")" [[ "$certificate_result" == *' does match certificate' ]] || fail "certyfikat nie odpowiada adresowi aplikacji; zachowano istniejący klucz, potrzebna oddzielna konfiguracja TLS" if [[ -f "$TARGET_DIR/ristu-certificate.crt" ]]; then cmp -s "$TARGET_DIR/ristu-certificate.crt" "$TEMP_DIR/certificate.crt" || fail "zmienił się istniejący certyfikat; nie zmieniono eksportowanego zaufania"; fi mv "$TEMP_DIR/certificate.crt" "$TARGET_DIR/ristu-certificate.crt"; chmod 644 "$TARGET_DIR/ristu-certificate.crt" cat > "$TARGET_DIR/TRUST-CERTIFICATE.txt" < Zaufane główne urzędy certyfikacji > Certyfikaty > Importuj. macOS: Dostęp do pęku kluczy > pęk logowania/System > import certyfikatu > Zaufanie > SSL: Zawsze ufaj. Linux: dodaj certyfikat do zaufanych w docelowej przeglądarce lub systemowym magazynie dystrybucji (np. /usr/local/share/ca-certificates i update-ca-certificates na Debian/Ubuntu). Firefox/Linux może używać osobnego NSS: znajdź Katalog główny profilu w about:profiles, zamknij Firefox i użyj Mozilla NSS certutil (pakiet libnss3-tools, NIE Windows certutil.exe): certutil -A -d "sql:/sciezka/profilu" -n Ristu -t "C,," -i "/sciezka/ristu-certificate.crt" Flaga C,, ustanawia zaufanie; samo P,, nie wystarcza. Zamknij i otwórz przeglądarkę. Adres ma otwierać się bez ostrzeżenia TLS; dopiero wtedy instaluj PWA. Samo pominięcie ostrzeżenia nie ustanawia zaufania. Aplikacja: https://$HOST_NAME:$HTTPS_PORT/ Administrator i pierwsze konto: https://$([[ "$INSTALL_MODE" == workstation ]] && printf localhost || printf '%s' "$HOST_NAME"):$ADMIN_PORT/ TRUST rm -f "$RECOVERY_FILE"; PHASE=complete log "$([[ "$ACTION" == install ]] && printf 'instalacja' || printf 'aktualizacja') zakończona: https://$HOST_NAME:$HTTPS_PORT/" log "Certyfikat publiczny: $TARGET_DIR/ristu-certificate.crt; instrukcja: TRUST-CERTIFICATE.txt" cat "$TEMP_DIR/fingerprint" log "Pierwsze konto/administrator: https://$([[ "$INSTALL_MODE" == workstation ]] && printf localhost || printf '%s' "$HOST_NAME"):$ADMIN_PORT/" [[ "$ACTION" != update ]] || log "Kopia baz/runtime/TLS/konfiguracji: $BACKUP_DIR; DICOM zachowano w tym samym wolumenie."