Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ In the example below, all `_test.rego` files' location are valid and will be exe
| `report_untested_files` | Check & report Rego files without corresponding test files | No | `false` |
| `opa_version` | Version of the OPA CLI to use. | No | `1.4.2` |
| `opa_static` | Whether to use the static binary for OPA installation. use. | No | `false` |
| `v1_compatible_check` | Flag to run `opa check --v1-compatible` against all Rego files in `path`. Fails the action with a clear error if any files use Rego v0-only syntax. Set to `false` to disable. | No | `true` |
| `indicate_source_message` | Flag to comment the origins watermark (this repository) of the GitHub Action in the PR comment. | No | `true` |

### Outputs
Expand All @@ -105,12 +106,13 @@ In the example below, all `_test.rego` files' location are valid and will be exe
This GitHub Action automates the process of testing OPA (Open Policy Agent) Rego policies and generating coverage reports. Here's a breakdown of its operation:

1. Setup: The action begins by setting up OPA using the open-policy-agent/setup-opa@v2 action, ensuring the necessary tools are available.
2. Run OPA Tests: It executes `opa test` on all .rego files in the specified directory (default is the root directory). The test results are captured and stored as an output.
3. Run OPA Coverage Tests: Enabled by default but optional, the action performs coverage tests on each .rego file that has a corresponding \_test.rego file. This step identifies which parts of your policies are covered by tests.
4. Find Untested Files: Optionally if enabled, it can identify Rego files that don't have corresponding test files, helping you maintain comprehensive test coverage.
5. Parse and Format Results: A custom TypeScript script (index.ts) processes the raw test and coverage outputs. It parses the results into a structured format and generates a user-friendly summary.
6. Generate PR Comment: The formatted results are used to create or update a comment on the pull request.
7. Fail the Action if Tests Fail: If any tests fail, the action is marked as failed, which can be used to block PR merges or trigger other workflows.
2. Find Untested Files: Optionally if enabled, it can identify Rego files that don't have corresponding test files, helping you maintain comprehensive test coverage.
3. Rego v1 Compatibility Check (optional, default enabled): Runs `opa check --v1-compatible` against all Rego files in the path. If any files use Rego v0-only syntax, the action fails immediately with OPA's error output identifying the offending files. This check can be disabled by setting `v1_compatible_check: false`.
4. Run OPA Tests: It executes `opa test` on all .rego files in the specified directory (default is the root directory). The test results are captured and stored as an output.
5. Run OPA Coverage Tests: Enabled by default but optional, the action performs coverage tests on each .rego file that has a corresponding \_test.rego file. This step identifies which parts of your policies are covered by tests.
6. Parse and Format Results: A custom TypeScript script (index.ts) processes the raw test and coverage outputs. It parses the results into a structured format and generates a user-friendly summary.
7. Generate PR Comment: The formatted results are used to create or update a comment on the pull request.
8. Fail the Action if Tests Fail: If any tests fail, the action is marked as failed, which can be used to block PR merges or trigger other workflows.

