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-setup-dgd.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"

airgap-setup-dgd.sh#

Render and apply the air-gapped DynamoGraphDeployment templates after the model cache PVC and private-registry pull secret already exist.

#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
#
# Render and apply the air-gapped DynamoGraphDeployment templates.
#
# Prerequisites (not created by this script):
#   - Dynamo CRDs/platform already installed
#   - Private-registry pull secret in DYNAMO_NAMESPACE
#   - Pre-populated cache PVC named by DYNAMO_CACHE_CLAIM
#   - Absolute local DYNAMO_NIM_MODEL_PATH under /opt/nim/.cache
#
# This script does not download models and does not set NGC_API_KEY / HF_TOKEN.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"

MANIFEST_DIR="${MANIFEST_DIR:-$PROJECT_DIR/deploy/airgap}"
OUTPUT_DIR="${OUTPUT_DIR:-/tmp/nim-airgap-dgd}"
DRY_RUN=false
WAIT=true
TEARDOWN=false
TIMEOUT="${TIMEOUT:-30m}"

usage() {
  cat <<'EOF'
Usage:
  scripts/airgap-setup-dgd.sh [options]

Required environment variables:
  DYNAMO_NAMESPACE
  DYNAMO_DGD_NAME
  DYNAMO_NIM_IMAGE
  DYNAMO_IMAGE_PULL_SECRET
  DYNAMO_CACHE_CLAIM
  DYNAMO_NIM_MODEL_PATH
  DYNAMO_WORKER_REPLICAS
  DYNAMO_GPU_NODE_HOSTNAME
  DYNAMO_GPU_TAINT_KEY
  DYNAMO_GPU_TAINT_VALUE
  DYNAMO_RUNTIME_CLASS_NAME

Optional environment variables:
  KUBERNETES_API_SERVICE_CIDR     Defaults to the kubernetes Service ClusterIP /32.
  KUBERNETES_API_ENDPOINT_CIDR    If unset, every kubernetes endpoint IP is
                                  added as its own /32 ipBlock.
  MANIFEST_DIR                    Directory containing dgd.yaml and
                                  deny-egress-dgd.yaml.
                                  Default: deploy/airgap (or
                                  $AIRGAP_BUNDLE/manifests when set).
  OUTPUT_DIR                      Where rendered manifests are written.
                                  Default: /tmp/nim-airgap-dgd
  TIMEOUT                         kubectl wait timeout. Default: 30m

Options:
  --manifest-dir <path>   Override MANIFEST_DIR.
  --output-dir <path>     Override OUTPUT_DIR.
  --dry-run               Render manifests and validate with server-side
                          dry-run (no cluster writes).
  --no-wait               Do not wait for DGD pods to become Ready.
  --teardown              Delete the DGD and NetworkPolicy, then exit.
  -h, --help              Show this help.

Example:
  export DYNAMO_NAMESPACE=nim-d
  export DYNAMO_DGD_NAME=nim-d-airgap
  export DYNAMO_NIM_IMAGE=registry.example.com/nim/nim-d:2.1.0
  export DYNAMO_IMAGE_PULL_SECRET=registry-pull
  export DYNAMO_CACHE_CLAIM=nim-cache
  export DYNAMO_NIM_MODEL_PATH=/opt/nim/.cache/models/tinyllama
  export DYNAMO_WORKER_REPLICAS=1
  export DYNAMO_GPU_NODE_HOSTNAME=gpu-node-1
  export DYNAMO_GPU_TAINT_KEY=nvidia.com/gpu
  export DYNAMO_GPU_TAINT_VALUE=true
  export DYNAMO_RUNTIME_CLASS_NAME=nvidia

  scripts/airgap-setup-dgd.sh
EOF
}

die() {
  echo "ERROR: $*" >&2
  exit 1
}

require_command() {
  command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"
}

require_env() {
  local name="$1"
  [ -n "${!name:-}" ] || die "Required environment variable is unset: $name"
}

