feat(install): add offline artifact installation mode

A normal install needs the internet: the installer fetches Docker from
get.docker.com, host packages from APT, container images from registries,
and its own helper scripts from GitHub. That leaves no way to provision a
machine that has no connectivity, or to rebuild one after connectivity is
gone.

Artifact mode closes that bootstrap gap without adding a second installer.
install_nomad.sh gains --artifacts <dir> (and NOMAD_ARTIFACT_PATH); with no
such argument it behaves exactly as before. When a bundle is selected every
dependency comes from that bundle and the installer is fail-closed: it
never falls back to the network, and a missing input is an error.

build_offline_bundle.sh prepares a bundle on a connected machine from a
source checkout, resolving the package closure and discovering images from
management_compose.yaml rather than hardcoding them, so the bundle tracks
the compose file. build_offline_bundle_docker.sh wraps it so the build only
needs Docker on the host. verify_offline_bundle.sh inspects a bundle before
transfer; capture_installed_images.sh exports images from a populated host.

APT is isolated to a bundle-local file: repository so package installation
cannot resolve through a configured remote, image archives are loaded from
disk, and Compose starts with pulls disabled via a generated override that
leaves the canonical management compose file untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ken Eucker 2026-08-14 14:17:03 -07:00
parent 0bd1c6f4f9
commit 40ee026938
6 changed files with 2694 additions and 33 deletions

741
install/build_offline_bundle.sh Executable file
View File

