Custom Installation with cloud-init#

By accessing the instructions for custom installation and scaled deployment, the Customer acknowledges that this workflow bypasses individual end-user license prompts. Use of these tools is governed by the NVIDIA Software License Agreement (which includes the NVIDIA CUDA EULA). By proceeding, the Administrator represents they have the authority to bind the Customer to these terms and conditions.

Overview#

This topic describes custom installation workflows for IT administrators and engineers who deploy NVIDIA DGX Spark systems with cloud-init, USB recovery media, and locally hosted Debian packages and firmware. These workflows implement the initial provisioning stage described in Enterprise Lifecycle Integration. For cloud-init concepts and where provisioning fits in the fleet lifecycle, refer to Cloud-init for DGX Spark.

Cloud-init Support#

Cloud-init support on DGX Spark Founders Edition is provided by the nvidia-spark-cloud-init package, which is included in the October 2026 Release and later. If the system runs an earlier software release, reflash it with an October 2026 Release or later recovery image before using the workflows in this topic. For the recovery download and procedure, refer to System Recovery.

For an OEM system, cloud-init is enabled unless the OEM removes it during the image build. Refer to the OEM release documentation for cloud-init availability and recovery-media requirements.

The provided user-data examples disable cloud-init after a successful run. If user-data creates a user, the initial system setup screens (or out-of-box experience [OOBE]) are skipped. If user-data does not create a user, those screens remain available.

IT Administrator Workflows#

Determine the system software release before you prepare the OEMDATA volume:

Device State

Typical First Step

When to Use This Topic

Founders Edition with the October 2026 Release or later

Prepare an OEMDATA volume

Use the cloud-init, package, firmware, and local-repository workflows in this topic.

Founders Edition earlier than the October 2026 Release

Create recovery media for the October 2026 Release or later, then add an OEMDATA partition

Reflash the system before using cloud-init. Reflashing erases local storage.

OEM system

Follow the OEM release and recovery documentation

Use this topic only when the OEM documentation confirms that these October 2026 Release cloud-init workflows apply.

Which Installation Path to Use#

Choose one setup option and one package or firmware option.

To determine whether your device needs re-imaging, refer to IT Administrator Workflows. For details about each pattern, refer to Air-Gapped and Custom Installation Patterns.

Setup Option

Start Here

Skip OOBE

Use the user-data example that creates a user; refer to Cloud-init Configurations.

Keep OOBE and provision without creating a user

Use the user-data example that does not create a user. Cloud-init runs provisioning from bootcmd while OOBE remains available for the user to create an account.

Package or Firmware Option

Start Here

Deliver packages or firmware from USB

USB and Image Layout and USB Partitioning and the OEMDATA Layout

Host a curated APT and firmware tree

Host a Minimal APT Repository and Firmware Tree and On the DGX Spark Client: hook.sh and OEMDATA Files

Mirror Ubuntu ports and LVFS on the installation network

Mirror the Full Ubuntu Ports and LVFS Content on a Server

Prerequisites and Source Media#

Obtain the resources required by your selected workflow:

Resource

Format

Source

DGX Spark recovery image (required for systems earlier than the October 2026 Release)

.tar.gz

DGX Spark System Recovery Image (refer also to System Recovery). The download page lists the current recovery-image file name.

Cloud-init seed and hook

Directory tree

meta-data identifies the cloud-init instance. user-data is the cloud-init configuration and calls optional hook.sh. hook.sh installs Debian packages and firmware. The examples are in Reference: Scripts and cloud-init.

Debian packages (optional)

.deb

Debian packages that you supply from public repositories, an OEM, or packages you build or approve. This topic does not provide a package download portal. Place .deb files on OEMDATA or in a minimal repository you host per Host a Minimal APT Repository and Firmware Tree.

Firmware (optional)

.cab/.cap or LVFS-style mirror content

Firmware that you supply from public repositories, an OEM or ODM, or packages you build or approve. This topic does not provide a firmware download portal. Place firmware on OEMDATA or in a minimal tree you host per Host a Minimal APT Repository and Firmware Tree. For current Founders Edition component versions, refer to DGX Spark Release Notes.

Note

These workflows apply to Founders Edition systems running the October 2026 Release or later. For an OEM system, follow the OEM release documentation.

Host Tools#

Install the tools required by the procedure you follow:

  • USB OEMDATA partition: gdisk (for sgdisk), dosfstools (for mkfs.vfat), and util-linux (for lsblk and wipefs).

  • Minimal APT repository: dpkg-dev, which provides dpkg-scanpackages.

  • HTTP server for a minimal repository or ~/mirror: python3.

  • Mirror script download and URL checks: wget or curl.

  • Optional guids.txt workflow, and the final firmware check in hook.sh: jq.

  • Firmware collection and installation on DGX Spark: fwupdtool and fwupdmgr.

  • Full Ubuntu ports and LVFS mirror: apt-mirror and the Python packages listed in that procedure.

USB and Image Layout#

This section shows the OEMDATA USB layout. Use it for the cloud-init seed in every workflow, and when you deliver packages or firmware from USB. Refer to USB Partitioning and the OEMDATA Layout for the procedure.

For a system that already runs the October 2026 Release or later, a USB drive can contain one FAT32 partition labeled OEMDATA. At minimum, place meta-data and user-data in the partition root. The following example also includes the optional hook.sh file:

$ lsblk -o NAME,SIZE,FSTYPE,LABEL,PARTLABEL /dev/sdb
NAME    SIZE FSTYPE LABEL   PARTLABEL
sdb    57.8G
└─sdb1 57.7G vfat   OEMDATA OEMDATA

$ sudo mount /dev/sdb1 /mnt/oemdata
$ tree /mnt/oemdata
/mnt/oemdata
├── hook.sh
├── meta-data
└── user-data

For a system earlier than the October 2026 Release, first create recovery media for the October 2026 Release or later. Then add a FAT32 OEMDATA partition. The resulting USB drive has a recovery partition and an OEMDATA partition:

$ lsblk -o NAME,SIZE,FSTYPE,LABEL,PARTLABEL /dev/sdb
NAME    SIZE FSTYPE LABEL                 PARTLABEL
sdb    57.8G
├─sdb1   31G vfat   BOOTME
└─sdb2 26.7G vfat   OEMDATA              OEMDATA

You can also host packages and firmware on a network server.

Host curated APT packages and firmware on a host reachable from the installation network. Use the optional URL files on OEMDATA to point the client at that server.

First Boot Behavior#

All of the following conditions must be met for DGX Spark to use the setup volume. If a condition is not met, the system boots normally and ignores the volume without reporting or logging an error:

  • Attach the volume before power-on. The systemd generator decides whether to run cloud-init early in the boot process. A volume attached during boot, at the login screen, or after login is not detected.

  • Format the partition as FAT32 or ISO 9660. Cloud-init accepts the label match only on a vfat or iso9660 filesystem.

  • Use the uppercase label OEMDATA. The label is case sensitive.

  • Place both seed files in the partition root. Both meta-data and user-data are required, even when meta-data only sets an instance ID.

If user-data creates a user, cloud-init completes automated setup and OOBE is skipped. If user-data does not create a user, provisioning runs from bootcmd while OOBE remains available for the user to create an account. When OOBE runs, runcmd does not run.

When present, hook.sh installs packages and firmware from debs/ and firmware/ and configures APT and LVFS sources from the optional URL files.

Where to Place Customization Content#

The following table summarizes where each type of content belongs. Place meta-data and user-data on the USB OEMDATA volume for every workflow. Do not host those two files on the network server.

Content

USB OEMDATA Partition

Network Server

meta-data and user-data

Required — partition root

No — USB only

hook.sh

Optional — partition root

Optional — keep-OOBE network fallback

.deb packages

Optional — debs/

Yes — served APT tree

Firmware (.cab/.cap)

Optional — firmware/

Yes — LVFS mirror or signbinpack directory

apt-repo.url

Optional

N/A (points to server)

apt-packages.txt

Optional

N/A

lvfs-mirror.url

Optional

N/A (points to server)

OEMDATA URL and List File Reference#

When you use OEMDATA with a network mirror or curated local server, hook.sh reads the following optional files from the OEMDATA partition root.

File

Required

Format and Behavior

apt-repo.url

Optional

Format: One line containing the base URL of the APT repository. The URL must match the path your web server serves (the parent of Packages.gz). With a unified server layout, the web root typically contains both deb-repo/ and lvfs-mirror/ subdirectories; place APT packages under deb-repo/ (you can rename that folder). Use the full path—for example, http://192.168.1.1:8080/deb-repo/. Do not use the bare host root as apt-repo.url. hook.sh records this value in /etc/apt/sources.list.d/oem-local.list. If omitted: hook.sh does not configure this additional OEM APT source. The script still configures the example full mirror specified by MIRROR_SERVER_IP and MIRROR_SERVER_PORT.

apt-packages.txt

Optional

Format: One package name or .deb file name per line. Example: nvidia-spark-ota-check. If omitted but ``apt-repo.url`` is present: hook.sh adds the OEM source and runs a single-source apt upgrade against that repository only.

lvfs-mirror.url

Optional

Format: One line containing the base URL of the firmware directory. Point at a directory of .cab/.cap files that you supply—for example, http://192.168.1.1:8080/lvfs-mirror/signbinpack-2.152.3-release/. hook.sh reads the directory listing and runs fwupdmgr install for each file. If omitted: hook.sh skips this additional firmware directory. The script still configures the example full mirror specified by MIRROR_SERVER_IP and MIRROR_SERVER_PORT.

The following examples match these formats:

http://192.168.1.1:8080/lvfs-mirror/signbinpack-2.152.3-release
http://192.168.1.1:8080/deb-repo/
nvidia-spark-ota-check

Example Hardware Connection#

The following figure shows an example hardware topology and workflow for the cloud-init customization process. It illustrates USB preparation, the USB OEMDATA layout, the DGX Spark device under test (DUT), and a local server that hosts curated or mirrored APT packages and firmware.

The figure uses the following color legend:

  • Yellow: Cloud-init files and USB preparation

  • Pink: USB OEMDATA partition layout and files

  • Blue: DGX Spark DUT

  • Aqua: Local server hosting curated or mirrored APT and firmware content

  • Switch: Network switch that connects the DGX Spark systems

  • Red: Public upstream servers (APT and LVFS)

_images/image1.png

Spark custom installation: USB OEMDATA, optional local server, and client integration.#

Air-Gapped and Custom Installation Patterns#

The following patterns describe how cloud-init, USB layout, and optional local services combine. They align with enterprise customization workflows that use cloud-init OEM seeds and, when needed, an OEMDATA partition on the USB device.

Installation and Update Patterns

The following table summarizes the installation and update patterns described in this topic:

Pattern

What You Configure

OEMDATA Partition

Related Sections

Skip OOBE

user-data on OEMDATA creates the user and runs the provisioning workflow. OOBE is skipped.

Required

Cloud-init Configurations and USB Partitioning and the OEMDATA Layout.

Keep OOBE and provision without creating a user

user-data on OEMDATA runs provisioning from bootcmd without creating a user. The user creates an account through OOBE.

Required

Same cloud-init and USB references as the row above.

USB-hosted packages and firmware

The OEMDATA partition contains the required seed files and optional hook.sh, debs/, and firmware/ content. Cloud-init runs hook.sh on first boot while the USB drive is present.

Required

USB Partitioning and the OEMDATA Layout.

