#!/usr/bin/env bash # scripts/migrate-data-directory.sh # Migrate Nextcloud data directory content to an external host path and # update docker-compose app volume mapping to use that path. set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" COMPOSE_FILE="${REPO_DIR}/docker-compose.yml" APP_CONTAINER="${NEXTCLOUD_APP_CONTAINER:-nextcloud-app}" TARGET_DATA_DIR="${1:-/media/rbhat/DATA/nextcloud/NextCloudData}" if [ "${TARGET_DATA_DIR}" = "-h" ] || [ "${TARGET_DATA_DIR}" = "--help" ]; then echo "Usage: ./scripts/migrate-data-directory.sh [target_data_dir]" echo "" echo "Default target: /media/rbhat/DATA/nextcloud/NextCloudData" exit 0 fi if ! command -v docker >/dev/null 2>&1; then echo "ERROR: docker is required but not installed." exit 1 fi if ! command -v python3 >/dev/null 2>&1; then echo "ERROR: python3 is required but not installed." exit 1 fi if [ ! -f "${COMPOSE_FILE}" ]; then echo "ERROR: Compose file not found at ${COMPOSE_FILE}" exit 1 fi if ! docker ps --format '{{.Names}}' | grep -qx "${APP_CONTAINER}"; then echo "ERROR: Container '${APP_CONTAINER}' is not running." echo "Start the stack first (docker compose up -d)." exit 1 fi compose() { if docker compose version >/dev/null 2>&1; then docker compose -f "${COMPOSE_FILE}" "$@" else docker-compose -f "${COMPOSE_FILE}" "$@" fi } wait_for_occ() { local max_attempts=30 local attempt=1 while [ "${attempt}" -le "${max_attempts}" ]; do if docker exec --user www-data "${APP_CONTAINER}" php occ status >/dev/null 2>&1; then return 0 fi sleep 2 attempt=$((attempt + 1)) done return 1 } if ! mkdir -p "${TARGET_DATA_DIR}" 2>/dev/null; then if command -v sudo >/dev/null 2>&1; then sudo mkdir -p "${TARGET_DATA_DIR}" else echo "ERROR: Unable to create ${TARGET_DATA_DIR}. Run as a user with write permission." exit 1 fi fi CURRENT_BIND_SOURCE="$(docker inspect "${APP_CONTAINER}" --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/data"}}{{.Source}}{{end}}{{end}}')" maintenance_enabled=0 cleanup() { if [ "${maintenance_enabled}" -eq 1 ]; then docker exec --user www-data "${APP_CONTAINER}" php occ maintenance:mode --off >/dev/null 2>&1 || true fi } trap cleanup EXIT echo "==> Enabling Nextcloud maintenance mode..." docker exec --user www-data "${APP_CONTAINER}" php occ maintenance:mode --on >/dev/null maintenance_enabled=1 if [ "${CURRENT_BIND_SOURCE}" = "${TARGET_DATA_DIR}" ]; then echo "==> App already mounted to target data directory; skipping data copy." else echo "==> Copying data from running Nextcloud container to ${TARGET_DATA_DIR} ..." docker exec "${APP_CONTAINER}" sh -lc 'cd /var/www/html/data && tar -cf - .' | \ docker run --rm -i -v "${TARGET_DATA_DIR}:/to" alpine:3.20 sh -lc 'cd /to && tar -xf -' fi echo "==> Normalizing ownership and permissions on target data directory..." docker run --rm -v "${TARGET_DATA_DIR}:/to" alpine:3.20 sh -lc '\ chown -R 33:33 /to && \ find /to -type d -exec chmod 0750 {} + && \ find /to -type f -exec chmod 0640 {} + && \ touch /to/.ocdata && \ chown 33:33 /to/.ocdata && \ chmod 0640 /to/.ocdata\ ' echo "==> Updating compose app volume mapping to use external data directory..." python3 - "${COMPOSE_FILE}" "${TARGET_DATA_DIR}" <<'PY' from pathlib import Path import sys compose_file = Path(sys.argv[1]) target = sys.argv[2] lines = compose_file.read_text().splitlines() try: app_idx = next(i for i, line in enumerate(lines) if line.startswith(" app:")) except StopIteration: raise SystemExit("ERROR: Could not find 'app' service in compose file") app_end = len(lines) for i in range(app_idx + 1, len(lines)): line = lines[i] if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): app_end = i break try: volumes_idx = next(i for i in range(app_idx + 1, app_end) if lines[i].startswith(" volumes:")) except StopIteration: raise SystemExit("ERROR: Could not find volumes block in app service") items_start = volumes_idx + 1 while items_start < app_end and lines[items_start].strip() == "": items_start += 1 items_end = items_start while items_end < app_end and lines[items_end].startswith(" - "): items_end += 1 new_line = f" - {target}:/var/www/html/data" updated = False for i in range(items_start, items_end): if lines[i].strip().endswith(":/var/www/html/data"): if lines[i] != new_line: lines[i] = new_line updated = True break if not updated: lines.insert(items_end, new_line) compose_file.write_text("\n".join(lines) + "\n") print("Updated compose file with app data bind mount") PY echo "==> Recreating Nextcloud app/web services with new mount..." compose up -d app web if ! wait_for_occ; then echo "ERROR: Nextcloud did not become ready after restart." exit 1 fi echo "==> Disabling maintenance mode..." docker exec --user www-data "${APP_CONTAINER}" php occ maintenance:mode --off >/dev/null maintenance_enabled=0 echo "==> Verifying effective datadirectory and mount..." docker exec --user www-data "${APP_CONTAINER}" php occ config:system:get datadirectory docker inspect "${APP_CONTAINER}" --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/data"}}{{println .Destination "<-" .Type ":" .Source}}{{end}}{{end}}' echo "==> Migration complete."