701 lines
31 KiB
Bash
701 lines
31 KiB
Bash
#!/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
|