Local server with mirrored (MIRRORED) public sources

A separate host mirrors upstream Ubuntu ports and LVFS content (for example, using spark-mirror-sync.sh and related steps), then serves them over HTTP. OEMDATA includes hook.sh so the client is configured to use that mirror (the sync script runs on the mirror server, not on the USB drive).

Required (for hook.sh-based client wiring from USB)

Mirror the Full Ubuntu Ports and LVFS Content on a Server.

How the Pieces Fit

The following sequence illustrates a typical deployment workflow:

  1. If the DGX Spark runs a release earlier than the October 2026 Release, reflash it with an October 2026 Release or later recovery image.

  2. Prepare an OEMDATA volume, with or without an APT or firmware server.

  3. Power off the DGX Spark, attach the OEMDATA volume, and power on the system.

  4. Apply configuration with cloud-init. Configuration can include network settings, user accounts, firmware updates, APT updates, and related tasks.

    1. Store configuration and firmware or APT content on the OEMDATA volume.

    2. Store configuration on the OEMDATA volume and host curated firmware and APT sources on a server.

    3. Host all firmware and APT configuration on the server.

Select and adapt one of these options for your deployment. The examples in this topic provide a framework that you can extend to match your site requirements.

For USB partition layout, content placement, and first-boot behavior, refer to USB and Image Layout, Where to Place Customization Content, and First Boot Behavior. hook.sh is reference material for the USB and server layouts described in this topic. If your layout changes, adapt hook.sh accordingly.

Example Constants#

The following tables list example IP addresses, ports, and directory paths. Replace these values with settings that match your environment. In particular, 192.168.1.1:8080 is the example address for the local server. Replace the address in user-data and hook.sh with the IP address and port for your local server.

Warning

Do not use the default username and password in production deployments. Remove the default credentials or replace them with credentials approved by your organization.

Example Constants for the Repository Server (Port 8080 or 80)

Item

Example Value

Desktop or server IP

192.168.1.1

Python HTTP server port

8080

Web root

/var/www or for example $HOME/oem-server

APT subdirectory

deb-repo under WEB_ROOT

LVFS subdirectory

lvfs-mirror under WEB_ROOT

USB Partitioning and the OEMDATA Layout#

Use a FAT32 partition labeled OEMDATA for the cloud-init seed and optional packages, firmware, and repository URL files. Both meta-data and user-data are required in the partition root.

Create an OEMDATA-Only USB Drive#

For a system that already runs the October 2026 Release or later, create one FAT32 partition on a USB drive:

Warning

The following procedure removes existing filesystem signatures from the selected partition. Verify the device and partition names with lsblk before running wipefs or mkfs.vfat. Selecting the wrong device can erase data.

  1. Create a partition that uses the available space on the USB drive.

  2. Format the partition as FAT32 and label it OEMDATA:

    $ sudo wipefs -a /dev/<partition>
    $ sudo mkfs.vfat -F 32 -n OEMDATA /dev/<partition>
    
  3. Mount the partition and copy the required seed files:

    $ sudo mkdir -p /mnt/oemdata
    $ sudo mount /dev/<partition> /mnt/oemdata
    $ sudo install -m 0644 <seed-directory>/meta-data /mnt/oemdata/meta-data
    $ sudo install -m 0644 <seed-directory>/user-data /mnt/oemdata/user-data
    
  4. Copy optional hook.sh, package, firmware, and URL files as required by your workflow.

  5. Run sync, unmount the partition, and confirm the layout:

    $ sync
    $ sudo umount /mnt/oemdata
    $ lsblk -o NAME,SIZE,FSTYPE,LABEL,PARTLABEL /dev/<USB-device>
    

Add OEMDATA to Recovery Media#

If the system runs a release earlier than the October 2026 Release, create an October 2026 Release or later recovery USB drive first. Refer to System Recovery for the recovery-image download and CreateUSBKey.sh procedure. Then add OEMDATA as partition 2.

The following reference script creates and populates partition 2. Edit the variables before you run it. DISK must identify the whole USB device, not a partition.

#!/bin/bash
# Add an OEMDATA partition to a USB drive that contains a recovery image.
# Edit the variables, then run with sudo.
set -eu

SEED=<path-to-oemdata>  # Directory containing meta-data and user-data.
DISK=/dev/<USB-device>  # Whole USB drive, not a partition.
DEBS_PATH=              # Optional directory of .deb files.
FW_PATH=                # Optional directory of .cab/.cap files.
URL_FILES=              # Optional URL and package-list files.
MNT=/mnt/oemdata

[ "$(id -u)" -eq 0 ] || { echo "Run with sudo." >&2; exit 1; }
read -r rm tran < <(lsblk -dno RM,TRAN "$DISK")
[ "$rm" = 1 ] || [ "$tran" = usb ] || {
  echo "$DISK is not a removable USB drive." >&2
  exit 1
}
case "$DISK" in
  *[0-9]) PART="${DISK}p2" ;;
  *) PART="${DISK}2" ;;
esac

lsblk -o NAME,SIZE,FSTYPE,LABEL,PARTLABEL,TRAN "$DISK"
read -rp "Create $PART and format it FAT32 as OEMDATA? Type $DISK to confirm: " ok
[ "$ok" = "$DISK" ] || { echo "Aborted."; exit 1; }

if [ ! -b "$PART" ]; then
  # 2:0:0 uses the first aligned sector of the largest free block
  # through the end of that block.
  sgdisk -n 2:0:0 -t 2:0700 -c 2:OEMDATA "$DISK"
  partprobe "$DISK" || true
  udevadm settle || true
fi
[ -b "$PART" ] || { echo "$PART did not appear." >&2; exit 1; }

umount "$PART" 2>/dev/null || true
wipefs -a "$PART"
mkfs.vfat -F 32 -n OEMDATA "$PART"
mkdir -p "$MNT"
mount "$PART" "$MNT"

