diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d501849 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*.sh] +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[Makefile] +indent_style = tab +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[*.md] +end_of_line = lf +insert_final_newline = true +charset = utf-8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b6f1a51 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update + - run: sudo apt-get install -y python3-pip + - run: python3 -m pip install --user check-jsonschema + - run: ~/.local/bin/check-jsonschema --help + - run: PATH="$HOME/.local/bin:$PATH" scripts/validate-config.sh config/defaults.yaml + + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update + - run: sudo apt-get install -y bats ansible python3-pip + - run: python3 -m pip install --user check-jsonschema + - run: PATH="$HOME/.local/bin:$PATH" make ci-check + + verify-boot: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update + - run: sudo apt-get install -y qemu-system-x86 ovmf bats + - run: bats tests/bats/boot_smoke_signals.bats + - run: truncate -s 128M /tmp/ghostdrive-boot-smoke.img + - run: if scripts/verify/boot_smoke_bios.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10; then echo "unexpected BIOS smoke success for blank image"; exit 1; fi + - run: if scripts/verify/boot_smoke_uefi.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10; then echo "unexpected UEFI smoke success for blank image"; exit 1; fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..066b330 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.worktrees/ +.env +.env.* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8394f17 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +.PHONY: test ci-check + +test: + bats tests/bats + +ci-check: + scripts/validate-config.sh config/defaults.yaml + bats tests/bats diff --git a/README.md b/README.md index d229a03..00cc1eb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,111 @@ # ghostdrive -A bootable USB with everything to start hacking + +Ghostdrive builds a reproducible Ubuntu engineering USB image using an install-to-USB pipeline. + +## V1 Scope + +- Linux build host only. +- Ubuntu LTS target only (`24.04`, `x86_64`). +- Install-to-USB architecture (not live+persistence mode). +- Curated baseline provisioning through Ansible. +- Golden image capture and restore workflow. + +## Safety and Security Rules + +- All destructive operations require an explicit `--device` argument. +- For non-CI runs, require an interactive human confirmation after printing the selected block device before any destructive write step. +- Validate configuration before install or CI checks. +- Keep secrets out of the repository. +- Generalize a target filesystem before capture to avoid cloned identity collisions. + +## Prerequisites + +Install required tools on a Linux host: + +```bash +sudo apt-get update +sudo apt-get install -y \ + bats \ + ansible \ + qemu-system-x86 \ + ovmf \ + zstd \ + python3-pip +python3 -m pip install --user check-jsonschema +export PATH="$HOME/.local/bin:$PATH" +command -v check-jsonschema +``` + +If `command -v check-jsonschema` prints nothing, your shell is missing `$HOME/.local/bin` on PATH. + +## Quickstart + +1. Validate config: + +```bash +scripts/validate-config.sh config/defaults.yaml +``` + +2. Prepare install orchestration for a target device: + +```bash +scripts/build/install_to_usb.sh \ + --config config/defaults.yaml \ + --device /dev/sdX \ + --workdir .ghostdrive-work +``` + +For non-CI usage, review the printed `/dev/sdX` target and complete the interactive confirmation before allowing the install step to proceed. + +3. Apply curated baseline to the mounted target root filesystem: + +```bash +scripts/provision/apply_baseline.sh --target-root /mnt/ghostdrive-root +``` + +4. Generalize and capture a golden image: + +```bash +scripts/image/generalize.sh --root /mnt/ghostdrive-root +scripts/image/capture.sh --device /dev/sdX --output artifacts/ghostdrive.img.zst +scripts/image/write_manifest.sh --image artifacts/ghostdrive.img.zst --output artifacts/ghostdrive.manifest.json +``` + +5. Restore to another USB device: + +```bash +scripts/image/restore.sh \ + --image artifacts/ghostdrive.img.zst \ + --manifest artifacts/ghostdrive.manifest.json \ + --device /dev/sdY +``` + +## Verification + +Run all local checks: + +```bash +make test +``` + +CI-oriented checks: + +```bash +make ci-check +``` + +Boot smoke checks (BIOS + UEFI): + +```bash +truncate -s 128M /tmp/ghostdrive-boot-smoke.img +scripts/verify/boot_smoke_bios.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10 +scripts/verify/boot_smoke_uefi.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10 +``` + +## Documentation + +- `docs/architecture.md` +- `docs/provisioning.md` +- `docs/cloning-workflow.md` +- `docs/threat-model.md` +- `docs/compatibility-matrix.md` diff --git a/ansible/inventory/local.ini b/ansible/inventory/local.ini new file mode 100644 index 0000000..5e65f38 --- /dev/null +++ b/ansible/inventory/local.ini @@ -0,0 +1,2 @@ +[ghostdrive_target] +target ansible_connection=chroot ansible_host=/__ghostdrive_target_root_required__ diff --git a/ansible/roles/curated_default/defaults/main.yml b/ansible/roles/curated_default/defaults/main.yml new file mode 100644 index 0000000..238a224 --- /dev/null +++ b/ansible/roles/curated_default/defaults/main.yml @@ -0,0 +1,6 @@ +--- +curated_default_packages: + - git + - curl + - vim + - build-essential diff --git a/ansible/roles/curated_default/tasks/main.yml b/ansible/roles/curated_default/tasks/main.yml new file mode 100644 index 0000000..ef297b0 --- /dev/null +++ b/ansible/roles/curated_default/tasks/main.yml @@ -0,0 +1,5 @@ +--- +- name: Install curated baseline packages + ansible.builtin.package: + name: "{{ curated_default_packages }}" + state: present diff --git a/ansible/site.yml b/ansible/site.yml new file mode 100644 index 0000000..a1ef0bb --- /dev/null +++ b/ansible/site.yml @@ -0,0 +1,6 @@ +--- +- name: Apply Ghostdrive curated baseline + hosts: ghostdrive_target + become: true + roles: + - curated_default diff --git a/config/defaults.yaml b/config/defaults.yaml new file mode 100644 index 0000000..ea93b98 --- /dev/null +++ b/config/defaults.yaml @@ -0,0 +1,3 @@ +ubuntu_release: "24.04" +target_arch: "x86_64" +baseline_profile: "curated-default" \ No newline at end of file diff --git a/config/schema.json b/config/schema.json new file mode 100644 index 0000000..987d18d --- /dev/null +++ b/config/schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "ubuntu_release", + "target_arch", + "baseline_profile" + ], + "properties": { + "ubuntu_release": { + "enum": [ + "24.04" + ] + }, + "target_arch": { + "enum": [ + "x86_64" + ] + }, + "baseline_profile": { + "enum": [ + "curated-default" + ] + } + }, + "additionalProperties": true +} \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f70bcf2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,34 @@ +# Architecture + +Ghostdrive v1 is an install-to-USB pipeline for Ubuntu 24.04 x86_64. + +## Modules + +- Installer Orchestrator: `scripts/build/install_to_usb.sh` +- Seed Renderer: `scripts/build/render_autoinstall_seed.sh` +- Provisioner: `scripts/provision/apply_baseline.sh` and `ansible/` +- Image Pipeline: `scripts/image/generalize.sh`, `scripts/image/capture.sh`, `scripts/image/write_manifest.sh`, `scripts/image/restore.sh` +- Verifier: `scripts/verify/boot_smoke_bios.sh`, `scripts/verify/boot_smoke_uefi.sh`, and Bats tests under `tests/bats/` + +## Data Flow + +1. Validate the user configuration (`scripts/validate-config.sh`) against `config/schema.json`. +2. Prepare install orchestration for a specific target block device. +3. Apply the curated baseline profile with Ansible to the target root filesystem. +4. Generalize machine identity artifacts before capture. +5. Capture a compressed image and write checksum/size metadata. +6. Restore the image to additional USB devices. +7. Verify behavior in CI and with boot smoke checks. + +## Constraints + +- Build host: Linux only. +- Target OS and architecture: Ubuntu 24.04 LTS x86_64. +- Output model: fully installed USB image. +- Repository policy: no secrets in source control. + +## Operational Boundaries + +- Device writes are destructive and require explicit `--device` selection. +- Image scripts require real block devices and image files. +- Boot smoke scripts validate firmware-level boot signals for BIOS and UEFI paths. \ No newline at end of file diff --git a/docs/cloning-workflow.md b/docs/cloning-workflow.md new file mode 100644 index 0000000..88a8f20 --- /dev/null +++ b/docs/cloning-workflow.md @@ -0,0 +1,51 @@ +# Cloning Workflow + +This workflow captures a golden image from a prepared USB install and restores it to additional devices. + +## 1. Generalize Before Capture + +Generalize the mounted target root so clones do not share host identity artifacts. + +```bash +scripts/image/generalize.sh --root /mnt/ghostdrive-root +``` + +The generalization step clears machine identity and volatile runtime artifacts: + +- `/etc/machine-id` +- `/var/lib/dbus/machine-id` +- `/etc/ssh/ssh_host_*` +- `/var/log/*` + +## 2. Capture Compressed Image + +```bash +scripts/image/capture.sh --device /dev/sdX --output artifacts/ghostdrive.img.zst +``` + +## 3. Write Artifact Manifest + +```bash +scripts/image/write_manifest.sh --image artifacts/ghostdrive.img.zst --output artifacts/ghostdrive.manifest.json +``` + +Manifest fields include image path, SHA-256 checksum, and size in bytes. + +## 4. Restore to a New USB Device + +```bash +scripts/image/restore.sh --image artifacts/ghostdrive.img.zst --device /dev/sdY --manifest artifacts/ghostdrive.manifest.json +``` + +Restore requires integrity verification on every run. Provide either `--manifest` or `--sha256`. + +## 5. Validate Restored Media + +- Confirm device partitions and filesystem mount cleanly. +- Run BIOS and UEFI boot smoke scripts when validating pipeline behavior. +- Perform first-boot checks on hardware before fleet replication. + +## Safety Notes + +- `capture.sh` and `restore.sh` require real block devices. +- Always verify selected `--device` values before running destructive commands. \ No newline at end of file diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md new file mode 100644 index 0000000..64b3bbd --- /dev/null +++ b/docs/compatibility-matrix.md @@ -0,0 +1,19 @@ +# Compatibility Matrix + +| Area | Supported in v1 | Notes | +|---|---|---| +| Build host OS | Linux | Ubuntu LTS recommended | +| Build host architecture | x86_64 | CI runs on Ubuntu 24.04 x86_64 | +| Target OS | Ubuntu 24.04 LTS | Fixed by config schema | +| Target architecture | x86_64 | Fixed by config schema | +| Output format | Installed USB image | Captured as compressed `.img.zst` | +| Provisioning | Ansible curated baseline | `ansible/site.yml` | +| VM boot verification | BIOS and UEFI smoke checks | QEMU + OVMF | +| Cross-platform builders | No | Out of scope for v1 | +| ARM targets | No | Out of scope for v1 | + +## Validation Sources + +- Config constraints: `config/schema.json` +- CI checks: `.github/workflows/ci.yml` +- Boot smoke scripts: `scripts/verify/boot_smoke_bios.sh`, `scripts/verify/boot_smoke_uefi.sh` \ No newline at end of file diff --git a/docs/provisioning.md b/docs/provisioning.md new file mode 100644 index 0000000..b736bbf --- /dev/null +++ b/docs/provisioning.md @@ -0,0 +1,39 @@ +# Provisioning Runbook + +This runbook applies the curated Ghostdrive baseline to an installed target root filesystem. + +## Inputs + +- Target root mount path (required): `--target-root` +- Optional inventory override: `--inventory` +- Baseline playbook: `ansible/site.yml` +- Baseline profile in config: `curated-default` + +## Prerequisites + +- Linux build host. +- `ansible-playbook` available on `PATH`. +- Target root filesystem mounted (for example `/mnt/ghostdrive-root`). + +## Apply Baseline + +```bash +scripts/provision/apply_baseline.sh --target-root /mnt/ghostdrive-root +``` + +The script creates a temporary chroot inventory and runs: + +```bash +ansible-playbook -i ansible/inventory/local.ini -i ansible/site.yml -e target_root=/mnt/ghostdrive-root +``` + +## Common Failures + +- `--target-root is required`: pass a mount path. +- `target root does not exist`: mount the target filesystem first. +- `inventory does not exist`: check the `--inventory` path. + +## Post-Run Checks + +- Confirm baseline packages and configuration are present on target. +- Continue to generalization and image capture workflow. \ No newline at end of file diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..d993f12 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,37 @@ +# Threat Model + +## Scope + +Ghostdrive v1 produces cloneable Ubuntu USB images for engineering environments. + +Assumptions: + +- Build host is Linux. +- Target is Ubuntu 24.04 x86_64. +- Repository stores no credentials, keys, or other secrets. + +## Primary Risks + +- Cloned identity collisions from shared machine IDs or SSH host keys. +- Accidental overwrite of the wrong block device during capture or restore. +- Artifact tampering between capture and restore. +- Configuration drift from unvalidated config files. + +## Mitigations + +- Run `scripts/image/generalize.sh` before image capture. +- Require explicit `--device` flags for destructive commands. +- Publish and verify manifest checksums generated by `scripts/image/write_manifest.sh`. +- Enforce config validation with `scripts/validate-config.sh` in local and CI workflows. + +## Operational Controls + +- Keep artifact storage access controlled and auditable. +- Require operator verification of source and destination devices. +- Run boot smoke checks as a pre-release gate. + +## Out of Scope for v1 + +- Windows/macOS build hosts. +- ARM targets. +- Secret injection and secret management inside this repository. \ No newline at end of file diff --git a/scripts/build/install_to_usb.sh b/scripts/build/install_to_usb.sh new file mode 100755 index 0000000..144ca7a --- /dev/null +++ b/scripts/build/install_to_usb.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +# shellcheck source=scripts/lib/common.sh +source "$repo_root/scripts/lib/common.sh" + +CONFIG="" +DEVICE="" +WORKDIR="" +YES=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --config) + [[ $# -ge 2 ]] || { echo "missing value for --config"; exit 2; } + CONFIG="$2" + shift 2 + ;; + --device) + [[ $# -ge 2 ]] || { echo "missing value for --device"; exit 2; } + DEVICE="$2" + shift 2 + ;; + --workdir) + [[ $# -ge 2 ]] || { echo "missing value for --workdir"; exit 2; } + WORKDIR="$2" + shift 2 + ;; + --yes) + YES=1 + shift + ;; + *) + echo "unknown argument: $1" + exit 2 + ;; + esac +done + +[[ -n "$DEVICE" ]] || { echo "--device is required"; exit 2; } +[[ -n "$CONFIG" ]] || { echo "--config is required"; exit 2; } + +"$repo_root/scripts/validate-config.sh" "$CONFIG" + +confirm_destructive_action "$DEVICE" "$YES" "install" || exit $? + +if ! is_ci_mode && [[ ! -b "$DEVICE" ]]; then + echo "device is not a block device: $DEVICE" + exit 2 +fi + +mkdir -p "${WORKDIR:-.ghostdrive-work}" +echo "install orchestration prepared" diff --git a/scripts/build/render_autoinstall_seed.sh b/scripts/build/render_autoinstall_seed.sh new file mode 100755 index 0000000..ce5bca9 --- /dev/null +++ b/scripts/build/render_autoinstall_seed.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +CONFIG="" +OUTDIR="" + +read_config_value() { + local key="$1" + local value + + value="$(awk -v key="$key" ' + function trim(s) { + sub(/^[ \t]+/, "", s) + sub(/[ \t]+$/, "", s) + return s + } + + $0 ~ "^[[:space:]]*" key ":[[:space:]]*" { + line = $0 + sub("^[[:space:]]*" key ":[[:space:]]*", "", line) + + out = "" + in_single = 0 + in_double = 0 + + for (i = 1; i <= length(line); i++) { + ch = substr(line, i, 1) + + if (ch == "\"" && !in_single) { + in_double = !in_double + } else if (ch == "\047" && !in_double) { + in_single = !in_single + } + + if (ch == "#" && !in_single && !in_double) { + break + } + + out = out ch + } + + out = trim(out) + + if ((substr(out, 1, 1) == "\"" && substr(out, length(out), 1) == "\"") || + (substr(out, 1, 1) == "\047" && substr(out, length(out), 1) == "\047")) { + out = substr(out, 2, length(out) - 2) + } + + out = trim(out) + print out + found = 1 + exit + } + + END { + if (!found) { + exit 1 + } + } + ' "$CONFIG")" + if [[ -z "$value" ]]; then + echo "missing required config key: $key" >&2 + exit 1 + fi + + printf '%s\n' "$value" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --config) + [[ $# -ge 2 ]] || { echo "missing value for --config"; exit 2; } + CONFIG="$2" + shift 2 + ;; + --outdir) + [[ $# -ge 2 ]] || { echo "missing value for --outdir"; exit 2; } + OUTDIR="$2" + shift 2 + ;; + *) + echo "unknown argument: $1" + exit 2 + ;; + esac +done + +[[ -n "$CONFIG" ]] || { echo "--config is required"; exit 2; } +[[ -n "$OUTDIR" ]] || { echo "--outdir is required"; exit 2; } + +"$repo_root/scripts/validate-config.sh" "$CONFIG" + +ubuntu_release="$(read_config_value ubuntu_release)" +target_arch="$(read_config_value target_arch)" +baseline_profile="$(read_config_value baseline_profile)" + +mkdir -p "$OUTDIR" +cat >"$OUTDIR/user-data" < --output ' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +device="" +output="" +yes=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --device) + require_value "$1" "${2:-}" + device="$2" + shift 2 + ;; + --output) + require_value "$1" "${2:-}" + output="$2" + shift 2 + ;; + --yes) + yes=1 + shift + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$device" ]]; then + printf '%s\n' '--device is required' >&2 + usage >&2 + exit 2 +fi + +if [[ -z "$output" ]]; then + printf '%s\n' '--output is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -b "$device" ]]; then + printf 'device is not a block device: %s\n' "$device" >&2 + exit 2 +fi + +confirm_destructive_action "$device" "$yes" "capture" || exit $? + +output_dir="$(dirname "$output")" +mkdir -p "$output_dir" + +dd if="$device" bs=16M status=progress | zstd -19 -T0 -o "$output" diff --git a/scripts/image/generalize.sh b/scripts/image/generalize.sh new file mode 100755 index 0000000..e38b0f4 --- /dev/null +++ b/scripts/image/generalize.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf '%s\n' 'usage: generalize.sh --root ' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +ensure_not_symlink() { + local path="$1" + local label="$2" + if [[ -L "$path" ]]; then + printf 'refusing to generalize symlinked path (%s): %s\n' "$label" "$path" >&2 + exit 2 + fi +} + +root="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --root) + require_value "$1" "${2:-}" + root="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$root" ]]; then + printf '%s\n' '--root is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -d "$root" ]]; then + printf 'root path does not exist: %s\n' "$root" >&2 + exit 2 +fi + +root_realpath="$(readlink -f "$root")" +if [[ "$root_realpath" == "/" ]]; then + printf 'refusing to generalize host root: %s\n' "$root_realpath" >&2 + exit 2 +fi + +ensure_not_symlink "$root_realpath/etc" "etc" +ensure_not_symlink "$root_realpath/etc/ssh" "etc/ssh" +ensure_not_symlink "$root_realpath/var" "var" +ensure_not_symlink "$root_realpath/var/log" "var/log" +ensure_not_symlink "$root_realpath/var/lib" "var/lib" +ensure_not_symlink "$root_realpath/var/lib/dbus" "var/lib/dbus" +ensure_not_symlink "$root_realpath/etc/machine-id" "etc/machine-id" +ensure_not_symlink "$root_realpath/var/lib/dbus/machine-id" "var/lib/dbus/machine-id" + +truncate -s 0 "$root_realpath/etc/machine-id" +rm -f "$root_realpath/var/lib/dbus/machine-id" +rm -f "$root_realpath"/etc/ssh/ssh_host_* +rm -rf "$root_realpath"/var/log/* diff --git a/scripts/image/restore.sh b/scripts/image/restore.sh new file mode 100755 index 0000000..1a4433f --- /dev/null +++ b/scripts/image/restore.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +# shellcheck source=scripts/lib/common.sh +source "$repo_root/scripts/lib/common.sh" + +usage() { + printf '%s\n' 'usage: restore.sh --image --device (--manifest | --sha256 ) [--yes]' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +image="" +device="" +manifest="" +expected_sha="" +yes=0 + +manifest_sha256() { + local manifest_path="$1" + + python3 - "$manifest_path" <<'PY' +import json +import sys + +path = sys.argv[1] +with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + +value = data.get("sha256", "") +if not isinstance(value, str) or not value.strip(): + raise SystemExit(1) + +print(value.strip()) +PY +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + require_value "$1" "${2:-}" + image="$2" + shift 2 + ;; + --device) + require_value "$1" "${2:-}" + device="$2" + shift 2 + ;; + --manifest) + require_value "$1" "${2:-}" + manifest="$2" + shift 2 + ;; + --sha256) + require_value "$1" "${2:-}" + expected_sha="$2" + shift 2 + ;; + --yes) + yes=1 + shift + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$image" ]]; then + printf '%s\n' '--image is required' >&2 + usage >&2 + exit 2 +fi + +if [[ -z "$device" ]]; then + printf '%s\n' '--device is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -f "$image" ]]; then + printf 'image file does not exist: %s\n' "$image" >&2 + exit 2 +fi + +if [[ -n "$manifest" ]]; then + if [[ ! -f "$manifest" ]]; then + printf 'manifest file does not exist: %s\n' "$manifest" >&2 + exit 2 + fi + + if ! manifest_expected_sha="$(manifest_sha256 "$manifest")"; then + printf 'manifest is missing a valid sha256: %s\n' "$manifest" >&2 + exit 2 + fi + + if [[ -n "$expected_sha" && "$expected_sha" != "$manifest_expected_sha" ]]; then + printf 'provided --sha256 does not match manifest sha256\n' >&2 + exit 2 + fi + + expected_sha="$manifest_expected_sha" +fi + +if [[ -z "$manifest" && -z "$expected_sha" ]]; then + printf '%s\n' 'one of --manifest or --sha256 is required' >&2 + usage >&2 + exit 2 +fi + +if [[ -n "$expected_sha" ]]; then + actual_sha="$(sha256sum "$image" | awk '{print $1}')" + if [[ "$actual_sha" != "$expected_sha" ]]; then + printf 'image checksum mismatch: expected %s, got %s\n' "$expected_sha" "$actual_sha" >&2 + exit 1 + fi +fi + +if [[ ! -b "$device" ]]; then + printf 'device is not a block device: %s\n' "$device" >&2 + exit 2 +fi + +confirm_destructive_action "$device" "$yes" "restore" || exit $? + +zstd -dc "$image" | dd of="$device" bs=16M status=progress conv=fsync diff --git a/scripts/image/write_manifest.sh b/scripts/image/write_manifest.sh new file mode 100755 index 0000000..d7fbd42 --- /dev/null +++ b/scripts/image/write_manifest.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf '%s\n' 'usage: write_manifest.sh --image --output ' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +image="" +output="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + require_value "$1" "${2:-}" + image="$2" + shift 2 + ;; + --output) + require_value "$1" "${2:-}" + output="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$image" ]]; then + printf '%s\n' '--image is required' >&2 + usage >&2 + exit 2 +fi + +if [[ -z "$output" ]]; then + printf '%s\n' '--output is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -f "$image" ]]; then + printf 'image file does not exist: %s\n' "$image" >&2 + exit 2 +fi + +sha256="$(sha256sum "$image" | awk '{print $1}')" +size_bytes="$(wc -c < "$image" | tr -d ' ')" + +output_dir="$(dirname "$output")" +mkdir -p "$output_dir" + +python3 - "$image" "$sha256" "$size_bytes" "$output" <<'PY' +import json +import sys + +image, sha256, size_bytes, output = sys.argv[1:5] +payload = { + "image": image, + "sha256": sha256, + "size_bytes": int(size_bytes), +} +with open(output, "w", encoding="utf-8") as fh: + json.dump(payload, fh, separators=(",", ":")) + fh.write("\n") +PY diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..30fa085 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +require_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +log_info() { + printf '[INFO] %s\n' "$1" +} + +log_error() { + printf '[ERROR] %s\n' "$1" >&2 +} + +is_ci_mode() { + case "${CI:-}" in + 1|true|TRUE|yes|YES) + return 0 + ;; + esac + + case "${GITHUB_ACTIONS:-}" in + 1|true|TRUE|yes|YES) + return 0 + ;; + esac + + return 1 +} + +confirm_destructive_action() { + local device="$1" + local yes_flag="$2" + local action_name="$3" + local timeout_seconds="${GHOSTDRIVE_CONFIRM_TIMEOUT_SECONDS:-30}" + local confirmation="" + + log_info "$action_name target device: $device" + + if [ "$yes_flag" = "1" ]; then + log_info "confirmation bypassed via --yes" + return 0 + fi + + if is_ci_mode; then + log_info "CI mode detected; skipping interactive confirmation" + return 0 + fi + + if [ ! -t 0 ] || [ ! -t 1 ]; then + log_error "non-interactive mode requires --yes for destructive operations" + return 2 + fi + + printf 'Type YES to continue within %s seconds: ' "$timeout_seconds" + if ! IFS= read -r -t "$timeout_seconds" confirmation; then + log_error "confirmation timed out or unavailable; aborting" + return 2 + fi + if [ "$confirmation" != "YES" ]; then + log_error "confirmation declined; aborting" + return 2 + fi + + return 0 +} diff --git a/scripts/provision/apply_baseline.sh b/scripts/provision/apply_baseline.sh new file mode 100755 index 0000000..a34d924 --- /dev/null +++ b/scripts/provision/apply_baseline.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +target_root="" +inventory="$repo_root/ansible/inventory/local.ini" + +usage() { + printf '%s\n' 'usage: apply_baseline.sh --target-root [--inventory ]' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --target-root) + require_value "$1" "${2:-}" + target_root="$2" + shift 2 + ;; + --inventory) + require_value "$1" "${2:-}" + inventory="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$target_root" ]]; then + printf '%s\n' '--target-root is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -d "$target_root" ]]; then + printf 'target root does not exist: %s\n' "$target_root" >&2 + exit 2 +fi + +if [[ ! -f "$inventory" ]]; then + printf 'inventory does not exist: %s\n' "$inventory" >&2 + exit 2 +fi + +tmp_inventory="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-chroot-inventory.XXXXXX")" +trap 'rm -f "$tmp_inventory"' EXIT + +cat >"$tmp_inventory" <" + exit 1 +fi + +config_path="$1" +schema_path="$repo_root/config/schema.json" + +if ! check-jsonschema --schemafile "$schema_path" "$config_path"; then + log_error "config validation failed for: $config_path" + exit 1 +fi \ No newline at end of file diff --git a/scripts/verify/boot_smoke_bios.sh b/scripts/verify/boot_smoke_bios.sh new file mode 100755 index 0000000..3d4039f --- /dev/null +++ b/scripts/verify/boot_smoke_bios.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="" +TIMEOUT_SECONDS=20 + +usage() { + printf '%s\n' 'usage: boot_smoke_bios.sh --image [--timeout-seconds ]' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + require_value "$1" "${2:-}" + IMAGE="$2" + shift 2 + ;; + --timeout-seconds) + require_value "$1" "${2:-}" + TIMEOUT_SECONDS="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$IMAGE" ]]; then + printf '%s\n' '--image is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -f "$IMAGE" ]]; then + printf 'image does not exist: %s\n' "$IMAGE" >&2 + exit 2 +fi + +if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then + printf '%s\n' 'qemu-system-x86_64 is required but was not found on PATH' >&2 + exit 1 +fi + +log_file="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-bios-boot-smoke.XXXXXX")" +trap 'rm -f "$log_file"' EXIT + +# Require explicit boot progression signals, and fail hard on known negatives. +positive_signal_pattern='GNU GRUB|Welcome to GRUB|ISOLINUX|SYSLINUX|Loading Linux|Linux version' +negative_signal_pattern='No bootable device|Boot failed|Could not read from the boot medium|PXE-E[0-9]+' + +set +e +timeout "$TIMEOUT_SECONDS" qemu-system-x86_64 \ + -machine pc \ + -m 1024 \ + -nographic \ + -serial stdio \ + -monitor none \ + -display none \ + -no-reboot \ + -snapshot \ + -drive "file=$IMAGE,format=raw,if=virtio" \ + >"$log_file" 2>&1 +exit_code=$? +set -e + +if grep -Eiq "$negative_signal_pattern" "$log_file"; then + printf 'bios boot smoke observed negative boot signal\n' >&2 + printf '%s\n' 'recent boot log:' >&2 + tail -n 40 "$log_file" >&2 || true + exit 1 +fi + +if grep -Eiq "$positive_signal_pattern" "$log_file" && [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then + printf 'bios boot smoke completed with explicit boot progression signal (exit=%s)\n' "$exit_code" + exit 0 +fi + +if [[ "$exit_code" -eq 124 ]]; then + printf 'bios boot smoke timed out without detecting explicit boot progression signal\n' >&2 +else + printf 'bios boot smoke failed (exit=%s)\n' "$exit_code" >&2 +fi + +printf '%s\n' 'recent boot log:' >&2 +tail -n 40 "$log_file" >&2 || true +exit "$exit_code" diff --git a/scripts/verify/boot_smoke_uefi.sh b/scripts/verify/boot_smoke_uefi.sh new file mode 100755 index 0000000..558235e --- /dev/null +++ b/scripts/verify/boot_smoke_uefi.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="" +TIMEOUT_SECONDS=20 + +usage() { + printf '%s\n' 'usage: boot_smoke_uefi.sh --image [--timeout-seconds ]' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + require_value "$1" "${2:-}" + IMAGE="$2" + shift 2 + ;; + --timeout-seconds) + require_value "$1" "${2:-}" + TIMEOUT_SECONDS="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$IMAGE" ]]; then + printf '%s\n' '--image is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -f "$IMAGE" ]]; then + printf 'image does not exist: %s\n' "$IMAGE" >&2 + exit 2 +fi + +if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then + printf '%s\n' 'qemu-system-x86_64 is required but was not found on PATH' >&2 + exit 1 +fi + +ovmf_code="${OVMF_CODE_PATH:-}" +ovmf_vars="${OVMF_VARS_PATH:-}" + +if [[ -z "$ovmf_code" ]]; then + for candidate in /usr/share/OVMF/OVMF_CODE.fd /usr/share/edk2/ovmf/OVMF_CODE.fd; do + if [[ -f "$candidate" ]]; then + ovmf_code="$candidate" + break + fi + done +fi + +if [[ -z "$ovmf_vars" ]]; then + for candidate in /usr/share/OVMF/OVMF_VARS.fd /usr/share/edk2/ovmf/OVMF_VARS.fd; do + if [[ -f "$candidate" ]]; then + ovmf_vars="$candidate" + break + fi + done +fi + +if [[ -z "$ovmf_code" || -z "$ovmf_vars" ]]; then + printf '%s\n' 'OVMF firmware files were not found; install ovmf/edk2-ovmf packages' >&2 + exit 1 +fi + +vars_copy="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-ovmf-vars.XXXXXX")" +log_file="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-uefi-boot-smoke.XXXXXX")" +trap 'rm -f "$vars_copy" "$log_file"' EXIT +cp "$ovmf_vars" "$vars_copy" + +# Require explicit boot progression signals, and fail hard on known negatives. +positive_signal_pattern='GNU GRUB|Welcome to GRUB|ISOLINUX|SYSLINUX|Loading Linux|Linux version|EFI stub:' +negative_signal_pattern='No bootable option or device was found|BdsDxe: failed to load|Could not read from the boot medium|iPXE' + +set +e +timeout "$TIMEOUT_SECONDS" qemu-system-x86_64 \ + -machine q35 \ + -m 1024 \ + -nographic \ + -serial stdio \ + -monitor none \ + -display none \ + -no-reboot \ + -snapshot \ + -drive if=pflash,format=raw,readonly=on,file="$ovmf_code" \ + -drive if=pflash,format=raw,file="$vars_copy" \ + -drive "file=$IMAGE,format=raw,if=virtio" \ + >"$log_file" 2>&1 +exit_code=$? +set -e + +if grep -Eiq "$negative_signal_pattern" "$log_file"; then + printf 'uefi boot smoke observed negative boot signal\n' >&2 + printf '%s\n' 'recent boot log:' >&2 + tail -n 40 "$log_file" >&2 || true + exit 1 +fi + +if grep -Eiq "$positive_signal_pattern" "$log_file" && [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then + printf 'uefi boot smoke completed with explicit boot progression signal (exit=%s)\n' "$exit_code" + exit 0 +fi + +if [[ "$exit_code" -eq 124 ]]; then + printf 'uefi boot smoke timed out without detecting explicit boot progression signal\n' >&2 +else + printf 'uefi boot smoke failed (exit=%s)\n' "$exit_code" >&2 +fi + +printf '%s\n' 'recent boot log:' >&2 +tail -n 40 "$log_file" >&2 || true +exit "$exit_code" diff --git a/tests/bats/ansible_syntax.bats b/tests/bats/ansible_syntax.bats new file mode 100644 index 0000000..c5372ff --- /dev/null +++ b/tests/bats/ansible_syntax.bats @@ -0,0 +1,66 @@ +#!/usr/bin/env bats + +@test "ansible playbook passes syntax check" { + run ansible-playbook -i ansible/inventory/local.ini ansible/site.yml --syntax-check + [ "$status" -eq 0 ] +} + +@test "apply_baseline rejects missing value for --target-root" { + run scripts/provision/apply_baseline.sh --target-root + [ "$status" -eq 2 ] + [[ "$output" == *"missing value for --target-root"* ]] +} + +@test "apply_baseline rejects missing value for --inventory" { + run scripts/provision/apply_baseline.sh --target-root / --inventory + [ "$status" -eq 2 ] + [[ "$output" == *"missing value for --inventory"* ]] +} + +@test "apply_baseline invokes ansible with chroot inventory targeting rootfs" { + stub_log="$BATS_TEST_TMPDIR/ansible_stub.log" + inventory_dump="$BATS_TEST_TMPDIR/generated_inventory.log" + stub_bin="$BATS_TEST_TMPDIR/ansible-playbook" + + cat >"$stub_bin" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "$BATS_TEST_TMPDIR/ansible_stub.log" +for arg in "$@"; do + if [[ "$arg" == *ghostdrive-chroot-inventory.* ]]; then + cat "$arg" > "$BATS_TEST_TMPDIR/generated_inventory.log" + break + fi +done +exit 0 +EOF + chmod +x "$stub_bin" + + export PATH="$BATS_TEST_TMPDIR:$PATH" + + rootfs_dir="$BATS_TEST_TMPDIR/rootfs" + custom_inventory="$BATS_TEST_TMPDIR/custom.ini" + mkdir -p "$rootfs_dir" + printf '%s\n' '[all]' > "$custom_inventory" + + run scripts/provision/apply_baseline.sh --target-root "$rootfs_dir" --inventory "$custom_inventory" + [ "$status" -eq 0 ] + + run grep -F -- "-i" "$stub_log" + [ "$status" -eq 0 ] + + run grep -F -- "$custom_inventory" "$stub_log" + [ "$status" -eq 0 ] + + run awk '/ghostdrive-chroot-inventory\./ { print; exit }' "$stub_log" + [ "$status" -eq 0 ] + + run grep -F -- "ansible_connection=chroot" "$inventory_dump" + [ "$status" -eq 0 ] + + run grep -F -- "ansible_host=$rootfs_dir" "$inventory_dump" + [ "$status" -eq 0 ] + + run grep -F -- "target_root=$rootfs_dir" "$stub_log" + [ "$status" -eq 0 ] +} diff --git a/tests/bats/boot_smoke_signals.bats b/tests/bats/boot_smoke_signals.bats new file mode 100644 index 0000000..504f7c8 --- /dev/null +++ b/tests/bats/boot_smoke_signals.bats @@ -0,0 +1,106 @@ +#!/usr/bin/env bats + +setup() { + TEST_TMPDIR="$(mktemp -d)" + export TEST_TMPDIR + MOCK_BIN="$TEST_TMPDIR/mockbin" + mkdir -p "$MOCK_BIN" + + cat >"$MOCK_BIN/qemu-system-x86_64" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$MOCK_BIN/qemu-system-x86_64" + + cat >"$MOCK_BIN/timeout" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "${MOCK_TIMEOUT_OUTPUT:-}" +exit "${MOCK_TIMEOUT_EXIT:-124}" +EOF + chmod +x "$MOCK_BIN/timeout" + + IMAGE="$TEST_TMPDIR/disk.img" + truncate -s 16M "$IMAGE" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +@test "bios verify fails when timeout occurs without a boot signal" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="SeaBIOS (version rel-1.16.3)" + + run scripts/verify/boot_smoke_bios.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -ne 0 ] + [[ "$output" == *"timed out without detecting explicit boot progression signal"* ]] +} + +@test "bios verify succeeds on timeout when a boot signal is observed" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="GNU GRUB 2.06" + + run scripts/verify/boot_smoke_bios.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -eq 0 ] + [[ "$output" == *"completed with explicit boot progression signal"* ]] +} + +@test "bios verify fails when negative signal appears even with positive token" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="GNU GRUB\nNo bootable device" + + run scripts/verify/boot_smoke_bios.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -ne 0 ] + [[ "$output" == *"observed negative boot signal"* ]] +} + +@test "uefi verify fails when timeout occurs without a boot signal" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="still nothing useful" + + OVMF_CODE="$TEST_TMPDIR/OVMF_CODE.fd" + OVMF_VARS="$TEST_TMPDIR/OVMF_VARS.fd" + : >"$OVMF_CODE" + : >"$OVMF_VARS" + + run env OVMF_CODE_PATH="$OVMF_CODE" OVMF_VARS_PATH="$OVMF_VARS" \ + scripts/verify/boot_smoke_uefi.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -ne 0 ] + [[ "$output" == *"timed out without detecting explicit boot progression signal"* ]] +} + +@test "uefi verify succeeds on timeout when a boot signal is observed" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="EFI stub: Booting Linux Kernel" + + OVMF_CODE="$TEST_TMPDIR/OVMF_CODE.fd" + OVMF_VARS="$TEST_TMPDIR/OVMF_VARS.fd" + : >"$OVMF_CODE" + : >"$OVMF_VARS" + + run env OVMF_CODE_PATH="$OVMF_CODE" OVMF_VARS_PATH="$OVMF_VARS" \ + scripts/verify/boot_smoke_uefi.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -eq 0 ] + [[ "$output" == *"completed with explicit boot progression signal"* ]] +} + +@test "uefi verify fails when negative signal appears" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="iPXE initialising devices" + + OVMF_CODE="$TEST_TMPDIR/OVMF_CODE.fd" + OVMF_VARS="$TEST_TMPDIR/OVMF_VARS.fd" + : >"$OVMF_CODE" + : >"$OVMF_VARS" + + run env OVMF_CODE_PATH="$OVMF_CODE" OVMF_VARS_PATH="$OVMF_VARS" \ + scripts/verify/boot_smoke_uefi.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -ne 0 ] + [[ "$output" == *"observed negative boot signal"* ]] +} diff --git a/tests/bats/ci_commands.bats b/tests/bats/ci_commands.bats new file mode 100644 index 0000000..59b483b --- /dev/null +++ b/tests/bats/ci_commands.bats @@ -0,0 +1,8 @@ +#!/usr/bin/env bats + +@test "make ci-check invokes validation and tests" { + run make -n ci-check + [ "$status" -eq 0 ] + [[ "$output" == *"scripts/validate-config.sh config/defaults.yaml"* ]] + [[ "$output" == *"bats tests/bats"* ]] +} diff --git a/tests/bats/config_validation.bats b/tests/bats/config_validation.bats new file mode 100644 index 0000000..a377c6a --- /dev/null +++ b/tests/bats/config_validation.bats @@ -0,0 +1,33 @@ +#!/usr/bin/env bats + +setup() { + TEST_TMPDIR="$(mktemp -d)" + MOCK_BIN="$TEST_TMPDIR/mockbin" + mkdir -p "$MOCK_BIN" + + cat >"$MOCK_BIN/check-jsonschema" <<'EOF' +#!/usr/bin/env bash +target="${@: -1}" +if [[ "$target" == *"invalid-config.yaml" ]]; then + echo "mock schema validation failed" >&2 + exit 1 +fi +exit 0 +EOF + chmod +x "$MOCK_BIN/check-jsonschema" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +@test "valid config passes schema validation" { + run env PATH="$MOCK_BIN:$PATH" scripts/validate-config.sh config/defaults.yaml + [ "$status" -eq 0 ] +} + +@test "invalid config fails schema validation" { + run env PATH="$MOCK_BIN:$PATH" scripts/validate-config.sh tests/fixtures/invalid-config.yaml + [ "$status" -eq 1 ] + [[ "$output" == *"config validation failed"* ]] +} \ No newline at end of file diff --git a/tests/bats/docs_presence.bats b/tests/bats/docs_presence.bats new file mode 100644 index 0000000..87564a1 --- /dev/null +++ b/tests/bats/docs_presence.bats @@ -0,0 +1,9 @@ +#!/usr/bin/env bats + +@test "required operator docs exist" { + [ -f docs/architecture.md ] + [ -f docs/provisioning.md ] + [ -f docs/cloning-workflow.md ] + [ -f docs/threat-model.md ] + [ -f docs/compatibility-matrix.md ] +} \ No newline at end of file diff --git a/tests/bats/generalize_safety.bats b/tests/bats/generalize_safety.bats new file mode 100644 index 0000000..53f6515 --- /dev/null +++ b/tests/bats/generalize_safety.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats + +setup() { + ROOT_DIR="$(mktemp -d)" + mkdir -p "$ROOT_DIR/etc/ssh" "$ROOT_DIR/var/lib/dbus" "$ROOT_DIR/var/log" + : >"$ROOT_DIR/etc/machine-id" + : >"$ROOT_DIR/var/lib/dbus/machine-id" + : >"$ROOT_DIR/etc/ssh/ssh_host_rsa_key" + : >"$ROOT_DIR/var/log/messages" +} + +teardown() { + rm -rf "$ROOT_DIR" +} + +@test "generalize rejects symlinked critical paths" { + rm -rf "$ROOT_DIR/var/log" + ln -s /tmp "$ROOT_DIR/var/log" + + run scripts/image/generalize.sh --root "$ROOT_DIR" + [ "$status" -eq 2 ] + [[ "$output" == *"symlinked path"* ]] +} + +@test "generalize clears expected identifiers on regular paths" { + run scripts/image/generalize.sh --root "$ROOT_DIR" + [ "$status" -eq 0 ] + + run test ! -e "$ROOT_DIR/var/lib/dbus/machine-id" + [ "$status" -eq 0 ] + + run test ! -e "$ROOT_DIR/etc/ssh/ssh_host_rsa_key" + [ "$status" -eq 0 ] + + run test ! -e "$ROOT_DIR/var/log/messages" + [ "$status" -eq 0 ] + + run test -f "$ROOT_DIR/etc/machine-id" + [ "$status" -eq 0 ] + + run test ! -s "$ROOT_DIR/etc/machine-id" + [ "$status" -eq 0 ] +} diff --git a/tests/bats/image_pipeline_args.bats b/tests/bats/image_pipeline_args.bats new file mode 100644 index 0000000..949bc8c --- /dev/null +++ b/tests/bats/image_pipeline_args.bats @@ -0,0 +1,137 @@ +#!/usr/bin/env bats + +setup() { + TEST_TMPDIR="$(mktemp -d)" + MOCK_BIN="$TEST_TMPDIR/mockbin" + mkdir -p "$MOCK_BIN" + + cat >"$MOCK_BIN/zstd" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "-dc" && -n "${2:-}" ]]; then + cat "$2" +else + cat +fi +EOF + chmod +x "$MOCK_BIN/zstd" + + cat >"$MOCK_BIN/dd" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +cat >/dev/null +EOF + chmod +x "$MOCK_BIN/dd" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +find_block_device() { + local candidate + for candidate in /dev/loop* /dev/nvme*n1 /dev/vd* /dev/sd* /dev/ram*; do + if [[ -b "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +@test "capture requires --device and --output" { + run scripts/image/capture.sh --device /dev/sdb + [ "$status" -eq 2 ] + [[ "$output" == *"--output is required"* ]] +} + +@test "restore fails when manifest sha does not match image" { + workdir="$(mktemp -d)" + image="$workdir/test.img.zst" + manifest="$workdir/manifest.json" + printf 'not-an-image' >"$image" + cat >"$manifest" <<'EOF' +{"sha256":"deadbeef"} +EOF + + run scripts/image/restore.sh --image "$image" --device /dev/sdz --manifest "$manifest" --yes + [ "$status" -eq 1 ] + [[ "$output" == *"image checksum mismatch"* ]] + + rm -rf "$workdir" +} + +@test "restore fails when --sha256 conflicts with manifest sha" { + workdir="$(mktemp -d)" + image="$workdir/test.img.zst" + manifest="$workdir/manifest.json" + printf 'not-an-image' >"$image" + actual_sha="$(sha256sum "$image" | awk '{print $1}')" + cat >"$manifest" <"$image" + + run scripts/image/restore.sh --image "$image" --device /dev/sdz --yes + [ "$status" -eq 2 ] + [[ "$output" == *"one of --manifest or --sha256 is required"* ]] + + rm -rf "$workdir" +} + +@test "capture non-CI requires --yes before destructive execution" { + if ! block_device="$(find_block_device)"; then + skip "no block device available for destructive-guard test" + fi + + run env PATH="$MOCK_BIN:$PATH" scripts/image/capture.sh --device "$block_device" --output "$TEST_TMPDIR/capture.img.zst" + [ "$status" -eq 2 ] + [[ "$output" == *"requires --yes"* ]] +} + +@test "restore non-CI requires --yes before destructive execution" { + if ! block_device="$(find_block_device)"; then + skip "no block device available for destructive-guard test" + fi + + workdir="$(mktemp -d)" + image="$workdir/test.img.zst" + printf 'restore-payload' >"$image" + actual_sha="$(sha256sum "$image" | awk '{print $1}')" + + run env PATH="$MOCK_BIN:$PATH" scripts/image/restore.sh --image "$image" --device "$block_device" --sha256 "$actual_sha" + [ "$status" -eq 2 ] + [[ "$output" == *"requires --yes"* ]] + + rm -rf "$workdir" +} + +@test "restore CI permits no --yes when integrity input is provided" { + if ! block_device="$(find_block_device)"; then + skip "no block device available for destructive-guard test" + fi + + workdir="$(mktemp -d)" + image="$workdir/test.img.zst" + printf 'restore-payload' >"$image" + actual_sha="$(sha256sum "$image" | awk '{print $1}')" + + run env PATH="$MOCK_BIN:$PATH" CI=true scripts/image/restore.sh --image "$image" --device "$block_device" --sha256 "$actual_sha" + [ "$status" -eq 0 ] + [[ "$output" == *"CI mode detected; skipping interactive confirmation"* ]] + + rm -rf "$workdir" +} diff --git a/tests/bats/install_to_usb_args.bats b/tests/bats/install_to_usb_args.bats new file mode 100644 index 0000000..4dc0c2c --- /dev/null +++ b/tests/bats/install_to_usb_args.bats @@ -0,0 +1,59 @@ +#!/usr/bin/env bats + +setup() { + TEST_TMPDIR="$(mktemp -d)" + MOCK_BIN="$TEST_TMPDIR/mockbin" + mkdir -p "$MOCK_BIN" + + cat >"$MOCK_BIN/check-jsonschema" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$MOCK_BIN/check-jsonschema" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +@test "install_to_usb rejects missing --device" { + run env PATH="$MOCK_BIN:$PATH" scripts/build/install_to_usb.sh --config config/defaults.yaml + [ "$status" -eq 2 ] + [[ "$output" == *"--device is required"* ]] +} + +@test "install_to_usb requires --yes for non-interactive non-CI runs" { + run env PATH="$MOCK_BIN:$PATH" scripts/build/install_to_usb.sh --config config/defaults.yaml --device /dev/sdX + [ "$status" -eq 2 ] + [[ "$output" == *"requires --yes"* ]] +} + +@test "install_to_usb allows CI runs without --yes" { + run env PATH="$MOCK_BIN:$PATH" CI=true scripts/build/install_to_usb.sh --config config/defaults.yaml --device /dev/sdX --workdir "$BATS_TEST_TMPDIR/workdir" + [ "$status" -eq 0 ] + [[ "$output" == *"install orchestration prepared"* ]] +} + +@test "render_autoinstall_seed parses quoted values with trailing comments" { + config_file="$TEST_TMPDIR/quoted-config.yaml" + outdir="$TEST_TMPDIR/seed" + + cat >"$config_file" <<'EOF' +ubuntu_release: "24.04" # pinned release +target_arch: 'x86_64' # current architecture +baseline_profile: curated-default # unquoted with trailing comment +EOF + + run env PATH="$MOCK_BIN:$PATH" scripts/build/render_autoinstall_seed.sh --config "$config_file" --outdir "$outdir" + [ "$status" -eq 0 ] + [[ "$output" == *"seed rendered"* ]] + + run grep -F "# ghostdrive-config: ubuntu_release=24.04" "$outdir/user-data" + [ "$status" -eq 0 ] + + run grep -F "# ghostdrive-config: target_arch=x86_64" "$outdir/user-data" + [ "$status" -eq 0 ] + + run grep -F "# ghostdrive-config: baseline_profile=curated-default" "$outdir/user-data" + [ "$status" -eq 0 ] +} diff --git a/tests/bats/readme_safety_prereqs.bats b/tests/bats/readme_safety_prereqs.bats new file mode 100644 index 0000000..076b7c1 --- /dev/null +++ b/tests/bats/readme_safety_prereqs.bats @@ -0,0 +1,13 @@ +#!/usr/bin/env bats + +@test "README includes non-CI confirmation safety guidance" { + grep -F "For non-CI runs, require an interactive human confirmation after printing the selected block device before any destructive write step." README.md + grep -F "For non-CI usage, review the printed \`/dev/sdX\` target and complete the interactive confirmation before allowing the install step to proceed." README.md +} + +@test "README clarifies check-jsonschema user PATH requirement" { + grep -F 'python3 -m pip install --user check-jsonschema' README.md + grep -F 'export PATH="$HOME/.local/bin:$PATH"' README.md + grep -F 'command -v check-jsonschema' README.md + grep -F 'If `command -v check-jsonschema` prints nothing, your shell is missing `$HOME/.local/bin` on PATH.' README.md +} diff --git a/tests/bats/repo_scaffold.bats b/tests/bats/repo_scaffold.bats new file mode 100644 index 0000000..3288817 --- /dev/null +++ b/tests/bats/repo_scaffold.bats @@ -0,0 +1,7 @@ +#!/usr/bin/env bats + +@test "make test target exists" { + run make -n test + [ "$status" -eq 0 ] + [[ "$output" == *"bats tests/bats"* ]] +} diff --git a/tests/fixtures/invalid-config.yaml b/tests/fixtures/invalid-config.yaml new file mode 100644 index 0000000..293fd90 --- /dev/null +++ b/tests/fixtures/invalid-config.yaml @@ -0,0 +1,3 @@ +ubuntu_release: 22.04 +target_arch: arm64 +baseline_profile: custom \ No newline at end of file