discover_api_cidrs() {
  EXTRA_ENDPOINT_CIDRS=()

  # Honor a pre-set Service CIDR so manual / offline CIDR paths work even when
  # the kubernetes Service cannot be queried from this host.
  if [ -z "${KUBERNETES_API_SERVICE_CIDR:-}" ]; then
    local service_ip
    service_ip="$(kubectl get svc kubernetes -n default -o jsonpath='{.spec.clusterIP}')"
    [ -n "$service_ip" ] || die "Could not discover kubernetes Service ClusterIP"
    KUBERNETES_API_SERVICE_CIDR="${service_ip}/32"
  fi

  # Honor a pre-set endpoint CIDR the same way; do not fail discovery when the
  # user already supplied the allowlist.
  if [ -n "${KUBERNETES_API_ENDPOINT_CIDR:-}" ]; then
    return 0
  fi

  mapfile -t ENDPOINT_IPS < <(
    kubectl get endpoints kubernetes -n default \
      -o jsonpath='{range .subsets[*].addresses[*]}{.ip}{"\n"}{end}' 2>/dev/null \
      || true
  )
  if [ "${#ENDPOINT_IPS[@]}" -eq 0 ]; then
    mapfile -t ENDPOINT_IPS < <(
      kubectl get endpointslices -n default -l kubernetes.io/service-name=kubernetes \
        -o jsonpath='{range .items[*].endpoints[*].addresses[*]}{@}{"\n"}{end}' 2>/dev/null \
        || true
    )
  fi

  local -a filtered=()
  local ip
  for ip in "${ENDPOINT_IPS[@]+"${ENDPOINT_IPS[@]}"}"; do
    [ -n "$ip" ] && filtered+=("$ip")
  done
  ENDPOINT_IPS=("${filtered[@]+"${filtered[@]}"}")

  [ "${#ENDPOINT_IPS[@]}" -gt 0 ] || die \
    "Could not discover kubernetes API endpoint IPs (set KUBERNETES_API_ENDPOINT_CIDR to bypass)"

  # Prefer explicit /32s for every endpoint so the policy does not over-allow.
  KUBERNETES_API_ENDPOINT_CIDR="${ENDPOINT_IPS[0]}/32"
  for ip in "${ENDPOINT_IPS[@]:1}"; do
    EXTRA_ENDPOINT_CIDRS+=("${ip}/32")
  done
}

require_pyyaml() {
  require_command python3
  python3 -c 'import yaml' >/dev/null 2>&1 || die \
    "PyYAML is required to expand multiple Kubernetes API endpoint CIDRs. Install python3-yaml / PyYAML, or set a single KUBERNETES_API_ENDPOINT_CIDR."
}

render_endpoint_ipblocks() {
  local rendered="$1"
  # When there is only the primary endpoint CIDR, envsubst already filled both
  # placeholders. Expand with PyYAML only when additional /32s must be added.
  if [ "${#EXTRA_ENDPOINT_CIDRS[@]}" -eq 0 ]; then
    return 0
  fi

  require_pyyaml
  # Keep the Service ClusterIP allow and expand endpoint IPs to one /32 each
  # so multi-endpoint control planes work.
  python3 - "$rendered" "$KUBERNETES_API_SERVICE_CIDR" \
    "$KUBERNETES_API_ENDPOINT_CIDR" \
    "${EXTRA_ENDPOINT_CIDRS[@]}" <<'PY'
import sys
from pathlib import Path

import yaml

path = Path(sys.argv[1])
service_cidr = sys.argv[2]
endpoint_cidrs = [c for c in sys.argv[3:] if c]
doc = yaml.safe_load(path.read_text())
egress = doc["spec"]["egress"]
api_rule = egress[-1]
api_rule["to"] = [{"ipBlock": {"cidr": service_cidr}}] + [
    {"ipBlock": {"cidr": cidr}} for cidr in endpoint_cidrs
]
path.write_text(yaml.safe_dump(doc, sort_keys=False))
PY
}

render_manifests() {
  local dgd_src netpol_src
  dgd_src="$MANIFEST_DIR/dgd.yaml"
  netpol_src="$MANIFEST_DIR/deny-egress-dgd.yaml"
  [ -f "$dgd_src" ] || die "DGD template not found: $dgd_src"
  [ -f "$netpol_src" ] || die "NetworkPolicy template not found: $netpol_src"

  mkdir -p "$OUTPUT_DIR"
  export DYNAMO_NAMESPACE DYNAMO_DGD_NAME DYNAMO_NIM_IMAGE DYNAMO_IMAGE_PULL_SECRET
  export DYNAMO_CACHE_CLAIM DYNAMO_NIM_MODEL_PATH DYNAMO_WORKER_REPLICAS
  export DYNAMO_GPU_NODE_HOSTNAME DYNAMO_GPU_TAINT_KEY DYNAMO_GPU_TAINT_VALUE
  export DYNAMO_RUNTIME_CLASS_NAME
  export KUBERNETES_API_SERVICE_CIDR KUBERNETES_API_ENDPOINT_CIDR

  envsubst < "$dgd_src" > "$OUTPUT_DIR/dgd.yaml"
  envsubst < "$netpol_src" > "$OUTPUT_DIR/deny-egress-dgd.yaml"
  render_endpoint_ipblocks "$OUTPUT_DIR/deny-egress-dgd.yaml"

  if grep -E '\$\{|<[^>]+>' "$OUTPUT_DIR/dgd.yaml" "$OUTPUT_DIR/deny-egress-dgd.yaml" >/dev/null; then
    die "Rendered manifests still contain unresolved placeholders"
  fi

  echo "Rendered:"
  echo "  $OUTPUT_DIR/dgd.yaml"
  echo "  $OUTPUT_DIR/deny-egress-dgd.yaml"
  echo "API allowlist:"
  echo "  service:   $KUBERNETES_API_SERVICE_CIDR"
  echo "  endpoints: $KUBERNETES_API_ENDPOINT_CIDR${EXTRA_ENDPOINT_CIDRS[*]:+ ${EXTRA_ENDPOINT_CIDRS[*]}}"
}

