From d944e464d0056501741ac0547edb50e0b7394fd0 Mon Sep 17 00:00:00 2001 From: ansible user Date: Thu, 23 Oct 2025 12:56:30 +0200 Subject: [PATCH] first commit --- .env | 19 ++ .gitignore | 1 + Dockerfile | 23 ++ docker-compose.yml | 30 ++ files/entrypoint.sh | 28 ++ files/netbox-reporter.sh | 504 ++++++++++++++++++++++++++++++++ files/netbox-reporter.sh-backup | 443 ++++++++++++++++++++++++++++ 7 files changed, 1048 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 files/entrypoint.sh create mode 100644 files/netbox-reporter.sh create mode 100644 files/netbox-reporter.sh-backup diff --git a/.env b/.env new file mode 100644 index 0000000..6ab317c --- /dev/null +++ b/.env @@ -0,0 +1,19 @@ +# ===== RabbitMQ (HTTP Management API) ===== +# If RabbitMQ is on the same VM, leave localhost and keep network_mode: host in compose. +RMQ_HOST=localhost +RMQ_PORT=15672 + +# Management user/pass +RMQ_USER=admin +RMQ_PASS=change_me + +# Virtual host and queue to poll +VHOST=app +QUEUE=queue_controls + +# Poll interval (seconds) when queue is empty +SLEEP_SECS=2 + +# ===== NetBox ===== +NB_URL=http://netbox.gt-tiso.ikeja.co.za +NB_TOKEN=7648e4f5ee370cda7834682e61b47c2ee8e95623 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fce603 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..be4b8b8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV APP_DIR=/opt/containers/netbox-reporter +ENV DATA_DIR=${APP_DIR}/data + +# Minimal deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash curl jq ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* + +# App payload (immutable inside image) +RUN mkdir -p ${APP_DIR}/app/bin +COPY files/netbox-reporter.sh ${APP_DIR}/app/bin/netbox-reporter.sh +COPY files/entrypoint.sh /usr/local/bin/netbox-reporter-entrypoint +RUN chmod +x /usr/local/bin/netbox-reporter-entrypoint \ + && chmod +x ${APP_DIR}/app/bin/netbox-reporter.sh + +WORKDIR ${APP_DIR}/app + +# Use tini as PID1; entrypoint validates env and execs the main script +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/netbox-reporter-entrypoint"] +CMD ["/opt/containers/netbox-reporter/app/bin/netbox-reporter.sh"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..901aea0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + netbox-reporter: + build: + context: . + dockerfile: Dockerfile + image: netbox-reporter:latest + + # Share host network so "localhost:15672" talks to RabbitMQ on the host + network_mode: host + + working_dir: /opt/containers/netbox-reporter/app + entrypoint: ["/usr/bin/tini", "--", "/usr/local/bin/netbox-reporter-entrypoint"] + command: ["/opt/containers/netbox-reporter/app/bin/netbox-reporter.sh"] + + restart: unless-stopped + tty: true + stdin_open: true + + # Load ALL runtime parameters from .env (one file to rule them all) + env_file: + - .env + + volumes: + - ./data:/opt/containers/netbox-reporter/data:rw + + healthcheck: + test: ["CMD-SHELL", "curl -fsS -u \"$RMQ_USER:$RMQ_PASS\" \"http://$RMQ_HOST:$RMQ_PORT/api/overview\" >/dev/null"] + interval: 30s + timeout: 10s + retries: 5 diff --git a/files/entrypoint.sh b/files/entrypoint.sh new file mode 100755 index 0000000..53af04b --- /dev/null +++ b/files/entrypoint.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +log(){ echo "[netbox-reporter][entrypoint] $*"; } +err(){ echo "[netbox-reporter][entrypoint][ERROR] $*" >&2; } + +# Sanity: tools +for bin in curl jq; do + command -v "$bin" >/dev/null 2>&1 || { err "Missing $bin"; exit 2; } +done + +# Required env (all fed from .env via compose) +: "${RMQ_USER:?RMQ_USER required}" +: "${RMQ_PASS:?RMQ_PASS required}" +: "${RMQ_HOST:?RMQ_HOST required}" +: "${RMQ_PORT:?RMQ_PORT required}" +: "${VHOST:?VHOST required}" +: "${QUEUE:?QUEUE required}" +: "${SLEEP_SECS:?SLEEP_SECS required}" + +: "${NB_URL:?NB_URL required}" +: "${NB_TOKEN:?NB_TOKEN required}" + +log "RMQ=http://${RMQ_HOST}:${RMQ_PORT} vhost='${VHOST}' queue='${QUEUE}'" +log "NB_URL=${NB_URL}" + +# Hand off to main script +exec "$@" diff --git a/files/netbox-reporter.sh b/files/netbox-reporter.sh new file mode 100644 index 0000000..784fd70 --- /dev/null +++ b/files/netbox-reporter.sh @@ -0,0 +1,504 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --- Minimal deps check --- +for bin in curl jq; do + command -v "$bin" >/dev/null 2>&1 || { echo "[ERROR] Missing dependency: $bin" >&2; exit 2; } +done + +# --- RabbitMQ config (override via env) --- +RMQ_USER="${RMQ_USER:-admin}" +RMQ_PASS="${RMQ_PASS:-change_me}" +RMQ_HOST="${RMQ_HOST:-localhost}" +RMQ_PORT="${RMQ_PORT:-15672}" +VHOST="${VHOST:-app}" +QUEUE="${QUEUE:-queue_controls}" +SLEEP_SECS="${SLEEP_SECS:-2}" + +# --- NetBox config (override via env) --- +NB_URL="${NB_URL:-http://netbox.gt-tiso.ikeja.co.za}" +# Tip: Prefer setting NB_TOKEN via environment. Leaving default blank avoids accidental leaks. +NB_TOKEN="${NB_TOKEN:-}" + +log() { echo "[netbox-reporter] $*"; } +warn(){ echo "[netbox-reporter][WARN] $*" >&2; } +err() { echo "[netbox-reporter][ERROR] $*" >&2; } + +# Require NB_TOKEN +if [[ -z "${NB_TOKEN}" ]]; then + err "NB_TOKEN is not set. Export NB_TOKEN and retry." + exit 2 +fi + +# URL-encode for RabbitMQ HTTP API paths +urlenc() { printf '%s' "$1" | sed -e 's, ,%20,g' -e 's,/,%2F,g'; } + +# Simple RabbitMQ HTTP call +rmq_api() { + local method="$1" path="$2" data="${3:-}" + local url="http://${RMQ_HOST}:${RMQ_PORT}${path}" + if [[ -n "$data" ]]; then + curl -sS -u "$RMQ_USER:$RMQ_PASS" -H "content-type:application/json" -X "$method" "$url" -d "$data" + else + curl -sS -u "$RMQ_USER:$RMQ_PASS" -H "content-type:application/json" -X "$method" "$url" + fi +} + +# Find NetBox device ID by exact name +nb_find_device_id() { + local name="$1" + local resp + resp="$(curl -sS \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Accept: application/json" \ + "$NB_URL/api/dcim/devices/?name=$(printf '%s' "$name" | jq -sRr @uri)&limit=1")" || return 1 + jq -r '.results[0].id // empty' <<<"$resp" +} + +# PATCH NetBox custom fields (update_wo_restart) +nb_patch_update_wo_restart() { + local dev_id="$1" update_progress="$2" updating_to="$3" + local body + body="$(jq -n --arg p "$update_progress" --arg u "$updating_to" \ + '{custom_fields: {update_progress: $p, updating_to: $u}}')" + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ ]]; then + log "Updated device id=$dev_id custom_fields OK" + return 0 + else + err "Failed to update device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# PATCH NetBox custom field: wifidebug (deploy_wifidebug) +nb_patch_wifidebug() { + local dev_id="$1" value="$2" + local body + body="$(jq -n --arg v "$value" '{custom_fields: {wifidebug: $v}}')" + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ ]]; then + log "Updated device id=$dev_id wifidebug='$value' OK" + return 0 + else + err "Failed to set wifidebug for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# POST a Journal Entry on the device (journal_add) with prefix "updated: " +nb_add_journal() { + local dev_id="$1" text="$2" + local comment="updated: ${text}" + local body + body="$(jq -n --arg c "$comment" --argjson oid "$dev_id" \ + '{assigned_object_type:"dcim.device", assigned_object_id:$oid, comments:$c}')" + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST "$NB_URL/api/extras/journal-entries/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ || "$code" == "201" ]]; then + log "Journal added for device id=$dev_id" + return 0 + else + err "Failed to add journal for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# POST a Journal Entry with raw text (no prefix) — used for task_notify path +nb_add_journal_raw() { + local dev_id="$1" text="$2" + local body + body="$(jq -n --arg c "$text" --argjson oid "$dev_id" \ + '{assigned_object_type:"dcim.device", assigned_object_id:$oid, comments:$c}')" + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST "$NB_URL/api/extras/journal-entries/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ || "$code" == "201" ]]; then + log "Journal (raw) added for device id=$dev_id" + return 0 + else + err "Failed to add raw journal for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# PATCH custom field: multiple_ssids = "yes" — used for task_notify path +nb_set_multiple_ssids_yes() { + local dev_id="$1" + local body='{"custom_fields":{"multiple_ssids":"yes"}}' + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ ]]; then + log "Set multiple_ssids='yes' for device id=$dev_id" + return 0 + else + err "Failed to set multiple_ssids for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# --- Add a tag to a device using tag slug (ensures tag exists; merges dictionaries) --- +nb_add_tag_by_slug() { + local dev_id="$1" tag_slug="$2" + + # 1) Lookup tag by slug + local tag_resp tag_id tag_name + tag_resp="$(curl -sS \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Accept: application/json" \ + "$NB_URL/api/extras/tags/?slug=$(printf '%s' "$tag_slug" | jq -sRr @uri)&limit=1")" || tag_resp='{}' + tag_id="$(jq -r '.results[0].id // empty' <<<"$tag_resp")" + tag_name="$(jq -r '.results[0].name // empty' <<<"$tag_resp")" + + # 2) Create tag if missing + if [[ -z "$tag_id" ]]; then + local derived_name create_body create_tmp create_code + derived_name="$(printf '%s' "$tag_slug" | sed -E 's/-+/ /g')" + create_body="$(jq -n --arg name "$derived_name" --arg slug "$tag_slug" '{name:$name, slug:$slug}')" + create_tmp="$(mktemp)" + create_code="$(curl -sS -o "$create_tmp" -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -X POST "$NB_URL/api/extras/tags/" \ + -d "$create_body" || true)" + if [[ "$create_code" != "201" && ! "$create_code" =~ ^20[0-9]$ ]]; then + err "Failed to create tag slug='$tag_slug' (HTTP $create_code)" + cat "$create_tmp" >&2 || true + rm -f "$create_tmp" + return 1 + fi + tag_name="$(jq -r '.name' < "$create_tmp")" + rm -f "$create_tmp" + fi + + # 3) Read existing device tags, normalize to [{name, slug}] + local dev_detail existing_objs_json + dev_detail="$(curl -sS \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Accept: application/json" \ + "$NB_URL/api/dcim/devices/$dev_id/")" + existing_objs_json="$( + jq -c ' + (.tags // []) as $t | + if ( ($t|length)>0 and (($t[0]|type)=="object") ) then + [ $t[] | {name:.name, slug:.slug} ] + else + [ $t[] | {name:.} ] + end + ' <<<"$dev_detail" + )" + + # 4) Merge + dedupe; build PATCH body + local merged_objs_json patch_body patch_tmp patch_code + merged_objs_json="$( + jq -cn --arg name "$tag_name" --arg slug "$tag_slug" --argjson existing "$existing_objs_json" ' + ($existing + [ {name:$name, slug:$slug} ]) + | group_by(.name) + | map(.[0]) + ' + )" + patch_body="$(jq -n --argjson tags "$merged_objs_json" '{tags:$tags}')" + + # 5) PATCH device + patch_tmp="$(mktemp)" + patch_code="$(curl -sS -o "$patch_tmp" -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$patch_body" || true)" + + if [[ "$patch_code" =~ ^20[0-9]$ ]]; then + log "Tag '$tag_slug' applied to device id=$dev_id" + rm -f "$patch_tmp" + return 0 + else + err "Failed to set tag '$tag_slug' for device id=$dev_id (HTTP $patch_code)" + cat "$patch_tmp" >&2 || true + rm -f "$patch_tmp" + return 1 + fi +} + +# --- remove a tag from a device by slug (idempotent) --- +# --- remove a tag from a device by slug (idempotent) --- +nb_remove_tag_by_slug() { + local dev_id="$1" tag_slug="$2" + + # 1) Get current tags + local dev_detail existing_objs_json + dev_detail="$(curl -sS \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Accept: application/json" \ + "$NB_URL/api/dcim/devices/$dev_id/")" || return 1 + + # Normalize tags to objects: [{name, slug?}] + existing_objs_json="$( + jq -c ' + (.tags // []) as $t | + if ( ($t|length)>0 and (($t[0]|type)=="object") ) then + [ $t[] | {name:(.name//""), slug:(.slug//"")} ] + else + [ $t[] | {name:.} ] + end + ' <<<"$dev_detail" + )" + + # 2) Build a friendly name from slug (e.g. indoor-restart-scheduled -> indoor restart scheduled) + local name_from_slug + name_from_slug="$(sed -E 's/-+/ /g' <<<"$tag_slug")" + + # 3) Filter out any tag whose slug == tag_slug OR name == tag_slug OR name == name_from_slug (case-insensitive) + local filtered_objs_json + filtered_objs_json="$( + jq -cn --arg slug "$tag_slug" --arg name "$name_from_slug" --argjson existing "$existing_objs_json" ' + [ $existing[] + | select( + ((.slug // "" | ascii_downcase) != ($slug | ascii_downcase)) + and ((.name // "" | ascii_downcase) != ($slug | ascii_downcase)) + and ((.name // "" | ascii_downcase) != ($name | ascii_downcase)) + ) + ] + ' + )" + + # 4) PATCH back + local patch_body patch_tmp patch_code + patch_body="$(jq -n --argjson tags "$filtered_objs_json" '{tags:$tags}')" + + patch_tmp="$(mktemp)" + patch_code="$(curl -sS -o "$patch_tmp" -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$patch_body" || true)" + + if [[ "$patch_code" =~ ^20[0-9]$ ]]; then + log "Removed tag '$tag_slug' from device id=$dev_id (if present)" + rm -f "$patch_tmp" + return 0 + else + err "Failed to remove tag '$tag_slug' for device id=$dev_id (HTTP $patch_code)" + cat "$patch_tmp" >&2 || true + rm -f "$patch_tmp" + return 1 + fi +} + +# --- clear only custom_field update_progress (set to null) --- +nb_clear_update_progress() { + local dev_id="$1" + local body='{"custom_fields":{"update_progress":null}}' + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$body")" || code="000" + if [[ "$code" =~ ^20[0-9]$ ]]; then + log "Cleared update_progress for device id=$dev_id" + return 0 + else + err "Failed to clear update_progress for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# --- NEW: generic single custom field patch --- +nb_patch_custom_field() { + local dev_id="$1" key="$2" value="$3" + # Build {"custom_fields": { "": "" }} + local body + body="$(jq -n --arg k "$key" --arg v "$value" '{custom_fields: {($k): $v}}')" + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ ]]; then + log "Updated device id=$dev_id custom_field '$key'='$value' OK" + return 0 + else + err "Failed to set custom_field '$key' for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# Handle one JSON payload (object) +handle_payload() { + local payload="$1" + + local device task task_result task_result_typo add1 notify + device="$(jq -r '.inscope_device // empty' <<<"$payload")" + task="$(jq -r '.task_name // empty' <<<"$payload")" + task_result="$(jq -r '.task_result // empty' <<<"$payload")" + task_result_typo="$(jq -r '.taks_result // empty' <<<"$payload")" # backward-compat typo + add1="$(jq -r '.task_add1 // empty' <<<"$payload")" + notify="$(jq -r '.task_notify // empty' <<<"$payload")" # notify path + + [[ -z "$task_result" && -n "$task_result_typo" ]] && task_result="$task_result_typo" + + if [[ -z "$device" || -z "$task" ]]; then + warn "Skipping message (missing inscope_device/task_name)" + return 0 + fi + + local dev_id + dev_id="$(nb_find_device_id "$device")" + if [[ -z "$dev_id" ]]; then + err "Device '$device' not found in NetBox" + return 0 + fi + + case "$task" in + update_wo_restart) + # If task_notify is present, set multiple_ssids=yes AND add journal with notify text + if [[ -n "$notify" ]]; then + if nb_set_multiple_ssids_yes "$dev_id"; then + echo ">>> Device '$device' (id=$dev_id) updated:" + echo " multiple_ssids = 'yes'" + fi + if nb_add_journal_raw "$dev_id" "$notify"; then + echo ">>> Device '$device' (id=$dev_id) journal:" + echo " added comment = '$notify'" + fi + return 0 + fi + + # existing behavior + if nb_patch_update_wo_restart "$dev_id" "$task_result" "$add1"; then + echo ">>> Device '$device' (id=$dev_id) updated:" + echo " update_progress = '$task_result'" + echo " updating_to = '$add1'" + fi + ;; + deploy_wifidebug) + if nb_patch_wifidebug "$dev_id" "$task_result"; then + echo ">>> Device '$device' (id=$dev_id) updated:" + echo " wifidebug = '$task_result'" + fi + ;; + journal_add) + if nb_add_journal "$dev_id" "$task_result"; then + echo ">>> Device '$device' (id=$dev_id) journal:" + echo " added comment = 'updated: $task_result'" + fi + ;; + tag_add) + # add a tag to the device using task_result as slug + if [[ -z "$task_result" ]]; then + warn "tag_add: task_result (tag slug) is empty; skipping." + return 0 + fi + if nb_add_tag_by_slug "$dev_id" "$task_result"; then + echo ">>> Device '$device' (id=$dev_id) updated:" + echo " tag added (slug) = '$task_result'" + fi + ;; + update_cleanup_success) + # cleanup after successful update + nb_remove_tag_by_slug "$dev_id" "update-in-progress" || true + nb_remove_tag_by_slug "$dev_id" "update-auto-restarted" || true + nb_add_tag_by_slug "$dev_id" "update-successful" || true + nb_clear_update_progress "$dev_id" || true + + echo ">>> Device '$device' (id=$dev_id) cleanup done:" + echo " - tag removed: update-in-progress" + echo " - tag removed: update-auto-restarted" + echo " - tag added: update-successful" + echo " - custom field cleared: update_progress" + ;; + custom_field_set) + # NEW: generic setter + # task_add1 = field key (e.g., Multissidfix), task_result = value (e.g., yes) + if [[ -z "$add1" ]]; then + warn "custom_field_set: task_add1 (field key) is empty; skipping." + return 0 + fi + if nb_patch_custom_field "$dev_id" "$add1" "$task_result"; then + echo ">>> Device '$device' (id=$dev_id) updated:" + echo " custom_fields.$add1 = '$task_result'" + fi + ;; + tag_remove) + # remove a tag from the device using task_result as slug + if [[ -z "$task_result" ]]; then + warn "tag_remove: task_result (tag slug) is empty; skipping." + return 0 + fi + if nb_remove_tag_by_slug "$dev_id" "$task_result"; then + echo ">>> Device '$device' (id=$dev_id) updated:" + echo " tag removed (slug) = '$task_result'" + fi + ;; + *) + log "Ignoring unsupported task_name='$task' (no-op)" + ;; + esac +} + +# --- Main consume loop --- +log "Consuming from queue='${QUEUE}' (vhost='${VHOST}')" +while :; do + RESP="$(rmq_api POST "/api/queues/$(urlenc "$VHOST")/$(urlenc "$QUEUE")/get" '{ + "count": 1, "ackmode": "ack_requeue_false", "encoding": "auto", "truncate": 1000000 + }')" + + # No messages -> pause + if [[ -z "$RESP" || "$RESP" == "[]" ]]; then + sleep "$SLEEP_SECS" + continue + fi + + # Process each message (API returns an array) + echo "$RESP" | jq -c '.[]' | while read -r item; do + raw_payload="$(jq -r '.payload' <<<"$item")" + + # Payload might be a JSON object OR a JSON-encoded string; handle both. + if jq -e . >/dev/null 2>&1 <<<"$raw_payload"; then + # If it's a string that contains JSON, decode once + if [[ "$(jq -r 'type' <<<"$raw_payload")" == "string" ]] && jq -e . >/dev/null 2>&1 <<<"$(jq -r . <<<"$raw_payload")"; then + decoded="$(jq -r . <<<"$raw_payload")" + handle_payload "$decoded" + else + handle_payload "$raw_payload" + fi + else + warn "Skipping non-JSON payload" + fi + done +done diff --git a/files/netbox-reporter.sh-backup b/files/netbox-reporter.sh-backup new file mode 100644 index 0000000..015d224 --- /dev/null +++ b/files/netbox-reporter.sh-backup @@ -0,0 +1,443 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --- Minimal deps check --- +for bin in curl jq; do + command -v "$bin" >/dev/null 2>&1 || { echo "[ERROR] Missing dependency: $bin" >&2; exit 2; } +done + +# --- RabbitMQ config (override via env) --- +RMQ_USER="${RMQ_USER:-admin}" +RMQ_PASS="${RMQ_PASS:-change_me}" +RMQ_HOST="${RMQ_HOST:-localhost}" +RMQ_PORT="${RMQ_PORT:-15672}" +VHOST="${VHOST:-app}" +QUEUE="${QUEUE:-queue_controls}" +SLEEP_SECS="${SLEEP_SECS:-2}" + +# --- NetBox config (override via env) --- +NB_URL="${NB_URL:-http://netbox.gt-tiso.ikeja.co.za}" +NB_TOKEN="${NB_TOKEN:-7648e4f5ee370cda7834682e61b47c2ee8e95623}" + +log() { echo "[netbox-reporter] $*"; } +warn(){ echo "[netbox-reporter][WARN] $*" >&2; } +err() { echo "[netbox-reporter][ERROR] $*" >&2; } + +# URL-encode for RabbitMQ HTTP API paths +urlenc() { printf '%s' "$1" | sed -e 's, ,%20,g' -e 's,/,%2F,g'; } + +# Simple RabbitMQ HTTP call +rmq_api() { + local method="$1" path="$2" data="${3:-}" + local url="http://${RMQ_HOST}:${RMQ_PORT}${path}" + if [[ -n "$data" ]]; then + curl -sS -u "$RMQ_USER:$RMQ_PASS" -H "content-type:application/json" -X "$method" "$url" -d "$data" + else + curl -sS -u "$RMQ_USER:$RMQ_PASS" -H "content-type:application/json" -X "$method" "$url" + fi +} + +# Find NetBox device ID by exact name +nb_find_device_id() { + local name="$1" + local resp + resp="$(curl -sS \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Accept: application/json" \ + "$NB_URL/api/dcim/devices/?name=$(printf '%s' "$name" | jq -sRr @uri)&limit=1")" || return 1 + jq -r '.results[0].id // empty' <<<"$resp" +} + +# PATCH NetBox custom fields (update_wo_restart) +nb_patch_update_wo_restart() { + local dev_id="$1" update_progress="$2" updating_to="$3" + local body + body="$(jq -n --arg p "$update_progress" --arg u "$updating_to" \ + '{custom_fields: {update_progress: $p, updating_to: $u}}')" + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ ]]; then + log "Updated device id=$dev_id custom_fields OK" + return 0 + else + err "Failed to update device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# PATCH NetBox custom field: wifidebug (deploy_wifidebug) +nb_patch_wifidebug() { + local dev_id="$1" value="$2" + local body + body="$(jq -n --arg v "$value" '{custom_fields: {wifidebug: $v}}')" + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ ]]; then + log "Updated device id=$dev_id wifidebug='$value' OK" + return 0 + else + err "Failed to set wifidebug for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# POST a Journal Entry on the device (journal_add) with prefix "updated: " +nb_add_journal() { + local dev_id="$1" text="$2" + local comment="updated: ${text}" + local body + body="$(jq -n --arg c "$comment" --argjson oid "$dev_id" \ + '{assigned_object_type:"dcim.device", assigned_object_id:$oid, comments:$c}')" + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST "$NB_URL/api/extras/journal-entries/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ || "$code" == "201" ]]; then + log "Journal added for device id=$dev_id" + return 0 + else + err "Failed to add journal for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# POST a Journal Entry with raw text (no prefix) — used for task_notify path +nb_add_journal_raw() { + local dev_id="$1" text="$2" + local body + body="$(jq -n --arg c "$text" --argjson oid "$dev_id" \ + '{assigned_object_type:"dcim.device", assigned_object_id:$oid, comments:$c}')" + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST "$NB_URL/api/extras/journal-entries/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ || "$code" == "201" ]]; then + log "Journal (raw) added for device id=$dev_id" + return 0 + else + err "Failed to add raw journal for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# PATCH custom field: multiple_ssids = "yes" — used for task_notify path +nb_set_multiple_ssids_yes() { + local dev_id="$1" + local body='{"custom_fields":{"multiple_ssids":"yes"}}' + + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$body")" || code="000" + + if [[ "$code" =~ ^20[0-9]$ ]]; then + log "Set multiple_ssids='yes' for device id=$dev_id" + return 0 + else + err "Failed to set multiple_ssids for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# --- NEW: Add a tag to a device using tag slug (ensures tag exists; merges dictionaries) --- +nb_add_tag_by_slug() { + local dev_id="$1" tag_slug="$2" + + # 1) Lookup tag by slug + local tag_resp tag_id tag_name + tag_resp="$(curl -sS \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Accept: application/json" \ + "$NB_URL/api/extras/tags/?slug=$(printf '%s' "$tag_slug" | jq -sRr @uri)&limit=1")" || tag_resp='{}' + tag_id="$(jq -r '.results[0].id // empty' <<<"$tag_resp")" + tag_name="$(jq -r '.results[0].name // empty' <<<"$tag_resp")" + + # 2) Create tag if missing + if [[ -z "$tag_id" ]]; then + local derived_name create_body create_tmp create_code + derived_name="$(printf '%s' "$tag_slug" | sed -E 's/-+/ /g')" + create_body="$(jq -n --arg name "$derived_name" --arg slug "$tag_slug" '{name:$name, slug:$slug}')" + create_tmp="$(mktemp)" + create_code="$(curl -sS -o "$create_tmp" -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -X POST "$NB_URL/api/extras/tags/" \ + -d "$create_body" || true)" + if [[ "$create_code" != "201" && ! "$create_code" =~ ^20[0-9]$ ]]; then + err "Failed to create tag slug='$tag_slug' (HTTP $create_code)" + cat "$create_tmp" >&2 || true + rm -f "$create_tmp" + return 1 + fi + tag_name="$(jq -r '.name' < "$create_tmp")" + rm -f "$create_tmp" + fi + + # 3) Read existing device tags, normalize to [{name, slug}] + local dev_detail existing_objs_json + dev_detail="$(curl -sS \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Accept: application/json" \ + "$NB_URL/api/dcim/devices/$dev_id/")" + existing_objs_json="$( + jq -c ' + (.tags // []) as $t | + if ( ($t|length)>0 and (($t[0]|type)=="object") ) then + [ $t[] | {name:.name, slug:.slug} ] + else + [ $t[] | {name:.} ] + end + ' <<<"$dev_detail" + )" + + # 4) Merge + dedupe; build PATCH body + local merged_objs_json patch_body patch_tmp patch_code + merged_objs_json="$( + jq -cn --arg name "$tag_name" --arg slug "$tag_slug" --argjson existing "$existing_objs_json" ' + ($existing + [ {name:$name, slug:$slug} ]) + | group_by(.name) + | map(.[0]) + ' + )" + patch_body="$(jq -n --argjson tags "$merged_objs_json" '{tags:$tags}')" + + # 5) PATCH device + patch_tmp="$(mktemp)" + patch_code="$(curl -sS -o "$patch_tmp" -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$patch_body" || true)" + + if [[ "$patch_code" =~ ^20[0-9]$ ]]; then + log "Tag '$tag_slug' applied to device id=$dev_id" + rm -f "$patch_tmp" + return 0 + else + err "Failed to set tag '$tag_slug' for device id=$dev_id (HTTP $patch_code)" + cat "$patch_tmp" >&2 || true + rm -f "$patch_tmp" + return 1 + fi +} + +# --- NEW: remove a tag from a device by slug (idempotent) --- +nb_remove_tag_by_slug() { + local dev_id="$1" tag_slug="$2" + + # 1) Get current tags + local dev_detail existing_objs_json + dev_detail="$(curl -sS \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Accept: application/json" \ + "$NB_URL/api/dcim/devices/$dev_id/")" || return 1 + + existing_objs_json="$( + jq -c ' + (.tags // []) as $t | + if ( ($t|length)>0 and (($t[0]|type)=="object") ) then + [ $t[] | {name:.name, slug:.slug} ] + else + [ $t[] | {name:.} ] + end + ' <<<"$dev_detail" + )" + + # 2) Filter out the slug (match either by slug or by legacy name) + local filtered_objs_json + filtered_objs_json="$( + jq -cn --arg slug "$tag_slug" --argjson existing "$existing_objs_json" ' + [ $existing[] | select((.slug // "") != $slug and (.name // "") != $slug) ] + ' + )" + + # 3) PATCH back + local patch_body patch_tmp patch_code + patch_body="$(jq -n --argjson tags "$filtered_objs_json" '{tags:$tags}')" + + patch_tmp="$(mktemp)" + patch_code="$(curl -sS -o "$patch_tmp" -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$patch_body" || true)" + + if [[ "$patch_code" =~ ^20[0-9]$ ]]; then + log "Removed tag '$tag_slug' from device id=$dev_id (if present)" + rm -f "$patch_tmp" + return 0 + else + err "Failed to remove tag '$tag_slug' for device id=$dev_id (HTTP $patch_code)" + cat "$patch_tmp" >&2 || true + rm -f "$patch_tmp" + return 1 + fi +} + +# --- NEW: clear only custom_field update_progress (set to null) --- +nb_clear_update_progress() { + local dev_id="$1" + local body='{"custom_fields":{"update_progress":null}}' + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Token $NB_TOKEN" \ + -H "Content-Type: application/json" \ + -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ + -d "$body")" || code="000" + if [[ "$code" =~ ^20[0-9]$ ]]; then + log "Cleared update_progress for device id=$dev_id" + return 0 + else + err "Failed to clear update_progress for device id=$dev_id (HTTP $code)" + return 1 + fi +} + +# Handle one JSON payload (object) +handle_payload() { + local payload="$1" + + local device task task_result task_result_typo add1 notify + device="$(jq -r '.inscope_device // empty' <<<"$payload")" + task="$(jq -r '.task_name // empty' <<<"$payload")" + task_result="$(jq -r '.task_result // empty' <<<"$payload")" + task_result_typo="$(jq -r '.taks_result // empty' <<<"$payload")" # backward-compat typo + add1="$(jq -r '.task_add1 // empty' <<<"$payload")" + notify="$(jq -r '.task_notify // empty' <<<"$payload")" # NEW: notify path + + [[ -z "$task_result" && -n "$task_result_typo" ]] && task_result="$task_result_typo" + + if [[ -z "$device" || -z "$task" ]]; then + warn "Skipping message (missing inscope_device/task_name)" + return 0 + fi + + local dev_id + dev_id="$(nb_find_device_id "$device")" + if [[ -z "$dev_id" ]]; then + err "Device '$device' not found in NetBox" + return 0 + fi + + case "$task" in + update_wo_restart) + # NEW BEHAVIOR: if task_notify is present, set multiple_ssids=yes AND add journal with notify text + if [[ -n "$notify" ]]; then + if nb_set_multiple_ssids_yes "$dev_id"; then + echo ">>> Device '$device' (id=$dev_id) updated:" + echo " multiple_ssids = 'yes'" + fi + if nb_add_journal_raw "$dev_id" "$notify"; then + echo ">>> Device '$device' (id=$dev_id) journal:" + echo " added comment = '$notify'" + fi + return 0 + fi + + # existing behavior (unchanged) + if nb_patch_update_wo_restart "$dev_id" "$task_result" "$add1"; then + echo ">>> Device '$device' (id=$dev_id) updated:" + echo " update_progress = '$task_result'" + echo " updating_to = '$add1'" + fi + ;; + deploy_wifidebug) + if nb_patch_wifidebug "$dev_id" "$task_result"; then + echo ">>> Device '$device' (id=$dev_id) updated:" + echo " wifidebug = '$task_result'" + fi + ;; + journal_add) + if nb_add_journal "$dev_id" "$task_result"; then + echo ">>> Device '$device' (id=$dev_id) journal:" + echo " added comment = 'updated: $task_result'" + fi + ;; + tag_add) + # NEW: add a tag to the device using task_result as slug + if [[ -z "$task_result" ]]; then + warn "tag_add: task_result (tag slug) is empty; skipping." + return 0 + fi + if nb_add_tag_by_slug "$dev_id" "$task_result"; then + echo ">>> Device '$device' (id=$dev_id) updated:" + echo " tag added (slug) = '$task_result'" + fi + ;; + # --- NEW ACTION: cleanup on successful update --- + update_cleanup_success) + # a) remove "update-in-progress" + nb_remove_tag_by_slug "$dev_id" "update-in-progress" || true + # b) remove "update-auto-restarted" + nb_remove_tag_by_slug "$dev_id" "update-auto-restarted" || true + # c) add "update-successful" + nb_add_tag_by_slug "$dev_id" "update-successful" || true + # d) clear custom field update_progress + nb_clear_update_progress "$dev_id" || true + + echo ">>> Device '$device' (id=$dev_id) cleanup done:" + echo " - tag removed: update-in-progress" + echo " - tag removed: update-auto-restarted" + echo " - tag added: update-successful" + echo " - custom field cleared: update_progress" + ;; + *) + log "Ignoring unsupported task_name='$task' (no-op)" + ;; + esac +} + +# --- Main consume loop --- +log "Consuming from queue='${QUEUE}' (vhost='${VHOST}')" +while :; do + RESP="$(rmq_api POST "/api/queues/$(urlenc "$VHOST")/$(urlenc "$QUEUE")/get" '{ + "count": 1, "ackmode": "ack_requeue_false", "encoding": "auto", "truncate": 1000000 + }')" + + # No messages -> pause + if [[ -z "$RESP" || "$RESP" == "[]" ]]; then + sleep "$SLEEP_SECS" + continue + fi + + # Process each message (API returns an array) + echo "$RESP" | jq -c '.[]' | while read -r item; do + raw_payload="$(jq -r '.payload' <<<"$item")" + + # Payload might be a JSON object OR a JSON-encoded string; handle both. + if jq -e . >/dev/null 2>&1 <<<"$raw_payload"; then + # If it's a string that contains JSON, decode once + if [[ "$(jq -r 'type' <<<"$raw_payload")" == "string" ]] && jq -e . >/dev/null 2>&1 <<<"$(jq -r . <<<"$raw_payload")"; then + decoded="$(jq -r . <<<"$raw_payload")" + handle_payload "$decoded" + else + handle_payload "$raw_payload" + fi + else + warn "Skipping non-JSON payload" + fi + done +done