From 7bee1615795e3c80b5f350bacef281893194cabe Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:45:14 +0530 Subject: [PATCH] feat(ci): add local macOS install matrix harness --- .ai-context/COMMANDS.md | 6 + .ai-context/STATUS.md | 2 + .ai-context/WORKFLOWS.md | 7 + bin/base-matrix | 25 + .../basectl/tests/macos_matrix_admin.bats | 112 ++++ cli/python/base_matrix/__init__.py | 7 + cli/python/base_matrix/__main__.py | 7 + cli/python/base_matrix/runner.py | 526 ++++++++++++++++++ cli/python/base_matrix/tests/__init__.py | 3 + cli/python/base_matrix/tests/test_runner.py | 194 +++++++ docs/README.md | 2 + docs/macos-install-matrix.md | 83 +++ docs/macos-install-validation.md | 4 + docs/schemas/macos-matrix-result.json | 77 +++ docs/schemas/macos-matrix-scenario.json | 34 ++ tests/fixtures/macos_matrix/trivial.json | 21 + tools/macos_matrix_admin.sh | 233 ++++++++ 17 files changed, 1343 insertions(+) create mode 100755 bin/base-matrix create mode 100644 cli/bash/commands/basectl/tests/macos_matrix_admin.bats create mode 100644 cli/python/base_matrix/__init__.py create mode 100644 cli/python/base_matrix/__main__.py create mode 100644 cli/python/base_matrix/runner.py create mode 100644 cli/python/base_matrix/tests/__init__.py create mode 100644 cli/python/base_matrix/tests/test_runner.py create mode 100644 docs/macos-install-matrix.md create mode 100644 docs/schemas/macos-matrix-result.json create mode 100644 docs/schemas/macos-matrix-scenario.json create mode 100644 tests/fixtures/macos_matrix/trivial.json create mode 100755 tools/macos_matrix_admin.sh diff --git a/.ai-context/COMMANDS.md b/.ai-context/COMMANDS.md index 09a10e4e..b9b2bbc2 100644 --- a/.ai-context/COMMANDS.md +++ b/.ai-context/COMMANDS.md @@ -60,6 +60,12 @@ the v1.x compatibility window; new automation should use `--debug-wrapper`. product assessment prompt with current Base metadata, and `--output ` writes the rendered Markdown; Base does not send the prompt to an AI provider. - `basectl docs` - open the Base documentation home page on GitHub. +- `bin/base-matrix ` - validate and run versioned local macOS + install-matrix scenarios through the fixed four-account, non-interactive + runas contract. This is maintainer validation tooling, not hosted CI. +- `tools/macos_matrix_admin.sh [--dry-run|--remove]` - preview, apply, or + remove the one-time macOS account/sudoers preparation required by the local + matrix; it is intentionally outside ordinary `basectl setup`. - `basectl projects list` - list Base-managed projects discovered in the workspace. - `basectl workspace ` - diff --git a/.ai-context/STATUS.md b/.ai-context/STATUS.md index 6be1164c..bb0f7676 100644 --- a/.ai-context/STATUS.md +++ b/.ai-context/STATUS.md @@ -50,6 +50,8 @@ The current command surface covers: - explicit `linux-lab` prerequisite profile for host-side Multipass checks and setup - Ubuntu/Debian runtime checks, diagnostics, and source-checkout validation +- local macOS install-matrix scenario validation with a guarded four-account + runas contract; real Homebrew/account mutations remain maintainer-run Recent trust and workspace updates are part of the current implementation: diff --git a/.ai-context/WORKFLOWS.md b/.ai-context/WORKFLOWS.md index d9a666be..8de5f8e3 100644 --- a/.ai-context/WORKFLOWS.md +++ b/.ai-context/WORKFLOWS.md @@ -107,6 +107,13 @@ cross-command workflows, setup/check/doctor interactions, shell profile behavior, installation layout assumptions, or public behavior that cannot be proven by a focused unit test. +The local macOS install matrix is a maintainer-run validation boundary, not a +hosted CI job. Review `tools/macos_matrix_admin.sh --dry-run` before applying +the one-time account and sudoers contract, then use `bin/base-matrix` to run +versioned scenarios through `sudo -n -u -i`. Persisted result +logs are redacted and mode `0600`; real Homebrew upgrades and user-owned shell +state remain outside ordinary CI. + Documentation-only changes usually need `git diff --check`. First external PRs should start from an issue labeled `good first issue`. Contributor-facing guidance lives in `CONTRIBUTING.md`; the workflow policy and diff --git a/bin/base-matrix b/bin/base-matrix new file mode 100755 index 00000000..8dc9d78d --- /dev/null +++ b/bin/base-matrix @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +if [[ -n "${BASE_MATRIX_PYTHON:-}" ]]; then + PYTHON_BIN="$BASE_MATRIX_PYTHON" +elif [[ -x "$HOME/.base.d/base/.venv/bin/python" ]]; then + PYTHON_BIN="$HOME/.base.d/base/.venv/bin/python" +else + PYTHON_BIN="python3" +fi + +base_cli_source="${BASE_CLI_SOURCE_DIR:-}" +if [[ -z "$base_cli_source" && -f "$REPO_ROOT/../base-cli/lib/python/base_cli/__init__.py" ]]; then + base_cli_source="$REPO_ROOT/../base-cli/lib/python" +fi +if [[ -n "$base_cli_source" ]]; then + export PYTHONPATH="$base_cli_source:$REPO_ROOT/cli/python${PYTHONPATH:+:$PYTHONPATH}" +else + export PYTHONPATH="$REPO_ROOT/cli/python${PYTHONPATH:+:$PYTHONPATH}" +fi +exec "$PYTHON_BIN" -m base_matrix "$@" diff --git a/cli/bash/commands/basectl/tests/macos_matrix_admin.bats b/cli/bash/commands/basectl/tests/macos_matrix_admin.bats new file mode 100644 index 00000000..91dcc054 --- /dev/null +++ b/cli/bash/commands/basectl/tests/macos_matrix_admin.bats @@ -0,0 +1,112 @@ +#!/usr/bin/env bats + +load ./basectl_helpers.bash + +setup() { + TEST_ROOT="$BATS_TEST_TMPDIR/matrix-admin" + mkdir -p "$TEST_ROOT/bin" + SHELLS_FILE="$TEST_ROOT/shells" + SUDOERS_FILE="$TEST_ROOT/sudoers.d/base-matrix" + STATE_FILE="$TEST_ROOT/state" + printf '/bin/zsh\n' >"$SHELLS_FILE" +} + +write_stub_commands() { + cat >"$TEST_ROOT/bin/id" <<'EOF' +#!/bin/sh +if [ "$1" = "-u" ]; then + printf '0\n' +else + printf 'matrix-admin\n' +fi +EOF + cat >"$TEST_ROOT/bin/dscl" <<'EOF' +#!/bin/sh +state="${BASE_MATRIX_DSCL_STATE:?}" +if [ "$2" = "-read" ]; then + user="${3##*/}" + shell="$(awk -F= -v user="$user" '$1 == user {print $2; exit}' "$state" 2>/dev/null)" + printf 'UserShell: %s\n' "${shell:-/bin/zsh}" + exit 0 +fi +if [ "$2" = "-create" ]; then + user="${3##*/}" + printf '%s=%s\n' "$user" "$5" >>"$state" + exit 0 +fi +exit 2 +EOF + cat >"$TEST_ROOT/bin/visudo" <<'EOF' +#!/bin/sh +exit 0 +EOF + cat >"$TEST_ROOT/bin/install" <<'EOF' +#!/bin/sh +while [ "$#" -gt 0 ]; do + case "$1" in + -o|-g|-m) shift 2 ;; + *) break ;; + esac +done +source="$1" +destination="$2" +mkdir -p "$(dirname "$destination")" +cp "$source" "$destination" +EOF + chmod +x "$TEST_ROOT/bin/id" "$TEST_ROOT/bin/dscl" "$TEST_ROOT/bin/visudo" "$TEST_ROOT/bin/install" +} + +run_admin() { + run env \ + PATH="$TEST_ROOT/bin:$PATH" \ + BASE_MATRIX_BASH_PATH=/opt/homebrew/bin/bash \ + BASE_MATRIX_SHELLS_FILE="$SHELLS_FILE" \ + BASE_MATRIX_SUDOERS_FILE="$SUDOERS_FILE" \ + BASE_MATRIX_STATE_FILE="$STATE_FILE" \ + BASE_MATRIX_OPERATOR=matrix-admin \ + BASE_MATRIX_DSCL="$TEST_ROOT/bin/dscl" \ + BASE_MATRIX_DSCL_STATE="$TEST_ROOT/dscl-state" \ + BASE_MATRIX_VISUDO="$TEST_ROOT/bin/visudo" \ + BASE_MATRIX_INSTALL="$TEST_ROOT/bin/install" \ + "$BASE_REPO_ROOT/tools/macos_matrix_admin.sh" "$@" +} + +@test "macOS matrix admin dry-run is non-mutating and shows blast radius" { + run env \ + BASE_MATRIX_BASH_PATH=/opt/homebrew/bin/bash \ + BASE_MATRIX_SHELLS_FILE="$SHELLS_FILE" \ + BASE_MATRIX_SUDOERS_FILE="$SUDOERS_FILE" \ + BASE_MATRIX_STATE_FILE="$STATE_FILE" \ + BASE_MATRIX_OPERATOR=matrix-admin \ + "$BASE_REPO_ROOT/tools/macos_matrix_admin.sh" --dry-run + + [ "$status" -eq 0 ] + [[ "$output" == *"DRY-RUN: would configure matrix accounts"* ]] + [[ "$output" == *"DRY-RUN: would grant matrix-admin passwordless sudo"* ]] + [[ "$output" == *"DRY-RUN: would set base-source-bash login shell"* ]] + [ ! -e "$STATE_FILE" ] + [ ! -e "$SUDOERS_FILE" ] + [ "$(cat "$SHELLS_FILE")" = "/bin/zsh" ] +} + +@test "macOS matrix admin apply is idempotent and remove restores recorded state" { + write_stub_commands + + run_admin + [ "$status" -eq 0 ] + [ -f "$STATE_FILE" ] + [ -f "$SUDOERS_FILE" ] + grep -Fxq /opt/homebrew/bin/bash "$SHELLS_FILE" + grep -Fxq 'matrix-admin ALL = (base-source-bash, base-source-zsh, base-brew-bash, base-brew-zsh) NOPASSWD: ALL' "$SUDOERS_FILE" + + run_admin + [ "$status" -eq 0 ] + [ "$(grep -Fc /opt/homebrew/bin/bash "$SHELLS_FILE")" -eq 1 ] + + run_admin --remove + [ "$status" -eq 0 ] + [ ! -e "$STATE_FILE" ] + [ ! -e "$SUDOERS_FILE" ] + [ "$(cat "$SHELLS_FILE")" = "/bin/zsh" ] + [[ "$output" == *"macOS install-matrix admin setup removed."* ]] +} diff --git a/cli/python/base_matrix/__init__.py b/cli/python/base_matrix/__init__.py new file mode 100644 index 00000000..fe3ec38f --- /dev/null +++ b/cli/python/base_matrix/__init__.py @@ -0,0 +1,7 @@ +"""Local macOS install-matrix scenario runner.""" + +from __future__ import annotations + +from .runner import main + +__all__ = ["main"] diff --git a/cli/python/base_matrix/__main__.py b/cli/python/base_matrix/__main__.py new file mode 100644 index 00000000..86fc8a70 --- /dev/null +++ b/cli/python/base_matrix/__main__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .runner import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/python/base_matrix/runner.py b/cli/python/base_matrix/runner.py new file mode 100644 index 00000000..59e7b60d --- /dev/null +++ b/cli/python/base_matrix/runner.py @@ -0,0 +1,526 @@ +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import platform +import shlex +import subprocess +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Sequence + +import base_cli +from base_setup.process import redact_command_argv +from base_setup.process import redact_command_output + + +SCHEMA_VERSION = 1 +MATRIX_CELLS: dict[str, dict[str, str]] = { + "source-bash": {"account": "base-source-bash", "route": "source", "shell": "bash"}, + "source-zsh": {"account": "base-source-zsh", "route": "source", "shell": "zsh"}, + "brew-bash": {"account": "base-brew-bash", "route": "homebrew", "shell": "bash"}, + "brew-zsh": {"account": "base-brew-zsh", "route": "homebrew", "shell": "zsh"}, +} +ALL_CELLS = tuple(MATRIX_CELLS) + + +class MatrixUsageError(ValueError): + """Raised when a scenario or runner argument is invalid.""" + + +@dataclass(frozen=True) +class Step: + step_id: str + command: tuple[str, ...] + expected_exit: int + stdout_contains: tuple[str, ...] + stderr_contains: tuple[str, ...] + stdout_not_contains: tuple[str, ...] + stderr_not_contains: tuple[str, ...] + finding_ids: tuple[str, ...] + timeout_seconds: float + cwd: str | None + + +@dataclass(frozen=True) +class Scenario: + name: str + version: str + description: str + preconditions: tuple[Step, ...] + steps: tuple[Step, ...] + + +@dataclass(frozen=True) +class RunContext: + base_ref: str + base_commit: str + run_id: str + repo_root: Path + output_dir: Path + + +@dataclass(frozen=True) +class StepOutcome: + command: Sequence[str] + log_path: str + result: subprocess.CompletedProcess[str] | None + duration_ms: int + status: str + extra_findings: Sequence[str] = () + error: str = "" + + +def _utc_now() -> str: + return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _as_string_list(value: object, field: str) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise MatrixUsageError(f"{field} must be an array of strings") + return tuple(value) + + +def _parse_step(value: object, location: str) -> Step: + if not isinstance(value, dict): + raise MatrixUsageError(f"{location} must be an object") + step_id = value.get("id") + command = value.get("command") + expected_exit = value.get("expected_exit", 0) + timeout_seconds = value.get("timeout_seconds", 300) + cwd = value.get("cwd") + if not isinstance(step_id, str) or not step_id: + raise MatrixUsageError(f"{location}.id must be a non-empty string") + if not isinstance(command, list) or not command or any(not isinstance(item, str) or not item for item in command): + raise MatrixUsageError(f"{location}.command must be a non-empty array of non-empty strings") + if not isinstance(expected_exit, int) or expected_exit < 0 or expected_exit > 255: + raise MatrixUsageError(f"{location}.expected_exit must be an integer from 0 to 255") + if not isinstance(timeout_seconds, (int, float)) or timeout_seconds <= 0: + raise MatrixUsageError(f"{location}.timeout_seconds must be greater than zero") + if cwd is not None and (not isinstance(cwd, str) or not cwd): + raise MatrixUsageError(f"{location}.cwd must be a non-empty string when provided") + return Step( + step_id=step_id, + command=tuple(command), + expected_exit=expected_exit, + stdout_contains=_as_string_list(value.get("stdout_contains"), f"{location}.stdout_contains"), + stderr_contains=_as_string_list(value.get("stderr_contains"), f"{location}.stderr_contains"), + stdout_not_contains=_as_string_list(value.get("stdout_not_contains"), f"{location}.stdout_not_contains"), + stderr_not_contains=_as_string_list(value.get("stderr_not_contains"), f"{location}.stderr_not_contains"), + finding_ids=_as_string_list(value.get("finding_ids"), f"{location}.finding_ids"), + timeout_seconds=float(timeout_seconds), + cwd=cwd, + ) + + +def load_scenario(path: Path) -> Scenario: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise MatrixUsageError(f"Unable to read scenario '{path}': {exc}") from exc + except json.JSONDecodeError as exc: + raise MatrixUsageError(f"Scenario '{path}' is not valid JSON: {exc}") from exc + if not isinstance(payload, dict): + raise MatrixUsageError("Scenario root must be an object") + if payload.get("schema_version") != SCHEMA_VERSION: + raise MatrixUsageError(f"Scenario schema_version must be {SCHEMA_VERSION}") + name = payload.get("name") + version = payload.get("version") + if not isinstance(name, str) or not name: + raise MatrixUsageError("Scenario name must be a non-empty string") + if not isinstance(version, str) or not version: + raise MatrixUsageError("Scenario version must be a non-empty string") + preconditions = payload.get("preconditions", []) + steps = payload.get("steps") + if not isinstance(preconditions, list): + raise MatrixUsageError("Scenario preconditions must be an array") + if not isinstance(steps, list) or not steps: + raise MatrixUsageError("Scenario steps must be a non-empty array") + parsed_preconditions = tuple( + _parse_step(item, f"preconditions[{index}]") for index, item in enumerate(preconditions) + ) + parsed_steps = tuple(_parse_step(item, f"steps[{index}]") for index, item in enumerate(steps)) + ids = [step.step_id for step in (*parsed_preconditions, *parsed_steps)] + if len(ids) != len(set(ids)): + raise MatrixUsageError("Scenario step ids must be unique across preconditions and steps") + return Scenario( + name=name, + version=version, + description=str(payload.get("description", "")), + preconditions=parsed_preconditions, + steps=parsed_steps, + ) + + +def _git_value(repo_root: Path, ref: str) -> tuple[str, str]: + try: + commit = subprocess.run( + ["git", "-C", str(repo_root), "rev-parse", f"{ref}^{{commit}}"], + check=False, + capture_output=True, + text=True, + ) + except OSError as exc: + raise MatrixUsageError(f"Unable to resolve Base ref '{ref}': {exc}") from exc + if commit.returncode != 0 or not commit.stdout.strip(): + raise MatrixUsageError(f"Unable to resolve Base ref '{ref}': {commit.stderr.strip()}") + return ref, commit.stdout.strip() + + +def _repo_root() -> Path: + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], check=False, capture_output=True, text=True + ) + except OSError as exc: + raise MatrixUsageError(f"Unable to find the Base checkout: {exc}") from exc + if result.returncode != 0 or not result.stdout.strip(): + raise MatrixUsageError("The matrix runner must run inside a Base Git checkout") + return Path(result.stdout.strip()).resolve() + + +def _redacted_command(command: Sequence[str]) -> list[str]: + return redact_command_argv([str(item) for item in command]) + + +def _log_step( + log_path: Path, + command: Sequence[str], + result: subprocess.CompletedProcess[str] | None, + error: str = "", +) -> None: + log_path.parent.mkdir(parents=True, exist_ok=True) + lines = [f"Command: {shlex.join(_redacted_command(command))}"] + if result is not None: + lines.extend( + [ + f"Exit code: {result.returncode}", + "--- stdout ---", + redact_command_output(result.stdout or ""), + "--- stderr ---", + redact_command_output(result.stderr or ""), + ] + ) + if error: + lines.extend(["--- runner error ---", redact_command_output(error)]) + log_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + os.chmod(log_path, 0o600) + + +def _step_result( + step: Step, + outcome: StepOutcome, +) -> dict[str, Any]: + actual_exit = outcome.result.returncode if outcome.result is not None else None + finding_ids = list(dict.fromkeys((*step.finding_ids, *outcome.extra_findings))) + return { + "id": step.step_id, + "command": _redacted_command(outcome.command), + "expected_exit": step.expected_exit, + "actual_exit": actual_exit, + "status": outcome.status, + "duration_ms": outcome.duration_ms, + "finding_ids": finding_ids, + "log_path": outcome.log_path, + **({"error": redact_command_output(outcome.error)} if outcome.error else {}), + } + + +def _assert_step(step: Step, result: subprocess.CompletedProcess[str]) -> tuple[bool, list[str]]: + failures: list[str] = [] + stdout = result.stdout or "" + stderr = result.stderr or "" + if result.returncode != step.expected_exit: + failures.append(f"exit-{result.returncode}") + failures.extend( + f"stdout-missing-{index}" for index, expected in enumerate(step.stdout_contains) if expected not in stdout + ) + failures.extend( + f"stderr-missing-{index}" for index, expected in enumerate(step.stderr_contains) if expected not in stderr + ) + failures.extend( + f"stdout-forbidden-{index}" + for index, forbidden in enumerate(step.stdout_not_contains) + if forbidden in stdout + ) + failures.extend( + f"stderr-forbidden-{index}" + for index, forbidden in enumerate(step.stderr_not_contains) + if forbidden in stderr + ) + return not failures, failures + + +def _run_step( + step: Step, + user: str, + repo_root: Path, + output_dir: Path, + log_name: str, +) -> tuple[dict[str, Any], bool]: + command = ["sudo", "-n", "-u", user, "-i", "--", *step.command] + cwd = repo_root if step.cwd is None else (repo_root / step.cwd).resolve() + try: + cwd.relative_to(repo_root) + except ValueError as exc: + raise MatrixUsageError(f"Step '{step.step_id}' cwd must remain inside the Base checkout") from exc + started = time.monotonic() + try: + result = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + cwd=cwd, + timeout=step.timeout_seconds, + ) + duration_ms = int((time.monotonic() - started) * 1000) + passed, assertion_failures = _assert_step(step, result) + status = "passed" if passed else "failed" + log_path = output_dir / log_name + _log_step(log_path, command, result) + return ( + _step_result( + step, + StepOutcome( + command, + str(log_path.relative_to(output_dir)), + result, + duration_ms, + status, + [f"matrix.{step.step_id}.{failure}" for failure in assertion_failures], + ), + ), + passed, + ) + except subprocess.TimeoutExpired: + duration_ms = int((time.monotonic() - started) * 1000) + log_path = output_dir / log_name + error = f"step timed out after {step.timeout_seconds:g} seconds" + _log_step(log_path, command, None, error) + return ( + _step_result( + step, + StepOutcome( + command, + str(log_path.relative_to(output_dir)), + None, + duration_ms, + "failed", + [f"matrix.{step.step_id}.timeout"], + error, + ), + ), + False, + ) + except OSError as exc: + duration_ms = int((time.monotonic() - started) * 1000) + log_path = output_dir / log_name + error = str(exc) + _log_step(log_path, command, None, error) + return ( + _step_result( + step, + StepOutcome( + command, + str(log_path.relative_to(output_dir)), + None, + duration_ms, + "failed", + [f"matrix.{step.step_id}.runner-error"], + error, + ), + ), + False, + ) + + +def _runas_preflight(user: str, output_dir: Path) -> tuple[dict[str, Any], bool]: + step = Step("runas", ("/usr/bin/true",), 0, (), (), (), (), (), 30.0, None) + command = ["sudo", "-n", "-u", user, "-i", "--", *step.command] + started = time.monotonic() + try: + result = subprocess.run(command, check=False, capture_output=True, text=True, timeout=step.timeout_seconds) + duration_ms = int((time.monotonic() - started) * 1000) + passed = result.returncode == 0 + log_path = output_dir / "logs" / "00-runas.log" + _log_step(log_path, command, result) + error = "" if passed else "passwordless sudo runas is unavailable for this matrix account" + return ( + _step_result( + step, + StepOutcome( + command, + str(log_path.relative_to(output_dir)), + result, + duration_ms, + "passed" if passed else "blocked", + () if passed else ("matrix.runas.unavailable",), + error, + ), + ), + passed, + ) + except (OSError, subprocess.TimeoutExpired): + duration_ms = int((time.monotonic() - started) * 1000) + log_path = output_dir / "logs" / "00-runas.log" + error = "passwordless sudo runas is unavailable for this matrix account" + _log_step(log_path, command, None, error) + return ( + _step_result( + step, + StepOutcome( + command, + str(log_path.relative_to(output_dir)), + None, + duration_ms, + "blocked", + ("matrix.runas.unavailable",), + error, + ), + ), + False, + ) + + +def _run_cell(scenario: Scenario, cell: str, context: RunContext) -> tuple[dict[str, Any], bool]: + cell_info = MATRIX_CELLS[cell] + account = cell_info["account"] + started_at = _utc_now() + cell_dir = context.output_dir / cell + cell_dir.mkdir(parents=True, exist_ok=True) + precondition_results: list[dict[str, Any]] = [] + step_results: list[dict[str, Any]] = [] + runas_result, runas_ok = _runas_preflight(account, cell_dir) + precondition_results.append(runas_result) + status = "passed" + if not runas_ok: + status = "blocked" + else: + for index, step in enumerate(scenario.preconditions, start=1): + log_name = f"logs/{index:02d}-precondition-{step.step_id}.log" + result, passed = _run_step(step, account, context.repo_root, cell_dir, log_name) + precondition_results.append(result) + if not passed: + status = "failed" + break + if status == "passed": + for index, step in enumerate(scenario.steps, start=1): + log_name = f"logs/{index + len(scenario.preconditions):02d}-{step.step_id}.log" + result, passed = _run_step(step, account, context.repo_root, cell_dir, log_name) + step_results.append(result) + if not passed: + status = "failed" + break + failed_steps = [item["id"] for item in (*precondition_results, *step_results) if item["status"] != "passed"] + result_payload: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "run_id": context.run_id, + "scenario": {"name": scenario.name, "version": scenario.version}, + "base": {"ref": context.base_ref, "commit": context.base_commit}, + "platform": { + "os": platform.system(), + "os_version": platform.mac_ver()[0] or platform.release(), + "architecture": platform.machine(), + }, + "cell": cell, + "account": account, + "route": cell_info["route"], + "shell": cell_info["shell"], + "started_at": started_at, + "completed_at": _utc_now(), + "status": status, + "preconditions": precondition_results, + "steps": step_results, + "summary": { + "status": status, + "failed_steps": failed_steps, + "message": "passed" if status == "passed" else "one or more matrix steps did not pass", + }, + } + result_path = context.output_dir / f"{cell}.json" + result_path.write_text(json.dumps(result_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.chmod(result_path, 0o600) + return result_payload, status == "passed" + + +def _write_summary(results: Sequence[dict[str, Any]], output_dir: Path) -> None: + summary = { + "schema_version": SCHEMA_VERSION, + "run_id": results[0]["run_id"] if results else "", + "results": [ + {"cell": result["cell"], "status": result["status"], "path": f"{result['cell']}.json"} + for result in results + ], + } + summary_path = output_dir / "summary.json" + summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.chmod(summary_path, 0o600) + + +def _parse_cells(value: str) -> tuple[str, ...]: + cells = ALL_CELLS if value == "all" else tuple(part.strip() for part in value.split(",") if part.strip()) + if not cells: + raise MatrixUsageError("At least one matrix cell is required") + unknown = [cell for cell in cells if cell not in MATRIX_CELLS] + if unknown: + raise MatrixUsageError(f"Unknown matrix cell(s): {', '.join(unknown)}") + if len(set(cells)) != len(cells): + raise MatrixUsageError("Matrix cells must be unique") + return cells + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="base-matrix", description="Run Base's local macOS install-matrix scenarios.") + subparsers = parser.add_subparsers(dest="command", required=True) + validate = subparsers.add_parser("validate", help="Validate a scenario file without running it.") + validate.add_argument("--scenario", required=True, type=Path) + run = subparsers.add_parser("run", help="Run a scenario through the named matrix accounts.") + run.add_argument("--scenario", required=True, type=Path) + run.add_argument("--cells", default="all", help="Comma-separated cells or 'all'.") + run.add_argument("--user", help="Explicit matrix account; must match the selected single cell.") + run.add_argument("--output-dir", type=Path, default=Path("artifacts/macos-matrix")) + run.add_argument("--base-ref", default="HEAD", help="Git ref recorded in results; defaults to HEAD.") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + try: + scenario = load_scenario(args.scenario.resolve()) + if args.command == "validate": + print(f"Scenario valid: {scenario.name} v{scenario.version}") + return base_cli.ExitCode.SUCCESS + cells = _parse_cells(args.cells) + if args.user is not None: + if len(cells) != 1: + raise MatrixUsageError("--user requires exactly one selected cell") + expected_user = MATRIX_CELLS[cells[0]]["account"] + if args.user != expected_user or args.user not in {info["account"] for info in MATRIX_CELLS.values()}: + raise MatrixUsageError(f"Refusing non-matrix account '{args.user}' for cell '{cells[0]}'") + repo_root = _repo_root() + base_ref, base_commit = _git_value(repo_root, args.base_ref) + run_id = f"matrix-{dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}" + output_dir = args.output_dir.resolve() / run_id + output_dir.mkdir(parents=True, exist_ok=False) + context = RunContext(base_ref, base_commit, run_id, repo_root, output_dir) + results: list[dict[str, Any]] = [] + all_passed = True + for cell in cells: + result, passed = _run_cell(scenario, cell, context) + results.append(result) + all_passed = all_passed and passed + print(f"{cell}: {result['status']} ({output_dir / f'{cell}.json'})") + _write_summary(results, output_dir) + print(f"Matrix run {run_id}: {'passed' if all_passed else 'failed'}") + return base_cli.ExitCode.SUCCESS if all_passed else base_cli.ExitCode.FAILURE + except MatrixUsageError as exc: + parser.error(str(exc)) + return base_cli.ExitCode.USAGE_ERROR diff --git a/cli/python/base_matrix/tests/__init__.py b/cli/python/base_matrix/tests/__init__.py new file mode 100644 index 00000000..c46c0151 --- /dev/null +++ b/cli/python/base_matrix/tests/__init__.py @@ -0,0 +1,3 @@ +"""Tests for the local macOS install-matrix runner.""" + +from __future__ import annotations diff --git a/cli/python/base_matrix/tests/test_runner.py b/cli/python/base_matrix/tests/test_runner.py new file mode 100644 index 00000000..5a290b10 --- /dev/null +++ b/cli/python/base_matrix/tests/test_runner.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import json +import os +import stat +import subprocess +import sys +from pathlib import Path + +import jsonschema +from base_matrix.runner import Step +from base_matrix.runner import _run_step + + +REPO_ROOT = Path(__file__).resolve().parents[4] +SCENARIO = REPO_ROOT / "tests/fixtures/macos_matrix/trivial.json" +SCENARIO_SCHEMA = REPO_ROOT / "docs/schemas/macos-matrix-scenario.json" +RESULT_SCHEMA = REPO_ROOT / "docs/schemas/macos-matrix-result.json" + + +def _environment(stub_dir: Path) -> dict[str, str]: + environment = os.environ.copy() + environment["PATH"] = f"{stub_dir}{os.pathsep}{environment['PATH']}" + environment["PYTHONPATH"] = os.pathsep.join( + [str(REPO_ROOT / "cli/python"), str(REPO_ROOT / "lib/python"), environment.get("PYTHONPATH", "")] + ) + return environment + + +def _write_sudo_stub(directory: Path) -> None: + script = directory / "sudo" + script.write_text( + "#!/bin/sh\n" + "if [ \"${BASE_MATRIX_STUB_MODE:-pass}\" = blocked ]; then exit 1; fi\n" + "while [ \"$1\" != \"--\" ]; do shift; done\n" + "shift\n" + "exec \"$@\"\n", + encoding="utf-8", + ) + script.chmod(script.stat().st_mode | stat.S_IXUSR) + + +def _run(tmp_path: Path, *args: str, mode: str = "pass") -> subprocess.CompletedProcess[str]: + stub_dir = tmp_path / "bin" + stub_dir.mkdir(exist_ok=True) + _write_sudo_stub(stub_dir) + environment = _environment(stub_dir) + environment["BASE_MATRIX_STUB_MODE"] = mode + return subprocess.run( + [sys.executable, "-m", "base_matrix", *args], + cwd=REPO_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + ) + + +def test_validate_accepts_trivial_scenario() -> None: + scenario_schema = json.loads(SCENARIO_SCHEMA.read_text(encoding="utf-8")) + jsonschema.Draft202012Validator(scenario_schema).validate(json.loads(SCENARIO.read_text(encoding="utf-8"))) + result = subprocess.run( + [sys.executable, "-m", "base_matrix", "validate", "--scenario", str(SCENARIO)], + cwd=REPO_ROOT, + env=_environment(Path("/nonexistent")), + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "Scenario valid: trivial v1" in result.stdout + + +def test_runner_emits_schema_valid_result_for_all_cells(tmp_path: Path) -> None: + result = _run( + tmp_path, + "run", + "--scenario", + str(SCENARIO), + "--cells", + "all", + "--output-dir", + str(tmp_path / "results"), + ) + + assert result.returncode == 0, result.stderr + assert sum( + line.startswith(f"{cell}: passed") + for line in result.stdout.splitlines() + for cell in ("source-bash", "source-zsh", "brew-bash", "brew-zsh") + ) == 4 + run_dir = next((tmp_path / "results").iterdir()) + schema = json.loads(RESULT_SCHEMA.read_text(encoding="utf-8")) + for cell in ("source-bash", "source-zsh", "brew-bash", "brew-zsh"): + payload = json.loads((run_dir / f"{cell}.json").read_text(encoding="utf-8")) + jsonschema.Draft202012Validator(schema).validate(payload) + assert payload["status"] == "passed" + assert payload["account"].endswith(cell.split("-", 1)[1]) + assert (run_dir / cell / payload["steps"][0]["log_path"]).exists() + + +def test_runner_refuses_non_matrix_account(tmp_path: Path) -> None: + result = _run( + tmp_path, + "run", + "--scenario", + str(SCENARIO), + "--cells", + "source-bash", + "--user", + "outside-matrix", + "--output-dir", + str(tmp_path / "results"), + ) + + assert result.returncode == 2 + assert "Refusing non-matrix account 'outside-matrix'" in result.stderr + + +def test_runner_blocks_when_passwordless_runas_is_unavailable(tmp_path: Path) -> None: + result = _run( + tmp_path, + "run", + "--scenario", + str(SCENARIO), + "--cells", + "source-bash", + "--output-dir", + str(tmp_path / "results"), + mode="blocked", + ) + + assert result.returncode == 1 + assert "source-bash: blocked" in result.stdout + run_dir = next((tmp_path / "results").iterdir()) + payload = json.loads((run_dir / "source-bash.json").read_text(encoding="utf-8")) + assert payload["status"] == "blocked" + assert payload["preconditions"][0]["finding_ids"] == ["matrix.runas.unavailable"] + assert payload["steps"] == [] + + +def test_runner_redacts_step_output_in_persistent_log(tmp_path: Path) -> None: + scenario = tmp_path / "secret.json" + scenario.write_text( + json.dumps( + { + "schema_version": 1, + "name": "redaction", + "version": "1", + "preconditions": [], + "steps": [ + { + "id": "secret-output", + "command": ["/usr/bin/printf", "TOKEN=ghp_matrix_secret\\n"], + "expected_exit": 0, + } + ], + } + ), + encoding="utf-8", + ) + result = _run( + tmp_path, + "run", + "--scenario", + str(scenario), + "--cells", + "source-bash", + "--output-dir", + str(tmp_path / "results"), + ) + + assert result.returncode == 0, result.stderr + run_dir = next((tmp_path / "results").iterdir()) + log_path = run_dir / "source-bash" / "logs" / "01-secret-output.log" + log_text = log_path.read_text(encoding="utf-8") + assert "ghp_matrix_secret" not in log_text + assert "TOKEN=[REDACTED]" in log_text + + +def test_runner_records_os_error_as_failed_step(tmp_path: Path, monkeypatch) -> None: + def raise_os_error(*_args, **_kwargs): + raise OSError("runner unavailable") + + monkeypatch.setattr("base_matrix.runner.subprocess.run", raise_os_error) + step = Step("missing-runner", ("/bin/true",), 0, (), (), (), (), (), 1.0, None) + + result, passed = _run_step(step, "base-source-bash", REPO_ROOT, tmp_path, "logs/01-step.log") + + assert not passed + assert result["status"] == "failed" + assert result["error"] == "runner unavailable" + assert (tmp_path / "logs/01-step.log").exists() diff --git a/docs/README.md b/docs/README.md index 8368a894..75975abc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -115,6 +115,8 @@ reference. The filename should answer "what is this about?" shortest supported install-to-verified-project-PR journey. - [Clean macOS Install Validation](macos-install-validation.md) defines the repeatable Homebrew and source checkout validation checklist. +- [Local macOS Install Matrix](macos-install-matrix.md) defines the guarded + four-account scenario runner and maintainer-only account preparation. - [Execution Model](execution-model.md) documents the current `basectl` runtime, dispatch order, public launchers, and runtime shell behavior. - [Linux Support](linux-support.md) defines the Ubuntu/Debian runtime support, diff --git a/docs/macos-install-matrix.md b/docs/macos-install-matrix.md new file mode 100644 index 00000000..4c872f2e --- /dev/null +++ b/docs/macos-install-matrix.md @@ -0,0 +1,83 @@ +# Local macOS Install Matrix + +Base's local install matrix runs controlled scenarios as four dedicated macOS +accounts: + +| Cell | Account | Route | Login shell | +| --- | --- | --- | --- | +| `source-bash` | `base-source-bash` | source checkout | Homebrew Bash | +| `source-zsh` | `base-source-zsh` | source checkout | Zsh | +| `brew-bash` | `base-brew-bash` | Homebrew | Homebrew Bash | +| `brew-zsh` | `base-brew-zsh` | Homebrew | Zsh | + +The matrix is local macOS validation, not hosted CI. It deliberately does not +create a second Homebrew prefix or mutate accounts during an ordinary test +run. + +## One-time administrator setup + +Review the machine changes first: + +```bash +./tools/macos_matrix_admin.sh --dry-run +``` + +The applying command must run with administrator privileges. It: + +- writes `/etc/sudoers.d/base-macos-install-matrix`, scoped to the current + operator and the four named matrix accounts; +- registers the Homebrew Bash path in `/etc/shells` when needed; +- assigns the two Bash accounts to Homebrew Bash and leaves the two Zsh + accounts on `/bin/zsh`; and +- records the prior login shells and whether it added the shell entry. + +After reviewing the dry-run, apply it with: + +```bash +sudo ./tools/macos_matrix_admin.sh +``` + +The setup is idempotent. To restore the recorded account and shell state: + +```bash +sudo ./tools/macos_matrix_admin.sh --remove +``` + +`--remove` only removes the sudoers file, shell entry, and login-shell changes +recorded by this setup. It does not remove Homebrew, checkouts, or user data. + +## Scenario runner + +Validate a scenario without running it: + +```bash +./bin/base-matrix validate --scenario tests/fixtures/macos_matrix/trivial.json +``` + +Run the trivial contract in all four cells: + +```bash +./bin/base-matrix run \ + --scenario tests/fixtures/macos_matrix/trivial.json \ + --cells all \ + --output-dir /tmp/base-macos-matrix +``` + +The runner performs a non-interactive `sudo -n -u -i` preflight before +running scenario steps. It refuses accounts outside the fixed matrix and +blocks before scenario execution when passwordless runas is unavailable. A +scenario contains a version, preconditions, ordered steps, expected exit +codes, output assertions, optional finding IDs, and timeouts. Commands are +argument arrays rather than shell strings. + +Each cell produces a result JSON document validated by +`docs/schemas/macos-matrix-result.json`, plus mode `0600` logs. Captured output +is redacted before it is persisted. Results include the full Base commit, +route, shell, account, platform, preconditions, step durations, finding IDs, +and relative log paths so later matrix-evidence tooling can reject stale or +ambiguous runs. + +The real Homebrew upgrade and account-state validation remains a maintainer-run +step. Attach the result JSON and the exact host context to the implementation +PR; do not put private account paths, credentials, or unredacted logs into +public release evidence. diff --git a/docs/macos-install-validation.md b/docs/macos-install-validation.md index a429a743..3ec06a9b 100644 --- a/docs/macos-install-validation.md +++ b/docs/macos-install-validation.md @@ -13,6 +13,10 @@ The checklist has two goals: - keep the boundary between automated CI checks and manual macOS user testing explicit +The reusable local runner and one-time account preparation contract are +documented in [Local macOS Install Matrix](macos-install-matrix.md). Use that +runner for the four-account scenario evidence described below. + If a checklist step exposes a missing automated check, file a focused follow-up issue instead of expanding this checklist into a second test suite. diff --git a/docs/schemas/macos-matrix-result.json b/docs/schemas/macos-matrix-result.json new file mode 100644 index 00000000..b4024abc --- /dev/null +++ b/docs/schemas/macos-matrix-result.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base/schemas/macos-matrix-result.json", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "run_id", "scenario", "base", "platform", "cell", "account", "route", "shell", "started_at", "completed_at", "status", "preconditions", "steps", "summary"], + "properties": { + "schema_version": {"const": 1}, + "run_id": {"type": "string", "minLength": 1}, + "scenario": {"$ref": "#/$defs/scenario"}, + "base": { + "type": "object", + "additionalProperties": false, + "required": ["ref", "commit"], + "properties": { + "ref": {"type": "string", "minLength": 1}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"} + } + }, + "platform": { + "type": "object", + "additionalProperties": false, + "required": ["os", "os_version", "architecture"], + "properties": { + "os": {"type": "string", "minLength": 1}, + "os_version": {"type": "string", "minLength": 1}, + "architecture": {"type": "string", "minLength": 1} + } + }, + "cell": {"enum": ["source-bash", "source-zsh", "brew-bash", "brew-zsh"]}, + "account": {"enum": ["base-source-bash", "base-source-zsh", "base-brew-bash", "base-brew-zsh"]}, + "route": {"enum": ["source", "homebrew"]}, + "shell": {"enum": ["bash", "zsh"]}, + "started_at": {"type": "string", "minLength": 1}, + "completed_at": {"type": "string", "minLength": 1}, + "status": {"enum": ["passed", "failed", "blocked"]}, + "preconditions": {"type": "array", "items": {"$ref": "#/$defs/step_result"}}, + "steps": {"type": "array", "items": {"$ref": "#/$defs/step_result"}}, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["status", "failed_steps", "message"], + "properties": { + "status": {"enum": ["passed", "failed", "blocked"]}, + "failed_steps": {"type": "array", "items": {"type": "string"}}, + "message": {"type": "string", "minLength": 1} + } + } + }, + "$defs": { + "scenario": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "version": {"type": "string", "minLength": 1} + } + }, + "step_result": { + "type": "object", + "additionalProperties": false, + "required": ["id", "command", "expected_exit", "actual_exit", "status", "duration_ms", "finding_ids", "log_path"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "command": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, + "expected_exit": {"type": "integer", "minimum": 0, "maximum": 255}, + "actual_exit": {"type": ["integer", "null"], "minimum": 0, "maximum": 255}, + "status": {"enum": ["passed", "failed", "blocked"]}, + "duration_ms": {"type": "integer", "minimum": 0}, + "finding_ids": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "log_path": {"type": "string", "minLength": 1}, + "error": {"type": "string", "minLength": 1} + } + } + } +} diff --git a/docs/schemas/macos-matrix-scenario.json b/docs/schemas/macos-matrix-scenario.json new file mode 100644 index 00000000..efde4c2a --- /dev/null +++ b/docs/schemas/macos-matrix-scenario.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base/schemas/macos-matrix-scenario.json", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "name", "version", "preconditions", "steps"], + "properties": { + "schema_version": {"const": 1}, + "name": {"type": "string", "minLength": 1, "pattern": "^[a-z0-9][a-z0-9._-]*$"}, + "version": {"type": "string", "minLength": 1}, + "description": {"type": "string"}, + "preconditions": {"type": "array", "items": {"$ref": "#/$defs/step"}}, + "steps": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/step"}} + }, + "$defs": { + "step": { + "type": "object", + "additionalProperties": false, + "required": ["id", "command", "expected_exit"], + "properties": { + "id": {"type": "string", "minLength": 1, "pattern": "^[a-z0-9][a-z0-9._-]*$"}, + "command": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, + "expected_exit": {"type": "integer", "minimum": 0, "maximum": 255}, + "stdout_contains": {"type": "array", "items": {"type": "string"}}, + "stderr_contains": {"type": "array", "items": {"type": "string"}}, + "stdout_not_contains": {"type": "array", "items": {"type": "string"}}, + "stderr_not_contains": {"type": "array", "items": {"type": "string"}}, + "finding_ids": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "timeout_seconds": {"type": "number", "exclusiveMinimum": 0}, + "cwd": {"type": "string", "minLength": 1} + } + } + } +} diff --git a/tests/fixtures/macos_matrix/trivial.json b/tests/fixtures/macos_matrix/trivial.json new file mode 100644 index 00000000..b7fe664d --- /dev/null +++ b/tests/fixtures/macos_matrix/trivial.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "name": "trivial", + "version": "1", + "description": "Minimal scenario used to validate the local matrix runner contract.", + "preconditions": [ + { + "id": "identity", + "command": ["/usr/bin/id", "-un"], + "expected_exit": 0 + } + ], + "steps": [ + { + "id": "healthy", + "command": ["/usr/bin/printf", "matrix-ok\\n"], + "expected_exit": 0, + "stdout_contains": ["matrix-ok"] + } + ] +} diff --git a/tools/macos_matrix_admin.sh b/tools/macos_matrix_admin.sh new file mode 100755 index 00000000..5e42acd5 --- /dev/null +++ b/tools/macos_matrix_admin.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash + +# Configure the four local macOS accounts used by the install matrix. This +# script is intentionally separate from Base's ordinary setup path: it changes +# login shells and a narrowly scoped sudoers drop-in and therefore belongs to a +# maintainer-run, one-time machine preparation step. + +MATRIX_USERS=(base-source-bash base-source-zsh base-brew-bash base-brew-zsh) +MATRIX_SUDOERS_FILE="${BASE_MATRIX_SUDOERS_FILE:-/etc/sudoers.d/base-macos-install-matrix}" +MATRIX_STATE_FILE="${BASE_MATRIX_STATE_FILE:-/var/db/base-macos-install-matrix/state}" +MATRIX_SHELLS_FILE="${BASE_MATRIX_SHELLS_FILE:-/etc/shells}" +MATRIX_OPERATOR="${BASE_MATRIX_OPERATOR:-${SUDO_USER:-$(id -un 2>/dev/null)}}" +MATRIX_DSCL="${BASE_MATRIX_DSCL:-/usr/bin/dscl}" +MATRIX_VISUDO="${BASE_MATRIX_VISUDO:-/usr/sbin/visudo}" +MATRIX_INSTALL="${BASE_MATRIX_INSTALL:-/usr/bin/install}" +MATRIX_BASH_PATH="${BASE_MATRIX_BASH_PATH:-}" + +usage() { + cat <<'EOF' +Usage: + tools/macos_matrix_admin.sh [--dry-run] + tools/macos_matrix_admin.sh --remove [--dry-run] + tools/macos_matrix_admin.sh --help + +Configure or remove the local four-account macOS install-matrix contract. + +The normal action writes a scoped sudoers drop-in, registers the Homebrew Bash +login shell, and sets the two Bash matrix accounts to that shell. --dry-run +prints the planned actions without requiring root or changing the machine. +--remove restores the login shells and removes only state recorded by this +script. +EOF +} + +log() { + printf '%s\n' "$*" +} + +error() { + printf 'ERROR: %s\n' "$*" >&2 +} + +fail() { + error "$*" + return 1 +} + +is_dry_run=0 +is_remove=0 + +for arg in "$@"; do + case "$arg" in + --dry-run) is_dry_run=1 ;; + --remove) is_remove=1 ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; error "Unknown option '$arg'."; exit 2 ;; + esac +done + +if [[ -z "$MATRIX_BASH_PATH" ]]; then + if ((is_remove)) && [[ -f "$MATRIX_STATE_FILE" ]]; then + MATRIX_BASH_PATH="$(awk -F= '$1 == "bash_path" {print substr($0, index($0, "=") + 1); exit}' "$MATRIX_STATE_FILE")" + elif ((is_remove)); then + MATRIX_BASH_PATH=/opt/homebrew/bin/bash + elif [[ -x /opt/homebrew/bin/bash ]]; then + MATRIX_BASH_PATH=/opt/homebrew/bin/bash + elif [[ -x /usr/local/bin/bash ]]; then + MATRIX_BASH_PATH=/usr/local/bin/bash + else + error "Homebrew Bash was not found at /opt/homebrew/bin/bash or /usr/local/bin/bash." + exit 1 + fi +fi + +require_root() { + local uid + uid="$(id -u 2>/dev/null)" || return 1 + ((uid == 0)) || fail "This action changes macOS account state and must run as root (use --dry-run to preview it)." +} + +read_user_shell() { + local user="$1" output line + output="$($MATRIX_DSCL . -read "/Users/$user" UserShell 2>/dev/null)" || return 1 + while IFS= read -r line; do + case "$line" in + UserShell:*) printf '%s\n' "${line#UserShell: }"; return 0 ;; + esac + done <<<"$output" + return 1 +} + +state_value() { + local key="$1" + [[ -f "$MATRIX_STATE_FILE" ]] || return 1 + awk -F= -v wanted="$key" '$1 == wanted {print substr($0, index($0, "=") + 1); exit}' "$MATRIX_STATE_FILE" +} + +state_user_shell() { + local user="$1" + [[ -f "$MATRIX_STATE_FILE" ]] || return 1 + awk -F '\t' -v wanted="$user" '$1 == "user" && $2 == wanted {print $3; exit}' "$MATRIX_STATE_FILE" +} + +write_state() { + local state_dir user shell + state_dir="$(dirname "$MATRIX_STATE_FILE")" + mkdir -p "$state_dir" || return 1 + { + printf 'version=1\n' + printf 'bash_path=%s\n' "$MATRIX_BASH_PATH" + printf 'operator=%s\n' "$MATRIX_OPERATOR" + printf 'shell_added=%s\n' "$(grep -Fxq "$MATRIX_BASH_PATH" "$MATRIX_SHELLS_FILE" 2>/dev/null && printf 0 || printf 1)" + for user in "${MATRIX_USERS[@]}"; do + shell="$(read_user_shell "$user")" || return 1 + printf 'user\t%s\t%s\n' "$user" "$shell" + done + } >"$MATRIX_STATE_FILE" || return 1 + chmod 0600 "$MATRIX_STATE_FILE" || return 1 +} + +ensure_shell_entry() { + if grep -Fxq "$MATRIX_BASH_PATH" "$MATRIX_SHELLS_FILE" 2>/dev/null; then + log "Homebrew Bash is already registered in $MATRIX_SHELLS_FILE." + return 0 + fi + log "Registering $MATRIX_BASH_PATH in $MATRIX_SHELLS_FILE." + ((is_dry_run)) && return 0 + printf '%s\n' "$MATRIX_BASH_PATH" >>"$MATRIX_SHELLS_FILE" || return 1 +} + +write_sudoers() { + local sudoers_dir temp + sudoers_dir="$(dirname "$MATRIX_SUDOERS_FILE")" + mkdir -p "$sudoers_dir" || return 1 + temp="${MATRIX_SUDOERS_FILE}.tmp.$$" + printf '%s ALL = (base-source-bash, base-source-zsh, base-brew-bash, base-brew-zsh) NOPASSWD: ALL\n' "$MATRIX_OPERATOR" >"$temp" || return 1 + if ! "$MATRIX_VISUDO" -cf "$temp" >/dev/null 2>&1; then + rm -f "$temp" + return 1 + fi + "$MATRIX_INSTALL" -o root -g wheel -m 0440 "$temp" "$MATRIX_SUDOERS_FILE" || { + rm -f "$temp" + return 1 + } + rm -f "$temp" +} + +configure() { + local user desired current + if ((is_dry_run)); then + log "DRY-RUN: would configure matrix accounts: ${MATRIX_USERS[*]}" + log "DRY-RUN: would grant $MATRIX_OPERATOR passwordless sudo only when running as the four matrix users." + log "DRY-RUN: would register $MATRIX_BASH_PATH in $MATRIX_SHELLS_FILE." + else + require_root || return 1 + if [[ ! -f "$MATRIX_STATE_FILE" ]]; then + write_state || return 1 + else + MATRIX_BASH_PATH="$(state_value bash_path)" || return 1 + log "Using existing matrix state at $MATRIX_STATE_FILE." + fi + fi + + ensure_shell_entry || return 1 + if ((is_dry_run)); then + for user in "${MATRIX_USERS[@]}"; do + desired=/bin/zsh + [[ "$user" == *-bash ]] && desired="$MATRIX_BASH_PATH" + log "DRY-RUN: would set $user login shell to $desired." + done + log "DRY-RUN: would validate and install $MATRIX_SUDOERS_FILE." + return 0 + fi + + for user in "${MATRIX_USERS[@]}"; do + desired=/bin/zsh + [[ "$user" == *-bash ]] && desired="$MATRIX_BASH_PATH" + current="$(read_user_shell "$user")" || return 1 + if [[ "$current" == "$desired" ]]; then + log "$user already uses $desired." + else + log "Setting $user login shell to $desired." + "$MATRIX_DSCL" . -create "/Users/$user" UserShell "$desired" || return 1 + fi + done + log "Validating and installing $MATRIX_SUDOERS_FILE." + write_sudoers || return 1 + log "macOS install-matrix admin setup complete." +} + +remove_configuration() { + local user old_shell shell_added + if [[ ! -f "$MATRIX_STATE_FILE" ]]; then + log "No matrix state exists at $MATRIX_STATE_FILE; nothing to remove." + return 0 + fi + MATRIX_BASH_PATH="$(state_value bash_path)" || return 1 + if ((is_dry_run)); then + log "DRY-RUN: would restore matrix account login shells from $MATRIX_STATE_FILE." + log "DRY-RUN: would remove $MATRIX_SUDOERS_FILE." + if [[ "$(state_value shell_added)" == 1 ]]; then + log "DRY-RUN: would remove $MATRIX_BASH_PATH from $MATRIX_SHELLS_FILE." + fi + return 0 + fi + require_root || return 1 + for user in "${MATRIX_USERS[@]}"; do + old_shell="$(state_user_shell "$user")" || return 1 + log "Restoring $user login shell to $old_shell." + "$MATRIX_DSCL" . -create "/Users/$user" UserShell "$old_shell" || return 1 + done + shell_added="$(state_value shell_added)" + if [[ "$shell_added" == 1 ]] && grep -Fxq "$MATRIX_BASH_PATH" "$MATRIX_SHELLS_FILE"; then + log "Removing $MATRIX_BASH_PATH from $MATRIX_SHELLS_FILE." + grep -Fvx "$MATRIX_BASH_PATH" "$MATRIX_SHELLS_FILE" >"${MATRIX_SHELLS_FILE}.tmp.$$" || { + [[ "$?" -eq 1 ]] || return 1 + } + "$MATRIX_INSTALL" -o root -g wheel -m 0644 "${MATRIX_SHELLS_FILE}.tmp.$$" "$MATRIX_SHELLS_FILE" || return 1 + rm -f "${MATRIX_SHELLS_FILE}.tmp.$$" + fi + if [[ -e "$MATRIX_SUDOERS_FILE" ]]; then + log "Removing $MATRIX_SUDOERS_FILE." + rm -f "$MATRIX_SUDOERS_FILE" || return 1 + fi + rm -f "$MATRIX_STATE_FILE" || return 1 + log "macOS install-matrix admin setup removed." +} + +if ((is_remove)); then + remove_configuration +else + configure +fi