![Masterpoint OPA Rego Test Action Diagram](https://lucid.app/publicSegments/view/60bf898e-2640-475f-b130-2a70d317a65d/image.png)

Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ inputs:
description: "Whether to use the static binary. Default is false."
required: false
default: "false"
v1_compatible_check:
description: Flag to run OPA v1 compatibility check (`opa check --v1-compatible`) on all Rego files in the path. Fails the action if any files are not Rego v1 compatible. Default of true.
required: false
default: "true"
Comment thread
glaracuente marked this conversation as resolved.
indicate_source_message:
description: Flag to comment the origins (this repository) of the GitHub Action in the PR comment. Default of true.
required: false
Expand Down Expand Up @@ -102,6 +106,7 @@ runs:
no_test_files: ${{ steps.find-no-test.outputs.no_test_files }}
pr_comment_title: ${{ inputs.pr_comment_title }}
run_coverage_report: ${{ inputs.run_coverage_report }}
v1_compatible_check: ${{ inputs.v1_compatible_check }}
indicate_source_message: ${{ inputs.indicate_source_message }}
path: ${{ inputs.path }}
test_file_postfix: ${{ inputs.test_file_postfix }}
Expand Down
148 changes: 104 additions & 44 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26288,60 +26288,87 @@ function main() {
const reportNoTestFiles = process.env.report_untested_files === "true";
const noTestFiles = process.env.no_test_files;
const runCoverageReport = process.env.run_coverage_report === "true";
const useV1Compatible = process.env.v1_compatible_check !== "false";
const path = process.env.path;
const test_file_postfix = process.env.test_file_postfix;
if (!path || !test_file_postfix) {
throw new Error("Both 'path' and 'test_file_postfix' environment variables must be set.");
}
let v1CheckFailed = false;
let v1CheckError = "";
let opaOutput = "";
let opaError = "";
let exitCode = 0;
let coverageOutput;
if (test_mode === "directory") {
({
output: opaOutput,
error: opaError,
exitCode: exitCode,
coverageOutput: coverageOutput,
} = yield (0, opaCommands_1.executeOpaTestByDirectory)(path, true));
if (useV1Compatible) {
console.log(`Running OPA v1 compatibility check on: ${path}`);
const { output: v1Output, error: v1Error, exitCode: v1ExitCode, } = yield (0, opaCommands_1.executeOpaV1CompatibilityCheck)(path);
if (v1ExitCode !== 0) {
v1CheckFailed = true;
v1CheckError = [v1Output, v1Error].filter(Boolean).join("\n");
console.log("OPA v1 compatibility check failed.");
}
else {
console.log("OPA v1 compatibility check passed.");
}
}
else {
({
output: opaOutput,
error: opaError,
exitCode: exitCode,
coverageOutput: coverageOutput,
} = yield (0, opaCommands_1.executeIndividualOpaTests)(path, test_file_postfix, true));
console.log("OPA v1 compatibility check skipped.");
}
let parsedResults = (0, testResultProcessing_1.processTestResults)(JSON.parse(opaOutput));
let parsedResults = [];
let coverageResults = [];
if (runCoverageReport) {
if (coverageOutput) {
coverageResults = (0, testResultProcessing_1.processCoverageReport)(JSON.parse(coverageOutput));
if (!v1CheckFailed) {
if (test_mode === "directory") {
({
output: opaOutput,
error: opaError,
exitCode: exitCode,
coverageOutput: coverageOutput,
} = yield (0, opaCommands_1.executeOpaTestByDirectory)(path, true, useV1Compatible));
}
else {
({
output: opaOutput,
error: opaError,
exitCode: exitCode,
coverageOutput: coverageOutput,
} = yield (0, opaCommands_1.executeIndividualOpaTests)(path, test_file_postfix, true, useV1Compatible));
}
parsedResults = (0, testResultProcessing_1.processTestResults)(JSON.parse(opaOutput));
if (runCoverageReport) {
if (coverageOutput) {
coverageResults = (0, testResultProcessing_1.processCoverageReport)(JSON.parse(coverageOutput));
}
}
// At the end of the table, if the reportNoTestFile flag is on, add all the files that didn't have an associated test with it.
if (noTestFiles && reportNoTestFiles) {
const noTestFileResults = noTestFiles
.split("\n")
.map((file) => ({
file: file.trim(),
status: "NO TESTS",
passed: 0,
total: 0,
details: [],
}));
parsedResults = [...parsedResults, ...noTestFileResults];
}
}
// At the end of the table, if the reportNoTestFile flag is on, add all the files that didn't have an associated test with it.
if (noTestFiles && reportNoTestFiles) {
const noTestFileResults = noTestFiles
.split("\n")
.map((file) => ({
file: file.trim(),
status: "NO TESTS",
passed: 0,
total: 0,
details: [],
}));
parsedResults = [...parsedResults, ...noTestFileResults];
}
let formattedOutput = (0, formatResults_1.formatResults)(parsedResults, coverageResults, runCoverageReport);
if (formattedOutput === "") {
formattedOutput = errorString;
}
if (v1CheckFailed) {
formattedOutput += `\n\n## ⛔️ Rego v1 Compatibility Check Failed\n\nOne or more Rego files are not v1 compatible.\n\n\`\`\`\n${v1CheckError}\n\`\`\``;
}
// This is the output that will be used in the GitHub Pull Request comment.
core.setOutput("parsed_results", formattedOutput);
const testsFailed = parsedResults.some((result) => result.status === "FAIL");
core.setOutput("tests_failed", testsFailed.toString());
if (testsFailed) {
if (v1CheckFailed) {
core.setFailed(`OPA v1 compatibility check failed. One or more Rego files are not v1 compatible.\n${v1CheckError}`);
}
else if (testsFailed) {
core.setFailed(`One or more OPA tests failed: ${opaError}`);
}
}
Expand Down Expand Up @@ -26401,13 +26428,46 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.executeOpaV1CompatibilityCheck = executeOpaV1CompatibilityCheck;
exports.executeOpaTestByDirectory = executeOpaTestByDirectory;
exports.executeIndividualOpaTests = executeIndividualOpaTests;
const exec = __importStar(__nccwpck_require__(1514));
const path_1 = __importDefault(__nccwpck_require__(1017));
const opaV0CompatibleFlag = "--v0-compatible"; // https://www.openpolicyagent.org/docs/latest/v0-compatibility/
const opaV1CompatibleFlag = "--v1-compatible";
/**
* Run `opa check --v1-compatible` against a directory to validate all Rego files
* are compatible with OPA v1 / Rego v1 syntax.
* @param path - The directory containing Rego files to validate.
* @returns An object containing stdout output, error output, and exit code.
*/
function executeOpaV1CompatibilityCheck(path) {
return __awaiter(this, void 0, void 0, function* () {
let opaOutput = "";
let opaError = "";
const exitCode = yield exec.exec("opa", ["check", path, opaV1CompatibleFlag], {
listeners: {
stdout: (data) => {
opaOutput += data.toString();
},
stderr: (data) => {
opaError += data.toString();
},
},
ignoreReturnCode: true,
});
return { output: opaOutput, error: opaError, exitCode };
});
}
/**
* Run OPA tests on all files in the specified directory.
* @param path - The directory containing Rego files to test.
* @param runCoverageReport - Whether to run coverage report (default: false).
* @param useV1Compatible - Whether to run tests using --v1-compatible flag (default: false).
* @returns An object containing the test results, error messages, and exit codes.
*/
function executeOpaTestByDirectory(path_2) {
return __awaiter(this, arguments, void 0, function* (path, runCoverageReport = false) {
return __awaiter(this, arguments, void 0, function* (path, runCoverageReport = false, useV1Compatible = false) {
let opaOutput = "";
let opaError = "";
let opaCoverageOutput = "";
Expand All @@ -26424,7 +26484,10 @@ function executeOpaTestByDirectory(path_2) {
},
ignoreReturnCode: true,
};
exitCode = yield exec.exec("opa", ["test", path, "--format=json", opaV0CompatibleFlag], options);
const compatFlag = useV1Compatible
? opaV1CompatibleFlag
: opaV0CompatibleFlag;
exitCode = yield exec.exec("opa", ["test", path, "--format=json", compatFlag], options);
if (runCoverageReport) {
const coverageOptions = {
listeners: {
Expand All @@ -26437,7 +26500,7 @@ function executeOpaTestByDirectory(path_2) {
},
ignoreReturnCode: true,
};
coverageExitCode = yield exec.exec("opa", ["test", path, "--format=json", "--coverage", opaV0CompatibleFlag], coverageOptions);
coverageExitCode = yield exec.exec("opa", ["test", path, "--format=json", "--coverage", compatFlag], coverageOptions);
}
else {
console.log("Coverage reporting skipped due to runCoverageReport flag set to false");
Expand All @@ -26454,10 +26517,11 @@ function executeOpaTestByDirectory(path_2) {
* @param basePath - The base path to search for test files.
* @param testFilePostfix - The postfix of the test files to look for (e.g., "_test").
* @param runCoverageReport - Whether to run coverage report (default: false).
* @param useV1Compatible - Whether to run tests using --v1-compatible flag (default: false).
* @returns An object containing the test results, error messages, and exit codes.
*/
function executeIndividualOpaTests(basePath_1, testFilePostfix_1) {
return __awaiter(this, arguments, void 0, function* (basePath, testFilePostfix, runCoverageReport = false) {
return __awaiter(this, arguments, void 0, function* (basePath, testFilePostfix, runCoverageReport = false, useV1Compatible = false) {
const allTestResults = [];
let opaError = "";
let exitCode = 0;
Expand All @@ -26476,6 +26540,9 @@ function executeIndividualOpaTests(basePath_1, testFilePostfix_1) {
opaError += findStderr + "\n";
exitCode = 1;
}
const compatFlag = useV1Compatible
? opaV1CompatibleFlag
: opaV0CompatibleFlag;
const testFiles = findStdout.trim().split("\n").filter(Boolean);
for (const testFile of testFiles) {
const base = path_1.default.basename(testFile, `${testFilePostfix}.rego`);
Expand Down Expand Up @@ -26505,7 +26572,7 @@ function executeIndividualOpaTests(basePath_1, testFilePostfix_1) {
// -------- Running OPA test --------
let testOutput = "";
let testErrMsg = "";
const testExitCode = yield exec.exec("opa", ["test", testFile, implFile, "--format=json", opaV0CompatibleFlag], {
const testExitCode = yield exec.exec("opa", ["test", testFile, implFile, "--format=json", compatFlag], {
listeners: {
stdout: (b) => (testOutput += b.toString()),
stderr: (b) => (testErrMsg += b.toString()),
Expand All @@ -26529,14 +26596,7 @@ function executeIndividualOpaTests(basePath_1, testFilePostfix_1) {
if (runCoverageReport) {
let covOut = "";
let covErr = "";
const covExit = yield exec.exec("opa", [
"test",
testFile,
implFile,
"--coverage",
"--format=json",
opaV0CompatibleFlag,
], {
const covExit = yield exec.exec("opa", ["test", testFile, implFile, "--coverage", "--format=json", compatFlag], {
listeners: {
stdout: (b) => (covOut += b.toString()),
stderr: (b) => (covErr += b.toString()),
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion examples/cancel-in-progress-runs.rego
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package spacelift

import rego.v1

# The push policy can be used to have the new run pre-empt any runs that are
# currently in progress. The input document includes the in_progress key, which
# contains an array of runs that are currently either still queued or are awaiting
# human confirmation. You can use it in conjunction with the cancel rule like this:
cancel[run.id] {
cancel contains run.id if {
run := input.in_progress[_]
run.type == "PROPOSED"
run.state == "QUEUED"
Expand Down
4 changes: 2 additions & 2 deletions examples/do-not-delete-stateful-resources.rego
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package spacelift

import future.keywords.in
import rego.v1

# This policy is a plan policy, it will validate the resources during the plan phase.
# More details at: https://docs.spacelift.io/concepts/policy/terraform-plan-policy
# The "deny" rule fires when a specified resource is being deleted.
# The result is a formatted message with the address of the offending resource.

deny[sprintf(message, [resource.address])] {
deny contains sprintf(message, [resource.address]) if {
# Define the error message format
message := "do not delete %s"

Expand Down
4 changes: 3 additions & 1 deletion examples/drift-detection.rego
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package spacelift

slack[{"channel_id": "C000000"}] {
import rego.v1

slack contains {"channel_id": "C000000"} if {
# Checking if drift detection is present in the run update
input.run_updated.run.drift_detection

Expand Down
Loading
Loading