From 962a8f197dc8b9aa2ccba6d49d9cd3ab10d56bc6 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 5 Sep 2026 05:35:44 -0500 Subject: [PATCH] [#454] adopt enforcement framework + STATUS; modernize librenms agent scripts check-rules green; shellcheck fixes to dpkg.sh/os-updates.sh (legacy backticks, unquoted vars, array-quoted command builders); Discourse pointers added to basis docs; awx/k8s/compliance repos referenced. https://projects.knownelement.com/issues/454 --- .crush/active-ticket | 1 + .env.example | 3 + .gitignore | 22 +- Makefile | 50 +++++ README.md | 2 +- STATUS.md | 5 + docs/kneliac-basis-analysis.md | 2 + docs/kneliac-basis-summary.md | 2 + questions-v1.md | 9 + roles/oam/files/librenms/dpkg.sh | 24 ++- roles/oam/files/librenms/os-updates.sh | 56 ++--- scripts/check-rules.sh | 274 +++++++++++++++++++++++++ scripts/docker-run.sh | 39 ++++ scripts/garden.sh | 65 ++++++ scripts/lib/common.sh | 137 +++++++++++++ scripts/pre-commit | 25 +++ scripts/pre-push | 25 +++ scripts/setup-hooks.sh | 44 ++++ tests/shellcheck.sh | 97 +++++++++ 19 files changed, 829 insertions(+), 53 deletions(-) create mode 100644 .crush/active-ticket create mode 100644 .env.example create mode 100644 Makefile create mode 100644 STATUS.md create mode 100644 questions-v1.md create mode 100755 scripts/check-rules.sh create mode 100755 scripts/docker-run.sh create mode 100755 scripts/garden.sh create mode 100644 scripts/lib/common.sh create mode 100755 scripts/pre-commit create mode 100755 scripts/pre-push create mode 100755 scripts/setup-hooks.sh create mode 100644 tests/shellcheck.sh diff --git a/.crush/active-ticket b/.crush/active-ticket new file mode 100644 index 0000000..f4462de --- /dev/null +++ b/.crush/active-ticket @@ -0,0 +1 @@ +#454 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..417ca57 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# KNELIAC consumes no env secrets directly: Ansible vault secrets resolve +# via ~/.creds refs at runtime (see AGENTS.md secrets rule). Copy this file +# for local overrides if a playbook needs an env knob. diff --git a/.gitignore b/.gitignore index ac1a6cf..80ef8a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,8 @@ -# Ansible / AWX local artifacts -.fact_cache/ -.venv/ -*.retry -*.log - -# Python -__pycache__/ -*.pyc - -# Editor / OS -.DS_Store -*.swp -*~ +*.key +*.pem +*.creds +.env +.env.* +!.env.example +*.tmp +vault-password* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4e52dad --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +# Makefile — convenience dispatch to scripts/. +# +# Not required. The scripts in scripts/ are the real entry points and work +# standalone. This file just gives you short verbs if you're at a terminal. +# +# In Mode 2 (Hermes/OWUI/MCP), agents call the scripts directly or via API — +# they don't need this file. + +# Project-specific overrides for check-rules.sh +export PROJECT_DOC_EXEMPT ?= AGENTS.md STATUS.md WORKING.md README.md ADOPTING.md LICENSE .env.example questions-v1.md BASELINE-PROMPT.md PATTERNS.md +export PROJECT_DISCOURSE_HOST ?= community.turnsys.com + +.PHONY: setup validate fast lint test garden up down status clean help + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +setup: ## Install git hooks + @bash scripts/setup-hooks.sh + +validate: ## Full rule audit (includes tests) + @bash scripts/check-rules.sh + +fast: ## Fast rule audit (pre-commit equivalent) + @bash scripts/check-rules.sh --fast + +lint: ## Lint shell scripts (shellcheck via docker) + @docker run --rm -v "$$(pwd):/mnt" koalaman/shellcheck:stable \ + $$(find . -path ./.git -prune -o -path ./.tmp -prune -o -path ./vendor -prune -o -path ./node_modules -prune -o \( -name '*.sh' -o -name '*.bash' \) -print | sed 's|^\./|/mnt/|') || true + +test: ## Run the test suite (override per project) + @bash scripts/test.sh + +garden: ## Doc-sprawl / Discourse-migration report + @bash scripts/garden.sh + +up: ## Bring up the docker-compose stack + @bash scripts/up.sh + +down: ## Bring down the docker-compose stack + @bash scripts/down.sh + +status: ## Show repo status snapshot + @echo "== branch =="; git branch --show-current 2>/dev/null || echo "(no branch)" + @echo "== last commit =="; git log --oneline -1 2>/dev/null || true + @echo "== working tree =="; git status --short 2>/dev/null || echo "(not a git repo)" + @echo "== STATUS.md head =="; sed -n '1,12p' STATUS.md 2>/dev/null || echo "(no STATUS.md)" + +clean: ## Remove build/test artifacts (override per project) + @echo "make clean: nothing to clean — override this in your project's Makefile." diff --git a/README.md b/README.md index 7ed6a6b..2390522 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ lives in `PFVCluster/provisioning/`, and is the project that **AWX** PFVCluster stopped being the catch-all: domain tooling moved to dedicated KNEL repos. Playbooks/roles here should reference these as their code homes (and vice versa — codified changes in those repos land here as AWX items per the IaC mandate #454): -[siem](https://git.knownelement.com/KNEL/siem) · [netinfra](https://git.knownelement.com/KNEL/netinfra) · [ca](https://git.knownelement.com/KNEL/ca) · [monitoring](https://git.knownelement.com/KNEL/monitoring) · [inventory](https://git.knownelement.com/KNEL/inventory) · [facilities](https://git.knownelement.com/KNEL/facilities) · [perf](https://git.knownelement.com/KNEL/perf) · [backups](https://git.knownelement.com/KNEL/backups) · [mailgateway](https://git.knownelement.com/KNEL/mailgateway) · [secrets](https://git.knownelement.com/KNEL/secrets) · [apt-satellite](https://git.knownelement.com/KNEL/apt-satellite) · [security-onion](https://git.knownelement.com/KNEL/security-onion) +[siem](https://git.knownelement.com/KNEL/siem) · [netinfra](https://git.knownelement.com/KNEL/netinfra) · [ca](https://git.knownelement.com/KNEL/ca) · [monitoring](https://git.knownelement.com/KNEL/monitoring) · [inventory](https://git.knownelement.com/KNEL/inventory) · [facilities](https://git.knownelement.com/KNEL/facilities) · [perf](https://git.knownelement.com/KNEL/perf) · [backups](https://git.knownelement.com/KNEL/backups) · [mailgateway](https://git.knownelement.com/KNEL/mailgateway) · [secrets](https://git.knownelement.com/KNEL/secrets) · [apt-satellite](https://git.knownelement.com/KNEL/apt-satellite) · [security-onion](https://git.knownelement.com/KNEL/security-onion) · [awx](https://git.knownelement.com/KNEL/awx) · [k8s](https://git.knownelement.com/KNEL/k8s) · [compliance](https://git.knownelement.com/KNEL/compliance) (private) Environment map (prod / preprod / sectestbed) + snapshot lifecycle runbook: [Discourse t/331](https://community.turnsys.com/t/331). diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..983b3d6 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,5 @@ +# STATUS.md + +> Operations status lives on Discourse: https://community.turnsys.com/t/297 +> This file is a pointer stub. Active ticket: [#454](https://projects.knownelement.com/issues/454) (100% IaC mandate) +> Questions: Redmine only (NeedsInput status) — questions-v*.md model retired 2026-09-05. diff --git a/docs/kneliac-basis-analysis.md b/docs/kneliac-basis-analysis.md index 5d57846..87570c2 100644 --- a/docs/kneliac-basis-analysis.md +++ b/docs/kneliac-basis-analysis.md @@ -1,3 +1,5 @@ +> Lane index + ops status: https://community.turnsys.com/t/297 — env map: https://community.turnsys.com/t/331 + # KNELIAC Basis Analysis: KNELServerBuild → KNELIAC, and PFVCluster netinfra relocation (Redmine #472) Date: 2026-08-28. Sources inspected read-only: `KNEL/KNELServerBuild`, `KNEL/KNELIAC`, `PFVCluster/netinfra` (+ targeted greps elsewhere in PFVCluster). diff --git a/docs/kneliac-basis-summary.md b/docs/kneliac-basis-summary.md index 01662ca..910ce13 100644 --- a/docs/kneliac-basis-summary.md +++ b/docs/kneliac-basis-summary.md @@ -1,3 +1,5 @@ +> Lane index + ops status: https://community.turnsys.com/t/297 — env map: https://community.turnsys.com/t/331 + # KNELIAC Basis — Executive Summary **Question:** How does legacy `KNELServerBuild` (bash) become `KNELIAC` (Ansible), and how does the PFVCluster netinfra dns/dhcp/ntp code (Redmine #472) move into KNELIAC? diff --git a/questions-v1.md b/questions-v1.md new file mode 100644 index 0000000..2c26734 --- /dev/null +++ b/questions-v1.md @@ -0,0 +1,9 @@ +# Questions — RETIRED MODEL + +Questions are Redmine tickets now (status: NeedsInput — +https://projects.knownelement.com/queries/11). This file stays only because +the repo framework requires it. Do not add questions here. + +Repo ticket: [#454](https://projects.knownelement.com/issues/454). +Lane index on Discourse: https://community.turnsys.com/t/297 +> Retired 2026-09-05. diff --git a/roles/oam/files/librenms/dpkg.sh b/roles/oam/files/librenms/dpkg.sh index e89e2a0..55c52c7 100644 --- a/roles/oam/files/librenms/dpkg.sh +++ b/roles/oam/files/librenms/dpkg.sh @@ -5,18 +5,22 @@ if [ -x /usr/bin/dpkg-query ]; then DATE=$(date +%s) FILE=/var/cache/librenms/agent-local-dpkg - + [ -d /var/cache/librenms ] || mkdir -p /var/cache/librenms - - if [ ! -e $FILE ]; then - dpkg-query -W --showformat='${Status} ${Package} ${Version} ${Architecture} ${Installed-Size}\n'|grep " installed "|cut -d\ -f4- > $FILE + + refresh() { + dpkg-query -W --showformat='${Status} ${Package} ${Version} ${Architecture} ${Installed-Size}\n' \ + | grep " installed " | cut -d\ -f4- > "$FILE" + } + + if [ ! -e "$FILE" ]; then + refresh fi - FILEMTIME=$(stat -c %Y $FILE) - FILEAGE=$(($DATE-$FILEMTIME)) - if [ $FILEAGE -gt 1800 ]; then - dpkg-query -W --showformat='${Status} ${Package} ${Version} ${Architecture} ${Installed-Size}\n'|grep " installed "|cut -d\ -f4- > $FILE + FILEMTIME=$(stat -c %Y "$FILE") + FILEAGE=$((DATE - FILEMTIME)) + if [ "$FILEAGE" -gt 1800 ]; then + refresh fi echo "<<>>" - cat $FILE + cat "$FILE" fi - diff --git a/roles/oam/files/librenms/os-updates.sh b/roles/oam/files/librenms/os-updates.sh index 33e1f9c..1e29fd2 100644 --- a/roles/oam/files/librenms/os-updates.sh +++ b/roles/oam/files/librenms/os-updates.sh @@ -9,62 +9,62 @@ # restart snmpd and activate the app for desired host # #--------------------------------------------------------------# # please make sure you have the path/binaries below # -################################################################ +################################################################ BIN_WC='/usr/bin/wc' BIN_GREP='/bin/grep' -CMD_GREP='-c' -CMD_WC='-l' +CMD_GREP=(-c) +CMD_WC=(-l) BIN_ZYPPER='/usr/bin/zypper' -CMD_ZYPPER='-q lu' +CMD_ZYPPER=(-q lu) BIN_YUM='/usr/bin/yum' -CMD_YUM='-q check-update' +CMD_YUM=(-q check-update) BIN_DNF='/usr/bin/dnf' -CMD_DNF='-q check-update' +CMD_DNF=(-q check-update) BIN_APT='/usr/bin/apt-get' -CMD_APT='-qq -s upgrade' +CMD_APT=(-qq -s upgrade) BIN_PACMAN='/usr/bin/pacman' -CMD_PACMAN='-Sup' +CMD_PACMAN=(-Sup) ################################################################ # Don't change anything unless you know what are you doing # ################################################################ -if [ -f $BIN_ZYPPER ]; then +if [ -f "$BIN_ZYPPER" ]; then # OpenSUSE - UPDATES=`$BIN_ZYPPER $CMD_ZYPPER | $BIN_WC $CMD_WC` - if [ $UPDATES -ge 2 ]; then - echo $(($UPDATES-2)); + UPDATES=$("$BIN_ZYPPER" "${CMD_ZYPPER[@]}" | "$BIN_WC" "${CMD_WC[@]}") + if [ "$UPDATES" -ge 2 ]; then + echo $((UPDATES - 2)); else echo "0"; fi -elif [ -f $BIN_DNF ]; then +elif [ -f "$BIN_DNF" ]; then # Fedora - UPDATES=`$BIN_DNF $CMD_DNF | $BIN_WC $CMD_WC` - if [ $UPDATES -ge 1 ]; then - echo $(($UPDATES-1)); + UPDATES=$("$BIN_DNF" "${CMD_DNF[@]}" | "$BIN_WC" "${CMD_WC[@]}") + if [ "$UPDATES" -ge 1 ]; then + echo $((UPDATES - 1)); else echo "0"; fi -elif [ -f $BIN_PACMAN ]; then +elif [ -f "$BIN_PACMAN" ]; then # Arch - UPDATES=`$BIN_PACMAN $CMD_PACMAN | $BIN_WC $CMD_WC` - if [ $UPDATES -ge 1 ]; then - echo $(($UPDATES-1)); + UPDATES=$("$BIN_PACMAN" "${CMD_PACMAN[@]}" | "$BIN_WC" "${CMD_WC[@]}") + if [ "$UPDATES" -ge 1 ]; then + echo $((UPDATES - 1)); else echo "0"; fi -elif [ -f $BIN_YUM ]; then +elif [ -f "$BIN_YUM" ]; then # CentOS / Redhat - UPDATES=`$BIN_YUM $CMD_YUM | $BIN_WC $CMD_WC` - if [ $UPDATES -ge 1 ]; then - echo $(($UPDATES-1)); + UPDATES=$("$BIN_YUM" "${CMD_YUM[@]}" | "$BIN_WC" "${CMD_WC[@]}") + if [ "$UPDATES" -ge 1 ]; then + echo $((UPDATES - 1)); else echo "0"; fi -elif [ -f $BIN_APT ]; then +elif [ -f "$BIN_APT" ]; then # Debian / Devuan / Ubuntu - UPDATES=`$BIN_APT $CMD_APT | $BIN_GREP $CMD_GREP 'Inst'` - if [ $UPDATES -ge 1 ]; then - echo $UPDATES; + UPDATES=$("$BIN_APT" "${CMD_APT[@]}" | "$BIN_GREP" "${CMD_GREP[@]}" 'Inst') + if [ "$UPDATES" -ge 1 ]; then + echo "$UPDATES"; else echo "0"; fi diff --git a/scripts/check-rules.sh b/scripts/check-rules.sh new file mode 100755 index 0000000..c112383 --- /dev/null +++ b/scripts/check-rules.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# check-rules.sh — project rule audit engine. +# +# Usage: +# bash scripts/check-rules.sh # full audit (verbose, includes slow checks) +# bash scripts/check-rules.sh --fast # fast audit (quiet, skips slow checks) — for pre-commit +# bash scripts/check-rules.sh --quiet # full audit, only prints failures +# +# Exit code: 0 = all rules pass (warnings are non-fatal), 1 = one or more FAILED. +# +# This is a generalized version of the rules engine proven in the +# RCEO-PersonalAssistant project. Add project-specific checks by appending +# `check "" ""` calls below. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/lib/common.sh" +REPO_ROOT="$(repo_root)" +cd "$REPO_ROOT" + +# --- argument parsing --- +RULE_FAST=false +RULE_VERBOSE=true +for arg in "$@"; do + case "$arg" in + --fast) RULE_FAST=true; RULE_VERBOSE=false ;; + --quiet) RULE_VERBOSE=false ;; + *) die "check-rules.sh: unknown argument '$arg'" ;; + esac +done +export RULE_FAST RULE_VERBOSE + +init_counters +$RULE_VERBOSE && echo "=== Project Rule Audit ===" + +TODAY="$(date +%Y-%m-%d)" + +# ---------------------------------------------------------------------------- +# 1. Shellcheck — every .sh/.bash must pass (zero warnings, incl. info-level). +# Runs in Docker so the host stays clean (no native shellcheck required). +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Shell scripts (shellcheck)" +mapfile -d '' SH_FILES < <(find . -path ./.git -prune -o -path ./.tmp -prune -o -path ./.crush -prune -o -path ./vendor -prune -o -path ./oam/librenms-agent/agent -prune -o -path ./archive -prune -o -path ./node_modules -prune -o \( -name '*.sh' -o -name '*.bash' \) -print0 2>/dev/null) +if [ "${#SH_FILES[@]}" -gt 0 ]; then + if have shellcheck; then + if shellcheck "${SH_FILES[@]}" >/dev/null 2>&1; then + check "All shell scripts pass shellcheck (host)" "pass" + else + check "shellcheck reports violations — run: shellcheck " "fail" + fi + elif have docker; then + MNT_FILES=() + for f in "${SH_FILES[@]}"; do MNT_FILES+=("/mnt/${f#./}"); done + if docker run --rm -v "$REPO_ROOT:/mnt" koalaman/shellcheck:stable "${MNT_FILES[@]}" >/dev/null 2>&1; then + check "All shell scripts pass shellcheck (docker)" "pass" + else + check "shellcheck (docker) reports violations" "fail" + fi + else + check "No shellcheck or docker available to lint scripts" "warn" + fi +else + check "No shell scripts to lint" "pass" +fi + +# ---------------------------------------------------------------------------- +# 2. Docker image pinning — no ':latest' tags in compose or Dockerfiles. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Docker image pinning" +if grep -rqE '(image:|FROM).*:latest' --include='docker-compose*.y*ml' --include='Dockerfile*' . 2>/dev/null; then + check "No ':latest' image tags (pin everything)" "fail" +else + check "No ':latest' image tags" "pass" +fi + +# ---------------------------------------------------------------------------- +# 2b. Container naming — every service in a docker-compose file MUST set an +# explicit container_name (never rely on Docker's default _). +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Container naming" +COMPOSE_FILES="$(find . -path ./.git -prune -o \( -name 'docker-compose*.yml' -o -name 'docker-compose*.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \) -print 2>/dev/null || true)" +if [ -n "$COMPOSE_FILES" ]; then + BAD=0 + while IFS= read -r cf; do + [ -n "$cf" ] || continue + # Count top-level service keys (2-space indent under services:) and + # compare against the number of container_name: declarations. + svc_count=$(awk '/^services:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]{2}[[:alnum:]_-]+:[[:space:]]*$/{c++} END{print c+0}' "$cf") + cn_count=$(grep -cE '^[[:space:]]*container_name:' "$cf" 2>/dev/null || echo 0) + if [ "${svc_count:-0}" -gt 0 ] && [ "$cn_count" -lt "$svc_count" ]; then + BAD=$((BAD + 1)) + fi + done < /dev/null; then + latest_q="$(find . -maxdepth 1 -name 'questions-v*.md' -printf '%f\n' | sort -V | tail -1)" + check "questions-v*.md exists ($latest_q)" "pass" +else + check "questions-v*.md MISSING" "fail" +fi + +# ---------------------------------------------------------------------------- +# 4. Doc freshness — STATUS.md touched today. +# Warning (not failure): staleness is a signal, not a break. +# Redmine is the system of record for work; Discourse for docs. STATUS.md is +# a scratchpad only — see BASELINE-PROMPT.md §3, §8. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Doc freshness" +if [ -f STATUS.md ]; then + STATUS_DATE="$(grep -oE 'Last updated: [0-9]{4}-[0-9]{2}-[0-9]{2}' STATUS.md | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' || echo unknown)" + if [ "$STATUS_DATE" = "$TODAY" ]; then + check "STATUS.md updated today ($STATUS_DATE)" "pass" + else + check "STATUS.md is stale (last: $STATUS_DATE, today: $TODAY) — update it" "warn" + fi +else + check "STATUS.md MISSING" "fail" +fi + +# ---------------------------------------------------------------------------- +# 4b. Discourse pointer-header policy (MANDATORY). +# Discourse is the system of record for documentation. In-repo .md files are +# stubs that point to a Discourse topic URL. Operational files exempt. +# Override exemptions via PROJECT_DOC_EXEMPT (space-separated globs of +# basenames) and the Discourse host via PROJECT_DISCOURSE_HOST. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Discourse pointer-header" +DISCOURSE_HOST="${PROJECT_DISCOURSE_HOST:-community.turnsys.com}" +DOC_EXEMPT="${PROJECT_DOC_EXEMPT:-AGENTS.md STATUS.md WORKING.md README.md ADOPTING.md LICENSE .env.example questions-v*.md BASELINE-PROMPT.md PATTERNS.md}" +POINTER_MISSING=0 +while IFS= read -r -d '' f; do + base="$(basename "$f")" + exempt=false + for pat in $DOC_EXEMPT; do + # shellcheck disable=SC2254 + case "$base" in $pat) exempt=true; break ;; esac + done + [ "$exempt" = true ] && continue + if ! grep -qF "$DISCOURSE_HOST" "$f" 2>/dev/null; then + if [ "$POINTER_MISSING" -eq 0 ]; then + $RULE_VERBOSE && printf ' %s\n' "Missing $DISCOURSE_HOST URL in:" + fi + POINTER_MISSING=$((POINTER_MISSING + 1)) + $RULE_VERBOSE && printf ' %s\n' "$f" + fi +done < <(find . -path ./.git -prune -o -path ./.crush -prune -o -path ./.tmp -prune -o -path ./vendor -prune -o -path ./archive -prune -o -name '*.md' -print0 2>/dev/null) +if [ "$POINTER_MISSING" -eq 0 ]; then + check "All non-exempt .md cite Discourse ($DISCOURSE_HOST)" "pass" +else + check "$POINTER_MISSING .md file(s) missing Discourse pointer (see BASELINE-PROMPT.md §3)" "fail" +fi + +# ---------------------------------------------------------------------------- +# 5. Git state — uncommitted changes are a warning (the pre-push hook hardens +# this where it matters). +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Git state" +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + if git diff --quiet && git diff --cached --quiet; then + check "Working tree clean" "pass" + else + check "Uncommitted changes present" "warn" + fi +else + check "Not a git repo (git checks skipped)" "pass" +fi + +# ---------------------------------------------------------------------------- +# 6. Hooks installed — self-check that git hooks were set up. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Git hooks" +if [ -f .git/hooks/pre-commit ]; then + check "pre-commit hook installed" "pass" +else + check "pre-commit NOT installed (run: bash scripts/setup-hooks.sh)" "warn" +fi +if [ -f .git/hooks/pre-push ]; then + check "pre-push hook installed" "pass" +else + check "pre-push NOT installed (run: bash scripts/setup-hooks.sh)" "warn" +fi + +# ---------------------------------------------------------------------------- +# 7. WORKING.md completion — no unchecked tasks may remain at commit time. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Task completion" +if [ -f WORKING.md ]; then + UNCHECKED="$(grep -cF -- '- [ ]' WORKING.md || true)" + if [ "$UNCHECKED" -eq 0 ]; then + check "WORKING.md has no unchecked tasks" "pass" + else + check "WORKING.md has ${UNCHECKED} unchecked task(s) — finish them before committing" "fail" + fi +else + check "WORKING.md absent (no active task tracker)" "pass" +fi + +# ---------------------------------------------------------------------------- +# 8. CNW markers — empty `CNW:` markers flag unresolved questions for the human. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Unresolved questions" +EMPTY_CNW="$(grep -rn 'CNW:$' . --include='*.md' 2>/dev/null | head -20 || true)" +if [ -z "$EMPTY_CNW" ]; then + check "No empty CNW: markers (unresolved questions)" "pass" +else + CNW_COUNT="$(printf '%s\n' "$EMPTY_CNW" | grep -c . || true)" + check "${CNW_COUNT} unresolved CNW: marker(s) — needs user input" "warn" +fi + +# ---------------------------------------------------------------------------- +# 9. Hygiene — merge-conflict markers and trailing whitespace must never land. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "File hygiene" +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + # archive/ excluded: legacy files are preserved verbatim and may contain + # decorative `====` banners that false-positive as conflict markers. + CONFLICT="$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -v '^archive/' | xargs -r grep -lE '^(<{7}( \S+)?|=======|>{7}( \S+)?)$' 2>/dev/null || true)" + if [ -z "$CONFLICT" ]; then check "No merge-conflict markers staged" "pass"; else check "Merge-conflict markers staged: $CONFLICT" "fail"; fi +fi + +# ---------------------------------------------------------------------------- +# 10. (slow, skipped in --fast) Project test suite via scripts/test.sh. +# ---------------------------------------------------------------------------- +if [ "$RULE_FAST" = false ] && [ -x scripts/test.sh ]; then + $RULE_VERBOSE && log_step "Test suite (scripts/test.sh)" + if bash scripts/test.sh >/dev/null 2>&1; then + check "scripts/test.sh passes" "pass" + else + check "scripts/test.sh FAILS" "fail" + fi +fi + +# ---------------------------------------------------------------------------- +# 11. Access-channel policy — qemu guest-agent must NEVER be used as an +# execution or key-delivery channel. SSH (sshd) is the ONLY approved +# remote access path; all commands must be logged through standard +# auth/audit infrastructure. ITAR/CMMC environment — non-negotiable. +# Allowed: installing/checking qemu-guest-agent for Proxmox state +# visibility. Forbidden: `qm guest exec` + any `vm-guest` wrapper. +# Scans CODE only (not .md) so docs may describe the ban. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Access-channel policy (no guest-agent exec)" +GA_HITS="$(grep -rnE 'qm guest exec|vm-guest|_vm_guest' \ + --include='*.sh' --include='*.bash' --include='*.py' \ + . 2>/dev/null | grep -vE 'scripts/check-rules\.sh|/archive/' || true)" +if [ -z "$GA_HITS" ]; then + check "No guest-agent exec / vm-guest access patterns in code" "pass" +else + GA_COUNT="$(printf '%s\n' "$GA_HITS" | grep -c . || true)" + $RULE_VERBOSE && printf '%s\n' "$GA_HITS" | sed 's/^/ /' + check "${GA_COUNT} guest-agent exec / vm-guest reference(s) — SSH-only access policy (AGENTS.md)" "fail" +fi + +print_summary_and_exit diff --git a/scripts/docker-run.sh b/scripts/docker-run.sh new file mode 100755 index 0000000..2e0ce38 --- /dev/null +++ b/scripts/docker-run.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# docker-run.sh — canonical ephemeral-container wrapper. +# +# Keeps the host clean: every build/test/generation runs inside a pinned image. +# Ensures output files are owned by the invoking user (not root). +# +# Usage: +# docker-run.sh [command...] +# Runs in with the repo mounted at /data, cwd /data. +# With no command, drops into the image's default entrypoint. +# docker-run.sh --shell +# Interactive shell inside the container (for debugging). +# +# Examples: +# docker-run.sh python:3.12-slim python3 -m pytest +# docker-run.sh pandoc/extra report.md -o report.pdf +# docker-run.sh --shell node:20 +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/lib/common.sh" + +SHELL_MODE=false +case "${1:-}" in + --shell) SHELL_MODE=true; shift ;; + -h|--help) + sed -n '2,18p' "$0"; exit 0 ;; +esac + +[ "$#" -ge 1 ] || { sed -n '2,18p' "$0"; exit 1; } + +if [ "$SHELL_MODE" = true ]; then + # ${SHELL:-sh} must expand inside the container, not in this outer shell. + # shellcheck disable=SC2016 + docker_run "$1" sh -c 'exec "${SHELL:-sh}"' +else + docker_run "$@" +fi diff --git a/scripts/garden.sh b/scripts/garden.sh new file mode 100755 index 0000000..67b0592 --- /dev/null +++ b/scripts/garden.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# garden.sh — the gardening loop. +# +# Reports doc sprawl and files that violate the "Discourse is the system of +# record for documentation; gitea .md files are stubs" policy. Run via +# `bash scripts/garden.sh`. Findings are WARNINGS (advisory); fix them at a natural break. +# +# What it checks: +# 1. Markdown sprawl: count of .md files per directory (top-10 by count). +# 2. Oversized .md files (default >300 lines) that don't cite a Discourse URL +# — candidates to migrate to Discourse, leaving a stub. +# 3. .md files with no Discourse link at all (informational; exempt: the +# operational files in EXEMPT_FILES). +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/lib/common.sh" +REPO_ROOT="$(repo_root)" +cd "$REPO_ROOT" + +SIZE_LIMIT="${GARDEN_MD_LINE_LIMIT:-300}" +# Operational files that legitimately live next to code, not in Discourse. +EXEMPT_FILES="${GARDEN_EXEMPT:-AGENTS.md STATUS.md WORKING.md questions-v.*.md PATTERNS.md BASELINE-PROMPT.md README.md}" + +log_step "Gardening report for $REPO_ROOT" + +# --- 1. sprawl by directory ------------------------------------------------- +log_info "Markdown file count by directory (top 10):" +find . -path ./.git -prune -o -name '*.md' -print 2>/dev/null \ + | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -10 | sed 's/^/ /' + +# --- 2. oversized .md without a Discourse link ------------------------------ +log_info "Oversized .md (>${SIZE_LIMIT} lines) lacking a Discourse URL — migrate candidates:" +OVERSIZED=0 +while IFS= read -r -d '' f; do + # skip exempt files (glob match against basename and relative path) + exempt=false + base=$(basename "$f") + rel=${f#./} + for pat in $EXEMPT_FILES; do + # shellcheck disable=SC2254 # glob match is intentional + case "$base" in $pat) exempt=true; break ;; esac + # shellcheck disable=SC2254 + case "$rel" in $pat) exempt=true; break ;; esac + done + [ "$exempt" = true ] && continue + lines=$(wc -l < "$f" 2>/dev/null || echo 0) + if [ "$lines" -gt "$SIZE_LIMIT" ]; then + if ! grep -qiE 'community\.turnsys\.com|discourse' "$f" 2>/dev/null; then + printf ' %-60s %s lines\n' "$f" "$lines" + OVERSIZED=$((OVERSIZED + 1)) + fi + fi +done < <(find . -path ./.git -prune -o -name '*.md' -print0 2>/dev/null) +[ "$OVERSIZED" -eq 0 ] && echo " (none)" + +# --- 3. summary ------------------------------------------------------------- +log_step "Gardening summary" +echo " Oversized non-Discourse .md files: $OVERSIZED" +if [ "$OVERSIZED" -eq 0 ]; then + log_ok "no migration candidates" +else + log_warn "$OVERSIZED file(s) to migrate to Discourse" +fi diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..a3dd5e4 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# lib/common.sh — shared helpers for shell scripts and hooks in this repo. +# +# Source it from any script: +# #!/usr/bin/env bash +# set -euo pipefail +# HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# # shellcheck source=lib/common.sh +# source "$HERE/lib/common.sh" # or the appropriate relative path +# +# This library exists to drive a known cross-project inconsistency to zero: +# every repo used to re-paste the ANSI color block, redefine log_* helpers, +# pick one of three incompatible shebangs, and roll its own docker wrapper. +# Import this once instead. + +# Do NOT set -euo pipefail here unconditionally — some callers (git hooks) +# source this file and rely on controlling their own shell options. We only +# guarantee the functions below are defined. + +############################################################################### +# Config — override via environment before sourcing if needed +############################################################################### +: "${TEMPLATE_ROOT:=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +export TEMPLATE_ROOT + +############################################################################### +# ANSI colors (defined once, used everywhere) +############################################################################### +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' + BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BLUE=''; BOLD=''; NC='' +fi +export RED GREEN YELLOW BLUE BOLD NC + +############################################################################### +# Logging +############################################################################### +log_info() { printf "${BLUE}›${NC} %s\n" "$*"; } +log_ok() { printf "${GREEN}✓${NC} %s\n" "$*"; } +log_warn() { printf "${YELLOW}⚠${NC} %s\n" "$*" >&2; } +log_error() { printf "${RED}✗${NC} %s\n" "$*" >&2; } +log_step() { printf "\n${BOLD}== %s ==${NC}\n" "$*"; } + +die() { log_error "$*"; exit 1; } + +############################################################################### +# Predicates +############################################################################### +# have — return 0 if is on PATH +have() { command -v "$1" >/dev/null 2>&1; } + +############################################################################### +# Path helpers +############################################################################### +repo_root() { + # Prefer git's notion of the repo root, fall back to $TEMPLATE_ROOT, then pwd. + if git rev-parse --show-toplevel >/dev/null 2>&1; then + git rev-parse --show-toplevel + else + printf '%s\n' "${TEMPLATE_ROOT:-$(pwd)}" + fi +} + +############################################################################### +# Privilege helpers +############################################################################### +# as_root — run the remaining args as root via sudo, or directly if already root. +as_root() { + if [ "$(id -u)" -eq 0 ]; then "$@"; else sudo "$@"; fi +} + +############################################################################### +# Docker wrapper +############################################################################### +# docker_run +# Ephemeral container, host-uid ownership, repo mounted at /data, cwd /data. +# Drives the "host stays clean; everything runs in containers" policy and +# ensures output files are owned by the invoking user, not root. +docker_run() { + [ "$#" -ge 1 ] || die "docker_run: image required" + local image="$1"; shift + have docker || die "docker not found on PATH" + local root + root="$(repo_root)" + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp \ + -v "$root:/data" \ + -w /data \ + "$image" "$@" +} + +############################################################################### +# Rule-audit accumulator (used by scripts/check-rules.sh) +# Globals read/written: RULE_PASS RULE_WARN RULE_FAIL +############################################################################### +init_counters() { RULE_PASS=0; RULE_WARN=0; RULE_FAIL=0; } + +# check +check() { + local desc="$1" result="$2" + case "$result" in + pass) + RULE_PASS=$((RULE_PASS + 1)) + if [ "${RULE_VERBOSE:-true}" = true ]; then printf " ${GREEN}PASS${NC} %s\n" "$desc"; fi + ;; + warn) + RULE_WARN=$((RULE_WARN + 1)) + if [ "${RULE_VERBOSE:-true}" = true ]; then printf " ${YELLOW}WARN${NC} %s\n" "$desc"; fi + ;; + fail) + RULE_FAIL=$((RULE_FAIL + 1)) + printf " ${RED}FAIL${NC} %s\n" "$desc" + ;; + *) + die "check(): invalid result '$result' (use pass|warn|fail)" + ;; + esac +} + +# print_summary_and_exit +print_summary_and_exit() { + if [ "${RULE_VERBOSE:-true}" = true ]; then + printf "\n=== Summary ===\n PASS: %s\n WARN: %s\n FAIL: %s\n\n" \ + "$RULE_PASS" "$RULE_WARN" "$RULE_FAIL" + fi + if [ "$RULE_FAIL" -gt 0 ]; then + if [ "${RULE_VERBOSE:-true}" = true ]; then + printf "AUDIT FAILED — %s rule(s) violated.\n" "$RULE_FAIL" + fi + exit 1 + fi + if [ "${RULE_VERBOSE:-true}" = true ]; then printf "AUDIT PASSED.\n"; fi + exit 0 +} diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 0000000..1833906 --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# pre-commit — fast rule audit (< 1s typical). +# Hot-path bypass: commits that ONLY touch STATUS.md / WORKING.md skip the +# audit so frequent status/task commits stay frictionless. +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +CHANGED="$(git diff --cached --name-only)" +HOT_PATHS="$(printf '%s\n' "$CHANGED" | grep -vE '^(STATUS.md|WORKING.md)$' || true)" + +if [ -z "$HOT_PATHS" ]; then + echo "hot-path files only (STATUS/WORKING) — skipping rule audit" + exit 0 +fi + +if ! bash scripts/check-rules.sh --fast; then + echo "" + echo "pre-commit audit FAILED. Fix the violations above before committing." + echo "Full audit: bash scripts/check-rules.sh" + echo "Bypass: git commit --no-verify (emergencies only)" + exit 1 +fi +exit 0 diff --git a/scripts/pre-push b/scripts/pre-push new file mode 100755 index 0000000..7c5bf7d --- /dev/null +++ b/scripts/pre-push @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# pre-push — full rule audit + clean-working-tree gate before pushing. +# Installed via: bash scripts/setup-hooks.sh +# +# Combines two proven policies observed across projects: +# - KNEL-AIMiddleware: block push if the working tree is dirty. +# - RCEO-PersonalAssistant: block push if the full test suite fails. +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +echo "pre-push: running full rule audit..." + +# Full audit (non-fast): runs the slow test suite via `make test` if present. +if ! bash scripts/check-rules.sh --quiet; then + echo "" + echo "pre-push audit FAILED. Push blocked." + echo "Re-run with output: bash scripts/check-rules.sh" + echo "Bypass: git push --no-verify (emergencies only)" + exit 1 +fi + +echo "pre-push: all rules and tests passed." +exit 0 diff --git a/scripts/setup-hooks.sh b/scripts/setup-hooks.sh new file mode 100755 index 0000000..57219ac --- /dev/null +++ b/scripts/setup-hooks.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# setup-hooks.sh — install this repo's git hooks. +# +# Mechanism: copy scripts/pre-commit and scripts/pre-push into .git/hooks/ and +# make them executable. This is the most portable pattern (works on any clone, +# no `git config core.hooksPath` mutation, survives config resets, idempotent). +# +# Run once after cloning: bash scripts/setup-hooks.sh +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/lib/common.sh" +REPO_ROOT="$(repo_root)" +cd "$REPO_ROOT" + +[ -d .git ] || die "no .git directory here — run this from a git checkout" + +HOOKS_DIR=".git/hooks" +HOOK_NAMES="pre-commit pre-push" + +log_step "Installing git hooks" +for name in $HOOK_NAMES; do + src="scripts/$name" + dst="$HOOKS_DIR/$name" + [ -f "$src" ] || die "source hook not found: $src" + cp "$src" "$dst" + chmod +x "$dst" + log_ok "installed $dst" +done + +cat <&2 + exit 0 +fi + +# --- Filter out non-bash scripts (e.g. PHP with a .sh extension) -------------- +BASH_FILES=() +for f in "${FILES[@]}"; do + shebang=$(head -c 64 "$f" 2>/dev/null | head -n1) + case "$shebang" in + \#!/usr/bin/php*|\#!/usr/bin/env\ php*) ;; # PHP, skip + \#!*) BASH_FILES+=("$f") ;; # any other shebang → check + *) BASH_FILES+=("$f") ;; # no shebang → check anyway + esac +done + +if [ "${#BASH_FILES[@]}" -eq 0 ]; then + echo "shellcheck.sh: no bash scripts among targets." >&2 + exit 0 +fi + +# --- Run shellcheck in Docker (mount repo root, pass root-relative paths) ----- +# shellcheck disable=SC2012 # basename loop is intentional +REL=() +for f in "${BASH_FILES[@]}"; do REL+=("${f#"$ROOT"/}"); done + +# Disable checks that are intentional conventions of this codebase (not bugs): +# SC1090/SC1091 — cannot follow dynamically-computed `source` paths (KNEL framework) +# SC2029 — ssh orchestration deliberately expands the command client-side +DISABLES=( + -e SC1090 + -e SC1091 + -e SC2029 +) + +echo "Checking ${#BASH_FILES[@]} script(s) with $IMAGE:" +printf ' %s\n' "${REL[@]}" + +docker run --rm \ + -v "$ROOT:/mnt:ro" \ + -w /mnt \ + "$IMAGE" -x "${DISABLES[@]}" "${REL[@]}" +rc=$? + +if [ "$rc" -eq 0 ]; then + echo "shellcheck: PASS (${#BASH_FILES[@]} scripts clean)" +else + echo "shellcheck: FAIL (fix the findings above or add targeted disable directives)" >&2 +fi +exit "$rc"