diff --git a/Makefile b/Makefile index ac3beb0..c6c0dc3 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ export TOOLCHAIN_REPO ROOT := $(CURDIR) EDITION := $(filter-out $@,$(MAKECMDGOALS)) -.PHONY: help deps check toolchain test clean +.PHONY: help deps check toolchain vendor test clean # ── help ──────────────────────────────────────────────────────────────────── help: @@ -45,9 +45,11 @@ help: @echo " make check Validate manifests + shell syntax" @echo " make rootfs- Build rootfs (server|workstation|cloud)" @echo " make iso- Build ISO (server|workstation|cloud)" + @echo " make image- Build disk image (qcow2; cloud edition)" @echo " make iso Build ISO for every edition" @echo " make iso--minimal Build ISO WITHOUT the Arcline toolchain" @echo " make toolchain Build the 11 Go tools into .deb" + @echo " make vendor Build grafana/loki/promtail .debs" @echo " make test Run smoke tests against a built image" @echo " make clean Remove build/ artifacts" @echo @@ -70,19 +72,25 @@ check: # require fail if no .deb files are available # The *-minimal targets are a shortcut for ARCLINE_TOOLCHAIN=skip. define ISO_RULE -.PHONY: rootfs-$(1) iso-$(1) clean-$(1) rootfs-$(1)-minimal iso-$(1)-minimal +.PHONY: rootfs-$(1) iso-$(1) image-$(1) clean-$(1) rootfs-$(1)-minimal iso-$(1)-minimal image-$(1)-minimal rootfs-$(1): @./scripts/build-edition.sh $(1) rootfs iso-$(1): @./scripts/build-edition.sh $(1) iso +image-$(1): + @./scripts/build-edition.sh $(1) image + rootfs-$(1)-minimal: @ARCLINE_TOOLCHAIN=skip ./scripts/build-edition.sh $(1) rootfs iso-$(1)-minimal: @ARCLINE_TOOLCHAIN=skip ./scripts/build-edition.sh $(1) iso +image-$(1)-minimal: + @ARCLINE_TOOLCHAIN=skip ./scripts/build-edition.sh $(1) image + clean-$(1): @rm -rf "$(ROOTFS_DIR)/$(1)" "$(IMAGE_DIR)/$(1)" endef @@ -94,6 +102,10 @@ iso: $(foreach e,$(EDITIONS),iso-$(e)) toolchain: @./toolchain/build-tools.sh +# ── vendor packaging (grafana / loki / promtail) ──────────────────────────── +vendor: + @./toolchain/build-vendor.sh + # ── tests ─────────────────────────────────────────────────────────────────── test: @./tests/run-tests.sh diff --git a/ci/.gitlab-ci.yml b/ci/.gitlab-ci.yml index 72701b8..e2a4a29 100644 --- a/ci/.gitlab-ci.yml +++ b/ci/.gitlab-ci.yml @@ -31,9 +31,9 @@ validate: image: debian:bookworm before_script: - apt-get update -qq - - apt-get install -y -qq debootstrap squashfs-tools grub2-common xorriso cpio curl git make bash + - apt-get install -y -qq debootstrap squashfs-tools grub2-common xorriso cpio curl git make bash gdisk parted rsync dosfstools qemu-utils unzip dpkg openssl sbsigntool script: - - make iso-${EDITION} + - make ${TARGET:-iso}-${EDITION} artifacts: name: "arcline-${EDITION}-${VERSION}" paths: @@ -58,6 +58,7 @@ build-cloud: extends: .build variables: EDITION: cloud + TARGET: image # cloud edition ships as a qcow2 disk image # ── test ──────────────────────────────────────────────────────────────────── test: diff --git a/editions/cloud/metadata.yaml b/editions/cloud/metadata.yaml index acdf155..037e479 100644 --- a/editions/cloud/metadata.yaml +++ b/editions/cloud/metadata.yaml @@ -9,10 +9,11 @@ description: > network stack. Same hardening baseline as server. image: - type: iso - boot: bios+efi + type: disk # cloud edition ships as a bootable qcow2/raw disk image + boot: bios # primary boot mode (UEFI via --boot efi) filesystem: btrfs compression: zstd:3 + default_size: 4G packages: extra_repos: [] diff --git a/scripts/apply-fstab.sh b/scripts/apply-fstab.sh new file mode 100755 index 0000000..05555a4 --- /dev/null +++ b/scripts/apply-fstab.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Arcline OS — write a real /etc/fstab from an edition template +# +# scripts/apply-fstab.sh [efi-uuid] +# +# Renders editions//fstab with the real root partition UUID, drops +# the template's swapfile line (no swap is created during deploy), and — in +# EFI installs — appends the EFI system partition mount. +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +TARGET="${1:?usage: apply-fstab.sh [efi-uuid]}" +EDITION="${2:?usage: apply-fstab.sh [efi-uuid]}" +ROOT_UUID="${3:?usage: apply-fstab.sh [efi-uuid]}" +EFI_UUID="${4:-}" + +validate_edition "$EDITION" +[[ -d "$TARGET" ]] || die "target '$TARGET' does not exist" + +EDIR="$(edition_dir "$EDITION")" +tmp="$(mktemp)" +trap 'rm -f "$tmp" "$tmp.clean"' EXIT + +# template → real fstab; remove the swapfile line + its comment (no swap created on deploy) +sed "s/__ROOT_UUID__/$ROOT_UUID/g" "$EDIR/fstab" | grep -v -E '(swapfile|^[[:space:]]*#.*swap)' > "$tmp.clean" + +if [[ -n "$EFI_UUID" ]]; then + printf 'UUID=%s /boot/efi vfat umask=0077 0 1\n' "$EFI_UUID" >> "$tmp.clean" +fi + +install -Dm0644 "$tmp.clean" "$TARGET/etc/fstab" +log "wrote $TARGET/etc/fstab (root UUID $ROOT_UUID${EFI_UUID:+, efi UUID $EFI_UUID})" diff --git a/scripts/build-edition.sh b/scripts/build-edition.sh index 6b1f6f2..31b02cf 100755 --- a/scripts/build-edition.sh +++ b/scripts/build-edition.sh @@ -21,7 +21,10 @@ case "$STAGE" in iso) "$ROOT/scripts/build-iso.sh" "$EDITION" ;; + image) + "$ROOT/scripts/build-image.sh" "$EDITION" + ;; *) - die "unknown stage '$STAGE' (expected: rootfs | iso)" + die "unknown stage '$STAGE' (expected: rootfs | iso | image)" ;; esac diff --git a/scripts/build-image.sh b/scripts/build-image.sh new file mode 100755 index 0000000..9293c1a --- /dev/null +++ b/scripts/build-image.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Arcline OS — disk image builder (qcow2 / raw) +# +# scripts/build-image.sh [--format qcow2|raw] [--size 4G] [--boot bios|efi] +# +# Produces a bootable disk image (the cloud edition's real output — and useful +# for testing server/workstation locally in a VM): +# +# 1. ensure the rootfs exists (build it if needed) +# 2. create a sparse raw file, attach a loop device +# 3. deploy-disk.sh writes the finished system (btrfs layout + GRUB + fstab) +# 4. optionally qemu-img convert to qcow2 +# 5. artifact → build/artifacts/arcline---. + sha256 +# +# Requires root (for the loop device + deploy step). +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +EDITION="${1:?usage: build-image.sh [--format qcow2|raw] [--size 4G] [--boot bios|efi]}" +FORMAT="qcow2" +SIZE="4G" +BOOT="bios" + +shift || true +while [[ $# -gt 0 ]]; do + case "$1" in + --format) FORMAT="${2:?}"; shift 2 ;; + --size) SIZE="${2:?}"; shift 2 ;; + --boot) BOOT="${2:?}"; shift 2 ;; + *) die "unknown option '$1'" ;; + esac +done + +validate_edition "$EDITION" +[[ "$FORMAT" == "qcow2" || "$FORMAT" == "raw" ]] || die "--format must be qcow2|raw" +[[ "$BOOT" == "bios" || "$BOOT" == "efi" ]] || die "--boot must be bios|efi" +require_root "$0" "$@" + +command -v qemu-img >/dev/null || die "missing 'qemu-img' (package: qemu-utils)" +command -v losetup >/dev/null || die "missing 'losetup' (package: util-linux)" + +ROOTFS="$ROOTFS_DIR/$EDITION" +RAW="$IMAGE_DIR/$EDITION/arcline-$EDITION-$VERSION-$ARCH.raw" +EXT="$FORMAT" +ARTIFACT="$ARTIFACT_DIR/arcline-$EDITION-$VERSION-$ARCH.$EXT" + +mkdir -p "$IMAGE_DIR/$EDITION" "$ARTIFACT_DIR" +rm -f "$RAW" "$ARTIFACT" "$ARTIFACT.sha256" + +# ── 1. rootfs ─────────────────────────────────────────────────────────────── +if [[ ! -d "$ROOTFS" ]]; then + log "rootfs missing — building it first" + "$ROOT/scripts/build-rootfs.sh" "$EDITION" +fi + +# ── 2. sparse raw file + loop device ──────────────────────────────────────── +log "creating ${SIZE} sparse image (edition $EDITION, $BOOT boot)" +truncate -s "$SIZE" "$RAW" +LOOP="$(losetup --find --show --partscan "$RAW")" +cleanup() { losetup -d "$LOOP" 2>/dev/null || true; } +trap cleanup EXIT +log "attached loop device: $LOOP" + +# ── 3. deploy ─────────────────────────────────────────────────────────────── +"$ROOT/scripts/deploy-disk.sh" "$LOOP" "$ROOTFS" "$EDITION" --boot "$BOOT" + +# ── 4. convert ────────────────────────────────────────────────────────────── +losetup -d "$LOOP" +trap - EXIT + +if [[ "$FORMAT" == "qcow2" ]]; then + log "converting to qcow2" + qemu-img convert -f raw -O qcow2 "$RAW" "$ARTIFACT" + rm -f "$RAW" +else + mv "$RAW" "$ARTIFACT" +fi + +log "image artifact: $ARTIFACT" +sha256sum "$ARTIFACT" | tee "$ARTIFACT.sha256" diff --git a/scripts/check-host-deps.sh b/scripts/check-host-deps.sh index d8b6801..5e3190f 100755 --- a/scripts/check-host-deps.sh +++ b/scripts/check-host-deps.sh @@ -18,6 +18,19 @@ NEEDED=( "xorriso|xorriso" "cpio|cpio" "bash|bash" + # disk images / installer + "sgdisk|gdisk" + "partprobe|parted" + "rsync|rsync" + "mkfs.vfat|dosfstools" + "qemu-img|qemu-utils" + "losetup|util-linux" + # vendor packaging + secure boot + "unzip|unzip" + "dpkg-deb|dpkg" + "openssl|openssl" + "sbsign|sbsigntool" + "sbverify|sbsigntool" ) MISSING=() diff --git a/scripts/deploy-disk.sh b/scripts/deploy-disk.sh new file mode 100755 index 0000000..ddce866 --- /dev/null +++ b/scripts/deploy-disk.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Arcline OS — deploy a rootfs to a disk +# +# scripts/deploy-disk.sh [--boot bios|efi] +# +# The shared "write a finished Arcline system to a block device" step, used by +# both the disk image builder (scripts/build-image.sh) and the scripted +# installer (scripts/install.sh): +# +# 1. partition the device (GPT): +# bios: [1M BIOS boot] + [btrfs root] +# efi: [512M EFI] + [btrfs root] +# 2. mkfs.btrfs + btrfs/init.sh → @, @home, @log, @snapshots +# 3. rsync the rootfs into @ +# 4. chroot: write real /etc/fstab (real UUIDs), install GRUB, update-grub +# +# DANGER: this wipes . There is no confirmation — the callers are +# responsible for safety (build-image.sh always uses a throwaway loop device; +# install.sh asks for explicit confirmation). +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +DEV="${1:?usage: deploy-disk.sh [--boot bios|efi]}" +ROOTFS="${2:?usage: deploy-disk.sh [--boot bios|efi]}" +EDITION="${3:?usage: deploy-disk.sh [--boot bios|efi]}" +BOOT="${4:-bios}" + +require_root "$0" "$@" +validate_edition "$EDITION" +[[ "$BOOT" == "bios" || "$BOOT" == "efi" ]] || die "--boot must be bios|efi (got '$BOOT')" +[[ -d "$ROOTFS" ]] || die "rootfs '$ROOTFS' does not exist (build it first: make rootfs-$EDITION)" +[[ -e "$DEV" ]] || die "device '$DEV' does not exist" + +for tool in sgdisk mkfs.btrfs rsync; do + command -v "$tool" >/dev/null || die "missing host tool '$tool' (run: scripts/check-host-deps.sh --install)" +done +[[ "$BOOT" == "efi" ]] && { command -v mkfs.vfat >/dev/null || die "missing 'mkfs.vfat' (package: dosfstools)"; } + +# device + partition naming: /dev/sda → /dev/sda1, /dev/nvme0n1 → /dev/nvme0n1p1, /dev/loop0 → /dev/loop0p1 +part_of() { + if [[ "$1" =~ [0-9]$ ]]; then printf '%sp%s' "$1" "$2"; else printf '%s%s' "$1" "$2"; fi +} + +MNT="$(mktemp -d /run/arcline-deploy.XXXXXX)" +cleanup() { + umount -R "$MNT" 2>/dev/null || true + rmdir "$MNT" 2>/dev/null || true +} +trap cleanup EXIT + +# ── 1. partition ──────────────────────────────────────────────────────────── +log "wiping and partitioning $DEV ($BOOT boot)" +wipefs -a "$DEV" >/dev/null 2>&1 || true +sgdisk --zap-all "$DEV" >/dev/null +case "$BOOT" in + bios) + sgdisk -n 1:0:+1M -t 1:ef02 -c 1:"BIOS boot" "$DEV" >/dev/null + sgdisk -n 2:0:0 -t 2:8300 -c 2:"Arcline root" "$DEV" >/dev/null + ROOT_PART="$(part_of "$DEV" 2)" + EFI_PART="" + ;; + efi) + sgdisk -n 1:0:+512M -t 1:ef00 -c 1:"EFI System" "$DEV" >/dev/null + sgdisk -n 2:0:0 -t 2:8300 -c 2:"Arcline root" "$DEV" >/dev/null + ROOT_PART="$(part_of "$DEV" 2)" + EFI_PART="$(part_of "$DEV" 1)" + ;; +esac +partprobe "$DEV" 2>/dev/null || udevadm settle +sleep 1 +[[ -b "$ROOT_PART" ]] || die "root partition $ROOT_PART did not appear" + +# ── 2. filesystems + btrfs layout ─────────────────────────────────────────── +log "formatting root partition $ROOT_PART (btrfs)" +mkfs.btrfs -f -L arcline-root "$ROOT_PART" >/dev/null +"$ROOT/btrfs/init.sh" "$ROOT_PART" +if [[ -n "$EFI_PART" ]]; then + log "formatting EFI partition $EFI_PART (vfat)" + mkfs.vfat -F32 -n ARCLINE-EFI "$EFI_PART" >/dev/null +fi + +ROOT_UUID="$(blkid -s UUID -o value "$ROOT_PART")" +EFI_UUID="" +[[ -n "$EFI_PART" ]] && EFI_UUID="$(blkid -s UUID -o value "$EFI_PART")" + +# ── 3. mount subvolumes + copy rootfs ─────────────────────────────────────── +log "mounting subvolumes and copying rootfs (this takes a while)" +mkdir -p "$MNT/root" +mount -o subvol=@,compress=zstd:3 "$ROOT_PART" "$MNT/root" +mkdir -p "$MNT/root/home" "$MNT/root/var/log" "$MNT/root/.snapshots" +mount -o subvol=@home,compress=zstd:3 "$ROOT_PART" "$MNT/root/home" +mount -o subvol=@log,compress=zstd:3 "$ROOT_PART" "$MNT/root/var/log" +mount -o subvol=@snapshots,compress=zstd:3 "$ROOT_PART" "$MNT/root/.snapshots" +if [[ -n "$EFI_PART" ]]; then + mkdir -p "$MNT/root/boot/efi" + mount "$EFI_PART" "$MNT/root/boot/efi" +fi + +rsync -aHAX --numeric-ids \ + --exclude='/proc/*' --exclude='/sys/*' --exclude='/dev/*' \ + --exclude='/run/*' --exclude='/tmp/*' --exclude='/boot/grub/grub.cfg' \ + "$ROOTFS/" "$MNT/root/" + +# ── 4. chroot: fstab + grub + hostname ────────────────────────────────────── +log "configuring system in chroot" +mount --bind /dev "$MNT/root/dev" +mount --bind /proc "$MNT/root/proc" +mount --bind /sys "$MNT/root/sys" + +# /etc/fstab from the edition template, with real UUIDs (swap line dropped; +# add a swapfile later if you want one — btrfs swapfiles need nocow). +"$ROOT/scripts/apply-fstab.sh" "$MNT/root" "$EDITION" "$ROOT_UUID" "$EFI_UUID" + +chroot "$MNT/root" /bin/bash -c ' +set -e +case "$1" in + bios) grub-install --target=i386-pc --recheck "$0" ;; + efi) grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=arcline --recheck ;; +esac +update-grub +' "$DEV" "$BOOT" + +# ── secure boot (optional: ARCLINE_SIGN=1 + a MOK keypair) ────────────────── +if [[ "${ARCLINE_SIGN:-0}" == "1" ]]; then + log "secure boot: shipping MOK and signing installed boot chain" + [[ -f "$BUILD_DIR/keys/MOK.der" ]] || die "ARCLINE_SIGN=1 but no MOK keypair — run: scripts/secureboot/gen-keys.sh" + mkdir -p "$MNT/root/etc/arcline" + cp "$BUILD_DIR/keys/MOK.der" "$MNT/root/etc/arcline/MOK.der" + "$ROOT/scripts/secureboot/sign-image.sh" "$MNT/root" --keydir "$BUILD_DIR/keys" +fi + +if [[ "$EDITION" != "cloud" ]]; then + echo "$RELEASE_NAME" > "$MNT/root/etc/hostname" +fi + +log "deploy complete: $DEV is a bootable $EDITION ($BOOT) system" +log "root UUID: $ROOT_UUID" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..7c0305e --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Arcline OS — scripted installer +# +# scripts/install.sh [--edition server] [--boot bios|efi] +# [--rootfs | --image ] +# +# Installs an Arcline OS rootfs onto a real disk: +# +# sudo scripts/install.sh /dev/sda --edition server +# sudo scripts/install.sh /dev/nvme0n1 --edition cloud --image build/artifacts/arcline-cloud-0.1.0-amd64.tar.xz +# +# WARNING: this WIPES . You are asked to confirm the exact device path +# before anything is written. +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +DEV="${1:?usage: install.sh [--edition server] [--boot bios|efi] [--rootfs |--image ]}" +EDITION="server" +BOOT="bios" +ROOTFS_SRC="" + +shift || true +while [[ $# -gt 0 ]]; do + case "$1" in + --edition) EDITION="${2:?}"; shift 2 ;; + --boot) BOOT="${2:?}"; shift 2 ;; + --rootfs) ROOTFS_SRC="${2:?}"; shift 2 ;; + --image) ROOTFS_SRC="${2:?}"; shift 2 ;; + *) die "unknown option '$1'" ;; + esac +done + +require_root "$0" "$@" +validate_edition "$EDITION" +[[ "$BOOT" == "bios" || "$BOOT" == "efi" ]] || die "--boot must be bios|efi" +[[ -b "$DEV" ]] || die "'$DEV' is not a block device — refusing to install" + +# ── resolve rootfs (dir or tarball) ───────────────────────────────────────── +TMPROOT="" +if [[ -n "$ROOTFS_SRC" ]]; then + if [[ -d "$ROOTFS_SRC" ]]; then + ROOTFS="$ROOTFS_SRC" + elif [[ -f "$ROOTFS_SRC" ]]; then + TMPROOT="$(mktemp -d /run/arcline-install.XXXXXX)" + log "extracting rootfs image: $ROOTFS_SRC" + tar -xJf "$ROOTFS_SRC" -C "$TMPROOT" + ROOTFS="$TMPROOT" + else + die "rootfs source '$ROOTFS_SRC' not found" + fi +else + ROOTFS="$ROOTFS_DIR/$EDITION" + [[ -d "$ROOTFS" ]] || die "no rootfs for '$EDITION' at $ROOTFS (build it: make rootfs-$EDITION, or pass --rootfs/--image)" +fi +trap '[[ -n "$TMPROOT" ]] && { rm -rf "$TMPROOT"; }' EXIT + +# ── safety confirmation ───────────────────────────────────────────────────── +echo +echo "Arcline OS installer" +echo " target device : $DEV" +echo " edition : $EDITION" +echo " boot : $BOOT" +echo +echo "!!! ALL DATA ON $DEV WILL BE DESTROYED !!!" +read -r -p "Type the device path to confirm ($DEV): " CONFIRM +[[ "$CONFIRM" == "$DEV" ]] || die "confirmation mismatch — aborting" + +"$ROOT/scripts/deploy-disk.sh" "$DEV" "$ROOTFS" "$EDITION" --boot "$BOOT" + +echo +log "install complete. Remove the installation media and reboot." +log "After reboot: set a root password and enroll your ssh key" +log " sudo passwd root"