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.
The fleet has ~700 devices and no way to see them: the device console manages one
machine from its own keyboard, and the question "which devices are installed, and
which wave is each in" had no surface at all. This adds it as the first screen.
Python standard library only -- http.server, html.escape, string.Template. No
framework and no third-party import, because the fleet installs offline from a
committed package pool that contains no web framework, so fastapi/uvicorn/flask
would have made the panel uninstallable on the devices it manages. That was
decided with the user over the FastAPI they first picked, and a test asserts it:
the module imports only stdlib plus its own siblings.
What it shows: the dark header with the target and the privilege level; a summary
of counts by state with one loud figure; the device list with a state badge per
row, monospace ids and hostnames so columns line up, non-ready devices sorted
first, and a filter bar; the selected device's detail with its four-channel
verification, since READY is refused until every channel is verified (docs/27).
Nothing is colour-only: every badge carries its Turkish state word.
Data has no live source yet, so a missing inventory file falls back to synthetic
records that the page labels as demo in two places -- the panel never presents
invented devices as real ones.
Two defects found by reviewing a real render, both fixed here with a test that
fails when the bug returns (proven by mutation: each assertion goes red against
the reintroduced bug, green after):
- --state-provisioned resolved to var(--info), and --info is --accent, so a
KURULDU badge and the selected row were the same blue. The state now resolves
to its own colour, and the test resolves var() aliases before comparing --
comparing the raw declarations does not detect this, which is how the first
version of the test passed against the bug.
- the wave cell printed the bare code and then a label that already contains it,
so one device read "P2 P2 — PILOT-2". Each wave is named once, now.
Also fixes two entries in docs/27 that had become untrue: it claimed a clone
carries the packages and the installer downloads nothing, which stopped being the
case when the pool moved to Git LFS and bf-bootstrap began cloning without
smudge. The real flow -- light clone, payload from a local mirror or a targeted
`git lfs pull` -- is documented instead.
Tests: tests/test_web_static.py, 112 tests, collected by tests/run-all.sh
(12/12 PASS overall).
The console was a default-grey tkinter window: no grouping, no state colour, no
type scale, the privilege warning buried inside a monospace paragraph beside the
repository path, "Çık" offered twice at two different sizes, and an output area
that looked editable. The catalogue is 48 operations, 13 of them destructive, so
the screen an operator actually reads before touching a fleet was the one surface
with no hierarchy at all.
Adds bfos/ui_theme.py, which mirrors web/static/tokens.css value for value --
the two surfaces describe the same fleet and must not disagree about what a
colour means. It selects the `clam` theme because that renderer honours
background/bordercolor on the elements being restyled; note that theming an
element is not the same as redesigning a control, and the remaining gap is
recorded in docs/UI-REMAINING-WORK.md rather than claimed as finished.
What the redesign changes:
- sections get headed regions instead of floating on the root background;
- the privilege level becomes a header element and a banner, not a sentence;
- one exit action, not two;
- write operations are disabled with their reason visible when unprivileged,
rather than hidden or silently listed;
- the output well is styled as the read-only evidence it is.
Tests: tests/test_bfos_gui_static.py moves to the new contract (68 -> assertions
updated, none deleted); tests/test_bfos.py still 155/155, which is the check that
the CLI and backend contracts survived a UI rewrite.
The device console is a default-grey tkinter window with no hierarchy: no
grouping, no state colour, no type scale, the privilege warning buried in a
monospace paragraph, "Çık" duplicated at two sizes, and an output area that
looks editable. Every measurement is in the delivered work; the point here is
that the fix is a system, not a repaint.
Adds PRODUCT.md (product truth: who uses this, from where, under what
constraints), docs/DESIGN.md (the direction contract and the shared status
vocabulary) and web/static/tokens.css (the values both surfaces read).
The vocabulary is the load-bearing part. Four severities -- ok / warn / danger /
info -- plus one action accent, and one Turkish word per device stage
(KURULDU / KAYITLI / HAZIR / HATA / YOK) used identically on the device and in
the central panel. A fifth hue is a bug, and colour is never the only carrier:
every badge ships its word, so the fleet list stays readable in greyscale.
The ground is light on purpose. The console runs in workshops and dealer service
areas in daylight; a near-black ops console loses its own body text there and its
state colours collapse into equal-brightness smudges.
No webfont: the fleet installs offline from a committed pool, so a font CDN is a
broken page on every device.
Follow-up to fc4c87c, which committed the pool files themselves. This records
the .gitignore rule that stops excluding built/pool/ and the two assertions that
keep it that way: the suite now fails if either the bundle's pool/ or its index
becomes gitignored, because a cloning device receives nothing but what the
repository tracks.
Verified: check-offline-repo-static.sh 140/140 with the pool committed.
The installer resolves every pinned package as "<bundle>/pool/<source>" and the
lock rows name the files there, but built/pool was gitignored. A device that
cloned the repository therefore received the index and no packages and stopped
with "Kilitte yazılı paket depoda yok" -- the repository looked complete (2.7 MB
of index) while the payload was absent.
The bundle pool is committed now. The same blobs also live under
provisioning/offline-repo/packages/, which is the maintainer's intake directory;
Git LFS keys objects by content hash, so this is a second path and not a second
copy of the bytes.
Tests
- The suite asserts that neither the bundle's pool/ nor its index is gitignored,
which is the condition a cloning device depends on. It passed 140/140 with the
pool staged, and the check fails against the previous .gitignore.
dpkg-deb prints a BARE VALUE when one control field is requested and
"<Field>: <value>" when several are. Every reader was written for one shape and
broke on the other, so no pool could be read and no pin could be matched against
real files:
- build-index.sh: kept the label, so "Package: curl" failed the name check and
the pool came out empty -- the build refused with "no .deb files";
- add-packages.sh (read_deb): rejected every genuine .deb as an invalid name;
(scan_existing): produced empty keys, so a version conflict went undetected
and the same package was copied twice;
- build-offline-repo.sh: compared a labelled value with a "package=version" pin,
which can never match.
A first attempt with `sed 's/^Field: //'` was wrong in the other direction: sed
-v prints nothing when the pattern does not match, which emptied the single-field
reads. The label is now dropped only when it is present
(awk: if the first word ends with a colon), which leaves a bare value and a
version like "1:2.3-4" untouched.
Also fixed in build-index.sh: the foreign-file guard did not list
repo-manifest.yaml, the record this script writes into the bundle itself, so
every second build stopped on its own previous output. Accepting it is what
makes a rebuild possible at all.
Tests
- The dpkg-deb stub now reproduces both shapes, the single- and the multi-field
one. It emitted only one, which is exactly why this class stayed invisible
while the suite was green.
- The rebuild case mirrors the production layout (record inside the output
directory) and is proven by mutation: removing repo-manifest.yaml from the
guard's allow-list turns it red.
The guide claimed four secondary tools (environment info, live hardware check,
remote-access status, hardware inventory) exist only in the terminal console.
Measured against the loaded catalogue, all of them are reachable from the GUI's
Detaylı mod tabs -- the Araçlar tab lists 9 operations and Cihaz bilgisi carries
the info screens -- and the TUI uses the same bfos.operations catalogue, so
neither surface holds anything the other lacks. `--tui` is a fallback for a
missing window, not a place where missing features live.
The package pool was committed but the installer still stopped at "no local
package repository": it requires the compiled bundle (Packages, Packages.gz,
packages.lock.tsv, SHA256SUMS), and building that had never run.
The bundle
- 1321 packages, 884 MiB, Ubuntu 26.04 amd64, built with apt-ftparchive on the
fleet's own release so the closure matches the devices.
- built/pool/ is gitignored: those .deb files are already tracked once under
provisioning/offline-repo/packages/, and the same ~890 MB does not need a
second path. What the installer reads from the bundle is the index.
- The output directory is chmod 0755: mktemp -d created it 0700 and left it
so, which made a valid bundle unreadable to the installer's probe.
dpkg-deb output was parsed wrong in four places
- `dpkg-deb -f <file> Package Version` prints "<Field>: <value>", and the
callers kept the whole line, so the value carried its label. build-index.sh
therefore accepted no package at all and reported an empty pool;
add-packages.sh rejected every real .deb as an invalid name, missed version
conflicts, and copied a duplicate; build-offline-repo.sh never matched a pin.
- The test suite could not see any of this: its dpkg-deb stub emitted bare
values. The stub now prints the labels exactly as the real tool does, which
is what surfaced the remaining three sites.
Tests
- The secret scan covers the files we author, not apt-ftparchive's index over
third-party packages: polkit's own description reads "...asked for their
password:", which is prose in the packages, not a leak. Any secret reaching a
manifest, README or tool is still caught.
- The "test build must not touch the bundle" assertion now compares the
directory before and after instead of demanding it be empty, so a
maintainer's real build is not reported as test spill.
The field installer needs no network at all, which means the packages have to
travel with the repository. This adds the resolved pool and pins every package
to the exact version the pool carries.
Package pool
- 1312 .deb files (884 MB), resolved on Ubuntu 26.04 in a container so the
closure matches the release the fleet runs: a .deb built against another glibc
cannot be installed on the devices.
- Tracked through Git LFS (*.deb), so history stays navigable while the working
tree keeps the real files the installer reads from disk.
- Two RustDesk dependencies (libxdo3, libva-x11-2) were missing: the vendor .deb
is installed with dpkg, so nothing else pulls them in and `dpkg -i rustdesk`
would have left the package unconfigured on a device.
- Every file validated in the same release: 1312/1312 readable, 0 corrupt, 0
foreign architecture, and the pool satisfies all 24 pins (PLAN READY).
Pins
- All 24 UNPINNED sentinels replaced with the versions read from the packages
themselves (dpkg-deb), with requirements.tsv updated in the same change so the
contract has no drift.
build-index.sh: stop destroying the shipped record
- The repository record next to this script is the reviewed contract template
(state: UNBUILT, packages: []) that documents the schema. Its default record
destination pointed at that same file, so any default-path run overwrote the
template with BUILT rows -- which happened twice, once from a hand run and
once while this change was being developed.
- The record now defaults to <output>/repo-manifest.yaml, and pointing it at the
template is refused before any work starts. The template header documents this.
Tests
- The unpinned-pin and missing-pin cases read the sentinel out of the real
manifests, so they became no-ops the moment the contract was filled instead of
failing. They now set up the condition explicitly, and the fixture manifests
carry the synthetic versions their pool is built from.
- check-provisioning-static required packages/.gitkeep; the pool is what keeps
that directory in git now, so it asserts a populated pool directory instead.
The reviewed .deb files ship inside the repository so the installing device
downloads nothing, but a full Ubuntu dependency closure is roughly 900 MB of
binaries. Committing them as ordinary blobs would put that weight into every
clone of the project's history.
Track *.deb through Git LFS instead: the working tree keeps the real files
(the installer reads them from disk unchanged) while the history stores
pointers. Contributors need `git lfs install` once.
Note the GitHub storage and bandwidth quotas are not verified here -- a set
this size is close to the free tier's storage allowance and every clone spends
bandwidth.
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.
Switches the operator path from bash-only to a Python console, keeps the bash
menu as a fallback, and makes the offline package set travel with the repository
so the person installing a device never downloads anything.
bfos/ (stdlib only, no textual/rich: those would need a download, which defeats
the offline goal):
- simple mode offers three choices (Kur / Durum / Çıkış); the detailed mode with
all 21 fleet operations and the local installer is reachable from the menu or
with --full, so a first-time operator is not buried in options
- gate parity with the bash console: scope read from the playbook file and
refused on mismatch, production needs a typed token plus a separate approval,
release-gated runs need their wave name typed, destructive runs need a second
approval, "all" walks wave by wave and stops at the first failure
- Turkish throughout; audit log at /var/log/blueforce-console.log (600, falls
back to the user state dir) with a single redaction choke point
- bfos/ui_gui.py adds an optional tkinter window over the same operations layer;
headless and missing-tkinter cases explain themselves in Turkish
admin/bf-bootstrap.sh: links the Python console as `bf` (primary) and keeps
`bf-menu` (fallback), reports both, and recommends `sudo bf`.
Offline packages are committable now (this was the blocking contradiction):
- .gitignore ignored `Packages`, `Packages.gz` and `packages.lock.tsv` everywhere,
so a built bundle could never be committed and the installer would always have
been told to download. provisioning/offline-repo/built/ is now allowed through
while scratch output stays untracked
- provisioning/offline-repo/{add-packages.sh,build-index.sh,repo-manifest.yaml}
plus a Turkish step-by-step README for the repository owner
- the over-broad `*token*` ignore rule is narrowed to `*[-_.]token*` so ordinary
filenames are not silently excluded while token files still are
Tests: run-all.sh now runs the Python suites too (previously it collected only
*.sh, so 212 Python assertions were silently skipped) and executes from the repo
root. 11/11 pass, exit 0.
Two test expectations were corrected rather than the code: a read-only
wave-scoped report legitimately asks for no gate token (the bash console never
did either), so that assertion now checks the command carries operation_wave and
operation_confirmed, and a new test asserts release-gated runs still require
their token. The bootstrap completion contract moved from `sudo bf-menu` to
`sudo bf`.
Simplifies the primary path from "build a custom ISO" to "install from git and
run one menu", which is what the operator actually asked for. The ISO tooling
stays in the tree as a secondary/optional route and is not removed.
admin/bf-bootstrap.sh -- one command on Ubuntu:
- clones the repo to /opt/blueforce-linux or fast-forwards an existing checkout
(a dirty tree is reported and skipped, never reset or stashed)
- installs git/curl/ansible-core, links admin/bf-* and scripts/*/bf-* into
/usr/local/bin, and finishes with the exact next command
- --check is read-only, works without root, and mutates nothing; the piped form
(curl ... | sudo -S -p '' bash) is guarded against eating the script
admin/bf-menu -- numbered operator console (categories 1-5):
- 1 installs THIS device locally; 2 and 5 drive the fleet through the existing
21 Ansible playbooks; 3 prepares and verifies central integration without
installing a central server; 4 holds local tools
- every operation picks one target: a dealer number or an approved wave group.
Scope is derived from the playbook file itself, so a playbook whose declared
scope disagrees with the console's table is refused rather than run
- the command is always shown before it runs; release-gated operations require
a typed wave token, production requires a second token, and "all" runs
wave-by-wave, stopping at the first failure
- audit log at /var/log/blueforce-console.log (mode 600, falls back to
$XDG_STATE_HOME when not writable), recording sanitized target/command/exit
only: no playbook output, no secrets
admin/bf-creds -- optional credential record, DEFAULT OFF:
- a bare run generates nothing and reports what already exists (device id,
WireGuard public key, private key path only, RustDesk id)
- --generate writes /root/blueforce-credentials.txt (0600) and --fetch delivers
it to admin/secrets/<dealer>.txt (0700/0600, git-ignored). The secret never
reaches stdout, stderr or argv
- this does not change the SSH design: password auth stays off and the
blueforce account stays locked, per docs/06. It is a local delivery aid only
docs/33-OPERATOR-CONSOLE.md -- step-by-step guide for an operator new to Linux
and DevOps: which command to type where, what the screen will ask, both
end-to-end sample sessions, the five most common errors, and the security rules.
Tests: run-all.sh -> 9/9 pass, exit 0 (adds check-admin-static.sh, 159 checks,
with negative controls and mutation-proven non-vacuity).
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.