From e9ec956dc51e6ed20c0205bee460df1ce7ff5f36 Mon Sep 17 00:00:00 2001 From: Kurt Roekle Date: Fri, 5 Jun 2026 17:20:29 -0500 Subject: [PATCH 1/7] adding cli for testing Signed-off-by: Kurt Roekle --- .gitignore | 2 + build.gradle.kts | 64 +-- cli/README.md | 165 +++++++ cli/build.gradle.kts | 41 ++ .../opa/cli/CoverageReporter.java | 137 ++++++ .../open_policy_agent/opa/cli/Eval.java | 332 +++++++++++++ .../open_policy_agent/opa/cli/Format.java | 25 + .../opa/cli/MetricsReporter.java | 256 ++++++++++ .../opa/cli/ProfileReporter.java | 325 +++++++++++++ .../open_policy_agent/opa/cli/Regoj.java | 18 + .../opa/cli/StatementReporter.java | 438 ++++++++++++++++++ .../opa/cli/TraceReporter.java | 97 ++++ .../open_policy_agent/opa/cli/CliTest.java | 333 +++++++++++++ cli/src/test/resources/input.json | 8 + .../test/resources/ir_simple_dir/.manifest | 1 + .../test/resources/ir_simple_dir/data.json | 1 + .../test/resources/ir_simple_dir/plan.json | 1 + .../test/resources/ir_simple_dir/simple.rego | 11 + settings.gradle.kts | 1 + 19 files changed, 2225 insertions(+), 31 deletions(-) create mode 100644 cli/README.md create mode 100644 cli/build.gradle.kts create mode 100644 cli/src/main/java/io/github/open_policy_agent/opa/cli/CoverageReporter.java create mode 100644 cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java create mode 100644 cli/src/main/java/io/github/open_policy_agent/opa/cli/Format.java create mode 100644 cli/src/main/java/io/github/open_policy_agent/opa/cli/MetricsReporter.java create mode 100644 cli/src/main/java/io/github/open_policy_agent/opa/cli/ProfileReporter.java create mode 100644 cli/src/main/java/io/github/open_policy_agent/opa/cli/Regoj.java create mode 100644 cli/src/main/java/io/github/open_policy_agent/opa/cli/StatementReporter.java create mode 100644 cli/src/main/java/io/github/open_policy_agent/opa/cli/TraceReporter.java create mode 100644 cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java create mode 100644 cli/src/test/resources/input.json create mode 100644 cli/src/test/resources/ir_simple_dir/.manifest create mode 100644 cli/src/test/resources/ir_simple_dir/data.json create mode 100644 cli/src/test/resources/ir_simple_dir/plan.json create mode 100644 cli/src/test/resources/ir_simple_dir/simple.rego diff --git a/.gitignore b/.gitignore index d6d9a269..84168191 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ build/ target/ out/ +.out/ # Gradle .gradle/ @@ -58,3 +59,4 @@ nb-configuration.xml # OS files .DS_Store Thumbs.db + diff --git a/build.gradle.kts b/build.gradle.kts index 9e1a2295..d05a4e58 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,40 +25,42 @@ subprojects { ruleSetFiles = rootProject.files("config/pmd/ruleset.xml") } - apply(plugin = "com.vanniktech.maven.publish") + if (project.path != ":cli") { + apply(plugin = "com.vanniktech.maven.publish") - configure { - publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) - signAllPublications() - coordinates( - rootProject.property("group") as String, - project.name, - rootProject.property("version") as String - ) - pom { - name.set(project.name) - description.set("Java SDK for Open Policy Agent") - url.set("https://github.com/open-policy-agent/java-opa-sdk") - licenses { - license { - name.set("Apache-2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") + configure { + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() + coordinates( + rootProject.property("group") as String, + project.name, + rootProject.property("version") as String + ) + pom { + name.set(project.name) + description.set("Java SDK for Open Policy Agent") + url.set("https://github.com/open-policy-agent/java-opa-sdk") + licenses { + license { + name.set("Apache-2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + } } - } - developers { - developer { - id.set("sspaink") - name.set("Sebastian Spaink") + developers { + developer { + id.set("sspaink") + name.set("Sebastian Spaink") + } + } + scm { + url.set("https://github.com/open-policy-agent/java-opa-sdk") + connection.set("scm:git:git://github.com/open-policy-agent/java-opa-sdk.git") + developerConnection.set("scm:git:ssh://github.com/open-policy-agent/java-opa-sdk.git") + } + issueManagement { + system.set("GitHub") + url.set("https://github.com/open-policy-agent/java-opa-sdk/issues") } - } - scm { - url.set("https://github.com/open-policy-agent/java-opa-sdk") - connection.set("scm:git:git://github.com/open-policy-agent/java-opa-sdk.git") - developerConnection.set("scm:git:ssh://github.com/open-policy-agent/java-opa-sdk.git") - } - issueManagement { - system.set("GitHub") - url.set("https://github.com/open-policy-agent/java-opa-sdk/issues") } } } diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..9262fd86 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,165 @@ +# cli (`regoj`) + +> ## ⚠️ WARNING +> **This CLI is for testing and benchmarking only.** It is not a supported +> distribution artifact and is not published. Do not embed it in production +> tooling or shell scripts that ship to users. Use the OPA CLI +> (https://www.openpolicyagent.org/docs/cli) for production workflows. + +`regoj` is a small command-line driver for the Java OPA SDK. It loads a +pre-compiled rego plan bundle, evaluates an entrypoint against an input +document, and (optionally) prints metrics, traces, and per-statletement / +per-location profiling tables. + +## Building & running + +The module is wired into the standard Gradle multi-module build: + +```bash +./gradlew :cli:build +./gradlew :cli:run --args="eval --help" +``` + +To produce a runnable distribution: + +```bash +./gradlew :cli:installDist +./cli/build/install/cli/bin/cli eval --help +``` + +## `eval` subcommand + +``` +regoj eval [OPTIONS] [ENTRYPOINT] +``` + +Loads one or more bundles, prepares the entrypoint, evaluates it against an +input document, and prints the JSON result. The `ENTRYPOINT` positional +argument is the entrypoint name as it appears in the compiled plan (for +example, `authz/allow`); it can also be provided via `-e`/`--entrypoint`. + +### Options + +| Flag | Description | +| ---- | ----------- | +| `-b`, `--bundle ` | Bundle to load. Either a `.tar.gz`/`.tgz` produced by `opa build -t plan ...` or an unpacked directory containing `plan.json` (and optional `data.json`, `*.rego`). May be repeated. | +| `-e`, `--entrypoint ` | Entrypoint name. Overrides the positional `ENTRYPOINT` if both are given. | +| `-i`, `--input ` | Path to the JSON input document. Required unless `-I` is used. | +| `-I`, `--stdin-input` | Read the input document from stdin instead of `-i`. | +| `-f`, `--format ` | Output format: `json` (default, single line) or `pretty` (indented). | +| `--capabilities-current` | Print the capabilities JSON for the currently registered builtins and exit. | +| `--metrics` | Print a metrics table after evaluation (parse / build / prepare / eval timings). | +| `--instrument` | Alias for `--metrics`. | +| `--profile` | Print per-location and per-statement timing tables. Implies `--metrics`. | +| `--profile-limit ` | Cap the profile table to the top `n` rows (default `10`). | +| `--profile-sort ` | Profiler sort key: `total_time` (default), `num_eval`, or `location`. | +| `--coverage` | Print a per-file table of executed source lines after evaluation. | +| `--explain` | Print a step-by-step trace of statements entered and exited during evaluation. With `--count > 1`, only the first run's trace is printed. | +| `--fail` | Exit with a non-zero status if the result is undefined / empty. | +| `--fail-defined` | Exit with a non-zero status if the result is defined / non-empty. | +| `--count ` | Repeat the prepare + evaluate loop `n` times. With `--metrics` / `--profile`, the report switches from a single-run table to a min / max / mean / p90 / p99 table across the runs. By default, bundle load and engine build happen once before the loop; only prepare + eval are repeated. Useful for rough benchmarking. | +| `--count-includes-load` | Include bundle load and engine build inside each `--count` iteration. Restores the legacy behavior where every repetition re-loads bundles and rebuilds the engine. | + +`print()` output from policies is forwarded to stderr, line-by-line. + +### Examples + +The test resources include a small unpacked plan bundle in +`ir_simple_dir/`, plus a sample `input.json`. All examples are run from the +`cli/` module directory. + +Evaluate against the directory bundle: + +```bash +./gradlew :cli:run --args="\ + eval \ + -b cli/src/test/resources/ir_simple_dir \ + -i cli/src/test/resources/input.json \ + authz/allow" +``` + +Output: + +``` +[{"result":true}] +``` + +Pretty-print the result: + +```bash +./gradlew :cli:run --args="\ + eval \ + -b cli/src/test/resources/ir_simple_dir \ + -i cli/src/test/resources/input.json \ + --format pretty \ + authz/allow" +``` + +To run against a tarball, build one from the directory bundle (or compile your +own with `opa build -t plan -o bundle.tar.gz `) and pass that +path: + +```bash +tar -C cli/src/test/resources/ir_simple_dir -czf /tmp/ir_simple.tar.gz . +./gradlew :cli:run --args="\ + eval -b /tmp/ir_simple.tar.gz -i cli/src/test/resources/input.json authz/allow" +``` + +Read input from stdin: + +```bash +echo '{"user":{"id":"alicex","groups":["super"]}}' | ./gradlew --quiet :cli:run --args="\ + eval -b cli/src/test/resources/ir_simple_dir -I authz/allow" +``` + +Use the result for shell exit-code gating (e.g., a deny-by-default check): + +```bash +./gradlew :cli:run --args="\ + eval -b cli/src/test/resources/ir_simple_dir -i cli/src/test/resources/input.json \ + --fail-defined authz/allow" +``` + +Coverage report: + +```bash +./gradlew :cli:run --args="\ + eval -b cli/src/test/resources/ir_simple_dir -i cli/src/test/resources/input.json \ + --coverage authz/allow" +``` + +Profile (top-3 rows by total time, repeated 5 times): + +```bash +./gradlew :cli:run --args="\ + eval -b cli/src/test/resources/ir_simple_dir -i cli/src/test/resources/input.json \ + --profile --profile-limit 3 --count 5 authz/allow" +``` + +Sample metrics output: + +``` +[{"result":true}] ++---------------------------+-----------+-----------+-----------+-----------+-----------+ +| METRIC | MIN | MAX | MEAN | 90% | 99% | ++---------------------------+-----------+-----------+-----------+-----------+-----------+ +| cli_capabilities_register | 672.750µs | 211.377ms | 70.994ms | 211.377ms | 211.377ms | +| cli_engine_build | 46.833µs | 2.121ms | 740.458µs | 2.121ms | 2.121ms | +| ... | | | | | | ++---------------------------+-----------+-----------+-----------+-----------+-----------+ +``` + +### Bundle format + +The SDK loads `.tar.gz` plan bundles or unpacked directories (with +`plan.json` and optional `data.json`/`*.rego` siblings). Compile a Rego +project to a plan bundle with the standard OPA CLI: + +```bash +opa build -t plan -o bundle.tar.gz +``` + +### Contributions +Contributions toward feature parity with OPA's `eval` are welcome, +but keep the warning at the top of this file in mind: the goal is a tool for +local testing and benchmarking, not a user-facing CLI. diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts new file mode 100644 index 00000000..d644a34a --- /dev/null +++ b/cli/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + application +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(project(":opa-evaluator")) + implementation(project(":opa-services")) + implementation("com.fasterxml.jackson.core:jackson-databind:2.21.3") + implementation("info.picocli:picocli:4.7.7") + implementation("org.apache.commons:commons-compress:1.28.0") + + runtimeOnly(project(":opa-builtins")) + runtimeOnly(project(":opa-jackson")) + + testImplementation("org.junit.jupiter:junit-jupiter:5.10.1") + testImplementation("org.assertj:assertj-core:3.27.7") +} + +application { + mainClass = "io.github.open_policy_agent.opa.cli.Regoj" +} + +tasks.test { + useJUnitPlatform() +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(11) + } +} + +tasks.named("run") { + isIgnoreExitValue = true + workingDir = rootProject.projectDir + standardInput = System.`in` +} diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/CoverageReporter.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/CoverageReporter.java new file mode 100644 index 00000000..61c56505 --- /dev/null +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/CoverageReporter.java @@ -0,0 +1,137 @@ +package io.github.open_policy_agent.opa.cli; + +import io.github.open_policy_agent.opa.tracing.CoverageProfiler; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +public class CoverageReporter { + + public void printCoverage(List allProfilers, String[] fileNames) { + if (allProfilers.isEmpty()) { + return; + } + + final Map> mergedHits = mergeHits(allProfilers); + if (mergedHits.isEmpty()) { + return; + } + + int fileWidth = "FILE".length(); + int hitsWidth = "HITS".length(); + int linesWidth = "COVERED LINES".length(); + + final List rows = new ArrayList<>(); + + for (final Map.Entry> entry : mergedHits.entrySet()) { + final int fileIdx = entry.getKey(); + final String fileRef = Format.fileRef(fileIdx, fileNames); + final TreeSet sortedRows = new TreeSet<>(entry.getValue()); + final String linesStr = formatRanges(sortedRows); + final String hitsStr = String.valueOf(sortedRows.size()); + + rows.add(new CoverageRow(fileRef, hitsStr, linesStr)); + + fileWidth = Math.max(fileWidth, fileRef.length()); + hitsWidth = Math.max(hitsWidth, hitsStr.length()); + linesWidth = Math.max(linesWidth, linesStr.length()); + } + + fileWidth += 2; + hitsWidth += 2; + linesWidth += 2; + + rows.sort(Comparator.comparing(a -> a.file)); + + printBorder(fileWidth, hitsWidth, linesWidth); + printRow("FILE", "HITS", "COVERED LINES", fileWidth, hitsWidth, linesWidth); + printBorder(fileWidth, hitsWidth, linesWidth); + + for (final CoverageRow row : rows) { + printRow(row.file, row.hits, row.lines, fileWidth, hitsWidth, linesWidth); + } + + printBorder(fileWidth, hitsWidth, linesWidth); + } + + private Map> mergeHits(List profilers) { + final java.util.HashMap> merged = new java.util.HashMap<>(); + for (final CoverageProfiler profiler : profilers) { + for (final Map.Entry> entry : profiler.getCoveredLines().entrySet()) { + merged.computeIfAbsent(entry.getKey(), k -> new TreeSet<>()).addAll(entry.getValue()); + } + } + return merged; + } + + private String formatRanges(TreeSet sortedRows) { + if (sortedRows.isEmpty()) { + return ""; + } + final StringBuilder sb = new StringBuilder(); + Integer start = null; + Integer prev = null; + for (final Integer row : sortedRows) { + if (start == null) { + start = row; + } else if (row != prev + 1) { + appendRange(sb, start, prev); + start = row; + } + prev = row; + } + appendRange(sb, start, prev); + return sb.toString(); + } + + private void appendRange(StringBuilder sb, int start, int end) { + if (sb.length() > 0) { + sb.append(","); + } + if (start == end) { + sb.append(start); + } else { + sb.append(start).append("-").append(end); + } + } + + private void printBorder(int col1Width, int col2Width, int col3Width) { + System.out.print("+"); + System.out.print("-".repeat(col1Width)); + System.out.print("+"); + System.out.print("-".repeat(col2Width)); + System.out.print("+"); + System.out.print("-".repeat(col3Width)); + System.out.println("+"); + } + + private void printRow( + String col1, String col2, String col3, int col1Width, int col2Width, int col3Width) { + System.out.printf( + "| %-" + + (col1Width - 2) + + "s | %-" + + (col2Width - 2) + + "s | %-" + + (col3Width - 2) + + "s |%n", + col1, + col2, + col3); + } + + private static class CoverageRow { + final String file; + final String hits; + final String lines; + + CoverageRow(String file, String hits, String lines) { + this.file = file; + this.hits = hits; + this.lines = lines; + } + } +} diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java new file mode 100644 index 00000000..a18d3624 --- /dev/null +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java @@ -0,0 +1,332 @@ +package io.github.open_policy_agent.opa.cli; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import io.github.open_policy_agent.opa.ast.builtin.BuiltinRegistry; +import io.github.open_policy_agent.opa.bundle.FileSystemBundleLoader; +import io.github.open_policy_agent.opa.bundle.TarballBundleLoader; +import io.github.open_policy_agent.opa.ir.policy.Policy; +import io.github.open_policy_agent.opa.ir.policy.StringConst; +import io.github.open_policy_agent.opa.metrics.Metrics; +import io.github.open_policy_agent.opa.metrics.NoOpMetrics; +import io.github.open_policy_agent.opa.metrics.SimpleMetrics; +import io.github.open_policy_agent.opa.profiling.NoOpStatementProfiler; +import io.github.open_policy_agent.opa.profiling.SimpleStatementProfiler; +import io.github.open_policy_agent.opa.profiling.StatementProfiler; +import io.github.open_policy_agent.opa.rego.Capabilities; +import io.github.open_policy_agent.opa.rego.Engine; +import io.github.open_policy_agent.opa.rego.PrintHook; +import io.github.open_policy_agent.opa.storage.InMem; +import io.github.open_policy_agent.opa.storage.Store; +import io.github.open_policy_agent.opa.tracing.BufferedQueryTracer; +import io.github.open_policy_agent.opa.tracing.CoverageProfiler; +import io.github.open_policy_agent.opa.tracing.DurationProfiler; +import io.github.open_policy_agent.opa.tracing.QueryTracer; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +@Command(name = "eval") +public class Eval implements Callable { + + @Option( + names = {"-b", "--bundle"}, + description = "set bundle file or directory path(s). This flag can be repeated.") + private List bundleFilePaths; + + @Option( + names = {"--capabilities-current"}, + description = "displays current capabilities JSON and ends process") + private boolean showCapabilities; + + @Option( + names = {"--coverage"}, + description = "report coverage of policy lines executed during evaluation") + private boolean showCoverage; + + @Option( + names = {"--explain"}, + description = + "enable query explanations (when --count > 1, only the first run's trace is printed)") + private boolean explain; + + @Option( + names = {"--fail"}, + description = "exit with non-zero exit code on undefined/empty result") + private boolean fail; + + @Option( + names = {"--fail-defined"}, + description = "exit with non-zero exit code on defined/non-empty result") + private boolean failDefined; + + @Option( + names = {"-f", "--format"}, + description = "set output format: json (default) or pretty") + private String format = "json"; + + @Option( + names = {"-i", "--input"}, + description = "set input file path; required unless --stdin-input is used") + private Path input; + + @Option( + names = {"--instrument"}, + description = "enable query instrumentation metrics (implies --metrics)") + private boolean instrument; + + @Option( + names = {"--metrics"}, + description = "report query performance metrics") + private boolean showMetrics; + + @Option( + names = {"--profile"}, + description = "perform expression profiling") + private boolean showProfile; + + @Option( + names = {"--profile-limit"}, + description = "set number of profiling results to show (default 10)") + private int profileLimit = 10; + + @Option( + names = {"--profile-sort"}, + description = + "sort key for profiler output: total_time (default), num_eval, or location") + private String profileSort = "total_time"; + + @Option( + names = {"-I", "--stdin-input"}, + description = "read input document from stdin") + private boolean stdinInput; + + @Option( + names = {"--count"}, + description = "number of times to repeat each benchmark (default 1)") + private int count = 1; + + @Option( + names = {"--count-includes-load"}, + description = + "include bundle load and engine build in each --count iteration " + + "(default: load and build once, then repeat only prepare + eval)") + private boolean countIncludesLoad; + + @Option( + names = {"-e", "--entrypoint"}, + description = "set entrypoint name (overrides positional ENTRYPOINT)") + private String entrypointFlag; + + @Parameters(arity = "0..1", paramLabel = "ENTRYPOINT") + private String entrypointPositional; + + public Integer call() { + + final String entrypoint = + entrypointFlag != null ? entrypointFlag : entrypointPositional; + + if (showCapabilities) { + try { + System.out.println(new ObjectMapper().writeValueAsString(BuiltinRegistry.generateCapabilities())); + } catch (IOException e) { + System.err.println(e.getMessage()); + return 1; + } + return 0; + } + + if (entrypoint == null || entrypoint.isEmpty()) { + System.err.println("Missing required ENTRYPOINT argument (or use -e/--entrypoint)"); + return 2; + } + + if (instrument) { + showMetrics = true; + } + + final ObjectMapper objectMapper = new ObjectMapper(); + + final List allMetrics = new ArrayList<>(count); + final List allTracers = new ArrayList<>(count); + final List allProfilers = new ArrayList<>(count); + final List allStatementProfilers = new ArrayList<>(count); + final List allCoverageProfilers = new ArrayList<>(count); + String[] fileNames = new String[] {}; + List lastResults = null; + + Engine sharedEngine = null; + if (!countIncludesLoad) { + final Capabilities capabilities = BuiltinRegistry.generateCapabilities(); + final Store store = new InMem(); + final Engine.Builder eb = + new Engine.Builder() + .withStore(store) + .withCapabilities(capabilities) + .withEntrypoint(entrypoint); + loadBundles(store); + sharedEngine = eb.build(); + fileNames = extractFileNamesFromStore(store, entrypoint); + } + + final Object inputDoc; + try { + if (stdinInput) { + inputDoc = objectMapper.readValue(System.in, Object.class); + } else if (input != null) { + inputDoc = objectMapper.readValue(this.input.toFile(), Object.class); + } else { + inputDoc = null; + } + } catch (IOException e) { + throw new IllegalArgumentException("Error reading input: " + e.getMessage(), e); + } + + for (int i = 0; i < count; i++) { + Metrics metrics = NoOpMetrics.Instance(); + StatementProfiler statementProfiler = new NoOpStatementProfiler(); + + if (showMetrics || showProfile) { // profile assumes metrics + metrics = new SimpleMetrics(); + allMetrics.add(metrics); + } + + if (showProfile) { + statementProfiler = new SimpleStatementProfiler(); + allStatementProfilers.add(statementProfiler); + } + + final Engine engine; + if (countIncludesLoad) { + metrics.timer("cli_capabilities_register").start(); + final Capabilities capabilities = BuiltinRegistry.generateCapabilities(); + metrics.timer("cli_capabilities_register").stop(); + + final Store store = new InMem(); + final Engine.Builder eb = + new Engine.Builder() + .withStore(store) + .withCapabilities(capabilities) + .withEntrypoint(entrypoint); + + metrics.timer("cli_load_bundles").start(); + loadBundles(store); + metrics.timer("cli_load_bundles").stop(); + + metrics.timer("cli_engine_build").start(); + engine = eb.build(); + metrics.timer("cli_engine_build").stop(); + + if (i == 0) { + fileNames = extractFileNamesFromStore(store, entrypoint); + } + } else { + engine = sharedEngine; + } + + final BufferedQueryTracer tracer = new BufferedQueryTracer(); + allTracers.add(tracer); + + Engine.PreparedQuery.Builder pqBuilder = + engine + .prepareForEvaluation() + .withEntrypoint(entrypoint) + .withTracer(tracer) + .withMetrics(metrics) + .withStatementProfiler(statementProfiler) + .withPrintHook(PrintHook.of(System.err)); + + if (showProfile) { + final DurationProfiler profiler = new DurationProfiler(); + allProfilers.add(profiler); + pqBuilder = pqBuilder.withProfiler(profiler); + } + + if (showCoverage) { + final CoverageProfiler coverageProfiler = new CoverageProfiler(); + allCoverageProfilers.add(coverageProfiler); + pqBuilder = pqBuilder.withProfiler(coverageProfiler); + } + + metrics.timer("cli_prepare_query").start(); + final Engine.PreparedQuery pq = pqBuilder.build(); + metrics.timer("cli_prepare_query").stop(); + + lastResults = pq.eval(inputDoc); + } + + if (lastResults != null) { + try { + final ObjectWriter writer = + "pretty".equalsIgnoreCase(format) + ? objectMapper.writerWithDefaultPrettyPrinter() + : objectMapper.writer(); + System.out.println(writer.writeValueAsString(lastResults)); + } catch (IOException e) { + throw new IllegalStateException("Error serializing results: " + e.getMessage(), e); + } + } + + if (showMetrics || showProfile) { + new MetricsReporter().printMetricsTable(allMetrics); + } + + if (explain) { + new TraceReporter().printTraceOutput(allTracers, fileNames); + } + + if (showProfile) { + new ProfileReporter() + .printProfileOutput(allProfilers, fileNames, profileLimit, profileSort); + new StatementReporter().printStatementOutput(allStatementProfilers); + } + + if (showCoverage) { + new CoverageReporter().printCoverage(allCoverageProfilers, fileNames); + } + + if (fail && (lastResults == null || lastResults.isEmpty())) { + return 1; + } + if (failDefined && lastResults != null && !lastResults.isEmpty()) { + return 1; + } + return 0; + } + + private void loadBundles(Store store) { + if (bundleFilePaths == null) { + return; + } + for (final Path path : bundleFilePaths) { + if (Files.isDirectory(path)) { + new FileSystemBundleLoader(path.toString(), path).load(store); + } else { + final String name = path.getFileName().toString().toLowerCase(); + if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) { + new TarballBundleLoader(path.toString(), path).load(store); + } else { + throw new IllegalArgumentException( + "Bundle path must be a directory or a .tar.gz/.tgz file: " + path); + } + } + } + } + + private String[] extractFileNamesFromStore(Store store, String entrypoint) { + final Policy policy = store.getIrPolicyForEntrypoint(entrypoint); + + final List files = policy.getStatic().getFiles(); + + if (files != null && !files.isEmpty()) { + return files.stream().map(StringConst::getValue).toArray(String[]::new); + } + return new String[0]; + } +} diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Format.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Format.java new file mode 100644 index 00000000..b582a8ca --- /dev/null +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Format.java @@ -0,0 +1,25 @@ +package io.github.open_policy_agent.opa.cli; + +final class Format { + + private Format() {} + + static String duration(long nanos) { + if (nanos < 1000) { + return nanos + "ns"; + } else if (nanos < 1000000) { + return String.format("%.3fµs", nanos / 1000.0); + } else if (nanos < 1000000000) { + return String.format("%.3fms", nanos / 1000000.0); + } else { + return String.format("%.3fs", nanos / 1000000000.0); + } + } + + static String fileRef(int fileIdx, String[] fileNames) { + if (fileIdx >= 0 && fileNames != null && fileIdx < fileNames.length) { + return "/" + fileNames[fileIdx]; + } + return "/"; + } +} diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/MetricsReporter.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/MetricsReporter.java new file mode 100644 index 00000000..8266e4ac --- /dev/null +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/MetricsReporter.java @@ -0,0 +1,256 @@ +package io.github.open_policy_agent.opa.cli; + +import io.github.open_policy_agent.opa.metrics.Metrics; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class MetricsReporter { + + public void printMetricsTable(List allMetrics) { + if (allMetrics.isEmpty()) { + return; + } + + final Map metricsData = allMetrics.get(0).all(); + if (metricsData.isEmpty()) { + return; + } + + if (allMetrics.size() == 1) { + printSingleMetricsTable(allMetrics); + } else { + printStatisticalMetricsTable(allMetrics); + } + } + + private void printSingleMetricsTable(List allMetrics) { + final Map metricsData = allMetrics.get(0).all(); + + int maxMetricNameWidth = "METRIC".length(); + int maxValueWidth = "TIME".length(); + + for (final Map.Entry entry : metricsData.entrySet()) { + final String metricName = entry.getKey(); + maxMetricNameWidth = Math.max(maxMetricNameWidth, metricName.length()); + + String value = ""; + if (entry.getValue() instanceof Metrics.Timer) { + final Metrics.Timer timer = (Metrics.Timer) entry.getValue(); + value = Format.duration(timer.value().toNanos()); + } + maxValueWidth = Math.max(maxValueWidth, value.length()); + } + + maxMetricNameWidth += 2; + maxValueWidth += 2; + + printBorder(maxMetricNameWidth, maxValueWidth); + printRow("METRIC", "TIME", maxMetricNameWidth, maxValueWidth); + printBorder(maxMetricNameWidth, maxValueWidth); + + for (final Map.Entry entry : metricsData.entrySet()) { + final String metricName = entry.getKey(); + String value = ""; + + if (entry.getValue() instanceof Metrics.Timer) { + final Metrics.Timer timer = (Metrics.Timer) entry.getValue(); + value = Format.duration(timer.value().toNanos()); + } + + printRow(metricName, value, maxMetricNameWidth, maxValueWidth); + } + + printBorder(maxMetricNameWidth, maxValueWidth); + } + + private void printStatisticalMetricsTable(List allMetrics) { + final Map> timerValuesByName = new LinkedHashMap<>(); + + final Map firstMetrics = allMetrics.get(0).all(); + for (final Map.Entry entry : firstMetrics.entrySet()) { + if (entry.getValue() instanceof Metrics.Timer) { + timerValuesByName.put(entry.getKey(), new ArrayList<>()); + } + } + + for (final Metrics metrics : allMetrics) { + for (final Map.Entry entry : metrics.all().entrySet()) { + if (entry.getValue() instanceof Metrics.Timer) { + final Metrics.Timer timer = (Metrics.Timer) entry.getValue(); + final List values = timerValuesByName.get(entry.getKey()); + if (values != null) { + values.add(timer.value().toNanos()); + } + } + } + } + + if (timerValuesByName.isEmpty()) { + return; + } + + int metricWidth = "METRIC".length(); + int minWidth = "MIN".length(); + int maxWidth = "MAX".length(); + int meanWidth = "MEAN".length(); + int p90Width = "90%".length(); + int p99Width = "99%".length(); + + final Map statsByName = new LinkedHashMap<>(); + for (final Map.Entry> entry : timerValuesByName.entrySet()) { + final String metricName = entry.getKey(); + final Statistics stats = calculateStatistics(entry.getValue()); + statsByName.put(metricName, stats); + + metricWidth = Math.max(metricWidth, metricName.length()); + minWidth = Math.max(minWidth, Format.duration(stats.min).length()); + maxWidth = Math.max(maxWidth, Format.duration(stats.max).length()); + meanWidth = Math.max(meanWidth, Format.duration(stats.mean).length()); + p90Width = Math.max(p90Width, Format.duration(stats.p90).length()); + p99Width = Math.max(p99Width, Format.duration(stats.p99).length()); + } + + metricWidth += 2; + minWidth += 2; + maxWidth += 2; + meanWidth += 2; + p90Width += 2; + p99Width += 2; + + printStatisticalBorder(metricWidth, minWidth, maxWidth, meanWidth, p90Width, p99Width); + printStatisticalRow( + "METRIC", + "MIN", + "MAX", + "MEAN", + "90%", + "99%", + metricWidth, + minWidth, + maxWidth, + meanWidth, + p90Width, + p99Width); + printStatisticalBorder(metricWidth, minWidth, maxWidth, meanWidth, p90Width, p99Width); + + for (final Map.Entry entry : statsByName.entrySet()) { + final Statistics stats = entry.getValue(); + printStatisticalRow( + entry.getKey(), + Format.duration(stats.min), + Format.duration(stats.max), + Format.duration(stats.mean), + Format.duration(stats.p90), + Format.duration(stats.p99), + metricWidth, + minWidth, + maxWidth, + meanWidth, + p90Width, + p99Width); + } + + printStatisticalBorder(metricWidth, minWidth, maxWidth, meanWidth, p90Width, p99Width); + } + + private Statistics calculateStatistics(List values) { + if (values.isEmpty()) { + return new Statistics(0, 0, 0, 0, 0); + } + + final List sorted = values.stream().sorted().collect(Collectors.toList()); + final long min = sorted.get(0); + final long max = sorted.get(sorted.size() - 1); + final long mean = (long) values.stream().mapToLong(v -> v).average().orElse(0); + + final int p90Index = (int) Math.ceil(0.90 * sorted.size()) - 1; + final int p99Index = (int) Math.ceil(0.99 * sorted.size()) - 1; + final long p90 = sorted.get(Math.max(0, Math.min(p90Index, sorted.size() - 1))); + final long p99 = sorted.get(Math.max(0, Math.min(p99Index, sorted.size() - 1))); + + return new Statistics(min, max, mean, p90, p99); + } + + private void printBorder(int col1Width, int col2Width) { + System.out.print("+"); + System.out.print("-".repeat(col1Width)); + System.out.print("+"); + System.out.print("-".repeat(col2Width)); + System.out.println("+"); + } + + private void printRow(String col1, String col2, int col1Width, int col2Width) { + System.out.printf("| %-" + (col1Width - 2) + "s | %-" + (col2Width - 2) + "s |%n", col1, col2); + } + + private void printStatisticalBorder( + int col1Width, int col2Width, int col3Width, int col4Width, int col5Width, int col6Width) { + System.out.print("+"); + System.out.print("-".repeat(col1Width)); + System.out.print("+"); + System.out.print("-".repeat(col2Width)); + System.out.print("+"); + System.out.print("-".repeat(col3Width)); + System.out.print("+"); + System.out.print("-".repeat(col4Width)); + System.out.print("+"); + System.out.print("-".repeat(col5Width)); + System.out.print("+"); + System.out.print("-".repeat(col6Width)); + System.out.println("+"); + } + + private void printStatisticalRow( + String col1, + String col2, + String col3, + String col4, + String col5, + String col6, + int col1Width, + int col2Width, + int col3Width, + int col4Width, + int col5Width, + int col6Width) { + System.out.printf( + "| %-" + + (col1Width - 2) + + "s | %-" + + (col2Width - 2) + + "s | %-" + + (col3Width - 2) + + "s | %-" + + (col4Width - 2) + + "s | %-" + + (col5Width - 2) + + "s | %-" + + (col6Width - 2) + + "s |%n", + col1, + col2, + col3, + col4, + col5, + col6); + } + + private static class Statistics { + final long min; + final long max; + final long mean; + final long p90; + final long p99; + + Statistics(long min, long max, long mean, long p90, long p99) { + this.min = min; + this.max = max; + this.mean = mean; + this.p90 = p90; + this.p99 = p99; + } + } +} diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/ProfileReporter.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/ProfileReporter.java new file mode 100644 index 00000000..7b3bc3fd --- /dev/null +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/ProfileReporter.java @@ -0,0 +1,325 @@ +package io.github.open_policy_agent.opa.cli; + +import io.github.open_policy_agent.opa.tracing.DurationProfiler; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class ProfileReporter { + + public void printProfileOutput( + List allProfilers, String[] fileNames, int limit, String sortKey) { + if (allProfilers.isEmpty()) { + return; + } + + if (allProfilers.get(0).getDurations().isEmpty()) { + return; + } + + if (allProfilers.size() == 1) { + printSingleProfileTable(allProfilers, fileNames, limit, sortKey); + } else { + printStatisticalProfileTable(allProfilers, fileNames, limit, sortKey); + } + } + + private void printSingleProfileTable( + List allProfilers, String[] fileNames, int limit, String sortKey) { + int timeWidth = "TIME".length(); + int numEvalWidth = "NUM EVAL".length(); + int locationWidth = "LOCATION".length(); + + final List tableData = new ArrayList<>(); + + for (final Map.Entry entry : + allProfilers.get(0).getDurations().entrySet()) { + final DurationProfiler.Loc loc = entry.getKey(); + final DurationProfiler.EvalTotal evalTotal = entry.getValue(); + + final String location = Format.fileRef(loc.getFile(), fileNames) + ":" + loc.getRow(); + final long nanos = evalTotal.getTotalDuration().toNanos(); + final String timeStr = Format.duration(nanos); + final int numEval = evalTotal.getCount(); + + final ProfileRow row = new ProfileRow(timeStr, numEval, location, nanos); + tableData.add(row); + + timeWidth = Math.max(timeWidth, timeStr.length()); + numEvalWidth = Math.max(numEvalWidth, String.valueOf(numEval).length()); + locationWidth = Math.max(locationWidth, location.length()); + } + + timeWidth += 2; + numEvalWidth += 2; + locationWidth += 2; + + tableData.sort(profileRowComparator(sortKey)); + final List rows = + limit > 0 && tableData.size() > limit ? tableData.subList(0, limit) : tableData; + + printProfileBorder(timeWidth, numEvalWidth, locationWidth); + printProfileRow("TIME", "NUM EVAL", "LOCATION", timeWidth, numEvalWidth, locationWidth); + printProfileBorder(timeWidth, numEvalWidth, locationWidth); + + for (final ProfileRow row : rows) { + printProfileRow( + row.time, + String.valueOf(row.numEval), + row.location, + timeWidth, + numEvalWidth, + locationWidth); + } + + printProfileBorder(timeWidth, numEvalWidth, locationWidth); + } + + private void printStatisticalProfileTable( + List allProfilers, String[] fileNames, int limit, String sortKey) { + final Map> durationsByLocation = new HashMap<>(); + + for (final DurationProfiler profiler : allProfilers) { + for (final Map.Entry entry : + profiler.getDurations().entrySet()) { + final DurationProfiler.Loc loc = entry.getKey(); + final DurationProfiler.EvalTotal evalTotal = entry.getValue(); + final String location = Format.fileRef(loc.getFile(), fileNames) + ":" + loc.getRow(); + + durationsByLocation + .computeIfAbsent(location, k -> new ArrayList<>()) + .add(evalTotal.getTotalDuration().toNanos()); + } + } + + if (durationsByLocation.isEmpty()) { + return; + } + + int locationWidth = "LOCATION".length(); + int minWidth = "MIN".length(); + int maxWidth = "MAX".length(); + int meanWidth = "MEAN".length(); + int p90Width = "90%".length(); + int p99Width = "99%".length(); + + final Map statsByLocation = new HashMap<>(); + for (final Map.Entry> entry : durationsByLocation.entrySet()) { + final String location = entry.getKey(); + final ProfileStatistics stats = calculateProfileStatistics(entry.getValue()); + statsByLocation.put(location, stats); + + locationWidth = Math.max(locationWidth, location.length()); + minWidth = Math.max(minWidth, Format.duration(stats.min).length()); + maxWidth = Math.max(maxWidth, Format.duration(stats.max).length()); + meanWidth = Math.max(meanWidth, Format.duration(stats.mean).length()); + p90Width = Math.max(p90Width, Format.duration(stats.p90).length()); + p99Width = Math.max(p99Width, Format.duration(stats.p99).length()); + } + + locationWidth += 2; + minWidth += 2; + maxWidth += 2; + meanWidth += 2; + p90Width += 2; + p99Width += 2; + + final List> sortedEntries = + statsByLocation.entrySet().stream() + .sorted(profileStatisticsComparator(sortKey)) + .collect(Collectors.toList()); + final List> limitedEntries = + limit > 0 && sortedEntries.size() > limit + ? sortedEntries.subList(0, limit) + : sortedEntries; + + printStatisticalProfileBorder(locationWidth, minWidth, maxWidth, meanWidth, p90Width, p99Width); + printStatisticalProfileRow( + "LOCATION", + "MIN", + "MAX", + "MEAN", + "90%", + "99%", + locationWidth, + minWidth, + maxWidth, + meanWidth, + p90Width, + p99Width); + printStatisticalProfileBorder(locationWidth, minWidth, maxWidth, meanWidth, p90Width, p99Width); + + for (final Map.Entry entry : limitedEntries) { + final ProfileStatistics stats = entry.getValue(); + printStatisticalProfileRow( + entry.getKey(), + Format.duration(stats.min), + Format.duration(stats.max), + Format.duration(stats.mean), + Format.duration(stats.p90), + Format.duration(stats.p99), + locationWidth, + minWidth, + maxWidth, + meanWidth, + p90Width, + p99Width); + } + + printStatisticalProfileBorder(locationWidth, minWidth, maxWidth, meanWidth, p90Width, p99Width); + } + + private ProfileStatistics calculateProfileStatistics(List values) { + if (values.isEmpty()) { + return new ProfileStatistics(0, 0, 0, 0, 0); + } + + final List sorted = values.stream().sorted().collect(Collectors.toList()); + final long min = sorted.get(0); + final long max = sorted.get(sorted.size() - 1); + final long mean = (long) values.stream().mapToLong(v -> v).average().orElse(0); + + final int p90Index = (int) Math.ceil(0.90 * sorted.size()) - 1; + final int p99Index = (int) Math.ceil(0.99 * sorted.size()) - 1; + final long p90 = sorted.get(Math.max(0, Math.min(p90Index, sorted.size() - 1))); + final long p99 = sorted.get(Math.max(0, Math.min(p99Index, sorted.size() - 1))); + + return new ProfileStatistics(min, max, mean, p90, p99); + } + + private Comparator profileRowComparator(String sortKey) { + switch (sortKey == null ? "total_time" : sortKey.toLowerCase()) { + case "num_eval": + return Comparator.comparingInt((ProfileRow r) -> r.numEval).reversed(); + case "location": + return Comparator.comparing((ProfileRow r) -> r.location); + case "total_time": + default: + return Comparator.comparingLong((ProfileRow r) -> r.durationNanos).reversed(); + } + } + + private Comparator> profileStatisticsComparator( + String sortKey) { + switch (sortKey == null ? "total_time" : sortKey.toLowerCase()) { + case "location": + return Map.Entry.comparingByKey(); + case "num_eval": + case "total_time": + default: + return Comparator.comparingLong( + (Map.Entry e) -> e.getValue().mean) + .reversed(); + } + } + + private void printProfileBorder(int col1Width, int col2Width, int col3Width) { + System.out.print("+"); + System.out.print("-".repeat(col1Width)); + System.out.print("+"); + System.out.print("-".repeat(col2Width)); + System.out.print("+"); + System.out.print("-".repeat(col3Width)); + System.out.println("+"); + } + + private void printProfileRow( + String col1, String col2, String col3, int col1Width, int col2Width, int col3Width) { + System.out.printf( + "| %-" + + (col1Width - 2) + + "s | %-" + + (col2Width - 2) + + "s | %-" + + (col3Width - 2) + + "s |%n", + col1, + col2, + col3); + } + + private void printStatisticalProfileBorder( + int col1Width, int col2Width, int col3Width, int col4Width, int col5Width, int col6Width) { + System.out.print("+"); + System.out.print("-".repeat(col1Width)); + System.out.print("+"); + System.out.print("-".repeat(col2Width)); + System.out.print("+"); + System.out.print("-".repeat(col3Width)); + System.out.print("+"); + System.out.print("-".repeat(col4Width)); + System.out.print("+"); + System.out.print("-".repeat(col5Width)); + System.out.print("+"); + System.out.print("-".repeat(col6Width)); + System.out.println("+"); + } + + private void printStatisticalProfileRow( + String col1, + String col2, + String col3, + String col4, + String col5, + String col6, + int col1Width, + int col2Width, + int col3Width, + int col4Width, + int col5Width, + int col6Width) { + System.out.printf( + "| %-" + + (col1Width - 2) + + "s | %-" + + (col2Width - 2) + + "s | %-" + + (col3Width - 2) + + "s | %-" + + (col4Width - 2) + + "s | %-" + + (col5Width - 2) + + "s | %-" + + (col6Width - 2) + + "s |%n", + col1, + col2, + col3, + col4, + col5, + col6); + } + + private static class ProfileRow { + final String time; + final int numEval; + final String location; + final long durationNanos; + + ProfileRow(String time, int numEval, String location, long durationNanos) { + this.time = time; + this.numEval = numEval; + this.location = location; + this.durationNanos = durationNanos; + } + } + + private static class ProfileStatistics { + final long min; + final long max; + final long mean; + final long p90; + final long p99; + + ProfileStatistics(long min, long max, long mean, long p90, long p99) { + this.min = min; + this.max = max; + this.mean = mean; + this.p90 = p90; + this.p99 = p99; + } + } +} diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Regoj.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Regoj.java new file mode 100644 index 00000000..92e8c98b --- /dev/null +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Regoj.java @@ -0,0 +1,18 @@ +/* + * Entry point for the regoj CLI. + */ +package io.github.open_policy_agent.opa.cli; + +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command( + name = "regoj", + subcommands = {Eval.class}) +public class Regoj { + public static void main(String[] args) { + int exitCode = + new CommandLine(new Regoj()).setCaseInsensitiveEnumValuesAllowed(true).execute(args); + System.exit(exitCode); + } +} diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/StatementReporter.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/StatementReporter.java new file mode 100644 index 00000000..5f592705 --- /dev/null +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/StatementReporter.java @@ -0,0 +1,438 @@ +package io.github.open_policy_agent.opa.cli; + +import io.github.open_policy_agent.opa.profiling.StatementProfiler; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class StatementReporter { + + public void printStatementOutput(List allProfilers) { + if (allProfilers.isEmpty()) { + return; + } + + final Map> summariesByStatement = + new HashMap<>(); + + for (final StatementProfiler profiler : allProfilers) { + final Map summaries = + profiler.getStatementSummaries(); + for (final Map.Entry entry : + summaries.entrySet()) { + summariesByStatement + .computeIfAbsent(entry.getKey(), k -> new ArrayList<>()) + .add(entry.getValue()); + } + } + + if (summariesByStatement.isEmpty()) { + return; + } + + if (allProfilers.size() == 1) { + printSingleStatementTable(summariesByStatement); + } else { + printStatisticalStatementTable(summariesByStatement); + } + } + + private void printSingleStatementTable( + Map> summariesByStatement) { + int totalTimeWidth = "TOTAL TIME".length(); + int avgTimeWidth = "AVG TIME".length(); + int countWidth = "COUNT".length(); + int statementWidth = "STATEMENT".length(); + + final List tableData = new ArrayList<>(); + + for (final Map.Entry> entry : + summariesByStatement.entrySet()) { + final String statement = entry.getKey(); + final StatementProfiler.StatementSummary summary = entry.getValue().get(0); + + final int count = summary.getCount(); + final Duration totalDuration = summary.getDuration(); + final long totalNanos = totalDuration.toNanos(); + + final long avgNanos = count > 0 ? totalNanos / count : 0; + final String totalTimeStr = Format.duration(totalNanos); + final String avgTimeStr = Format.duration(avgNanos); + + final StatementRow row = + new StatementRow(totalTimeStr, avgTimeStr, count, statement, totalNanos, avgNanos); + tableData.add(row); + + totalTimeWidth = Math.max(totalTimeWidth, totalTimeStr.length()); + avgTimeWidth = Math.max(avgTimeWidth, avgTimeStr.length()); + countWidth = Math.max(countWidth, String.valueOf(count).length()); + statementWidth = Math.max(statementWidth, statement.length()); + } + + totalTimeWidth += 2; + avgTimeWidth += 2; + countWidth += 2; + statementWidth += 2; + + tableData.sort((a, b) -> Long.compare(b.getTotalNanos(), a.getTotalNanos())); + + printStatementBorder(totalTimeWidth, avgTimeWidth, countWidth, statementWidth); + printStatementRow( + "TOTAL TIME", + "AVG TIME", + "COUNT", + "STATEMENT", + totalTimeWidth, + avgTimeWidth, + countWidth, + statementWidth); + printStatementBorder(totalTimeWidth, avgTimeWidth, countWidth, statementWidth); + + for (final StatementRow row : tableData) { + printStatementRow( + row.totalTime, + row.avgTime, + String.valueOf(row.count), + row.statement, + totalTimeWidth, + avgTimeWidth, + countWidth, + statementWidth); + } + + printStatementBorder(totalTimeWidth, avgTimeWidth, countWidth, statementWidth); + } + + private void printStatisticalStatementTable( + Map> summariesByStatement) { + final Map> totalTimesByStatement = new HashMap<>(); + final Map> avgTimesByStatement = new HashMap<>(); + final Map> countsByStatement = new HashMap<>(); + + for (final Map.Entry> entry : + summariesByStatement.entrySet()) { + final String statement = entry.getKey(); + + final List totalTimes = new ArrayList<>(); + final List avgTimes = new ArrayList<>(); + final List counts = new ArrayList<>(); + + for (final StatementProfiler.StatementSummary summary : entry.getValue()) { + final int count = summary.getCount(); + final Duration totalDuration = summary.getDuration(); + final long totalNanos = totalDuration.toNanos(); + final long avgNanos = count > 0 ? totalNanos / count : 0; + + totalTimes.add(totalNanos); + avgTimes.add(avgNanos); + counts.add(count); + } + + totalTimesByStatement.put(statement, totalTimes); + avgTimesByStatement.put(statement, avgTimes); + countsByStatement.put(statement, counts); + } + + int statementWidth = "STATEMENT".length(); + int avgCountWidth = "COUNT".length(); + int totalTimeWidth = "MEAN TOTAL".length(); + int minTimeWidth = "MIN".length(); + int maxTimeWidth = "MAX".length(); + int meanTimeWidth = "MEAN".length(); + int p90TimeWidth = "90%".length(); + int p99TimeWidth = "99%".length(); + + final Map statsByStatement = new HashMap<>(); + for (final Map.Entry> entry : totalTimesByStatement.entrySet()) { + final String statement = entry.getKey(); + final List totalTimes = entry.getValue(); + final List avgTimes = avgTimesByStatement.get(statement); + final List counts = countsByStatement.get(statement); + + final StatementStatistics stats = + calculateStatementStatistics(totalTimes, avgTimes, counts); + statsByStatement.put(statement, stats); + + statementWidth = Math.max(statementWidth, statement.length()); + avgCountWidth = Math.max(avgCountWidth, String.valueOf(stats.avgCount).length()); + totalTimeWidth = Math.max(totalTimeWidth, Format.duration(stats.meanTotalTime).length()); + minTimeWidth = Math.max(minTimeWidth, Format.duration(stats.minTime).length()); + maxTimeWidth = Math.max(maxTimeWidth, Format.duration(stats.maxTime).length()); + meanTimeWidth = Math.max(meanTimeWidth, Format.duration(stats.meanTime).length()); + p90TimeWidth = Math.max(p90TimeWidth, Format.duration(stats.p90Time).length()); + p99TimeWidth = Math.max(p99TimeWidth, Format.duration(stats.p99Time).length()); + } + + statementWidth += 2; + avgCountWidth += 2; + totalTimeWidth += 2; + minTimeWidth += 2; + maxTimeWidth += 2; + meanTimeWidth += 2; + p90TimeWidth += 2; + p99TimeWidth += 2; + + final List> sortedEntries = + statsByStatement.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue().meanTotalTime, a.getValue().meanTotalTime)) + .collect(Collectors.toList()); + + printStatisticalStatementBorder( + statementWidth, + avgCountWidth, + totalTimeWidth, + minTimeWidth, + maxTimeWidth, + meanTimeWidth, + p90TimeWidth, + p99TimeWidth); + printStatisticalStatementRow( + "STATEMENT", + "COUNT", + "MEAN TOTAL", + "MIN", + "MAX", + "MEAN", + "90%", + "99%", + statementWidth, + avgCountWidth, + totalTimeWidth, + minTimeWidth, + maxTimeWidth, + meanTimeWidth, + p90TimeWidth, + p99TimeWidth); + printStatisticalStatementBorder( + statementWidth, + avgCountWidth, + totalTimeWidth, + minTimeWidth, + maxTimeWidth, + meanTimeWidth, + p90TimeWidth, + p99TimeWidth); + + for (final Map.Entry entry : sortedEntries) { + final StatementStatistics stats = entry.getValue(); + printStatisticalStatementRow( + entry.getKey(), + String.valueOf(stats.avgCount), + Format.duration(stats.meanTotalTime), + Format.duration(stats.minTime), + Format.duration(stats.maxTime), + Format.duration(stats.meanTime), + Format.duration(stats.p90Time), + Format.duration(stats.p99Time), + statementWidth, + avgCountWidth, + totalTimeWidth, + minTimeWidth, + maxTimeWidth, + meanTimeWidth, + p90TimeWidth, + p99TimeWidth); + } + + printStatisticalStatementBorder( + statementWidth, + avgCountWidth, + totalTimeWidth, + minTimeWidth, + maxTimeWidth, + meanTimeWidth, + p90TimeWidth, + p99TimeWidth); + } + + private StatementStatistics calculateStatementStatistics( + List totalTimes, List avgTimes, List counts) { + if (avgTimes.isEmpty()) { + return new StatementStatistics(0, 0, 0, 0, 0, 0, 0); + } + + final List sortedTimes = avgTimes.stream().sorted().collect(Collectors.toList()); + final long minTime = sortedTimes.get(0); + final long maxTime = sortedTimes.get(sortedTimes.size() - 1); + final long meanTime = (long) avgTimes.stream().mapToLong(v -> v).average().orElse(0); + + final int p90Index = (int) Math.ceil(0.90 * sortedTimes.size()) - 1; + final int p99Index = (int) Math.ceil(0.99 * sortedTimes.size()) - 1; + final long p90Time = sortedTimes.get(Math.max(0, Math.min(p90Index, sortedTimes.size() - 1))); + final long p99Time = sortedTimes.get(Math.max(0, Math.min(p99Index, sortedTimes.size() - 1))); + + final int avgCount = (int) counts.stream().mapToInt(v -> v).average().orElse(0); + final long meanTotalTime = (long) totalTimes.stream().mapToLong(v -> v).average().orElse(0); + + return new StatementStatistics( + avgCount, meanTotalTime, minTime, maxTime, meanTime, p90Time, p99Time); + } + + private void printStatementBorder(int col1Width, int col2Width, int col3Width, int col4Width) { + System.out.print("+"); + System.out.print("-".repeat(col1Width)); + System.out.print("+"); + System.out.print("-".repeat(col2Width)); + System.out.print("+"); + System.out.print("-".repeat(col3Width)); + System.out.print("+"); + System.out.print("-".repeat(col4Width)); + System.out.println("+"); + } + + private void printStatementRow( + String col1, + String col2, + String col3, + String col4, + int col1Width, + int col2Width, + int col3Width, + int col4Width) { + System.out.printf( + "| %-" + + (col1Width - 2) + + "s | %-" + + (col2Width - 2) + + "s | %-" + + (col3Width - 2) + + "s | %-" + + (col4Width - 2) + + "s |%n", + col1, + col2, + col3, + col4); + } + + private void printStatisticalStatementBorder( + int col1Width, + int col2Width, + int col3Width, + int col4Width, + int col5Width, + int col6Width, + int col7Width, + int col8Width) { + System.out.print("+"); + System.out.print("-".repeat(col1Width)); + System.out.print("+"); + System.out.print("-".repeat(col2Width)); + System.out.print("+"); + System.out.print("-".repeat(col3Width)); + System.out.print("+"); + System.out.print("-".repeat(col4Width)); + System.out.print("+"); + System.out.print("-".repeat(col5Width)); + System.out.print("+"); + System.out.print("-".repeat(col6Width)); + System.out.print("+"); + System.out.print("-".repeat(col7Width)); + System.out.print("+"); + System.out.print("-".repeat(col8Width)); + System.out.println("+"); + } + + private void printStatisticalStatementRow( + String col1, + String col2, + String col3, + String col4, + String col5, + String col6, + String col7, + String col8, + int col1Width, + int col2Width, + int col3Width, + int col4Width, + int col5Width, + int col6Width, + int col7Width, + int col8Width) { + System.out.printf( + "| %-" + + (col1Width - 2) + + "s | %-" + + (col2Width - 2) + + "s | %-" + + (col3Width - 2) + + "s | %-" + + (col4Width - 2) + + "s | %-" + + (col5Width - 2) + + "s | %-" + + (col6Width - 2) + + "s | %-" + + (col7Width - 2) + + "s | %-" + + (col8Width - 2) + + "s |%n", + col1, + col2, + col3, + col4, + col5, + col6, + col7, + col8); + } + + private static class StatementRow { + final String totalTime; + final String avgTime; + final int count; + final String statement; + final long totalNanos; + final long avgNanos; + + StatementRow( + String totalTime, + String avgTime, + int count, + String statement, + long totalNanos, + long avgNanos) { + this.totalTime = totalTime; + this.avgTime = avgTime; + this.count = count; + this.statement = statement; + this.totalNanos = totalNanos; + this.avgNanos = avgNanos; + } + + public long getTotalNanos() { + return totalNanos; + } + } + + private static class StatementStatistics { + final int avgCount; + final long meanTotalTime; + final long minTime; + final long maxTime; + final long meanTime; + final long p90Time; + final long p99Time; + + StatementStatistics( + int avgCount, + long meanTotalTime, + long minTime, + long maxTime, + long meanTime, + long p90Time, + long p99Time) { + this.avgCount = avgCount; + this.meanTotalTime = meanTotalTime; + this.minTime = minTime; + this.maxTime = maxTime; + this.meanTime = meanTime; + this.p90Time = p90Time; + this.p99Time = p99Time; + } + } +} diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/TraceReporter.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/TraceReporter.java new file mode 100644 index 00000000..0e8ac38c --- /dev/null +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/TraceReporter.java @@ -0,0 +1,97 @@ +package io.github.open_policy_agent.opa.cli; + +import io.github.open_policy_agent.opa.ir.Location; +import io.github.open_policy_agent.opa.tracing.Event; +import io.github.open_policy_agent.opa.tracing.QueryTracer; +import java.util.ArrayList; +import java.util.List; + +public class TraceReporter { + + public void printTraceOutput(List allTracers, String[] fileNames) { + final List events = allTracers.get(0).getEvents(); + if (events.isEmpty()) { + return; + } + + final List processedEvents = consolidateEvents(events); + + int nestingLevel = 0; + + for (final TraceItem item : processedEvents) { + final Event event = item.event; + final int count = item.count; + final Location location = event.getLocation(); + final String opValue = event.getOp().getValue(); + + final String fileRef = + location == null + ? "" + : Format.fileRef(location.getFile(), fileNames) + ":" + location.getRow(); + + String indent = "| ".repeat(nestingLevel); + + final String operationText; + if (opValue.equals("EnterStmt")) { + operationText = "Enter"; + nestingLevel += count; + } else if (opValue.equals("ExitStmt")) { + nestingLevel = Math.max(0, nestingLevel - count); + indent = "| ".repeat(nestingLevel); + operationText = "Exit"; + } else { + operationText = opValue; + } + + final String suffix = count > 1 ? " (x" + count + ")" : ""; + + System.out.printf("%-15s %s%s%s%n", fileRef, indent, operationText, suffix); + } + } + + private List consolidateEvents(List events) { + final List result = new ArrayList<>(); + + if (events.size() <= 2) { + for (final Event event : events) { + result.add(new TraceItem(event, 1)); + } + return result; + } + + int i = 0; + while (i < events.size()) { + final Event currentEvent = events.get(i); + final Location currentLocation = currentEvent.getLocation(); + final String currentOp = currentEvent.getOp().getValue(); + + int j = i + 1; + while (j < events.size()) { + final Event nextEvent = events.get(j); + final Location nextLocation = nextEvent.getLocation(); + if (currentLocation == null + || nextLocation == null + || currentLocation.getFile() != nextLocation.getFile() + || currentLocation.getRow() != nextLocation.getRow() + || !currentOp.equals(nextEvent.getOp().getValue())) { + break; + } + j++; + } + result.add(new TraceItem(currentEvent, j - i)); + i = j; + } + + return result; + } + + private static class TraceItem { + final Event event; + final int count; + + TraceItem(Event event, int count) { + this.event = event; + this.count = count; + } + } +} diff --git a/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java b/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java new file mode 100644 index 00000000..c26962a6 --- /dev/null +++ b/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java @@ -0,0 +1,333 @@ +package io.github.open_policy_agent.opa.cli; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; +import java.util.zip.GZIPOutputStream; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +public class CliTest { + + private static final String BUNDLE_DIR = "src/test/resources/ir_simple_dir"; + private static final String INPUT_JSON = "src/test/resources/input.json"; + private static final String ENTRYPOINT = "authz/allow"; + + @TempDir static Path sharedTempDir; + + private static String bundleTgz; + + @BeforeAll + static void buildBundleTarball() throws IOException { + final Path source = Path.of(BUNDLE_DIR); + final Path target = sharedTempDir.resolve("ir_simple.tar.gz"); + try (OutputStream fileOut = Files.newOutputStream(target); + BufferedOutputStream buffered = new BufferedOutputStream(fileOut); + GZIPOutputStream gzipOut = new GZIPOutputStream(buffered); + TarArchiveOutputStream tarOut = new TarArchiveOutputStream(gzipOut)) { + tarOut.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + try (Stream walk = Files.walk(source)) { + final Iterable ordered = + walk.sorted(Comparator.naturalOrder())::iterator; + for (final Path entry : ordered) { + if (Files.isDirectory(entry)) { + continue; + } + final String entryName = source.relativize(entry).toString().replace('\\', '/'); + final TarArchiveEntry tarEntry = new TarArchiveEntry(entry.toFile(), entryName); + tarOut.putArchiveEntry(tarEntry); + Files.copy(entry, tarOut); + tarOut.closeArchiveEntry(); + } + } + tarOut.finish(); + } + bundleTgz = target.toString(); + } + + private final ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + private final ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + private PrintStream originalOut; + private PrintStream originalErr; + private InputStream originalIn; + + @BeforeEach + void captureStreams() { + originalOut = System.out; + originalErr = System.err; + originalIn = System.in; + System.setOut(new PrintStream(outContent, true, StandardCharsets.UTF_8)); + System.setErr(new PrintStream(errContent, true, StandardCharsets.UTF_8)); + } + + @AfterEach + void restoreStreams() { + System.setOut(originalOut); + System.setErr(originalErr); + System.setIn(originalIn); + } + + private int run(String... args) { + return new CommandLine(new Regoj()).setCaseInsensitiveEnumValuesAllowed(true).execute(args); + } + + @Test + void evalCommandExitsNonZeroWhenBundleMissing() { + final int exitCode = + run("eval", "-b", "/tmp/does-not-exist.tar.gz", "-i", INPUT_JSON, ENTRYPOINT); + assertThat(exitCode).isNotZero(); + } + + @Test + void evalCommandPrintsResultJsonByDefault() { + final int exitCode = run("eval", "-b", bundleTgz, "-i", INPUT_JSON, ENTRYPOINT); + assertThat(exitCode).isZero(); + final String out = outContent.toString(StandardCharsets.UTF_8); + assertThat(out).contains("\"result\":true"); + assertThat(out.trim().lines().count()).isEqualTo(1); + } + + @Test + void evalCommandPrettyFormatProducesMultipleLines() { + final int exitCode = + run("eval", "-b", bundleTgz, "-i", INPUT_JSON, "--format", "pretty", ENTRYPOINT); + assertThat(exitCode).isZero(); + final String out = outContent.toString(StandardCharsets.UTF_8); + assertThat(out).contains("\"result\" : true"); + assertThat(out.lines().count()).isGreaterThan(1); + } + + @Test + void evalCommandReadsInputFromStdin() { + final byte[] inputBytes = + "{\"user\":{\"id\":\"alicex\",\"groups\":[\"super\"]}}" + .getBytes(StandardCharsets.UTF_8); + System.setIn(new ByteArrayInputStream(inputBytes)); + + final int exitCode = run("eval", "-b", bundleTgz, "-I", ENTRYPOINT); + assertThat(exitCode).isZero(); + assertThat(outContent.toString(StandardCharsets.UTF_8)).contains("\"result\":true"); + } + + @Test + void evalCommandLoadsDirectoryBundle() { + final int exitCode = run("eval", "-b", BUNDLE_DIR, "-i", INPUT_JSON, ENTRYPOINT); + assertThat(exitCode).isZero(); + assertThat(outContent.toString(StandardCharsets.UTF_8)).contains("\"result\":true"); + } + + @Test + void evalCommandFailDefinedReturnsNonZeroWhenResultDefined() { + final int exitCode = + run("eval", "-b", bundleTgz, "-i", INPUT_JSON, "--fail-defined", ENTRYPOINT); + assertThat(exitCode).isOne(); + } + + @Test + void evalCommandFailReturnsZeroWhenResultDefined() { + final int exitCode = run("eval", "-b", bundleTgz, "-i", INPUT_JSON, "--fail", ENTRYPOINT); + assertThat(exitCode).isZero(); + } + + @Test + void evalCommandEntrypointFlagWorksWithoutPositional() { + final int exitCode = run("eval", "-b", bundleTgz, "-i", INPUT_JSON, "-e", ENTRYPOINT); + assertThat(exitCode).isZero(); + assertThat(outContent.toString(StandardCharsets.UTF_8)).contains("\"result\":true"); + } + + @Test + void evalCommandWithoutEntrypointReturnsTwo() { + final int exitCode = run("eval", "-b", bundleTgz, "-i", INPUT_JSON); + assertThat(exitCode).isEqualTo(2); + assertThat(errContent.toString(StandardCharsets.UTF_8)).contains("ENTRYPOINT"); + } + + @Test + void evalCommandCoveragePrintsCoverageTable() { + final int exitCode = + run("eval", "-b", bundleTgz, "-i", INPUT_JSON, "--coverage", ENTRYPOINT); + assertThat(exitCode).isZero(); + final String out = outContent.toString(StandardCharsets.UTF_8); + assertThat(out).contains("FILE").contains("HITS").contains("COVERED LINES"); + assertThat(out).contains("simple.rego"); + } + + @Test + void evalCommandProfileLimitTruncatesRows() { + final int exitCode = + run( + "eval", + "-b", + bundleTgz, + "-i", + INPUT_JSON, + "--profile", + "--profile-limit", + "1", + ENTRYPOINT); + assertThat(exitCode).isZero(); + final String out = outContent.toString(StandardCharsets.UTF_8); + final long dataRows = + out.lines() + .filter(l -> l.startsWith("|")) + .filter(l -> !l.contains("TIME") && !l.contains("LOCATION")) + .filter(l -> !l.contains("METRIC") && !l.contains("STATEMENT")) + .filter(l -> !l.contains("MEAN") && !l.contains("MIN") && !l.contains("MAX")) + .count(); + assertThat(dataRows).isGreaterThan(0); + final long profileLocationRows = + out.lines().filter(l -> l.contains("simple.rego:") && l.startsWith("|")).count(); + assertThat(profileLocationRows).isEqualTo(1); + } + + @Test + void evalCommandCapabilitiesCurrentPrintsCapabilitiesAndExits() { + final int exitCode = run("eval", "--capabilities-current", ENTRYPOINT); + assertThat(exitCode).isZero(); + assertThat(outContent.toString(StandardCharsets.UTF_8)).contains("builtins"); + } + + @Test + void evalCommandInstrumentImpliesMetrics() { + final int exitCode = + run("eval", "-b", bundleTgz, "-i", INPUT_JSON, "--instrument", ENTRYPOINT); + assertThat(exitCode).isZero(); + assertThat(outContent.toString(StandardCharsets.UTF_8)).contains("METRIC"); + } + + @Test + void evalCommandMetricsPrintsMetricsTable() { + final int exitCode = + run("eval", "-b", bundleTgz, "-i", INPUT_JSON, "--metrics", ENTRYPOINT); + assertThat(exitCode).isZero(); + final String out = outContent.toString(StandardCharsets.UTF_8); + assertThat(out).contains("METRIC").contains("TIME"); + assertThat(out).contains("cli_prepare_query"); + } + + @Test + void evalCommandCountGreaterThanOneSwitchesToStatisticalMetrics() { + final int exitCode = + run( + "eval", + "-b", + bundleTgz, + "-i", + INPUT_JSON, + "--metrics", + "--count", + "3", + ENTRYPOINT); + assertThat(exitCode).isZero(); + final String out = outContent.toString(StandardCharsets.UTF_8); + assertThat(out).contains("MIN").contains("MAX").contains("MEAN").contains("90%").contains("99%"); + } + + @Test + void evalCommandCountWithProfileExercisesStatementAndProfileStats() { + final int exitCode = + run( + "eval", + "-b", + bundleTgz, + "-i", + INPUT_JSON, + "--profile", + "--count", + "3", + ENTRYPOINT); + assertThat(exitCode).isZero(); + final String out = outContent.toString(StandardCharsets.UTF_8); + assertThat(out).contains("LOCATION").contains("STATEMENT"); + assertThat(out).contains("MEAN TOTAL"); + assertThat(out).contains("simple.rego:"); + } + + @Test + void evalCommandCountIncludesLoadReloadsBundleEachIteration() { + final int exitCode = + run( + "eval", + "-b", + bundleTgz, + "-i", + INPUT_JSON, + "--metrics", + "--count", + "2", + "--count-includes-load", + ENTRYPOINT); + assertThat(exitCode).isZero(); + final String out = outContent.toString(StandardCharsets.UTF_8); + assertThat(out).contains("cli_load_bundles"); + assertThat(out).contains("cli_engine_build"); + assertThat(out).contains("cli_capabilities_register"); + } + + @Test + void evalCommandExplainPrintsTraceLines() { + final int exitCode = + run("eval", "-b", bundleTgz, "-i", INPUT_JSON, "--explain", ENTRYPOINT); + assertThat(exitCode).isZero(); + final String out = outContent.toString(StandardCharsets.UTF_8); + assertThat(out).contains("Enter"); + assertThat(out).contains("simple.rego"); + } + + @Test + void evalCommandProfileSortByLocationOrdersAlphabetically() { + final int exitCode = + run( + "eval", + "-b", + bundleTgz, + "-i", + INPUT_JSON, + "--profile", + "--profile-sort", + "location", + ENTRYPOINT); + assertThat(exitCode).isZero(); + final String out = outContent.toString(StandardCharsets.UTF_8); + final java.util.regex.Pattern locationPattern = + java.util.regex.Pattern.compile("(/simple\\.rego:\\d+)"); + final java.util.List locations = + out.lines() + .filter(l -> l.startsWith("|") && l.contains("simple.rego:")) + .map( + l -> { + final java.util.regex.Matcher m = locationPattern.matcher(l); + return m.find() ? m.group(1) : l; + }) + .collect(java.util.stream.Collectors.toList()); + assertThat(locations).isNotEmpty(); + final java.util.List sorted = new java.util.ArrayList<>(locations); + sorted.sort(java.util.Comparator.naturalOrder()); + assertThat(locations).isEqualTo(sorted); + } + + @Test + void evalCommandMissingInputFileExitsNonZero() { + final int exitCode = + run("eval", "-b", bundleTgz, "-i", "/tmp/does-not-exist-input.json", ENTRYPOINT); + assertThat(exitCode).isNotZero(); + } +} diff --git a/cli/src/test/resources/input.json b/cli/src/test/resources/input.json new file mode 100644 index 00000000..4167e0eb --- /dev/null +++ b/cli/src/test/resources/input.json @@ -0,0 +1,8 @@ +{ + "user": { + "groups": [ + "super" + ], + "id": "alicex" + } +} diff --git a/cli/src/test/resources/ir_simple_dir/.manifest b/cli/src/test/resources/ir_simple_dir/.manifest new file mode 100644 index 00000000..602dc133 --- /dev/null +++ b/cli/src/test/resources/ir_simple_dir/.manifest @@ -0,0 +1 @@ +{"revision":"","roots":[""],"rego_version":1} diff --git a/cli/src/test/resources/ir_simple_dir/data.json b/cli/src/test/resources/ir_simple_dir/data.json new file mode 100644 index 00000000..9c53fa52 --- /dev/null +++ b/cli/src/test/resources/ir_simple_dir/data.json @@ -0,0 +1 @@ +{"groups":{"super":{"privileged":true}}} diff --git a/cli/src/test/resources/ir_simple_dir/plan.json b/cli/src/test/resources/ir_simple_dir/plan.json new file mode 100644 index 00000000..3ffc1fd4 --- /dev/null +++ b/cli/src/test/resources/ir_simple_dir/plan.json @@ -0,0 +1 @@ +{"static":{"strings":[{"value":"result"},{"value":"user"},{"value":"id"},{"value":"alice"},{"value":"kurt"},{"value":"groups"},{"value":"privileged"}],"builtin_funcs":[{"name":"internal.member_2","decl":{"args":[{"type":"any"},{"type":"any"}],"result":{"type":"boolean"},"type":"function"}}],"files":[{"value":"simple.rego"}]},"plans":{"plans":[{"name":"authz/allow","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":2,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":3,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":3},"object":4,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":4,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.authz.allow","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":5}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":3,"row":6}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":4},"key":{"type":"string_index","value":2},"target":5,"file":0,"col":3,"row":6}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":5},"target":6,"file":0,"col":3,"row":6}},{"type":"MakeSetStmt","stmt":{"target":7,"file":0,"col":3,"row":6}},{"type":"SetAddStmt","stmt":{"value":{"type":"string_index","value":3},"set":7,"file":0,"col":3,"row":6}},{"type":"SetAddStmt","stmt":{"value":{"type":"string_index","value":4},"set":7,"file":0,"col":3,"row":6}},{"type":"CallStmt","stmt":{"func":"internal.member_2","args":[{"type":"local","value":6},{"type":"local","value":7}],"result":8,"file":0,"col":3,"row":6}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":8},"b":{"type":"bool","value":false},"file":0,"col":3,"row":6}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":5}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":5}}]},{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":9}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":15,"row":10}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":4},"key":{"type":"string_index","value":5},"target":5,"file":0,"col":15,"row":10}},{"type":"ScanStmt","stmt":{"source":5,"key":6,"value":7,"block":{"stmts":[{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":6},"target":8,"file":0,"col":15,"row":10}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":7},"target":9,"file":0,"col":15,"row":10}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":1},"key":{"type":"string_index","value":5},"target":10,"file":0,"col":3,"row":10}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":10},"key":{"type":"local","value":9},"target":11,"file":0,"col":3,"row":10}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"DotStmt","stmt":{"source":{"type":"local","value":11},"key":{"type":"string_index","value":6},"target":14,"file":0,"col":3,"row":10}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":3,"row":10}}]}],"file":0,"col":3,"row":10}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":3,"row":10}}]}],"file":0,"col":3,"row":10}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":14},"b":{"type":"bool","value":false},"file":0,"col":3,"row":10}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":9}}]},"file":0,"col":15,"row":10}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":9}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":9}}]},{"stmts":[{"type":"IsUndefinedStmt","stmt":{"source":2,"file":0,"col":9,"row":3}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":false},"target":2,"file":0,"col":9,"row":3}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":9,"row":3}}]}],"path":["g0","authz","allow"]}]}} \ No newline at end of file diff --git a/cli/src/test/resources/ir_simple_dir/simple.rego b/cli/src/test/resources/ir_simple_dir/simple.rego new file mode 100644 index 00000000..3727f067 --- /dev/null +++ b/cli/src/test/resources/ir_simple_dir/simple.rego @@ -0,0 +1,11 @@ +package authz + +default allow := false + +allow if { + input.user.id in {"kurt", "alice"} +} + +allow if { + data.groups[input.user.groups[_]].privileged +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e481c68e..b43b402f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,7 @@ plugins { rootProject.name = "java-opa-sdk" +include("cli") include("opa-evaluator") include("opa-jackson") include("opa-gson") From 1c64f376863b70c60d94de854196d5fc6c618feb Mon Sep 17 00:00:00 2001 From: Kurt Roekle Date: Mon, 8 Jun 2026 10:06:46 -0500 Subject: [PATCH 2/7] adding cli for testing Signed-off-by: Kurt Roekle --- cli/README.md | 4 ++-- cli/build.gradle.kts | 1 - .../io/github/open_policy_agent/opa/cli/Eval.java | 14 +++++++++++--- .../open_policy_agent/opa/cli/TraceReporter.java | 3 +++ .../github/open_policy_agent/opa/cli/CliTest.java | 15 +++++++++++++++ 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/cli/README.md b/cli/README.md index 9262fd86..163011e8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -8,7 +8,7 @@ `regoj` is a small command-line driver for the Java OPA SDK. It loads a pre-compiled rego plan bundle, evaluates an entrypoint against an input -document, and (optionally) prints metrics, traces, and per-statletement / +document, and (optionally) prints metrics, traces, and per-statement / per-location profiling tables. ## Building & running @@ -66,7 +66,7 @@ example, `authz/allow`); it can also be provided via `-e`/`--entrypoint`. The test resources include a small unpacked plan bundle in `ir_simple_dir/`, plus a sample `input.json`. All examples are run from the -`cli/` module directory. +repository root. Evaluate against the directory bundle: diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index d644a34a..0a18a1af 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -35,7 +35,6 @@ java { } tasks.named("run") { - isIgnoreExitValue = true workingDir = rootProject.projectDir standardInput = System.`in` } diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java index a18d3624..df8bc3e3 100644 --- a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java @@ -147,6 +147,16 @@ public Integer call() { return 2; } + if (count < 1) { + System.err.println("--count must be >= 1"); + return 2; + } + + if (!stdinInput && input == null) { + System.err.println("Either -i/--input or -I/--stdin-input is required"); + return 2; + } + if (instrument) { showMetrics = true; } @@ -179,10 +189,8 @@ public Integer call() { try { if (stdinInput) { inputDoc = objectMapper.readValue(System.in, Object.class); - } else if (input != null) { - inputDoc = objectMapper.readValue(this.input.toFile(), Object.class); } else { - inputDoc = null; + inputDoc = objectMapper.readValue(this.input.toFile(), Object.class); } } catch (IOException e) { throw new IllegalArgumentException("Error reading input: " + e.getMessage(), e); diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/TraceReporter.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/TraceReporter.java index 0e8ac38c..b7bdbc3a 100644 --- a/cli/src/main/java/io/github/open_policy_agent/opa/cli/TraceReporter.java +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/TraceReporter.java @@ -9,6 +9,9 @@ public class TraceReporter { public void printTraceOutput(List allTracers, String[] fileNames) { + if (allTracers == null || allTracers.isEmpty()) { + return; + } final List events = allTracers.get(0).getEvents(); if (events.isEmpty()) { return; diff --git a/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java b/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java index c26962a6..85f7ecf8 100644 --- a/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java +++ b/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java @@ -330,4 +330,19 @@ void evalCommandMissingInputFileExitsNonZero() { run("eval", "-b", bundleTgz, "-i", "/tmp/does-not-exist-input.json", ENTRYPOINT); assertThat(exitCode).isNotZero(); } + + @Test + void evalCommandRejectsNonPositiveCount() { + final int exitCode = + run("eval", "-b", bundleTgz, "-i", INPUT_JSON, "--count", "0", ENTRYPOINT); + assertThat(exitCode).isEqualTo(2); + assertThat(errContent.toString(StandardCharsets.UTF_8)).contains("--count"); + } + + @Test + void evalCommandRejectsMissingInput() { + final int exitCode = run("eval", "-b", bundleTgz, ENTRYPOINT); + assertThat(exitCode).isEqualTo(2); + assertThat(errContent.toString(StandardCharsets.UTF_8)).contains("--input"); + } } From 6a2af26f2f1e8813c4af4d21f9b53774638cb44e Mon Sep 17 00:00:00 2001 From: Kurt Roekle Date: Mon, 8 Jun 2026 13:05:06 -0500 Subject: [PATCH 3/7] review fixes Signed-off-by: Kurt Roekle --- .../open_policy_agent/opa/cli/Eval.java | 21 +++++++++++++----- .../open_policy_agent/opa/cli/CliTest.java | 22 +++++++++++++------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java index df8bc3e3..dcf38df9 100644 --- a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java @@ -157,6 +157,11 @@ public Integer call() { return 2; } + if (bundleFilePaths == null || bundleFilePaths.isEmpty()) { + System.err.println("At least one -b/--bundle path is required"); + return 2; + } + if (instrument) { showMetrics = true; } @@ -193,7 +198,8 @@ public Integer call() { inputDoc = objectMapper.readValue(this.input.toFile(), Object.class); } } catch (IOException e) { - throw new IllegalArgumentException("Error reading input: " + e.getMessage(), e); + System.err.println("Error reading input: " + e.getMessage()); + return 1; } for (int i = 0; i < count; i++) { @@ -238,18 +244,20 @@ public Integer call() { engine = sharedEngine; } - final BufferedQueryTracer tracer = new BufferedQueryTracer(); - allTracers.add(tracer); - Engine.PreparedQuery.Builder pqBuilder = engine .prepareForEvaluation() .withEntrypoint(entrypoint) - .withTracer(tracer) .withMetrics(metrics) .withStatementProfiler(statementProfiler) .withPrintHook(PrintHook.of(System.err)); + if (explain) { + final BufferedQueryTracer tracer = new BufferedQueryTracer(); + allTracers.add(tracer); + pqBuilder = pqBuilder.withTracer(tracer); + } + if (showProfile) { final DurationProfiler profiler = new DurationProfiler(); allProfilers.add(profiler); @@ -277,7 +285,8 @@ public Integer call() { : objectMapper.writer(); System.out.println(writer.writeValueAsString(lastResults)); } catch (IOException e) { - throw new IllegalStateException("Error serializing results: " + e.getMessage(), e); + System.err.println("Error serializing results: " + e.getMessage()); + return 1; } } diff --git a/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java b/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java index 85f7ecf8..6a2a97c7 100644 --- a/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java +++ b/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java @@ -324,13 +324,6 @@ void evalCommandProfileSortByLocationOrdersAlphabetically() { assertThat(locations).isEqualTo(sorted); } - @Test - void evalCommandMissingInputFileExitsNonZero() { - final int exitCode = - run("eval", "-b", bundleTgz, "-i", "/tmp/does-not-exist-input.json", ENTRYPOINT); - assertThat(exitCode).isNotZero(); - } - @Test void evalCommandRejectsNonPositiveCount() { final int exitCode = @@ -345,4 +338,19 @@ void evalCommandRejectsMissingInput() { assertThat(exitCode).isEqualTo(2); assertThat(errContent.toString(StandardCharsets.UTF_8)).contains("--input"); } + + @Test + void evalCommandRejectsMissingBundle() { + final int exitCode = run("eval", "-i", INPUT_JSON, ENTRYPOINT); + assertThat(exitCode).isEqualTo(2); + assertThat(errContent.toString(StandardCharsets.UTF_8)).contains("--bundle"); + } + + @Test + void evalCommandUnreadableInputExitsOneWithMessage() { + final int exitCode = + run("eval", "-b", bundleTgz, "-i", "/tmp/does-not-exist-input.json", ENTRYPOINT); + assertThat(exitCode).isOne(); + assertThat(errContent.toString(StandardCharsets.UTF_8)).contains("Error reading input"); + } } From d4e62e04b1bf96b6e899a0c478852630820685f6 Mon Sep 17 00:00:00 2001 From: Kurt Roekle Date: Mon, 8 Jun 2026 15:23:37 -0500 Subject: [PATCH 4/7] more review fixes Signed-off-by: Kurt Roekle --- .../open_policy_agent/opa/cli/Eval.java | 35 ++++++++++++++----- .../open_policy_agent/opa/cli/Regoj.java | 1 + 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java index dcf38df9..380d4091 100644 --- a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java @@ -32,7 +32,7 @@ import picocli.CommandLine.Option; import picocli.CommandLine.Parameters; -@Command(name = "eval") +@Command(name = "eval", mixinStandardHelpOptions = true) public class Eval implements Callable { @Option( @@ -185,9 +185,14 @@ public Integer call() { .withStore(store) .withCapabilities(capabilities) .withEntrypoint(entrypoint); - loadBundles(store); - sharedEngine = eb.build(); - fileNames = extractFileNamesFromStore(store, entrypoint); + try { + loadBundles(store); + sharedEngine = eb.build(); + fileNames = extractFileNamesFromStore(store, entrypoint); + } catch (RuntimeException e) { + System.err.println("Error preparing engine: " + e.getMessage()); + return 1; + } } final Object inputDoc; @@ -230,12 +235,24 @@ public Integer call() { .withEntrypoint(entrypoint); metrics.timer("cli_load_bundles").start(); - loadBundles(store); - metrics.timer("cli_load_bundles").stop(); + try { + loadBundles(store); + } catch (RuntimeException e) { + System.err.println("Error loading bundles: " + e.getMessage()); + return 1; + } finally { + metrics.timer("cli_load_bundles").stop(); + } metrics.timer("cli_engine_build").start(); - engine = eb.build(); - metrics.timer("cli_engine_build").stop(); + try { + engine = eb.build(); + } catch (RuntimeException e) { + System.err.println("Error building engine: " + e.getMessage()); + return 1; + } finally { + metrics.timer("cli_engine_build").stop(); + } if (i == 0) { fileNames = extractFileNamesFromStore(store, entrypoint); @@ -252,7 +269,7 @@ public Integer call() { .withStatementProfiler(statementProfiler) .withPrintHook(PrintHook.of(System.err)); - if (explain) { + if (explain && i == 0) { final BufferedQueryTracer tracer = new BufferedQueryTracer(); allTracers.add(tracer); pqBuilder = pqBuilder.withTracer(tracer); diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Regoj.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Regoj.java index 92e8c98b..1e7cf257 100644 --- a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Regoj.java +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Regoj.java @@ -8,6 +8,7 @@ @Command( name = "regoj", + mixinStandardHelpOptions = true, subcommands = {Eval.class}) public class Regoj { public static void main(String[] args) { From b5dc7c4a2ddf2e52dab37d56a845e4ba894f9d8f Mon Sep 17 00:00:00 2001 From: Kurt Roekle Date: Mon, 8 Jun 2026 17:08:46 -0500 Subject: [PATCH 5/7] more review fixes Signed-off-by: Kurt Roekle --- cli/README.md | 4 +-- .../open_policy_agent/opa/cli/Eval.java | 22 +++++++++------- .../opa/cli/ProfileReporter.java | 25 +++++++++++++++---- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/cli/README.md b/cli/README.md index 163011e8..a1eb57dc 100644 --- a/cli/README.md +++ b/cli/README.md @@ -42,12 +42,12 @@ example, `authz/allow`); it can also be provided via `-e`/`--entrypoint`. | Flag | Description | | ---- | ----------- | -| `-b`, `--bundle ` | Bundle to load. Either a `.tar.gz`/`.tgz` produced by `opa build -t plan ...` or an unpacked directory containing `plan.json` (and optional `data.json`, `*.rego`). May be repeated. | +| `-b`, `--bundle ` | **Required** (except with `--capabilities-current`). Bundle to load. Either a `.tar.gz`/`.tgz` produced by `opa build -t plan ...` or an unpacked directory containing `plan.json` (and optional `data.json`, `*.rego`). May be repeated. | | `-e`, `--entrypoint ` | Entrypoint name. Overrides the positional `ENTRYPOINT` if both are given. | | `-i`, `--input ` | Path to the JSON input document. Required unless `-I` is used. | | `-I`, `--stdin-input` | Read the input document from stdin instead of `-i`. | | `-f`, `--format ` | Output format: `json` (default, single line) or `pretty` (indented). | -| `--capabilities-current` | Print the capabilities JSON for the currently registered builtins and exit. | +| `--capabilities-current` | Print the capabilities JSON for the currently registered builtins and exit. When set, `-b`, `-i`/`-I`, and `ENTRYPOINT` are not required. | | `--metrics` | Print a metrics table after evaluation (parse / build / prepare / eval timings). | | `--instrument` | Alias for `--metrics`. | | `--profile` | Print per-location and per-statement timing tables. Implies `--metrics`. | diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java index 380d4091..07ee9d56 100644 --- a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java @@ -78,7 +78,7 @@ public class Eval implements Callable { @Option( names = {"--instrument"}, - description = "enable query instrumentation metrics (implies --metrics)") + description = "alias for --metrics") private boolean instrument; @Option( @@ -223,9 +223,10 @@ public Integer call() { final Engine engine; if (countIncludesLoad) { - metrics.timer("cli_capabilities_register").start(); + final Metrics.Timer capabilitiesTimer = metrics.timer("cli_capabilities_register"); + capabilitiesTimer.start(); final Capabilities capabilities = BuiltinRegistry.generateCapabilities(); - metrics.timer("cli_capabilities_register").stop(); + capabilitiesTimer.stop(); final Store store = new InMem(); final Engine.Builder eb = @@ -234,24 +235,26 @@ public Integer call() { .withCapabilities(capabilities) .withEntrypoint(entrypoint); - metrics.timer("cli_load_bundles").start(); + final Metrics.Timer loadBundlesTimer = metrics.timer("cli_load_bundles"); + loadBundlesTimer.start(); try { loadBundles(store); } catch (RuntimeException e) { System.err.println("Error loading bundles: " + e.getMessage()); return 1; } finally { - metrics.timer("cli_load_bundles").stop(); + loadBundlesTimer.stop(); } - metrics.timer("cli_engine_build").start(); + final Metrics.Timer engineBuildTimer = metrics.timer("cli_engine_build"); + engineBuildTimer.start(); try { engine = eb.build(); } catch (RuntimeException e) { System.err.println("Error building engine: " + e.getMessage()); return 1; } finally { - metrics.timer("cli_engine_build").stop(); + engineBuildTimer.stop(); } if (i == 0) { @@ -287,9 +290,10 @@ public Integer call() { pqBuilder = pqBuilder.withProfiler(coverageProfiler); } - metrics.timer("cli_prepare_query").start(); + final Metrics.Timer prepareQueryTimer = metrics.timer("cli_prepare_query"); + prepareQueryTimer.start(); final Engine.PreparedQuery pq = pqBuilder.build(); - metrics.timer("cli_prepare_query").stop(); + prepareQueryTimer.stop(); lastResults = pq.eval(inputDoc); } diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/ProfileReporter.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/ProfileReporter.java index 7b3bc3fd..d93bf3e1 100644 --- a/cli/src/main/java/io/github/open_policy_agent/opa/cli/ProfileReporter.java +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/ProfileReporter.java @@ -81,6 +81,7 @@ private void printSingleProfileTable( private void printStatisticalProfileTable( List allProfilers, String[] fileNames, int limit, String sortKey) { final Map> durationsByLocation = new HashMap<>(); + final Map> countsByLocation = new HashMap<>(); for (final DurationProfiler profiler : allProfilers) { for (final Map.Entry entry : @@ -92,6 +93,9 @@ private void printStatisticalProfileTable( durationsByLocation .computeIfAbsent(location, k -> new ArrayList<>()) .add(evalTotal.getTotalDuration().toNanos()); + countsByLocation + .computeIfAbsent(location, k -> new ArrayList<>()) + .add(evalTotal.getCount()); } } @@ -109,7 +113,8 @@ private void printStatisticalProfileTable( final Map statsByLocation = new HashMap<>(); for (final Map.Entry> entry : durationsByLocation.entrySet()) { final String location = entry.getKey(); - final ProfileStatistics stats = calculateProfileStatistics(entry.getValue()); + final List counts = countsByLocation.get(location); + final ProfileStatistics stats = calculateProfileStatistics(entry.getValue(), counts); statsByLocation.put(location, stats); locationWidth = Math.max(locationWidth, location.length()); @@ -172,9 +177,9 @@ private void printStatisticalProfileTable( printStatisticalProfileBorder(locationWidth, minWidth, maxWidth, meanWidth, p90Width, p99Width); } - private ProfileStatistics calculateProfileStatistics(List values) { + private ProfileStatistics calculateProfileStatistics(List values, List counts) { if (values.isEmpty()) { - return new ProfileStatistics(0, 0, 0, 0, 0); + return new ProfileStatistics(0, 0, 0, 0, 0, 0); } final List sorted = values.stream().sorted().collect(Collectors.toList()); @@ -187,7 +192,12 @@ private ProfileStatistics calculateProfileStatistics(List values) { final long p90 = sorted.get(Math.max(0, Math.min(p90Index, sorted.size() - 1))); final long p99 = sorted.get(Math.max(0, Math.min(p99Index, sorted.size() - 1))); - return new ProfileStatistics(min, max, mean, p90, p99); + final long meanCount = + counts == null || counts.isEmpty() + ? 0 + : (long) counts.stream().mapToInt(v -> v).average().orElse(0); + + return new ProfileStatistics(min, max, mean, p90, p99, meanCount); } private Comparator profileRowComparator(String sortKey) { @@ -208,6 +218,9 @@ private Comparator> profileStatisticsCompar case "location": return Map.Entry.comparingByKey(); case "num_eval": + return Comparator.comparingLong( + (Map.Entry e) -> e.getValue().meanCount) + .reversed(); case "total_time": default: return Comparator.comparingLong( @@ -313,13 +326,15 @@ private static class ProfileStatistics { final long mean; final long p90; final long p99; + final long meanCount; - ProfileStatistics(long min, long max, long mean, long p90, long p99) { + ProfileStatistics(long min, long max, long mean, long p90, long p99, long meanCount) { this.min = min; this.max = max; this.mean = mean; this.p90 = p90; this.p99 = p99; + this.meanCount = meanCount; } } } From 3392e2faa7ef6fdbd84e9fa03c5abe31f7fcd5c9 Mon Sep 17 00:00:00 2001 From: Kurt Roekle Date: Tue, 9 Jun 2026 09:09:23 -0500 Subject: [PATCH 6/7] even more review fixes Signed-off-by: Kurt Roekle --- cli/README.md | 4 +-- .../open_policy_agent/opa/cli/Eval.java | 15 ++++++++++ .../open_policy_agent/opa/cli/CliTest.java | 29 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/cli/README.md b/cli/README.md index a1eb57dc..2d158878 100644 --- a/cli/README.md +++ b/cli/README.md @@ -46,13 +46,13 @@ example, `authz/allow`); it can also be provided via `-e`/`--entrypoint`. | `-e`, `--entrypoint ` | Entrypoint name. Overrides the positional `ENTRYPOINT` if both are given. | | `-i`, `--input ` | Path to the JSON input document. Required unless `-I` is used. | | `-I`, `--stdin-input` | Read the input document from stdin instead of `-i`. | -| `-f`, `--format ` | Output format: `json` (default, single line) or `pretty` (indented). | +| `-f`, `--format ` | Output format: `json` (default, single line) or `pretty` (indented). Any other value exits 2. | | `--capabilities-current` | Print the capabilities JSON for the currently registered builtins and exit. When set, `-b`, `-i`/`-I`, and `ENTRYPOINT` are not required. | | `--metrics` | Print a metrics table after evaluation (parse / build / prepare / eval timings). | | `--instrument` | Alias for `--metrics`. | | `--profile` | Print per-location and per-statement timing tables. Implies `--metrics`. | | `--profile-limit ` | Cap the profile table to the top `n` rows (default `10`). | -| `--profile-sort ` | Profiler sort key: `total_time` (default), `num_eval`, or `location`. | +| `--profile-sort ` | Profiler sort key: `total_time` (default), `num_eval`, or `location`. Any other value exits 2. | | `--coverage` | Print a per-file table of executed source lines after evaluation. | | `--explain` | Print a step-by-step trace of statements entered and exited during evaluation. With `--count > 1`, only the first run's trace is printed. | | `--fail` | Exit with a non-zero status if the result is undefined / empty. | diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java index 07ee9d56..22c7acb5 100644 --- a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java @@ -162,6 +162,21 @@ public Integer call() { return 2; } + if (!"json".equalsIgnoreCase(format) && !"pretty".equalsIgnoreCase(format)) { + System.err.println("Invalid --format value '" + format + "' (allowed: json, pretty)"); + return 2; + } + + if (!"total_time".equalsIgnoreCase(profileSort) + && !"num_eval".equalsIgnoreCase(profileSort) + && !"location".equalsIgnoreCase(profileSort)) { + System.err.println( + "Invalid --profile-sort value '" + + profileSort + + "' (allowed: total_time, num_eval, location)"); + return 2; + } + if (instrument) { showMetrics = true; } diff --git a/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java b/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java index 6a2a97c7..47fda317 100644 --- a/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java +++ b/cli/src/test/java/io/github/open_policy_agent/opa/cli/CliTest.java @@ -353,4 +353,33 @@ void evalCommandUnreadableInputExitsOneWithMessage() { assertThat(exitCode).isOne(); assertThat(errContent.toString(StandardCharsets.UTF_8)).contains("Error reading input"); } + + @Test + void evalCommandRejectsUnknownFormat() { + final int exitCode = + run("eval", "-b", bundleTgz, "-i", INPUT_JSON, "--format", "jsno", ENTRYPOINT); + assertThat(exitCode).isEqualTo(2); + assertThat(errContent.toString(StandardCharsets.UTF_8)) + .contains("Invalid --format") + .contains("jsno"); + } + + @Test + void evalCommandRejectsUnknownProfileSort() { + final int exitCode = + run( + "eval", + "-b", + bundleTgz, + "-i", + INPUT_JSON, + "--profile", + "--profile-sort", + "bogus", + ENTRYPOINT); + assertThat(exitCode).isEqualTo(2); + assertThat(errContent.toString(StandardCharsets.UTF_8)) + .contains("Invalid --profile-sort") + .contains("bogus"); + } } From b394305c2e4af66d9c7deafdadc2150b4b97336a Mon Sep 17 00:00:00 2001 From: Kurt Roekle Date: Tue, 9 Jun 2026 09:59:51 -0500 Subject: [PATCH 7/7] even more review fixes Signed-off-by: Kurt Roekle --- cli/README.md | 2 +- cli/build.gradle.kts | 1 + .../github/open_policy_agent/opa/cli/Eval.java | 18 +++++++++++++++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/cli/README.md b/cli/README.md index 2d158878..24d3bcb3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -24,7 +24,7 @@ To produce a runnable distribution: ```bash ./gradlew :cli:installDist -./cli/build/install/cli/bin/cli eval --help +./cli/build/install/regoj/bin/regoj eval --help ``` ## `eval` subcommand diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 0a18a1af..8d0c44b3 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { application { mainClass = "io.github.open_policy_agent.opa.cli.Regoj" + applicationName = "regoj" } tasks.test { diff --git a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java index 22c7acb5..41cf2202 100644 --- a/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java +++ b/cli/src/main/java/io/github/open_policy_agent/opa/cli/Eval.java @@ -307,10 +307,22 @@ public Integer call() { final Metrics.Timer prepareQueryTimer = metrics.timer("cli_prepare_query"); prepareQueryTimer.start(); - final Engine.PreparedQuery pq = pqBuilder.build(); - prepareQueryTimer.stop(); + final Engine.PreparedQuery pq; + try { + pq = pqBuilder.build(); + } catch (RuntimeException e) { + System.err.println("Error preparing query: " + e.getMessage()); + return 1; + } finally { + prepareQueryTimer.stop(); + } - lastResults = pq.eval(inputDoc); + try { + lastResults = pq.eval(inputDoc); + } catch (RuntimeException e) { + System.err.println("Error evaluating query: " + e.getMessage()); + return 1; + } } if (lastResults != null) {