Professional package installer for SUSE family created
This commit is contained in:
parent
3c5b900ac6
commit
99c4a41662
10 changed files with 751 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||||
108
RATIONALE.md
Normal file
108
RATIONALE.md
Normal file
|
|
@ -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)
|
||||||
18
README.md
18
README.md
|
|
@ -1,2 +1,20 @@
|
||||||
# suse-professional-package-installer
|
# 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.
|
||||||
|
|
|
||||||
36
lib/00_preflight.sh
Executable file
36
lib/00_preflight.sh
Executable file
|
|
@ -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"
|
||||||
39
lib/10_zypper-packages.sh
Executable file
39
lib/10_zypper-packages.sh
Executable file
|
|
@ -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
|
||||||
219
lib/ce-common.sh
Executable file
219
lib/ce-common.sh
Executable file
|
|
@ -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
|
||||||
|
}
|
||||||
60
main.sh
Executable file
60
main.sh
Executable file
|
|
@ -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 <<EOF
|
||||||
|
Usage: $(basename "${0}") [package-list-file]
|
||||||
|
|
||||||
|
Installs zypper packages listed in package-list-file (default:
|
||||||
|
local/zypper.list — gitignored, machine-specific; copy
|
||||||
|
packages/zypper.list.example there to start one) — one package name
|
||||||
|
per line, '#' comments and blank lines ignored.
|
||||||
|
|
||||||
|
Each package manager gets its own dedicated subscript under lib/,
|
||||||
|
invoked separately; this orchestrator never mixes zypper and pip/pipx
|
||||||
|
installs in the same run.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
_LIST_FILE="${1:-${_SCRIPT_DIR}/local/zypper.list}"
|
||||||
|
|
||||||
|
# shellcheck source=lib/ce-common.sh
|
||||||
|
source "${_SCRIPT_DIR}/lib/ce-common.sh"
|
||||||
|
|
||||||
|
log_info "=== suse-professional-package-installer starting ==="
|
||||||
|
log_info "Orchestrator holds no elevation; ${_SCRIPT_DIR}/lib/10_zypper-packages.sh escalates on its own"
|
||||||
|
|
||||||
|
"${_SCRIPT_DIR}/lib/00_preflight.sh"
|
||||||
|
|
||||||
|
"${_SCRIPT_DIR}/lib/10_zypper-packages.sh" "${_LIST_FILE}"
|
||||||
|
_STATUS=$?
|
||||||
|
|
||||||
|
log_info "=== suse-professional-package-installer finished (exit ${_STATUS}) ==="
|
||||||
|
exit "${_STATUS}"
|
||||||
10
packages/zypper.list.example
Normal file
10
packages/zypper.list.example
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# zypper package list — one package name per line, '#' comments ignored.
|
||||||
|
#
|
||||||
|
# This is a tracked FORMAT EXAMPLE, not a real list. Real, machine-specific
|
||||||
|
# package lists belong in local/ (gitignored — see .gitignore) so a
|
||||||
|
# public clone of this repo never carries anyone's actual install choices.
|
||||||
|
#
|
||||||
|
# Usage: copy this file to local/<name>.list, edit it, then run:
|
||||||
|
# ./main.sh local/<name>.list
|
||||||
|
|
||||||
|
# example-package-name
|
||||||
185
spec/main_spec.md
Normal file
185
spec/main_spec.md
Normal file
|
|
@ -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.
|
||||||
73
tests/test_list_parsing.sh
Executable file
73
tests/test_list_parsing.sh
Executable file
|
|
@ -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 ==="
|
||||||
Loading…
Add table
Add a link
Reference in a new issue