Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions appliance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,24 @@ boot during the build.
# also emit an OVA (VMware / VirtualBox)
./appliance/build-appliance.sh --ova

# generic kernel — hi-res console for a hypervisor window (homelab/desktop)
./appliance/build-appliance.sh --ova --kernel generic

# pin a ref / label; opt out of the Cockpit host console
./appliance/build-appliance.sh --ref v0.15.1 --version 0.15.1 --ova --no-cockpit
```

Artifacts land in `appliance/out/`. The Debian base is cached in
`appliance/.cache/` between builds (both are git-ignored).

**Kernel variants** (`--kernel`, default `cloud`) — the image ships in two
flavors, and `publish.sh appliance` builds both (ADR-119):

| Variant | Console | Pick it if… |
|---------|---------|-------------|
| `cloud` (default, unsuffixed) | 80×25 VGA | headless / real-cloud — you never look at the VM console (least overhead). |
| `generic` (`-generic` suffix) | hi-res framebuffer (160×50+) | you run it in a **VirtualBox/VMware/Hyper-V window** and read the console there. Also enables AHCI/SATA (CD-drive config carrier). |

The qcow2/OVA is built **locally** and attached to the matching GitHub Release,
together with a `SHA256SUMS` file — `./publish.sh appliance` automates the build,
`.xz` compression, checksums, and upload (the previously-manual step). Same
Expand Down
52 changes: 49 additions & 3 deletions appliance/build-appliance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
# --size SIZE root disk size (default: 20G)
# --output DIR output directory (default: ./appliance/out)
# --ova also emit an OVA (qcow2 -> vmdk -> OVF wrap)
# --kernel FLAVOR cloud (default) | generic. cloud = lean, headless/cloud
# (console stays 80x25 VGA — nobody looks at it). generic =
# homelab/desktop: real framebuffer console (legible in a
# VirtualBox/VMware window) + AHCI/SATA. generic artifacts
# get a "-generic" name suffix.
# --no-cockpit skip the Cockpit host console (:9090)
# --help
#
Expand All @@ -48,6 +53,7 @@ DISK_SIZE="20G"
OUTPUT_DIR="${SCRIPT_DIR}/out"
MAKE_OVA="false"
WITH_COCKPIT="true"
KERNEL="cloud"
CACHE_DIR="${SCRIPT_DIR}/.cache"

# --- Parse args --------------------------------------------------------------
Expand All @@ -59,12 +65,20 @@ while [ $# -gt 0 ]; do
--size) DISK_SIZE="$2"; shift 2 ;;
--output) OUTPUT_DIR="$2"; shift 2 ;;
--ova) MAKE_OVA="true"; shift ;;
--kernel) KERNEL="$2"; shift 2 ;;
--no-cockpit) WITH_COCKPIT="false"; shift ;;
--help|-h) sed -n '2,42p' "${BASH_SOURCE[0]}"; exit 0 ;;
--help|-h) sed -n '2,46p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done

case "${KERNEL}" in
cloud|generic) ;;
*) echo "Unknown --kernel '${KERNEL}' (expected: cloud | generic)" >&2; exit 1 ;;
esac
# generic artifacts carry a "-generic" suffix so both variants coexist in out/.
KSUF=""; [ "${KERNEL}" = "generic" ] && KSUF="-generic"

[ -n "${VERSION}" ] || VERSION="$(cd "${REPO_ROOT}" && git describe --tags --always --dirty 2>/dev/null || echo dev)"

log() { echo -e "\033[0;34m[build]\033[0m $*"; }
Expand Down Expand Up @@ -102,7 +116,7 @@ log "archiving repo at ref '${REF}'..."
(cd "${REPO_ROOT}" && git archive --format=tar --prefix=kg/ "${REF}") > "${REPO_TAR}"

# --- 3. Copy base -> working disk and resize ---------------------------------
OUT_QCOW="${OUTPUT_DIR}/kg-appliance-${VERSION}.qcow2"
OUT_QCOW="${OUTPUT_DIR}/kg-appliance-${VERSION}${KSUF}.qcow2"
log "preparing working disk ${OUT_QCOW} (${DISK_SIZE})..."
qemu-img convert -O qcow2 "${BASE_IMG}" "${OUT_QCOW}"
qemu-img resize "${OUT_QCOW}" "${DISK_SIZE}"
Expand All @@ -125,12 +139,43 @@ PKGS="qemu-guest-agent,ca-certificates,curl,python3,openssl,git,jq"
[ "${WITH_COCKPIT}" = "true" ] && PKGS="${PKGS},cockpit"
DOCKER_PKGS="docker-ce,docker-ce-cli,containerd.io,docker-buildx-plugin,docker-compose-plugin"

# --- Kernel flavor (generic only) --------------------------------------------
# The Debian *cloud* kernel ships NO DRM/framebuffer drivers, so the console is
# locked at 80x25 VGA and a higher GRUB_GFXMODE is silently ignored. That is fine
# for a headless cloud box nobody looks at — but the homelab/desktop user runs
# this in a VirtualBox/VMware window and reads the console *there* (it never
# occurs to them to switch to tty1 or SSH). For them, install the generic kernel
# (virtio-gpu / bochs / vmwgfx DRM) and request a real framebuffer console via
# GRUB; gfxpayload=keep carries the mode into the kernel. Generic also brings
# AHCI/SATA — the "attach the config ISO to a CD drive" lever (ADR-119).
# Expands to nothing for --kernel cloud.
KERNEL_ARGS=()
if [ "${KERNEL}" = "generic" ]; then
KERNEL_ARGS=(
--install 'linux-image-amd64'
# Purge whatever cloud-kernel packages are actually installed (meta +
# versioned), robust to the exact name; xargs -r no-ops if none match.
# Use '&&' (not ';') so a failed purge aborts the build rather than being
# masked by autoremove's exit status.
--run-command "dpkg-query -W -f='\${Package}\n' 'linux-image-*cloud*' 2>/dev/null | xargs -r apt-get purge -y && apt-get autoremove --purge -y"
# Hard post-condition (C1): a bootable NON-cloud kernel must survive the
# swap, or we'd ship a kernel-less / cloud-only image that the build still
# reports as success. Fail loud — independent of the op ordering above.
--run-command 'ls /boot/vmlinuz-*-amd64 2>/dev/null | grep -qv cloud || { echo "FATAL: generic kernel swap left no bootable non-cloud kernel"; exit 1; }'
--append-line '/etc/default/grub:GRUB_GFXMODE=1280x1024'
--append-line '/etc/default/grub:GRUB_GFXPAYLOAD_LINUX=keep'
--run-command 'update-grub'
)
fi

VC_ARGS=(
-a "${OUT_QCOW}"
--network
--hostname "kg-appliance"
--run-command 'apt-get update'
--install "${PKGS}"
# Kernel flavor: generic swaps cloud→generic + hi-res console; cloud = no-op.
"${KERNEL_ARGS[@]}"
# Docker's official apt repo (keyring + source), then install docker-ce.
--run-command 'install -m 0755 -d /etc/apt/keyrings'
--run-command 'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc'
Expand Down Expand Up @@ -176,6 +221,7 @@ VC_ARGS=(

log "customizing image (no VM boot; --network lets apt fetch base + docker repo)..."
[ "${WITH_COCKPIT}" = "true" ] && log " including Cockpit host console (:9090)" || log " Cockpit disabled (--no-cockpit)"
[ "${KERNEL}" = "generic" ] && log " generic kernel + hi-res framebuffer console (homelab/desktop variant)" || log " cloud kernel (lean, headless — console stays 80x25)"
virt-customize "${VC_ARGS[@]}"

# --- 5. Sparsify -------------------------------------------------------------
Expand Down Expand Up @@ -210,7 +256,7 @@ if [ "${MAKE_OVA}" = "true" ]; then
echo "SHA1(kg-appliance-disk1.vmdk)= $(sha1sum kg-appliance-disk1.vmdk | cut -d' ' -f1)"
} > kg-appliance.mf
)
OUT_OVA="${OUTPUT_DIR}/kg-appliance-${VERSION}.ova"
OUT_OVA="${OUTPUT_DIR}/kg-appliance-${VERSION}${KSUF}.ova"
( cd "${OVA_TMP}" && tar -cf "${OUT_OVA}" kg-appliance.ovf kg-appliance.mf kg-appliance-disk1.vmdk )
log "OVA ready: ${OUT_OVA}"
fi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,13 @@ predictable address (`/dev/vdb`) so the user points an existing slot at their
`.img` rather than adding a controller. *(Follow-on, not yet shipped: the current
`ovf/kg-appliance.ovf.template` declares only the primary disk; until the empty
slot lands, the carrier is attached as an added virtio disk — the same friction
this is meant to remove. Tracked with the first-boot orchestration work below.)* The cloud kernel has no AHCI, so the bus is
virtio ("attach this `.img` as a disk"); we accept that over a one-click
"attach ISO to CD" because broad compatibility beats the convenience of a kernel
swap. (Revisitable — an AHCI kernel later would unlock the CD-drive UX without
changing this model.)
this is meant to remove. Tracked with the first-boot orchestration work below.)*

The bus the carrier hangs off is governed by the **kernel flavor**, which is now a
shipped distribution variant (see "Kernel variants" below). The default **cloud**
kernel has no AHCI, so its carrier is a virtio disk ("attach this `.img` as a
disk"). The **generic** variant brings AHCI/SATA, unlocking the one-click
"attach the config ISO to a CD drive" UX for the hypervisors that expect it.

### B — First boot is presence-driven, not interactive

Expand Down Expand Up @@ -146,6 +148,27 @@ in the console's *export* flow ("pack and download the config out for re-use").
emits a stock NoCloud seed any cloud-init consumes — it is "a tool to generate
cloud-init user-data easily," not a custom format.

### E — Kernel variants: cloud (default) and generic (homelab)

The Debian *cloud* kernel ships no DRM/framebuffer or AHCI drivers — fine for a
headless cloud instance, but it locks the console to 80×25 VGA. That matters
because of *who looks at the console*: the homelab/desktop user runs the appliance
in a VirtualBox/VMware/Hyper-V window and reads the console **there** — it never
occurs to them to switch to tty1 or SSH. For them the cramped 80×25 *is* the
product. So the image ships as **two variants**, selected at build time
(`build-appliance.sh --kernel`):

| Variant | Kernel | Console | For |
|---------|--------|---------|-----|
| **cloud** (default) | `linux-image-cloud-amd64` | 80×25 VGA (nobody looks) | headless / real-cloud; least overhead. The "expected shape." |
| **generic** (`-generic` suffix) | `linux-image-amd64` + `GRUB_GFXMODE`/`gfxpayload=keep` | real hi-res framebuffer (legible in a hypervisor window) | homelab/desktop: the person who needs a hand and reads the console window. Also gains AHCI/SATA → the CD-drive carrier UX (§A). |

cloud stays the default because the appliance's headless/edge path is the common
one and cloud users have other access; generic is the opt-in for the
console-window user. `publish.sh appliance` ships **both** OVAs/qcow2s on the
release, and the install docs point VirtualBox/VMware users (the console-window
crowd) at the **generic** artifact.

## Consequences

### Positive
Expand Down Expand Up @@ -178,8 +201,9 @@ cloud-init user-data easily," not a custom format.
### Neutral

- cloud-init is used as-is (standard datasources), neither demoted nor extended.
- The bus/kernel trade is revisitable (AHCI kernel → CD-drive UX) without touching
this model.
- The bus/kernel trade is resolved by shipping **two kernel variants** (§E): cloud
(default, virtio-only) and generic (AHCI + hi-res console). Neither changes this
config-delivery model; they differ only in drivers and console.
- The builder lands as an operator verb (ADR-211 sink), not a new tool.
- **Convergence contract (ADR-103).** The OVA is a thin *bootstrap seed*:
downloaded once, run, then kept current by `operator.sh upgrade` pulling fresh
Expand Down
77 changes: 51 additions & 26 deletions scripts/publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1094,11 +1094,13 @@ cmd_appliance() {
local out_dir="$PROJECT_ROOT/appliance/out"
local label="$VERSION"
local tag="v${VERSION}"
local qcow2="$out_dir/kg-appliance-${label}.qcow2"
local qcow2_xz="${qcow2}.xz"
local ova="$out_dir/kg-appliance-${label}.ova"

echo -e "${BOLD}Publishing appliance bootstrap image${NC}"
# Two kernel variants ship per release (ADR-119): cloud (default, unsuffixed)
# and generic (homelab/desktop, "-generic" suffix, hi-res console + AHCI).
# Each entry is "<name-suffix>:<--kernel flavor>".
local -a variants=( ":cloud" "-generic:generic" )

echo -e "${BOLD}Publishing appliance bootstrap images (cloud + generic)${NC}"
echo -e " Version: ${BLUE}${label}${NC} → GitHub release ${BLUE}${tag}${NC}"
[ "$DRY_RUN" = "true" ] && echo -e " Mode: ${YELLOW}DRY RUN${NC}"
echo ""
Expand All @@ -1111,41 +1113,64 @@ cmd_appliance() {
echo -e "${RED}gh (GitHub CLI) not found${NC}"; echo " Install: https://cli.github.com/"; exit 1
fi

# --- Build (unless --skip-build) -----------------------------------------
# --- Build each variant (unless --skip-build) ----------------------------
if [ "$SKIP_BUILD" = "false" ]; then
echo -e "${BLUE}→ Building OVA (kg-appliance-${label})...${NC}"
"$PROJECT_ROOT/appliance/build-appliance.sh" --ova --version "$label"
echo -e "${GREEN}✓ Build complete${NC}"
local v suf kern
for v in "${variants[@]}"; do
suf="${v%%:*}"; kern="${v##*:}"
echo -e "${BLUE}→ Building ${kern} variant (kg-appliance-${label}${suf})...${NC}"
"$PROJECT_ROOT/appliance/build-appliance.sh" --ova --version "$label" --kernel "$kern"
done
echo -e "${GREEN}✓ Builds complete${NC}"
echo ""
fi
if [ ! -f "$ova" ]; then
echo -e "${RED}OVA not found: $ova${NC}"
echo -e " ${DIM}Build it: appliance/build-appliance.sh --ova --version $label${NC}"
exit 1

# --- Resolve the asset set for both variants -----------------------------
# OVA always; the qcow2's .xz only when a source qcow2 exists this run (a
# --skip-build OVA-only run ships just the OVA, never a stale leftover .xz).
local -a assets=()
local v suf base ova qcow2
for v in "${variants[@]}"; do
suf="${v%%:*}"; base="kg-appliance-${label}${suf}"
ova="$out_dir/${base}.ova"; qcow2="$out_dir/${base}.qcow2"
if [ ! -f "$ova" ]; then
# Post-build, a missing OVA means the build silently failed → hard stop.
# With --skip-build, publish whatever IS ready: warn and skip (M1).
if [ "$SKIP_BUILD" = "true" ]; then
echo -e "${YELLOW}⚠ ${base}.ova not present — skipping ${v##*:} variant (--skip-build)${NC}"
continue
fi
echo -e "${RED}OVA not found: $ova${NC}"
echo -e " ${DIM}Build it: appliance/build-appliance.sh --ova --version $label --kernel ${v##*:}${NC}"
exit 1
fi
assets+=( "$ova" )
[ -f "$qcow2" ] && assets+=( "${qcow2}.xz" )
done
if [ "${#assets[@]}" -eq 0 ]; then
echo -e "${RED}No appliance OVAs found to publish (build first, or drop --skip-build).${NC}"; exit 1
fi

# --- Dry run: report intent only, NO side effects (no xz, no SHA file) ---
if [ "$DRY_RUN" = "true" ]; then
echo -e "${DIM}Would compress, checksum, and upload to release ${tag}:${NC}"
echo " $(basename "$ova") ($(du -h "$ova" | cut -f1))"
[ -f "$qcow2" ] && echo " $(basename "$qcow2_xz") (compressed from $(du -h "$qcow2" | cut -f1) qcow2)"
for a in "${assets[@]}"; do echo " $(basename "$a")"; done
echo " SHA256SUMS"
return 0
fi

# --- Compress the qcow2 FRESH every run -----------------------------------
# The .xz name is keyed on the clean VERSION, so a stale same-VERSION .xz
# from a prior build must never be reused (xz -f overwrites). Only publish a
# .xz when we have a source qcow2 *this* run — a --skip-build OVA-only run
# ships just the OVA, never a leftover .xz.
if [ -f "$qcow2" ]; then
echo -e "${BLUE}→ Compressing qcow2 → .xz (a few minutes)...${NC}"
xz -T0 -k -f "$qcow2"
fi
# --- Compress each variant's qcow2 FRESH every run ------------------------
# The .xz name is keyed on the clean VERSION+variant, so a stale same-name .xz
# from a prior build must never be reused (xz -f overwrites).
for v in "${variants[@]}"; do
suf="${v%%:*}"; qcow2="$out_dir/kg-appliance-${label}${suf}.qcow2"
if [ -f "$qcow2" ]; then
echo -e "${BLUE}→ Compressing $(basename "$qcow2") → .xz (a few minutes)...${NC}"
xz -T0 -k -f "$qcow2"
fi
done

# --- Checksums over exactly the binary assets we publish -----------------
local -a assets=( "$ova" )
[ -f "$qcow2" ] && assets+=( "$qcow2_xz" )
echo -e "${BLUE}→ Writing SHA256SUMS${NC}"
( cd "$out_dir" && sha256sum $(for a in "${assets[@]}"; do basename "$a"; done) > SHA256SUMS )
sed 's/^/ /' "$out_dir/SHA256SUMS"
Expand All @@ -1163,7 +1188,7 @@ cmd_appliance() {
echo -e "${BLUE}→ Uploading ${#assets[@]} image asset(s) to ${tag}${NC}"
gh release upload "$tag" "${assets[@]}" --clobber
gh release upload "$tag" "$out_dir/SHA256SUMS" --clobber
echo -e "${GREEN}✓ Published appliance bootstrap image to release ${tag}${NC}"
echo -e "${GREEN}✓ Published ${#assets[@]} appliance asset(s) — cloud + generic — to release ${tag}${NC}"
echo -e " ${DIM}Verify: gh release download ${tag} -p 'kg-appliance-*' -p SHA256SUMS && sha256sum -c SHA256SUMS${NC}"
}

Expand Down
Loading