diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0713530 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Real, machine-specific package lists live here — never pushed to the +# public repo. See packages/*.example for the tracked format reference. +/local/ diff --git a/RATIONALE.md b/RATIONALE.md new file mode 100644 index 0000000..cf496b3 --- /dev/null +++ b/RATIONALE.md @@ -0,0 +1,108 @@ +# Rationale + +Why a ~400-500 line orchestrator/subscript script set is worth more +than a one-line `sudo zypper install X Y Z`, and when it isn't. + +## The core argument + +The complexity here isn't overhead wrapped around a trivial task — +the logging and verification machinery *is* the actual value being +built. A one-liner installs packages. This project installs packages +**and produces a record of having done so correctly.** + +## What the pre-flight/test machinery actually buys you + +- **Audit trail.** Pre-flight state, exactly what was already + installed vs. newly installed, post-install verification per + package, and cache-cleanup outcome — all captured permanently in + `~/.local/logs/ceos_installer/`, rather than scrolling past in a + terminal and gone. +- **Transparency.** Nothing happens invisibly. Every check, skip, + install, and verification is logged as it happens. +- **No silent failure.** A bare `zypper install X Y Z` gives no + structured signal when one package among several fails or is + skipped — you find out later, indirectly, when something that + depended on it doesn't work. `test_package_available` and the + post-install `run_test` check surface that class of problem at the + point it happens, not downstream. +- **Correct handling of naming and dependency quirks.** Not + hypothetical — see `~/.local/opt/INSTALLER_NOTES.md`'s + `tinkerpad-stt` entry: `ffmpeg` doesn't exist as a bare package name + on Tumbleweed (only `ffmpeg-8` etc. do), and a naive + `zypper install ffmpeg` just fails with no clear diagnostic path. + The warn-not-fail design here exists specifically to surface that + kind of problem cleanly instead of aborting the whole run or + failing silently. +- **Reproducibility.** The package list itself is a declarative spec + of "what should be installed here." Rebuilding this machine, or + standing up a second one needing the same tooling, is "run this + list" instead of trying to recall what got installed by hand over + months. +- **Disaster recovery.** After a wipe or hardware failure, the list + plus its log history is the recovery runbook — not just what to + reinstall, but a record proving the last known-good state and + confirming each package actually verified present afterward, not + just that the install command didn't error. + +## Who this is actually for + +A single, private user is entirely free to use the one-liner to their +heart's content — nothing here claims that's wrong for personal use. +The case for this project's thoroughness is specifically: + +1. **A regulated business context**, where a documented, auditable + change process is a real requirement, not a preference. This isn't + just opinion — it lines up with how established frameworks are + actually written: + - **NIST SP 800-53, CM-3 (Configuration Change Control)** requires + documenting configuration change decisions and retaining records + of configuration-controlled changes for a defined period. + - **SOC 2, CC8.1** expects a reconstructable change history and + evidence of testing — the `run_test` PASS/FAIL/WARN lines in this + project's logs are exactly that evidence. + - **ISO 27001, Annex A Control 8.32** requires changes to be + planned, assessed, authorised, tested, documented, and + communicated. + + These frameworks scope themselves to organizations under audit or + compliance obligation — a private individual has no external party + requiring this evidence, which is exactly why the one-liner remains + a legitimate choice for that case. + +2. **A machine you intend to keep documenting and maintaining long + past this session** — the specific motivating case for this repo is + tinkerpad13, John's first SUSE machine, being deliberately built as + an opinionated Dev/SysAdmin OS. Every install is scripted + specifically *for* the documentation this produces, not + incidentally. The payoff comes from repetition and time, not the + first run — it's overkill for a machine you're about to reinstall + next week anyway. + +## Removal is not just install-in-reverse + +Apps that get installed and later turn out not to be worth keeping +should have their removal documented with the same rigor as their +install — an incomplete history that only records what was kept, not +what was tried and rejected, isn't a complete build record. + +This is **not yet built** (only `10_zypper-packages.sh`, the install +subscript, exists as of this writing). When it is, it must not simply +mirror the install subscript with `zypper remove` swapped in — removal +is inherently more dangerous than install, because it can break +dependencies that other, still-wanted packages rely on. The removal +subscript's design needs a `zypper remove --dry-run`-equivalent step +that surfaces what else would be pulled out as a dependent, shown for +confirmation *before* the real removal runs — not just a bare y/N on +the package name itself. + +## Supporting sources + +- [CM-3: Configuration Change Control (NIST SP 800-53 r5)](https://csf.tools/reference/nist-sp-800-53/r5/cm/cm-3/) +- [A Practical Guide to SOC 2 Change Management Controls](https://soc2auditors.org/insights/soc-2-change-management-controls/) +- [SOC 2 Change Management (CC8.1 Controls & Evidence)](https://episki.com/frameworks/soc2/change-management) +- [ISO 27001:2022 Annex A Control 8.32 Explained](https://www.isms.online/iso-27001/annex-a-2022/8-32-change-management-2022/) +- [ISO 27001 Change Management Policy: A Complete Guide](https://sprinto.com/blog/iso-27001-change-management-policy/) +- [Mastering `set -e` in Linux](https://linuxvox.com/blog/linux-set-e/) +- [Google SRE Book — Eliminating Toil](https://sre.google/workbook/eliminating-toil/) +- [Testing Idempotence for Infrastructure as Code](https://www.researchgate.net/publication/255978303_Testing_Idempotence_for_Infrastructure_as_Code) +- [Google Cloud Anthos — Running preflight checks](https://docs.cloud.google.com/anthos/clusters/docs/on-prem/1.11/how-to/preflight-checks) diff --git a/README.md b/README.md index 6d0973d..27be650 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,20 @@ # suse-professional-package-installer +A small, reusable zypper package installer for SUSE-family systems. +Instead of a one-off install script per project, packages to install +are kept in an external, hand-edited list; the installer reads it, +skips what's already installed, warns (without aborting) on anything +zypper doesn't recognize, and installs the rest — prompting for +privilege escalation only when it's actually about to run zypper. + +Follows the CE OS orchestrator/subscript script standard: an +unprivileged orchestrator (`main.sh`) dispatches to a dedicated +subscript per package manager, each of which escalates privileges only +for its own run. zypper and pip/pipx are always kept as fully separate +subscripts, never combined into one installer. + +Real, machine-specific package lists live in `local/` (gitignored — +never pushed). `packages/*.example` holds the tracked format +reference only. + +See the wiki for usage instructions. diff --git a/lib/00_preflight.sh b/lib/00_preflight.sh new file mode 100755 index 0000000..2189843 --- /dev/null +++ b/lib/00_preflight.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Created by John A. Hoeven with the ethical assistance of Claude AI +# --------------------------------------------------------------------------- +# 00_preflight.sh +# /home/john/projects/suse-professional-package-installer/lib/00_preflight.sh +# Version: v0.1.0 | Status: DEVELOPMENT +# --------------------------------------------------------------------------- +# Purpose: Environment checks only — no system changes. Runs unconditionally +# before any privileged subscript, unprivileged itself. +# Target: SUSE family (zypper). +# Entry: ./00_preflight.sh +# Depends: ce-common.sh (same directory) +# --------------------------------------------------------------------------- + +# shellcheck disable=SC2016 +# ^ run_test()'s cmd args are intentionally single-quoted — deferred-eval +# expressions, not meant to expand at the call site. + +_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +# shellcheck source=./ce-common.sh +source "${_SCRIPT_DIR}/ce-common.sh" + +log_info "=== PRE-FLIGHT ===" + +run_test "not running as root" '[[ "${EUID}" -ne 0 ]]' fail +run_test "zypper available" 'command -v zypper >/dev/null 2>&1' fail +run_test "sudo or doas available" \ + 'command -v sudo >/dev/null 2>&1 || command -v doas >/dev/null 2>&1' fail +run_test "internet reachable" \ + 'curl --silent --max-time 5 --output /dev/null https://download.opensuse.org' \ + warn +run_test "disk space >= 500MB" \ + '[[ $(df / --output=avail | tail -1) -ge 512000 ]]' \ + warn + +log_info "Pre-flight complete" diff --git a/lib/10_zypper-packages.sh b/lib/10_zypper-packages.sh new file mode 100755 index 0000000..dd33a52 --- /dev/null +++ b/lib/10_zypper-packages.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Created by John A. Hoeven with the ethical assistance of Claude AI +# --------------------------------------------------------------------------- +# 10_zypper-packages.sh +# /home/john/projects/suse-professional-package-installer/lib/10_zypper-packages.sh +# Version: v0.1.0 | Status: DEVELOPMENT +# --------------------------------------------------------------------------- +# Purpose: Install every package listed in a zypper package-list file. +# Target: SUSE family (zypper). +# Entry: ./10_zypper-packages.sh [package-list-file] +# Depends: ce-common.sh (same directory) +# --------------------------------------------------------------------------- + +_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +# shellcheck source=./ce-common.sh +source "${_SCRIPT_DIR}/ce-common.sh" + +_LIST_FILE="${1:-${_SCRIPT_DIR}/../local/zypper.list}" + +# ── Read list, confirm, detect environment ────────────────────────────── + +declare -a PACKAGES +read_package_list "${_LIST_FILE}" PACKAGES +log_info "Package list: ${_LIST_FILE} (${#PACKAGES[@]} entries)" + +detect_package_manager +detect_priv_cmd + +confirm_proceed "Install ${#PACKAGES[@]} package(s) via zypper: ${PACKAGES[*]}?" + +# ── Install ─────────────────────────────────────────────────────────────── + +if install_packages PACKAGES; then + log_info "zypper package install complete — no failures" + exit 0 +else + log_error "zypper package install finished with failures — see log above" + exit 1 +fi diff --git a/lib/ce-common.sh b/lib/ce-common.sh new file mode 100755 index 0000000..920644c --- /dev/null +++ b/lib/ce-common.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# Created by John A. Hoeven with the ethical assistance of Claude AI +# --------------------------------------------------------------------------- +# ce-common.sh +# /home/john/projects/suse-professional-package-installer/lib/ce-common.sh +# Version: v0.1.0 | Status: DEVELOPMENT +# --------------------------------------------------------------------------- +# Purpose: Shared logging, privilege-command detection, and zypper wrapper +# functions for this installer's subscripts. +# Target: SUSE family (zypper). Not a multi-distro abstraction — that's a +# deliberate scope choice, see spec/main_spec.md. +# Entry: source ce-common.sh — not executed directly. +# Depends: N/A +# --------------------------------------------------------------------------- + +# Guard against double-sourcing (main.sh sources this directly, then each +# subscript it invokes also sources it for standalone-run capability). +[[ -n "${_CE_COMMON_SOURCED:-}" ]] && return 0 +readonly _CE_COMMON_SOURCED=1 + +# ── Logging ────────────────────────────────────────────────────────────── + +readonly LOG_DIR="${HOME}/.local/logs/ceos_installer" +mkdir -p "${LOG_DIR}" +_ce_log_date="$(date '+%Y-%m-%d')" +readonly LOG_FILE="${LOG_DIR}/${CE_INSTALLER_NAME:-suse-pkg-install}-${_ce_log_date}.log" + +_log() { + local level="${1}" + local msg="${2}" + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + printf '[%s] [%s] %s\n' "${ts}" "${level}" "${msg}" | tee -a "${LOG_FILE}" +} +log_info() { _log "INFO " "${1}"; } +log_warn() { _log "WARN " "${1}" >&2; } +log_error() { _log "ERROR" "${1}" >&2; } +log_debug() { [[ "${CE_DEBUG:-0}" == "1" ]] && _log "DEBUG" "${1}"; } + +# ── Test helper ────────────────────────────────────────────────────────── +# shellcheck disable=SC2016 +# ^ run_test()'s cmd args are single-quoted at call sites throughout this +# project — deferred-eval expressions passed to `eval`, not meant to +# expand at the call site. + +run_test() { + local desc="${1}" + local cmd="${2}" + local mode="${3:-fail}" # fail | warn + + if eval "${cmd}" >/dev/null 2>&1; then + log_info " PASS: ${desc}" + return 0 + fi + + if [[ "${mode}" == "fail" ]]; then + log_error " FAIL: ${desc}" + exit 1 + fi + + log_warn " WARN: ${desc}" + return 1 +} + +# ── Privilege command detection ───────────────────────────────────────── +# Never assume doas or sudo — detect per source-discipline doc. + +detect_priv_cmd() { + if command -v doas >/dev/null 2>&1; then + PRIV_CMD="doas" + elif command -v sudo >/dev/null 2>&1; then + PRIV_CMD="sudo" + else + log_error "Neither doas nor sudo found — cannot perform privileged operations" + exit 1 + fi + log_info "Privilege command: ${PRIV_CMD}" +} + +# ── zypper wrapper ─────────────────────────────────────────────────────── + +detect_package_manager() { + if ! command -v zypper >/dev/null 2>&1; then + log_error "zypper not found — this installer targets the SUSE family only" + exit 1 + fi + log_info "Package manager: zypper" +} + +test_package_available() { + local pkg="${1:?test_package_available: pkg required}" + rpm -q "${pkg}" >/dev/null 2>&1 && return 0 + zypper --non-interactive search -x "${pkg}" 2>/dev/null | grep -q " ${pkg} " && return 0 + log_warn "Package '${pkg}' not available via zypper" + return 1 +} + +package_installed() { + local pkg="${1:?package_installed: pkg required}" + rpm -q "${pkg}" >/dev/null 2>&1 +} + +install_packages() { + local -n pkg_list="${1:?install_packages: pkg_list required}" + local failed=() + local unavailable=() + local skipped=() + + for pkg in "${pkg_list[@]}"; do + if package_installed "${pkg}"; then + log_info "Already installed, skipping: ${pkg}" + skipped+=( "${pkg}" ) + continue + fi + if ! test_package_available "${pkg}"; then + unavailable+=( "${pkg}" ) + continue + fi + if ! ${PRIV_CMD} zypper --non-interactive install "${pkg}"; then + log_error "Failed to install: ${pkg}" + failed+=( "${pkg}" ) + else + log_info "Installed: ${pkg}" + CE_PKG_MODIFIED=1 + run_test "${pkg} present after install" \ + "package_installed '${pkg}'" \ + warn + fi + done + + if [[ ${#unavailable[@]} -gt 0 ]]; then + log_warn "Unavailable: ${unavailable[*]}" + fi + if [[ ${#failed[@]} -gt 0 ]]; then + log_error "Installation failures: ${failed[*]}" + return 1 + fi + return 0 +} + +# ── Package-list file parsing ──────────────────────────────────────────── +# One package name per line; '#'-prefixed comments and blank lines ignored. + +read_package_list() { + local list_file="${1:?read_package_list: list_file required}" + local -n out_array="${2:?read_package_list: out_array required}" + + if [[ ! -f "${list_file}" ]]; then + log_error "Package list not found: ${list_file}" + exit 1 + fi + + out_array=() + while IFS= read -r line; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "${line}" ]] && continue + out_array+=( "${line}" ) + done < "${list_file}" + + if [[ ${#out_array[@]} -eq 0 ]]; then + log_error "Package list is empty: ${list_file}" + exit 1 + fi +} + +# ── Cleanup trap (shared) ──────────────────────────────────────────────── +# Registered unconditionally, before any work begins (style guide §2.9). +# Temp-dir removal always runs (harmless no-op if CE_TEMP_DIRS is empty). +# The privileged cache-clean step only runs if CE_PKG_MODIFIED was +# actually set — i.e. this run really touched zypper — not inferred from +# an unrelated side effect. + +CE_TEMP_DIRS=() +CE_PKG_MODIFIED=0 +_CE_CLEANUP_TRAP_SET=0 + +register_temp_dir() { + CE_TEMP_DIRS+=( "${1:?register_temp_dir: path required}" ) +} + +_ce_cleanup() { + local exit_code="${?}" + for d in "${CE_TEMP_DIRS[@]}"; do + if [[ -n "${d}" && -d "${d}" ]]; then + rm -rf "${d}" + log_info "Removed temp dir: ${d}" + fi + done + if [[ "${CE_PKG_MODIFIED}" -eq 1 && -n "${PRIV_CMD:-}" ]]; then + if ${PRIV_CMD} zypper clean --all 2>/dev/null; then + log_info "Package cache cleared" + else + log_warn "Package cache clear skipped or failed (non-fatal)" + fi + fi + log_info "Cleanup complete (exit code: ${exit_code})" +} + +ensure_cleanup_trap() { + [[ "${_CE_CLEANUP_TRAP_SET}" -eq 1 ]] && return 0 + trap _ce_cleanup EXIT INT TERM HUP + _CE_CLEANUP_TRAP_SET=1 +} + +ensure_cleanup_trap + +# ── User confirmation ──────────────────────────────────────────────────── + +confirm_proceed() { + local prompt="${1:-Proceed with installation?}" + printf '\n%s [y/N] ' "${prompt}" + read -r response + case "${response}" in + [yY]|[yY][eE][sS]) return 0 ;; + *) log_info "Aborted by Organic Humanoid I/O Device unit ID ${USER}"; exit 0 ;; + esac +} diff --git a/main.sh b/main.sh new file mode 100755 index 0000000..a82bef9 --- /dev/null +++ b/main.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Created by John A. Hoeven with the ethical assistance of Claude AI +# --------------------------------------------------------------------------- +# main.sh +# /home/john/projects/suse-professional-package-installer/main.sh +# Version: v0.1.0 | Status: DEVELOPMENT +# --------------------------------------------------------------------------- +# Purpose: Orchestrator — installs zypper packages from an external, +# hand-edited package list instead of a one-off script per +# project. Add new package-list-driven subscripts under lib/ as +# new package managers are needed (kept fully separate per +# subscript — never combined, see spec/main_spec.md). +# Target: SUSE family (zypper). Common orchestrator, distro-specific +# subscripts. +# Entry: ./main.sh [package-list-file] +# Depends: lib/ce-common.sh, lib/00_preflight.sh, lib/10_zypper-packages.sh +# --------------------------------------------------------------------------- +# Orchestrator: exempt from the 100-line subscript boundary (argument +# parsing and subscript dispatch legitimately require it) — see +# ceos_script_architecture.md. +# --------------------------------------------------------------------------- + +set -euo pipefail + +_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" + +usage() { + cat <.list, edit it, then run: +# ./main.sh local/.list + +# example-package-name diff --git a/spec/main_spec.md b/spec/main_spec.md new file mode 100644 index 0000000..e5669ec --- /dev/null +++ b/spec/main_spec.md @@ -0,0 +1,185 @@ +# suse-professional-package-installer — spec + +Spec-first entries per CE OS Script Development standards, one per +script, written before generation. + +--- + +## `main.sh` (orchestrator) + +- **Name:** `main.sh` — top-level entry point; parses arguments, sources + `lib/ce-common.sh`, invokes the requested subscript(s) in order. +- **Inputs:** CLI args — an optional package-list file path, default + `local/zypper.list` (gitignored, machine-specific real list — see + `.gitignore` and `packages/*.example` below). +- **Outputs:** stdout/log lines via `ce-common.sh` logging; exit 0 on + full success, non-zero if any subscript reports failure. +- **Error behaviour:** halt on missing/unreadable list file; otherwise + log-and-continue between independent subscripts (a zypper failure + doesn't block an unrelated later subscript), non-zero exit if any + subscript failed. +- **Privilege:** standard user — orchestrator never elevates itself or + carries elevation between subscripts (per CE OS privilege model). +- **Doc references:** `ceos_script_architecture.md` (orchestrator/ + subscript model, privilege model). +- **Distro target:** common (dispatches to distro-specific subscripts). +- Exempt from the 100-line subscript boundary per the orchestrator + exemption. + +--- + +## `lib/ce-common.sh` (shared library) + +- **Name:** `ce-common.sh` — logging, privilege-command detection, + zypper wrapper functions, cleanup trap, user confirmation. Promoted/ + generalized from the private `tinkerpad-stt` deployment's copy — + project-specific bits (`VOSK_HOME`, hardcoded installer name) + stripped for public reuse. +- **Inputs:** sourced only, not executed directly. Reads + `CE_INSTALLER_NAME` env var (optional, defaults to `suse-pkg-install`). +- **Outputs:** exported functions/vars for consuming scripts + (`log_info`, `run_test`, `detect_priv_cmd`, `test_package_available`, + `package_installed`, `install_packages`, `confirm_proceed`, etc.). + `run_test(desc, cmd, mode=fail|warn)` is the shared PASS/FAIL/WARN + check helper (matches the established pattern in + `~/.local/opt/package-installer/`) — used both by `00_preflight.sh` + and by `install_packages()` itself for a post-install presence check + per package (warn-mode: a verification miss is logged, not fatal). +- **Error behaviour:** `log_error` + `exit 1` on unrecoverable + conditions (no priv command found, no zypper found). +- **Privilege:** none itself — only detects what's available; + escalation happens per-subscript at the point of use. +- **Doc references:** `ceos_script_architecture.md`, + `ceos_script_source_discipline.md` (never assume sudo vs doas). +- **Distro target:** SUSE family (zypper only, scoped to this repo's + name/purpose — unlike the private multi-manager `ce-common.sh`). +- **Line count:** 188 non-blank lines — over the 100-line soft signal + but within the 200-line hard ceiling for non-orchestrator scripts + (RIS doc-set `65ppjj`, section `lin`, chunks `sft`/`hrd`/`exm` — + supersedes `0nxwoh`, the original text which omitted the 200-line + figure entirely; corrected 2026-08-06 per John's direct clarification + during this project's review). Also arguably orchestrator-like + itself, per John. Line limits are being deliberately not enforced + strictly at this early-dev stage, with the intent to split this file + further once real use/testing surfaces where the actual seams are — + splitting now, before that, would be guessing at boundaries rather + than finding them. + +--- + +## `lib/00_preflight.sh` (subscript) + +- **Name:** `00_preflight.sh` — environment checks only, no system + changes. This is the "dry run" step: it always runs, unconditionally, + before any privileged subscript, and never itself prompts for + privilege escalation. +- **Inputs:** none. +- **Outputs:** exit 0 if all `fail`-mode checks pass (warn-mode checks — + internet reachability, disk space — log but don't block); exit 1 on + the first fail-mode check that fails. +- **Error behaviour:** halt immediately via `run_test`'s fail mode + (not running as root, zypper present, sudo-or-doas present); warn + and continue for environmental checks that aren't hard requirements. +- **Privilege:** standard user only — this subscript makes no + privileged calls itself, by design. +- **Doc references:** matches the pre-flight phase pattern in + `~/.local/opt/package-installer/20260726-201557-package-installer.sh` + (the established local convention for this project's "dry-run and + tests" behaviour, per John, 2026-08-06). +- **Distro target:** SUSE family (zypper check), otherwise common. + +--- + +## `tests/test_list_parsing.sh` (test script — kept separate, never merged into an install script) + +- **Name:** `test_list_parsing.sh` — exercises `read_package_list()` + (comment stripping, whitespace trimming, blank-line skipping, + comment-only-file rejection, missing-file rejection) with no zypper + calls and no privilege escalation. +- **Inputs:** none (writes its own fixture files to a `mktemp -d` + registered via `register_temp_dir` for automatic cleanup). +- **Outputs:** exit 0 if every `run_test` check passes; exit 1 (via + `run_test`'s fail mode) on the first failing assertion. +- **Error behaviour:** fail-fast — every assertion in this file is + fail-mode, since a parsing regression should never be silently + downgraded to a warning. +- **Privilege:** standard user only. +- **Doc references:** `ceos_script_architecture.md` ("Test scripts + always remain separate — they are never merged into installation or + configuration scripts"). +- **Distro target:** common (pure bash, no package-manager calls). + +--- + +## `lib/10_zypper-packages.sh` (subscript) + +- **Name:** `10_zypper-packages.sh` — installs every package listed in + a given zypper package-list file. +- **Inputs:** `$1` = path to a package-list file (one package name per + line, `#`-prefixed comments and blank lines ignored). Defaults to + `local/zypper.list` (gitignored) if not given. +- **Outputs:** exit 0 if every listed package ends up installed or was + already present; exit 1 if any install failed. Unavailable packages + are warned about, not treated as fatal (matches existing + `install_packages()` behaviour — a typo in one line shouldn't abort + the whole run). +- **Error behaviour:** halt immediately if the list file doesn't + exist or is empty; per-package log-and-continue during install. +- **Privilege:** requires escalation (installing system packages) — + prompts for it at the point `install_packages()` first needs it, via + `detect_priv_cmd`. Does not hold elevation before that point. +- **Doc references:** `ceos_script_architecture.md` (privilege model — + escalation isolated to the subscript that needs it). +- **Distro target:** SUSE family (zypper). + +--- + +## Deferred (not built this pass) + +- **`lib/20_pip-packages.sh`** — pip/pipx package-list subscript. + Not needed for the immediate target (`poppler-tools`, a zypper + package), and per the standing rule zypper/pip must stay fully + separate subscripts, never combined. Spec entry to be written when + an actual pip/pipx list is needed. +- **Multi-distro (`apk`/`apt`/`nix`) support** — deliberately out of + scope. This repo's name and purpose are SUSE-specific; a general + multi-distro engine already exists privately as `ce-common.sh` in + `~/.local/opt/tinkerpad-stt/lib/` and is not what this public repo + is for. +- **Textual list-authoring/validation TUI** — explicitly deferred + until the plain-text list format has been used for real and any + rough edges are known. + +--- + +## Known gaps (Ambrosiana Gap Protocol — documented per `ceos_script_source_discipline.md`) + +- **No Ambrosiana-sourced zypper/Tumbleweed documentation.** Per the + "no training-derived assumptions" rule, every zypper-specific + behavior used here (`zypper --non-interactive search -x`, cache + cleanup via `zypper clean --all`, `rpm -q` as the query mechanism) + was verified live against the real machine, not sourced from + Ambrosiana — it holds no zypper/Tumbleweed doc-set yet. Same gap + already logged privately for `tinkerpad-stt` in + `~/.local/opt/INSTALLER_NOTES.md`. Per protocol step 3 (no quality + alternative exists yet): proceeding with elevated live-verification + discipline instead of blocking on it; this note is that documentation + requirement being met. Should travel into the README's "Known bugs" + section at public-release time. +- **Full CE OS deployment workflow not implemented.** The methodology's + full sequence (full-stack discovery, conflict analysis, FHS Relocator, + offline USB preparation, formal two-stage QC with a specialised local + agent ahead of Claude Code) targets full service/system deployments. + This project is a lightweight package-list installer, not a service + deployer — no package here lands in a non-FHS default location the + Relocator would need to catch, and there's no service being deployed + to discover conflicts against. Treating those pieces as out of + proportion to scope rather than a compliance gap, but flagging + explicitly rather than silently omitting them — John's call to + confirm. +- **QC is single-stage in practice.** No specialised local agent is + currently trusted to run a Stage 1 pass ahead of Claude Code — local + fleet models aren't yet evaluated as reliable enough for this role. + Claude Code performs both drafting and QC (shellcheck + manual + review) in one pass, consistent with how this gap is already handled + elsewhere in this project ecosystem. diff --git a/tests/test_list_parsing.sh b/tests/test_list_parsing.sh new file mode 100755 index 0000000..0c921a6 --- /dev/null +++ b/tests/test_list_parsing.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Created by John A. Hoeven with the ethical assistance of Claude AI +# --------------------------------------------------------------------------- +# test_list_parsing.sh +# /home/john/projects/suse-professional-package-installer/tests/test_list_parsing.sh +# Version: v0.1.0 | Status: DEVELOPMENT +# --------------------------------------------------------------------------- +# Purpose: Exercises read_package_list() (comments/blank-line handling, +# empty-file rejection) without touching zypper or requiring +# privilege escalation. Kept separate from installation scripts +# per CE OS standard — never merged into main.sh or a subscript. +# Target: common (pure bash, no package-manager calls). +# Entry: ./test_list_parsing.sh +# Depends: lib/ce-common.sh +# --------------------------------------------------------------------------- + +# shellcheck disable=SC2016 +# ^ run_test()'s cmd args are intentionally single-quoted — deferred-eval +# expressions, not meant to expand at the call site. + +_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +# shellcheck source=../lib/ce-common.sh +source "${_SCRIPT_DIR}/../lib/ce-common.sh" + +_TMP="$(mktemp -d)" +register_temp_dir "${_TMP}" + +log_info "=== test_list_parsing ===" + +# ── Comments, blank lines, whitespace ──────────────────────────────────── + +cat > "${_TMP}/mixed.list" <<'EOF' +# a full-line comment + + foo-pkg +bar-pkg # trailing comment + baz-pkg + +EOF + +# shellcheck disable=SC2034 # populated via nameref inside read_package_list, read via eval in run_test +declare -a parsed +read_package_list "${_TMP}/mixed.list" parsed + +run_test "parses exactly 3 packages" '[[ ${#parsed[@]} -eq 3 ]]' fail +run_test "first entry is foo-pkg" '[[ "${parsed[0]}" == "foo-pkg" ]]' fail +run_test "second entry is bar-pkg (trailing comment stripped)" \ + '[[ "${parsed[1]}" == "bar-pkg" ]]' fail +run_test "third entry is baz-pkg (leading whitespace stripped)" \ + '[[ "${parsed[2]}" == "baz-pkg" ]]' fail + +# ── Empty / comment-only file is rejected ──────────────────────────────── + +cat > "${_TMP}/empty.list" <<'EOF' +# nothing but comments + +EOF + +# Functions are inherited into subshells, so a bare subshell (no re-source +# needed) is enough to isolate read_package_list()'s own `exit 1` from this +# test script's own process. + +run_test "comment-only list exits non-zero" \ + "! (declare -a x; read_package_list '${_TMP}/empty.list' x) 2>/dev/null" \ + fail + +# ── Missing file is rejected ───────────────────────────────────────────── + +run_test "missing list file exits non-zero" \ + "! (declare -a x; read_package_list '${_TMP}/does-not-exist.list' x) 2>/dev/null" \ + fail + +log_info "=== test_list_parsing: all checks passed ==="