This commit is contained in:
22
scripts/build-iso.sh
Normal file
22
scripts/build-iso.sh
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build in a new directory so old profiles and work directories are never reused.
|
||||
set -Eeuo pipefail
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
DEST=${1:?Usage: bash scripts/build-iso.sh /absolute/path/to/new-build-directory}
|
||||
[[ ! -e "$DEST" ]] || { echo 'Destination must not already exist.' >&2; exit 2; }
|
||||
mkdir -p "$DEST"
|
||||
DEST=$(cd "$DEST" && pwd)
|
||||
bash "$ROOT/scripts/create-profile.sh" "$DEST/profile"
|
||||
python3 "$ROOT/scripts/release-manifest.py" --verify-profile "$DEST/profile"
|
||||
sudo mkarchiso -v -w "$DEST/work" -o "$DEST/out" "$DEST/profile"
|
||||
python3 "$ROOT/scripts/release-manifest.py" --verify-profile "$DEST/profile"
|
||||
shopt -s nullglob
|
||||
isos=("$DEST"/out/*.iso)
|
||||
[[ ${#isos[@]} == 1 ]] || { echo 'Expected exactly one ISO.' >&2; exit 1; }
|
||||
bsdtar -tf "${isos[0]}" > "$DEST/iso-files.txt"
|
||||
grep -qx 'arch/boot/x86_64/vmlinuz-linux' "$DEST/iso-files.txt"
|
||||
grep -qx 'arch/boot/x86_64/initramfs-linux.img' "$DEST/iso-files.txt"
|
||||
[[ $(stat -c %s "${isos[0]}") -lt 4294967295 ]] || { echo 'ISO exceeds FAT32 file size limit.' >&2; exit 1; }
|
||||
sha256sum "${isos[0]}" > "$DEST/out/SHA256SUMS"
|
||||
cp "$DEST/profile/airootfs/etc/csr-intake/release.json" "$DEST/out/release.json"
|
||||
echo "ISO validated in $DEST/out. Physical UEFI/BIOS boot and hardware tests still required."
|
||||
13
scripts/build-release.sh
Normal file
13
scripts/build-release.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
export CSR_BUILD_ID
|
||||
CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id)
|
||||
for component in scanner controller gui; do
|
||||
cargo test --locked --manifest-path "$ROOT/$component/Cargo.toml"
|
||||
cargo build --locked --release --manifest-path "$ROOT/$component/Cargo.toml"
|
||||
done
|
||||
python3 -m unittest discover -s "$ROOT/tests" -p 'test_*.py'
|
||||
bash "$ROOT/tests/smoke.sh"
|
||||
python3 "$ROOT/scripts/release-manifest.py" --create "$ROOT/build/release.json"
|
||||
echo "Validated release: $CSR_BUILD_ID"
|
||||
62
scripts/create-profile.sh
Executable file
62
scripts/create-profile.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
DEST=${1:-$ROOT/build/profile}
|
||||
[[ -d /usr/share/archiso/configs/releng ]] || { echo 'Install archiso first.' >&2; exit 1; }
|
||||
[[ ! -e $DEST ]] || { echo "Profile already exists: $DEST" >&2; exit 1; }
|
||||
bash "$ROOT/scripts/build-release.sh"
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
cp -a /usr/share/archiso/configs/releng "$DEST"
|
||||
for pkg in cage seatd xorg-xwayland mesa vulkan-radeon vulkan-intel libglvnd fontconfig ttf-dejavu networkmanager curl ca-certificates sqlite smartmontools ffmpeg gcc glmark2 mpv pciutils util-linux e2fsprogs stress-ng memtester alsa-utils v4l-utils dmidecode python usbutils xdg-utils firefox; do
|
||||
grep -qxF "$pkg" "$DEST/packages.x86_64" || echo "$pkg" >> "$DEST/packages.x86_64"
|
||||
done
|
||||
mkdir -p "$DEST/airootfs/usr/local/bin" "$DEST/airootfs/usr/local/lib/pc-intake" "$DEST/airootfs/etc/systemd/system/multi-user.target.wants"
|
||||
install -m755 "$ROOT/scanner/target/release/pc-intake" "$DEST/airootfs/usr/local/bin/pc-intake"
|
||||
install -m755 "$ROOT/controller/target/release/intake-controller" "$DEST/airootfs/usr/local/bin/intake-controller"
|
||||
install -m755 "$ROOT/gui/target/release/intake-gui" "$DEST/airootfs/usr/local/bin/intake-gui"
|
||||
install -m755 "$ROOT/scripts/intake-session.sh" "$DEST/airootfs/usr/local/bin/intake-session"
|
||||
# The GUI must run in a real logind session, not a root system service attached to a TTY.
|
||||
install -m755 "$ROOT/live/intake-prepare.sh" "$DEST/airootfs/usr/local/bin/intake-prepare"
|
||||
install -m755 "$ROOT/live/intake-login.sh" "$DEST/airootfs/usr/local/bin/intake-login"
|
||||
install -m644 "$ROOT/live/intake-prepare.service" "$DEST/airootfs/etc/systemd/system/intake-prepare.service"
|
||||
mkdir -p "$DEST/airootfs/etc/systemd/system/getty@tty1.service.d"
|
||||
install -m644 "$ROOT/live/autologin.conf" "$DEST/airootfs/etc/systemd/system/getty@tty1.service.d/autologin.conf"
|
||||
ln -s ../intake-prepare.service "$DEST/airootfs/etc/systemd/system/multi-user.target.wants/intake-prepare.service"
|
||||
rm -f "$DEST/airootfs/etc/systemd/system/getty@tty1.service"
|
||||
rm -f "$DEST/airootfs/etc/systemd/system/multi-user.target.wants/pc-intake.service"
|
||||
rm -f "$DEST/airootfs/etc/systemd/system/pc-intake.service"
|
||||
# Archiso can override copied file modes. Record explicit permissions too.
|
||||
cat >> "$DEST/profiledef.sh" <<'EOF'
|
||||
file_permissions+=(
|
||||
["/usr/local/bin/intake-session"]="0:0:755"
|
||||
["/usr/local/bin/pc-intake"]="0:0:755"
|
||||
["/usr/local/bin/intake-controller"]="0:0:755"
|
||||
["/usr/local/bin/intake-gui"]="0:0:755"
|
||||
["/usr/local/bin/intake-prepare"]="0:0:755"
|
||||
["/usr/local/bin/intake-login"]="0:0:755"
|
||||
)
|
||||
EOF
|
||||
for path in usr/local/bin/intake-session usr/local/bin/pc-intake usr/local/bin/intake-controller usr/local/bin/intake-gui usr/local/bin/intake-prepare usr/local/bin/intake-login; do
|
||||
test -x "$DEST/airootfs/$path" || { echo "FATAL: missing executable $path" >&2; exit 1; }
|
||||
done
|
||||
# Optional operator-approved provisioning at build time; ISO secrets are extractable.
|
||||
if [[ ${CSR_EMBED_INVENTORY_CONFIG:-0} == 1 ]]; then
|
||||
: "${CSR_PRIVATE_CONFIG_DIR:?Set CSR_PRIVATE_CONFIG_DIR to local private folder}"
|
||||
test -f "$CSR_PRIVATE_CONFIG_DIR/inventory.json"
|
||||
test -f "$CSR_PRIVATE_CONFIG_DIR/inventory-api-key"
|
||||
mkdir -p "$DEST/airootfs/etc/csr-intake"
|
||||
install -m600 "$CSR_PRIVATE_CONFIG_DIR/inventory.json" "$DEST/airootfs/etc/csr-intake/inventory.json"
|
||||
install -m600 "$CSR_PRIVATE_CONFIG_DIR/inventory-api-key" "$DEST/airootfs/etc/csr-intake/inventory-api-key"
|
||||
cat >> "$DEST/profiledef.sh" <<'PERMS'
|
||||
file_permissions+=( ["/etc/csr-intake/inventory.json"]="0:0:600" ["/etc/csr-intake/inventory-api-key"]="0:0:600" )
|
||||
PERMS
|
||||
fi
|
||||
echo "Profile created at $DEST"
|
||||
|
||||
# Keep NetworkManager running in the live image for LAN/WLAN diagnostics.
|
||||
mkdir -p "$DEST/airootfs/etc/systemd/system/multi-user.target.wants"
|
||||
ln -sfn /usr/lib/systemd/system/NetworkManager.service "$DEST/airootfs/etc/systemd/system/multi-user.target.wants/NetworkManager.service"
|
||||
|
||||
mkdir -p "$DEST/airootfs/etc/csr-intake"
|
||||
install -m644 "$ROOT/build/release.json" "$DEST/airootfs/etc/csr-intake/release.json"
|
||||
python3 "$ROOT/scripts/release-manifest.py" --verify-profile "$DEST"
|
||||
17
scripts/db-import.py
Executable file
17
scripts/db-import.py
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse, datetime, json, pathlib, sqlite3
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('database', type=pathlib.Path)
|
||||
parser.add_argument('report', type=pathlib.Path)
|
||||
args=parser.parse_args()
|
||||
report=json.loads(args.report.read_text(encoding='utf-8'))
|
||||
asset=report['asset_id']
|
||||
if not isinstance(asset,str) or not asset or len(asset)>64: raise ValueError('Invalid asset ID')
|
||||
args.database.parent.mkdir(parents=True,exist_ok=True)
|
||||
with sqlite3.connect(args.database,timeout=30) as db:
|
||||
db.execute('PRAGMA journal_mode=DELETE')
|
||||
db.execute('PRAGMA synchronous=FULL')
|
||||
db.execute('CREATE TABLE IF NOT EXISTS scans (scan_id INTEGER PRIMARY KEY, asset_id TEXT NOT NULL, scanned_at_unix INTEGER, imported_at_utc TEXT NOT NULL, tool_version TEXT, model TEXT, cpu TEXT, health_gate TEXT, json TEXT NOT NULL)')
|
||||
db.execute('CREATE INDEX IF NOT EXISTS scans_asset ON scans(asset_id,scan_id DESC)')
|
||||
db.execute('INSERT INTO scans(asset_id,scanned_at_unix,imported_at_utc,tool_version,model,cpu,health_gate,json) VALUES(?,?,?,?,?,?,?,?)', (asset,report.get('timestamp_unix'),datetime.datetime.now(datetime.timezone.utc).isoformat(),report.get('tool_version'),report.get('model'),report.get('cpu'),report.get('health_gate'),json.dumps(report,separators=(',',':'))))
|
||||
print('Recorded',asset,'in',args.database)
|
||||
10
scripts/db-view.py
Normal file
10
scripts/db-view.py
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
import pathlib, sqlite3, sys
|
||||
p=pathlib.Path(sys.argv[1])
|
||||
if not p.is_file():
|
||||
print('No database scans yet.'); sys.exit(0)
|
||||
with sqlite3.connect(f'file:{p}?mode=ro', uri=True) as db:
|
||||
print('Total scans:', db.execute('SELECT count(*) FROM scans').fetchone()[0])
|
||||
print('Unique assets:', db.execute('SELECT count(DISTINCT asset_id) FROM scans').fetchone()[0])
|
||||
for asset, model, gate, timestamp in db.execute('SELECT asset_id, model, health_gate, scanned_at_unix FROM scans ORDER BY scan_id DESC LIMIT 15'):
|
||||
print(f'{asset:14} | {(model or "unknown")[:28]:28} | {gate or "UNKNOWN"} | {timestamp}')
|
||||
80
scripts/install-usb.sh
Executable file
80
scripts/install-usb.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# DESTRUCTIVE: explicitly invoked, strongly confirmed, never automatic.
|
||||
set -Eeuo pipefail
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
DEVICE=${1:-}
|
||||
ISO=${2:-}
|
||||
if [[ ! "$DEVICE" =~ ^/dev/[a-zA-Z0-9/_-]+$ || ! -f "$ISO" ]]; then
|
||||
echo "Usage: sudo bash scripts/install-usb.sh /dev/sdb /absolute/path/to/pc-intake.iso" >&2; exit 2
|
||||
fi
|
||||
[[ $EUID -eq 0 ]] || { echo 'Run as root.' >&2; exit 2; }
|
||||
for cmd in lsblk blkid sgdisk mkfs.vfat mkfs.ext4 grub-install mount umount findmnt wipefs dd sync awk bsdtar blockdev partprobe udevadm; do
|
||||
command -v "$cmd" >/dev/null || { echo "Missing: $cmd" >&2; exit 2; }
|
||||
done
|
||||
DEVICE=$(readlink -f "$DEVICE")
|
||||
[[ -b $DEVICE && $(lsblk -dn -o TYPE "$DEVICE") == disk ]] || { echo 'Target must be an entire block disk.' >&2; exit 2; }
|
||||
[[ $(lsblk -dn -o TRAN "$DEVICE" | tr -d ' ') == usb ]] || { echo 'Refusing a device that is not identified as USB transport.' >&2; exit 2; }
|
||||
root_source=$(findmnt -n -o SOURCE / || true)
|
||||
root_disk=$(lsblk -no PKNAME "$root_source" 2>/dev/null | head -1 || true)
|
||||
[[ -z "$root_disk" || "/dev/$root_disk" != "$DEVICE" ]] || { echo 'Refusing root system disk.' >&2; exit 2; }
|
||||
if lsblk -nr -o MOUNTPOINT "$DEVICE" | grep -q '[^[:space:]]'; then
|
||||
echo 'USB contains mounted partitions; unmount them before continuing.' >&2; lsblk "$DEVICE"; exit 2
|
||||
fi
|
||||
size_bytes=$(blockdev --getsize64 "$DEVICE")
|
||||
((size_bytes >= 16*1024*1024*1024)) || { echo 'USB must be at least 16 GiB.' >&2; exit 2; }
|
||||
iso_bytes=$(stat -c %s "$ISO")
|
||||
((iso_bytes < 4294967295)) || { echo 'ISO exceeds the FAT32 single-file limit.' >&2; exit 2; }
|
||||
model=$(lsblk -dn -o MODEL "$DEVICE" | xargs)
|
||||
serial=$(lsblk -dn -o SERIAL "$DEVICE" | xargs)
|
||||
echo '===== DESTRUCTIVE USB INSTALL ====='
|
||||
echo "Device: $DEVICE Model: $model Serial: $serial"
|
||||
echo "Capacity: $(lsblk -dn -o SIZE "$DEVICE") ISO: $ISO"
|
||||
echo 'ALL PARTITIONS AND DATA ON THIS DEVICE WILL BE ERASED.'
|
||||
read -rp "Type exactly ERASE $DEVICE to continue: " answer
|
||||
[[ "$answer" == "ERASE $DEVICE" ]] || { echo 'Cancelled. Nothing erased.'; exit 1; }
|
||||
# GPT: p1 BIOS boot (2MiB), p2 ESP FAT32 (7GiB), p3 ext4 remainder.
|
||||
sgdisk --zap-all "$DEVICE"
|
||||
wipefs -a "$DEVICE" >/dev/null
|
||||
sgdisk -n 1:2048:+2M -t 1:ef02 -c 1:BIOS_GRUB "$DEVICE"
|
||||
sgdisk -n 2:0:+7G -t 2:ef00 -c 2:INTAKE_BOOT "$DEVICE"
|
||||
sgdisk -n 3:0:0 -t 3:8300 -c 3:INTAKE_DATA "$DEVICE"
|
||||
partprobe "$DEVICE" || true
|
||||
udevadm settle
|
||||
if [[ "$DEVICE" =~ [0-9]$ ]]; then p2="${DEVICE}p2"; p3="${DEVICE}p3"; else p2="${DEVICE}2"; p3="${DEVICE}3"; fi
|
||||
for i in {1..10}; do [[ -b "$p3" ]] && break; sleep 1; done
|
||||
[[ -b "$p3" ]] || { echo 'Partitions not visible after repartition.' >&2; exit 1; }
|
||||
mkfs.vfat -F32 -n INTAKE_BOOT "$p2"
|
||||
mkfs.ext4 -F -L INTAKE_DATA "$p3"
|
||||
TMP=$(mktemp -d)
|
||||
cleanup(){ umount "$TMP/boot" 2>/dev/null || true; umount "$TMP/data" 2>/dev/null || true; rm -rf "$TMP"; }
|
||||
trap cleanup EXIT
|
||||
mkdir -p "$TMP/boot" "$TMP/data"
|
||||
mount "$p2" "$TMP/boot"; mount "$p3" "$TMP/data"
|
||||
mkdir -p "$TMP/boot/boot/grub" "$TMP/data/reports" "$TMP/data/database" "$TMP/data/checklists" "$TMP/data/reference"
|
||||
cp "$ISO" "$TMP/boot/pc-intake.iso"
|
||||
# Copy video reference files separately under INTAKE_DATA/reference (optional).
|
||||
cat > "$TMP/boot/boot/grub/grub.cfg" <<'GRUBCFG'
|
||||
set timeout=5
|
||||
set default=0
|
||||
menuentry 'PC Intake Live USB (offline technician mode)' {
|
||||
search --no-floppy --label INTAKE_BOOT --set=root
|
||||
set isofile='/pc-intake.iso'
|
||||
loopback loop $isofile
|
||||
linux (loop)/arch/boot/x86_64/vmlinuz-linux img_dev=/dev/disk/by-label/INTAKE_BOOT img_loop=$isofile archisobasedir=arch loglevel=3
|
||||
initrd (loop)/arch/boot/x86_64/initramfs-linux.img
|
||||
}
|
||||
menuentry 'Reboot' { reboot }
|
||||
menuentry 'Power off' { halt }
|
||||
GRUBCFG
|
||||
# Ensure expected kernel layout exists *in the built ISO* before proceeding.
|
||||
if ! bsdtar -tf "$ISO" 2>/dev/null | grep -qx 'arch/boot/x86_64/vmlinuz-linux'; then
|
||||
echo 'ISO layout does not match GRUB config (kernel missing). Aborting bootloader setup.' >&2; exit 1
|
||||
fi
|
||||
if ! bsdtar -tf "$ISO" 2>/dev/null | grep -qx 'arch/boot/x86_64/initramfs-linux.img'; then
|
||||
echo 'ISO layout does not match GRUB config (initramfs missing).' >&2; exit 1
|
||||
fi
|
||||
grub-install --target=x86_64-efi --efi-directory="$TMP/boot" --boot-directory="$TMP/boot/boot" --removable --no-nvram --recheck
|
||||
grub-install --target=i386-pc --boot-directory="$TMP/boot/boot" --recheck "$DEVICE"
|
||||
sync
|
||||
echo "USB is prepared. Results will be saved to $p3 (INTAKE_DATA)."
|
||||
echo 'Unmount complete; boot a test PC with UEFI or legacy BIOS support.'
|
||||
28
scripts/intake-session.sh
Executable file
28
scripts/intake-session.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
DATA=/mnt/intake-data
|
||||
mkdir -p "$DATA"
|
||||
if ! mountpoint -q "$DATA"; then
|
||||
disk=$(blkid -L INTAKE_DATA || true)
|
||||
if [[ -z "$disk" ]]; then echo 'FATAL: INTAKE_DATA partition missing. No scan will run.'; exec bash; fi
|
||||
mount -o rw,nosuid,nodev "$disk" "$DATA" || { echo 'FATAL: cannot mount persistent storage'; exec bash; }
|
||||
fi
|
||||
# First boot: provision Inventory config to the persistent data partition.
|
||||
mkdir -p "$DATA/config"
|
||||
if [[ -d /etc/csr-intake ]]; then
|
||||
for f in inventory.json inventory-api-key; do
|
||||
if [[ -f "/etc/csr-intake/$f" && ! -e "$DATA/config/$f" ]]; then
|
||||
install -m600 "/etc/csr-intake/$f" "$DATA/config/$f"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
export XDG_RUNTIME_DIR=/run/pc-intake-runtime
|
||||
mkdir -p "$XDG_RUNTIME_DIR"
|
||||
chmod 0700 "$XDG_RUNTIME_DIR"
|
||||
export WLR_RENDERER_ALLOW_SOFTWARE=1
|
||||
if command -v cage >/dev/null && [[ -x /usr/local/bin/intake-gui ]]; then
|
||||
echo "Launching graphical intake station..."
|
||||
cage -s -- /usr/local/bin/intake-gui --data "$DATA"
|
||||
echo "Graphical interface exited. Starting terminal fallback." >&2
|
||||
fi
|
||||
exec /usr/local/bin/intake-controller --data "$DATA"
|
||||
424
scripts/inventory-sync.py
Normal file
424
scripts/inventory-sync.py
Normal file
@@ -0,0 +1,424 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline-first bridge with atomic server-side hardware registration.
|
||||
Stdlib only; server assigns numbers and immutable reports stay unchanged.
|
||||
"""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import time
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import ssl
|
||||
import socket
|
||||
import uuid
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_DATA = Path('/mnt/intake-data')
|
||||
|
||||
def identity_value(value):
|
||||
value = str(value or '').strip().casefold()
|
||||
if value in ('', 'unknown', 'none', 'default string', 'to be filled by o.e.m.',
|
||||
'not specified', 'system serial number', 'not applicable', 'n/a'):
|
||||
return ''
|
||||
if not value.strip('0f- '):
|
||||
return ''
|
||||
return value
|
||||
|
||||
def hardware_id(report):
|
||||
raw_uuid = identity_value(report.get('system_uuid'))
|
||||
try:
|
||||
token = uuid.UUID(raw_uuid)
|
||||
if token.int not in (0, (1 << 128) - 1):
|
||||
return 'dmi-uuid:' + str(token)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
serial = identity_value(report.get('serial_number'))
|
||||
vendor = identity_value(report.get('manufacturer'))
|
||||
if serial and vendor:
|
||||
payload = json.dumps([vendor, serial], ensure_ascii=True, separators=(',', ':'))
|
||||
return 'dmi-serial-sha256:' + hashlib.sha256(payload.encode()).hexdigest()
|
||||
raise ValueError('Keine zuverlässige Hardware-Kennung im Bericht. Mit der aktuellen USB-Version Hardware erneut erfassen; alte Berichte ohne Kennung werden nicht automatisch zugeordnet.')
|
||||
|
||||
def hardware_aliases(report):
|
||||
primary = hardware_id(report)
|
||||
aliases = [primary]
|
||||
try:
|
||||
serial = hardware_id({**report, 'system_uuid': ''})
|
||||
if serial not in aliases:
|
||||
aliases.append(serial)
|
||||
except ValueError:
|
||||
pass
|
||||
return aliases
|
||||
|
||||
def device_kind(report):
|
||||
chassis = str(report.get('chassis_type', ''))
|
||||
if chassis == '13':
|
||||
return 'AiO'
|
||||
if chassis in ('8', '9', '10', '11', '14', '30', '31', '32') or report.get('batteries'):
|
||||
return 'Laptop'
|
||||
return 'Desktop'
|
||||
|
||||
def map_fields(report):
|
||||
values = {}
|
||||
for name in ('cpu', 'model'):
|
||||
value = report.get(name)
|
||||
if isinstance(value, str) and value.strip() and value.upper() != 'UNKNOWN':
|
||||
values[name] = value.strip()[:200]
|
||||
# Physically installed RAM, not Linux's usable memory (affected by iGPU reservations).
|
||||
ram = report.get('installed_ram_gib')
|
||||
if isinstance(ram, (int, float)) and not isinstance(ram, bool) and 1 <= ram <= 2048:
|
||||
values['ram_gb'] = round(ram)
|
||||
disks = report.get('disks') or []
|
||||
ssd, hdd = [], []
|
||||
for disk in disks:
|
||||
if disk.get('removable') is not False:
|
||||
continue
|
||||
gib = disk.get('size_gib')
|
||||
if not isinstance(gib, (int, float)) or isinstance(gib, bool) or not 1 <= gib <= 100000:
|
||||
continue
|
||||
# The kernel rotating flag is not reliable on USB drives; USB disks are excluded above.
|
||||
(hdd if disk.get('rotational') is True else ssd if disk.get('rotational') is False else []).append(gib)
|
||||
if ssd:
|
||||
values['ssd_gb'] = round(sum(ssd) * (1024 ** 3) / 1e9)
|
||||
if hdd:
|
||||
values['hdd_gb'] = round(sum(hdd) * (1024 ** 3) / 1e9)
|
||||
return values
|
||||
|
||||
def init_db(db):
|
||||
db.execute('PRAGMA busy_timeout=10000')
|
||||
db.execute('CREATE TABLE IF NOT EXISTS device_links (hwid TEXT PRIMARY KEY, inventory_number INTEGER NOT NULL)')
|
||||
db.execute('CREATE TABLE IF NOT EXISTS inventory_queue (report_sha256 TEXT PRIMARY KEY, report_path TEXT NOT NULL, asset_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT \'PENDING\', attempts INTEGER NOT NULL DEFAULT 0, last_attempt INTEGER, next_attempt INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT \'\')')
|
||||
db.execute('CREATE TABLE IF NOT EXISTS inventory_sync (report_sha256 TEXT PRIMARY KEY, asset_id TEXT NOT NULL, inventory_number INTEGER NOT NULL, status TEXT NOT NULL, last_attempt_utc TEXT, response TEXT)')
|
||||
|
||||
def api_request(base, key, method, suffix, payload=None, timeout=12, ca_file=None):
|
||||
body = None if payload is None else json.dumps(payload).encode('utf-8')
|
||||
url = base.rstrip('/') + '/api/v1/' + suffix.lstrip('/')
|
||||
request = urllib.request.Request(url, data=body, method=method, headers={
|
||||
'Authorization': 'Api-Key ' + key,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'pc-intake-usb/1.4',
|
||||
})
|
||||
context = ssl.create_default_context()
|
||||
if ca_file:
|
||||
context.load_verify_locations(cafile=str(ca_file))
|
||||
with urllib.request.urlopen(request, timeout=timeout, context=context) as res:
|
||||
return json.loads(res.read(1024 * 1024).decode('utf-8'))
|
||||
|
||||
def credentials(config):
|
||||
base = config.get('url', '')
|
||||
if not base: raise ValueError('Inventory URL is not configured; reports remain offline')
|
||||
parsed = urllib.parse.urlparse(base)
|
||||
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError('Inventory URL must be an HTTPS origin (e.g. https://inventory.example.org)')
|
||||
key = os.environ.get('CSR_API_KEY', '')
|
||||
if not key:
|
||||
secret = Path(config.get('key_file', '/mnt/intake-data/config/inventory-api-key'))
|
||||
key = secret.read_text(encoding='utf-8').strip()
|
||||
if not key:
|
||||
raise ValueError('No API key configured')
|
||||
return base, key
|
||||
|
||||
def connection_error(ex):
|
||||
# Never include server responses, request headers, or raw URLs in errors.
|
||||
if isinstance(ex, urllib.error.HTTPError):
|
||||
hints = {401: 'API key missing, expired or invalid', 403: 'API key lacks permission',
|
||||
404: 'Inventory API endpoint not found. Server update required for hardware registration', 409: 'Hardware identity or revision conflict. Check existing or archived records on the server'}
|
||||
return f'HTTP {ex.code}: {hints.get(ex.code, "Inventory server rejected the request")}'
|
||||
reason = ex.reason if isinstance(ex, urllib.error.URLError) else ex
|
||||
if isinstance(reason, ssl.SSLCertVerificationError):
|
||||
return 'TLS certificate verification failed. Check config/inventory-server.crt, server address and system clock.'
|
||||
if isinstance(reason, ssl.SSLError):
|
||||
return 'TLS connection failed. Check the server HTTPS configuration.'
|
||||
if isinstance(reason, socket.gaierror):
|
||||
return 'DNS lookup failed. Check Wi-Fi, DNS and inventory server address.'
|
||||
if isinstance(reason, (TimeoutError, socket.timeout)):
|
||||
return 'Connection timed out. Connect Wi-Fi and check the inventory server is reachable.'
|
||||
if isinstance(reason, ConnectionRefusedError):
|
||||
return 'Connection refused. Check the inventory server address, port and service.'
|
||||
if isinstance(ex, urllib.error.URLError):
|
||||
return 'Inventory server unreachable. Check Wi-Fi, network routing and server address.'
|
||||
if isinstance(ex, FileNotFoundError):
|
||||
return 'Inventory API key or certificate file is missing. Check the files in config/.'
|
||||
if isinstance(ex, ValueError):
|
||||
return str(ex)[:200]
|
||||
return type(ex).__name__
|
||||
|
||||
def check_connection(config):
|
||||
base, key = credentials(config)
|
||||
tls = {'ca_file': config['ca_file']} if config.get('ca_file') else {}
|
||||
api_request(base, key, 'GET', 'choices/', **tls)
|
||||
return 'Inventory connection OK: HTTPS certificate and API key verified. No records uploaded.'
|
||||
|
||||
def sync_one(report_path, config, db, dry_run=False):
|
||||
raw = report_path.read_bytes()
|
||||
sha = hashlib.sha256(raw).hexdigest()
|
||||
report = json.loads(raw)
|
||||
asset = report['asset_id']
|
||||
hwid = hardware_id(report)
|
||||
values = map_fields(report)
|
||||
done = db.execute('SELECT status FROM inventory_sync WHERE report_sha256=?', (sha,)).fetchone()
|
||||
if done and done[0] == 'SYNCED':
|
||||
row = db.execute('SELECT inventory_number FROM inventory_sync WHERE report_sha256=?', (sha,)).fetchone()
|
||||
return 'ALREADY_SYNCED', row[0]
|
||||
if not values:
|
||||
return 'NO_VERIFIED_FIELDS', None
|
||||
if dry_run:
|
||||
print(json.dumps({'hwid': hwid, 'number': 'assigned by server', 'changes': values}, indent=2, ensure_ascii=False))
|
||||
return 'DRY_RUN', None
|
||||
base, key = credentials(config)
|
||||
tls = {'ca_file': config['ca_file']} if config.get('ca_file') else {}
|
||||
# Resolve atomically on the server; local asset labels never select a server record.
|
||||
current = api_request(base, key, 'POST', 'devices/resolve/',
|
||||
{'hwid': hwid, 'aliases': hardware_aliases(report), 'device_kind': device_kind(report), 'model': values.get('model', '')}, **tls)
|
||||
number = current.get('number')
|
||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||
raise ValueError('Server did not return a valid inventory number')
|
||||
with db:
|
||||
db.execute('INSERT INTO device_links(hwid,inventory_number) VALUES(?,?) ON CONFLICT(hwid) DO UPDATE SET inventory_number=excluded.inventory_number', (hwid, number))
|
||||
revision = current.get('revision')
|
||||
if not isinstance(revision, int) or revision < 1:
|
||||
raise ValueError('Server did not return a valid revision')
|
||||
# Reject suspicious identity collisions instead of overwriting another model.
|
||||
existing_model = (current.get('model') or '').strip()
|
||||
if existing_model and values.get('model') and existing_model.casefold() != values['model'].casefold():
|
||||
raise ValueError(f'MODEL_MISMATCH: inventory #{number} is {existing_model!r}; scan is {values["model"]!r}')
|
||||
# Avoid unnecessary revisions when all mapped values already match.
|
||||
actual = {k: v for k, v in values.items() if current.get(k) != v}
|
||||
if current.get('stage') not in (None, 'INTAKE'): actual = {}
|
||||
if not actual:
|
||||
status = 'SYNCED'
|
||||
else:
|
||||
updated = api_request(base, key, 'PATCH', f'devices/{number}/', {**actual, 'expected_revision': revision}, **tls)
|
||||
if not isinstance(updated.get('revision'), int) or updated['revision'] <= revision:
|
||||
raise ValueError('Server did not acknowledge the new revision; retry will recheck current values')
|
||||
status = 'SYNCED'
|
||||
now = dt.datetime.now(dt.timezone.utc).isoformat()
|
||||
with db:
|
||||
db.execute('INSERT INTO inventory_sync(report_sha256,asset_id,inventory_number,status,last_attempt_utc,response) VALUES(?,?,?,?,?,?) ON CONFLICT(report_sha256) DO UPDATE SET status=excluded.status,last_attempt_utc=excluded.last_attempt_utc,response=excluded.response', (sha, asset, number, status, now, 'OK'))
|
||||
return status, number
|
||||
|
||||
def discover_queue(root, db):
|
||||
"""Discover immutable reports even when no inventory connection is configured."""
|
||||
for path in sorted((root / 'reports').glob('*/*/report.json')):
|
||||
if not path.resolve().is_relative_to((root / 'reports').resolve()):
|
||||
continue
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
report = json.loads(raw)
|
||||
asset = report['asset_id']
|
||||
sha = hashlib.sha256(raw).hexdigest()
|
||||
done = db.execute('SELECT status FROM inventory_sync WHERE report_sha256=?', (sha,)).fetchone()
|
||||
with db:
|
||||
db.execute('INSERT OR IGNORE INTO inventory_queue(report_sha256,report_path,asset_id,status) VALUES(?,?,?,?)', (sha, str(path.resolve()), asset, 'SYNCED' if done and done[0] == 'SYNCED' else 'PENDING'))
|
||||
except (OSError, ValueError, KeyError):
|
||||
continue
|
||||
|
||||
def process_queue(root, db, config, retry=False, selected=None, retry_seconds=60):
|
||||
discover_queue(root, db)
|
||||
failures = 0
|
||||
rows = db.execute("SELECT report_sha256, report_path, attempts, next_attempt FROM inventory_queue WHERE status != 'SYNCED' ORDER BY report_path").fetchall()
|
||||
for sha, raw_path, attempts, next_attempt in rows:
|
||||
path = Path(raw_path)
|
||||
if selected and path.resolve() != selected.resolve():
|
||||
continue
|
||||
if not retry and next_attempt > int(time.time()):
|
||||
continue
|
||||
stamp = int(time.time())
|
||||
# Persist before the request; interrupted uploads can be retried with the same report hash.
|
||||
with db:
|
||||
db.execute("UPDATE inventory_queue SET status='UPLOADING', attempts=attempts+1, last_attempt=? WHERE report_sha256=?", (stamp, sha))
|
||||
try:
|
||||
if not path.resolve().is_relative_to((root / 'reports').resolve()):
|
||||
raise ValueError('Report path outside reports directory')
|
||||
if hashlib.sha256(path.read_bytes()).hexdigest() != sha:
|
||||
raise ValueError('Queued report changed; original upload cancelled')
|
||||
result, number = sync_one(path, config, db)
|
||||
if result not in ('SYNCED', 'ALREADY_SYNCED'):
|
||||
raise ValueError(result)
|
||||
with db:
|
||||
db.execute("UPDATE inventory_queue SET status='SYNCED', error='', next_attempt=0 WHERE report_sha256=?", (sha,))
|
||||
if db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='scans'").fetchone():
|
||||
db.execute("UPDATE scans SET sync_status='SYNCED', synced_inventory_id=? WHERE report_path=?", (str(number), str(path)))
|
||||
print(f'{path.parent.parent.name}: SYNCED -> #{number}')
|
||||
except (OSError, ValueError, KeyError, urllib.error.URLError) as ex:
|
||||
failures += 1
|
||||
# Error strings deliberately exclude URLs, API keys and raw server responses.
|
||||
error = connection_error(ex)
|
||||
delay = min(3600, retry_seconds * (2 ** min(attempts, 6)))
|
||||
with db:
|
||||
db.execute("UPDATE inventory_queue SET status='ERROR', error=?, next_attempt=? WHERE report_sha256=?", (error, stamp + delay, sha))
|
||||
print(f'{path.parent.parent.name}: {error}', file=sys.stderr)
|
||||
return failures
|
||||
|
||||
def station_identity():
|
||||
def read(name):
|
||||
for folder in ['/sys/class/dmi/id', '/run/csr-intake']:
|
||||
try:
|
||||
value = (Path(folder)/name).read_text().strip()
|
||||
if value: return value
|
||||
except OSError: pass
|
||||
return ''
|
||||
return {'system_uuid':read('product_uuid'),'serial_number':read('product_serial'),
|
||||
'manufacturer':read('sys_vendor'),'model':read('product_name'),'chassis_type':read('chassis_type')}
|
||||
|
||||
def station_config(root):
|
||||
config=json.loads((root/'config/inventory.json').read_text())
|
||||
config.setdefault('key_file',str(root/'config/inventory-api-key'))
|
||||
if (root/'config/inventory-server.crt').exists():config.setdefault('ca_file',str(root/'config/inventory-server.crt'))
|
||||
return config
|
||||
|
||||
def station_api(config,method,suffix,payload=None):
|
||||
base,key=credentials(config)
|
||||
return api_request(base,key,method,suffix,payload,**({'ca_file':config['ca_file']} if config.get('ca_file') else {}))
|
||||
|
||||
def station_resolve(config,report):
|
||||
return station_api(config,'POST','devices/resolve/',{'hwid':hardware_id(report),'aliases':hardware_aliases(report),
|
||||
'device_kind':device_kind(report),'model':report.get('model','')})
|
||||
|
||||
def station_save(path,value):
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
tmp=path.with_suffix('.new')
|
||||
with tmp.open('w') as f:
|
||||
json.dump(value,f,ensure_ascii=False);f.flush();os.fsync(f.fileno())
|
||||
os.replace(tmp,path)
|
||||
|
||||
def queue_inspection(root,path,phase):
|
||||
inspection=json.loads(path.read_text())
|
||||
ident=inspection['identity']
|
||||
identity={'system_uuid':ident.get('uuid'),'serial_number':ident.get('serial'),
|
||||
'manufacturer':ident.get('manufacturer'),'model':ident.get('model')}
|
||||
# An immutable report provides chassis information, not the USB host's hardware.
|
||||
if inspection.get('report'): identity.update({k:inspection['report'].get(k,identity.get(k)) for k in ['system_uuid','serial_number','manufacturer','model','chassis_type','batteries']})
|
||||
results={k:{'outcome':v['outcome'],'notes':v.get('notes',''),'history':v.get('history',[]),'evidence':v.get('evidence','')} for k,v in inspection['checks'].items()}
|
||||
body={'hwid':hardware_id(identity),'phase':phase,'technician':inspection.get('technician',''),
|
||||
'notes':inspection.get('notes',''),'results':results}
|
||||
digest=hashlib.sha256(json.dumps(body,sort_keys=True).encode()).hexdigest()
|
||||
body['request_id']=str(uuid.uuid5(uuid.NAMESPACE_URL,'csr-inspection:'+inspection['asset']+':'+str(inspection['revision'])+':'+str(inspection['updated'])+':'+digest))
|
||||
file=root/'outbox/inspections'/f"{inspection['updated']:020}-{inspection['revision']:020}-{body['request_id']}.json"
|
||||
receipt=root/'outbox/sent'/file.name
|
||||
if not file.exists() and not receipt.exists():station_save(file,{'identity':identity,'body':body})
|
||||
return file
|
||||
|
||||
def flush_inspections(root,config):
|
||||
latest=None
|
||||
for path in sorted((root/'outbox/inspections').glob('*.json')):
|
||||
event=json.loads(path.read_text());device=station_resolve(config,event['identity'])
|
||||
body={**event['body'],'expected_revision':device['revision']}
|
||||
latest=station_api(config,'POST',f"devices/{device['number']}/inspection/",body)
|
||||
done=root/'outbox/sent'/path.name;done.parent.mkdir(parents=True,exist_ok=True)
|
||||
os.replace(path,done)
|
||||
return latest
|
||||
|
||||
def run_station(root,action,report_path):
|
||||
# Recover the last saved snapshot even if power was lost before a worker queued it.
|
||||
# These files may belong to a different PC previously visited by this USB.
|
||||
if action in ['boot','flush']:
|
||||
for saved in sorted((root/'inspections').glob('*.json')):
|
||||
inspection=json.loads(saved.read_text())
|
||||
if inspection.get('sync_phase'):
|
||||
queue_inspection(root,saved,inspection['sync_phase'])
|
||||
# Enqueue before reading config/network so interruption and offline use preserve results.
|
||||
if action in ['start','progress','complete','queue']:
|
||||
if not report_path:raise ValueError('Inspection file is required')
|
||||
phase={'start':'STARTED','progress':'IN_PROGRESS','complete':'COMPLETE'}.get(action)
|
||||
if phase is None: phase=json.loads(report_path.read_text()).get('sync_phase') or 'IN_PROGRESS'
|
||||
queue_inspection(root,report_path,phase)
|
||||
if action=='queue':
|
||||
print(json.dumps({'queued':True}))
|
||||
return 0
|
||||
(root/'database').mkdir(parents=True,exist_ok=True)
|
||||
config=station_config(root)
|
||||
if action in ['start','progress','complete','flush']:
|
||||
flush_inspections(root,config)
|
||||
device=station_resolve(config,station_identity())
|
||||
else:
|
||||
report=json.loads(report_path.read_text()) if report_path else station_identity()
|
||||
device=station_resolve(config,report)
|
||||
if action=='intake' and device['stage']=='INTAKE':
|
||||
device=station_api(config,'POST',f"devices/{device['number']}/intake/",{
|
||||
'hwid':hardware_id(report),'expected_revision':device['revision'],'specifications':map_fields(report)})
|
||||
if action == 'intake' and report_path:
|
||||
raw=report_path.read_bytes(); sha=hashlib.sha256(raw).hexdigest()
|
||||
with sqlite3.connect(root/'database/intake.sqlite3') as db:
|
||||
init_db(db);discover_queue(root,db)
|
||||
db.execute("INSERT OR REPLACE INTO inventory_sync(report_sha256,asset_id,inventory_number,status,last_attempt_utc,response) VALUES(?,?,?,'SYNCED',?,'OK')",(sha,report.get('asset_id',''),device['number'],dt.datetime.now(dt.timezone.utc).isoformat()))
|
||||
db.execute("UPDATE inventory_queue SET status='SYNCED',error='',next_attempt=0 WHERE report_sha256=?",(sha,))
|
||||
if db.execute("SELECT name FROM sqlite_master WHERE name='scans'").fetchone():
|
||||
db.execute("UPDATE scans SET sync_status='SYNCED',synced_inventory_id=? WHERE report_path=?",(str(device['number']),str(report_path)))
|
||||
result={'device':device,'pending':len(list((root/'outbox/inspections').glob('*.json')))}
|
||||
station_save(root/'database/station-device.json',result)
|
||||
print(json.dumps(result))
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Offline inventory queue with server-assigned device numbers')
|
||||
parser.add_argument('--data', type=Path, default=DEFAULT_DATA)
|
||||
parser.add_argument('--station', choices=['boot','intake','start','progress','complete','flush','queue'])
|
||||
parser.add_argument('--identity-json', help=argparse.SUPPRESS)
|
||||
parser.add_argument('--report', type=Path)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
parser.add_argument('--list', action='store_true', help='Discover reports and print queue as JSON; no network requests')
|
||||
parser.add_argument('--check', action='store_true', help='Verify TLS and API authentication without uploading')
|
||||
parser.add_argument('--retry', action='store_true', help='Retry errors immediately, ignoring backoff')
|
||||
args = parser.parse_args()
|
||||
if args.identity_json:
|
||||
try:
|
||||
hwid = hardware_id(json.loads(args.identity_json))
|
||||
print(json.dumps({'hwid': hwid, 'local_id': 'HW-' + hashlib.sha256(hwid.encode()).hexdigest()[:24]}))
|
||||
return 0
|
||||
except ValueError as ex:
|
||||
print(str(ex), file=sys.stderr)
|
||||
return 2
|
||||
root = args.data.resolve()
|
||||
(root / 'database').mkdir(parents=True, exist_ok=True)
|
||||
# Cross-process lock prevents GUI/CLI concurrent PATCH requests.
|
||||
with (root / 'database' / 'inventory-sync.lock').open('a') as lock:
|
||||
try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
print('Another upload worker is running', file=sys.stderr)
|
||||
return 2
|
||||
if args.station:
|
||||
try: return run_station(root,args.station,args.report)
|
||||
except (OSError,ValueError,KeyError) as ex:
|
||||
print(connection_error(ex),file=sys.stderr)
|
||||
return 2
|
||||
with sqlite3.connect(root / 'database' / 'intake.sqlite3', timeout=20) as db:
|
||||
init_db(db)
|
||||
discover_queue(root, db)
|
||||
if args.list:
|
||||
db.row_factory = sqlite3.Row
|
||||
print(json.dumps([dict(row) for row in db.execute('SELECT q.*, s.inventory_number FROM inventory_queue q LEFT JOIN inventory_sync s USING(report_sha256) ORDER BY q.last_attempt DESC, q.report_path')]))
|
||||
return 0
|
||||
if args.dry_run:
|
||||
paths = [args.report] if args.report else sorted((root / 'reports').glob('*/*/report.json'))
|
||||
for path in paths: sync_one(path, {}, db, True)
|
||||
return 0
|
||||
config_path = root / 'config' / 'inventory.json'
|
||||
try:
|
||||
config = json.loads(config_path.read_text()) if config_path.exists() else {}
|
||||
config.setdefault('key_file', str(root / 'config' / 'inventory-api-key'))
|
||||
cert = root / 'config' / 'inventory-server.crt'
|
||||
if cert.exists():
|
||||
config.setdefault('ca_file', str(cert))
|
||||
if args.check:
|
||||
print(check_connection(config))
|
||||
return 0
|
||||
settings_path = root / 'config' / 'workshop.json'
|
||||
settings = json.loads(settings_path.read_text()) if settings_path.exists() else {}
|
||||
delay = max(10, min(3600, int(settings.get('retry_seconds', 60))))
|
||||
return 1 if process_queue(root, db, config, args.retry, args.report, delay) else 0
|
||||
except (OSError, ValueError, KeyError) as ex:
|
||||
print(connection_error(ex), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
78
scripts/release-manifest.py
Normal file
78
scripts/release-manifest.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source fingerprint and artifact verification; never touches USB devices."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tomllib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMPONENTS = {'gui': 'intake-gui', 'scanner': 'pc-intake', 'controller': 'intake-controller'}
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
def source_id():
|
||||
paths = []
|
||||
for component in COMPONENTS:
|
||||
base = ROOT / component
|
||||
paths.extend((base / 'src').rglob('*.rs'))
|
||||
paths.extend(p for p in [base / 'Cargo.toml', base / 'Cargo.lock', base / 'build.rs'] if p.exists())
|
||||
for folder in ['scripts', 'live']:
|
||||
paths.extend(p for p in (ROOT / folder).iterdir() if p.is_file())
|
||||
h = hashlib.sha256()
|
||||
for path in sorted(paths):
|
||||
h.update(str(path.relative_to(ROOT)).encode() + b'\0')
|
||||
h.update(path.read_bytes())
|
||||
return 'csr-' + h.hexdigest()[:20]
|
||||
|
||||
def create(path):
|
||||
binaries = {}
|
||||
for component, name in COMPONENTS.items():
|
||||
package = tomllib.loads((ROOT / component / 'Cargo.toml').read_text())['package']
|
||||
binary = ROOT / component / 'target/release' / name
|
||||
binaries[name] = {'version': package['version'], 'sha256': digest(binary)}
|
||||
build_id = source_id()
|
||||
version = subprocess.check_output([ROOT / 'gui/target/release/intake-gui', '--version'], text=True).strip()
|
||||
if build_id not in version:
|
||||
raise ValueError('GUI source fingerprint does not match current sources; rebuild release')
|
||||
manifest = {'schema_version': 1, 'build_id': build_id, 'rustc': subprocess.check_output(['rustc', '--version'], text=True).strip(), 'binaries': binaries}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
|
||||
def verify(profile):
|
||||
root = profile / 'airootfs'
|
||||
manifest = json.loads((root / 'etc/csr-intake/release.json').read_text())
|
||||
for name, spec in manifest['binaries'].items():
|
||||
binary = root / 'usr/local/bin' / name
|
||||
if digest(binary) != spec['sha256']:
|
||||
raise ValueError(f'Binary hash mismatch: {name}')
|
||||
if not binary.stat().st_mode & 0o111:
|
||||
raise ValueError(f'Binary is not executable: {name}')
|
||||
version = subprocess.check_output([root / 'usr/local/bin/intake-gui', '--version'], text=True)
|
||||
if manifest['build_id'] not in version:
|
||||
raise ValueError('GUI build ID differs from release manifest')
|
||||
required = ['networkmanager', 'curl', 'ca-certificates', 'python', 'usbutils', 'smartmontools', 'alsa-utils', 'ffmpeg', 'stress-ng', 'xdg-utils', 'firefox']
|
||||
packages = (profile / 'packages.x86_64').read_text().splitlines()
|
||||
for package in required:
|
||||
if package not in packages:
|
||||
raise ValueError(f'Missing live package: {package}')
|
||||
for file in ['usr/local/bin/intake-login', 'usr/local/bin/intake-prepare', 'etc/systemd/system/getty@tty1.service.d/autologin.conf', 'etc/systemd/system/multi-user.target.wants/NetworkManager.service']:
|
||||
p = root / file
|
||||
if not p.exists() and not p.is_symlink():
|
||||
raise ValueError(f'Missing live startup file: {file}')
|
||||
print(f"Profile verified: {manifest['build_id']}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument('--source-id', action='store_true')
|
||||
group.add_argument('--create', type=Path)
|
||||
group.add_argument('--verify-profile', type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.source_id: print(source_id())
|
||||
elif args.create: create(args.create)
|
||||
else: verify(args.verify_profile)
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
9
scripts/report-view.py
Normal file
9
scripts/report-view.py
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
import json,sys
|
||||
r=json.load(open(sys.argv[1],encoding='utf8'))
|
||||
for k in ('asset_id','tool_version','model','cpu','ram_gib','health_gate'):
|
||||
print(f'{k}: {r.get(k,"UNKNOWN")}')
|
||||
print('Disks:')
|
||||
for d in r.get('disks',[]):print(' ',d.get('name'),d.get('model'),d.get('smart_health'))
|
||||
print('Warnings:')
|
||||
for c in r.get('concerns',[]):print(' -',c)
|
||||
Reference in New Issue
Block a user