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:
parent
0bd1c6f4f9
commit
40ee026938
|
|
@ -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 "$@"
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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."
|
||||
Loading…
Reference in New Issue