refactor: selfhosting docs (#798)f
* Remove the run-docker-full-stack.sh script to streamline the deployment process and eliminate unnecessary complexity. * fix: update documentation to clarify script behavior for building and pulling images * update docs * fix: update script URL to use classroomio.com for downloads * fix: humanize selfhosting docs and minor amendment * fix: attend to comment * fix: humanize docs * update docs * fix: adjusted the Restarting ClassroomIO... function * fix: update commands and documentation for environment variable handling and upgrades * chore: added space for rebuild
This commit is contained in:
Executable
+666
@@ -0,0 +1,666 @@
|
||||
#!/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
|
||||
#
|
||||
# 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 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-minio empties it.
|
||||
PROFILE_ARGS="--profile minio"
|
||||
|
||||
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 MinIO volume into ./backups/.
|
||||
|
||||
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-minio Exclude the bundled MinIO (requires external S3-compatible storage).
|
||||
-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. MinIO media is served on :9000 — operators must reverse-proxy /media to it.
|
||||
# `mode` is "bundled" (ensure_minio_env) or "external" (ensure_storage_env's --no-minio
|
||||
# path). For bundled MinIO, 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 MinIO (port 9000)."
|
||||
echo " -> For presigned access to the videos/documents buckets behind a domain, expose"
|
||||
echo " MinIO's 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
|
||||
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
|
||||
}
|
||||
|
||||
ensure_minio_env() {
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
touch "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
local minio_password
|
||||
minio_password="$(get_env_value MINIO_ROOT_PASSWORD)"
|
||||
|
||||
# First provision: randomize credentials whenever the MinIO password is still empty or the
|
||||
# well-known `minioadmin` placeholder shipped in .env.example. (Previously this gated on
|
||||
# OBJECT_STORAGE_ENDPOINT being empty, but .env.example ships it non-empty — so on a normal
|
||||
# copy-the-example first run the randomization was always skipped and minioadmin/minioadmin
|
||||
# survived.) MinIO uses its root credentials as the S3 access key / secret.
|
||||
if [[ -z "${minio_password}" || "${minio_password}" =~ ^[[:space:]]*$ || "${minio_password}" == "minioadmin" ]]; then
|
||||
local minio_user
|
||||
minio_user="cio-$(generate_secure_token | cut -c1-16)"
|
||||
minio_password="$(generate_secure_token)"
|
||||
|
||||
upsert_env_value MINIO_ROOT_USER "${minio_user}"
|
||||
upsert_env_value MINIO_ROOT_PASSWORD "${minio_password}"
|
||||
upsert_env_value OBJECT_STORAGE_ENDPOINT "http://minio:9000"
|
||||
upsert_env_value OBJECT_STORAGE_ACCESS_KEY_ID "${minio_user}"
|
||||
upsert_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY "${minio_password}"
|
||||
upsert_env_value OBJECT_STORAGE_FORCE_PATH_STYLE "true"
|
||||
echo "Provisioned MinIO with randomized credentials in .env"
|
||||
fi
|
||||
|
||||
derive_public_storage_urls "bundled"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Shared checks
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# When MinIO 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 the
|
||||
# bundled MinIO host (which won't be running with --no-minio).
|
||||
ensure_storage_env() {
|
||||
if [[ "${PROFILE_ARGS}" == *"minio"* ]]; then
|
||||
ensure_minio_env
|
||||
else
|
||||
local external_endpoint
|
||||
external_endpoint="$(get_env_value OBJECT_STORAGE_ENDPOINT)"
|
||||
if [[ -z "${external_endpoint}" || "${external_endpoint}" == *"minio:9000"* ]]; then
|
||||
echo "Error: --no-minio was passed but no external object storage is configured."
|
||||
echo "OBJECT_STORAGE_ENDPOINT is unset or still points at the bundled MinIO (minio:9000)."
|
||||
echo "Configure an external S3-compatible store via OBJECT_STORAGE_* in .env,"
|
||||
echo "or drop --no-minio to use the bundled MinIO."
|
||||
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}"
|
||||
}
|
||||
|
||||
prepare_env_and_secrets() {
|
||||
ensure_secure_auth_tokens
|
||||
ensure_secure_betterauth_secret
|
||||
ensure_storage_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
|
||||
warn_if_unpinned_version
|
||||
|
||||
echo "Starting ClassroomIO..."
|
||||
if [[ "${PROFILE_ARGS}" == *"minio"* ]]; then
|
||||
echo "Including MinIO (object storage, default)..."
|
||||
fi
|
||||
if [[ "${USE_IMAGES}" == "true" ]]; then
|
||||
echo "Using pre-built images from Docker Hub (CIO_VERSION=$(get_env_value CIO_VERSION))..."
|
||||
compose pull
|
||||
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
|
||||
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}"
|
||||
|
||||
# MinIO volume name is derived from the compose project name — same variable the
|
||||
# -p flag uses, so a project rename can't silently desync the backup target.
|
||||
local minio_volume="${PROJECT_NAME}_minio-data"
|
||||
if docker volume inspect "${minio_volume}" >/dev/null 2>&1; then
|
||||
echo "Backing up MinIO volume '${minio_volume}'..."
|
||||
local minio_file="classroomio-minio-${ts}.tar.gz"
|
||||
# Git Bash (MSYS) rewrites container paths like /data into Windows paths; disable
|
||||
# conversion for this one command 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}")"
|
||||
if ! MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "${minio_volume}:/data:ro" \
|
||||
-v "${backup_dir_host}:/backup" \
|
||||
alpine tar czf "/backup/${minio_file}" -C /data .; then
|
||||
echo "Error: MinIO volume backup failed."
|
||||
return 1
|
||||
fi
|
||||
echo " -> ${backup_dir}/${minio_file}"
|
||||
else
|
||||
# Not an error: --no-minio installs use external storage and have no local volume.
|
||||
echo "MinIO volume '${minio_volume}' not 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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 + MinIO volume archive)"
|
||||
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 ;;
|
||||
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-minio)
|
||||
PROFILE_ARGS=""
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
print_usage
|
||||
exit 0
|
||||
;;
|
||||
install|start|stop|restart|upgrade|logs|backup)
|
||||
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 ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user