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"