apply_manifests() {
  if [ "$DRY_RUN" = true ]; then
    # Validate only: do not create the namespace or apply any objects.
    kubectl create namespace "$DYNAMO_NAMESPACE" \
      --dry-run=client -o yaml | kubectl apply --dry-run=server -f -
    kubectl apply --dry-run=server -f "$OUTPUT_DIR/deny-egress-dgd.yaml"
    kubectl apply --dry-run=server -f "$OUTPUT_DIR/dgd.yaml"
    echo "Dry-run succeeded."
    return
  fi

  kubectl create namespace "$DYNAMO_NAMESPACE" \
    --dry-run=client -o yaml | kubectl apply -f -

  # Isolation before the DGD so Frontend/Workers are offline from first boot.
  kubectl apply -f "$OUTPUT_DIR/deny-egress-dgd.yaml"
  kubectl apply -f "$OUTPUT_DIR/dgd.yaml"

  if [ "$WAIT" = true ]; then
    echo "Waiting for DGD pods to become Ready (timeout=$TIMEOUT)..."
    kubectl wait -n "$DYNAMO_NAMESPACE" \
      --for=condition=Ready pod \
      -l "nvidia.com/dynamo-graph-deployment-name=$DYNAMO_DGD_NAME" \
      --timeout="$TIMEOUT"
    kubectl get pods -n "$DYNAMO_NAMESPACE" \
      -l "nvidia.com/dynamo-graph-deployment-name=$DYNAMO_DGD_NAME" \
      -L nvidia.com/dynamo-component -o wide
  fi
}

teardown() {
  require_env DYNAMO_NAMESPACE
  require_env DYNAMO_DGD_NAME
  require_command kubectl
  kubectl delete dgd "$DYNAMO_DGD_NAME" -n "$DYNAMO_NAMESPACE" --ignore-not-found --wait=true
  kubectl delete networkpolicy \
    "${DYNAMO_DGD_NAME}-deny-external-egress" \
    -n "$DYNAMO_NAMESPACE" --ignore-not-found --wait=true
  echo "Teardown complete for $DYNAMO_NAMESPACE/$DYNAMO_DGD_NAME"
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --manifest-dir)
      [[ $# -ge 2 ]] || die "Missing value for --manifest-dir"
      MANIFEST_DIR="$2"
      shift 2
      ;;
    --output-dir)
      [[ $# -ge 2 ]] || die "Missing value for --output-dir"
      OUTPUT_DIR="$2"
      shift 2
      ;;
    --dry-run)
      DRY_RUN=true
      shift
      ;;
    --no-wait)
      WAIT=false
      shift
      ;;
    --teardown)
      TEARDOWN=true
      shift
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      die "Unknown argument: $1"
      ;;
  esac
done

if [ -n "${AIRGAP_BUNDLE:-}" ] && [ "$MANIFEST_DIR" = "$PROJECT_DIR/deploy/airgap" ]; then
  MANIFEST_DIR="$AIRGAP_BUNDLE/manifests"
fi

if [ "$TEARDOWN" = true ]; then
  teardown
  exit 0
fi

for var in \
  DYNAMO_NAMESPACE DYNAMO_DGD_NAME DYNAMO_NIM_IMAGE DYNAMO_IMAGE_PULL_SECRET \
  DYNAMO_CACHE_CLAIM DYNAMO_NIM_MODEL_PATH DYNAMO_WORKER_REPLICAS \
  DYNAMO_GPU_NODE_HOSTNAME DYNAMO_GPU_TAINT_KEY DYNAMO_GPU_TAINT_VALUE \
  DYNAMO_RUNTIME_CLASS_NAME
do
  require_env "$var"
done

require_command kubectl
require_command envsubst
require_command grep

EXTRA_ENDPOINT_CIDRS=()
discover_api_cidrs
render_manifests
apply_manifests