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
This commit is contained in:
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user