From 5113cc3eeda95601bd434845205faf397244b61e Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Sun, 15 Mar 2026 00:00:33 +0000 Subject: [PATCH 01/55] build: disk-collector sidecar and associated workflows --- .github/workflows/build-disk-collector.yml | 51 ++++++++++++++++ .../{docker.yml => build-primary-image.yml} | 2 +- install/sidecar-disk-collector/Dockerfile | 6 ++ .../collect-disk-info.sh | 59 +++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build-disk-collector.yml rename .github/workflows/{docker.yml => build-primary-image.yml} (98%) create mode 100644 install/sidecar-disk-collector/Dockerfile create mode 100755 install/sidecar-disk-collector/collect-disk-info.sh diff --git a/.github/workflows/build-disk-collector.yml b/.github/workflows/build-disk-collector.yml new file mode 100644 index 0000000..7649ba5 --- /dev/null +++ b/.github/workflows/build-disk-collector.yml @@ -0,0 +1,51 @@ +name: Build Disk Collector Image + +on: + workflow_dispatch: + inputs: + version: + description: 'Semantic version to label the Docker image under (no "v" prefix, e.g. "1.2.3")' + required: true + type: string + tag_latest: + description: 'Also tag this image as :latest?' + required: false + type: boolean + default: false + +jobs: + check_authorization: + name: Check authorization to publish new Docker image + runs-on: ubuntu-latest + outputs: + isAuthorized: ${{ steps.check-auth.outputs.is_authorized }} + steps: + - name: check-auth + id: check-auth + run: echo "is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}" >> $GITHUB_OUTPUT + build: + name: Build disk-collector image + needs: check_authorization + if: needs.check_authorization.outputs.isAuthorized == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: install/sidecar-disk-collector + push: true + tags: | + ghcr.io/crosstalk-solutions/project-nomad-disk-collector:${{ inputs.version }} + ghcr.io/crosstalk-solutions/project-nomad-disk-collector:v${{ inputs.version }} + ${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest' || '' }} diff --git a/.github/workflows/docker.yml b/.github/workflows/build-primary-image.yml similarity index 98% rename from .github/workflows/docker.yml rename to .github/workflows/build-primary-image.yml index 1cebccf..daf0e54 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/build-primary-image.yml @@ -1,4 +1,4 @@ -name: Build Docker Image +name: Build Primary Docker Image on: workflow_dispatch: diff --git a/install/sidecar-disk-collector/Dockerfile b/install/sidecar-disk-collector/Dockerfile new file mode 100644 index 0000000..bf22f96 --- /dev/null +++ b/install/sidecar-disk-collector/Dockerfile @@ -0,0 +1,6 @@ +FROM alpine:3.20 +RUN apk add --no-cache util-linux bash +COPY collect-disk-info.sh /usr/local/bin/collect-disk-info.sh +RUN chmod +x /usr/local/bin/collect-disk-info.sh && mkdir -p /storage +WORKDIR /storage +CMD ["/usr/local/bin/collect-disk-info.sh"] diff --git a/install/sidecar-disk-collector/collect-disk-info.sh b/install/sidecar-disk-collector/collect-disk-info.sh new file mode 100755 index 0000000..0fbf805 --- /dev/null +++ b/install/sidecar-disk-collector/collect-disk-info.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Project N.O.M.A.D. - Disk Info Collector Sidecar +# +# Reads host block device and filesystem info via the /:/host:ro,rslave bind-mount. +# No special capabilities required. Writes JSON to /storage/nomad-disk-info.json, which is read by the admin container. +# Runs continually and updates the JSON data every 2 minutes. + +log() { + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" +} + +log "disk-collector sidecar starting..." + +while true; do + + # Get disk layout + DISK_LAYOUT=$(lsblk --sysroot /host --json -o NAME,SIZE,TYPE,MODEL,SERIAL,VENDOR,ROTA,TRAN 2>/dev/null) + if [[ -z "$DISK_LAYOUT" ]]; then + log "WARNING: lsblk --sysroot /host failed, using empty block devices" + DISK_LAYOUT='{"blockdevices":[]}' + fi + + # Get filesystem usage by parsing /host/proc/mounts and running df on each mountpoint + FS_JSON="[" + FIRST=1 + while IFS=' ' read -r dev mountpoint fstype opts _rest; do + # Disregard pseudo and virtual filesystems + [[ "$fstype" =~ ^(tmpfs|devtmpfs|squashfs|sysfs|proc|devpts|cgroup|cgroup2|overlay|nsfs|autofs|hugetlbfs|mqueue|pstore|fusectl|binfmt_misc)$ ]] && continue + [[ "$mountpoint" == "none" ]] && continue + + STATS=$(df -B1 "/host${mountpoint}" 2>/dev/null | awk 'NR==2{print $2,$3,$4,$5}') + [[ -z "$STATS" ]] && continue + + read -r size used avail pct <<< "$STATS" + pct="${pct/\%/}" + + [[ "$FIRST" -eq 0 ]] && FS_JSON+="," + FS_JSON+="{\"fs\":\"${dev}\",\"size\":${size},\"used\":${used},\"available\":${avail},\"use\":${pct},\"mount\":\"${mountpoint}\"}" + FIRST=0 + done < /host/proc/mounts + FS_JSON+="]" + + # Use a tmp file for atomic update + cat > /storage/nomad-disk-info.json.tmp << EOF +{ +"diskLayout": ${DISK_LAYOUT}, +"fsSize": ${FS_JSON} +} +EOF + + if mv /storage/nomad-disk-info.json.tmp /storage/nomad-disk-info.json; then + log "Disk info updated successfully." + else + log "ERROR: Failed to move temp file to /storage/nomad-disk-info.json" + fi + + sleep 120 +done From a4e6a9bd9fda8e368159fc853cc4e45947c24afd Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Sun, 15 Mar 2026 00:10:40 +0000 Subject: [PATCH 02/55] build: compose and install script updates for disk-collector sidecar --- install/install_nomad.sh | 26 ---- install/management_compose.yaml | 9 +- install/migrate-disk-collector.md | 40 +++++ install/migrate-disk-collector.sh | 248 ++++++++++++++++++++++++++++++ install/uninstall_nomad.sh | 26 ---- 5 files changed, 296 insertions(+), 53 deletions(-) create mode 100644 install/migrate-disk-collector.md create mode 100755 install/migrate-disk-collector.sh diff --git a/install/install_nomad.sh b/install/install_nomad.sh index f841f05..b7ac85e 100644 --- a/install/install_nomad.sh +++ b/install/install_nomad.sh @@ -38,7 +38,6 @@ START_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project- STOP_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/stop_nomad.sh" UPDATE_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/update_nomad.sh" WAIT_FOR_IT_SCRIPT_URL="https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -COLLECT_DISK_INFO_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/collect_disk_info.sh" script_option_debug='true' accepted_terms='false' @@ -381,12 +380,6 @@ create_nomad_directory(){ sudo touch "${NOMAD_DIR}/storage/logs/admin.log" } -create_disk_info_file() { - # Disk info file MUST be created before the admin container starts. - # Otherwise, Docker will assume we meant to mount a directory and will create an empty directory at the mount point - echo '{}' > /tmp/nomad-disk-info.json -} - download_management_compose_file() { local compose_file_path="${NOMAD_DIR}/compose.yml" @@ -463,24 +456,6 @@ download_sidecar_files() { echo -e "${GREEN}#${RESET} Sidecar updater script downloaded successfully to $sidecar_script_path.\\n" } -download_and_start_collect_disk_info_script() { - local collect_disk_info_script_path="${NOMAD_DIR}/collect_disk_info.sh" - - echo -e "${YELLOW}#${RESET} Downloading collect_disk_info script...\\n" - if ! curl -fsSL "$COLLECT_DISK_INFO_SCRIPT_URL" -o "$collect_disk_info_script_path"; then - echo -e "${RED}#${RESET} Failed to download the collect_disk_info script. Please check the URL and try again." - exit 1 - fi - chmod +x "$collect_disk_info_script_path" - echo -e "${GREEN}#${RESET} collect_disk_info script downloaded successfully to $collect_disk_info_script_path.\\n" - - # Start script in background and store PID for easy removal on uninstall - echo -e "${YELLOW}#${RESET} Starting collect_disk_info script in the background...\\n" - nohup bash "$collect_disk_info_script_path" > /dev/null 2>&1 & - echo $! > "${NOMAD_DIR}/nomad-collect-disk-info.pid" - echo -e "${GREEN}#${RESET} collect_disk_info script started successfully.\\n" -} - download_helper_scripts() { local start_script_path="${NOMAD_DIR}/start_nomad.sh" local stop_script_path="${NOMAD_DIR}/stop_nomad.sh" @@ -609,7 +584,6 @@ download_wait_for_it_script download_entrypoint_script download_sidecar_files download_helper_scripts -download_and_start_collect_disk_info_script download_management_compose_file start_management_containers verify_gpu_setup diff --git a/install/management_compose.yaml b/install/management_compose.yaml index f3dc199..b1b17d1 100644 --- a/install/management_compose.yaml +++ b/install/management_compose.yaml @@ -11,7 +11,6 @@ services: - "8080:8080" volumes: - /opt/project-nomad/storage:/app/storage - - /tmp/nomad-disk-info.json:/app/storage/nomad-disk-info.json - /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon - ./entrypoint.sh:/usr/local/bin/entrypoint.sh - ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh @@ -95,6 +94,14 @@ services: - /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon - /opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml - nomad-update-shared:/shared # Shared volume for communication with admin container + disk-collector: + image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest + pull_policy: always + container_name: nomad_disk_collector + restart: unless-stopped + volumes: + - /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible + - /opt/project-nomad/storage:/storage volumes: nomad-update-shared: diff --git a/install/migrate-disk-collector.md b/install/migrate-disk-collector.md new file mode 100644 index 0000000..f4c9e2b --- /dev/null +++ b/install/migrate-disk-collector.md @@ -0,0 +1,40 @@ +# Project N.O.M.A.D. — About the Disk Collector Migration Script + +This script migrates your Project N.O.M.A.D. installation from the old host-based disk info collector to the new disk-collector sidecar. It modifies `/opt/project-nomad/compose.yml` to add the new service and remove the old bind mount, then restarts the full compose stack to apply the changes. + +### Why the Migration? +The new disk-collector sidecar provides a more robust and scalable way to collect disk information from the host. It removes the original bind mount to `/tmp/nomad-disk-info.json`, which was fragile and prone to issues on host reboots. + +The original host-based collector relied on a process running on the host that wrote disk info to a file, which was then read by the admin container via a bind mount. This approach had several drawbacks: +- The host process could fail or be killed, leading to stale or missing disk info. +- The bind mount to `/tmp/nomad-disk-info.json` was cleared on host reboots, causing Docker to create a directory at the mount point instead of a file. +- Necessitated a tighter coupling to the host, which would make more flexible future deployment options tougher to achieve. + +The migration script automates the necessary changes to your compose configuration and ensures a smooth transition to the new architecture. + +### Why does Nomad need the nomad-disk-info.json file? +Nomad uses the disk info stored and updated in `nomad-disk-info.json` to allow users to view disk usage and availability within the Nomad "Command Center". While not critical to the core functionality of Nomad, it provides a more pleasant experience for users with limited storage space and/or who aren't familiar with command-line tools and Linux management. + +### Why a separate container? +The disk-collector runs in a separate container to isolate its functionality from the main admin container. This separation provides several benefits: +- **Stability**: If the disk-collector encounters an issue or crashes, it won't affect the main admin container and vice versa. +- **Security**: The main admin container already has significant host access via the Docker socket, storage directory, and host.docker.internal. Additionally, Nomad may add more features in the future that support multi-user environments and/or more network exposure, so isolating the disk-collector reduces the exposure of the host filesystem (even if read-only) to just the one container, which has a very limited scope of functionality and access. +- **Modularity**: Because having the host disk info is not a critical component of Nomad's core functionality, isolating it in a sidecar allows users who don't need/want the disk info features to simply not run that container, without impacting the main admin container or other services. It also allows for more flexible future development of the disk-collector without needing to modify the main admin container. + +### What if I don't want to run the migration script? +No worries - you can replicate the changes manually by editing your `/opt/project-nomad/compose.yml` to add the new disk-collector service and remove the old bind mount from the admin service, then restarting your compose stack. The migration script just automates these steps and ensures they're done correctly, but the underlying changes are straightforward if you prefer to do it yourself. Just be sure to back up your `compose.yml` before making any changes. + +Here's the disk-collector service configuration to add to your `compose.yml`: + +```yml + disk-collector: + image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest + pull_policy: always + container_name: nomad_disk_collector + restart: unless-stopped + volumes: + - /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible + - /opt/project-nomad/storage:/storage +``` + +and remove the `- /tmp/nomad-disk-info.json:/app/storage/nomad-disk-info.json` bind mount from the admin service volumes. \ No newline at end of file diff --git a/install/migrate-disk-collector.sh b/install/migrate-disk-collector.sh new file mode 100755 index 0000000..dc04138 --- /dev/null +++ b/install/migrate-disk-collector.sh @@ -0,0 +1,248 @@ +#!/bin/bash + +# Project N.O.M.A.D. — Disk Collector Migration Script +# +# Script | Project N.O.M.A.D. Disk Collector Migration Script +# Version | 1.0.0 +# Author | Crosstalk Solutions, LLC +# Website | https://crosstalksolutions.com +# +# PURPOSE: +# One-time migration from the host-based disk info collector to the +# disk-collector Docker sidecar. The old approach used a nohup background +# process that wrote to /tmp/nomad-disk-info.json, which was bind-mounted +# into the admin container. This broke on host reboots because /tmp is +# cleared and Docker would create a directory at the mount point instead of a file. +# +# The new approach uses a disk-collector sidecar container that reads host +# disk info via the /:/host:ro,rslave bind-mount pattern (same pattern as Prometheus +# node-exporter, and no SYS_ADMIN or privileged capabilities required) and writes directly to +# /opt/project-nomad/storage/nomad-disk-info.json, which the admin container +# already reads via its existing storage bind-mount. Thus, no admin image update +# or new volume mounts required. + +############################################################################### +# Color Codes +############################################################################### + +RESET='\033[0m' +YELLOW='\033[1;33m' +RED='\033[1;31m' +GREEN='\033[1;32m' +WHITE_R='\033[39m' + +############################################################################### +# Constants +############################################################################### + +NOMAD_DIR="/opt/project-nomad" +COMPOSE_FILE="${NOMAD_DIR}/compose.yml" +COMPOSE_PROJECT_NAME="project-nomad" + +############################################################################### +# Pre-flight Checks +############################################################################### + +check_is_bash() { + if [[ -z "$BASH_VERSION" ]]; then + echo -e "${RED}#${RESET} This script must be run with bash." + echo -e "${RED}#${RESET} Example: bash $(basename "$0")" + exit 1 + fi + echo -e "${GREEN}#${RESET} Running in bash.\n" +} + +check_has_sudo() { + if sudo -n true 2>/dev/null; then + echo -e "${GREEN}#${RESET} Sudo permissions confirmed.\n" + else + echo -e "${RED}#${RESET} This script requires sudo permissions." + echo -e "${RED}#${RESET} Example: sudo bash $(basename "$0")" + exit 1 + fi +} + +check_confirmation() { + echo -e "${YELLOW}#${RESET} This script migrates your Project N.O.M.A.D. installation from the" + echo -e "${YELLOW}#${RESET} host-based disk info collector to the new disk-collector sidecar." + echo -e "${YELLOW}#${RESET} It will modify compose.yml and restart the full compose stack" + echo -e "${YELLOW}#${RESET} to drop the old /tmp bind mount and start the disk-collector sidecar." + echo -e "${YELLOW}#${RESET} Please ensure you have a backup of your data before proceeding." + read -rp "Do you want to continue? (y/N) " response + if [[ ! "$response" =~ ^[Yy]$ ]]; then + echo -e "${RED}#${RESET} Aborting. No changes have been made." + exit 0 + fi + echo -e "${GREEN}#${RESET} Confirmation received. Proceeding with migration...\n" +} + +check_docker_running() { + if ! command -v docker &>/dev/null; then + echo -e "${RED}#${RESET} Docker is not installed. Cannot proceed." + exit 1 + fi + if ! systemctl is-active --quiet docker; then + echo -e "${RED}#${RESET} Docker is not running. Please start Docker and try again." + exit 1 + fi + echo -e "${GREEN}#${RESET} Docker is running.\n" +} + +check_compose_file() { + if [[ ! -f "$COMPOSE_FILE" ]]; then + echo -e "${RED}#${RESET} compose.yml not found at ${COMPOSE_FILE}." + echo -e "${RED}#${RESET} Project N.O.M.A.D. does not appear to be installed or compose.yml is missing." + exit 1 + fi + echo -e "${GREEN}#${RESET} Found compose.yml at ${COMPOSE_FILE}.\n" +} + +# Step 1: Stop old host process +stop_old_host_process() { + local pid_file="${NOMAD_DIR}/nomad-collect-disk-info.pid" + + if [[ -f "$pid_file" ]]; then + echo -e "${YELLOW}#${RESET} Stopping old collect-disk-info background process..." + local pid + pid=$(cat "$pid_file") + if kill "$pid" 2>/dev/null; then + echo -e "${GREEN}#${RESET} Process ${pid} stopped.\n" + else + echo -e "${YELLOW}#${RESET} Process ${pid} was not running (already stopped).\n" + fi + rm -f "$pid_file" + else + echo -e "${GREEN}#${RESET} No old collect-disk-info PID file found — nothing to stop.\n" + fi +} + +# Step 2: Backup compose.yml +backup_compose_file() { + local backup="${COMPOSE_FILE}.bak.$(date +%Y%m%d%H%M%S)" + echo -e "${YELLOW}#${RESET} Backing up compose.yml to ${backup}..." + if cp "$COMPOSE_FILE" "$backup"; then + echo -e "${GREEN}#${RESET} Backup created at ${backup}.\n" + else + echo -e "${RED}#${RESET} Failed to create backup. Aborting." + exit 1 + fi +} + +# Step 3: Remove old bind-mount from admin volumes +remove_old_bind_mount() { + if ! grep -q 'nomad-disk-info\.json' "$COMPOSE_FILE"; then + echo -e "${GREEN}#${RESET} Old /tmp/nomad-disk-info.json bind-mount not found — already removed.\n" + return 0 + fi + + echo -e "${YELLOW}#${RESET} Removing old /tmp/nomad-disk-info.json bind-mount from admin volumes..." + sed -i '/\/tmp\/nomad-disk-info\.json:\/app\/storage\/nomad-disk-info\.json/d' "$COMPOSE_FILE" + + if grep -q 'nomad-disk-info\.json' "$COMPOSE_FILE"; then + echo -e "${RED}#${RESET} Failed to remove old bind-mount from compose.yml. Please remove it manually:" + echo -e "${WHITE_R} - /tmp/nomad-disk-info.json:/app/storage/nomad-disk-info.json${RESET}" + exit 1 + fi + + echo -e "${GREEN}#${RESET} Old bind-mount removed.\n" +} + +# Step 4: Add disk-collector service block +add_disk_collector_service() { + if grep -q 'disk-collector:' "$COMPOSE_FILE"; then + echo -e "${GREEN}#${RESET} disk-collector service already present in compose.yml — skipping.\n" + return 0 + fi + + echo -e "${YELLOW}#${RESET} Adding disk-collector service to compose.yml..." + + # Insert the disk-collector service block before the top-level `volumes:` key + awk '/^volumes:/{ + print " disk-collector:" + print " image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest" + print " pull_policy: always" + print " container_name: nomad_disk_collector" + print " restart: unless-stopped" + print " volumes:" + print " - /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible" + print " - /opt/project-nomad/storage:/storage # Shared storage dir — disk info written here is read by the admin container" + print "" + } + {print}' "$COMPOSE_FILE" > "${COMPOSE_FILE}.tmp" && mv "${COMPOSE_FILE}.tmp" "$COMPOSE_FILE" + + if ! grep -q 'disk-collector:' "$COMPOSE_FILE"; then + echo -e "${RED}#${RESET} Failed to add disk-collector service. Please add it manually before the top-level volumes: key." + exit 1 + fi + + echo -e "${GREEN}#${RESET} disk-collector service added.\n" +} + +# Step 5 — Pull new image and restart the full stack +# This will re-create the admin container and drop the old /tmp bind, and +# also starts the new disk-collector sidecar we just added to compose.yml +restart_stack() { + echo -e "${YELLOW}#${RESET} Pulling latest images (including disk-collector)..." + if ! docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" pull; then + echo -e "${RED}#${RESET} Failed to pull images. Check your network connection." + exit 1 + fi + echo -e "${GREEN}#${RESET} Images pulled.\n" + + echo -e "${YELLOW}#${RESET} Restarting stack..." + if ! docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" up -d; then + echo -e "${RED}#${RESET} Failed to bring the stack up." + exit 1 + fi + echo -e "${GREEN}#${RESET} Stack restarted.\n" +} + +# Step 6: Verify +verify_disk_collector_running() { + sleep 3 + if docker ps --filter "name=^nomad_disk_collector$" --filter "status=running" --format '{{.Names}}' | grep -qx "nomad_disk_collector"; then + echo -e "${GREEN}#${RESET} disk-collector container is running.\n" + else + echo -e "${RED}#${RESET} disk-collector container does not appear to be running." + echo -e "${RED}#${RESET} Check its logs with: docker logs nomad_disk_collector" + exit 1 + fi +} + +# Main +echo -e "${GREEN}#########################################################################${RESET}" +echo -e "${GREEN}#${RESET} Project N.O.M.A.D. — Disk Collector Migration Script ${GREEN}#${RESET}" +echo -e "${GREEN}#########################################################################${RESET}\n" + +check_is_bash +check_has_sudo +check_confirmation +check_docker_running +check_compose_file + +echo -e "${YELLOW}#${RESET} Step 1: Stopping old host process...\n" +stop_old_host_process + +echo -e "${YELLOW}#${RESET} Step 2: Backing up compose.yml...\n" +backup_compose_file + +echo -e "${YELLOW}#${RESET} Step 3: Removing old bind-mount...\n" +remove_old_bind_mount + +echo -e "${YELLOW}#${RESET} Step 4: Adding disk-collector service...\n" +add_disk_collector_service + +echo -e "${YELLOW}#${RESET} Step 5: Pulling images and restarting stack...\n" +restart_stack + +echo -e "${YELLOW}#${RESET} Step 6: Verifying disk-collector is running...\n" +verify_disk_collector_running + +echo -e "${GREEN}#########################################################################${RESET}" +echo -e "${GREEN}#${RESET} Migration completed successfully!" +echo -e "${GREEN}#${RESET}" +echo -e "${GREEN}#${RESET} The disk-collector sidecar is now running and will update disk info" +echo -e "${GREEN}#${RESET} every 2 minutes. The /api/system/info endpoint will return disk data" +echo -e "${GREEN}#${RESET} after the first collector write (~5 seconds after startup)." +echo -e "${GREEN}#${RESET}" +echo -e "${GREEN}#########################################################################${RESET}\n" diff --git a/install/uninstall_nomad.sh b/install/uninstall_nomad.sh index dcadf48..1ddf106 100644 --- a/install/uninstall_nomad.sh +++ b/install/uninstall_nomad.sh @@ -17,8 +17,6 @@ NOMAD_DIR="/opt/project-nomad" MANAGEMENT_COMPOSE_FILE="${NOMAD_DIR}/compose.yml" -COLLECT_DISK_INFO_PID="/var/run/nomad-collect-disk-info.pid" -DISK_INFO_FILE="/tmp/nomad-disk-info.json" ################################################################################################################################################################################################### # # @@ -77,24 +75,6 @@ ensure_docker_installed() { fi } -try_remove_disk_info_script() { - echo "Checking for running collect-disk-info script..." - if [ -f "$COLLECT_DISK_INFO_PID" ]; then - echo "Stopping collect-disk-info script..." - kill "$(cat "$COLLECT_DISK_INFO_PID")" - rm -f "$COLLECT_DISK_INFO_PID" - echo "collect-disk-info script stopped." - fi -} - -try_remove_disk_info_file() { - if [ -f "$DISK_INFO_FILE" ]; then - echo "Removing disk info file..." - rm -f "$DISK_INFO_FILE" - echo "Disk info file removed." - fi -} - storage_cleanup() { read -p "Do you want to delete the Project N.O.M.A.D. storage directory (${NOMAD_DIR})? This is best if you want to start a completely fresh install. This will PERMANENTLY DELETE all stored Nomad data and can't be undone! (y/N): " delete_dir_choice case "$delete_dir_choice" in @@ -135,12 +115,6 @@ uninstall_nomad() { echo "Removing project-nomad_nomad-update-shared volume if it exists..." docker volume rm project-nomad_nomad-update-shared 2>/dev/null && echo "Volume removed." || echo "Volume already removed or not found." - # Try to stop the collect-disk-info script if it's running - try_remove_disk_info_script - - # Try to remove the disk info file if it exists - try_remove_disk_info_file - # Prompt user for storage cleanup and handle it if so storage_cleanup From fb05ab53e2c48d01632f3722b05bfd9f00de21ea Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Sun, 15 Mar 2026 00:51:32 +0000 Subject: [PATCH 03/55] build: fix collect-disk-info output --- .../collect-disk-info.sh | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/install/sidecar-disk-collector/collect-disk-info.sh b/install/sidecar-disk-collector/collect-disk-info.sh index 0fbf805..09927d5 100755 --- a/install/sidecar-disk-collector/collect-disk-info.sh +++ b/install/sidecar-disk-collector/collect-disk-info.sh @@ -12,16 +12,27 @@ log() { log "disk-collector sidecar starting..." +# Write a valid placeholder immediately so admin has something to parse if the +# file is missing (first install, user deleted it, etc.). The real data from the +# first full collection cycle below will overwrite this within seconds. +if [[ ! -f /storage/nomad-disk-info.json ]]; then + echo '{"diskLayout":{"blockdevices":[]},"fsSize":[]}' > /storage/nomad-disk-info.json + log "Created initial placeholder — will be replaced after first collection." +fi + while true; do - # Get disk layout - DISK_LAYOUT=$(lsblk --sysroot /host --json -o NAME,SIZE,TYPE,MODEL,SERIAL,VENDOR,ROTA,TRAN 2>/dev/null) + # Get disk layout (-b outputs SIZE in bytes as a number rather than a human-readable string) + DISK_LAYOUT=$(lsblk --sysroot /host --json -b -o NAME,SIZE,TYPE,MODEL,SERIAL,VENDOR,ROTA,TRAN 2>/dev/null) if [[ -z "$DISK_LAYOUT" ]]; then log "WARNING: lsblk --sysroot /host failed, using empty block devices" DISK_LAYOUT='{"blockdevices":[]}' fi - # Get filesystem usage by parsing /host/proc/mounts and running df on each mountpoint + # Get filesystem usage by parsing /host/proc/1/mounts (PID 1 = host init = root mount namespace) + # /host/proc/mounts is a symlink to /proc/self/mounts, which always reflects the CURRENT + # process's mount namespace (the container's), not the host's. /proc/1/mounts reflects the + # host init process's namespace, giving us the true host mount table. FS_JSON="[" FIRST=1 while IFS=' ' read -r dev mountpoint fstype opts _rest; do @@ -38,7 +49,7 @@ while true; do [[ "$FIRST" -eq 0 ]] && FS_JSON+="," FS_JSON+="{\"fs\":\"${dev}\",\"size\":${size},\"used\":${used},\"available\":${avail},\"use\":${pct},\"mount\":\"${mountpoint}\"}" FIRST=0 - done < /host/proc/mounts + done < /host/proc/1/mounts FS_JSON+="]" # Use a tmp file for atomic update From 8bb8b414f8c70419533aa1ca0cc50270c27edf35 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Sun, 15 Mar 2026 03:19:52 +0000 Subject: [PATCH 04/55] chore: add additional warnings to migrate-disk-collector --- install/migrate-disk-collector.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/install/migrate-disk-collector.sh b/install/migrate-disk-collector.sh index dc04138..a4c5c6f 100755 --- a/install/migrate-disk-collector.sh +++ b/install/migrate-disk-collector.sh @@ -67,7 +67,9 @@ check_confirmation() { echo -e "${YELLOW}#${RESET} host-based disk info collector to the new disk-collector sidecar." echo -e "${YELLOW}#${RESET} It will modify compose.yml and restart the full compose stack" echo -e "${YELLOW}#${RESET} to drop the old /tmp bind mount and start the disk-collector sidecar." - echo -e "${YELLOW}#${RESET} Please ensure you have a backup of your data before proceeding." + echo -e "${YELLOW}#${RESET} Please ensure you have a backup of your data before proceeding.\n" + + echo -e "${RED}#${RESET} STOP: If you have customized your compose.yml or Nomad's storage setup (not common), please make these changes manually instead of using this script!\n" read -rp "Do you want to continue? (y/N) " response if [[ ! "$response" =~ ^[Yy]$ ]]; then echo -e "${RED}#${RESET} Aborting. No changes have been made." From b40d8190af1e0f534597311bae061b01d21c1332 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Thu, 19 Mar 2026 23:08:13 +0000 Subject: [PATCH 05/55] ci: add sidecar-updater build action --- .github/workflows/build-sidecar-updater.yml | 51 +++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/build-sidecar-updater.yml diff --git a/.github/workflows/build-sidecar-updater.yml b/.github/workflows/build-sidecar-updater.yml new file mode 100644 index 0000000..822bc28 --- /dev/null +++ b/.github/workflows/build-sidecar-updater.yml @@ -0,0 +1,51 @@ +name: Build Sidecar Updater Image + +on: + workflow_dispatch: + inputs: + version: + description: 'Semantic version to label the Docker image under (no "v" prefix, e.g. "1.2.3")' + required: true + type: string + tag_latest: + description: 'Also tag this image as :latest?' + required: false + type: boolean + default: false + +jobs: + check_authorization: + name: Check authorization to publish new Docker image + runs-on: ubuntu-latest + outputs: + isAuthorized: ${{ steps.check-auth.outputs.is_authorized }} + steps: + - name: check-auth + id: check-auth + run: echo "is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}" >> $GITHUB_OUTPUT + build: + name: Build sidecar-updater image + needs: check_authorization + if: needs.check_authorization.outputs.isAuthorized == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: install/sidecar-updater + push: true + tags: | + ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:${{ inputs.version }} + ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:v${{ inputs.version }} + ${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest' || '' }} From ed0b0f76ec62e34c13eb10d9753ee3e0392f7bb1 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Thu, 19 Mar 2026 23:15:24 +0000 Subject: [PATCH 06/55] docs: update feature request and issues config --- .github/ISSUE_TEMPLATE/config.yml | 3 +++ .github/ISSUE_TEMPLATE/feature_request.yml | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c0a662d..ec28b8d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -12,3 +12,6 @@ contact_links: - name: 🤝 Contributing Guide url: https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CONTRIBUTING.md about: Learn how to contribute to Project N.O.M.A.D. + - name: 📅 Roadmap + url: https://roadmap.projectnomad.us + about: See our public roadmap, vote on features, and suggest new ones \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 9a86a68..4fd8296 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -6,13 +6,17 @@ body: - type: markdown attributes: value: | - Thanks for your interest in improving Project N.O.M.A.D.! + Thanks for your interest in improving Project N.O.M.A.D.! Before you submit a feature request, consider checking our [roadmap](https://roadmap.projectnomad.us) to see if it's already planned or in progress. You're welcome to suggest new ideas there if you don't plan on opening PRs yourself. + **Please note:** Feature requests are not guaranteed to be implemented. All requests are evaluated based on alignment with the project's goals, feasibility, and community demand. **Before submitting:** - - Search existing feature requests to avoid duplicates + - Search existing feature requests and our [roadmap](https://roadmap.projectnomad.us) to avoid duplicates - Consider if this aligns with N.O.M.A.D.'s mission: offline-first knowledge and education + - Consider the technical feasibility of the feature: N.O.M.A.D. is designed to be containerized and run on a wide range of hardware, so features that require heavy resources (aside from GPU-intensive tasks) or complex host configurations may be less likely to be implemented + - Consider the scope of the feature: Small, focused enhancements that can be implemented incrementally are more likely to be implemented than large, broad features that would require significant development effort or have an unclear path forward + - If you're able to contribute code, testing, or documentation, that significantly increases the chances of your feature being implemented - type: dropdown id: feature-category From b1edef27e8e4c6efad139a2492707cfb443d85b0 Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Mon, 16 Mar 2026 09:17:05 -0700 Subject: [PATCH 07/55] feat(UI): add Night Ops dark mode with theme toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a warm charcoal dark mode ("Night Ops") using CSS variable swapping under [data-theme="dark"]. All 23 desert palette variables are overridden with dark-mode counterparts, and ~313 generic Tailwind classes (bg-white, text-gray-*, border-gray-*) are replaced with semantic tokens. Infrastructure: - CSS variable overrides in app.css for both themes - ThemeProvider + useTheme hook (localStorage + KV store sync) - ThemeToggle component (moon/sun icons, "Night Ops"/"Day Ops" labels) - FOUC prevention script in inertia_layout.edge - Toggle placed in StyledSidebar and Footer for access on every page Color replacements across 50 files: - bg-white → bg-surface-primary - bg-gray-50/100 → bg-surface-secondary - text-gray-900/800 → text-text-primary - text-gray-600/500 → text-text-secondary/text-text-muted - border-gray-200/300 → border-border-subtle/border-border-default - text-desert-white → text-white (fixes invisible text on colored bg) - Button hover/active states use dedicated btn-green-hover/active vars Co-Authored-By: Claude Opus 4.6 --- admin/constants/kv_store.ts | 2 +- admin/inertia/app/app.tsx | 19 +-- admin/inertia/components/ActiveDownloads.tsx | 2 +- admin/inertia/components/ActiveEmbedJobs.tsx | 2 +- .../components/ActiveModelDownloads.tsx | 2 +- admin/inertia/components/Alert.tsx | 18 +-- admin/inertia/components/BouncingDots.tsx | 8 +- admin/inertia/components/DownloadURLModal.tsx | 6 +- admin/inertia/components/Footer.tsx | 8 +- .../inertia/components/HorizontalBarChart.tsx | 2 +- .../components/InstallActivityFeed.tsx | 14 +- admin/inertia/components/LoadingSpinner.tsx | 4 +- admin/inertia/components/ProgressBar.tsx | 4 +- .../components/StorageProjectionBar.tsx | 2 +- admin/inertia/components/StyledButton.tsx | 18 +-- admin/inertia/components/StyledModal.tsx | 6 +- admin/inertia/components/StyledSidebar.tsx | 8 +- admin/inertia/components/StyledTable.tsx | 18 +-- admin/inertia/components/ThemeToggle.tsx | 24 ++++ .../inertia/components/TierSelectionModal.tsx | 34 ++--- .../inertia/components/UpdateServiceModal.tsx | 18 +-- .../inertia/components/WikipediaSelector.tsx | 16 +-- .../inertia/components/chat/ChatInterface.tsx | 24 ++-- .../components/chat/ChatMessageBubble.tsx | 12 +- admin/inertia/components/chat/ChatModal.tsx | 2 +- admin/inertia/components/chat/ChatSidebar.tsx | 12 +- .../components/chat/KnowledgeBaseModal.tsx | 20 +-- admin/inertia/components/chat/index.tsx | 18 +-- admin/inertia/components/inputs/Input.tsx | 6 +- admin/inertia/components/inputs/Switch.tsx | 6 +- .../components/layout/BackToHomeHeader.tsx | 4 +- admin/inertia/components/markdoc/Table.tsx | 6 +- .../components/systeminfo/InfoCard.tsx | 4 +- admin/inertia/css/app.css | 92 ++++++++++++- admin/inertia/hooks/useTheme.ts | 47 +++++++ admin/inertia/layouts/AppLayout.tsx | 2 +- admin/inertia/layouts/SettingsLayout.tsx | 2 +- admin/inertia/pages/easy-setup/complete.tsx | 2 +- admin/inertia/pages/easy-setup/index.tsx | 128 +++++++++--------- admin/inertia/pages/home.tsx | 2 +- admin/inertia/pages/maps.tsx | 4 +- admin/inertia/pages/settings/apps.tsx | 16 +-- admin/inertia/pages/settings/legal.tsx | 34 ++--- admin/inertia/pages/settings/maps.tsx | 8 +- admin/inertia/pages/settings/models.tsx | 40 +++--- admin/inertia/pages/settings/system.tsx | 4 +- admin/inertia/pages/settings/update.tsx | 12 +- admin/inertia/pages/settings/zim/index.tsx | 6 +- .../pages/settings/zim/remote-explorer.tsx | 20 +-- .../providers/NotificationProvider.tsx | 2 +- admin/inertia/providers/ThemeProvider.tsx | 27 ++++ admin/resources/views/inertia_layout.edge | 11 ++ admin/types/kv_store.ts | 1 + 53 files changed, 503 insertions(+), 306 deletions(-) create mode 100644 admin/inertia/components/ThemeToggle.tsx create mode 100644 admin/inertia/hooks/useTheme.ts create mode 100644 admin/inertia/providers/ThemeProvider.tsx diff --git a/admin/constants/kv_store.ts b/admin/constants/kv_store.ts index 7cae751..69872ff 100644 --- a/admin/constants/kv_store.ts +++ b/admin/constants/kv_store.ts @@ -1,3 +1,3 @@ import { KVStoreKey } from "../types/kv_store.js"; -export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'system.earlyAccess', 'ai.assistantCustomName']; \ No newline at end of file +export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName']; \ No newline at end of file diff --git a/admin/inertia/app/app.tsx b/admin/inertia/app/app.tsx index 2eabe10..6026347 100644 --- a/admin/inertia/app/app.tsx +++ b/admin/inertia/app/app.tsx @@ -11,6 +11,7 @@ import { generateUUID } from '~/lib/util' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import NotificationsProvider from '~/providers/NotificationProvider' +import { ThemeProvider } from '~/providers/ThemeProvider' import { UsePageProps } from '../../types/system' const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.' @@ -38,14 +39,16 @@ createInertiaApp({ const showDevtools = ['development', 'staging'].includes(environment) createRoot(el).render( - - - - - {showDevtools && } - - - + + + + + + {showDevtools && } + + + + ) }, diff --git a/admin/inertia/components/ActiveDownloads.tsx b/admin/inertia/components/ActiveDownloads.tsx index 5eb30f4..1319aaa 100644 --- a/admin/inertia/components/ActiveDownloads.tsx +++ b/admin/inertia/components/ActiveDownloads.tsx @@ -32,7 +32,7 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) )) ) : ( -

No active downloads

+

No active downloads

)} diff --git a/admin/inertia/components/ActiveEmbedJobs.tsx b/admin/inertia/components/ActiveEmbedJobs.tsx index 5e6914e..9da78bc 100644 --- a/admin/inertia/components/ActiveEmbedJobs.tsx +++ b/admin/inertia/components/ActiveEmbedJobs.tsx @@ -35,7 +35,7 @@ const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => { )) ) : ( -

No files are currently being processed

+

No files are currently being processed

)} diff --git a/admin/inertia/components/ActiveModelDownloads.tsx b/admin/inertia/components/ActiveModelDownloads.tsx index 1727fe5..d1d0b85 100644 --- a/admin/inertia/components/ActiveModelDownloads.tsx +++ b/admin/inertia/components/ActiveModelDownloads.tsx @@ -33,7 +33,7 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps) )) ) : ( -

No active model downloads

+

No active model downloads

)} diff --git a/admin/inertia/components/Alert.tsx b/admin/inertia/components/Alert.tsx index ceff2a0..40fca57 100644 --- a/admin/inertia/components/Alert.tsx +++ b/admin/inertia/components/Alert.tsx @@ -43,7 +43,7 @@ export default function Alert({ } const getIconColor = () => { - if (variant === 'solid') return 'text-desert-white' + if (variant === 'solid') return 'text-white' switch (type) { case 'warning': return 'text-desert-orange' @@ -81,15 +81,15 @@ export default function Alert({ case 'solid': variantStyles.push( type === 'warning' - ? 'bg-desert-orange text-desert-white border border-desert-orange-dark' + ? 'bg-desert-orange text-white border border-desert-orange-dark' : type === 'error' - ? 'bg-desert-red text-desert-white border border-desert-red-dark' + ? 'bg-desert-red text-white border border-desert-red-dark' : type === 'success' - ? 'bg-desert-olive text-desert-white border border-desert-olive-dark' + ? 'bg-desert-olive text-white border border-desert-olive-dark' : type === 'info' - ? 'bg-desert-green text-desert-white border border-desert-green-dark' + ? 'bg-desert-green text-white border border-desert-green-dark' : type === 'info-inverted' - ? 'bg-desert-tan text-desert-white border border-desert-tan-dark' + ? 'bg-desert-tan text-white border border-desert-tan-dark' : '' ) return classNames(baseStyles, 'shadow-lg', ...variantStyles) @@ -112,7 +112,7 @@ export default function Alert({ } const getTitleColor = () => { - if (variant === 'solid') return 'text-desert-white' + if (variant === 'solid') return 'text-white' switch (type) { case 'warning': @@ -131,7 +131,7 @@ export default function Alert({ } const getMessageColor = () => { - if (variant === 'solid') return 'text-desert-white text-opacity-90' + if (variant === 'solid') return 'text-white text-opacity-90' switch (type) { case 'warning': @@ -149,7 +149,7 @@ export default function Alert({ const getCloseButtonStyles = () => { if (variant === 'solid') { - return 'text-desert-white hover:text-desert-white hover:bg-black hover:bg-opacity-20' + return 'text-white hover:text-white hover:bg-black hover:bg-opacity-20' } switch (type) { diff --git a/admin/inertia/components/BouncingDots.tsx b/admin/inertia/components/BouncingDots.tsx index e01c3cc..64027f0 100644 --- a/admin/inertia/components/BouncingDots.tsx +++ b/admin/inertia/components/BouncingDots.tsx @@ -9,18 +9,18 @@ interface BouncingDotsProps { export default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) { return (
- {text} + {text} diff --git a/admin/inertia/components/DownloadURLModal.tsx b/admin/inertia/components/DownloadURLModal.tsx index 6209578..7298da8 100644 --- a/admin/inertia/components/DownloadURLModal.tsx +++ b/admin/inertia/components/DownloadURLModal.tsx @@ -63,7 +63,7 @@ const DownloadURLModal: React.FC = ({ large >
-

+

Enter the URL of the map region file you wish to download. The URL must be publicly reachable and end with .pmtiles. A preflight check will be run to verify the file's availability, type, and approximate size. @@ -76,11 +76,11 @@ const DownloadURLModal: React.FC = ({ value={url} onChange={(e) => setUrl(e.target.value)} /> -

+
{messages.map((message, idx) => (

{message}

diff --git a/admin/inertia/components/Footer.tsx b/admin/inertia/components/Footer.tsx index 78765f7..8c991db 100644 --- a/admin/inertia/components/Footer.tsx +++ b/admin/inertia/components/Footer.tsx @@ -1,14 +1,16 @@ import { usePage } from '@inertiajs/react' import { UsePageProps } from '../../types/system' +import ThemeToggle from '~/components/ThemeToggle' export default function Footer() { const { appVersion } = usePage().props as unknown as UsePageProps return ( -