#!/usr/bin/env bash # # ClassroomIO self-host lifecycle manager. # # Run with no arguments for an interactive menu, or pass a subcommand directly: # # ./classroomio.sh # interactive menu # ./classroomio.sh install # first-time setup: fetch files, create .env, start # ./classroomio.sh start # non-interactive start (env auto-setup + up -d) # ./classroomio.sh stop|restart|upgrade|logs|backup|migrate-storage # # The script is standalone: run it in an empty directory and `install` downloads # docker-compose.images.yaml + .env.example from GitHub. Inside a repo checkout it # uses the local files. It never overwrites an existing .env or (unless it still uses MinIO) compose file. # # The compose file tracks `main` (the stack topology); CIO_VERSION in .env pins the # app images. Pin an exact release (e.g. 1.4.2) in production. set -euo pipefail ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" PROJECT_NAME="classroomio" ENV_FILE="${ROOT_DIR}/.env" ENV_EXAMPLE_FILE="${ROOT_DIR}/.env.example" BUILD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.yaml" IMAGES_COMPOSE_FILE="${ROOT_DIR}/docker-compose.images.yaml" RAW_BASE_URL="https://raw.githubusercontent.com/classroomio/classroomio/main" # Default to PULLING pre-built images (fast). Pass --build to build from source. USE_IMAGES=true COMPOSE_FILE="${IMAGES_COMPOSE_FILE}" # --profile flags live in ONE variable so every command (start, stop, logs, backup) # sees the same profile state. --no-storage empties it. PROFILE_ARGS="--profile storage" # Pre-SeaweedFS installs keep their uploads in this volume unless MINIO_LEGACY_DATA says otherwise. LEGACY_MINIO_VOLUME="${PROJECT_NAME}_minio-data" API_IMAGE_REPO="classroomio/api" # Set on app images whose upload URLs the bundled SeaweedFS accepts. SEAWEEDFS_READY_LABEL="com.classroomio.storage.seaweedfs-ready" # Reads the old volume when no MinIO image is cached; override with MINIO_LEGACY_IMAGE. LEGACY_MINIO_FALLBACK_IMAGE="pgsty/silo:RELEASE.2026-09-16T00-00-00Z" RCLONE_IMAGE="rclone/rclone:1.75.1" print_usage() { cat <<'USAGE' Usage: ./classroomio.sh [options] [command] [args] Commands: install First-time setup: fetch compose file + .env.example (if missing), create .env, generate secrets, pull images and start the stack. start Start the stack (auto-generates missing secrets first). stop Stop all containers (data volumes are kept). restart Restart all containers. upgrade Back up first, then pull newer images and restart. logs [svc] Follow logs (all services, or one: api, dashboard, jobs, postgres, ...). backup Dump Postgres and archive the object-storage volume into ./backups/. migrate-storage Copy uploads from the old bundled MinIO volume into the bundled SeaweedFS store. start/restart/upgrade run this automatically when needed. Run without a command to get an interactive menu. Options: --build Use docker-compose.yaml and build images from source instead of pulling. Requires a local repo checkout (git clone) — not available in a standalone directory set up via 'install'. --no-storage Exclude the bundled object storage (requires external S3-compatible storage). --no-minio is accepted as a deprecated alias. -h, --help Show this help message. USAGE } # ────────────────────────────────────────────── # Compose CLI detection: `docker compose` (v2) or legacy `docker-compose`. # ────────────────────────────────────────────── COMPOSE_CMD="" detect_compose() { if ! command -v docker >/dev/null 2>&1; then echo "Error: docker is not installed or not on PATH." exit 1 fi if docker compose version >/dev/null 2>&1; then COMPOSE_CMD="docker compose" elif command -v docker-compose >/dev/null 2>&1; then COMPOSE_CMD="docker-compose" else echo "Error: neither 'docker compose' nor 'docker-compose' is available." exit 1 fi } # All compose invocations go through this so the env file, project name, compose # file, and profiles are consistent everywhere. Word-splitting of COMPOSE_CMD and # PROFILE_ARGS is intentional. compose() { # shellcheck disable=SC2086 ${COMPOSE_CMD} --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${PROFILE_ARGS} "$@" } # ────────────────────────────────────────────── # .env helpers # ────────────────────────────────────────────── get_env_value() { local key="$1" local line line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)" printf '%s' "${line#*=}" } upsert_env_value() { local key="$1" local value="$2" local tmp_file tmp_file="$(mktemp)" awk -v key="${key}" -v value="${value}" ' BEGIN { updated = 0 } $0 ~ ("^" key "=") { if (updated == 0) { print key "=" value; updated = 1; } next; } { print } END { if (updated == 0) { print key "=" value; } } ' "${ENV_FILE}" > "${tmp_file}" mv "${tmp_file}" "${ENV_FILE}" } is_insecure_token_value() { local value="$1" local normalized normalized="$(printf '%s' "${value}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" if [[ "${normalized}" =~ ^\"(.*)\"$ ]]; then normalized="${BASH_REMATCH[1]}" elif [[ "${normalized}" =~ ^\'(.*)\'$ ]]; then normalized="${BASH_REMATCH[1]}" fi case "${normalized}" in "" | "replace-with-a-long-random-token" | "local-dev-api-key" | "changeme" | "replace-me") return 0 ;; replace-with-* | *local-dev* | *change-this* | *your-*) return 0 ;; *) return 1 ;; esac } generate_secure_token() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 return fi if command -v node >/dev/null 2>&1; then node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" return fi if command -v python3 >/dev/null 2>&1; then python3 -c "import secrets; print(secrets.token_hex(32))" return fi if [[ -r /dev/urandom ]]; then head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' echo return fi # Last resort only: no openssl, node, python3, or /dev/urandom. A timestamp-derived # value is weak (guessable within the install's time window) but better than failing. date +%s%N | shasum | awk '{ print $1 }' } ensure_secure_auth_tokens() { if [[ ! -f "${ENV_FILE}" ]]; then touch "${ENV_FILE}" fi local private_server_key private_server_key="$(get_env_value PRIVATE_SERVER_KEY)" # PRIVATE_SERVER_KEY authenticates the dashboard's server-side calls to the API and must be # identical in both services. Generate a strong value when it's empty or a known placeholder. if is_insecure_token_value "${private_server_key}"; then upsert_env_value PRIVATE_SERVER_KEY "$(generate_secure_token)" echo "Generated secure PRIVATE_SERVER_KEY in .env" fi } ensure_secure_betterauth_secret() { if [[ ! -f "${ENV_FILE}" ]]; then touch "${ENV_FILE}" fi local secret secret="$(get_env_value BETTER_AUTH_SECRET)" # Only (re)generate when the value is empty or a known placeholder; never clobber # a strong secret the user has set themselves. if is_insecure_token_value "${secret}"; then upsert_env_value BETTER_AUTH_SECRET "$(generate_secure_token)" echo "Generated secure BETTER_AUTH_SECRET in .env" fi } is_local_origin() { local value="$1" case "${value}" in "" | *localhost* | *127.0.0.1* | *0.0.0.0*) return 0 ;; *) return 1 ;; esac } # Point browser-facing storage URLs at the public domain when DASHBOARD_ORIGIN is not # localhost. Bundled media is served on :9000 — operators must reverse-proxy /media to it. # `mode` is "bundled" (ensure_bundled_storage_env) or "external" (ensure_storage_env's # --no-storage path). For bundled storage, OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL is script-owned, so we # keep it in sync with DASHBOARD_ORIGIN on every run — otherwise changing DASHBOARD_ORIGIN # after install would leave media links pointed at the old domain forever. For external # storage it's operator-owned (their CDN/bucket URL), so we only fill in a default and # never clobber an explicit value. derive_public_storage_urls() { local mode="$1" local dashboard_origin public_endpoint media_base dashboard_origin="$(get_env_value DASHBOARD_ORIGIN)" public_endpoint="$(get_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT)" media_base="$(get_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL)" if ! is_local_origin "${dashboard_origin}"; then local origin="${dashboard_origin%/}" local derived="${origin}/media" # Public media is served from the public-download `media` bucket, so deriving its base # URL from the domain is what fixes broken media links in served pages. We deliberately # do NOT touch OBJECT_STORAGE_PUBLIC_ENDPOINT (the S3 API endpoint) — pointing it at the # dashboard origin would break presigned URLs for the non-public videos/documents buckets. if [[ "${mode}" == "bundled" ]]; then if [[ "${media_base}" != "${derived}" ]]; then upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${derived}" echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${derived})." echo " -> Ensure your reverse proxy routes ${origin}/media to the storage service (port 9000)." echo " -> For presigned access to the videos/documents buckets behind a domain, expose" echo " the storage S3 API on its own route and set OBJECT_STORAGE_PUBLIC_ENDPOINT explicitly." fi else if is_local_origin "${media_base}"; then upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${derived}" echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${derived})." fi fi allow_storage_in_csp else # Local demo: ensure localhost defaults are present. if [[ -z "${public_endpoint}" ]]; then upsert_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT "http://localhost:9000" fi if [[ -z "${media_base}" ]]; then upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "http://localhost:9000/media" fi fi } url_origin() { printf '%s' "$1" | sed -E 's#^(https?://[^/]+).*#\1#' } add_csp_domain() { local key="$1" origin="$2" current current="$(get_env_value "${key}")" # A bare host in the list means https://host, so both forms count as already listed. if [[ -z "${origin}" ]] || is_local_origin "${origin}" || [[ ",${current// /}," == *",${origin},"* || ",${current// /}," == *",${origin#https://},"* ]]; then return 0 fi upsert_env_value "${key}" "${current:+${current},}${origin}" echo "Allowed ${origin} in ${key} so browsers can reach object storage." } # Browsers upload straight to storage and load media from it, so the dashboard's CSP must allow both. allow_storage_in_csp() { # ALLOWED_EXTERNAL_DOMAINS replaces every per-directive list; it's operator-owned. if [[ -n "$(get_env_value ALLOWED_EXTERNAL_DOMAINS)" ]]; then return 0 fi local endpoint media endpoint="$(url_origin "$(get_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT)")" media="$(url_origin "$(get_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL)")" add_csp_domain CSP_CONNECT_SRC_DOMAINS "${endpoint}" add_csp_domain CSP_MEDIA_SRC_DOMAINS "${endpoint}" if [[ "${media}" != "$(url_origin "$(get_env_value DASHBOARD_ORIGIN)")" ]]; then add_csp_domain CSP_MEDIA_SRC_DOMAINS "${media}" fi } is_legacy_minio_endpoint() { [[ "$(get_env_value OBJECT_STORAGE_ENDPOINT)" == *"minio:9000"* ]] } ensure_bundled_storage_env() { if [[ ! -f "${ENV_FILE}" ]]; then touch "${ENV_FILE}" fi local secret secret="$(get_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY)" # Randomize the key pair while the secret is still empty or a known placeholder. if [[ "${secret}" == "minioadmin" ]] || is_insecure_token_value "${secret}"; then upsert_env_value OBJECT_STORAGE_ACCESS_KEY_ID "cio-$(generate_secure_token | cut -c1-16)" upsert_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY "$(generate_secure_token)" upsert_env_value OBJECT_STORAGE_FORCE_PATH_STYLE "true" echo "Provisioned bundled object storage with randomized credentials in .env" fi # A legacy minio:9000 endpoint is left alone: it marks an install that still needs migrating. if [[ -z "$(get_env_value OBJECT_STORAGE_ENDPOINT)" ]]; then upsert_env_value OBJECT_STORAGE_ENDPOINT "http://storage:9000" fi derive_public_storage_urls "bundled" } # ────────────────────────────────────────────── # Shared checks # ────────────────────────────────────────────── # When bundled storage is excluded the user must supply external object storage, or # uploads/media silently break. Fail fast instead. The endpoint must be set AND not still # point at a bundled host (which won't be running with --no-storage). ensure_storage_env() { if [[ "${PROFILE_ARGS}" == *"storage"* ]]; then ensure_bundled_storage_env else local external_endpoint external_endpoint="$(get_env_value OBJECT_STORAGE_ENDPOINT)" if [[ -z "${external_endpoint}" || "${external_endpoint}" == *"storage:9000"* || "${external_endpoint}" == *"minio:9000"* ]]; then echo "Error: --no-storage was passed but no external object storage is configured." echo "OBJECT_STORAGE_ENDPOINT is unset or still points at the bundled store (storage:9000)." echo "Configure an external S3-compatible store via OBJECT_STORAGE_* in .env," echo "or drop --no-storage to use the bundled store." exit 1 fi derive_public_storage_urls "external" fi } warn_if_unpinned_version() { local version version="$(get_env_value CIO_VERSION)" if [[ -z "${version}" || "${version}" == "latest" ]]; then echo echo "WARNING: CIO_VERSION is '${version:-unset}' — you are tracking the rolling 'latest' tag." echo " 'latest' moves on every merge to main and may include unreleased code." echo " For production, pin an exact release in .env, e.g.: CIO_VERSION=1.4.2" echo " Available tags: https://hub.docker.com/r/classroomio/api/tags" echo fi } # `docker compose up -d` returns once containers are *started*, not *healthy*. On a fresh # deployment the API runs DB migrations before it listens, so the endpoints aren't reachable # immediately. Poll with a retry instead of a single curl — and never let a not-yet-ready # check abort the script (set -euo pipefail is active), so first runs end cleanly. check_endpoint() { local name="$1" url="$2" curl_flags="$3" attempts="$4" local i=0 while ((i < attempts)); do if curl "${curl_flags}" --max-time 5 "${url}" >/dev/null 2>&1; then echo "${name} is reachable on ${url}" return 0 fi i=$((i + 1)) sleep 5 done echo "Note: ${name} is not reachable yet at ${url} (waited $((attempts * 5))s)." echo " It may still be starting — the first run migrates the database. Follow logs with:" echo " ./classroomio.sh logs" return 0 } wait_for_endpoints() { if command -v curl >/dev/null 2>&1; then echo echo "Waiting for services to come up (first run can take a minute while the API migrates)..." check_endpoint "API" "http://localhost:3081/" "-fsS" 24 check_endpoint "Dashboard" "http://localhost:3082/" "-fsSI" 12 else echo echo "curl not found, skipped endpoint checks." fi } fetch_if_missing() { local file="$1" url="$2" if [[ -f "${file}" ]]; then echo "Keeping existing $(basename "${file}") (not overwritten)." return 0 fi echo "Downloading $(basename "${file}")..." # Download to a temp name and move into place so a failed transfer can never # leave a partial file that a re-run would mistake for a kept local copy. local tmp="${file}.download" if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 3 --retry-delay 2 -o "${tmp}" "${url}" || { rm -f "${tmp}"; echo "Error: failed to download ${url}"; exit 1; } elif command -v wget >/dev/null 2>&1; then wget -q -O "${tmp}" "${url}" || { rm -f "${tmp}"; echo "Error: failed to download ${url}"; exit 1; } else echo "Error: need curl or wget to download $(basename "${file}")." exit 1 fi mv "${tmp}" "${file}" } # A downloaded compose file that still references the removed MinIO images can't be pulled; replace it. refresh_stale_compose_file() { if [[ "${USE_IMAGES}" != "true" || ! -f "${IMAGES_COMPOSE_FILE}" ]]; then return 0 fi if ! grep -q 'image: minio/' "${IMAGES_COMPOSE_FILE}"; then return 0 fi local backup="${IMAGES_COMPOSE_FILE}.bak" mv "${IMAGES_COMPOSE_FILE}" "${backup}" echo "$(basename "${IMAGES_COMPOSE_FILE}") still references the removed MinIO images." echo " Kept your copy as $(basename "${backup}") — re-apply any local edits to the new file." # Subshell so a failed download can't exit before the old file is restored. if ! (fetch_if_missing "${IMAGES_COMPOSE_FILE}" "${RAW_BASE_URL}/docker-compose.images.yaml"); then mv "${backup}" "${IMAGES_COMPOSE_FILE}" exit 1 fi } # Reported in the anonymous usage ping; kept if the user already set it. ensure_deployment_method() { if [[ -z "$(get_env_value CIO_DEPLOYMENT_METHOD)" ]]; then upsert_env_value CIO_DEPLOYMENT_METHOD "docker" fi } prepare_env_and_secrets() { refresh_stale_compose_file ensure_secure_auth_tokens ensure_secure_betterauth_secret ensure_storage_env ensure_deployment_method } # ────────────────────────────────────────────── # Legacy MinIO → SeaweedFS migration # ────────────────────────────────────────────── # rclone on the compose network: src = old MinIO, dst = new store. run_rclone() { ( export RCLONE_CONFIG_SRC_TYPE=s3 export RCLONE_CONFIG_SRC_PROVIDER=Minio export RCLONE_CONFIG_SRC_ENDPOINT="http://cio-minio-legacy:9000" export RCLONE_CONFIG_SRC_ACCESS_KEY_ID="${LEGACY_ACCESS_KEY}" export RCLONE_CONFIG_SRC_SECRET_ACCESS_KEY="${LEGACY_SECRET_KEY}" export RCLONE_CONFIG_DST_TYPE=s3 export RCLONE_CONFIG_DST_PROVIDER=SeaweedFS export RCLONE_CONFIG_DST_ENDPOINT="http://storage:9000" RCLONE_CONFIG_DST_ACCESS_KEY_ID="$(get_env_value OBJECT_STORAGE_ACCESS_KEY_ID)" RCLONE_CONFIG_DST_SECRET_ACCESS_KEY="$(get_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY)" export RCLONE_CONFIG_DST_ACCESS_KEY_ID RCLONE_CONFIG_DST_SECRET_ACCESS_KEY # "notfound" silences rclone's missing-config notice. export RCLONE_CONFIG=notfound docker run --rm --network "${PROJECT_NAME}_default" -e RCLONE_CONFIG \ -e RCLONE_CONFIG_SRC_TYPE -e RCLONE_CONFIG_SRC_PROVIDER -e RCLONE_CONFIG_SRC_ENDPOINT \ -e RCLONE_CONFIG_SRC_ACCESS_KEY_ID -e RCLONE_CONFIG_SRC_SECRET_ACCESS_KEY \ -e RCLONE_CONFIG_DST_TYPE -e RCLONE_CONFIG_DST_PROVIDER -e RCLONE_CONFIG_DST_ENDPOINT \ -e RCLONE_CONFIG_DST_ACCESS_KEY_ID -e RCLONE_CONFIG_DST_SECRET_ACCESS_KEY \ "${RCLONE_IMAGE}" "$@" ) } pick_legacy_minio_image() { if [[ -n "${MINIO_LEGACY_IMAGE:-}" ]]; then printf '%s' "${MINIO_LEGACY_IMAGE}" return fi local candidate for candidate in minio/minio:latest quay.io/minio/minio:latest; do if docker image inspect "${candidate}" >/dev/null 2>&1; then printf '%s' "${candidate}" return fi done printf '%s' "${LEGACY_MINIO_FALLBACK_IMAGE}" } # Prints where the old MinIO data lives (volume name or host path), or nothing if there is none. find_legacy_minio_data() { local data data="$(legacy_minio_data_setting)" if [[ "${data}" == "none" ]]; then return 0 fi if [[ -z "${data}" ]]; then # The old container's own mount covers custom volume names and bind mounts. data="$(MSYS_NO_PATHCONV=1 docker inspect cio-minio --format \ '{{range .Mounts}}{{if eq .Destination "/data"}}{{if eq .Type "volume"}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' \ 2>/dev/null || true)" fi data="${data:-${LEGACY_MINIO_VOLUME}}" data="${data//\\//}" # It must already exist: mounting a missing volume or path would silently create it empty. if [[ "${data}" == */* ]]; then [[ -e "${data}" ]] || return 0 elif ! docker volume inspect "${data}" >/dev/null 2>&1; then return 0 fi printf '%s' "${data}" } # A volume name, a host path, or "none" to confirm there is nothing to copy. legacy_minio_data_setting() { printf '%s' "${MINIO_LEGACY_DATA:-$(get_env_value MINIO_LEGACY_DATA)}" } install_has_run_before() { docker volume inspect "${PROJECT_NAME}_postgres-data" >/dev/null 2>&1 || docker inspect cio-postgres >/dev/null 2>&1 || docker inspect cio-minio >/dev/null 2>&1 } # rclone --combined: "+" missing in the new store, "!" unreadable, "*" differs. # "*" is only legitimate on a forced re-run, where the app may have changed objects since. copy_report_ok() { local check_ok="$1" report="$2" allow_changed="$3" if grep -q '^[+!] ' <<<"${report}"; then return 1 fi if grep -q '^\* ' <<<"${report}"; then [[ "${allow_changed}" == "true" ]] return fi [[ "${check_ok}" == "true" ]] } app_is_seaweedfs_ready() { local version image version="$(get_env_value CIO_VERSION)" image="${API_IMAGE_REPO}:${version:-latest}" if ! docker image inspect "${image}" >/dev/null 2>&1; then echo "Downloading ${image} to check it supports the new storage (this can take a few minutes)..." >&2 fi docker pull -q "${image}" >/dev/null 2>&1 || true [[ "$(docker image inspect "${image}" --format "{{index .Config.Labels \"${SEAWEEDFS_READY_LABEL}\"}}" 2>/dev/null)" == "true" ]] } # Older app images sign upload URLs that SeaweedFS rejects, so never switch stores under one. require_seaweedfs_ready_app() { if [[ "${USE_IMAGES}" != "true" ]] || app_is_seaweedfs_ready; then return 0 fi echo "Error: CIO_VERSION=$(get_env_value CIO_VERSION) predates the switch to SeaweedFS storage." echo " Browser uploads from that app version fail against the new store (BadDigest)." echo " Set CIO_VERSION in .env to a release that includes the switch, then re-run." echo " Your storage has not been switched." exit 1 } # Copies every bucket out of the old MinIO data over the S3 API; nothing is deleted or overwritten. migrate_legacy_minio_data() { local legacy_data="$1" legacy_image bucket buckets=() legacy_image="$(pick_legacy_minio_image)" # Saved so a re-run still finds the data after the old container is removed below. upsert_env_value MINIO_LEGACY_DATA "${legacy_data}" LEGACY_ACCESS_KEY="$(get_env_value MINIO_ROOT_USER)" LEGACY_ACCESS_KEY="${LEGACY_ACCESS_KEY:-minioadmin}" LEGACY_SECRET_KEY="$(get_env_value MINIO_ROOT_PASSWORD)" LEGACY_SECRET_KEY="${LEGACY_SECRET_KEY:-minioadmin}" for bucket in OBJECT_STORAGE_BUCKET_VIDEOS:videos OBJECT_STORAGE_BUCKET_DOCUMENTS:documents OBJECT_STORAGE_BUCKET_MEDIA:media; do local name name="$(get_env_value "${bucket%%:*}")" buckets+=("${name:-${bucket##*:}}") done echo "Migrating uploads from the old bundled MinIO to the new bundled store (SeaweedFS)..." echo " Every object is copied, so this needs free disk space about equal to your current" echo " uploads. The app is stopped while it runs; the old data (${legacy_data}) is left untouched." compose stop api dashboard jobs >/dev/null 2>&1 || true # The old containers still hold port 9000. docker rm -f cio-minio cio-minio-init cio-minio-legacy >/dev/null 2>&1 || true compose up -d storage storage-init docker wait cio-storage-init >/dev/null echo "Reading the old data with ${legacy_image}..." MSYS_NO_PATHCONV=1 docker run -d --name cio-minio-legacy \ --network "${PROJECT_NAME}_default" \ -e MINIO_ROOT_USER="${LEGACY_ACCESS_KEY}" \ -e MINIO_ROOT_PASSWORD="${LEGACY_SECRET_KEY}" \ -v "${legacy_data}:/data" \ "${legacy_image}" server /data >/dev/null local attempt=0 listing="" until listing="$(run_rclone lsd src: 2>/dev/null)"; do attempt=$((attempt + 1)) if ((attempt >= 30)); then echo "Error: could not read the old MinIO data at ${legacy_data} (see: docker logs cio-minio-legacy)." echo " Check MINIO_ROOT_USER / MINIO_ROOT_PASSWORD in .env, or set MINIO_LEGACY_IMAGE to a" echo " MinIO-compatible image, then re-run: ./classroomio.sh migrate-storage" docker rm -f cio-minio-legacy >/dev/null 2>&1 || true return 1 fi sleep 2 done for bucket in "${buckets[@]}"; do if ! grep -qE "[[:space:]]${bucket}\$" <<<"${listing}"; then echo " ${bucket}: not in the old store — skipping." continue fi echo " ${bucket}: copying..." # --ignore-existing: a repeat run never overwrites or deletes what is already in the new store. local report="" check_ok=true changed if run_rclone copy --ignore-existing --stats 30s --stats-one-line --stats-log-level NOTICE "src:${bucket}" "dst:${bucket}"; then echo " ${bucket}: verifying every object byte for byte (reads the data once more)..." # --download compares contents, so objects without a comparable hash aren't checked by size alone. report="$(run_rclone check --one-way --download --combined - "src:${bucket}" "dst:${bucket}" 2>/dev/null)" || check_ok=false else check_ok=false fi changed="$(grep -c '^\* ' <<<"${report}" || true)" if ! copy_report_ok "${check_ok}" "${report}" "$([[ "${MINIO_MIGRATION_FORCE:-}" == "1" ]] && echo true)"; then echo "Error: bucket '${bucket}' did not verify against the old copy. Nothing was deleted or" echo " overwritten. Objects reported as different must be removed from the new store before" echo " re-running: ./classroomio.sh migrate-storage" grep -E '^[+!*] ' <<<"${report}" | head -20 | sed 's/^/ /' || true docker rm -f cio-minio-legacy >/dev/null 2>&1 || true return 1 fi echo " ${bucket}: $(grep -c '^= ' <<<"${report}" || true) object(s) verified against the old copy." if ((changed > 0)); then echo " ${bucket}: ${changed} object(s) already changed in the new store were kept as they are." fi done docker rm -f cio-minio-legacy >/dev/null echo "Migration complete. Once you've confirmed your uploads load, you can delete the old copy:" echo " ${legacy_data}" } # The endpoint is rewritten last, so an interrupted migration is retried on the next run. migrate_legacy_storage_if_needed() { if [[ "${PROFILE_ARGS}" != *"storage"* ]] || ! is_legacy_minio_endpoint; then return 0 fi local legacy_data legacy_data="$(find_legacy_minio_data)" if [[ -n "${legacy_data}" ]]; then require_seaweedfs_ready_app migrate_legacy_minio_data "${legacy_data}" elif [[ "$(legacy_minio_data_setting)" != "none" ]] && install_has_run_before; then echo "Error: this install used the old bundled MinIO, but its uploads could not be located." echo " Set MINIO_LEGACY_DATA in .env to the old MinIO volume name or data path, then re-run." echo " If there are no old uploads to copy, set MINIO_LEGACY_DATA=none instead." echo " Nothing has been changed." exit 1 fi upsert_env_value OBJECT_STORAGE_ENDPOINT "http://storage:9000" echo "Set OBJECT_STORAGE_ENDPOINT=http://storage:9000 in .env" } # ────────────────────────────────────────────── # Commands # ────────────────────────────────────────────── cmd_install() { echo "Installing ClassroomIO..." echo if [[ "${USE_IMAGES}" != "true" && ! -f "${BUILD_COMPOSE_FILE}" ]]; then echo "Error: --build install requires a local repo checkout (docker-compose.yaml not found)." echo "Clone the repository instead, or run 'install' without --build to pull pre-built images." exit 1 fi if [[ "${USE_IMAGES}" == "true" ]]; then # The compose file tracks main (stack topology only — CIO_VERSION pins the app # images). Never overwritten; refresh manually if you need a newer topology: # curl -fsSLO ${RAW_BASE_URL}/docker-compose.images.yaml fetch_if_missing "${IMAGES_COMPOSE_FILE}" "${RAW_BASE_URL}/docker-compose.images.yaml" fi fetch_if_missing "${ENV_EXAMPLE_FILE}" "${RAW_BASE_URL}/.env.example" if [[ -f "${ENV_FILE}" ]]; then echo "Keeping existing .env (not overwritten)." else cp "${ENV_EXAMPLE_FILE}" "${ENV_FILE}" echo "Created .env from .env.example" fi prepare_env_and_secrets warn_if_unpinned_version echo "Review these values in .env before going to production:" echo " DASHBOARD_ORIGIN (your public https:// dashboard URL)" echo " SMTP_* (outgoing email — invites, notifications)" echo " CIO_VERSION (pin an exact release for production)" echo cmd_start } cmd_start() { prepare_env_and_secrets migrate_legacy_storage_if_needed warn_if_unpinned_version echo "Starting ClassroomIO..." if [[ "${PROFILE_ARGS}" == *"storage"* ]]; then echo "Including bundled object storage (SeaweedFS, default)..." fi if [[ "${USE_IMAGES}" == "true" ]]; then echo "Using pre-built images from Docker Hub (CIO_VERSION=$(get_env_value CIO_VERSION))..." compose pull if [[ "${PROFILE_ARGS}" == *"storage"* ]] && ! app_is_seaweedfs_ready; then echo "WARNING: this CIO_VERSION predates the SeaweedFS storage switch; browser uploads will fail" echo " (BadDigest) until you move CIO_VERSION to a newer release." fi compose up -d else compose up --build -d fi echo echo "Current service status:" compose ps wait_for_endpoints echo echo "Done. Full stack is running." } cmd_stop() { echo "Stopping ClassroomIO (data volumes are kept)..." compose stop echo "Stopped. Start again with: ./classroomio.sh start" } cmd_restart() { prepare_env_and_secrets migrate_legacy_storage_if_needed echo "Restarting ClassroomIO..." # `compose restart` only bounces the container *process* — it never re-reads .env, so # edits to DASHBOARD_ORIGIN/SMTP_*/LICENSE_KEY/etc. were silently ignored. Recreate # instead: --force-recreate guarantees an actual restart even when config is # unchanged, without pulling new images (that's what `upgrade` is for). compose up -d --force-recreate compose ps wait_for_endpoints } cmd_logs() { compose logs -f --tail=100 "$@" } cmd_backup() { local ts backup_dir db_user db_name ts="$(date +%Y%m%d-%H%M%S)" backup_dir="${ROOT_DIR}/backups" mkdir -p "${backup_dir}" db_user="$(get_env_value POSTGRES_USER)" db_user="${db_user:-postgres}" db_name="$(get_env_value POSTGRES_DB)" db_name="${db_name:-classroomio}" echo "Backing up Postgres database '${db_name}'..." local db_file="${backup_dir}/classroomio-db-${ts}.sql.gz" # pg_dump failure must fail the backup (and abort an upgrade) — don't let gzip's # exit code mask it. if ! compose exec -T postgres pg_dump -U "${db_user}" "${db_name}" | gzip > "${db_file}"; then rm -f "${db_file}" echo "Error: Postgres backup failed. Is the stack running? (./classroomio.sh start)" return 1 fi echo " -> ${db_file}" # Volume names are derived from the compose project name — same variable the -p flag # uses, so a project rename can't silently desync the backup target. local label volume storage_file found_volume=false # Git Bash (MSYS) rewrites container paths like /data into Windows paths; disable # conversion for the docker run below and pre-convert the host dir with cygpath. # On Linux/macOS cygpath doesn't exist and the fallback keeps the path as-is. local backup_dir_host backup_dir_host="$(cygpath -w "${backup_dir}" 2>/dev/null || printf '%s' "${backup_dir}")" for label in storage minio; do volume="${PROJECT_NAME}_${label}-data" if [[ "${label}" == "minio" ]]; then volume="$(find_legacy_minio_data)" elif ! docker volume inspect "${volume}" >/dev/null 2>&1; then volume="" fi if [[ -z "${volume}" ]]; then continue fi found_volume=true echo "Backing up object-storage volume '${volume}'..." storage_file="classroomio-${label}-${ts}.tar.gz" if ! MSYS_NO_PATHCONV=1 docker run --rm \ -v "${volume}:/data:ro" \ -v "${backup_dir_host}:/backup" \ alpine tar czf "/backup/${storage_file}" -C /data .; then echo "Error: object-storage volume backup failed." return 1 fi echo " -> ${backup_dir}/${storage_file}" done if [[ "${found_volume}" != "true" ]]; then # Not an error: --no-storage installs use external storage and have no local volume. echo "No bundled object-storage volume found — skipping object-storage backup." echo " (Using external S3/R2? Back that up with your provider's tools.)" fi echo "Backup complete: ${backup_dir}" echo "Copy backups off this machine — a server failure takes local backups with it." } cmd_upgrade() { prepare_env_and_secrets echo "Upgrading ClassroomIO..." echo echo "Step 1/3: backing up before touching images..." cmd_backup echo if [[ "${USE_IMAGES}" == "true" ]]; then # CIO_VERSION only selects a published image tag — meaningless for --build, # which always builds whatever is in the local checkout. local current_version current_version="$(get_env_value CIO_VERSION)" echo "Step 2/3: current CIO_VERSION is '${current_version:-unset}'." if [[ -t 0 ]]; then read -r -p "Enter a new version tag to pin (or press Enter to keep the current one): " new_version if [[ -n "${new_version}" ]]; then upsert_env_value CIO_VERSION "${new_version}" echo "Set CIO_VERSION=${new_version} in .env" fi fi warn_if_unpinned_version else echo "Step 2/3: building from source — CIO_VERSION doesn't apply." fi # After the version prompt, so the app-image check sees the version actually chosen. migrate_legacy_storage_if_needed if [[ "${USE_IMAGES}" == "true" ]]; then echo "Step 3/3: pulling images and restarting..." compose pull compose up -d else echo "Step 3/3: rebuilding from source and restarting..." compose up --build -d fi compose ps wait_for_endpoints echo echo "Upgrade complete." if [[ "${USE_IMAGES}" == "true" ]]; then echo "Roll back by setting CIO_VERSION back in .env and running: ./classroomio.sh upgrade" fi } cmd_migrate_storage() { prepare_env_and_secrets if [[ "${PROFILE_ARGS}" != *"storage"* ]]; then echo "Error: migrate-storage copies into the bundled store, so it can't be combined with --no-storage." exit 1 fi # After cutover the old copy is stale: copying again would bring back files deleted since. if ! is_legacy_minio_endpoint && [[ "${MINIO_MIGRATION_FORCE:-}" != "1" ]]; then echo "Storage has already been migrated (OBJECT_STORAGE_ENDPOINT no longer points at MinIO)." echo " Copying again can restore files that were deleted since. To do it anyway, run:" echo " MINIO_MIGRATION_FORCE=1 ./classroomio.sh migrate-storage" return 0 fi local legacy_data legacy_data="$(find_legacy_minio_data)" if [[ -z "${legacy_data}" ]]; then echo "No old MinIO data found — nothing was copied and .env was not changed." echo " Set MINIO_LEGACY_DATA in .env to its volume name or path, or to 'none' if there is none." return 0 fi require_seaweedfs_ready_app migrate_legacy_minio_data "${legacy_data}" if is_legacy_minio_endpoint; then upsert_env_value OBJECT_STORAGE_ENDPOINT "http://storage:9000" echo "Set OBJECT_STORAGE_ENDPOINT=http://storage:9000 in .env" fi compose up -d compose ps } show_menu() { echo "ClassroomIO self-host manager" echo echo " 1) Install (first-time setup: fetch files, create .env, start)" echo " 2) Start" echo " 3) Stop" echo " 4) Restart" echo " 5) Upgrade (backs up first, then pulls new images)" echo " 6) View logs" echo " 7) Backup (Postgres dump + object-storage volume archive)" echo " 8) Migrate storage (copy uploads from the old bundled MinIO)" echo " q) Quit" echo read -r -p "Pick an option: " choice case "${choice}" in 1) cmd_install ;; 2) cmd_start ;; 3) cmd_stop ;; 4) cmd_restart ;; 5) cmd_upgrade ;; 6) cmd_logs ;; 7) cmd_backup ;; 8) cmd_migrate_storage ;; q|Q) exit 0 ;; *) echo "Unknown option: ${choice}" exit 1 ;; esac } # ────────────────────────────────────────────── # Argument parsing # ────────────────────────────────────────────── # Options may appear before or after the command: `./classroomio.sh start --build` # and `./classroomio.sh --build start` both work. COMMAND="" COMMAND_ARGS=() while [[ $# -gt 0 ]]; do case "$1" in --build) USE_IMAGES=false COMPOSE_FILE="${BUILD_COMPOSE_FILE}" shift ;; --no-storage) PROFILE_ARGS="" shift ;; --no-minio) echo "Note: --no-minio is deprecated; use --no-storage." PROFILE_ARGS="" shift ;; -h|--help) print_usage exit 0 ;; install|start|stop|restart|upgrade|logs|backup|migrate-storage) if [[ -n "${COMMAND}" ]]; then echo "Unexpected extra command: $1 (already running '${COMMAND}')" print_usage exit 1 fi COMMAND="$1" shift ;; *) if [[ "${COMMAND}" == "logs" ]]; then COMMAND_ARGS+=("$1") shift else echo "Unknown option: $1" print_usage exit 1 fi ;; esac done detect_compose case "${COMMAND}" in "") show_menu ;; install) cmd_install ;; start) cmd_start ;; stop) cmd_stop ;; restart) cmd_restart ;; upgrade) cmd_upgrade ;; logs) cmd_logs "${COMMAND_ARGS[@]+"${COMMAND_ARGS[@]}"}" ;; backup) cmd_backup ;; migrate-storage) cmd_migrate_storage ;; esac