diff --git a/bin/baudbot b/bin/baudbot index 73b88a1..525ae7a 100755 --- a/bin/baudbot +++ b/bin/baudbot @@ -327,74 +327,122 @@ else cmd_attach() { echo "❌ Missing CLI runtime helper. Run: sudo baudbot deploy"; exit 1; } fi -case "${1:-}" in +require_systemd_runtime() { + local message="$1" + if ! has_systemd; then + echo "$message" + exit 1 + fi +} + +cmd_systemctl_start() { + require_systemd_runtime "systemd not available. Use: baudbot start --direct" + exec systemctl start baudbot "$@" +} + +cmd_systemctl_stop() { + require_systemd_runtime "systemd not available. Kill the agent process manually." + exec systemctl stop baudbot "$@" +} + +cmd_systemctl_restart() { + require_systemd_runtime "systemd not available." + exec systemctl restart baudbot "$@" +} + +declare -A CMD_KIND=() +declare -A CMD_TARGET=() +declare -A CMD_REQUIRE_ROOT=() +declare -A CMD_REQUIRE_SYSTEMD=() +declare -A CMD_RUNTIME_ERROR=() + +register_command() { + local name="$1" + local kind="$2" + local target="$3" + local require_root_flag="$4" + local require_systemd_flag="$5" + local runtime_error="$6" + + CMD_KIND["$name"]="$kind" + CMD_TARGET["$name"]="$target" + CMD_REQUIRE_ROOT["$name"]="$require_root_flag" + CMD_REQUIRE_SYSTEMD["$name"]="$require_systemd_flag" + CMD_RUNTIME_ERROR["$name"]="$runtime_error" +} + +dispatch_registered_command() { + local command_name="$1" + shift || true + + local kind="${CMD_KIND[$command_name]:-}" + [ -n "$kind" ] || return 1 + + local target="${CMD_TARGET[$command_name]}" + local require_root_flag="${CMD_REQUIRE_ROOT[$command_name]}" + local require_systemd_flag="${CMD_REQUIRE_SYSTEMD[$command_name]}" + local runtime_error="${CMD_RUNTIME_ERROR[$command_name]}" + + if [ "$require_root_flag" = "1" ]; then + require_root "$command_name" + fi + + if [ "$require_systemd_flag" = "1" ]; then + require_systemd_runtime "$runtime_error" + fi + + case "$kind" in + exec) + exec "$target" "$@" + ;; + function) + "$target" "$@" + ;; + *) + echo "❌ invalid command registration: $command_name" + exit 1 + ;; + esac +} + +register_command "start" "function" "cmd_systemctl_start" "1" "0" "" +register_command "stop" "function" "cmd_systemctl_stop" "1" "0" "" +register_command "restart" "function" "cmd_systemctl_restart" "1" "0" "" +register_command "status" "function" "cmd_status" "0" "0" "" +register_command "logs" "function" "cmd_logs" "0" "0" "" +register_command "sessions" "function" "cmd_sessions" "0" "0" "" +register_command "attach" "function" "cmd_attach" "0" "0" "" +register_command "config" "exec" "$BAUDBOT_ROOT/bin/config.sh" "0" "0" "" +register_command "env" "exec" "$BAUDBOT_ROOT/bin/env.sh" "0" "0" "" +register_command "deploy" "exec" "$BAUDBOT_ROOT/bin/deploy.sh" "1" "0" "" +register_command "audit" "exec" "$BAUDBOT_ROOT/bin/security-audit.sh" "0" "0" "" +register_command "test" "exec" "$BAUDBOT_ROOT/bin/test.sh" "0" "0" "" +register_command "update" "exec" "$BAUDBOT_ROOT/bin/update-release.sh" "1" "0" "" +register_command "rollback" "exec" "$BAUDBOT_ROOT/bin/rollback-release.sh" "1" "0" "" +register_command "uninstall" "exec" "$BAUDBOT_ROOT/bin/uninstall.sh" "1" "0" "" +register_command "doctor" "exec" "$BAUDBOT_ROOT/bin/doctor.sh" "0" "0" "" + +COMMAND_NAME="${1:-}" +if [ -n "$COMMAND_NAME" ]; then + shift +fi + +case "$COMMAND_NAME" in install) - shift bootstrap_install "$@" ;; start) - shift if [ "${1:-}" = "--direct" ]; then # Foreground mode: run start.sh directly (for dev/CI/debugging) shift require_root "start --direct" exec sudo -u baudbot_agent "$BAUDBOT_ROOT/start.sh" "$@" - else - require_root "start" - if has_systemd; then - exec systemctl start baudbot "$@" - else - echo "systemd not available. Use: baudbot start --direct" - exit 1 - fi - fi - ;; - - stop) - shift - require_root "stop" - if has_systemd; then - exec systemctl stop baudbot "$@" - else - echo "systemd not available. Kill the agent process manually." - exit 1 - fi - ;; - - restart) - shift - require_root "restart" - if has_systemd; then - exec systemctl restart baudbot "$@" - else - echo "systemd not available." - exit 1 fi - ;; - - status) - shift - cmd_status "$@" - ;; - - logs) - shift - cmd_logs "$@" - ;; - - sessions) - shift - cmd_sessions "$@" - ;; - - attach) - shift - cmd_attach "$@" + dispatch_registered_command "start" "$@" ;; setup) - shift if [ "${1:-}" = "--slack-broker" ]; then shift require_root "broker register" @@ -411,18 +459,7 @@ case "${1:-}" in exec "$BAUDBOT_ROOT/setup.sh" "$@" ;; - config) - shift - exec "$BAUDBOT_ROOT/bin/config.sh" "$@" - ;; - - env) - shift - exec "$BAUDBOT_ROOT/bin/env.sh" "$@" - ;; - broker) - shift case "${1:-}" in register) shift @@ -446,51 +483,7 @@ case "${1:-}" in esac ;; - deploy) - shift - require_root "deploy" - exec "$BAUDBOT_ROOT/bin/deploy.sh" "$@" - ;; - - audit) - shift - exec "$BAUDBOT_ROOT/bin/security-audit.sh" "$@" - ;; - - test) - shift - exec "$BAUDBOT_ROOT/bin/test.sh" "$@" - ;; - - update) - shift - require_root "update" - exec "$BAUDBOT_ROOT/bin/update-release.sh" "$@" - ;; - - rollback) - shift - require_root "rollback" - exec "$BAUDBOT_ROOT/bin/rollback-release.sh" "$@" - ;; - - uninstall) - shift - require_root "uninstall" - exec "$BAUDBOT_ROOT/bin/uninstall.sh" "$@" - ;; - - doctor) - shift - exec "$BAUDBOT_ROOT/bin/doctor.sh" "$@" - ;; - - version) - shift - echo "baudbot $(version_display)" - ;; - - --version|-v) + version|--version|-v) echo "baudbot $(version_display)" ;; @@ -499,9 +492,12 @@ case "${1:-}" in ;; *) - echo "Unknown command: $1" - echo "" - usage - exit 1 + if [ -z "${CMD_KIND[$COMMAND_NAME]:-}" ]; then + echo "Unknown command: $COMMAND_NAME" + echo "" + usage + exit 1 + fi + dispatch_registered_command "$COMMAND_NAME" "$@" ;; esac diff --git a/bin/deploy.sh b/bin/deploy.sh index f2d2a0c..954ad81 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -77,19 +77,47 @@ STAGE_DIR=$(mktemp -d /tmp/baudbot-deploy.XXXXXX) chmod 755 "$STAGE_DIR" trap 'rm -rf "$STAGE_DIR"' EXIT +# shellcheck disable=SC2034 # consumed via nameref in bb_manifest_for_each +STAGE_MANIFEST=( + "dir|pi/extensions|extensions|required|always" + "dir|pi/skills|skills|required|always" + "file|start.sh|start.sh|required|always" + "file|bin/harden-permissions.sh|bin/harden-permissions.sh|optional|always" + "file|bin/redact-logs.sh|bin/redact-logs.sh|optional|always" + "file|bin/prune-session-logs.sh|bin/prune-session-logs.sh|optional|always" + "file|bin/verify-manifest.sh|bin/verify-manifest.sh|optional|always" + "file|bin/lib/runtime-node.sh|bin/lib/runtime-node.sh|optional|always" + "file|bin/lib/bridge-restart-policy.sh|bin/lib/bridge-restart-policy.sh|optional|always" + "file|pi/settings.json|settings.json|optional|always" + "file|.env.schema|.env.schema|optional|always" +) + +stage_manifest_entry() { + local entry="$1" + local item_type src_rel stage_rel required gate + IFS='|' read -r item_type src_rel stage_rel required gate <<<"$entry" + + bb_feature_gate_enabled "$gate" "$EXPERIMENTAL_MODE" || return 0 + + local src_path="$BAUDBOT_SRC/$src_rel" + local stage_path="$STAGE_DIR/$stage_rel" + + if [ ! -e "$src_path" ]; then + [ "$required" = "required" ] && bb_die "missing required deploy source: $src_rel" + return 0 + fi + + mkdir -p "$(dirname "$stage_path")" + + if [ "$item_type" = "dir" ]; then + cp -r --no-preserve=ownership "$src_path" "$stage_path" + else + cp --no-preserve=ownership "$src_path" "$stage_path" + fi +} + if [ "$DRY_RUN" -eq 0 ]; then - cp -r --no-preserve=ownership "$BAUDBOT_SRC/pi/extensions" "$STAGE_DIR/extensions" - cp -r --no-preserve=ownership "$BAUDBOT_SRC/pi/skills" "$STAGE_DIR/skills" - cp --no-preserve=ownership "$BAUDBOT_SRC/start.sh" "$STAGE_DIR/start.sh" - mkdir -p "$STAGE_DIR/bin" - mkdir -p "$STAGE_DIR/bin/lib" - for script in harden-permissions.sh redact-logs.sh prune-session-logs.sh verify-manifest.sh; do - [ -f "$BAUDBOT_SRC/bin/$script" ] && cp --no-preserve=ownership "$BAUDBOT_SRC/bin/$script" "$STAGE_DIR/bin/$script" - done - [ -f "$BAUDBOT_SRC/bin/lib/runtime-node.sh" ] && cp --no-preserve=ownership "$BAUDBOT_SRC/bin/lib/runtime-node.sh" "$STAGE_DIR/bin/lib/runtime-node.sh" - [ -f "$BAUDBOT_SRC/bin/lib/bridge-restart-policy.sh" ] && cp --no-preserve=ownership "$BAUDBOT_SRC/bin/lib/bridge-restart-policy.sh" "$STAGE_DIR/bin/lib/bridge-restart-policy.sh" - [ -f "$BAUDBOT_SRC/pi/settings.json" ] && cp --no-preserve=ownership "$BAUDBOT_SRC/pi/settings.json" "$STAGE_DIR/settings.json" - [ -f "$BAUDBOT_SRC/.env.schema" ] && cp --no-preserve=ownership "$BAUDBOT_SRC/.env.schema" "$STAGE_DIR/.env.schema" + bb_manifest_for_each STAGE_MANIFEST stage_manifest_entry chmod -R a+rX "$STAGE_DIR" fi @@ -202,27 +230,70 @@ else log "would copy: skills/" fi -# ── Heartbeat ──────────────────────────────────────────────────────────────── +# ── Runtime assets (manifest-driven) ──────────────────────────────────────── echo "Deploying heartbeat checklist..." +echo "Deploying memory seeds..." +echo "Deploying runtime scripts..." +echo "Deploying settings..." +echo "Deploying env schema..." -HEARTBEAT_SRC="$STAGE_DIR/skills/control-agent/HEARTBEAT.md" -HEARTBEAT_DEST="$BAUDBOT_HOME/.pi/agent/HEARTBEAT.md" +# shellcheck disable=SC2034 # consumed via nameref in bb_manifest_for_each +RUNTIME_ASSET_MANIFEST=( + "file|skills/control-agent/HEARTBEAT.md|.pi/agent/HEARTBEAT.md|644|agent|0|always|optional|HEARTBEAT.md" + "file|bin/harden-permissions.sh|runtime/bin/harden-permissions.sh|u+x|agent|0|always|optional|bin/harden-permissions.sh" + "file|bin/redact-logs.sh|runtime/bin/redact-logs.sh|u+x|agent|0|always|optional|bin/redact-logs.sh" + "file|bin/prune-session-logs.sh|runtime/bin/prune-session-logs.sh|u+x|agent|0|always|optional|bin/prune-session-logs.sh" + "file|bin/verify-manifest.sh|runtime/bin/verify-manifest.sh|u+x|agent|0|always|optional|bin/verify-manifest.sh" + "file|bin/lib/runtime-node.sh|runtime/bin/lib/runtime-node.sh|u+r|agent|0|always|optional|bin/lib/runtime-node.sh" + "file|bin/lib/bridge-restart-policy.sh|runtime/bin/lib/bridge-restart-policy.sh|u+r|agent|0|always|optional|bin/lib/bridge-restart-policy.sh" + "file|start.sh|runtime/start.sh|u+x|agent|0|always|required|start.sh" + "file|settings.json|.pi/agent/settings.json|600|agent|0|always|optional|settings.json" + "file|.env.schema|.config/.env.schema|644|agent|0|always|optional|.env.schema → ~/.config/.env.schema" +) + +deploy_runtime_asset_entry() { + local entry="$1" + local src_rel dest_rel mode owner read_only gate required log_label + IFS='|' read -r _ src_rel dest_rel mode owner read_only gate required log_label <<<"$entry" + + bb_feature_gate_enabled "$gate" "$EXPERIMENTAL_MODE" || return 0 + + local src_path="$STAGE_DIR/$src_rel" + local dest_path="$BAUDBOT_HOME/$dest_rel" + + if [ ! -e "$src_path" ]; then + [ "$required" = "required" ] && bb_die "missing required staged file: $src_rel" + return 0 + fi -if [ "$DRY_RUN" -eq 0 ]; then - # HEARTBEAT.md — always overwrite (admin-managed checklist) - if [ -f "$HEARTBEAT_SRC" ]; then - as_agent cp "$HEARTBEAT_SRC" "$HEARTBEAT_DEST" - as_agent chmod 644 "$HEARTBEAT_DEST" - log "✓ HEARTBEAT.md" + if [ "$DRY_RUN" -eq 1 ]; then + if [ "$read_only" = "1" ]; then + log "would copy: $log_label (read-only)" + else + log "would copy: $log_label" + fi + return 0 fi -else - log "would copy: HEARTBEAT.md" -fi -# ── Memory Seeds ───────────────────────────────────────────────────────────── + if [ "$owner" = "agent" ]; then + as_agent mkdir -p "$(dirname "$dest_path")" + as_agent cp "$src_path" "$dest_path" + as_agent chmod "$mode" "$dest_path" + if [ "$read_only" = "1" ]; then + as_agent chmod a-w "$dest_path" + log "✓ $log_label (read-only)" + else + log "✓ $log_label" + fi + else + bb_die "unsupported deploy owner: $owner" + fi +} -echo "Deploying memory seeds..." +bb_manifest_for_each RUNTIME_ASSET_MANIFEST deploy_runtime_asset_entry + +# ── Memory Seeds ───────────────────────────────────────────────────────────── MEMORY_SEED_DIR="$STAGE_DIR/skills/control-agent/memory" MEMORY_DEST="$BAUDBOT_HOME/.pi/agent/memory" @@ -242,68 +313,6 @@ else log "would seed: memory/*.md (only if missing)" fi -# ── Runtime bin (utility scripts + start.sh) ───────────────────────────────── - -echo "Deploying runtime scripts..." - -if [ "$DRY_RUN" -eq 0 ]; then - as_agent mkdir -p "$BAUDBOT_HOME/runtime/bin" - as_agent mkdir -p "$BAUDBOT_HOME/runtime/bin/lib" - - for script in harden-permissions.sh redact-logs.sh prune-session-logs.sh verify-manifest.sh; do - if [ -f "$STAGE_DIR/bin/$script" ]; then - as_agent cp "$STAGE_DIR/bin/$script" "$BAUDBOT_HOME/runtime/bin/$script" - as_agent chmod u+x "$BAUDBOT_HOME/runtime/bin/$script" - log "✓ bin/$script" - fi - done - - if [ -f "$STAGE_DIR/bin/lib/runtime-node.sh" ]; then - as_agent cp "$STAGE_DIR/bin/lib/runtime-node.sh" "$BAUDBOT_HOME/runtime/bin/lib/runtime-node.sh" - as_agent chmod u+r "$BAUDBOT_HOME/runtime/bin/lib/runtime-node.sh" - log "✓ bin/lib/runtime-node.sh" - fi - - if [ -f "$STAGE_DIR/bin/lib/bridge-restart-policy.sh" ]; then - as_agent cp "$STAGE_DIR/bin/lib/bridge-restart-policy.sh" "$BAUDBOT_HOME/runtime/bin/lib/bridge-restart-policy.sh" - as_agent chmod u+r "$BAUDBOT_HOME/runtime/bin/lib/bridge-restart-policy.sh" - log "✓ bin/lib/bridge-restart-policy.sh" - fi - - as_agent cp "$STAGE_DIR/start.sh" "$BAUDBOT_HOME/runtime/start.sh" - as_agent chmod u+x "$BAUDBOT_HOME/runtime/start.sh" - log "✓ start.sh" -else - log "would copy: runtime scripts" -fi - -# ── Settings ───────────────────────────────────────────────────────────────── - -echo "Deploying settings..." - -if [ -f "$STAGE_DIR/settings.json" ]; then - if [ "$DRY_RUN" -eq 0 ]; then - as_agent bash -c "cp '$STAGE_DIR/settings.json' '$BAUDBOT_HOME/.pi/agent/settings.json' && chmod 600 '$BAUDBOT_HOME/.pi/agent/settings.json'" - log "✓ settings.json" - else - log "would copy: settings.json" - fi -fi - -# ── Env schema ─────────────────────────────────────────────────────────────── - -echo "Deploying env schema..." - -if [ -f "$STAGE_DIR/.env.schema" ]; then - if [ "$DRY_RUN" -eq 0 ]; then - as_agent cp "$STAGE_DIR/.env.schema" "$BAUDBOT_HOME/.config/.env.schema" - as_agent chmod 644 "$BAUDBOT_HOME/.config/.env.schema" - log "✓ .env.schema → ~/.config/.env.schema" - else - log "would copy: .env.schema → ~/.config/.env.schema" - fi -fi - # ── Admin config (secrets) ──────────────────────────────────────────────────── echo "Deploying config..." diff --git a/bin/doctor.sh b/bin/doctor.sh index 48dfd80..cf908a9 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -18,14 +18,10 @@ bb_init_paths BAUDBOT_ROOT="${BAUDBOT_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" -for arg in "$@"; do - case "$arg" in - -h|--help) - echo "Usage: baudbot doctor" - exit 0 - ;; - esac -done +if bb_has_arg "--help" "$@" || bb_has_arg "-h" "$@"; then + echo "Usage: baudbot doctor" + exit 0 +fi doctor_init_counters IS_ROOT=0 diff --git a/bin/lib/check-report-common.sh b/bin/lib/check-report-common.sh new file mode 100644 index 0000000..a761e6b --- /dev/null +++ b/bin/lib/check-report-common.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Shared counter helpers for check/report style shell scripts. + +bb_counter_reset_many() { + local counter_name + for counter_name in "$@"; do + local -n counter_ref="$counter_name" + counter_ref=0 + done +} + +bb_counter_inc() { + local counter_name="$1" + local -n counter_ref="$counter_name" + counter_ref=$((counter_ref + 1)) +} + +bb_summary_print_header() { + echo "Summary" + echo "───────" +} + +bb_summary_print_item() { + local icon="$1" + local label="$2" + local value="$3" + + printf " %s %-9s %s\n" "$icon" "$label:" "$value" +} diff --git a/bin/lib/check-report-common.test.sh b/bin/lib/check-report-common.test.sh new file mode 100644 index 0000000..60a9c25 --- /dev/null +++ b/bin/lib/check-report-common.test.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Tests for bin/lib/check-report-common.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=bin/lib/check-report-common.sh +source "$SCRIPT_DIR/check-report-common.sh" + +TOTAL=0 +PASSED=0 +FAILED=0 + +run_test() { + local name="$1" + shift + local out + + TOTAL=$((TOTAL + 1)) + printf " %-45s " "$name" + + out="$(mktemp /tmp/baudbot-check-report-common-test-output.XXXXXX)" + if "$@" >"$out" 2>&1; then + echo "✓" + PASSED=$((PASSED + 1)) + else + echo "✗ FAILED" + tail -40 "$out" | sed 's/^/ /' + FAILED=$((FAILED + 1)) + fi + rm -f "$out" +} + +test_reset_many_sets_zero() { + ( + set -euo pipefail + a=7 + b=3 + c=9 + bb_counter_reset_many a b c + [ "$a" -eq 0 ] + [ "$b" -eq 0 ] + [ "$c" -eq 0 ] + ) +} + +test_inc_increments_named_counter() { + ( + set -euo pipefail + value=0 + bb_counter_inc value + bb_counter_inc value + [ "$value" -eq 2 ] + ) +} + +test_summary_helpers_render_rows() { + ( + set -euo pipefail + local out + out="$(mktemp /tmp/check-report-summary.XXXXXX)" + trap 'rm -f "$out"' EXIT + + { + bb_summary_print_header + bb_summary_print_item "✅" "Pass" "3" + } >"$out" + + grep -q '^Summary$' "$out" + grep -q '✅ Pass:' "$out" + ) +} + +echo "=== check-report-common tests ===" +echo "" + +run_test "reset many sets counters to zero" test_reset_many_sets_zero +run_test "increment updates named counter" test_inc_increments_named_counter +run_test "summary helpers render rows" test_summary_helpers_render_rows + +echo "" +echo "=== $PASSED/$TOTAL passed, $FAILED failed ===" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/bin/lib/deploy-common.sh b/bin/lib/deploy-common.sh index 93ff580..84b871d 100644 --- a/bin/lib/deploy-common.sh +++ b/bin/lib/deploy-common.sh @@ -48,3 +48,26 @@ bb_source_env_value() { return 0 } + +bb_feature_gate_enabled() { + local gate="$1" + local experimental_mode="$2" + + case "$gate" in + ""|always) return 0 ;; + experimental) [ "$experimental_mode" = "1" ] ;; + stable) [ "$experimental_mode" != "1" ] ;; + *) return 1 ;; + esac +} + +bb_manifest_for_each() { + local manifest_name="$1" + local callback="$2" + local -n manifest_ref="$manifest_name" + + local entry + for entry in "${manifest_ref[@]}"; do + "$callback" "$entry" + done +} diff --git a/bin/lib/deploy-common.test.sh b/bin/lib/deploy-common.test.sh index 1d628a4..55cf355 100644 --- a/bin/lib/deploy-common.test.sh +++ b/bin/lib/deploy-common.test.sh @@ -143,7 +143,47 @@ run_test "resolve: SUDO_USER wins when non-root" test_resolve_prefers_sudo_user_ run_test "resolve: fallback to owner/whoami" test_resolve_falls_back_to_owner_or_whoami run_test "source_env: render script preferred" test_source_env_uses_render_script_when_present run_test "source_env: fallback to admin config" test_source_env_falls_back_to_admin_config +test_feature_gate_enabled_modes() { + ( + set -euo pipefail + + bb_feature_gate_enabled "always" "0" + bb_feature_gate_enabled "always" "1" + bb_feature_gate_enabled "experimental" "1" + bb_feature_gate_enabled "stable" "0" + + if bb_feature_gate_enabled "experimental" "0"; then + return 1 + fi + if bb_feature_gate_enabled "stable" "1"; then + return 1 + fi + if bb_feature_gate_enabled "unknown" "1"; then + return 1 + fi + ) +} + +test_manifest_for_each_iterates_entries() { + ( + set -euo pipefail + # shellcheck disable=SC2034 # consumed by nameref in bb_manifest_for_each + local entries=("one" "two" "three") + local visited="" + + visit_entry() { + local value="$1" + visited+="$value," + } + + bb_manifest_for_each entries visit_entry + [ "$visited" = "one,two,three," ] + ) +} + run_test "source_env: missing returns empty" test_source_env_returns_empty_when_missing +run_test "feature gate: always/experimental/stable" test_feature_gate_enabled_modes +run_test "manifest: iterates all entries" test_manifest_for_each_iterates_entries echo "" echo "=== $PASSED/$TOTAL passed, $FAILED failed ===" diff --git a/bin/lib/doctor-common.sh b/bin/lib/doctor-common.sh index 36cb6a5..045b781 100644 --- a/bin/lib/doctor-common.sh +++ b/bin/lib/doctor-common.sh @@ -1,25 +1,27 @@ #!/bin/bash # Shared helpers for bin/doctor.sh +BB_DOCTOR_COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/lib/check-report-common.sh +source "$BB_DOCTOR_COMMON_DIR/check-report-common.sh" + doctor_init_counters() { - PASS=0 - FAIL=0 - WARN=0 + bb_counter_reset_many PASS FAIL WARN } doctor_pass() { echo " ✓ $1" - PASS=$((PASS + 1)) + bb_counter_inc PASS } doctor_fail() { echo " ✗ $1" - FAIL=$((FAIL + 1)) + bb_counter_inc FAIL } doctor_warn() { echo " ⚠ $1" - WARN=$((WARN + 1)) + bb_counter_inc WARN } doctor_summary_and_exit() { diff --git a/bin/lib/release-runtime-common.sh b/bin/lib/release-runtime-common.sh new file mode 100644 index 0000000..cb422d5 --- /dev/null +++ b/bin/lib/release-runtime-common.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Shared runtime helpers for release update/rollback scripts. +# +# Expects caller to provide: +# - log() and die() functions +# - restart_baudbot_service_if_active() +# - json_get_string_stdin() +# - BAUDBOT_AGENT_USER and BAUDBOT_AGENT_HOME + +bb_run_release_override_cmd() { + local description="$1" + local command="$2" + local env_array_name="$3" + + [ -n "$command" ] || return 1 + + local -n env_ref="$env_array_name" + log "running $description override" + env "${env_ref[@]}" bash -lc "$command" +} + +bb_run_release_restart_and_health() { + local restart_cmd="$1" + local skip_restart="$2" + local health_cmd="$3" + local env_array_name="$4" + + if [ -n "$restart_cmd" ]; then + bb_run_release_override_cmd "restart" "$restart_cmd" "$env_array_name" + elif [ "$skip_restart" = "1" ]; then + log "skipping restart" + else + restart_baudbot_service_if_active + fi + + if [ -n "$health_cmd" ]; then + bb_run_release_override_cmd "health" "$health_cmd" "$env_array_name" + fi +} + +bb_verify_deployed_release_sha() { + local expected_sha="$1" + local skip_version_check="$2" + local verified_label="${3:-}" + + if [ "$skip_version_check" = "1" ]; then + return 0 + fi + + if [ "$(id -u)" -ne 0 ]; then + log "non-root run: skipping deployed version verification" + return 0 + fi + + if ! id "$BAUDBOT_AGENT_USER" >/dev/null 2>&1; then + log "agent user '$BAUDBOT_AGENT_USER' missing; skipping deployed version verification" + return 0 + fi + + local version_file="$BAUDBOT_AGENT_HOME/.pi/agent/baudbot-version.json" + local deployed_sha + + deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "sha" 2>/dev/null || true)" + + if [ -z "$deployed_sha" ]; then + die "deployed version file missing or unreadable: $version_file" + fi + + if [ "$deployed_sha" != "$expected_sha" ]; then + die "deployed sha mismatch (expected $expected_sha, got $deployed_sha)" + fi + + if [ -n "$verified_label" ]; then + log "deployed version verified: $verified_label" + fi +} diff --git a/bin/lib/release-runtime-common.test.sh b/bin/lib/release-runtime-common.test.sh new file mode 100644 index 0000000..c549a0f --- /dev/null +++ b/bin/lib/release-runtime-common.test.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# Tests for bin/lib/release-runtime-common.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=bin/lib/release-runtime-common.sh +source "$SCRIPT_DIR/release-runtime-common.sh" + +TOTAL=0 +PASSED=0 +FAILED=0 + +run_test() { + local name="$1" + shift + local out + + TOTAL=$((TOTAL + 1)) + printf " %-45s " "$name" + + out="$(mktemp /tmp/baudbot-release-runtime-common-test-output.XXXXXX)" + if "$@" >"$out" 2>&1; then + echo "✓" + PASSED=$((PASSED + 1)) + else + echo "✗ FAILED" + tail -40 "$out" | sed 's/^/ /' + FAILED=$((FAILED + 1)) + fi + rm -f "$out" +} + +TEST_LOGS=() +RESTART_CALLS=0 + +log() { + TEST_LOGS+=("$*") +} + +die() { + echo "die:$*" >&2 + exit 1 +} + +restart_baudbot_service_if_active() { + RESTART_CALLS=$((RESTART_CALLS + 1)) +} + +json_get_string_stdin() { + local _key="$1" + cat +} + +reset_state() { + TEST_LOGS=() + RESTART_CALLS=0 +} + +test_restart_override_failure_does_not_fallback() { + ( + set -euo pipefail + reset_state + # shellcheck disable=SC2034 # consumed via nameref in bb_run_release_restart_and_health + local hook_env=("X_TEST=1") + + set +e + bb_run_release_restart_and_health "false" "0" "" hook_env + rc=$? + set -e + + [ "$rc" -ne 0 ] + [ "$RESTART_CALLS" -eq 0 ] + ) +} + +test_no_restart_override_uses_default_restart() { + ( + set -euo pipefail + reset_state + local hook_env=() + + bb_run_release_restart_and_health "" "0" "" hook_env + [ "$RESTART_CALLS" -eq 1 ] + ) +} + +test_skip_restart_logs_and_does_not_restart() { + ( + set -euo pipefail + reset_state + local hook_env=() + + bb_run_release_restart_and_health "" "1" "" hook_env + [ "$RESTART_CALLS" -eq 0 ] + ) +} + +test_health_override_failure_propagates() { + ( + set -euo pipefail + reset_state + # shellcheck disable=SC2034 # consumed via nameref in bb_run_release_restart_and_health + local hook_env=("X_TEST=1") + + set +e + bb_run_release_restart_and_health "" "1" "false" hook_env + rc=$? + set -e + + [ "$rc" -ne 0 ] + ) +} + +echo "=== release-runtime-common tests ===" +echo "" + +run_test "restart override failure does not fallback" test_restart_override_failure_does_not_fallback +run_test "default restart runs without override" test_no_restart_override_uses_default_restart +run_test "skip restart avoids default restart" test_skip_restart_logs_and_does_not_restart +run_test "health override failure propagates" test_health_override_failure_propagates + +echo "" +echo "=== $PASSED/$TOTAL passed, $FAILED failed ===" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/bin/lib/shell-common.sh b/bin/lib/shell-common.sh index 4cd3410..d789194 100644 --- a/bin/lib/shell-common.sh +++ b/bin/lib/shell-common.sh @@ -83,3 +83,26 @@ bb_read_env_value() { [ -n "$line" ] || return 0 echo "${line#*=}" } + +bb_require_option_value() { + local option="$1" + local argc="$2" + + if [ "$argc" -lt 2 ]; then + bb_die "$option requires a value" + fi +} + +bb_has_arg() { + local needle="$1" + shift || true + + local arg + for arg in "$@"; do + if [ "$arg" = "$needle" ]; then + return 0 + fi + done + + return 1 +} diff --git a/bin/lib/shell-common.test.sh b/bin/lib/shell-common.test.sh new file mode 100644 index 0000000..a0a0c44 --- /dev/null +++ b/bin/lib/shell-common.test.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Tests for bin/lib/shell-common.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=bin/lib/shell-common.sh +source "$SCRIPT_DIR/shell-common.sh" + +TOTAL=0 +PASSED=0 +FAILED=0 + +run_test() { + local name="$1" + shift + local out + + TOTAL=$((TOTAL + 1)) + printf " %-45s " "$name" + + out="$(mktemp /tmp/baudbot-shell-common-test-output.XXXXXX)" + if "$@" >"$out" 2>&1; then + echo "✓" + PASSED=$((PASSED + 1)) + else + echo "✗ FAILED" + tail -40 "$out" | sed 's/^/ /' + FAILED=$((FAILED + 1)) + fi + rm -f "$out" +} + +test_has_arg_detects_present() { + ( + set -euo pipefail + bb_has_arg "--flag" "--foo" "--flag" "--bar" + ) +} + +test_has_arg_returns_not_found() { + ( + set -euo pipefail + if bb_has_arg "--missing" "--foo" "--bar"; then + return 1 + fi + ) +} + +test_require_option_value_allows_value() { + ( + set -euo pipefail + bb_require_option_value "--repo" 2 + ) +} + +test_require_option_value_fails_without_value() { + ( + set -euo pipefail + local out + out="$(mktemp /tmp/shell-common-require-option.XXXXXX)" + trap 'rm -f "$out"' EXIT + + set +e + bash -c ' + source "$1" + bb_require_option_value "--repo" 1 + ' _ "$SCRIPT_DIR/shell-common.sh" >"$out" 2>&1 + rc=$? + set -e + + [ "$rc" -ne 0 ] + grep -q -- "--repo requires a value" "$out" + ) +} + +echo "=== shell-common tests ===" +echo "" + +run_test "has_arg detects present value" test_has_arg_detects_present +run_test "has_arg returns not found" test_has_arg_returns_not_found +run_test "require_option_value allows value" test_require_option_value_allows_value +run_test "require_option_value fails when missing" test_require_option_value_fails_without_value + +echo "" +echo "=== $PASSED/$TOTAL passed, $FAILED failed ===" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/bin/rollback-release.sh b/bin/rollback-release.sh index c647b59..fa17b20 100755 --- a/bin/rollback-release.sh +++ b/bin/rollback-release.sh @@ -34,6 +34,8 @@ EOF # shellcheck source=bin/lib/release-common.sh source "$SCRIPT_DIR/lib/release-common.sh" +# shellcheck source=bin/lib/release-runtime-common.sh +source "$SCRIPT_DIR/lib/release-runtime-common.sh" # shellcheck source=bin/lib/json-common.sh source "$SCRIPT_DIR/lib/json-common.sh" @@ -45,7 +47,7 @@ fi while [ "$#" -gt 0 ]; do case "$1" in --release-root) - [ "$#" -ge 2 ] || die "--release-root requires a value" + bb_require_option_value "--release-root" "$#" BAUDBOT_RELEASE_ROOT="$2" shift 2 ;; @@ -121,50 +123,20 @@ run_deploy() { } run_restart_and_health() { - if [ -n "$BAUDBOT_ROLLBACK_RESTART_CMD" ]; then - log "running restart override" - BAUDBOT_ROLLBACK_TARGET_RELEASE="$TARGET_RELEASE" bash -lc "$BAUDBOT_ROLLBACK_RESTART_CMD" - elif [ "$BAUDBOT_ROLLBACK_SKIP_RESTART" = "1" ]; then - log "skipping restart" - else - restart_baudbot_service_if_active - fi - - if [ -n "$BAUDBOT_ROLLBACK_HEALTH_CMD" ]; then - log "running health override" - BAUDBOT_ROLLBACK_TARGET_RELEASE="$TARGET_RELEASE" bash -lc "$BAUDBOT_ROLLBACK_HEALTH_CMD" - fi - - if [ "$BAUDBOT_ROLLBACK_SKIP_VERSION_CHECK" = "1" ]; then - return 0 - fi - - if [ "$(id -u)" -ne 0 ]; then - log "non-root run: skipping deployed version verification" - return 0 - fi - - if ! id "$BAUDBOT_AGENT_USER" >/dev/null 2>&1; then - log "agent user '$BAUDBOT_AGENT_USER' missing; skipping deployed version verification" - return 0 - fi - - local version_file="$BAUDBOT_AGENT_HOME/.pi/agent/baudbot-version.json" + # shellcheck disable=SC2034 # consumed via nameref in bb_run_release_restart_and_health + local release_hook_env=("BAUDBOT_ROLLBACK_TARGET_RELEASE=$TARGET_RELEASE") local expected_sha - local deployed_sha + + bb_run_release_restart_and_health \ + "$BAUDBOT_ROLLBACK_RESTART_CMD" \ + "$BAUDBOT_ROLLBACK_SKIP_RESTART" \ + "$BAUDBOT_ROLLBACK_HEALTH_CMD" \ + release_hook_env expected_sha="$(json_get_string_or_empty "$TARGET_RELEASE/baudbot-release.json" "sha")" [ -n "$expected_sha" ] || expected_sha="$(basename "$TARGET_RELEASE")" - deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "sha" 2>/dev/null || true)" - - if [ -z "$deployed_sha" ]; then - die "deployed version file missing or unreadable: $version_file" - fi - - if [ "$deployed_sha" != "$expected_sha" ]; then - die "deployed sha mismatch (expected $expected_sha, got $deployed_sha)" - fi + bb_verify_deployed_release_sha "$expected_sha" "$BAUDBOT_ROLLBACK_SKIP_VERSION_CHECK" } install_cli_link() { diff --git a/bin/security-audit.sh b/bin/security-audit.sh index 7d2a1bb..720f75f 100755 --- a/bin/security-audit.sh +++ b/bin/security-audit.sh @@ -16,6 +16,8 @@ source "$SCRIPT_DIR/lib/shell-common.sh" source "$SCRIPT_DIR/lib/paths-common.sh" # shellcheck source=bin/lib/runtime-node.sh source "$SCRIPT_DIR/lib/runtime-node.sh" +# shellcheck source=bin/lib/check-report-common.sh +source "$SCRIPT_DIR/lib/check-report-common.sh" bb_enable_strict_mode bb_init_paths @@ -27,21 +29,18 @@ source "$SCRIPT_DIR/lib/json-common.sh" DEEP=0 FIX=0 -for arg in "$@"; do - case "$arg" in - --deep) DEEP=1 ;; - --fix) FIX=1 ;; - esac -done +bb_has_arg "--deep" "$@" && DEEP=1 +bb_has_arg "--fix" "$@" && FIX=1 # Counters -critical=0 -warn=0 -info=0 -pass=0 -fixed=0 -skipped=0 -fix_errors=0 +critical= +warn= +info= +pass= +fixed= +skipped= +fix_errors= +bb_counter_reset_many critical warn info pass fixed skipped fix_errors finding() { local severity="$1" @@ -49,16 +48,16 @@ finding() { local detail="${3:-}" case "$severity" in - CRITICAL) echo " ❌ CRITICAL: $title"; critical=$((critical + 1)) ;; - WARN) echo " ⚠️ WARN: $title"; warn=$((warn + 1)) ;; - INFO) echo " ℹ️ INFO: $title"; info=$((info + 1)) ;; + CRITICAL) echo " ❌ CRITICAL: $title"; bb_counter_inc critical ;; + WARN) echo " ⚠️ WARN: $title"; bb_counter_inc warn ;; + INFO) echo " ℹ️ INFO: $title"; bb_counter_inc info ;; esac [ -n "$detail" ] && echo " $detail" } ok() { echo " ✅ PASS: $1" - pass=$((pass + 1)) + bb_counter_inc pass } # fix_action: attempt a remediation and report result @@ -71,11 +70,11 @@ fix_action() { fi if "$@" 2>/dev/null; then echo " 🔧 FIXED: $desc" - fixed=$((fixed + 1)) + bb_counter_inc fixed return 0 else echo " ❌ FIX-ERR: $desc (command failed)" - fix_errors=$((fix_errors + 1)) + bb_counter_inc fix_errors return 1 fi } @@ -86,7 +85,7 @@ fix_skip() { local reason="$2" if [ "$FIX" -eq 1 ]; then echo " ⏭️ SKIPPED: $desc — $reason" - skipped=$((skipped + 1)) + bb_counter_inc skipped fi } @@ -722,18 +721,17 @@ echo "" # ── Summary ────────────────────────────────────────────────────────────────── -echo "Summary" -echo "───────" -echo " ✅ Pass: $pass" -echo " ❌ Critical: $critical" -echo " ⚠️ Warn: $warn" -echo " ℹ️ Info: $info" +bb_summary_print_header +bb_summary_print_item "✅" "Pass" "$pass" +bb_summary_print_item "❌" "Critical" "$critical" +bb_summary_print_item "⚠️" "Warn" "$warn" +bb_summary_print_item "ℹ️" "Info" "$info" if [ "$FIX" -eq 1 ]; then echo "" - echo " 🔧 Fixed: $fixed" - echo " ⏭️ Skipped: $skipped" - echo " ❌ Errors: $fix_errors" + bb_summary_print_item "🔧" "Fixed" "$fixed" + bb_summary_print_item "⏭️" "Skipped" "$skipped" + bb_summary_print_item "❌" "Errors" "$fix_errors" fi echo "" diff --git a/bin/update-release.sh b/bin/update-release.sh index 4135943..9099a18 100755 --- a/bin/update-release.sh +++ b/bin/update-release.sh @@ -48,6 +48,8 @@ die() { bb_die "$1"; } # shellcheck source=bin/lib/release-common.sh source "$SCRIPT_DIR/lib/release-common.sh" +# shellcheck source=bin/lib/release-runtime-common.sh +source "$SCRIPT_DIR/lib/release-runtime-common.sh" # shellcheck source=bin/lib/json-common.sh source "$SCRIPT_DIR/lib/json-common.sh" @@ -79,22 +81,22 @@ EOF while [ "$#" -gt 0 ]; do case "$1" in --repo) - [ "$#" -ge 2 ] || die "--repo requires a value" + bb_require_option_value "--repo" "$#" BAUDBOT_UPDATE_REPO="$2" shift 2 ;; --branch) - [ "$#" -ge 2 ] || die "--branch requires a value" + bb_require_option_value "--branch" "$#" BAUDBOT_UPDATE_BRANCH="$2" shift 2 ;; --ref) - [ "$#" -ge 2 ] || die "--ref requires a value" + bb_require_option_value "--ref" "$#" BAUDBOT_UPDATE_REF="$2" shift 2 ;; --release-root) - [ "$#" -ge 2 ] || die "--release-root requires a value" + bb_require_option_value "--release-root" "$#" BAUDBOT_RELEASE_ROOT="$2" shift 2 ;; @@ -283,48 +285,19 @@ run_deploy() { } run_restart_and_health() { - if [ -n "$BAUDBOT_UPDATE_RESTART_CMD" ]; then - log "running restart override" - BAUDBOT_UPDATE_RELEASE_DIR="$RELEASE_DIR" BAUDBOT_UPDATE_CHECKOUT_DIR="$CHECKOUT_DIR" bash -lc "$BAUDBOT_UPDATE_RESTART_CMD" - elif [ "$BAUDBOT_UPDATE_SKIP_RESTART" = "1" ]; then - log "skipping restart" - else - restart_baudbot_service_if_active - fi - - if [ -n "$BAUDBOT_UPDATE_HEALTH_CMD" ]; then - log "running health override" - BAUDBOT_UPDATE_RELEASE_DIR="$RELEASE_DIR" BAUDBOT_UPDATE_CHECKOUT_DIR="$CHECKOUT_DIR" bash -lc "$BAUDBOT_UPDATE_HEALTH_CMD" - fi - - if [ "$BAUDBOT_UPDATE_SKIP_VERSION_CHECK" = "1" ]; then - return 0 - fi - - if [ "$(id -u)" -ne 0 ]; then - log "non-root run: skipping deployed version verification" - return 0 - fi - - if ! id "$BAUDBOT_AGENT_USER" >/dev/null 2>&1; then - log "agent user '$BAUDBOT_AGENT_USER' missing; skipping deployed version verification" - return 0 - fi - - local version_file="$BAUDBOT_AGENT_HOME/.pi/agent/baudbot-version.json" - local deployed_sha - - deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "sha" 2>/dev/null || true)" - - if [ -z "$deployed_sha" ]; then - die "deployed version file missing or unreadable: $version_file" - fi + # shellcheck disable=SC2034 # consumed via nameref in bb_run_release_restart_and_health + local release_hook_env=( + "BAUDBOT_UPDATE_RELEASE_DIR=$RELEASE_DIR" + "BAUDBOT_UPDATE_CHECKOUT_DIR=$CHECKOUT_DIR" + ) - if [ "$deployed_sha" != "$TARGET_SHA" ]; then - die "deployed sha mismatch (expected $TARGET_SHA, got $deployed_sha)" - fi + bb_run_release_restart_and_health \ + "$BAUDBOT_UPDATE_RESTART_CMD" \ + "$BAUDBOT_UPDATE_SKIP_RESTART" \ + "$BAUDBOT_UPDATE_HEALTH_CMD" \ + release_hook_env - log "deployed version verified: $TARGET_SHORT" + bb_verify_deployed_release_sha "$TARGET_SHA" "$BAUDBOT_UPDATE_SKIP_VERSION_CHECK" "$TARGET_SHORT" } install_cli_link() { diff --git a/test/shell-scripts.test.mjs b/test/shell-scripts.test.mjs index 06d35cb..dc969e5 100644 --- a/test/shell-scripts.test.mjs +++ b/test/shell-scripts.test.mjs @@ -35,14 +35,26 @@ describe("shell script test suites", () => { expect(() => runScript("bin/lib/json-common.test.sh")).not.toThrow(); }); + it("shell helper", () => { + expect(() => runScript("bin/lib/shell-common.test.sh")).not.toThrow(); + }); + it("deploy helpers", () => { expect(() => runScript("bin/lib/deploy-common.test.sh")).not.toThrow(); }); + it("release runtime helpers", () => { + expect(() => runScript("bin/lib/release-runtime-common.test.sh")).not.toThrow(); + }); + it("bridge restart policy helpers", () => { expect(() => runScript("bin/lib/bridge-restart-policy.test.sh")).not.toThrow(); }); + it("check report helpers", () => { + expect(() => runScript("bin/lib/check-report-common.test.sh")).not.toThrow(); + }); + it("doctor helpers", () => { expect(() => runScript("bin/lib/doctor-common.test.sh")).not.toThrow(); });