install -m 0644 "$SEED/meta-data" "$MNT/meta-data"
install -m 0644 "$SEED/user-data" "$MNT/user-data"
[ ! -f "$SEED/hook.sh" ] || install -m 0755 "$SEED/hook.sh" "$MNT/hook.sh"
for f in $URL_FILES; do install -m 0644 "$f" "$MNT/"; done
[ -z "$DEBS_PATH" ] || {
  mkdir -p "$MNT/debs"
  cp "$DEBS_PATH"/*.deb "$MNT/debs/"
}
[ -z "$FW_PATH" ] || {
  mkdir -p "$MNT/firmware"
  cp "$FW_PATH"/*.ca[bp] "$MNT/firmware/"
}

sync
ls -la "$MNT"
umount "$MNT"
lsblk -o NAME,SIZE,FSTYPE,LABEL,PARTLABEL "$DISK"
echo "OEMDATA ready. Power off DGX Spark, attach the drive, then power on."

OEMDATA Partition Layout#

The following tree shows all supported files. Only meta-data and user-data are mandatory:

.
├── apt-packages.txt    # Optional package list.
├── apt-repo.url        # Optional APT repository URL for hook.sh.
├── debs/               # Optional .deb files.
├── firmware/           # Optional .cab/.cap files.
├── hook.sh             # Optional extended script.
├── lvfs-mirror.url     # Optional firmware repository URL for hook.sh.
├── meta-data           # Required cloud-init meta-data.
└── user-data           # Required cloud-init user-data.

For URL-file formats, refer to OEMDATA URL and List File Reference.

Host a Minimal APT Repository and Firmware Tree#

This section describes how to host a minimal APT repository and firmware tree on a desktop or server. Use this workflow when you have a curated set of packages and firmware to serve and you do not need a full Ubuntu ports mirror or an LVFS archive mirror. In this context, minimal means you index your Debian packages with dpkg-scanpackages, host those packages and your firmware in a compact directory layout, and point clients at that layout over HTTP. The steps here assume packages and firmware are trusted, as in many air-gapped installations, and they do not cover hardening for an internet-exposed package mirror. Refer to Security Considerations for risks and mitigations.

One network resource can serve both the APT repository and the LVFS-related tree. Use a single web root (WEB_ROOT) with REPO_DIR and LVFS_DIR as subdirectories.

Define the web root and repository directories with the following commands:

WEB_ROOT=/var/www   # or for example $HOME/oem-server
REPO_DIR="$WEB_ROOT/deb-repo"
LVFS_DIR="$WEB_ROOT/lvfs-mirror"
sudo mkdir -p "$REPO_DIR" "$LVFS_DIR"
sudo chown "$USER" "$REPO_DIR" "$LVFS_DIR"

On a Desktop or Server#

Complete the following steps to host a minimal APT repository and firmware tree on a desktop or server system:

  1. Install tools for the APT repository: sudo apt-get install -y dpkg-dev

  2. Copy .deb files into REPO_DIR.

  3. Generate the APT index (Packages.gz). Re-run this step whenever you add or change .deb files. Run the following commands:

cd "$REPO_DIR"
dpkg-scanpackages . /dev/null | gzip -9c > Packages.gz
  1. Optional: Add Release and uncompressed Packages to avoid 404 responses. apt might request Release, Packages (uncompressed), and similar. A repository that only has Packages.gz can return 404 responses that apt can tolerate when Packages.gz is present. To serve these files, run the following commands:

cd "$REPO_DIR"
zcat Packages.gz > Packages 2>/dev/null || gzip -dc Packages.gz > Packages
# Minimal Release file (paths relative to repo root); example block:
{
  echo "Origin: OEM Local Repo"
  echo "Label: oem-local"
  echo "Suite: ."
  echo "Codename: ."
  echo "Architectures: arm64 amd64"
  echo "Components: ."
  echo "Description: OEM local package repository"
  echo "Date: $(date -u -R)"
  echo "MD5Sum:"
  printf ' %s %s Packages.gz\n' "$(md5sum Packages.gz | awk '{print $1}')" "$(stat -c%s Packages.gz)"
  printf ' %s %s Packages\n' "$(md5sum Packages | awk '{print $1}')" "$(stat -c%s Packages)"
  echo "SHA256:"
  printf ' %s %s Packages.gz\n' "$(sha256sum Packages.gz | awk '{print $1}')" "$(stat -c%s Packages.gz)"
  printf ' %s %s Packages\n' "$(sha256sum Packages | awk '{print $1}')" "$(stat -c%s Packages)"
} > Release
cp Release InRelease

Whenever you regenerate Packages.gz in step 3, repeat step 4: recreate uncompressed Packages, write Release, and copy InRelease using the commands in the code block above.

  1. Serve both APT and LVFS over HTTP from one server.

Option A (Python): Run the following command from WEB_ROOT to start a simple HTTP server:

cd "$WEB_ROOT"
python3 -m http.server 8080 --bind 0.0.0.0

Option B (Nginx): Install and enable Nginx, then add a configuration similar to the following example:

location /deb-repo {
  alias /var/www/deb-repo;
  autoindex on;
}
location /lvfs-mirror {
  alias /var/www/lvfs-mirror;
  autoindex on;
}

With this layout, the server root lists only deb-repo/ and lvfs-mirror/. On the USB drive you must use full paths, not the server root alone.

The following examples show typical APT and LVFS URLs:

  • APT repository URL: http://192.168.1.1:8080/deb-repo/ (Python) or http://192.168.1.1/deb-repo/ (Nginx).

  • Firmware directory URL: a subdirectory of .cab/.cap files, for example http://192.168.1.1:8080/lvfs-mirror/signbinpack-2.152.3-release/ (hook.sh auto-detects and installs each file).

  1. Firewall: allow inbound HTTP on the port you use (for example, sudo ufw allow 80/tcp, sudo ufw allow 8080/tcp, sudo ufw reload).

On the DGX Spark Client: hook.sh and OEMDATA Files#

Place the following files on the USB drive OEMDATA partition when you want hook.sh on the client to use your hosted APT repository. For file format rules, refer to OEMDATA URL and List File Reference.

  • apt-repo.url: Include this file when the client should use your hosted APT repository.

  • apt-packages.txt: Optional. If present, one package per line. Each line can be either a package name (for example, nvidia-spark-ota-check) or a full .deb file name (for example, nvidia-spark-ota-check_1.0.0-1_arm64.deb); the hook derives the package name from a .deb file name when needed and runs apt-get install for that set.

With apt-repo.url present, hook.sh wires oem-local.list, updates the index, then either installs listed packages from apt-packages.txt or performs the single-source upgrade when apt-packages.txt is absent. Refer to the listing in First Boot: OEMDATA hook.sh and cloud-init Seed or enterprise-custom-install-oemdata-hook.sh in this documentation source.

Troubleshooting: Use the following notes when you diagnose APT repository issues on the client:

  • Ignore or 404 for Release.gpg and InRelease: Expected for an unsigned repository; [trusted=yes] makes apt ignore the missing signature. Refer to Security Considerations for the risks of unsigned local repositories.

  • “Unable to locate package”: The repository is added (/etc/apt/sources.list.d/oem-local.list). apt fetches Release but might not load Packages if the Release file is wrong. Ensure that Release has Date, paths Packages.gz and Packages, and run cp Release InRelease (step 4 above). The hook’s fallback (download .deb and dpkg -i) works even when apt does not see the package.

Minimal LVFS Mirror on the Same Host#

Use LVFS_DIR under the same WEB_ROOT as the APT repository. hook.sh can install firmware from this tree over the LAN when lvfs-mirror.url points at a directory of .cab/.cap files, in addition to any .cab/.cap from the USB firmware/ directory.

On a Desktop (Server)#

Complete the following steps to populate and serve a minimal LVFS mirror on the same host as the APT repository:

  1. Populate LVFS_DIR with firmware content that you supply (for example, from a public repository, an OEM or ODM, or packages you build or approve). Place firmware files (for example, from a signbinpack release) in a subdirectory such as $LVFS_DIR/signbinpack-2.152.3-release/. Point lvfs-mirror.url on the client at that subdirectory’s URL. hook.sh reads the directory listing and runs fwupdmgr install for each .cab/.cap file.

  2. Serve the mirror over HTTP using the same web server as the APT repository (run from WEB_ROOT). For example: http://192.168.1.1:8080/lvfs-mirror/signbinpack-2.152.3-release/ (Python) or http://192.168.1.1/lvfs-mirror/signbinpack-2.152.3-release/ (Nginx).

  3. On the host that serves both trees, allow inbound HTTP on the ports you use for that server (typically the same ports you opened for the APT repository). Run the following commands, for example:

sudo ufw allow 80/tcp
sudo ufw allow 8080/tcp
sudo ufw reload

On the DGX Spark Client#

Place the following file on the USB drive OEMDATA partition when you want hook.sh on the client to use your hosted firmware mirror:

  • lvfs-mirror.url: Include a single line with the base URL of the firmware directory. When the URL points to a directory of .cab/.cap files (for example, a signbinpack release subdirectory), the hook fetches the directory listing, downloads each file, and runs fwupdmgr install for each one.

By default, fwupd installs only trusted (LVFS-signed) firmware. If installation of local or vendor .cab/.cap files fails with a message such as “firmware signature missing or not trusted” (for example, signbinpack content from the mirror or from USB firmware/), edit /etc/fwupd/fwupd.conf on the client and set OnlyTrusted=false under [fwupd]: The following example shows the required setting:

[fwupd]
OnlyTrusted=false

Note

Use OnlyTrusted=false only when you control the firmware source and accept the risk. Refer to Security Considerations for related firmware-mirror guidance.

Mirror the Full Ubuntu Ports and LVFS Content on a Server#

Use this workflow when you need Ubuntu ports and LVFS content on the installation network and can host a large mirror tree. The following procedures create a unified layout for Ubuntu ports APT repositories and LVFS content under ~/mirror. In the examples, Spark A acts as the mirror server and serves the content over HTTP on port 8080; Spark B acts as the client.

The mirror server can be another DGX Spark or an x86 system. The example scripts were verified on DGX Spark. An x86 server might require changes. The APT mirror example adds only the noble-proposed repository by default. To mirror additional repositories, update the script by following the same pattern used for noble-proposed.

Server Directory Layout#

The following example shows the expected directory tree under the mirror root (for example, tree -L 2 ~/mirror):

.
├── apt
│   ├── mirror
│   ├── skel
│   └── var
├── guids.txt              # optional: LVFS partial sync (--guid-file)
├── hook.sh
├── lvfs                   # LVFS mirror (metadata + .cab)
├── spark-mirror-sync.sh   # One-shot mirror script
└── sync-pulp.py           # LVFS sync script (from LVFS upstream)

Client URL Patterns (Must Match Layout)

The following table shows the client URL patterns that must match the server layout:

Service

URL Pattern

apt

http://SERVER_IP:8080/apt/mirror/ports.ubuntu.com/ubuntu-ports/

fwupd

MetadataURI=http://SERVER_IP:8080/lvfs/<metadata-file> and FirmwareBaseURI=http://SERVER_IP:8080/lvfs

If you rename lvfs, change both MetadataURI and FirmwareBaseURI on clients to match.

Create the Top-Level Tree and sync-pulp.py#

Create the mirror directory tree and download sync-pulp.py with the following commands:

mkdir -p ~/mirror/apt ~/mirror/lvfs
cd ~/mirror
wget -O sync-pulp.py https://gitlab.com/fwupd/lvfs-website/raw/master/contrib/sync-pulp.py
chmod +x sync-pulp.py

Create guids.txt only if you plan a partial LVFS sync. For the sync command, refer to Partial Mirror (GUID File, ~50 MB). For file generation instructions and formatting rules, refer to guids.txt for a Partial LVFS Sync.

One-Shot Sync Script: spark-mirror-sync.sh#

Copy oemdata/spark-mirror-sync.sh from your distribution package onto the Spark, or run it from a repository clone. Some trees place this file under scripts/; use the path that matches your bundle. Run as root (sudo); the script does not invoke sudo internally.

The script mirrors both APT and LVFS content. It was verified on DGX Spark and might require changes on an x86 server. The APT examples in this guide use noble-proposed only. Add other suites or repositories to the script if your deployment requires them.

The script performs the following actions:

  • Installs dependencies only if you pass --install-deps / --install-apt-mirror.

  • Creates ${MIRROR_ROOT}/apt-mirror.list.spark if missing (noble-proposed, base_path = $MIRROR_ROOT/apt).

  • Runs apt-mirror, then sync-pulp.py into $MIRROR_ROOT/lvfs.

  • Symlinks /usr/local/bin/python to python3 for tools that expect python, and runs sync-pulp.py with python3.

Run spark-mirror-sync.sh with environment variables set as shown in the following examples:

export LVFS_USERNAME='you@example.com'
export LVFS_TOKEN='your-lvfs-token'
sudo -E ./spark-mirror-sync.sh --install-deps --install-apt-mirror   # first run only
sudo -E ./spark-mirror-sync.sh --install-deps --install-apt-mirror --skip-lvfs   # skip LVFS; sync separately
sudo -E ./spark-mirror-sync.sh

Default MIRROR_ROOT with sudo and without -H is /root/mirror. To mirror under a user home directory (for example, /home/nvidia/mirror), run the following command:

sudo env MIRROR_ROOT=/home/nvidia/mirror ./spark-mirror-sync.sh

Optional: APT_MIRROR_LIST, --skip-apt, --skip-lvfs, LVFS_CLEANUP=1 for --cleanup on LVFS. If $MIRROR_ROOT/guids.txt exists, the script passes --guid-file automatically.

APT Mirror (noble-proposed under ~/mirror/apt)#

If a manual APT mirror is required, this section provides an example workflow for creating one. The packaged /usr/bin/apt-mirror on Ubuntu is often too old to mirror some DEP-11 paths (for example, icons-64x64@2.tar). Install the current upstream apt-mirror Perl script from GitHub with the following commands:

sudo apt install -y perl wget
sudo cp -a /usr/bin/apt-mirror /usr/bin/apt-mirror.distpkg 2>/dev/null || true
sudo wget -O /usr/local/bin/apt-mirror https://raw.githubusercontent.com/apt-mirror/apt-mirror/master/apt-mirror
sudo chmod +x /usr/local/bin/apt-mirror

Always run synchronization with /usr/local/bin/apt-mirror.

apt-mirror stores the Ubuntu tree under $base_path/mirror/. With base_path set to ~/mirror/apt, the live archive path is ~/mirror/apt/mirror/ports.ubuntu.com/ubuntu-ports/, matching the client URI after http://SERVER_IP:8080/apt/.

Create the mirror list file that /usr/local/bin/apt-mirror uses for the mirror configuration. The following example uses /home/nvidia/mirror/apt as the base path:

set base_path /home/nvidia/mirror/apt

# Only noble-proposed for ports.ubuntu.com/ubuntu-ports (+ optional deb-src if you mirror sources)

clean http://ports.ubuntu.com/ubuntu-ports
sudo /usr/local/bin/apt-mirror /etc/apt/mirror.spark.list

Re-run periodically (for example, through cron) when you need fresher packages. Allow 8080/tcp from client subnets if a host firewall is enabled.

The LVFS (fwupd) Mirror Under ~/mirror/lvfs#

Install the Python dependencies for sync-pulp.py with the following command:

sudo apt install -y python3 python3-requests python3-lxml

Full Mirror (Large, on the Order of ~300 GB)

A full mirror requires an LVFS account and user token (not your account password). Refer to the LVFS site for account and token issuance. Run the following command to synchronize the full LVFS tree:

cd ~/mirror
./sync-pulp.py https://fwupd.org/downloads ~/mirror/lvfs \
  --username='your-email@example.com' \
  --token='YOUR_USER_TOKEN'

Re-run to update; existing valid files are skipped.

Optional: --cleanup removes files no longer in the manifest.

Partial Mirror (GUID File, ~50 MB)

On a representative Spark, run sudo fwupdtool get-devices or fwupdmgr get-devices --show-all. Build ~/mirror/guids.txt, then run the following command:

./sync-pulp.py https://fwupd.org/downloads ~/mirror/lvfs \
  --username='your-email@example.com' \
  --token='YOUR_USER_TOKEN' \
  --guid-file=guids.txt

Patch After Mirror: Choose MetadataURI for Clients#

After synchronization, choose a metadata file that has a matching .jcat file with the same basename (for example, firmware.xml.xz and firmware.xml.xz.jcat, or a versioned pair such as firmware-08681-stable.xml.xz and firmware-08681-stable.xml.xz.jcat). Use the newest stable pair that exists on disk. Partial mirrors from --guid-file often omit top-level firmware.xml.* metadata. Run the following safety-net script on the server before configuring clients:

LVFS_DIR="$HOME/mirror/lvfs"
LVFS_CDN="https://cdn.fwupd.org/downloads"
for ext in xz gz zst; do
  meta="${LVFS_DIR}/firmware.xml.${ext}"
  jcat="${meta}.jcat"
  if [[ ! -s "${meta}" ]]; then
    echo "Safety-net: ${meta##*/} missing; fetching from ${LVFS_CDN}"
    if curl -fSL --connect-timeout 10 --max-time 120 \
      -o "${meta}.new" "${LVFS_CDN}/firmware.xml.${ext}" \
      && curl -fSL --connect-timeout 10 --max-time 30 \
      -o "${jcat}.new" "${LVFS_CDN}/firmware.xml.${ext}.jcat"; then
      mv -f "${meta}.new" "${meta}"
      mv -f "${jcat}.new" "${jcat}"
    else
      rm -f "${meta}.new" "${jcat}.new"
      echo "WARN: failed to fetch firmware.xml.${ext} / .jcat from ${LVFS_CDN}" >&2
    fi
  fi
