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
|
||||
|
||||
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