diff --git a/actions/README.md b/actions/README.md index 8341c4b9..592b1df6 100644 --- a/actions/README.md +++ b/actions/README.md @@ -25,7 +25,8 @@ For more details, consult the [GitHub Actions documentation](https://docs.github ## Examples -Here are examples of workflow snippets that demonstrate how to use these actions in the `trestle-bot` project. +Here are examples of workflow snippets that demonstrate how `trestle-bot` actions can be used for authoring. For the examples below, the OSCAL Component Definition authoring workflow is explored. + See each action README for more details about the inputs and outputs. ### Create a New Component Definition @@ -57,64 +58,120 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} ``` -## Transform Rules +## Transform and Autosync -```yaml -name: transform +Review human-friendly formats in code reviews and apply OSCAL JSON changes after PR merge. +The `autosync` action can be used with any supported model for authoring with Trestle. The +`transform` action is only supported for component definitions. +```yaml +name: Push to main on: push: - branches-ignore: - - main - paths: - - 'rules/**' + branches: + - main + paths: + - 'profiles/**' + - 'catalogs/**' + - 'component-definitions/**' + - 'md_comp/**' + - 'rules/**' + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true jobs: - transform-rules: - name: Transform rules content + transform-and-sync: + name: Automatically Sync Content runs-on: ubuntu-latest - permissions: - content: write steps: - - uses: actions/checkout@v4 - - uses: RedHatProductSecurity/trestle-bot/actions/rules-transform@main - + - name: Clone + uses: actions/checkout@v4 + + # Update JSON with any markdown edits. Markdown will also be regenerated to + # follow the generate-edit-assemble workflow. At this stage, we are on the + # edit step. So autosync runs assemble then generate. + - name: AutoSync + id: autosync + uses: RedHatProductSecurity/trestle-bot/actions/autosync@main + with: + markdown_path: "md_comp" + oscal_model: "compdef" + commit_message: "Autosync component definition content [skip ci]" + # Rule transformation is not idempotent, so you may only want to run this + # if your rules directly has changes to avoid UUID regeneration. + - uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + rules: + - 'rules/**' + # Transformation of rules will updates the OSCAL JSON. These changes will + # then be propagated to Markdown. Transformation and regeneration are run together + # to ensure the Markdown has the most up to date rule information. + - name: Transform + if: steps.changes.outputs.rules == 'true' + id: transform + uses: RedHatProductSecurity/trestle-bot/actions/rules-transform@main + with: + markdown_path: "md_comp" + commit_message: "Auto-transform rules [skip ci]" ``` -## Autosync Markdown and JSON +## Verify Changes -```yaml -name: autosync +Run actions in dry run mode on pull requests to ensure content +can be transformed and assembled on merge without errors. +```yaml +name: Validate PR with CI on: - pull-request: + pull_request: branches: - - 'main' - paths: - - 'component-definitions/**' - - 'markdown/components/**' + - main + paths: + - 'profiles/**' + - 'catalogs/**' + - 'component-definitions/**' + - 'md_comp/**' + - 'rules/**' jobs: - autosync: - name: Sync Markdown and JSON + transform-and-regenerate: + name: Rules Transform and Content Syncing runs-on: ubuntu-latest - permissions: - contents: write steps: - - uses: actions/checkout@v4 - - uses: RedHatProductSecurity/trestle-bot/actions/autosync@main + - name: Clone + uses: actions/checkout@v4 + - name: AutoSync + id: autosync + uses: RedHatProductSecurity/trestle-bot/actions/autosync@main with: - markdown_path: "markdown/components" + markdown_path: "md_comp" oscal_model: "compdef" - branch: ${{ github.head_ref }} + dry_run: true + - uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + rules: + - 'rules/**' + - name: Transform + if: steps.changes.outputs.rules == 'true' + id: transform + uses: RedHatProductSecurity/trestle-bot/actions/rules-transform@main + with: + markdown_path: "md_comp" + dry_run: true ``` ## Propagate changes from upstream sources -### Storing and syncing upstream content - > Note: The upstream repo must be a valid trestle workspace. +This example demonstrates how to use outputs by labeling pull requests. + ```yaml name: Sync Upstream @@ -131,55 +188,81 @@ jobs: pull-requests: write steps: - uses: actions/checkout@v4 - - name: Run trestlebot + - name: Sync content id: trestlebot uses: RedHatProductSecurity/trestle-bot/actions/sync-upstreams@main with: branch: "sync-upstream-${{ github.run_id }}" + # We set the target branch here to create a pull request + # for review target_branch: "main" github_token: ${{ secrets.GITHUB_TOKEN }} sources: | https://github.com/myorg/myprofiles@main + - uses: actions/labeler@v4 + if: steps.trestlebot.outputs.changes == 'true' + with: + pr-number: | + ${{ steps.trestlebot.outputs.pr_number }} + # Regenerate Markdown for an easier to control diff review and + # to understand change impact. + - name: Regenerate markdown (optionally) + if: steps.trestlebot.outputs.changes == 'true' + uses: RedHatProductSecurity/trestle-bot/actions/autosync@main + with: + markdown_path: "markdown/components" + oscal_model: "compdef" + branch: "sync-upstream-${{ github.run_id }}" + skip_assemble: true + github_token: ${{ secrets.GITHUB_TOKEN }} ``` -### Component Definition Regeneration -This example demonstrates how to use outputs and also includes labeling pull requests. +## Release + +Below is an example release workflow using the `version` on the `autosync` action set the +component definition version in the OSCAL metadata. ```yaml -name: Regenerate Markdown +name: Release on: - push: - branches: - - 'main' - paths: - paths: - - 'profiles/**' - - 'catalogs/**' + workflow_dispatch: + inputs: + version: + description: 'Release version' + required: true jobs: - regenerate-content: - name: Regenerate the component definition + release: runs-on: ubuntu-latest permissions: contents: write - pull-requests: write steps: - - uses: actions/checkout@v4 - - name: Run trestlebot - id: trestlebot - uses: RedHatProductSecurity/trestle-bot/actions/autosync@main - with: - markdown_path: "markdown/components" - oscal_model: "compdef" - branch: "autoupdate-${{ github.run_id }}" - target_branch: "main" - skip_assemble: true - github_token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/labeler@v4 - if: steps.trestlebot.outputs.changes == 'true' - with: - pr-number: | - ${{ steps.trestlebot.outputs.pr_number }} + - name: Clone + uses: actions/checkout@v4 + - name: Autosync + uses: RedHatProductSecurity/trestle-bot/actions/autosync@main + with: + markdown_path: "md_comp" + oscal_model: "compdef" + commit_message: "Update content for release [skip ci]" + version: ${{ github.event.inputs.version }} + - name: Create and push tags + env: + VERSION: ${{ github.event.inputs.version }} + run: | + git tag "${VERSION}" + git push origin "${VERSION}" + - name: Create Release + uses: actions/github-script@v7 + with: + script: | + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: '${{ github.event.inputs.version }}', + name: 'Release v${{ github.event.inputs.version }}', + generate_release_notes: true, + }) ``` diff --git a/actions/rules-transform/README.md b/actions/rules-transform/README.md index 56138bef..b5860a01 100644 --- a/actions/rules-transform/README.md +++ b/actions/rules-transform/README.md @@ -13,6 +13,9 @@ name: Example Workflow - name: Run trestlebot id: trestlebot uses: RedHatProductSecurity/trestle-bot/actions/rules-transform@main + with: + markdown_path: "markdown/components" + ``` With custom rules directory: @@ -24,6 +27,7 @@ With custom rules directory: id: trestlebot uses: RedHatProductSecurity/trestle-bot/actions/rules-transform@main with: + markdown_path: "markdown/components" rules_view_path: "custom-rules-dir/" ``` @@ -32,6 +36,7 @@ With custom rules directory: | Name | Description | Default | Required | | --- | --- | --- | --- | +| markdown_path | Path relative to the repository path to create markdown files. See action README.md for more information. | None | True | | rules_view_path | Path relative to the repository path where the Trestle rules view files are located. Defaults to `rules/`. | rules/ | False | | dry_run | Runs tasks without pushing changes to the repository. | false | False | | github_token | "GitHub token used to make authenticated API requests. Note: You should use a defined secret like "secrets.GITHUB_TOKEN" in your workflow file, do not hardcode the token." | None | False | @@ -63,7 +68,7 @@ With custom rules directory: ## Action Behavior -The purpose of this action is to sync the rules view data in YAML to OSCAL with `compliance-trestle` and commit changes back to the branch or submit a pull request (if desired). Below are the main use-cases/workflows available: +The purpose of this action is to sync the rules view data in YAML to OSCAL with `compliance-trestle` and generation corresponding Markdown and commit changes back to the branch or submit a pull request (if desired). Below are the main use-cases/workflows available: - The default behavior of this action is to run the rules transformation and commit the changes back to the branch the workflow ran from ( `github.ref_name` ). The branch can be changed by setting the field `branch`. If no changes exist or the changes do not exist with the file pattern set, no changes will be made and the action will exit successfully. diff --git a/actions/rules-transform/action.yml b/actions/rules-transform/action.yml index 4ce6f772..9d815963 100644 --- a/actions/rules-transform/action.yml +++ b/actions/rules-transform/action.yml @@ -1,8 +1,11 @@ name: "trestle-bot-rules-transform" author: "Red Hat Product Security" -description: "A rules transform action to convert trestle rules in YAML format to OSCAL" +description: "A rules transform action to convert trestle rules in YAML format to OSCAL and propagates changes to Markdown." inputs: + markdown_path: + description: Path relative to the repository path to create markdown files. See action README.md for more information. + required: true rules_view_path: description: Path relative to the repository path where the Trestle rules view files are located. Defaults to `rules/`. required: false diff --git a/actions/rules-transform/rules-transform-entrypoint.sh b/actions/rules-transform/rules-transform-entrypoint.sh index 3131129e..22c18b99 100644 --- a/actions/rules-transform/rules-transform-entrypoint.sh +++ b/actions/rules-transform/rules-transform-entrypoint.sh @@ -9,6 +9,7 @@ set_git_safe_directory # Initialize the command variable command="trestlebot-rules-transform \ + --markdown-path=\"${INPUT_MARKDOWN_PATH}\" \ --rules-view-path=\"${INPUT_RULES_VIEW_PATH}\" \ --commit-message=\"${INPUT_COMMIT_MESSAGE}\" \ --pull-request-title=\"${INPUT_PULL_REQUEST_TITLE}\" \ diff --git a/tests/data/yaml/test_complete_rule.yaml b/tests/data/yaml/test_complete_rule.yaml index 07557190..2ed8b1ce 100644 --- a/tests/data/yaml/test_complete_rule.yaml +++ b/tests/data/yaml/test_complete_rule.yaml @@ -16,7 +16,7 @@ x-trestle-rule-info: description: My check description profile: description: Simple NIST Profile - href: profiles/simplified_nist_profile/profile.json + href: trestle://profiles/simplified_nist_profile/profile.json include-controls: - id: ac-1 x-trestle-component-info: diff --git a/tests/data/yaml/test_complete_rule_multiple_controls.yaml b/tests/data/yaml/test_complete_rule_multiple_controls.yaml index 8660a716..3629c15e 100644 --- a/tests/data/yaml/test_complete_rule_multiple_controls.yaml +++ b/tests/data/yaml/test_complete_rule_multiple_controls.yaml @@ -15,7 +15,7 @@ x-trestle-rule-info: description: My check description profile: description: Simple NIST Profile - href: profiles/simplified_nist_profile/profile.json + href: trestle://profiles/simplified_nist_profile/profile.json include-controls: - id: ac-1 - id: ac-1_smt.a diff --git a/tests/data/yaml/test_complete_rule_no_params.yaml b/tests/data/yaml/test_complete_rule_no_params.yaml index 2df9d39f..ade44cea 100644 --- a/tests/data/yaml/test_complete_rule_no_params.yaml +++ b/tests/data/yaml/test_complete_rule_no_params.yaml @@ -3,7 +3,7 @@ x-trestle-rule-info: description: My rule description for example rule 2 profile: description: Simple NIST Profile - href: profiles/simplified_nist_profile/profile.json + href: trestle://profiles/simplified_nist_profile/profile.json include-controls: - id: ac-1 x-trestle-component-info: diff --git a/tests/e2e/test_e2e_compdef.py b/tests/e2e/test_e2e_compdef.py index c3630f27..31840409 100644 --- a/tests/e2e/test_e2e_compdef.py +++ b/tests/e2e/test_e2e_compdef.py @@ -36,6 +36,7 @@ "success/happy path", { "branch": "test", + "markdown-path": "md_comp", "rules-view-path": RULES_VIEW_DIR, "committer-name": "test", "committer-email": "test@email.com", @@ -46,6 +47,7 @@ { "branch": "test", "rules-view-path": RULES_VIEW_DIR, + "markdown-path": "md_comp", "committer-name": "test", "committer-email": "test", "skip-items": test_comp_name, @@ -82,6 +84,7 @@ def test_rules_transform_e2e( tmp_repo_path, test_comp_name, ComponentDefinition, FileContentType.JSON ) assert comp_path.exists() + assert tmp_repo_path.joinpath("md_comp").exists() assert f"input: {test_comp_name}.csv" in response_stdout branch = command_args["branch"] assert f"Changes pushed to {branch} successfully." in response_stdout diff --git a/tests/trestlebot/entrypoints/test_rules_transform.py b/tests/trestlebot/entrypoints/test_rules_transform.py new file mode 100644 index 00000000..5a1fe816 --- /dev/null +++ b/tests/trestlebot/entrypoints/test_rules_transform.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2024 Red Hat, Inc. + + +"""Test for Rules Transform CLI""" + +import pathlib +from typing import Dict, Tuple +from unittest.mock import patch + +import pytest +from git import Repo + +from tests.testutils import args_dict_to_list, setup_for_compdef, setup_rules_view +from trestlebot.entrypoints.rule_transform import main as cli_main + + +@pytest.fixture +def valid_args_dict() -> Dict[str, str]: + return { + "markdown-path": "markdown", + "branch": "test", + "committer-name": "test", + "committer-email": "test@email.com", + } + + +test_comp_name = "test_comp" +test_md = "md_cd" + + +def test_rules_transform( + tmp_repo: Tuple[str, Repo], valid_args_dict: Dict[str, str] +) -> None: + """Test rules transform on a happy path.""" + repo_path_str, repo = tmp_repo + + args_dict = valid_args_dict + args_dict["working-dir"] = repo_path_str + args_dict["markdown-path"] = test_md + + repo_path = pathlib.Path(repo_path_str) + + _ = setup_for_compdef(repo_path, test_comp_name, test_md) + setup_rules_view(repo_path, test_comp_name) + + assert not repo_path.joinpath(test_md).exists() + + with patch("sys.argv", ["trestlebot", "--dry-run", *args_dict_to_list(args_dict)]): + with pytest.raises(SystemExit, match="0"): + cli_main() + + assert repo_path.joinpath(test_md).exists() + commit = next(repo.iter_commits()) + assert len(commit.stats.files) == 9 diff --git a/tests/trestlebot/transformers/test_yaml_transformer.py b/tests/trestlebot/transformers/test_yaml_transformer.py index 8b20eb22..4bdee37b 100644 --- a/tests/trestlebot/transformers/test_yaml_transformer.py +++ b/tests/trestlebot/transformers/test_yaml_transformer.py @@ -48,7 +48,9 @@ def test_rule_transformer() -> None: } assert rule.parameter.default_value == "5%" assert rule.profile.description == "Simple NIST Profile" - assert rule.profile.href == "profiles/simplified_nist_profile/profile.json" + assert ( + rule.profile.href == "trestle://profiles/simplified_nist_profile/profile.json" + ) assert rule.check is not None assert rule.check.name == "my_check" assert rule.check.description == "My check description" diff --git a/trestlebot/entrypoints/autosync.py b/trestlebot/entrypoints/autosync.py index 0cc451c6..76ec25cc 100644 --- a/trestlebot/entrypoints/autosync.py +++ b/trestlebot/entrypoints/autosync.py @@ -14,6 +14,7 @@ import argparse import logging import sys +import traceback from typing import List from trestlebot.const import SUCCESS_EXIT_CODE @@ -159,7 +160,8 @@ def run(self, args: argparse.Namespace) -> None: super().run_base(args, pre_tasks) except Exception as e: - exit_code = handle_exception(e) + traceback_str = traceback.format_exc() + exit_code = handle_exception(e, traceback_str) sys.exit(exit_code) diff --git a/trestlebot/entrypoints/create_cd.py b/trestlebot/entrypoints/create_cd.py index e620ab29..47f19d22 100644 --- a/trestlebot/entrypoints/create_cd.py +++ b/trestlebot/entrypoints/create_cd.py @@ -15,6 +15,7 @@ import logging import pathlib import sys +import traceback from typing import List, Optional from trestlebot.const import RULE_PREFIX, RULES_VIEW_DIR, SUCCESS_EXIT_CODE @@ -143,7 +144,8 @@ def run(self, args: argparse.Namespace) -> None: super().run_base(args, pre_tasks) except Exception as e: - exit_code = handle_exception(e) + traceback_str = traceback.format_exc() + exit_code = handle_exception(e, traceback_str) sys.exit(exit_code) diff --git a/trestlebot/entrypoints/create_ssp.py b/trestlebot/entrypoints/create_ssp.py index c4c917b4..81b8f0d6 100644 --- a/trestlebot/entrypoints/create_ssp.py +++ b/trestlebot/entrypoints/create_ssp.py @@ -13,6 +13,7 @@ import logging import pathlib import sys +import traceback from typing import List from trestlebot.const import SUCCESS_EXIT_CODE @@ -120,7 +121,8 @@ def run(self, args: argparse.Namespace) -> None: super().run_base(args, pre_tasks) except Exception as e: - exit_code = handle_exception(e) + traceback_str = traceback.format_exc() + exit_code = handle_exception(e, traceback_str) sys.exit(exit_code) diff --git a/trestlebot/entrypoints/entrypoint_base.py b/trestlebot/entrypoints/entrypoint_base.py index e1af21ca..1930f124 100644 --- a/trestlebot/entrypoints/entrypoint_base.py +++ b/trestlebot/entrypoints/entrypoint_base.py @@ -278,10 +278,13 @@ def __init__(self, arg: str, msg: str): def handle_exception( - exception: Exception, msg: str = "Exception occurred during execution" + exception: Exception, + traceback_str: str, + msg: str = "Exception occurred during execution", ) -> int: """Log the exception and return the exit code""" logger.error(msg + f": {exception}") + logger.debug(traceback_str) if isinstance(exception, EntrypointInvalidArgException): return const.INVALID_ARGS_EXIT_CODE diff --git a/trestlebot/entrypoints/rule_transform.py b/trestlebot/entrypoints/rule_transform.py index d3a71339..fcf8ee7c 100644 --- a/trestlebot/entrypoints/rule_transform.py +++ b/trestlebot/entrypoints/rule_transform.py @@ -7,16 +7,19 @@ import argparse import logging import sys +import traceback from typing import List -from trestlebot.const import SUCCESS_EXIT_CODE +from trestlebot.const import RULES_VIEW_DIR, SUCCESS_EXIT_CODE from trestlebot.entrypoints.entrypoint_base import ( EntrypointBase, comma_sep_to_list, handle_exception, ) from trestlebot.entrypoints.log import set_log_level_from_args +from trestlebot.tasks.authored.compdef import AuthoredComponentDefinition from trestlebot.tasks.base_task import ModelFilter, TaskBase +from trestlebot.tasks.regenerate_task import RegenerateTask from trestlebot.tasks.rule_transform_task import RuleTransformTask from trestlebot.transformers.yaml_transformer import ToRulesYAMLTransformer @@ -36,10 +39,17 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: def setup_rules_transformation_arguments(self) -> None: """Setup arguments for the rule transformer entrypoint.""" self.parser.add_argument( - "--rules-view-path", + "--markdown-path", required=True, type=str, + help="Path to create markdown files in.", + ) + self.parser.add_argument( + "--rules-view-path", + required=False, + type=str, help="Path to top-level rules-view directory", + default=RULES_VIEW_DIR, ) self.parser.add_argument( "--skip-items", @@ -68,11 +78,18 @@ def run(self, args: argparse.Namespace) -> None: rule_transformer=transformer, model_filter=model_filter, ) - pre_tasks: List[TaskBase] = [rule_transform_task] + regenerate_task: RegenerateTask = RegenerateTask( + markdown_dir=args.markdown_path, + authored_object=AuthoredComponentDefinition(args.working_dir), + model_filter=model_filter, + ) + + pre_tasks: List[TaskBase] = [rule_transform_task, regenerate_task] super().run_base(args, pre_tasks) except Exception as e: - exit_code = handle_exception(e) + traceback_str = traceback.format_exc() + exit_code = handle_exception(e, traceback_str) sys.exit(exit_code) diff --git a/trestlebot/entrypoints/sync_upstreams.py b/trestlebot/entrypoints/sync_upstreams.py index ff77ea8c..7a92a224 100644 --- a/trestlebot/entrypoints/sync_upstreams.py +++ b/trestlebot/entrypoints/sync_upstreams.py @@ -13,6 +13,7 @@ import argparse import logging import sys +import traceback from typing import List from trestlebot.const import SUCCESS_EXIT_CODE @@ -100,7 +101,8 @@ def run(self, args: argparse.Namespace) -> None: super().run_base(args, pre_tasks) except Exception as e: - exit_code = handle_exception(e) + traceback_str = traceback.format_exc() + exit_code = handle_exception(e, traceback_str) sys.exit(exit_code)