done

Run the following commands on the server to confirm that metadata and signature files are present:

cd ~/mirror/lvfs
for f in firmware*.xml.xz firmware*.xml.gz firmware*.xml.zst; do
  [ -f "$f" ] || continue
  [ -f "${f}.jcat" ] && echo "OK: $f"
done

# below three messages should be observed
OK: firmware.xml.xz
OK: firmware.xml.gz
OK: firmware.xml.zst

guids.txt for a Partial LVFS Sync#

For a partial LVFS sync, create guids.txt with the GUIDs of the devices whose firmware you want to mirror.

Generate guids.txt#

To create the file manually, save the fwupdtool get-devices output and copy only UUIDs in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx into ~/mirror/guids.txt. Run sort -u ~/mirror/guids.txt -o ~/mirror/guids.txt to sort the file and remove duplicate lines.

Alternatively, use the following JSON workflow from your user session to extract GUIDs from fwupdmgr output. This command requires jq:

fwupdmgr get-devices --json | jq -r '
  .. | objects | select(has("Guid")) | .Guid,
  (.. | objects | select(has("Guids")) | .Guids[]?)
' | sort -u > ~/mirror/guids.txt

To remove non-UUID characters from guids.txt, run the following commands:

awk '{ gsub(/[^0-9A-Fa-f-]/, "", $0); if (length($0)) print $0 }' \
    ~/mirror/guids.txt > ~/mirror/guids.txt.clean
mv ~/mirror/guids.txt.clean ~/mirror/guids.txt

Formatting Rules#

Follow these rules when you create guids.txt for a partial LVFS sync:

Rule

Detail

One UUID per line

Use the UUID exactly as printed (lowercase hex is acceptable).

No comments or blank lines

Do not use # comments, hardware hints after ←, or blank lines.

Include device GUIDs

Copy only the UUIDs from Guid: lines and GUIDs: blocks for devices whose firmware you want to mirror.

Unique UUIDs only

The same GUID often appears on more than one device (for example, several identical NICs). List each UUID only once.

What to include

Include UUIDs for internal or updatable components whose firmware you want in the mirror (such as EC, TPM, UEFI capsules, NVMe, or dbx).

What to omit

Omit removable USB devices if you do not require LVFS content for that class of hardware.

Example guids.txt Listing#

Example guids.txt for NVIDIA DGX Spark (spark-cr01, sudo fwupdtool get-devices): The following listing reflects a typical Spark (Kingston USB flash drive, EC, four ConnectX-7 ports with the same four GUIDs repeated, Samsung NVMe, TPM, two UEFI ESRT firmware slots, and UEFI dbx), including that USB device:

09321615-5d32-5758-8308-52a4a7be8efc
095ba8dd-3778-52b4-9f32-02a67c210ce5
0eb9bda9-3010-493a-a6a8-b5e80eddf870
10ec82f4-ff64-5362-9e5d-688febf5dbb0
12029307-5bb1-5200-99a5-536f1be9d081
35abf34a-7ed8-51b2-ba1b-edef527d47e6
3d13c989-e6a8-4ead-95ee-921f09868f65
59007998-a3d7-54a3-b30e-eb3b77e2f351
5f106816-21fe-5d90-896a-175038b9256f
67d35028-ca5b-5834-834a-f97380381082
75b1af35-b88a-59d2-a3c7-a38537f8607f
93768061-87bf-5c78-b9ea-5b7a6301012b
b488217b-3895-4fc0-b1bf-ab7005a2d45a
b5e95689-ad65-5e57-8778-897f04396256
cfc0de0b-adb3-5060-ba22-e4010a78368f
dd1a238a-5f8e-46bd-9401-a88da99c5a96

Smaller Mirror (same machine; omit DT microDuo 3C): Delete these three Kingston-only lines:

  • 09321615-5d32-5758-8308-52a4a7be8efc

  • 5f106816-21fe-5d90-896a-175038b9256f

  • 75b1af35-b88a-59d2-a3c7-a38537f8607f

sync-pulp.py cannot combine --guid-file and --filter-tag in one run; run twice if you need both, or consult LVFS offline documentation. Filtered syncs can occasionally leave orphaned metadata relative to .jcat pairs; verify pairs and fetch missing top-level metadata with the safety-net script above if needed.

Serve ~/mirror With Python#

After the mirror sync completes, serve the ~/mirror tree over HTTP with Python as follows.

cd ~/mirror
python3 -m http.server 8080 --bind 0.0.0.0
  • apt on the wire: http://SERVER_IP:8080/apt/mirror/...

  • LVFS on the wire: http://SERVER_IP:8080/lvfs/...

Keep this process running (tmux, a systemd user unit, or equivalent) while clients update.

apt-repo.url and lvfs-mirror.url must point to the correct addresses to use the mirror APT repository and LVFS content.

Cloud-init Integration#

The user-data file integrates hook.sh with cloud-init during first boot. Place the applicable user-data example and hook.sh in the OEMDATA partition root. If the hook exit code is 1, the example records mirror-setup success, runs sync, and schedules a reboot.

Security Considerations#

Consider the following security implications when you deploy custom installation media, local mirrors, and hook.sh automation:

  • Secure Boot: Before booting recovery or customized installation media, confirm the Secure Boot requirements for your deployment and platform policy. Use only NVIDIA-provided or otherwise approved boot media. If Secure Boot settings are changed for installation or recovery, restore the required Secure Boot state before returning the system to service.

  • hook.sh and USB contents: The hook runs with elevated privileges and executes content from the OEMDATA partition. Anyone with physical access can replace hook.sh, Debian packages, or firmware on the USB drive. Treat the USB drive as trusted input: use tamper-aware handling, restrict who can prepare USB devices, or verify integrity (for example, hashes or signatures) if your policy requires it.

  • Local APT repository: The hook adds the repository with [trusted=yes], so packages from that repository are not signature-verified. Ensure the repository server and network are trusted.

  • Local firmware mirror: Firmware from the mirror (or a directory of .cab/.cap) is installed by fwupd. If you set OnlyTrusted=false, unverified or vendor-signed firmware is allowed only when you control the firmware source and accept the risk.

  • URLs on the USB drive: The values in apt-repo.url and lvfs-mirror.url identify servers on your network. Those servers must be trustworthy. If an attacker compromises one of those servers, or performs a man-in-the-middle (MITM) attack on the path between the client and the server, the client could install malicious packages or firmware.

  • Network exposure: The host that serves the APT repository and firmware tree is reachable from other systems on the same local network used for DGX Spark installation. Harden that host (access control, firewall, operating system updates). When your security policy requires it, place installation and repository access on a dedicated or isolated network segment instead of a general-purpose LAN.

  • Account password: The user-creation example stores the chpasswd password as cleartext in user-data on the FAT32 OEMDATA partition. Anyone with physical access to the volume can read it. Protect the volume and replace the example credentials with values approved by your organization.

Verify the Customization and Installation Outcomes#

Use the following subsections to confirm that your custom installation, mirror configuration, and first-boot provisioning completed successfully.

Prove cloud-init runs#

Installation of nvidia-spark-cloud-init is logged in /var/log/oem-iso-cfg.log during OS installation. Review this log to confirm that the package was installed.

Cloud-init writes user-data and executor output to /var/log/cloud-init-output.log. Review this log to confirm that the provisioning scripts ran and to diagnose errors.

After Mirror- or USB-Driven Updates#

After first boot completes, confirm that mirror- or USB-driven updates applied successfully:

  • Confirm that the expected Debian or Ubuntu packages are installed from the mirror.

  • Confirm expected firmware versions after fwupdmgr update, as applicable.

  • Inspect cloud-init logs for hook execution and errors.

  • Disable cloud-init for subsequent boots if your operational model requires it, per your site policy.

Prepare the Installation Media and Client (Verification Flow)#

Use the following checklist when validating an end-to-end flow:

  1. When you use the curated local repository workflow, host packages and firmware per Host a Minimal APT Repository and Firmware Tree and start the web server from WEB_ROOT.

  2. Prepare the FAT32 OEMDATA volume with the required meta-data and user-data files.

  3. Copy hook.sh and optional packages, firmware, and URL files to OEMDATA as required by your workflow.

  4. If the system runs a release earlier than the October 2026 Release, create an October 2026 Release or later recovery media and reflash the system.

    Warning

    Flashing the client re-images the DGX Spark system and erases local storage. Do not proceed until backups are complete, the bootable media has been verified, and the target system has been identified.

  5. Power off the DGX Spark, attach the OEMDATA volume, and power on the system.

  6. Verify that the DGX Spark boots successfully, cloud-init completed without errors, the expected users and configuration are present, required packages and firmware versions are installed, and the Secure Boot state matches your deployment policy.

Reference: Scripts and cloud-init#

Note

The sample code uses YOUR_USERNAME and YOUR_PASSWORD as placeholder values for the DGX Spark account credentials. Before you use the sample code, replace these placeholders with the username and password for your DGX Spark system.

The following listings provide reference copies of hook.sh and cloud-init seed files for the October 2026 Release workflow.

The following tree shows the supported OEMDATA layout:

.
├── apt-packages.txt
├── apt-repo.url
├── debs/
├── firmware/
├── hook.sh
├── lvfs-mirror.url
├── meta-data
└── user-data

First Boot: OEMDATA hook.sh and cloud-init Seed#

hook.sh lives on the OEMDATA partition. The user-creation user-data example mounts that partition, copies the hook to /tmp, exports OEM_MNT, and runs the copy. The keep-OOBE example runs the provisioning worker from bootcmd.

Important

Keep the OEMDATA partition and any required local mirror reachable during first boot. Cloud-init runs the provisioning workflow during this stage and can take several minutes while it installs packages, applies firmware updates, refreshes APT or fwupd metadata, and runs hook.sh. Do not power off the system or disconnect required media until provisioning completes.

oemdata/hook.sh#

The following script is the reference hook.sh that runs from the OEMDATA partition or from a network URL during first boot.

What this script does

The script performs the following actions in order:

  1. Applies default mirror URL settings and switches the client to the local APT and LVFS mirror endpoints.

  2. Installs Debian packages from the USB debs/ directory when present and from the local APT repository when apt-repo.url is present (with optional apt-packages.txt).

  3. Installs firmware from the USB firmware/ directory when present and from the URL in lvfs-mirror.url when that file is present.

  4. Refreshes fwupd, checks for remaining firmware and APT upgrades, applies available upgrades, then restores stock APT sources and re-enables the public LVFS remote after a successful final pass.

