Last active
October 19, 2026 18:21
-
-
Save VentByte/92dffbcd725b06e9a2db5c10ecd2f119 to your computer and use it in GitHub Desktop.
update-netbox.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # update-netbox.sh | |
| # | |
| # Upgrade a NetBox installation (release archive or git checkout) following | |
| # the official procedure: | |
| # https://netboxlabs.com/docs/netbox/installation/upgrading/ | |
| # | |
| # Steps performed: | |
| # 1. Pre-flight checks (install method, versions, Python/PostgreSQL/Redis, | |
| # ltree privileges, LDAP requirements, plugins, git working tree, | |
| # data directory locations) | |
| # 2. Backup of the database (pg_dump) and local files (config, media, | |
| # scripts, reports) | |
| # 3. Installation of the new release (archive download or git checkout) | |
| # 4. Execution of upgrade.sh | |
| # 5. Restart and verification of the NetBox services | |
| # 6. Optional cleanup of old release directories (archive installs only) | |
| # | |
| # Usage: | |
| # sudo ./update-netbox.sh [options] | |
| # | |
| # Options: | |
| # -v, --version X.Y.Z Target version (default: latest stable release) | |
| # -p, --python PATH Python interpreter for upgrade.sh (default: python3) | |
| # -b, --backup-dir DIR Backup location (default: /var/netbox/backup) | |
| # -k, --keep N Old release directories to keep (default: 2) | |
| # -y, --yes Do not prompt for confirmation | |
| # --skip-backup Skip the backup (not recommended) | |
| # --check Run the pre-flight checks only, change nothing | |
| # -h, --help Show this help | |
| # | |
| # Examples: | |
| # sudo ./update-netbox.sh --check | |
| # sudo ./update-netbox.sh -v 4.7.3 | |
| # sudo ./update-netbox.sh -y -p /usr/bin/python3.12 | |
| # | |
| # Data directories: | |
| # Media, scripts and reports are stored outside the NetBox project root, | |
| # so they survive upgrades without copying. Set them in configuration.py: | |
| # MEDIA_ROOT = '/var/netbox/media' | |
| # SCRIPTS_ROOT = '/var/netbox/scripts' | |
| # REPORTS_ROOT = '/var/netbox/reports' | |
| # The paths are read from the NetBox configuration. The values below are | |
| # the expected defaults: a different configured path produces a warning | |
| # and the configured path is used. The script aborts if a data directory | |
| # lies inside the project root of an archive install (data loss on switch). | |
| set -Eeuo pipefail | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| NETBOX_ROOT="/opt/netbox" | |
| NETBOX_USER="netbox" | |
| SERVICES=(netbox netbox-rq) | |
| GITHUB_REPO="netbox-community/netbox" | |
| BACKUP_DIR="/var/netbox/backup" | |
| LOG_DIR="/var/log/netbox" | |
| # Expected data directories (configuration.py takes precedence) | |
| MEDIA_DIR="/var/netbox/media" | |
| SCRIPTS_DIR="/var/netbox/scripts" | |
| REPORTS_DIR="/var/netbox/reports" | |
| KEEP_RELEASES=2 | |
| PYTHON_BIN="python3" | |
| TARGET_VERSION="" | |
| ASSUME_YES=false | |
| SKIP_BACKUP=false | |
| CHECK_ONLY=false | |
| # Minimum dependency versions per NetBox minor release, taken from the | |
| # "Version History" table in the upgrade documentation. | |
| # Format: "python_min postgresql_min redis_min" | |
| # Extend this table when a new minor release ships. | |
| declare -A REQS=( | |
| [4.0]="3.10 12 4.0" | |
| [4.1]="3.10 12 4.0" | |
| [4.2]="3.10 13 4.0" | |
| [4.3]="3.10 14 5.0" | |
| [4.4]="3.10 14 5.0" | |
| [4.5]="3.12 14 5.0" | |
| [4.6]="3.12 14 5.0" | |
| [4.7]="3.12 15 6.0" | |
| ) | |
| TIMESTAMP="$(date +%Y%m%d-%H%M%S)" | |
| LOG_FILE="${LOG_DIR}/upgrade-${TIMESTAMP}.log" | |
| LOCK_FILE="/run/update-netbox.lock" | |
| # Runtime state | |
| METHOD="" | |
| CURRENT_DIR="" | |
| CURRENT_VERSION="" | |
| NEW_DIR="" | |
| DB_DUMP="" | |
| FILES_BACKUP="" | |
| CHANGES_STARTED=false | |
| TMP_DIR="" | |
| declare -A DB=() | |
| declare -A PATHS=() | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; } | |
| info() { log "INFO $*"; } | |
| warn() { log "WARN $*" >&2; } | |
| die() { log "ERROR $*" >&2; exit 1; } | |
| usage() { sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# \{0,1\}//'; exit 0; } | |
| confirm() { | |
| $ASSUME_YES && return 0 | |
| local reply | |
| read -r -p "$1 [y/N] " reply | |
| [[ $reply =~ ^[Yy]$ ]] | |
| } | |
| # ver_ge A B: true if version A >= version B | |
| ver_ge() { | |
| [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" == "$2" ]] | |
| } | |
| minor_of() { echo "$1" | cut -d. -f1,2; } | |
| major_of() { echo "$1" | cut -d. -f1; } | |
| # Run Python code inside the NetBox Django context of the CURRENT installation. | |
| nb_python() { | |
| local code="import django | |
| django.setup() | |
| $1" | |
| ( | |
| cd "$NETBOX_ROOT/netbox" && | |
| DJANGO_SETTINGS_MODULE=netbox.settings \ | |
| "$NETBOX_ROOT/venv/bin/python" -c "$code" | |
| ) 2>/dev/null | |
| } | |
| cleanup() { | |
| [[ -n $TMP_DIR && -d $TMP_DIR ]] && rm -rf "$TMP_DIR" | |
| return 0 | |
| } | |
| on_error() { | |
| local exit_code=$? | |
| trap - ERR | |
| warn "Upgrade failed (exit code ${exit_code}). Log: ${LOG_FILE}" | |
| if $CHANGES_STARTED; then | |
| warn "Changes were already applied. Roll back manually:" | |
| warn " 1. Stop the services: systemctl stop ${SERVICES[*]}" | |
| if [[ $METHOD == archive ]]; then | |
| warn " 2. Restore the code: ln -sfn ${CURRENT_DIR} ${NETBOX_ROOT}" | |
| else | |
| warn " 2. Restore the code: git -C ${NETBOX_ROOT} checkout v${CURRENT_VERSION} && (cd ${NETBOX_ROOT} && PYTHON=${PYTHON_BIN} ./upgrade.sh)" | |
| fi | |
| if [[ -n $DB_DUMP ]]; then | |
| warn " 3. Restore the DB (only if migrations ran):" | |
| warn " pg_restore --clean --if-exists ${DB[host]:+-h ${DB[host]}} ${DB[port]:+-p ${DB[port]}} -U ${DB[user]} -d ${DB[name]} ${DB_DUMP}" | |
| fi | |
| warn " 4. Start the services: systemctl start ${SERVICES[*]}" | |
| fi | |
| exit "$exit_code" | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Argument parsing | |
| # --------------------------------------------------------------------------- | |
| parse_args() { | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -v|--version) TARGET_VERSION="${2#v}"; shift 2 ;; | |
| -p|--python) PYTHON_BIN="$2"; shift 2 ;; | |
| -b|--backup-dir) BACKUP_DIR="$2"; shift 2 ;; | |
| -k|--keep) KEEP_RELEASES="$2"; shift 2 ;; | |
| -y|--yes) ASSUME_YES=true; shift ;; | |
| --skip-backup) SKIP_BACKUP=true; shift ;; | |
| --check) CHECK_ONLY=true; shift ;; | |
| -h|--help) usage ;; | |
| *) die "Unknown option: $1 (see --help)" ;; | |
| esac | |
| done | |
| [[ $KEEP_RELEASES =~ ^[0-9]+$ ]] || die "--keep requires a number" | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Detection | |
| # --------------------------------------------------------------------------- | |
| detect_install() { | |
| if [[ -L $NETBOX_ROOT ]]; then | |
| METHOD="archive" | |
| CURRENT_DIR="$(readlink -f "$NETBOX_ROOT")" | |
| elif [[ -d $NETBOX_ROOT/.git ]]; then | |
| METHOD="git" | |
| CURRENT_DIR="$NETBOX_ROOT" | |
| elif [[ -d $NETBOX_ROOT ]]; then | |
| die "${NETBOX_ROOT} is neither a symlink (archive install) nor a git checkout. Python package installs are experimental and not supported by this script." | |
| else | |
| die "${NETBOX_ROOT} does not exist." | |
| fi | |
| [[ -x $NETBOX_ROOT/venv/bin/python ]] || die "Virtual environment not found in ${NETBOX_ROOT}/venv" | |
| info "Install method: ${METHOD} (${CURRENT_DIR})" | |
| } | |
| detect_current_version() { | |
| local release_file="$CURRENT_DIR/netbox/release.yaml" | |
| if [[ -f $release_file ]]; then | |
| CURRENT_VERSION="$(sed -nE 's/^version:[[:space:]]*"?([0-9]+\.[0-9]+\.[0-9]+)"?.*/\1/p' "$release_file" | head -n1)" | |
| fi | |
| if [[ -z $CURRENT_VERSION && $METHOD == archive ]]; then | |
| CURRENT_VERSION="$(basename "$CURRENT_DIR" | sed -nE 's/^netbox-([0-9]+\.[0-9]+\.[0-9]+)$/\1/p')" | |
| fi | |
| if [[ -z $CURRENT_VERSION && $METHOD == git ]]; then | |
| CURRENT_VERSION="$(git -C "$NETBOX_ROOT" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')" | |
| fi | |
| [[ -n $CURRENT_VERSION ]] || die "Unable to determine the installed NetBox version." | |
| info "Installed version: ${CURRENT_VERSION}" | |
| } | |
| detect_target_version() { | |
| if [[ -z $TARGET_VERSION ]]; then | |
| TARGET_VERSION="$(curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | | |
| sed -nE 's/.*"tag_name":[[:space:]]*"v?([^"]+)".*/\1/p' | head -n1)" | |
| [[ -n $TARGET_VERSION ]] || die "Unable to determine the latest release from GitHub. Specify it with --version." | |
| fi | |
| [[ $TARGET_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "Invalid target version: ${TARGET_VERSION}" | |
| info "Target version: ${TARGET_VERSION}" | |
| } | |
| load_settings() { | |
| local out | |
| out="$(nb_python ' | |
| from django.conf import settings | |
| d = settings.DATABASES["default"] | |
| print("name=" + str(d.get("NAME") or "")) | |
| print("user=" + str(d.get("USER") or "")) | |
| print("host=" + str(d.get("HOST") or "")) | |
| print("port=" + str(d.get("PORT") or "")) | |
| print("password=" + str(d.get("PASSWORD") or "")) | |
| print("media=" + str(getattr(settings, "MEDIA_ROOT", "") or "")) | |
| print("scripts=" + str(getattr(settings, "SCRIPTS_ROOT", "") or "")) | |
| print("reports=" + str(getattr(settings, "REPORTS_ROOT", "") or "")) | |
| ')" || die "Unable to load the NetBox configuration. Check ${NETBOX_ROOT}/netbox/netbox/configuration.py" | |
| local line key value | |
| while IFS= read -r line; do | |
| key="${line%%=*}" | |
| value="${line#*=}" | |
| case $key in | |
| name|user|host|port|password) DB[$key]="$value" ;; | |
| media|scripts|reports) PATHS[$key]="$value" ;; | |
| esac | |
| done <<<"$out" | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Pre-flight checks | |
| # --------------------------------------------------------------------------- | |
| check_versions() { | |
| if [[ $CURRENT_VERSION == "$TARGET_VERSION" ]]; then | |
| info "NetBox is already at version ${TARGET_VERSION}. Nothing to do." | |
| exit 0 | |
| fi | |
| ver_ge "$TARGET_VERSION" "$CURRENT_VERSION" || | |
| die "Target ${TARGET_VERSION} is older than installed ${CURRENT_VERSION}. Downgrades are not supported." | |
| if [[ $(major_of "$TARGET_VERSION") != "$(major_of "$CURRENT_VERSION")" ]]; then | |
| die "Major version change (${CURRENT_VERSION} to ${TARGET_VERSION}). Upgrade to the latest minor release of v$(major_of "$CURRENT_VERSION") first, then rerun this script." | |
| fi | |
| } | |
| check_dependencies() { | |
| local reqs="${REQS[$(minor_of "$TARGET_VERSION")]:-}" | |
| if [[ -z $reqs ]]; then | |
| warn "No dependency data for NetBox $(minor_of "$TARGET_VERSION"). Verify Python, PostgreSQL and Redis versions manually and extend the REQS table." | |
| return 0 | |
| fi | |
| local py_min pg_min redis_min | |
| read -r py_min pg_min redis_min <<<"$reqs" | |
| # Python | |
| local py_ver | |
| py_ver="$("$PYTHON_BIN" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null)" || | |
| die "Python interpreter not usable: ${PYTHON_BIN}" | |
| ver_ge "$py_ver" "$py_min" || | |
| die "Python ${py_ver} (${PYTHON_BIN}) is too old, NetBox ${TARGET_VERSION} requires >= ${py_min}. Use --python to select a newer interpreter." | |
| info "Python ${py_ver}: OK (>= ${py_min})" | |
| # PostgreSQL | |
| local pg_num pg_major | |
| pg_num="$(nb_python ' | |
| from django.db import connection | |
| connection.ensure_connection() | |
| print(connection.pg_version) | |
| ')" || die "Unable to connect to the database." | |
| pg_major=$((pg_num / 10000)) | |
| ver_ge "$pg_major" "$pg_min" || | |
| die "PostgreSQL ${pg_major} is too old, NetBox ${TARGET_VERSION} requires >= ${pg_min}." | |
| info "PostgreSQL ${pg_major}: OK (>= ${pg_min})" | |
| # Redis (best effort, Sentinel setups are not detected) | |
| local redis_ver | |
| redis_ver="$(nb_python ' | |
| import redis | |
| from django.conf import settings | |
| c = settings.REDIS["tasks"] | |
| r = redis.Redis(host=c.get("HOST", "localhost"), port=c.get("PORT", 6379), | |
| username=c.get("USERNAME") or None, password=c.get("PASSWORD") or None, | |
| ssl=c.get("SSL", False), socket_timeout=5) | |
| print(r.info("server")["redis_version"]) | |
| ')" || redis_ver="" | |
| if [[ -z $redis_ver ]]; then | |
| warn "Unable to determine the Redis version. Verify manually (>= ${redis_min})." | |
| else | |
| ver_ge "$redis_ver" "$redis_min" || | |
| die "Redis ${redis_ver} is too old, NetBox ${TARGET_VERSION} requires >= ${redis_min}." | |
| info "Redis ${redis_ver}: OK (>= ${redis_min})" | |
| fi | |
| } | |
| check_ltree() { | |
| # NetBox 4.7+ requires the PostgreSQL ltree extension. | |
| ver_ge "$TARGET_VERSION" "4.7.0" || return 0 | |
| local result has_ext can_create | |
| result="$(nb_python ' | |
| from django.db import connection | |
| with connection.cursor() as c: | |
| c.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = %s), has_database_privilege(current_user, current_database(), %s)", ["ltree", "CREATE"]) | |
| a, b = c.fetchone() | |
| print(a, b) | |
| ')" || die "Unable to check the ltree extension." | |
| read -r has_ext can_create <<<"$result" | |
| if [[ $has_ext == True ]]; then | |
| info "ltree extension: present" | |
| elif [[ $can_create == True ]]; then | |
| info "ltree extension: missing, will be created by the migrations (CREATE privilege present)" | |
| else | |
| die "ltree extension missing and user '${DB[user]}' lacks CREATE on '${DB[name]}'. Run as postgres: GRANT CREATE ON DATABASE ${DB[name]} TO ${DB[user]};" | |
| fi | |
| } | |
| check_local_config() { | |
| local cfg_dir="$CURRENT_DIR/netbox/netbox" | |
| local req_file="$CURRENT_DIR/local_requirements.txt" | |
| [[ -f $cfg_dir/configuration.py ]] || die "configuration.py not found in ${cfg_dir}" | |
| if [[ -f $cfg_dir/ldap_config.py ]]; then | |
| if [[ -f $req_file ]] && grep -qiE '^[[:space:]]*django-auth-ldap' "$req_file"; then | |
| info "LDAP: ldap_config.py present, django-auth-ldap listed in local_requirements.txt" | |
| else | |
| die "ldap_config.py exists but django-auth-ldap is missing from local_requirements.txt. upgrade.sh rebuilds the venv, so LDAP login would break." | |
| fi | |
| fi | |
| local plugins | |
| plugins="$(nb_python ' | |
| from django.conf import settings | |
| print("\n".join(settings.PLUGINS)) | |
| ')" || plugins="" | |
| if [[ -n $plugins ]]; then | |
| warn "Installed plugins (verify compatibility with NetBox ${TARGET_VERSION}):" | |
| while IFS= read -r p; do warn " - ${p}"; done <<<"$plugins" | |
| fi | |
| if [[ -f $req_file ]]; then | |
| info "local_requirements.txt:" | |
| grep -vE '^[[:space:]]*(#|$)' "$req_file" | sed 's/^/ /' || true | |
| fi | |
| } | |
| check_git_tree() { | |
| [[ $METHOD == git ]] || return 0 | |
| if [[ -n $(git -C "$NETBOX_ROOT" status --porcelain --untracked-files=no) ]]; then | |
| die "The git working tree in ${NETBOX_ROOT} has local modifications. Commit, stash or revert them first." | |
| fi | |
| git -C "$NETBOX_ROOT" fetch --tags --quiet | |
| git -C "$NETBOX_ROOT" rev-parse --quiet --verify "v${TARGET_VERSION}^{commit}" >/dev/null || | |
| die "Tag v${TARGET_VERSION} not found in the repository." | |
| } | |
| check_archive_target() { | |
| [[ $METHOD == archive ]] || return 0 | |
| NEW_DIR="$(dirname "$NETBOX_ROOT")/netbox-${TARGET_VERSION}" | |
| [[ ! -e $NEW_DIR ]] || die "${NEW_DIR} already exists. Remove or rename it first." | |
| } | |
| # Resolve media, scripts and reports from the NetBox configuration and verify | |
| # them. The configured path wins over the expected default. Data inside the | |
| # project root of an archive install would be lost when the symlink switches | |
| # to a new release, so that case aborts. | |
| check_data_dirs() { | |
| local key setting expected configured owner project_root | |
| local failed=false | |
| project_root="$(realpath -m "$CURRENT_DIR")" | |
| for key in media scripts reports; do | |
| case $key in | |
| media) setting="MEDIA_ROOT"; expected="$MEDIA_DIR" ;; | |
| scripts) setting="SCRIPTS_ROOT"; expected="$SCRIPTS_DIR" ;; | |
| reports) setting="REPORTS_ROOT"; expected="$REPORTS_DIR" ;; | |
| esac | |
| if [[ -z ${PATHS[$key]} ]]; then | |
| # Setting not defined by this NetBox release (e.g. REPORTS_ROOT). | |
| # Fall back to the expected directory so it is still backed up. | |
| if [[ -d $expected ]]; then | |
| info "${setting} not defined by NetBox, using ${expected} for the backup" | |
| PATHS[$key]="$expected" | |
| else | |
| info "${setting} not defined by NetBox and ${expected} does not exist, skipping" | |
| fi | |
| continue | |
| fi | |
| configured="$(realpath -m "${PATHS[$key]}")" | |
| PATHS[$key]="$configured" | |
| if [[ $configured != "$(realpath -m "$expected")" ]]; then | |
| warn "${setting} is '${configured}' (expected default '${expected}'). Using the configured path." | |
| fi | |
| if [[ $METHOD == archive && $configured/ == "$project_root"/* ]]; then | |
| warn "${setting} '${configured}' is inside the project root and would be lost on upgrade. Move it to ${expected} and set ${setting} = '${expected}' in configuration.py." | |
| failed=true | |
| continue | |
| fi | |
| if [[ ! -d $configured ]]; then | |
| if [[ $key == reports ]]; then | |
| info "${setting} directory ${configured} does not exist, skipping" | |
| PATHS[$key]="" | |
| continue | |
| fi | |
| warn "${setting} directory ${configured} does not exist." | |
| failed=true | |
| continue | |
| fi | |
| owner="$(stat -c %U "$configured")" | |
| if [[ $owner != "$NETBOX_USER" ]]; then | |
| warn "${configured} is owned by '${owner}', expected '${NETBOX_USER}'. Fix with: chown -R ${NETBOX_USER} ${configured}" | |
| fi | |
| info "${setting}: ${configured}" | |
| done | |
| if $failed; then | |
| die "Data directory check failed. Fix the issues above and rerun the script." | |
| fi | |
| case "$(realpath -m "$BACKUP_DIR")/" in | |
| "$(realpath -m "$CURRENT_DIR")"/*) die "Backup directory ${BACKUP_DIR} must not be inside the NetBox project root." ;; | |
| esac | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Backup | |
| # --------------------------------------------------------------------------- | |
| backup() { | |
| if $SKIP_BACKUP; then | |
| warn "Backup skipped (--skip-backup). A rollback of the database will not be possible." | |
| return 0 | |
| fi | |
| [[ -d $BACKUP_DIR ]] || install -d -m 0700 "$BACKUP_DIR" | |
| DB_DUMP="${BACKUP_DIR}/netbox-db-${CURRENT_VERSION}-${TIMESTAMP}.dump" | |
| info "Dumping database '${DB[name]}' to ${DB_DUMP}" | |
| if [[ -n ${DB[host]} ]]; then | |
| PGPASSWORD="${DB[password]}" pg_dump -Fc \ | |
| -h "${DB[host]}" ${DB[port]:+-p "${DB[port]}"} \ | |
| -U "${DB[user]}" -d "${DB[name]}" -f "$DB_DUMP" | |
| else | |
| # No host configured: Django uses the local socket, use peer auth as postgres. | |
| # The redirect runs as root on purpose, so the dump stays root-owned. | |
| # shellcheck disable=SC2024 | |
| sudo -u postgres pg_dump -Fc -d "${DB[name]}" >"$DB_DUMP" | |
| fi | |
| chmod 0600 "$DB_DUMP" | |
| FILES_BACKUP="${BACKUP_DIR}/netbox-files-${CURRENT_VERSION}-${TIMESTAMP}.tar.gz" | |
| local items=() | |
| local f | |
| for f in \ | |
| "$CURRENT_DIR/netbox/netbox/configuration.py" \ | |
| "$CURRENT_DIR/netbox/netbox/ldap_config.py" \ | |
| "$CURRENT_DIR/local_requirements.txt" \ | |
| "$CURRENT_DIR/gunicorn.py" \ | |
| "${PATHS[media]}" "${PATHS[scripts]}" "${PATHS[reports]}"; do | |
| [[ -n $f && -e $f ]] && items+=("$f") | |
| done | |
| info "Archiving local files to ${FILES_BACKUP}" | |
| tar -czPf "$FILES_BACKUP" "${items[@]}" | |
| chmod 0600 "$FILES_BACKUP" | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Installation | |
| # --------------------------------------------------------------------------- | |
| copy_if_exists() { | |
| local src="$1" dst="$2" | |
| [[ -e $src ]] && cp -p "$src" "$dst" && info "Copied $(basename "$src")" | |
| return 0 | |
| } | |
| install_archive() { | |
| TMP_DIR="$(mktemp -d)" | |
| local tarball="$TMP_DIR/netbox-${TARGET_VERSION}.tar.gz" | |
| info "Downloading NetBox ${TARGET_VERSION}" | |
| curl -fsSL -o "$tarball" "https://github.com/${GITHUB_REPO}/archive/refs/tags/v${TARGET_VERSION}.tar.gz" | |
| tar -xzf "$tarball" -C "$(dirname "$NEW_DIR")" | |
| [[ -d $NEW_DIR ]] || die "Extraction did not produce ${NEW_DIR}" | |
| copy_if_exists "$CURRENT_DIR/local_requirements.txt" "$NEW_DIR/" | |
| copy_if_exists "$CURRENT_DIR/netbox/netbox/configuration.py" "$NEW_DIR/netbox/netbox/" | |
| copy_if_exists "$CURRENT_DIR/netbox/netbox/ldap_config.py" "$NEW_DIR/netbox/netbox/" | |
| copy_if_exists "$CURRENT_DIR/gunicorn.py" "$NEW_DIR/" | |
| # Media, scripts and reports live under /var/netbox (verified in | |
| # check_data_dirs), so the new release needs no copy of them. | |
| CHANGES_STARTED=true | |
| ln -sfn "$NEW_DIR" "$NETBOX_ROOT" | |
| info "Symlink ${NETBOX_ROOT} now points to ${NEW_DIR}" | |
| } | |
| install_git() { | |
| CHANGES_STARTED=true | |
| info "Checking out v${TARGET_VERSION}" | |
| git -C "$NETBOX_ROOT" checkout --quiet "v${TARGET_VERSION}" | |
| } | |
| run_upgrade_script() { | |
| info "Running upgrade.sh (this rebuilds the venv and applies migrations)" | |
| (cd "$NETBOX_ROOT" && PYTHON="$PYTHON_BIN" ./upgrade.sh) | |
| } | |
| restart_services() { | |
| info "Restarting ${SERVICES[*]}" | |
| systemctl restart "${SERVICES[@]}" | |
| sleep 5 | |
| local s | |
| for s in "${SERVICES[@]}"; do | |
| systemctl is-active --quiet "$s" || die "Service ${s} is not running. Check: journalctl -u ${s}" | |
| info "Service ${s}: active" | |
| done | |
| } | |
| cleanup_old_releases() { | |
| [[ $METHOD == archive ]] || return 0 | |
| local parent active | |
| parent="$(dirname "$NETBOX_ROOT")" | |
| active="$(readlink -f "$NETBOX_ROOT")" | |
| local old=() | |
| local d | |
| while IFS= read -r d; do | |
| [[ $d != "$active" ]] && old+=("$d") | |
| done < <(find "$parent" -maxdepth 1 -type d -name 'netbox-[0-9]*' | sort -V) | |
| local remove_count=$(( ${#old[@]} - KEEP_RELEASES )) | |
| (( remove_count > 0 )) || return 0 | |
| local to_remove=("${old[@]:0:remove_count}") | |
| info "Old releases to remove (keeping the newest ${KEEP_RELEASES}):" | |
| printf ' %s\n' "${to_remove[@]}" | |
| if confirm "Remove these directories?"; then | |
| rm -rf -- "${to_remove[@]}" | |
| info "Old releases removed" | |
| fi | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Main | |
| # --------------------------------------------------------------------------- | |
| main() { | |
| parse_args "$@" | |
| [[ $EUID -eq 0 ]] || die "Run this script as root (sudo)." | |
| exec 9>"$LOCK_FILE" | |
| flock -n 9 || die "Another upgrade is already running." | |
| # Create the log directory only if missing, keep existing permissions | |
| # (NetBox's own LOGGING configuration may write here as well). | |
| [[ -d $LOG_DIR ]] || install -d -m 0750 "$LOG_DIR" | |
| exec > >(tee -a "$LOG_FILE") 2>&1 | |
| trap on_error ERR | |
| trap cleanup EXIT | |
| local cmd | |
| for cmd in curl tar sort git systemctl; do | |
| command -v "$cmd" >/dev/null || die "Required command not found: ${cmd}" | |
| done | |
| $SKIP_BACKUP || command -v pg_dump >/dev/null || die "pg_dump not found (install postgresql-client)." | |
| info "NetBox upgrade started, log: ${LOG_FILE}" | |
| detect_install | |
| detect_current_version | |
| detect_target_version | |
| load_settings | |
| check_versions | |
| check_dependencies | |
| check_ltree | |
| check_local_config | |
| check_git_tree | |
| check_archive_target | |
| check_data_dirs | |
| info "Review the release notes for every version since ${CURRENT_VERSION}:" | |
| info " https://github.com/${GITHUB_REPO}/releases" | |
| if $CHECK_ONLY; then | |
| info "Pre-flight checks passed (--check). No changes made." | |
| exit 0 | |
| fi | |
| confirm "Upgrade NetBox from ${CURRENT_VERSION} to ${TARGET_VERSION}?" || die "Aborted by user." | |
| backup | |
| if [[ $METHOD == archive ]]; then install_archive; else install_git; fi | |
| run_upgrade_script | |
| restart_services | |
| CHANGES_STARTED=false | |
| info "NetBox upgraded from ${CURRENT_VERSION} to ${TARGET_VERSION}" | |
| [[ -n $DB_DUMP ]] && info "Database backup: ${DB_DUMP}" | |
| [[ -n $FILES_BACKUP ]] && info "File backup: ${FILES_BACKUP}" | |
| cleanup_old_releases | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment