diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..c142d75 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,85 @@ +# Static validation plus per-language template smoke builds. +# +# What CI can and cannot verify: these jobs only run scripts/validate.py and +# build the hello-syscall templates. They CANNOT run the demos that need a +# KVM/libvirt VM, root privileges, or the LGTM stack — none of that exists on +# GitHub-hosted runners. The authoritative gate for the book remains a host +# run of scripts/test-all-examples.py against the real VM lab. + +name: validate + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # Needed for validate.py's Lua syntax pass; non-fatal so a transient + # apt outage doesn't block the whole workflow (validate.py skips the + # pass when lua5.4 is absent). + - name: Install Lua 5.4 (best effort) + run: sudo apt-get update && sudo apt-get install -y lua5.4 || echo "lua5.4 install failed; Lua syntax pass will be skipped" + + - name: Run validate.py + run: python3 scripts/validate.py + + smoke-go: + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: examples/_template/go/go.mod + + - name: Build Go template + working-directory: examples/_template/go + run: go build ./... + + smoke-rust: + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v5 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.97.1" + + - name: Build Rust template + working-directory: examples/_template/rust + run: cargo build --release + + smoke-cpp: + runs-on: ubuntu-latest + # Match the book's primary toolchain (Fedora GCC) rather than Ubuntu's. + container: fedora:44 + steps: + - name: Check out repo + uses: actions/checkout@v5 + + - name: Install toolchain + run: dnf install -y gcc-c++ cmake ninja-build + + - name: Build C++ template + working-directory: examples/_template/cpp + run: | + cmake --preset release + cmake --build --preset release diff --git a/.gitignore b/.gitignore index 28d1dc4..7123213 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ __pycache__/ # Reports reports/ +build-logs/ diff --git a/examples/_infra/README.md b/examples/_infra/README.md new file mode 100644 index 0000000..f77880a --- /dev/null +++ b/examples/_infra/README.md @@ -0,0 +1,60 @@ +# Shared example infrastructure + +Host-side services that some examples depend on. Today that is one thing: the +Grafana LGTM observability stack (Loki + Grafana + Tempo + Mimir, with a +built-in OpenTelemetry Collector), run as a single container via podman compose. + +## LGTM stack + +### Start / stop + +From the repo root: + +```sh +podman compose -f examples/_infra/compose-lgtm.yaml up -d +podman compose -f examples/_infra/compose-lgtm.yaml down +``` + +The container is named `lsp-lgtm` and has a healthcheck; wait for it to report +healthy before pointing examples at it (first boot can take ~30s): + +```sh +podman inspect --format '{{.State.Health.Status}}' lsp-lgtm +``` + +### Ports + +| Port | Purpose | +|------|---------| +| 3000 | Grafana UI (`http://localhost:3000`, anonymous admin) | +| 4317 | OTLP gRPC ingest | +| 4318 | OTLP HTTP ingest | + +### How examples declare the dependency + +An example that emits telemetry declares the stack in its manifest: + +```yaml +requires: [lgtm] +``` + +The verification harness reads this and checks that the OTLP port is reachable +(via `expect_port_open`) before running the example, skipping (exit 77) when +the stack is not up rather than failing. + +### Exporting from inside the lab VM + +Examples deployed to the lab VM (`TARGET=...` with `deploy-to-vm.sh`) cannot +reach `localhost` on the host. From a libvirt guest on the default NAT network, +the host is reachable at the network's gateway IP — on this lab that is +`192.168.124.1` (libvirt's stock default is `192.168.122.1`; check yours with +`ip route show default` inside the guest). Pass the endpoint through +`OTEL_ENDPOINT`: + +```sh +OTEL_ENDPOINT=http://192.168.124.1:4318 TARGET=systems-target ./demo.sh run +``` + +Verify the actual gateway from inside the guest with `ip route show default` +if the network is not the libvirt default. Ports 4317/4318 must be open to the +libvirt bridge on the host firewall (on Fedora: the `libvirt` firewalld zone). diff --git a/examples/_infra/compose-lgtm.yaml b/examples/_infra/compose-lgtm.yaml new file mode 100644 index 0000000..503787a --- /dev/null +++ b/examples/_infra/compose-lgtm.yaml @@ -0,0 +1,39 @@ +# compose-lgtm.yaml +# +# Shared observability backend for the book's examples: the Grafana LGTM +# all-in-one image (Grafana + Loki + Tempo + Mimir + Prometheus + the +# OpenTelemetry Collector) as a single container on the host. +# +# Start/stop: +# podman compose -f examples/_infra/compose-lgtm.yaml up -d +# podman compose -f examples/_infra/compose-lgtm.yaml down +# +# Access after startup: +# Grafana UI: http://localhost:3000 +# OTLP gRPC: localhost:4317 +# OTLP HTTP: http://localhost:4318 + +services: + lgtm: + image: docker.io/grafana/otel-lgtm:0.8.1 + container_name: lsp-lgtm + ports: + - "3000:3000" # Grafana UI + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + mem_limit: 2g + environment: + # Anonymous admin access keeps local demos friction-free. Never ship this. + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_DISABLE_LOGIN_FORM=true + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 30s # the all-in-one image takes a while on first boot + +networks: + default: + name: lsp-observability diff --git a/examples/_template/README.md b/examples/_template/README.md new file mode 100644 index 0000000..39b523c --- /dev/null +++ b/examples/_template/README.md @@ -0,0 +1,51 @@ +# _template — hello-syscall + +The seed for every runnable example in the book. It implements the same tiny +program three times — C++23, Go, and Rust — each printing exactly one line: + +``` +pid on () +``` + +via `getpid(2)` + `uname(2)` equivalents, with each language's idiomatic error +handling (`std::expected` / wrapped errors via `%w` / `rustix`). + +## Layout + +``` +_template/ +├── demo.sh # dispatcher: ./demo.sh [cpp|go|rust|all|build] [args...] +├── verify.lua # automated check driven by CI +├── cpp/ # CMake preset build, demo.sh +├── go/ # go build, demo.sh +└── rust/ # cargo build, demo.sh +``` + +## The demo contract + +Every language directory has a `demo.sh` with the same interface, identical +across the whole book: + +- `./demo.sh` — build then run +- `./demo.sh build` — build only +- `./demo.sh run [args]` — run the built binary +- With env `TARGET` set, `run` deploys the binary to that lab VM via + `scripts/lab/deploy-to-vm.sh "$TARGET" -- [args]` instead of + running locally. + +The top-level `demo.sh` here is only a dispatcher: `./demo.sh cpp run` execs +`cpp/demo.sh run`; bare `./demo.sh` (or `all`) builds and runs all three. + +## Verification + +`verify.lua` is run from this directory with `LSP_LANG` set to one language at +a time. It uses `scripts/lib/checks.lua` to run `./demo.sh $LSP_LANG run` and +asserts the output matches `pid %d+ on Linux`. Exit 0 = pass, 1 = fail, +77 = skip. + +## Mapping to a chapter + +Each chapter's example is a copy of this template (`scripts/new-example.sh +NN-slug`) with the program body replaced by that chapter's code. Keep the +demo contract and `verify.lua` shape intact so CI and the manifest +(`examples/manifest.yaml`) keep working. diff --git a/examples/_template/cpp/.clang-format b/examples/_template/cpp/.clang-format new file mode 100644 index 0000000..2c8640f --- /dev/null +++ b/examples/_template/cpp/.clang-format @@ -0,0 +1,8 @@ +--- +BasedOnStyle: LLVM +Language: Cpp +Standard: c++20 +IndentWidth: 4 +ColumnLimit: 100 +PointerAlignment: Left +AllowShortFunctionsOnASingleLine: Inline diff --git a/examples/_template/cpp/.clang-tidy b/examples/_template/cpp/.clang-tidy new file mode 100644 index 0000000..2f275ba --- /dev/null +++ b/examples/_template/cpp/.clang-tidy @@ -0,0 +1,11 @@ +--- +Checks: > + bugprone-*, + modernize-*, + performance-*, + readability-*, + -modernize-use-trailing-return-type, + -readability-identifier-length +WarningsAsErrors: '' +HeaderFilterRegex: '.*' +FormatStyle: file diff --git a/examples/_template/cpp/CMakeLists.txt b/examples/_template/cpp/CMakeLists.txt new file mode 100644 index 0000000..6bdb445 --- /dev/null +++ b/examples/_template/cpp/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.25) +project(template_hello CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_executable(app src/main.cpp) diff --git a/examples/_template/cpp/CMakePresets.json b/examples/_template/cpp/CMakePresets.json new file mode 100644 index 0000000..e31bd2f --- /dev/null +++ b/examples/_template/cpp/CMakePresets.json @@ -0,0 +1,62 @@ +{ + "version": 6, + "cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 }, + "configurePresets": [ + { + "name": "_base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "release", + "inherits": "_base", + "displayName": "Release (-O2 with debug info)", + "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" } + }, + { + "name": "debug", + "inherits": "_base", + "displayName": "Debug", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } + }, + { + "name": "asan", + "inherits": "_base", + "displayName": "ASan + UBSan instrumented build", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_FLAGS": "-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined", + "CMAKE_EXE_LINKER_FLAGS": "-fsanitize=address,undefined" + } + }, + { + "name": "release-clang", + "inherits": "release", + "displayName": "Release (clang)", + "cacheVariables": { + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_COMPILER": "clang++" + } + }, + { + "name": "debug-clang", + "inherits": "debug", + "displayName": "Debug (clang)", + "cacheVariables": { + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_COMPILER": "clang++" + } + } + ], + "buildPresets": [ + { "name": "release", "configurePreset": "release" }, + { "name": "debug", "configurePreset": "debug" }, + { "name": "asan", "configurePreset": "asan" }, + { "name": "release-clang", "configurePreset": "release-clang" }, + { "name": "debug-clang", "configurePreset": "debug-clang" } + ] +} diff --git a/examples/_template/cpp/demo.sh b/examples/_template/cpp/demo.sh new file mode 100755 index 0000000..6af6509 --- /dev/null +++ b/examples/_template/cpp/demo.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +REPO_ROOT="$(cd ../../.. && pwd)" +BIN=build/release/app + +build() { + cmake --preset release + cmake --build --preset release +} + +run() { + if [[ ! -x "$BIN" ]]; then + build + fi + if [[ -n "${TARGET:-}" ]]; then + "$REPO_ROOT/scripts/lab/deploy-to-vm.sh" "$TARGET" "$BIN" -- "$@" + else + "./$BIN" "$@" + fi +} + +case "${1:-}" in + build) + build + ;; + run) + shift + run "$@" + ;; + "") + build + run + ;; + *) + echo "usage: $0 [build|run [args]]" >&2 + exit 2 + ;; +esac diff --git a/examples/_template/cpp/src/main.cpp b/examples/_template/cpp/src/main.cpp new file mode 100644 index 0000000..74a956e --- /dev/null +++ b/examples/_template/cpp/src/main.cpp @@ -0,0 +1,32 @@ +// hello-syscall: print "pid on ()" via getpid(2) + uname(2). + +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +[[nodiscard]] std::expected system_info() { + utsname info{}; + if (::uname(&info) != 0) { + return std::unexpected(std::error_code{errno, std::system_category()}); + } + return info; +} + +} // namespace + +int main() { + const auto info = system_info(); + if (!info) { + std::println(stderr, "uname failed: {}", info.error().message()); + return 1; + } + std::println("pid {} on {} {} ({})", ::getpid(), info->sysname, info->release, info->machine); + return 0; +} diff --git a/examples/_template/demo.sh b/examples/_template/demo.sh new file mode 100755 index 0000000..afbbe4c --- /dev/null +++ b/examples/_template/demo.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +langs=(cpp go rust) + +case "${1:-all}" in + cpp|go|rust) + lang="$1" + shift + exec "./$lang/demo.sh" "$@" + ;; + all) + for lang in "${langs[@]}"; do "./$lang/demo.sh"; done + ;; + build) + for lang in "${langs[@]}"; do "./$lang/demo.sh" build; done + ;; + *) + echo "usage: $0 [cpp|go|rust|all|build] [args...]" >&2 + exit 2 + ;; +esac diff --git a/examples/_template/go/demo.sh b/examples/_template/go/demo.sh new file mode 100755 index 0000000..28606df --- /dev/null +++ b/examples/_template/go/demo.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +REPO_ROOT="$(cd ../../.. && pwd)" +BIN=bin/app + +build() { + go build -o "$BIN" . +} + +run() { + if [[ -n "${TARGET:-}" ]]; then + exec "$REPO_ROOT/scripts/lab/deploy-to-vm.sh" "$TARGET" "$BIN" -- "$@" + fi + exec "./$BIN" "$@" +} + +case "${1:-}" in + build) build ;; + run) shift; run "$@" ;; + "") build; run ;; + *) echo "usage: $0 [build|run [args...]]" >&2; exit 2 ;; +esac diff --git a/examples/_template/go/go.mod b/examples/_template/go/go.mod new file mode 100644 index 0000000..032b7d8 --- /dev/null +++ b/examples/_template/go/go.mod @@ -0,0 +1,7 @@ +module github.com/patterncatalyst/linux-systems-programming/examples/_template/go + +go 1.26 + +toolchain go1.26.5 + +require golang.org/x/sys v0.47.0 diff --git a/examples/_template/go/go.sum b/examples/_template/go/go.sum new file mode 100644 index 0000000..37ee2d4 --- /dev/null +++ b/examples/_template/go/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/examples/_template/go/main.go b/examples/_template/go/main.go new file mode 100644 index 0000000..98d75b2 --- /dev/null +++ b/examples/_template/go/main.go @@ -0,0 +1,30 @@ +// hello-syscall: print "pid on ()". +package main + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +func helloLine() (string, error) { + var uts unix.Utsname + if err := unix.Uname(&uts); err != nil { + return "", fmt.Errorf("uname: %w", err) + } + return fmt.Sprintf("pid %d on %s %s (%s)", + unix.Getpid(), + unix.ByteSliceToString(uts.Sysname[:]), + unix.ByteSliceToString(uts.Release[:]), + unix.ByteSliceToString(uts.Machine[:])), nil +} + +func main() { + line, err := helloLine() + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + fmt.Println(line) +} diff --git a/examples/_template/rust/Cargo.lock b/examples/_template/rust/Cargo.lock new file mode 100644 index 0000000..9fc0988 --- /dev/null +++ b/examples/_template/rust/Cargo.lock @@ -0,0 +1,66 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "template-hello" +version = "0.1.0" +dependencies = [ + "rustix", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/examples/_template/rust/Cargo.toml b/examples/_template/rust/Cargo.toml new file mode 100644 index 0000000..62c962f --- /dev/null +++ b/examples/_template/rust/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "template-hello" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "app" +path = "src/main.rs" + +[dependencies] +rustix = { version = "1", features = ["system"] } diff --git a/examples/_template/rust/demo.sh b/examples/_template/rust/demo.sh new file mode 100755 index 0000000..220feea --- /dev/null +++ b/examples/_template/rust/demo.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +BIN=target/release/app + +build() { + cargo build --release +} + +run() { + if [[ -n "${TARGET:-}" ]]; then + local repo_root + repo_root=$(cd ../../.. && pwd) + "$repo_root/scripts/lab/deploy-to-vm.sh" "$TARGET" "$BIN" -- "$@" + else + "./$BIN" "$@" + fi +} + +case "${1:-}" in + build) build ;; + run) shift; run "$@" ;; + "") build; run ;; + *) echo "usage: $0 [build|run [args...]]" >&2; exit 2 ;; +esac diff --git a/examples/_template/rust/rust-toolchain.toml b/examples/_template/rust/rust-toolchain.toml new file mode 100644 index 0000000..4099f1a --- /dev/null +++ b/examples/_template/rust/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy", "rust-src"] diff --git a/examples/_template/rust/src/main.rs b/examples/_template/rust/src/main.rs new file mode 100644 index 0000000..9536d12 --- /dev/null +++ b/examples/_template/rust/src/main.rs @@ -0,0 +1,12 @@ +use rustix::system::uname; + +fn main() { + let info = uname(); + println!( + "pid {} on {} {} ({})", + std::process::id(), + info.sysname().to_string_lossy(), + info.release().to_string_lossy(), + info.machine().to_string_lossy(), + ); +} diff --git a/examples/_template/verify.lua b/examples/_template/verify.lua new file mode 100644 index 0000000..230569e --- /dev/null +++ b/examples/_template/verify.lua @@ -0,0 +1,17 @@ +-- Verify the hello-syscall template: run the demo for LSP_LANG and check its output. +-- Invoked from the example directory with LSP_LANG (cpp|go|rust) and EXAMPLE_DIR set. + +local script_dir = arg[0]:match("(.*/)") or "./" +local repo_root = os.getenv("REPO_ROOT") or (script_dir .. "../..") +package.path = package.path .. ";" .. repo_root .. "/scripts/lib/?.lua" +local checks = require("checks") + +local lang = os.getenv("LSP_LANG") +if lang ~= "cpp" and lang ~= "go" and lang ~= "rust" then + checks.skip("LSP_LANG must be cpp|go|rust (got " .. tostring(lang) .. ")") +end + +local r = checks.run("./demo.sh " .. lang .. " run") +checks.expect_exit(r, 0, lang .. ": demo exits 0") +checks.expect_match(r.out, "pid %d+ on Linux", lang .. ": prints pid/uname line") +checks.finish() diff --git a/examples/manifest.yaml b/examples/manifest.yaml new file mode 100644 index 0000000..6337fb2 --- /dev/null +++ b/examples/manifest.yaml @@ -0,0 +1,15 @@ +# Manifest of runnable examples — one entry per directory under examples/. +# +# Fields: +# id: directory name under examples/ (e.g. 02-hello-syscall) +# langs: languages implemented; subset of [cpp, go, rust] +# mode: local — build and run on the host +# vm — run the built binary inside a lab VM (TARGET set) +# vm-peer — needs two lab VMs talking to each other +# requires: optional prerequisites, e.g. [lgtm] for the observability stack +# timeout: per-language verification budget in seconds +examples: + - id: _template + langs: [cpp, go, rust] + mode: local + timeout: 180 diff --git a/scripts/check-host.sh b/scripts/check-host.sh new file mode 100755 index 0000000..43c09ca --- /dev/null +++ b/scripts/check-host.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# Verify the Fedora 44 host has every prerequisite the demos and the +# KVM lab need. Prints a table of PASS / WARN / FAIL lines and exits +# non-zero if any hard requirement fails. +# +# Run this once after following §2's installation steps, and re-run +# any time you change toolchains. +# +# Deliberately no -e: failing checks are recorded in the table, not +# fatal on the spot. +set -uo pipefail + +# ── Colors ─────────────────────────────────────────────────────────────── +# Honor NO_COLOR if set or if stdout is not a tty. +if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then + C_RED=$'\033[31m' + C_GREEN=$'\033[32m' + C_YELLOW=$'\033[33m' + C_RESET=$'\033[0m' +else + C_RED="" + C_GREEN="" + C_YELLOW="" + C_RESET="" +fi + +log_ok() { printf '%s[ ok ]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } +log_warn() { printf '%s[warn]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } +log_err() { printf '%s[fail]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; } + +# ── Status table accumulator ───────────────────────────────────────────── +# Status values: +# ok — hard requirement satisfied +# fail — hard requirement missing; gates exit code +# warn — soft requirement missing; informational, doesn't gate exit +PASS=0; FAIL=0; WARN=0 +TABLE=() + +record() { + local status="$1"; shift + local label="$1"; shift + local detail="$1"; shift + local hint="${1:-}" + case "$status" in + ok) + PASS=$((PASS + 1)) + TABLE+=("$(printf '%s[ ok ]%s %-32s %s' "$C_GREEN" "$C_RESET" "$label" "$detail")") + ;; + warn) + WARN=$((WARN + 1)) + TABLE+=("$(printf '%s[warn]%s %-32s %s' "$C_YELLOW" "$C_RESET" "$label" "$detail")") + if [[ -n "$hint" ]]; then + TABLE+=(" ↳ $hint") + fi + ;; + *) + FAIL=$((FAIL + 1)) + TABLE+=("$(printf '%s[fail]%s %-32s %s' "$C_RED" "$C_RESET" "$label" "$detail")") + if [[ -n "$hint" ]]; then + TABLE+=(" ↳ $hint") + fi + ;; + esac +} + +# check_version