Blueforce-Linux/admin/bf-bootstrap.sh
emirhanmamak-blueforce 06c96690ca fix(offline): keep the package payload out of a clone, and say so when it is missing
The bundle pool is ~884 MB and now travels through Git LFS, so a plain clone
materialises all of it. Measured on this repository: 2.7 GB working tree, against
20 MB with smudge disabled -- the same 135x that decides whether one device or
seven hundred can be provisioned from a git remote.

bf-bootstrap exports GIT_LFS_SKIP_SMUDGE=1 for every git operation it performs
(clone and fetch), so provisioning a device no longer implies downloading the
package payload. The trade is explicit and documented rather than hidden: with
smudge off the pool files on disk are 131-byte pointer texts, and the installer
must not mistake one for a package.

So the installer gained a guard. A pointer text satisfies every existence check,
so without it the run died later inside `sha256sum -c` and reported a checksum
mismatch -- a misdiagnosis that sends an operator looking for corruption when the
payload was simply never fetched. It now recognises the pointer by content and
names the remedy instead.

Getting the payload is a deliberate second step, and both documented routes are
real: a local mirror (the offline-correct path, and the one the fleet uses) or
`git lfs pull --include='provisioning/offline-repo/built/pool/*'`, which fetches
only the delivery pool and leaves the intake copy under packages/ unfetched --
verified: 1.8 GB and a real .deb in built/pool, 131-byte stub still in packages/.

Tests: test-field-os-lifecycle.sh gains a scenario that lays down a pointer text
where a .deb belongs and asserts three things -- the run refuses, the message
names the cause, and it does not blame a checksum. Also asserts apt-get is never
invoked. 16 scenarios, all passing.

docs/27 is corrected where it had gone stale: it claimed a clone carries the
packages and the installer downloads nothing, which stopped being true the moment
the payload moved to LFS.
2026-09-16 22:41:53 +03:00

