Air-Gap Helper Scripts#
The documentation site does not distribute executable shell files. Copy each code block on this page into the specified filename on the network-connected machine, keep the files in the same directory, and make them executable:
chmod +x airgap-bundle.sh airgap-load-images.sh
airgap-bundle.sh#
#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
#
# Create a transfer bundle for an air-gapped NIM-LLM deployment.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
OUTPUT_DIR="${OUTPUT_DIR:-$PROJECT_DIR/nim-airgap-bundle}"
CHART_DIR="${CHART_DIR:-$PROJECT_DIR/helm}"
ARCHIVE_PATH=""
SKIP_PULL=false
SKIP_SAVE=false
IMAGES=()
VALUES_FILES=()
MANIFEST_FILES=()
EXTRA_FILES=()
usage() {
cat <<'EOF'
Usage:
scripts/airgap-bundle.sh --image <image:tag> [options]
Options:
--image <image:tag> Container image to include. Repeat for multiple images.
--images-file <path> File containing one image per line. Blank lines and # comments are ignored.
--values <path> Helm values file to copy into the bundle. Repeat as needed.
--manifest <path> Kubernetes manifest or recipe file to copy. Repeat as needed.
--extra <path> Extra file to copy into extras/. Repeat as needed.
--chart-dir <path> Helm chart directory to package. Default: ./helm.
--output-dir <path> Bundle directory. Default: ./nim-airgap-bundle.
--archive <path> Archive path. Default: <output-dir>.tar.gz.
--skip-pull Do not docker pull images before saving.
--skip-save Do not docker save images. Useful for validating bundle layout.
-h, --help Show this help.
Examples:
scripts/airgap-bundle.sh \
--image nvcr.io/nim/my-nim:2.1.0 \
--values ./airgap-values.yaml
scripts/airgap-bundle.sh \
--images-file ./images.txt \
--values ./airgap-values.yaml \
--manifest ./deny-egress.yaml \
--output-dir ./nim-airgap-bundle
EOF
}
die() {
echo "ERROR: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"
}
append_images_file() {
local file="$1"
[ -f "$file" ] || die "Images file not found: $file"
while IFS= read -r line || [ -n "$line" ]; do
line="${line%%#*}"
line="$(echo "$line" | xargs)"
[ -n "$line" ] || continue
IMAGES+=("$line")
done < "$file"
}
copy_file() {
local source="$1"
local dest_dir="$2"
local dest_base
[ -f "$source" ] || die "File not found: $source"
dest_base="$(basename "$source")"
if [ -e "$dest_dir/$dest_base" ]; then
die "Destination file already exists (basename collision): $dest_base. Rename one of the source files"
fi
cp "$source" "$dest_dir/"
}
# Refuse --output-dir values that would make `rm -rf` catastrophic.
# Normal defaults (e.g. ./nim-airgap-bundle) are unchanged.
assert_safe_output_dir() {
local dir="$1"
[ -n "$dir" ] || die "--output-dir must not be empty"
# Strip trailing slashes so "/" and "/." are caught consistently.
while [ -n "$dir" ] && [ "${dir%/}" != "$dir" ]; do
dir="${dir%/}"
done
[ -n "$dir" ] || die "Refusing unsafe --output-dir that resolves to /"
case "$dir" in
.|..)
die "Refusing unsafe --output-dir: $dir"
;;
esac
if command -v realpath >/dev/null 2>&1; then
local resolved
resolved="$(realpath -m -- "$dir")"
[ "$resolved" != "/" ] || die "Refusing unsafe --output-dir that resolves to /"
printf '%s\n' "$resolved"
else
printf '%s\n' "$dir"
fi
}
# airgap-load-images.sh retags by last path component; fail early if two
# distinct sources would map to the same destination name:tag.
assert_unique_image_basenames() {
local -A seen=()
local image name_tag
for image in "$@"; do
name_tag="${image##*/}"
if [[ -v seen[$name_tag] && "${seen[$name_tag]}" != "$image" ]]; then
die "Image basename collision: '${seen[$name_tag]}' and '$image' both map to '$name_tag' after retagging"
fi
seen[$name_tag]="$image"
done
}
while [[ $# -gt 0 ]]; do
case "$1" in
--image)
[[ $# -ge 2 ]] || die "Missing value for --image"
IMAGES+=("$2")
shift 2
;;
--image=*)
IMAGES+=("${1#*=}")
shift
;;
--images-file)
[[ $# -ge 2 ]] || die "Missing value for --images-file"
append_images_file "$2"
shift 2
;;
--images-file=*)
append_images_file "${1#*=}"
shift
;;
--values)
[[ $# -ge 2 ]] || die "Missing value for --values"
VALUES_FILES+=("$2")
shift 2
;;
--values=*)
VALUES_FILES+=("${1#*=}")
shift
;;
--manifest)
[[ $# -ge 2 ]] || die "Missing value for --manifest"
MANIFEST_FILES+=("$2")
shift 2
;;
--manifest=*)
MANIFEST_FILES+=("${1#*=}")
shift
;;
--extra)
[[ $# -ge 2 ]] || die "Missing value for --extra"
EXTRA_FILES+=("$2")
shift 2
;;
--extra=*)
EXTRA_FILES+=("${1#*=}")
shift
;;
--chart-dir)
[[ $# -ge 2 ]] || die "Missing value for --chart-dir"
CHART_DIR="$2"
shift 2
;;
--chart-dir=*)
CHART_DIR="${1#*=}"
shift
;;
--output-dir)
[[ $# -ge 2 ]] || die "Missing value for --output-dir"
OUTPUT_DIR="$2"
shift 2
;;
--output-dir=*)
OUTPUT_DIR="${1#*=}"
shift
;;
--archive)
[[ $# -ge 2 ]] || die "Missing value for --archive"
ARCHIVE_PATH="$2"
shift 2
;;
--archive=*)
ARCHIVE_PATH="${1#*=}"
shift
;;
--skip-pull)
SKIP_PULL=true
shift
;;
--skip-save)
SKIP_SAVE=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown argument: $1"
;;
esac
done
[ "${#IMAGES[@]}" -gt 0 ] || die "At least one --image or --images-file entry is required"
[ -d "$CHART_DIR" ] || die "Chart directory not found: $CHART_DIR"
assert_unique_image_basenames "${IMAGES[@]}"
require_command helm
require_command tar
if [ "$SKIP_SAVE" = false ]; then
require_command docker
fi
OUTPUT_DIR="$(assert_safe_output_dir "$OUTPUT_DIR")"
if [ -z "$ARCHIVE_PATH" ]; then
ARCHIVE_PATH="${OUTPUT_DIR}.tar.gz"
fi
if [ -e "$OUTPUT_DIR" ]; then
echo "WARNING: removing existing output directory: $OUTPUT_DIR" >&2
fi
rm -rf -- "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"/{charts,images,values,manifests,extras,tools}
printf '%s\n' "${IMAGES[@]}" > "$OUTPUT_DIR/images/images.txt"
echo "Packaging Helm chart from $CHART_DIR"
helm package "$CHART_DIR" --destination "$OUTPUT_DIR/charts"
cp "$SCRIPT_DIR/airgap-load-images.sh" "$OUTPUT_DIR/tools/"
chmod +x "$OUTPUT_DIR/tools/airgap-load-images.sh"
if [ -f "$SCRIPT_DIR/airgap-setup-dgd.sh" ]; then
cp "$SCRIPT_DIR/airgap-setup-dgd.sh" "$OUTPUT_DIR/tools/"
chmod +x "$OUTPUT_DIR/tools/airgap-setup-dgd.sh"
fi
for file in "${VALUES_FILES[@]}"; do
copy_file "$file" "$OUTPUT_DIR/values"
done
for file in "${MANIFEST_FILES[@]}"; do
copy_file "$file" "$OUTPUT_DIR/manifests"
done
for file in "${EXTRA_FILES[@]}"; do
copy_file "$file" "$OUTPUT_DIR/extras"
done
if [ "$SKIP_SAVE" = false ]; then
if [ "$SKIP_PULL" = false ]; then
for image in "${IMAGES[@]}"; do
echo "Pulling $image"
docker pull "$image"
done
fi
echo "Saving images to $OUTPUT_DIR/images/nim-images.tar"
docker save --output "$OUTPUT_DIR/images/nim-images.tar" "${IMAGES[@]}"
else
echo "Skipping docker save"
fi
echo "Writing bundle manifest"
{
echo "created_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "chart_dir: $CHART_DIR"
echo "images:"
for image in "${IMAGES[@]}"; do
echo " - $image"
done
} > "$OUTPUT_DIR/bundle-manifest.yaml"
echo "Creating archive $ARCHIVE_PATH"
tar -C "$(dirname "$OUTPUT_DIR")" -czf "$ARCHIVE_PATH" "$(basename "$OUTPUT_DIR")"
echo "Air-gap bundle created:"
echo " directory: $OUTPUT_DIR"
echo " archive: $ARCHIVE_PATH"
airgap-load-images.sh#
#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
#
# Load a NIM-LLM air-gap image archive and push images to a private registry.
set -euo pipefail
BUNDLE_DIR=""
REGISTRY=""
IMAGE_TAR=""
SKIP_PUSH=false
usage() {
cat <<'EOF'
Usage:
scripts/airgap-load-images.sh --bundle-dir <bundle> --registry <registry/path> [options]
Options:
--bundle-dir <path> Extracted air-gap bundle directory.
--registry <registry> Destination registry prefix used for retagging, for example
registry.airgap.example.com/nim. Required even with --skip-push
because images are retagged before any push.
--image-tar <path> Image tar path. Default: <bundle>/images/nim-images.tar.
--skip-push Load and retag images, but do not push.
-h, --help Show this help.
The script reads <bundle>/images/images.txt. Each source image is retagged as:
<registry>/<last-source-path-component>
Example:
nvcr.io/nim/nim-llm:2.1.0 -> registry.airgap.example.com/nim/nim-llm:2.1.0
EOF
}
die() {
echo "ERROR: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--bundle-dir)
[[ $# -ge 2 ]] || die "Missing value for --bundle-dir"
BUNDLE_DIR="$2"
shift 2
;;
--bundle-dir=*)
BUNDLE_DIR="${1#*=}"
shift
;;
--registry)
[[ $# -ge 2 ]] || die "Missing value for --registry"
REGISTRY="${2%/}"
shift 2
;;
--registry=*)
REGISTRY="${1#*=}"
REGISTRY="${REGISTRY%/}"
shift
;;
--image-tar)
[[ $# -ge 2 ]] || die "Missing value for --image-tar"
IMAGE_TAR="$2"
shift 2
;;
--image-tar=*)
IMAGE_TAR="${1#*=}"
shift
;;
--skip-push)
SKIP_PUSH=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown argument: $1"
;;
esac
done
[ -n "$BUNDLE_DIR" ] || die "--bundle-dir is required"
# Registry is required for retagging, not only for push. --skip-push still
# rewrites image names to <registry>/<basename>.
[ -n "$REGISTRY" ] || die "--registry is required (needed for retagging, including with --skip-push)"
[ -d "$BUNDLE_DIR" ] || die "Bundle directory not found: $BUNDLE_DIR"
IMAGES_FILE="$BUNDLE_DIR/images/images.txt"
[ -f "$IMAGES_FILE" ] || die "Images file not found: $IMAGES_FILE"
if [ -z "$IMAGE_TAR" ]; then
IMAGE_TAR="$BUNDLE_DIR/images/nim-images.tar"
fi
[ -f "$IMAGE_TAR" ] || die "Image tar not found: $IMAGE_TAR"
require_command docker
# Fail before docker load if two distinct sources share a basename.
# Retagging uses only the last path component, so collisions would
# silently overwrite tags/pushes.
declare -A DEST_TO_SOURCE=()
while IFS= read -r image || [ -n "$image" ]; do
image="${image%%#*}"
image="$(echo "$image" | xargs)"
[ -n "$image" ] || continue
name_tag="${image##*/}"
destination="$REGISTRY/$name_tag"
if [[ -v DEST_TO_SOURCE[$destination] ]]; then
if [[ "${DEST_TO_SOURCE[$destination]}" == "$image" ]]; then
continue
fi
die "Destination tag collision: '$destination' already mapped from '${DEST_TO_SOURCE[$destination]}'; cannot also map '$image'"
fi
DEST_TO_SOURCE[$destination]="$image"
done < "$IMAGES_FILE"
[ "${#DEST_TO_SOURCE[@]}" -gt 0 ] || die "No images found in $IMAGES_FILE"
echo "Loading images from $IMAGE_TAR"
docker load --input "$IMAGE_TAR"
# Re-read images.txt for tag/push. DEST_TO_SOURCE already validated uniqueness.
declare -A PUSHED=()
while IFS= read -r image || [ -n "$image" ]; do
image="${image%%#*}"
image="$(echo "$image" | xargs)"
[ -n "$image" ] || continue
name_tag="${image##*/}"
destination="$REGISTRY/$name_tag"
if [[ -v PUSHED[$destination] ]]; then
echo "Skipping duplicate source image $image"
continue
fi
PUSHED[$destination]=1
echo "Tagging $image -> $destination"
docker tag "$image" "$destination"
if [ "$SKIP_PUSH" = false ]; then
echo "Pushing $destination"
docker push "$destination"
fi
done < "$IMAGES_FILE"
echo "Image load complete"