#!/usr/bin/env bash # Project NOMAD Offline Artifact Bundle Builder — Docker entry point ################################################################################################################################################################################################### # Script | Project NOMAD Offline Bundle Builder (Docker entry point) # Version | 1.1.0 # Author | Crosstalk Solutions, LLC # Website | https://crosstalksolutions.com ################################################################################################################################################################################################### # # Runs build_offline_bundle.sh inside a container so the build environment is # identical everywhere and the build machine needs nothing but Docker. # # ./install/build_offline_bundle_docker.sh --target ubuntu:26.04 # # With no --output the script asks where to put the bundle, offering the current # location and any connected removable drives. Use --no-prompt for unattended # builds. All other options are passed straight through to # build_offline_bundle.sh. # # Requirements on the build machine: Docker (running, with internet access). # No git, bash 4, coreutils or other host tooling is needed — the container # supplies all of it. # ################################################################################################################################################################################################### set -Eeuo pipefail RESET='\033[0m' YELLOW='\033[1;33m' RED='\033[1;31m' GREEN='\033[1;32m' # Alpine image carrying the Docker CLI and Compose v2. Override for a mirror. BUILDER_IMAGE="${NOMAD_BUILDER_IMAGE:-docker:cli}" DOCKER_SOCKET="${NOMAD_DOCKER_SOCKET:-/var/run/docker.sock}" case "$(uname -s 2>/dev/null || echo unknown)" in MINGW*|MSYS*|CYGWIN*) HOST_IS_WINDOWS='1' ;; *) HOST_IS_WINDOWS='0' ;; esac NO_PROMPT="${NOMAD_NO_PROMPT:-0}" # A finished bundle is roughly 1 GB; the build needs headroom on top of that. REQUIRED_SPACE_KB=3145728 log() { echo -e "${YELLOW}#${RESET} $*"; } die() { echo -e "${RED}#${RESET} $*" >&2 exit 1 } usage() { cat <<'EOF' Project NOMAD Offline Bundle Builder — Docker entry point Usage: ./install/build_offline_bundle_docker.sh [options] Runs the bundle build inside a container. Docker is the only requirement on this machine. Options handled here: --output DIR Where to write the bundle. If omitted you are asked, with the current location and any connected removable drives offered as choices. --no-prompt Never ask; use the default location. Implied when this is not an interactive terminal (CI, pipes). -h, --help Show this help text and exit. Environment: NOMAD_NO_PROMPT=1 Same as --no-prompt NOMAD_BUILDER_IMAGE Build container image (default: docker:cli) NOMAD_DOCKER_SOCKET Docker socket (default: /var/run/docker.sock) Every other option is passed through to install/build_offline_bundle.sh: --target OS:VERSION Target operating system (default: ubuntu:26.04) --arch ARCH Target architecture (default: amd64) --repo PATH Project NOMAD source checkout --without-nvidia-toolkit Omit the NVIDIA Container Toolkit packages --extra-image-list FILE Also pull and bundle these image references --extra-image-archive FILE Include an existing docker-save archive --content-dir DIR Include pre-staged NOMAD storage content --archive Also produce a .tar.gz of the bundle EOF } check_docker_available() { command -v docker > /dev/null 2>&1 || die "docker was not found. Docker is the only requirement for building a bundle." docker info > /dev/null 2>&1 || die "The Docker daemon is not reachable. Start Docker and try again." # Docker Desktop on Windows has no Unix socket to stat — it is reached over a # named pipe, and the daemon resolves the literal string /var/run/docker.sock # against its own Linux VM, where the socket does exist. So the filesystem test # only means something where a socket file is what gets mounted. if [[ "${HOST_IS_WINDOWS}" != '1' ]]; then [[ -S "${DOCKER_SOCKET}" ]] || die "No Docker socket at ${DOCKER_SOCKET}. Set NOMAD_DOCKER_SOCKET if yours lives elsewhere." fi } # Whether the daemon can actually see a host directory through a bind mount. # # Not a formality. Docker Desktop silently yields an EMPTY directory for a path # it does not share — removable and exFAT volumes among them — so a build whose # source or output lives there produces a hollow bundle that still passes its own # verification, because every check reads the same phantom directory. Detect it # up front by planting a marker and looking for it from inside a container. docker_can_mount() { local dir="$1" marker=".nomad-mount-probe.$$" found='' : > "${dir}/${marker}" 2>/dev/null || return 1 found="$(MSYS_NO_PATHCONV=1 docker run --rm \ -v "$(host_mount_source "${dir}"):/probe:ro" \ "${BUILDER_IMAGE}" \ sh -c "[ -f '/probe/${marker}' ] && echo yes" 2>/dev/null || true)" rm -f "${dir}/${marker}" [[ "${found}" == 'yes' ]] } # The string the DAEMON needs in order to resolve a host directory. Under MSYS, # a POSIX path like /d/foo means nothing to Docker Desktop, so hand it the # Windows form; everywhere else the path is already what the daemon expects. host_mount_source() { local dir="$1" if [[ "${HOST_IS_WINDOWS}" == '1' ]]; then cygpath -m "${dir}" 2>/dev/null || echo "${dir}" else echo "${dir}" fi } # Where a host directory must live INSIDE the build container. # # The build starts further containers, and their bind mounts are resolved by the # host daemon — so a path has to mean the same thing in both places. On Linux # that is free: mount the host path at itself. On Windows it is not, because the # daemon speaks Windows paths and a Linux container cannot have a directory # called C:/Users. Docker Desktop bridges this by exposing host drives inside its # VM at /run/desktop/mnt/host/, and the daemon resolves that form too — so # mounting there makes the path identical from both sides. # # Without this the resolver container writes its .debs into a phantom directory # and the build fails with "no package index was produced", or worse, quietly # produces a bundle with nothing in it. daemon_identity_path() { local dir="$1" win drive rest if [[ "${HOST_IS_WINDOWS}" != '1' ]]; then echo "${dir}" return fi win="$(cygpath -m "${dir}" 2>/dev/null)" || { echo "${dir}"; return; } drive="$(printf '%s' "${win:0:1}" | tr '[:upper:]' '[:lower:]')" rest="${win:2}" echo "/run/desktop/mnt/host/${drive}${rest}" } SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" ################################################################################################################################################################################################### # # # Output Location # # # ################################################################################################################################################################################################### # Free space on the nearest existing ancestor of a not-yet-created directory. space_of() { local path="$1" unit="$2" while [[ ! -d "${path}" && "${path}" != '/' ]]; do path="$(dirname -- "${path}")" done if [[ "${unit}" == 'kb' ]]; then df -Pk "${path}" 2>/dev/null | awk 'NR == 2 { print $4 }' else df -h "${path}" 2>/dev/null | awk 'NR == 2 { print $4 }' fi } is_mount_point() { local path="$1" parent parent="$(dirname -- "${path}")" [[ "$(df -P "${path}" 2>/dev/null | awk 'NR == 2 { print $1 }')" \ != "$(df -P "${parent}" 2>/dev/null | awk 'NR == 2 { print $1 }')" ]] } # Removable/external volumes, so a bundle can be written straight to the USB # drive it will be carried on. Platform-specific because there is no portable # way to enumerate mounted media. detect_removable_mounts() { local volume base case "$(uname -s)" in Darwin) for volume in /Volumes/*; do # The boot volume appears here as a symlink to /. [[ -d "${volume}" && ! -L "${volume}" && -w "${volume}" ]] || continue echo "${volume}" done ;; Linux) for base in "/media/$(id -un)" /media "/run/media/$(id -un)" /mnt; do [[ -d "${base}" ]] || continue for volume in "${base}"/*; do [[ -d "${volume}" && -w "${volume}" ]] || continue is_mount_point "${volume}" || continue echo "${volume}" done done ;; esac } # Which volume, if any, the checkout itself lives on — used to point out that a # choice keeps everything together on one drive. containing_volume() { local path="$1" volume while IFS= read -r volume; do [[ -n "${volume}" ]] || continue if [[ "${path}" == "${volume}" || "${path}" == "${volume}"/* ]]; then echo "${volume}" return 0 fi done < <(detect_removable_mounts) return 1 } # Populates OUTPUT_CANDIDATES / OUTPUT_LABELS: where the build is running from # first, then any connected removable drives, then the home directory. build_output_candidates() { local default_dir="${PWD}/dist" local repo_volume='' volume label OUTPUT_CANDIDATES=("${default_dir}") OUTPUT_LABELS=('here (current directory)') repo_volume="$(containing_volume "${REPO_ROOT}" || true)" while IFS= read -r volume; do [[ -n "${volume}" ]] || continue # Already covered by the "here" option. [[ "${volume}/nomad-bundles" != "${default_dir}" ]] || continue label="removable drive: $(basename -- "${volume}")" [[ "${volume}" != "${repo_volume}" ]] || label="${label} — same drive as this checkout" OUTPUT_CANDIDATES+=("${volume}/nomad-bundles") OUTPUT_LABELS+=("${label}") done < <(detect_removable_mounts | sort -u) OUTPUT_CANDIDATES+=("${HOME}/nomad-bundles") OUTPUT_LABELS+=('home directory') } # Renders the menu and reads a choice. Assumes stdin is worth reading; the # interactivity decision belongs to choose_output_dir. prompt_for_output_dir() { build_output_candidates echo '' echo -e "${GREEN}#${RESET} Where should the bundle be written? (about 1 GB)" echo '' local index=1 free while [[ "${index}" -le "${#OUTPUT_CANDIDATES[@]}" ]]; do free="$(space_of "${OUTPUT_CANDIDATES[$((index - 1))]}" 'human')" printf ' %d) %s\n' "${index}" "${OUTPUT_CANDIDATES[$((index - 1))]}" printf ' %s%s\n' "${OUTPUT_LABELS[$((index - 1))]}" "${free:+ — ${free} free}" [[ "${index}" -ne 1 ]] || printf ' [default]\n' index=$((index + 1)) done echo ' c) enter a custom path' echo '' local reply chosen='' while [[ -z "${chosen}" ]]; do read -r -p "Choice [1]: " reply || reply='' reply="${reply:-1}" case "${reply}" in c|C) read -r -p 'Path: ' reply || reply='' [[ -n "${reply}" ]] || continue chosen="${reply}" ;; *[!0-9]*) echo 'Please enter one of the numbers above, or c for a custom path.' ;; *) if [[ "${reply}" -ge 1 && "${reply}" -le "${#OUTPUT_CANDIDATES[@]}" ]]; then chosen="${OUTPUT_CANDIDATES[$((reply - 1))]}" else echo 'Please enter one of the numbers above, or c for a custom path.' fi ;; esac done OUTPUT_CHOICE="${chosen}" } choose_output_dir() { # Non-interactive: never block a script or CI run waiting on stdin. if [[ "${NO_PROMPT}" == '1' || ! -t 0 ]]; then OUTPUT_CHOICE="${PWD}/dist" log "Writing the bundle to ${OUTPUT_CHOICE} (use --output to change it)." return 0 fi prompt_for_output_dir } ################################################################################################################################################################################################### # # # Main Script # # # ################################################################################################################################################################################################### # The test suite sources this script to exercise individual functions. Every # other invocation runs the build normally. if [[ "${NOMAD_BUILDER_LIB_ONLY:-}" == '1' ]]; then return 0 2>/dev/null || exit 0 fi # --help must work without a running daemon, so handle it before any checks. for arg in "$@"; do case "${arg}" in -h|--help) usage exit 0 ;; esac done check_docker_available [[ -f "${REPO_ROOT}/install/build_offline_bundle.sh" ]] || die "Could not find install/build_offline_bundle.sh next to this script." # The build starts further containers whose bind mounts are resolved by the host # Docker daemon, so every path the build touches has to exist at the SAME # absolute location inside the container as on the host. Resolve each # path-valued option to an absolute host path and mount it there. mount_paths=("${REPO_ROOT}") passthrough_args=() output_dir='' require_value() { [[ $2 -ge 2 ]] || die "$1 requires a value."; } while [[ $# -gt 0 ]]; do case "$1" in --output) require_value "$1" $# mkdir -p "$2" || die "Could not create output directory: $2" output_dir="$(cd -- "$2" && pwd)" shift 2 ;; --no-prompt) NO_PROMPT='1' shift ;; --repo|--content-dir) require_value "$1" $# [[ -d "$2" ]] || die "$1: directory not found: $2" resolved="$(cd -- "$2" && pwd)" mount_paths+=("${resolved}") passthrough_args+=("$1" "${resolved}") shift 2 ;; --extra-image-archive|--extra-image-list) require_value "$1" $# [[ -f "$2" ]] || die "$1: file not found: $2" resolved_dir="$(cd -- "$(dirname -- "$2")" && pwd)" mount_paths+=("${resolved_dir}") passthrough_args+=("$1" "${resolved_dir}/$(basename -- "$2")") shift 2 ;; *) passthrough_args+=("$1") shift ;; esac done if [[ -z "${output_dir}" ]]; then OUTPUT_CHOICE='' choose_output_dir mkdir -p "${OUTPUT_CHOICE}" || die "Could not create output directory: ${OUTPUT_CHOICE}" output_dir="$(cd -- "${OUTPUT_CHOICE}" && pwd)" fi mount_paths+=("${output_dir}") passthrough_args+=(--output "${output_dir}") available_kb="$(space_of "${output_dir}" 'kb')" if [[ -n "${available_kb}" && "${available_kb}" -lt "${REQUIRED_SPACE_KB}" ]]; then log "Warning: only $(space_of "${output_dir}" 'human') free at ${output_dir}; a bundle needs roughly 1 GB plus working room." fi ################################################################################################################################################################################################### # # # Run the Build # # # ################################################################################################################################################################################################### # A destination the daemon cannot mount is still a perfectly good place to PUT a # finished bundle — the host can write there even when Docker cannot read there. # Removable and exFAT volumes are the common case, and carrying the bundle away # on one is the entire point of this tool. So build somewhere mountable and copy # the result to where it was asked for, rather than refusing. STAGING_ROOT='' staged_output='' cleanup_staging() { [[ -n "${STAGING_ROOT}" && -d "${STAGING_ROOT}" ]] && rm -rf "${STAGING_ROOT}" } trap cleanup_staging EXIT ensure_staging_root() { [[ -z "${STAGING_ROOT}" ]] || return 0 STAGING_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/nomad-bundle-staging.XXXXXX")" || die "Could not create a staging directory." docker_can_mount "${STAGING_ROOT}" || die "\ Docker cannot bind-mount the staging directory ${STAGING_ROOT} either. Docker Desktop shares only fixed drives by default. Set TMPDIR to a directory on a shared drive, or add this drive under Docker Desktop → Settings → Resources → File sharing." } # The daemon cannot read a checkout on an unshared volume — the build container # would see an empty directory and produce a hollow bundle that still passes its # own verification, because every check reads the same phantom path. Copy the # source somewhere mountable and build from the copy. The working tree is copied # as-is, uncommitted changes and .git included, so the bundle records the right # commit and carries exactly what is checked out. if ! docker_can_mount "${REPO_ROOT}"; then ensure_staging_root log "Docker cannot mount ${REPO_ROOT} (removable or unshared drive)." log "Copying the checkout to ${STAGING_ROOT}/src to build from..." mkdir -p "${STAGING_ROOT}/src" || die "Could not create the source staging directory." tar -cf - -C "${REPO_ROOT}" \ --exclude='./admin/node_modules' \ --exclude='./admin/node_modules_stale_delete_me' \ --exclude='./node_modules' \ --exclude='./dist' \ . | tar -xf - -C "${STAGING_ROOT}/src" || die "Could not copy the checkout to ${STAGING_ROOT}/src." REPO_ROOT="${STAGING_ROOT}/src" mount_paths[0]="${REPO_ROOT}" # A --repo the caller passed explicitly pointed at the same unusable volume. for i in "${!passthrough_args[@]}"; do [[ "${passthrough_args[$i]}" == '--repo' ]] || continue passthrough_args[$((i + 1))]="${REPO_ROOT}" break done fi # A destination the daemon cannot mount is still a perfectly good place to PUT a # finished bundle — the host can write there even when Docker cannot read there. # Removable volumes are the common case, and carrying the bundle away on one is # the entire point of this tool. So build somewhere mountable and copy across. if ! docker_can_mount "${output_dir}"; then ensure_staging_root log "Docker cannot mount ${output_dir} (removable or unshared drive)." log "Building into staging, then copying the finished bundle across." staged_output="${STAGING_ROOT}/out" mkdir -p "${staged_output}" || die "Could not create the output staging directory." # Replace the --output already queued for the inner script. for i in "${!passthrough_args[@]}"; do [[ "${passthrough_args[$i]}" == '--output' ]] || continue passthrough_args[$((i + 1))]="${staged_output}" break done mount_paths[${#mount_paths[@]} - 1]="${staged_output}" fi # Sort shortest-first so an ancestor is always seen before anything nested # inside it, then skip paths an existing mount already covers. mount_args=() mounted=() while IFS= read -r path; do [[ -n "${path}" ]] || continue covered='false' for existing in ${mounted[@]+"${mounted[@]}"}; do if [[ "${path}" == "${existing}" || "${path}" == "${existing}"/* ]]; then covered='true' break fi done [[ "${covered}" == 'false' ]] || continue mounted+=("${path}") # Source is what the daemon must resolve; target is the path the build (and # every container it starts) will use, chosen so the daemon resolves it to the # same directory. On Linux both are the host path. mount_args+=(-v "$(host_mount_source "${path}"):$(daemon_identity_path "${path}")") done < <(printf '%s\n' "${mount_paths[@]}" | awk '{ print length, $0 }' | sort -n | cut -d' ' -f2-) log "Building in ${BUILDER_IMAGE} (host needs only Docker)..." log "Bundle will be written to ${output_dir}" # git safe.directory is set because the checkout is owned by the host user, not # by root inside the container. # MSYS_NO_PATHCONV stops Git Bash rewriting the Unix-looking paths in -v and -w # into Windows ones. The mount SOURCES are converted deliberately above; the # targets, the socket path and the working directory must survive verbatim. # Every path the inner script receives has to be the in-container form, since # it both reads them directly and hands them to nested containers. for i in "${!passthrough_args[@]}"; do case "${passthrough_args[$i]}" in --output|--repo|--content-dir|--extra-image-list|--extra-image-archive) passthrough_args[$((i + 1))]="$(daemon_identity_path "${passthrough_args[$((i + 1))]}")" ;; esac done MSYS_NO_PATHCONV=1 docker run --rm \ -v "${DOCKER_SOCKET}:/var/run/docker.sock" \ "${mount_args[@]}" \ -w "$(daemon_identity_path "${REPO_ROOT}")" \ "${BUILDER_IMAGE}" \ sh -c ' set -e apk add --no-cache bash git coreutils findutils tar gzip > /dev/null git config --global --add safe.directory "*" exec bash install/build_offline_bundle.sh "$@" ' sh "${passthrough_args[@]}" if [[ -n "${staged_output}" ]]; then log "Copying the finished bundle to ${output_dir}..." cp -R "${staged_output}"/. "${output_dir}/" || die "The bundle built successfully but could not be copied to ${output_dir}." fi echo '' echo -e "${GREEN}#${RESET} Done. The bundle is in ${output_dir}"