399 lines
16 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
#
# bf-bootstrap.sh — one-command install of the Blueforce Linux admin toolbox.
#
# Documented entry points:
# curl -fsSL https://raw.githubusercontent.com/emirhanmamak-blueforce/Blueforce-Linux/main/admin/bf-bootstrap.sh | sudo bash
# sudo admin/bf-bootstrap.sh # from a checkout
#
# Steps, in order:
# 1. root check (apply mode only; --check is read-only and needs no root),
# 2. Ubuntu release check against the supported baseline (24.04 / 26.04),
# 3. dependency install: git, curl, ansible-core,
# 4. clone the repository into <dir> (default /opt/blueforce-linux) or
# fast-forward an existing checkout (never resets, never discards work),
# 5. link admin/bf-*, scripts/diagnostics/bf-* and scripts/maintenance/bf-*
# into /usr/local/bin (idempotent; a re-run refreshes the links),
# 6. make admin/bf-menu executable so `sudo bf-menu` starts the on-device
# installer menu,
# 7. print a clear summary.
#
# Safety
# * This script writes no secret and installs code only: credentials stay on
# the device and are never fetched by the installer (see admin/bf-creds).
# * Re-running it is safe: the checkout is fast-forwarded and the tool links
# are re-created. Nothing is deleted and no `git reset` is ever performed,
# so local work in the checkout is never discarded.
# * --check reports the same plan without touching the system.
#
set -euo pipefail
REPO_URL="${BF_REPO_URL:-https://github.com/emirhanmamak-blueforce/Blueforce-Linux.git}"
BRANCH_DEFAULT="main"
INSTALL_DIR_DEFAULT="/opt/blueforce-linux"
BIN_DIR="${BF_BIN_DIR:-/usr/local/bin}"
SUPPORTED_UBUNTU=("24.04" "26.04")
# Command name / apt package name pairs for the installer prerequisites.
DEPENDENCY_COMMANDS=(git curl ansible-playbook)
DEPENDENCY_PACKAGES=(git curl ansible-core)
CHECK_MODE=0
ASSUME_YES=0
BRANCH="$BRANCH_DEFAULT"
DIR=""
LINKED=0
MENU_READY=0
usage() {
cat <<'EOF'
Usage: sudo bf-bootstrap.sh [--check] [--dir DIR] [--branch BRANCH] [--yes] [--help]
Install (or refresh) the Blueforce Linux admin toolbox from git: fetch the
repository, install the prerequisites, link the bf-* tools into
/usr/local/bin, and make `sudo bf-menu` available for the on-device setup.
Options:
--check Read-only: report what would happen. It never mutates
anything and never requires root.
--dir DIR Checkout directory. Default: the checkout this script lives
in, otherwise /opt/blueforce-linux.
--branch BRANCH Branch to track (default: main).
--yes, -y Do not ask for confirmation.
--help, -h Show this help and exit.
Environment:
BF_REPO_URL Repository URL (default: the upstream GitHub repository).
BF_INSTALL_DIR Checkout directory; same as --dir.
BF_BIN_DIR Tool link directory (default: /usr/local/bin).
Notes:
* Installer prerequisites are git, curl and ansible-core (ansible-playbook).
* Supported baseline is Ubuntu 24.04 / 26.04; any other release only warns.
* This script installs code only. It never writes, fetches, or prints a
secret; device credentials are handled by admin/bf-creds.
EOF
}
say() { printf 'bf-bootstrap: %s\n' "$*"; }
warn() { printf 'bf-bootstrap: WARN: %s\n' "$*" >&2; }
die() { printf 'bf-bootstrap: ERROR: %s\n' "$*" >&2; exit 1; }
# Git-LFS smudge is disabled for every git operation this script performs.
#
# Why: the offline package pool is ~884 MB of .deb payload tracked through Git
# LFS, and a default clone materialises all of it (measured: 2.7 GB working tree
# against 20 MB with smudge off, a 135x difference). A device that only needs to
# reach the console and the manifest pays that cost on every clone for bytes it
# may never install from.
#
# The trade is explicit: with smudge off the pool files on disk are 131-byte
# pointer texts, and the installer refuses such a bundle rather than installing
# from it ("Nokta dosyası" below). Fetching the payload is therefore a separate,
# deliberate step -- `git lfs pull --include='provisioning/offline-repo/built/pool/*'`
# -- so that "I cloned the repository" and "I have the packages" stay two facts.
#
# This is deliberately the smudge-off half only. Enabling a full `git lfs pull`
# here would reintroduce the 884 MB download this prevents.
export GIT_LFS_SKIP_SMUDGE=1
run() {
if [[ "$CHECK_MODE" -eq 1 ]]; then
printf 'bf-bootstrap: check: would run: %s\n' "$*"
return 0
fi
"$@"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--check) CHECK_MODE=1; shift ;;
--dir) DIR="${2:?missing value for --dir}"; shift 2 ;;
--branch) BRANCH="${2:?missing value for --branch}"; shift 2 ;;
--yes|-y) ASSUME_YES=1; shift ;;
--help|-h) usage; exit 0 ;;
*) printf 'bf-bootstrap: ERROR: unknown argument: %s\n\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
# --------------------------------------------------------------------------- #
# Where does this script live? When it runs from a checkout, that checkout is
# the default target; the piped one-command form has no checkout, so the
# default install directory is used.
# --------------------------------------------------------------------------- #
SCRIPT_SOURCE="${BASH_SOURCE[0]:-}"
if [[ -n "$SCRIPT_SOURCE" && -r "$SCRIPT_SOURCE" ]]; then
SCRIPT_REAL="$(readlink -f "$SCRIPT_SOURCE" 2>/dev/null || printf '%s' "$SCRIPT_SOURCE")"
CANDIDATE="$(cd "$(dirname "$SCRIPT_REAL")/.." 2>/dev/null && pwd || true)"
if [[ -n "$CANDIDATE" && -f "$CANDIDATE/admin/bf-bootstrap.sh" && -d "$CANDIDATE/scripts/diagnostics" ]]; then
CHECKOUT_ROOT="$CANDIDATE"
fi
fi
if [[ -z "$DIR" ]]; then
DIR="${BF_INSTALL_DIR:-${CHECKOUT_ROOT:-$INSTALL_DIR_DEFAULT}}"
fi
if [[ -z "$BRANCH" ]]; then
die 'branch must not be empty'
fi
# --------------------------------------------------------------------------- #
# 1. Root check. --check only reports, so it works as an unprivileged user.
# --------------------------------------------------------------------------- #
if [[ "$CHECK_MODE" -eq 1 ]]; then
if [[ "$(id -u)" -ne 0 ]]; then
say 'check: not running as root; apply mode will require sudo'
fi
else
if [[ "$(id -u)" -ne 0 ]]; then
die "must run as root: sudo $0 (or: sudo bf-bootstrap.sh)"
fi
fi
# --------------------------------------------------------------------------- #
# 2. Ubuntu release check. A different release is only a warning: the goal is
# to install the toolbox, not to block a working host.
# --------------------------------------------------------------------------- #
DISTRO_ID=""
DISTRO_VERSION=""
DISTRO_PRETTY="unknown"
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
DISTRO_ID="${ID:-}"
DISTRO_VERSION="${VERSION_ID:-}"
DISTRO_PRETTY="${PRETTY_NAME:-unknown}"
fi
if [[ "$DISTRO_ID" == "ubuntu" ]]; then
SUPPORTED=0
for release in "${SUPPORTED_UBUNTU[@]}"; do
if [[ "$DISTRO_VERSION" == "$release" ]]; then
SUPPORTED=1
fi
done
if [[ "$SUPPORTED" -eq 1 ]]; then
say "ubuntu ${DISTRO_VERSION} is a supported baseline"
else
warn "Ubuntu ${DISTRO_VERSION:-unknown} is outside the tested baseline (${SUPPORTED_UBUNTU[*]}); continuing"
fi
else
warn "this is not an Ubuntu host (ID=${DISTRO_ID:-unknown}, ${DISTRO_PRETTY}); the Field OS baseline is Ubuntu ${SUPPORTED_UBUNTU[*]}"
fi
# --------------------------------------------------------------------------- #
# Operator confirmation. Only a terminal prompts: the documented one-command
# form (`curl ... | sudo bash`) has no terminal on stdin and never blocks.
# --------------------------------------------------------------------------- #
if [[ "$CHECK_MODE" -eq 0 && "$ASSUME_YES" -eq 0 && -t 0 ]]; then
printf 'Install or update the Blueforce toolbox in %s? [y/N] ' "$DIR"
ANSWER=""
read -r ANSWER || ANSWER=""
if [[ ! "$ANSWER" =~ ^[Yy]([Ee][Ss])?$ ]]; then
say 'aborted by the operator'
exit 2
fi
fi
# --------------------------------------------------------------------------- #
# 3. Dependencies.
# --------------------------------------------------------------------------- #
MISSING_COMMANDS=()
MISSING_PACKAGES=()
say 'dependency check'
for index in "${!DEPENDENCY_COMMANDS[@]}"; do
if ! command -v "${DEPENDENCY_COMMANDS[$index]}" >/dev/null 2>&1; then
MISSING_COMMANDS+=("${DEPENDENCY_COMMANDS[$index]}")
MISSING_PACKAGES+=("${DEPENDENCY_PACKAGES[$index]}")
fi
done
if [[ ${#MISSING_PACKAGES[@]} -eq 0 ]]; then
say "dependencies present: ${DEPENDENCY_COMMANDS[*]}"
elif [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would install with apt-get: ${MISSING_PACKAGES[*]} (missing: ${MISSING_COMMANDS[*]})"
else
if ! command -v apt-get >/dev/null 2>&1; then
die "apt-get not found; install these packages first: ${MISSING_PACKAGES[*]}"
fi
say "installing dependencies: ${MISSING_PACKAGES[*]}"
export DEBIAN_FRONTEND=noninteractive
run apt-get update
run apt-get install -y --no-install-recommends "${MISSING_PACKAGES[@]}"
fi
# --------------------------------------------------------------------------- #
# 4. Repository: clone once, then fast-forward only.
# --------------------------------------------------------------------------- #
update_checkout() {
local remote="" origin_url="" dirty=""
if ! git -C "$DIR" rev-parse --verify --quiet HEAD >/dev/null 2>&1; then
warn "checkout has no commits yet: $DIR; skipping the update"
return 0
fi
# Prefer `origin`, otherwise accept the checkout's single remote (a local
# clone of this project may name it `github`, as the upstream checkout does).
if git -C "$DIR" remote get-url origin >/dev/null 2>&1; then
remote="origin"
else
remote="$(git -C "$DIR" remote 2>/dev/null | head -n 1 || true)"
fi
if [[ -z "$remote" ]]; then
warn "checkout has no git remote: $DIR; skipping the update"
return 0
fi
origin_url="$(git -C "$DIR" remote get-url "$remote" 2>/dev/null || true)"
if [[ "$origin_url" != *"Blueforce-Linux"* ]]; then
warn "remote '$remote' looks unexpected: $origin_url"
fi
dirty="$(git -C "$DIR" status --porcelain 2>/dev/null || true)"
if [[ -n "$dirty" ]]; then
warn "local changes present in $DIR; skipping the update (nothing was reset or stashed)"
return 0
fi
if [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would fast-forward $DIR to $remote/$BRANCH"
return 0
fi
say "updating $DIR (fast-forward only, remote $remote)"
if ! git -C "$DIR" fetch --prune --quiet "$remote" "$BRANCH"; then
warn "fetch failed (offline?); keeping the current checkout"
return 0
fi
if ! git -C "$DIR" merge --ff-only --quiet "$remote/$BRANCH"; then
warn "cannot fast-forward to $remote/$BRANCH; keeping the current checkout (no reset performed)"
fi
return 0
}
if [[ -e "$DIR" && ! -d "$DIR" ]]; then
die "install directory exists and is not a directory: $DIR"
fi
if [[ -d "$DIR" ]]; then
if [[ -d "$DIR/.git" ]]; then
say "existing checkout: $DIR"
update_checkout
else
die "directory exists but is not a git checkout; refusing to touch it: $DIR"
fi
elif [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would clone $REPO_URL (branch $BRANCH) into $DIR"
else
say "cloning $REPO_URL (branch $BRANCH) into $DIR"
run install -d -m 0755 "$(dirname "$DIR")"
run git clone --branch "$BRANCH" --single-branch -- "$REPO_URL" "$DIR"
fi
# --------------------------------------------------------------------------- #
# 5. Link the tools into /usr/local/bin. Idempotent: `ln -sfn` refreshes an
# existing link, and a re-run after `git pull` needs no extra step.
# --------------------------------------------------------------------------- #
link_tools() {
local source="" base=""
for source in "$DIR"/admin/bf-* "$DIR"/scripts/diagnostics/bf-* "$DIR"/scripts/maintenance/bf-*; do
if [[ ! -f "$source" ]]; then
continue
fi
base="$(basename "$source")"
if [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would link $source -> $BIN_DIR/$base"
else
chmod 0755 "$source"
ln -sfn "$source" "$BIN_DIR/$base"
fi
LINKED=$((LINKED + 1))
done
}
if [[ -d "$DIR" ]]; then
run install -d -m 0755 "$BIN_DIR"
link_tools
if [[ "$LINKED" -eq 0 ]]; then
warn "no bf-* tools found under $DIR (admin/, scripts/diagnostics/, scripts/maintenance/)"
elif [[ "$CHECK_MODE" -eq 0 ]]; then
say "linked $LINKED tools into $BIN_DIR"
fi
elif [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would link the bf-* tools from $DIR into $BIN_DIR"
fi
# --------------------------------------------------------------------------- #
# 6. Operator console entry point.
#
# The Python console (`admin/bf` -> bfos) is the primary path; the bash
# `bf-menu` stays linked as a fallback so a host without python3 can still be
# operated.
# --------------------------------------------------------------------------- #
PY_CONSOLE="$DIR/admin/bf"
MENU_SOURCE="$DIR/admin/bf-menu"
MENU_READY=0
if [[ -f "$PY_CONSOLE" ]]; then
if [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would make $PY_CONSOLE executable and link $BIN_DIR/bf"
else
chmod 0755 "$PY_CONSOLE"
ln -sfn "$PY_CONSOLE" "$BIN_DIR/bf"
say "bf ready: $BIN_DIR/bf (Python console)"
fi
MENU_READY=1
else
warn "admin/bf is not present in $DIR; the Python console arrives with the next revision of the checkout"
fi
if [[ -f "$MENU_SOURCE" ]]; then
if [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would make $MENU_SOURCE executable and link $BIN_DIR/bf-menu"
else
chmod 0755 "$MENU_SOURCE"
ln -sfn "$MENU_SOURCE" "$BIN_DIR/bf-menu"
say "bf-menu ready: $BIN_DIR/bf-menu (bash fallback)"
fi
MENU_READY=1
else
warn "admin/bf-menu is not present in $DIR; the bash fallback will work once it is in the checkout"
fi
if [[ "$MENU_READY" -eq 0 ]]; then
warn "no operator console was found in $DIR; link one of admin/bf or admin/bf-menu manually"
fi
# --------------------------------------------------------------------------- #
# 7. Summary.
# --------------------------------------------------------------------------- #
if [[ "$CHECK_MODE" -eq 1 ]]; then
printf '\nbf-bootstrap: check complete -- no changes were made.\n'
printf ' checkout : %s\n' "$DIR"
printf ' branch : %s\n' "$BRANCH"
printf ' tools : %s tool(s) would be linked into %s\n' "$LINKED" "$BIN_DIR"
printf ' next : sudo bf-bootstrap.sh --yes\n'
exit 0
fi
REVISION=""
if [[ -d "$DIR/.git" ]]; then
REVISION="$(git -C "$DIR" rev-parse --short HEAD 2>/dev/null || true)"
fi
printf '\nbf-bootstrap: summary\n'
printf ' repository : %s\n' "$DIR"
printf ' branch : %s\n' "$BRANCH"
if [[ -n "$REVISION" ]]; then
printf ' revision : %s\n' "$REVISION"
fi
printf ' tools : %s linked into %s\n' "$LINKED" "$BIN_DIR"
if [[ -x "$BIN_DIR/bf" ]]; then
printf ' console : %s (Python, primary)\n' "$BIN_DIR/bf"
else
printf ' console : pending (admin/bf is not in the checkout yet)\n'
fi
if [[ -x "$BIN_DIR/bf-menu" ]]; then
printf ' fallback : %s (bash)\n' "$BIN_DIR/bf-menu"
else
printf ' fallback : pending (admin/bf-menu is not in the checkout yet)\n'
fi
# The Python console is the recommended entry point; the bash menu stays as the
# fallback for a host without python3.
if [[ -x "$BIN_DIR/bf" ]]; then
printf ' next : sudo bf\n'
else
printf ' next : sudo bf-menu\n'
fi
printf '\nInstallation complete. To get started: sudo bf\n'
printf 'Kurulum tamam. Başlamak için: sudo bf\n'