#!/bin/sh
# OEM hook script: run from cloud-init when OEMDATA partition is mounted.
# Copy this file to the root of the OEMDATA partition (next to debs/ and firmware/).
# Optional: apt-repo.url (full path to repo, e.g. …/deb-repo/), apt-packages.txt;
# lvfs-mirror.url (base URL only, dir containing firmware.xml.gz,
# e.g. …/lvfs-mirror/ — not a subpath).
# Exit: 0 = success, no apt/fwupd updates in final pass;
# 1 = success and at least one applied;
# 255 = failure (-1 in 8-bit).
# After a successful final pass, current sources.list.d is renamed to
# sources.list.d.cldnt,
# stock apt is restored from sources.list.d.org, and public LVFS is re-enabled.
# Override: MIRROR_SERVER_IP, MIRROR_SERVER_PORT, LVFS_METADATA_NAME, LVFS_WEB_SUBDIR
# OEM_MNT is the OEMDATA partition root (debs/, firmware/, urls).
# Normally the directory containing this script; cloud-init may copy this file
# to /tmp and set OEM_MNT explicitly.
# This tree does not use functions.sh. If you extend the USB copy,
# source helpers only as
#   . "$OEM_MNT/your-helper.sh"
# never . "$(dirname "$0")/..." or files vanish when $0 is under /tmp.

# By USING THIS SCRIPT, USER AGREES TO THE TERMS OF THE EULA,
# SOL is enabled by default.
# TELEMETRY is not enabled by default. If enabling telemetry, user agrees to
# accept TELEMETRY terms.

OEM_MNT=${OEM_MNT:-$(dirname "$0")}
WIFI_ADAPTER=${WIFI_ADAPTER:-wlP9s9} # Default WiFi adapter name

# -----------------------------------------------------------------------------
# Defaults / mirror URLs (used by mirror + OEM stages)
# -----------------------------------------------------------------------------
_hook_config_defaults() {
 # Example defaults. Replace these values for your installation network.
 MIRROR_SERVER_IP=${MIRROR_SERVER_IP:-192.168.1.1}
 MIRROR_SERVER_PORT=${MIRROR_SERVER_PORT:-8080}
 LVFS_WEB_SUBDIR=${LVFS_WEB_SUBDIR:-lvfs}
 LVFS_METADATA_NAME=${LVFS_METADATA_NAME:-firmware.xml.xz}
 MIRROR_APT_URI="http://${MIRROR_SERVER_IP}:${MIRROR_SERVER_PORT}/apt/mirror/"\
 "ports.ubuntu.com/ubuntu-ports/"
 MIRROR_FW_BASE="http://${MIRROR_SERVER_IP}:${MIRROR_SERVER_PORT}/${LVFS_WEB_SUBDIR}"
}

# -----------------------------------------------------------------------------
# HTTP GET to stdout (wget or curl)
# -----------------------------------------------------------------------------
_hook_http_get() {
 wget -qO - "$1" 2>/dev/null || curl -sL "$1" 2>/dev/null
}

# -----------------------------------------------------------------------------
# OOBE post-steps (EXIT trap): run when user "YOUR_USERNAME" exists;
# never fail the hook.
# -----------------------------------------------------------------------------
_hook_oobe_supplementary_groups() {
 usermod -aG adm,sudo,audio,dip,plugdev,users,lpadmin \
  YOUR_USERNAME 2>/dev/null || true
}

_hook_oobe_spark_autostart_and_keyboard() {
 install -d -m 0755 -o YOUR_USERNAME -g YOUR_USERNAME \
  /home/YOUR_USERNAME/.config/autostart 2>/dev/null || true
 if [ ! -f /home/YOUR_USERNAME/.config/autostart/nvidia-spark-docs.desktop ]; then
   ( umask 022
     cat > /home/YOUR_USERNAME/.config/autostart/nvidia-spark-docs.desktop << 'EOF'
[Desktop Entry]
Type=Application
Name=NVIDIA Spark documentation
Exec=xdg-open https://build.nvidia.com/spark
X-GNOME-Autostart-enabled=true
EOF
   ) 2>/dev/null || true
   chown YOUR_USERNAME:YOUR_USERNAME \
     /home/YOUR_USERNAME/.config/autostart/nvidia-spark-docs.desktop \
     2>/dev/null || true
   chmod 0644 /home/YOUR_USERNAME/.config/autostart/nvidia-spark-docs.desktop \
     2>/dev/null || true
 fi
 if [ -f "$OEM_MNT/oem-keyboard-spark.sh" ]; then
   echo "[oem hook] running $OEM_MNT/oem-keyboard-spark.sh"
   sh "$OEM_MNT/oem-keyboard-spark.sh" || true
 fi
}

_hook_oobe_skip_gnome_initial_setup() {
 install -d -m 0755 -o YOUR_USERNAME -g YOUR_USERNAME \
  /home/YOUR_USERNAME/.config 2>/dev/null || true
 touch /home/YOUR_USERNAME/.config/gnome-initial-setup-done 2>/dev/null || true
 chown YOUR_USERNAME:YOUR_USERNAME \
  /home/YOUR_USERNAME/.config/gnome-initial-setup-done 2>/dev/null || true
}

_hook_oobe_hotspot_teardown_if_ethernet() {
 # Run as a child (not ".") so dgx-oobe sees $0 under /opt/nvidia/dgx-oobe
 # (functions.sh path).
 # Use bash: functions.sh uses bash syntax; /bin/sh (dash) errors with
 # "(" unexpected.
 _hs=/opt/nvidia/dgx-oobe/oobe-hotspot-shutdown.sh
 if [ -f "$_hs" ]; then
   command -v bash >/dev/null 2>&1 && bash "$_hs" || true
 fi
}

_hook_oobe_disable_systemd_units() {
 for u in dgx-oobe dgx-oobe-admin dgx-oobe-hotspot dgx-oobe-hostname \
   dgx-oobe-hotspot-watchdog; do
   systemctl stop "$u" 2>/dev/null || true
   systemctl disable "$u" 2>/dev/null || true
 done
 if [ -f /etc/NetworkManager/dnsmasq-shared.d/dgx-oobe.conf ]; then
   rm -f /etc/NetworkManager/dnsmasq-shared.d/dgx-oobe.conf
 fi
 systemctl restart avahi-daemon 2>/dev/null || true
 # Disable WiFi adapter scan
 if [ -z "${WIFI_ADAPTER}" ]; then
     return 0
 fi
 if ip link show ${WIFI_ADAPTER}_scan >/dev/null 2>&1; then
     /usr/bin/ip link set ${WIFI_ADAPTER}_scan down || true
     /usr/sbin/iw dev ${WIFI_ADAPTER}_scan del || true
 fi
}

_hook_oobe_ubuntu_pro_attach() {
 if [ -n "${UBUNTU_PRO_TOKEN:-}" ] && command -v pro >/dev/null 2>&1; then
   pro attach "$UBUNTU_PRO_TOKEN" --no-prompt 2>/dev/null || true
 fi
}

_hook_oobe_sol_if_consent() {
 echo "[oem hook] Enabling SOL"
 install -d -m 0755 /opt/nvidia/dgx-telemetry 2>/dev/null || true
 touch /opt/nvidia/dgx-telemetry/eula_accepted 2>/dev/null || true
 sync
 systemctl daemon-reload 2>/dev/null || true
 if ! systemctl enable --now nvidia-dgx-sol 2>/dev/null; then
   echo "[oem hook] warning: systemctl enable --now nvidia-dgx-sol failed" \
     "(check status; unit may stay disabled)" >&2 || true
   systemctl start nvidia-dgx-sol 2>/dev/null || true
 fi
}

_hook_oobe_telemetry_if_consent() {
 echo "[oem hook] Enabling telemetry"
 install -d -m 0755 /opt/nvidia/dgx-telemetry 2>/dev/null || true
 touch /opt/nvidia/dgx-telemetry/technical_consent \
   /opt/nvidia/dgx-telemetry/functional_consent 2>/dev/null || true
 sync
 systemctl daemon-reload 2>/dev/null || true
 if ! systemctl enable --now nvidia-dgx-telemetry 2>/dev/null; then
   echo "[oem hook] warning: systemctl enable --now nvidia-dgx-telemetry failed" \
     "(check status; unit may stay disabled)" >&2 || true
   systemctl start nvidia-dgx-telemetry 2>/dev/null || true
 fi
}

_hook_oobe_complete_flag_marker() {
 install -d -m 0755 /opt/nvidia/dgx-oobe 2>/dev/null || true
 touch /opt/nvidia/dgx-oobe/oobe-complete-flag 2>/dev/null || true
}

# When cloud-init created user "nvidia", run one-time OOBE-aligned steps on every
# script exit.
# (EXIT runs after normal completion, exit 1, or exit 255 so these steps still run.)
_hook_oobe_post() {
 set +e
 if [ "$(id -u)" -ne 0 ]; then
   return 0
 fi
 if ! getent passwd YOUR_USERNAME >/dev/null 2>&1; then
   return 0
 fi
 echo "[oem hook] OOBE post-steps for user YOUR_USERNAME (EXIT trap)"
 _hook_restore_stock_apt_and_lvfs || true
 _hook_oobe_supplementary_groups || true
 _hook_oobe_spark_autostart_and_keyboard || true
 _hook_oobe_skip_gnome_initial_setup || true
 _hook_oobe_hotspot_teardown_if_ethernet || true
 _hook_oobe_disable_systemd_units || true
 _hook_oobe_ubuntu_pro_attach || true
 _hook_oobe_sol_if_consent || true
 _hook_oobe_complete_flag_marker || true

 return 0
}

trap '_hook_oobe_post' EXIT

# -----------------------------------------------------------------------------
# Unified Spark mirror: local apt + fwupd LVFS remote
# -----------------------------------------------------------------------------
_hook_mirror_archive_stock_sources() {
 if [ ! -d /etc/apt/sources.list.d.org ]; then
   if [ -d /etc/apt/sources.list.d ]; then
     mv /etc/apt/sources.list.d /etc/apt/sources.list.d.org
   fi
 fi
 rm -rf /etc/apt/sources.list.d
 mkdir -p /etc/apt/sources.list.d
}

_hook_mirror_write_deb822_sources() {
 cat > /etc/apt/sources.list.d/local-mirror.sources <<EOF
# Ubuntu from local mirror (under web root .../apt/mirror/)
Types: deb deb-src
URIs: ${MIRROR_APT_URI}
Suites: noble-proposed
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF
}

_hook_mirror_write_fwupd_local_remote() {
 mkdir -p /etc/fwupd/remotes.d
 cat > /etc/fwupd/remotes.d/local-lvfs-mirror.conf <<EOF
[fwupd Remote]
Enabled=true
Type=download
Title=Local LVFS Mirror
MetadataURI=${MIRROR_FW_BASE}/${LVFS_METADATA_NAME}
FirmwareBaseURI=${MIRROR_FW_BASE}
EOF
}

_hook_mirror_disable_public_lvfs() {
 fwupdmgr disable-remote lvfs 2>/dev/null || {
   [ -f /etc/fwupd/remotes.d/lvfs.conf ] && \
     mv /etc/fwupd/remotes.d/lvfs.conf \
       /etc/fwupd/remotes.d/lvfs.conf.disabled
 }
}

_hook_mirror_apply_local_mirror() {
 _hook_mirror_archive_stock_sources
 _hook_mirror_write_deb822_sources
 _hook_mirror_write_fwupd_local_remote
 _hook_mirror_disable_public_lvfs
}

