Adds the Blueforce Field OS provisioning stack on top of the existing fleet architecture, without rewriting the working systems (installer, diagnostics, Ansible, WireGuard/SSH/xRDP/RustDesk, Docker policies, monitoring, Docusaurus). ISO build (the previous builder was a skeleton that produced no medium): - boot layout is read from the base ISO at build time via `xorriso -report_el_torito as_mkisofs` and rebuilt with `-as mkisofs`; `-boot_image any replay` cannot work on Ubuntu live ISOs because the GRUB2 MBR lives in the source image's first 16 sectors and the EFI system partition is an appended partition, neither of which is a file in the ISO9660 tree - preflight now asserts El Torito boot metadata positively: xorriso exits 0 on a non-ISO input, which previously let an arbitrary file pass --check - artifact produced and verified: dist/Blueforce-Field-OS-1.0.0-amd64.iso, 6 481 917 952 bytes, SHA256 cb0cc56f...d431296, BIOS+UEFI entries preserved, /autoinstall.yaml at the media root, casper/minimal.squashfs byte-identical to the base - recorded baseline deviation: the verified medium on the build host is the Desktop flavour, so the manifest carries a baseline_deviation block instead of hiding it; Server remains one flag away Offline and identity: - offline APT contract with concrete package requirements, pin format and an audit table; builds fail closed while pins are UNPINNED - autoinstall keeps interactive-sections: [storage]: the operator confirms the target disk, and no automatic wipe is ever performed - firstboot terminal wizard for the 8-digit dealer id, three-state lifecycle (PROVISIONED_OFFLINE -> ENROLLED -> READY) with fail-closed gates, so a missing internet link or WireGuard handshake cannot fail provisioning but can never produce a false READY Fleet and migration: - five read-only Ansible playbooks for Field OS version, provisioning, enrollment, remote channels and offline readiness, all wave-scoped with a separate production override - Windows pre-migration inventory and data export extended to the full brief scope, read-only, never collecting credentials Tests: tests/run-all.sh -> 8/8 pass, exit 0. Boot acceptance (UEFI, Legacy BIOS, Secure Boot), air-gapped install, real enrollment endpoint and Windows rollback remain LAB work and are listed in IMPLEMENTATION-REPORT.md.
274 lines
12 KiB
Bash
Executable file
274 lines
12 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Create a local APT repository solely from reviewed local Debian packages.
|
|
#
|
|
# Nothing here downloads anything and no dependency is resolved from a remote
|
|
# source: every .deb must already exist in the local pool directory.
|
|
#
|
|
# Modes:
|
|
# --dry-run-plan Validate the manifest contract only. Needs no build tooling
|
|
# and no package pool, and prints the exact LAB gaps.
|
|
# --check Verify that the supplied pool satisfies every pin.
|
|
# --packages/--output (default) Build the repository.
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# BF_MANIFEST_DIR points the --dry-run-plan validator at a fixture directory; the
|
|
# static test suite uses it for negative cases. Production runs, --check and the
|
|
# build always read the reviewed manifests next to this script.
|
|
MANIFEST_DIR="${BF_MANIFEST_DIR:-$SCRIPT_DIR/manifests}"
|
|
MANIFESTS=(base-packages.txt remote-access.txt docker.txt)
|
|
REQUIREMENTS_FILE="requirements.tsv"
|
|
LOCK_SCHEMA_FILE="packages.lock.schema.tsv"
|
|
LOCK_HEADER="$(printf 'package\tversion\tarchitecture\tsha256\tsource')"
|
|
UNPINNED="UNPINNED"
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: build-offline-repo.sh --packages DIR --output DIR [--check]
|
|
build-offline-repo.sh --dry-run-plan
|
|
|
|
Uses only local .deb files. apt-ftparchive is required; no package is fetched.
|
|
Every package is recorded as package, version, architecture, SHA256, and source.
|
|
|
|
--dry-run-plan validates manifests/*.txt, manifests/requirements.tsv and
|
|
manifests/packages.lock.schema.tsv without apt-ftparchive, dpkg-deb or a package
|
|
pool. It exits non-zero while a pin still reads UNPINNED or a required package is
|
|
absent from the contract, and lists every gap so the LAB can fill it in.
|
|
EOF
|
|
}
|
|
|
|
PACKAGES=""; OUTPUT=""; CHECK_ONLY=0; PLAN_ONLY=0
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--packages) PACKAGES="${2:-}"; shift 2 ;;
|
|
--output) OUTPUT="${2:-}"; shift 2 ;;
|
|
--check) CHECK_ONLY=1; shift ;;
|
|
--dry-run-plan) PLAN_ONLY=1; shift ;;
|
|
--help|-h) usage; exit 0 ;;
|
|
*) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
# Static validation of the pin contract. Findings are collected first so the
|
|
# report is complete, sorted, and identical on every machine.
|
|
plan_report() {
|
|
local errors=0 awaiting=0 filled=0 total=0 absent=0 known=0 rows=0
|
|
local -A owner=() pinned=() required=()
|
|
local -a awaiting_lines=() absent_lines=() error_lines=()
|
|
local manifest file line lineno package version key name extra tool_apt tool_deb mf pkg source why
|
|
|
|
if command -v apt-ftparchive >/dev/null 2>&1; then tool_apt=present; else tool_apt=absent; fi
|
|
if command -v dpkg-deb >/dev/null 2>&1; then tool_deb=present; else tool_deb=absent; fi
|
|
|
|
if [[ ! -d "$MANIFEST_DIR" ]]; then
|
|
printf 'PLAN ERROR: manifest directory not found: %s\n' "$MANIFEST_DIR"
|
|
printf '\nPLAN NOT READY: manifest directory is missing.\n'
|
|
return 1
|
|
fi
|
|
|
|
# The offline installer reads every manifests/*.txt as a pin manifest, so an
|
|
# extra file there silently becomes part of the contract.
|
|
while IFS= read -r -d '' extra; do
|
|
name="$(basename "$extra")"
|
|
known=0
|
|
for manifest in "${MANIFESTS[@]}"; do
|
|
if [[ "$name" == "$manifest" ]]; then known=1; fi
|
|
done
|
|
if [[ "$known" -eq 0 ]]; then
|
|
error_lines+=("$name: unexpected manifests/*.txt file; scripts/install/blueforce-install.sh treats every one of them as a pin manifest")
|
|
errors=$((errors + 1))
|
|
fi
|
|
done < <(find "$MANIFEST_DIR" -maxdepth 1 -type f -name '*.txt' -print0 | sort -z)
|
|
|
|
for manifest in "${MANIFESTS[@]}"; do
|
|
file="$MANIFEST_DIR/$manifest"
|
|
if [[ ! -s "$file" ]]; then
|
|
error_lines+=("$manifest: missing or empty; the offline installer refuses an empty pin manifest")
|
|
errors=$((errors + 1))
|
|
continue
|
|
fi
|
|
lineno=0
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
lineno=$((lineno + 1))
|
|
if [[ -z "$line" || "$line" == \#* ]]; then continue; fi
|
|
if [[ "$line" == *[![:print:]]* ]]; then
|
|
error_lines+=("$manifest:$lineno: non-printable character in pin")
|
|
errors=$((errors + 1))
|
|
continue
|
|
fi
|
|
if [[ "$line" == *[[:space:]]* ]]; then
|
|
error_lines+=("$manifest:$lineno: whitespace in a pin; expected exactly package=version with no inline comment: $line")
|
|
errors=$((errors + 1))
|
|
continue
|
|
fi
|
|
if [[ ! "$line" =~ ^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$ ]]; then
|
|
error_lines+=("$manifest:$lineno: not an exact pin (expected package=version): $line")
|
|
errors=$((errors + 1))
|
|
continue
|
|
fi
|
|
if [[ "${line,,}" =~ (placeholder|replace|todo|tbd|fixme|changeme|fillme|example) ]]; then
|
|
error_lines+=("$manifest:$lineno: placeholder token left in pin: $line")
|
|
errors=$((errors + 1))
|
|
continue
|
|
fi
|
|
package="${line%%=*}"
|
|
version="${line#*=}"
|
|
total=$((total + 1))
|
|
if [[ "${version^^}" == "$UNPINNED" || ! "$version" =~ [0-9] ]]; then
|
|
awaiting=$((awaiting + 1))
|
|
awaiting_lines+=("$manifest: $package=$version")
|
|
else
|
|
filled=$((filled + 1))
|
|
fi
|
|
if [[ -n "${owner[$package]:-}" ]]; then
|
|
error_lines+=("$manifest:$lineno: duplicate pin for $package; already declared in ${owner[$package]}")
|
|
errors=$((errors + 1))
|
|
fi
|
|
owner[$package]="$manifest"
|
|
pinned["$manifest:$package"]=1
|
|
done < "$file"
|
|
done
|
|
|
|
if [[ ! -s "$MANIFEST_DIR/$REQUIREMENTS_FILE" ]]; then
|
|
error_lines+=("$REQUIREMENTS_FILE: missing or empty; the contract has no audit table of why each package is needed")
|
|
errors=$((errors + 1))
|
|
else
|
|
lineno=0
|
|
while IFS=$'\t' read -r mf pkg source why || [[ -n "${mf:-}" ]]; do
|
|
lineno=$((lineno + 1))
|
|
if [[ -z "$mf" || "$mf" == \#* ]]; then continue; fi
|
|
if [[ -z "${pkg:-}" || -z "${source:-}" || -z "${why:-}" ]]; then
|
|
error_lines+=("$REQUIREMENTS_FILE:$lineno: expected four TAB-separated fields (manifest, package, source, why)")
|
|
errors=$((errors + 1))
|
|
continue
|
|
fi
|
|
known=0
|
|
for manifest in "${MANIFESTS[@]}"; do
|
|
if [[ "$mf" == "$manifest" ]]; then known=1; fi
|
|
done
|
|
if [[ "$known" -eq 0 ]]; then
|
|
error_lines+=("$REQUIREMENTS_FILE:$lineno: unknown manifest column: $mf")
|
|
errors=$((errors + 1))
|
|
continue
|
|
fi
|
|
if [[ ! "$pkg" =~ ^[a-z0-9][a-z0-9+.-]*$ ]]; then
|
|
error_lines+=("$REQUIREMENTS_FILE:$lineno: invalid package name: $pkg")
|
|
errors=$((errors + 1))
|
|
continue
|
|
fi
|
|
key="$mf:$pkg"
|
|
required[$key]=1
|
|
if [[ -z "${pinned[$key]:-}" ]]; then
|
|
absent_lines+=("$mf: $pkg (required by $source)")
|
|
absent=$((absent + 1))
|
|
fi
|
|
done < "$MANIFEST_DIR/$REQUIREMENTS_FILE"
|
|
fi
|
|
|
|
for key in "${!pinned[@]}"; do
|
|
if [[ -z "${required[$key]:-}" ]]; then
|
|
error_lines+=("${key%%:*}: ${key#*:} is pinned but absent from $REQUIREMENTS_FILE; add the audit row or drop the pin")
|
|
errors=$((errors + 1))
|
|
fi
|
|
done
|
|
|
|
if [[ ! -s "$MANIFEST_DIR/$LOCK_SCHEMA_FILE" ]]; then
|
|
error_lines+=("$LOCK_SCHEMA_FILE: missing or empty; the released packages.lock.tsv layout is undocumented")
|
|
errors=$((errors + 1))
|
|
else
|
|
lineno=0
|
|
rows=0
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
lineno=$((lineno + 1))
|
|
if [[ -z "$line" || "$line" == \#* ]]; then continue; fi
|
|
rows=$((rows + 1))
|
|
if [[ "$line" != "$LOCK_HEADER" ]]; then
|
|
if [[ "$rows" -eq 1 ]]; then
|
|
error_lines+=("$LOCK_SCHEMA_FILE:$lineno: first uncommented line must be the exact lock header (TAB-separated package/version/architecture/sha256/source)")
|
|
else
|
|
error_lines+=("$LOCK_SCHEMA_FILE:$lineno: schema template must not contain data rows; keep examples commented out")
|
|
fi
|
|
errors=$((errors + 1))
|
|
fi
|
|
done < "$MANIFEST_DIR/$LOCK_SCHEMA_FILE"
|
|
fi
|
|
|
|
printf '=== Offline APT repository dry-run plan (no build, no network) ===\n'
|
|
printf 'Manifest directory: %s\n' "$MANIFEST_DIR"
|
|
printf 'Build tooling (not used by this plan): apt-ftparchive=%s dpkg-deb=%s\n' "$tool_apt" "$tool_deb"
|
|
printf 'A real build additionally needs an Ubuntu 26.04 build machine with both tools.\n'
|
|
printf 'SUMMARY: pins=%d filled=%d awaiting=%d missing_packages=%d errors=%d\n' \
|
|
"$total" "$filled" "$awaiting" "$absent" "$errors"
|
|
|
|
if [[ ${#awaiting_lines[@]} -gt 0 ]]; then
|
|
printf '\nMISSING VERSIONS (LAB: replace the %s sentinel with the exact version of the reviewed .deb):\n' "$UNPINNED"
|
|
while IFS= read -r line; do printf ' %s\n' "$line"; done < <(printf '%s\n' "${awaiting_lines[@]}" | sort)
|
|
fi
|
|
if [[ ${#absent_lines[@]} -gt 0 ]]; then
|
|
printf '\nMISSING PACKAGES (required by the installer or the audit table but not pinned):\n'
|
|
while IFS= read -r line; do printf ' %s\n' "$line"; done < <(printf '%s\n' "${absent_lines[@]}" | sort)
|
|
fi
|
|
if [[ ${#error_lines[@]} -gt 0 ]]; then
|
|
printf '\nPLAN ERRORS (contract violations, must be fixed before a build):\n'
|
|
while IFS= read -r line; do printf ' %s\n' "$line"; done < <(printf '%s\n' "${error_lines[@]}" | sort)
|
|
fi
|
|
|
|
if [[ "$errors" -gt 0 || "$absent" -gt 0 || "$awaiting" -gt 0 ]]; then
|
|
printf '\nPLAN NOT READY: %d pin(s) await a LAB version, %d required package(s) unpinned, %d error(s).\n' \
|
|
"$awaiting" "$absent" "$errors"
|
|
return 1
|
|
fi
|
|
printf '\nPLAN READY: %d pin(s) cover the contract; run --check on the Ubuntu 26.04 build machine with the reviewed pool.\n' "$total"
|
|
return 0
|
|
}
|
|
|
|
if [[ "$PLAN_ONLY" -eq 1 ]]; then
|
|
[[ -z "$PACKAGES" && -z "$OUTPUT" && "$CHECK_ONLY" -eq 0 ]] || {
|
|
printf 'Refusing: --dry-run-plan cannot be combined with --packages, --output or --check.\n' >&2
|
|
exit 2
|
|
}
|
|
if plan_report; then exit 0; fi
|
|
exit 1
|
|
fi
|
|
|
|
[[ -n "$PACKAGES" && -n "$OUTPUT" ]] || { usage >&2; exit 2; }
|
|
[[ -d "$PACKAGES" ]] || { printf 'Package directory not found: %s\n' "$PACKAGES" >&2; exit 2; }
|
|
command -v apt-ftparchive >/dev/null 2>&1 || { printf 'Refusing build: apt-ftparchive is not installed.\n' >&2; exit 1; }
|
|
command -v dpkg-deb >/dev/null 2>&1 || { printf 'Refusing build: dpkg-deb is not installed.\n' >&2; exit 1; }
|
|
mapfile -d '' debs < <(find "$PACKAGES" -maxdepth 1 -type f -name '*.deb' -print0 | sort -z)
|
|
[[ ${#debs[@]} -gt 0 ]] || { printf 'Refusing build: no local .deb files supplied.\n' >&2; exit 1; }
|
|
for list in "$SCRIPT_DIR/manifests/base-packages.txt" "$SCRIPT_DIR/manifests/remote-access.txt" "$SCRIPT_DIR/manifests/docker.txt"; do
|
|
pins=0
|
|
while IFS= read -r pin || [[ -n "$pin" ]]; do
|
|
[[ -z "$pin" || "$pin" == \#* ]] && continue
|
|
[[ "$pin" =~ ^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$ && "$pin" != *placeholder* && "$pin" != *REPLACE* && "$pin" != *replace* ]] || { printf 'Invalid exact package pin in %s: %s\n' "$list" "$pin" >&2; exit 2; }
|
|
version_part="${pin#*=}"
|
|
case "${version_part^^}" in
|
|
*UNPINNED*) printf 'Refusing build: pin still awaits its LAB version in %s: %s\n' "$list" "$pin" >&2; exit 2 ;;
|
|
esac
|
|
pins=$((pins + 1))
|
|
found=0
|
|
for deb in "${debs[@]}"; do
|
|
[[ "$(dpkg-deb -f "$deb" Package)=$(dpkg-deb -f "$deb" Version)" == "$pin" ]] && found=1 && break
|
|
done
|
|
[[ "$found" -eq 1 ]] || { printf 'Pinned package not supplied locally: %s\n' "$pin" >&2; exit 1; }
|
|
done < "$list"
|
|
[[ "$pins" -gt 0 ]] || { printf 'Refusing build: manifest has no concrete package pins: %s\n' "$list" >&2; exit 1; }
|
|
done
|
|
if [[ "$CHECK_ONLY" -eq 1 ]]; then
|
|
printf 'Check passed: %d local packages and all requested pins are available.\n' "${#debs[@]}"
|
|
exit 0
|
|
fi
|
|
[[ ! -e "$OUTPUT" ]] || { printf 'Refusing to overwrite output: %s\n' "$OUTPUT" >&2; exit 2; }
|
|
mkdir -p "$OUTPUT/pool"
|
|
lock="$OUTPUT/packages.lock.tsv"
|
|
printf 'package\tversion\tarchitecture\tsha256\tsource\n' > "$lock"
|
|
for deb in "${debs[@]}"; do
|
|
base="$(basename "$deb")"
|
|
cp -- "$deb" "$OUTPUT/pool/$base"
|
|
printf '%s\t%s\t%s\t%s\t%s\n' \
|
|
"$(dpkg-deb -f "$deb" Package)" "$(dpkg-deb -f "$deb" Version)" "$(dpkg-deb -f "$deb" Architecture)" \
|
|
"$(sha256sum "$deb" | awk '{print $1}')" "$base" >> "$lock"
|
|
done
|
|
( cd "$OUTPUT"; apt-ftparchive packages pool > Packages; gzip -n -9 -c Packages > Packages.gz; sha256sum Packages Packages.gz packages.lock.tsv > SHA256SUMS )
|
|
printf 'Built offline repository: %s\n' "$OUTPUT"
|