diff --git a/.github/workflows/stackql-assert.yml b/.github/workflows/stackql-assert-test.yml similarity index 90% rename from .github/workflows/stackql-assert.yml rename to .github/workflows/stackql-assert-test.yml index 0473d19..a7615e3 100644 --- a/.github/workflows/stackql-assert.yml +++ b/.github/workflows/stackql-assert-test.yml @@ -1,4 +1,4 @@ -name: 'StackQL Assert' +name: 'stackql-assert' on: push: @@ -15,7 +15,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4.1.4 + + # + # Pull required providers + # + - name: pull required providers + uses: stackql/stackql-exec@v2.2.1 + with: + is_command: true + query: "REGISTRY PULL google; REGISTRY PULL github" # # Example `test_query` with `expected_rows` @@ -24,7 +33,6 @@ jobs: uses: ./ with: test_query: | - REGISTRY PULL google; SELECT name FROM google.compute.instances WHERE project = 'stackql-demo' AND zone = 'australia-southeast1-a' AND name = 'stackql-demo-001'; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index ad90499..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: 'Build and Test' -on: - push: - pull_request: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js 16 - uses: actions/setup-node@v3 - with: - node-version: 16.x - - run: npm ci - - run: npm test - \ No newline at end of file diff --git a/.github/workflows/workflow_scripts/github-example.iql b/.github/workflows/workflow_scripts/github-example.iql index 68dadb5..5dc84fb 100644 --- a/.github/workflows/workflow_scripts/github-example.iql +++ b/.github/workflows/workflow_scripts/github-example.iql @@ -1,2 +1 @@ -REGISTRY PULL github; SELECT name FROM github.repos.repos WHERE org = '{{ .org }}' AND name = '{{ .repo }}'; diff --git a/README.md b/README.md index 34b4661..46322cc 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,26 @@ +[![StackQL Assert](https://github.com/stackql/stackql-assert/actions/workflows/stackql-assert-test.yml/badge.svg)](https://github.com/stackql/stackql-assert/actions/workflows/stackql-assert-test.yml) + # stackql-assert The `stackql/stackql-assert` action is an composite action that runs a `stackql` query and checks if the result matches an expected result # Usage +> This action uses the [setup-stackql](https://github.com/marketplace/actions/stackql-studios-setup-stackql) and [stackql-exec](https://github.com/marketplace/actions/stackql-studios-stackql-exec) actions + ## Provider Authentication Authentication to StackQL providers is done via environment variables source from GitHub Actions Secrets. To learn more about authentication, see the setup instructions for your provider or providers at the [StackQL Provider Registry Docs](https://stackql.io/registry). ## Inputs -- `test_query` - stackql query to execute **(need to supply either `test_query` or `test_query_file_path`)** -- `test_query_file_path` - stackql query file to execute **(need to supply either `test_query` or `test_query_file_path`)** -- `data_file_path` - (optional) path to data file to pass to the stackql query preprocessor (`json` or `jsonnet`) -- `vars` - (optional) comma delimited list of variables to pass to the stackql query preprocessor (supported with `jsonnet` config blocks or `jsonnet` data files only), accepts `var1=val1,var2=val2`, can be used to source environment variables into stackql queries -- `expected_rows` - (optional) Expected number of rows in the result. -- `expected_results_str` - (optional) Expected result (`json`) from executing test query, support object string (overrides `expected_results_file_path`) -- `expected_results_file_path` - (optional) Results file (`json`) that stores expected result, json is support -- `auth_obj_path` - (optional) the path of json file that stores stackql AUTH string **(only required when using non-standard environment variable names)** -- `auth_str` - (optional) stackql AUTH string **(only required when using non-standard environment variable names)** +- **`test_query`** - stackql query to execute *(need to supply either `test_query` or `test_query_file_path`)* +- **`test_query_file_path`** - stackql query file to execute *(need to supply either `test_query` or `test_query_file_path`)* +- **`data_file_path`** - (optional) path to data file to pass to the stackql query preprocessor (`json` or `jsonnet`) +- **`vars`** - (optional) comma delimited list of variables to pass to the stackql query preprocessor (supported with `jsonnet` config blocks or `jsonnet` data files only), accepts `var1=val1,var2=val2`, can be used to source environment variables into stackql queries +- **`expected_rows`** - (optional) Expected number of rows in the result. +- **`expected_results_str`** - (optional) Expected result (`json`) from executing test query, support object string (overrides `expected_results_file_path`) +- **`expected_results_file_path`** - (optional) Results file (`json`) that stores expected result, json is support +- **`auth_obj_path`** - (optional) the path of json file that stores stackql AUTH string *(only required when using non-standard environment variable names)* +- **`auth_str`** - (optional) stackql AUTH string *(only required when using non-standard environment variable names)* **__NOTE:__ one of `expected_rows`, `expected_results_str` or `expected_results_file_path` is required** @@ -28,7 +32,7 @@ Authentication to StackQL providers is done via environment variables source fro ## Expected Result - Use `expected_results_str` or `expected_results_file_path` or `expected_rows` to pass the expected result to the action. The expected result (`expected_results_str` or `expected_results_file_path`) should be a valid `json` object. The action will compare the result with the expected result. If the result is not the same as the expected result, the action will fail the step. - Either `expected_results_str` or `expected_results_file_path` or `expected_rows` are required. If `expected_results_str` and `expected_results_file_path` are provided, `expected_results_str` will be used. -- Expected result example can be found in [example workflow](./.github/workflows/stackql-assert.yml) and [example .json file](./.github/workflows/workflow_scripts) +- Expected result example can be found in [example workflow](./.github/workflows/stackql-assert-test.yml) and [example .json file](./.github/workflows/workflow_scripts) ## Examples The following excerpts from a GitHub Actions workflow demonstrate how to use the `stackql/stackql-assert` action. @@ -40,7 +44,6 @@ The following excerpts from a GitHub Actions workflow demonstrate how to use the uses: ./ with: test_query: | - REGISTRY PULL google; SELECT name FROM google.compute.instances WHERE project = 'stackql-demo' AND zone = 'australia-southeast1-a' AND name = 'stackql-demo-001'; diff --git a/action.yml b/action.yml index f19148c..4d31e12 100644 --- a/action.yml +++ b/action.yml @@ -1,120 +1,69 @@ name: 'StackQL Studios - StackQL Assert' -description: 'run StackQL query to test and audit your infrastructure.' +description: 'Run StackQL query to test and audit your infrastructure.' author: 'Yuncheng Yang, StackQL Studios' inputs: test_query: - description: stackql query to execute (need to supply either test_query or test_query_file_path) + description: 'StackQL query to execute (supply either test_query or test_query_file_path)' required: false test_query_file_path: - description: stackql query file to execute (need to supply either test_query or test_query_file_path) + description: 'StackQL query file to execute (supply either test_query or test_query_file_path)' required: false data_file_path: - description: path to data file to pass to the stackql query preprocessor (json or jsonnet file) + description: 'Path to data file to pass to the StackQL query preprocessor (JSON or Jsonnet file)' required: false vars: - description: comma delimited list of variables to pass to the stackql query preprocessor (supported with jsonnet config blocks or jsonnet data files only), accepts 'var1=val1,var2=val2', can be used to source environment variables into stackql queries + description: 'Comma delimited list of variables to pass to the StackQL query preprocessor (supported with Jsonnet config blocks or Jsonnet data files only)' required: false - expected_rows: - description: expected number of rows from executing test query + expected_rows: + description: 'Expected number of rows from executing test query' required: false expected_results_str: - description: expected result (as a json string) from executing test query, overrides expected_results_file_path + description: 'Expected result (as a JSON string) from executing test query, overrides expected_results_file_path' required: false expected_results_file_path: - description: json file with the expected result + description: 'JSON file with the expected result' required: false auth_obj_path: - description: the path of json file that stores stackql AUTH string (only required when using non-standard environment variable names) + description: 'Path of JSON file that stores StackQL AUTH string (only required when using non-standard environment variable names)' required: false auth_str: - description: stackql AUTH string (only required when using non-standard environment variable names) + description: 'StackQL AUTH string (only required when using non-standard environment variable names)' required: false runs: using: "composite" steps: - - name: check if stackql is installed and set output - id: check-stackql - shell: bash - run: | - if command -v stackql &> /dev/null; then - echo "stackql_installed=true" >> $GITHUB_OUTPUT - else - echo "stackql_installed=false" >> $GITHUB_OUTPUT - fi - - - name: setup stackql - uses: stackql/setup-stackql@v1.2.0 - if: ${{steps.check-stackql.outputs.stackql_installed == 'false'}} + - name: Setup StackQL + uses: stackql/setup-stackql@v2.2.1 with: use_wrapper: true - - name: setup auth - if: (inputs.auth_obj_path != '') || (inputs.auth_str != '') - id: setup-auth - uses: actions/github-script@v6 - with: - script: | - const path = require('path'); - const utilsPath = path.join(process.env.GITHUB_ACTION_PATH, 'lib', 'utils.js') - const {setupAuth} = require(utilsPath) - setupAuth(core) - env: - AUTH_FILE_PATH: ${{ inputs.auth_obj_path }} - AUTH_STR: ${{inputs.auth_str}} - - - name: get stackql command - uses: actions/github-script@v6 - with: - script: | - const path = require('path'); - const utilsPath = path.join(process.env.GITHUB_ACTION_PATH, 'lib', 'utils.js') - const {getStackqlCommand} = require(utilsPath) - getStackqlCommand(core) - env: - QUERY_FILE_PATH: ${{ inputs.test_query_file_path }} - QUERY: ${{inputs.test_query}} - DATA_FILE_PATH: ${{inputs.data_file_path}} - VARS: ${{inputs.vars}} - OUTPUT: 'json' - - - name: dryrun stackql command - id: dryrun-query - shell: bash - run: | - ${{ env.STACKQL_DRYRUN_COMMAND }} - - - name: show rendered stackql query - uses: actions/github-script@v6 + - name: Execute StackQL Command + id: exec-query + uses: stackql/stackql-exec@v2.2.1 with: - script: | - const path = require('path'); - const utilsPath = path.join(process.env.GITHUB_ACTION_PATH, 'lib', 'utils.js') - const {showStackQLQuery} = require(utilsPath) - showStackQLQuery(core) - env: - DRYRUN_RESULT: ${{steps.dryrun-query.outputs.stdout}} + query: ${{ inputs.test_query }} + query_file_path: ${{ inputs.test_query_file_path }} + data_file_path: ${{ inputs.data_file_path }} + vars: ${{ inputs.vars }} + auth_obj_path: ${{ inputs.auth_obj_path }} + auth_str: ${{ inputs.auth_str }} + dry_run: false - - name: execute stackql command - id: exec-query - shell: bash - run: | - ${{ env.STACKQL_COMMAND }} - - - name: Check results - uses: actions/github-script@v6 + - name: Check Results + uses: actions/github-script@v7.0.1 with: script: | const path = require('path'); - const assertPath = path.join(process.env.GITHUB_ACTION_PATH, 'stackql-assert.js') + const assertPath = path.join(process.env.GITHUB_ACTION_PATH, 'lib', 'assert.js') const {assertResult} = require(assertPath) assertResult(core) env: - RESULT: ${{steps.exec-query.outputs.stdout}} + RESULT: ${{ steps.exec-query.outputs.stackql-query-results }} EXPECTED_RESULTS_STR: ${{ inputs.expected_results_str }} - EXPECTED_RESULTS_FILE_PATH: ${{inputs.expected_results_file_path}} - EXPECTED_ROWS: ${{inputs.expected_rows}} - + EXPECTED_RESULTS_FILE_PATH: ${{ inputs.expected_results_file_path }} + EXPECTED_ROWS: ${{ inputs.expected_rows }} + branding: icon: 'terminal' - color: 'green' \ No newline at end of file + color: 'green' diff --git a/lib/assert.js b/lib/assert.js index f498458..f82946a 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -1,136 +1,67 @@ -let core; const fs = require("fs"); -const parseResult = (resultStr, varName) => { - const regex = /\[(.*)\]/; // match the first occurrence of square brackets and capture everything in between - const matches = resultStr.match(regex); - if (matches) { - const jsonStr = matches[0]; // extract the captured string - try { - const jsonObj = JSON.parse(jsonStr); // parse the JSON string into an object - return jsonObj; - } catch (error) { - throw(`❌ Failed to parse ${varName} JSON - \nvalue: ${resultStr} - \nerror: ${error}`); - } +const parseResult = (resultStr, description = "result") => { + try { + return JSON.parse(resultStr); + } catch (error) { + throw new Error(`❌ Failed to parse ${description} JSON: + Value: ${resultStr} + Error: ${error.message}`); } - return null; // return null if no JSON object was found in the string }; const getExpectedResult = (expectedResultStr, expectedResultFilePath) => { - let expectedResult; if (expectedResultStr) { - expectedResult = parseResult(expectedResultStr, "expectedResultStr"); + return parseResult(expectedResultStr, "expected results string"); } else if (expectedResultFilePath) { - const fileContent = fs.readFileSync(expectedResultFilePath).toString(); - expectedResult = parseResult(fileContent, "expectedResultFilePath"); + const fileContent = fs.readFileSync(expectedResultFilePath, 'utf-8'); + return parseResult(fileContent, "expected results file"); } - - return expectedResult; + return null; }; -const checkResult = (expectedResult, expectedRows, actualResult) => { - let equality; - let message; - expectedRows = parseInt(expectedRows); - const successMessage = `✅ StackQL Assert Successful`; - const failureMessage = `❌ StackQL Assert Failed`; +const checkResult = (core, expected, actual, expectedRows) => { + const actualLength = actual.length; - // if only passed expectedRows, check expectedRows - // if only passed expected result, only check expected result - // if both passed, check both - if (expectedRows) { - equality = actualResult.length === expectedRows; - if(equality) { - message = `============ ${successMessage} (row count) ============ \n - Expected Number of Rows: ${expectedRows} \n - Actual Number of Rows: ${actualResult.length} \n - `; - } else { - message = `============ ${failureMessage} (row count) ============ \n - Expected Number of Rows: ${expectedRows} \n - Actual Number of Rows: ${actualResult.length} \n - Execution Result: ${JSON.stringify(actualResult)} \n - `; - } + if (expectedRows && actualLength !== parseInt(expectedRows)) { + core.error(`Expected rows: ${expectedRows}, got: ${actualLength}`); + return false; } - if (expectedResult) { - equality = JSON.stringify(expectedResult) === JSON.stringify(actualResult); - if(equality) { - message = `============ ${successMessage} (expected results) ============`; - } else { - message = `============ ${failureMessage} (expected results) ============ \n - Expected: ${JSON.stringify(expectedResult)}\n - Actual: ${JSON.stringify(actualResult)} - `; - } + if (expected && JSON.stringify(expected) !== JSON.stringify(actual)) { + core.error(`Expected results do not match actual results.\nExpected: ${JSON.stringify(expected)}\nActual: ${JSON.stringify(actual)}`); + return false; } - return { equality, message }; + return true; }; -function checkParameters(expectedResultStr, expectedResultFilePath, expectedRows) { - const params = [ - { name: "expectedResultStr", value: expectedResultStr }, - { name: "expectedResultFilePath", value: expectedResultFilePath }, - { name: "expectedRows", value: expectedRows }, - ]; +function assertResult(core) { + try { + const { RESULT, EXPECTED_RESULTS_STR, EXPECTED_RESULTS_FILE_PATH, EXPECTED_ROWS } = process.env; + core.info(`RESULT: ${RESULT}`); + core.info(`EXPECTED_RESULTS_STR: ${EXPECTED_RESULTS_STR}`); + core.info(`EXPECTED_RESULTS_FILE_PATH: ${EXPECTED_RESULTS_FILE_PATH}`); + core.info(`EXPECTED_ROWS: ${EXPECTED_ROWS}`); - const missingParams = params - .filter(param => !param.value || param.value === "undefined") - .map(param => param.name) - - if (missingParams.length === 3) { - const errorMessage = "❌ Cannot find expected result, file path or expected rows"; - throw errorMessage; - } -} + if (!RESULT) throw new Error("Result from StackQL execution is missing."); + + const actualResult = parseResult(RESULT); + + core.info("🔍 Checking results..."); + const expectedResult = getExpectedResult(EXPECTED_RESULTS_STR, EXPECTED_RESULTS_FILE_PATH); -const assertResult = (coreObj) =>{ - core = coreObj; - try { - let [ - execResultStr, - expectedResultStr, - expectedResultFilePath, - expectedRows, - ] = [ - process.env.RESULT, - process.env.EXPECTED_RESULTS_STR, - process.env.EXPECTED_RESULTS_FILE_PATH, - process.env.EXPECTED_ROWS, - ]; - - checkParameters(expectedResultStr, expectedResultFilePath, expectedRows) - - let expectedResult = getExpectedResult( - expectedResultStr, - expectedResultFilePath - ); - - const actualResult = parseResult(execResultStr); - - if (!actualResult) { - core.setFailed(`❌ No Output from executing query`); - } - - const {equality, message} = checkResult(expectedResult, expectedRows, actualResult); - if (equality) { - core.info(message); - } else { - core.setFailed(message); - } - } catch (e) { - core.setFailed(e); + const resultSuccessful = checkResult(core, expectedResult, actualResult, EXPECTED_ROWS); + + if (resultSuccessful) { + core.info("✅ StackQL Assert Successful"); + } else { + core.setFailed("❌ StackQL Assert Failed"); } + } catch (error) { + core.setFailed(`Assertion error: ${error.message}`); + } } -module.exports = { - assertResult, - parseResult, - checkResult, - getExpectedResult -}; +module.exports = { assertResult }; diff --git a/lib/tests/utils.test.js b/lib/tests/utils.test.js index 4284027..ab73fb7 100644 --- a/lib/tests/utils.test.js +++ b/lib/tests/utils.test.js @@ -1,123 +1,79 @@ -const { setupAuth, getStackqlCommand } = require("../utils"); +const { assertResult, parseResult, getExpectedResult } = require('./assert'); +const fs = require('fs'); -describe("util", () => { +jest.mock('fs'); + +describe('assert.js functions', () => { let core; - const expectedAuth = '{ "google": { "type": "service_account", "credentialsfilepath": "sa-key.json" }}'; beforeEach(() => { core = { - setFailed: jest.fn(), info: jest.fn(), - exportVariable: jest.fn(), error: jest.fn(), + setFailed: jest.fn() }; + process.env.RESULT = JSON.stringify([{ id: 1, value: 'test' }]); + process.env.EXPECTED_ROWS = '1'; }); - describe("setupAuth", () => { - let AUTH_ENV = { - AUTH_STR: expectedAuth, - AUTH_FILE_PATH: "./lib/tests/test-auth.json", - }; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...AUTH_ENV }; - }); + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); - afterEach(() => { - process.env = AUTH_ENV; + describe('parseResult', () => { + it('should correctly parse valid JSON', () => { + const input = JSON.stringify({ key: 'value' }); + expect(parseResult(input, 'valid JSON')).toEqual({ key: 'value' }); }); - it("should not throw an error when neither AUTH_STR or AUTH_FILE_PATH is set", () => { - process.env.AUTH_STR = undefined; - process.env.AUTH_FILE_PATH = undefined; - - setupAuth(core); - - expect(core.setFailed).not.toBeCalled(); + it('should throw an error on invalid JSON', () => { + const input = "invalid JSON"; + expect(() => parseResult(input, 'invalid JSON')).toThrow('Failed to parse invalid JSON JSON'); }); + }); - it("should set AUTH environment variable when AUTH_STR is set", () => { - process.env.AUTH_FILE_PATH = undefined; - - setupAuth(core); - - expect(core.exportVariable).toBeCalledWith("AUTH", expectedAuth); + describe('getExpectedResult', () => { + it('should return parsed result from string', () => { + const input = JSON.stringify({ key: 'value' }); + expect(getExpectedResult(input, null)).toEqual({ key: 'value' }); }); - it("should set AUTH environment variable when AUTH_FILE_PATH is set", () => { - process.env.AUTH_STR = undefined; - - setupAuth(core); - - expect(core.exportVariable).toBeCalledWith("AUTH", expectedAuth); + it('should return parsed result from file', () => { + const input = JSON.stringify({ key: 'value' }); + fs.readFileSync.mockReturnValue(input); + expect(getExpectedResult(null, 'path/to/file')).toEqual({ key: 'value' }); + expect(fs.readFileSync).toHaveBeenCalledWith('path/to/file', 'utf-8'); }); - it("should throw error when AUTH_FILE_PATH is set but file does not exist", () => { - process.env.AUTH_STR = undefined; - process.env.AUTH_FILE_PATH = "./failed-test-auth.json"; - - setupAuth(core); - - expect(core.setFailed).toBeCalledWith(`Cannot find auth file ${process.env.AUTH_FILE_PATH}`); + it('should throw an error if no input is provided', () => { + expect(() => getExpectedResult(null, null)).toThrow('No expected result provided.'); }); }); - describe("getStackqlCommand", () => { - const EXECUTE_ENV = { - QUERY: "test", - QUERY_FILE_PATH: "test-query.iql", - AUTH: "test-auth", - }; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...EXECUTE_ENV }; + describe('assertResult', () => { + it('should log success if the expected rows and results match', () => { + process.env.EXPECTED_RESULTS_STR = process.env.RESULT; + assertResult(core); + expect(core.info).toHaveBeenCalledWith("✅ StackQL Assert Successful"); }); - afterEach(() => { - process.env = EXECUTE_ENV; - jest.clearAllMocks(); + it('should fail if expected rows do not match', () => { + process.env.EXPECTED_ROWS = '2'; // Actual result will have only one item + assertResult(core); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining("Expected rows: 2, got: 1")); }); - it("should return error when there is neither query or query file path", () => { - process.env.QUERY = undefined; - process.env.QUERY_FILE_PATH = undefined; - - getStackqlCommand(core); - - expect(core.setFailed).toBeCalledWith("Either test_query or test_query_file_path need to be set"); + it('should fail if expected results do not match', () => { + process.env.EXPECTED_RESULTS_STR = JSON.stringify([{ id: 1, value: 'wrong' }]); + assertResult(core); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining("Expected results do not match actual results.")); }); - // it("should not return error when there is no AUTH", () => { - // process.env.AUTH = undefined; - - // getStackqlCommand(core); - - // expect(core.setFailed).not.toBeCalled(); - // expect(core.exportVariable).toBeCalledWith( - // "STACKQL_COMMAND", "stackql exec \"test\" --output='json'" - // ); - // }); - - it("should execute stackql with query file path", () => { - process.env.QUERY = undefined; - - getStackqlCommand(core); - - expect(core.exportVariable).toBeCalledWith( - "STACKQL_COMMAND", "stackql exec -i test-query.iql --auth='test-auth' --output='json'" - ); - }); - - it("should execute stackql with query", () => { - process.env.QUERY_FILE_PATH = undefined; - - getStackqlCommand(core); - - expect(core.exportVariable).toBeCalledWith( - "STACKQL_COMMAND", "stackql exec \"test\" --auth='test-auth' --output='json'" - ); + it('should handle errors during processing', () => { + process.env.RESULT = 'invalid json'; + assertResult(core); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining("Assertion error")); }); }); });