# Retry apt update to tolerate transient lock contention (e.g. another apt process).
# 600 cycles, 1 second each.
_hook_apt_update_with_retries() {
 _attempt=1
 _max_attempts=600
 while [ "$_attempt" -le "$_max_attempts" ]; do
   if apt-get update -o Acquire::Languages=none || apt-get update; then
     return 0
   fi
   if [ "$_attempt" -lt "$_max_attempts" ]; then
     echo "apt update failed (attempt ${_attempt}/${_max_attempts});" \
       "retrying in 1s..."
     sleep 1
   fi
   _attempt=$((_attempt + 1))
 done
 echo "apt update failed after ${_max_attempts} attempts"
 return 1
}

# Succeed if either update works; exit 255 only when both fail.
_hook_apt_update_initial() {
 if _hook_apt_update_with_retries; then
   return 0
 fi
 exit 255
}

# -----------------------------------------------------------------------------
# OEMDATA: local .deb drop, optional apt repo, firmware USB, optional LVFS URL
# -----------------------------------------------------------------------------
_hook_oem_install_debs_from_usb() {
 echo "Checking for debs in USB OEMDATA partition..."
 if [ -d "$OEM_MNT/debs" ] && ls "$OEM_MNT/debs"/*.deb >/dev/null 2>&1; then
   echo "Installing debs from USB OEMDATA partition..."
   dpkg -i "$OEM_MNT/debs"/*.deb || true
   apt-get install -f -y
 fi
}

_hook_oem_install_from_local_repo() {
 echo "Checking for local APT repo..."
 if [ ! -f "$OEM_MNT/apt-repo.url" ]; then
   echo "apt-repo.url not found"
   return 0
 fi
 repo_url=$(sed -n '1s/[[:space:]]*//p' "$OEM_MNT/apt-repo.url")
 if [ -z "$repo_url" ]; then
   echo "No local APT repo URL found"
   return 0
 fi
 echo "Adding local APT repo: $repo_url"
 printf 'deb [trusted=yes] %s ./\n' "$repo_url" > \
   /etc/apt/sources.list.d/oem-local.list
 repo_host=$(echo "$repo_url" | sed -n 's|.*://\([^:/]*\).*|\1|p')
 if [ -n "$repo_host" ]; then
   rm -f /var/lib/apt/lists/partial/*"$repo_host"* \
     /var/lib/apt/lists/*"$repo_host"* 2>/dev/null || true
 fi
 _hook_apt_update_with_retries || true

 if [ ! -f "$OEM_MNT/apt-packages.txt" ]; then
   echo "apt-packages.txt not found; apt upgrade using OEM local repo only" \
     "(single-source apt)"
   apt upgrade -y \
     -o Dir::Etc::sourcelist="/etc/apt/sources.list.d/oem-local.list" \
     -o APT::Architecture="$(dpkg --print-architecture)" \
     || true
   return 0
 fi
 pkgs=$(grep -v '^[#;]' "$OEM_MNT/apt-packages.txt" | while read -r line; do
   line="${line%%[[:space:]]*}"
   [ -z "$line" ] && continue
   case "$line" in *\.deb)
     line="${line%.deb}"; line="${line%_*}"; line="${line%_*}"
   esac
   echo "$line"
 done | tr '\n' ' ')
 if [ -z "$pkgs" ]; then
   return 0
 fi
 echo "Installing packages from local repo: $pkgs"
 if ! apt-get install -y $pkgs; then
   echo "Fallback: downloading .deb and installing with dpkg..."
   base="${repo_url%/}"
   for pkg in $pkgs; do
     pkg_file=$(
       _hook_http_get "$base/Packages.gz" | zcat 2>/dev/null | awk -v pkg="$pkg" '
/^Package: /{name=$2}
/^Filename: /{if(name==pkg){print $2; exit}}
/^$/{name=""}
'
     )
     pkg_file="${pkg_file#./}"
     [ -z "$pkg_file" ] && continue
     tmp_deb="/tmp/$(basename "$pkg_file")"
     if _hook_http_get "$base/$pkg_file" > "$tmp_deb" 2>/dev/null && \
       [ -s "$tmp_deb" ]; then
       dpkg -i "$tmp_deb" && echo "PASS: $pkg (dpkg)" || true
     else
       echo "FAIL: could not download $pkg"
     fi
     rm -f "$tmp_deb"
   done
   apt-get install -f -y 2>/dev/null || true
 fi
}

_hook_oem_install_firmware_usb() {
 echo "Checking for firmware in USB OEMDATA partition..."
 if [ ! -d "$OEM_MNT/firmware" ]; then
   return 0
 fi
 echo "Installing firmware from USB OEMDATA partition..."
 find "$OEM_MNT/firmware" -maxdepth 1 -type f \
   \( -name '*.cab' -o -name '*.cap' \) | while read -r f; do
   name=$(basename "$f")
   echo "Installing firmware: $name"
   if fwupdmgr install --allow-reinstall "$f"; then
     echo "PASS: $name"
   else
     echo "FAIL: $name"
   fi
 done
}

# Supports (1) full LVFS mirror: URL points to dir with firmware.xml.gz;
# (2) directory of .cab/.cap only.
_hook_oem_lvfs_mirror_from_url() {
 echo "Checking for LVFS mirror URL..."
 if [ ! -f "$OEM_MNT/lvfs-mirror.url" ]; then
   return 0
 fi
 lvfs_base=$(sed -n '1s/[[:space:]]*//p' "$OEM_MNT/lvfs-mirror.url")
 lvfs_base="${lvfs_base%/}/"
 if [ -z "$lvfs_base" ]; then
   return 0
 fi
 lvfs_meta_url="${lvfs_base}firmware.xml.gz"
 curl_meta_code=$(curl -sI -o /dev/null -w '%{http_code}' \
   "$lvfs_meta_url" 2>/dev/null)
 if ( wget -q --spider "$lvfs_meta_url" 2>/dev/null ) || \
   [ "$curl_meta_code" = "200" ]; then
   echo "Adding LVFS mirror (metadata): $lvfs_base"
   mkdir -p /etc/fwupd/remotes.d
   cat > /etc/fwupd/remotes.d/oem-lvfs-mirror.conf << EOF
[fwupd Remote]
Title=OEM LVFS Mirror
MetadataURI=${lvfs_base}firmware.xml.gz
FirmwareBaseURI=$lvfs_base
Enabled=true
EOF
   echo "Refreshing fwupd and upgrading firmware from mirror..."
   fwupdmgr refresh --force || fwupdmgr refresh
   fwupdmgr update || true
 else
   echo "No firmware.xml.gz at $lvfs_base; treating as directory of .cab/.cap..."
   _hook_http_get "$lvfs_base" | grep -oE 'href="[^"]*\.(cab|cap)"' | \
     sed 's/href="//;s/"$//' | while read -r f; do
     [ -z "$f" ] && continue
     tmp_f="/tmp/$(basename "$f")"
     if _hook_http_get "$lvfs_base$f" > "$tmp_f" 2>/dev/null && [ -s "$tmp_f" ]; then
       echo "Installing firmware from mirror: $f"
       fwupdmgr install --allow-reinstall "$tmp_f" && \
         echo "PASS: $f" || echo "FAIL: $f"
     fi
     rm -f "$tmp_f"
   done
 fi
}

# -----------------------------------------------------------------------------
# Final pass: apt upgrade + fwupd loop; sets HOOK_APT_UPDATED,
# HOOK_FW_UPDATED, HOOK_FW_FAILED
# -----------------------------------------------------------------------------
_hook_final_apt_upgrade() {
 echo "apt update with retry (600x, 1s)"
 if _hook_apt_update_with_retries; then
   :
 else
   echo "apt update failed"
   exit 255
 fi

 echo "apt -s upgrade | grep -q '^[[:space:]]*Inst '"
 if apt -s upgrade | grep -q '^[[:space:]]*Inst '; then
   DEBIAN_FRONTEND=noninteractive apt -y \
     -o Dpkg::Options::=--force-confold upgrade || exit 255
   HOOK_APT_UPDATED=1
 fi
}

_hook_fwupdmgr_refresh_and_count() {
 echo "fwupdmgr refresh --force"
 fwupdmgr refresh --force

 HOOK_FW_APPLICABLE=""
 if command -v jq >/dev/null 2>&1; then
   _hook_jq_fw_count='(if type == "object" and (.Devices | type) == "array" '
   _hook_jq_fw_count="${_hook_jq_fw_count}then .Devices "
   _hook_jq_fw_count="${_hook_jq_fw_count}"'elif type == "array" then . else '
   _hook_jq_fw_count="${_hook_jq_fw_count}[] end) | "
   _hook_jq_fw_count="${_hook_jq_fw_count}"'[.[] | select((.Releases // []) | '
   _hook_jq_fw_count="${_hook_jq_fw_count}"'length > 0)] | length'
   HOOK_FW_APPLICABLE=$(
     fwupdmgr get-upgrades --json 2>/dev/null | jq -r "$_hook_jq_fw_count" \
       2>/dev/null || echo ""
   )
 fi
 HOOK_FW_APPLICABLE=$(printf '%s' "$HOOK_FW_APPLICABLE" | tr -d '\r\n\t ')
 case "$HOOK_FW_APPLICABLE" in
   ''|*[!0-9]*) HOOK_FW_APPLICABLE="?" ;;
 esac
 echo "fwupdmgr: applicable firmware devices (Releases>0): ${HOOK_FW_APPLICABLE:-?}"
}

_hook_fwupdmgr_upgrade_loop() {
 set +e
 case "$HOOK_FW_APPLICABLE" in
   [1-9]|[1-9][0-9]*)
     HOOK_FW_ITER=0
     while [ "$HOOK_FW_ITER" -lt 5 ]; do
       HOOK_FW_ITER=$((HOOK_FW_ITER + 1))
       HOOK_FW_OFFLINE_CAN_BREAK=0
       HOOK_FW_IMMEDIATE_CAN_BREAK=0
       echo "fwupdmgr upgrade -y --offline"
       fwupdmgr upgrade -y --offline
       HOOK_FW_R1=$?
       echo "fwupdmgr upgrade -y --no-reboot-check"
       fwupdmgr upgrade -y --no-reboot-check
       HOOK_FW_R2=$?
       case $HOOK_FW_R1 in
         0) HOOK_FW_UPDATED=1; HOOK_FW_OFFLINE_CAN_BREAK=1 ;;
         2) HOOK_FW_OFFLINE_CAN_BREAK=1 ;;
         *) echo "fwupdmgr upgrade -y --offline failed (exit $HOOK_FW_R1)"; \
            HOOK_FW_FAILED=1 ;;
       esac
       case $HOOK_FW_R2 in
         0) HOOK_FW_UPDATED=1; HOOK_FW_IMMEDIATE_CAN_BREAK=1 ;;
         2) HOOK_FW_IMMEDIATE_CAN_BREAK=1 ;;
         *) echo "fwupdmgr upgrade -y --no-reboot-check failed (exit $HOOK_FW_R2)"; \
            HOOK_FW_FAILED=1 ;;
       esac
       if [ "$HOOK_FW_FAILED" -eq 1 ]; then
         break
       fi
       if [ "$HOOK_FW_OFFLINE_CAN_BREAK" -eq 1 ] && \
         [ "$HOOK_FW_IMMEDIATE_CAN_BREAK" -eq 1 ]; then
         echo "fwupdmgr upgrade -y --offline and fwupdmgr upgrade -y" \
           "--no-reboot-check both finished OK"
         break
       fi
     done
     ;;
 esac
 set -e
}