@ -0,0 +1,741 @@
#!/bin/bash
# Project NOMAD Offline Artifact Bundle Builder
###################################################################################################################################################################################################
# Script | Project NOMAD Offline Artifact Bundle Builder
# Version | 1.0.0
# Author | Crosstalk Solutions, LLC
# Website | https://crosstalksolutions.com
###################################################################################################################################################################################################
#
# Builds an offline artifact bundle from a Project NOMAD source checkout.
#
# This script runs on a CONNECTED build machine. The bundle it produces lets
# install_nomad.sh --artifacts install Project NOMAD on a disconnected target of
# the same OS, version and architecture. See admin/docs/offline-install.md.
#
###################################################################################################################################################################################################
set -Eeuo pipefail
###################################################################################################################################################################################################
# #
# Color Codes #
# #
###################################################################################################################################################################################################
RESET='\033[0m'
YELLOW='\033[1;33m'
RED='\033[1;31m' # Light Red.
GREEN='\033[1;32m' # Light Green.
###################################################################################################################################################################################################
# #
# Constants & Variables #
# #
###################################################################################################################################################################################################
# Bumped only when the on-disk bundle layout changes in a way install_nomad.sh
# must be able to reject. Keep in sync with SUPPORTED_BUNDLE_FORMAT_VERSION.
BUNDLE_FORMAT_VERSION='1'
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
TARGET_OS='ubuntu'
TARGET_VERSION='26.04'
TARGET_ARCH='amd64'
OUTPUT_BASE="${PWD}/dist"
WITH_NVIDIA='1'
EXTRA_IMAGE_LIST=''
EXTRA_IMAGE_ARCHIVE=''
CONTENT_DIR=''
WITH_APPS=''
LIST_APPS='0'
# Supply Depot apps whose images are worth carrying by default: broadly useful,
# and modest in size compared with the AI/education stack.
DEFAULT_APP_SET='kiwix,cyberchef,it_tools,flatnotes,excalidraw,filebrowser,stirling_pdf'
CREATE_ARCHIVE='0'
# Host packages installed on the target from the bundle. Must stay in step with
# the package list in install_packages_from_artifacts (install_nomad.sh).
ARTIFACT_PACKAGES=(
ca-certificates
curl
gnupg
jq
pciutils
docker-ce
docker-ce-cli
containerd.io
docker-buildx-plugin
docker-compose-plugin
)
###################################################################################################################################################################################################
# #
# Functions #
# #
###################################################################################################################################################################################################
log() { echo -e "${YELLOW}#${RESET} $*"; }
ok() { echo -e "${GREEN}#${RESET} $*"; }
die() {
echo -e "${RED}#${RESET} $*" >&2
exit 1
}
usage() {
cat <<'EOF'
Project NOMAD Offline Artifact Bundle Builder
Usage:
./build_offline_bundle.sh [options]
Options:
--repo PATH Project NOMAD source checkout (default: the
parent directory of this script)
--target OS:VERSION Target operating system (default: ubuntu:26.04)
--arch ARCH Target architecture (default: amd64)
--output DIR Directory to create the bundle in (default: ./dist)
--with-apps LIST Also bundle Supply Depot app images so apps can
be installed on the offline target. LIST is a
comma-separated set of app names, "default" for a
useful starter set, or "all". Adds significant
size — see --list-apps.
--list-apps Print the installable app names and exit
--without-nvidia-toolkit Omit the NVIDIA Container Toolkit packages
--extra-image-list FILE Also pull and bundle the image references in FILE
--extra-image-archive FILE Copy an existing docker-save archive into the
bundle as images/optional-images.tar
--content-dir DIR Copy pre-staged NOMAD storage content into the bundle
--archive Also produce a .tar.gz of the finished bundle
-h, --help Show this help text and exit
Requirements on the build machine:
internet access, docker with compose v2, git, sha256sum, gzip, tar
The bundle is specific to one OS, version and architecture, and carries the
install_nomad.sh from the source checkout it was built from.
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--repo)
[[ $# -ge 2 ]] || die "--repo requires a path."
REPO_ROOT="$(cd -- "$2" 2>/dev/null && pwd)" || die "Source checkout not found: $2"
shift
;;
--target)
[[ $# -ge 2 ]] || die "--target requires OS:VERSION."
[[ "$2" == *:* ]] || die "--target must be given as OS:VERSION (for example ubuntu:26.04)."
TARGET_OS="${2%%:*}"
TARGET_VERSION="${2#*:}"
shift
;;
--arch)
[[ $# -ge 2 ]] || die "--arch requires a value."
TARGET_ARCH="$2"
shift
;;
--output)
[[ $# -ge 2 ]] || die "--output requires a directory."
OUTPUT_BASE="$2"
shift
;;
--with-apps)
[[ $# -ge 2 ]] || die "--with-apps requires a list, \"default\" or \"all\"."
WITH_APPS="$2"
shift
;;
--list-apps)
LIST_APPS='1'
;;
--without-nvidia-toolkit)
WITH_NVIDIA='0'
;;
--extra-image-list)
[[ $# -ge 2 ]] || die "--extra-image-list requires a file."
EXTRA_IMAGE_LIST="$2"
shift
;;
--extra-image-archive)
[[ $# -ge 2 ]] || die "--extra-image-archive requires a file."
EXTRA_IMAGE_ARCHIVE="$2"
shift
;;
--content-dir)
[[ $# -ge 2 ]] || die "--content-dir requires a directory."
CONTENT_DIR="$2"
shift
;;
--archive)
CREATE_ARCHIVE='1'
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
die "Unknown option: $1"
;;
esac
shift
done
}
check_build_requirements() {
local cmd
for cmd in docker git sha256sum awk sed gzip tar; do
command -v "$cmd" > /dev/null 2>&1 ||
die "Required build command not found: ${cmd}. Run build_offline_bundle_docker.sh instead, which supplies a complete build environment and needs only Docker."
done
docker compose version > /dev/null 2>&1 ||
die "Docker Compose v2 is required on the build machine."
[[ -f "${REPO_ROOT}/install/install_nomad.sh" ]] ||
die "install/install_nomad.sh not found under ${REPO_ROOT}. Point --repo at a Project NOMAD checkout."
[[ -f "${REPO_ROOT}/install/management_compose.yaml" ]] ||
die "install/management_compose.yaml not found under ${REPO_ROOT}."
git -C "${REPO_ROOT}" rev-parse HEAD > /dev/null 2>&1 ||
die "${REPO_ROOT} is not a git checkout, so the bundle commit cannot be recorded."
# The upstream installer supports x86_64 only. Widening this belongs with the
# architecture support work, not here.
[[ "${TARGET_ARCH}" == 'amd64' ]] ||
die "This builder currently supports --arch amd64 only, matching the installer's supported architecture."
}
detect_target_os_release() {
# The manifest must record the values install_nomad.sh compares against
# /etc/os-release on the target, which are not always the image tag (for
# example debian:12 reports VERSION_ID=12, but a "bookworm" tag would not).
local os_release
os_release="$(docker run --rm --platform "linux/${TARGET_ARCH}" "${TARGET_OS}:${TARGET_VERSION}" cat /etc/os-release)" ||
die "Could not read /etc/os-release from ${TARGET_OS}:${TARGET_VERSION}."
local detected_id detected_version
detected_id="$(echo "${os_release}" | awk -F= '$1 == "ID" { gsub(/^"|"$/, "", $2); print $2; exit }')"
detected_version="$(echo "${os_release}" | awk -F= '$1 == "VERSION_ID" { gsub(/^"|"$/, "", $2); print $2; exit }')"
[[ -n "${detected_id}" && -n "${detected_version}" ]] ||
die "${TARGET_OS}:${TARGET_VERSION} does not report a usable ID/VERSION_ID."
if [[ "${detected_id}" != "${TARGET_OS}" ]]; then
die "Image ${TARGET_OS}:${TARGET_VERSION} reports ID=${detected_id}. Use --target ${detected_id}:${TARGET_VERSION}."
fi
if [[ "${detected_version}" != "${TARGET_VERSION}" ]]; then
log "Recording TARGET_VERSION=${detected_version} (reported by ${TARGET_OS}:${TARGET_VERSION})."
TARGET_VERSION="${detected_version}"
fi
}
copy_installer_and_payload() {
# The bundle carries the ordinary installer from this exact checkout, so the
# target runs the same code that was reviewed and built against.
cp "${REPO_ROOT}/install/install_nomad.sh" "${BUNDLE_DIR}/install_nomad.sh"
chmod 0755 "${BUNDLE_DIR}/install_nomad.sh"
local name
for name in management_compose.yaml start_nomad.sh stop_nomad.sh update_nomad.sh; do
[[ -f "${REPO_ROOT}/install/${name}" ]] || die "Expected ${REPO_ROOT}/install/${name} to exist."
cp "${REPO_ROOT}/install/${name}" "${BUNDLE_DIR}/payload/nomad/${name}"
done
# A disconnected host cannot fetch the uninstall script later, so ship it when
# the checkout has one.
if [[ -f "${REPO_ROOT}/install/uninstall_nomad.sh" ]]; then
cp "${REPO_ROOT}/install/uninstall_nomad.sh" "${BUNDLE_DIR}/payload/nomad/uninstall_nomad.sh"
fi
ok "Copied installer and payload from ${REPO_ROOT}."
}
target_packages() {
local packages=("${ARTIFACT_PACKAGES[@]}")
[[ "${WITH_NVIDIA}" != '1' ]] || packages+=(nvidia-container-toolkit)
echo "${packages[*]}"
}
fetch_third_party_apt_config() {
# Fetching the Docker and NVIDIA repository keys needs curl and gnupg, which
# would pollute the package set of whatever container installs them. So this
# runs in a throwaway container and hands the resulting files to the resolver,
# which must stay pristine.
local config_dir="$1"
# -i is required: the build steps are fed to the container over stdin.
docker run --rm -i \
--platform "linux/${TARGET_ARCH}" \
-e TARGET_OS="${TARGET_OS}" \
-e WITH_NVIDIA="${WITH_NVIDIA}" \
-v "${config_dir}:/aptcfg" \
"${TARGET_OS}:${TARGET_VERSION}" \
bash -s <<'CONTAINER_SCRIPT' || die "Failed to fetch third-party APT repository configuration."
set -Eeuo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl gnupg
. /etc/os-release
# The resolver needs to reach HTTPS repositories without installing
# ca-certificates (which would take openssl out of the dependency closure), so
# hand it the trust store as a plain file.
cp /etc/ssl/certs/ca-certificates.crt /aptcfg/ca-certificates.crt
curl -fsSL "https://download.docker.com/linux/${TARGET_OS}/gpg" -o /aptcfg/docker.asc
chmod a+r /aptcfg/docker.asc
arch="$(dpkg --print-architecture)"
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${TARGET_OS} ${VERSION_CODENAME} stable" \
> /aptcfg/docker.list
if [[ "${WITH_NVIDIA}" == '1' ]]; then
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| gpg --dearmor -o /aptcfg/nvidia-container-toolkit-keyring.gpg
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/etc/apt/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
> /aptcfg/nvidia-container-toolkit.list
fi
CONTAINER_SCRIPT
}
build_local_apt_repo() {
local out_dir="${BUNDLE_DIR}/packages/apt"
local config_dir
config_dir="$(mktemp -d)" || die "Could not create a temporary directory."
log "Building local APT repository for ${TARGET_OS} ${TARGET_VERSION} (${TARGET_ARCH})..."
fetch_third_party_apt_config "${config_dir}"
# Resolve the closure in a PRISTINE container of the target distribution.
# APT only downloads packages that are not already installed, so anything
# installed here first would silently drop out of the bundle and fail on the
# target. A base image is a subset of any real install of the same release,
# so its closure is a superset of what the target needs.
docker run --rm -i \
--platform "linux/${TARGET_ARCH}" \
-e PACKAGES="$(target_packages)" \
-v "${config_dir}:/aptcfg:ro" \
-v "${out_dir}:/out" \
"${TARGET_OS}:${TARGET_VERSION}" \
bash -s <<'CONTAINER_SCRIPT' || die "Failed to resolve the host package closure."
set -Eeuo pipefail
export DEBIAN_FRONTEND=noninteractive
# File copies only — installing packages at this point would corrupt the closure.
install -m 0755 -d /etc/apt/keyrings
install -m 0644 /aptcfg/docker.asc /etc/apt/keyrings/docker.asc
install -m 0644 /aptcfg/docker.list /etc/apt/sources.list.d/docker.list
if [[ -f /aptcfg/nvidia-container-toolkit-keyring.gpg ]]; then
install -m 0644 /aptcfg/nvidia-container-toolkit-keyring.gpg /etc/apt/keyrings/nvidia-container-toolkit-keyring.gpg
install -m 0644 /aptcfg/nvidia-container-toolkit.list /etc/apt/sources.list.d/nvidia-container-toolkit.list
fi
# The Docker and NVIDIA repositories are HTTPS. Point APT at the trust store
# supplied by the fetcher rather than installing ca-certificates, which would
# take openssl out of the dependency closure.
apt_opts=(-o "Acquire::https::CaInfo=/aptcfg/ca-certificates.crt")
apt-get "${apt_opts[@]}" update
read -ra packages <<< "${PACKAGES}"
apt-get "${apt_opts[@]}" install -y --download-only --reinstall "${packages[@]}"
cp /var/cache/apt/archives/*.deb /out/
# Only now, with the closure already copied out, is it safe to install the
# tooling that generates the package index.
apt-get "${apt_opts[@]}" install -y --no-install-recommends dpkg-dev
cd /out
dpkg-scanpackages . /dev/null > Packages
gzip -9c Packages > Packages.gz
CONTAINER_SCRIPT
rm -rf "${config_dir}"
[[ -s "${out_dir}/Packages" && -s "${out_dir}/Packages.gz" ]] ||
die "Local APT repository generation failed — no package index was produced."
local deb_count
deb_count="$(find "${out_dir}" -maxdepth 1 -name '*.deb' ! -name '._*' | wc -l | tr -d ' ')"
[[ "${deb_count}" -gt 0 ]] || die "Local APT repository contains no .deb packages."
ok "Local APT repository created (${deb_count} packages)."
}
verify_local_apt_repo() {
local out_dir="${BUNDLE_DIR}/packages/apt"
log "Verifying the local APT repository resolves with no network..."
# Dry-run the exact isolated installation the target performs, in a clean
# container of the target distribution with no network at all. An incomplete
# closure fails here, on the build machine, instead of in the field.
docker run --rm -i \
--platform "linux/${TARGET_ARCH}" \
--network none \
-e PACKAGES="$(target_packages)" \
-v "${out_dir}:/repo:ro" \
"${TARGET_OS}:${TARGET_VERSION}" \
bash -s <<'CONTAINER_SCRIPT' || die "The bundled APT repository cannot satisfy its own package list offline. The bundle would fail on the target."
set -Eeuo pipefail
export DEBIAN_FRONTEND=noninteractive
mkdir -p /tmp/apt/lists/partial
echo 'deb [trusted=yes] file:/repo ./' > /tmp/apt/sources.list
apt_opts=(
-o Dir::Etc::sourcelist=/tmp/apt/sources.list
-o Dir::Etc::sourceparts=-
-o Dir::State::Lists=/tmp/apt/lists
-o APT::Get::List-Cleanup=0
-o Acquire::Languages=none
-o Acquire::Retries=0
)
apt-get "${apt_opts[@]}" update
read -ra packages <<< "${PACKAGES}"
apt-get "${apt_opts[@]}" install -y --no-install-recommends --simulate "${packages[@]}"
CONTAINER_SCRIPT
ok "Local APT repository resolves offline."
}
discover_and_save_images() {
local compose_file="${REPO_ROOT}/install/management_compose.yaml"
local image_list="${BUNDLE_DIR}/images/core-images.txt"
log "Discovering management images from management_compose.yaml..."
docker compose -f "${compose_file}" config --images | sort -u > "${image_list}" ||
die "Could not read service images from ${compose_file}."
if [[ -n "${EXTRA_IMAGE_LIST}" ]]; then
[[ -f "${EXTRA_IMAGE_LIST}" ]] || die "Extra image list not found: ${EXTRA_IMAGE_LIST}"
grep -Ev '^[[:space:]]*(#|$)' "${EXTRA_IMAGE_LIST}" >> "${image_list}" || true
sort -u -o "${image_list}" "${image_list}"
fi
# Read with a loop rather than mapfile so the builder also runs under the
# bash 3.2 that ships with macOS.
local images=()
local image
while IFS= read -r image; do
[[ -n "${image}" ]] || continue
images+=("${image}")
done < "${image_list}"
[[ ${#images[@]} -gt 0 ]] || die "No images were discovered from ${compose_file}."
for image in "${images[@]}"; do
log "Pulling ${image}..."
docker pull --platform "linux/${TARGET_ARCH}" "${image}" ||
die "Failed to pull ${image}. The build machine needs internet access and registry availability."
done
log "Saving ${#images[@]} image(s) to images/core-images.tar..."
docker save -o "${BUNDLE_DIR}/images/core-images.tar" "${images[@]}" ||
die "docker save failed."
{
printf 'IMAGE\tID\tREPO_DIGESTS\n'
for image in "${images[@]}"; do
printf '%s\t%s\t%s\n' \
"${image}" \
"$(docker image inspect --format '{{.Id}}' "${image}")" \
"$(docker image inspect --format '{{join .RepoDigests ","}}' "${image}")"
done
} > "${BUNDLE_DIR}/images/core-image-metadata.tsv"
ok "Saved ${#images[@]} management image(s)."
}
app_seeder_path() {
echo "${REPO_ROOT}/admin/database/seeders/service_seeder.ts"
}
# Supply Depot apps are seeded into the Command Center's database from a file
# baked into the admin image — the catalog is local, not fetched — so reading the
# same file here keeps bundled images in step with what the UI will offer.
discover_app_images() {
local seeder
seeder="$(app_seeder_path)"
[[ -f "${seeder}" ]] || die "Could not find the app seeder at ${seeder}."
awk '
/service_name: SERVICE_NAMES\./ {
name = $0
sub(/.*SERVICE_NAMES\./, "", name)
sub(/,.*/, "", name)
}
/container_image: '"'"'/ {
image = $0
sub(/.*container_image: '"'"'/, "", image)
sub(/'"'"'.*/, "", image)
if (name != "") {
print tolower(name) "\t" image
name = ""
}
}
' "${seeder}"
}
list_apps() {
echo 'Supply Depot apps available to bundle with --with-apps:'
echo ''
local name image
while IFS="$(printf '\t')" read -r name image; do
[[ -n "${name}" ]] || continue
printf ' %-16s %s\n' "${name}" "${image}"
done < <(discover_app_images)
echo ''
echo "Default set (--with-apps default): ${DEFAULT_APP_SET}"
echo 'Use "all" for every app, or a comma-separated list of names.'
}
# Resolves WITH_APPS into the image references to bundle.
selected_app_images() {
local requested="${WITH_APPS}"
[[ "${requested}" != 'default' ]] || requested="${DEFAULT_APP_SET}"
local name image
if [[ "${requested}" == 'all' ]]; then
while IFS="$(printf '\t')" read -r name image; do
[[ -n "${image}" ]] || continue
echo "${image}"
done < <(discover_app_images)
return 0
fi
# Validate every requested name so a typo fails the build rather than silently
# producing a bundle without the app the user asked for.
local available
available="$(discover_app_images)"
local wanted
IFS=',' read -ra wanted <<< "${requested}"
local entry match
for entry in "${wanted[@]}"; do
entry="$(echo "${entry}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
[[ -n "${entry}" ]] || continue
match="$(echo "${available}" | awk -F'\t' -v want="${entry}" '$1 == want { print $2; exit }')"
[[ -n "${match}" ]] ||
die "Unknown app '${entry}'. Run --list-apps to see the available names."
echo "${match}"
done
}
bundle_app_images() {
[[ -n "${WITH_APPS}" ]] || return 0
local images=()
local image
while IFS= read -r image; do
[[ -n "${image}" ]] || continue
images+=("${image}")
done < <(selected_app_images | sort -u)
[[ ${#images[@]} -gt 0 ]] || die "--with-apps selected no images."
log "Bundling ${#images[@]} Supply Depot app image(s)..."
for image in "${images[@]}"; do
log "Pulling ${image}"
docker pull --platform "linux/${TARGET_ARCH}" "${image}" ||
die "Failed to pull ${image}."
done
printf '%s\n' "${images[@]}" > "${BUNDLE_DIR}/images/app-images.txt"
docker save -o "${BUNDLE_DIR}/images/app-images.tar" "${images[@]}" ||
die "Failed to save app images."
ok "Saved ${#images[@]} app image(s)."
# Kiwix refuses to install without at least one ZIM present, and its
# pre-install step fetches one from GitHub. Carry the small Wikipedia sample
# from this checkout so the file is already in storage on the target.
if printf '%s\n' "${images[@]}" | grep -q 'kiwix'; then
local zim
zim="$(find "${REPO_ROOT}/install" -maxdepth 1 -name 'wikipedia_en_*.zim' ! -name '._*' | sort | tail -1)"
if [[ -n "${zim}" ]]; then
mkdir -p "${BUNDLE_DIR}/content/zim"
cp "${zim}" "${BUNDLE_DIR}/content/zim/$(basename "${zim}")"
ok "Included $(basename "${zim}") for Kiwix."
else
log "Warning: no wikipedia_en_*.zim found in the checkout; Kiwix will have no starter content."
fi
fi
}
write_pull_never_override() {
# Kept separate from service discovery so it can be unit tested without Docker.
local override_path="$1"
shift
local services=("$@")
[[ ${#services[@]} -gt 0 ]] || die "Refusing to write a Compose override with no services."
{
echo "# Generated by install/build_offline_bundle.sh — do not edit."
echo "#"
echo "# Layered over compose.yml during artifact-mode installs so that every service"
echo "# uses the images loaded from the bundle instead of contacting a registry."
echo "services:"
local service
for service in "${services[@]}"; do
echo " ${service}:"
echo " pull_policy: never"
done
} > "${override_path}"
}
generate_pull_never_override() {
local compose_file="${REPO_ROOT}/install/management_compose.yaml"
local services=()
local service
while IFS= read -r service; do
[[ -n "${service}" ]] || continue
services+=("${service}")
done < <(docker compose -f "${compose_file}" config --services | sort)
[[ ${#services[@]} -gt 0 ]] || die "No Compose services were discovered from ${compose_file}."
write_pull_never_override "${BUNDLE_DIR}/payload/nomad/compose.artifact.yml" "${services[@]}"
ok "Generated pull_policy: never override for ${#services[@]} service(s)."
}
add_optional_content() {
if [[ -n "${EXTRA_IMAGE_ARCHIVE}" ]]; then
[[ -f "${EXTRA_IMAGE_ARCHIVE}" ]] || die "Extra image archive not found: ${EXTRA_IMAGE_ARCHIVE}"
log "Adding optional image archive..."
cp "${EXTRA_IMAGE_ARCHIVE}" "${BUNDLE_DIR}/images/optional-images.tar"
fi
if [[ -n "${CONTENT_DIR}" ]]; then
[[ -d "${CONTENT_DIR}" ]] || die "Content directory not found: ${CONTENT_DIR}"
log "Copying pre-staged NOMAD storage content..."
mkdir -p "${BUNDLE_DIR}/content"
cp -a "${CONTENT_DIR}/." "${BUNDLE_DIR}/content/"
fi
}
write_manifest() {
# Plain data, read key by key on the target. Never sourced as shell.
cat > "${BUNDLE_DIR}/manifest" <<EOF
BUNDLE_FORMAT_VERSION=${BUNDLE_FORMAT_VERSION}
NOMAD_COMMIT=${NOMAD_COMMIT}
TARGET_OS=${TARGET_OS}
TARGET_VERSION=${TARGET_VERSION}
TARGET_ARCH=${TARGET_ARCH}
WITH_NVIDIA_TOOLKIT=${WITH_NVIDIA}
CREATED_AT_UTC=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF
cat > "${BUNDLE_DIR}/README.txt" <<EOF
Project NOMAD Offline Artifact Bundle
=====================================
NOMAD commit : ${NOMAD_COMMIT}
Target : ${TARGET_OS} ${TARGET_VERSION} (${TARGET_ARCH})
Install on a matching, disconnected target:
sudo bash ./install_nomad.sh --artifacts .
Artifact mode never falls back to the network. If a required package, image or
payload file is missing from this bundle, the installation stops with an error.
The SHA256SUMS file verifies that this bundle transferred intact. It is not a
publisher signature.
EOF
}
write_checksums() {
# Bundles are routinely built straight onto FAT/exFAT removable media, where
# macOS deposits AppleDouble sidecars ("._name") and .DS_Store alongside real
# files. They are OS metadata, not bundle content: they appear and vanish
# outside our control, so checksumming them produces a bundle that fails its
# own verification. Remove them, and never list them.
find "${BUNDLE_DIR}" -type f \( -name '._*' -o -name '.DS_Store' \) -delete 2>/dev/null || true
log "Generating SHA-256 checksums..."
(
cd "${BUNDLE_DIR}"
find . -type f ! -name SHA256SUMS ! -name '._*' ! -name '.DS_Store' -print0 \
| sort -z | xargs -0 sha256sum > SHA256SUMS
)
(
cd "${BUNDLE_DIR}"
sha256sum -c SHA256SUMS > /dev/null
) || die "Checksum verification of the finished bundle failed."
ok "Checksums written and verified."
}
main() {
parse_args "$@"
if [[ "${LIST_APPS}" == '1' ]]; then
list_apps
exit 0
fi
check_build_requirements
detect_target_os_release
NOMAD_COMMIT="$(git -C "${REPO_ROOT}" rev-parse HEAD)"
BUNDLE_NAME="project-nomad-offline-${TARGET_OS}-${TARGET_VERSION}-${TARGET_ARCH}-${NOMAD_COMMIT:0:12}"
BUNDLE_DIR="${OUTPUT_BASE%/}/${BUNDLE_NAME}"
if ! git -C "${REPO_ROOT}" diff --quiet HEAD 2>/dev/null; then
log "Warning: ${REPO_ROOT} has uncommitted changes. The manifest records ${NOMAD_COMMIT:0:12} regardless."
fi
log "Building ${BUNDLE_NAME}"
rm -rf "${BUNDLE_DIR}"
mkdir -p "${BUNDLE_DIR}/packages/apt" "${BUNDLE_DIR}/images" "${BUNDLE_DIR}/payload/nomad"
copy_installer_and_payload
build_local_apt_repo
verify_local_apt_repo
discover_and_save_images
bundle_app_images
generate_pull_never_override
add_optional_content
write_manifest
write_checksums
if [[ "${CREATE_ARCHIVE}" == '1' ]]; then
log "Creating ${BUNDLE_DIR}.tar.gz..."
tar -C "${OUTPUT_BASE%/}" -czf "${BUNDLE_DIR}.tar.gz" "${BUNDLE_NAME}"
ok "Created ${BUNDLE_DIR}.tar.gz"
fi
echo ''
ok "Bundle ready: ${BUNDLE_DIR}"
echo ''
echo -e "${GREEN}#${RESET} Copy it to the target and run:"
echo -e "${GREEN}#${RESET} sudo bash ./install_nomad.sh --artifacts ."
}
###################################################################################################################################################################################################
# #
# Main Script #
# #
###################################################################################################################################################################################################
# The test suite sources this script to exercise individual functions. Every
# other invocation builds a bundle normally.
if [[ "${NOMAD_BUNDLE_LIB_ONLY:-}" == '1' ]]; then
return 0 2>/dev/null || exit 0
fi
main "$@"

View File

@ -0,0 +1,377 @@
#!/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}"
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."
[[ -S "${DOCKER_SOCKET}" ]] ||
die "No Docker socket at ${DOCKER_SOCKET}. Set NOMAD_DOCKER_SOCKET if yours lives elsewhere."
}
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 #
# #
###################################################################################################################################################################################################
# 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}")
mount_args+=(-v "${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.
docker run --rm \
-v "${DOCKER_SOCKET}:/var/run/docker.sock" \
"${mount_args[@]}" \
-w "${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[@]}"
echo ''
echo -e "${GREEN}#${RESET} Done. The bundle is in ${output_dir}"

View File

@ -0,0 +1,122 @@
#!/bin/bash
# Project NOMAD Installed Image Capture Helper
###################################################################################################################################################################################################
#
# Exports the Docker images present on an already-populated Project NOMAD host so
# they can be added to an offline artifact bundle with:
#
# ./build_offline_bundle.sh --extra-image-archive <archive>
#
# This transports images only. It does NOT prove that the corresponding Supply
# Depot apps install offline — their install logic and catalog metadata may have
# their own network dependencies. See admin/docs/offline-install.md.
#
###################################################################################################################################################################################################
set -Eeuo pipefail
RESET='\033[0m'
RED='\033[1;31m'
GREEN='\033[1;32m'
OUTPUT_DIR="${PWD}/nomad-image-export"
INCLUDE_ALL='0'
die() {
echo -e "${RED}#${RESET} $*" >&2
exit 1
}
usage() {
cat <<'EOF'
Usage:
./capture_installed_images.sh [--output DIR] [--all-local-images]
Discovers, by default:
* images referenced by /opt/project-nomad/compose.yml
* images used by containers whose names start with "nomad_"
Options:
--output DIR Where to write the archive (default: ./nomad-image-export)
--all-local-images Also export every tagged image present on this host
-h, --help Show this help text and exit
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
[[ $# -ge 2 ]] || die "--output requires a directory."
OUTPUT_DIR="$2"
shift
;;
--all-local-images)
INCLUDE_ALL='1'
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
die "Unknown option: $1"
;;
esac
shift
done
command -v docker > /dev/null 2>&1 || die "docker is required."
mkdir -p "${OUTPUT_DIR}"
declare -A seen=()
images=()
add_image() {
local image="$1"
[[ -n "${image}" && "${image}" != '<none>:<none>' ]] || return 0
if [[ -z "${seen[${image}]+set}" ]]; then
seen["${image}"]='1'
images+=("${image}")
fi
}
if [[ -f /opt/project-nomad/compose.yml ]]; then
while IFS= read -r image; do
add_image "${image}"
done < <(docker compose -f /opt/project-nomad/compose.yml config --images 2>/dev/null || true)
fi
while IFS= read -r image; do
add_image "${image}"
done < <(docker ps -a --filter 'name=^nomad_' --format '{{.Image}}')
if [[ "${INCLUDE_ALL}" == '1' ]]; then
while IFS= read -r image; do
add_image "${image}"
done < <(docker image ls --format '{{.Repository}}:{{.Tag}}')
fi
[[ ${#images[@]} -gt 0 ]] || die "No images were discovered on this host."
missing='0'
for image in "${images[@]}"; do
if ! docker image inspect "${image}" > /dev/null 2>&1; then
echo "Image referenced but not present locally: ${image}" >&2
missing='1'
fi
done
[[ "${missing}" == '0' ]] || die "Every discovered image must exist locally before export."
printf '%s\n' "${images[@]}" | sort -u > "${OUTPUT_DIR}/optional-images.txt"
docker save -o "${OUTPUT_DIR}/optional-images.tar" "${images[@]}" || die "docker save failed."
(cd "${OUTPUT_DIR}" && sha256sum optional-images.txt optional-images.tar > SHA256SUMS)
echo ''
echo -e "${GREEN}#${RESET} Exported ${#images[@]} image(s) to ${OUTPUT_DIR}/optional-images.tar"
echo ''
echo "Add to a bundle with:"
printf ' ./build_offline_bundle.sh --extra-image-archive %q\n' "${OUTPUT_DIR}/optional-images.tar"

View File

@ -37,6 +37,24 @@ UPDATE_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project
script_option_debug='true'
accepted_terms='false'
local_ip_address=''
has_lan_address='false'
# Offline artifact mode. When an artifact bundle is selected (--artifacts or
# NOMAD_ARTIFACT_PATH) every dependency — host packages, Docker images, helper
# scripts and the management compose file — is taken from that bundle, and the
# installer never falls back to the network. See admin/docs/offline-install.md.
SUPPORTED_BUNDLE_FORMAT_VERSION='1'
NOMAD_ARTIFACT_PATH="${NOMAD_ARTIFACT_PATH:-}"
artifact_mode='false'
existing_install='false'
existing_app_key=''
existing_db_password=''
existing_db_root_password=''
existing_url=''
artifact_manifest_file=''
artifact_apt_repo_dir=''
artifact_image_dir=''
artifact_payload_dir=''
###################################################################################################################################################################################################
# #
@ -44,6 +62,208 @@ local_ip_address=''
# #
###################################################################################################################################################################################################
usage() {
cat <<EOF
Project NOMAD Installation Script
Usage:
sudo bash $(basename "$0") [options]
Options:
--artifacts PATH Install from a local offline artifact bundle instead of
downloading dependencies from the internet. Bundles are
produced on a connected machine by
install/build_offline_bundle.sh and are specific to one
OS / version / architecture.
-h, --help Show this help text and exit.
Environment:
NOMAD_ARTIFACT_PATH Equivalent to --artifacts. The command line option wins
when both are supplied.
With no artifact path the installer behaves exactly as before and downloads its
dependencies from the internet.
EOF
}
parse_installer_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--artifacts)
if [[ $# -lt 2 || -z "$2" ]]; then
echo -e "${RED}#${RESET} --artifacts requires the path to an offline artifact bundle."
exit 1
fi
NOMAD_ARTIFACT_PATH="$2"
shift
;;
--artifacts=*)
NOMAD_ARTIFACT_PATH="${1#*=}"
if [[ -z "${NOMAD_ARTIFACT_PATH}" ]]; then
echo -e "${RED}#${RESET} --artifacts requires the path to an offline artifact bundle."
exit 1
fi
;;
-h|--help)
usage
exit 0
;;
*)
echo -e "${RED}#${RESET} Unknown option: $1"
usage
exit 1
;;
esac
shift
done
if [[ -n "${NOMAD_ARTIFACT_PATH}" ]]; then
local resolved_path
if ! resolved_path="$(cd -- "${NOMAD_ARTIFACT_PATH}" 2>/dev/null && pwd)"; then
echo -e "${RED}#${RESET} Offline artifact bundle directory not found: ${NOMAD_ARTIFACT_PATH}"
exit 1
fi
NOMAD_ARTIFACT_PATH="${resolved_path}"
artifact_mode='true'
artifact_manifest_file="${NOMAD_ARTIFACT_PATH}/manifest"
artifact_apt_repo_dir="${NOMAD_ARTIFACT_PATH}/packages/apt"
artifact_image_dir="${NOMAD_ARTIFACT_PATH}/images"
artifact_payload_dir="${NOMAD_ARTIFACT_PATH}/payload/nomad"
fi
}
artifact_mode_enabled() {
[[ "${artifact_mode}" == 'true' ]]
}
artifact_manifest_get() {
# The manifest is data, not shell. Read individual keys instead of sourcing it
# so a malformed or hostile bundle cannot execute code on the target.
local key="$1"
awk -F= -v wanted="${key}" '$1 == wanted { sub(/^[^=]*=/, "", $0); print; exit }' "${artifact_manifest_file}"
}
normalize_arch() {
case "$1" in
x86_64|amd64) echo 'amd64' ;;
aarch64|arm64) echo 'arm64' ;;
*) echo "$1" ;;
esac
}
os_release_get() {
local key="$1"
[[ -r /etc/os-release ]] || return 0
awk -F= -v wanted="${key}" '$1 == wanted { gsub(/^"|"$/, "", $2); print $2; exit }' /etc/os-release
}
validate_artifact_bundle() {
echo -e "${YELLOW}#${RESET} Offline artifact mode selected: ${NOMAD_ARTIFACT_PATH}\\n"
if ! command -v sha256sum &> /dev/null; then
header_red
echo -e "${RED}#${RESET} sha256sum is required to verify an offline artifact bundle but was not found."
exit 1
fi
# Distinguish "not a bundle at all" from "a damaged bundle". Pointing
# --artifacts at a source checkout instead of a built bundle is an easy
# mistake to make, and deserves a better answer than a missing-file list.
if [[ ! -f "${artifact_manifest_file}" ]]; then
header_red
echo -e "${RED}#${RESET} ${NOMAD_ARTIFACT_PATH} is not an offline artifact bundle (it has no 'manifest' file).\\n"
echo -e "${RED}#${RESET} --artifacts must point at a bundle directory, not at a Project NOMAD source"
echo -e "${RED}#${RESET} checkout. Bundles are named project-nomad-offline-<os>-<version>-<arch>-<commit>"
echo -e "${RED}#${RESET} and contain a manifest, SHA256SUMS, packages/, images/ and payload/.\\n"
echo -e "${RED}#${RESET} Build one on an internet-connected machine:"
echo -e "${RED}#${RESET} ./install/build_offline_bundle.sh --target ubuntu:26.04 --output <output-dir>\\n"
echo -e "${RED}#${RESET} Then copy the resulting directory to this machine, cd into it, and run:"
echo -e "${RED}#${RESET} sudo bash ./install_nomad.sh --artifacts ."
exit 1
fi
# Every one of these is required for a complete offline install. A bundle that
# is missing any of them must fail here rather than part-way through, because
# artifact mode has no network fallback to fill the gap.
local required_files=(
"${NOMAD_ARTIFACT_PATH}/SHA256SUMS"
"${artifact_apt_repo_dir}/Packages"
"${artifact_apt_repo_dir}/Packages.gz"
"${artifact_image_dir}/core-images.txt"
"${artifact_image_dir}/core-images.tar"
"${artifact_payload_dir}/management_compose.yaml"
"${artifact_payload_dir}/compose.artifact.yml"
"${artifact_payload_dir}/start_nomad.sh"
"${artifact_payload_dir}/stop_nomad.sh"
"${artifact_payload_dir}/update_nomad.sh"
)
local required_file
for required_file in "${required_files[@]}"; do
if [[ ! -f "${required_file}" ]]; then
header_red
echo -e "${RED}#${RESET} The offline artifact bundle is incomplete. Missing: ${required_file}"
echo -e "${RED}#${RESET} Rebuild the bundle with install/build_offline_bundle.sh on a connected machine and try again."
exit 1
fi
done
# Transfer integrity only. These checksums prove the bundle arrived intact;
# they are not a publisher signature and do not prove who produced it.
echo -e "${YELLOW}#${RESET} Verifying artifact bundle checksums...\\n"
if ! (cd "${NOMAD_ARTIFACT_PATH}" && sha256sum -c SHA256SUMS > /dev/null); then
header_red
echo -e "${RED}#${RESET} The offline artifact bundle failed checksum verification."
echo -e "${RED}#${RESET} It is corrupt or incomplete — copy it again from the build machine and retry."
exit 1
fi
local bundle_format target_os target_version target_arch
bundle_format="$(artifact_manifest_get BUNDLE_FORMAT_VERSION)"
target_os="$(artifact_manifest_get TARGET_OS)"
target_version="$(artifact_manifest_get TARGET_VERSION)"
target_arch="$(artifact_manifest_get TARGET_ARCH)"
if [[ "${bundle_format}" != "${SUPPORTED_BUNDLE_FORMAT_VERSION}" ]]; then
header_red
echo -e "${RED}#${RESET} Unsupported artifact bundle format '${bundle_format:-unknown}'."
echo -e "${RED}#${RESET} This installer supports bundle format ${SUPPORTED_BUNDLE_FORMAT_VERSION}."
exit 1
fi
if [[ -z "${target_os}" || -z "${target_version}" || -z "${target_arch}" ]]; then
header_red
echo -e "${RED}#${RESET} The artifact manifest is incomplete — TARGET_OS, TARGET_VERSION and TARGET_ARCH are all required."
exit 1
fi
# Bundles carry a dependency closure resolved for one exact distribution and
# architecture, so a mismatch is an error rather than a warning.
local host_os host_version host_arch
host_os="$(os_release_get ID)"
host_version="$(os_release_get VERSION_ID)"
host_arch="$(normalize_arch "$(dpkg --print-architecture 2>/dev/null || uname -m)")"
if [[ "${host_os}" != "${target_os}" ]]; then
header_red
echo -e "${RED}#${RESET} This bundle targets ${target_os}, but this host is ${host_os:-unknown}."
echo -e "${RED}#${RESET} Build a bundle for ${host_os:-this host} and try again."
exit 1
fi
if [[ "${host_version}" != "${target_version}" ]]; then
header_red
echo -e "${RED}#${RESET} This bundle targets ${target_os} ${target_version}, but this host runs ${host_version:-unknown}."
echo -e "${RED}#${RESET} Bundles are specific to one OS version because they carry a resolved package set."
exit 1
fi
if [[ "${host_arch}" != "${target_arch}" ]]; then
header_red
echo -e "${RED}#${RESET} This bundle targets ${target_arch}, but this host is ${host_arch:-unknown}."
exit 1
fi
echo -e "${GREEN}#${RESET} Artifact bundle validated (NOMAD commit $(artifact_manifest_get NOMAD_COMMIT), built $(artifact_manifest_get CREATED_AT_UTC)).\\n"
}
header() {
if [[ "${script_option_debug}" != 'true' ]]; then clear; clear; fi
echo -e "${GREEN}#########################################################################${RESET}\\n"
@ -217,6 +437,98 @@ ensure_docker_installed() {
fi
}
install_packages_from_artifacts() {
# Artifact-mode replacement for ensure_dependencies_installed + ensure_docker_installed.
# Everything comes from the bundle's flat APT repository; the host's configured
# remote repositories take no part in dependency resolution.
echo -e "${YELLOW}#${RESET} Installing host dependencies from the offline artifact bundle...\\n"
local apt_root
if ! apt_root="$(mktemp -d /tmp/nomad-artifact-apt.XXXXXX)"; then
header_red
echo -e "${RED}#${RESET} Failed to create a temporary directory for the local APT repository."
exit 1
fi
# Removable media is routinely mounted under a path containing spaces, which
# the APT "file:" source syntax cannot express. Reach the bundle repository
# through a whitespace-free symlink instead.
if ! ln -s "${artifact_apt_repo_dir}" "${apt_root}/repo"; then
header_red
echo -e "${RED}#${RESET} Failed to link the local APT repository at ${artifact_apt_repo_dir}."
exit 1
fi
mkdir -p "${apt_root}/lists/partial"
echo "deb [trusted=yes] file:${apt_root}/repo ./" > "${apt_root}/sources.list"
# Isolation: only the bundle repository is visible (sourcelist), /etc/apt/sources.list.d
# is excluded (sourceparts=-), list state is kept out of the host's (Dir::State::Lists),
# and APT is told not to retry fetches. A dependency that is missing from the
# bundle therefore fails the install instead of being pulled from the internet.
local apt_opts=(
-o "Dir::Etc::sourcelist=${apt_root}/sources.list"
-o "Dir::Etc::sourceparts=-"
-o "Dir::State::Lists=${apt_root}/lists"
-o "APT::Get::List-Cleanup=0"
-o "Acquire::Languages=none"
-o "Acquire::Retries=0"
)
# Mirrors what the online path installs: the dependencies checked by
# ensure_dependencies_installed, the Docker packages the convenience script
# would install, and the host utilities used later by verify_gpu_setup.
local packages=(
ca-certificates
curl
gnupg
jq
pciutils
docker-ce
docker-ce-cli
containerd.io
docker-buildx-plugin
docker-compose-plugin
)
if [[ "$(artifact_manifest_get WITH_NVIDIA_TOOLKIT)" == '1' ]]; then
packages+=(nvidia-container-toolkit)
fi
if ! sudo apt-get "${apt_opts[@]}" update; then
header_red
echo -e "${RED}#${RESET} Failed to read the offline APT repository at ${artifact_apt_repo_dir}."
rm -rf "${apt_root}"
exit 1
fi
if ! sudo DEBIAN_FRONTEND=noninteractive apt-get "${apt_opts[@]}" install -y --no-install-recommends "${packages[@]}"; then
header_red
echo -e "${RED}#${RESET} Failed to install host dependencies from the offline artifact bundle."
echo -e "${RED}#${RESET} The bundle is missing one or more packages required by this system. Rebuild it"
echo -e "${RED}#${RESET} on a connected machine for $(artifact_manifest_get TARGET_OS) $(artifact_manifest_get TARGET_VERSION) and try again."
rm -rf "${apt_root}"
exit 1
fi
rm -rf "${apt_root}"
if ! command -v docker &> /dev/null; then
header_red
echo -e "${RED}#${RESET} Docker is still not available after installing packages from the bundle."
exit 1
fi
if ! systemctl is-active --quiet docker; then
echo -e "${YELLOW}#${RESET} Docker is installed but not running. Attempting to start Docker...\\n"
sudo systemctl enable --now docker
if ! systemctl is-active --quiet docker; then
echo -e "${RED}#${RESET} Failed to start Docker. Please check the Docker service status and try again."
exit 1
fi
fi
echo -e "${GREEN}#${RESET} Host dependencies installed from the offline artifact bundle.\\n"
}
check_docker_compose() {
# Check if 'docker compose' (v2 plugin) is available
if ! docker compose version &>/dev/null; then
@ -227,12 +539,10 @@ check_docker_compose() {
fi
}
setup_nvidia_container_toolkit() {
# This function attempts to set up NVIDIA GPU support but is non-blocking
# Any failures will result in warnings but will NOT stop the installation process
echo -e "${YELLOW}#${RESET} Checking for NVIDIA GPU...\\n"
detect_nvidia_gpu() {
# Shared hardware detection for the online and artifact toolkit paths.
# Returns 0 when an NVIDIA GPU is present.
# Safely detect NVIDIA GPU
local has_nvidia_gpu=false
if command -v lspci &> /dev/null; then
@ -241,7 +551,7 @@ setup_nvidia_container_toolkit() {
echo -e "${GREEN}#${RESET} NVIDIA GPU detected.\\n"
fi
fi
# Also check for nvidia-smi
if ! $has_nvidia_gpu && command -v nvidia-smi &> /dev/null; then
if nvidia-smi &> /dev/null; then
@ -249,12 +559,63 @@ setup_nvidia_container_toolkit() {
echo -e "${GREEN}#${RESET} NVIDIA GPU detected via nvidia-smi.\\n"
fi
fi
if ! $has_nvidia_gpu; then
$has_nvidia_gpu
}
setup_nvidia_container_toolkit_from_artifacts() {
# Artifact-mode counterpart of setup_nvidia_container_toolkit. Same non-blocking
# philosophy: warn and continue rather than fail the install. The toolkit, when
# present, was installed from the bundle by install_packages_from_artifacts —
# the NVIDIA package repository is never contacted.
echo -e "${YELLOW}#${RESET} Checking for NVIDIA GPU...\\n"
if ! detect_nvidia_gpu; then
echo -e "${YELLOW}#${RESET} No NVIDIA GPU detected. Skipping NVIDIA container toolkit configuration.\\n"
return 0
fi
if ! command -v nvidia-ctk &> /dev/null; then
echo -e "${YELLOW}#${RESET} Warning: an NVIDIA GPU is present but this bundle does not include the NVIDIA"
echo -e "${YELLOW}#${RESET} Container Toolkit. Rebuild the bundle without --without-nvidia-toolkit to add it.\\n"
echo -e "${YELLOW}#${RESET} Continuing without NVIDIA container acceleration.\\n"
return 0
fi
if ! command -v nvidia-smi &> /dev/null || ! nvidia-smi &> /dev/null; then
echo -e "${YELLOW}#${RESET} Warning: the NVIDIA Container Toolkit is installed but no working host NVIDIA"
echo -e "${YELLOW}#${RESET} driver was detected. Host GPU drivers are outside the scope of offline bundles"
echo -e "${YELLOW}#${RESET} and must be installed separately. Continuing without GPU acceleration.\\n"
return 0
fi
echo -e "${YELLOW}#${RESET} Configuring Docker to use NVIDIA runtime...\\n"
if ! sudo nvidia-ctk runtime configure --runtime=docker 2>/dev/null; then
echo -e "${YELLOW}#${RESET} Warning: nvidia-ctk runtime configuration failed. GPU support may require manual setup.\\n"
return 0
fi
echo -e "${YELLOW}#${RESET} Restarting Docker service...\\n"
if ! sudo systemctl restart docker 2>/dev/null; then
echo -e "${YELLOW}#${RESET} Warning: Failed to restart Docker service. You may need to restart it manually.\\n"
return 0
fi
echo -e "${GREEN}#${RESET} NVIDIA container toolkit configuration completed.\\n"
}
setup_nvidia_container_toolkit() {
# This function attempts to set up NVIDIA GPU support but is non-blocking
# Any failures will result in warnings but will NOT stop the installation process
echo -e "${YELLOW}#${RESET} Checking for NVIDIA GPU...\\n"
if ! detect_nvidia_gpu; then
echo -e "${YELLOW}#${RESET} No NVIDIA GPU detected. Skipping NVIDIA container toolkit installation.\\n"
return 0
fi
# Check if nvidia-container-toolkit is already installed
if command -v nvidia-ctk &> /dev/null; then
echo -e "${GREEN}#${RESET} NVIDIA container toolkit is already installed.\\n"
@ -353,6 +714,25 @@ setup_nvidia_container_toolkit() {
}
get_install_confirmation(){
if artifact_mode_enabled && [[ "${existing_install}" == 'true' ]]; then
# Re-running artifact mode over an existing install is the supported offline
# update path: new images and helper scripts are applied, data is kept.
echo -e "${YELLOW}#${RESET} An existing Project NOMAD installation was found at ${NOMAD_DIR}."
echo -e "${YELLOW}#${RESET} This will update it from the artifact bundle, keeping your database, installed apps and content."
echo -e "${YELLOW}#${RESET} Backing up ${NOMAD_DIR} first is still recommended."
read -p "Are you sure you want to continue? (y/N): " choice
case "$choice" in
y|Y )
echo -e "${GREEN}#${RESET} User chose to continue with the update."
return 0
;;
* )
echo "User chose not to continue with the update."
exit 0
;;
esac
fi
echo -e "${YELLOW}#${RESET} This script will install Project NOMAD and its dependencies on your machine."
echo -e "${YELLOW}#${RESET} If you already have Project NOMAD installed with customized config or data, please be aware that running this installation script may overwrite existing files and configurations. It is highly recommended to back up any important data/configs before proceeding."
read -p "Are you sure you want to continue? (y/N): " choice
@ -407,6 +787,23 @@ create_nomad_directory(){
sudo touch "${NOMAD_DIR}/storage/logs/admin.log"
}
copy_artifact_payload_file() {
local source_name="$1"
local destination="$2"
if [[ ! -f "${artifact_payload_dir}/${source_name}" ]]; then
header_red
echo -e "${RED}#${RESET} Required file missing from the artifact bundle payload: ${source_name}"
exit 1
fi
if ! cp "${artifact_payload_dir}/${source_name}" "${destination}"; then
header_red
echo -e "${RED}#${RESET} Failed to copy ${source_name} from the artifact bundle to ${destination}."
exit 1
fi
}
download_management_compose_file() {
local compose_file_path="${NOMAD_DIR}/compose.yml"
@ -416,23 +813,89 @@ download_management_compose_file() {
exit 1
fi
echo -e "${GREEN}#${RESET} Docker compose file downloaded successfully to $compose_file_path.\\n"
}
local app_key=$(generateRandomPass)
local db_root_password=$(generateRandomPass)
local db_user_password=$(generateRandomPass)
copy_management_compose_file_from_artifacts() {
local compose_file_path="${NOMAD_DIR}/compose.yml"
# If MySQL data directory exists from a previous install attempt, remove it.
# MySQL only initializes credentials on first startup when the data dir is empty.
# If stale data exists, MySQL ignores the new passwords above and uses the old ones,
# causing "Access denied" errors when the admin container tries to connect.
if [[ -d "${NOMAD_DIR}/mysql" ]]; then
echo -e "${YELLOW}#${RESET} Removing existing MySQL data directory to ensure credentials match...\\n"
sudo rm -rf "${NOMAD_DIR}/mysql"
echo -e "${YELLOW}#${RESET} Installing docker-compose file for management from the artifact bundle...\\n"
copy_artifact_payload_file management_compose.yaml "$compose_file_path"
# Layered at startup to force pull_policy: never for every service, so the
# canonical compose file keeps its normal pull_policy: always.
copy_artifact_payload_file compose.artifact.yml "${NOMAD_DIR}/compose.artifact.yml"
echo -e "${GREEN}#${RESET} Docker compose file installed successfully to $compose_file_path.\\n"
}
compose_env_value() {
# Reads a "- KEY=value" entry from a compose file. Prints nothing when the key
# is absent or still holds the placeholder.
local key="$1" file="$2" value
[[ -f "$file" ]] || return 0
value="$(awk -v key="${key}" '
$0 ~ "^[[:space:]]*-[[:space:]]*" key "=" {
sub("^[[:space:]]*-[[:space:]]*" key "=", "", $0)
print
exit
}
' "$file")"
[[ "$value" != 'replaceme' ]] || return 0
echo "$value"
}
detect_existing_installation() {
# Must run before the compose file is replaced, so an update can carry the
# generated credentials forward instead of orphaning the existing database.
local compose_file_path="${NOMAD_DIR}/compose.yml"
[[ -f "$compose_file_path" ]] || return 0
existing_app_key="$(compose_env_value APP_KEY "$compose_file_path")"
existing_db_password="$(compose_env_value MYSQL_PASSWORD "$compose_file_path")"
existing_db_root_password="$(compose_env_value MYSQL_ROOT_PASSWORD "$compose_file_path")"
existing_url="$(compose_env_value URL "$compose_file_path")"
if [[ -n "$existing_app_key" && -n "$existing_db_password" && -n "$existing_db_root_password" ]]; then
existing_install='true'
fi
}
configure_management_compose_file() {
local compose_file_path="${NOMAD_DIR}/compose.yml"
local app_key db_root_password db_user_password
# Re-running artifact mode over an existing install is an update, not a fresh
# install: keep the credentials the database was initialised with so its data,
# installed apps and settings survive. MySQL only applies these passwords on
# first startup, so regenerating them here is what would force wiping the data
# directory.
if artifact_mode_enabled && [[ "${existing_install}" == 'true' ]]; then
echo -e "${YELLOW}#${RESET} Existing Project NOMAD installation detected — updating in place and keeping your data.\\n"
app_key="${existing_app_key}"
db_root_password="${existing_db_root_password}"
db_user_password="${existing_db_password}"
else
app_key=$(generateRandomPass)
db_root_password=$(generateRandomPass)
db_user_password=$(generateRandomPass)
# If MySQL data directory exists from a previous install attempt, remove it.
# MySQL only initializes credentials on first startup when the data dir is empty.
# If stale data exists, MySQL ignores the new passwords above and uses the old ones,
# causing "Access denied" errors when the admin container tries to connect.
if [[ -d "${NOMAD_DIR}/mysql" ]]; then
echo -e "${YELLOW}#${RESET} Removing existing MySQL data directory to ensure credentials match...\\n"
sudo rm -rf "${NOMAD_DIR}/mysql"
fi
fi
# Inject dynamic env values into the compose file
echo -e "${YELLOW}#${RESET} Configuring docker-compose file env variables...\\n"
sed -i "s|URL=replaceme|URL=http://${local_ip_address}:8080|g" "$compose_file_path"
if artifact_mode_enabled && [[ -n "${existing_url}" ]]; then
# Keep the address the instance is already reachable at rather than silently
# repointing it during an update.
sed -i "s|URL=replaceme|URL=${existing_url}|g" "$compose_file_path"
else
sed -i "s|URL=replaceme|URL=http://${local_ip_address}:8080|g" "$compose_file_path"
fi
sed -i "s|APP_KEY=replaceme|APP_KEY=${app_key}|g" "$compose_file_path"
sed -i "s|DB_PASSWORD=replaceme|DB_PASSWORD=${db_user_password}|g" "$compose_file_path"
@ -442,6 +905,18 @@ download_management_compose_file() {
echo -e "${GREEN}#${RESET} Docker compose file configured successfully.\\n"
}
setup_management_compose_file() {
# Read the outgoing compose file before it is overwritten.
detect_existing_installation
if artifact_mode_enabled; then
copy_management_compose_file_from_artifacts
else
download_management_compose_file
fi
configure_management_compose_file
}
download_helper_scripts() {
local start_script_path="${NOMAD_DIR}/start_nomad.sh"
local stop_script_path="${NOMAD_DIR}/stop_nomad.sh"
@ -469,9 +944,113 @@ download_helper_scripts() {
echo -e "${GREEN}#${RESET} Helper scripts downloaded successfully to $start_script_path, $stop_script_path, and $update_script_path.\\n"
}
copy_helper_scripts_from_artifacts() {
local start_script_path="${NOMAD_DIR}/start_nomad.sh"
local stop_script_path="${NOMAD_DIR}/stop_nomad.sh"
local update_script_path="${NOMAD_DIR}/update_nomad.sh"
echo -e "${YELLOW}#${RESET} Installing helper scripts from the artifact bundle...\\n"
copy_artifact_payload_file start_nomad.sh "$start_script_path"
chmod +x "$start_script_path"
copy_artifact_payload_file stop_nomad.sh "$stop_script_path"
chmod +x "$stop_script_path"
copy_artifact_payload_file update_nomad.sh "$update_script_path"
chmod +x "$update_script_path"
# The uninstall script is normally fetched from GitHub on demand, which a
# disconnected host cannot do. Ship it alongside the others when the bundle
# carries it.
if [[ -f "${artifact_payload_dir}/uninstall_nomad.sh" ]]; then
copy_artifact_payload_file uninstall_nomad.sh "${NOMAD_DIR}/uninstall_nomad.sh"
chmod +x "${NOMAD_DIR}/uninstall_nomad.sh"
fi
echo -e "${GREEN}#${RESET} Helper scripts installed successfully to $start_script_path, $stop_script_path, and $update_script_path.\\n"
}
setup_helper_scripts() {
if artifact_mode_enabled; then
copy_helper_scripts_from_artifacts
else
download_helper_scripts
fi
}
load_artifact_images() {
echo -e "${YELLOW}#${RESET} Loading Docker images from the artifact bundle...\\n"
local archive
local archives_loaded=0
for archive in "${artifact_image_dir}"/*.tar; do
[[ -f "$archive" ]] || continue
# Skip AppleDouble sidecars ("._core-images.tar"), which macOS leaves on
# FAT/exFAT media next to real files. They match *.tar but are metadata.
[[ "$(basename "$archive")" != ._* ]] || continue
echo -e "${YELLOW}#${RESET} Loading $(basename "$archive")...\\n"
if ! sudo docker load -i "$archive"; then
header_red
echo -e "${RED}#${RESET} Failed to load Docker images from $(basename "$archive")."
exit 1
fi
archives_loaded=$((archives_loaded + 1))
done
if [[ "$archives_loaded" -eq 0 ]]; then
header_red
echo -e "${RED}#${RESET} No Docker image archives were found in ${artifact_image_dir}."
exit 1
fi
# Fail closed: the stack starts with --pull never, so a management image that
# did not make it into the archive must be caught here with a clear message
# rather than as an opaque Compose failure.
local image
while IFS= read -r image; do
[[ -n "$image" ]] || continue
if ! sudo docker image inspect "$image" &> /dev/null; then
header_red
echo -e "${RED}#${RESET} The bundle lists image ${image} but it is not present after loading."
echo -e "${RED}#${RESET} Rebuild the bundle on a connected machine and try again."
exit 1
fi
done < "${artifact_image_dir}/core-images.txt"
echo -e "${GREEN}#${RESET} Docker images loaded successfully from the artifact bundle.\\n"
}
seed_artifact_content() {
# Optional extension point: a bundle may carry pre-staged NOMAD storage
# content. Transporting files here does not make every optional app or content
# type installable offline — see admin/docs/offline-install.md.
[[ -d "${NOMAD_ARTIFACT_PATH}/content" ]] || return 0
echo -e "${YELLOW}#${RESET} Seeding pre-staged content into ${NOMAD_DIR}/storage...\\n"
sudo mkdir -p "${NOMAD_DIR}/storage"
# -n so re-running never overwrites content the user has changed or replaced;
# new files in a later bundle are still added.
if ! sudo cp -a -n "${NOMAD_ARTIFACT_PATH}/content/." "${NOMAD_DIR}/storage/"; then
header_red
echo -e "${RED}#${RESET} Failed to seed pre-staged content from the artifact bundle."
exit 1
fi
echo -e "${GREEN}#${RESET} Pre-staged content seeded successfully.\\n"
}
start_management_containers() {
echo -e "${YELLOW}#${RESET} Starting management containers using docker compose...\\n"
if ! sudo docker compose -p project-nomad -f "${NOMAD_DIR}/compose.yml" up -d; then
local compose_args=(-p project-nomad -f "${NOMAD_DIR}/compose.yml")
if artifact_mode_enabled; then
# Layer the bundle's override (pull_policy: never for every service) and
# forbid pulls outright, so startup uses only the images loaded above.
compose_args+=(-f "${NOMAD_DIR}/compose.artifact.yml" up -d --pull never)
else
compose_args+=(up -d)
fi
if ! sudo docker compose "${compose_args[@]}"; then
echo -e "${RED}#${RESET} Failed to start management containers. Please check the logs and try again."
exit 1
fi
@ -479,11 +1058,23 @@ start_management_containers() {
}
get_local_ip() {
local_ip_address=$(hostname -I | awk '{print $1}')
if [[ -z "$local_ip_address" ]]; then
echo -e "${RED}#${RESET} Unable to determine local IP address. Please check your network configuration."
exit 1
local_ip_address=$(hostname -I 2>/dev/null | awk '{print $1}')
if [[ -n "$local_ip_address" ]]; then
has_lan_address='true'
return 0
fi
if artifact_mode_enabled; then
# A genuinely disconnected target may have no LAN address at all. That must
# not block an offline install, so fall back to localhost and simply don't
# advertise a LAN URL.
echo -e "${YELLOW}#${RESET} No LAN address detected. Project NOMAD will be reachable at http://localhost:8080.\\n"
local_ip_address='localhost'
return 0
fi
echo -e "${RED}#${RESET} Unable to determine local IP address. Please check your network configuration."
exit 1
}
verify_gpu_setup() {
# This function only displays GPU setup status and is completely non-blocking
@ -604,7 +1195,12 @@ success_message() {
echo -e "${GREEN}#${RESET} Project NOMAD installation completed successfully!\\n"
echo -e "${GREEN}#${RESET} Installation files are located at /opt/project-nomad\\n\n"
echo -e "${GREEN}#${RESET} Project NOMAD's Command Center should automatically start whenever your device reboots. However, if you need to start it manually, you can always do so by running: ${WHITE_R}${NOMAD_DIR}/start_nomad.sh${RESET}\\n"
echo -e "${GREEN}#${RESET} You can now access the management interface at http://localhost:8080 or http://${local_ip_address}:8080\\n"
if [[ "${has_lan_address}" == 'true' ]]; then
echo -e "${GREEN}#${RESET} You can now access the management interface at http://localhost:8080 or http://${local_ip_address}:8080\\n"
else
# No LAN address was available (offline install on a host with no network).
echo -e "${GREEN}#${RESET} You can now access the management interface at http://localhost:8080\\n"
fi
echo -e "${GREEN}#${RESET} Thank you for supporting Project NOMAD!\\n"
}
@ -614,24 +1210,51 @@ success_message() {
# #
###################################################################################################################################################################################################
# The test suite sources this script to exercise individual functions. Every
# other invocation runs the installer normally.
if [[ "${NOMAD_INSTALLER_LIB_ONLY:-}" == '1' ]]; then
return 0 2>/dev/null || exit 0
fi
parse_installer_args "$@"
# Pre-flight checks
check_is_debian_based
check_is_x86_64
check_is_bash
check_has_sudo
ensure_dependencies_installed
if artifact_mode_enabled; then
# Artifact mode is fail-closed from here on: no dependency is acquired over
# the network, so the bundle is validated up front instead.
validate_artifact_bundle
# Read before anything is written, so the confirmation prompt can tell the
# user whether this is a fresh install or an in-place update.
detect_existing_installation
else
ensure_dependencies_installed
fi
check_is_debug_mode
# Main install
get_install_confirmation
accept_terms
ensure_docker_installed
check_docker_compose
setup_nvidia_container_toolkit
if artifact_mode_enabled; then
install_packages_from_artifacts
check_docker_compose
setup_nvidia_container_toolkit_from_artifacts
else
ensure_docker_installed
check_docker_compose
setup_nvidia_container_toolkit
fi
get_local_ip
create_nomad_directory
download_helper_scripts
download_management_compose_file
setup_helper_scripts
setup_management_compose_file
if artifact_mode_enabled; then
load_artifact_images
seed_artifact_content
fi
start_management_containers
verify_gpu_setup
success_message

View File

@ -0,0 +1,700 @@
#!/bin/bash
# Project NOMAD Offline Artifact Mode Regression Tests
###################################################################################################################################################################################################
#
# Guards the fail-closed behaviour of install_nomad.sh --artifacts and the bundle
# builder. Runs offline and needs no Docker daemon.
#
# Requires a Linux host (the installer reads /etc/os-release and dpkg). Run it
# from anywhere:
#
# bash install/tests/offline_artifact_tests.sh
#
# Or in a container from the repository root:
#
# docker run --rm --network none -v "$PWD:/repo" -w /repo ubuntu:26.04 \
# bash install/tests/offline_artifact_tests.sh
#
###################################################################################################################################################################################################
set -uo pipefail
TESTS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="$(cd -- "${TESTS_DIR}/.." && pwd)"
INSTALLER="${INSTALL_DIR}/install_nomad.sh"
BUILDER="${INSTALL_DIR}/build_offline_bundle.sh"
COMPOSE_FILE="${INSTALL_DIR}/management_compose.yaml"
pass_count=0
fail_count=0
pass() {
pass_count=$((pass_count + 1))
echo " ok - $1"
}
fail() {
fail_count=$((fail_count + 1))
echo " FAIL - $1"
[[ $# -lt 2 ]] || echo " $2"
}
assert_eq() {
local expected="$1" actual="$2" name="$3"
if [[ "${expected}" == "${actual}" ]]; then
pass "${name}"
else
fail "${name}" "expected '${expected}', got '${actual}'"
fi
}
assert_contains() {
local haystack="$1" needle="$2" name="$3"
if [[ "${haystack}" == *"${needle}"* ]]; then
pass "${name}"
else
fail "${name}" "expected to find '${needle}'"
fi
}
assert_not_contains() {
local haystack="$1" needle="$2" name="$3"
if [[ "${haystack}" != *"${needle}"* ]]; then
pass "${name}"
else
fail "${name}" "did not expect to find '${needle}'"
fi
}
# Run a snippet against the installer's functions without running the installer.
installer_eval() {
NOMAD_INSTALLER_LIB_ONLY='1' bash -c "source '${INSTALLER}'; $1"
}
# Same, capturing the exit status of a snippet expected to fail.
installer_status() {
installer_eval "$1" > /dev/null 2>&1
echo "$?"
}
# Print the body of a shell function, relying on the closing brace being in
# column 0 as it is throughout these scripts.
extract_function() {
awk -v fn="$1" '
$0 ~ "^" fn "\\(\\) \\{" { inside = 1; next }
inside && /^\}/ { exit }
inside { print }
' "$2"
}
# Service names declared in the management compose file, parsed without Docker.
compose_services() {
awk '
/^services:[[:space:]]*$/ { in_services = 1; next }
/^[^[:space:]#]/ { in_services = 0 }
in_services && /^ [A-Za-z0-9._-]+:[[:space:]]*$/ {
gsub(/^ |:[[:space:]]*$/, "", $0)
print
}
' "$1"
}
###################################################################################################################################################################################################
# #
# Test Fixtures #
# #
###################################################################################################################################################################################################
FIXTURE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/nomad-artifact-tests.XXXXXX")"
trap 'rm -rf "${FIXTURE_ROOT}"' EXIT
host_os="$(awk -F= '$1 == "ID" { gsub(/^"|"$/, "", $2); print $2; exit }' /etc/os-release 2>/dev/null || true)"
host_version="$(awk -F= '$1 == "VERSION_ID" { gsub(/^"|"$/, "", $2); print $2; exit }' /etc/os-release 2>/dev/null || true)"
host_arch="$(dpkg --print-architecture 2>/dev/null || uname -m)"
case "${host_arch}" in
x86_64|amd64) host_arch='amd64' ;;
aarch64|arm64) host_arch='arm64' ;;
esac
if [[ -z "${host_os}" || -z "${host_version}" ]]; then
echo "These tests need a Linux host with /etc/os-release." >&2
echo "Run them in a container, for example:" >&2
echo " docker run --rm --network none -v \"\$PWD:/repo\" -w /repo ubuntu:26.04 \\" >&2
echo " bash install/tests/offline_artifact_tests.sh" >&2
exit 2
fi
# Build a structurally complete bundle. Contents are placeholders — validation
# checks presence, checksums and the manifest, not package internals.
make_fixture_bundle() {
local bundle="$1"
local os="${2:-${host_os}}"
local version="${3:-${host_version}}"
local arch="${4:-${host_arch}}"
local format="${5:-1}"
mkdir -p "${bundle}/packages/apt" "${bundle}/images" "${bundle}/payload/nomad"
echo 'placeholder' > "${bundle}/install_nomad.sh"
printf 'Package: docker-ce\n' > "${bundle}/packages/apt/Packages"
gzip -9c "${bundle}/packages/apt/Packages" > "${bundle}/packages/apt/Packages.gz"
echo 'placeholder' > "${bundle}/packages/apt/docker-ce.deb"
printf 'mysql:8.0\nredis:7-alpine\n' > "${bundle}/images/core-images.txt"
echo 'placeholder' > "${bundle}/images/core-images.tar"
cp "${COMPOSE_FILE}" "${bundle}/payload/nomad/management_compose.yaml"
local service
{
echo 'services:'
while IFS= read -r service; do
echo " ${service}:"
echo ' pull_policy: never'
done < <(compose_services "${COMPOSE_FILE}")
} > "${bundle}/payload/nomad/compose.artifact.yml"
local name
for name in start_nomad.sh stop_nomad.sh update_nomad.sh; do
echo 'placeholder' > "${bundle}/payload/nomad/${name}"
done
cat > "${bundle}/manifest" <<EOF
BUNDLE_FORMAT_VERSION=${format}
NOMAD_COMMIT=0123456789abcdef0123456789abcdef01234567
TARGET_OS=${os}
TARGET_VERSION=${version}
TARGET_ARCH=${arch}
WITH_NVIDIA_TOOLKIT=1
CREATED_AT_UTC=2026-08-14T00:00:00Z
EOF
(
cd "${bundle}" || exit 1
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
)
}
VALID_BUNDLE="${FIXTURE_ROOT}/valid"
make_fixture_bundle "${VALID_BUNDLE}"
###################################################################################################################################################################################################
# #
# Tests #
# #
###################################################################################################################################################################################################
echo ''
echo '# Argument and environment parsing'
assert_eq 'true' \
"$(installer_eval "parse_installer_args --artifacts '${VALID_BUNDLE}'; echo \"\${artifact_mode}\"")" \
'--artifacts PATH enables artifact mode'
assert_eq 'true' \
"$(installer_eval "parse_installer_args --artifacts='${VALID_BUNDLE}'; echo \"\${artifact_mode}\"")" \
'--artifacts=PATH enables artifact mode'
assert_eq 'true' \
"$(NOMAD_ARTIFACT_PATH="${VALID_BUNDLE}" installer_eval 'parse_installer_args; echo "${artifact_mode}"')" \
'NOMAD_ARTIFACT_PATH enables artifact mode'
mkdir -p "${FIXTURE_ROOT}/from-env"
assert_eq "${VALID_BUNDLE}" \
"$(NOMAD_ARTIFACT_PATH="${FIXTURE_ROOT}/from-env" installer_eval \
"parse_installer_args --artifacts '${VALID_BUNDLE}'; echo \"\${NOMAD_ARTIFACT_PATH}\"")" \
'command line argument wins over NOMAD_ARTIFACT_PATH'
assert_eq 'false' \
"$(installer_eval 'parse_installer_args; echo "${artifact_mode}"')" \
'no artifact path leaves artifact mode disabled (online install unchanged)'
assert_eq "${VALID_BUNDLE}/payload/nomad" \
"$(installer_eval "parse_installer_args --artifacts '${VALID_BUNDLE}'; echo \"\${artifact_payload_dir}\"")" \
'artifact sub-paths are derived from the bundle root'
assert_eq '1' "$(installer_status 'parse_installer_args --artifacts')" \
'--artifacts without a value fails'
assert_eq '1' "$(installer_status 'parse_installer_args --artifacts=')" \
'--artifacts= with an empty value fails'
assert_eq '1' "$(installer_status 'parse_installer_args --not-a-real-flag')" \
'unknown option fails'
assert_eq '1' "$(installer_status "parse_installer_args --artifacts '${FIXTURE_ROOT}/does-not-exist'")" \
'missing artifact directory fails'
assert_eq '0' "$(installer_status 'parse_installer_args --help')" \
'--help exits successfully'
echo ''
echo '# Bundle validation'
assert_eq '0' \
"$(installer_status "parse_installer_args --artifacts '${VALID_BUNDLE}'; validate_artifact_bundle")" \
'a complete, matching bundle validates'
corrupt_bundle="${FIXTURE_ROOT}/corrupt"
make_fixture_bundle "${corrupt_bundle}"
echo 'tampered' >> "${corrupt_bundle}/images/core-images.tar"
assert_eq '1' \
"$(installer_status "parse_installer_args --artifacts '${corrupt_bundle}'; validate_artifact_bundle")" \
'a bundle failing checksum verification is rejected'
# Pointing --artifacts at a source checkout instead of a bundle is a common
# mistake and must produce a specific, actionable message.
not_a_bundle="${FIXTURE_ROOT}/not-a-bundle"
mkdir -p "${not_a_bundle}"
cp "${INSTALLER}" "${not_a_bundle}/install_nomad.sh"
cp "${COMPOSE_FILE}" "${not_a_bundle}/management_compose.yaml"
not_a_bundle_output="$(installer_eval "parse_installer_args --artifacts '${not_a_bundle}'; validate_artifact_bundle" 2>&1)"
assert_eq '1' \
"$(installer_status "parse_installer_args --artifacts '${not_a_bundle}'; validate_artifact_bundle")" \
'a directory that is not a bundle is rejected'
assert_contains "${not_a_bundle_output}" 'is not an offline artifact bundle' \
'a non-bundle directory gets a specific error, not a missing-file list'
assert_contains "${not_a_bundle_output}" 'build_offline_bundle.sh' \
'the non-bundle error tells the user how to build one'
no_sums_bundle="${FIXTURE_ROOT}/no-sums"
make_fixture_bundle "${no_sums_bundle}"
rm -f "${no_sums_bundle}/SHA256SUMS"
assert_eq '1' \
"$(installer_status "parse_installer_args --artifacts '${no_sums_bundle}'; validate_artifact_bundle")" \
'a bundle without SHA256SUMS is rejected'
for missing in payload/nomad/compose.artifact.yml images/core-images.tar packages/apt/Packages.gz payload/nomad/update_nomad.sh; do
incomplete="${FIXTURE_ROOT}/incomplete-$(echo "${missing}" | tr '/.' '--')"
make_fixture_bundle "${incomplete}"
rm -f "${incomplete}/${missing}"
assert_eq '1' \
"$(installer_status "parse_installer_args --artifacts '${incomplete}'; validate_artifact_bundle")" \
"a bundle missing ${missing} is rejected"
done
incomplete_manifest="${FIXTURE_ROOT}/manifest-incomplete"
make_fixture_bundle "${incomplete_manifest}"
grep -v '^TARGET_ARCH=' "${incomplete_manifest}/manifest" > "${incomplete_manifest}/manifest.tmp"
mv "${incomplete_manifest}/manifest.tmp" "${incomplete_manifest}/manifest"
(cd "${incomplete_manifest}" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS)
assert_eq '1' \
"$(installer_status "parse_installer_args --artifacts '${incomplete_manifest}'; validate_artifact_bundle")" \
'a manifest missing TARGET_ARCH is rejected'
wrong_format="${FIXTURE_ROOT}/wrong-format"
make_fixture_bundle "${wrong_format}" "${host_os}" "${host_version}" "${host_arch}" '99'
assert_eq '1' \
"$(installer_status "parse_installer_args --artifacts '${wrong_format}'; validate_artifact_bundle")" \
'an unsupported bundle format version is rejected'
wrong_os="${FIXTURE_ROOT}/wrong-os"
make_fixture_bundle "${wrong_os}" 'definitely-not-this-os'
assert_eq '1' \
"$(installer_status "parse_installer_args --artifacts '${wrong_os}'; validate_artifact_bundle")" \
'an OS mismatch is rejected'
wrong_version="${FIXTURE_ROOT}/wrong-version"
make_fixture_bundle "${wrong_version}" "${host_os}" '0.00'
assert_eq '1' \
"$(installer_status "parse_installer_args --artifacts '${wrong_version}'; validate_artifact_bundle")" \
'an OS version mismatch is rejected'
wrong_arch="${FIXTURE_ROOT}/wrong-arch"
make_fixture_bundle "${wrong_arch}" "${host_os}" "${host_version}" 'sparc64'
assert_eq '1' \
"$(installer_status "parse_installer_args --artifacts '${wrong_arch}'; validate_artifact_bundle")" \
'an architecture mismatch is rejected'
assert_eq 'amd64' "$(installer_eval 'normalize_arch x86_64')" 'x86_64 normalizes to amd64'
assert_eq 'arm64' "$(installer_eval 'normalize_arch aarch64')" 'aarch64 normalizes to arm64'
# The manifest must be read as data. Proving it is never sourced: a manifest
# holding shell syntax must not execute it.
hostile="${FIXTURE_ROOT}/hostile-manifest"
make_fixture_bundle "${hostile}"
printf 'EVIL=$(touch %s/pwned)\n' "${FIXTURE_ROOT}" >> "${hostile}/manifest"
(cd "${hostile}" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS)
installer_eval "parse_installer_args --artifacts '${hostile}'; validate_artifact_bundle" > /dev/null 2>&1
if [[ -e "${FIXTURE_ROOT}/pwned" ]]; then
fail 'manifest is parsed as data, never sourced' 'manifest contents were executed'
else
pass 'manifest is parsed as data, never sourced'
fi
echo ''
echo '# Local APT repository isolation'
apt_body="$(extract_function install_packages_from_artifacts "${INSTALLER}")"
for opt in \
'Dir::Etc::sourcelist=' \
'Dir::Etc::sourceparts=-' \
'APT::Get::List-Cleanup=0' \
'Acquire::Retries=0'
do
assert_contains "${apt_body}" "${opt}" "artifact APT invocation sets ${opt}"
done
assert_contains "${apt_body}" 'deb [trusted=yes] file:' \
'artifact APT source points at a local file: repository'
apt_invocations="$(echo "${apt_body}" | grep -c 'apt-get' || true)"
isolated_invocations="$(echo "${apt_body}" | grep 'apt-get' | grep -c '"${apt_opts\[@\]}"' || true)"
assert_eq "${apt_invocations}" "${isolated_invocations}" \
'every apt-get invocation in artifact mode uses the isolation options'
echo ''
echo '# Fail-closed: no network acquisition in artifact code paths'
artifact_functions=(
validate_artifact_bundle
install_packages_from_artifacts
setup_nvidia_container_toolkit_from_artifacts
copy_artifact_payload_file
copy_helper_scripts_from_artifacts
copy_management_compose_file_from_artifacts
load_artifact_images
seed_artifact_content
start_management_containers
)
for fn in "${artifact_functions[@]}"; do
body="$(extract_function "${fn}" "${INSTALLER}")"
if [[ -z "${body}" ]]; then
fail "${fn} exists in the installer" 'function not found'
continue
fi
# Comments may legitimately describe the online path, so only executable
# lines are scanned.
offenders="$(echo "${body}" | grep -v '^[[:space:]]*#' \
| grep -nE 'https?://|\bwget\b|curl[[:space:]]+-|docker[[:space:]]+(compose[[:space:]]+)?pull|get\.docker\.com' || true)"
if [[ -z "${offenders}" ]]; then
pass "${fn} performs no network acquisition"
else
fail "${fn} performs no network acquisition" "${offenders}"
fi
done
echo ''
echo '# Compose startup behaviour'
# Intercept the privileged call so the compose command line can be inspected.
compose_stub='sudo() { echo "SUDO $*"; }; NOMAD_DIR=/opt/project-nomad'
artifact_start="$(installer_eval "${compose_stub}; parse_installer_args --artifacts '${VALID_BUNDLE}'; start_management_containers" 2>&1)"
assert_contains "${artifact_start}" '--pull never' 'artifact startup passes --pull never'
assert_contains "${artifact_start}" '-f /opt/project-nomad/compose.artifact.yml' \
'artifact startup layers the generated compose override'
assert_not_contains "${artifact_start}" 'docker compose pull' 'artifact startup never pulls'
online_start="$(installer_eval "${compose_stub}; start_management_containers" 2>&1)"
assert_not_contains "${online_start}" '--pull never' 'online startup is unchanged (no --pull never)'
assert_not_contains "${online_start}" 'compose.artifact.yml' 'online startup does not reference the override'
assert_contains "${online_start}" 'SUDO docker compose -p project-nomad -f /opt/project-nomad/compose.yml up -d' \
'online startup keeps its original compose command'
echo ''
echo '# Acquisition dispatch'
dispatch_stub='download_helper_scripts() { echo ONLINE_HELPERS; }; copy_helper_scripts_from_artifacts() { echo ARTIFACT_HELPERS; }; download_management_compose_file() { echo ONLINE_COMPOSE; }; copy_management_compose_file_from_artifacts() { echo ARTIFACT_COMPOSE; }; configure_management_compose_file() { echo CONFIGURED; }'
assert_eq 'ONLINE_HELPERS' \
"$(installer_eval "${dispatch_stub}; setup_helper_scripts")" \
'helper scripts are downloaded in online mode'
assert_eq 'ARTIFACT_HELPERS' \
"$(installer_eval "${dispatch_stub}; parse_installer_args --artifacts '${VALID_BUNDLE}'; setup_helper_scripts")" \
'helper scripts are copied from the bundle in artifact mode'
assert_eq 'ONLINE_COMPOSE
CONFIGURED' \
"$(installer_eval "${dispatch_stub}; setup_management_compose_file")" \
'online compose acquisition still runs the shared configuration step'
assert_eq 'ARTIFACT_COMPOSE
CONFIGURED' \
"$(installer_eval "${dispatch_stub}; parse_installer_args --artifacts '${VALID_BUNDLE}'; setup_management_compose_file")" \
'artifact compose acquisition runs the same shared configuration step'
echo ''
echo '# Host without a LAN address'
no_ip_stub='hostname() { :; }'
assert_eq 'localhost' \
"$(installer_eval "${no_ip_stub}; parse_installer_args --artifacts '${VALID_BUNDLE}'; get_local_ip > /dev/null; echo \"\${local_ip_address}\"")" \
'artifact mode falls back to localhost when there is no LAN address'
assert_eq 'false' \
"$(installer_eval "${no_ip_stub}; parse_installer_args --artifacts '${VALID_BUNDLE}'; get_local_ip > /dev/null; echo \"\${has_lan_address}\"")" \
'no LAN address is recorded when none exists'
assert_eq '1' "$(installer_status "${no_ip_stub}; get_local_ip")" \
'online mode still fails when no LAN address can be determined'
assert_eq 'true' \
"$(installer_eval 'hostname() { echo "192.168.1.50 10.0.0.1"; }; get_local_ip > /dev/null; echo "${local_ip_address} ${has_lan_address}"' | awk '{print $2}')" \
'a real LAN address is still detected normally'
success_no_lan="$(installer_eval "${no_ip_stub}; parse_installer_args --artifacts '${VALID_BUNDLE}'; get_local_ip > /dev/null; success_message")"
assert_not_contains "${success_no_lan}" ' or http://' \
'no LAN URL is advertised when the host has no LAN address'
success_with_lan="$(installer_eval 'hostname() { echo 192.168.1.50; }; get_local_ip > /dev/null; success_message')"
assert_contains "${success_with_lan}" 'http://192.168.1.50:8080' \
'the LAN URL is still advertised when a LAN address exists'
echo ''
echo '# Bundle builder'
builder_eval() {
NOMAD_BUNDLE_LIB_ONLY='1' bash -c "source '${BUILDER}'; $1"
}
override_out="${FIXTURE_ROOT}/override.yml"
builder_eval "write_pull_never_override '${override_out}' admin mysql redis" > /dev/null 2>&1
override_content="$(cat "${override_out}" 2>/dev/null || true)"
assert_contains "${override_content}" 'pull_policy: never' 'builder writes pull_policy: never'
for service in admin mysql redis; do
assert_contains "${override_content}" " ${service}:" "builder override includes service ${service}"
done
builder_eval "write_pull_never_override '${FIXTURE_ROOT}/empty.yml'" > /dev/null 2>&1
assert_eq '1' "$?" 'builder refuses to write an override with no services'
# Every service in the canonical compose file must end up in the override, or
# that service would still contact a registry on the target.
mapfile -t all_services < <(compose_services "${COMPOSE_FILE}")
if [[ ${#all_services[@]} -eq 0 ]]; then
fail 'management compose services were discovered' 'no services parsed'
else
pass "management compose services were discovered (${#all_services[@]})"
full_override="${FIXTURE_ROOT}/full-override.yml"
builder_eval "write_pull_never_override '${full_override}' ${all_services[*]}" > /dev/null 2>&1
override_service_count="$(grep -c 'pull_policy: never' "${full_override}" || true)"
assert_eq "${#all_services[@]}" "${override_service_count}" \
'generated override covers every management service'
for service in "${all_services[@]}"; do
assert_contains "$(cat "${full_override}")" " ${service}:" \
"generated override covers ${service}"
done
fi
# The builder's package set must stay in step with the installer's, or the
# bundle will be missing something artifact mode then tries to install.
builder_packages="$(awk '/^ARTIFACT_PACKAGES=\(/{p=1;next} p&&/^\)/{exit} p{gsub(/[[:space:]]/,"");print}' "${BUILDER}")"
installer_packages="$(echo "${apt_body}" | awk '/local packages=\(/{p=1;next} p&&/^[[:space:]]*\)/{exit} p{gsub(/[[:space:]]/,"");print}')"
assert_eq "${builder_packages}" "${installer_packages}" \
'builder and installer agree on the host package list'
# Docker must be acquired from the bundle, never from the network, or an
# offline target has no container runtime at all.
for docker_package in docker-ce docker-ce-cli containerd.io docker-compose-plugin; do
assert_contains "${builder_packages}" "${docker_package}" \
"bundle includes ${docker_package} for offline Docker installation"
done
echo ''
echo '# Re-running over an existing install (offline update)'
existing_dir="${FIXTURE_ROOT}/existing-install"
mkdir -p "${existing_dir}"
cat > "${existing_dir}/compose.yml" <<'EOF'
services:
admin:
environment:
- APP_KEY=EXISTINGAPPKEY123456
- URL=http://192.168.1.77:8080
- DB_PASSWORD=EXISTINGDBPASS
mysql:
environment:
- MYSQL_ROOT_PASSWORD=EXISTINGROOTPASS
- MYSQL_PASSWORD=EXISTINGDBPASS
EOF
detect_snippet="NOMAD_DIR='${existing_dir}'; parse_installer_args --artifacts '${VALID_BUNDLE}'; detect_existing_installation"
assert_eq 'true' \
"$(installer_eval "${detect_snippet}; echo \"\${existing_install}\"")" \
'an existing installation is detected from its compose file'
assert_eq 'EXISTINGAPPKEY123456' \
"$(installer_eval "${detect_snippet}; echo \"\${existing_app_key}\"")" \
'the existing APP_KEY is recovered'
assert_eq 'EXISTINGDBPASS' \
"$(installer_eval "${detect_snippet}; echo \"\${existing_db_password}\"")" \
'the existing database password is recovered'
assert_eq 'EXISTINGROOTPASS' \
"$(installer_eval "${detect_snippet}; echo \"\${existing_db_root_password}\"")" \
'the existing database root password is recovered'
assert_eq 'http://192.168.1.77:8080' \
"$(installer_eval "${detect_snippet}; echo \"\${existing_url}\"")" \
'the existing access URL is recovered'
# A fresh install must not be mistaken for an update.
fresh_dir="${FIXTURE_ROOT}/fresh-install"
mkdir -p "${fresh_dir}"
assert_eq 'false' \
"$(installer_eval "NOMAD_DIR='${fresh_dir}'; parse_installer_args --artifacts '${VALID_BUNDLE}'; detect_existing_installation; echo \"\${existing_install}\"")" \
'a directory with no compose file is treated as a fresh install'
placeholder_dir="${FIXTURE_ROOT}/placeholder-install"
mkdir -p "${placeholder_dir}"
printf 'services:\n admin:\n environment:\n - APP_KEY=replaceme\n' > "${placeholder_dir}/compose.yml"
assert_eq 'false' \
"$(installer_eval "NOMAD_DIR='${placeholder_dir}'; parse_installer_args --artifacts '${VALID_BUNDLE}'; detect_existing_installation; echo \"\${existing_install}\"")" \
'an unconfigured compose file is not treated as an existing install'
# The critical guarantee: updating must not wipe the database directory.
update_dir="${FIXTURE_ROOT}/update-preserves"
mkdir -p "${update_dir}/mysql"
cp "${existing_dir}/compose.yml" "${update_dir}/compose.yml"
echo 'user data' > "${update_dir}/mysql/ibdata1"
installer_eval "NOMAD_DIR='${update_dir}'; parse_installer_args --artifacts '${VALID_BUNDLE}'; sudo() { :; }; detect_existing_installation; configure_management_compose_file" > /dev/null 2>&1
if [[ -f "${update_dir}/mysql/ibdata1" ]]; then
pass 'updating an existing install does not delete the MySQL data directory'
else
fail 'updating an existing install does not delete the MySQL data directory' 'data directory was removed'
fi
updated_compose="$(cat "${update_dir}/compose.yml")"
assert_contains "${updated_compose}" 'APP_KEY=EXISTINGAPPKEY123456' \
'the existing APP_KEY is carried into the updated compose file'
assert_contains "${updated_compose}" 'MYSQL_PASSWORD=EXISTINGDBPASS' \
'the existing database password is carried into the updated compose file'
# Online mode keeps its original behaviour, including the deliberate wipe.
online_dir="${FIXTURE_ROOT}/online-install"
mkdir -p "${online_dir}/mysql"
cp "${existing_dir}/compose.yml" "${online_dir}/compose.yml"
online_configure="$(installer_eval "NOMAD_DIR='${online_dir}'; detect_existing_installation; configure_management_compose_file" 2>&1)"
assert_contains "${online_configure}" 'Removing existing MySQL data directory' \
'online mode still resets the database directory as before'
echo ''
echo '# Removable media metadata (FAT/exFAT AppleDouble sidecars)'
# A bundle carried on FAT/exFAT collects "._name" sidecars from macOS. They must
# never be treated as bundle content, or checksums fail and ._core-images.tar
# gets fed to docker load.
appledouble_bundle="${FIXTURE_ROOT}/appledouble"
make_fixture_bundle "${appledouble_bundle}"
printf 'mac metadata\n' > "${appledouble_bundle}/images/._core-images.tar"
printf 'mac metadata\n' > "${appledouble_bundle}/._install_nomad.sh"
printf 'mac metadata\n' > "${appledouble_bundle}/packages/apt/._docker-ce.deb"
assert_eq '0' \
"$(installer_status "parse_installer_args --artifacts '${appledouble_bundle}'; validate_artifact_bundle")" \
'a bundle still validates when the OS adds AppleDouble sidecars'
assert_contains "$(extract_function load_artifact_images "${INSTALLER}")" '._*' \
'image loading skips AppleDouble sidecars that match *.tar'
checksum_body="$(extract_function write_checksums "${BUILDER}")"
assert_contains "${checksum_body}" "! -name '._*'" \
'checksum generation excludes AppleDouble sidecars'
assert_contains "${checksum_body}" "-name '.DS_Store'" \
'checksum generation excludes .DS_Store'
echo ''
echo '# Docker-only build entry point'
DOCKER_WRAPPER="${INSTALL_DIR}/build_offline_bundle_docker.sh"
if [[ -f "${DOCKER_WRAPPER}" ]]; then
pass 'the Docker build entry point exists'
wrapper_src="$(cat "${DOCKER_WRAPPER}")"
assert_contains "${wrapper_src}" '${DOCKER_SOCKET}:/var/run/docker.sock' \
'wrapper mounts the Docker socket so nested builds reach the host daemon'
assert_contains "${wrapper_src}" '-v "${path}:${path}"' \
'wrapper mounts host paths at identical locations inside the container'
assert_contains "${wrapper_src}" 'exec bash install/build_offline_bundle.sh "$@"' \
'wrapper delegates to the real builder with arguments passed through'
# The whole point is that the host needs nothing but Docker, so the wrapper
# must not depend on host tooling the builder needs.
wrapper_code="$(echo "${wrapper_src}" | grep -v '^[[:space:]]*#')"
for host_tool in sha256sum dpkg-scanpackages; do
assert_not_contains "${wrapper_code}" "${host_tool}" \
"wrapper does not require ${host_tool} on the host"
done
# Options that name a path must be resolved and mounted, or the nested
# containers cannot see them.
for path_option in '--output' '--repo' '--content-dir' '--extra-image-archive' '--extra-image-list'; do
assert_contains "${wrapper_code}" "${path_option}" \
"wrapper resolves and mounts ${path_option}"
done
wrapper_eval() {
NOMAD_BUILDER_LIB_ONLY='1' bash -c "source '${DOCKER_WRAPPER}'; $1"
}
# Output location selection. The menu offers the current location first, then
# connected removable drives, then home.
assert_eq "${PWD}/dist" \
"$(printf '1\n' | wrapper_eval 'OUTPUT_CHOICE=""; prompt_for_output_dir >/dev/null 2>&1; echo "${OUTPUT_CHOICE}"')" \
'choosing 1 writes the bundle where the build is running from'
assert_eq "${PWD}/dist" \
"$(printf '\n' | wrapper_eval 'OUTPUT_CHOICE=""; prompt_for_output_dir >/dev/null 2>&1; echo "${OUTPUT_CHOICE}"')" \
'pressing enter accepts the default location'
assert_eq '/tmp/nomad-custom-target' \
"$(printf 'c\n/tmp/nomad-custom-target\n' | wrapper_eval 'OUTPUT_CHOICE=""; prompt_for_output_dir >/dev/null 2>&1; echo "${OUTPUT_CHOICE}"')" \
'a custom path can be entered'
assert_eq "${PWD}/dist" \
"$(printf '99\nnonsense\n1\n' | wrapper_eval 'OUTPUT_CHOICE=""; prompt_for_output_dir >/dev/null 2>&1; echo "${OUTPUT_CHOICE}"')" \
'invalid input re-prompts rather than picking something arbitrary'
menu_output="$(printf '1\n' | wrapper_eval 'prompt_for_output_dir 2>&1' || true)"
assert_contains "${menu_output}" 'enter a custom path' 'the menu offers a custom path'
assert_contains "${menu_output}" '[default]' 'the menu marks a default choice'
# Unattended runs must never block on stdin.
assert_eq "${PWD}/dist" \
"$(printf '' | wrapper_eval 'OUTPUT_CHOICE=""; choose_output_dir >/dev/null 2>&1; echo "${OUTPUT_CHOICE}"')" \
'a non-interactive stdin uses the default without prompting'
assert_eq "${PWD}/dist" \
"$(NOMAD_NO_PROMPT=1 wrapper_eval 'OUTPUT_CHOICE=""; choose_output_dir >/dev/null 2>&1; echo "${OUTPUT_CHOICE}"' < /dev/null)" \
'NOMAD_NO_PROMPT skips the prompt'
assert_contains "${wrapper_code}" '--no-prompt' 'wrapper accepts --no-prompt'
assert_contains "${wrapper_code}" '! -t 0' 'wrapper treats a non-TTY as non-interactive'
else
fail 'the Docker build entry point exists' "${DOCKER_WRAPPER} not found"
fi
echo ''
echo '# Online installation path is untouched'
for fn in ensure_dependencies_installed ensure_docker_installed download_helper_scripts download_management_compose_file setup_nvidia_container_toolkit; do
if [[ -n "$(extract_function "${fn}" "${INSTALLER}")" ]]; then
pass "online function ${fn} is still present"
else
fail "online function ${fn} is still present" 'function not found'
fi
done
assert_contains "$(extract_function download_management_compose_file "${INSTALLER}")" \
'curl -fsSL "$MANAGEMENT_COMPOSE_FILE_URL"' \
'online compose download still uses the original curl call'
assert_contains "$(extract_function ensure_docker_installed "${INSTALLER}")" \
'https://get.docker.com' \
'online Docker installation still uses the convenience script'
###################################################################################################################################################################################################
echo ''
echo "-------------------------------------------------"
echo " passed: ${pass_count} failed: ${fail_count}"
echo "-------------------------------------------------"
[[ "${fail_count}" -eq 0 ]] || exit 1

View File

@ -0,0 +1,98 @@
#!/bin/bash
# Project NOMAD Offline Artifact Bundle Verifier
###################################################################################################################################################################################################
#
# Inspects and verifies an offline artifact bundle without installing anything.
#
# Useful before transferring a bundle to removable media, and on the target
# before running the installer. Runs entirely offline.
#
# Usage:
# ./verify_offline_bundle.sh [/path/to/bundle]
#
###################################################################################################################################################################################################
set -Eeuo pipefail
RESET='\033[0m'
RED='\033[1;31m'
GREEN='\033[1;32m'
die() {
echo -e "${RED}#${RESET} $*" >&2
exit 1
}
BUNDLE="${1:-.}"
BUNDLE="$(cd -- "${BUNDLE}" 2>/dev/null && pwd)" || die "Bundle directory not found: ${1:-.}"
command -v sha256sum > /dev/null 2>&1 || die "sha256sum is required."
manifest_get() {
local key="$1"
awk -F= -v wanted="${key}" '$1 == wanted { sub(/^[^=]*=/, "", $0); print; exit }' "${BUNDLE}/manifest"
}
# Same required set the installer enforces, so a bundle that passes here will
# not fail validation on the target for a missing file.
required_files=(
manifest
SHA256SUMS
install_nomad.sh
packages/apt/Packages
packages/apt/Packages.gz
images/core-images.txt
images/core-images.tar
payload/nomad/management_compose.yaml
payload/nomad/compose.artifact.yml
payload/nomad/start_nomad.sh
payload/nomad/stop_nomad.sh
payload/nomad/update_nomad.sh
)
for required_file in "${required_files[@]}"; do
[[ -f "${BUNDLE}/${required_file}" ]] || die "Bundle is incomplete. Missing: ${required_file}"
done
echo "Verifying checksums..."
(cd "${BUNDLE}" && sha256sum -c SHA256SUMS > /dev/null) ||
die "Checksum verification failed — the bundle is corrupt or incomplete."
# ! -name '._*' skips macOS AppleDouble sidecars left on FAT/exFAT media.
deb_count="$(find "${BUNDLE}/packages/apt" -maxdepth 1 -name '*.deb' ! -name '._*' | wc -l | tr -d ' ')"
[[ "${deb_count}" -gt 0 ]] || die "Bundle contains no .deb packages."
image_count="$(grep -c '[^[:space:]]' "${BUNDLE}/images/core-images.txt" || true)"
[[ "${image_count}" -gt 0 ]] || die "Bundle lists no management images."
# Every service in the bundled compose file must appear in the pull-never
# override, or that service would still try to reach a registry on the target.
missing_overrides=''
while IFS= read -r service; do
grep -qE "^[[:space:]]+${service}:[[:space:]]*$" "${BUNDLE}/payload/nomad/compose.artifact.yml" ||
missing_overrides="${missing_overrides} ${service}"
done < <(awk '
/^services:[[:space:]]*$/ { in_services = 1; next }
/^[^[:space:]#]/ { in_services = 0 }
in_services && /^ [A-Za-z0-9._-]+:[[:space:]]*$/ {
gsub(/^ |:[[:space:]]*$/, "", $0)
print
}
' "${BUNDLE}/payload/nomad/management_compose.yaml")
[[ -z "${missing_overrides}" ]] ||
die "compose.artifact.yml is missing pull_policy overrides for:${missing_overrides}"
echo ''
printf 'Bundle format : %s\n' "$(manifest_get BUNDLE_FORMAT_VERSION)"
printf 'NOMAD commit : %s\n' "$(manifest_get NOMAD_COMMIT)"
printf 'Target : %s %s (%s)\n' \
"$(manifest_get TARGET_OS)" "$(manifest_get TARGET_VERSION)" "$(manifest_get TARGET_ARCH)"
printf 'NVIDIA toolkit: %s\n' "$(manifest_get WITH_NVIDIA_TOOLKIT)"
printf 'Created (UTC) : %s\n' "$(manifest_get CREATED_AT_UTC)"
printf 'Packages : %s .deb files\n' "${deb_count}"
printf 'Images : %s\n' "${image_count}"
echo ''
echo -e "${GREEN}#${RESET} Bundle verified. Checksums confirm transfer integrity, not publisher identity."