Blueforce-Linux/provisioning/iso/build-blueforce-iso.sh
emirhanmamak-blueforce da460519e8 feat(install): internet-free install flow, auto-discovered local packages
The operator no longer answers an "offline?" question and no longer needs
network access: the installer finds the package set that ships with the
checkout, so the only input left is the dealer number.

Installer
- Auto-discover the local APT repository (BF_OFFLINE_REPO, then
  provisioning/offline-repo/built, then /opt/blueforce/offline-repo) and
  enter offline mode on its own; --offline stays for compatibility.
- Fail closed with an explicit Turkish message when nothing is found: the
  searched locations, what to do, and a named exit reason -- no state touched.
- Distinguish "repository absent" (operator error, actionable) from
  "repository present but invalid" (OFFLINE BLOCKED, verification gate).
- Network is informational only in precheck/network modules; no probe runs in
  offline mode and archive.ubuntu.com is no longer mandatory.

ISO builder
- Look for built/ first, which is what build-index.sh produces, and accept
  build/ as a warned legacy path. The two disagreed, so a medium could be
  built without the package set and fail only at install time.

Docs
- docs/27, scripts/install/README, provisioning/offline-repo/README and
  provisioning/firstboot/README describe the discovered flow, the two failure
  modes, and that the installer downloads nothing.

Tests
- check-admin-static exercised the "console missing from the clone" branch by
  deleting a tracked file; git clone restored it from history, so the branch
  never ran and the warning was effectively untested. Build the stripped
  source as a real commit instead; verified by mutation.
2026-09-16 16:53:24 +03:00

878 lines
38 KiB
Bash
Executable file

