diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 703712d..3104013 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,3 +146,4 @@ jobs: - name: Run bootstrap test suite run: bash tests/run.sh + diff --git a/README.md b/README.md index 5f3153f..bb00d26 100755 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A reusable, distro-aware Linux workstation bootstrap utility for preparing and configuring Linux workstations in a consistent and controlled way. -Linux Workstation Bootstrap is being built as a modular replacement for a single large workstation setup script. It separates host detection, command execution, distribution-specific behavior, installable capabilities, and workstation profiles so that each concern can evolve independently. +Linux Workstation Bootstrap is a modular replacement for a monolithic workstation setup script. It separates host detection, portable software intent, distribution-specific package resolution, external software installation, installation-state detection, user configuration, command execution, installable capabilities, and workstation profiles so each concern can evolve independently. ## Current Capabilities @@ -15,6 +15,9 @@ The current bootstrap foundation provides: * Package-manager identification * Interactive distribution profile confirmation and selection * Command-line distribution profile override +* Optional user-local configuration +* Deterministic configuration precedence +* Validation of supported user configuration values * Dry-run execution mode * Persistent per-session logging * Centralized application configuration @@ -24,8 +27,19 @@ The current bootstrap foundation provides: * Ubuntu package-management adapter * Debian package-management adapter * Kali Linux package-management adapter +* Portable package capability resolution +* Independent package mappings per distribution profile +* Distribution-specific package-state detection +* Idempotent package installation filtering +* Controlled external software installer loading and validation +* External software installed-state detection +* Idempotent external software execution +* Repository, local-package, archive, and script installation methods +* Predictable temporary staging for external artifacts +* HTTPS-only external artifact downloads +* Local distribution package installation through distro adapters * Controlled workstation module loading and validation -* Module package requirement handling +* Module capability requirement handling * Optional module operation hooks * Controlled workstation profile loading and validation * Declarative profile-to-module composition @@ -40,68 +54,128 @@ Supported distribution profiles: Current distribution adapter status: -| Distribution | Adapter | -| --- | --- | -| Ubuntu | Implemented | -| Debian | Implemented | -| Kali Linux | Implemented | +| Distribution | Adapter | Package Mapping | Package State | Local Packages | +| --- | --- | --- | --- | --- | +| Ubuntu | Implemented | Implemented | Implemented | Implemented | +| Debian | Implemented | Implemented | Implemented | Implemented | +| Kali Linux | Implemented | Implemented | Implemented | Implemented | ## Architecture -The project is intentionally split into layers rather than combining host detection, package installation, prompts, and system changes in one script. +The project separates workstation intent from distro-specific implementation. + +Configuration is resolved using deterministic precedence: + +```text +Repository Defaults + | + v +User Configuration + | + v +Command-Line Arguments +``` + +Standard repository software follows the capability path: ```text Workstation Profile - │ - ▼ + | + v Modules - │ - ▼ - Module Framework - │ - ▼ -Distribution Adapter - │ - ▼ - Execution Layer - │ - ▼ -System Package Manager + | + | capabilities + v +Capability Resolver + | + | distro package mapping + v + Package State Check + | + +----+----+ + | | +installed absent + | | + v v + skip Distribution Adapter + | + v + Execution Layer + | + v + System Package Manager +``` + +Software that cannot be installed exclusively from the standard distribution repositories follows a separate controlled path: + +```text + Modules + | + | external software requirement + v +External Installer + | + v + External State Check + | + +----+----+ + | | +installed absent + | | + v v + skip Installation Method + | + +-- Repository + +-- Local Package + +-- Archive + `-- Installer Script + | + v + Shared Execution Layer ``` The application entry flow coordinates these layers: ```text User - │ - ▼ + | + v bin/linux-workstation-bootstrap - │ - ▼ + | + v lib/bootstrap.sh - │ - ├── Configuration - ├── CLI parsing - ├── Logging - ├── Runtime validation - ├── System detection - ├── Terminal UI - ├── Execution framework - ├── Distribution adapter framework - ├── Module framework - └── Profile framework + | + +-- Repository defaults + +-- CLI parsing + +-- Logging + +-- User configuration + +-- Runtime validation + +-- System detection + +-- Terminal UI + +-- Execution framework + +-- Distribution adapter framework + +-- Capability resolution + +-- External installation framework + +-- Module framework + `-- Profile framework ``` The key separation is: ```text -Profiles decide which capabilities are wanted. -Modules define what those capabilities require. -Distribution adapters implement how the operating system satisfies them. +Repository defaults define built-in behaviour. +User configuration provides safe local overrides. +CLI arguments provide the highest-precedence runtime selections. +Profiles decide which workstation capabilities are wanted. +Modules declare portable software and operation requirements. +Capability mappings translate portable intent into distro package names. +Distribution adapters determine package state and perform package operations. +External installers define controlled non-standard installation paths. +State checks determine whether installation work is actually required. The execution layer controls how system commands are run. ``` -This prevents workstation intent from becoming coupled to one Linux distribution or package manager. +This prevents workstation modules from becoming coupled to one Linux distribution, package manager, vendor-specific installation mechanism, package-state implementation, or machine-specific configuration. ## Component Responsibilities @@ -113,25 +187,190 @@ It enables safe Bash behavior, resolves the project root, loads the bootstrap or ### Configuration -`config/defaults.sh` contains built-in application defaults and platform mappings, including: +Repository-managed defaults live in: + +```text +config/defaults.sh +``` + +They define built-in application values such as: * application identity and version * minimum Bash version -* supported distributions +* supported distribution profiles * distro-family mappings * package-manager mappings * architecture normalization +* user configuration location * logging defaults +Distribution-specific package mappings live separately under: + +```text +config/packages/ +├── debian.sh +├── kali.sh +└── ubuntu.sh +``` + +User configuration is handled by: + +```text +lib/config.sh +``` + +The normal user configuration path is: + +```text +~/.config/linux-workstation-bootstrap/config.conf +``` + +If `XDG_CONFIG_HOME` is configured, it is used instead of `~/.config`. + +User configuration is optional. The bootstrap operates normally when the file does not exist. + +Configuration precedence is: + +```text +repository defaults + | + v +user configuration + | + v +CLI arguments +``` + +CLI arguments therefore remain the highest-precedence runtime input. + +### User Configuration + +User configuration allows supported defaults to be changed without editing repository-managed files. + +The configuration file is parsed as data rather than sourced as shell code. + +A minimal example is: + +```ini +# Linux Workstation Bootstrap + +distro_profile=ubuntu +``` + +The currently supported setting is: + +```text +distro_profile +``` + +Supported values are: + +```text +auto +ubuntu +debian +kali +``` + +`auto` preserves normal host detection and interactive selection behaviour. + +For example: + +```ini +distro_profile=ubuntu +``` + +sets Ubuntu as the user's default distribution profile. + +However: + +```bash +./bin/linux-workstation-bootstrap \ + --distro debian \ + --dry-run +``` + +still selects Debian because the CLI has higher precedence. + +Unsupported settings fail clearly rather than being silently ignored. + +For example: + +```ini +magic_setting=true +``` + +is rejected. + +Duplicate settings are also rejected: + +```ini +distro_profile=ubuntu +distro_profile=debian +``` + +This prevents ambiguous configuration. + +The user configuration parser does not perform shell expansion, command substitution, or arbitrary code execution. + +Values such as: + +```text +$(command) +``` + +are treated as configuration data and rejected when they are not valid for the setting. + +Internal constants that are not explicitly exposed as user settings remain controlled by repository configuration. + ### Bootstrap Orchestration `lib/bootstrap.sh` coordinates the application lifecycle. -It determines the order in which shared components operate but does not contain distro-specific package commands or workstation module implementations. +Its current startup order is: + +```text +Parse CLI + | + v +Handle help/version + | + v +Initialize logging + | + v +Load user configuration + | + v +Validate runtime + | + v +Detect system + | + v +Resolve distribution profile + | + v +Load distro adapter + | + v +Load package capability mapping +``` + +The bootstrap orchestrator does not contain distro-specific package commands, external software implementations, or workstation module implementations. ### CLI Handling -`lib/arguments.sh` parses command-line options and records runtime selections such as dry-run mode and distribution-profile overrides. +`lib/arguments.sh` parses command-line options and records runtime selections such as: + +* dry-run mode +* non-interactive confirmation +* distribution-profile overrides +* informational actions + +CLI selections remain separate from user configuration state. + +This makes precedence explicit rather than rewriting CLI state while configuration is loaded. ### Core Utilities @@ -154,6 +393,10 @@ Detected host information remains separate from the distribution profile selecte `lib/logging.sh` provides persistent per-session application logging. +Configuration loading and selection decisions are recorded without executing or blindly persisting arbitrary user-supplied shell content. + +Package and external-software state decisions are also recorded so skipped and required actions remain visible across repeated bootstrap runs. + ### Terminal UI `lib/ui.sh` owns human-facing output, prompts, menus, help, version information, and execution summaries. @@ -189,9 +432,276 @@ Every adapter implements: distro_validate_environment distro_update_package_index distro_install_packages +distro_install_local_package +distro_package_state +``` + +`distro_package_state` provides distro-specific package-state detection. + +The function reports one of: + +```text +installed +absent +``` + +An absent package is a successfully determined state, not an error. + +A non-zero return code means that package state could not be determined reliably. The bootstrap treats that as a failure instead of assuming the package is absent. + +For the current Debian-family adapters, package state is determined using: + +```text +dpkg-query +``` + +The same abstraction allows future distributions to use their native mechanisms: + +```text +Debian family -> dpkg-query +Fedora -> RPM or DNF state mechanisms +Arch -> Pacman state mechanisms +``` + +`distro_install_local_package` provides the distro-specific implementation for a previously downloaded local package file. + +For example: + +```text +Ubuntu / Debian / Kali + | + v +apt-get install ./package.deb +``` + +Future adapters can provide their own equivalent operations without changing the external installation framework. + +Package-manager commands remain inside distribution adapters. + +### Capability Resolution + +`lib/capability.sh` translates portable capability identifiers into package names for the selected distribution profile. + +For example, a module can request: + +```text +xz +``` + +without knowing that the Debian-family package is: + +```text +xz-utils +``` + +The mapping is selected independently for each distro: + +```text +Module capability + | + v + xz + | + +-- Ubuntu -> xz-utils + +-- Debian -> xz-utils + `-- Kali -> xz-utils +``` + +Future distributions can provide different mappings without changing the module. + +For example: + +```text +Fedora -> xz +Arch -> xz +``` + +A capability may also map to more than one package where a workstation capability requires a package set. + +Resolved package names are deduplicated before package-state evaluation. + +Missing mappings fail explicitly rather than silently skipping requirements. + +### Idempotent Package Installation + +Resolved packages are checked before installation. + +For each package: + +```text +Resolved package + | + v +distro_package_state + | + +---+---+ + | | +installed absent + | | + v v + skip install +``` + +Packages that are already installed are not sent back to the package manager. + +For example: + +```text +curl -> installed -> skip +xz-utils -> absent -> install +``` + +If all package requirements for a module are already satisfied, package installation is skipped completely. + +Package-state detection remains centralized in distro adapters rather than being duplicated in each module. + +This makes repeated bootstrap runs safe and predictable. + +### External Installation Framework + +`lib/external.sh` provides a controlled path for software that is not installed exclusively from the selected distribution's standard package repositories. + +External installer definitions live under: + +```text +installers/.sh +``` + +Every external installer implements: + +```text +external_id +external_name +external_description +external_method +external_state +``` + +`external_state` must report: + +```text +installed +``` + +or: + +```text +absent +``` + +A non-zero return code means the installer could not determine current software state. + +Unknown state is treated as an error rather than silently assuming the software is absent. + +Supported installation methods are: + +```text +repository +package +archive +script +``` + +Each method requires its matching implementation: + +```text +external_repository_apply +external_package_apply +external_archive_apply +external_script_apply +``` + +An installer may optionally implement: + +```text +external_validate_environment +``` + +Environment validation and installation are only required when `external_state` reports that the software is absent. + +If the external software is already installed: + +```text +External installer + | + v + external_state + | + installed + | + v + skip +``` + +The framework provides controlled helpers for: + +* HTTPS artifact downloads +* temporary installation staging +* local distribution package installation +* installation of prepared system files +* tar archive extraction +* ZIP archive extraction +* staged installer-script execution + +External installation logic remains software-specific rather than being added to the bootstrap core. + +#### Repository Installations + +Repository-based software can prepare vendor repository metadata or signing material and install it through controlled system-file operations. + +Repository configuration remains distinct from normal package installation. + +#### Local Packages + +Downloaded distribution packages are passed back to the selected distro adapter. + +This preserves distro independence: + +```text +External Installer + | + v +Local package + | + v +Distribution Adapter + | + +-- Debian family -> .deb + +-- Fedora -> .rpm + `-- Arch -> local package format +``` + +Fedora and Arch support are future targets and are not currently implemented. + +#### Archive Installations + +Archive-based software can be downloaded into temporary staging and extracted using controlled helpers. + +The framework currently supports tar-compatible archives and ZIP archives. + +#### Installer Scripts + +Installer scripts must first exist as local staged files. + +The framework intentionally does not provide a primitive equivalent to: + +```text +curl | sh +``` + +A script must instead be: + +```text +downloaded + | + v +staged locally + | + v +executed through an explicit interpreter ``` -Shared application and module code does not call `apt-get` directly. +Artifact integrity and installer verification are handled by dedicated integrity work rather than silently trusting downloaded content. ### Module Framework @@ -209,7 +719,7 @@ Every module implements: module_id module_name module_description -module_packages +module_capabilities ``` A module may optionally implement: @@ -218,9 +728,18 @@ A module may optionally implement: module_apply ``` -Package requirements are delegated to the selected distribution adapter. +`module_capabilities` emits one portable capability identifier per line. -A module therefore describes **what capability is required**, while the distro adapter determines **how that capability is installed on the operating system**. +The framework: + +1. resolves those capabilities using the selected distro package mapping +2. checks each resolved package through `distro_package_state` +3. skips packages already installed +4. sends only missing packages to `distro_install_packages` + +Modules therefore do not implement their own package-state detection. + +Modules also do not need to know whether the selected operating system uses `apt-get`, `dnf`, `pacman`, or another package-management implementation. ### Profile Framework @@ -241,68 +760,169 @@ profile_description profile_modules ``` -`profile_modules` declares one module ID per line. +Profiles are intentionally declarative. They compose modules without containing package-manager commands, package lists, package-state logic, or duplicated module implementation. + +## Configuration Precedence + +Runtime configuration follows: + +```text +repository defaults + | + v +user-local configuration + | + v +explicit CLI arguments +``` + +For example: -Profiles are intentionally declarative. They do not contain package-manager commands, package lists, or system-changing operation hooks. +```text +repository default : auto +user configuration: ubuntu +CLI --distro : debian +``` + +results in: -Before a profile can be accepted, every referenced module is loaded and validated. +```text +debian +``` -Profile execution then runs those modules in declaration order through the shared module framework. +If no CLI override is present: + +```text +repository default : auto +user configuration: ubuntu +``` + +results in: + +```text +ubuntu +``` + +If no user configuration exists: + +```text +repository default : auto +``` + +normal host detection and profile-selection behaviour applies. + +This precedence model will also support future workstation profile and module configuration without requiring repository-managed files to contain machine-specific preferences. ## Execution Flow -The bootstrap foundation establishes the host and distribution layer: +The bootstrap establishes the host and distro-specific foundation: ```text Parse CLI arguments - │ - ▼ + | + v Initialize logging - │ - ▼ + | + v +Load user configuration + | + v Validate runtime - │ - ▼ + | + v Detect host system - │ - ▼ -Select distribution profile - │ - ▼ + | + v +Resolve distribution profile + | + v Load distribution adapter - │ - ▼ + | + v Validate adapter environment + | + v +Load distro capability mapping +``` + +Distribution-profile resolution follows: + +```text +CLI --distro specified? + | + +----+----+ + | | + yes no + | | + v v + use CLI user config set? + | + +----+----+ + | | + yes no + | | + v v + use config detection / + interaction ``` -The workstation configuration layers build on that foundation: - -```text -Select workstation profile - │ - ▼ -Load profile - │ - ▼ -Validate profile contract - │ - ▼ -Validate referenced modules - │ - ▼ -Execute modules in profile order - │ - ▼ -Resolve module requirements - │ - ▼ -Selected distro adapter - │ - ▼ +Workstation configuration then builds on that foundation. + +Repository-backed capability: + +```text +Workstation profile + | + v +Module + | + v +Portable capability + | + v +Distro package mapping + | + v +Package state check + | + +----+----+ + | | +skip install + | + v +Distribution adapter + | + v Shared execution layer ``` -Workstation profile selection through the public CLI is introduced separately. Loading the profile framework itself does not install software. +External software: + +```text +Workstation module + | + v +External installer definition + | + v +External state check + | + +----+----+ + | | +skip install + | + v +Explicit installation method + | + v +External framework helper + | + v +Distribution adapter and/or +shared execution layer +``` + +Workstation profile selection through the public CLI is introduced separately. Loading the frameworks themselves does not install software. ## Distribution Profiles vs Workstation Profiles @@ -330,15 +950,117 @@ For example: Ubuntu distribution profile + Developer workstation profile - │ - ▼ + | + v Developer modules - │ - ▼ + | + v +Portable capabilities + | + v +Ubuntu package mappings + | + v +Package-state checks + | + v Ubuntu adapter ``` -Keeping these concepts separate allows the same workstation profile to eventually operate across multiple supported Linux distributions. +Keeping these concepts separate allows the same workstation profile and modules to operate across multiple Linux distributions. + +## Package Capability Model + +Capability identifiers represent software intent rather than package-manager syntax. + +Examples: + +```text +curl +wget +nano +sed +dos2unix +xz +figlet +``` + +The identifiers should remain stable even when distro package names differ. + +For example: + +```text +Ubuntu -> xz-utils +Debian -> xz-utils +Kali -> xz-utils +Fedora -> xz +Arch -> xz +``` + +Fedora and Arch are future support targets. Their package managers, state mechanisms, and mappings should be added through distro adapters and capability mapping files rather than conditions inside shared modules. + +## Idempotency + +Linux Workstation Bootstrap is designed to be safely rerunnable. + +A repeated run evaluates the current workstation state before attempting installation. + +For repository packages: + +```text +already installed -> skip +missing -> install +unknown state -> fail safely +``` + +For external software: + +```text +already installed -> skip +missing -> execute installer +unknown state -> fail safely +``` + +A state-check failure is never silently interpreted as absence. + +This protects against unnecessary reinstallations and prevents uncertain state from triggering unintended system changes. + +Dry-run mode still evaluates current installation state so it can distinguish operations that are already satisfied from operations that would be required. + +## Standard vs External Software + +Software from the selected distribution's normal repositories should use: + +```text +Capability + | + v +Package Mapping + | + v +Package State + | + v +Distro Adapter +``` + +Software requiring additional mechanisms should use: + +```text +External Installer +``` + +Examples of external installation candidates include: + +```text +vendor repositories +vendor distribution packages +downloaded archives +language-package-manager tools +explicit installer scripts +``` + +The external path should not be used merely to bypass normal package capability resolution. ## Modules and Profiles @@ -366,8 +1088,6 @@ developer profile └── database tooling ``` -A security-focused profile could reuse some of those same modules while selecting additional security capabilities. - ## Requirements * Linux @@ -378,6 +1098,15 @@ Ubuntu, Debian, and Kali Linux package operations additionally require: * APT * `apt-get` +* `dpkg-query` + +Some external installation methods may additionally require utilities such as: + +* `curl` +* `tar` +* `unzip` + +Specific external installer definitions are responsible for validating the tools they require. ## Clone and Run @@ -405,7 +1134,7 @@ Run the bootstrap: ./bin/linux-workstation-bootstrap ``` -The detected system configuration is displayed before a distribution profile is selected and its adapter is loaded. +The detected system configuration is displayed before a distribution profile, adapter, and package capability mapping are selected. ## Usage @@ -451,6 +1180,34 @@ Examples: Selecting a different distribution profile does not alter the detected host information. +An explicit `--distro` option overrides `distro_profile` from user configuration. + +## User Configuration Example + +Create the configuration directory: + +```bash +mkdir -p ~/.config/linux-workstation-bootstrap +``` + +Create: + +```text +~/.config/linux-workstation-bootstrap/config.conf +``` + +Example: + +```ini +# Linux Workstation Bootstrap + +distro_profile=ubuntu +``` + +The file is user-local and lives outside the repository. + +Do not store machine-specific preferences in `config/defaults.sh`. + ## Logging Each bootstrap execution creates a dedicated session log. @@ -465,6 +1222,8 @@ If `XDG_STATE_HOME` is configured, it is used as the state directory instead. The bootstrap displays the current session log and log directory during execution. +Configuration loading, selection precedence, installation-state decisions, skipped package requirements, missing packages, external-software state checks, installation attempts, and failures are recorded in the session log. + ## Project Structure ```text @@ -472,14 +1231,21 @@ linux-workstation-bootstrap/ ├── bin/ │ └── linux-workstation-bootstrap ├── config/ -│ └── defaults.sh +│ ├── defaults.sh +│ └── packages/ +│ ├── debian.sh +│ ├── kali.sh +│ └── ubuntu.sh ├── lib/ │ ├── arguments.sh │ ├── bootstrap.sh +│ ├── capability.sh +│ ├── config.sh │ ├── core.sh │ ├── detect.sh │ ├── distro.sh │ ├── execution.sh +│ ├── external.sh │ ├── logging.sh │ ├── module.sh │ ├── profile.sh @@ -488,31 +1254,67 @@ linux-workstation-bootstrap/ │ ├── debian.sh │ ├── kali.sh │ └── ubuntu.sh +├── installers/ ├── modules/ ├── profiles/ ├── tests/ -└── docs/ +├── docs/ +└── README.md ``` -Concrete workstation modules and profiles are added independently from their shared frameworks. +The `installers/` directory contains software-specific external installer definitions as they are introduced. + +Concrete workstation modules and profiles are added independently of their shared frameworks. ## Safety Linux Workstation Bootstrap is designed to make system changes deliberately and visibly. -Host detection, distribution selection, adapter validation, module validation, profile validation, configuration, logging, and execution logic are separated so operations can be checked before system changes are applied. +Repository defaults, user-local configuration, CLI arguments, host detection, distribution selection, capability resolution, package-state detection, external installer validation, external software state detection, adapter validation, module validation, profile validation, logging, and execution logic are separated so operations can be checked before system changes are applied. + +User configuration is parsed as data and is never sourced as arbitrary shell code. + +Only explicitly supported settings may be overridden. + +Invalid, duplicate, or unsupported configuration values fail clearly. + +CLI selections retain higher precedence than user configuration. + +Machine-specific configuration remains outside repository-managed defaults. Dry-run mode provides a way to preview execution without applying changes. -Command execution and privilege escalation are centralized so system-changing operations use a consistent execution path. Command arguments are not blindly written to logs because future operations may contain credentials, tokens, sensitive URLs, or other values that should not be persisted. +Current installation state is evaluated before package or external software installation. + +Already-satisfied requirements are skipped. -Distribution-specific operations are isolated behind a validated adapter contract. +Unknown or failed state checks stop execution rather than being interpreted as missing software. -Modules do not call package managers directly. +Command execution and privilege escalation are centralized so system-changing operations use a consistent execution path. + +Distribution-specific package names are isolated in package mapping files. + +Distribution-specific package-state checks remain inside distro adapters. + +Package-manager commands remain inside distribution adapters. + +Modules request portable capabilities instead of embedding distro package names. + +Modules do not duplicate package-state detection logic. + +External software uses explicit, reviewable installation methods and explicit installed-state checks. + +External downloads require HTTPS. + +Remote scripts are not piped directly into a shell. + +Temporary external installation files are staged predictably and cleaned after execution. Profiles do not contain package-manager commands or duplicate module implementation. -Loading a distro adapter, module, or profile does not itself change the workstation. +Loading configuration, a distro adapter, capability mapping, external installer definition, module, or profile does not itself change the workstation. + +Artifact integrity and external installer verification are separate concerns that build on the external installation framework. ## Project diff --git a/config/defaults.sh b/config/defaults.sh index 95357a3..c3ed3cc 100755 --- a/config/defaults.sh +++ b/config/defaults.sh @@ -9,7 +9,8 @@ # 2. Defines runtime requirements. # 3. Defines supported distribution profiles. # 4. Defines distribution, package manager, and architecture mappings. -# 5. Defines persistent logging defaults. +# 5. Defines user-configuration defaults and locations. +# 6. Defines persistent logging defaults. # Application identity. readonly LWBS_APP_NAME="Linux Workstation Bootstrap" @@ -36,6 +37,18 @@ declare -Ar LWBS_DISTRO_DISPLAY_NAMES=( [kali]="Kali Linux" ) +# Default distribution selection behaviour. +# +# "auto" means the bootstrap should use normal host detection and +# confirmation rather than forcing a specific distribution profile. +readonly LWBS_DEFAULT_DISTRO_PROFILE="auto" + +# User configuration location. +# +# The actual root is resolved from XDG_CONFIG_HOME or ~/.config at runtime. +readonly LWBS_CONFIG_DIRECTORY_NAME="$LWBS_APP_SLUG" +readonly LWBS_CONFIG_FILE_NAME="config.conf" + # Distribution-to-family mappings. # Additional distributions may be detected even when they do not yet # have an installation profile supported by the application. diff --git a/config/packages/debian.sh b/config/packages/debian.sh new file mode 100644 index 0000000..218ee07 --- /dev/null +++ b/config/packages/debian.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Linux Workstation Bootstrap +# Author: David Kariuki +# +# Debian package capability mappings. +# +# Capability identifiers are portable workstation intents. +# Values are the Debian package names that satisfy those capabilities. +# +# A capability may map to multiple packages by separating package names +# with newlines. + +LWBS_CAPABILITY_PACKAGES=( + [curl]="curl" + [dos2unix]="dos2unix" + [figlet]="figlet" + [nano]="nano" + [sed]="sed" + [wget]="wget" + [xz]="xz-utils" +) \ No newline at end of file diff --git a/config/packages/kali.sh b/config/packages/kali.sh new file mode 100644 index 0000000..03bfc35 --- /dev/null +++ b/config/packages/kali.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Linux Workstation Bootstrap +# Author: David Kariuki +# +# Kali Linux package capability mappings. +# +# Capability identifiers are portable workstation intents. +# Values are the Kali Linux package names that satisfy those capabilities. +# +# A capability may map to multiple packages by separating package names +# with newlines. + +LWBS_CAPABILITY_PACKAGES=( + [curl]="curl" + [dos2unix]="dos2unix" + [figlet]="figlet" + [nano]="nano" + [sed]="sed" + [wget]="wget" + [xz]="xz-utils" +) \ No newline at end of file diff --git a/config/packages/ubuntu.sh b/config/packages/ubuntu.sh new file mode 100644 index 0000000..356a2b8 --- /dev/null +++ b/config/packages/ubuntu.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Linux Workstation Bootstrap +# Author: David Kariuki +# +# Ubuntu package capability mappings. +# +# Capability identifiers are portable workstation intents. +# Values are the Ubuntu package names that satisfy those capabilities. +# +# A capability may map to multiple packages by separating package names +# with newlines. + +LWBS_CAPABILITY_PACKAGES=( + [curl]="curl" + [dos2unix]="dos2unix" + [figlet]="figlet" + [nano]="nano" + [sed]="sed" + [wget]="wget" + [xz]="xz-utils" +) \ No newline at end of file diff --git a/distros/debian.sh b/distros/debian.sh index 67bef63..1f1dda0 100644 --- a/distros/debian.sh +++ b/distros/debian.sh @@ -9,13 +9,13 @@ # # This file: # 1. Validates the Debian package-management environment. -# 2. Refreshes the package index. -# 3. Installs one or more packages. -# 4. Routes privileged package operations through the shared execution layer. +# 2. Detects whether repository packages are already installed. +# 3. Refreshes the package index. +# 4. Installs repository packages. +# 5. Installs local distribution package files. +# 6. Routes privileged package operations through the shared execution layer. -# Validate the environment required by the Debian adapter. distro_validate_environment() { - # System detection must run before the adapter can validate the host. if [[ -z "${SYSTEM_DISTRO_FAMILY:-}" ]]; then printf 'Error: system detection must run before validating the Debian adapter.\n' \ >&2 @@ -26,7 +26,6 @@ distro_validate_environment() { return 1 fi - # Debian uses the Debian package-management family. if [[ "$SYSTEM_DISTRO_FAMILY" != "debian" ]]; then printf 'Error: the Debian profile requires a Debian-family system.\n' \ >&2 @@ -37,7 +36,6 @@ distro_validate_environment() { return 1 fi - # The detected package manager must match the adapter implementation. if [[ "$SYSTEM_PACKAGE_MANAGER" != "apt" ]]; then printf 'Error: the Debian profile requires the APT package manager.\n' \ >&2 @@ -48,26 +46,75 @@ distro_validate_environment() { return 1 fi - # apt-get provides the stable command interface used for scripted - # package-management operations. if ! require_command apt-get; then log_error "Debian adapter requires apt-get but it is unavailable." return 1 fi + if ! require_command dpkg-query; then + log_error "Debian adapter requires dpkg-query but it is unavailable." + return 1 + fi + log_info "Debian distribution adapter environment validated." return 0 } -# Refresh the Debian package index. +distro_package_state() { + local package="${1:-}" + local package_status + local exit_code + + if [[ -z "$package" ]]; then + printf 'Error: no Debian package was provided for state checking.\n' >&2 + return 2 + fi + + if [[ ! "$package" =~ ^[A-Za-z0-9][A-Za-z0-9.+:_-]*$ ]]; then + printf 'Error: invalid Debian package identifier: %s\n' \ + "$package" >&2 + return 2 + fi + + if package_status="$( + dpkg-query \ + -W \ + -f='${db:Status-Status}' \ + -- "$package" \ + 2>/dev/null + )"; then + if [[ "$package_status" == "installed" ]]; then + printf '%s\n' "installed" + else + printf '%s\n' "absent" + fi + + return 0 + else + exit_code=$? + fi + + if ((exit_code == 1)); then + printf '%s\n' "absent" + return 0 + fi + + printf 'Error: unable to determine Debian package state: %s\n' \ + "$package" >&2 + + log_error \ + "Debian package state check failed: package=$package exit_code=$exit_code" + + return "$exit_code" +} + distro_update_package_index() { log_info "Refreshing Debian package index." run_privileged_command apt-get update } -# Install one or more Debian packages. distro_install_packages() { local -a packages=("$@") @@ -81,11 +128,35 @@ distro_install_packages() { return 2 fi - # Package arguments are intentionally not written blindly to the log. - # Module-level code can log package identifiers that it knows are safe. log_info \ "Debian package installation requested: count=${#packages[@]}" run_privileged_command \ apt-get install --yes "${packages[@]}" +} + +distro_install_local_package() { + local package_file="${1:-}" + + if [[ -z "$package_file" ]]; then + printf 'Error: no local Debian package file was provided.\n' >&2 + log_error "Debian local package installation requested without a file." + return 2 + fi + + if [[ "$package_file" != /* ]]; then + printf 'Error: local Debian package path must be absolute.\n' >&2 + return 2 + fi + + if ! "$LWBS_DRY_RUN" && [[ ! -f "$package_file" ]]; then + printf 'Error: local Debian package file not found: %s\n' \ + "$package_file" >&2 + return 1 + fi + + log_info "Debian local package installation requested." + + run_privileged_command \ + apt-get install --yes "$package_file" } \ No newline at end of file diff --git a/distros/kali.sh b/distros/kali.sh index c2a3eb4..ee56374 100644 --- a/distros/kali.sh +++ b/distros/kali.sh @@ -4,18 +4,18 @@ # Author: David Kariuki # # Kali Linux distribution adapter. -# Implements Kali package-management operations behind the shared +# Implements Kali Linux package-management operations behind the shared # distribution adapter contract. # # This file: # 1. Validates the Kali Linux package-management environment. -# 2. Refreshes the package index. -# 3. Installs one or more packages. -# 4. Routes privileged package operations through the shared execution layer. +# 2. Detects whether repository packages are already installed. +# 3. Refreshes the package index. +# 4. Installs repository packages. +# 5. Installs local distribution package files. +# 6. Routes privileged package operations through the shared execution layer. -# Validate the environment required by the Kali Linux adapter. distro_validate_environment() { - # System detection must run before the adapter can validate the host. if [[ -z "${SYSTEM_DISTRO_FAMILY:-}" ]]; then printf 'Error: system detection must run before validating the Kali Linux adapter.\n' \ >&2 @@ -26,7 +26,6 @@ distro_validate_environment() { return 1 fi - # Kali Linux is based on the Debian package-management family. if [[ "$SYSTEM_DISTRO_FAMILY" != "debian" ]]; then printf 'Error: the Kali Linux profile requires a Debian-family system.\n' \ >&2 @@ -37,7 +36,6 @@ distro_validate_environment() { return 1 fi - # The detected package manager must match the adapter implementation. if [[ "$SYSTEM_PACKAGE_MANAGER" != "apt" ]]; then printf 'Error: the Kali Linux profile requires the APT package manager.\n' \ >&2 @@ -48,26 +46,76 @@ distro_validate_environment() { return 1 fi - # apt-get provides the stable command interface used for scripted - # package-management operations. if ! require_command apt-get; then log_error "Kali Linux adapter requires apt-get but it is unavailable." return 1 fi + if ! require_command dpkg-query; then + log_error "Kali Linux adapter requires dpkg-query but it is unavailable." + return 1 + fi + log_info "Kali Linux distribution adapter environment validated." return 0 } -# Refresh the Kali Linux package index. +distro_package_state() { + local package="${1:-}" + local package_status + local exit_code + + if [[ -z "$package" ]]; then + printf 'Error: no Kali Linux package was provided for state checking.\n' \ + >&2 + return 2 + fi + + if [[ ! "$package" =~ ^[A-Za-z0-9][A-Za-z0-9.+:_-]*$ ]]; then + printf 'Error: invalid Kali Linux package identifier: %s\n' \ + "$package" >&2 + return 2 + fi + + if package_status="$( + dpkg-query \ + -W \ + -f='${db:Status-Status}' \ + -- "$package" \ + 2>/dev/null + )"; then + if [[ "$package_status" == "installed" ]]; then + printf '%s\n' "installed" + else + printf '%s\n' "absent" + fi + + return 0 + else + exit_code=$? + fi + + if ((exit_code == 1)); then + printf '%s\n' "absent" + return 0 + fi + + printf 'Error: unable to determine Kali Linux package state: %s\n' \ + "$package" >&2 + + log_error \ + "Kali Linux package state check failed: package=$package exit_code=$exit_code" + + return "$exit_code" +} + distro_update_package_index() { log_info "Refreshing Kali Linux package index." run_privileged_command apt-get update } -# Install one or more Kali Linux packages. distro_install_packages() { local -a packages=("$@") @@ -81,11 +129,35 @@ distro_install_packages() { return 2 fi - # Package arguments are intentionally not written blindly to the log. - # Module-level code can log package identifiers that it knows are safe. log_info \ "Kali Linux package installation requested: count=${#packages[@]}" run_privileged_command \ apt-get install --yes "${packages[@]}" +} + +distro_install_local_package() { + local package_file="${1:-}" + + if [[ -z "$package_file" ]]; then + printf 'Error: no local Kali Linux package file was provided.\n' >&2 + log_error "Kali Linux local package installation requested without a file." + return 2 + fi + + if [[ "$package_file" != /* ]]; then + printf 'Error: local Kali Linux package path must be absolute.\n' >&2 + return 2 + fi + + if ! "$LWBS_DRY_RUN" && [[ ! -f "$package_file" ]]; then + printf 'Error: local Kali Linux package file not found: %s\n' \ + "$package_file" >&2 + return 1 + fi + + log_info "Kali Linux local package installation requested." + + run_privileged_command \ + apt-get install --yes "$package_file" } \ No newline at end of file diff --git a/distros/ubuntu.sh b/distros/ubuntu.sh index aaf0a3b..3c58f3a 100644 --- a/distros/ubuntu.sh +++ b/distros/ubuntu.sh @@ -9,13 +9,13 @@ # # This file: # 1. Validates the Ubuntu package-management environment. -# 2. Refreshes the package index. -# 3. Installs one or more packages. -# 4. Routes privileged package operations through the shared execution layer. +# 2. Detects whether repository packages are already installed. +# 3. Refreshes the package index. +# 4. Installs repository packages. +# 5. Installs local distribution package files. +# 6. Routes privileged package operations through the shared execution layer. -# Validate the environment required by the Ubuntu adapter. distro_validate_environment() { - # System detection must run before the adapter can validate the host. if [[ -z "${SYSTEM_DISTRO_FAMILY:-}" ]]; then printf 'Error: system detection must run before validating the Ubuntu adapter.\n' \ >&2 @@ -26,7 +26,6 @@ distro_validate_environment() { return 1 fi - # Ubuntu uses the Debian package-management family. if [[ "$SYSTEM_DISTRO_FAMILY" != "debian" ]]; then printf 'Error: the Ubuntu profile requires a Debian-family system.\n' \ >&2 @@ -37,7 +36,6 @@ distro_validate_environment() { return 1 fi - # The detected package manager must match the adapter implementation. if [[ "$SYSTEM_PACKAGE_MANAGER" != "apt" ]]; then printf 'Error: the Ubuntu profile requires the APT package manager.\n' \ >&2 @@ -48,25 +46,84 @@ distro_validate_environment() { return 1 fi - # apt-get is preferred over apt for scripted package operations. if ! require_command apt-get; then log_error "Ubuntu adapter requires apt-get but it is unavailable." return 1 fi + if ! require_command dpkg-query; then + log_error "Ubuntu adapter requires dpkg-query but it is unavailable." + return 1 + fi + log_info "Ubuntu distribution adapter environment validated." return 0 } -# Refresh the Ubuntu package index. +# Report whether a package is installed. +# +# Output: +# installed +# absent +# +# A successfully determined absent package still returns zero. Non-zero +# return values are reserved for state-check failures. +distro_package_state() { + local package="${1:-}" + local package_status + local exit_code + + if [[ -z "$package" ]]; then + printf 'Error: no Ubuntu package was provided for state checking.\n' >&2 + return 2 + fi + + if [[ ! "$package" =~ ^[A-Za-z0-9][A-Za-z0-9.+:_-]*$ ]]; then + printf 'Error: invalid Ubuntu package identifier: %s\n' \ + "$package" >&2 + return 2 + fi + + if package_status="$( + dpkg-query \ + -W \ + -f='${db:Status-Status}' \ + -- "$package" \ + 2>/dev/null + )"; then + if [[ "$package_status" == "installed" ]]; then + printf '%s\n' "installed" + else + printf '%s\n' "absent" + fi + + return 0 + else + exit_code=$? + fi + + # dpkg-query returns 1 when the requested package is unknown or absent. + if ((exit_code == 1)); then + printf '%s\n' "absent" + return 0 + fi + + printf 'Error: unable to determine Ubuntu package state: %s\n' \ + "$package" >&2 + + log_error \ + "Ubuntu package state check failed: package=$package exit_code=$exit_code" + + return "$exit_code" +} + distro_update_package_index() { log_info "Refreshing Ubuntu package index." run_privileged_command apt-get update } -# Install one or more Ubuntu packages. distro_install_packages() { local -a packages=("$@") @@ -80,11 +137,35 @@ distro_install_packages() { return 2 fi - # Package arguments are intentionally not written blindly to the log. - # Module-level code can log package identifiers that it knows are safe. log_info \ "Ubuntu package installation requested: count=${#packages[@]}" run_privileged_command \ apt-get install --yes "${packages[@]}" +} + +distro_install_local_package() { + local package_file="${1:-}" + + if [[ -z "$package_file" ]]; then + printf 'Error: no local Ubuntu package file was provided.\n' >&2 + log_error "Ubuntu local package installation requested without a file." + return 2 + fi + + if [[ "$package_file" != /* ]]; then + printf 'Error: local Ubuntu package path must be absolute.\n' >&2 + return 2 + fi + + if ! "$LWBS_DRY_RUN" && [[ ! -f "$package_file" ]]; then + printf 'Error: local Ubuntu package file not found: %s\n' \ + "$package_file" >&2 + return 1 + fi + + log_info "Ubuntu local package installation requested." + + run_privileged_command \ + apt-get install --yes "$package_file" } \ No newline at end of file diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..618ad88 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,15 @@ +# Testing + +Linux Workstation Bootstrap includes an automated Bash test suite and shell-quality checks for validating the bootstrap foundation without modifying the developer workstation. + +Validation covers core runtime behaviour, operating-system detection, command-line parsing, user configuration, command execution, distribution adapters, Bash syntax, static analysis, and formatting. + +## Validation Layers + +Repository validation consists of four independent layers: + +```text +Bash syntax +ShellCheck +shfmt +Automated tests \ No newline at end of file diff --git a/lib/arguments.sh b/lib/arguments.sh index 8e4c566..c9708d1 100755 --- a/lib/arguments.sh +++ b/lib/arguments.sh @@ -9,31 +9,27 @@ # 2. Stores command-line selections for the current session. # 3. Rejects unknown or incomplete options before bootstrap processing begins. -# Runtime command-line state. -# These values describe the current execution and are not application configuration. LWBS_DRY_RUN=false LWBS_ASSUME_YES=false LWBS_REQUESTED_DISTRO_PROFILE="" LWBS_CLI_ACTION="run" -# Parse command-line arguments supplied to the bootstrap executable. parse_arguments() { local distro_value while (($# > 0)); do case "$1" in --dry-run) - # Preview intended actions without applying system changes. LWBS_DRY_RUN=true ;; -y | --yes) - # Accept supported detected configuration without prompting. + # Explicitly approve supported non-interactive confirmations, + # including detected distro selection and execution plans. LWBS_ASSUME_YES=true ;; --distro) - # --distro requires a separate distribution profile value. if (($# < 2)) || [[ -z "${2:-}" || "$2" == -* ]]; then printf 'Error: %s requires a distribution profile.\n' \ "$1" >&2 @@ -41,13 +37,10 @@ parse_arguments() { fi LWBS_REQUESTED_DISTRO_PROFILE="${2,,}" - - # Consume the distribution value together with the option. shift ;; --distro=*) - # Support the --distro=ubuntu form in addition to --distro ubuntu. distro_value="${1#*=}" if [[ -z "$distro_value" ]]; then @@ -60,23 +53,19 @@ parse_arguments() { ;; -h | --help) - # Help is handled by the bootstrap layer after parsing completes. LWBS_CLI_ACTION="help" ;; --version) - # Version output is handled without starting a bootstrap session. LWBS_CLI_ACTION="version" ;; -*) - # Reject options that are not part of the supported CLI. printf 'Error: unknown option: %s\n' "$1" >&2 return 2 ;; *) - # Positional arguments are not currently supported. printf 'Error: unexpected argument: %s\n' "$1" >&2 return 2 ;; diff --git a/lib/bootstrap.sh b/lib/bootstrap.sh index f44900d..6fba6d0 100755 --- a/lib/bootstrap.sh +++ b/lib/bootstrap.sh @@ -6,75 +6,62 @@ # Main application bootstrap. # Coordinates the shared bootstrap layers without embedding distribution- # specific installation logic in the application entry flow. -# -# This file: -# 1. Loads application configuration and shared components. -# 2. Processes command-line arguments. -# 3. Initializes application logging. -# 4. Validates the runtime environment. -# 5. Coordinates system detection. -# 6. Determines the distribution profile to use. -# 7. Loads and validates the selected distribution adapter. -# 8. Exposes the workstation module framework. -# 9. Exposes the workstation profile framework. -# 10. Controls the main bootstrap execution flow. - -# Load default application configuration. + # shellcheck source=../config/defaults.sh source "$LWBS_ROOT/config/defaults.sh" -# Load command-line argument handling. +# shellcheck source=./config.sh +source "$LWBS_ROOT/lib/config.sh" + # shellcheck source=./arguments.sh source "$LWBS_ROOT/lib/arguments.sh" -# Load application logging utilities. # shellcheck source=./logging.sh source "$LWBS_ROOT/lib/logging.sh" -# Load core application utilities. # shellcheck source=./core.sh source "$LWBS_ROOT/lib/core.sh" -# Load command execution utilities. # shellcheck source=./execution.sh source "$LWBS_ROOT/lib/execution.sh" -# Load distribution adapter utilities. # shellcheck source=./distro.sh source "$LWBS_ROOT/lib/distro.sh" -# Load workstation module utilities. +# shellcheck source=./capability.sh +source "$LWBS_ROOT/lib/capability.sh" + +# shellcheck source=./external.sh +source "$LWBS_ROOT/lib/external.sh" + # shellcheck source=./module.sh source "$LWBS_ROOT/lib/module.sh" -# Load workstation profile utilities. # shellcheck source=./profile.sh source "$LWBS_ROOT/lib/profile.sh" -# Load system detection utilities. # shellcheck source=./detect.sh source "$LWBS_ROOT/lib/detect.sh" -# Load terminal user interface utilities. # shellcheck source=./ui.sh source "$LWBS_ROOT/lib/ui.sh" -# Distribution profile selected for the current bootstrap session. +# shellcheck source=./verification.sh +source "$LWBS_ROOT/lib/verification.sh" + +# shellcheck source=./plan.sh +source "$LWBS_ROOT/lib/plan.sh" + SELECTED_DISTRO_PROFILE="" -# Main application entry point. -# All command-line arguments from the executable are passed here. bootstrap_main() { - # Start every interactive invocation with a clean terminal. clear_screen - # Parse command-line arguments before starting the bootstrap workflow. if ! parse_arguments "$@"; then printf '\nUse --help to view supported options.\n' >&2 return 2 fi - # Informational actions do not require logging or system detection. case "$LWBS_CLI_ACTION" in help) show_help @@ -86,27 +73,27 @@ bootstrap_main() { ;; esac - # Create a dedicated log file before bootstrap processing begins. init_logging - - # Show application and logging information immediately. show_startup_info log_info "$LWBS_APP_NAME $LWBS_VERSION started." - # Record the selected execution mode. if "$LWBS_DRY_RUN"; then log_info "Dry-run mode enabled." fi - # Validate the environment before performing system operations. + if ! load_user_config; then + log_error "User configuration validation failed." + show_failure_info + return 1 + fi + if ! validate_runtime; then log_error "Runtime validation failed." show_failure_info return 1 fi - # Detect the host operating system and architecture. if ! detect_system; then log_error "System detection failed." show_failure_info @@ -118,19 +105,18 @@ bootstrap_main() { show_system_summary - # Determine which distribution profile should control this session. if ! select_distro_profile; then log_info "Bootstrap cancelled by user." show_cancellation_info return 0 fi - log_info "Selected distribution profile: $SELECTED_DISTRO_PROFILE" + log_info \ + "Selected distribution profile: $SELECTED_DISTRO_PROFILE" printf 'Selected profile: %s\n' \ - "$(profile_name "$SELECTED_DISTRO_PROFILE")" + "$(distro_profile_name "$SELECTED_DISTRO_PROFILE")" - # Make profile overrides explicit when they differ from the detected host. if [[ "$SELECTED_DISTRO_PROFILE" != "$SYSTEM_DISTRO_ID" ]]; then printf 'Profile override: detected %s, selected %s\n' \ "$SYSTEM_DISTRO_ID" \ @@ -140,50 +126,36 @@ bootstrap_main() { "Distribution profile override: detected=$SYSTEM_DISTRO_ID selected=$SELECTED_DISTRO_PROFILE" fi - # Load the implementation associated with the selected distro profile. if ! load_distro_adapter "$SELECTED_DISTRO_PROFILE"; then - log_error \ - "Unable to load distribution adapter: $SELECTED_DISTRO_PROFILE" - show_failure_info return 1 fi - # Verify that the selected adapter can operate on the detected system. if ! distro_validate_environment; then - log_error \ - "Distribution adapter environment validation failed: $SELECTED_DISTRO_PROFILE" - show_failure_info return 1 fi - log_info \ - "Distribution adapter ready: $DISTRO_ADAPTER_PROFILE" + if ! load_capability_map "$SELECTED_DISTRO_PROFILE"; then + show_failure_info + return 1 + fi - # Workstation profile selection is introduced separately from the profile - # framework itself. Loading the framework does not execute any modules. if "$LWBS_DRY_RUN"; then printf '\nDry-run mode enabled. No system changes will be applied.\n' fi log_info "Bootstrap foundation completed." - # Show completion information and the session log location. show_completion_info } -# Determine which supported distribution profile should be used. select_distro_profile() { - # A profile explicitly supplied on the command line takes precedence. if [[ -n "$LWBS_REQUESTED_DISTRO_PROFILE" ]]; then if ! is_supported_distro "$LWBS_REQUESTED_DISTRO_PROFILE"; then printf 'Error: unsupported distribution profile: %s\n' \ "$LWBS_REQUESTED_DISTRO_PROFILE" >&2 - log_error \ - "Unsupported distribution profile requested: $LWBS_REQUESTED_DISTRO_PROFILE" - return 1 fi @@ -191,7 +163,13 @@ select_distro_profile() { return 0 fi - # Automatically accept a supported detected profile when --yes is used. + if [[ -n "${LWBS_CONFIG_DISTRO_PROFILE:-}" && + "$LWBS_CONFIG_DISTRO_PROFILE" != "auto" ]]; then + + SELECTED_DISTRO_PROFILE="$LWBS_CONFIG_DISTRO_PROFILE" + return 0 + fi + if "$LWBS_ASSUME_YES"; then if is_supported_distro "$SYSTEM_DISTRO_ID"; then SELECTED_DISTRO_PROFILE="$SYSTEM_DISTRO_ID" @@ -201,25 +179,15 @@ select_distro_profile() { printf 'Error: detected distribution "%s" is not currently supported.\n' \ "$SYSTEM_DISTRO_ID" >&2 - printf 'Specify a supported profile using --distro.\n' >&2 - - log_error \ - "Automatic profile selection failed for unsupported distro: $SYSTEM_DISTRO_ID" - return 1 fi - # Use the detected distribution when it is supported and confirmed. if is_supported_distro "$SYSTEM_DISTRO_ID" && confirm_detected_profile; then + SELECTED_DISTRO_PROFILE="$SYSTEM_DISTRO_ID" return 0 fi - # Fall back to interactive profile selection. - if ! choose_distro_profile; then - return 1 - fi - - return 0 + choose_distro_profile } \ No newline at end of file diff --git a/lib/capability.sh b/lib/capability.sh new file mode 100644 index 0000000..d7ed0a1 --- /dev/null +++ b/lib/capability.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash + +# Linux Workstation Bootstrap +# Author: David Kariuki +# +# Package capability resolution utilities. +# Provides the translation layer between portable workstation capability +# identifiers and distro-specific package names. +# +# This file: +# 1. Loads package capability mappings for the selected distribution profile. +# 2. Validates capability identifiers and mapped package names. +# 3. Resolves one capability into one or more distro-specific packages. +# 4. Resolves and deduplicates multiple capability requirements. +# 5. Keeps distro-specific package names outside shared module definitions. + +# Capability mapping loaded for the current distribution profile. +LWBS_CAPABILITY_PROFILE="" +LWBS_CAPABILITY_MAP_PATH="" + +# Capability-to-package mappings supplied by the selected distro mapping file. +declare -A LWBS_CAPABILITY_PACKAGES=() + +# Load package capability mappings for a supported distribution profile. +load_capability_map() { + local profile="${1:-}" + local mapping_directory + local mapping_path + + if [[ -z "$profile" ]]; then + printf 'Error: no distribution profile was provided for capability mapping.\n' \ + >&2 + + log_error \ + "Capability map load requested without a distribution profile." + + return 2 + fi + + if ! is_supported_distro "$profile"; then + printf 'Error: unsupported distribution profile for capability mapping: %s\n' \ + "$profile" >&2 + + log_error \ + "Capability map requested for unsupported profile: $profile" + + return 1 + fi + + mapping_directory="${LWBS_CAPABILITY_MAPS_DIR_OVERRIDE:-$LWBS_ROOT/config/packages}" + mapping_path="$mapping_directory/$profile.sh" + + if [[ ! -r "$mapping_path" ]]; then + printf 'Error: capability mapping not found: %s\n' \ + "$mapping_path" >&2 + + log_error \ + "Capability mapping file not found: profile=$profile path=$mapping_path" + + return 1 + fi + + # Remove mappings belonging to any previously loaded distro profile. + reset_capability_map + + log_info "Loading capability mapping: $profile" + + # shellcheck disable=SC1090 + if ! source "$mapping_path"; then + printf 'Error: failed to load capability mapping: %s\n' \ + "$profile" >&2 + + log_error \ + "Capability mapping source failed: profile=$profile path=$mapping_path" + + reset_capability_map + return 1 + fi + + if ! validate_capability_map; then + log_error \ + "Capability mapping validation failed: profile=$profile" + + reset_capability_map + return 1 + fi + + LWBS_CAPABILITY_PROFILE="$profile" + LWBS_CAPABILITY_MAP_PATH="$mapping_path" + + log_info \ + "Capability mapping loaded: profile=$profile capabilities=${#LWBS_CAPABILITY_PACKAGES[@]}" + + return 0 +} + +# Validate all capabilities and packages in the currently loaded mapping. +validate_capability_map() { + local capability + local package + local package_count + local package_output + + if ((${#LWBS_CAPABILITY_PACKAGES[@]} == 0)); then + printf 'Error: capability mapping does not define any capabilities.\n' \ + >&2 + + return 1 + fi + + for capability in "${!LWBS_CAPABILITY_PACKAGES[@]}"; do + if [[ ! "$capability" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then + printf 'Error: invalid capability identifier in mapping: %s\n' \ + "$capability" >&2 + + return 1 + fi + + package_output="${LWBS_CAPABILITY_PACKAGES[$capability]}" + package_count=0 + + while IFS= read -r package; do + if [[ -z "$package" ]]; then + continue + fi + + # Package names are data passed to the distro adapter and must + # never be interpreted as arbitrary command-line options. + if [[ ! "$package" =~ ^[A-Za-z0-9][A-Za-z0-9.+:_-]*$ ]]; then + printf 'Error: invalid package mapping for capability %s: %s\n' \ + "$capability" \ + "$package" >&2 + + return 1 + fi + + ((package_count += 1)) + done <<<"$package_output" + + if ((package_count == 0)); then + printf 'Error: capability does not map to any packages: %s\n' \ + "$capability" >&2 + + return 1 + fi + done + + return 0 +} + +# Resolve one capability into the package names required by the loaded profile. +resolve_capability() { + local capability="${1:-}" + + if [[ -z "$capability" ]]; then + printf 'Error: no capability was provided for resolution.\n' >&2 + log_error "Capability resolution requested without a capability ID." + return 2 + fi + + if [[ ! "$capability" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then + printf 'Error: invalid capability identifier: %s\n' \ + "$capability" >&2 + + log_error \ + "Invalid capability identifier requested: $capability" + + return 2 + fi + + if [[ -z "$LWBS_CAPABILITY_PROFILE" ]]; then + printf 'Error: no capability mapping is currently loaded.\n' >&2 + + log_error \ + "Capability resolution requested without a loaded mapping: $capability" + + return 1 + fi + + if [[ -z "${LWBS_CAPABILITY_PACKAGES[$capability]+defined}" ]]; then + printf 'Error: capability "%s" is not supported by distribution profile "%s".\n' \ + "$capability" \ + "$LWBS_CAPABILITY_PROFILE" >&2 + + log_error \ + "Capability mapping missing: profile=$LWBS_CAPABILITY_PROFILE capability=$capability" + + return 1 + fi + + log_info \ + "Resolved capability: profile=$LWBS_CAPABILITY_PROFILE capability=$capability" + + printf '%s\n' "${LWBS_CAPABILITY_PACKAGES[$capability]}" +} + +# Resolve multiple capabilities into a deduplicated package array. +# +# Usage: +# local -a packages=() +# resolve_capabilities packages curl wget xz +resolve_capabilities() { + local result_name="${1:-}" + local capability + local package + local package_output + local exit_code + + if [[ -z "$result_name" ]]; then + printf 'Error: no output array was provided for capability resolution.\n' \ + >&2 + + log_error \ + "Capability resolution requested without an output array." + + return 2 + fi + + # Namerefs accept variable names, not arbitrary expressions. + if [[ ! "$result_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + printf 'Error: invalid output array name for capability resolution: %s\n' \ + "$result_name" >&2 + + return 2 + fi + + shift + + # The nameref deliberately uses a different local identifier from the + # caller-provided array name to avoid circular Bash nameref resolution. + local -n output_array_ref="$result_name" + local -A seen_packages=() + + output_array_ref=() + + for capability in "$@"; do + if package_output="$(resolve_capability "$capability")"; then + : + else + exit_code=$? + return "$exit_code" + fi + + while IFS= read -r package; do + if [[ -z "$package" ]]; then + continue + fi + + if [[ -n "${seen_packages[$package]+defined}" ]]; then + continue + fi + + seen_packages["$package"]=1 + output_array_ref+=("$package") + done <<<"$package_output" + done + + return 0 +} + +# Remove the currently loaded capability mapping. +reset_capability_map() { + LWBS_CAPABILITY_PROFILE="" + LWBS_CAPABILITY_MAP_PATH="" + LWBS_CAPABILITY_PACKAGES=() +} \ No newline at end of file diff --git a/lib/config.sh b/lib/config.sh new file mode 100644 index 0000000..978a32f --- /dev/null +++ b/lib/config.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash + +# Linux Workstation Bootstrap +# Author: David Kariuki +# +# User configuration handling. +# Provides a safe, deterministic way to override supported application +# defaults without modifying repository-managed configuration files. +# +# Configuration precedence: +# +# repository defaults +# -> user configuration +# -> command-line arguments +# +# User configuration is parsed as data. The file is never sourced as shell +# code, preventing configuration values from becoming arbitrary execution. +# +# Supported settings: +# +# distro_profile=auto| +# +# Additional settings should only be introduced when the corresponding +# application behaviour exists and can be validated safely. + +LWBS_USER_CONFIG_PATH="" +LWBS_CONFIG_DISTRO_PROFILE="$LWBS_DEFAULT_DISTRO_PROFILE" + +# Resolve the location of the optional user configuration file. +resolve_user_config_path() { + local config_root + + # Primarily used by automated tests and controlled embedding scenarios. + if [[ -n "${LWBS_USER_CONFIG_PATH_OVERRIDE:-}" ]]; then + printf '%s\n' "$LWBS_USER_CONFIG_PATH_OVERRIDE" + return 0 + fi + + if [[ -n "${XDG_CONFIG_HOME:-}" ]]; then + config_root="$XDG_CONFIG_HOME" + elif [[ -n "${HOME:-}" ]]; then + config_root="$HOME/.config" + else + printf 'Error: unable to determine the user configuration directory.\n' \ + >&2 + return 1 + fi + + printf '%s/%s/%s\n' \ + "$config_root" \ + "$LWBS_CONFIG_DIRECTORY_NAME" \ + "$LWBS_CONFIG_FILE_NAME" +} + +# Trim leading and trailing whitespace from a string. +trim_config_value() { + local value="${1:-}" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + printf '%s\n' "$value" +} + +# Validate and store one supported user configuration value. +apply_user_config_value() { + local key="${1:-}" + local value="${2:-}" + + case "$key" in + distro_profile) + value="${value,,}" + + if [[ "$value" == "auto" ]]; then + LWBS_CONFIG_DISTRO_PROFILE="auto" + return 0 + fi + + if ! is_supported_distro "$value"; then + printf 'Error: unsupported distro_profile in user configuration: %s\n' \ + "$value" >&2 + + return 1 + fi + + LWBS_CONFIG_DISTRO_PROFILE="$value" + ;; + + *) + printf 'Error: unsupported user configuration setting: %s\n' \ + "$key" >&2 + + return 1 + ;; + esac + + return 0 +} + +# Load the optional user-local configuration file. +load_user_config() { + local config_path + local line + local key + local value + local line_number=0 + + local -A seen_keys=() + + # Start from repository defaults for every bootstrap invocation. + LWBS_CONFIG_DISTRO_PROFILE="$LWBS_DEFAULT_DISTRO_PROFILE" + LWBS_USER_CONFIG_PATH="" + + if config_path="$(resolve_user_config_path)"; then + : + else + return $? + fi + + LWBS_USER_CONFIG_PATH="$config_path" + + # User configuration is optional. + if [[ ! -e "$config_path" ]]; then + log_info \ + "User configuration not present; repository defaults will be used." + + return 0 + fi + + if [[ ! -f "$config_path" ]]; then + printf 'Error: user configuration path is not a regular file: %s\n' \ + "$config_path" >&2 + + log_error \ + "User configuration path is not a regular file: $config_path" + + return 1 + fi + + if [[ ! -r "$config_path" ]]; then + printf 'Error: user configuration file is not readable: %s\n' \ + "$config_path" >&2 + + log_error \ + "User configuration file is not readable: $config_path" + + return 1 + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + ((line_number += 1)) + + line="$(trim_config_value "$line")" + + # Ignore empty lines and full-line comments. + if [[ -z "$line" || "$line" == \#* ]]; then + continue + fi + + if [[ "$line" != *=* ]]; then + printf 'Error: invalid user configuration syntax at line %d.\n' \ + "$line_number" >&2 + + log_error \ + "Invalid user configuration syntax: path=$config_path line=$line_number" + + return 1 + fi + + key="$(trim_config_value "${line%%=*}")" + value="$(trim_config_value "${line#*=}")" + + if [[ ! "$key" =~ ^[a-z][a-z0-9_]*$ ]]; then + printf 'Error: invalid user configuration key at line %d: %s\n' \ + "$line_number" \ + "$key" >&2 + + log_error \ + "Invalid user configuration key: path=$config_path line=$line_number key=$key" + + return 1 + fi + + if [[ -z "$value" ]]; then + printf 'Error: empty user configuration value at line %d: %s\n' \ + "$line_number" \ + "$key" >&2 + + log_error \ + "Empty user configuration value: path=$config_path line=$line_number key=$key" + + return 1 + fi + + if [[ -n "${seen_keys[$key]+defined}" ]]; then + printf 'Error: duplicate user configuration setting at line %d: %s\n' \ + "$line_number" \ + "$key" >&2 + + log_error \ + "Duplicate user configuration setting: path=$config_path line=$line_number key=$key" + + return 1 + fi + + seen_keys["$key"]=1 + + if ! apply_user_config_value "$key" "$value"; then + log_error \ + "Invalid user configuration value: path=$config_path line=$line_number key=$key" + + return 1 + fi + done <"$config_path" + + log_info "User configuration loaded: $config_path" + log_info \ + "Configured default distribution profile: $LWBS_CONFIG_DISTRO_PROFILE" + + return 0 +} \ No newline at end of file diff --git a/lib/distro.sh b/lib/distro.sh index 2c0d4e4..1e33557 100644 --- a/lib/distro.sh +++ b/lib/distro.sh @@ -12,6 +12,7 @@ # 2. Validates the distribution adapter interface. # 3. Tracks the adapter loaded for the current bootstrap session. # 4. Prevents unsupported or incomplete adapters from being used. +# 5. Requires distro-specific package-state detection. # Distribution adapter loaded for the current bootstrap session. DISTRO_ADAPTER_PROFILE="" @@ -22,14 +23,12 @@ load_distro_adapter() { local profile="${1:-}" local adapter_path - # A distribution profile must be selected before an adapter can be loaded. if [[ -z "$profile" ]]; then printf 'Error: no distribution profile was provided.\n' >&2 log_error "Distribution adapter load requested without a profile." return 2 fi - # Only configured supported profiles may load distribution adapters. if ! is_supported_distro "$profile"; then printf 'Error: unsupported distribution profile: %s\n' \ "$profile" >&2 @@ -42,7 +41,6 @@ load_distro_adapter() { adapter_path="$LWBS_ROOT/distros/$profile.sh" - # A supported profile must have a corresponding readable adapter. if [[ ! -r "$adapter_path" ]]; then printf 'Error: distribution adapter not found: %s\n' \ "$adapter_path" >&2 @@ -53,8 +51,6 @@ load_distro_adapter() { return 1 fi - # Remove any previously loaded contract functions before sourcing another - # adapter so validation cannot succeed using stale functions. reset_distro_adapter_contract log_info "Loading distribution adapter: $profile" @@ -71,7 +67,6 @@ load_distro_adapter() { return 1 fi - # Reject adapters that do not implement the required interface. if ! validate_distro_adapter; then log_error \ "Distribution adapter validation failed: profile=$profile" @@ -91,10 +86,13 @@ load_distro_adapter() { # Validate the interface required from every distribution adapter. validate_distro_adapter() { local required_function + local -a required_functions=( "distro_validate_environment" "distro_update_package_index" "distro_install_packages" + "distro_install_local_package" + "distro_package_state" ) for required_function in "${required_functions[@]}"; do @@ -115,6 +113,8 @@ reset_distro_adapter_contract() { distro_validate_environment \ distro_update_package_index \ distro_install_packages \ + distro_install_local_package \ + distro_package_state \ 2>/dev/null || true DISTRO_ADAPTER_PROFILE="" diff --git a/lib/external.sh b/lib/external.sh new file mode 100644 index 0000000..6a86685 --- /dev/null +++ b/lib/external.sh @@ -0,0 +1,730 @@ +#!/usr/bin/env bash + +# Linux Workstation Bootstrap +# Author: David Kariuki +# +# External software installation framework. +# Provides a controlled path for software that is not installed exclusively +# from the selected distribution's standard package repositories. +# +# This file: +# 1. Loads software-specific installer definitions. +# 2. Validates external installer contracts and installation methods. +# 3. Requires explicit installed-state detection for idempotent execution. +# 4. Skips external installation when the requested software is satisfied. +# 5. Provides predictable temporary staging for downloaded artifacts. +# 6. Provides controlled helpers for downloads, local packages, archives, +# repository files, and installer scripts. +# 7. Routes commands through the shared execution layer. +# 8. Preserves dry-run behaviour. + +LWBS_EXTERNAL_ID="" +LWBS_EXTERNAL_NAME="" +LWBS_EXTERNAL_DESCRIPTION="" +LWBS_EXTERNAL_METHOD="" +LWBS_EXTERNAL_PATH="" +LWBS_EXTERNAL_STAGE_DIR="" + +declare -ar LWBS_EXTERNAL_METHODS=( + "repository" + "package" + "archive" + "script" +) + +load_external_installer() { + local requested_installer="${1:-}" + local installer_directory + local installer_path + + if [[ -z "$requested_installer" ]]; then + printf 'Error: no external installer was provided.\n' >&2 + log_error "External installer load requested without an installer ID." + return 2 + fi + + if [[ ! "$requested_installer" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then + printf 'Error: invalid external installer ID: %s\n' \ + "$requested_installer" >&2 + + log_error \ + "Invalid external installer ID requested: $requested_installer" + + return 2 + fi + + installer_directory="${LWBS_EXTERNAL_INSTALLERS_DIR_OVERRIDE:-$LWBS_ROOT/installers}" + installer_path="$installer_directory/$requested_installer.sh" + + if [[ ! -r "$installer_path" ]]; then + printf 'Error: external installer not found: %s\n' \ + "$installer_path" >&2 + + log_error \ + "External installer file not found: installer=$requested_installer path=$installer_path" + + return 1 + fi + + reset_external_installer_contract + + log_info "Loading external installer: $requested_installer" + + # shellcheck disable=SC1090 + if ! source "$installer_path"; then + printf 'Error: failed to load external installer: %s\n' \ + "$requested_installer" >&2 + + log_error \ + "External installer source failed: installer=$requested_installer path=$installer_path" + + reset_external_installer_contract + return 1 + fi + + if ! validate_external_installer_contract; then + log_error \ + "External installer contract validation failed: $requested_installer" + + reset_external_installer_contract + return 1 + fi + + LWBS_EXTERNAL_ID="$(external_id)" + LWBS_EXTERNAL_NAME="$(external_name)" + LWBS_EXTERNAL_DESCRIPTION="$(external_description)" + LWBS_EXTERNAL_METHOD="$(external_method)" + + if [[ -z "$LWBS_EXTERNAL_ID" ]]; then + printf 'Error: external installer returned an empty installer ID.\n' >&2 + reset_external_installer_contract + return 1 + fi + + if [[ "$LWBS_EXTERNAL_ID" != "$requested_installer" ]]; then + printf 'Error: external installer ID mismatch: requested %s, installer declared %s\n' \ + "$requested_installer" \ + "$LWBS_EXTERNAL_ID" >&2 + + log_error \ + "External installer ID mismatch: requested=$requested_installer declared=$LWBS_EXTERNAL_ID" + + reset_external_installer_contract + return 1 + fi + + if [[ -z "$LWBS_EXTERNAL_NAME" ]]; then + printf 'Error: external installer returned an empty display name: %s\n' \ + "$requested_installer" >&2 + + reset_external_installer_contract + return 1 + fi + + if [[ -z "$LWBS_EXTERNAL_DESCRIPTION" ]]; then + printf 'Error: external installer returned an empty description: %s\n' \ + "$requested_installer" >&2 + + reset_external_installer_contract + return 1 + fi + + if ! is_supported_external_method "$LWBS_EXTERNAL_METHOD"; then + printf 'Error: unsupported external installation method "%s" for installer "%s".\n' \ + "$LWBS_EXTERNAL_METHOD" \ + "$requested_installer" >&2 + + log_error \ + "Unsupported external installation method: installer=$requested_installer method=$LWBS_EXTERNAL_METHOD" + + reset_external_installer_contract + return 1 + fi + + if ! validate_external_method_contract "$LWBS_EXTERNAL_METHOD"; then + log_error \ + "External installer method contract validation failed: installer=$requested_installer method=$LWBS_EXTERNAL_METHOD" + + reset_external_installer_contract + return 1 + fi + + LWBS_EXTERNAL_PATH="$installer_path" + + log_info \ + "External installer loaded: id=$LWBS_EXTERNAL_ID method=$LWBS_EXTERNAL_METHOD" + + return 0 +} + +validate_external_installer_contract() { + local required_function + + local -a required_functions=( + "external_id" + "external_name" + "external_description" + "external_method" + "external_state" + ) + + for required_function in "${required_functions[@]}"; do + if ! declare -F "$required_function" >/dev/null 2>&1; then + printf 'Error: external installer is missing required function: %s\n' \ + "$required_function" >&2 + + return 1 + fi + done + + return 0 +} + +is_supported_external_method() { + local requested_method="${1:-}" + local supported_method + + for supported_method in "${LWBS_EXTERNAL_METHODS[@]}"; do + if [[ "$requested_method" == "$supported_method" ]]; then + return 0 + fi + done + + return 1 +} + +validate_external_method_contract() { + local method="${1:-}" + local required_function + + required_function="external_${method}_apply" + + if ! declare -F "$required_function" >/dev/null 2>&1; then + printf 'Error: external installer method "%s" requires function: %s\n' \ + "$method" \ + "$required_function" >&2 + + return 1 + fi + + return 0 +} + +# Query the current state reported by the loaded external installer. +# +# The installer must return one of: +# installed +# absent +# +# Any non-zero return code means the state could not be determined. +query_external_installation_state() { + local installation_state + local exit_code + + if [[ -z "$LWBS_EXTERNAL_ID" ]]; then + printf 'Error: no external installer is currently loaded.\n' >&2 + return 2 + fi + + if installation_state="$(external_state)"; then + : + else + exit_code=$? + + printf 'Error: unable to determine external software state: %s\n' \ + "$LWBS_EXTERNAL_ID" >&2 + + log_error \ + "External software state check failed: installer=$LWBS_EXTERNAL_ID exit_code=$exit_code" + + return "$exit_code" + fi + + case "$installation_state" in + installed | absent) + printf '%s\n' "$installation_state" + return 0 + ;; + + *) + printf 'Error: external installer "%s" returned invalid state "%s".\n' \ + "$LWBS_EXTERNAL_ID" \ + "$installation_state" >&2 + + log_error \ + "Invalid external software state: installer=$LWBS_EXTERNAL_ID state=$installation_state" + + return 1 + ;; + esac +} + +execute_loaded_external_installer() { + local apply_function + local installation_state + local apply_exit_code=0 + local cleanup_exit_code=0 + local validation_exit_code + local state_exit_code + + if [[ -z "$LWBS_EXTERNAL_ID" ]]; then + printf 'Error: no external installer is currently loaded.\n' >&2 + log_error "External installer execution requested without a loaded installer." + return 2 + fi + + if [[ -z "${DISTRO_ADAPTER_PROFILE:-}" ]]; then + printf 'Error: a distribution adapter must be loaded before executing external installers.\n' \ + >&2 + + log_error \ + "External installer execution requested without a distro adapter: installer=$LWBS_EXTERNAL_ID" + + return 1 + fi + + printf '\nExternal software: %s\n' "$LWBS_EXTERNAL_NAME" + printf ' ID : %s\n' "$LWBS_EXTERNAL_ID" + printf ' Description : %s\n' "$LWBS_EXTERNAL_DESCRIPTION" + printf ' Method : %s\n' "$LWBS_EXTERNAL_METHOD" + + log_info \ + "Evaluating external installer: id=$LWBS_EXTERNAL_ID method=$LWBS_EXTERNAL_METHOD" + + if installation_state="$(query_external_installation_state)"; then + : + else + state_exit_code=$? + return "$state_exit_code" + fi + + if [[ "$installation_state" == "installed" ]]; then + printf ' Status : already installed\n' + printf ' Action : skip\n' + + log_info \ + "External installer skipped because software is already installed: $LWBS_EXTERNAL_ID" + + return 0 + fi + + printf ' Status : not installed\n' + + # Installation-specific requirements are evaluated only when an + # installation is actually necessary. + if declare -F external_validate_environment >/dev/null 2>&1; then + if external_validate_environment; then + : + else + validation_exit_code=$? + + log_error \ + "External installer environment validation failed: installer=$LWBS_EXTERNAL_ID exit_code=$validation_exit_code" + + return "$validation_exit_code" + fi + fi + + if ! prepare_external_staging; then + log_error \ + "Unable to prepare external installer staging: $LWBS_EXTERNAL_ID" + + return 1 + fi + + apply_function="external_${LWBS_EXTERNAL_METHOD}_apply" + + if "$apply_function"; then + apply_exit_code=0 + else + apply_exit_code=$? + + log_error \ + "External installation failed: installer=$LWBS_EXTERNAL_ID method=$LWBS_EXTERNAL_METHOD exit_code=$apply_exit_code" + fi + + if cleanup_external_staging; then + cleanup_exit_code=0 + else + cleanup_exit_code=$? + + log_error \ + "External installer staging cleanup failed: installer=$LWBS_EXTERNAL_ID exit_code=$cleanup_exit_code" + fi + + if ((apply_exit_code != 0)); then + return "$apply_exit_code" + fi + + if ((cleanup_exit_code != 0)); then + return "$cleanup_exit_code" + fi + + if "$LWBS_DRY_RUN"; then + log_info \ + "Dry-run external installer completed: $LWBS_EXTERNAL_ID" + else + log_info \ + "External installer completed successfully: $LWBS_EXTERNAL_ID" + fi + + return 0 +} + +run_external_installer() { + local installer_id="${1:-}" + local exit_code + + if load_external_installer "$installer_id"; then + : + else + exit_code=$? + return "$exit_code" + fi + + execute_loaded_external_installer +} + +prepare_external_staging() { + local temp_root + + if [[ -n "$LWBS_EXTERNAL_STAGE_DIR" ]]; then + return 0 + fi + + temp_root="${TMPDIR:-/tmp}" + + if "$LWBS_DRY_RUN"; then + LWBS_EXTERNAL_STAGE_DIR="$temp_root/${LWBS_APP_SLUG}-external-dry-run" + + log_info \ + "Dry-run external staging path prepared: installer=$LWBS_EXTERNAL_ID" + + return 0 + fi + + if [[ ! -d "$temp_root" || ! -w "$temp_root" ]]; then + printf 'Error: external staging root is not writable: %s\n' \ + "$temp_root" >&2 + + return 1 + fi + + if ! LWBS_EXTERNAL_STAGE_DIR="$( + mktemp -d "$temp_root/${LWBS_APP_SLUG}-external.XXXXXX" + )"; then + printf 'Error: unable to create external installation staging directory.\n' \ + >&2 + return 1 + fi + + chmod 700 -- "$LWBS_EXTERNAL_STAGE_DIR" + + log_info \ + "External installer staging created: installer=$LWBS_EXTERNAL_ID" + + return 0 +} + +cleanup_external_staging() { + local stage_directory="$LWBS_EXTERNAL_STAGE_DIR" + + LWBS_EXTERNAL_STAGE_DIR="" + + if [[ -z "$stage_directory" ]]; then + return 0 + fi + + if "$LWBS_DRY_RUN"; then + log_info \ + "Dry-run external staging cleanup completed: installer=$LWBS_EXTERNAL_ID" + + return 0 + fi + + if [[ ! -d "$stage_directory" ]]; then + return 0 + fi + + if run_command rm -rf -- "$stage_directory"; then + return 0 + fi + + printf 'Error: unable to clean external installer staging directory.\n' \ + >&2 + + return 1 +} + +external_stage_path() { + local filename="${1:-}" + + if [[ -z "$LWBS_EXTERNAL_STAGE_DIR" ]]; then + printf 'Error: external installer staging has not been prepared.\n' >&2 + return 1 + fi + + if [[ -z "$filename" || + ! "$filename" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + printf 'Error: invalid external staging filename: %s\n' \ + "$filename" >&2 + + return 2 + fi + + printf '%s/%s\n' \ + "$LWBS_EXTERNAL_STAGE_DIR" \ + "$filename" +} + +external_download() { + local url="${1:-}" + local destination="${2:-}" + + if [[ -z "$url" || -z "$destination" ]]; then + printf 'Error: external download requires a URL and destination.\n' \ + >&2 + return 2 + fi + + if [[ ! "$url" =~ ^https://[^[:space:]]+$ ]]; then + printf 'Error: external download URL must use HTTPS.\n' >&2 + + log_error \ + "Rejected non-HTTPS external download: installer=$LWBS_EXTERNAL_ID" + + return 1 + fi + + if [[ "$destination" != /* ]]; then + printf 'Error: external download destination must be an absolute path.\n' \ + >&2 + return 2 + fi + + if ! require_command curl; then + log_error \ + "External installer requires curl: installer=$LWBS_EXTERNAL_ID" + return 1 + fi + + log_info \ + "Downloading external artifact: installer=$LWBS_EXTERNAL_ID" + + run_command \ + curl \ + --fail \ + --location \ + --silent \ + --show-error \ + --output "$destination" \ + "$url" +} + +external_install_local_package() { + local package_file="${1:-}" + + if [[ -z "$package_file" ]]; then + printf 'Error: no local package file was provided.\n' >&2 + return 2 + fi + + if [[ "$package_file" != /* ]]; then + printf 'Error: local package path must be absolute.\n' >&2 + return 2 + fi + + if ! declare -F distro_install_local_package >/dev/null 2>&1; then + printf 'Error: the loaded distribution adapter cannot install local packages.\n' \ + >&2 + + return 1 + fi + + if ! "$LWBS_DRY_RUN" && [[ ! -f "$package_file" ]]; then + printf 'Error: local package file not found: %s\n' \ + "$package_file" >&2 + + return 1 + fi + + distro_install_local_package "$package_file" +} + +external_install_system_file() { + local source_file="${1:-}" + local destination="${2:-}" + local mode="${3:-0644}" + + if [[ -z "$source_file" || -z "$destination" ]]; then + printf 'Error: system file installation requires source and destination paths.\n' \ + >&2 + return 2 + fi + + if [[ "$source_file" != /* || "$destination" != /* ]]; then + printf 'Error: system file installation paths must be absolute.\n' \ + >&2 + return 2 + fi + + if [[ ! "$mode" =~ ^0?[0-7]{3,4}$ ]]; then + printf 'Error: invalid system file mode: %s\n' \ + "$mode" >&2 + return 2 + fi + + if ! "$LWBS_DRY_RUN" && [[ ! -f "$source_file" ]]; then + printf 'Error: source file not found: %s\n' \ + "$source_file" >&2 + return 1 + fi + + run_privileged_command \ + install \ + -D \ + -m "$mode" \ + -- "$source_file" "$destination" +} + +external_extract_tar_archive() { + local archive_file="${1:-}" + local destination="${2:-}" + local exit_code + + if [[ -z "$archive_file" || -z "$destination" ]]; then + printf 'Error: archive extraction requires an archive and destination.\n' \ + >&2 + return 2 + fi + + if [[ "$archive_file" != /* || "$destination" != /* ]]; then + printf 'Error: archive and destination paths must be absolute.\n' \ + >&2 + return 2 + fi + + if ! require_command tar; then + return 1 + fi + + if ! "$LWBS_DRY_RUN" && [[ ! -f "$archive_file" ]]; then + printf 'Error: archive file not found: %s\n' \ + "$archive_file" >&2 + return 1 + fi + + if run_command mkdir -p -- "$destination"; then + : + else + exit_code=$? + return "$exit_code" + fi + + run_command \ + tar \ + -xf "$archive_file" \ + -C "$destination" +} + +external_extract_zip_archive() { + local archive_file="${1:-}" + local destination="${2:-}" + local exit_code + + if [[ -z "$archive_file" || -z "$destination" ]]; then + printf 'Error: ZIP extraction requires an archive and destination.\n' \ + >&2 + return 2 + fi + + if [[ "$archive_file" != /* || "$destination" != /* ]]; then + printf 'Error: archive and destination paths must be absolute.\n' \ + >&2 + return 2 + fi + + if ! require_command unzip; then + return 1 + fi + + if ! "$LWBS_DRY_RUN" && [[ ! -f "$archive_file" ]]; then + printf 'Error: ZIP archive file not found: %s\n' \ + "$archive_file" >&2 + return 1 + fi + + if run_command mkdir -p -- "$destination"; then + : + else + exit_code=$? + return "$exit_code" + fi + + run_command \ + unzip \ + -q \ + "$archive_file" \ + -d "$destination" +} + +external_run_script_file() { + local interpreter="${1:-}" + local script_file="${2:-}" + + if [[ -z "$interpreter" || -z "$script_file" ]]; then + printf 'Error: script execution requires an interpreter and script file.\n' \ + >&2 + return 2 + fi + + if [[ ! "$interpreter" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then + printf 'Error: invalid installer script interpreter: %s\n' \ + "$interpreter" >&2 + return 2 + fi + + if [[ "$script_file" != /* ]]; then + printf 'Error: installer script path must be absolute.\n' >&2 + return 2 + fi + + if ! require_command "$interpreter"; then + return 1 + fi + + if ! "$LWBS_DRY_RUN" && [[ ! -f "$script_file" ]]; then + printf 'Error: installer script file not found: %s\n' \ + "$script_file" >&2 + return 1 + fi + + log_info \ + "Executing staged installer script: installer=$LWBS_EXTERNAL_ID interpreter=$interpreter" + + run_command \ + "$interpreter" \ + "$script_file" +} + +reset_external_installer_contract() { + unset -f \ + external_id \ + external_name \ + external_description \ + external_method \ + external_state \ + external_validate_environment \ + external_repository_apply \ + external_package_apply \ + external_archive_apply \ + external_script_apply \ + 2>/dev/null || true + + LWBS_EXTERNAL_ID="" + LWBS_EXTERNAL_NAME="" + LWBS_EXTERNAL_DESCRIPTION="" + LWBS_EXTERNAL_METHOD="" + LWBS_EXTERNAL_PATH="" + LWBS_EXTERNAL_STAGE_DIR="" +} \ No newline at end of file diff --git a/lib/module.sh b/lib/module.sh index 1d5459b..9d432a4 100644 --- a/lib/module.sh +++ b/lib/module.sh @@ -10,26 +10,35 @@ # This file: # 1. Loads workstation modules from the modules directory. # 2. Validates the module contract. -# 3. Captures module metadata and package requirements. -# 4. Routes package installation through the selected distro adapter. -# 5. Supports optional module-specific operations. -# 6. Preserves dry-run behaviour through shared execution utilities. +# 3. Captures module metadata and capability requirements. +# 4. Captures declarative external software requirements. +# 5. Resolves capabilities through the selected distro package mapping. +# 6. Checks resolved package installation state through the distro adapter. +# 7. Skips package requirements that are already satisfied. +# 8. Routes missing packages through the selected distro adapter. +# 9. Executes declared external installers through the shared framework. +# 10. Supports optional module-specific operations. +# 11. Supports optional module-level post-install verification. +# 12. Preserves dry-run behaviour through shared execution utilities. -# State for the module currently loaded by the bootstrap. LWBS_MODULE_ID="" LWBS_MODULE_NAME="" LWBS_MODULE_DESCRIPTION="" LWBS_MODULE_PATH="" -declare -a LWBS_MODULE_PACKAGES=() +declare -a LWBS_MODULE_CAPABILITIES=() +declare -a LWBS_MODULE_EXTERNAL_INSTALLERS=() -# Load a workstation module by ID. load_module() { local requested_module="${1:-}" local module_directory local module_path - local package_output - local package + local capability_output + local external_output + local capability + local installer_id + + local -A seen_external_installers=() if [[ -z "$requested_module" ]]; then printf 'Error: no module was provided.\n' >&2 @@ -37,7 +46,6 @@ load_module() { return 2 fi - # Restrict module IDs to predictable file-safe identifiers. if [[ ! "$requested_module" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then printf 'Error: invalid module ID: %s\n' \ "$requested_module" >&2 @@ -61,8 +69,6 @@ load_module() { return 1 fi - # Remove the previous module contract before loading another module so - # stale functions cannot make an incomplete module appear valid. reset_module_contract log_info "Loading module: $requested_module" @@ -87,14 +93,12 @@ load_module() { return 1 fi - # Read module metadata only after the contract has been validated. LWBS_MODULE_ID="$(module_id)" LWBS_MODULE_NAME="$(module_name)" LWBS_MODULE_DESCRIPTION="$(module_description)" if [[ -z "$LWBS_MODULE_ID" ]]; then printf 'Error: module returned an empty module ID.\n' >&2 - log_error "Module returned an empty ID: $requested_module" reset_module_contract return 1 fi @@ -104,9 +108,6 @@ load_module() { "$requested_module" \ "$LWBS_MODULE_ID" >&2 - log_error \ - "Module ID mismatch: requested=$requested_module declared=$LWBS_MODULE_ID" - reset_module_contract return 1 fi @@ -115,9 +116,6 @@ load_module() { printf 'Error: module returned an empty display name: %s\n' \ "$requested_module" >&2 - log_error \ - "Module returned an empty display name: $requested_module" - reset_module_contract return 1 fi @@ -126,59 +124,84 @@ load_module() { printf 'Error: module returned an empty description: %s\n' \ "$requested_module" >&2 - log_error \ - "Module returned an empty description: $requested_module" - reset_module_contract return 1 fi - # Collect package requirements as one package identifier per line. - if ! package_output="$(module_packages)"; then - printf 'Error: unable to read package requirements for module: %s\n' \ + if capability_output="$(module_capabilities)"; then + : + else + printf 'Error: unable to read capability requirements for module: %s\n' \ "$requested_module" >&2 - log_error \ - "Module package requirement resolution failed: $requested_module" - reset_module_contract return 1 fi - LWBS_MODULE_PACKAGES=() + LWBS_MODULE_CAPABILITIES=() - while IFS= read -r package; do - # Empty lines are ignored so package-less modules remain possible - # when they provide an operation hook instead. - if [[ -z "$package" ]]; then - continue - fi + while IFS= read -r capability; do + [[ -z "$capability" ]] && continue - # Package declarations are data, not arbitrary package-manager options. - if [[ ! "$package" =~ ^[A-Za-z0-9][A-Za-z0-9.+:_-]*$ ]]; then - printf 'Error: invalid package declaration in module %s: %s\n' \ + if [[ ! "$capability" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then + printf 'Error: invalid capability declaration in module %s: %s\n' \ "$requested_module" \ - "$package" >&2 + "$capability" >&2 - log_error \ - "Invalid package declaration: module=$requested_module package=$package" + reset_module_contract + return 1 + fi + + LWBS_MODULE_CAPABILITIES+=("$capability") + done <<<"$capability_output" + + LWBS_MODULE_EXTERNAL_INSTALLERS=() + + if declare -F module_external_installers >/dev/null 2>&1; then + if external_output="$(module_external_installers)"; then + : + else + printf 'Error: unable to read external installer requirements for module: %s\n' \ + "$requested_module" >&2 reset_module_contract return 1 fi - LWBS_MODULE_PACKAGES+=("$package") - done <<<"$package_output" + while IFS= read -r installer_id; do + [[ -z "$installer_id" ]] && continue + + if [[ ! "$installer_id" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then + printf 'Error: invalid external installer ID in module %s: %s\n' \ + "$requested_module" \ + "$installer_id" >&2 + + reset_module_contract + return 1 + fi + + if [[ -n "${seen_external_installers[$installer_id]+defined}" ]]; then + printf 'Error: duplicate external installer in module %s: %s\n' \ + "$requested_module" \ + "$installer_id" >&2 + + reset_module_contract + return 1 + fi + + seen_external_installers["$installer_id"]=1 + LWBS_MODULE_EXTERNAL_INSTALLERS+=("$installer_id") + done <<<"$external_output" + fi + + if ((${#LWBS_MODULE_CAPABILITIES[@]} == 0 && + ${#LWBS_MODULE_EXTERNAL_INSTALLERS[@]} == 0)) && + ! declare -F module_apply >/dev/null 2>&1 && + ! declare -F module_verify >/dev/null 2>&1; then - # A module must provide at least one package or a custom operation hook. - if ((${#LWBS_MODULE_PACKAGES[@]} == 0)) && - ! declare -F module_apply >/dev/null 2>&1; then printf 'Error: module %s does not declare any actions.\n' \ "$requested_module" >&2 - log_error \ - "Module contains no package requirements or operation hook: $requested_module" - reset_module_contract return 1 fi @@ -186,12 +209,11 @@ load_module() { LWBS_MODULE_PATH="$module_path" log_info \ - "Module loaded: id=$LWBS_MODULE_ID package_count=${#LWBS_MODULE_PACKAGES[@]}" + "Module loaded: id=$LWBS_MODULE_ID capability_count=${#LWBS_MODULE_CAPABILITIES[@]} external_count=${#LWBS_MODULE_EXTERNAL_INSTALLERS[@]}" return 0 } -# Validate the interface required from every workstation module. validate_module_contract() { local required_function @@ -199,7 +221,7 @@ validate_module_contract() { "module_id" "module_name" "module_description" - "module_packages" + "module_capabilities" ) for required_function in "${required_functions[@]}"; do @@ -214,118 +236,158 @@ validate_module_contract() { return 0 } -# Execute the module currently loaded by load_module(). +collect_missing_module_packages() { + local result_name="${1:-}" + local package + local package_state + local exit_code + + if [[ -z "$result_name" ]]; then + printf 'Error: no output array was provided for package-state evaluation.\n' \ + >&2 + return 2 + fi + + if [[ ! "$result_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + printf 'Error: invalid output array name for package-state evaluation: %s\n' \ + "$result_name" >&2 + return 2 + fi + + shift + + local -n output_array_ref="$result_name" + + output_array_ref=() + + for package in "$@"; do + if package_state="$(distro_package_state "$package")"; then + : + else + exit_code=$? + + printf 'Error: unable to determine package state: %s\n' \ + "$package" >&2 + + log_error \ + "Package state check failed: module=$LWBS_MODULE_ID package=$package exit_code=$exit_code" + + return "$exit_code" + fi + + case "$package_state" in + installed) + printf ' - %s [installed, skip]\n' "$package" + ;; + + absent) + printf ' - %s [missing]\n' "$package" + output_array_ref+=("$package") + ;; + + *) + printf 'Error: distribution adapter returned invalid package state "%s" for %s.\n' \ + "$package_state" \ + "$package" >&2 + + return 1 + ;; + esac + done + + return 0 +} + execute_loaded_module() { + local installer_id local exit_code - local package + + local -a resolved_packages=() + local -a missing_packages=() if [[ -z "$LWBS_MODULE_ID" ]]; then printf 'Error: no module is currently loaded.\n' >&2 - log_error "Module execution requested without a loaded module." return 2 fi - # Module package requirements must be resolved through the distro adapter. if [[ -z "${DISTRO_ADAPTER_PROFILE:-}" ]]; then printf 'Error: a distribution adapter must be loaded before executing modules.\n' \ >&2 - - log_error \ - "Module execution requested without a distribution adapter: module=$LWBS_MODULE_ID" - return 1 fi - if ! declare -F distro_install_packages >/dev/null 2>&1; then - printf 'Error: the loaded distribution adapter cannot install packages.\n' \ + if [[ -z "${LWBS_CAPABILITY_PROFILE:-}" ]]; then + printf 'Error: a capability mapping must be loaded before executing modules.\n' \ >&2 - - log_error \ - "Loaded distribution adapter is missing distro_install_packages." - return 1 fi - printf '\nModule: %s\n' "$LWBS_MODULE_NAME" - printf ' ID : %s\n' "$LWBS_MODULE_ID" - printf ' Description : %s\n' "$LWBS_MODULE_DESCRIPTION" - - log_info \ - "Executing module: id=$LWBS_MODULE_ID name=$LWBS_MODULE_NAME" - - # Package names originate from trusted module declarations and are safe - # to present explicitly as part of the module execution plan. - if ((${#LWBS_MODULE_PACKAGES[@]} > 0)); then - printf ' Packages : %d\n' "${#LWBS_MODULE_PACKAGES[@]}" - - for package in "${LWBS_MODULE_PACKAGES[@]}"; do - printf ' - %s\n' "$package" - done - - log_info \ - "Installing module package requirements: module=$LWBS_MODULE_ID count=${#LWBS_MODULE_PACKAGES[@]}" - - if distro_install_packages "${LWBS_MODULE_PACKAGES[@]}"; then + if ((${#LWBS_MODULE_CAPABILITIES[@]} > 0)); then + if resolve_capabilities \ + resolved_packages \ + "${LWBS_MODULE_CAPABILITIES[@]}"; then : else - exit_code=$? + return $? + fi - log_error \ - "Module package installation failed: module=$LWBS_MODULE_ID exit_code=$exit_code" + if collect_missing_module_packages \ + missing_packages \ + "${resolved_packages[@]}"; then + : + else + return $? + fi - return "$exit_code" + if ((${#missing_packages[@]} > 0)); then + distro_install_packages "${missing_packages[@]}" fi fi - # Modules may provide additional operations that are not package - # installation. Those operations must use shared execution/configuration - # facilities so dry-run and safety guarantees remain intact. - if declare -F module_apply >/dev/null 2>&1; then - log_info \ - "Executing module operation hook: $LWBS_MODULE_ID" - - if module_apply; then - : + for installer_id in "${LWBS_MODULE_EXTERNAL_INSTALLERS[@]}"; do + if run_external_installer "$installer_id"; then + reset_external_installer_contract else exit_code=$? - - log_error \ - "Module operation failed: module=$LWBS_MODULE_ID exit_code=$exit_code" - + reset_external_installer_contract return "$exit_code" fi - fi - - log_info \ - "Module completed successfully: $LWBS_MODULE_ID" + done - return 0 + if declare -F module_apply >/dev/null 2>&1; then + module_apply + fi } -# Load and execute a workstation module through the controlled module path. run_module() { local module_id="${1:-}" + local exit_code - if ! load_module "$module_id"; then - return $? + if load_module "$module_id"; then + : + else + exit_code=$? + return "$exit_code" fi execute_loaded_module } -# Remove functions and state belonging to the current module contract. reset_module_contract() { unset -f \ module_id \ module_name \ module_description \ - module_packages \ + module_capabilities \ + module_external_installers \ module_apply \ + module_verify \ 2>/dev/null || true LWBS_MODULE_ID="" LWBS_MODULE_NAME="" LWBS_MODULE_DESCRIPTION="" LWBS_MODULE_PATH="" - LWBS_MODULE_PACKAGES=() + LWBS_MODULE_CAPABILITIES=() + LWBS_MODULE_EXTERNAL_INSTALLERS=() } \ No newline at end of file diff --git a/lib/plan.sh b/lib/plan.sh new file mode 100644 index 0000000..2cd54f4 --- /dev/null +++ b/lib/plan.sh @@ -0,0 +1,893 @@ +#!/usr/bin/env bash + +# Linux Workstation Bootstrap +# Author: David Kariuki +# +# Execution planning, controlled execution, and post-install verification. +# +# A profile is fully resolved before modification. Approved actions are then +# executed and verified before a final result is reported. + +LWBS_PLAN_STATUS="empty" + +LWBS_PLAN_PROFILE_ID="" +LWBS_PLAN_PROFILE_NAME="" +LWBS_PLAN_PROFILE_DESCRIPTION="" +LWBS_PLAN_DISTRO_PROFILE="" + +LWBS_PLAN_INSTALL_ACTION_COUNT=0 +LWBS_PLAN_SKIP_ACTION_COUNT=0 +LWBS_PLAN_CUSTOM_ACTION_COUNT=0 + +declare -a LWBS_PLAN_MODULES=() + +declare -A LWBS_PLAN_MODULE_NAMES=() +declare -A LWBS_PLAN_MODULE_INSTALL_PACKAGES=() +declare -A LWBS_PLAN_MODULE_SKIP_PACKAGES=() +declare -A LWBS_PLAN_MODULE_EXTERNALS=() +declare -A LWBS_PLAN_EXTERNAL_ACTIONS=() +declare -A LWBS_PLAN_MODULE_HAS_APPLY=() +declare -A LWBS_PLAN_MODULE_HAS_VERIFY=() + +append_execution_plan_value() { + local map_name="${1:-}" + local key="${2:-}" + local value="${3:-}" + + local -n plan_map_ref="$map_name" + + if [[ -n "${plan_map_ref[$key]:-}" ]]; then + plan_map_ref["$key"]+=$'\n'"$value" + else + plan_map_ref["$key"]="$value" + fi +} + +execution_plan_external_key() { + printf '%s::%s\n' \ + "${1:-}" \ + "${2:-}" +} + +build_profile_execution_plan() { + local requested_profile="${1:-}" + local module_id + local package + local package_state + local installer_id + local installer_state + local external_key + local exit_code + + local -a resolved_packages=() + local -A seen_modules=() + + reset_execution_plan + LWBS_PLAN_STATUS="building" + + if [[ -z "$requested_profile" ]]; then + printf 'Error: no workstation profile was provided for execution planning.\n' \ + >&2 + LWBS_PLAN_STATUS="invalid" + return 2 + fi + + if [[ -z "${DISTRO_ADAPTER_PROFILE:-}" || + -z "${LWBS_CAPABILITY_PROFILE:-}" ]]; then + + printf 'Error: distro adapter and capability mapping must be loaded before planning.\n' \ + >&2 + + LWBS_PLAN_STATUS="invalid" + return 1 + fi + + if load_profile "$requested_profile"; then + : + else + exit_code=$? + LWBS_PLAN_STATUS="invalid" + return "$exit_code" + fi + + LWBS_PLAN_PROFILE_ID="$LWBS_PROFILE_ID" + LWBS_PLAN_PROFILE_NAME="$LWBS_PROFILE_NAME" + LWBS_PLAN_PROFILE_DESCRIPTION="$LWBS_PROFILE_DESCRIPTION" + LWBS_PLAN_DISTRO_PROFILE="$DISTRO_ADAPTER_PROFILE" + LWBS_PLAN_MODULES=("${LWBS_PROFILE_MODULES[@]}") + + for module_id in "${LWBS_PLAN_MODULES[@]}"; do + if [[ -n "${seen_modules[$module_id]+defined}" ]]; then + printf 'Error: execution plan contains duplicate module: %s\n' \ + "$module_id" >&2 + + LWBS_PLAN_STATUS="invalid" + return 1 + fi + + seen_modules["$module_id"]=1 + + if load_module "$module_id"; then + : + else + exit_code=$? + LWBS_PLAN_STATUS="invalid" + return "$exit_code" + fi + + LWBS_PLAN_MODULE_NAMES["$module_id"]="$LWBS_MODULE_NAME" + + resolved_packages=() + + if ((${#LWBS_MODULE_CAPABILITIES[@]} > 0)); then + if resolve_capabilities \ + resolved_packages \ + "${LWBS_MODULE_CAPABILITIES[@]}"; then + : + else + exit_code=$? + reset_module_contract + LWBS_PLAN_STATUS="invalid" + return "$exit_code" + fi + + for package in "${resolved_packages[@]}"; do + if package_state="$(distro_package_state "$package")"; then + : + else + exit_code=$? + reset_module_contract + LWBS_PLAN_STATUS="invalid" + return "$exit_code" + fi + + case "$package_state" in + installed) + append_execution_plan_value \ + LWBS_PLAN_MODULE_SKIP_PACKAGES \ + "$module_id" \ + "$package" + + ((LWBS_PLAN_SKIP_ACTION_COUNT += 1)) + ;; + + absent) + append_execution_plan_value \ + LWBS_PLAN_MODULE_INSTALL_PACKAGES \ + "$module_id" \ + "$package" + + ((LWBS_PLAN_INSTALL_ACTION_COUNT += 1)) + ;; + + *) + reset_module_contract + LWBS_PLAN_STATUS="invalid" + return 1 + ;; + esac + done + fi + + for installer_id in "${LWBS_MODULE_EXTERNAL_INSTALLERS[@]}"; do + if load_external_installer "$installer_id"; then + : + else + exit_code=$? + reset_module_contract + LWBS_PLAN_STATUS="invalid" + return "$exit_code" + fi + + if installer_state="$(query_external_installation_state)"; then + : + else + exit_code=$? + reset_external_installer_contract + reset_module_contract + LWBS_PLAN_STATUS="invalid" + return "$exit_code" + fi + + append_execution_plan_value \ + LWBS_PLAN_MODULE_EXTERNALS \ + "$module_id" \ + "$installer_id" + + external_key="$( + execution_plan_external_key \ + "$module_id" \ + "$installer_id" + )" + + case "$installer_state" in + installed) + LWBS_PLAN_EXTERNAL_ACTIONS["$external_key"]="skip" + ((LWBS_PLAN_SKIP_ACTION_COUNT += 1)) + ;; + absent) + LWBS_PLAN_EXTERNAL_ACTIONS["$external_key"]="install" + ((LWBS_PLAN_INSTALL_ACTION_COUNT += 1)) + ;; + esac + + reset_external_installer_contract + done + + if declare -F module_apply >/dev/null 2>&1; then + LWBS_PLAN_MODULE_HAS_APPLY["$module_id"]="true" + ((LWBS_PLAN_CUSTOM_ACTION_COUNT += 1)) + else + LWBS_PLAN_MODULE_HAS_APPLY["$module_id"]="false" + fi + + if declare -F module_verify >/dev/null 2>&1; then + LWBS_PLAN_MODULE_HAS_VERIFY["$module_id"]="true" + else + LWBS_PLAN_MODULE_HAS_VERIFY["$module_id"]="false" + fi + + reset_module_contract + done + + reset_profile_contract + + LWBS_PLAN_STATUS="resolved" + + log_execution_plan + + return 0 +} + +log_execution_plan() { + log_info \ + "Execution plan resolved: profile=$LWBS_PLAN_PROFILE_ID distro=$LWBS_PLAN_DISTRO_PROFILE modules=${#LWBS_PLAN_MODULES[@]} install_actions=$LWBS_PLAN_INSTALL_ACTION_COUNT skip_actions=$LWBS_PLAN_SKIP_ACTION_COUNT custom_actions=$LWBS_PLAN_CUSTOM_ACTION_COUNT" +} + +show_execution_plan() { + local module_id + local package + local installer_id + local external_key + local action + + printf '\nExecution plan\n\n' + + printf ' Profile : %s (%s)\n' \ + "$LWBS_PLAN_PROFILE_NAME" \ + "$LWBS_PLAN_PROFILE_ID" + + printf ' Distribution : %s\n' \ + "$(distro_profile_name "$LWBS_PLAN_DISTRO_PROFILE")" + + printf ' Modules : %d\n\n' \ + "${#LWBS_PLAN_MODULES[@]}" + + for module_id in "${LWBS_PLAN_MODULES[@]}"; do + printf 'Module: %s\n' \ + "${LWBS_PLAN_MODULE_NAMES[$module_id]:-$module_id}" + + while IFS= read -r package; do + [[ -z "$package" ]] && continue + printf ' Package : %s [installed, skip]\n' "$package" + done <<<"${LWBS_PLAN_MODULE_SKIP_PACKAGES[$module_id]:-}" + + while IFS= read -r package; do + [[ -z "$package" ]] && continue + printf ' Package : %s [install]\n' "$package" + done <<<"${LWBS_PLAN_MODULE_INSTALL_PACKAGES[$module_id]:-}" + + while IFS= read -r installer_id; do + [[ -z "$installer_id" ]] && continue + + external_key="$( + execution_plan_external_key \ + "$module_id" \ + "$installer_id" + )" + + action="${LWBS_PLAN_EXTERNAL_ACTIONS[$external_key]}" + + if [[ "$action" == "skip" ]]; then + printf ' External : %s [installed, skip]\n' "$installer_id" + else + printf ' External : %s [install]\n' "$installer_id" + fi + done <<<"${LWBS_PLAN_MODULE_EXTERNALS[$module_id]:-}" + + if [[ "${LWBS_PLAN_MODULE_HAS_APPLY[$module_id]:-false}" == "true" ]]; then + printf ' Custom action: module_apply\n' + fi + + if [[ "${LWBS_PLAN_MODULE_HAS_VERIFY[$module_id]:-false}" == "true" ]]; then + printf ' Post-check : module_verify\n' + fi + + printf '\n' + done +} + +execution_plan_has_changes() { + ((LWBS_PLAN_INSTALL_ACTION_COUNT > 0 || + LWBS_PLAN_CUSTOM_ACTION_COUNT > 0)) +} + +execute_planned_packages() { + local module_id="${1:-}" + local package + local package_state + local result_key + local exit_code + local verification_failed=false + + local -a packages_to_install=() + + while IFS= read -r package; do + [[ -z "$package" ]] && continue + + result_key="$(package_result_key "$module_id" "$package")" + + if package_state="$(distro_package_state "$package")"; then + : + else + record_execution_result \ + "$result_key" \ + "package" \ + "$module_id" \ + "$package" \ + "unverifiable" \ + "state check failed" + + return 1 + fi + + if [[ "$package_state" != "installed" ]]; then + record_execution_result \ + "$result_key" \ + "package" \ + "$module_id" \ + "$package" \ + "failed" \ + "planned installed state changed" + + return 1 + fi + + record_execution_result \ + "$result_key" \ + "package" \ + "$module_id" \ + "$package" \ + "skipped" \ + "already installed" + + done <<<"${LWBS_PLAN_MODULE_SKIP_PACKAGES[$module_id]:-}" + + while IFS= read -r package; do + [[ -z "$package" ]] && continue + + if package_state="$(distro_package_state "$package")"; then + : + else + result_key="$(package_result_key "$module_id" "$package")" + + record_execution_result \ + "$result_key" \ + "package" \ + "$module_id" \ + "$package" \ + "unverifiable" \ + "pre-install state check failed" + + return 1 + fi + + case "$package_state" in + installed) + result_key="$(package_result_key "$module_id" "$package")" + + record_execution_result \ + "$result_key" \ + "package" \ + "$module_id" \ + "$package" \ + "skipped" \ + "became installed before execution" + ;; + + absent) + packages_to_install+=("$package") + ;; + + *) + return 1 + ;; + esac + done <<<"${LWBS_PLAN_MODULE_INSTALL_PACKAGES[$module_id]:-}" + + if ((${#packages_to_install[@]} == 0)); then + return 0 + fi + + if distro_install_packages "${packages_to_install[@]}"; then + : + else + exit_code=$? + + for package in "${packages_to_install[@]}"; do + result_key="$(package_result_key "$module_id" "$package")" + + record_execution_result \ + "$result_key" \ + "package" \ + "$module_id" \ + "$package" \ + "failed" \ + "package installation command failed" + done + + return "$exit_code" + fi + + for package in "${packages_to_install[@]}"; do + result_key="$(package_result_key "$module_id" "$package")" + + if package_state="$(distro_package_state "$package")"; then + case "$package_state" in + installed) + record_execution_result \ + "$result_key" \ + "package" \ + "$module_id" \ + "$package" \ + "successful" \ + "verified installed" + ;; + + absent) + record_execution_result \ + "$result_key" \ + "package" \ + "$module_id" \ + "$package" \ + "failed" \ + "package remained absent after installation" + + verification_failed=true + ;; + esac + else + record_execution_result \ + "$result_key" \ + "package" \ + "$module_id" \ + "$package" \ + "unverifiable" \ + "post-install state check failed" + + verification_failed=true + fi + done + + if "$verification_failed"; then + return 1 + fi + + return 0 +} + +execute_planned_external_actions() { + local module_id="${1:-}" + local installer_id + local external_key + local result_key + local action + local current_state + local exit_code + + while IFS= read -r installer_id; do + [[ -z "$installer_id" ]] && continue + + external_key="$( + execution_plan_external_key \ + "$module_id" \ + "$installer_id" + )" + + result_key="$( + external_result_key \ + "$module_id" \ + "$installer_id" + )" + + action="${LWBS_PLAN_EXTERNAL_ACTIONS[$external_key]:-}" + + if load_external_installer "$installer_id"; then + : + else + record_execution_result \ + "$result_key" \ + "external" \ + "$module_id" \ + "$installer_id" \ + "failed" \ + "installer definition unavailable" + + return 1 + fi + + if current_state="$(query_external_installation_state)"; then + : + else + reset_external_installer_contract + + record_execution_result \ + "$result_key" \ + "external" \ + "$module_id" \ + "$installer_id" \ + "unverifiable" \ + "state check failed" + + return 1 + fi + + if [[ "$action" == "skip" ]]; then + if [[ "$current_state" == "installed" ]]; then + record_execution_result \ + "$result_key" \ + "external" \ + "$module_id" \ + "$installer_id" \ + "skipped" \ + "already installed" + + reset_external_installer_contract + continue + fi + + record_execution_result \ + "$result_key" \ + "external" \ + "$module_id" \ + "$installer_id" \ + "failed" \ + "planned installed state changed" + + reset_external_installer_contract + return 1 + fi + + if [[ "$current_state" == "installed" ]]; then + record_execution_result \ + "$result_key" \ + "external" \ + "$module_id" \ + "$installer_id" \ + "skipped" \ + "became installed before execution" + + reset_external_installer_contract + continue + fi + + if execute_loaded_external_installer; then + : + else + exit_code=$? + + record_execution_result \ + "$result_key" \ + "external" \ + "$module_id" \ + "$installer_id" \ + "failed" \ + "external installer failed" + + reset_external_installer_contract + return "$exit_code" + fi + + if current_state="$(query_external_installation_state)"; then + if [[ "$current_state" == "installed" ]]; then + record_execution_result \ + "$result_key" \ + "external" \ + "$module_id" \ + "$installer_id" \ + "successful" \ + "verified installed" + + reset_external_installer_contract + continue + fi + + record_execution_result \ + "$result_key" \ + "external" \ + "$module_id" \ + "$installer_id" \ + "failed" \ + "software remained absent after installation" + + reset_external_installer_contract + return 1 + fi + + record_execution_result \ + "$result_key" \ + "external" \ + "$module_id" \ + "$installer_id" \ + "unverifiable" \ + "post-install state check failed" + + reset_external_installer_contract + return 1 + done <<<"${LWBS_PLAN_MODULE_EXTERNALS[$module_id]:-}" + + return 0 +} + +execute_and_verify_module_action() { + local module_id="${1:-}" + local result_key + local exit_code + + local has_apply="${LWBS_PLAN_MODULE_HAS_APPLY[$module_id]:-false}" + local has_verify="${LWBS_PLAN_MODULE_HAS_VERIFY[$module_id]:-false}" + + if [[ "$has_apply" != "true" && "$has_verify" != "true" ]]; then + return 0 + fi + + result_key="$(module_result_key "$module_id")" + + if load_module "$module_id"; then + : + else + record_execution_result \ + "$result_key" \ + "module" \ + "$module_id" \ + "$module_id" \ + "failed" \ + "module could not be reloaded" + + return 1 + fi + + if [[ "$has_apply" == "true" ]]; then + if module_apply; then + : + else + exit_code=$? + + record_execution_result \ + "$result_key" \ + "module" \ + "$module_id" \ + "$module_id" \ + "failed" \ + "module_apply failed" + + reset_module_contract + return "$exit_code" + fi + fi + + if [[ "$has_verify" == "true" ]]; then + if module_verify; then + record_execution_result \ + "$result_key" \ + "module" \ + "$module_id" \ + "$module_id" \ + "successful" \ + "module verification passed" + + reset_module_contract + return 0 + fi + + exit_code=$? + + record_execution_result \ + "$result_key" \ + "module" \ + "$module_id" \ + "$module_id" \ + "failed" \ + "module verification failed" + + reset_module_contract + return "$exit_code" + fi + + record_execution_result \ + "$result_key" \ + "module" \ + "$module_id" \ + "$module_id" \ + "unverifiable" \ + "module has no verification hook" + + reset_module_contract + + return 0 +} + +execute_resolved_plan() { + local module_id + local exit_code + + if [[ "$LWBS_PLAN_STATUS" != "approved" ]]; then + printf 'Error: execution plan must be approved before execution.\n' \ + >&2 + return 1 + fi + + reset_execution_results + + LWBS_PLAN_STATUS="executing" + + for module_id in "${LWBS_PLAN_MODULES[@]}"; do + if execute_planned_packages "$module_id"; then + : + else + exit_code=$? + LWBS_PLAN_STATUS="failed" + show_execution_summary + return "$exit_code" + fi + + if execute_planned_external_actions "$module_id"; then + : + else + exit_code=$? + LWBS_PLAN_STATUS="failed" + show_execution_summary + return "$exit_code" + fi + + if execute_and_verify_module_action "$module_id"; then + : + else + exit_code=$? + LWBS_PLAN_STATUS="failed" + show_execution_summary + return "$exit_code" + fi + done + + show_execution_summary + + if ! execution_results_are_successful; then + LWBS_PLAN_STATUS="failed" + return 1 + fi + + LWBS_PLAN_STATUS="completed" + + log_info \ + "Execution plan completed successfully: profile=$LWBS_PLAN_PROFILE_ID" + + return 0 +} + +run_profile_execution_plan() { + local profile_id="${1:-}" + local confirmation_exit_code + local exit_code + + if build_profile_execution_plan "$profile_id"; then + : + else + return $? + fi + + show_execution_plan + + if "$LWBS_DRY_RUN"; then + LWBS_PLAN_STATUS="previewed" + + printf 'Dry-run mode: execution plan was not applied.\n' + + return 0 + fi + + if ! execution_plan_has_changes; then + reset_execution_results + + # Record actions that were already satisfied even when no execution + # was required. + local module_id + local package + local installer_id + local result_key + + for module_id in "${LWBS_PLAN_MODULES[@]}"; do + while IFS= read -r package; do + [[ -z "$package" ]] && continue + + result_key="$(package_result_key "$module_id" "$package")" + + record_execution_result \ + "$result_key" \ + "package" \ + "$module_id" \ + "$package" \ + "skipped" \ + "already installed" + done <<<"${LWBS_PLAN_MODULE_SKIP_PACKAGES[$module_id]:-}" + + while IFS= read -r installer_id; do + [[ -z "$installer_id" ]] && continue + + result_key="$( + external_result_key \ + "$module_id" \ + "$installer_id" + )" + + record_execution_result \ + "$result_key" \ + "external" \ + "$module_id" \ + "$installer_id" \ + "skipped" \ + "already installed" + done <<<"${LWBS_PLAN_MODULE_EXTERNALS[$module_id]:-}" + done + + LWBS_PLAN_STATUS="completed" + + show_execution_summary + + return 0 + fi + + if confirm_execution_plan; then + LWBS_PLAN_STATUS="approved" + else + confirmation_exit_code=$? + + if ((confirmation_exit_code == 1)); then + LWBS_PLAN_STATUS="cancelled" + printf '\nExecution cancelled. No planned changes were applied.\n' + return 0 + fi + + return "$confirmation_exit_code" + fi + + if execute_resolved_plan; then + return 0 + else + exit_code=$? + return "$exit_code" + fi +} + +reset_execution_plan() { + LWBS_PLAN_STATUS="empty" + + LWBS_PLAN_PROFILE_ID="" + LWBS_PLAN_PROFILE_NAME="" + LWBS_PLAN_PROFILE_DESCRIPTION="" + LWBS_PLAN_DISTRO_PROFILE="" + + LWBS_PLAN_INSTALL_ACTION_COUNT=0 + LWBS_PLAN_SKIP_ACTION_COUNT=0 + LWBS_PLAN_CUSTOM_ACTION_COUNT=0 + + LWBS_PLAN_MODULES=() + + LWBS_PLAN_MODULE_NAMES=() + LWBS_PLAN_MODULE_INSTALL_PACKAGES=() + LWBS_PLAN_MODULE_SKIP_PACKAGES=() + LWBS_PLAN_MODULE_EXTERNALS=() + LWBS_PLAN_EXTERNAL_ACTIONS=() + LWBS_PLAN_MODULE_HAS_APPLY=() + LWBS_PLAN_MODULE_HAS_VERIFY=() +} \ No newline at end of file diff --git a/lib/profile.sh b/lib/profile.sh index e41eb7b..0dfe208 100644 --- a/lib/profile.sh +++ b/lib/profile.sh @@ -4,18 +4,13 @@ # Author: David Kariuki # # Workstation profile utilities. -# Provides the controlled interface used to load, validate, and execute -# declarative workstation profiles. +# Provides the controlled interface used to load and validate declarative +# workstation profiles. # -# This file: -# 1. Loads workstation profiles from the profiles directory. -# 2. Validates the profile contract. -# 3. Captures profile metadata and selected modules. -# 4. Validates referenced modules before profile execution. -# 5. Executes profile modules through the shared module framework. -# 6. Preserves dry-run behaviour through module and distro execution layers. - -# State for the profile currently loaded by the bootstrap. +# Profile execution is delegated to the execution-plan framework so all +# package, external software, and custom module actions are resolved before +# any system-changing operation begins. + LWBS_PROFILE_ID="" LWBS_PROFILE_NAME="" LWBS_PROFILE_DESCRIPTION="" @@ -23,7 +18,6 @@ LWBS_PROFILE_PATH="" declare -a LWBS_PROFILE_MODULES=() -# Load a workstation profile by ID. load_profile() { local requested_profile="${1:-}" local profile_directory @@ -37,14 +31,10 @@ load_profile() { return 2 fi - # Restrict profile IDs to predictable file-safe identifiers. if [[ ! "$requested_profile" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then printf 'Error: invalid profile ID: %s\n' \ "$requested_profile" >&2 - log_error \ - "Invalid profile ID requested: $requested_profile" - return 2 fi @@ -55,14 +45,9 @@ load_profile() { printf 'Error: profile not found: %s\n' \ "$profile_path" >&2 - log_error \ - "Profile file not found: profile=$requested_profile path=$profile_path" - return 1 fi - # Remove the previous profile contract before loading another profile so - # stale functions cannot make an incomplete profile appear valid. reset_profile_contract log_info "Loading profile: $requested_profile" @@ -72,29 +57,21 @@ load_profile() { printf 'Error: failed to load profile: %s\n' \ "$requested_profile" >&2 - log_error \ - "Profile source failed: profile=$requested_profile path=$profile_path" - reset_profile_contract return 1 fi if ! validate_profile_contract; then - log_error \ - "Profile contract validation failed: $requested_profile" - reset_profile_contract return 1 fi - # Read profile metadata only after the contract has been validated. LWBS_PROFILE_ID="$(profile_id)" LWBS_PROFILE_NAME="$(profile_name)" LWBS_PROFILE_DESCRIPTION="$(profile_description)" if [[ -z "$LWBS_PROFILE_ID" ]]; then printf 'Error: profile returned an empty profile ID.\n' >&2 - log_error "Profile returned an empty ID: $requested_profile" reset_profile_contract return 1 fi @@ -104,9 +81,6 @@ load_profile() { "$requested_profile" \ "$LWBS_PROFILE_ID" >&2 - log_error \ - "Profile ID mismatch: requested=$requested_profile declared=$LWBS_PROFILE_ID" - reset_profile_contract return 1 fi @@ -115,9 +89,6 @@ load_profile() { printf 'Error: profile returned an empty display name: %s\n' \ "$requested_profile" >&2 - log_error \ - "Profile returned an empty display name: $requested_profile" - reset_profile_contract return 1 fi @@ -126,21 +97,16 @@ load_profile() { printf 'Error: profile returned an empty description: %s\n' \ "$requested_profile" >&2 - log_error \ - "Profile returned an empty description: $requested_profile" - reset_profile_contract return 1 fi - # Collect module selections as one module ID per line. - if ! module_output="$(profile_modules)"; then + if module_output="$(profile_modules)"; then + : + else printf 'Error: unable to read module selections for profile: %s\n' \ "$requested_profile" >&2 - log_error \ - "Profile module resolution failed: $requested_profile" - reset_profile_contract return 1 fi @@ -148,18 +114,13 @@ load_profile() { LWBS_PROFILE_MODULES=() while IFS= read -r module_id; do - if [[ -z "$module_id" ]]; then - continue - fi + [[ -z "$module_id" ]] && continue if [[ ! "$module_id" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then printf 'Error: invalid module ID in profile %s: %s\n' \ "$requested_profile" \ "$module_id" >&2 - log_error \ - "Invalid profile module declaration: profile=$requested_profile module=$module_id" - reset_profile_contract return 1 fi @@ -171,20 +132,13 @@ load_profile() { printf 'Error: profile %s does not select any modules.\n' \ "$requested_profile" >&2 - log_error \ - "Profile contains no module selections: $requested_profile" - reset_profile_contract return 1 fi LWBS_PROFILE_PATH="$profile_path" - # Validate every referenced module before the profile is accepted. if ! validate_profile_modules; then - log_error \ - "Profile module validation failed: $requested_profile" - reset_profile_contract return 1 fi @@ -195,7 +149,6 @@ load_profile() { return 0 } -# Validate the interface required from every workstation profile. validate_profile_contract() { local required_function @@ -218,99 +171,57 @@ validate_profile_contract() { return 0 } -# Validate every module referenced by the currently loaded profile. validate_profile_modules() { local module_id + local exit_code for module_id in "${LWBS_PROFILE_MODULES[@]}"; do - if ! load_module "$module_id"; then + if load_module "$module_id"; then + : + else + exit_code=$? + printf 'Error: profile %s references an invalid or unavailable module: %s\n' \ "$LWBS_PROFILE_ID" \ "$module_id" >&2 - log_error \ - "Profile references invalid module: profile=$LWBS_PROFILE_ID module=$module_id" - reset_module_contract - return 1 + return "$exit_code" fi - # Module validation should not leave one of the profile modules loaded. reset_module_contract done return 0 } -# Execute the profile currently loaded by load_profile(). +# Execute the currently loaded profile through the mandatory planning layer. execute_loaded_profile() { - local module_id - local exit_code - if [[ -z "$LWBS_PROFILE_ID" ]]; then printf 'Error: no profile is currently loaded.\n' >&2 - log_error "Profile execution requested without a loaded profile." return 2 fi - if [[ -z "${DISTRO_ADAPTER_PROFILE:-}" ]]; then - printf 'Error: a distribution adapter must be loaded before executing profiles.\n' \ - >&2 - - log_error \ - "Profile execution requested without a distribution adapter: profile=$LWBS_PROFILE_ID" - + if ! declare -F run_profile_execution_plan >/dev/null 2>&1; then + printf 'Error: execution-plan framework is unavailable.\n' >&2 return 1 fi - printf '\nProfile: %s\n' "$LWBS_PROFILE_NAME" - printf ' ID : %s\n' "$LWBS_PROFILE_ID" - printf ' Description : %s\n' "$LWBS_PROFILE_DESCRIPTION" - printf ' Modules : %d\n' "${#LWBS_PROFILE_MODULES[@]}" - - for module_id in "${LWBS_PROFILE_MODULES[@]}"; do - printf ' - %s\n' "$module_id" - done - - log_info \ - "Executing profile: id=$LWBS_PROFILE_ID name=$LWBS_PROFILE_NAME module_count=${#LWBS_PROFILE_MODULES[@]}" - - # Execute modules in the order declared by the profile. - for module_id in "${LWBS_PROFILE_MODULES[@]}"; do - if run_module "$module_id"; then - continue - else - exit_code=$? - - log_error \ - "Profile module execution failed: profile=$LWBS_PROFILE_ID module=$module_id exit_code=$exit_code" - - return "$exit_code" - fi - done - - log_info \ - "Profile completed successfully: $LWBS_PROFILE_ID" - - return 0 + run_profile_execution_plan "$LWBS_PROFILE_ID" } -# Load and execute a workstation profile through the controlled profile path. +# Load and execute a profile through the mandatory execution-plan workflow. run_profile() { local profile_id="${1:-}" - local exit_code - if load_profile "$profile_id"; then - : - else - exit_code=$? - return "$exit_code" + if ! declare -F run_profile_execution_plan >/dev/null 2>&1; then + printf 'Error: execution-plan framework is unavailable.\n' >&2 + return 1 fi - execute_loaded_profile + run_profile_execution_plan "$profile_id" } -# Remove functions and state belonging to the current profile contract. reset_profile_contract() { unset -f \ profile_id \ diff --git a/lib/testlib.sh b/lib/testlib.sh new file mode 100644 index 0000000..9d9c8c8 --- /dev/null +++ b/lib/testlib.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash + +# Linux Workstation Bootstrap +# Author: David Kariuki +# +# Minimal test harness used by the repository test suite. +# +# The harness intentionally depends only on Bash so the core test suite can +# run locally and in CI without installing a separate testing framework. + +LWBS_TEST_COUNT=0 +LWBS_TEST_FAILURE_COUNT=0 + +# Execute one test function in an isolated subshell. +run_test() { + local test_name="${1:-}" + local test_function="${2:-}" + + if [[ -z "$test_name" || -z "$test_function" ]]; then + printf 'Error: run_test requires a name and test function.\n' >&2 + return 2 + fi + + ((LWBS_TEST_COUNT += 1)) + + printf 'TEST: %s\n' "$test_name" + + if ( + set -Eeuo pipefail + "$test_function" + ); then + printf 'PASS: %s\n\n' "$test_name" + return 0 + fi + + ((LWBS_TEST_FAILURE_COUNT += 1)) + + printf 'FAIL: %s\n\n' "$test_name" + + return 0 +} + +# Assert that two values are exactly equal. +assert_equal() { + local expected="${1-}" + local actual="${2-}" + local message="${3:-values are not equal}" + + if [[ "$expected" == "$actual" ]]; then + return 0 + fi + + printf 'Assertion failed: %s\n' "$message" >&2 + printf ' Expected: %q\n' "$expected" >&2 + printf ' Actual : %q\n' "$actual" >&2 + + return 1 +} + +# Assert that one string contains another string. +assert_contains() { + local haystack="${1-}" + local needle="${2-}" + local message="${3:-expected text was not found}" + + if [[ "$haystack" == *"$needle"* ]]; then + return 0 + fi + + printf 'Assertion failed: %s\n' "$message" >&2 + printf ' Expected to contain: %q\n' "$needle" >&2 + printf ' Actual : %q\n' "$haystack" >&2 + + return 1 +} + +# Assert that a filesystem path does not exist. +assert_not_exists() { + local path="${1:-}" + local message="${2:-path unexpectedly exists}" + + if [[ ! -e "$path" ]]; then + return 0 + fi + + printf 'Assertion failed: %s\n' "$message" >&2 + printf ' Path: %s\n' "$path" >&2 + + return 1 +} + +# Create an isolated temporary directory for one test. +make_test_temp_dir() { + mktemp -d "${TMPDIR:-/tmp}/lwbs-test.XXXXXX" +} + +# Initialize logging beneath an isolated test directory. +setup_test_logging() { + local test_directory="${1:-}" + + if [[ -z "$test_directory" ]]; then + printf 'Error: setup_test_logging requires a test directory.\n' >&2 + return 2 + fi + + export XDG_STATE_HOME="$test_directory/state" + export HOME="$test_directory/home" + + mkdir -p -- "$HOME" + + init_logging +} + +# Print the result for the current test file. +finish_tests() { + local passed_count + + passed_count=$((LWBS_TEST_COUNT - LWBS_TEST_FAILURE_COUNT)) + + printf 'Tests: %d | Passed: %d | Failed: %d\n' \ + "$LWBS_TEST_COUNT" \ + "$passed_count" \ + "$LWBS_TEST_FAILURE_COUNT" + + if ((LWBS_TEST_FAILURE_COUNT > 0)); then + return 1 + fi + + return 0 +} \ No newline at end of file diff --git a/lib/ui.sh b/lib/ui.sh index ef9a7bc..156d720 100755 --- a/lib/ui.sh +++ b/lib/ui.sh @@ -10,23 +10,20 @@ # 3. Displays detected system information. # 4. Handles confirmation prompts. # 5. Allows the user to select a distribution profile. -# 6. Displays command-line help and version information. +# 6. Confirms resolved execution plans. +# 7. Displays command-line help and version information. -# Clear the terminal when running interactively. -# Non-interactive output, such as CI logs, is left untouched. clear_screen() { if [[ -t 1 ]]; then printf '\033[2J\033[H' fi } -# Display the log location for the current bootstrap session. show_log_location() { printf ' Session log : %s\n' "$LWBS_LOG_FILE" printf ' All logs : %s\n' "$LWBS_LOG_DIR" } -# Display application information when the bootstrap starts. show_startup_info() { printf '%s %s\n\n' "$LWBS_APP_NAME" "$LWBS_VERSION" @@ -36,7 +33,6 @@ show_startup_info() { printf '\n' } -# Display application information when the bootstrap completes successfully. show_completion_info() { printf '\nBootstrap completed.\n\n' @@ -46,7 +42,6 @@ show_completion_info() { printf '\n' } -# Display application information when the bootstrap is cancelled. show_cancellation_info() { printf '\nBootstrap cancelled.\n\n' @@ -56,7 +51,6 @@ show_cancellation_info() { printf '\n' } -# Display application information when the bootstrap stops due to an error. show_failure_info() { printf '\nBootstrap stopped because of an error.\n\n' @@ -66,7 +60,6 @@ show_failure_info() { printf '\n' } -# Display the detected system information. show_system_summary() { printf 'Detected system\n\n' @@ -82,19 +75,25 @@ show_system_summary() { } # Return the configured display name for a distribution profile. -profile_name() { +# +# This intentionally does not use the generic name profile_name because +# workstation profile definitions use that function as part of their contract. +distro_profile_name() { printf '%s\n' \ "${LWBS_DISTRO_DISPLAY_NAMES[$1]:-$1}" } -# Ask whether the user wants to use the automatically detected profile. confirm_detected_profile() { local answer printf 'Use the detected %s profile? [Y/n] ' \ - "$(profile_name "$SYSTEM_DISTRO_ID")" + "$(distro_profile_name "$SYSTEM_DISTRO_ID")" - read -r answer + if ! read -r answer; then + printf '\nError: unable to read distribution profile confirmation.\n' \ + >&2 + return 2 + fi case "${answer,,}" in "" | y | yes) @@ -106,7 +105,6 @@ confirm_detected_profile() { esac } -# Prompt the user to manually select a supported distribution profile. choose_distro_profile() { local choice local distro @@ -116,13 +114,12 @@ choose_distro_profile() { while true; do printf '\nSelect a distribution profile:\n\n' - # Build the menu from the configured supported distribution profiles. index=1 for distro in "${LWBS_SUPPORTED_DISTROS[@]}"; do printf ' %d) %s\n' \ "$index" \ - "$(profile_name "$distro")" + "$(distro_profile_name "$distro")" ((index += 1)) done @@ -130,7 +127,11 @@ choose_distro_profile() { printf ' q) Exit\n\n' printf 'Selection: ' - read -r choice + if ! read -r choice; then + printf '\nError: unable to read distribution profile selection.\n' \ + >&2 + return 2 + fi case "${choice,,}" in q | quit | exit) @@ -138,17 +139,16 @@ choose_distro_profile() { ;; esac - # Allow selection using the displayed menu number. if [[ "$choice" =~ ^[0-9]+$ ]]; then selected_index=$((10#$choice - 1)) if ((selected_index >= 0 && selected_index < ${#LWBS_SUPPORTED_DISTROS[@]})); then + SELECTED_DISTRO_PROFILE="${LWBS_SUPPORTED_DISTROS[$selected_index]}" return 0 fi - # Also allow the distribution ID to be entered directly. elif is_supported_distro "${choice,,}"; then SELECTED_DISTRO_PROFILE="${choice,,}" return 0 @@ -158,7 +158,43 @@ choose_distro_profile() { done } -# Display command-line usage and supported options. +# Confirm a fully resolved execution plan. +# +# --yes explicitly approves the plan without a prompt. Non-interactive +# execution without --yes fails rather than unexpectedly waiting for input. +confirm_execution_plan() { + local answer + + if "$LWBS_ASSUME_YES"; then + printf 'Execution plan approved by --yes.\n' + return 0 + fi + + if [[ ! -t 0 ]]; then + printf 'Error: non-interactive execution requires --yes to approve the execution plan.\n' \ + >&2 + + return 2 + fi + + printf 'Apply this execution plan? [y/N] ' + + if ! read -r answer; then + printf '\nError: unable to read execution-plan confirmation.\n' \ + >&2 + return 2 + fi + + case "${answer,,}" in + y | yes) + return 0 + ;; + *) + return 1 + ;; + esac +} + show_help() { cat < Use a specific distribution profile --dry-run Preview actions without applying system changes - -y, --yes Accept a supported detected profile without prompting + -y, --yes Approve confirmation prompts without interaction --version Display the application version -h, --help Display this help -Supported profiles: +Supported distribution profiles: $(printf ' %s\n' "${LWBS_SUPPORTED_DISTROS[@]}") EOF } -# Display the current application version. show_version() { printf '%s %s\n' "$LWBS_APP_NAME" "$LWBS_VERSION" } \ No newline at end of file diff --git a/lib/verification.sh b/lib/verification.sh new file mode 100644 index 0000000..084dfcb --- /dev/null +++ b/lib/verification.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash + +# Linux Workstation Bootstrap +# Author: David Kariuki +# +# Post-execution verification and result tracking. +# +# This file: +# 1. Records final statuses for planned bootstrap actions. +# 2. Distinguishes successful, skipped, failed, and unverifiable actions. +# 3. Provides a concise final execution summary. +# 4. Records verification results in the session log. +# 5. Keeps result tracking separate from installation implementation. + +declare -a LWBS_RESULT_KEYS=() + +declare -A LWBS_RESULT_TYPE=() +declare -A LWBS_RESULT_MODULE=() +declare -A LWBS_RESULT_TARGET=() +declare -A LWBS_RESULT_STATUS=() +declare -A LWBS_RESULT_DETAIL=() + +LWBS_RESULT_SUCCESSFUL_COUNT=0 +LWBS_RESULT_SKIPPED_COUNT=0 +LWBS_RESULT_FAILED_COUNT=0 +LWBS_RESULT_UNVERIFIABLE_COUNT=0 + +# Clear all recorded execution results. +reset_execution_results() { + LWBS_RESULT_KEYS=() + + LWBS_RESULT_TYPE=() + LWBS_RESULT_MODULE=() + LWBS_RESULT_TARGET=() + LWBS_RESULT_STATUS=() + LWBS_RESULT_DETAIL=() + + LWBS_RESULT_SUCCESSFUL_COUNT=0 + LWBS_RESULT_SKIPPED_COUNT=0 + LWBS_RESULT_FAILED_COUNT=0 + LWBS_RESULT_UNVERIFIABLE_COUNT=0 +} + +# Record one final action result. +record_execution_result() { + local key="${1:-}" + local type="${2:-}" + local module_id="${3:-}" + local target="${4:-}" + local status="${5:-}" + local detail="${6:-}" + + if [[ -z "$key" || + -z "$type" || + -z "$module_id" || + -z "$target" || + -z "$status" ]]; then + + printf 'Error: incomplete execution result.\n' >&2 + return 2 + fi + + case "$status" in + successful | skipped | failed | unverifiable) + ;; + *) + printf 'Error: invalid execution result status: %s\n' \ + "$status" >&2 + return 2 + ;; + esac + + if [[ -n "${LWBS_RESULT_STATUS[$key]+defined}" ]]; then + printf 'Error: execution result already recorded: %s\n' \ + "$key" >&2 + return 1 + fi + + LWBS_RESULT_KEYS+=("$key") + + LWBS_RESULT_TYPE["$key"]="$type" + LWBS_RESULT_MODULE["$key"]="$module_id" + LWBS_RESULT_TARGET["$key"]="$target" + LWBS_RESULT_STATUS["$key"]="$status" + LWBS_RESULT_DETAIL["$key"]="$detail" + + case "$status" in + successful) + ((LWBS_RESULT_SUCCESSFUL_COUNT += 1)) + ;; + skipped) + ((LWBS_RESULT_SKIPPED_COUNT += 1)) + ;; + failed) + ((LWBS_RESULT_FAILED_COUNT += 1)) + ;; + unverifiable) + ((LWBS_RESULT_UNVERIFIABLE_COUNT += 1)) + ;; + esac + + log_info \ + "Execution result: type=$type module=$module_id target=$target status=$status detail=${detail:-none}" + + return 0 +} + +# Produce a stable result key for one package action. +package_result_key() { + local module_id="${1:-}" + local package="${2:-}" + + printf 'package::%s::%s\n' \ + "$module_id" \ + "$package" +} + +# Produce a stable result key for one external software action. +external_result_key() { + local module_id="${1:-}" + local installer_id="${2:-}" + + printf 'external::%s::%s\n' \ + "$module_id" \ + "$installer_id" +} + +# Produce a stable result key for module-level verification. +module_result_key() { + local module_id="${1:-}" + + printf 'module::%s\n' "$module_id" +} + +# Display the final execution result. +show_execution_summary() { + local key + local status + local type + local target + local module_id + local detail + + printf '\nExecution summary\n\n' + + printf ' Successful : %d\n' \ + "$LWBS_RESULT_SUCCESSFUL_COUNT" + + printf ' Skipped : %d\n' \ + "$LWBS_RESULT_SKIPPED_COUNT" + + printf ' Failed : %d\n' \ + "$LWBS_RESULT_FAILED_COUNT" + + printf ' Unverifiable : %d\n' \ + "$LWBS_RESULT_UNVERIFIABLE_COUNT" + + printf '\n' + + if ((${#LWBS_RESULT_KEYS[@]} == 0)); then + printf ' No execution actions were recorded.\n\n' + return 0 + fi + + for key in "${LWBS_RESULT_KEYS[@]}"; do + status="${LWBS_RESULT_STATUS[$key]}" + type="${LWBS_RESULT_TYPE[$key]}" + target="${LWBS_RESULT_TARGET[$key]}" + module_id="${LWBS_RESULT_MODULE[$key]}" + detail="${LWBS_RESULT_DETAIL[$key]}" + + printf ' [%s] %s: %s (module: %s)' \ + "$status" \ + "$type" \ + "$target" \ + "$module_id" + + if [[ -n "$detail" ]]; then + printf ' - %s' "$detail" + fi + + printf '\n' + done + + printf '\n' +} + +# Return success when no required verification failure was recorded. +execution_results_are_successful() { + ((LWBS_RESULT_FAILED_COUNT == 0)) +} \ No newline at end of file diff --git a/tests/fixtures/os-release/derived-debian b/tests/fixtures/os-release/derived-debian new file mode 100644 index 0000000..3ba4d4e --- /dev/null +++ b/tests/fixtures/os-release/derived-debian @@ -0,0 +1,5 @@ +ID=examplelinux +PRETTY_NAME="Example Linux" +VERSION_ID="1" +VERSION_CODENAME=example +ID_LIKE="debian ubuntu" \ No newline at end of file diff --git a/tests/fixtures/os-release/fedora b/tests/fixtures/os-release/fedora new file mode 100644 index 0000000..3d60586 --- /dev/null +++ b/tests/fixtures/os-release/fedora @@ -0,0 +1,5 @@ +ID=fedora +PRETTY_NAME="Fedora Linux 43" +VERSION_ID="43" +VERSION_CODENAME="" +ID_LIKE="rhel centos" \ No newline at end of file diff --git a/tests/fixtures/os-release/ubuntu b/tests/fixtures/os-release/ubuntu new file mode 100644 index 0000000..2e653b3 --- /dev/null +++ b/tests/fixtures/os-release/ubuntu @@ -0,0 +1,5 @@ +ID=ubuntu +PRETTY_NAME="Ubuntu 26.04.1 LTS" +VERSION_ID="26.04" +VERSION_CODENAME=resolute +ID_LIKE=debian \ No newline at end of file diff --git a/tests/fixtures/os-release/unknown b/tests/fixtures/os-release/unknown new file mode 100644 index 0000000..4240b32 --- /dev/null +++ b/tests/fixtures/os-release/unknown @@ -0,0 +1,5 @@ +ID=unknownlinux +PRETTY_NAME="Unknown Linux" +VERSION_ID="1" +VERSION_CODENAME="" +ID_LIKE="" \ No newline at end of file diff --git a/tests/run.sh b/tests/run.sh new file mode 100644 index 0000000..5f1d946 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -uo pipefail + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$TEST_DIR/.." && pwd)" + +declare -a TEST_FILES=( + "$TEST_DIR/test_core.sh" + "$TEST_DIR/test_detection.sh" + "$TEST_DIR/test_arguments.sh" + "$TEST_DIR/test_config.sh" + "$TEST_DIR/test_execution.sh" + "$TEST_DIR/test_distro.sh" +) + +failed_files=0 + +printf 'Linux Workstation Bootstrap test suite\n' +printf 'Repository: %s\n\n' "$REPO_ROOT" + +for test_file in "${TEST_FILES[@]}"; do + printf '========================================\n' + printf 'Running %s\n' "$(basename -- "$test_file")" + printf '========================================\n\n' + + if bash "$test_file"; then + : + else + ((failed_files += 1)) + fi + + printf '\n' +done + +printf '========================================\n' + +if ((failed_files > 0)); then + printf 'Test suite failed: %d test file(s) failed.\n' \ + "$failed_files" + + exit 1 +fi + +printf 'Test suite passed.\n' \ No newline at end of file diff --git a/tests/test_arguments.sh b/tests/test_arguments.sh new file mode 100644 index 0000000..d2fbdc1 --- /dev/null +++ b/tests/test_arguments.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +set -uo pipefail + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$TEST_DIR/.." && pwd)" + +# shellcheck source=./testlib.sh +source "$TEST_DIR/testlib.sh" + +# shellcheck source=../lib/arguments.sh +source "$REPO_ROOT/lib/arguments.sh" + +test_parse_dry_run_and_yes() { + parse_arguments --dry-run --yes + + assert_equal "true" "$LWBS_DRY_RUN" + assert_equal "true" "$LWBS_ASSUME_YES" + assert_equal "run" "$LWBS_CLI_ACTION" +} + +test_parse_separate_distro_argument() { + parse_arguments --distro Debian + + assert_equal "debian" "$LWBS_REQUESTED_DISTRO_PROFILE" +} + +test_parse_equals_distro_argument() { + parse_arguments --distro=Ubuntu + + assert_equal "ubuntu" "$LWBS_REQUESTED_DISTRO_PROFILE" +} + +test_parse_help() { + parse_arguments --help + + assert_equal "help" "$LWBS_CLI_ACTION" +} + +test_parse_version() { + parse_arguments --version + + assert_equal "version" "$LWBS_CLI_ACTION" +} + +test_missing_distro_value_fails() { + local exit_code + + if parse_arguments --distro >/dev/null 2>&1; then + printf 'Missing --distro value was accepted.\n' >&2 + return 1 + else + exit_code=$? + fi + + assert_equal "2" "$exit_code" +} + +test_unknown_option_fails() { + local exit_code + + if parse_arguments --definitely-not-valid >/dev/null 2>&1; then + printf 'Unknown option was accepted.\n' >&2 + return 1 + else + exit_code=$? + fi + + assert_equal "2" "$exit_code" +} + +test_positional_argument_fails() { + local exit_code + + if parse_arguments unexpected >/dev/null 2>&1; then + printf 'Unexpected positional argument was accepted.\n' >&2 + return 1 + else + exit_code=$? + fi + + assert_equal "2" "$exit_code" +} + +run_test \ + "parse dry-run and non-interactive approval options" \ + test_parse_dry_run_and_yes + +run_test \ + "parse --distro with a separate value" \ + test_parse_separate_distro_argument + +run_test \ + "parse --distro=value" \ + test_parse_equals_distro_argument + +run_test \ + "parse help action" \ + test_parse_help + +run_test \ + "parse version action" \ + test_parse_version + +run_test \ + "reject missing --distro value" \ + test_missing_distro_value_fails + +run_test \ + "reject unknown CLI option" \ + test_unknown_option_fails + +run_test \ + "reject positional arguments" \ + test_positional_argument_fails + +finish_tests \ No newline at end of file diff --git a/tests/test_config.sh b/tests/test_config.sh new file mode 100644 index 0000000..222fd6d --- /dev/null +++ b/tests/test_config.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash + +set -uo pipefail + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$TEST_DIR/.." && pwd)" + +# shellcheck source=./testlib.sh +source "$TEST_DIR/testlib.sh" + +LWBS_ROOT="$REPO_ROOT" + +# shellcheck source=../config/defaults.sh +source "$REPO_ROOT/config/defaults.sh" + +# shellcheck source=../lib/core.sh +source "$REPO_ROOT/lib/core.sh" + +# shellcheck source=../lib/logging.sh +source "$REPO_ROOT/lib/logging.sh" + +# shellcheck source=../lib/config.sh +source "$REPO_ROOT/lib/config.sh" + +test_missing_config_uses_defaults() { + local temp_dir + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + LWBS_USER_CONFIG_PATH_OVERRIDE="$temp_dir/does-not-exist.conf" + + load_user_config + + assert_equal "auto" "$LWBS_CONFIG_DISTRO_PROFILE" +} + +test_valid_config_override() { + local temp_dir + local config_file + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + config_file="$temp_dir/config.conf" + + cat >"$config_file" <<'EOF' +# Linux Workstation Bootstrap + +distro_profile=ubuntu +EOF + + LWBS_USER_CONFIG_PATH_OVERRIDE="$config_file" + + load_user_config + + assert_equal "ubuntu" "$LWBS_CONFIG_DISTRO_PROFILE" +} + +test_invalid_config_key_fails() { + local temp_dir + local config_file + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + config_file="$temp_dir/config.conf" + + printf '%s\n' \ + 'unsupported_setting=true' \ + >"$config_file" + + LWBS_USER_CONFIG_PATH_OVERRIDE="$config_file" + + if load_user_config >/dev/null 2>&1; then + printf 'Unsupported configuration setting was accepted.\n' >&2 + return 1 + fi + + return 0 +} + +test_duplicate_config_key_fails() { + local temp_dir + local config_file + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + config_file="$temp_dir/config.conf" + + cat >"$config_file" <<'EOF' +distro_profile=ubuntu +distro_profile=debian +EOF + + LWBS_USER_CONFIG_PATH_OVERRIDE="$config_file" + + if load_user_config >/dev/null 2>&1; then + printf 'Duplicate configuration setting was accepted.\n' >&2 + return 1 + fi + + return 0 +} + +test_unsupported_distro_config_fails() { + local temp_dir + local config_file + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + config_file="$temp_dir/config.conf" + + printf '%s\n' \ + 'distro_profile=gentoo' \ + >"$config_file" + + LWBS_USER_CONFIG_PATH_OVERRIDE="$config_file" + + if load_user_config >/dev/null 2>&1; then + printf 'Unsupported distribution profile was accepted.\n' >&2 + return 1 + fi + + return 0 +} + +test_config_is_not_executed() { + local temp_dir + local config_file + local marker + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + config_file="$temp_dir/config.conf" + marker="$temp_dir/executed" + + cat >"$config_file" </dev/null 2>&1; then + printf 'Invalid command-like configuration value was accepted.\n' >&2 + return 1 + fi + + assert_not_exists \ + "$marker" \ + "user configuration was executed as shell code" +} + +run_test \ + "use repository defaults when user configuration is absent" \ + test_missing_config_uses_defaults + +run_test \ + "load a valid user configuration override" \ + test_valid_config_override + +run_test \ + "reject unsupported configuration settings" \ + test_invalid_config_key_fails + +run_test \ + "reject duplicate configuration settings" \ + test_duplicate_config_key_fails + +run_test \ + "reject unsupported distro configuration" \ + test_unsupported_distro_config_fails + +run_test \ + "parse user configuration as data rather than shell code" \ + test_config_is_not_executed + +finish_tests \ No newline at end of file diff --git a/tests/test_core.sh b/tests/test_core.sh new file mode 100644 index 0000000..8eee470 --- /dev/null +++ b/tests/test_core.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash + +set -uo pipefail + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$TEST_DIR/.." && pwd)" + +# shellcheck source=./testlib.sh +source "$TEST_DIR/testlib.sh" + +LWBS_ROOT="$REPO_ROOT" + +# shellcheck source=../config/defaults.sh +source "$REPO_ROOT/config/defaults.sh" + +# shellcheck source=../lib/core.sh +source "$REPO_ROOT/lib/core.sh" + +test_supported_distros() { + is_supported_distro ubuntu + is_supported_distro debian + is_supported_distro kali +} + +test_future_detectable_distros_not_yet_supported() { + if is_supported_distro fedora; then + printf 'Fedora unexpectedly appears as an implemented profile.\n' >&2 + return 1 + fi + + if is_supported_distro arch; then + printf 'Arch unexpectedly appears as an implemented profile.\n' >&2 + return 1 + fi + + return 0 +} + +test_command_exists() { + command_exists bash + + if command_exists "lwbs-command-that-does-not-exist-$$"; then + printf 'command_exists accepted a nonexistent command.\n' >&2 + return 1 + fi +} + +test_require_command_rejects_missing_command() { + if require_command "lwbs-command-that-does-not-exist-$$" \ + >/dev/null 2>&1; then + + printf 'require_command accepted a nonexistent command.\n' >&2 + return 1 + fi + + return 0 +} + +test_runtime_accepts_linux() { + uname() { + printf '%s\n' "Linux" + } + + validate_runtime +} + +test_runtime_rejects_non_linux() { + uname() { + printf '%s\n' "Darwin" + } + + if validate_runtime >/dev/null 2>&1; then + printf 'validate_runtime accepted a non-Linux platform.\n' >&2 + return 1 + fi + + return 0 +} + +run_test \ + "recognize implemented distribution profiles" \ + test_supported_distros + +run_test \ + "keep Fedora and Arch detectable but unsupported for installation" \ + test_future_detectable_distros_not_yet_supported + +run_test \ + "detect commands in PATH" \ + test_command_exists + +run_test \ + "reject missing required commands" \ + test_require_command_rejects_missing_command + +run_test \ + "accept Linux runtime" \ + test_runtime_accepts_linux + +run_test \ + "reject non-Linux runtime" \ + test_runtime_rejects_non_linux + +finish_tests \ No newline at end of file diff --git a/tests/test_detection.sh b/tests/test_detection.sh new file mode 100644 index 0000000..906b420 --- /dev/null +++ b/tests/test_detection.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash + +set -uo pipefail + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$TEST_DIR/.." && pwd)" + +# shellcheck source=./testlib.sh +source "$TEST_DIR/testlib.sh" + +LWBS_ROOT="$REPO_ROOT" + +# shellcheck source=../config/defaults.sh +source "$REPO_ROOT/config/defaults.sh" + +# shellcheck source=../lib/detect.sh +source "$REPO_ROOT/lib/detect.sh" + +test_detect_ubuntu_fixture() { + LWBS_OS_RELEASE_OVERRIDE="$TEST_DIR/fixtures/os-release/ubuntu" + LWBS_ARCH_OVERRIDE="x86_64" + + detect_system + + assert_equal "ubuntu" "$SYSTEM_DISTRO_ID" + assert_equal "Ubuntu 26.04.1 LTS" "$SYSTEM_DISTRO_NAME" + assert_equal "26.04" "$SYSTEM_DISTRO_VERSION" + assert_equal "resolute" "$SYSTEM_DISTRO_CODENAME" + assert_equal "debian" "$SYSTEM_DISTRO_FAMILY" + assert_equal "amd64" "$SYSTEM_ARCH" + assert_equal "apt" "$SYSTEM_PACKAGE_MANAGER" +} + +test_detect_fedora_family() { + LWBS_OS_RELEASE_OVERRIDE="$TEST_DIR/fixtures/os-release/fedora" + LWBS_ARCH_OVERRIDE="x86_64" + + detect_system + + assert_equal "fedora" "$SYSTEM_DISTRO_ID" + assert_equal "rpm" "$SYSTEM_DISTRO_FAMILY" + assert_equal "dnf" "$SYSTEM_PACKAGE_MANAGER" + assert_equal "amd64" "$SYSTEM_ARCH" +} + +test_detect_family_from_id_like() { + LWBS_OS_RELEASE_OVERRIDE="$TEST_DIR/fixtures/os-release/derived-debian" + LWBS_ARCH_OVERRIDE="aarch64" + + detect_system + + assert_equal "examplelinux" "$SYSTEM_DISTRO_ID" + assert_equal "debian" "$SYSTEM_DISTRO_FAMILY" + assert_equal "apt" "$SYSTEM_PACKAGE_MANAGER" + assert_equal "arm64" "$SYSTEM_ARCH" +} + +test_detect_unknown_distribution_family() { + LWBS_OS_RELEASE_OVERRIDE="$TEST_DIR/fixtures/os-release/unknown" + LWBS_ARCH_OVERRIDE="riscv64" + + detect_system + + assert_equal "unknown" "$SYSTEM_DISTRO_FAMILY" + assert_equal "unknown" "$SYSTEM_PACKAGE_MANAGER" + assert_equal "riscv64" "$SYSTEM_ARCH" +} + +test_architecture_normalization() { + LWBS_ARCH_OVERRIDE="x86_64" + assert_equal "amd64" "$(detect_architecture)" + + LWBS_ARCH_OVERRIDE="aarch64" + assert_equal "arm64" "$(detect_architecture)" + + LWBS_ARCH_OVERRIDE="i686" + assert_equal "i386" "$(detect_architecture)" + + LWBS_ARCH_OVERRIDE="riscv64" + assert_equal "riscv64" "$(detect_architecture)" +} + +test_missing_os_release_fails() { + LWBS_OS_RELEASE_OVERRIDE="/tmp/lwbs-os-release-does-not-exist-$$" + + if detect_system >/dev/null 2>&1; then + printf 'detect_system unexpectedly accepted a missing os-release file.\n' \ + >&2 + return 1 + fi + + return 0 +} + +run_test \ + "detect Ubuntu from controlled os-release fixture" \ + test_detect_ubuntu_fixture + +run_test \ + "detect Fedora family without requiring Fedora support" \ + test_detect_fedora_family + +run_test \ + "resolve distro family through ID_LIKE" \ + test_detect_family_from_id_like + +run_test \ + "handle an unknown distribution family" \ + test_detect_unknown_distribution_family + +run_test \ + "normalize known architectures and preserve unknown ones" \ + test_architecture_normalization + +run_test \ + "reject an unreadable os-release source" \ + test_missing_os_release_fails + +finish_tests \ No newline at end of file diff --git a/tests/test_distro.sh b/tests/test_distro.sh new file mode 100644 index 0000000..9ae3caf --- /dev/null +++ b/tests/test_distro.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash + +set -uo pipefail + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$TEST_DIR/.." && pwd)" + +# shellcheck source=./testlib.sh +source "$TEST_DIR/testlib.sh" + +LWBS_ROOT="$REPO_ROOT" + +# shellcheck source=../config/defaults.sh +source "$REPO_ROOT/config/defaults.sh" + +# shellcheck source=../lib/core.sh +source "$REPO_ROOT/lib/core.sh" + +# shellcheck source=../lib/logging.sh +source "$REPO_ROOT/lib/logging.sh" + +# shellcheck source=../lib/execution.sh +source "$REPO_ROOT/lib/execution.sh" + +# shellcheck source=../lib/distro.sh +source "$REPO_ROOT/lib/distro.sh" + +test_load_supported_adapters() { + local temp_dir + local profile + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + for profile in ubuntu debian kali; do + LWBS_ROOT="$REPO_ROOT" + + load_distro_adapter "$profile" + + assert_equal \ + "$profile" \ + "$DISTRO_ADAPTER_PROFILE" + + reset_distro_adapter_contract + done +} + +test_reject_unsupported_adapter() { + local temp_dir + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + LWBS_ROOT="$REPO_ROOT" + + if load_distro_adapter fedora >/dev/null 2>&1; then + printf 'Unsupported Fedora adapter was accepted.\n' >&2 + return 1 + fi + + return 0 +} + +test_missing_adapter_fails() { + local temp_dir + local fake_root + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + fake_root="$temp_dir/repository" + + mkdir -p -- "$fake_root/distros" + + LWBS_ROOT="$fake_root" + + if load_distro_adapter ubuntu >/dev/null 2>&1; then + printf 'Missing Ubuntu adapter was accepted.\n' >&2 + return 1 + fi + + return 0 +} + +test_incomplete_adapter_fails_contract_validation() { + local temp_dir + local fake_root + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + fake_root="$temp_dir/repository" + + mkdir -p -- "$fake_root/distros" + + cat >"$fake_root/distros/ubuntu.sh" <<'EOF' +distro_validate_environment() { + return 0 +} +EOF + + LWBS_ROOT="$fake_root" + + if load_distro_adapter ubuntu >/dev/null 2>&1; then + printf 'Incomplete distro adapter was accepted.\n' >&2 + return 1 + fi + + return 0 +} + +test_adapter_environment_validation_without_host_changes() { + local temp_dir + local profile + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + require_command() { + return 0 + } + + SYSTEM_DISTRO_FAMILY="debian" + SYSTEM_PACKAGE_MANAGER="apt" + + for profile in ubuntu debian kali; do + LWBS_ROOT="$REPO_ROOT" + + load_distro_adapter "$profile" + distro_validate_environment + + reset_distro_adapter_contract + done +} + +test_adapter_rejects_incompatible_family() { + local temp_dir + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + require_command() { + return 0 + } + + SYSTEM_DISTRO_FAMILY="rpm" + SYSTEM_PACKAGE_MANAGER="dnf" + + LWBS_ROOT="$REPO_ROOT" + + load_distro_adapter ubuntu + + if distro_validate_environment >/dev/null 2>&1; then + printf 'Ubuntu adapter accepted an RPM-family environment.\n' >&2 + return 1 + fi + + return 0 +} + +test_adapter_package_install_is_safe_in_dry_run() { + local temp_dir + local output + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + LWBS_ROOT="$REPO_ROOT" + LWBS_DRY_RUN=true + + load_distro_adapter ubuntu + + output="$( + distro_install_packages \ + lwbs-example-package + )" + + assert_contains \ + "$output" \ + "[DRY-RUN] Would execute with root privileges: apt-get" +} + +test_local_package_install_is_safe_in_dry_run() { + local temp_dir + local output + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + LWBS_ROOT="$REPO_ROOT" + LWBS_DRY_RUN=true + + load_distro_adapter ubuntu + + output="$( + distro_install_local_package \ + "/tmp/lwbs-example-package.deb" + )" + + assert_contains \ + "$output" \ + "[DRY-RUN] Would execute with root privileges: apt-get" +} + +run_test \ + "load every currently supported distro adapter" \ + test_load_supported_adapters + +run_test \ + "reject distro profiles without implemented adapters" \ + test_reject_unsupported_adapter + +run_test \ + "fail clearly when an adapter file is missing" \ + test_missing_adapter_fails + +run_test \ + "reject incomplete distro adapter contracts" \ + test_incomplete_adapter_fails_contract_validation + +run_test \ + "validate Debian-family adapters without modifying the host" \ + test_adapter_environment_validation_without_host_changes + +run_test \ + "reject an incompatible distro family" \ + test_adapter_rejects_incompatible_family + +run_test \ + "keep repository package installation safe in dry-run" \ + test_adapter_package_install_is_safe_in_dry_run + +run_test \ + "keep local package installation safe in dry-run" \ + test_local_package_install_is_safe_in_dry_run + +finish_tests \ No newline at end of file diff --git a/tests/test_execution.sh b/tests/test_execution.sh new file mode 100644 index 0000000..11bcdf1 --- /dev/null +++ b/tests/test_execution.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash + +set -uo pipefail + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$TEST_DIR/.." && pwd)" + +# shellcheck source=./testlib.sh +source "$TEST_DIR/testlib.sh" + +LWBS_ROOT="$REPO_ROOT" + +# shellcheck source=../config/defaults.sh +source "$REPO_ROOT/config/defaults.sh" + +# shellcheck source=../lib/core.sh +source "$REPO_ROOT/lib/core.sh" + +# shellcheck source=../lib/logging.sh +source "$REPO_ROOT/lib/logging.sh" + +# shellcheck source=../lib/execution.sh +source "$REPO_ROOT/lib/execution.sh" + +test_dry_run_does_not_execute_command() { + local temp_dir + local marker + local output + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + marker="$temp_dir/marker" + + LWBS_DRY_RUN=true + + output="$(run_command touch "$marker")" + + assert_not_exists \ + "$marker" \ + "dry-run executed a system command" + + assert_contains \ + "$output" \ + "[DRY-RUN] Would execute: touch" +} + +test_command_exit_code_is_preserved() { + local temp_dir + local exit_code + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + LWBS_DRY_RUN=false + + controlled_failure() { + return 7 + } + + if run_command controlled_failure; then + printf 'Failing command unexpectedly succeeded.\n' >&2 + return 1 + else + exit_code=$? + fi + + assert_equal "7" "$exit_code" +} + +test_privileged_dry_run_performs_no_change() { + local temp_dir + local marker + local output + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + marker="$temp_dir/privileged-marker" + + LWBS_DRY_RUN=true + + output="$( + run_privileged_command \ + touch \ + "$marker" + )" + + assert_not_exists \ + "$marker" \ + "privileged dry-run modified the host" + + assert_contains \ + "$output" \ + "[DRY-RUN] Would execute with root privileges: touch" +} + +test_execution_requires_command() { + local temp_dir + local exit_code + + temp_dir="$(make_test_temp_dir)" + register_test_cleanup "$temp_dir" + + setup_test_logging "$temp_dir" + + LWBS_DRY_RUN=false + + if run_command >/dev/null 2>&1; then + printf 'run_command accepted an empty command.\n' >&2 + return 1 + else + exit_code=$? + fi + + assert_equal "2" "$exit_code" +} + +run_test \ + "dry-run skips normal command execution" \ + test_dry_run_does_not_execute_command + +run_test \ + "preserve command failure exit codes" \ + test_command_exit_code_is_preserved + +run_test \ + "dry-run skips privileged command execution" \ + test_privileged_dry_run_performs_no_change + +run_test \ + "reject empty command execution requests" \ + test_execution_requires_command + +finish_tests \ No newline at end of file diff --git a/tests/testlib.sh b/tests/testlib.sh new file mode 100644 index 0000000..c9fcfee --- /dev/null +++ b/tests/testlib.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash + +# Linux Workstation Bootstrap +# Author: David Kariuki +# +# Minimal test harness used by the repository test suite. +# +# The harness intentionally depends only on Bash so the core test suite can +# run locally and in CI without installing a separate testing framework. + +LWBS_TEST_COUNT=0 +LWBS_TEST_FAILURE_COUNT=0 + +# Execute one test function in an isolated subshell. +run_test() { + local test_name="${1:-}" + local test_function="${2:-}" + + if [[ -z "$test_name" || -z "$test_function" ]]; then + printf 'Error: run_test requires a name and test function.\n' >&2 + return 2 + fi + + ((LWBS_TEST_COUNT += 1)) + + printf 'TEST: %s\n' "$test_name" + + if ( + set -Eeuo pipefail + "$test_function" + ); then + printf 'PASS: %s\n\n' "$test_name" + return 0 + fi + + ((LWBS_TEST_FAILURE_COUNT += 1)) + + printf 'FAIL: %s\n\n' "$test_name" + + return 0 +} + +# Assert that two values are exactly equal. +assert_equal() { + local expected="${1-}" + local actual="${2-}" + local message="${3:-values are not equal}" + + if [[ "$expected" == "$actual" ]]; then + return 0 + fi + + printf 'Assertion failed: %s\n' "$message" >&2 + printf ' Expected: %q\n' "$expected" >&2 + printf ' Actual : %q\n' "$actual" >&2 + + return 1 +} + +# Assert that one string contains another string. +assert_contains() { + local haystack="${1-}" + local needle="${2-}" + local message="${3:-expected text was not found}" + + if [[ "$haystack" == *"$needle"* ]]; then + return 0 + fi + + printf 'Assertion failed: %s\n' "$message" >&2 + printf ' Expected to contain: %q\n' "$needle" >&2 + printf ' Actual : %q\n' "$haystack" >&2 + + return 1 +} + +# Assert that a filesystem path does not exist. +assert_not_exists() { + local path="${1:-}" + local message="${2:-path unexpectedly exists}" + + if [[ ! -e "$path" ]]; then + return 0 + fi + + printf 'Assertion failed: %s\n' "$message" >&2 + printf ' Path: %s\n' "$path" >&2 + + return 1 +} + +# Create an isolated temporary directory for one test. +make_test_temp_dir() { + mktemp -d "${TMPDIR:-/tmp}/lwbs-test.XXXXXX" +} + +# Register one temporary path for removal when the isolated test exits. +# +# The path is shell-escaped when the trap is registered so cleanup does not +# depend on a local variable that may no longer exist when the EXIT trap runs. +register_test_cleanup() { + local path="${1:-}" + local escaped_path + + if [[ -z "$path" ]]; then + printf 'Error: register_test_cleanup requires a path.\n' >&2 + return 2 + fi + + printf -v escaped_path '%q' "$path" + + trap "rm -rf -- $escaped_path" EXIT +} + +# Initialize logging beneath an isolated test directory. +setup_test_logging() { + local test_directory="${1:-}" + + if [[ -z "$test_directory" ]]; then + printf 'Error: setup_test_logging requires a test directory.\n' >&2 + return 2 + fi + + export XDG_STATE_HOME="$test_directory/state" + export HOME="$test_directory/home" + + mkdir -p -- "$HOME" + + init_logging +} + +# Print the result for the current test file. +finish_tests() { + local passed_count + + passed_count=$((LWBS_TEST_COUNT - LWBS_TEST_FAILURE_COUNT)) + + printf 'Tests: %d | Passed: %d | Failed: %d\n' \ + "$LWBS_TEST_COUNT" \ + "$passed_count" \ + "$LWBS_TEST_FAILURE_COUNT" + + if ((LWBS_TEST_FAILURE_COUNT > 0)); then + return 1 + fi + + return 0 +} \ No newline at end of file