#!/bin/bash # ============================================================================ # run.sh -- drive this coursework archive. # # ./run.sh check run the full local gate (pre-commit, all files) # ./run.sh install alias for ./install.sh # ./run.sh status probe every course dir, rewrite DOCS-runnability.md # ./run.sh run actually execute one course project # ./run.sh sync-vendored show the mirrors that gates/vendored.txt feeds # # `status` PROBES: it asks each directory whether its toolchain is present and # never executes the project, so it takes seconds and is safe in CI. Use # `run ` to actually run something. Those are very different commands and # conflating them is how a "status check" turns into 44 builds. # ============================================================================ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR readonly RUNNABILITY_DOC="${SCRIPT_DIR}/DOCS-runnability.md" # Exit codes a per-directory run.sh uses to report itself. # Resource ceiling for `run`. A course build is background work and must never # be what makes an interactive machine stutter -- PORR's suite solves # 10000x10000 systems and happily eats every core it is given. # # Same mechanism as the pre-push hook in testsAndMisc: a transient systemd # scope, so the limit is enforced by the kernel rather than by good intentions. # MemorySwapMax=0 means it gets OOM-killed instead of thrashing swap. readonly RUN_MEMORY_MAX="${RUN_MEMORY_MAX:-2G}" readonly RUN_CPU_QUOTA="${RUN_CPU_QUOTA:-200%}" readonly EXIT_BLOCKED=78 readonly EXIT_NO_CODE=79 log() { printf '==> %s\n' "$*"; } err() { printf 'error: %s\n' "$*" >&2; } usage() { grep -E '^# \./run\.sh' "${BASH_SOURCE[0]}" | sed 's/^# //' exit "${1:-0}" } course_dirs() { find "${SCRIPT_DIR}/Programming" "${SCRIPT_DIR}/NotProgramming" \ -mindepth 1 -maxdepth 1 -type d | sort } cmd_check() { log "running the full gate" ( cd "$SCRIPT_DIR" && pre-commit run --all-files ) } cmd_install() { exec "${SCRIPT_DIR}/install.sh" "$@"; } # A runner opts in either by handling --probe itself, or by delegating to the # shared library (which does). Anything else predates this harness and is left # strictly alone. _supports_probe() { grep -qF -- '--probe' "$1" || grep -qF 'course_lib.sh' "$1" } # Probe one directory. Prints "\t"; never executes a project. # # The guard below is load-bearing. Some course dirs already ship their own # run.sh from years ago -- Programming/PSD/run.sh installs packages with sudo # and starts Kafka. Calling one of those with an unknown --probe flag does not # probe anything, it RUNS the project. So a runner is only invoked if it # actually declares --probe; anything else is reported unprobed and left alone. # `timeout` is defence in depth for a script that declares the flag and then # blocks on input anyway. probe_one() { local dir="$1" runner="$1/run.sh" if [[ -x "$runner" ]] && _supports_probe "$runner"; then local out status out="$(timeout 20s "$runner" --probe 2> /dev/null)" && status=0 || status=$? case "$status" in 0) printf 'runnable\t%s\n' "${out:-ok}" ;; "$EXIT_BLOCKED") printf 'blocked\t%s\n' "${out:-unknown}" ;; "$EXIT_NO_CODE") printf 'no-code\t%s\n' "${out:-unknown}" ;; *) printf 'error\t%s\n' "probe exited $status" ;; esac return 0 fi if [[ -x "$runner" ]]; then printf 'unprobed\tpre-existing run.sh, no --probe support\n' else printf 'unprobed\tno run.sh yet\n' fi } cmd_status() { local env_label="${CI:+ci}" env_label="${env_label:-local}" local store="${SCRIPT_DIR}/gates/runnability-${env_label}.tsv" log "probing course directories (${env_label})" local dir rel line : > "$store" while IFS= read -r dir; do rel="${dir#"${SCRIPT_DIR}"/}" line="$(probe_one "$dir")" printf '%s\t%s\n' "$rel" "$line" >> "$store" done < <(course_dirs) # Each environment writes only its OWN file, then the doc is rendered from # both. Writing a single shared column would mean a CI run silently # overwrote the local results with its own -- and the whole point of # recording per environment is that "runnable here, blocked on a runner" # is the normal case rather than drift. write_runnability_doc log "wrote ${RUNNABILITY_DOC##*/}" awk -F'\t' '{ n[$2]++ } END { for (k in n) printf " %-10s %d\n", k, n[k] }' "$store" } write_runnability_doc() { local local_tsv="${SCRIPT_DIR}/gates/runnability-local.tsv" local ci_tsv="${SCRIPT_DIR}/gates/runnability-ci.tsv" [[ -f "$local_tsv" ]] || : > "$local_tsv" [[ -f "$ci_tsv" ]] || : > "$ci_tsv" { cat <<'MD' # Runnability Generated by `./run.sh status`. Do not edit by hand. Status is recorded PER ENVIRONMENT, from `gates/runnability-local.tsv` and `gates/runnability-ci.tsv`. A directory that is `runnable` locally and `blocked` in CI is the normal case, not drift: the runner has no MATLAB, no Unity and no .NET SDK. Each environment rewrites only its own file, so a CI run cannot clobber the local column. `-` means that environment has not probed yet. MD printf '| Course | local | ci | Detail (local) |\n|---|---|---|---|\n' # Compare FILENAME rather than using the NR==FNR idiom: with an EMPTY # first file awk never reads it, so NR==FNR stays true for every row # of the second file and the whole table comes out blank. awk -F'\t' -v cifile="$ci_tsv" ' FILENAME == cifile { ci[$1] = $2; next } { printf "| `%s` | %s | %s | %s |\n", $1, $2, ($1 in ci ? ci[$1] : "-"), $3 } ' "$ci_tsv" "$local_tsv" } > "$RUNNABILITY_DOC" } cmd_run() { local target="${1:-}" [[ -n "$target" ]] || { err "run needs a directory"; usage 1; } local runner="${SCRIPT_DIR}/${target}/run.sh" [[ -x "$runner" ]] || { err "no executable run.sh in $target"; return 1; } log "running $target (capped at ${RUN_MEMORY_MAX} RAM, ${RUN_CPU_QUOTA} CPU)" _run_capped "$runner" } # Run a command under a memory/CPU cap, niced so it yields to anything # interactive. Falls back to plain nice if systemd-run is unavailable. _run_capped() { if command -v systemd-run > /dev/null 2>&1; then systemd-run --user --scope --quiet \ -p "MemoryMax=${RUN_MEMORY_MAX}" \ -p MemorySwapMax=0 \ -p "CPUQuota=${RUN_CPU_QUOTA}" \ nice -n 19 ionice -c 3 "$@" else nice -n 19 ionice -c 3 "$@" fi } cmd_sync_vendored() { log "gates/vendored.txt declares:" grep -vE '^\s*#|^\s*$' "${SCRIPT_DIR}/gates/vendored.txt" | sed 's/^/ /' printf '\nMirrored into:\n' printf ' ~/utils/file_length/_tables.py VENDORED_SUBPATHS["WUT_Computer_Science"]\n' printf ' pyproject.toml [tool.ruff] exclude (Milestone 4)\n' printf ' pyproject.toml [tool.coverage.run] omit (Milestone 4)\n' } main() { case "${1:-}" in check) shift; cmd_check "$@" ;; install) shift; cmd_install "$@" ;; status) shift; cmd_status "$@" ;; run) shift; cmd_run "$@" ;; sync-vendored) shift; cmd_sync_vendored "$@" ;; -h | --help | "") usage ;; *) err "unknown command: $1"; usage 1 ;; esac } main "$@"