#!/usr/bin/env bash
# Build the bootable Blueforce Field OS installation ISO from a local, verified upstream ISO.
#
# Output contract (written next to --output):
# <iso> dist/Blueforce-Field-OS-<version>-amd64.iso
# <iso>.sha256 SHA256 of the produced ISO
# <version>-manifest.yaml release manifest (field_os_version, base_os, ubuntu_version, ...)
#
# The build is flavour-agnostic: --upstream-iso may point at an Ubuntu Server or an
# Ubuntu Desktop ISO. The flavour is detected from the ISO volume label, recorded in
# the manifest as base_flavor, and never inferred from the file name alone.
#
# Safeguards that must not be weakened:
# * Nothing is downloaded. The upstream ISO and its SHA256 record must already exist
# locally, and the digest is always recomputed from the real file.
# * Device identity, dealer id, private keys and passwords are never embedded. The
# builder refuses to run when it finds them in the media inputs.
# * The media root carries autoinstall.yaml with `interactive-sections: [storage]`,
# so the operator still selects and confirms the target disk. No automatic wipe.
# * Existing boot files are never destroyed: every patched boot configuration keeps a
# pristine copy on the media as <name>.blueforce-orig.
#
# --check validates every precondition and writes nothing.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VERIFY_SCRIPT="$SCRIPT_DIR/verify-upstream-iso.sh"
VERSION_FILE="$SCRIPT_DIR/field-os-version"
PROVISIONING_DIR="$REPO_ROOT/provisioning"
AUTOINSTALL_DIR="$PROVISIONING_DIR/autoinstall"
FIRSTBOOT_DIR="$PROVISIONING_DIR/firstboot"
RELEASE_DIR="$PROVISIONING_DIR/release"
OFFLINE_REPO_DIR="$PROVISIONING_DIR/offline-repo"
OFFLINE_AUTODETECT="$OFFLINE_REPO_DIR/built"
# `build-index.sh` writes to built/. Older tooling in this repository used
# build/, so accept that as a second candidate rather than silently building a
# medium without the package set.
OFFLINE_AUTODETECT_LEGACY="$OFFLINE_REPO_DIR/build"
DEFAULT_DIST="$REPO_ROOT/dist"
MEDIA_PREFIX="/blueforce-provisioning"
FIRSTBOOT_PATH="$MEDIA_PREFIX/firstboot"
ARCH="amd64"
REPRO_STAMP="2024010100000000"
# Identity and credential patterns that must never reach the media. Written with
# bracket expressions so this scanner cannot match its own source text.
IDENTITY_PATTERN='(BEGIN[[:space:]]+(OPENSSH|RSA|EC|DSA|PGP)[[:space:]]+PRIVATE[[:space:]]+KEY|WG_PRIVATE[[:space:]]+KEY|dealer[-_ ]?id[[:space:]]*[:=][[:space:]]*[0-9]{8}|BF-[0-9]{8}|bf-[0-9]{8})'
CREDENTIAL_PATTERN='(password|passwd|credential|api[_-]?key|access[_-]?token)[[:space:]]*[:=][[:space:]]*[^[:space:]<#]'
usage() {
cat <<'USAGE'
Usage: build-blueforce-iso.sh --upstream-iso PATH --checksum-file PATH [options]
Builds the bootable Blueforce Field OS installation medium from a local, SHA256
verified Ubuntu ISO (Server or Desktop flavour). No image is downloaded.
Required:
--upstream-iso PATH Local Ubuntu ISO used as the base medium
--checksum-file PATH SHA256SUMS record for that ISO (alias: --checksum)
Options:
--output PATH ISO path (default: dist/Blueforce-Field-OS-<version>-amd64.iso)
--version VERSION Field OS version (default: provisioning/iso/field-os-version)
--offline-repo DIR Built offline APT repository to embed (Packages, Packages.gz,
SHA256SUMS, packages.lock.tsv). Default: auto-detect
provisioning/offline-repo/built, else build without it.
--require-offline-repo Fail closed when no built offline repository is available
--volid LABEL ISO volume label (default: the upstream label, preserved so the
live-media discovery path stays untouched; recorded in the manifest)
--work-dir DIR Scratch directory (default: the output filesystem when it has
room, else $TMPDIR, else /var/tmp; needs about 2x the base ISO)
--keep-work Do not delete the scratch directory (debugging)
--reproducible Ask xorriso for fixed volume and file dates (byte-stable rebuilds)
--allow-remaster Deprecated no-op: this command always performs a real build
--check Validate every precondition, write nothing, exit 0/1
--help Show this help
Media layout produced inside the ISO:
/autoinstall.yaml assisted autoinstall (root of the medium)
/blueforce-provisioning/firstboot/ first boot payload (fixed contract path)
/blueforce-provisioning/autoinstall/ NoCloud seed copy of the same configuration
/blueforce-provisioning/offline-repo/ offline APT tooling, manifests and built repo
/blueforce-provisioning/release/ release and media metadata
USAGE
}
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
ok() { printf 'OK: %s\n' "$*"; }
note() { printf '%s\n' "$*"; }
step() { printf '\n==> %s\n' "$*"; }
warn() { printf 'WARNING: %s\n' "$*" >&2; }
UPSTREAM=""
CHECKSUM_RECORD=""
OUTPUT=""
OUTPUT_DIR=""
ISO_NAME=""
SHA_FILE=""
MANIFEST=""
VERSION=""
OFFLINE_REPO=""
OFFLINE_MODE="none"
REQUIRE_OFFLINE=0
VOLID=""
VOLID_SOURCE="upstream-preserved"
CHECK_ONLY=0
REPRODUCIBLE=0
KEEP_WORK=0
OPT_WORK_DIR=""
WORK_DIR=""
WORK_ROOT=""
MEDIA=""
UP_IS_SIZE_KB=0
BASE_FLAVOR="unknown"
UBUNTU_VERSION="unknown"
UP_VOLID=""
UPSTREAM_SHA256=""
while [[ $# -gt 0 ]]; do
case "$1" in
--upstream-iso) UPSTREAM="${2:-}"; shift 2 ;;
--checksum|--checksum-file) CHECKSUM_RECORD="${2:-}"; shift 2 ;;
--output) OUTPUT="${2:-}"; shift 2 ;;
--version) VERSION="${2:-}"; shift 2 ;;
--offline-repo) OFFLINE_REPO="${2:-}"; shift 2 ;;
--require-offline-repo) REQUIRE_OFFLINE=1; shift ;;
--volid) VOLID="${2:-}"; VOLID_SOURCE="explicit"; shift 2 ;;
--work-dir) OPT_WORK_DIR="${2:-}"; shift 2 ;;
--keep-work) KEEP_WORK=1; shift ;;
--reproducible) REPRODUCIBLE=1; shift ;;
--allow-remaster) warn "--allow-remaster is deprecated and ignored: this command always builds a real ISO."; shift ;;
--check) CHECK_ONLY=1; shift ;;
--help|-h) usage; exit 0 ;;
*) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$UPSTREAM" ]] || { printf 'Missing required argument: --upstream-iso\n' >&2; usage >&2; exit 2; }
[[ -n "$CHECKSUM_RECORD" ]] || { printf 'Missing required argument: --checksum-file\n' >&2; usage >&2; exit 2; }
# ----------------------------------------------------------------- small tools --
free_kb() {
df -Pk -- "$1" 2>/dev/null | awk 'NR==2 {print $4}'
}
existing_ancestor() {
local path="$1"
while [[ ! -d "$path" && "$path" != "/" ]]; do
path="$(dirname "$path")"
done
printf '%s' "$path"
}
detect_flavor_from_label() {
local label="$1"
case "$label" in
*Server*) printf 'server' ;;
*Desktop*) printf 'desktop' ;;
*[Uu]buntu*) printf 'desktop' ;;
*) printf 'unknown' ;;
esac
}
version_from_text() {
printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n 1 || true
}
base_os_string() {
if [[ "$UBUNTU_VERSION" == "unknown" ]]; then
printf 'Ubuntu %s' "$BASE_FLAVOR"
else
# Title-case the flavour for display only; the machine-readable flavour stays
# in base_flavor, so nothing depends on this cosmetic spelling.
printf 'Ubuntu %s %s LTS' "$(printf '%s' "${BASE_FLAVOR:0:1}" | tr '[:lower:]' '[:upper:]')${BASE_FLAVOR:1}" "$UBUNTU_VERSION"
fi
}
# ------------------------------------------------------------------ parameters --
resolve_parameters() {
if [[ -z "$VERSION" ]]; then
[[ -r "$VERSION_FILE" ]] || die "Field OS version file is missing: $VERSION_FILE (pass --version)"
VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")"
fi
[[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,3}([-.+][0-9A-Za-z.-]+)?$ ]] \
|| die "Invalid Field OS version '${VERSION}': expected e.g. 1.0.0 or 1.1.0-rc1"
ISO_NAME="Blueforce-Field-OS-${VERSION}-${ARCH}.iso"
if [[ -z "$OUTPUT" ]]; then
OUTPUT="$DEFAULT_DIST/$ISO_NAME"
elif [[ "$OUTPUT" != /* ]]; then
OUTPUT="$PWD/$OUTPUT"
fi
[[ "$(basename "$OUTPUT")" == *.iso ]] || die "--output must name an .iso file: $OUTPUT"
OUTPUT_DIR="$(dirname "$OUTPUT")"
SHA_FILE="${OUTPUT}.sha256"
MANIFEST="$OUTPUT_DIR/${VERSION}-manifest.yaml"
case "$(basename "$UPSTREAM")" in
*"${ARCH}"*) : ;;
*i386*|*arm64*|*ppc64el*|*s390x*) die "Only ${ARCH} media is supported; got: $(basename "$UPSTREAM")" ;;
*) warn "Upstream ISO name does not mention ${ARCH}: $(basename "$UPSTREAM")" ;;
esac
}
require_media_inputs() {
local missing=() file
local -a required=(
"$AUTOINSTALL_DIR/autoinstall.yaml"
"$AUTOINSTALL_DIR/user-data"
"$AUTOINSTALL_DIR/meta-data"
"$FIRSTBOOT_DIR/bf-firstboot"
"$FIRSTBOOT_DIR/blueforce-firstboot.service"
"$RELEASE_DIR/manifest.yaml"
)
for file in "${required[@]}"; do
[[ -f "$file" ]] || missing+=("${file#"$REPO_ROOT"/}")
done
[[ -d "$OFFLINE_REPO_DIR" ]] || missing+=("${OFFLINE_REPO_DIR#"$REPO_ROOT"/}/")
[[ ${#missing[@]} -eq 0 ]] || die "Required provisioning inputs are missing: ${missing[*]}"
ok "provisioning inputs present (autoinstall, firstboot, offline-repo tooling, release manifest)"
}
resolve_offline_repo() {
local missing=() marker
if [[ -n "$OFFLINE_REPO" && "$OFFLINE_REPO" != "none" ]]; then
[[ -d "$OFFLINE_REPO" ]] || die "Offline repository directory not found: $OFFLINE_REPO"
elif [[ -z "$OFFLINE_REPO" && -d "$OFFLINE_AUTODETECT" ]]; then
OFFLINE_REPO="$OFFLINE_AUTODETECT"
elif [[ -z "$OFFLINE_REPO" && -d "$OFFLINE_AUTODETECT_LEGACY" ]]; then
OFFLINE_REPO="$OFFLINE_AUTODETECT_LEGACY"
warn "Using the legacy offline repository path ${OFFLINE_AUTODETECT_LEGACY#"$REPO_ROOT"/}; build-index.sh writes to built/ instead."
else
OFFLINE_REPO=""
fi
if [[ -z "$OFFLINE_REPO" ]]; then
if [[ "$REQUIRE_OFFLINE" -eq 1 ]]; then
die "No built offline APT repository found (looked at ${OFFLINE_AUTODETECT#"$REPO_ROOT"/} and --offline-repo). Build one with provisioning/offline-repo/build-index.sh, then retry."
fi
OFFLINE_MODE="not-included"
warn "No built offline APT repository is embedded: the medium ships the offline tooling and manifests only."
warn "Package installation on the installed system stays OFFLINE BLOCKED until a repository is built and re-embedded."
return
fi
for marker in Packages Packages.gz SHA256SUMS packages.lock.tsv; do
[[ -f "$OFFLINE_REPO/$marker" ]] || missing+=("$marker")
done
[[ ${#missing[@]} -eq 0 ]] || die "Offline repository $OFFLINE_REPO is incomplete; missing: ${missing[*]}"
OFFLINE_MODE="embedded"
ok "offline APT repository will be embedded: $OFFLINE_REPO"
}
scan_media_inputs() {
local -a scan_dirs=("$AUTOINSTALL_DIR" "$FIRSTBOOT_DIR" "$RELEASE_DIR" "$OFFLINE_REPO_DIR")
local hits
hits="$(grep -RIlE --exclude='*.md' -- "$IDENTITY_PATTERN" "${scan_dirs[@]}" 2>/dev/null || true)"
[[ -z "$hits" ]] || die "Device identity found in media inputs; refusing to build:"$'\n'"$hits"
hits="$(grep -RIlE --exclude='*.md' -- "$CREDENTIAL_PATTERN" "${scan_dirs[@]}" 2>/dev/null || true)"
[[ -z "$hits" ]] || die "Credential-like value found in media inputs; refusing to build:"$'\n'"$hits"
ok "media inputs carry no device identity, private key or credential"
}
resolve_work_root() {
local need_kb=$(( UP_IS_SIZE_KB * 2 + 1048576 )) candidate available detail=""
local -a candidates=()
if [[ -n "$OPT_WORK_DIR" ]]; then
candidates=("$OPT_WORK_DIR")
elif [[ "$CHECK_ONLY" -eq 1 ]]; then
# Dry runs create no directories, so judge the filesystem the output will land on.
candidates=("$(existing_ancestor "$OUTPUT_DIR")" "${TMPDIR:-/tmp}" "/var/tmp")
else
# Real builds reuse the output directory, which keeps the scratch tree on the same
# filesystem as the artifact and inside a directory that already has to exist.
install -d "$OUTPUT_DIR" || die "Could not create the output directory: $OUTPUT_DIR"
candidates=("$OUTPUT_DIR" "${TMPDIR:-/tmp}" "/var/tmp")
fi
for candidate in "${candidates[@]}"; do
if [[ ! -d "$candidate" ]]; then
detail+=" ${candidate}: not a directory"$'\n'
continue
fi
available="$(free_kb "$candidate")"
if [[ -n "$available" && "$available" -ge "$need_kb" ]]; then
WORK_ROOT="$candidate"
return 0
fi
detail+=" ${candidate}: $(( ${available:-0} / 1024 )) MiB free"$'\n'
done
die "Not enough scratch space. This build extracts the base ISO and writes a new one, so it needs about $(( need_kb / 1024 )) MiB free. Checked:
${detail}Pass --work-dir with a directory that has room. Nothing is downloaded to satisfy this."
}
# ------------------------------------------------------------------- preflight --
preflight() {
step "Preconditions"
[[ -f "$UPSTREAM" ]] || die "Upstream ISO not found: $UPSTREAM"
[[ -s "$UPSTREAM" ]] || die "Upstream ISO is empty: $UPSTREAM"
UP_IS_SIZE_KB=$(( $(stat -c %s -- "$UPSTREAM") / 1024 ))
ok "upstream ISO present: $(basename "$UPSTREAM") ($(( UP_IS_SIZE_KB / 1024 )) MiB)"
[[ -f "$CHECKSUM_RECORD" ]] || die "Checksum record not found: $CHECKSUM_RECORD"
command -v xorriso >/dev/null 2>&1 \
|| die "xorriso is required to build bootable media and was not found in PATH. Install it (for example: apt-get install xorriso) and retry. Nothing was written."
ok "xorriso available: $(xorriso --version 2>/dev/null | awk 'NR==1 {print $2}')"
local boot_report
boot_report="$(xorriso -indev "$UPSTREAM" -report_el_torito plain 2>&1)" \
|| die "xorriso cannot read El Torito boot metadata from $(basename "$UPSTREAM"); refusing to build. Nothing was written."
# xorriso exits 0 even when the input is not an ISO at all: it prints
# "No ISO 9660 image at LBA 0" and reports nothing. Combined with a checksum
# record that happens to match, that would let a non-ISO file through the
# preflight, so assert positively that boot metadata was actually produced.
grep -qE 'El Torito boot img' <<<"$boot_report" \
|| die "$(basename "$UPSTREAM") is not usable as an installation ISO: no El Torito boot image was reported. Nothing was written."
grep -qE 'El Torito boot img.*BIOS' <<<"$boot_report" \
|| warn "upstream ISO reports no BIOS El Torito entry; the produced medium may not boot on Legacy BIOS"
grep -qE 'El Torito boot img.*(UEFI|EFI)' <<<"$boot_report" \
|| warn "upstream ISO reports no UEFI El Torito entry; the produced medium may not boot on UEFI"
ok "El Torito boot metadata readable from the upstream ISO"
UP_VOLID="$(sed -n 's/^Volume id[^:]*:[[:space:]]*//p' <<<"$boot_report" | head -n 1 | tr -d "\"'" || true)"
UP_VOLID="${UP_VOLID:-unknown}"
BASE_FLAVOR="$(detect_flavor_from_label "$UP_VOLID")"
UBUNTU_VERSION="$(version_from_text "$UP_VOLID")"
[[ -n "$UBUNTU_VERSION" ]] || UBUNTU_VERSION="unknown"
ok "base label '${UP_VOLID}' => base_flavor=${BASE_FLAVOR}, ubuntu_version=${UBUNTU_VERSION}"
if [[ "$BASE_FLAVOR" != "server" ]]; then
warn "Base medium is not the documented Server baseline (docs/24 K-01, docs/26)."
warn "The deviation is recorded in the manifest as base_flavor=${BASE_FLAVOR}; a Server ISO can be used with --upstream-iso."
fi
[[ -n "$VOLID" ]] || VOLID="$UP_VOLID"
[[ "$VOLID" != "unknown" ]] || VOLID="BLUEFORCE_FIELD_OS"
(( ${#VOLID} <= 32 )) || die "Volume label too long for ISO 9660 (max 32 characters): $VOLID"
ok "output volume label: '${VOLID}' (${VOLID_SOURCE})"
require_media_inputs
resolve_offline_repo
local -a existing=()
[[ -e "$OUTPUT" ]] && existing+=("$OUTPUT")
[[ -e "$SHA_FILE" ]] && existing+=("$SHA_FILE")
[[ -e "$MANIFEST" ]] && existing+=("$MANIFEST")
[[ ${#existing[@]} -eq 0 ]] || die "Refusing to overwrite existing artifacts: ${existing[*]}"
ok "no existing artifact would be overwritten"
scan_media_inputs
local verify_output
if ! verify_output="$("$VERIFY_SCRIPT" --iso "$UPSTREAM" --checksum "$CHECKSUM_RECORD" --check 2>&1)"; then
printf '%s\n' "$verify_output" >&2
die "Upstream ISO failed SHA256 verification against $CHECKSUM_RECORD"
fi
UPSTREAM_SHA256="$(sha256sum -- "$UPSTREAM" | awk '{print $1}')"
ok "upstream SHA256 recomputed from the real file: $UPSTREAM_SHA256"
resolve_work_root
ok "scratch directory will be created under: ${WORK_ROOT} (about $(( (UP_IS_SIZE_KB * 2 + 1048576) / 1024 )) MiB required)"
ok "output contract: $OUTPUT"
ok " ${SHA_FILE}"
ok " ${MANIFEST}"
}
# -------------------------------------------------------------- boot injection --
# Insert the `autoinstall` kernel argument into a boot configuration file without
# damaging the original: the pristine copy is kept on the medium as <name>.blueforce-orig.
# kind=grub -> `linux`/`linuxefi` lines get the argument right after the kernel path,
# ahead of the `---` separator.
# kind=isolinux -> `append` lines get the argument right after the keyword.
# Re-running is idempotent: lines that already request autoinstall are left untouched.
inject_autoinstall_arg() {
local file="$1" kind="$2" out="${1}.blueforce-inject.tmp" line changed=0
: > "$out"
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$kind" == "grub" ]] \
&& [[ "$line" =~ ^([[:space:]]*(linux|linuxefi)[[:space:]]+)([^[:space:]]+)(.*)$ ]] \
&& [[ "$line" != *autoinstall* ]]; then
printf '%s%s autoinstall%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}" >> "$out"
changed=$((changed + 1))
continue
fi
if [[ "$kind" == "isolinux" ]] \
&& [[ "$line" =~ ^([[:space:]]*(append|APPEND)[[:space:]]+)(.*)$ ]] \
&& [[ "$line" != *autoinstall* ]]; then
printf '%sautoinstall %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]}" >> "$out"
changed=$((changed + 1))
continue
fi
printf '%s\n' "$line" >> "$out"
done < "$file"
if [[ "$changed" -gt 0 ]]; then
[[ -e "${file}.blueforce-orig" ]] || cp -p "$file" "${file}.blueforce-orig"
mv "$out" "$file"
else
rm -f "$out"
fi
printf '%d' "$changed"
}
inject_boot_configuration() {
step "Boot configuration"
local -a grub_files=(
"$MEDIA/boot/grub/grub.cfg"
"$MEDIA/boot/grub/loopback.cfg"
"$MEDIA/EFI/boot/grub.cfg"
)
local -a isolinux_files=(
"$MEDIA/isolinux/txt.cfg"
"$MEDIA/isolinux/isolinux.cfg"
"$MEDIA/isolinux/gtk.cfg"
)
local file changed grub_total=0 isolinux_total=0
for file in "${grub_files[@]}"; do
[[ -f "$file" ]] || continue
changed="$(inject_autoinstall_arg "$file" grub)"
grub_total=$((grub_total + changed))
ok "GRUB ${file#"$MEDIA"/}: ${changed} kernel line(s) now request autoinstall"
done
for file in "${isolinux_files[@]}"; do
[[ -f "$file" ]] || continue
changed="$(inject_autoinstall_arg "$file" isolinux)"
isolinux_total=$((isolinux_total + changed))
ok "isolinux ${file#"$MEDIA"/}: ${changed} append line(s) now request autoinstall"
done
[[ $((grub_total + isolinux_total)) -gt 0 ]] \
|| die "No recognised boot configuration (GRUB or isolinux) was found in the upstream ISO; refusing to write a medium that cannot start the installer."
if [[ "$grub_total" -eq 0 ]]; then
warn "No GRUB configuration was patched: automatic autoinstall start is not prepared for this base ISO."
fi
if [[ "$isolinux_total" -eq 0 ]]; then
note "note: this base ISO carries no isolinux configuration (Legacy BIOS boots through the El Torito GRUB image, which reads the patched GRUB configuration)."
fi
}
# --------------------------------------------------------------------- staging --
write_media_metadata() {
local release_file="$MEDIA$MEDIA_PREFIX/release/blueforce-release"
cat > "$release_file" <<EOF
# Blueforce Field OS release identity, installed as /etc/blueforce-release.
# Device identity, dealer id and provisioning state are written by the firstboot
# installer on the target, never by the installation medium.
FIELD_OS_VERSION=${VERSION}
BASE_OS=$(base_os_string)
BASE_FLAVOR=${BASE_FLAVOR}
UBUNTU_VERSION=${UBUNTU_VERSION}
BASE_ISO_FILENAME=$(basename "$UPSTREAM")
BASE_ISO_SHA256=${UPSTREAM_SHA256}
ARCHITECTURE=${ARCH}
FIRSTBOOT_PATH=${FIRSTBOOT_PATH}
AUTOINSTALL_CONFIG=/autoinstall.yaml
OFFLINE_REPO_STATUS=${OFFLINE_MODE}
EOF
chmod 0644 "$release_file"
cat > "$MEDIA$MEDIA_PREFIX/release/media-info.yaml" <<EOF
schema_version: 1
field_os_version: "${VERSION}"
base_os: "$(base_os_string)"
base_flavor: "${BASE_FLAVOR}"
base_iso_filename: "$(basename "$UPSTREAM")"
base_iso_sha256: "${UPSTREAM_SHA256}"
ubuntu_version: "${UBUNTU_VERSION}"
architecture: "${ARCH}"
iso_volid: "${VOLID}"
autoinstall_config: "/autoinstall.yaml"
interactive_sections: [storage]
apt_fallback: offline-install
firstboot_path: "${FIRSTBOOT_PATH}"
offline_repo_status: "${OFFLINE_MODE}"
build_timestamp: omitted-on-purpose
EOF
chmod 0644 "$MEDIA$MEDIA_PREFIX/release/media-info.yaml"
# Generated files get a fixed modification time so a rebuild from identical inputs
# differs only in what --reproducible pins down in xorriso.
touch -t "${REPRO_STAMP:0:8}${REPRO_STAMP:8:4}.${REPRO_STAMP:12:2}" "$release_file" \
"$MEDIA$MEDIA_PREFIX/release/media-info.yaml"
ok "release metadata written (no build timestamp is embedded in the medium)"
}
prepare_media() {
step "Staging media tree"
MEDIA="$WORK_DIR/media"
install -d "$MEDIA"
if ! xorriso -osirrox on -indev "$UPSTREAM" -extract / "$MEDIA" >"$WORK_DIR/extract.log" 2>&1; then
tail -n 15 "$WORK_DIR/extract.log" >&2
die "ISO filesystem extraction failed (log: $WORK_DIR/extract.log)"
fi
chmod -R u+w "$MEDIA"
ok "upstream tree extracted to the scratch directory"
install -d "$MEDIA$MEDIA_PREFIX/autoinstall" "$MEDIA$MEDIA_PREFIX/firstboot" \
"$MEDIA$MEDIA_PREFIX/offline-repo" "$MEDIA$MEDIA_PREFIX/release"
install -m 0644 "$AUTOINSTALL_DIR/autoinstall.yaml" "$MEDIA/autoinstall.yaml"
install -m 0644 "$AUTOINSTALL_DIR/autoinstall.yaml" "$MEDIA$MEDIA_PREFIX/autoinstall/autoinstall.yaml"
install -m 0644 "$AUTOINSTALL_DIR/user-data" "$MEDIA$MEDIA_PREFIX/autoinstall/user-data"
install -m 0644 "$AUTOINSTALL_DIR/meta-data" "$MEDIA$MEDIA_PREFIX/autoinstall/meta-data"
if [[ -f "$AUTOINSTALL_DIR/README.md" ]]; then
install -m 0644 "$AUTOINSTALL_DIR/README.md" "$MEDIA$MEDIA_PREFIX/autoinstall/README.md"
fi
ok "autoinstall configuration placed at /autoinstall.yaml and ${MEDIA_PREFIX}/autoinstall/"
install -m 0755 "$FIRSTBOOT_DIR/bf-firstboot" "$MEDIA$FIRSTBOOT_PATH/bf-firstboot"
install -m 0644 "$FIRSTBOOT_DIR/blueforce-firstboot.service" "$MEDIA$FIRSTBOOT_PATH/blueforce-firstboot.service"
if [[ -f "$FIRSTBOOT_DIR/README.md" ]]; then
install -m 0644 "$FIRSTBOOT_DIR/README.md" "$MEDIA$FIRSTBOOT_PATH/README.md"
fi
ok "firstboot payload placed at ${FIRSTBOOT_PATH}/"
cp -a "$OFFLINE_REPO_DIR/." "$MEDIA$MEDIA_PREFIX/offline-repo/"
rm -f "$MEDIA$MEDIA_PREFIX/offline-repo/packages/.gitkeep"
if [[ "$OFFLINE_MODE" == "embedded" ]]; then
install -d "$MEDIA$MEDIA_PREFIX/offline-repo/built"
cp -a "$OFFLINE_REPO/." "$MEDIA$MEDIA_PREFIX/offline-repo/built/"
ok "offline APT repository placed at ${MEDIA_PREFIX}/offline-repo/built/"
else
ok "offline APT tooling and manifests placed at ${MEDIA_PREFIX}/offline-repo/ (no built repository)"
fi
install -m 0644 "$RELEASE_DIR/manifest.yaml" "$MEDIA$MEDIA_PREFIX/release/manifest.yaml"
write_media_metadata
chmod -R a+rX "$MEDIA$MEDIA_PREFIX"
}
# --------------------------------------------------------------------- ISO out --
# Rebuild the boot structure from the base ISO's own layout.
#
# Why this is not `-boot_image any replay`:
# Ubuntu live ISOs carry boot information in two places that a replay cannot
# re-establish, because neither is a data file inside the ISO9660 tree:
# 1. the GRUB2 MBR boot code lives in the first 16 sectors of the *source
# image*, and
# 2. the EFI system partition is an APPENDED PARTITION, i.e. a byte range of
# the source image.
# Replaying them fails with:
# "Cannot enable EL Torito boot image #1 because it is not a data file in
# the ISO filesystem"
# "Cannot refer by GRUB2 MBR to data outside of ISO 9660 filesystem"
#
# `xorriso -report_el_torito as_mkisofs` emits the exact option set that
# reconstructs the layout, so it is read from the base ISO at build time rather
# than hard-coded. That keeps Server and Desktop flavours, and future ISO
# revisions, working without editing this script.
read_boot_layout_options() {
local lines="$WORK_DIR/boot-layout.lines" tokens="$1"
xorriso -indev "$UPSTREAM" -report_el_torito as_mkisofs >"$WORK_DIR/boot-layout.raw" 2>/dev/null || true
# Step 1: keep the mkisofs option lines only, and drop the options this script
# owns.
#
# `--modification-date` must be dropped: xorriso translates it into its own
# `-volume_date uuid`, which then rejects the value as "not an ECMA-119 time
# string". This script controls the date itself.
#
# Nothing else is rewritten. The `--interval:local_fs:...` operands inside
# `--grub2-mbr` and `-append_partition` already name the base ISO with the very
# path this build passed to xorriso, so they are used verbatim. Rewriting them
# (e.g. via sed or an extra gsub) reorders the interval fields and xorriso then
# fails with "Number text too short or too long in interval reader description
# string". If a future build ever reads and writes the base ISO under different
# paths, that rewrite has to replace the path operand in place, not prefix it.
awk '
/^-/ {
if ($0 ~ /^-V / || $0 == "-V" || $0 ~ /^-o / || $0 == "-o" || $0 ~ /^--modification-date[= ]/) next
print
}
' "$WORK_DIR/boot-layout.raw" >"$lines"
# Step 2: split each line into individual arguments.
#
# as_mkisofs quotes values that contain spaces or commas, and those quoted
# values must reach xorriso as ONE argument. Emitting one option per line and
# relying on the shell to strip the quotes does not work: the option and its
# value collapse into a single argument, and xorriso then reports
# "Unrecognized option '--grub2-mbr --interval:...'".
# So tokenise here: honour quotes while splitting, then drop the quote
# characters, and emit one argument per line for mapfile to collect.
awk '
{
line = $0; n = length(line); i = 1; token = ""; inq = 0
while (i <= n) {
c = substr(line, i, 1)
if (c == "'"'"'") { inq = !inq; i++; continue }
if (!inq && (c == " " || c == "\t")) {
if (token != "") { print token; token = "" }
i++; continue
}
token = token c; i++
}
if (token != "") print token
}
' "$lines" >"$tokens"
grep -q -- '-append_partition' "$tokens" || return 1
return 0
}
assemble_iso() {
step "Writing the ISO"
# The boot layout comes from the base ISO itself; see read_boot_layout_options.
if ! read_boot_layout_options "$WORK_DIR/boot-layout.opts"; then
die "could not read a usable boot layout from $(basename "$UPSTREAM"); refusing to write a medium that may not boot."
fi
local -a boot_opts=()
while IFS= read -r line; do
[[ -n "$line" ]] && boot_opts+=("$line")
done <"$WORK_DIR/boot-layout.opts"
step "Boot layout: ${#boot_opts[@]} option line(s) replayed from the base ISO"
local -a cmd=(xorriso -as mkisofs)
cmd+=(-V "$VOLID")
if [[ "$REPRODUCIBLE" -eq 1 ]]; then
# -as mkisofs does not know xorriso's -volume_date; the mkisofs-compatible
# spelling is --modification-date=<YYYYMMDDhhmmsscc>.
# File mtimes come from the extracted base ISO plus the files this script
# installs, and are already stable for identical inputs; --reproducible only
# has to pin the volume-level dates for a byte-stable rebuild.
cmd+=("--modification-date=$REPRO_STAMP")
fi
cmd+=("${boot_opts[@]}" -o "$OUTPUT" "$MEDIA")
# Diagnostic: record the exact argv before running it. This is what settles
# "the file looks right but xorriso still rejects an option" reports.
local -a trace_cmd=()
if [[ "${BF_ISO_TRACE:-0}" == "1" ]]; then
{ printf 'argv:'; printf ' %q' "${cmd[@]}"; printf '\n'; } >"$WORK_DIR/assemble-argv.txt"
fi
if ! "${cmd[@]}" >"$WORK_DIR/assemble.log" 2>&1; then
tail -n 20 "$WORK_DIR/assemble.log" >&2
[[ -s "$WORK_DIR/assemble-argv.txt" ]] && cat "$WORK_DIR/assemble-argv.txt" >&2
rm -f "$OUTPUT"
die "xorriso failed to write the output ISO; the incomplete file was removed."
fi
[[ -s "$OUTPUT" ]] || { rm -f "$OUTPUT"; die "xorriso reported success but produced no data."; }
ok "ISO written: $OUTPUT ($(( $(stat -c %s -- "$OUTPUT") / 1024 / 1024 )) MiB)"
}
extract_from_iso() {
local iso_path="$1" dest="$2"
rm -f "$dest"
xorriso -osirrox on -indev "$OUTPUT" -extract "$iso_path" "$dest" >/dev/null 2>&1
[[ -f "$dest" ]]
}
verify_output_iso() {
step "Verifying the produced medium"
local check_dir="$WORK_DIR/verify"
install -d "$check_dir"
xorriso -indev "$OUTPUT" -report_el_torito plain > "$WORK_DIR/boot-report.txt" 2>&1 || true
grep -qE 'El Torito boot img.*BIOS' "$WORK_DIR/boot-report.txt" \
|| { rm -f "$OUTPUT"; die "produced ISO lost its BIOS El Torito entry; the broken output was removed."; }
grep -qE 'El Torito boot img.*(UEFI|EFI)' "$WORK_DIR/boot-report.txt" \
|| { rm -f "$OUTPUT"; die "produced ISO lost its UEFI El Torito entry; the broken output was removed."; }
grep -qF "Volume id : '$VOLID'" "$WORK_DIR/boot-report.txt" \
|| warn "produced ISO reports a different volume label than requested (${VOLID})"
ok "El Torito boot entries preserved (BIOS + UEFI) and readable by xorriso"
extract_from_iso /autoinstall.yaml "$check_dir/autoinstall.yaml" \
|| { rm -f "$OUTPUT"; die "the produced ISO has no /autoinstall.yaml at the medium root."; }
grep -q 'interactive-sections: \[storage\]' "$check_dir/autoinstall.yaml" \
|| { rm -f "$OUTPUT"; die "medium autoinstall lost 'interactive-sections: [storage]'; the broken output was removed."; }
grep -q 'offline-install' "$check_dir/autoinstall.yaml" \
|| { rm -f "$OUTPUT"; die "medium autoinstall is missing the offline-install apt fallback."; }
grep -q "$MEDIA_PREFIX/firstboot" "$check_dir/autoinstall.yaml" \
|| { rm -f "$OUTPUT"; die "medium autoinstall does not place the firstboot payload from ${FIRSTBOOT_PATH}/."; }
grep -q 'blueforce-firstboot.service' "$check_dir/autoinstall.yaml" \
|| { rm -f "$OUTPUT"; die "medium autoinstall does not enable blueforce-firstboot.service."; }
ok "medium root /autoinstall.yaml verified (interactive storage, offline-install fallback, firstboot late-commands)"
extract_from_iso /boot/grub/grub.cfg "$check_dir/grub.cfg" \
|| { rm -f "$OUTPUT"; die "the produced ISO has no /boot/grub/grub.cfg; the broken output was removed."; }
grep -q 'autoinstall' "$check_dir/grub.cfg" \
|| { rm -f "$OUTPUT"; die "the medium GRUB configuration does not start autoinstall; the broken output was removed."; }
ok "medium GRUB configuration requests autoinstall on boot"
if extract_from_iso /boot/grub/grub.cfg.blueforce-orig "$check_dir/grub.cfg.orig"; then
ok "pristine GRUB configuration kept on the medium as grub.cfg.blueforce-orig"
fi
local -a expected=(
"$MEDIA_PREFIX/firstboot/bf-firstboot"
"$MEDIA_PREFIX/firstboot/blueforce-firstboot.service"
"$MEDIA_PREFIX/release/blueforce-release"
"$MEDIA_PREFIX/release/media-info.yaml"
"$MEDIA_PREFIX/release/manifest.yaml"
"$MEDIA_PREFIX/offline-repo/manifests/base-packages.txt"
)
local relative
for relative in "${expected[@]}"; do
extract_from_iso "$relative" "$check_dir/payload" \
|| { rm -f "$OUTPUT"; die "the produced ISO is missing ${relative}; the broken output was removed."; }
done
ok "firstboot, release and offline-repo payloads present in the produced ISO"
if [[ "$OFFLINE_MODE" == "embedded" ]]; then
extract_from_iso "$MEDIA_PREFIX/offline-repo/built/Packages" "$check_dir/Packages" \
|| { rm -f "$OUTPUT"; die "the offline APT index is missing from the produced ISO."; }
ok "offline APT repository index present in the produced ISO"
fi
}
# ------------------------------------------------------------------ artifacts --
package_version_from_offline_repo() {
local lock="" value name
[[ "$OFFLINE_MODE" == "embedded" ]] && lock="$OFFLINE_REPO/packages.lock.tsv"
if [[ -z "$lock" || ! -f "$lock" ]]; then
printf 'unknown'
return
fi
for name in "$@"; do
value="$(awk -F'\t' -v p="$name" 'NR > 1 && $1 == p {print $2; exit}' "$lock" | head -n 1 || true)"
if [[ -n "$value" ]]; then
printf '%s' "$value"
return
fi
done
printf 'unknown'
}
package_version_by_pattern() {
local pattern="$1" lock="" value
if [[ "$OFFLINE_MODE" != "embedded" ]]; then
printf 'unknown'
return
fi
lock="$OFFLINE_REPO/packages.lock.tsv"
[[ -f "$lock" ]] || { printf 'unknown'; return; }
value="$(awk -F'\t' -v pat="$pattern" 'NR > 1 && $1 ~ pat {print $2; exit}' "$lock" | head -n 1 || true)"
printf '%s' "${value:-unknown}"
}
write_dist_artifacts() {
step "Checksums and manifest"
local iso_sha256 build_date git_commit git_state
iso_sha256="$(sha256sum -- "$OUTPUT" | awk '{print $1}')"
printf '%s %s\n' "$iso_sha256" "$(basename "$OUTPUT")" > "$SHA_FILE"
ok "checksum written: $SHA_FILE"
build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
git_commit="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true)"
git_commit="${git_commit:-unknown}"
# Distinguish "clean", "dirty" and "cannot tell". If git is unavailable or the
# tree is not a repository, the status command fails and the previous form would
# silently report "clean" - a claim the build has no evidence for.
if ! git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
git_state="unknown"
elif [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null | head -n 1 || true)" ]]; then
git_state="dirty-worktree"
else
git_state="clean"
fi
local rustdesk_pkg docker_pkg xrdp_pkg wireguard_pkg meg_pkg installer_pkg packages_source
rustdesk_pkg="$(package_version_by_pattern '^(rustdesk|rustdesk-server)$')"
docker_pkg="$(package_version_from_offline_repo docker-ce docker.io docker)"
xrdp_pkg="$(package_version_from_offline_repo xrdp xorgxrdp gnome-session)"
wireguard_pkg="$(package_version_from_offline_repo wireguard wireguard-tools)"
meg_pkg="$(package_version_by_pattern '^meg')"
installer_pkg="$(package_version_by_pattern '^blueforce-installer')"
if [[ "$OFFLINE_MODE" == "embedded" ]]; then
packages_source="embedded offline APT lock file (packages.lock.tsv)"
else
packages_source="no built offline repository was embedded; every package version is reported as unknown on purpose"
fi
{
printf 'schema_version: 1\n'
printf 'field_os_version: "%s"\n' "$VERSION"
printf 'base_os: "%s"\n' "$(base_os_string)"
printf 'base_flavor: "%s"\n' "$BASE_FLAVOR"
printf 'base_iso_filename: "%s"\n' "$(basename "$UPSTREAM")"
printf 'ubuntu_version: "%s"\n' "$UBUNTU_VERSION"
printf 'ubuntu_iso_sha256: "%s"\n' "$UPSTREAM_SHA256"
printf 'git_commit: "%s"\n' "$git_commit"
printf 'git_worktree: "%s"\n' "$git_state"
printf 'build_date: "%s"\n' "$build_date"
printf 'architecture: "%s"\n' "$ARCH"
printf 'iso_volid: "%s"\n' "$VOLID"
printf 'iso_filename: "%s"\n' "$(basename "$OUTPUT")"
printf 'iso_sha256: "%s"\n' "$iso_sha256"
printf 'release_status: "lab-artifact"\n'
printf 'packages:\n'
printf ' rustdesk: "%s"\n' "$rustdesk_pkg"
printf ' docker: "%s"\n' "$docker_pkg"
printf ' xrdp: "%s"\n' "$xrdp_pkg"
printf ' wireguard: "%s"\n' "$wireguard_pkg"
printf ' meg_package_version: "%s"\n' "$meg_pkg"
printf ' blueforce_installer_version: "%s"\n' "$installer_pkg"
printf 'packages_source: "%s"\n' "$packages_source"
printf 'media:\n'
printf ' autoinstall_config: "/autoinstall.yaml"\n'
printf ' interactive_sections: [storage]\n'
printf ' apt_fallback: "offline-install"\n'
printf ' firstboot_path: "%s/"\n' "$FIRSTBOOT_PATH"
printf ' offline_repo_status: "%s"\n' "$OFFLINE_MODE"
printf ' boot: "El Torito BIOS + UEFI rebuilt from the base ISO boot layout; autoinstall requested on every kernel line"\n'
printf ' checksum_record: "%s"\n' "$(basename "$CHECKSUM_RECORD")"
printf 'safety:\n'
printf ' device_identity_embedded: false\n'
printf ' automatic_disk_wipe: false\n'
printf ' identity_collection_point: "firstboot (dealer ID is entered on the installed system)"\n'
if [[ "$BASE_FLAVOR" != "server" ]]; then
printf 'baseline_deviation:\n'
printf ' documented_baseline: "Ubuntu Server 26.04.1+ LTS (docs/24 K-01, docs/26, docs/32)"\n'
printf ' built_base: "%s"\n' "$(base_os_string)"
printf ' reason: "The only checksum-verified installation medium available on this build host is the %s ISO, and builds must not download another image."\n' "$BASE_FLAVOR"
printf ' impact: "Desktop media already ships GNOME, so the GNOME desktop layer needs no extra installation step on the target. Server media stays supported through --upstream-iso."\n'
printf ' decision_needed: "Accept the Desktop-based baseline for LAB, or rebuild against a Server ISO before production use."\n'
printf ' lab_required: true\n'
else
printf 'baseline_deviation: none\n'
fi
printf 'notes:\n'
printf ' - "The build date lives in this manifest only; the ISO itself carries no build timestamp."\n'
printf ' - "Boot acceptance (UEFI, Legacy BIOS, Secure Boot) and the autoinstall path on this flavour are LAB work; this artifact is not a production release."\n'
printf ' - "Device identity, dealer ID, private keys and passwords are never embedded; identity is collected at firstboot."\n'
} > "$MANIFEST"
ok "manifest written: $MANIFEST"
}
print_summary() {
step "Done"
note "Field OS version : $VERSION"
note "Base medium : $(base_os_string) [base_flavor=${BASE_FLAVOR}]"
note "Base ISO SHA256 : $UPSTREAM_SHA256"
note "ISO : $OUTPUT"
note "ISO checksum : $SHA_FILE"
note "Manifest : $MANIFEST"
note "Offline repo : $OFFLINE_MODE"
note ""
note "The medium boots into an operator-assisted install: storage stays interactive and"
note "device identity is entered at firstboot. UEFI/BIOS boot acceptance is still a LAB step."
}
main() {
resolve_parameters
preflight
if [[ "$CHECK_ONLY" -eq 1 ]]; then
step "Check passed"
note "All preconditions for $ISO_NAME are satisfied. Nothing was written."
exit 0
fi
WORK_DIR="$(mktemp -d "$WORK_ROOT/blueforce-iso.XXXXXX")" || die "Could not create a scratch directory under $WORK_ROOT"
cleanup() {
if [[ "$KEEP_WORK" -eq 1 ]]; then
printf 'Scratch directory kept: %s\n' "$WORK_DIR"
else
rm -rf -- "$WORK_DIR"
fi
}
trap cleanup EXIT
prepare_media
inject_boot_configuration
assemble_iso
verify_output_iso
write_dist_artifacts
print_summary
}
main "$@"