_hook_restore_stock_apt_and_lvfs() {
 echo "Restoring stock apt sources and re-enabling public LVFS after" \
   "mirror-based updates"
 if [ -d /etc/apt/sources.list.d.org ]; then
   if [ -d /etc/apt/sources.list.d.cldnt ]; then
     rm -rf /etc/apt/sources.list.d.cldnt
   fi
   if [ -d /etc/apt/sources.list.d ]; then
     mv /etc/apt/sources.list.d /etc/apt/sources.list.d.cldnt
   fi
   mv /etc/apt/sources.list.d.org /etc/apt/sources.list.d
 fi
 if [ -f /etc/fwupd/remotes.d/lvfs.conf.disabled ]; then
   mv /etc/fwupd/remotes.d/lvfs.conf.disabled /etc/fwupd/remotes.d/lvfs.conf
 fi
 # Non-interactive: enable-remote otherwise blocks on "Enable new remote?"
# (no TTY under cloud-init).
# The LVFS disclaimer box goes to stdout; cloud-init captures runcmd output into
# cloud-init-provisioning.log.
 fwupdmgr -y --no-remote-check enable-remote lvfs >/dev/null 2>&1 || true
}

_hook_exit_with_status() {
 if [ "$HOOK_FW_FAILED" -eq 1 ]; then
   exit 255
 fi
 if [ "$HOOK_APT_UPDATED" -eq 1 ] || [ "$HOOK_FW_UPDATED" -eq 1 ]; then
   exit 1
 fi
 exit 0
}

# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
_hook_main() {
 _hook_config_defaults
 _hook_mirror_apply_local_mirror
 _hook_oem_install_debs_from_usb
 _hook_oem_install_from_local_repo
 _hook_oem_install_firmware_usb
 _hook_oem_lvfs_mirror_from_url

 HOOK_APT_UPDATED=0
 HOOK_FW_UPDATED=0
 HOOK_FW_FAILED=0
 _hook_fwupdmgr_refresh_and_count
 _hook_fwupdmgr_upgrade_loop
 _hook_apt_update_initial
 _hook_final_apt_upgrade
 _hook_restore_stock_apt_and_lvfs
 _hook_exit_with_status
}

_hook_main

Cloud-init Configurations#

The following examples provide the two required seed files. Place meta-data and the applicable user-data file in the root of the FAT32 OEMDATA partition.

meta-data#

The following file sets the cloud-init instance identifier:

# instance-id is used by cloud-init as a unique instance identifier.

instance-id: oem-spark-01

user-data with User Creation (Skip OOBE)#

Use this example when cloud-init creates the user and OOBE must be skipped.

Warning

If you use custom user-data to skip OOBE, create /etc/cloud/cloud-init.disabled to disable cloud-init on subsequent boots. The following example creates this file automatically.

#cloud-config
# Default user(YOUR_USERNAME:YOUR_PASSWORD)

growpart:
  mode: 'off'

no_ssh_fingerprints: true
resize_rootfs: false

# Allow password auth for console and SSH (required for YOUR_USERNAME login)
ssh_pwauth: true

# Create default user YOUR_USERNAME with password YOUR_PASSWORD
# Use hashed password so login works reliably (chpasswd as backup)
# **** REMOVE BOTH USERS AND CHPASSWD IF USER WANT TO RUN OOBE ****
users:
  - name: YOUR_USERNAME
    groups: [sudo]
    shell: /bin/bash
    lock_passwd: false
    # SHA-512 hash for password "YOUR_PASSWORD" (random 16-char salt)
    hashed_passwd: #GET_WITH_THIS_COMMAND <openssl passwd -6 -salt \
      random_16-char_salt "YOUR_PASSWORD">

chpasswd:
  expire: false
  users:
    - name: YOUR_USERNAME
      password: YOUR_PASSWORD
      type: text

runcmd:
  # hostnamectl can fail on first boot if systemd-hostnamed/D-Bus is not ready
  # yet; do not rely on it alone.
  - |
    echo YOUR_USERNAME > /etc/hostname
    hostname YOUR_USERNAME 2>/dev/null || true
    if grep -qE '^127\.0\.1\.1[[:space:]]+YOUR_USERNAME' /etc/hosts 2>/dev/null; then
      :
    elif grep -qE '^127\.0\.1\.1' /etc/hosts 2>/dev/null; then
      sed -i 's/^127\.0\.1\.1.*/127.0.1.1 YOUR_USERNAME/' /etc/hosts
    else
      echo "127.0.1.1 YOUR_USERNAME" >> /etc/hosts
    fi
    hostnamectl set-hostname YOUR_USERNAME 2>/dev/null || true
  - [ sh, -c, 'userdel ubuntu 2>/dev/null || true' ]
  - [ sh, -c, 'rm -rf /home/ubuntu 2>/dev/null || true' ]
  - |
    echo "OEM cloud-init seed ran at $(date -Iseconds)" \
      >> /var/log/oem-cloud-init-seed.log
  - [ chmod, '0644', /var/log/oem-cloud-init-seed.log ]
  - |
    mkdir -p /etc/ssh/sshd_config.d
    printf '%s\n' 'PasswordAuthentication yes' \
      'ChallengeResponseAuthentication no' \
      > /etc/ssh/sshd_config.d/99-oem-password-auth.conf
    systemctl reload sshd 2>/dev/null || true
  # Mount USB data partition (label OEMDATA) and run hook.sh if present
  # (hook installs debs/firmware)
  - |
    OEM_MNT=/mnt/oemdata
    HOOK_RUN=/tmp/oemdata-hook.sh
    NET_HOOK=/tmp/network-hook.sh
    HOOK_EXECUTED=0
    mkdir -p "$OEM_MNT"
    # One trap for all normal completion: umount + disable cloud-init on next boots.
    # Do not use "exit" here: cloud-init shellifies all runcmd items into one /bin/sh
    # script; exit would skip every later runcmd line (e.g. pending-reboot
    # check) before the EXIT trap runs.
    # Do not use "set -e" in this block: if sync/mkdir/touch after the hook
    # fails, the shell would exit before the post-hook runcmd; EXIT would still
    # run _oemdata_exit (cloud-init.disabled) but the pending-reboot log/reboot
    # would never run.
    _oemdata_exit() {
      echo "OEMDATA exit trap: disabling cloud-init and unmounting OEMDATA"
      umount "$OEM_MNT" 2>/dev/null || true
      rmdir "$OEM_MNT" 2>/dev/null || true
      mkdir -p /etc/cloud
      touch /etc/cloud/cloud-init.disabled
    }
    _run_oem_hook() {
      _src="$1"
      _origin="$2"
      cp -f "$_src" "$HOOK_RUN" || return 1
      chmod 700 "$HOOK_RUN"
      echo "Running OEM hook from $HOOK_RUN (source: $_origin)"
      set +e
      export OEM_MNT
      sh "$HOOK_RUN"
      _hook_rc=$?
      rm -f "$HOOK_RUN"
      HOOK_EXECUTED=1
      if [ "$_hook_rc" -eq 1 ]; then
        echo "mirror setup success"
        sync
        mkdir -p /var/lib/oem
        touch /var/lib/oem/oem-hook-pending-reboot
      elif [ "$_hook_rc" -ne 0 ]; then
        echo "OEM hook exited with code $_hook_rc"
      fi
      return 0
    }
    _iface_ipv4() {
      ip -4 -o addr show dev "$1" 2>/dev/null | awk '{print $4}' | \
        cut -d/ -f1 | head -n1
    }
    _download_hook() {
      _url="$1"
      _out="$2"
      if command -v curl >/dev/null 2>&1; then
        curl -fsSL --connect-timeout 5 --max-time 20 "$_url" -o "$_out"
      elif command -v wget >/dev/null 2>&1; then
        wget -q -T 20 -O "$_out" "$_url"
      else
        echo "Neither curl nor wget is available to download network hook."
        return 1
      fi
    }
    trap '_oemdata_exit' EXIT
    if mount -L OEMDATA "$OEM_MNT" 2>/dev/null; then
      echo "OEMDATA partition found, checking for hook.sh"
      if [ -f "$OEM_MNT/hook.sh" ]; then
        _run_oem_hook "$OEM_MNT/hook.sh" "OEMDATA"
      fi
    else
      echo "No OEMDATA partition found, skipping USB OEM hook."
      rmdir "$OEM_MNT" 2>/dev/null || true
    fi
  # cloud-init.disabled is created in OEM block EXIT trap above
  # (covers no-OEMDATA path too).
  # Reboot if OEM hook requested it (hook exit 1). Runs in same shellified script
  # after OEM block (no "exit" above).
  - |
    echo "OEM post-hook: checking pending-reboot marker"
    if [ -f /var/lib/oem/oem-hook-pending-reboot ]; then
      rm -f /var/lib/oem/oem-hook-pending-reboot
      echo "reboot required (OEM mirror apt/fwupd updates); scheduling reboot" \
        "(+30s so cloud-init can finish modules-final)"
      # EXIT trap may not run before reboot; disable cloud-init and unmount
      # OEMDATA now.
      _oemdata_exit
      sync
      # Immediate reboot races remaining modules-final (e.g. cc_keys_to_console)
      # and can log SystemExit:1.
      # Background sleep + reboot: runcmd exits, cloud-init completes, then reboot
      # (shutdown +m is minute-only).
      ( sleep 30; /sbin/reboot ) </dev/null >/dev/null 2>&1 &
    else
      echo "reboot not required (no OEM pending-reboot marker)"
    fi

user-data without User Creation (Keep OOBE)#

Use this example when cloud-init must run provisioning without creating a user. The provisioning worker runs from bootcmd because runcmd does not run when OOBE runs. Provisioning runs while OOBE remains available for the user to create an account. This example sets preserve_hostname: true and does not change the hostname. OOBE sets the hostname.

The worker runs separately from cloud-init so OOBE can proceed. It first tries hook.sh from OEMDATA. If the hook is not present, the worker tries the configured network location. If the hook runs, cloud-init is disabled after the hook exits, regardless of the hook exit code. If no hook runs, the next boot retries the workflow.

Before using this example, replace the example values for CLOUDINIT_IFACE, CLOUDINIT_SERVER_IP (192.168.1.1), and CLOUDINIT_SERVER_PORT (8080) with values for your installation network.

Warning

The network fallback downloads hook.sh over HTTP and runs it as root. Use this fallback only on a trusted installation network, and ensure that only authorized administrators can modify the hosted script.

#cloud-config
# No cloud-init user/password creation; leave account setup to OOBE / image defaults.

growpart:
  mode: 'off'

no_ssh_fingerprints: true
resize_rootfs: false

# Mandatory on an image that boots into OOBE. dgx-oobe-hostname.service runs
# set-hostname.sh, which rewrites /etc/hostname to spark-<4 hex>, clears
# /etc/machine-id, regenerates the SSH host keys and reboots whenever
# /etc/hostname does not already hold that name. The seed carries no
# local-hostname, so cc_set_hostname falls back to 'localhost' and
# cc_update_hostname re-asserts it on every boot at frequency always. The two
# then overwrite each other indefinitely: cloud-init puts the fallback name
# back, set-hostname.sh sees the mismatch and reboots, and provisioning never
# gets to write the cloud-init.disabled gate. Leaving the hostname alone is
# what breaks the cycle; OOBE owns the hostname on these units.
preserve_hostname: true

