diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 9ccb9b2..660c899 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -15,7 +15,10 @@ jobs: run: | python -m pip install --upgrade pip pip install pytest pytest-cov - pip install . + # With the 'tui' extra, so the interface's own tests actually run here. Installed + # plain, they skip -- and every line behind them counts as uncovered, which is a + # coverage report describing the environment rather than the tests. + pip install '.[tui]' - name: Run tests with coverage run: | pytest --cov-branch --cov-report=xml --cov=tirith tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b1b1332..19330f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [Unreleased] + +### Added +- `tirith ui`: an interactive interface with three tabs. + - **Explorer** — read an evaluation's results down to the resource behind each one. The result + document has always carried the resource address, the planned action and the before/after + values; the pretty printer prints only the message, so this detail was reachable only by + piping `--json` into another tool. Opens on the first failure, names replacements by their + ordering (destroy-first and create-first mean different things), and shows the attributes + that changed, flagging the ones that are unknown until apply. + - **Builder** — assemble a policy from a form whose fields follow the chosen provider and + operation. Values keep their JSON types, so `Equals: true` and `Equals: "true"` stay + distinguishable. + - **Playground** — edit a policy and an input side by side and watch the verdict move, with + five worked examples that mostly fail on purpose and explain why. + - `--serve` runs the same interface over HTTP for a browser. +- Optional extra: `pip install 'py-tirith[tui]'`. Not a hard dependency — the interface needs + Python 3.9 while tirith supports 3.8, and using tirith as a CI gate should stay + dependency-light. Without it, `tirith ui` prints how to install it and exits 1. +- A policy validator behind the interface, reporting the mistakes that are otherwise silent: + a provider argument the operation does not read, an id referenced in `eval_expression` but + never defined, a single `&` where `&&` was meant, an evaluator that does not exist. + +### Notes +- The local evaluation surface is untouched. `ui` is dispatched before the flat parser, like + `platform`, so `--json` output remains byte-identical to the golden file. +- No new runtime dependencies for anyone who does not install the extra. + ## [1.2.0] - 2026-08-03 ### Added diff --git a/MANIFEST.in b/MANIFEST.in index afc4cb9..3074ec4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -16,6 +16,9 @@ recursive-include resources *.svg recursive-include resources Makefile recursive-include src *.json recursive-include src *.md +# The TUI's stylesheet, and the about.md beside each bundled playground example. Without +# these the installed interface loads unstyled and the playground has nothing to open. +recursive-include src *.css recursive-include src *.new recursive-include src *.old recursive-include src *.py diff --git a/README.md b/README.md index 62a3acc..e84cece 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ verdict, same exit codes. That mode is optional and is the only part that talks - [Features](#features) - [Installation](#installation) - [Usage](#usage) +- [The interactive interface](#the-interactive-interface) + - [Explorer](#explorer) + - [Builder](#builder) + - [Playground](#playground) + - [Serving it on a port](#serving-it-on-a-port) - [Run it in CI](#run-it-in-ci) - [Exit codes](#exit-codes) - [Evaluating against your StackGuardian organization](#evaluating-against-your-stackguardian-organization) @@ -186,6 +191,8 @@ Subcommands: tirith platform check --help Evaluate against the policies your StackGuardian organization enforces, rather than local files. + tirith ui --help Explore results, build policies and experiment in + an interactive interface. Needs the 'tui' extra. About Tirith: @@ -198,6 +205,124 @@ About Tirith: ``` +## The interactive interface + +`tirith ui` opens a terminal interface with three tabs: an **Explorer** for reading results, a +**Builder** for assembling policies, and a **Playground** for experimenting. + +It is an optional extra, because tirith's main job is to be a dependency-light CI gate and +nobody gating a pipeline should pay to install an interface they never open. It needs Python +3.9 or newer, while tirith itself still supports 3.8: + +```bash +pip install 'py-tirith[tui]' +``` + +```bash +tirith ui # playground, with worked examples +tirith ui --policy policy.json --input plan.json # evaluate yours, open on the results +tirith ui --result result.json # an evaluation you already ran +tirith --json -policy-path p.json -input-path plan.json | tirith ui --result - +``` + +Naming both a policy and an input evaluates them and opens the **Explorer**, because that is +what you came to see. With only a policy, or nothing at all, it opens the Playground. + +### Explorer + +The output of `--json` and the pretty printer both tell you *that* a check failed. Neither +tells you *which resource* failed it — although the result document has carried the resource's +address, its planned action and its before/after values all along. + +The Explorer shows them. Selecting a failing result names the resource +(`aws_db_instance.primary`), the action in terraform's own vocabulary (**replace (destroy +first)** — distinct from create-first, because only one of them means downtime), and the +attributes that changed, including the ones that are unknown until apply. + +This matters most on a wildcard policy, where every message reads identically +(`` `"product-456"` is not empty ``) and only the address distinguishes one row from another. + +Three ways to get your own results in front of it: + +```bash +# 1. Evaluate now. Opens on the Explorer with the first failure selected. +tirith ui --policy policy.json --input plan.json + +# 2. A result you saved earlier -- a CI artifact, a colleague's run. +tirith --json -policy-path policy.json -input-path plan.json > result.json +tirith ui --result result.json + +# 3. Straight off a pipe, without the intermediate file. +tirith --json -policy-path policy.json -input-path plan.json | tirith ui --result - +``` + +The pipe needs a terminal to run in, since it is an interactive interface: if stdin is a pipe +and there is no terminal behind it — a CI job with output redirected — it says so instead of +starting and immediately exiting. `--serve` cannot read stdin at all, because the served +interface is a separate process with its own; pass it a file path. + +### Builder + +Pick a provider, an operation and a condition; the form's fields change to whatever that +operation actually accepts, and the policy JSON is generated as you go. The provider argument +names are not guessable — `stackguardian/json` reads `key_path` while +`stackguardian/kubernetes` reads `attribute_path`, and the terraform provider alone has seven +operations taking different arguments — so the form exists to stop you writing a policy that +parses cleanly and silently matches nothing. + +Values keep their JSON types: typing `true` gives you a boolean, `["a","b"]` a list, and +`production` the string, because `Equals: "true"` and `Equals: true` are different questions. + +**How the checks combine** is its own field, holding the policy's `eval_expression`: + +| | | +| --- | --- | +| `a && b` | both must pass | +| `a \|\| b` | either may pass | +| `!a` | passes when the check *fails* — how you write a detector | +| `(a \|\| b) && c` | grouping | + +It fills itself in with every check `&&`-ed together, and stops doing that the moment you edit +it. The expression is the one part of a policy that cannot be derived from the checks, so +regenerating it after you have written `a && !b` would throw away the only thing you could not +have expressed any other way. + +The form also names the document each provider expects, because choosing a provider is +choosing what you have to feed it. + +### Playground + +Load one of the bundled examples, change something, watch the verdict move. Evaluation runs as +you type. Broken JSON, a half-written policy and a provider that raises are all reported in the +findings pane rather than as a traceback — while you are editing, the broken state is the +normal state. + +The examples are worked lessons rather than fixtures. Most of them fail on purpose, and each +one's notes explain the mechanism it demonstrates and what to try next: + +| Example | Demonstrates | +| --- | --- | +| Required tags | One check, one condition, nested attributes. Why `error_tolerance` can turn a failure into a *skip* — and why a skip is not a pass. | +| No public buckets | Two checks joined with `&&`; two buckets, one at fault. | +| Cost ceiling | The infracost provider, and why a misspelled resource type sums to `0` and fails open. | +| Block destroy | A database being replaced inside a routine plan, and the attribute that forced it. | +| Kubernetes probes | Wildcard paths, why `IsNotEmpty` is the wrong question over a list, and the `!` operator. | + +### Serving it on a port + +The same interface runs in a browser, which is useful for sharing a result with someone who +does not have tirith installed: + +```bash +tirith ui --serve --port 8000 # then open http://localhost:8000 +``` + +It is the same interface relayed to the browser, not a second web-only implementation, so it +behaves identically and there is nothing extra to keep in sync. + +Bind address and port are yours to choose, but note the served interface can read any file path +the serving process can. Keep it on `localhost` unless you have a reason not to. + ## Run it in CI ### GitHub Actions diff --git a/setup.py b/setup.py index 667e0b5..6dc8be0 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,14 @@ def read(*names, **kwargs): package_dir={"": "src"}, py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")], include_package_data=True, + # Declared explicitly as well as in MANIFEST.in: MANIFEST governs the sdist, but a wheel + # built straight from the tree takes its data files from here. Without this the TUI + # installs with no stylesheet and no examples -- it starts, and every playground pane is + # empty, which is a worse failure than not starting at all. + package_data={ + "tirith.tui": ["*.css"], + "tirith.tui.examples": ["*/*.json", "*/*.md"], + }, zip_safe=False, classifiers=[ # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers @@ -71,9 +79,22 @@ def read(*names, **kwargs): python_requires=">=3.8", install_requires=["simplejson==3.17.2", "pydash==6.0.0", "PyYAML==6.0.1"], extras_require={ - # eg: - # 'rst': ['docutils>=0.11'], - # ':python_version=="2.6"': ['argparse'], + # `pip install py-tirith[tui]` adds the interactive interface (`tirith ui`). + # + # An extra rather than a dependency, for two reasons. The UI toolkit requires Python + # >=3.9 while tirith supports >=3.8, so a hard dependency would drop 3.8 support for + # everyone; the environment markers below let 3.8 users install the extra and simply + # get nothing rather than an error. And tirith's main use is as a CI gate, where every + # dependency is install time on every run -- people gating a pipeline should not pay + # for an interface they never open. + # textual>=8.0 rather than a looser floor: `Select.NULL` (the unselected sentinel the + # Playground and Builder both test against) only exists from 8.0. Before that it was + # named Select.BLANK, so on 0.60-7.x the example picker raises AttributeError the first + # time the prompt row is chosen. `Select(compact=...)` is likewise newer than 0.60. + "tui": [ + 'textual>=8.0; python_version >= "3.9"', + 'textual-serve>=1.0; python_version >= "3.9"', + ], }, setup_requires=[ "pytest-runner", diff --git a/src/tirith/cli.py b/src/tirith/cli.py index f08c85e..f75c941 100755 --- a/src/tirith/cli.py +++ b/src/tirith/cli.py @@ -40,7 +40,13 @@ def eprint(*args, **kwargs): # `--fail-on-error` fixed. No alias in either direction: nothing is released, so there is no caller to # keep working. SUBCOMMAND = "platform" -SUBCOMMANDS = {SUBCOMMAND} + +# `ui` joins it on the same terms: dispatched before the flat parser, so the local-evaluation +# surface and its golden-file output are untouched. It is an optional extra -- it needs Python +# 3.9 and tirith supports 3.8 -- so tui/cli.py reports the missing extra rather than failing on +# an import here. +UI_SUBCOMMAND = "ui" +SUBCOMMANDS = {SUBCOMMAND, UI_SUBCOMMAND} def main(args=None) -> ExitStatus: @@ -54,6 +60,11 @@ def main(args=None) -> ExitStatus: """ argv = list(sys.argv[1:] if args is None else args) + if argv and argv[0] == UI_SUBCOMMAND: + from tirith.tui import cli as tui_cli + + return tui_cli.main(argv) + if argv and argv[0] in SUBCOMMANDS: from tirith.platform import cli as platform_cli @@ -74,6 +85,8 @@ def __init__(self, prog="PROG") -> None: tirith platform check --help Evaluate against the policies your StackGuardian organization enforces, rather than local files. + tirith ui --help Explore results, build policies and experiment in + an interactive interface. Needs the 'tui' extra. About Tirith: diff --git a/src/tirith/tui/__init__.py b/src/tirith/tui/__init__.py new file mode 100644 index 0000000..73869d1 --- /dev/null +++ b/src/tirith/tui/__init__.py @@ -0,0 +1,31 @@ +""" +Interactive interface for Tirith: explore results, build policies, experiment in a playground. + +Optional. Everything that needs the UI toolkit lives in .app and its views, imported lazily by +run() rather than here, so that: + + * `import tirith.tui.schema` works without the extra installed -- which is what lets the + schema drift-guard tests run on CI's Python 3.8 leg, where the toolkit cannot be installed + at all (it requires >=3.9, tirith supports >=3.8); and + * a user who installed plain `py-tirith` gets an actionable message instead of an + ImportError traceback. +""" + +TUI_EXTRA_HINT = ( + "The Tirith interactive interface needs the optional 'tui' extra:\n" + " pip install 'py-tirith[tui]'\n" + "It is optional so that using tirith as a CI gate stays dependency-light. It needs " + "Python 3.9 or newer; tirith itself supports 3.8." +) + + +def run(argv=None): + """ + Entry point for `tirith ui`. Imports the app lazily; see the module docstring. + + :param argv: Arguments after the `ui` subcommand. + :return: An ExitStatus. + """ + from .cli import main + + return main(argv or []) diff --git a/src/tirith/tui/app.css b/src/tirith/tui/app.css new file mode 100644 index 0000000..a112ee3 --- /dev/null +++ b/src/tirith/tui/app.css @@ -0,0 +1,482 @@ +/* + * Styling for the Tirith TUI. + * + * Colours come from Textual's theme variables ($success, $error, $warning, ...) rather than + * being hard-coded, so the app follows the user's light/dark preference and stays legible on + * a terminal whose palette we do not control. The one thing never encoded by colour alone is + * a verdict: pass/fail/skip always carry a glyph too, because a red/green distinction is + * invisible to a good fraction of users and to anyone piping this through a recorder. + */ + +/* + * The app fills the terminal and never scrolls as a whole: each pane scrolls its own content. + * Without this the Screen itself scrolled once the fixed-height rows (banner, tabs, headers, + * footer) exceeded a short viewport, which put a scrollbar down the far right edge of the + * *application* and let the banner scroll out of view -- leaving a bare tab row at the top. + */ +Screen { + layers: base overlay; + overflow: hidden hidden; +} + +/* ---------------------------------------------------------------- header */ + +/* + * A taller, louder title bar. The default Header renders the app name in the same weight as + * everything else, which on a screen this dense reads as another row of chrome rather than as + * the name of the thing you are looking at. + */ +/* + * The wordmark, drawn over the right-hand end of the tab row on the overlay layer. It costs + * no height this way, which is what keeps the layout inside a short viewport -- as its own + * band it pushed the app past the bottom of the screen and made the whole thing scroll. + * + * `dock: top` with a right-aligned single row puts it opposite the tabs, where the eye is not + * already busy. + */ +/* + * Docked left, so the name is the first thing read and the tabs follow it -- the order a + * window title and its tabs normally appear in. + * + * Only as wide as its text: a full-width overlay swallows the row it spans whether or not it + * paints anything there, which hid the tab labels underneath. + */ +#app-banner { + layer: overlay; + dock: left; + height: 1; + width: auto; + padding: 0 2; + color: $text-muted; + background: $surface; +} + +/* + * Indented clear of the wordmark, which is drawn over this row's left end: without this the + * tab labels start at column 0 and the overlay eats them. + * + * 34 = the banner's 28 characters, its 2+2 padding, and two columns of gap. It has to be at + * least the banner's full width -- 27 clipped "Explorer" to "orer". + */ +#tabs Tabs { + padding-left: 34; +} + +/* ---------------------------------------------------------------- shared */ + +.pane-title { + background: $panel; + color: $text-muted; + padding: 0 1; + text-style: bold; + width: 100%; +} + +.verdict-pass { + color: $success; + text-style: bold; +} + +.verdict-fail { + color: $error; + text-style: bold; +} + +.verdict-skip { + color: $text-muted; + text-style: bold; +} + +.verdict-error { + color: $warning; + text-style: bold; +} + +.hint { + color: $text-muted; + padding: 1 2; +} + +/* + * The one-line "what this tab is for" strip at the top of each view. A single row by design: + * the Builder's form and the Playground's editors both need the vertical space more than a + * paragraph of prose does, and a description nobody can fit on screen helps nobody. + */ +.tab-description { + color: $text-muted; + background: $surface; + padding: 0 1; + height: 1; +} + +.empty-state { + color: $text-muted; + padding: 2 4; + text-align: center; +} + +/* ------------------------------------------------------------- explorer */ + +#explorer-body { + height: 1fr; +} + +#check-tree { + width: 42%; + border-right: solid $panel; + padding: 0 1; +} + +#detail-pane { + width: 1fr; + padding: 0 1; + overflow-y: auto; +} + +#report-summary { + background: $panel; + padding: 0 1; + height: auto; +} + +/* Hidden via .display = False in the view when there are no errors, which also removes it + * from the layout. */ +#report-errors { + color: $error; + padding: 0 1; + height: auto; +} + +.detail-heading { + text-style: bold; + padding: 1 0 0 0; +} + +.detail-message { + padding: 0 0 1 0; +} + +.resource-address { + color: $accent; + text-style: bold; +} + +.attribute-diff { + padding: 0 0 0 2; +} + +/* -------------------------------------------------------------- builder */ + +#builder-body { + height: 1fr; +} + +#builder-form { + width: 50%; + border-right: solid $panel; + padding: 0 1; + overflow-y: auto; +} + +#builder-preview { + width: 1fr; + padding: 0 1; +} + +.field-label { + text-style: bold; + height: 1; +} + +.field-help { + color: $text-muted; + height: auto; +} + +/* Argument name and its help on one row, to keep the form within a screen. */ +.field-label-inline { + height: auto; +} + +.field-required { + color: $warning; +} + +/* + * No vertical margin. Textual's Input and Select each carry a border, so a margin on top of + * that spaced the form out enough to push the Condition section below the fold on a normal + * terminal -- the form was unusable without scrolling to fields that should have been visible. + */ +#builder-form Input, #builder-form Select { + margin: 0; +} + +#builder-form Select { + height: 3; +} + +#check-list { + height: auto; + max-height: 6; + border: solid $panel; +} + +/* The JSON takes the slack, so the controls below it keep a fixed place on screen. */ +#policy-json-scroll { + height: 1fr; + min-height: 6; +} + +#builder-preview Input { + margin: 0; +} + +/* ----------------------------------------------------------- playground */ + +/* Takes the height left over by the description strip; without this the two columns inside it + * collapse to their content height and the editors disappear. */ +#playground-body { + height: 1fr; +} + +/* + * The column itself never scrolls -- each TextArea inside it scrolls its own document, so a + * scrollbar here is a second one at the same edge that moves nothing the first one does not. + */ +#playground-editors { + width: 55%; + border-right: solid $panel; + overflow: hidden hidden; +} + +#playground-results { + width: 1fr; + padding: 0 1; +} + +/* Sits directly under the results rather than being pushed to the middle of the column by + * the scroll region above it. */ +#playground-buttons { + height: auto; + padding: 0 0 1 0; +} + + +#playground-output-scroll { + height: 1fr; + min-height: 8; +} + +#example-detail { + padding: 0 1; +} + +/* + * One row per editor holding its label and its actions, so Copy/Open/Clear sit next to the + * thing they act on rather than in a shared block further down the column. + */ +.editor-toolbar { + height: 1; + background: $panel; +} + +.toolbar-label { + width: 1fr; + color: $text-muted; + text-style: bold; + padding: 0 1; +} + +/* Minimal chrome: a bordered Button is 3 rows tall, which would spend six rows of a column + * that needs them for JSON. `min-width: 0` lets the label decide the width. */ +.icon-button { + height: 1; + min-width: 0; + border: none; + padding: 0 1; + margin: 0; + background: $panel; + color: $text-muted; +} + +.icon-button:hover { + background: $primary; + color: $text; +} + +/* + * The tab's own header: what this screen is for on the left, the example picker on the right. + * One band, so the picker reads as a page-level control rather than as a third editor. + */ +#playground-header { + height: 1; + width: 100%; + background: $panel; +} + +/* + * The header row's pieces share one baseline and one left margin. Previously the label began + * hard against column 0 while the description ran to the far edge, so nothing in the row lined + * up with the pane titles beneath it. + */ +#playground-description { + width: 1fr; + height: 1; + color: $text-muted; + padding: 0 2; +} + +#example-label { + height: 1; + width: auto; + padding: 0 1; + color: $text-muted; + text-style: bold; +} + +/* + * Built with compact=True, which drops the border and lets this sit on one row inside the + * header band instead of as a lighter box floating on it. A fixed width keeps it from + * stretching across the pane on a wide terminal. + */ +/* + * Coloured like a control rather than like text. Unstyled it rendered as muted words next to + * other muted words, so the current example read as a label stating a fact instead of as the + * thing you click to change it. + */ +#example-select { + width: 24; + height: 1; + margin: 0 1; + background: $boost; + color: $accent; + text-style: bold; +} + +/* + * The open list needs an opaque background of its own. Compact mode drops the border, and + * without this the results underneath show through between the option labels, which makes + * both unreadable. + */ +#example-select SelectOverlay { + background: $surface; + border: round $primary; +} + +.header-button { + height: 1; + min-width: 0; + border: none; + padding: 0 2; + margin: 0; + background: $panel; + color: $text-muted; +} + +.header-button:hover { + background: $primary; + color: $text; +} + +/* ----------------------------------------------------------- file picker */ + +/* Dims whatever is behind it, so the modal reads as being on top rather than as another pane. */ +FilePicker { + align: center middle; + background: $background 60%; +} + +#file-picker { + width: 70%; + max-width: 90; + height: 80%; + background: $surface; + border: round $primary; + padding: 1 2; +} + +#file-picker-title { + text-style: bold; + color: $accent; + height: 1; +} + +/* The directory currently shown. Needed once the tree can move: without it, "up" gives no + * feedback about where you have arrived. */ +#file-picker-location { + color: $accent; + height: 1; +} + +/* $text-muted on the panel was too dark to read; this is the row that teaches the keys. */ +#file-picker-help { + color: $text-secondary; + height: 1; + margin: 0 0 1 0; +} + +#file-picker-tree { + height: 1fr; + border: none; + background: $surface; +} + +#file-picker-path { + height: 3; + margin: 1 0 0 0; +} + +#file-picker-buttons { + height: auto; + align-horizontal: right; +} + + +.editor-label { + background: $panel; + color: $text-muted; + padding: 0 1; + text-style: bold; +} + +/* + * Slim, muted scrollbars. The default is a wide $primary bar, and because each editor's + * scrollbar lands on the right edge of the left column it read as a heavy blue divider + * between the two panes rather than as part of the editor it belongs to. + */ +#policy-editor, #input-editor { + height: 1fr; + border: none; + scrollbar-size-vertical: 1; + scrollbar-background: $surface; + scrollbar-color: $panel-lighten-2; + scrollbar-color-hover: $accent; + scrollbar-color-active: $accent; +} + +#playground-status { + height: auto; + padding: 0 1; + background: $panel; +} + +/* Confirmations for actions with no visible result. Separate from the status line so the + * debounced re-evaluation does not wipe them a moment after they appear. */ +#playground-notice { + height: auto; + padding: 0 1; + color: $text-muted; +} + +#findings-list { + height: auto; + padding: 0 1; +} + +.finding-error { + color: $error; +} + +.finding-warning { + color: $warning; +} diff --git a/src/tirith/tui/app.py b/src/tirith/tui/app.py new file mode 100644 index 0000000..8c31187 --- /dev/null +++ b/src/tirith/tui/app.py @@ -0,0 +1,159 @@ +""" +The app shell: three tabs over one shared evaluation. + +The three views are tabs rather than separate screens so they can share state. Building a +policy in the Builder and opening it in the Playground, then reading the failure in the +Explorer, is one continuous task -- making each a separate program would mean saving a file +between every step. + +Requires textual. Nothing else in this package does; see tui/__init__.py. +""" + +import json +import os + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer, Static, TabbedContent, TabPane + +from . import results +from .views.builder import BuilderView +from .views.explorer import ExplorerView +from .views.playground import PlaygroundView + +CSS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app.css") + +# The wordmark. A terminal has one font size, so a title is made prominent by weight, spacing +# and a full-width coloured bar rather than by points. +# +# Drawn block glyphs were the obvious way to make it literally taller and were tried first: +# at this size the half-block `█ █` that has to stand for an H is indistinguishable from two +# `I`s, so TIRITH read as TIRITII. Letter-spaced bold caps stay unambiguous, which matters +# more for a name than height does. +BANNER = "[b]T I R I T H[/b] [dim]Policy as Code[/dim]" + + +class TirithApp(App): + """Explore results, build policies, experiment.""" + + CSS_PATH = CSS_PATH + TITLE = "TIRITH" + SUB_TITLE = "Policy as Code" + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("1", "show_tab('explorer')", "Explorer"), + Binding("2", "show_tab('builder')", "Builder"), + Binding("3", "show_tab('playground')", "Playground"), + Binding("r", "rerun", "Re-run"), + ] + + def __init__(self, report=None, start_tab="playground", policy=None, input_document=None, **kwargs): + super().__init__(**kwargs) + self._initial_report = report if report is not None else results.parse_report({}) + # Open on the Explorer when given something to explore, and on the Playground + # otherwise -- an empty Explorer has nothing to say. + self._start_tab = start_tab + self._initial_policy = policy + self._initial_input = input_document + # See _adopt_report: protects an explicitly-opened --result from being overwritten by + # the example the Playground loads while mounting. + self._explorer_is_pinned = report is not None + + def compose(self) -> ComposeResult: + # The wordmark shares the tab row rather than occupying a band of its own. As a + # separate row it cost height that the Builder's form and the Playground's editors + # need, and on a short terminal it pushed the layout past the viewport -- which made + # the whole Screen scroll, putting a scrollbar down the edge of the application and + # letting the title scroll out of sight. + # Sits on the overlay layer, drawn over the right-hand end of the tab row, so it costs + # no height at all. Nesting it in a Horizontal with the tabs would have worked too, but + # would constrain every pane inside that container for the sake of one label. + yield Static(BANNER, id="app-banner") + with TabbedContent(initial=self._start_tab, id="tabs"): + with TabPane("Explorer", id="explorer"): + yield ExplorerView(report=self._initial_report, id="explorer-view") + with TabPane("Builder", id="builder"): + yield BuilderView(on_policy_built=self._open_policy_in_playground, id="builder-view") + with TabPane("Playground", id="playground"): + yield PlaygroundView( + on_report=self._adopt_report, + on_user_action=self._release_explorer, + id="playground-view", + ) + yield Footer() + + def on_mount(self) -> None: + """Load anything supplied on the command line, once the widgets exist.""" + playground = self.query_one("#playground-view", PlaygroundView) + if self._initial_policy is not None: + playground.load_documents(self._initial_policy, self._initial_input) + elif self._initial_input is not None: + playground.load_documents(None, self._initial_input) + + # Someone who named their own policy and input came to see how it did, so show them + # the results rather than the editor they would have to leave to find them. Switched + # here rather than through `initial=`, because the Playground has to mount and + # evaluate before there is anything for the Explorer to show. + if self._initial_policy is not None and self._initial_input is not None: + self.query_one("#tabs", TabbedContent).active = "explorer" + + # ---------------------------------------------------------------- wiring + + def _adopt_report(self, report) -> None: + """ + Keep the Explorer showing whatever the Playground last evaluated. + + Except when the user opened a specific result with `--result`, which stays on screen + until they run something themselves. The Playground evaluates while mounting -- once + for the example it loads, and again if the command line supplied an --input -- so + anything that unpins on the first adopt is defeated by the second: the Explorer ended + up holding example 01 evaluated against the user's plan. + + Released by _release_explorer, called from the deliberate actions (Run, Re-run, an + edit, opening a document) rather than counted down here. + """ + if self._explorer_is_pinned: + return + self.query_one("#explorer-view", ExplorerView).refresh_report(report) + + def _release_explorer(self) -> None: + """The user has run something of their own, so the Explorer follows the Playground again.""" + self._explorer_is_pinned = False + + def _open_policy_in_playground(self, policy) -> None: + self.query_one("#playground-view", PlaygroundView).load_policy(policy) + self.query_one("#tabs", TabbedContent).active = "playground" + + # --------------------------------------------------------------- actions + + def action_show_tab(self, tab: str) -> None: + self.query_one("#tabs", TabbedContent).active = tab + + def action_rerun(self) -> None: + self.query_one("#playground-view", PlaygroundView).evaluate_now() + + +def build_app(report=None, policy=None, input_document=None) -> TirithApp: + """ + Construct the app, optionally pre-loaded. + + :param report: A parsed result document to open in the Explorer. + :param policy: A policy to open in the Playground. + :param input_document: The document to evaluate it against. + """ + return TirithApp( + report=report, + start_tab="explorer" if report is not None else "playground", + policy=policy, + input_document=input_document, + ) + + +def load_json_file(path: str): + """Read a JSON file, raising a clear message rather than a bare exception.""" + with open(path, encoding="utf-8") as f: + try: + return json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"{path} is not valid JSON: {e.msg} (line {e.lineno}, column {e.colno})") diff --git a/src/tirith/tui/cli.py b/src/tirith/tui/cli.py new file mode 100644 index 0000000..5f0dd4d --- /dev/null +++ b/src/tirith/tui/cli.py @@ -0,0 +1,280 @@ +""" +`tirith ui` -- argument parsing and launch, including the browser-served mode. + +Kept separate from app.py so that argument errors and the missing-extra message are reported +without importing the UI toolkit at all, which matters because the commonest reason to reach +this file is not having the extra installed. +""" + +import argparse +import json +import os +import shlex +import sys + +from .. import __version__ +from ..status import ExitStatus +from . import TUI_EXTRA_HINT + +DEFAULT_PORT = 8000 + +# The startup wordmark for `--serve`, drawn in the same three-row style the serving library +# uses for its own, so the terminal announces Tirith rather than the machinery behind it. +# Interpolates the version, which is the thing a reader actually wants from a banner. +# Every row is 21 characters wide. The first draft had the second T one column short, which +# ran its stem into the H beside it -- rows of 21/22/22 rather than 21/21/21. +SERVE_LOGO = ( + "[bold cyan]___ _ ____ _ ___ _ _\n" + " | | |__/ | | |__|\n" + " | | | \\ | | | |[not bold] v" + __version__ + "\n" +) + + +def _load(path, label): + """ + Read a JSON document from a path, or from stdin when the path is '-'. + + stdin exists so the interface composes with a pipe: + + tirith --json -policy-path p.json -input-path plan.json | tirith ui --result - + + Reading it has a consequence worth handling rather than discovering: once stdin has been + consumed it is the pipe, not the terminal, and an interactive interface that tries to read + keys from a spent pipe starts and immediately exits. `_reattach_stdin` puts the terminal + back. Only one document can come from stdin, since there is only one to give. + """ + if path != "-": + from .app import load_json_file + + return load_json_file(path) + + text = sys.stdin.read() + if not text.strip(): + raise ValueError(f"{label} - was given but stdin was empty") + try: + return json.loads(text) + except json.JSONDecodeError as e: + raise ValueError(f"{label} - is not valid JSON: {e.msg} (line {e.lineno}, column {e.colno})") + + +def _is_missing_toolkit(error): + """ + Whether an ImportError is the optional extra being absent, rather than a real fault. + + `ImportError.name` is the module that could not be found, so this distinguishes "textual is + not installed" from "views/playground.py imports a symbol that no longer exists" -- which + arrives as the same exception type from the same import statement. + """ + module = getattr(error, "name", None) or "" + return module.split(".")[0] in ("textual", "textual_serve") + + +def _reattach_stdin(): + """ + Point stdin back at the terminal after a document was piped in. + + Without this, `... | tirith ui --result -` draws one frame and quits: the interface reads + keys from stdin, which is an exhausted pipe reporting EOF. Reopening the controlling + terminal gives it a real input to read. + + Returns whether it worked. It cannot when there is no terminal at all -- a CI job with + output redirected -- and that is not an error worth failing on here; the caller reports it. + """ + try: + tty = open("/dev/tty") + except OSError: + return False + os.dup2(tty.fileno(), 0) + sys.stdin = tty + return True + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="tirith ui", + description="Explore policy results, build policies, and experiment in a playground.", + epilog=( + "With no arguments, opens the playground with worked examples.\n" + "Point it at a result document to explore an evaluation you already ran:\n" + " tirith -policy-path p.json -input-path i.json --json > result.json\n" + " tirith ui --result result.json" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + source = parser.add_argument_group("what to open") + source.add_argument( + "--result", + metavar="PATH", + help="A result document from `tirith --json`, opened in the Explorer. '-' reads stdin.", + ) + source.add_argument( + "--policy", + metavar="PATH", + help="A policy to open in the Playground.", + ) + source.add_argument( + "--input", + metavar="PATH", + dest="input_path", + help="The document to evaluate the policy against.", + ) + + serving = parser.add_argument_group("serving") + serving.add_argument( + "--serve", + action="store_true", + help="Serve the interface over HTTP instead of running in this terminal.", + ) + serving.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port for --serve. Default: {DEFAULT_PORT}", + ) + serving.add_argument( + "--host", + default="localhost", + help="Interface for --serve to bind. Default: localhost", + ) + + return parser + + +def main(argv): + parser = build_parser() + # argv arrives including the `ui` subcommand that dispatched us. + opts = parser.parse_args(argv[1:] if argv and argv[0] == "ui" else argv) + + for label, path in (("--result", opts.result), ("--policy", opts.policy), ("--input", opts.input_path)): + if path and path != "-" and not os.path.exists(path): + print(f"ERROR: {label} {path} does not exist", file=sys.stderr) + return ExitStatus.ERROR + + if opts.result and opts.policy: + print( + "ERROR: --result and --policy are alternatives; --result opens an evaluation that " + "already ran, --policy opens one to run now.", + file=sys.stderr, + ) + return ExitStatus.ERROR + + piped = "-" in (opts.result, opts.policy, opts.input_path) + + if piped and opts.serve: + # Checked before stdin is read, not after: the served interface is a separate process + # with its own stdin, so consuming the pipe here would throw the document away and + # then refuse anyway. + print("ERROR: --serve cannot read a document from stdin; pass a file path.", file=sys.stderr) + return ExitStatus.ERROR + + try: + from .app import build_app + except ImportError as e: + # Only the missing toolkit gets the install instruction. This import pulls in the whole + # package -- four views, the schema, the validator, the engine -- and a blanket except + # told someone whose textual is installed to install it again, discarding the traceback + # for the real fault (a typo'd symbol, an API removed in a later release). + if _is_missing_toolkit(e): + print(TUI_EXTRA_HINT, file=sys.stderr) + return ExitStatus.ERROR + raise + + from . import results + + try: + report = results.parse_report(_load(opts.result, "--result")) if opts.result else None + policy = _load(opts.policy, "--policy") if opts.policy else None + input_document = _load(opts.input_path, "--input") if opts.input_path else None + except (OSError, ValueError) as e: + print(f"ERROR: {e}", file=sys.stderr) + return ExitStatus.ERROR + + if opts.serve: + return _serve(opts, report is not None) + + if piped and not _reattach_stdin(): + print( + "ERROR: a document was piped in, but there is no terminal to run the interface on.\n" + "Write it to a file first, or use --serve to open it in a browser.", + file=sys.stderr, + ) + return ExitStatus.ERROR + + app = build_app(report=report, policy=policy, input_document=input_document) + app.run() + return ExitStatus.SUCCESS + + +def _serve(opts, has_result): + """ + Serve the interface over HTTP. + + The server runs the interface as a subprocess and relays it over a websocket, so it takes + a *command* rather than an app object -- which is why this re-invokes the CLI rather than + passing the app it would otherwise have built. + """ + try: + from textual_serve.server import Server + except ImportError as e: + # Narrowed for the same reason as the .app import above. + if not _is_missing_toolkit(e): + raise + print( + "Serving needs the optional 'tui' extra:\n pip install 'py-tirith[tui]'", + file=sys.stderr, + ) + return ExitStatus.ERROR + + class TirithServer(Server): + """A server that announces Tirith rather than the library serving it.""" + + async def on_startup(self, app): + """ + Replace the serving library's startup banner with our own. + + The default prints a large TEXTUAL-SERVE wordmark and the full `python -m` command + line, which tells the reader about our implementation rather than about the thing + they just started. Overriding this method is the supported way to change it -- the + base class documents it as such -- so there is nothing to monkey-patch. + """ + del app + self.console.print(SERVE_LOGO, highlight=False) + self.console.print(f"Policy playground on [link]{self.public_url}[/link]") + self.console.print("\n[cyan]Press Ctrl+C to quit") + + server = TirithServer(_rebuild_command(opts, has_result), host=opts.host, port=opts.port) + try: + server.serve() + except KeyboardInterrupt: + return ExitStatus.ERROR_CTRL_C + return ExitStatus.SUCCESS + + +def _rebuild_command(opts, has_result): + """ + The command textual-serve should run for each browser session. + + Absolute paths, because the served subprocess does not necessarily inherit this working + directory, and a relative --policy that resolved here would not resolve there. + """ + parts = [sys.executable, "-m", "tirith", "ui"] + if has_result and opts.result: + parts += ["--result", os.path.abspath(opts.result)] + if opts.policy: + parts += ["--policy", os.path.abspath(opts.policy)] + if opts.input_path: + parts += ["--input", os.path.abspath(opts.input_path)] + return " ".join(_quote(part) for part in parts) + + +def _quote(part): + """ + Quote one argument for a shell. + + textual-serve launches with `asyncio.create_subprocess_shell`, so this string really is + interpreted by sh. Wrapping in double quotes was not enough -- they leave `$`, backticks + and backslashes live, so a path like `/tmp/my $work/policy.json` had `$work` expanded to + nothing and every browser session started with a path that did not exist. + """ + return shlex.quote(part) diff --git a/src/tirith/tui/examples.py b/src/tirith/tui/examples.py new file mode 100644 index 0000000..75c30d4 --- /dev/null +++ b/src/tirith/tui/examples.py @@ -0,0 +1,119 @@ +""" +The worked examples the playground opens with. + +A playground with an empty buffer asks the user to already know the policy format, which is +the thing they came to learn. So it ships with runnable policy/input pairs -- pick one, see it +evaluate, edit it and watch the verdict move. + +The examples live in `examples/` beside this module rather than being read out of `tests/`. +Reusing the test fixtures was tempting since there are ~30 of them, but tests/ is not shipped +in the wheel (MANIFEST.in packages `src/*.json`, and the test tree only reaches an sdist), so +an installed `pip install py-tirith[tui]` would have found an empty playground. They are also +written to demonstrate the engine, not to teach it: several exist precisely because they are +malformed, and `policy.json` uses a `&` the engine rejects outright. + +Each example is a directory holding `policy.json`, `input.json` and `about.md`, discovered at +import rather than listed here, so adding one is a matter of adding the directory. +""" + +import json +import os +from typing import Any, Dict, List, NamedTuple, Optional + +EXAMPLES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples") + +POLICY_FILENAME = "policy.json" +INPUT_FILENAME = "input.json" +ABOUT_FILENAME = "about.md" + + +class Example(NamedTuple): + """One runnable policy/input pair.""" + + # Directory name, used as a stable id. The numeric prefix orders the list from simplest to + # most involved and is stripped from the display title. + key: str + title: str + summary: str + about: str + policy: Dict[str, Any] + input_document: Any + provider: str + + @property + def policy_json(self) -> str: + return json.dumps(self.policy, indent=2) + + @property + def input_json(self) -> str: + return json.dumps(self.input_document, indent=2) + + +def _title_from_key(key: str) -> str: + """`02-cost-ceiling` -> `Cost ceiling`.""" + without_prefix = key.split("-", 1)[1] if "-" in key and key.split("-", 1)[0].isdigit() else key + return without_prefix.replace("-", " ").capitalize() + + +def _read_about(directory: str) -> str: + path = os.path.join(directory, ABOUT_FILENAME) + if not os.path.exists(path): + return "" + with open(path, encoding="utf-8") as f: + return f.read().strip() + + +def _load_one(key: str) -> Optional[Example]: + directory = os.path.join(EXAMPLES_DIR, key) + policy_path = os.path.join(directory, POLICY_FILENAME) + input_path = os.path.join(directory, INPUT_FILENAME) + if not (os.path.exists(policy_path) and os.path.exists(input_path)): + return None + + with open(policy_path, encoding="utf-8") as f: + policy = json.load(f) + with open(input_path, encoding="utf-8") as f: + input_document = json.load(f) + + about = _read_about(directory) + # The first line of about.md is the one-line summary shown in the picker; the rest is the + # detail pane. Keeping both in one file means an example is a directory, with nothing to + # register anywhere else. + summary = about.splitlines()[0].strip() if about else "" + + return Example( + key=key, + title=_title_from_key(key), + summary=summary, + about=about, + policy=policy, + input_document=input_document, + provider=policy.get("meta", {}).get("required_provider", ""), + ) + + +def load_examples() -> List[Example]: + """ + Every bundled example, ordered by directory name. + + Returns an empty list rather than raising when the directory is missing, so a partial + install degrades to a playground with no examples instead of an unusable one. + """ + if not os.path.isdir(EXAMPLES_DIR): + return [] + + found: List[Example] = [] + for key in sorted(os.listdir(EXAMPLES_DIR)): + if key.startswith(".") or not os.path.isdir(os.path.join(EXAMPLES_DIR, key)): + continue + example = _load_one(key) + if example is not None: + found.append(example) + return found + + +def example_by_key(key: str) -> Optional[Example]: + for example in load_examples(): + if example.key == key: + return example + return None diff --git a/src/tirith/tui/examples/01-required-tags/about.md b/src/tirith/tui/examples/01-required-tags/about.md new file mode 100644 index 0000000..a5a8b20 --- /dev/null +++ b/src/tirith/tui/examples/01-required-tags/about.md @@ -0,0 +1,25 @@ +Require a costcenter tag on every resource — and watch one resource fail. + +This is the smallest useful policy: one check, one condition, no operators. + +`terraform_resource_type: "*"` matches every resource in the plan, and +`terraform_resource_attribute: "tags.costcenter"` reads a nested attribute — the dot +walks into the tags map. `IsNotEmpty` needs no `value`, because there is nothing to +compare against. + +The plan has two resources and only `aws_instance.web` is tagged, so the policy fails. + +**Things to try** + +- Add `"costcenter": "product-456"` to the bucket's tags and re-run. The verdict flips. +- Add `"error_tolerance": 2` to the condition. The verdict becomes *skipped*, not passed — + and a policy that skips every check has checked nothing. That is why `--fail-on-error` + treats skipped as a failure rather than a pass. +- Change `IsNotEmpty` to `Equals` with `"value": "product-123"` to pin one exact value. + +**A rough edge worth knowing** + +The failing row has no resource address. When the provider cannot find an attribute it +reports the miss without the resource it was looking at, so the message names the +attribute but not the bucket. Results that *do* find a value carry the full address — +select the passing row to see it. diff --git a/src/tirith/tui/examples/01-required-tags/input.json b/src/tirith/tui/examples/01-required-tags/input.json new file mode 100644 index 0000000..01f573e --- /dev/null +++ b/src/tirith/tui/examples/01-required-tags/input.json @@ -0,0 +1,48 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "instance_type": "t3.micro", + "tags": { + "Name": "web-server", + "costcenter": "product-123" + } + }, + "after_unknown": { + "arn": true, + "id": true + } + } + }, + { + "address": "aws_s3_bucket.assets", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "assets", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "bucket": "example-assets", + "tags": { + "Name": "assets" + } + }, + "after_unknown": { + "arn": true + } + } + } + ] +} diff --git a/src/tirith/tui/examples/01-required-tags/policy.json b/src/tirith/tui/examples/01-required-tags/policy.json new file mode 100644 index 0000000..daa3485 --- /dev/null +++ b/src/tirith/tui/examples/01-required-tags/policy.json @@ -0,0 +1,22 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Every resource carries a costcenter tag" + }, + "evaluators": [ + { + "id": "costcenter_tag_present", + "description": "Every taggable resource declares a costcenter tag", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "tags.costcenter" + }, + "condition": { + "type": "IsNotEmpty" + } + } + ], + "eval_expression": "costcenter_tag_present" +} diff --git a/src/tirith/tui/examples/02-no-public-buckets/about.md b/src/tirith/tui/examples/02-no-public-buckets/about.md new file mode 100644 index 0000000..5174f9b --- /dev/null +++ b/src/tirith/tui/examples/02-no-public-buckets/about.md @@ -0,0 +1,22 @@ +Two checks combined with `&&`, and two buckets that disagree. + +This is the shape most real policies take: several independent checks, joined into one +verdict by `eval_expression`. + +`NotContainedIn` tests membership against a list — the value must not be any of the ACLs +named. `IsNotEmpty` catches the other common shape of misconfiguration: the attribute is +present but set to nothing, which is what an unencrypted bucket looks like in a plan. + +Both checks fail, and both fail on the *same* resource: `aws_s3_bucket.public_site`. The +other bucket passes both. This is what the results view is for — the four messages are +nearly identical, and only the resource address tells you that one bucket is the problem +and the other is fine. + +**Things to try** + +- Change `eval_expression` to `acl_is_private || encryption_enabled`. Still fails — `||` + needs only one to pass, and neither does. +- Set the public bucket's `acl` to `"private"`. Now `acl_is_private` passes and only the + encryption check fails, so `&&` fails but `||` would pass. +- Add `!` to negate a check: `!acl_is_private` passes precisely when the ACL *is* public. + Useful for writing a policy that detects a condition rather than forbidding it. diff --git a/src/tirith/tui/examples/02-no-public-buckets/input.json b/src/tirith/tui/examples/02-no-public-buckets/input.json new file mode 100644 index 0000000..4c51e97 --- /dev/null +++ b/src/tirith/tui/examples/02-no-public-buckets/input.json @@ -0,0 +1,50 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_s3_bucket.private_data", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "private_data", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "bucket": "acme-private-data", + "acl": "private", + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { "sse_algorithm": "AES256" } + ] + } + ] + } + ] + }, + "after_unknown": { "arn": true } + } + }, + { + "address": "aws_s3_bucket.public_site", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "public_site", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "bucket": "acme-public-site", + "acl": "public-read", + "server_side_encryption_configuration": [] + }, + "after_unknown": { "arn": true } + } + } + ] +} diff --git a/src/tirith/tui/examples/02-no-public-buckets/policy.json b/src/tirith/tui/examples/02-no-public-buckets/policy.json new file mode 100644 index 0000000..ded9ef2 --- /dev/null +++ b/src/tirith/tui/examples/02-no-public-buckets/policy.json @@ -0,0 +1,36 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "No public S3 buckets", + "severity": "high" + }, + "evaluators": [ + { + "id": "acl_is_private", + "description": "Bucket ACLs must not grant public access", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket", + "terraform_resource_attribute": "acl" + }, + "condition": { + "type": "NotContainedIn", + "value": ["public-read", "public-read-write", "authenticated-read"] + } + }, + { + "id": "encryption_enabled", + "description": "Buckets declare server-side encryption", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket", + "terraform_resource_attribute": "server_side_encryption_configuration" + }, + "condition": { + "type": "IsNotEmpty" + } + } + ], + "eval_expression": "acl_is_private && encryption_enabled" +} diff --git a/src/tirith/tui/examples/03-cost-ceiling/about.md b/src/tirith/tui/examples/03-cost-ceiling/about.md new file mode 100644 index 0000000..abb705f --- /dev/null +++ b/src/tirith/tui/examples/03-cost-ceiling/about.md @@ -0,0 +1,26 @@ +Budget gates over an Infracost report, with a total and a per-service ceiling. + +A different provider and a different input document: this reads +`infracost breakdown --format json`, not a terraform plan. + +`resource_type` here is a **list**, unlike the terraform provider's plain string. +`["*"]` totals the whole plan; naming types instead sums only those. The two checks show +both: `$388.47` across everything, `$181.77` for the two `aws_instance` resources. + +Both pass, so the policy passes. + +**Things to try** + +- Lower the total budget to `300`. The first check fails and the verdict flips. +- Set `resource_type` to `["aws_rds_cluster"]` to gate the database separately. +- Note there are no resource addresses in the results. Infracost sums across resources, so + a cost check reports one number with no single resource behind it — unlike the terraform + examples, where every result names its resource. + +**A rough edge worth knowing** + +Matching is on the exact resource *type* — the part of the name before the first dot — so +`["aws_instance"]` matches `aws_instance.app_server`, but a partial type like `["aws_s3"]` +matches nothing and silently sums to `0`. A cost check that suddenly reads `0` is usually a +misspelled type rather than a free plan, and because `0` passes every `LessThanEqualTo` +ceiling, it fails open. Name types exactly. diff --git a/src/tirith/tui/examples/03-cost-ceiling/input.json b/src/tirith/tui/examples/03-cost-ceiling/input.json new file mode 100644 index 0000000..e11343a --- /dev/null +++ b/src/tirith/tui/examples/03-cost-ceiling/input.json @@ -0,0 +1,35 @@ +{ + "version": "0.2", + "currency": "USD", + "projects": [ + { + "name": "acme/infrastructure", + "breakdown": { + "resources": [ + { + "name": "aws_instance.app_server", + "monthlyCost": "121.18", + "hourlyCost": "0.166" + }, + { + "name": "aws_instance.worker", + "monthlyCost": "60.59", + "hourlyCost": "0.083" + }, + { + "name": "aws_rds_cluster.primary", + "monthlyCost": "204.40", + "hourlyCost": "0.280" + }, + { + "name": "aws_s3_bucket.assets", + "monthlyCost": "2.30", + "hourlyCost": "0.003" + } + ] + } + } + ], + "totalMonthlyCost": "388.47", + "totalHourlyCost": "0.532" +} diff --git a/src/tirith/tui/examples/03-cost-ceiling/policy.json b/src/tirith/tui/examples/03-cost-ceiling/policy.json new file mode 100644 index 0000000..d26ed89 --- /dev/null +++ b/src/tirith/tui/examples/03-cost-ceiling/policy.json @@ -0,0 +1,34 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/infracost", + "name": "Monthly spend stays under budget" + }, + "evaluators": [ + { + "id": "total_under_budget", + "description": "The whole plan costs less than $500 a month", + "provider_args": { + "operation_type": "total_monthly_cost", + "resource_type": ["*"] + }, + "condition": { + "type": "LessThanEqualTo", + "value": 500 + } + }, + { + "id": "compute_under_budget", + "description": "Compute alone stays under $200 a month", + "provider_args": { + "operation_type": "total_monthly_cost", + "resource_type": ["aws_instance"] + }, + "condition": { + "type": "LessThanEqualTo", + "value": 200 + } + } + ], + "eval_expression": "total_under_budget && compute_under_budget" +} diff --git a/src/tirith/tui/examples/04-block-destroy/about.md b/src/tirith/tui/examples/04-block-destroy/about.md new file mode 100644 index 0000000..b493377 --- /dev/null +++ b/src/tirith/tui/examples/04-block-destroy/about.md @@ -0,0 +1,30 @@ +Catch a database replacement hiding inside a routine plan. + +`operation_type: "action"` reads what terraform intends to *do* to a resource, rather than +an attribute of it. This is how you gate on destruction. + +The catch this policy exists for: `aws_db_instance.primary` is not being destroyed on +purpose. Its `instance_class` changed, which forces a replacement, and terraform expresses +that as `["delete", "create"]` — a destroy and a recreate. In a plan of any size that is +easy to miss, and it means losing the database. + +Select the failing row. The detail pane names the action **replace (destroy first)** and +shows the attribute that forced it: `instance_class`, `db.t3.medium → db.t3.large`. The +ordering matters — destroy-first has downtime, create-first does not — so the two are named +differently rather than both reading "replace". + +`aws_db_instance.replica` is only growing its storage, so it updates in place and passes. + +**Things to try** + +- Change `primary`'s `instance_class` back to `db.t3.medium` and set `actions` to + `["update"]`. The policy passes. +- Swap `NotContains` for `ContainedIn` with `["delete"]` to write the inverse check. +- Drop `error_tolerance: 1` and change the resource type to one the plan does not contain. + Without the tolerance a missing resource is a failure; with it the check is skipped. + +**Why the resource appears twice** + +The `action` operation emits one result per action, so a replacement produces two rows for +the same resource — one for `delete` (which fails) and one for `create` (which passes). +The check as a whole fails, because any failing result fails its check. diff --git a/src/tirith/tui/examples/04-block-destroy/input.json b/src/tirith/tui/examples/04-block-destroy/input.json new file mode 100644 index 0000000..0eb0d64 --- /dev/null +++ b/src/tirith/tui/examples/04-block-destroy/input.json @@ -0,0 +1,51 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_db_instance.primary", + "mode": "managed", + "type": "aws_db_instance", + "name": "primary", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["delete", "create"], + "before": { + "identifier": "acme-primary", + "instance_class": "db.t3.medium", + "allocated_storage": 100 + }, + "after": { + "identifier": "acme-primary", + "instance_class": "db.t3.large", + "allocated_storage": 100 + }, + "after_unknown": { + "endpoint": true, + "id": true + } + } + }, + { + "address": "aws_db_instance.replica", + "mode": "managed", + "type": "aws_db_instance", + "name": "replica", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["update"], + "before": { + "identifier": "acme-replica", + "instance_class": "db.t3.medium", + "allocated_storage": 100 + }, + "after": { + "identifier": "acme-replica", + "instance_class": "db.t3.medium", + "allocated_storage": 200 + }, + "after_unknown": {} + } + } + ] +} diff --git a/src/tirith/tui/examples/04-block-destroy/policy.json b/src/tirith/tui/examples/04-block-destroy/policy.json new file mode 100644 index 0000000..c15846c --- /dev/null +++ b/src/tirith/tui/examples/04-block-destroy/policy.json @@ -0,0 +1,24 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Stateful resources are never destroyed", + "severity": "critical" + }, + "evaluators": [ + { + "id": "database_not_destroyed", + "description": "No plan may destroy an RDS instance", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_db_instance" + }, + "condition": { + "type": "NotContains", + "value": "delete", + "error_tolerance": 1 + } + } + ], + "eval_expression": "database_not_destroyed" +} diff --git a/src/tirith/tui/examples/05-kubernetes-probes/about.md b/src/tirith/tui/examples/05-kubernetes-probes/about.md new file mode 100644 index 0000000..51e4699 --- /dev/null +++ b/src/tirith/tui/examples/05-kubernetes-probes/about.md @@ -0,0 +1,28 @@ +Kubernetes manifests, wildcard paths, and the `!` operator. + +A third input shape: a **list** of manifests, not a single object. `kubernetes_kind` picks +which ones to look at, so the `Service` here is ignored and only the `Pod` is checked. + +The `*` in `spec.containers.*.image` walks every container. This is the part worth +understanding, because it is where the engine surprises people: the wildcard collects the +containers into **one list**, and the condition is applied to that list rather than once +per container. So the question you ask has to be about the list. + +That is why both checks are phrased as `Contains`: + +- `has_liveness_probe` asks whether the list of probes contains `null` — a container with + no probe contributes a `null`. Written as `IsNotEmpty` it would pass, because a list + containing `null` is not empty. +- `uses_latest_tag` asks whether any image contains `:latest`. It is a *detector*, so the + expression negates it with `!`. + +Both fire on the same container: the `sidecar`, which has no probe and floats on `:latest`. + +**Things to try** + +- Give the sidecar a `livenessProbe` and pin its image to `acme/log-shipper:2.1.0`. The + policy passes. +- Change `has_liveness_probe` to `IsNotEmpty` and watch it pass while the probe is still + missing. This is the trap the check above avoids. +- Change `kubernetes_kind` to `Service` — no pod matches, so the checks report that the kind + was found but the path was not, rather than passing silently. diff --git a/src/tirith/tui/examples/05-kubernetes-probes/input.json b/src/tirith/tui/examples/05-kubernetes-probes/input.json new file mode 100644 index 0000000..662cc27 --- /dev/null +++ b/src/tirith/tui/examples/05-kubernetes-probes/input.json @@ -0,0 +1,38 @@ +[ + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "api", + "namespace": "production" + }, + "spec": { + "containers": [ + { + "name": "api", + "image": "acme/api:1.4.2", + "livenessProbe": { + "httpGet": { "path": "/healthz", "port": 8080 }, + "initialDelaySeconds": 10 + } + }, + { + "name": "sidecar", + "image": "acme/log-shipper:latest" + } + ] + } + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "api", + "namespace": "production" + }, + "spec": { + "selector": { "app": "api" }, + "ports": [{ "port": 80, "targetPort": 8080 }] + } + } +] diff --git a/src/tirith/tui/examples/05-kubernetes-probes/policy.json b/src/tirith/tui/examples/05-kubernetes-probes/policy.json new file mode 100644 index 0000000..72e8742 --- /dev/null +++ b/src/tirith/tui/examples/05-kubernetes-probes/policy.json @@ -0,0 +1,36 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/kubernetes", + "name": "Pods declare liveness probes and pinned images" + }, + "evaluators": [ + { + "id": "has_liveness_probe", + "description": "Every container declares a liveness probe", + "provider_args": { + "operation_type": "attribute", + "kubernetes_kind": "Pod", + "attribute_path": "spec.containers.*.livenessProbe" + }, + "condition": { + "type": "NotContains", + "value": null + } + }, + { + "id": "uses_latest_tag", + "description": "Detects any container running the floating :latest tag", + "provider_args": { + "operation_type": "attribute", + "kubernetes_kind": "Pod", + "attribute_path": "spec.containers.*.image" + }, + "condition": { + "type": "Contains", + "value": ":latest" + } + } + ], + "eval_expression": "has_liveness_probe && !uses_latest_tag" +} diff --git a/src/tirith/tui/render.py b/src/tirith/tui/render.py new file mode 100644 index 0000000..b2c65e5 --- /dev/null +++ b/src/tirith/tui/render.py @@ -0,0 +1,167 @@ +""" +Turning model objects into display strings. + +Split out from the widgets so it can be tested without a running terminal, and so the two +views that show a verdict show it identically. + +One rule throughout: a verdict is never communicated by colour alone. Every status carries a +glyph as well, because colour is invisible to a good fraction of users, and because these +strings also end up in a browser via `textual serve` and in terminal recordings. +""" + +import json +from typing import Any, List, Optional + +from . import results + +# Glyph and CSS class per status. Deliberately ASCII-safe apart from the check and cross, +# which render on every terminal worth supporting. +_STATUS_GLYPHS = { + results.PASSED: "✔", + results.FAILED: "✘", + results.SKIPPED: "=", + "errored": "!", +} + +_STATUS_CLASSES = { + results.PASSED: "verdict-pass", + results.FAILED: "verdict-fail", + results.SKIPPED: "verdict-skip", + "errored": "verdict-error", +} + + +def status_glyph(status: str) -> str: + return _STATUS_GLYPHS.get(status, "?") + + +def status_class(status: str) -> str: + return _STATUS_CLASSES.get(status, "verdict-skip") + + +def status_markup(status: str, label: Optional[str] = None) -> str: + """ + A status as Rich markup, glyph first. + + :param status: One of the results module's status names. + :param label: Text to show after the glyph. Defaults to the status name itself. + """ + text = label if label is not None else status.upper() + colours = { + results.PASSED: "green", + results.FAILED: "red", + results.SKIPPED: "bright_black", + "errored": "yellow", + } + colour = colours.get(status, "white") + return f"[{colour}]{status_glyph(status)} {escape(text)}[/{colour}]" + + +def escape(text: str) -> str: + """ + Neutralise Rich markup in text we did not write. + + Policy ids, resource addresses and provider messages all reach the screen, and any of them + can contain a `[` -- a terraform address with an index (`aws_instance.web[0]`) does by + definition. Without this, Rich reads that as a style tag and either swallows the text or + raises. Not a security boundary, just correctness. + """ + return str(text).replace("[", r"\[") + + +def check_label(check: results.Check) -> str: + """A check's row in the tree: status, id, and what its results did.""" + return f"{status_markup(check.status, check.id)} [dim]{escape(check.summary)}[/dim]" + + +def result_label(result: results.Result) -> str: + """ + A single result's row. + + Leads with the resource address when there is one. That ordering is the point of the + Explorer: on a wildcard policy every message reads the same and only the address + distinguishes them. + """ + resource = result.resource + if resource.label: + suffix = f" [dim]({escape(resource.action_summary)})[/dim]" if resource.action_summary else "" + return f"{status_markup(result.status, resource.label)}{suffix}" + return status_markup(result.status, _truncate(result.message, 70)) + + +def _truncate(text: str, limit: int) -> str: + text = str(text) + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def format_value(value: Any, limit: int = 400) -> str: + """ + Render a JSON value for display. + + Strings pass through unquoted; everything else is JSON, so `null`, `true` and `{}` are + unambiguous. The engine's own messages quote values this way, and a value shown as + `None` when the policy must say `null` is a trap worth not laying. + """ + if isinstance(value, str): + return _truncate(value, limit) + try: + return _truncate(json.dumps(value), limit) + except (TypeError, ValueError): + return _truncate(repr(value), limit) + + +def attribute_diff_lines(result: results.Result) -> List[str]: + """ + The changed attributes behind a result, as `name: before → after` lines. + + Empty when the provider recorded no before/after pair -- a create, a destroy, or any + non-terraform provider. The caller shows the resource's current values instead. + """ + lines = [] + for change in results.attribute_changes(result.raw_meta): + if change.after_unknown: + after = "[italic]known after apply[/italic]" + else: + after = escape(format_value(change.after, 120)) + before = escape(format_value(change.before, 120)) + lines.append(f"[bold]{escape(change.name)}[/bold]: {before} → {after}") + return lines + + +def resource_lines(result: results.Result) -> List[str]: + """The resource identity block for the detail pane.""" + resource = result.resource + if resource.is_empty: + return [] + + lines = [] + if resource.address: + lines.append(f"[bold]{escape(resource.address)}[/bold]") + if resource.action_summary: + lines.append(f"Action: {escape(resource.action_summary)}") + if resource.resource_type: + lines.append(f"Type: {escape(resource.resource_type)}") + if resource.mode: + lines.append(f"Mode: {escape(resource.mode)}") + if resource.provider_name: + lines.append(f"Provider: {escape(resource.provider_name)}") + return lines + + +def report_headline(report: results.Report) -> str: + """The one-line verdict shown above the results.""" + verdict_names = { + results.PASSED: "Policy passed", + results.FAILED: "Policy failed", + results.SKIPPED: "Policy skipped every check", + "errored": "Policy did not produce a verdict", + } + verdict = report.verdict + return f"{status_markup(verdict, verdict_names.get(verdict, verdict))} [dim]{escape(report.headline)}[/dim]" + + +def finding_markup(finding) -> str: + """A validator finding, coloured by severity.""" + colour = "red" if finding.severity == "error" else "yellow" + glyph = "✘" if finding.severity == "error" else "▲" + return f"[{colour}]{glyph} {escape(finding.where)}[/{colour}] {escape(finding.message)}" diff --git a/src/tirith/tui/results.py b/src/tirith/tui/results.py new file mode 100644 index 0000000..2b6cc0e --- /dev/null +++ b/src/tirith/tui/results.py @@ -0,0 +1,323 @@ +""" +Read the engine's result document into something a UI can render. + +This exists because the information the pretty printer drops is the information you actually +want when a policy fails. `pretty_print_result_dict` prints a check's message and nothing else, +but each result carries a `meta` block that, for terraform_plan, is the whole resource_change: +its address, its type, the planned actions, and the before/after values. On a plan with +hundreds of resources, "FAILED: `false` is not equal to `true`" is not an answer to "which +bucket?" -- and today the only way to get the answer is to pipe --json into jq. + +Nothing here mutates the result document or re-runs anything. These are read-only views over +whatever `start_policy_evaluation*` returned, so the Explorer can also open a --json file +captured from a CI run months ago. + +Kept free of textual imports so it can be tested on CI's Python 3.8 leg. +""" + +from typing import Any, Dict, Iterator, List, NamedTuple, Optional + +# The tri-state a check reports. `None` means skipped, and skipped is not a pass -- the engine +# is careful about this distinction (see the --fail-on-error commentary in cli.py) and so is +# every count here. +PASSED = "passed" +FAILED = "failed" +SKIPPED = "skipped" + + +def status_of(passed: Optional[bool]) -> str: + """Map a check's tri-state `passed` onto a status name.""" + if passed is None: + return SKIPPED + return PASSED if passed else FAILED + + +class ResourceRef(NamedTuple): + """ + The resource a single result came from, when the provider recorded one. + + Only terraform_plan populates `meta` richly; the json and sg_workflow providers pass None, + and infracost aggregates across resources so there is no single one to name. Everything + here is therefore optional, and `is_empty` says whether there is anything to show. + """ + + address: str = "" + resource_type: str = "" + name: str = "" + mode: str = "" + provider_name: str = "" + actions: tuple = () + + @property + def is_empty(self) -> bool: + return not (self.address or self.resource_type or self.actions) + + @property + def action_summary(self) -> str: + """The planned action, in terraform's own vocabulary.""" + actions = [a for a in self.actions if a] + if not actions: + return "" + if actions == ["no-op"]: + return "no change" + if actions == ["create"]: + return "create" + if actions == ["delete"]: + return "destroy" + if actions == ["update"]: + return "update in place" + # terraform expresses a replacement as an ordered pair, and which way round it is + # changes the risk: delete-then-create has downtime, create-then-delete does not. + if actions == ["delete", "create"]: + return "replace (destroy first)" + if actions == ["create", "delete"]: + return "replace (create first)" + return ", ".join(actions) + + @property + def label(self) -> str: + """The most specific name available, for a list row.""" + return self.address or self.resource_type or self.name or "" + + +class Result(NamedTuple): + """One evaluated value within a check.""" + + # Named `position` rather than `index` because NamedTuple inherits tuple.index(). + position: int + passed: Optional[bool] + message: str + resource: ResourceRef + # The untouched meta block, for the detail pane's raw view. May be None. + raw_meta: Optional[Dict] + + @property + def status(self) -> str: + return status_of(self.passed) + + +class Check(NamedTuple): + """One evaluator from the policy, with everything it produced.""" + + id: str + description: str + passed: Optional[bool] + results: List[Result] + + @property + def status(self) -> str: + return status_of(self.passed) + + @property + def counts(self) -> Dict[str, int]: + counts = {PASSED: 0, FAILED: 0, SKIPPED: 0} + for result in self.results: + counts[result.status] += 1 + return counts + + @property + def summary(self) -> str: + """A one-line count for the check's row, e.g. `3 passed, 1 failed`.""" + counts = self.counts + parts = [f"{counts[status]} {status}" for status in (FAILED, PASSED, SKIPPED) if counts[status]] + return ", ".join(parts) if parts else "no results" + + def failing_results(self) -> List[Result]: + return [r for r in self.results if r.passed is False] + + +class Report(NamedTuple): + """A whole evaluation, as the Explorer sees it.""" + + checks: List[Check] + final_result: Optional[bool] + # True when the document had no `final_result` key at all, which the engine does when the + # policy could not be loaded -- distinct from a final_result of None (everything skipped). + final_result_absent: bool + eval_expression: str + errors: List[str] + meta: Dict[str, Any] + + @property + def counts(self) -> Dict[str, int]: + counts = {PASSED: 0, FAILED: 0, SKIPPED: 0} + for check in self.checks: + counts[check.status] += 1 + return counts + + @property + def verdict(self) -> str: + """ + The policy's overall outcome. + + Mirrors the tri-state the CLI gates on under --fail-on-error, including the part that + is easy to get wrong: a final_result of None means every check was skipped, which is + not a pass. See the commentary in cli.py. + """ + if self.final_result_absent: + return "errored" + if self.final_result is None: + return SKIPPED + return PASSED if self.final_result else FAILED + + @property + def headline(self) -> str: + counts = self.counts + return f"{counts[PASSED]} passed · {counts[FAILED]} failed · {counts[SKIPPED]} skipped" + + def check_by_id(self, check_id: str) -> Optional[Check]: + for check in self.checks: + if check.id == check_id: + return check + return None + + def failing_checks(self) -> List[Check]: + return [c for c in self.checks if c.passed is False] + + def iter_failing_results(self) -> Iterator["Result"]: + for check in self.failing_checks(): + for result in check.failing_results(): + yield result + + +def _resource_from_meta(meta: Any) -> ResourceRef: + """ + Pull the resource identity out of a provider's meta block. + + Shaped around terraform_plan, whose meta is the resource_change verbatim. Other providers + pass None or something unrecognised, which yields an empty ref rather than an error -- + a UI showing "no resource detail" is correct there, not a failure. + """ + if not isinstance(meta, dict): + return ResourceRef() + + change = meta.get("change") + actions = () + if isinstance(change, dict): + raw_actions = change.get("actions") + if isinstance(raw_actions, list): + actions = tuple(raw_actions) + + return ResourceRef( + address=meta.get("address") or "", + resource_type=meta.get("type") or "", + name=meta.get("name") or "", + mode=meta.get("mode") or "", + provider_name=meta.get("provider_name") or "", + actions=actions, + ) + + +def parse_report(result_dict: Any) -> Report: + """ + Build a Report from whatever `start_policy_evaluation` returned, or from a --json file. + + Tolerant by construction. The input may be a result document from an older tirith, a file + the user picked by mistake, or -- in the playground -- the output of a policy that failed + to load and therefore has `errors` and nothing else. Anything unreadable becomes an empty + section rather than an exception, because the UI's job in that case is to show the errors. + + :param result_dict: A parsed result document. + :return: The report, with empty sections where the document had none. + """ + if not isinstance(result_dict, dict): + return Report([], None, True, "", ["Result document is not a JSON object."], {}) + + checks: List[Check] = [] + for check_dict in result_dict.get("evaluators") or []: + if not isinstance(check_dict, dict): + continue + + results: List[Result] = [] + for position, result_dict_inner in enumerate(check_dict.get("result") or []): + if not isinstance(result_dict_inner, dict): + continue + meta = result_dict_inner.get("meta") + results.append( + Result( + position=position, + passed=result_dict_inner.get("passed"), + message=str(result_dict_inner.get("message", "")), + resource=_resource_from_meta(meta), + raw_meta=meta if isinstance(meta, dict) else None, + ) + ) + + checks.append( + Check( + id=str(check_dict.get("id", "")), + description=str(check_dict.get("description") or ""), + passed=check_dict.get("passed"), + results=results, + ) + ) + + errors = [str(e) for e in (result_dict.get("errors") or [])] + meta = result_dict.get("meta") + + return Report( + checks=checks, + final_result=result_dict.get("final_result"), + final_result_absent="final_result" not in result_dict, + eval_expression=str(result_dict.get("eval_expression", "")), + errors=errors, + meta=meta if isinstance(meta, dict) else {}, + ) + + +class AttributeChange(NamedTuple): + """One attribute's before/after within a planned change.""" + + name: str + before: Any + after: Any + # terraform reports values it cannot know until apply separately from real values; showing + # them as `null` would misrepresent a computed value as an absent one. + after_unknown: bool = False + + @property + def is_addition(self) -> bool: + return self.before is None and self.after is not None + + @property + def is_removal(self) -> bool: + return self.before is not None and self.after is None + + +def attribute_changes(raw_meta: Optional[Dict]) -> List[AttributeChange]: + """ + The changed attributes of a terraform resource_change, as a diff. + + Returns only attributes that actually differ, which is what makes this readable: a plan's + `after` block lists every attribute of the resource, and showing all of them buries the two + that changed. Attributes whose value is unknown until apply are included and flagged, + since "this becomes something we cannot predict" is itself worth seeing. + + Empty for a create or a destroy, where there is no before/after pair to compare -- the + caller should show the whole `after` (or `before`) block instead. + """ + if not isinstance(raw_meta, dict): + return [] + change = raw_meta.get("change") + if not isinstance(change, dict): + return [] + + before = change.get("before") + after = change.get("after") + if not isinstance(before, dict) or not isinstance(after, dict): + return [] + + after_unknown = change.get("after_unknown") + unknown_keys = set() + if isinstance(after_unknown, dict): + unknown_keys = {key for key, value in after_unknown.items() if value is True} + + changes: List[AttributeChange] = [] + for key in sorted(set(before) | set(after) | unknown_keys): + old_value = before.get(key) + new_value = after.get(key) + is_unknown = key in unknown_keys + if not is_unknown and old_value == new_value: + continue + changes.append(AttributeChange(name=key, before=old_value, after=new_value, after_unknown=is_unknown)) + return changes diff --git a/src/tirith/tui/schema.py b/src/tirith/tui/schema.py new file mode 100644 index 0000000..4956566 --- /dev/null +++ b/src/tirith/tui/schema.py @@ -0,0 +1,324 @@ +""" +What each provider accepts, declared so a form can be generated from it. + +The engine has no machine-readable schema to read this from. `json` and `kubernetes` keep a +`SUPPORTED_OPS` dict, but `terraform_plan` -- the provider almost every real policy uses -- +dispatches through an if/elif chain and reads its arguments as bare subscripts +(`provider_inputs["terraform_resource_attribute"]`), and `sg_workflow` has no `operation_type` +at all. There is nothing to introspect, so this table is written by hand. + +A hand-written table is a second source of truth, and second sources of truth rot. What stops +that here is tests/tui/test_schema_matches_providers.py, which asserts every provider named +below is really in PROVIDERS_DICT and every operation below is really dispatched by its +handler -- so a provider that gains or renames an operation fails CI rather than silently +leaving the builder able to generate a policy the engine cannot run. + +This table is for *generating* and *validating* policies in the TUI. It is deliberately not +imported by the engine: the engine's behaviour is a contract fixed by golden-file tests, and +teaching it to consult a schema would change what it accepts. +""" + +from typing import Dict, List, NamedTuple + + +class Arg(NamedTuple): + """One provider argument, as a form field.""" + + name: str + required: bool + help: str + # Fixed choices where the provider accepts a closed set, else None for free text. + choices: tuple = () + # Rendered as a hint in the form, not as a default that gets written into the policy: + # writing an unasked-for value into a generated policy is how you get a policy that + # says something its author did not. + placeholder: str = "" + + +class Operation(NamedTuple): + """One `operation_type` of a provider.""" + + name: str + summary: str + args: List[Arg] + + +class Provider(NamedTuple): + """One provider, as named in a policy's `meta.required_provider`.""" + + name: str + summary: str + # The document this provider expects as input, for the playground's file picker. + input_hint: str + operations: List[Operation] + # sg_workflow takes `workflow_attribute` and no `operation_type`. Rather than pretend it + # has one, the flag says so and the builder omits the key entirely. + uses_operation_type: bool = True + + +# Shared by several terraform_plan operations. +_RESOURCE_TYPE = Arg( + "terraform_resource_type", + True, + "Resource type to match, or '*' for every type.", + placeholder="aws_s3_bucket", +) + +# Read once at the top of terraform_plan's provide() and honoured by the `attribute`, `action` +# and `count` branches alike -- so it belongs to all three. Listed against `attribute` only, it +# made the validator report a correct `count` policy as using an argument that "will be +# ignored", which is the opposite of true. +_EXCLUDE_RESOURCE_TYPES = Arg( + "exclude_resource_types", + False, + "Resource types to skip. Only consulted when the type is '*'.", + placeholder='["aws_iam_policy"]', +) + +# infracost takes a *list*, not a string, and `["*"]` is how you ask for the whole plan rather +# than by omitting the key -- `provide` raises KeyError when it is absent. So this is required +# with a wildcard default, where terraform_plan's equivalent is a plain string. +_INFRACOST_RESOURCE_TYPE = Arg( + "resource_type", + True, + 'Resource types to sum, as a JSON list. ["*"] totals the whole plan.', + placeholder='["*"]', +) + +PROVIDERS: Dict[str, Provider] = { + "stackguardian/terraform_plan": Provider( + name="stackguardian/terraform_plan", + summary="Query a `terraform show -json` plan: attributes, counts, actions, dependencies.", + input_hint="terraform plan JSON (terraform show -json tfplan)", + operations=[ + Operation( + "attribute", + "Value of an attribute on every matching resource.", + [ + _RESOURCE_TYPE, + Arg( + "terraform_resource_attribute", + True, + "Attribute to read from change.after. Supports dots and '*' for nesting.", + placeholder="tags.costcenter", + ), + _EXCLUDE_RESOURCE_TYPES, + ], + ), + Operation( + "count", + "How many resources of a type the plan changes.", + [_RESOURCE_TYPE, _EXCLUDE_RESOURCE_TYPES], + ), + Operation( + "action", + "The planned actions (create / update / delete / no-op) per resource.", + [_RESOURCE_TYPE, _EXCLUDE_RESOURCE_TYPES], + ), + Operation( + "direct_dependencies", + "Resource types a resource declares in `depends_on`.", + [_RESOURCE_TYPE], + ), + Operation( + "direct_references", + "Resources referenced by, or referencing, a resource type.", + [ + _RESOURCE_TYPE, + Arg( + "references_to", + False, + "Match resources this type points at. Pair with the type above.", + placeholder="aws_s3_bucket", + ), + Arg( + "referenced_by", + False, + "Match resources that point at this type instead.", + placeholder="aws_elb", + ), + ], + ), + Operation( + "terraform_version", + "The terraform version recorded in the plan.", + [], + ), + Operation( + "provider_config", + "An attribute of a configured provider, such as its region or version constraint.", + [ + Arg( + "terraform_provider_full_name", + True, + "Fully qualified provider name.", + placeholder="registry.terraform.io/hashicorp/aws", + ), + Arg( + "attribute", + True, + "Which provider attribute to read.", + choices=("version_constraint", "region"), + ), + ], + ), + ], + ), + "stackguardian/json": Provider( + name="stackguardian/json", + summary="Read any path out of an arbitrary JSON or YAML document.", + input_hint="any JSON or YAML document", + operations=[ + Operation( + "get_value", + "Value(s) at a dotted path. '*' matches every element of a list.", + [ + Arg( + "key_path", + True, + "Dotted path into the document. '*' walks every item of a list.", + placeholder="spec.containers.*.image", + ) + ], + ) + ], + ), + "stackguardian/kubernetes": Provider( + name="stackguardian/kubernetes", + summary="Read a path out of Kubernetes manifests, including multi-document YAML.", + input_hint="Kubernetes manifest YAML or JSON", + operations=[ + Operation( + "attribute", + "Value(s) at a dotted path within manifests of one kind.", + [ + Arg( + "kubernetes_kind", + True, + "Manifest kind to match. Documents of other kinds are ignored.", + placeholder="Pod", + ), + # Named attribute_path here, key_path in the json provider. The two + # providers really do disagree; do not "fix" one to match the other. + Arg( + "attribute_path", + True, + "Dotted path into the manifest. '*' walks every item of a list.", + placeholder="spec.containers.*.livenessProbe", + ), + ], + ) + ], + ), + "stackguardian/infracost": Provider( + name="stackguardian/infracost", + summary="Read costs out of an `infracost breakdown --format json` report.", + input_hint="infracost breakdown JSON", + operations=[ + Operation( + "total_monthly_cost", + "Monthly cost, summed across the named resource types.", + [_INFRACOST_RESOURCE_TYPE], + ), + Operation( + "total_hourly_cost", + "Hourly cost, summed across the named resource types.", + [_INFRACOST_RESOURCE_TYPE], + ), + ], + ), + "stackguardian/sg_workflow": Provider( + name="stackguardian/sg_workflow", + summary="Read a field off a StackGuardian workflow definition.", + input_hint="StackGuardian workflow JSON", + uses_operation_type=False, + operations=[ + Operation( + "workflow_attribute", + "A named field of the workflow.", + [ + Arg( + "workflow_attribute", + True, + "Which workflow field to read.", + # Exactly the keys __getValue branches on. Anything else raises + # KeyError inside the provider, so the closed list is the point. + choices=( + "Description", + "DocVersion", + "ResourceName", + "ResourceType", + "Tags", + "WfType", + "approvalPreApply", + "driftCheck", + "managedTerraformState", + "terraformVersion", + "integrationId", + "iacTemplateId", + "useMarketplaceTemplate", + "bucket_region", + "s3_bucket_acl", + "s3_bucket_block_public_acls", + "s3_bucket_block_public_policy", + "s3_bucket_force_destroy", + "s3_bucket_ignore_public_acls", + "s3_bucket_restrict_public_buckets", + ), + ) + ], + ) + ], + ), +} + + +class EvaluatorInfo(NamedTuple): + name: str + summary: str + # What `condition.value` should look like, to steer the form's input widget. + value_kind: str # "scalar" | "list" | "none" | "regex" + + +# Mirrors EVALUATORS_DICT; the drift-guard test asserts the two agree. +EVALUATORS: Dict[str, EvaluatorInfo] = { + "Equals": EvaluatorInfo("Equals", "Value is exactly this.", "scalar"), + "NotEquals": EvaluatorInfo("NotEquals", "Value is anything but this.", "scalar"), + "GreaterThan": EvaluatorInfo("GreaterThan", "Value is strictly greater.", "scalar"), + "GreaterThanEqualTo": EvaluatorInfo("GreaterThanEqualTo", "Value is greater or equal.", "scalar"), + "LessThan": EvaluatorInfo("LessThan", "Value is strictly less.", "scalar"), + "LessThanEqualTo": EvaluatorInfo("LessThanEqualTo", "Value is less or equal.", "scalar"), + "Contains": EvaluatorInfo("Contains", "Value contains this item or substring.", "scalar"), + "NotContains": EvaluatorInfo("NotContains", "Value does not contain this.", "scalar"), + "ContainedIn": EvaluatorInfo("ContainedIn", "Value is one of these.", "list"), + "NotContainedIn": EvaluatorInfo("NotContainedIn", "Value is none of these.", "list"), + "IsEmpty": EvaluatorInfo("IsEmpty", "Value is empty.", "none"), + "IsNotEmpty": EvaluatorInfo("IsNotEmpty", "Value is not empty.", "none"), + "RegexMatch": EvaluatorInfo("RegexMatch", "Value matches this regular expression.", "regex"), +} + + +# What the Builder opens on. Not the alphabetically-first provider, which is infracost: the +# terraform plan is what nearly every policy targets, so it is the representative starting +# point for someone learning the form. +DEFAULT_PROVIDER = "stackguardian/terraform_plan" + + +def provider_names() -> List[str]: + return sorted(PROVIDERS) + + +def evaluator_names() -> List[str]: + return sorted(EVALUATORS) + + +def operations_for(provider_name: str) -> List[Operation]: + provider = PROVIDERS.get(provider_name) + return list(provider.operations) if provider else [] + + +def operation_for(provider_name: str, operation_name: str): + for operation in operations_for(provider_name): + if operation.name == operation_name: + return operation + return None diff --git a/src/tirith/tui/validate.py b/src/tirith/tui/validate.py new file mode 100644 index 0000000..c148324 --- /dev/null +++ b/src/tirith/tui/validate.py @@ -0,0 +1,391 @@ +""" +Check a policy document before handing it to the engine. + +The engine assumes a well-formed policy: `start_policy_evaluation_from_dict` reads +`policy_dict.get("meta")` and immediately calls `.get()` on it, so a policy with no `meta` +raises AttributeError from inside core rather than reporting a problem. Under the CLI that is +merely a bad error message; in the playground, where the user is *editing* the policy and it is +malformed on almost every keystroke, an exception is the normal case and a traceback is not an +acceptable way to render it. + +So this validates first and returns problems as data. It is deliberately separate from the +engine and not imported by it: the engine's behaviour is pinned by golden-file tests, and +making it stricter would change what it accepts for every existing caller. + +Findings are advisory. `check_policy` returns errors (the engine will fail or misbehave) and +warnings (it will run, but probably not as intended) so the UI can show both without refusing +to evaluate -- experimenting with a half-written policy is the point of a playground. +""" + +import re +from typing import Any, Dict, List, NamedTuple, Tuple + +from ..core.evaluators import EVALUATORS_DICT +from ..providers import PROVIDERS_DICT +from . import schema + +# Mirrors core.final_evaluator's own substitution: it rewrites && || ! and then compiles what +# is left, so an id must survive as a Python name. +_ID_PATTERN = re.compile(r"^\w+$") + + +class Finding(NamedTuple): + severity: str # "error" | "warning" + # Where in the document, as a human-readable path like `evaluators[2].condition.type`. + where: str + message: str + + def __str__(self) -> str: + """Render as `where: message`, which is how findings appear in test failures.""" + return f"{self.where}: {self.message}" + + +def _error(where: str, message: str) -> Finding: + return Finding("error", where, message) + + +def _warning(where: str, message: str) -> Finding: + return Finding("warning", where, message) + + +def check_policy(policy: Any) -> List[Finding]: + """ + Validate a parsed policy document. + + :param policy: The parsed policy, which may be any JSON value -- the caller may be handing + us whatever their editor buffer currently parses to. + :return: Findings, worst first. Empty means the engine will accept it. + """ + if not isinstance(policy, dict): + return [_error("", f"A policy must be a JSON object, not {type(policy).__name__}.")] + + findings: List[Finding] = [] + provider_name = _check_meta(policy, findings) + declared_ids = _check_evaluators(policy, provider_name, findings) + _check_eval_expression(policy, declared_ids, findings) + + # Errors first so a UI showing only the first line shows the blocking one. + return sorted(findings, key=lambda f: 0 if f.severity == "error" else 1) + + +def _check_meta(policy: Dict, findings: List[Finding]) -> str: + meta = policy.get("meta") + if meta is None: + # Not merely invalid: core calls policy_meta.get(...) unguarded, so this is the input + # that raises AttributeError from inside the engine. + findings.append(_error("meta", "Missing. A policy needs a `meta` object naming its provider.")) + return "" + if not isinstance(meta, dict): + findings.append(_error("meta", f"Must be an object, not {type(meta).__name__}.")) + return "" + + if "version" not in meta: + findings.append(_warning("meta.version", 'Not set. Convention is "v1".')) + + # core defaults this to "core", which is not in PROVIDERS_DICT -- so every check then fails + # with "Provider 'core' is not found". Naming it explicitly is effectively required. + provider_name = meta.get("required_provider") + if not provider_name: + findings.append( + _error( + "meta.required_provider", + "Not set. Without it the engine looks for a provider named 'core', which does not exist.", + ) + ) + return "" + # Checked before the lookup, not after: `provider_name` is whatever the editor buffer + # parses to, and a list or an object is unhashable -- `x not in PROVIDERS_DICT` raises + # TypeError rather than returning False, which escaped this module and killed the app. + if not isinstance(provider_name, str): + findings.append(_error("meta.required_provider", f"Must be a string, not {type(provider_name).__name__}.")) + return "" + if provider_name not in PROVIDERS_DICT: + known = ", ".join(sorted(PROVIDERS_DICT)) + findings.append(_error("meta.required_provider", f"Unknown provider '{provider_name}'. Known: {known}.")) + return "" + return provider_name + + +def _check_evaluators(policy: Dict, provider_name: str, findings: List[Finding]) -> List[str]: + evaluators = policy.get("evaluators") + if evaluators is None: + findings.append(_error("evaluators", "Missing. A policy needs at least one check.")) + return [] + if not isinstance(evaluators, list): + findings.append(_error("evaluators", f"Must be a list, not {type(evaluators).__name__}.")) + return [] + if not evaluators: + findings.append(_warning("evaluators", "Empty, so this policy checks nothing.")) + return [] + + declared_ids: List[str] = [] + seen = set() + + for index, evaluator in enumerate(evaluators): + where = f"evaluators[{index}]" + if not isinstance(evaluator, dict): + findings.append(_error(where, f"Must be an object, not {type(evaluator).__name__}.")) + continue + + check_id = evaluator.get("id") + if not check_id: + findings.append(_error(f"{where}.id", "Missing. Every check needs an id to be named in eval_expression.")) + elif not isinstance(check_id, str): + findings.append(_error(f"{where}.id", f"Must be a string, not {type(check_id).__name__}.")) + else: + declared_ids.append(check_id) + if check_id in seen: + # The results dict is keyed by id, so the later check silently replaces the + # earlier one in the final expression. + findings.append(_error(f"{where}.id", f"Duplicate id '{check_id}'; ids must be unique.")) + seen.add(check_id) + if not _ID_PATTERN.match(check_id): + # Only a warning, and the distinction is subtle enough to be worth stating. + # core substitutes ids into the expression by regex *before* parsing it, so a + # defined `eval-id-1` becomes `True` and never reaches the parser as + # subtraction -- several shipped fixtures rely on this and evaluate correctly. + # It breaks only if the same id is left undefined, where the surviving `-` + # raises ValueError out of the engine; _check_eval_expression reports that + # case as an error separately. + findings.append( + _warning( + f"{where}.id", + f"'{check_id}' contains characters other than letters, digits and underscores. " + f"It works while it is defined, but becomes a hard error if it is ever " + f"dropped from eval_expression.", + ) + ) + + _check_provider_args(evaluator, provider_name, where, findings) + _check_condition(evaluator, where, findings) + + return declared_ids + + +def _check_provider_args(evaluator: Dict, provider_name: str, where: str, findings: List[Finding]) -> None: + provider_args = evaluator.get("provider_args") + if provider_args is None: + findings.append(_error(f"{where}.provider_args", "Missing. This says what to read from the input.")) + return + if not isinstance(provider_args, dict): + findings.append(_error(f"{where}.provider_args", f"Must be an object, not {type(provider_args).__name__}.")) + return + + described = schema.PROVIDERS.get(provider_name) + if described is None: + # Provider is real but the TUI has no description of it, or meta was already invalid. + # Either way there is nothing further to check here, and the earlier finding covers it. + return + + if not described.uses_operation_type: + (only_operation,) = described.operations + _check_args_against(provider_args, only_operation, f"{where}.provider_args", findings) + return + + operation_name = provider_args.get("operation_type") + if not operation_name: + known = ", ".join(op.name for op in described.operations) + findings.append(_error(f"{where}.provider_args.operation_type", f"Missing. Expected one of: {known}.")) + return + + operation = schema.operation_for(provider_name, operation_name) + if operation is None: + known = ", ".join(op.name for op in described.operations) + findings.append( + _error( + f"{where}.provider_args.operation_type", + f"'{operation_name}' is not supported by {provider_name}. Expected one of: {known}.", + ) + ) + return + + _check_args_against(provider_args, operation, f"{where}.provider_args", findings) + + +def _check_args_against(provider_args: Dict, operation: schema.Operation, where: str, findings: List[Finding]) -> None: + for arg in operation.args: + if arg.required and arg.name not in provider_args: + findings.append(_error(f"{where}.{arg.name}", f"Required by operation '{operation.name}'. {arg.help}")) + value = provider_args.get(arg.name) + if arg.choices and value is not None and value not in arg.choices: + findings.append(_error(f"{where}.{arg.name}", f"'{value}' is not one of: {', '.join(arg.choices)}.")) + + known_names = {arg.name for arg in operation.args} | {"operation_type"} + for key in provider_args: + if key not in known_names: + # A warning, not an error: providers ignore arguments they do not read, so this + # runs -- it just does not do what the extra key suggests. Usually a typo. + findings.append( + _warning(f"{where}.{key}", f"Not read by operation '{operation.name}'; it will be ignored.") + ) + + +def _check_condition(evaluator: Dict, where: str, findings: List[Finding]) -> None: + condition = evaluator.get("condition") + if condition is None: + findings.append(_error(f"{where}.condition", "Missing. This says what the value must satisfy.")) + return + if not isinstance(condition, dict): + findings.append(_error(f"{where}.condition", f"Must be an object, not {type(condition).__name__}.")) + return + + evaluator_name = condition.get("type") + if not evaluator_name: + findings.append(_error(f"{where}.condition.type", "Missing. Name the evaluator to apply.")) + evaluator_name = None + elif not isinstance(evaluator_name, str): + # Same unhashable-key hazard as meta.required_provider above: a list or object here + # made the EVALUATORS_DICT membership test raise instead of returning False. + findings.append(_error(f"{where}.condition.type", f"Must be a string, not {type(evaluator_name).__name__}.")) + evaluator_name = None + elif evaluator_name not in EVALUATORS_DICT: + known = ", ".join(sorted(EVALUATORS_DICT)) + findings.append(_error(f"{where}.condition.type", f"'{evaluator_name}' is not an evaluator. Known: {known}.")) + else: + info = schema.EVALUATORS.get(evaluator_name) + # Deliberately no type check on a "list" evaluator's value. ContainedIn and its + # negation branch explicitly on str (substring), list (membership) and dict (subset), + # so demanding a list would reject working policies -- the value_kind is a hint about + # which widget the builder shows, not a constraint the engine imposes. + pattern = condition.get("value") + if info and info.value_kind == "regex" and isinstance(pattern, str): + try: + re.compile(pattern) + except re.error as e: + findings.append(_error(f"{where}.condition.value", f"Not a valid regular expression: {e}.")) + + # `value` is genuinely optional for IsEmpty/IsNotEmpty and required otherwise, but a null + # value is meaningful (the kubernetes fixture checks Contains null), so only its absence + # is worth reporting. + info = schema.EVALUATORS.get(evaluator_name or "") + if info and info.value_kind != "none" and "value" not in condition: + findings.append( + _error(f"{where}.condition.value", f"Missing. {evaluator_name} needs something to compare against.") + ) + + tolerance = condition.get("error_tolerance") + if tolerance is not None and not isinstance(tolerance, int): + findings.append( + _error(f"{where}.condition.error_tolerance", f"Must be an integer, not {type(tolerance).__name__}.") + ) + + +def _check_eval_expression(policy: Dict, declared_ids: List[str], findings: List[Finding]) -> None: + expression = policy.get("eval_expression") + if expression is None: + findings.append(_error("eval_expression", "Missing. This combines the check ids into one verdict.")) + return + if not isinstance(expression, str): + findings.append(_error("eval_expression", f"Must be a string, not {type(expression).__name__}.")) + return + if not expression.strip(): + findings.append(_error("eval_expression", "Empty. Name at least one check id.")) + return + + # core rejects & and | with a clear message; catch them here so the playground says so + # before the engine runs at all. + if re.search(r"(? List[str]: + r""" + The identifiers an eval_expression refers to. + + Mirrors what core actually does, which is *not* a parse. core substitutes each declared id + into the string by regex (`\bid\b`) before compiling, so ids that are not valid Python + names -- `eval-id-1`, used by several shipped policies -- are resolved fine when declared + and only reach the parser when they are not. Parsing first would therefore read `eval-id-1` + as `eval - id - 1` and report three phantom undefined names. + + So: remove the declared ids the same way core does, then read whatever identifier-ish + tokens are left over as the undefined references. + + :param declared: Ids declared by the policy, removed before scanning for leftovers. + """ + remaining = expression + for check_id in sorted(declared, key=len, reverse=True): + remaining = re.sub(r"\b" + re.escape(check_id) + r"\b", " ", remaining) + + names = [check_id for check_id in declared if re.search(r"\b" + re.escape(check_id) + r"\b", expression)] + + # Whatever still looks like a name is undefined. Hyphens are included so that a stray + # `eval-id-9` is reported as one name rather than as `eval`, `id` and a number. + for token in re.findall(r"[A-Za-z_][\w-]*", remaining): + if token not in ("and", "or", "not", "True", "False", "None"): + names.append(token) + return names + + +def check_input_document(document: Any, provider_name: str) -> List[Finding]: + """ + Sanity-check an input document against what the provider expects. + + Shallow by design: it reports the shape mistakes that produce a confusing empty result -- + a terraform plan with no `resource_changes`, an infracost report with no `projects` -- and + says nothing about documents it has no expectations for. + """ + findings: List[Finding] = [] + + if provider_name == "stackguardian/terraform_plan": + if not isinstance(document, dict): + findings.append(_error("", "A terraform plan is a JSON object.")) + elif not document.get("resource_changes"): + findings.append( + _warning( + ".resource_changes", + "Absent or empty, so every check reports 'No Terraform resources changes are found'. " + "This should be the output of `terraform show -json`, not the binary plan.", + ) + ) + elif provider_name == "stackguardian/infracost": + if isinstance(document, dict) and "projects" not in document: + findings.append( + _warning( + ".projects", "Absent, so cost lookups error. Expected `infracost breakdown --format json`." + ) + ) + elif provider_name == "stackguardian/kubernetes": + if isinstance(document, dict): + findings.append( + _warning( + "", + "The kubernetes provider iterates a list of manifests; a single object will not match any kind.", + ) + ) + + return findings + + +def summarize(findings: List[Finding]) -> Tuple[int, int]: + """Return (error count, warning count).""" + errors = sum(1 for f in findings if f.severity == "error") + return errors, len(findings) - errors diff --git a/src/tirith/tui/views/__init__.py b/src/tirith/tui/views/__init__.py new file mode 100644 index 0000000..832087f --- /dev/null +++ b/src/tirith/tui/views/__init__.py @@ -0,0 +1,9 @@ +""" +The three screens of the TUI. + +Each is a container widget rather than a Screen, so the app can hold them in tabs and keep one +shared policy/input state across all of them -- building a policy in one tab and evaluating it +in another is the point. + +Importing this package requires textual. +""" diff --git a/src/tirith/tui/views/builder.py b/src/tirith/tui/views/builder.py new file mode 100644 index 0000000..1a3c0f1 --- /dev/null +++ b/src/tirith/tui/views/builder.py @@ -0,0 +1,415 @@ +""" +The Builder: assemble a policy by picking a provider, an operation and a condition. + +The form is generated from tui/schema.py, so the fields change with the operation and every +argument shown is one the provider actually reads. That is the value over writing JSON by +hand: the terraform provider alone has seven operations taking different arguments, none of +them documented in a machine-readable form anywhere in the engine. + +Checks accumulate into a list, and the generated policy is shown as JSON as it is built -- +the policy is the output, so it is never hidden behind a "generate" button. +""" + +import json + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.widgets import Button, Input, Label, ListItem, ListView, Select, Static + +from .. import render, schema, validate + + +# What a value typed into the condition's value box should become. The engine compares against +# real JSON types -- `Equals: "true"` and `Equals: true` are different questions -- so the box +# is parsed as JSON with a fallback to string, which is what a user typing `production` means. +def parse_value(raw: str): + """ + Parse a condition value the way the policy will hold it. + + Bare words become strings, so `production` works without quoting, but `true`, `42`, `null` + and `["a","b"]` keep their JSON meaning. Without this every value would be a string and + numeric comparisons would silently never match. + """ + text = raw.strip() + if not text: + return "" + try: + return json.loads(text) + except ValueError: + return text + + +class BuilderView(Vertical): + """Form on the left, generated policy on the right.""" + + def __init__(self, on_policy_built=None, **kwargs): + super().__init__(**kwargs) + # Called with the generated policy dict when the user sends it to the playground. + self._on_policy_built = on_policy_built + self._checks = [] + # Opens on terraform_plan rather than the alphabetically-first provider: it is what + # nearly every policy targets, and starting on infracost would make the first form a + # user sees the least representative one. + self._provider_name = schema.DEFAULT_PROVIDER + self._operation_name = "" + # Set once the user edits the expression themselves; see current_expression. + self._expression_is_custom = False + + DESCRIPTION = "Assemble a policy from fields the chosen provider actually reads. Values keep their JSON types." + + def compose(self) -> ComposeResult: + yield Static(self.DESCRIPTION, classes="tab-description") + with Horizontal(id="builder-body"): + with VerticalScroll(id="builder-form"): + yield Label("Provider", classes="field-label") + yield Select( + [(name, name) for name in schema.provider_names()], + value=self._provider_name, + allow_blank=False, + id="provider-select", + ) + yield Static("", id="provider-summary", classes="field-help") + + yield Label("Operation", classes="field-label") + # Populated with the first provider's operations rather than left empty: + # Select rejects an empty option list when it cannot be blank. + yield Select( + [(op.name, op.name) for op in schema.operations_for(self._provider_name)], + allow_blank=False, + id="operation-select", + ) + yield Static("", id="operation-summary", classes="field-help") + + yield Static("", id="arg-fields") + + yield Label("Condition", classes="field-label") + yield Select( + [(name, name) for name in schema.evaluator_names()], + value="Equals", + allow_blank=False, + id="evaluator-select", + ) + yield Static("", id="evaluator-summary", classes="field-help") + yield Input(placeholder="Value to compare against, as JSON", id="value-input") + + yield Label("Check id", classes="field-label") + yield Input(placeholder="e.g. bucket_is_private", id="id-input") + + yield Horizontal( + Button("Add check", variant="primary", id="add-check"), + Button("Clear", id="clear-checks"), + id="builder-buttons", + ) + + # The right pane holds the things that concern the policy as a whole -- its checks, + # how they combine, and the JSON that results -- while the left builds one check at + # a time. It also keeps the expression box on screen: stacked under the form it + # landed below the fold on a normal terminal, which is no use for the one field + # that cannot be derived from anything else. + with Vertical(id="builder-preview"): + yield Static("Generated policy", classes="pane-title") + yield VerticalScroll(Static(id="policy-json"), id="policy-json-scroll") + + yield Static("Checks in this policy", classes="pane-title") + yield ListView(id="check-list") + + yield Static("How the checks combine", classes="pane-title") + yield Static( + "&& all must pass · || any may pass · ! negates · ( ) group", + classes="field-help", + ) + yield Input(placeholder="e.g. a && !b", id="expression-input") + + yield Static("", id="builder-findings") + yield Button("Open in playground", variant="success", id="send-to-playground") + + def on_mount(self) -> None: + self._refresh_provider() + + # ---------------------------------------------------------------- events + + def on_select_changed(self, event: Select.Changed) -> None: + if event.select.id == "provider-select": + self._provider_name = str(event.value) + self._refresh_provider() + elif event.select.id == "operation-select": + self._operation_name = str(event.value) + self._refresh_operation() + elif event.select.id == "evaluator-select": + self._refresh_evaluator() + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id != "expression-input": + return + # Anything the user types here -- including clearing it back to empty -- takes over + # from the generated default. Emptying it is a deliberate state, not a reset. + self._expression_is_custom = True + self._refresh_preview() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "add-check": + self._add_check() + elif event.button.id == "clear-checks": + self._checks = [] + # The expression named checks that no longer exist, so a custom one is not worth + # keeping either -- it would report every id in it as undefined. + self._expression_is_custom = False + self._refresh_preview() + elif event.button.id == "send-to-playground": + self._send_to_playground() + + def _send_to_playground(self) -> None: + """ + Hand the assembled policy to the Playground, unless it is not ready to run. + + Sending an invalid policy dropped the user into the Playground showing "Policy is + incomplete" and no results, which reads as the button being broken rather than as the + policy being unfinished -- and leaves them in the wrong tab to fix it. The findings + pane already says what is wrong, so staying put is the more useful answer. + """ + findings = self.query_one("#builder-findings", Static) + + if not self._checks: + findings.update("[red]✘ Add a check before opening this in the playground.[/red]") + return + + policy = self.build_policy() + errors = [f for f in validate.check_policy(policy) if f.severity == "error"] + if errors: + findings.update( + "[red]✘ Not ready to run:[/red] " + + render.escape(errors[0].message) + + f" [dim]({errors[0].where})[/dim]" + ) + return + + if self._on_policy_built: + self._on_policy_built(policy) + + # ----------------------------------------------------------------- form + + def _refresh_provider(self) -> None: + provider = schema.PROVIDERS[self._provider_name] + # Naming the document the provider expects, because choosing a provider is choosing + # what you must feed it -- and a plan evaluated against the kubernetes provider fails + # in a way that says nothing about the real mistake. + self.query_one("#provider-summary", Static).update( + f"{render.escape(provider.summary)}\n[b]Expects:[/b] {render.escape(provider.input_hint)}" + ) + + operations = provider.operations + select = self.query_one("#operation-select", Select) + select.set_options([(op.name, op.name) for op in operations]) + if operations: + self._operation_name = operations[0].name + select.value = self._operation_name + self._refresh_operation() + + def _refresh_operation(self) -> None: + operation = schema.operation_for(self._provider_name, self._operation_name) + summary = self.query_one("#operation-summary", Static) + if operation is None: + summary.update("") + return + summary.update(render.escape(operation.summary)) + self._rebuild_arg_fields(operation) + self._refresh_preview() + + def _rebuild_arg_fields(self, operation) -> None: + """ + Replace the argument inputs with the ones this operation takes. + + Rebuilt rather than hidden, because the arguments differ per operation and a stale + value left in a hidden field would end up in the generated policy. + + The removal is awaited before anything is mounted. `remove_children()` is asynchronous, + so mounting straight after it races the removal: two operations that share an argument + name -- and most of the terraform ones share `terraform_resource_type` -- collide on + the widget id and Textual raises DuplicateIds. + """ + container = self.query_one("#arg-fields", Static) + + widgets = [] + for arg in operation.args: + # Name and help on one line. Two lines per argument pushed the expression box off + # the bottom of a normal terminal, and the help is short enough to sit inline. + marker = " [b]*[/b]" if arg.required else "" + widgets.append( + Static( + f"[b]{render.escape(arg.name)}[/b]{marker} [dim]{render.escape(arg.help)}[/dim]", + classes="field-label-inline", + ) + ) + if arg.choices: + widgets.append( + Select( + [(choice, choice) for choice in arg.choices], + allow_blank=True, + id=f"arg-{arg.name}", + ) + ) + else: + widgets.append(Input(placeholder=arg.placeholder, id=f"arg-{arg.name}")) + + async def replace(): + await container.remove_children() + await container.mount_all(widgets) + + self.call_next(replace) + + def _refresh_evaluator(self) -> None: + name = str(self.query_one("#evaluator-select", Select).value) + info = schema.EVALUATORS.get(name) + summary = self.query_one("#evaluator-summary", Static) + value_input = self.query_one("#value-input", Input) + + if info is None: + summary.update("") + return + + summary.update(render.escape(info.summary)) + # IsEmpty and IsNotEmpty take no comparison value, so offering the box invites a value + # that the policy would then carry meaninglessly. + takes_value = info.value_kind != "none" + value_input.display = takes_value + if not takes_value: + value_input.value = "" + elif info.value_kind == "list": + value_input.placeholder = 'A JSON list, e.g. ["a", "b"]' + elif info.value_kind == "regex": + value_input.placeholder = "A regular expression, e.g. ^prod-" + else: + value_input.placeholder = "Value to compare against, as JSON" + + # ---------------------------------------------------------------- checks + + def _collect_provider_args(self) -> dict: + operation = schema.operation_for(self._provider_name, self._operation_name) + if operation is None: + return {} + + provider = schema.PROVIDERS[self._provider_name] + args = {} + if provider.uses_operation_type: + args["operation_type"] = operation.name + + for arg in operation.args: + try: + widget = self.query_one(f"#arg-{arg.name}") + except Exception: + continue + raw = widget.value + # Select.NULL is the unselected sentinel; Select.BLANK is the literal False, so + # testing against it here would let the sentinel through and serialise + # "Select.NULL" into the policy as the argument's value. + if raw is None or raw is Select.NULL or str(raw).strip() == "": + continue + # Provider args are JSON too: exclude_resource_types and infracost's resource_type + # are lists, and typing one should not produce the string "[\"*\"]". + args[arg.name] = parse_value(str(raw)) + return args + + def _add_check(self) -> None: + """ + Add the check currently described by the form. + + Refuses a check whose operation is missing a required argument. It used to accept one, + which made an empty form a working button: pressing Add check on a blank form appended + a check with no provider_args at all, and doing it three times gave three of them and a + policy the engine could not run. + """ + provider_args = self._collect_provider_args() + operation = schema.operation_for(self._provider_name, self._operation_name) + missing = [ + arg.name for arg in (operation.args if operation else []) if arg.required and arg.name not in provider_args + ] + if missing: + self.query_one("#builder-findings", Static).update( + "[red]✘ Fill in " + + ", ".join(f"[b]{render.escape(name)}[/b]" for name in missing) + + " before adding this check.[/red]" + ) + return + + check_id = self.query_one("#id-input", Input).value.strip() + if not check_id: + check_id = f"check{len(self._checks) + 1}" + + evaluator_name = str(self.query_one("#evaluator-select", Select).value) + info = schema.EVALUATORS.get(evaluator_name) + + condition = {"type": evaluator_name} + if info and info.value_kind != "none": + condition["value"] = parse_value(self.query_one("#value-input", Input).value) + + self._checks.append( + { + "id": check_id, + "provider_args": provider_args, + "condition": condition, + } + ) + + self.query_one("#id-input", Input).value = "" + self.query_one("#value-input", Input).value = "" + self._refresh_preview() + + def default_expression(self) -> str: + """Every check must pass -- the commonest intent, and the one to start from.""" + return " && ".join(check["id"] for check in self._checks) + + def current_expression(self) -> str: + """ + The expression to write into the policy. + + The field tracks the checks automatically until the user types something of their own, + at which point it is left alone: an expression is the one part of a policy that cannot + be derived from the checks, so silently rewriting `a && !b` back to `a && b` on the + next Add check would discard the only thing the user could not express any other way. + """ + if self._expression_is_custom: + try: + return self.query_one("#expression-input", Input).value.strip() + except Exception: + # Called before the field exists, during the first refresh in compose. + return self.default_expression() + return self.default_expression() + + def build_policy(self) -> dict: + """The policy as currently assembled.""" + return { + "meta": {"version": "v1", "required_provider": self._provider_name}, + "evaluators": list(self._checks), + "eval_expression": self.current_expression(), + } + + def _refresh_preview(self) -> None: + # Keep the field showing the generated expression while it is still generated, so the + # user can see what they are about to edit rather than an empty box. `with_expression` + # suppresses the resulting Changed event, which would otherwise look like a user edit + # and pin the expression the first time a check was added. + if not self._expression_is_custom: + field = self.query_one("#expression-input", Input) + generated = self.default_expression() + if field.value != generated: + with self.prevent(Input.Changed): + field.value = generated + + policy = self.build_policy() + self.query_one("#policy-json", Static).update(render.escape(json.dumps(policy, indent=2))) + + listing = self.query_one("#check-list", ListView) + listing.clear() + for check in self._checks: + listing.append(ListItem(Label(render.escape(check["id"])))) + + findings = self.query_one("#builder-findings", Static) + if not self._checks: + findings.update("[dim]Add a check to build a policy.[/dim]") + return + + problems = validate.check_policy(policy) + if problems: + findings.update("\n".join(render.finding_markup(f) for f in problems[:4])) + else: + findings.update("[green]✔ Valid policy[/green]") diff --git a/src/tirith/tui/views/explorer.py b/src/tirith/tui/views/explorer.py new file mode 100644 index 0000000..a794e0a --- /dev/null +++ b/src/tirith/tui/views/explorer.py @@ -0,0 +1,178 @@ +r""" +The Explorer: navigate an evaluation's checks, results, and the resources behind them. + +The view this whole package was written for. `pretty_print_result_dict` prints a flat list of +messages, which on a wildcard policy over a real plan is hundreds of lines reading +`"product-456"` is not empty, with nothing to say which resource each came from -- while +the result document has carried the resource's full address, planned action and before/after +values all along. + +So: a tree of checks on the left, and the resource detail on the right. +""" + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.widgets import Static, Tree + +from .. import render, results + + +class ExplorerView(Vertical): + """Checks on the left, the selected result's detail on the right.""" + + def __init__(self, report=None, **kwargs): + super().__init__(**kwargs) + self._report = report if report is not None else results.parse_report({}) + # Maps a tree node's id to what it stands for, because Tree nodes carry arbitrary data + # but comparing node identity across rebuilds is unreliable. + self._node_targets = {} + + DESCRIPTION = "Read an evaluation down to the resource behind each result. Select a row for detail." + + def compose(self) -> ComposeResult: + yield Static(self.DESCRIPTION, classes="tab-description") + yield Static(id="report-summary") + yield Static(id="report-errors") + with Horizontal(id="explorer-body"): + yield Tree("Checks", id="check-tree") + with VerticalScroll(id="detail-pane"): + yield Static(id="detail-content") + + def on_mount(self) -> None: + self._render_report() + + def refresh_report(self, report) -> None: + """ + Show a new report. + + Callable before this view has mounted its children. The Playground evaluates as it + mounts and hands its result straight over, and the two views mount in an order Textual + does not guarantee -- so roughly one run in six the report arrived while + #report-summary did not yet exist, raising NoMatches out of a tab the user had not + even opened. + + Stores it and defers the draw rather than skipping it: skipping would lose whichever + report lost the race, and that is the report the user asked to see. + """ + self._report = report + if self.is_mounted: + self._render_report() + + def _render_report(self) -> None: + """Draw the stored report. Only reached once this view's own children exist.""" + report = self._report + self._node_targets = {} + + summary = self.query_one("#report-summary", Static) + summary.update(render.report_headline(report)) + + errors = self.query_one("#report-errors", Static) + if report.errors: + errors.update("\n".join(f"! {render.escape(e)}" for e in report.errors)) + errors.display = True + else: + errors.display = False + + tree = self.query_one("#check-tree", Tree) + tree.clear() + tree.root.expand() + + if not report.checks: + tree.root.label = "No checks" + self._show_empty() + return + + tree.root.label = f"{len(report.checks)} checks" + + first_failing_node = None + for check in report.checks: + node = tree.root.add(render.check_label(check), expand=check.passed is False) + self._node_targets[node.id] = ("check", check, None) + + for result in check.results: + leaf = node.add_leaf(render.result_label(result)) + self._node_targets[leaf.id] = ("result", check, result) + # Open on the first failure. A passing policy is read top-down, but a failing + # one is opened to find out what failed, and making that the default saves + # the user hunting for it. + if first_failing_node is None and result.passed is False: + first_failing_node = leaf + + target = first_failing_node + if target is not None: + tree.select_node(target) + tree.scroll_to_node(target) + self._show_result(*self._node_targets[target.id][1:]) + else: + self._show_check(report.checks[0]) + + def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + entry = self._node_targets.get(event.node.id) + if entry is None: + return + kind, check, result = entry + if kind == "check": + self._show_check(check) + else: + self._show_result(check, result) + + # Highlight follows the cursor as it moves with the arrow keys, so the detail pane tracks + # keyboard navigation rather than only responding to Enter. + def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None: + self.on_tree_node_selected(Tree.NodeSelected(event.node)) + + def _detail(self) -> Static: + return self.query_one("#detail-content", Static) + + def _show_empty(self) -> None: + self._detail().update("[dim]Nothing to show. Evaluate a policy to see its results here.[/dim]") + + def _show_check(self, check) -> None: + lines = [ + f"{render.status_markup(check.status, check.id)}", + "", + ] + if check.description: + lines.append(render.escape(check.description)) + lines.append("") + lines.append(f"[dim]{render.escape(check.summary)}[/dim]") + + failing = check.failing_results() + if failing: + lines.append("") + lines.append("[bold]Failing resources[/bold]") + for result in failing: + # Escaped once, on the next line. Escaping the fallback here too put a literal + # backslash on screen for every result without a resource label -- which is + # every json and sg_workflow result. + name = result.resource.label or result.message + lines.append(f" ✘ {render.escape(name)}") + + self._detail().update("\n".join(lines)) + + def _show_result(self, check, result) -> None: + lines = [ + render.status_markup(result.status, check.id), + "", + render.escape(result.message), + ] + + resource_lines = render.resource_lines(result) + if resource_lines: + lines.append("") + lines.append("[bold]Resource[/bold]") + lines.extend(f" {line}" for line in resource_lines) + + diff_lines = render.attribute_diff_lines(result) + if diff_lines: + lines.append("") + lines.append("[bold]Changed attributes[/bold]") + lines.extend(f" {line}" for line in diff_lines) + elif result.raw_meta and not resource_lines: + # A provider recorded something we do not model. Better to show it raw than to + # silently drop detail the user came here for. + lines.append("") + lines.append("[bold]Metadata[/bold]") + lines.append(f" {render.escape(render.format_value(result.raw_meta, 800))}") + + self._detail().update("\n".join(lines)) diff --git a/src/tirith/tui/views/filepicker.py b/src/tirith/tui/views/filepicker.py new file mode 100644 index 0000000..0b92775 --- /dev/null +++ b/src/tirith/tui/views/filepicker.py @@ -0,0 +1,173 @@ +""" +A modal file browser, for choosing a policy or an input document. + +Typing a path was the first version and it asks the user to already know where the file is, +which is the thing a browser is for. This walks the filesystem instead: arrow keys to move, +Enter to open a directory or choose a file. + +Filtered to the documents this program can actually read -- JSON and YAML -- plus directories +to descend into. Hidden entries are skipped, because `.git` and `.venv` are noise here and +expanding them is slow. +""" + +import os + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, DirectoryTree, Input, Static + +# What the engine can parse. YAML is included because the core reads .yaml/.yml inputs, even +# though the playground's editors show JSON. +READABLE_SUFFIXES = {".json", ".yaml", ".yml", ".jsonc"} + +# Directories that are never worth walking into from a project root, and are often enormous. +SKIP_DIRECTORIES = { + ".git", + ".venv", + "venv", + "node_modules", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".terraform", + "dist", + "build", +} + + +class DocumentTree(DirectoryTree): + """A DirectoryTree showing only directories and documents the engine can read.""" + + def filter_paths(self, paths): + keep = [] + for path in paths: + if path.name.startswith("."): + continue + if path.is_dir(): + if path.name not in SKIP_DIRECTORIES: + keep.append(path) + continue + if path.suffix.lower() in READABLE_SUFFIXES: + keep.append(path) + # Directories first, then files, each alphabetically -- the order a file manager uses, + # rather than the arbitrary one the filesystem returns. + return sorted(keep, key=lambda p: (not p.is_dir(), p.name.lower())) + + +class FilePicker(ModalScreen[str]): + """ + Choose a file. Dismissed with the chosen path, or with None if cancelled. + + A ModalScreen returning a value, so the caller awaits a path rather than wiring up + callbacks: `path = await self.app.push_screen_wait(FilePicker("policy"))`. + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + # Left and right walk the filesystem, the way a file manager does. A DirectoryTree can + # only ever descend from its root, so without re-rooting the picker could reach nothing + # outside the directory it opened in -- and the plan you want to check is usually in + # another repo entirely. + # + # Tree leaves plain left/right unbound (it uses shift+left / shift+right for parent and + # sibling), so these take nothing away. + Binding("left,backspace", "go_up", "Parent"), + Binding("right", "go_into", "Open"), + Binding("~", "go_home", "Home"), + ] + + def __init__(self, slot: str, start_directory: str = "."): + """ + :param slot: "policy" or "input", named in the title so it is clear which + editor the chosen file will fill. + :param start_directory: Where to open. Defaults to the working directory, which is + where a user running this in their repo expects to start. + """ + super().__init__() + self._slot = slot + self._directory = os.path.abspath(start_directory) + + def compose(self) -> ComposeResult: + with Vertical(id="file-picker"): + yield Static(f"Open a file as the {self._slot}", id="file-picker-title") + yield Static(self._directory, id="file-picker-location") + yield Static( + "↑↓ move · → into · ← back · Enter choose · ~ home · Esc cancel · .json .yaml .yml", + id="file-picker-help", + ) + yield DocumentTree(self._directory, id="file-picker-tree") + # Kept as an escape hatch: a path that is quicker to paste than to navigate to, + # and the only way to reach one whose directory is faster to type than to walk. + yield Input(placeholder="…or type a path and press Enter", id="file-picker-path") + with Horizontal(id="file-picker-buttons"): + yield Button("← Back", id="file-picker-up") + yield Button("Cancel", id="file-picker-cancel") + + def on_mount(self) -> None: + self.query_one("#file-picker-tree", DirectoryTree).focus() + + def _show_directory(self, directory: str) -> None: + """ + Re-root the tree at a new directory. + + Reassigning `path` rather than rebuilding the widget, which keeps focus where it is + and is what DirectoryTree supports for changing root. + """ + self._directory = os.path.abspath(directory) + tree = self.query_one("#file-picker-tree", DocumentTree) + tree.path = self._directory + tree.reload() + self.query_one("#file-picker-location", Static).update(self._directory) + tree.focus() + + def action_go_up(self) -> None: + parent = os.path.dirname(self._directory) + # dirname("/") is "/", so this stops at the filesystem root rather than looping. + if parent and parent != self._directory: + self._show_directory(parent) + + def action_go_into(self) -> None: + """ + Re-root at the directory under the cursor, so right/left are inverses of each other. + + Descending by re-rooting rather than expanding in place: it keeps the tree showing one + directory at a time, which is what makes `left` a reliable way back. Expanding instead + would leave the root where it was and the two keys would stop being symmetrical. + """ + node = self.query_one("#file-picker-tree", DocumentTree).cursor_node + entry = getattr(node, "data", None) + path = getattr(entry, "path", None) + if path and os.path.isdir(path): + self._show_directory(str(path)) + + def action_go_home(self) -> None: + self._show_directory(os.path.expanduser("~")) + + def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None: + self.dismiss(str(event.path)) + + def on_input_submitted(self, event: Input.Submitted) -> None: + raw = event.value.strip().strip("'\"") + if not raw: + return + path = os.path.expanduser(raw) + # A directory means "take me there", not "open this as a document" -- typing a path is + # often the fastest way to get to a folder deep in someone else's checkout. + if os.path.isdir(path): + event.input.value = "" + self._show_directory(path) + return + self.dismiss(path) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "file-picker-cancel": + self.action_cancel() + elif event.button.id == "file-picker-up": + self.action_go_up() + + def action_cancel(self) -> None: + # Dismissing with None rather than "" so the caller can tell "cancelled" from a path, + # and never has to guess at an empty string. + self.dismiss(None) diff --git a/src/tirith/tui/views/playground.py b/src/tirith/tui/views/playground.py new file mode 100644 index 0000000..926fb1a --- /dev/null +++ b/src/tirith/tui/views/playground.py @@ -0,0 +1,437 @@ +""" +The Playground: edit a policy and an input side by side and watch the verdict move. + +Load an example, change something, see what happens. That loop is the fastest way to learn a +policy language, and it is what the repository's fixtures cannot offer -- they teach the +engine's authors, not its users. + +Evaluation runs on every edit, debounced. Everything that can go wrong while typing -- JSON +that does not parse yet, a policy missing half its keys, a provider that raises -- is caught +and reported in the findings pane, because during editing the broken state is the normal state +and a traceback would be the usual output rather than the exception. +""" + +import json +import os + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.widgets import Button, Input, Markdown, Select, Static, TextArea + +from .. import examples as examples_module +from .. import render, results, schema, validate +from .filepicker import FilePicker +from ...core.core import start_policy_evaluation_from_dict + +# How long to wait after the last keystroke before re-evaluating. Long enough not to run on +# every character of a pasted document, short enough to feel immediate. +DEBOUNCE_SECONDS = 0.4 + + +class PlaygroundView(Vertical): + """A description strip, then examples and editors on the left, results on the right.""" + + DESCRIPTION = "Edit a policy or its input and watch the verdict change. Pick an example to start." + + def __init__(self, on_report=None, on_user_action=None, **kwargs): + super().__init__(**kwargs) + # Called with each new report so the Explorer tab can show the same evaluation. + self._on_report = on_report + # Called when the user does something deliberate here -- edits a document, presses Run, + # opens a file, picks an example. It tells the app that a report opened with --result + # is no longer what they are looking at. Mount-time evaluation does not call it. + self._on_user_action = on_user_action + self._examples = examples_module.load_examples() + self._timer = None + self._current_example = None + # Which editor a chosen file will fill; set by an Open button. + self._pending_slot = "input" + + def compose(self) -> ComposeResult: + # The example picker belongs to the whole tab, not to either editor: choosing one + # replaces the policy *and* the input together. Sitting it in the editor column made it + # look like a third document, and its bordered Select needs three rows next to + # one-row toolbars, which is what made that corner look wrong. + with Horizontal(id="playground-header"): + # Labelled and placed immediately after the sentence that tells you to use it. + # Unlabelled and pushed to the far right, it read as a status field showing the + # name of something rather than as the control that changes it. + yield Static("Example:", id="example-label") + # compact drops the border, so it sits on one row inside the band rather than as a + # bordered box floating in it. + yield Select( + [(e.title, i) for i, e in enumerate(self._examples)], + prompt="Choose one", + compact=True, + id="example-select", + ) + yield Button("About", id="toggle-about", classes="header-button") + yield Static(self.DESCRIPTION, id="playground-description") + + with Horizontal(id="playground-body"): + with Vertical(id="playground-editors"): + # Each editor carries its own actions, next to the thing they act on. + with Horizontal(classes="editor-toolbar"): + yield Static("Policy", classes="toolbar-label") + yield Button("Copy", id="copy-policy", classes="icon-button") + yield Button("Open", id="open-policy", classes="icon-button") + yield Button("Clear", id="clear-policy", classes="icon-button") + yield TextArea(id="policy-editor", language="json", soft_wrap=False) + + with Horizontal(classes="editor-toolbar"): + yield Static("Input document", classes="toolbar-label", id="input-label") + yield Button("Copy", id="copy-input", classes="icon-button") + yield Button("Open", id="open-input", classes="icon-button") + yield Button("Clear", id="clear-input", classes="icon-button") + yield TextArea(id="input-editor", language="json", soft_wrap=False) + + with Vertical(id="playground-results"): + yield Static("", id="playground-status") + yield Static("", id="playground-notice") + yield Static("", id="findings-list") + yield VerticalScroll(Static(id="playground-output"), id="playground-output-scroll") + yield Horizontal( + Button("Run", variant="primary", id="run-now"), + Button("Reset example", id="reset-example"), + id="playground-buttons", + ) + # Hidden until asked for. The notes are worth reading once per example and + # then permanently in the way of the results, which are the reason to be here. + yield Static("About this example", classes="editor-label", id="about-label") + yield VerticalScroll(Markdown("", id="example-detail"), id="about-scroll") + + def on_mount(self) -> None: + # Both start hidden: the notes are read once and then in the way, and the path box is + # only relevant once an Open button has been pressed. + self.query_one("#about-scroll").display = False + self.query_one("#about-label", Static).display = False + + if self._examples: + self.load_example(self._examples[0]) + # Select without firing Changed, which would re-load the example we just loaded. + with self.prevent(Select.Changed): + self.query_one("#example-select", Select).value = 0 + else: + self._set_status("[dim]No examples are bundled with this install.[/dim]") + + def _notify(self, message: str) -> None: + """ + Confirm an action that has no visible result of its own -- a copy, a file load. + + Its own row rather than the status line: the status line holds the verdict, and the + debounced re-evaluation overwrites whatever is there a moment later, so a confirmation + put in it disappeared before it could be read. + """ + self.query_one("#playground-notice", Static).update(f"[dim]{render.escape(message)}[/dim]") + + # --------------------------------------------------------------- loading + + def load_example(self, example) -> None: + self._current_example = example + # Suppressed for the same reason as in load_documents: these are our writes, not edits. + with self.prevent(TextArea.Changed): + self.query_one("#policy-editor", TextArea).text = example.policy_json + self.query_one("#input-editor", TextArea).text = example.input_json + self.query_one("#example-detail", Markdown).update(example.about) + self.evaluate_now() + + def load_policy(self, policy: dict) -> None: + """Used by the Builder's 'Open in playground' button.""" + self.load_documents(policy, None) + + def load_documents(self, policy=None, input_document=None) -> None: + """ + Replace either editor, leaving the other alone. + + Both are optional so `--policy` without `--input` keeps whichever example was loaded + as the document to evaluate against, rather than blanking it and reporting an error. + """ + # Suppresses the Changed events these writes raise, rather than flagging and ignoring + # them: the events are delivered a frame later, so any flag set around the assignment + # has already been cleared by the time the handler runs. Without this the handler + # wipes the confirmation the caller is about to display. + with self.prevent(TextArea.Changed): + if policy is not None: + self.query_one("#policy-editor", TextArea).text = json.dumps(policy, indent=2) + # The loaded policy is no longer the example's, so offering to "reset" to an + # example the user did not load would be misleading. + self._current_example = None + self.query_one("#example-detail", Markdown).update("") + if input_document is not None: + self.query_one("#input-editor", TextArea).text = json.dumps(input_document, indent=2) + self.evaluate_now() + + def on_select_changed(self, event: Select.Changed) -> None: + """ + Load the chosen example, or clear both editors when the prompt row is chosen. + + The blank value is `Select.NULL`, a NoSelection sentinel. Not `Select.BLANK`, which is + the literal `False` -- comparing against that let the sentinel through to int() and + crashed the app the first time anyone opened the dropdown and picked the prompt. + """ + if event.select.id != "example-select": + return + self._note_user_action() + + if event.value is Select.NULL: + # Choosing the prompt means "none of these": empty both editors, so it is a way to + # start from nothing rather than a no-op that leaves the last example loaded. + self._current_example = None + self.query_one("#example-detail", Markdown).update("") + self.query_one("#policy-editor", TextArea).text = "" + self.query_one("#input-editor", TextArea).text = "" + self.evaluate_now() + return + + index = int(event.value) + if 0 <= index < len(self._examples): + self.load_example(self._examples[index]) + + def on_button_pressed(self, event: Button.Pressed) -> None: + actions = { + "run-now": self.evaluate_now, + "reset-example": self._reset_example, + "toggle-about": self._toggle_about, + "copy-policy": lambda: self._copy("#policy-editor", "Policy"), + "copy-input": lambda: self._copy("#input-editor", "Input document"), + "clear-policy": lambda: self._clear("#policy-editor"), + "clear-input": lambda: self._clear("#input-editor"), + "open-policy": lambda: self._prompt_for_file("policy"), + "open-input": lambda: self._prompt_for_file("input"), + } + action = actions.get(event.button.id or "") + if action: + self._note_user_action() + action() + + def _note_user_action(self) -> None: + """Tell the app the user has taken over, so a --result report stops being pinned.""" + if self._on_user_action: + self._on_user_action() + + # ------------------------------------------------------------- toolbars + + def _reset_example(self) -> None: + if self._current_example: + self.load_example(self._current_example) + + def _copy(self, selector: str, label: str) -> None: + """ + Put an editor's contents on the system clipboard. + + Textual routes this through the terminal's OSC 52 escape, which most terminals honour + and some do not -- and there is no reply to tell us which. So the message says what was + attempted rather than claiming success. + """ + text = self.query_one(selector, TextArea).text + if not text.strip(): + self._notify(f"{label} is empty; nothing to copy.") + return + self.app.copy_to_clipboard(text) + self._notify(f"{label} sent to the clipboard ({len(text)} characters).") + + def _clear(self, selector: str) -> None: + self.query_one(selector, TextArea).text = "" + self.evaluate_now() + + def _prompt_for_file(self, slot: str) -> None: + """ + Open the file browser and load whatever comes back. + + A browser rather than the path box this replaced: typing a path assumes you already + know where the file is, which is the thing you open a file dialog to find out. + """ + self._pending_slot = slot + + def loaded(path): + # None means cancelled, which is not an error and needs no message. + if path: + self._load_path(path, slot) + + self.app.push_screen(FilePicker(slot), loaded) + + def _toggle_about(self) -> None: + showing = not self.query_one("#about-scroll").display + self.query_one("#about-scroll").display = showing + self.query_one("#about-label", Static).display = showing + + def _load_path(self, raw_path, slot=None): + """ + Read a file into the policy editor, the input editor, or whichever fits. + + :param raw_path: Path to read. `~` and surrounding quotes are tolerated, since both + arrive from ordinary shell copy-paste. + :param slot: "policy", "input", or None to decide from the document itself. A + policy is recognisable -- an object with `evaluators` and `meta` -- + so the guess is reliable when the caller has no preference. + """ + path = os.path.expanduser(str(raw_path).strip().strip("'\"")) + try: + with open(path, encoding="utf-8") as f: + document = json.load(f) + except FileNotFoundError: + self._report_problem(f"No such file: {path}") + return + except IsADirectoryError: + self._report_problem(f"{path} is a directory, not a JSON file.") + return + except OSError as e: + self._report_problem(f"Could not read {path}: {e}") + return + except json.JSONDecodeError as e: + self._report_problem(f"{path} is not valid JSON: {e.msg} (line {e.lineno}, column {e.colno})") + return + + if slot is None: + slot = "policy" if _looks_like_a_policy(document) else "input" + + if slot == "policy": + self.load_documents(policy=document) + else: + self.load_documents(input_document=document) + + self._notify(f"Loaded {os.path.basename(path)} as the {slot}.") + + # ------------------------------------------------------------ evaluation + + def on_text_area_changed(self, event: TextArea.Changed) -> None: + del event + # Typing here is a user action; programmatic writes suppress this event entirely. + self._note_user_action() + # Typing makes a previous confirmation ("Loaded plan.json") stale. Programmatic writes + # do not reach here at all -- load_documents suppresses their Changed events -- so this + # only ever runs for a real edit. + self.query_one("#playground-notice", Static).update("") + # Debounced rather than immediate: a paste would otherwise run the engine once per + # character, and each run walks the whole input document. + if self._timer is not None: + self._timer.stop() + self._timer = self.set_timer(DEBOUNCE_SECONDS, self.evaluate_now) + + def evaluate_now(self) -> None: + policy_text = self.query_one("#policy-editor", TextArea).text + input_text = self.query_one("#input-editor", TextArea).text + + policy, policy_error = _parse_json(policy_text, "Policy") + if policy_error: + self._report_problem(policy_error) + return + + input_document, input_error = _parse_json(input_text, "Input document") + if input_error: + self._report_problem(input_error) + return + + findings = validate.check_policy(policy) + errors = [f for f in findings if f.severity == "error"] + if not isinstance(policy, dict): + # Valid JSON but not an object -- a bare list or string parses fine and would + # otherwise reach the engine and raise. check_policy reports it; stop here so the + # engine is never handed something it cannot read. + self._show_findings(findings) + self._set_status("[red]✘ Cannot evaluate[/red]") + self.query_one("#playground-output", Static).update("") + return + if errors: + # Still shown as findings rather than refusing to run: a half-written policy is + # the normal state here, and the findings say what is missing. + self._show_findings(findings) + self._set_status("[yellow]▲ Policy is incomplete[/yellow]") + self.query_one("#playground-output", Static).update("") + return + + provider = policy.get("meta", {}).get("required_provider", "") + self._label_expected_input(provider) + findings = findings + validate.check_input_document(input_document, provider) + + try: + raw_result = start_policy_evaluation_from_dict(policy, input_document) + except Exception as e: + # The engine raises for things the validator cannot see -- an unsupported operator + # in eval_expression reaches here as ValueError. Report it as a finding; a + # traceback in a playground is a bug in the playground. + self._report_problem(f"{type(e).__name__}: {e}") + return + + report = results.parse_report(raw_result) + self._show_findings(findings) + self._set_status(render.report_headline(report)) + self._show_results(report) + + if self._on_report: + self._on_report(report) + + def _label_expected_input(self, provider_name: str) -> None: + """ + Say what document the policy's provider expects, above the editor it goes in. + + The commonest way to waste ten minutes here is feeding the right JSON to the wrong + provider: a terraform plan under `stackguardian/kubernetes` reports that no kind + matched, which is true and says nothing about the actual mistake. + """ + described = schema.PROVIDERS.get(provider_name) + label = self.query_one("#input-label", Static) + if described is None: + label.update("Input document") + return + label.update(f"Input document [dim]— {render.escape(described.input_hint)}[/dim]") + + def _report_problem(self, message: str) -> None: + self._set_status("[red]✘ Cannot evaluate[/red]") + self.query_one("#findings-list", Static).update(f"[red]{render.escape(message)}[/red]") + self.query_one("#playground-output", Static).update("") + + def _set_status(self, markup: str) -> None: + self.query_one("#playground-status", Static).update(markup) + + def _show_findings(self, findings) -> None: + widget = self.query_one("#findings-list", Static) + if not findings: + widget.update("") + return + widget.update("\n".join(render.finding_markup(f) for f in findings[:6])) + + def _show_results(self, report) -> None: + lines = [] + for check in report.checks: + lines.append(render.check_label(check)) + for result in check.results: + lines.append(f" {render.result_label(result)}") + if result.resource.label: + lines.append(f" [dim]{render.escape(result.message)}[/dim]") + lines.append("") + + if report.errors: + lines.append("[red]Errors[/red]") + lines.extend(f" {render.escape(e)}" for e in report.errors) + + if report.eval_expression: + lines.append(f"[dim]Expression: {render.escape(report.eval_expression)}[/dim]") + + self.query_one("#playground-output", Static).update("\n".join(lines)) + + +def _looks_like_a_policy(document) -> bool: + """ + Whether a loaded document is a Tirith policy rather than something to evaluate. + + Structural, not name-based: a policy is an object carrying `evaluators`, which is a key no + plan, manifest or cost report has. Guessing from the filename would be wrong as often as + right -- plenty of policies are called policy.json, but plenty are not. + """ + return isinstance(document, dict) and "evaluators" in document and "meta" in document + + +def _parse_json(text: str, label: str): + """ + Parse an editor buffer, returning (value, error_message). + + The error carries the line and column, because in a 200-line plan "Expecting ',' delimiter" + on its own is not enough to find the typo. + """ + if not text.strip(): + return None, f"{label} is empty." + try: + return json.loads(text), None + except json.JSONDecodeError as e: + return None, f"{label} is not valid JSON: {e.msg} (line {e.lineno}, column {e.colno})" diff --git a/tests/cli/test_dispatch.py b/tests/cli/test_dispatch.py index df0c012..b0dc8ab 100644 --- a/tests/cli/test_dispatch.py +++ b/tests/cli/test_dispatch.py @@ -94,15 +94,37 @@ def test_a_bare_word_is_not_mistaken_for_a_subcommand(capsys): assert "check" not in cli.SUBCOMMANDS -def test_there_is_exactly_one_subcommand_name(capsys): +def test_the_subcommand_names_are_exactly_these(capsys): """ `platform` was briefly renamed to `remote` and then reverted. Neither direction kept an alias -- - nothing is released, so there was never a caller to keep working -- and this pins the outcome: one - name, and `remote` is not quietly still accepted. + nothing is released, so there was never a caller to keep working -- and this pins the outcome: + `remote` is not quietly still accepted. + + `ui` was added alongside it later, on the same terms: dispatched before the flat parser so the + local surface and its golden-file output are untouched. The set is pinned rather than merely + checked for membership, so a new subcommand has to be a deliberate edit here. """ - assert cli.SUBCOMMANDS == {"platform"} + assert cli.SUBCOMMANDS == {"platform", "ui"} status = cli.main(["remote"]) assert status != ExitStatus.SUCCESS assert "tirith platform" not in capsys.readouterr().out + + +def test_ui_dispatches_to_its_own_parser(capsys): + """ + `ui` must reach its own parser rather than the flat one, which would reject it for having no + -policy-path. + + Asserted through a bad flag, so the interface itself never starts and this stays runnable + without the optional extra installed. argparse exits rather than returning on an unknown + flag -- the same thing `tirith platform --nope` does -- so the SystemExit is the pass + condition; what matters is *which* parser produced the complaint. + """ + with pytest.raises(SystemExit): + cli.main(["ui", "--no-such-flag"]) + + error = capsys.readouterr().err + assert "tirith ui" in error, error + assert "-policy-path" not in error diff --git a/tests/tui/test_app.py b/tests/tui/test_app.py new file mode 100644 index 0000000..c78bb73 --- /dev/null +++ b/tests/tui/test_app.py @@ -0,0 +1,1156 @@ +""" +Drive the real app headlessly and assert what it puts on screen. + +Textual's `run_test` runs the whole app -- compose, mount, layout, events -- against a virtual +terminal, so these are not widget unit tests: they catch the failures that only appear once +things are actually mounted, which is most of them. + +Every test here is skipped when textual is absent, because CI runs the suite on Python 3.8 +where it cannot be installed. The rest of the tui test files deliberately avoid importing +textual so they still run there. +""" + +import asyncio +import functools +import json +import os +from pathlib import Path + +from pytest import fixture, mark, importorskip + +importorskip("textual", reason="the TUI is an optional extra (pip install 'py-tirith[tui]')") + + +def drives_the_app(test): + """ + Run an async test body under asyncio. + + pytest skips `async def` tests unless an async plugin is installed, and a *skipped* test + reads as green -- so without this the whole file would appear to pass in CI while + asserting nothing. Wrapping them keeps the suite's dependencies unchanged (the repo pins + only pytest and black as dev dependencies) and makes a failure a real failure. + """ + + @functools.wraps(test) + def wrapper(*args, **kwargs): + return asyncio.run(test(*args, **kwargs)) + + return wrapper + + +# Imported after the importorskip above, so this module is skipped rather than failing to +# import when textual is absent. +from rich.text import Text # noqa: E402 +from textual.widgets import Button, Input, Select, Tabs # noqa: E402 + +from tirith.core.core import start_policy_evaluation_from_dict # noqa: E402 +from tirith.tui import examples, results, validate # noqa: E402 +from tirith.tui.app import build_app # noqa: E402 +from tirith.tui.views.builder import BuilderView, parse_value # noqa: E402 +from tirith.tui.views.explorer import ExplorerView # noqa: E402 +from tirith.tui.views.filepicker import DocumentTree, FilePicker # noqa: E402 +from tirith.tui.views.playground import PlaygroundView # noqa: E402 + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +TF_FIXTURES = os.path.join(REPO_ROOT, "tests", "providers", "terraform_plan", "fixtures") +K8S_EXAMPLE_DIR = os.path.join(REPO_ROOT, "src", "tirith", "tui", "examples", "05-kubernetes-probes") + +# A wide terminal, so panes are not collapsed to nothing and content is really laid out. +TERMINAL_SIZE = (140, 45) + + +@fixture +def failing_report(): + """A real evaluation with failures, resource addresses and an attribute diff.""" + example = examples.example_by_key("04-block-destroy") + return results.parse_report(start_policy_evaluation_from_dict(example.policy, example.input_document)) + + +def _text_of(widget): + """ + The rendered text of a Static, with markup resolved. + + Textual renamed `Static.renderable` to `.content` (returning a Content rather than a Rich + renderable), so both are tried -- these tests should not pin the app to one Textual + version when the app itself works on either. + """ + content = getattr(widget, "content", None) + if content is None: + content = getattr(widget, "renderable", "") + return content.plain if hasattr(content, "plain") else str(content) + + +# ------------------------------------------------------------------ explorer + + +@mark.passing +@drives_the_app +async def test_app_starts_on_explorer_when_given_a_report(failing_report): + app = build_app(report=failing_report) + async with app.run_test(size=TERMINAL_SIZE): + assert app.query_one("#tabs").active == "explorer" + + +@mark.passing +@drives_the_app +async def test_naming_a_policy_and_input_opens_on_the_results(): + """ + Someone who passed their own policy and plan came to see how it did, so the results are + what should be on screen -- not the editor they would have to leave to find them. + """ + example = examples.example_by_key("04-block-destroy") + app = build_app(policy=example.policy, input_document=example.input_document) + + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + + assert app.query_one("#tabs").active == "explorer" + detail = _text_of(app.query_one("#detail-content")) + assert "aws_db_instance.primary" in detail + + +@mark.passing +@drives_the_app +async def test_a_policy_without_an_input_stays_on_the_playground(): + """There is nothing to show in the Explorer without a document to evaluate against.""" + example = examples.example_by_key("04-block-destroy") + app = build_app(policy=example.policy) + + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + assert app.query_one("#tabs").active == "playground" + + +@mark.passing +@drives_the_app +async def test_app_starts_on_playground_with_no_arguments(): + """With nothing to explore, the useful thing to show is the playground.""" + app = build_app() + async with app.run_test(size=TERMINAL_SIZE): + assert app.query_one("#tabs").active == "playground" + + +@mark.passing +@drives_the_app +async def test_explorer_lists_every_check(failing_report): + app = build_app(report=failing_report) + async with app.run_test(size=TERMINAL_SIZE): + tree = app.query_one("#check-tree") + assert len(tree.root.children) == len(failing_report.checks) + + +@mark.passing +@drives_the_app +async def test_explorer_opens_on_the_first_failure(failing_report): + """ + A failing policy is opened to find out what failed, so the detail pane must already be + showing a failure rather than waiting to be navigated. + """ + app = build_app(report=failing_report) + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + detail = _text_of(app.query_one("#detail-content")) + assert "✘" in detail + + +@mark.passing +@drives_the_app +async def test_explorer_shows_the_resource_address(failing_report): + """ + The whole reason this view exists: name the resource, which the pretty printer cannot. + """ + app = build_app(report=failing_report) + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + detail = _text_of(app.query_one("#detail-content")) + assert "aws_db_instance.primary" in detail + + +@mark.passing +@drives_the_app +async def test_explorer_shows_the_attribute_that_forced_a_replacement(failing_report): + """ + The detail that makes a destroy comprehensible: *why* terraform is replacing it. + """ + app = build_app(report=failing_report) + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + detail = _text_of(app.query_one("#detail-content")) + assert "instance_class" in detail + assert "db.t3.medium" in detail and "db.t3.large" in detail + + +@mark.passing +@drives_the_app +async def test_explorer_names_the_replacement_ordering(failing_report): + """destroy-first means downtime; it must not read the same as create-first.""" + app = build_app(report=failing_report) + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + assert "destroy first" in _text_of(app.query_one("#detail-content")) + + +@mark.passing +@drives_the_app +async def test_explorer_accepts_a_report_before_it_has_mounted(failing_report): + """ + The Playground evaluates while mounting and hands its result to the Explorer, and the two + mount in an order Textual does not guarantee. This arrived before #report-summary existed + roughly one run in six, raising NoMatches out of a tab the user had not opened. + + Called directly on an unmounted view, which is the failing order made deterministic. It + must keep the report rather than raise, and show it once mounted. + """ + view = ExplorerView(id="unmounted-explorer") + view.refresh_report(failing_report) + + assert view._report is failing_report + + +@mark.passing +@drives_the_app +async def test_a_result_survives_an_input_given_alongside_it(failing_report): + """ + `--result r.json --input plan.json` is accepted, and two evaluations run while mounting: + the Playground's own example, then the supplied input. A pin consumed by the first was + defeated by the second, so the Explorer ended up holding example 01 evaluated against the + user's plan rather than the result they asked to explore. + """ + other_input = json.load( + open(os.path.join(REPO_ROOT, "src", "tirith", "tui", "examples", "01-required-tags", "input.json")) + ) + app = build_app(report=failing_report, input_document=other_input) + + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + + labels = " ".join(str(node.label) for node in app.query_one("#check-tree").root.children) + assert "database_not_destroyed" in labels, labels + + +@mark.passing +@drives_the_app +async def test_a_user_run_hands_the_explorer_over(failing_report): + """The pin protects the opening view, not the whole session: a deliberate Run takes over.""" + app = build_app(report=failing_report) + + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + app.query_one("#run-now", Button).press() + await pilot.pause() + + labels = " ".join(str(node.label) for node in app.query_one("#check-tree").root.children) + assert "costcenter_tag_present" in labels, labels + + +@mark.passing +@drives_the_app +async def test_a_message_without_a_resource_is_not_double_escaped(): + """ + The fallback was escaped twice, so every result without a resource label -- which is every + json and sg_workflow result -- showed a literal backslash before any bracket. + """ + report = results.parse_report( + { + "evaluators": [ + {"id": "a", "passed": False, "result": [{"passed": False, "message": "bucket[0] is not empty"}]} + ], + "final_result": False, + "errors": [], + } + ) + app = build_app(report=report) + + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + tree = app.query_one("#check-tree") + tree.select_node(tree.root.children[0]) + await pilot.pause() + + # Asserted on the *rendered* text, not the markup: one backslash in the markup is + # escaping working correctly, and only Rich's output shows whether it survives to the + # screen. Checking the markup would fail on a correct implementation. + rendered = Text.from_markup(str(app.query_one("#detail-content").content)).plain + assert "bucket[0] is not empty" in rendered + assert "\\" not in rendered + + +@mark.passing +@drives_the_app +async def test_explorer_handles_an_empty_report(): + """Opening with nothing must not crash the view.""" + app = build_app(report=results.parse_report({})) + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + assert app.query_one("#check-tree").root.label + assert _text_of(app.query_one("#detail-content")) + + +@mark.passing +@drives_the_app +async def test_explorer_survives_a_result_document_with_no_meta(): + """infracost sets meta to None; the detail pane must still render.""" + example = examples.example_by_key("03-cost-ceiling") + report = results.parse_report(start_policy_evaluation_from_dict(example.policy, example.input_document)) + + app = build_app(report=report) + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + assert _text_of(app.query_one("#detail-content")) + + +# ----------------------------------------------------------------- playground + + +@mark.passing +@drives_the_app +async def test_playground_loads_an_example_and_evaluates_it(): + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + assert app.query_one("#policy-editor").text.strip(), "no policy was loaded" + assert app.query_one("#input-editor").text.strip(), "no input was loaded" + # The first example fails by design, so a verdict must already be on screen. + assert _text_of(app.query_one("#playground-status")) + + +@mark.passing +@drives_the_app +async def test_playground_reports_broken_json_instead_of_crashing(): + """ + While typing, the buffer is invalid most of the time. That is the normal state here, and + it must produce a message rather than a traceback. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + playground = app.query_one("#playground-view", PlaygroundView) + app.query_one("#policy-editor").text = '{"meta": ' + playground.evaluate_now() + await pilot.pause() + + assert "Cannot evaluate" in _text_of(app.query_one("#playground-status")) + assert "valid JSON" in _text_of(app.query_one("#findings-list")) + + +@mark.passing +@drives_the_app +async def test_playground_reports_an_engine_exception_as_a_finding(): + """ + A single `&` makes the engine raise ValueError. The validator catches it first, but the + playground must also survive whatever the validator does not see. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + playground = app.query_one("#playground-view", PlaygroundView) + app.query_one("#policy-editor").text = json.dumps( + { + "meta": {"version": "v1", "required_provider": "stackguardian/json"}, + "evaluators": [ + { + "id": "a", + "provider_args": {"operation_type": "get_value", "key_path": "x"}, + "condition": {"type": "Equals", "value": 1}, + } + ], + "eval_expression": "a & a", + } + ) + app.query_one("#input-editor").text = '{"x": 1}' + playground.evaluate_now() + await pilot.pause() + + # Reported, not raised. Either the validator's message or the engine's is acceptable. + assert "&&" in _text_of(app.query_one("#findings-list")) + + +@mark.passing +@drives_the_app +async def test_playground_reports_a_policy_that_is_valid_json_but_not_an_object(): + """A bare list parses fine and would reach the engine; it must be stopped here.""" + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + playground = app.query_one("#playground-view", PlaygroundView) + app.query_one("#policy-editor").text = "[1, 2, 3]" + playground.evaluate_now() + await pilot.pause() + + assert "Cannot evaluate" in _text_of(app.query_one("#playground-status")) + + +@mark.passing +@drives_the_app +async def test_playground_evaluation_updates_the_explorer(): + """ + The tabs share one evaluation, so a run in the playground is explorable next door without + saving a file in between. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + tree = app.query_one("#check-tree") + assert len(tree.root.children) > 0 + + +@mark.passing +@drives_the_app +async def test_playground_loads_every_bundled_example(): + """Each example must survive being loaded into the editors and evaluated.""" + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + playground = app.query_one("#playground-view", PlaygroundView) + + for example in examples.load_examples(): + playground.load_example(example) + await pilot.pause() + status = _text_of(app.query_one("#playground-status")) + assert "Cannot evaluate" not in status, f"{example.key}: {status}" + + +# -------------------------------------------------------------------- builder + + +@mark.passing +@drives_the_app +async def test_builder_generates_a_valid_policy(): + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + builder = app.query_one("#builder-view", BuilderView) + # Fill the arguments the default operation requires, as a user would. + app.query_one("#arg-terraform_resource_type").value = "aws_s3_bucket" + app.query_one("#arg-terraform_resource_attribute").value = "acl" + app.query_one("#id-input").value = "bucket_is_private" + app.query_one("#value-input").value = '"private"' + builder._add_check() + await pilot.pause() + + policy = builder.build_policy() + evaluator = policy["evaluators"][0] + assert evaluator["id"] == "bucket_is_private" + assert evaluator["provider_args"]["terraform_resource_type"] == "aws_s3_bucket" + # The value keeps its JSON type rather than becoming the string '"private"'. + assert evaluator["condition"]["value"] == "private" + assert policy["eval_expression"] == "bucket_is_private" + assert "✔" in _text_of(app.query_one("#builder-findings")) + + +@mark.passing +@drives_the_app +async def test_builder_reports_a_missing_required_argument(): + """ + The form knows which arguments an operation requires, so leaving one out is caught before + the policy is ever run. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + builder = app.query_one("#builder-view", BuilderView) + app.query_one("#id-input").value = "incomplete" + builder._add_check() + await pilot.pause() + + findings = _text_of(app.query_one("#builder-findings")) + assert "terraform_resource_type" in findings + + +@mark.passing +@drives_the_app +async def test_builder_form_follows_the_selected_provider(): + """ + Choosing a provider must replace the argument fields, since no two providers take the + same ones -- that is the point of generating the form from the schema. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + app.query_one("#provider-select").value = "stackguardian/kubernetes" + await pilot.pause() + + # kubernetes takes kubernetes_kind and attribute_path, which no other provider does. + assert app.query_one("#arg-kubernetes_kind") + assert app.query_one("#arg-attribute_path") + + +@mark.passing +@drives_the_app +async def test_add_check_refuses_a_check_with_no_arguments(): + """ + An empty form used to be a working button: pressing Add check appended a check with no + provider_args, so three presses gave three of them and a policy the engine could not run. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + builder = app.query_one("#builder-view", BuilderView) + builder._add_check() + await pilot.pause() + + assert builder.build_policy()["evaluators"] == [] + # And it names what is missing rather than failing silently. + assert "terraform_resource_type" in _text_of(app.query_one("#builder-findings")) + + +@mark.passing +@drives_the_app +async def test_open_in_playground_refuses_an_unrunnable_policy(): + """ + Handing over an invalid policy dropped the user into the Playground showing "Policy is + incomplete" and no results -- which reads as a broken button, and leaves them in the wrong + tab to fix it. Staying put, with the reason, is the more useful answer. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + app.query_one("#send-to-playground", Button).press() + await pilot.pause() + + assert app.query_one("#tabs").active == "builder" + assert "Add a check" in _text_of(app.query_one("#builder-findings")) + + +@mark.passing +@drives_the_app +async def test_builder_sends_its_policy_to_the_playground(): + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + builder = app.query_one("#builder-view", BuilderView) + app.query_one("#arg-terraform_resource_type").value = "aws_instance" + app.query_one("#arg-terraform_resource_attribute").value = "instance_type" + app.query_one("#id-input").value = "check_one" + app.query_one("#value-input").value = '"t3.micro"' + builder._add_check() + await pilot.pause() + + # Pressed rather than clicked: a click depends on where the layout happens to put the + # button at this terminal size, which is not what this test is about. + app.query_one("#send-to-playground", Button).press() + await pilot.pause() + + assert app.query_one("#tabs").active == "playground" + assert "check_one" in app.query_one("#policy-editor").text + + +def _add_check(app, check_id, attribute="tags.costcenter", value="true"): + """Fill the form and add a check, the way a user would.""" + builder = app.query_one("#builder-view", BuilderView) + app.query_one("#arg-terraform_resource_type").value = "aws_s3_bucket" + app.query_one("#arg-terraform_resource_attribute").value = attribute + app.query_one("#id-input").value = check_id + app.query_one("#value-input").value = value + builder._add_check() + + +@mark.passing +@drives_the_app +async def test_the_expression_defaults_to_all_checks_passing(): + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + _add_check(app, "first") + await pilot.pause() + _add_check(app, "second", attribute="acl") + await pilot.pause() + + assert app.query_one("#expression-input").value == "first && second" + + +@mark.passing +@drives_the_app +async def test_a_custom_expression_survives_adding_another_check(): + """ + The expression is the one part of a policy that cannot be derived from the checks, so + regenerating it after the user has written `a && !b` would discard the only thing they + could not express any other way. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + _add_check(app, "tagged") + await pilot.pause() + _add_check(app, "public", attribute="acl") + await pilot.pause() + + app.query_one("#expression-input").value = "tagged && !public" + await pilot.pause() + + _add_check(app, "third", attribute="versioning") + await pilot.pause() + + builder = app.query_one("#builder-view", BuilderView) + assert builder.build_policy()["eval_expression"] == "tagged && !public" + + +@mark.passing +@drives_the_app +async def test_clearing_the_checks_releases_a_custom_expression(): + """It named checks that no longer exist, so keeping it would report every id as undefined.""" + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + _add_check(app, "only") + await pilot.pause() + app.query_one("#expression-input").value = "!only" + await pilot.pause() + + app.query_one("#clear-checks", Button).press() + await pilot.pause() + + _add_check(app, "fresh") + await pilot.pause() + + assert app.query_one("#expression-input").value == "fresh" + + +@mark.passing +@drives_the_app +async def test_an_or_expression_reaches_the_policy(): + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + _add_check(app, "a") + await pilot.pause() + _add_check(app, "b", attribute="acl") + await pilot.pause() + + app.query_one("#expression-input").value = "a || b" + await pilot.pause() + + builder = app.query_one("#builder-view", BuilderView) + policy = builder.build_policy() + assert policy["eval_expression"] == "a || b" + # And it is a policy the engine will accept, not just a string in a box. + assert not [f for f in validate.check_policy(policy) if f.severity == "error"] + + +@mark.passing +@drives_the_app +async def test_the_builder_names_the_document_its_provider_expects(): + """Choosing a provider is choosing what you must feed it, so the form says which.""" + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + assert "terraform plan JSON" in _text_of(app.query_one("#provider-summary")) + + app.query_one("#provider-select").value = "stackguardian/infracost" + await pilot.pause() + + assert "infracost" in _text_of(app.query_one("#provider-summary")).lower() + + +@mark.passing +@drives_the_app +async def test_the_playground_labels_the_expected_input_document(): + """ + The commonest way to waste ten minutes is feeding the right JSON to the wrong provider, + so the label tracks whatever the policy currently declares. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + + assert "terraform plan JSON" in _text_of(app.query_one("#input-label")) + + playground = app.query_one("#playground-view", PlaygroundView) + playground.load_example(examples.example_by_key("05-kubernetes-probes")) + await pilot.pause() + + assert "Kubernetes" in _text_of(app.query_one("#input-label")) + + +@mark.passing +@drives_the_app +async def test_loading_a_file_picks_the_slot_from_its_contents(): + """ + A policy is recognisable -- an object with `evaluators` and `meta` -- so a file chosen + without a stated destination still lands in the right editor. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + playground = app.query_one("#playground-view", PlaygroundView) + + playground._load_path(os.path.join(K8S_EXAMPLE_DIR, "policy.json")) + await pilot.pause() + + assert "kubernetes_kind" in app.query_one("#policy-editor").text + + playground._load_path(os.path.join(K8S_EXAMPLE_DIR, "input.json")) + await pilot.pause() + + assert "livenessProbe" in app.query_one("#input-editor").text + + +@mark.passing +@drives_the_app +async def test_the_about_notes_start_hidden_and_toggle(): + """ + Worth reading once per example, and permanently in the way of the results after that. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + + assert not app.query_one("#about-scroll").display + + app.query_one("#toggle-about", Button).press() + await pilot.pause() + assert app.query_one("#about-scroll").display + + app.query_one("#toggle-about", Button).press() + await pilot.pause() + assert not app.query_one("#about-scroll").display + + +@mark.passing +@drives_the_app +async def test_clearing_an_editor_empties_it_without_crashing(): + """Clearing leaves the policy unevaluatable, which must report rather than raise.""" + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + + app.query_one("#clear-policy", Button).press() + await pilot.pause() + + assert app.query_one("#policy-editor").text == "" + assert "Cannot evaluate" in _text_of(app.query_one("#playground-status")) + + +@mark.passing +@drives_the_app +async def test_copy_reports_what_it_sent(): + """ + The clipboard goes out as a terminal escape with no reply, so the message says what was + attempted rather than claiming it landed. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + + app.query_one("#copy-policy", Button).press() + await pilot.pause() + + assert "clipboard" in _text_of(app.query_one("#playground-notice")) + + +@mark.passing +@drives_the_app +async def test_copying_an_empty_editor_says_so(): + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + + app.query_one("#clear-input", Button).press() + await pilot.pause() + app.query_one("#copy-input", Button).press() + await pilot.pause() + + assert "empty" in _text_of(app.query_one("#playground-notice")) + + +@mark.passing +@drives_the_app +async def test_open_shows_the_file_browser(): + """Open puts a browser on screen rather than asking the user to know the path already.""" + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + + app.query_one("#open-input", Button).press() + await pilot.pause() + + picker = app.screen + assert isinstance(picker, FilePicker) + # Titled with the destination, so it is clear which editor is about to be filled. + assert "input" in _text_of(picker.query_one("#file-picker-title")) + + +@mark.passing +@drives_the_app +async def test_the_browser_lists_documents_and_hides_the_noise(): + """ + Only files the engine can read, and none of the directories that are always huge and + never interesting from a project root. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + app.query_one("#open-policy", Button).press() + await pilot.pause() + + tree = app.screen.query_one("#file-picker-tree", DocumentTree) + candidates = [ + Path(REPO_ROOT) / "README.md", # wrong suffix + Path(REPO_ROOT) / ".git", # hidden + Path(REPO_ROOT) / "venv", # skipped by name + Path(K8S_EXAMPLE_DIR) / "policy.json", # kept + ] + kept = {p.name for p in tree.filter_paths(candidates)} + + assert kept == {"policy.json"} + + +@mark.passing +@drives_the_app +async def test_choosing_a_file_loads_it_into_the_editor_that_asked(): + """ + Open on the input editor then choosing a *policy* file still fills the input editor: the + button named the destination, so the file's contents do not get to override it. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + + app.query_one("#open-input", Button).press() + await pilot.pause() + + app.screen.dismiss(os.path.join(K8S_EXAMPLE_DIR, "policy.json")) + await pilot.pause() + + assert "kubernetes_kind" in app.query_one("#input-editor").text + assert "Loaded" in _text_of(app.query_one("#playground-notice")) + + +@mark.passing +@drives_the_app +async def test_the_browser_can_leave_the_directory_it_opened_in(): + """ + A DirectoryTree only descends from its root, so without re-rooting the picker could reach + nothing outside the working directory -- and the plan you want to check is usually in + another repo entirely. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + app.query_one("#open-policy", Button).press() + await pilot.pause() + + picker = app.screen + started_at = picker._directory + + await pilot.press("left") + await pilot.pause() + + assert picker._directory == os.path.dirname(started_at) + # The tree really moved, and the header says where to. + assert str(picker.query_one("#file-picker-tree").path) == picker._directory + assert picker._directory in _text_of(picker.query_one("#file-picker-location")) + + +@mark.passing +@drives_the_app +async def test_left_and_right_are_inverses(): + """ + Right descends into the highlighted directory, left returns to the parent -- the pair a + file manager gives you, so walking in and back out lands where you began. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + app.query_one("#open-policy", Button).press() + await pilot.pause() + + picker = app.screen + started_at = picker._directory + + # Onto the first child directory, then into it. + await pilot.press("down") + await pilot.pause() + await pilot.press("right") + await pilot.pause() + + assert picker._directory != started_at + assert os.path.dirname(picker._directory) == started_at + + await pilot.press("left") + await pilot.pause() + + assert picker._directory == started_at + + +@mark.passing +@drives_the_app +async def test_right_on_a_file_does_not_navigate(): + """Only directories are somewhere to go; a file is chosen with Enter.""" + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + app.query_one("#open-policy", Button).press() + await pilot.pause() + + picker = app.screen + picker._show_directory(K8S_EXAMPLE_DIR) + await pilot.pause() + + # Down onto a file (this directory holds only files), then try to descend. + await pilot.press("down") + await pilot.pause() + await pilot.press("right") + await pilot.pause() + + assert picker._directory == K8S_EXAMPLE_DIR + assert isinstance(app.screen, FilePicker) + + +@mark.passing +@drives_the_app +async def test_going_up_stops_at_the_filesystem_root(): + """`dirname("/")` is `/`, so the guard has to stop rather than loop.""" + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + app.query_one("#open-policy", Button).press() + await pilot.pause() + + picker = app.screen + for _ in range(12): + picker.action_go_up() + await pilot.pause() + + assert picker._directory == os.path.abspath(os.sep) + + +@mark.passing +@drives_the_app +async def test_typing_a_directory_navigates_rather_than_loading_it(): + """A directory is somewhere to go, not a document to open.""" + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + app.query_one("#open-policy", Button).press() + await pilot.pause() + + picker = app.screen + field = picker.query_one("#file-picker-path", Input) + # Focused first: Enter goes to whatever has focus, which is the tree until the user + # clicks into the path box. + field.focus() + await pilot.pause() + field.value = K8S_EXAMPLE_DIR + await pilot.press("enter") + await pilot.pause() + + # Still open, now showing that directory, rather than dismissed with it as a "file". + assert isinstance(app.screen, FilePicker) + assert picker._directory == K8S_EXAMPLE_DIR + + +@mark.passing +@drives_the_app +async def test_cancelling_the_browser_changes_nothing(): + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + before = app.query_one("#input-editor").text + + app.query_one("#open-input", Button).press() + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + + assert app.query_one("#input-editor").text == before + assert not isinstance(app.screen, FilePicker) + + +@mark.passing +@drives_the_app +async def test_choosing_an_example_from_the_dropdown_loads_it(): + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + + keys = [e.key for e in examples.load_examples()] + target = keys.index("03-cost-ceiling") + app.query_one("#example-select", Select).value = target + await pilot.pause() + + assert "infracost" in app.query_one("#policy-editor").text + + +@mark.passing +@drives_the_app +async def test_choosing_the_prompt_row_clears_both_editors(): + """ + Picking the prompt means "none of these", so it empties both documents rather than doing + nothing and leaving the previous example loaded. + + This crashed the app: the guard tested `Select.BLANK`, which is the literal False, while + the unselected value is the `Select.NULL` sentinel -- so it fell through to int() and + raised TypeError the first time anyone opened the dropdown and chose the prompt. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + assert app.query_one("#policy-editor").text.strip() + + app.query_one("#example-select", Select).value = Select.NULL + await pilot.pause() + + assert app.query_one("#policy-editor").text == "" + assert app.query_one("#input-editor").text == "" + # And it reports rather than raising, since an empty policy cannot be evaluated. + assert "Cannot evaluate" in _text_of(app.query_one("#playground-status")) + + +@mark.passing +@drives_the_app +async def test_an_unset_optional_argument_is_left_out_of_the_policy(): + """ + Same sentinel confusion in the Builder: an untouched Select would serialise `Select.NULL` + into the policy as the argument's value instead of being omitted. + + Uses `attribute`, whose exclude_resource_types is genuinely optional -- the required ones + have to be filled or the check is refused, which is a different rule being tested + elsewhere. + """ + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + app.query_one("#tabs").active = "builder" + await pilot.pause() + + builder = app.query_one("#builder-view", BuilderView) + app.query_one("#arg-terraform_resource_type").value = "*" + app.query_one("#arg-terraform_resource_attribute").value = "tags.costcenter" + app.query_one("#id-input").value = "tagged" + app.query_one("#value-input").value = "true" + builder._add_check() + await pilot.pause() + + provider_args = builder.build_policy()["evaluators"][0]["provider_args"] + assert "exclude_resource_types" not in provider_args, provider_args + assert "NULL" not in json.dumps(provider_args) + + +@mark.passing +@drives_the_app +async def test_loading_a_missing_file_reports_rather_than_crashes(): + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + playground = app.query_one("#playground-view", PlaygroundView) + + playground._load_path("/no/such/plan.json") + await pilot.pause() + + assert "No such file" in _text_of(app.query_one("#findings-list")) + + +@mark.passing +@drives_the_app +async def test_loading_a_file_of_broken_json_names_the_position(tmp_path): + broken = tmp_path / "broken.json" + broken.write_text('{"resource_changes": [') + + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.pause() + playground = app.query_one("#playground-view", PlaygroundView) + + playground._load_path(str(broken)) + await pilot.pause() + + findings = _text_of(app.query_one("#findings-list")) + assert "line" in findings and "column" in findings + + +@mark.passing +def test_builder_parses_values_as_json_with_a_string_fallback(): + """ + `Equals: "true"` and `Equals: true` are different questions, so a typed value keeps its + JSON type -- while a bare word still means the string, which is what a user expects. + """ + assert parse_value("true") is True + assert parse_value("42") == 42 + assert parse_value("null") is None + assert parse_value('["a", "b"]') == ["a", "b"] + assert parse_value("production") == "production" + assert parse_value('"production"') == "production" + + +# ------------------------------------------------------------------ bindings + + +@mark.passing +@drives_the_app +async def test_the_app_itself_never_scrolls(): + """ + Each pane scrolls its own content; the application does not. Fixed-height rows summing past + a short viewport made the Screen scroll, which put a scrollbar down the far right edge of + the whole app and let the title scroll out of sight, leaving a bare tab row. + + Checked at a short terminal, which is where it appeared. + """ + app = build_app() + async with app.run_test(size=(200, 30)) as pilot: + await pilot.pause() + + assert not app.screen.show_vertical_scrollbar + scrolling = {w.id for w in app.screen.walk_children() if getattr(w, "show_vertical_scrollbar", False)} + # Only the editors, which have documents longer than themselves. + assert scrolling <= {"policy-editor", "input-editor"}, scrolling + + +@mark.passing +@drives_the_app +async def test_the_wordmark_does_not_cover_the_tabs(): + """ + The wordmark shares the tab row on the overlay layer, so it costs no height -- but an + overlay swallows the row it spans, so the tabs have to start clear of it. + + The indent is a hardcoded CSS value that has to track the banner string, and getting it + wrong is quiet: at one column short, "Explorer" rendered as "orer". Comparing the two + measured regions catches that, where reading either alone would not. + """ + app = build_app() + async with app.run_test(size=(200, 38)) as pilot: + await pilot.pause() + + banner = app.query_one("#app-banner") + tabs = app.query_one("#tabs").query_one(Tabs) + + assert banner.region.x == 0, "the wordmark should lead the row" + assert tabs.content_region.x >= banner.region.right, ( + f"tab labels start at {tabs.content_region.x}, inside the wordmark " + f"which ends at {banner.region.right} -- the first label will be clipped" + ) + + +@mark.passing +@drives_the_app +async def test_number_keys_switch_tabs(): + app = build_app() + async with app.run_test(size=TERMINAL_SIZE) as pilot: + await pilot.press("2") + await pilot.pause() + assert app.query_one("#tabs").active == "builder" + + await pilot.press("1") + await pilot.pause() + assert app.query_one("#tabs").active == "explorer" diff --git a/tests/tui/test_examples.py b/tests/tui/test_examples.py new file mode 100644 index 0000000..0e1f854 --- /dev/null +++ b/tests/tui/test_examples.py @@ -0,0 +1,122 @@ +""" +Every bundled example must load, validate and evaluate. + +The examples are the first thing a new user runs, so a broken one is worse than a missing +one. These assert the whole path: the files parse, the validator accepts them, the engine +evaluates them, and each produces the verdict its `about.md` claims. + +The verdicts are pinned deliberately. Several examples exist to *fail* -- that is how they +teach -- so an example silently flipping to passing would quietly destroy the lesson without +breaking anything else. + +No textual import; this runs on CI's Python 3.8 leg. +""" + +import json + +from pytest import mark + +from tirith.core.core import start_policy_evaluation_from_dict +from tirith.providers import PROVIDERS_DICT +from tirith.tui import examples, results, validate + +# What each example is supposed to demonstrate. An example whose verdict moves is either a +# broken example or a changed engine; either way it should be looked at, not absorbed. +EXPECTED_VERDICTS = { + "01-required-tags": results.FAILED, + "02-no-public-buckets": results.FAILED, + "03-cost-ceiling": results.PASSED, + "04-block-destroy": results.FAILED, + "05-kubernetes-probes": results.FAILED, +} + +ALL_EXAMPLES = examples.load_examples() +EXAMPLE_KEYS = [e.key for e in ALL_EXAMPLES] + + +@mark.passing +def test_examples_are_discovered(): + assert EXAMPLE_KEYS, "no examples were found; the examples/ directory may not have shipped" + assert set(EXAMPLE_KEYS) == set( + EXPECTED_VERDICTS + ), "the set of bundled examples changed; update EXPECTED_VERDICTS with what the new one demonstrates" + + +@mark.passing +@mark.parametrize("example", ALL_EXAMPLES, ids=EXAMPLE_KEYS) +def test_example_declares_a_real_provider(example): + assert example.provider in PROVIDERS_DICT, f"{example.key} names provider '{example.provider}'" + + +@mark.passing +@mark.parametrize("example", ALL_EXAMPLES, ids=EXAMPLE_KEYS) +def test_example_policy_validates_clean(example): + """An example that trips our own validator would teach the wrong thing.""" + errors = [f for f in validate.check_policy(example.policy) if f.severity == "error"] + assert not errors, f"{example.key}: " + "; ".join(str(e) for e in errors) + + +@mark.passing +@mark.parametrize("example", ALL_EXAMPLES, ids=EXAMPLE_KEYS) +def test_example_evaluates_to_its_documented_verdict(example): + report = results.parse_report(start_policy_evaluation_from_dict(example.policy, example.input_document)) + + assert ( + report.verdict == EXPECTED_VERDICTS[example.key] + ), f"{example.key} evaluated to {report.verdict}, expected {EXPECTED_VERDICTS[example.key]}" + + +@mark.passing +@mark.parametrize("example", ALL_EXAMPLES, ids=EXAMPLE_KEYS) +def test_example_produces_results_not_just_a_verdict(example): + """ + A policy can reach a verdict having evaluated nothing -- every check erroring out reads as + a failure. An example must actually exercise its input. + """ + report = results.parse_report(start_policy_evaluation_from_dict(example.policy, example.input_document)) + + assert report.checks, f"{example.key} produced no checks" + assert any(check.results for check in report.checks), f"{example.key} produced no results" + + +@mark.passing +@mark.parametrize("example", ALL_EXAMPLES, ids=EXAMPLE_KEYS) +def test_example_has_an_explanation(example): + """about.md is the teaching half; its first line is the picker's one-line summary.""" + assert example.summary, f"{example.key} has no summary line in about.md" + assert len(example.about.splitlines()) > 3, f"{example.key} has no body in about.md" + + +@mark.passing +@mark.parametrize("example", ALL_EXAMPLES, ids=EXAMPLE_KEYS) +def test_example_round_trips_through_json(example): + """The playground hands these to an editor as text, so they must survive the trip.""" + assert json.loads(example.policy_json) == example.policy + assert json.loads(example.input_json) == example.input_document + + +@mark.passing +def test_titles_are_human_readable(): + """The numeric prefix orders the list; it should not show up in the UI.""" + for example in ALL_EXAMPLES: + assert not example.title[0].isdigit(), f"{example.key} renders as '{example.title}'" + + +@mark.passing +def test_terraform_examples_carry_resource_addresses(): + """ + The terraform examples are the ones that demonstrate resource-level detail, so they must + actually produce it -- that is the feature they exist to show. + """ + for example in ALL_EXAMPLES: + if example.provider != "stackguardian/terraform_plan": + continue + report = results.parse_report(start_policy_evaluation_from_dict(example.policy, example.input_document)) + addresses = [r.resource.address for check in report.checks for r in check.results if r.resource.address] + assert addresses, f"{example.key} produced no resource addresses" + + +@mark.passing +def test_lookup_by_key(): + assert examples.example_by_key(EXAMPLE_KEYS[0]) is not None + assert examples.example_by_key("no-such-example") is None diff --git a/tests/tui/test_results.py b/tests/tui/test_results.py new file mode 100644 index 0000000..a89840d --- /dev/null +++ b/tests/tui/test_results.py @@ -0,0 +1,238 @@ +""" +The results model must read what the engine really emits, including the parts it omits. + +Most of these run the engine over repository fixtures rather than asserting against +hand-written dicts, because the shape being modelled is the engine's, and a hand-written +sample is exactly where a wrong assumption survives. The `meta` block in particular varies: +terraform_plan populates it with the whole resource_change, infracost sets it to None, and +some json-provider results omit the key entirely. + +No textual import; this runs on CI's Python 3.8 leg. +""" + +import json +import os + +from pytest import mark + +from tirith.core.core import start_policy_evaluation_from_dict +from tirith.tui import results + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +TF_FIXTURES = os.path.join(REPO_ROOT, "tests", "providers", "terraform_plan", "fixtures") + + +def _run(policy_path, input_path): + with open(policy_path) as f: + policy = json.load(f) + with open(input_path) as f: + input_data = json.load(f) + return start_policy_evaluation_from_dict(policy, input_data) + + +def _tf_report(policy_name, input_name): + return results.parse_report(_run(os.path.join(TF_FIXTURES, policy_name), os.path.join(TF_FIXTURES, input_name))) + + +@mark.passing +def test_parses_a_real_terraform_run(): + report = _tf_report("policy_costcenter_tags.json", "input_costcenter_tags.json") + + assert report.checks + assert report.verdict == results.PASSED + assert report.counts[results.PASSED] == len(report.checks) + + +@mark.passing +def test_resource_detail_is_recovered_from_meta(): + """ + The whole point of the Explorer: name the resource each result came from. + + The pretty printer prints only the message, so this detail exists in the document today + and has no way to reach the user. + """ + report = _tf_report("policy_costcenter_tags.json", "input_costcenter_tags.json") + + resources = [r.resource for check in report.checks for r in check.results] + assert resources, "fixture produced no results" + + # This policy matches on '*', so it spans several resource types -- which is exactly the + # case the Explorer exists for: the printed messages are indistinguishable from each other + # ("`\"product-456\"` is not empty") and only the address says which resource each is. + addresses = sorted(r.address for r in resources) + assert addresses == ["aws_instance.web", "aws_s3_bucket.logs", "aws_vpc.main"] + assert all(r.resource_type for r in resources), "every matched resource should name its type" + + +@mark.passing +def test_missing_meta_yields_an_empty_ref_not_an_error(): + """ + infracost sets meta to None and some json results omit the key. Both must parse. + """ + report = results.parse_report( + _run( + os.path.join(REPO_ROOT, "tests", "providers", "infracost", "policy.json"), + os.path.join(REPO_ROOT, "tests", "providers", "infracost", "input.json"), + ) + ) + + for check in report.checks: + for result in check.results: + assert result.resource.is_empty + assert result.resource.label == "" + + +@mark.passing +def test_skipped_is_not_counted_as_passed(): + """ + The engine is careful that a skipped check is not a pass; so is this model. + """ + report = results.parse_report( + { + "evaluators": [ + {"id": "a", "passed": None, "result": [{"passed": None, "message": "skipped"}]}, + {"id": "b", "passed": True, "result": [{"passed": True, "message": "ok"}]}, + ], + "final_result": None, + "errors": [], + } + ) + + assert report.counts == {results.PASSED: 1, results.FAILED: 0, results.SKIPPED: 1} + assert report.verdict == results.SKIPPED + + +@mark.passing +def test_absent_final_result_is_distinct_from_none(): + """ + `final_result: None` means everything was skipped. No key at all means the policy could + not be loaded. The CLI gates differently on each, so the model must not conflate them. + """ + skipped = results.parse_report({"evaluators": [], "final_result": None, "errors": []}) + errored = results.parse_report({"errors": ["Variables not found: env"]}) + + assert skipped.verdict == results.SKIPPED + assert errored.verdict == "errored" + assert errored.errors == ["Variables not found: env"] + + +@mark.passing +def test_garbage_input_does_not_raise(): + """The Explorer can be pointed at any file; it must report, not crash.""" + for junk in (None, [], "text", 3): + report = results.parse_report(junk) + assert report.checks == [] + assert report.verdict == "errored" + + +@mark.passing +def test_action_summary_names_replacement_order(): + """ + delete-then-create means downtime and create-then-delete does not, so the two orderings + must not render identically. + """ + destroy_first = results.ResourceRef(actions=("delete", "create")) + create_first = results.ResourceRef(actions=("create", "delete")) + + assert destroy_first.action_summary != create_first.action_summary + assert "destroy first" in destroy_first.action_summary + assert "create first" in create_first.action_summary + + +@mark.passing +def test_action_summary_reads_plainly_for_simple_actions(): + assert results.ResourceRef(actions=("create",)).action_summary == "create" + assert results.ResourceRef(actions=("delete",)).action_summary == "destroy" + assert results.ResourceRef(actions=("no-op",)).action_summary == "no change" + assert results.ResourceRef(actions=()).action_summary == "" + + +@mark.passing +def test_attribute_changes_lists_only_what_changed(): + """ + A plan's `after` block repeats every attribute of the resource. Showing all of them buries + the ones that moved, which is the reason this filters. + """ + meta = { + "change": { + "actions": ["update"], + "before": {"instance_type": "t2.micro", "ami": "ami-1", "tags": {"a": 1}}, + "after": {"instance_type": "t3.micro", "ami": "ami-1", "tags": {"a": 1}}, + } + } + + changes = results.attribute_changes(meta) + + assert [c.name for c in changes] == ["instance_type"] + assert changes[0].before == "t2.micro" + assert changes[0].after == "t3.micro" + + +@mark.passing +def test_attribute_changes_flags_values_unknown_until_apply(): + """ + An attribute that is computed at apply time is not the same as one set to null, and + rendering it as null would say something false about the plan. + """ + meta = { + "change": { + "actions": ["update"], + "before": {"arn": "arn:old"}, + "after": {}, + "after_unknown": {"arn": True}, + } + } + + (change,) = results.attribute_changes(meta) + + assert change.name == "arn" + assert change.after_unknown is True + + +@mark.passing +def test_attribute_changes_is_empty_without_a_before_after_pair(): + """A create has no before, so there is no diff to show; the caller shows `after` instead.""" + assert results.attribute_changes({"change": {"actions": ["create"], "before": None, "after": {"a": 1}}}) == [] + assert results.attribute_changes({}) == [] + assert results.attribute_changes(None) == [] + + +@mark.passing +def test_real_destroy_plan_is_summarized_as_destroy(): + """Checked against a real plan fixture rather than a hand-built change block.""" + report = _tf_report("policy_s3_destroy.json", "input_s3_destroy.json") + + actions = [r.resource.action_summary for check in report.checks for r in check.results if r.resource.actions] + assert any("destroy" in a for a in actions), actions + + +@mark.passing +def test_check_summary_counts_its_results(): + report = _tf_report("policy_costcenter_tags.json", "input_costcenter_tags.json") + check = report.checks[0] + + assert check.counts[results.PASSED] == len(check.results) + assert "passed" in check.summary + + +@mark.passing +def test_failing_results_are_reachable_directly(): + """The Explorer opens on failures, so getting to them must not require walking every check.""" + report = results.parse_report( + { + "evaluators": [ + { + "id": "a", + "passed": False, + "result": [{"passed": False, "message": "no"}, {"passed": True, "message": "yes"}], + } + ], + "final_result": False, + "errors": [], + } + ) + + failing = list(report.iter_failing_results()) + + assert len(failing) == 1 + assert failing[0].message == "no" diff --git a/tests/tui/test_schema_matches_providers.py b/tests/tui/test_schema_matches_providers.py new file mode 100644 index 0000000..674ee77 --- /dev/null +++ b/tests/tui/test_schema_matches_providers.py @@ -0,0 +1,168 @@ +""" +The TUI's hand-written provider table must describe the providers that actually exist. + +src/tirith/tui/schema.py cannot be derived from the engine -- terraform_plan dispatches through +an if/elif chain and sg_workflow has no operation_type at all -- so it is written by hand, and a +hand-written copy of someone else's structure rots. These tests are the thing that stops it: +add an operation to a provider without describing it here and CI goes red, which is the same +guardrail tests/test_readme_is_current.py puts on the README. + +Deliberately no textual import anywhere in this file. CI runs the suite on Python 3.8, where +textual cannot be installed, so schema.py is kept free of TUI imports and these run everywhere. +""" + +import ast +import os + +from pytest import mark + +from tirith.providers import PROVIDERS_DICT +from tirith.core.evaluators import EVALUATORS_DICT +from tirith.tui import schema + +PROVIDER_SRC_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "src", + "tirith", + "providers", +) + + +def _handler_source(provider_dirname): + with open(os.path.join(PROVIDER_SRC_DIR, provider_dirname, "handler.py")) as f: + return f.read() + + +def _string_constants(source): + """Every string literal in a module, which is where operation names live either way.""" + return { + node.value + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + + +# The directory each provider's handler lives in, keyed by the name policies use. +_DIRNAMES = { + "stackguardian/terraform_plan": "terraform_plan", + "stackguardian/json": "json", + "stackguardian/kubernetes": "kubernetes", + "stackguardian/infracost": "infracost", + "stackguardian/sg_workflow": "sg_workflow", +} + + +@mark.passing +def test_every_described_provider_is_registered(): + """A provider named in the table must be one the engine can actually dispatch to.""" + assert set(schema.PROVIDERS) == set(PROVIDERS_DICT), ( + "tui/schema.py PROVIDERS has drifted from providers/__init__.py PROVIDERS_DICT. " + "Add or remove the provider in the table to match." + ) + + +@mark.passing +def test_every_described_operation_appears_in_its_handler(): + """ + Each operation_type in the table must appear literally in the provider's handler. + + A string match rather than a call, because the handlers disagree on how they dispatch: + json and kubernetes use a SUPPORTED_OPS dict, terraform_plan an if/elif chain, infracost a + lookup table keyed by the operation name. The one thing all three share is that the name + appears as a literal in the module. That makes this a check against typos and removals -- + the drift that actually happens -- and not a proof the operation behaves as described. + """ + for provider_name, provider in schema.PROVIDERS.items(): + if not provider.uses_operation_type: + continue + literals = _string_constants(_handler_source(_DIRNAMES[provider_name])) + for operation in provider.operations: + assert operation.name in literals, ( + f"tui/schema.py describes operation '{operation.name}' for {provider_name}, " + f"but that string does not appear in its handler. Renamed or removed?" + ) + + +@mark.passing +def test_described_args_appear_in_their_handler(): + """ + Each argument name in the table must be one its handler actually reads. + + Same literal-match reasoning as the operations check above: the handlers read arguments by + subscript and by .get(), so the name appears as a literal either way. This catches the + failure the table exists to prevent -- the builder generating `terraform_resource_attr` + against a provider that reads `terraform_resource_attribute`, producing a policy that + parses fine and silently finds nothing. + """ + for provider_name, provider in schema.PROVIDERS.items(): + literals = _string_constants(_handler_source(_DIRNAMES[provider_name])) + for operation in provider.operations: + for arg in operation.args: + assert arg.name in literals, ( + f"tui/schema.py describes argument '{arg.name}' for " + f"{provider_name}.{operation.name}, but no such string appears in its handler." + ) + + +@mark.passing +def test_terraform_operations_that_honour_exclude_resource_types_all_declare_it(): + """ + The reverse direction: handler -> table. + + Every other check here asks whether what the table describes exists. Nothing asked whether + what the handler *reads* is described, which is how exclude_resource_types came to be + attached to `attribute` alone while provide() honours it in three branches -- so the + validator reported a correct `count` policy as using an ignored argument. + + Counted from the source: each branch that consults the name needs it in the table. + """ + # Code lines only: each branch carries a comment naming the argument as well, so counting + # every mention doubles the real figure. + source = _handler_source("terraform_plan") + honouring = sum( + 1 for line in source.splitlines() if "in exclude_resource_types" in line and not line.strip().startswith("#") + ) + + declared = [ + operation.name + for operation in schema.PROVIDERS["stackguardian/terraform_plan"].operations + if any(arg.name == "exclude_resource_types" for arg in operation.args) + ] + + assert len(declared) == honouring, ( + f"terraform_plan consults exclude_resource_types in {honouring} branches but the table " + f"declares it for {len(declared)}: {declared}. A policy using it on an undeclared " + f"operation is reported as ignoring the argument, which is wrong." + ) + + +@mark.passing +def test_sg_workflow_attribute_choices_are_all_handled(): + """ + sg_workflow raises KeyError on an attribute it does not branch on, so offering one that is + not handled would build a policy that always errors. Every choice must be a real branch. + """ + literals = _string_constants(_handler_source("sg_workflow")) + (operation,) = schema.PROVIDERS["stackguardian/sg_workflow"].operations + (attribute_arg,) = operation.args + for choice in attribute_arg.choices: + assert choice in literals, f"sg_workflow attribute '{choice}' is offered but never handled." + + +@mark.passing +def test_every_described_evaluator_is_registered(): + assert set(schema.EVALUATORS) == set( + EVALUATORS_DICT + ), "tui/schema.py EVALUATORS has drifted from core/evaluators/__init__.py EVALUATORS_DICT." + + +@mark.passing +def test_value_kinds_are_known(): + """value_kind steers which widget the builder shows, so a typo would silently degrade the form.""" + for info in schema.EVALUATORS.values(): + assert info.value_kind in ( + "scalar", + "list", + "none", + "regex", + ), f"{info.name} declares unknown value_kind '{info.value_kind}'" diff --git a/tests/tui/test_ui_cli.py b/tests/tui/test_ui_cli.py new file mode 100644 index 0000000..9a02de6 --- /dev/null +++ b/tests/tui/test_ui_cli.py @@ -0,0 +1,253 @@ +""" +Argument handling for `tirith ui`, up to but not including starting the interface. + +These exercise tui/cli.py's own logic -- path checks, stdin reading, the guards around it -- +which is deliberately free of any UI-toolkit import so that a missing optional extra produces +an instruction rather than a traceback. That also means these run on CI's Python 3.8 leg. +""" + +import io +import json +import os +import shlex +import sys + +from pytest import importorskip, mark, raises + +from tirith import __version__ +from tirith.status import ExitStatus +from tirith.tui import cli as ui_cli + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +EXAMPLE = os.path.join(REPO_ROOT, "src", "tirith", "tui", "examples", "04-block-destroy") + + +@mark.passing +def test_missing_files_are_reported_before_anything_starts(capsys): + """A path typo should be a message, not a half-started interface.""" + status = ui_cli.main(["ui", "--result", "/no/such/file.json"]) + + assert status == ExitStatus.ERROR + assert "does not exist" in capsys.readouterr().err + + +@mark.passing +def test_result_and_policy_are_alternatives(capsys): + """ + --result opens an evaluation that already happened; --policy opens one to run now. Asking + for both is a contradiction worth naming rather than resolving by precedence. + """ + status = ui_cli.main( + ["ui", "--result", os.path.join(EXAMPLE, "policy.json"), "--policy", os.path.join(EXAMPLE, "policy.json")] + ) + + assert status == ExitStatus.ERROR + assert "alternatives" in capsys.readouterr().err + + +@mark.passing +def test_dash_reads_a_document_from_stdin(monkeypatch): + """ + `tirith --json ... | tirith ui --result -` is the shape people reach for, so '-' has to + mean stdin rather than a file literally named '-'. + """ + document = {"evaluators": [], "final_result": True, "errors": []} + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(document))) + + assert ui_cli._load("-", "--result") == document + + +@mark.passing +def test_empty_stdin_is_reported_clearly(monkeypatch): + """An empty pipe is usually a failed upstream command; say that rather than 'invalid JSON'.""" + monkeypatch.setattr(sys, "stdin", io.StringIO(" \n")) + + with raises(ValueError) as caught: + ui_cli._load("-", "--result") + + assert "empty" in str(caught.value) + + +@mark.passing +def test_broken_stdin_json_names_the_position(monkeypatch): + """A 200-line plan needs a line and column, not just 'invalid JSON'.""" + monkeypatch.setattr(sys, "stdin", io.StringIO('{"evaluators": [')) + + with raises(ValueError) as caught: + ui_cli._load("-", "--result") + + message = str(caught.value) + assert "line" in message and "column" in message + + +@mark.passing +def test_a_dash_path_skips_the_existence_check(monkeypatch, capsys): + """ + '-' is not a path, so the existence check must not reject it -- it did, which made the + whole stdin form unreachable. + + Needs the optional extra: without it `main` reports the missing extra before it reaches + any of this, which is the correct order -- there is no point reading a document for an + interface that cannot start. + """ + importorskip("textual", reason="the TUI is an optional extra") + + monkeypatch.setattr(sys, "stdin", io.StringIO('{"evaluators": [], "errors": []}')) + # No terminal to hand the interface, so this stops at the tty guard rather than launching. + monkeypatch.setattr(ui_cli, "_reattach_stdin", lambda: False) + + status = ui_cli.main(["ui", "--result", "-"]) + + assert status == ExitStatus.ERROR + assert "no terminal" in capsys.readouterr().err + + +@mark.passing +def test_piping_into_serve_is_refused(monkeypatch, capsys): + """ + The served interface runs as a subprocess with its own stdin, so a document piped to this + process cannot reach it. Refusing beats serving an empty interface. + + Refused before stdin is read, and before the extra is even looked for, so this holds + whether or not the interface is installed. + """ + monkeypatch.setattr(sys, "stdin", io.StringIO('{"evaluators": [], "errors": []}')) + + status = ui_cli.main(["ui", "--result", "-", "--serve"]) + + assert status == ExitStatus.ERROR + assert "stdin" in capsys.readouterr().err + + +@mark.passing +def test_a_path_that_is_not_a_dash_is_read_from_disk(): + """The ordinary case: `--result some/file.json` reads that file.""" + importorskip("textual", reason="the TUI is an optional extra") + + document = ui_cli._load(os.path.join(EXAMPLE, "policy.json"), "--policy") + + assert document["meta"]["required_provider"] == "stackguardian/terraform_plan" + + +@mark.passing +def test_an_unreadable_file_is_reported_not_raised(capsys, monkeypatch): + """ + A directory named where a document was expected, which is what a shell completion of a + path one level too shallow produces. + """ + importorskip("textual", reason="the TUI is an optional extra") + monkeypatch.setattr(ui_cli, "_reattach_stdin", lambda: False) + + status = ui_cli.main(["ui", "--result", EXAMPLE]) + + assert status == ExitStatus.ERROR + assert "ERROR" in capsys.readouterr().err + + +@mark.passing +def test_the_missing_extra_is_reported_with_instructions(capsys, monkeypatch): + """ + The expected failure for anyone who installed plain `py-tirith`: an instruction, not an + ImportError traceback. + """ + + # Raised from the import itself, naming textual, which is what a missing extra produces. + # Evicting `tirith.tui.*` from sys.modules to force a real re-import would work too, but + # leaves the package to be re-imported under a broken textual by whichever test runs next + # -- which hung the suite rather than failing it. + # A stand-in module whose `build_app` attribute raises on access, so `from .app import + # build_app` fails with the ImportError a missing toolkit produces -- named textual, which + # is what the narrowed handler looks for. + # + # Patching sys.modules rather than __import__: blocking imports by name caught pytest's own + # machinery, and evicting tirith.tui.* left the package to be re-imported under a broken + # textual by whichever test ran next, which hung the suite instead of failing it. + class RaisesOnAccess: + def __getattr__(self, name): + raise ImportError("No module named 'textual'", name="textual") + + monkeypatch.setitem(sys.modules, "tirith.tui.app", RaisesOnAccess()) + + status = ui_cli.main(["ui"]) + + assert status == ExitStatus.ERROR + assert "pip install" in capsys.readouterr().err + + +@mark.passing +def test_served_paths_are_quoted_for_a_shell(): + """ + textual-serve launches with create_subprocess_shell, so this command really is parsed by + sh. Double quotes left `$`, backticks and backslashes live: a path like + `/tmp/my $work/policy.json` had `$work` expanded to nothing, and every browser session + started against a path that did not exist -- with nothing in the serving terminal to say + why. + """ + parser = ui_cli.build_parser() + opts = parser.parse_args(["--policy", "/tmp/my $work/policy.json"]) + + command = ui_cli._rebuild_command(opts, has_result=False) + + # The dangerous characters survive a round trip through the shell's own parser. + assert "/tmp/my $work/policy.json" in shlex.split(command) + + +@mark.passing +@mark.parametrize( + "module,is_missing", + [ + ("textual", True), + ("textual.widgets", True), + ("textual_serve", True), + ("tirith.tui.views.playground", False), + ("", False), + ], +) +def test_only_the_missing_toolkit_gets_the_install_message(module, is_missing): + """ + `from .app import build_app` pulls in four views, the schema, the validator and the engine. + A blanket `except ImportError` told someone whose toolkit is installed to install it again, + and threw away the traceback for the real fault. + """ + error = ImportError(f"no module named {module}") + error.name = module or None + + assert ui_cli._is_missing_toolkit(error) is is_missing + + +@mark.passing +def test_the_serve_banner_rows_are_the_same_width(): + """ + The wordmark is drawn by hand, and a row one character short runs one letter into the next + -- which is exactly what happened: the second T collided with the H, at 21/22/22. + + Compared by width rather than by eye, because that is the property that was actually wrong + and the one nobody notices by rereading the string literal. + """ + rows = ui_cli.SERVE_LOGO.split("\n") + # Strip the markup that opens the first row and the version that closes the last. + rows[0] = rows[0].replace("[bold cyan]", "") + rows[2] = rows[2].split("[not bold]")[0] + + widths = {len(row) for row in rows[:3]} + assert len(widths) == 1, f"rows are ragged: {[len(r) for r in rows[:3]]}" + + +@mark.passing +def test_the_serve_banner_names_tirith_and_its_version(): + """The banner exists to say what this is; the version is what a reader wants from it.""" + assert __version__ in ui_cli.SERVE_LOGO + + +@mark.passing +def test_the_served_command_uses_absolute_paths(): + """ + The subprocess does not necessarily inherit this working directory, so a relative --policy + that resolved here would not resolve there. + """ + parser = ui_cli.build_parser() + opts = parser.parse_args(["--policy", "policy.json", "--input", "plan.json"]) + + command = ui_cli._rebuild_command(opts, has_result=False) + + assert os.path.isabs(command.split("--policy ")[1].split()[0].strip('"')) diff --git a/tests/tui/test_validate.py b/tests/tui/test_validate.py new file mode 100644 index 0000000..1e2aba9 --- /dev/null +++ b/tests/tui/test_validate.py @@ -0,0 +1,500 @@ +""" +The validator must accept every policy that actually works, and explain the ones that do not. + +The first half matters more than the second. A validator that flags a working policy is worse +than no validator: it teaches the user to ignore it. `test_repo_fixture_policies_are_accepted` +runs it over every policy fixture in the repository -- the same files the engine's own tests +evaluate -- and requires zero errors on all of them. + +No textual import here either; validate.py depends only on the engine, so this runs on CI's +Python 3.8 leg where textual cannot be installed. +""" + +import json +import os + +from pytest import mark + +from tirith.tui import validate + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +FIXTURES_ROOT = os.path.join(REPO_ROOT, "tests", "providers") + + +def _valid_policy(): + return { + "meta": {"version": "v1", "required_provider": "stackguardian/json"}, + "evaluators": [ + { + "id": "check0", + "provider_args": {"operation_type": "get_value", "key_path": "a"}, + "condition": {"type": "Equals", "value": 1}, + } + ], + "eval_expression": "check0", + } + + +def _errors(policy): + return [f for f in validate.check_policy(policy) if f.severity == "error"] + + +def _collect_policy_fixtures(): + """Every *policy*.json under tests/providers, which are known-good by construction.""" + found = [] + for dirpath, _dirnames, filenames in os.walk(FIXTURES_ROOT): + for filename in filenames: + if not filename.endswith(".json"): + continue + if "policy" not in filename and not filename.endswith(".tirith.json"): + continue + found.append(os.path.join(dirpath, filename)) + return sorted(found) + + +# Fixtures the engine itself cannot run, with the reason each was confirmed against the engine +# rather than assumed. Listed by name so a fixture becoming valid -- or a new broken one landing +# -- fails this test instead of being silently tolerated by a blanket try/except. +KNOWN_INVALID_FIXTURES = { + # Declares `sg_workflow`, not the namespaced `stackguardian/sg_workflow` the engine + # dispatches on. PROVIDERS_DICT has no such key, so every check fails with + # "Provider is not found". + "wfPolicy.json": "legacy un-namespaced required_provider", + "policy.json:providers": "legacy un-namespaced required_provider", + # `eval_expression` uses a single `&`. Confirmed by running it: the engine raises + # ValueError("Unsupported operator '&' ... Use '&&' instead") and returns no result at all. + "policy.json:fixtures": "eval_expression uses '&' which the engine rejects", + # operation_types `jmespath` and `jq_query` appear nowhere in the engine, and no test + # references this file. It documents an intended feature that was never implemented. + "policy_mixed_queries.json": "uses unimplemented jmespath/jq_query operations", +} + + +def _fixture_key(policy_path): + """ + Key a fixture by name, falling back to name:parent for the several files called policy.json. + """ + name = os.path.basename(policy_path) + qualified = f"{name}:{os.path.basename(os.path.dirname(policy_path))}" + return qualified if qualified in KNOWN_INVALID_FIXTURES else name + + +@mark.passing +@mark.parametrize("policy_path", _collect_policy_fixtures()) +def test_repo_fixture_policies_are_accepted(policy_path): + """ + Every policy fixture the engine can actually run must validate clean. + + This is the check that keeps the validator honest. A validator that flags a working policy + trains the user to ignore it, so the bar is: if the engine runs it, we do not report an + error on it. Fixtures the engine genuinely cannot run are listed in KNOWN_INVALID_FIXTURES + with the reason, and are asserted to still be reported. + """ + with open(policy_path) as f: + policy = json.load(f) + + relative = os.path.relpath(policy_path, REPO_ROOT) + errors = _errors(policy) + + if _fixture_key(policy_path) in KNOWN_INVALID_FIXTURES: + assert errors, f"{relative} is listed as known-invalid but validates clean; remove it from the list." + return + + assert not errors, f"{relative} reported: " + "; ".join(str(e) for e in errors) + + +@mark.passing +def test_accepts_a_minimal_policy(): + assert validate.check_policy(_valid_policy()) == [] + + +@mark.passing +def test_missing_meta_is_an_error_not_an_exception(): + """ + The engine raises AttributeError on this input; the whole point is to report it instead. + """ + policy = _valid_policy() + del policy["meta"] + assert any(f.where == "meta" for f in _errors(policy)) + + +@mark.passing +def test_non_dict_policy_is_reported(): + for junk in ([], "a string", 7, None): + findings = validate.check_policy(junk) + assert findings and findings[0].severity == "error" + + +@mark.passing +def test_unknown_provider_is_reported(): + policy = _valid_policy() + policy["meta"]["required_provider"] = "stackguardian/nope" + assert any("Unknown provider" in f.message for f in _errors(policy)) + + +@mark.passing +def test_unknown_operation_is_reported(): + policy = _valid_policy() + policy["evaluators"][0]["provider_args"]["operation_type"] = "nope" + assert any("not supported" in f.message for f in _errors(policy)) + + +@mark.passing +def test_missing_required_provider_arg_is_reported(): + policy = _valid_policy() + del policy["evaluators"][0]["provider_args"]["key_path"] + assert any(f.where.endswith("key_path") for f in _errors(policy)) + + +@mark.passing +def test_unknown_evaluator_is_reported(): + policy = _valid_policy() + policy["evaluators"][0]["condition"]["type"] = "AlmostEquals" + assert any("is not an evaluator" in f.message for f in _errors(policy)) + + +@mark.passing +def test_hyphenated_id_that_is_used_still_works(): + """ + A hyphenated id is not an error while it is referenced. + + core substitutes ids into the expression by regex before compiling, so `eval-id-1` is + replaced by `True`/`False` and never reaches the parser. Several shipped fixtures rely on + this. Reporting it as an error would flag working policies, so it is a warning. + """ + policy = _valid_policy() + policy["evaluators"][0]["id"] = "check-0" + policy["eval_expression"] = "check-0" + + findings = validate.check_policy(policy) + assert not [f for f in findings if f.severity == "error"] + assert any(f.severity == "warning" and f.where.endswith(".id") for f in findings) + + +@mark.passing +def test_undefined_hyphenated_id_is_an_error(): + """ + Undefined is where a hyphen turns fatal: nothing substitutes it, the `-` survives to the + parser, and core raises ValueError instead of returning a verdict. + """ + policy = _valid_policy() + policy["eval_expression"] = "check0 && eval-id-9" + + errors = _errors(policy) + assert any("eval-id-9" in f.message for f in errors), errors + + +@mark.passing +def test_hyphenated_id_is_not_split_into_phantom_names(): + """ + A parse-first approach reads `eval-id-1` as `eval - id - 1` and invents two undefined + names. The reference scan must resolve declared ids the way core does instead. + """ + policy = _valid_policy() + policy["evaluators"][0]["id"] = "eval-id-1" + policy["eval_expression"] = "eval-id-1" + + messages = " ".join(f.message for f in validate.check_policy(policy)) + assert "'eval'" not in messages and "'id'" not in messages + + +@mark.passing +def test_duplicate_ids_are_reported(): + policy = _valid_policy() + policy["evaluators"].append(dict(policy["evaluators"][0])) + assert any("Duplicate id" in f.message for f in _errors(policy)) + + +@mark.passing +def test_single_ampersand_is_reported(): + """ + core raises ValueError for `&`, and the README documented it in two examples, so a user can + reach this by copying the docs. Report it before the engine runs. + """ + policy = _valid_policy() + policy["eval_expression"] = "check0 & check0" + assert any("&&" in f.message for f in _errors(policy)) + + +@mark.passing +def test_undefined_id_in_expression_is_reported(): + policy = _valid_policy() + policy["eval_expression"] = "check0 && typo_id" + assert any("typo_id" in f.message for f in _errors(policy)) + + +@mark.passing +def test_unused_check_is_a_warning_not_an_error(): + """A check that runs but is not referenced still evaluates, so it must not block.""" + policy = _valid_policy() + policy["evaluators"].append( + { + "id": "check1", + "provider_args": {"operation_type": "get_value", "key_path": "b"}, + "condition": {"type": "Equals", "value": 2}, + } + ) + findings = validate.check_policy(policy) + assert not [f for f in findings if f.severity == "error"] + assert any(f.severity == "warning" and "check1" in f.message for f in findings) + + +@mark.passing +def test_negation_counts_as_a_reference(): + """`!check0` refers to check0; a regex over the raw string would miss it.""" + policy = _valid_policy() + policy["eval_expression"] = "!check0" + assert validate.check_policy(policy) == [] + + +@mark.passing +def test_contained_in_accepts_a_string(): + """ + ContainedIn branches on str (substring), list (membership) and dict (subset). Requiring a + list here would reject a working policy, so value_kind steers the builder's widget only. + """ + policy = _valid_policy() + policy["evaluators"][0]["condition"] = {"type": "ContainedIn", "value": "a-substring"} + assert not _errors(policy) + + +@mark.passing +def test_invalid_regex_is_reported(): + policy = _valid_policy() + policy["evaluators"][0]["condition"] = {"type": "RegexMatch", "value": "([unclosed"} + assert any("regular expression" in f.message for f in _errors(policy)) + + +@mark.passing +def test_is_empty_needs_no_value(): + """IsEmpty/IsNotEmpty take no comparison value, so demanding one would be wrong.""" + policy = _valid_policy() + policy["evaluators"][0]["condition"] = {"type": "IsNotEmpty"} + assert validate.check_policy(policy) == [] + + +@mark.passing +def test_null_value_is_accepted(): + """The kubernetes fixture checks `Contains: null`; absence and null are different.""" + policy = _valid_policy() + policy["evaluators"][0]["condition"] = {"type": "Contains", "value": None} + assert not _errors(policy) + + +@mark.passing +def test_unexpected_provider_arg_is_a_warning(): + """Providers ignore args they do not read, so a typo'd key runs -- it just does nothing.""" + policy = _valid_policy() + policy["evaluators"][0]["provider_args"]["kee_path"] = "a" + findings = validate.check_policy(policy) + assert not [f for f in findings if f.severity == "error"] + assert any("ignored" in f.message for f in findings) + + +@mark.passing +def test_terraform_plan_without_resource_changes_is_flagged(): + """The commonest input mistake: passing the binary plan, or state, instead of show -json.""" + findings = validate.check_input_document({}, "stackguardian/terraform_plan") + assert any("resource_changes" in f.where for f in findings) + + +# Each malformed shape the validator recognises, as (mutation, expected text). These are the +# branches that exist *because* the engine reports them confusingly or not at all, so leaving +# them untested would leave the validator's whole reason for existing unexercised. +MALFORMED = [ + ("meta is not an object", {"meta": []}, "Must be an object"), + ("evaluators missing", {"evaluators": None}, "Missing"), + ("evaluators is not a list", {"evaluators": {}}, "Must be a list"), + ("evaluator is not an object", {"evaluators": ["nope"]}, "Must be an object"), + ("eval_expression missing", {"eval_expression": None}, "Missing"), + ("eval_expression is not a string", {"eval_expression": 7}, "Must be a string"), + ("eval_expression is empty", {"eval_expression": " "}, "Empty"), +] + + +@mark.passing +@mark.parametrize("label,patch,expected", MALFORMED, ids=[m[0] for m in MALFORMED]) +def test_malformed_policies_are_reported(label, patch, expected): + del label + policy = _valid_policy() + policy.update(patch) + # A key set to None means "absent" here, which is how these arrive from a half-written + # document rather than from a deliberate null. + for key, value in patch.items(): + if value is None: + del policy[key] + + assert any(expected in f.message for f in _errors(policy)), _errors(policy) + + +@mark.passing +def test_a_missing_version_is_only_a_warning(): + """The engine does not read it, so it is a convention rather than a requirement.""" + policy = _valid_policy() + del policy["meta"]["version"] + + findings = validate.check_policy(policy) + assert not [f for f in findings if f.severity == "error"] + assert any("version" in f.where for f in findings) + + +@mark.passing +def test_an_empty_evaluator_list_warns_that_nothing_is_checked(): + policy = _valid_policy() + policy["evaluators"] = [] + policy["eval_expression"] = "x" + + assert any("checks nothing" in f.message for f in validate.check_policy(policy)) + + +@mark.passing +def test_a_check_without_an_id_is_reported(): + policy = _valid_policy() + del policy["evaluators"][0]["id"] + + assert any(f.where.endswith(".id") for f in _errors(policy)) + + +@mark.passing +def test_missing_provider_args_and_condition_are_reported(): + for key in ("provider_args", "condition"): + policy = _valid_policy() + del policy["evaluators"][0][key] + assert any(f.where.endswith(key) for f in _errors(policy)), key + + +@mark.passing +def test_provider_args_and_condition_of_the_wrong_type_are_reported(): + for key in ("provider_args", "condition"): + policy = _valid_policy() + policy["evaluators"][0][key] = "not an object" + assert any("Must be an object" in f.message for f in _errors(policy)), key + + +@mark.passing +def test_a_value_outside_a_closed_choice_list_is_reported(): + """ + sg_workflow raises KeyError on an attribute it does not branch on, so a value outside the + list is a policy that always errors rather than one that merely reads oddly. + """ + policy = { + "meta": {"version": "v1", "required_provider": "stackguardian/sg_workflow"}, + "evaluators": [ + { + "id": "wf", + "provider_args": {"workflow_attribute": "NoSuchField"}, + "condition": {"type": "Equals", "value": True}, + } + ], + "eval_expression": "wf", + } + + assert any("is not one of" in f.message for f in _errors(policy)) + + +@mark.passing +def test_a_single_pipe_is_reported(): + """The mirror of the `&` case: core rejects it, so say so before the engine runs.""" + policy = _valid_policy() + policy["eval_expression"] = "check0 | check0" + + assert any("||" in f.message for f in _errors(policy)) + + +@mark.passing +def test_a_non_integer_error_tolerance_is_reported(): + policy = _valid_policy() + policy["evaluators"][0]["condition"]["error_tolerance"] = "two" + + assert any(f.where.endswith("error_tolerance") for f in _errors(policy)) + + +@mark.passing +def test_infracost_input_without_projects_is_flagged(): + findings = validate.check_input_document({}, "stackguardian/infracost") + assert any("projects" in f.where for f in findings) + + +@mark.passing +def test_a_single_kubernetes_object_is_flagged(): + """The provider iterates a list of manifests; one object matches no kind at all.""" + findings = validate.check_input_document({"kind": "Pod"}, "stackguardian/kubernetes") + assert findings + + +@mark.passing +def test_summarize_counts_errors_and_warnings(): + policy = _valid_policy() + policy["evaluators"][0]["provider_args"]["kee_path"] = "a" # warning + policy["evaluators"][0]["condition"]["type"] = "Nope" # error + + errors, warnings = validate.summarize(validate.check_policy(policy)) + + assert errors >= 1 and warnings >= 1 + + +@mark.passing +@mark.parametrize("bad", [["stackguardian/json"], {"name": "x"}], ids=["list", "dict"]) +def test_an_unhashable_provider_is_reported_not_raised(bad): + """ + A list or an object here is unhashable, so `x not in PROVIDERS_DICT` raised TypeError + instead of returning False -- and check_policy is called outside any try in the Playground, + so the exception escaped and killed the app mid-edit. That is the one thing this module + exists to prevent. + + Reachable by an ordinary mistake: the neighbouring resource_type argument really is a list. + """ + policy = _valid_policy() + policy["meta"]["required_provider"] = bad + + assert any("Must be a string" in f.message for f in _errors(policy)) + + +@mark.passing +@mark.parametrize("bad", [["Equals"], {"type": "Equals"}], ids=["list", "dict"]) +def test_an_unhashable_evaluator_type_is_reported_not_raised(bad): + """The same unhashable-key hazard at condition.type.""" + policy = _valid_policy() + policy["evaluators"][0]["condition"]["type"] = bad + + assert any("Must be a string" in f.message for f in _errors(policy)) + + +@mark.passing +def test_exclude_resource_types_is_accepted_by_count_and_action(): + """ + terraform_plan reads exclude_resource_types once and honours it in the attribute, count and + action branches alike. Described against attribute only, the validator told the author of a + correct count policy that the argument "will be ignored" -- the opposite of true. + """ + for operation, condition in ( + ("count", {"type": "GreaterThan", "value": 0}), + ("action", {"type": "NotContains", "value": "delete"}), + ): + policy = { + "meta": {"version": "v1", "required_provider": "stackguardian/terraform_plan"}, + "evaluators": [ + { + "id": "check", + "provider_args": { + "operation_type": operation, + "terraform_resource_type": "*", + "exclude_resource_types": ["aws_iam_policy"], + }, + "condition": condition, + } + ], + "eval_expression": "check", + } + + assert validate.check_policy(policy) == [], operation + + +@mark.passing +def test_errors_sort_before_warnings(): + """A UI showing only the first finding must show a blocking one.""" + policy = _valid_policy() + policy["evaluators"][0]["provider_args"]["kee_path"] = "a" # warning + policy["evaluators"][0]["condition"]["type"] = "Nope" # error + findings = validate.check_policy(policy) + assert findings[0].severity == "error"