Files
Claude a170dc6228 ISO: add --offline bundle so the kiosk installs with no internet
`iso/build-iso.sh --offline` (and the workflow's new `offline` box, on by
default) adds ~420 MB to the ISO and copies it to /opt/kiosk-offline:

- apt/: local repository with every package install.sh's provisioning
  and the CUPS addon install ask for, NodeSource's nodejs, and all
  dependencies a fresh server install doesn't already have. Resolved
  against the source ISO's own package manifest via a synthesized dpkg
  status (iso/offline/fake-dpkg-status.py), so apt downloads only what's
  missing and makes the same virtual/alternative choices as the target.
- npm/: prebuilt kiosk-app (Electron 42, binary included - it no longer
  downloads at npm install time) and Web UI node_modules.

On first boot, kiosk-firstboot installs online as before when there's
internet; with none it waits KIOSK_OFFLINE_WAIT (90s), then runs
`kiosk-offline-apt on` (apt.conf.d drop-in: bundle is the ONLY source)
and turns it off again afterwards. install.sh notices offline mode via
offline_mode_active (lib/config.sh) and installs nodejs from the bundle
and unpacks prebuilt node_modules instead of npm install.

Package lists moved into PROVISION_APT_PACKAGES /
PROVISION_INTEL_APT_PACKAGES / CUPS_APT_PACKAGES so the bundler reads
the same lists install.sh installs from.

Tested: built against the 26.04.1 ISO, then in a network-less chroot of
the ISO's own server filesystem installed every package set, ran
provision_create_kiosk_user/install_nodejs/install_app and
webui_install_app_files in offline mode (Electron runs, no missing
libraries, chrome-sandbox setuid), and ran kiosk-firstboot through the
offline switch with a stub install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 04:34:32 +00:00

105 lines
3.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Write a dpkg status file claiming a set of packages is installed.
Usage: fake-dpkg-status.py MANIFEST LISTS_DIR > status
MANIFEST is an Ubuntu ISO's casper/*.manifest.full ("name[:arch]<TAB>version"
per line) - the packages a default Ubuntu Server install ends up with.
LISTS_DIR is an apt lists directory holding uncompressed *_Packages indexes.
iso/offline/build-bundle.sh resolves the kiosk's packages against this, so
apt only downloads what a freshly installed server is actually missing
(and makes the same alternative/virtual-package choices the real target
will), instead of the whole dependency closure down to libc6.
"""
import glob
import os
import sys
KEEP = ("Package", "Essential", "Priority", "Section", "Architecture",
"Multi-Arch", "Source", "Version", "Replaces", "Provides", "Depends",
"Pre-Depends", "Recommends", "Suggests", "Breaks", "Conflicts")
def stanzas(path):
with open(path, encoding="utf-8", errors="replace") as f:
cur = []
for line in f:
if line.strip() == "":
if cur:
yield cur
cur = []
else:
cur.append(line.rstrip("\n"))
if cur:
yield cur
def parse(lines):
fields, order, key = {}, [], None
for line in lines:
if line[:1] in (" ", "\t") and key:
fields[key] += "\n" + line
else:
key, _, val = line.partition(":")
fields[key] = val.strip()
order.append(key)
return fields
def main():
manifest, lists_dir = sys.argv[1], sys.argv[2]
wanted = {}
with open(manifest) as f:
for line in f:
parts = line.rstrip("\n").split("\t")
if len(parts) != 2 or parts[0].startswith("snap:"):
continue
wanted[parts[0].split(":")[0]] = parts[1]
exact, any_version = {}, {}
for path in glob.glob(os.path.join(lists_dir, "*_Packages")):
for st in stanzas(path):
p = parse(st)
name = p.get("Package")
if name not in wanted:
continue
any_version.setdefault(name, p)
if p.get("Version") == wanted[name]:
exact[name] = p
missing = 0
for name, version in sorted(wanted.items()):
p = exact.get(name) or any_version.get(name)
if p is None:
missing += 1
continue
out = [f"Package: {name}", "Status: install ok installed"]
for k in KEEP[1:]:
if k == "Version":
out.append(f"Version: {version}")
elif k in p:
val = p[k]
# The archive no longer carries this exact version (an
# update superseded it), so these fields came from a
# different one and its dependencies (version pins on
# siblings, a newer kernel ABI...) don't describe what's
# installed. Already-installed packages' own dependencies
# are assumed satisfied anyway, so just leave them out.
if name not in exact and k in ("Depends", "Pre-Depends", "Recommends"):
continue
# Same for versioned Breaks/Conflicts - drop them outright
# (unversioned ones still hold for any version).
if name not in exact and k in ("Breaks", "Conflicts"):
val = ", ".join(c.strip() for c in val.split(",") if "(" not in c)
if not val:
continue
out.append(f"{k}: {val}")
print("\n".join(out) + "\n")
print(f"fake-dpkg-status: {len(wanted) - missing} packages marked installed,"
f" {missing} not in the archive indexes (skipped)", file=sys.stderr)
if __name__ == "__main__":
main()