# Explicitly create no users (avoids distro default_user creation).
users: []

# The payload cannot run from runcmd on an image that boots into OOBE: runcmd is
# written by modules:config but executed by scripts-user in modules:final, and
# cloud-final.service is ordered After=multi-user.target, which stays unreached
# while the OOBE session holds plymouth-quit-wait.service. bootcmd runs in the
# init stages, which always complete, so the payload is launched from there.
#
# bootcmd runs inside cloud-init-local.service: DefaultDependencies=no,
# Before=sysinit.target, i.e. before basic userspace, with systemd's initial boot
# transaction still in flight. Nothing here may talk to systemd: a daemon-reload
# at this point re-runs every generator and rebuilds the unit graph mid-boot,
# which leaves the boot unable to progress until the SBSA watchdog resets the
# machine. The payload is therefore a plain detached process, not a unit.
# cloud-init-local.service is Type=oneshot with RemainAfterExit=yes, so systemd
# leaves its cgroup alone and the child outlives the stage.
bootcmd:
  - |
    # /etc/cloud/cloud-init.disabled is the whole provisioning gate: the generator
    # honours it before any of this runs, so a boot that gets here is a boot that
    # should provision. The marker below is a per-boot guard only, so that the two
    # bootcmd invocations per boot (init-local and init) start one worker, not two.
    if [ ! -e /run/oemdata-provision.armed ]; then
    # The heredoc must stay at this indentation: YAML strips the block's base
    # indent, and a heredoc terminator cannot carry leading whitespace.
    cat > /run/oemdata-provision.sh <<'OEM_WORKER'
    #!/bin/sh
    # Detached from cloud-init and from systemd: nothing here can block the boot
    # transaction. All output goes to the seed log, there being no journal unit.
    OEM_MNT=/mnt/oemdata
    HOOK_RUN=/tmp/oemdata-hook.sh
    NET_HOOK=/tmp/network-hook.sh
    HOOK_EXECUTED=0
    SEED_LOG=/var/log/cloud-init-output.log
    exec >>"$SEED_LOG" 2>&1
    chmod 0644 "$SEED_LOG"
    echo "OEM cloud-init seed ran at $(date -Iseconds)"

    # Wait for the network the hook needs. This is not a wait on OOBE:
    # dgx-oobe.service is up on every boot long before the user touches the
    # screen, so gating on it would gate on nothing.
    _waited=0
    while [ "$_waited" -lt 60 ]; do
      [ -n "$(ip -4 route show default 2>/dev/null)" ] && break
      sleep 2
      _waited=$((_waited + 2))
    done
    echo "default route after ${_waited}s: $(ip -4 route show default 2>/dev/null | head -n1)"

    # Do not set the hostname here. OOBE owns it: dgx-oobe-hostname.service runs
    # set-hostname.sh, which requires /etc/hostname to be spark-<4 hex> and, on
    # any mismatch, rewrites it, clears /etc/machine-id, regenerates the SSH host
    # keys and reboots. Writing a name of our own made that mismatch true on
    # every boot, so the unit rebooted before provisioning could write the
    # cloud-init.disabled gate, and the next boot started the same race again.
    # That is the reboot loop seen when a unit is flashed and first-booted with
    # the OEMDATA stick still attached.

    mkdir -p "$OEM_MNT"
    _oemdata_exit() {
      umount "$OEM_MNT" 2>/dev/null || true
      rmdir "$OEM_MNT" 2>/dev/null || true
      if [ "$HOOK_EXECUTED" -eq 1 ]; then
        echo "OEMDATA exit trap: disabling cloud-init and unmounting OEMDATA"
        mkdir -p /etc/cloud
        touch /etc/cloud/cloud-init.disabled
      else
        echo "OEMDATA exit trap: unmounted, but not disabling cloud-init (hook_executed=$HOOK_EXECUTED); next boot will retry"
      fi
    }
    _run_oem_hook() {
      _src="$1"
      _origin="$2"
      cp -f "$_src" "$HOOK_RUN" || return 1
      chmod 700 "$HOOK_RUN"
      echo "Running OEM hook from $HOOK_RUN (source: $_origin)"
      export OEM_MNT
      sh "$HOOK_RUN"
      _hook_rc=$?
      rm -f "$HOOK_RUN"
      HOOK_EXECUTED=1
      if [ "$_hook_rc" -eq 1 ]; then
        echo "mirror setup success"
        sync
        mkdir -p /var/lib/oem
        touch /var/lib/oem/oem-hook-pending-reboot
      elif [ "$_hook_rc" -ne 0 ]; then
        echo "OEM hook exited with code $_hook_rc"
      fi
      return 0
    }
    _iface_ipv4() {
      ip -4 -o addr show dev "$1" 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -n1
    }
    _download_hook() {
      _url="$1"
      _out="$2"
      if command -v curl >/dev/null 2>&1; then
        curl -fsSL --connect-timeout 5 --max-time 20 "$_url" -o "$_out"
      elif command -v wget >/dev/null 2>&1; then
        wget -q -T 20 -O "$_out" "$_url"
      else
        echo "Neither curl nor wget is available to download network hook."
        return 1
      fi
    }
    trap '_oemdata_exit' EXIT
    if mount -L OEMDATA "$OEM_MNT" 2>/dev/null; then
      echo "OEMDATA partition found, checking for hook.sh"
      if [ -f "$OEM_MNT/hook.sh" ]; then
        _run_oem_hook "$OEM_MNT/hook.sh" "OEMDATA"
      fi
    else
      echo "No OEMDATA partition found, skipping USB OEM hook."
      rmdir "$OEM_MNT" 2>/dev/null || true
    fi
    if [ "$HOOK_EXECUTED" -eq 0 ]; then
      CLOUDINIT_IFACE="${CLOUDINIT_IFACE:-enP7s7}"
      CLOUDINIT_SERVER_IP="${CLOUDINIT_SERVER_IP:-192.168.1.1}"
      CLOUDINIT_SERVER_PORT="${CLOUDINIT_SERVER_PORT:-8080}"
      HOOK_URL="http://${CLOUDINIT_SERVER_IP}:${CLOUDINIT_SERVER_PORT}/hook.sh"
      echo "USB hook not executed. Trying network hook on ${CLOUDINIT_IFACE} from ${HOOK_URL}"
      _attempt=1
      while [ "$_attempt" -le 2 ]; do
        _ip="$(_iface_ipv4 "$CLOUDINIT_IFACE")"
        if [ -z "$_ip" ]; then
          echo "No DHCP IPv4 on ${CLOUDINIT_IFACE} (attempt ${_attempt}/2), requesting lease"
          if command -v dhclient >/dev/null 2>&1; then
            dhclient -4 -1 "$CLOUDINIT_IFACE" 2>/dev/null || true
          else
            echo "dhclient not found, cannot request DHCP lease."
          fi
          _ip="$(_iface_ipv4 "$CLOUDINIT_IFACE")"
        fi
        if [ -n "$_ip" ]; then
          echo "${CLOUDINIT_IFACE} has IPv4 ${_ip}; downloading ${HOOK_URL}"
          if _download_hook "$HOOK_URL" "$NET_HOOK"; then
            _run_oem_hook "$NET_HOOK" "$HOOK_URL"
            rm -f "$NET_HOOK"
            break
          else
            echo "Failed to download network hook from ${HOOK_URL}"
          fi
        fi
        if [ "$_attempt" -lt 2 ]; then
          echo "Waiting 10s before DHCP/download retry."
          sleep 10
        fi
        _attempt=$((_attempt + 1))
      done
    fi
    echo "OEM post-hook: checking pending-reboot marker"
    if [ -f /var/lib/oem/oem-hook-pending-reboot ]; then
      rm -f /var/lib/oem/oem-hook-pending-reboot
      echo "reboot required (OEM mirror apt/fwupd updates); scheduling reboot in 30s"
      # EXIT trap may not run before reboot; disable cloud-init and unmount OEMDATA now.
      _oemdata_exit
      trap - EXIT
      sync
      ( sleep 30; /sbin/reboot ) </dev/null >/dev/null 2>&1 &
    else
      echo "reboot not required (no OEM pending-reboot marker)"
    fi
    OEM_WORKER
    # Fully detached: no controlling terminal, and cloud-init's stdout pipe is not
    # inherited, which would otherwise make cloud-init wait for the hook.
    setsid /bin/sh /run/oemdata-provision.sh </dev/null >/dev/null 2>&1 &
    touch /run/oemdata-provision.armed
    fi

Validation Scenarios and Feedback Questions#

The validation scenarios in this section align with Table 1 and the procedures through Reference: Scripts and cloud-init. Complete the scenario that matches your deployment. For log-based and post-installation verification, refer to Verify the Customization and Installation Outcomes.

How Scenarios Map to This Document

The following table maps each validation scenario to the related installation patterns and sections in this topic:

Scenario

Related Table 1 patterns

Where to Work

Air-gapped USB installation

USB-hosted packages and firmware or local server sources through OEMDATA and hook.sh

USB Partitioning and the OEMDATA Layout; Host a Minimal APT Repository and Firmware Tree; On the DGX Spark Client: hook.sh and OEMDATA Files; First Boot: OEMDATA hook.sh and cloud-init Seed

Local repository and application updates

Curated or mirrored APT layout; can extend beyond first-boot automation

Host a Minimal APT Repository and Firmware Tree; On the DGX Spark Client: hook.sh and OEMDATA Files; After Mirror- or USB-Driven Updates

Validation Scenarios#

Complete each scenario that matches your deployment. Use the checklist in each scenario during deployment.

  1. Air-gapped installation using USB (OEMDATA, optional local mirror): Perform provisioning using Debian packages and firmware supplied from OEMDATA and, where applicable, your network mirror.

    Prepare the USB layout per USB Partitioning and the OEMDATA Layout. Host packages and firmware using Host a Minimal APT Repository and Firmware Tree, then configure the client with hook.sh and optional URL files as described in On the DGX Spark Client: hook.sh and OEMDATA Files.

    Checklist:

    • Wipe or prepare the USB drive if your process requires it. Reflashing can replace the entire device.

    • Obtain Debian packages and firmware that your process permits on the disconnected network:

      • Use the APT or package-acquisition tools that your OEM or NVIDIA program supplies.

      • Use the program manifest or equivalent bill of materials to determine which software and firmware to stage and which approved sources to use.

      • Populate OEMDATA and any server tree using the documented layout and URL files (debs/, firmware/, apt-repo.url, and lvfs-mirror.url as applicable).

    • If the system runs a release earlier than the October 2026 Release, reflash it with an October 2026 Release or later recovery image.

    • Power off the system, attach OEMDATA, then power on.

    • Verify packages, firmware, and customizations using After Mirror- or USB-Driven Updates.

  2. Local repository and application updates: Use your standard IT administration tools to host a local APT repository, then distribute application packages. Exclude firmware, kernel, and driver components unless your policy permits them.

    The minimal repository workflow shows an HTTP-served package tree. The client workflow describes how to configure the OEMDATA URL files. Record any additional steps required by your application-update delivery mechanism in your runbook.

    Checklist:

    • Configure the applicable repository URL files to use your local mirror URLs.

    • Deploy the updated user-data and meta-data to the device according to your update process.

    • Publish the approved application packages to the local repository.

    • Verify that the device receives the expected package updates.