`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
105 lines
3.8 KiB
Python
Executable File
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()
|