224 lines
9.1 KiB
Bash
224 lines
9.1 KiB
Bash
# shellcheck shell=bash
|
|
#
|
|
# Shared config loader for every build script in tools/. Source it, call
|
|
# `core_load`, and every CORE_* variable is in scope.
|
|
#
|
|
# source "$(dirname "${BASH_SOURCE[0]}")/lib/coreconfig.sh"
|
|
# core_load # core values only
|
|
# core_load --kiosk door-panel # + that kiosk's own values
|
|
#
|
|
# WHY THIS EXISTS: before it, every builder carried its own copy of the container
|
|
# host's IP, the MQTT port, the identity token and half a dozen URLs. Six scripts, six
|
|
# chances to typo one of them, and the symptom was always the same — a kiosk that
|
|
# boots fine and then can't reach something, discovered after a 40-minute ISO build.
|
|
# Now no build script contains an address at all; they all read one file, and
|
|
# validate-config.py has already refused the build if that file is wrong.
|
|
#
|
|
# Nothing here is exported to child processes on purpose: build scripts substitute
|
|
# these into generated files explicitly, so it's always visible in the script which
|
|
# value went where.
|
|
|
|
CORE_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
CORE_CONFIG_PATH="${CORE_CONFIG_PATH:-${CORE_REPO_ROOT}/CoreSystemConfig.json}"
|
|
CORE_TOOLS_DIR="${CORE_REPO_ROOT}/tools"
|
|
|
|
core_die() { echo -e "\033[1;31m[error]\033[0m $*" >&2; exit 1; }
|
|
core_log() { echo -e "\n\033[1;34m==>\033[0m $*"; }
|
|
core_warn() { echo -e "\033[1;33m[warn]\033[0m $*" >&2; }
|
|
|
|
core_load() {
|
|
if [[ ! -f "$CORE_CONFIG_PATH" ]]; then
|
|
core_die "No CoreSystemConfig.json found at $CORE_CONFIG_PATH
|
|
|
|
Create one from the template and fill it in:
|
|
cp ${CORE_REPO_ROOT}/CoreSystemConfig.json.template ${CORE_REPO_ROOT}/CoreSystemConfig.json
|
|
\$EDITOR ${CORE_REPO_ROOT}/CoreSystemConfig.json
|
|
|
|
Then check it with:
|
|
${CORE_TOOLS_DIR}/validate-config.py
|
|
|
|
(Set CORE_CONFIG_PATH to build from a different file — useful for a second
|
|
household or a test config.)"
|
|
fi
|
|
|
|
command -v python3 >/dev/null 2>&1 || core_die "python3 is required to read CoreSystemConfig.json"
|
|
|
|
# Fill in any empty service tokens BEFORE validating, since the validator's whole
|
|
# job is to refuse a build with empty ones. Only fills blanks, never overwrites — see
|
|
# generate-tokens.py for why persisting them matters rather than generating per build.
|
|
core_log "Checking secrets"
|
|
python3 "${CORE_TOOLS_DIR}/generate-tokens.py" "$CORE_CONFIG_PATH" \
|
|
|| core_die "Could not generate tokens into $CORE_CONFIG_PATH"
|
|
|
|
# VALIDATE BEFORE ANYTHING ELSE. An ISO build is long and mostly unattended; the
|
|
# entire value of the validator evaporates if it runs after 40 minutes of debootstrap
|
|
# rather than before it. Warnings print but don't stop the build.
|
|
core_log "Validating $(basename "$CORE_CONFIG_PATH")"
|
|
if ! python3 "${CORE_TOOLS_DIR}/validate-config.py" "$CORE_CONFIG_PATH"; then
|
|
core_die "CoreSystemConfig.json has errors (above) — fix them before building.
|
|
Nothing was built and nothing was written."
|
|
fi
|
|
|
|
local exported
|
|
if ! exported="$(python3 "${CORE_TOOLS_DIR}/config-export.py" "$CORE_CONFIG_PATH" "$@")"; then
|
|
# config-export.py emits a shell `echo ... >&2; exit 1` on an unknown kiosk, so
|
|
# evaluating its output is what surfaces that message.
|
|
eval "$exported"
|
|
core_die "Could not read $CORE_CONFIG_PATH"
|
|
fi
|
|
# Every value is shlex.quote'd on the Python side, so a password containing quotes,
|
|
# spaces or semicolons survives this intact rather than becoming shell syntax.
|
|
eval "$exported"
|
|
}
|
|
|
|
# Load the one kiosk this builder is for.
|
|
# core_select_kiosk door-panel # the only door-panel in the config
|
|
# core_select_kiosk thin-client living # a specific one, when there are several
|
|
#
|
|
# Taking the hostname as an argument (rather than a per-script constant) is what lets
|
|
# one builder produce several images: two thin clients in two rooms are two entries in
|
|
# CoreSystemConfig.json, not two copies of a script with one line changed — which is
|
|
# how the addresses drifted apart in the first place.
|
|
core_select_kiosk() {
|
|
local want_type="$1" want_host="${2:-}"
|
|
local matches
|
|
matches="$(python3 - "$CORE_CONFIG_PATH" "$want_type" <<'PY'
|
|
import json, sys
|
|
cfg = json.load(open(sys.argv[1]))
|
|
print("\n".join(k["hostname"] for k in cfg.get("kiosks", []) if k.get("type") == sys.argv[2]))
|
|
PY
|
|
)"
|
|
|
|
if [[ -z "$matches" ]]; then
|
|
core_die "No kiosk of type '${want_type}' in $(basename "$CORE_CONFIG_PATH").
|
|
Add one to the \"kiosks\" list and re-run."
|
|
fi
|
|
|
|
if [[ -z "$want_host" ]]; then
|
|
if [[ "$(wc -l <<< "$matches")" -gt 1 ]]; then
|
|
core_die "Several '${want_type}' kiosks are configured — say which one:
|
|
$(sed 's/^/ /' <<< "$matches")
|
|
|
|
e.g. $0 $(head -1 <<< "$matches")"
|
|
fi
|
|
want_host="$matches"
|
|
elif ! grep -qx "$want_host" <<< "$matches"; then
|
|
core_die "'${want_host}' is not a configured ${want_type}. Available:
|
|
$(sed 's/^/ /' <<< "$matches")"
|
|
fi
|
|
|
|
core_load --kiosk "$want_host"
|
|
}
|
|
|
|
# Same idea as core_select_kiosk, for the headless audio endpoints — selected by
|
|
# architecture, since those are two genuinely different builders (live-build for the
|
|
# amd64 mini-PC, rpi-image-gen for the arm64 Pi) rather than one with a flag.
|
|
core_select_audio_endpoint() {
|
|
local want_arch="$1" want_host="${2:-}"
|
|
local matches
|
|
matches="$(python3 - "$CORE_CONFIG_PATH" "$want_arch" <<'PY'
|
|
import json, sys
|
|
cfg = json.load(open(sys.argv[1]))
|
|
print("\n".join(a["hostname"] for a in cfg.get("audio_endpoints", []) if a.get("arch") == sys.argv[2]))
|
|
PY
|
|
)"
|
|
|
|
if [[ -z "$matches" ]]; then
|
|
core_die "No ${want_arch} audio endpoint in $(basename "$CORE_CONFIG_PATH").
|
|
Add one to the \"audio_endpoints\" list and re-run."
|
|
fi
|
|
|
|
if [[ -z "$want_host" ]]; then
|
|
if [[ "$(wc -l <<< "$matches")" -gt 1 ]]; then
|
|
core_die "Several ${want_arch} audio endpoints are configured — say which one:
|
|
$(sed 's/^/ /' <<< "$matches")"
|
|
fi
|
|
want_host="$matches"
|
|
elif ! grep -qx "$want_host" <<< "$matches"; then
|
|
core_die "'${want_host}' is not a configured ${want_arch} audio endpoint. Available:
|
|
$(sed 's/^/ /' <<< "$matches")"
|
|
fi
|
|
|
|
core_load --audio-endpoint "$want_host"
|
|
}
|
|
|
|
# Deterministic identifier for a matched set of images, printed at build time and
|
|
# written into every image as /etc/smarthome-build. Two ISOs built from the same
|
|
# config carry the same PAIR_ID; if you ever end up holding a container-host ISO and
|
|
# an llm-host ISO and can't remember whether they agree on addresses and tokens,
|
|
# compare this and you know.
|
|
core_pair_id() {
|
|
local hash
|
|
hash="$(python3 - "$CORE_CONFIG_PATH" <<'PY'
|
|
import hashlib, json, sys
|
|
# Hash the SEMANTIC content, not the bytes: reformatting the JSON or reordering keys
|
|
# must not change the pair ID, because it didn't change what gets built.
|
|
cfg = json.loads(open(sys.argv[1]).read())
|
|
def strip(node):
|
|
if isinstance(node, dict):
|
|
return {k: strip(v) for k, v in sorted(node.items()) if not k.startswith("_")}
|
|
if isinstance(node, list):
|
|
return [strip(v) for v in node]
|
|
return node
|
|
print(hashlib.sha256(json.dumps(strip(cfg), sort_keys=True).encode()).hexdigest()[:12])
|
|
PY
|
|
)"
|
|
echo "$hash"
|
|
}
|
|
|
|
# Written into every image so a booted machine can say what it was built from.
|
|
core_write_build_stamp() {
|
|
local dest="$1" role="$2"
|
|
mkdir -p "$(dirname "$dest")"
|
|
cat > "$dest" <<EOF
|
|
# Generated by tools/ at image build time — see tools/README.md
|
|
SMARTHOME_ROLE=${role}
|
|
SMARTHOME_PAIR_ID=$(core_pair_id)
|
|
SMARTHOME_BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
SMARTHOME_CONTAINER_HOST=${CORE_CONTAINER_HOST_IP}
|
|
SMARTHOME_LLM_HOST=${CORE_LLM_HOST_IP}
|
|
EOF
|
|
}
|
|
|
|
# Move a finished image out of its live-build tree into iso-out/, named for the
|
|
# thing it actually is.
|
|
#
|
|
# THIS IS LOAD-BEARING FOR MULTI-INSTANCE BUILDS, not cosmetic. live-build always
|
|
# writes the same filename (live-image-amd64.hybrid.iso) into the same per-host tree,
|
|
# so building two thin clients — a living-room one and a bedroom one — would have the
|
|
# second silently overwrite the first, leaving one ISO carrying the second room's
|
|
# hostname and no indication the first was ever lost. Publishing under
|
|
# <role>-<hostname>-<pairid> makes each config entry produce its own artifact.
|
|
core_publish_image() {
|
|
local src="$1" role="$2" instance="${3:-}"
|
|
local out_dir="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}"
|
|
mkdir -p "$out_dir"
|
|
|
|
# Hostnames usually already carry the role ("thin-client-bedroom"), so appending the
|
|
# role verbatim gives "thin-client-thin-client-bedroom". Use the hostname alone when
|
|
# it already starts with the role.
|
|
local name="smarthome-${role}"
|
|
if [[ -n "$instance" ]]; then
|
|
if [[ "$instance" == "$role"* ]]; then
|
|
name="smarthome-${instance}"
|
|
else
|
|
name="smarthome-${role}-${instance}"
|
|
fi
|
|
fi
|
|
local dest="${out_dir}/${name}-$(core_pair_id).${src##*.}"
|
|
|
|
if [[ ! -f "$src" ]]; then
|
|
core_warn "Expected an image at ${src} but found none — nothing published."
|
|
return 1
|
|
fi
|
|
mv "$src" "$dest"
|
|
core_log "Image: ${dest}"
|
|
echo "$dest"
|
|
}
|
|
|
|
# Guard for the ISO builders: `lb build` needs root, and finding that out after the
|
|
# config phase has already written files is worse than finding out now.
|
|
core_require_root() {
|
|
[[ $EUID -eq 0 ]] || core_die "This needs root (lb build does). Re-run with: sudo -E $0 $*"
|
|
}
|