├── .adr-dir ├── .grype.yaml ├── src ├── test │ ├── tasks │ │ ├── my-other-env │ │ ├── more-tasks │ │ │ ├── bar.yaml │ │ │ ├── baz.yaml │ │ │ ├── pattern.yaml │ │ │ └── foo.yaml │ │ ├── my-env │ │ ├── redefined-include.yaml │ │ ├── tasks-no-default.yaml │ │ ├── even-more-tasks-to-import.yaml │ │ ├── more-tasks-to-import.yaml │ │ ├── remote-import-tasks.yaml │ │ ├── loop-task.yaml │ │ ├── inputs │ │ │ ├── tasks.yaml │ │ │ └── tasks-with-inputs.yaml │ │ ├── conditionals │ │ │ └── tasks.yaml │ │ └── tasks.yaml │ ├── e2e │ │ ├── main_test.go │ │ └── runner_inputs_test.go │ └── common.go ├── cmd │ ├── version.go │ ├── internal.go │ ├── viper.go │ ├── login.go │ ├── root.go │ └── run.go ├── pkg │ ├── variables │ │ ├── common.go │ │ ├── types.go │ │ ├── variables.go │ │ └── variables_test.go │ ├── utils │ │ ├── tempate_test.go │ │ ├── template.go │ │ └── utils.go │ └── runner │ │ ├── runner.go │ │ ├── actions.go │ │ └── actions_test.go ├── config │ ├── config.go │ └── lang │ │ └── english.go ├── message │ ├── slog.go │ ├── message.go │ ├── logging.go │ ├── spinner.go │ └── logging_test.go └── types │ ├── tasks.go │ └── actions.go ├── CODEOWNERS ├── .github ├── codeql.yaml ├── actions │ ├── golang │ │ └── action.yaml │ ├── install-tools │ │ └── action.yaml │ ├── save-logs │ │ └── action.yaml │ └── zarf │ │ └── action.yaml ├── workflows │ ├── dependency-review.yaml │ ├── test-schema.yaml │ ├── test-unit-pr.yaml │ ├── scan-lint.yaml │ ├── commitlint.yaml │ ├── test-e2e-pr.yaml │ ├── ci-doc-shim.yaml │ ├── scan-codeql.yaml │ ├── scorecard.yaml │ └── release.yaml ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── tech_debt.md │ └── feature_request.md ├── main.go ├── hack ├── test-generate-schema.sh └── generate-schema.sh ├── .gitignore ├── LICENSING.md ├── .pre-commit-config.yaml ├── renovate.json ├── CONTRIBUTING.md ├── docs └── adr │ ├── 0001-runner-migration.md │ └── 0002-redesign-of-task-variables-and-or-inputs.md ├── .golangci.yaml ├── Makefile ├── go.mod ├── .goreleaser.yaml ├── CODE_OF_CONDUCT.md └── tasks.schema.json /.adr-dir: -------------------------------------------------------------------------------- 1 | docs/adr 2 | -------------------------------------------------------------------------------- /.grype.yaml: -------------------------------------------------------------------------------- 1 | ignore: 2 | -------------------------------------------------------------------------------- /src/test/tasks/my-other-env: -------------------------------------------------------------------------------- 1 | PORT=8080 2 | -------------------------------------------------------------------------------- /src/test/tasks/more-tasks/bar.yaml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: bar 3 | actions: 4 | - cmd: "echo bar" 5 | -------------------------------------------------------------------------------- /src/test/tasks/more-tasks/baz.yaml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: baz 3 | actions: 4 | - cmd: "echo baz" 5 | -------------------------------------------------------------------------------- /src/test/tasks/my-env: -------------------------------------------------------------------------------- 1 | SECRET_KEY=not-a-secret 2 | PORT=3000 3 | SPECIAL=$env/**/*var with#special%chars! 4 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @defenseunicorns/uds-cli 2 | 3 | # Additional privileged files 4 | /CODEOWNERS @jeff-mccoy @daveworth 5 | /LICENS* @jeff-mccoy @austenbryan 6 | -------------------------------------------------------------------------------- /src/test/tasks/redefined-include.yaml: -------------------------------------------------------------------------------- 1 | includes: 2 | - foo: "./tasks.yaml" 3 | 4 | tasks: 5 | - name: default 6 | actions: 7 | - task: foo:foobar 8 | -------------------------------------------------------------------------------- /src/test/tasks/tasks-no-default.yaml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: non-default 3 | description: non-default-task 4 | actions: 5 | - cmd: echo "Hello from non-default task" 6 | -------------------------------------------------------------------------------- /src/test/tasks/more-tasks/pattern.yaml: -------------------------------------------------------------------------------- 1 | variables: 2 | - name: HELLO 3 | pattern: ^HELLO$ 4 | 5 | tasks: 6 | - name: default 7 | actions: 8 | - cmd: echo ${HELLO} 9 | -------------------------------------------------------------------------------- /src/test/tasks/even-more-tasks-to-import.yaml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: set-var 3 | actions: 4 | - cmd: | 5 | echo "defenseunicorns" 6 | setVariables: 7 | - name: PRETTY_OK_COMPANY 8 | -------------------------------------------------------------------------------- /.github/codeql.yaml: -------------------------------------------------------------------------------- 1 | paths-ignore: 2 | - src/config/lang/lang.go 3 | - src/config/config.go 4 | - docs-website/** 5 | - build/** 6 | 7 | query-filters: 8 | - exclude: 9 | id: go/path-injection 10 | -------------------------------------------------------------------------------- /src/test/tasks/more-tasks-to-import.yaml: -------------------------------------------------------------------------------- 1 | includes: 2 | - even-more: even-more-tasks-to-import.yaml 3 | 4 | tasks: 5 | # single task that is imported with name collision 6 | - name: set-var 7 | actions: 8 | - task: even-more:set-var 9 | -------------------------------------------------------------------------------- /.github/actions/golang/action.yaml: -------------------------------------------------------------------------------- 1 | name: setup-go 2 | description: "Setup Go binary and caching" 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 8 | with: 9 | go-version: 1.25.x 10 | -------------------------------------------------------------------------------- /.github/actions/install-tools/action.yaml: -------------------------------------------------------------------------------- 1 | name: install-tools 2 | description: "Install pipeline tools" 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | # used by goreleaser to create SBOMs 8 | - uses: anchore/sbom-action/download-syft@da167eac915b4e86f08b264dbdbc867b61be6f0c # v0.20.5 9 | -------------------------------------------------------------------------------- /src/test/tasks/more-tasks/foo.yaml: -------------------------------------------------------------------------------- 1 | includes: 2 | - bar: ./bar.yaml 3 | 4 | variables: 5 | - name: FOO_VAR 6 | 7 | tasks: 8 | - name: foobar 9 | actions: 10 | - cmd: "echo foo" 11 | - task: bar:bar 12 | - name: fooybar 13 | actions: 14 | - cmd: echo "${FOO_VAR}" 15 | -------------------------------------------------------------------------------- /.github/actions/save-logs/action.yaml: -------------------------------------------------------------------------------- 1 | name: save-logs 2 | description: "Save debug logs" 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 8 | with: 9 | name: debug-log 10 | path: /tmp/maru-*.log 11 | -------------------------------------------------------------------------------- /.github/actions/zarf/action.yaml: -------------------------------------------------------------------------------- 1 | name: install-zarf 2 | description: "installs Zarf binary" 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - uses: defenseunicorns/setup-zarf@main 8 | with: 9 | # renovate: datasource=github-tags depName=zarf-dev/zarf 10 | version: v0.61.2 11 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package main is the entrypoint for the maru binary. 5 | package main 6 | 7 | import ( 8 | "github.com/defenseunicorns/maru-runner/src/cmd" 9 | ) 10 | 11 | func main() { 12 | cmd.Execute() 13 | } 14 | -------------------------------------------------------------------------------- /hack/test-generate-schema.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | check_git_status() { 4 | if [ -z "$(git status -s "$1")" ]; then 5 | echo "Success!" 6 | else 7 | echo "Schema changes found, please regenerate $1" 8 | git status "$1" 9 | exit 1 10 | fi 11 | } 12 | 13 | check_git_status tasks.schema.json 14 | 15 | exit 0 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !.env.example 2 | .DS_Store 3 | .env 4 | .env.* 5 | .idea/ 6 | .image-cache/ 7 | .release-downloads/ 8 | .scratch/ 9 | .vscode/ 10 | .zarf* 11 | *.bak 12 | *.key 13 | *.run.zstd 14 | *.tar 15 | *.tar.gz 16 | *.tgz 17 | *.vbox 18 | *.zst 19 | *.zim 20 | build 21 | data/ 22 | dist/ 23 | test-results/ 24 | zarf-pki 25 | zarf-sbom/ 26 | *.part* 27 | test-*.txt 28 | __debug_bin 29 | sboms 30 | bundle-sboms 31 | node_modules 32 | checksums.txt 33 | *.tape 34 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yaml: -------------------------------------------------------------------------------- 1 | name: Dependency Review 2 | on: pull_request 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | validate: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 13 | 14 | - name: Dependency Review 15 | uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3 16 | -------------------------------------------------------------------------------- /src/test/tasks/remote-import-tasks.yaml: -------------------------------------------------------------------------------- 1 | includes: 2 | - remote-more: https://raw.githubusercontent.com/defenseunicorns/maru-runner/${GIT_REVISION}/src/test/tasks/even-more-tasks-to-import.yaml 3 | - baz: ./more-tasks/baz.yaml 4 | 5 | tasks: 6 | - name: echo-var 7 | actions: 8 | - task: remote-more:set-var 9 | - cmd: | 10 | echo "${PRETTY_OK_COMPANY} is a pretty ok company" 11 | 12 | - name: local-baz 13 | actions: 14 | - task: baz:baz 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | ... 4 | 5 | ## Related Issue 6 | 7 | Fixes # 8 | 9 | Relates to # 10 | 11 | ## Type of change 12 | 13 | - [ ] Bug fix (non-breaking change which fixes an issue) 14 | - [ ] New feature (non-breaking change which adds functionality) 15 | - [ ] Other (security config, docs update, etc) 16 | 17 | ## Checklist before merging 18 | 19 | - [ ] Test, docs, adr added or updated as needed 20 | - [ ] [Contributor Guide Steps](https://github.com/defenseunicorns/maru-runner/blob/main/CONTRIBUTING.md) followed 21 | -------------------------------------------------------------------------------- /.github/workflows/test-schema.yaml: -------------------------------------------------------------------------------- 1 | name: Validate Schema 2 | on: 3 | pull_request: 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | validate: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 14 | 15 | - name: Setup golang 16 | uses: ./.github/actions/golang 17 | 18 | - name: Docs and schemas 19 | run: make test-schema 20 | 21 | - name: Save logs 22 | if: always() 23 | uses: ./.github/actions/save-logs 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'possible-bug 🐛' 6 | assignees: '' 7 | --- 8 | 9 | ### Environment 10 | 11 | Device and OS: 12 | App version: 13 | Kubernetes distro being used: 14 | Other: 15 | 16 | ### Steps to reproduce 17 | 18 | 1. 19 | 20 | ### Expected result 21 | 22 | ### Actual Result 23 | 24 | ### Visual Proof (screenshots, videos, text, etc) 25 | 26 | ### Severity/Priority 27 | 28 | ### Additional Context 29 | 30 | Add any other context or screenshots about the technical debt here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/tech_debt.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tech debt 3 | about: Record something that should be investigated or refactored in the future. 4 | title: '' 5 | labels: 'tech-debt 💳' 6 | assignees: '' 7 | --- 8 | 9 | ### Describe what should be investigated or refactored 10 | 11 | A clear and concise description of what should be changed/researched. Ex. This piece of the code is not DRY enough [...] 12 | 13 | ### Links to any relevant code 14 | 15 | (optional) i.e. - 16 | 17 | ### Additional context 18 | 19 | Add any other context or screenshots about the technical debt here. 20 | -------------------------------------------------------------------------------- /src/test/tasks/loop-task.yaml: -------------------------------------------------------------------------------- 1 | # Since every task and includes gets processed when running a task, and because this task file has an includes entry that 2 | # points back to the original task file, any task in this file will fail due to this infinite loop. 3 | 4 | includes: 5 | - original: "./tasks.yaml" 6 | 7 | variables: 8 | - name: LOOP_COUNT 9 | default: "10" 10 | 11 | tasks: 12 | - name: loop 13 | actions: 14 | - cmd: echo $((LOOP_COUNT - 1)) 15 | setVariables: 16 | - name: LOOP_COUNT 17 | - task: original:include-loop 18 | if: ${{ ne .variables.LOOP_COUNT "0" }} 19 | 20 | - name: direct-loop 21 | actions: 22 | - task: direct-loop 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement ✨' 6 | assignees: '' 7 | --- 8 | 9 | ### Is your feature request related to a problem? Please describe 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ### Describe the solution you'd like 14 | 15 | - **Given** a state 16 | - **When** an action is taken 17 | - **Then** something happens 18 | 19 | ### Describe alternatives you've considered 20 | 21 | (optional) A clear and concise description of any alternative solutions or features you've considered. 22 | 23 | ### Additional context 24 | 25 | Add any other context or screenshots about the feature request here. 26 | -------------------------------------------------------------------------------- /.github/workflows/test-unit-pr.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: 3 | pull_request: 4 | paths-ignore: 5 | - "**.md" 6 | - "**.jpg" 7 | - "**.png" 8 | - "**.gif" 9 | - "**.svg" 10 | - "adr/**" 11 | - "docs/**" 12 | - "CODEOWNERS" 13 | - "goreleaser.yml" 14 | 15 | # Abort prior jobs in the same workflow / PR 16 | concurrency: 17 | group: unit-runner-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | test: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 26 | 27 | - name: Setup golang 28 | uses: ./.github/actions/golang 29 | 30 | - name: Run unit tests 31 | run: | 32 | make test-unit 33 | -------------------------------------------------------------------------------- /hack/generate-schema.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Create the json schema for tasks.yaml 4 | go run main.go internal config-tasks-schema > tasks.schema.json 5 | 6 | # Adds pattern properties to all definitions to allow for yaml extensions 7 | jq ' 8 | def addPatternProperties: 9 | . + 10 | if has("properties") then 11 | {"patternProperties": {"^x-": {}}} 12 | else 13 | {} 14 | end; 15 | 16 | walk(if type == "object" then addPatternProperties else . end) 17 | ' tasks.schema.json > temp_tasks.schema.json 18 | 19 | mv temp_tasks.schema.json tasks.schema.json 20 | 21 | awk '{gsub(/\[github\.com\/defenseunicorns\/maru-runner\/src\/pkg\/variables\.ExtraVariableInfo\]/, ""); print}' tasks.schema.json > temp_tasks.schema.json 22 | 23 | mv temp_tasks.schema.json tasks.schema.json 24 | -------------------------------------------------------------------------------- /src/cmd/version.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package cmd contains the CLI commands for maru. 5 | package cmd 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/defenseunicorns/maru-runner/src/config" 11 | "github.com/defenseunicorns/maru-runner/src/config/lang" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var versionCmd = &cobra.Command{ 16 | Use: "version", 17 | Aliases: []string{"v"}, 18 | PersistentPreRun: func(_ *cobra.Command, _ []string) { 19 | skipLogFile = true 20 | cliSetup() 21 | }, 22 | Short: lang.CmdVersionShort, 23 | Long: lang.CmdVersionLong, 24 | Run: func(_ *cobra.Command, _ []string) { 25 | fmt.Println(config.CLIVersion) 26 | }, 27 | } 28 | 29 | func init() { 30 | rootCmd.AddCommand(versionCmd) 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/scan-lint.yaml: -------------------------------------------------------------------------------- 1 | name: Validate Lint 2 | on: pull_request 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | validate: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 13 | 14 | - name: Setup golang 15 | uses: ./.github/actions/golang 16 | 17 | # will run golangci-lint 2x due to pre-commit hook 18 | - name: Install golangci-lint 19 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 20 | with: 21 | version: latest 22 | 23 | - name: Run pre-commit 24 | uses: pre-commit/action@1b06ec171f2f6faa71ed760c4042bd969e4f8b43 # 25 | with: 26 | extra_args: --all-files --verbose # pre-commit run --all-files --verbose 27 | -------------------------------------------------------------------------------- /src/pkg/variables/common.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package variables contains functions for interacting with variables 5 | package variables 6 | 7 | import ( 8 | "log/slog" 9 | ) 10 | 11 | // VariableConfig represents a value to be templated into a text file. 12 | type VariableConfig[T any] struct { 13 | setVariableMap SetVariableMap[T] 14 | 15 | prompt func(variable InteractiveVariable[T]) (value string, err error) 16 | logger *slog.Logger 17 | } 18 | 19 | // New creates a new VariableConfig 20 | func New[T any](prompt func(variable InteractiveVariable[T]) (value string, err error), logger *slog.Logger) *VariableConfig[T] { 21 | return &VariableConfig[T]{ 22 | setVariableMap: make(SetVariableMap[T]), 23 | prompt: prompt, 24 | logger: logger, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSING.md: -------------------------------------------------------------------------------- 1 | # Dual Licensing 2 | 3 | This software is licensed under either of: 4 | 5 | - GNU Affero General Public License v3.0 (AGPLv3), see [LICENSE.md](./LICENSE.md) 6 | - Defense Unicorns Commercial License, see below 7 | 8 | ## Defense Unicorns Commercial License 9 | 10 | The use of this software under a commercial license is subject to the individual 11 | terms of the license agreement between the licensee and Defense Unicorns. The 12 | content of this license depends on the specific agreement and may vary. For 13 | more information about obtaining a commercial license, please contact 14 | Defense Unicorns at [defenseunicorns.com](https://defenseunicorns.com). 15 | 16 | To use this software under the commercial license, you must have a valid license 17 | agreement with Defense Unicorns. The terms of the Defense Unicorns, Inc. license 18 | agreement supplant and supersede the terms of the AGPL v3 license. 19 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yaml: -------------------------------------------------------------------------------- 1 | name: PR Title Check 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | types: [opened, edited, synchronize] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | title_check: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | pull-requests: read 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 25 | 26 | - name: Install commitlint 27 | run: npm install --save-dev @commitlint/{config-conventional,cli} 28 | 29 | - name: Lint PR title 30 | env: 31 | pull_request_title: ${{ github.event.pull_request.title }} 32 | run: | 33 | echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js 34 | echo "$pull_request_title" | npx commitlint 35 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.0.1 4 | hooks: 5 | - id: check-added-large-files 6 | args: ["--maxkb=1024"] 7 | - id: check-merge-conflict 8 | - id: detect-aws-credentials 9 | args: 10 | - "--allow-missing-credentials" 11 | - id: detect-private-key 12 | - id: end-of-file-fixer 13 | - id: fix-byte-order-marker 14 | - id: trailing-whitespace 15 | args: [--markdown-linebreak-ext=md] 16 | - repo: https://github.com/sirosen/fix-smartquotes 17 | rev: 0.2.0 18 | hooks: 19 | - id: fix-smartquotes 20 | - repo: https://github.com/golangci/golangci-lint 21 | rev: v2.4.0 22 | hooks: 23 | - id: golangci-lint 24 | args: [--timeout=5m] 25 | - repo: local 26 | hooks: 27 | - id: check-docs-and-schema 28 | name: Check for outdated schema 29 | entry: ./hack/test-generate-schema.sh 30 | files: "src/types/types.go" 31 | types: [go] 32 | language: script 33 | description: "Checks for schema changes" 34 | -------------------------------------------------------------------------------- /.github/workflows/test-e2e-pr.yaml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | on: 3 | pull_request: 4 | paths-ignore: 5 | - "**.md" 6 | - "**.jpg" 7 | - "**.png" 8 | - "**.gif" 9 | - "**.svg" 10 | - "adr/**" 11 | - "docs/**" 12 | - "CODEOWNERS" 13 | - "goreleaser.yml" 14 | 15 | # Abort prior jobs in the same workflow / PR 16 | concurrency: 17 | group: e2e-runner-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | test: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 26 | 27 | - name: Setup golang 28 | uses: ./.github/actions/golang 29 | 30 | - name: Install Zarf 31 | uses: ./.github/actions/zarf 32 | 33 | - name: Build runner binary 34 | run: make build-cli-linux-amd ARCH=amd64 35 | 36 | - name: Run e2e tests 37 | run: | 38 | make test-e2e 39 | env: 40 | MARU_AUTH: '{"gitlab.com": "${{ secrets.MARU_GITLAB_TOKEN }}"}' 41 | 42 | - name: Save logs 43 | if: always() 44 | uses: ./.github/actions/save-logs 45 | -------------------------------------------------------------------------------- /.github/workflows/ci-doc-shim.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Defense Unicorns 2 | # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial 3 | 4 | name: CI Docs Shim 5 | 6 | on: 7 | pull_request: 8 | paths: 9 | - "**.md" 10 | - "**.jpg" 11 | - "**.png" 12 | - "**.gif" 13 | - "**.svg" 14 | - "adr/**" 15 | - "docs/**" 16 | - "CODEOWNERS" 17 | - "goreleaser.yml" 18 | - .gitignore 19 | - renovate.json 20 | - CODEOWNERS 21 | - LICENSE 22 | - CONTRIBUTING.md 23 | - SECURITY.md 24 | 25 | # Permissions for the GITHUB_TOKEN used by the workflow. 26 | permissions: 27 | contents: read # Allows reading the content of the repository. 28 | 29 | jobs: 30 | test: 31 | runs-on: ubuntu-latest 32 | timeout-minutes: 20 33 | steps: 34 | - name: Shim for test 35 | run: | 36 | echo "Documentation-only change detected; test successful." 37 | 38 | validate: 39 | name: validate (go) 40 | runs-on: ubuntu-latest 41 | timeout-minutes: 20 42 | steps: 43 | - name: Shim for test 44 | run: | 45 | echo "Documentation-only change detected; validate successful." 46 | -------------------------------------------------------------------------------- /src/cmd/internal.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package cmd contains the CLI commands for maru. 5 | package cmd 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | 11 | "github.com/defenseunicorns/maru-runner/src/config/lang" 12 | "github.com/defenseunicorns/maru-runner/src/message" 13 | "github.com/defenseunicorns/maru-runner/src/types" 14 | "github.com/invopop/jsonschema" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | var internalCmd = &cobra.Command{ 19 | Use: "internal", 20 | Aliases: []string{"dev"}, 21 | Hidden: true, 22 | Short: lang.CmdInternalShort, 23 | } 24 | 25 | var configTasksSchemaCmd = &cobra.Command{ 26 | Use: "config-tasks-schema", 27 | Aliases: []string{"c"}, 28 | Short: lang.CmdInternalConfigSchemaShort, 29 | PersistentPreRun: func(_ *cobra.Command, _ []string) { 30 | skipLogFile = true 31 | }, 32 | Run: func(_ *cobra.Command, _ []string) { 33 | schema := jsonschema.Reflect(&types.TasksFile{}) 34 | output, err := json.MarshalIndent(schema, "", " ") 35 | if err != nil { 36 | message.Fatalf(err, "%s", lang.CmdInternalConfigSchemaErr) 37 | } 38 | fmt.Print(string(output) + "\n") 39 | }, 40 | } 41 | 42 | func init() { 43 | rootCmd.AddCommand(internalCmd) 44 | 45 | internalCmd.AddCommand(configTasksSchemaCmd) 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/scan-codeql.yaml: -------------------------------------------------------------------------------- 1 | name: Analyze CodeQL 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | paths-ignore: 11 | - "**.md" 12 | - "**.jpg" 13 | - "**.png" 14 | - "**.gif" 15 | - "**.svg" 16 | - "adr/**" 17 | - "docs/**" 18 | - "CODEOWNERS" 19 | - "goreleaser.yml" 20 | schedule: 21 | - cron: "32 2 * * 5" 22 | 23 | jobs: 24 | validate: 25 | runs-on: ubuntu-latest 26 | permissions: 27 | actions: read 28 | contents: read 29 | security-events: write 30 | 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | language: ["go"] 35 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 36 | 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 40 | 41 | - name: Setup golang 42 | uses: ./.github/actions/golang 43 | 44 | - name: Build maru CLI 45 | run: make build-cli-linux-amd 46 | 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1 49 | env: 50 | CODEQL_EXTRACTOR_GO_BUILD_TRACING: on 51 | with: 52 | languages: ${{ matrix.language }} 53 | config-file: ./.github/codeql.yaml 54 | 55 | 56 | - name: Perform CodeQL Analysis 57 | uses: github/codeql-action/analyze@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1 58 | with: 59 | category: "/language:${{matrix.language}}" 60 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "enabled": true, 3 | "forkProcessing": "enabled", 4 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 5 | "extends": [ 6 | "config:base" 7 | ], 8 | "ignorePaths": [], 9 | "timezone": "America/New_York", 10 | "rebaseStalePrs": true, 11 | "schedule": [ 12 | "after 12pm and before 11am every weekday" 13 | ], 14 | "dependencyDashboard": true, 15 | "dependencyDashboardTitle": "Renovate Dashboard 🤖", 16 | "rebaseWhen": "conflicted", 17 | "commitBodyTable": true, 18 | "suppressNotifications": [ 19 | "prIgnoreNotification" 20 | ], 21 | "postUpdateOptions": [ 22 | "gomodTidy" 23 | ], 24 | "regexManagers": [ 25 | { 26 | "fileMatch": [ 27 | "action.yaml" 28 | ], 29 | "matchStrings": [ 30 | "# renovate: datasource=(?.*) depName=(?.*)(versioning=(?.*))?(registryUrl=(?.*))?\\n\\s*(version|ref): (?.*)" 31 | ], 32 | "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}" 33 | } 34 | ], 35 | "packageRules": [ 36 | { 37 | "groupName": "Maru Support Dependencies", 38 | "labels": [ 39 | "support-deps" 40 | ], 41 | "commitMessageTopic": "support-deps", 42 | "packagePatterns": [ 43 | "*" 44 | ] 45 | }, 46 | { 47 | "groupName": "Maru Code Dependencies", 48 | "labels": [ 49 | "code-deps" 50 | ], 51 | "commitMessageTopic": "code-deps", 52 | "matchDatasources": [ 53 | "go" 54 | ] 55 | }, 56 | { 57 | "matchPackageNames": ["github.com/pterm/pterm"], 58 | "enabled": false 59 | } 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /src/test/tasks/inputs/tasks.yaml: -------------------------------------------------------------------------------- 1 | includes: 2 | - with: ./tasks-with-inputs.yaml 3 | 4 | variables: 5 | - name: FOO 6 | default: default-value 7 | 8 | tasks: 9 | - name: has-default-empty 10 | actions: 11 | - task: with:has-default 12 | 13 | - name: has-default-and-required-empty 14 | actions: 15 | - task: with:has-default-and-required 16 | 17 | - name: has-default-and-required-supplied 18 | actions: 19 | - task: with:has-default-and-required 20 | with: 21 | has-default-and-required: supplied-value 22 | 23 | - name: no-default-empty 24 | actions: 25 | - task: with:no-default 26 | 27 | - name: no-default-supplied 28 | actions: 29 | - task: with:no-default 30 | with: 31 | no-default: supplied-value 32 | 33 | - name: no-default-and-required-empty 34 | actions: 35 | - task: with:no-default-and-required 36 | 37 | - name: no-default-and-required-supplied 38 | actions: 39 | - task: with:no-default-and-required 40 | with: 41 | no-default-and-required: supplied-value 42 | 43 | - name: no-default-and-required-supplied-extra 44 | actions: 45 | - task: with:no-default-and-required 46 | with: 47 | no-default-and-required: supplied-value 48 | extra: extra-value 49 | 50 | - name: deprecated-task 51 | actions: 52 | - task: with:deprecated-message 53 | with: 54 | deprecated-message: deprecated-value 55 | 56 | - name: variable-as-input 57 | description: demonstrates that variables can be passed into tasks as 'with' inputs 58 | actions: 59 | - task: with:no-default 60 | with: 61 | no-default: ${FOO} 62 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yaml: -------------------------------------------------------------------------------- 1 | name: Scorecards supply-chain security 2 | on: 3 | # Only the default branch is supported. 4 | branch_protection_rule: 5 | schedule: 6 | - cron: '30 1 * * 6' 7 | push: 8 | branches: [ "main" ] 9 | 10 | # Declare default permissions as read only. 11 | permissions: read-all 12 | 13 | jobs: 14 | analysis: 15 | name: Scorecards analysis 16 | runs-on: ubuntu-latest 17 | permissions: 18 | # Needed to upload the results to code-scanning dashboard. 19 | security-events: write 20 | # Used to receive a badge. 21 | id-token: write 22 | 23 | steps: 24 | - name: "Checkout code" 25 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 26 | with: 27 | persist-credentials: false 28 | 29 | - name: "Run analysis" 30 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 31 | with: 32 | results_file: results.sarif 33 | results_format: sarif 34 | repo_token: ${{ secrets.SCORECARD_READ_TOKEN }} 35 | publish_results: true 36 | 37 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 38 | # format to the repository Actions tab. 39 | - name: "Upload artifact" 40 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 41 | with: 42 | name: SARIF file 43 | path: results.sarif 44 | retention-days: 5 45 | 46 | # Upload the results to GitHub's code scanning dashboard. 47 | - name: "Upload to code-scanning" 48 | uses: github/codeql-action/upload-sarif@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1 49 | with: 50 | sarif_file: results.sarif 51 | -------------------------------------------------------------------------------- /src/config/config.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package config contains configuration strings for maru 5 | package config 6 | 7 | const ( 8 | // TasksYAML is the string for the default tasks.yaml 9 | TasksYAML = "tasks.yaml" 10 | 11 | // EnvPrefix is the prefix for environment variables 12 | EnvPrefix = "MARU" 13 | 14 | // KeyringService is the name given to the service Maru uses in the Keyring 15 | KeyringService = "com.defenseunicorns.maru" 16 | ) 17 | 18 | var ( 19 | // CLIVersion track the version of the CLI 20 | CLIVersion = "unset" 21 | 22 | // CmdPrefix is used to prefix Zarf cmds (like wait-for), useful when vendoring both the runner and Zarf 23 | // if not set, the system Zarf will be used 24 | CmdPrefix string 25 | 26 | // TaskFileLocation is the location of the tasks file to run 27 | TaskFileLocation string 28 | 29 | // TempDirectory is the directory to store temporary files 30 | TempDirectory string 31 | 32 | // VendorPrefix is the prefix for environment variables that an application vendoring Maru wants to use 33 | VendorPrefix string 34 | 35 | // MaxStack is the maximum stack size for task references 36 | MaxStack = 2048 37 | 38 | extraEnv = map[string]string{"MARU": "true"} 39 | ) 40 | 41 | // AddExtraEnv adds a new envirmentment variable to the extraEnv to make it available to actions 42 | func AddExtraEnv(key string, value string) { 43 | extraEnv[key] = value 44 | } 45 | 46 | // GetExtraEnv returns the map of extra environment variables that have been set and made available to actions 47 | func GetExtraEnv() map[string]string { 48 | return extraEnv 49 | } 50 | 51 | // ClearExtraEnv clears extraEnv back to empty map 52 | func ClearExtraEnv() { 53 | extraEnv = make(map[string]string) 54 | } 55 | -------------------------------------------------------------------------------- /src/pkg/variables/types.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | package variables 5 | 6 | // VariableType represents a type of a variable 7 | type VariableType string 8 | 9 | const ( 10 | // RawVariableType is the default type for a variable 11 | RawVariableType VariableType = "raw" 12 | // FileVariableType is a type for a variable that loads its contents from a file 13 | FileVariableType VariableType = "file" 14 | ) 15 | 16 | // Variable represents a variable that has a value set programmatically 17 | type Variable[T any] struct { 18 | Name string `json:"name" jsonschema:"description=The name to be used for the variable,pattern=^[A-Z0-9_]+$"` 19 | Pattern string `json:"pattern,omitempty" jsonschema:"description=An optional regex pattern that a variable value must match before a package deployment can continue."` 20 | Extra T `json:",omitempty,inline"` 21 | } 22 | 23 | // InteractiveVariable is a variable that can be used to prompt a user for more information 24 | type InteractiveVariable[T any] struct { 25 | Variable[T] `json:",inline"` 26 | 27 | Description string `json:"description,omitempty" jsonschema:"description=A description of the variable to be used when prompting the user a value"` 28 | Default string `json:"default,omitempty" jsonschema:"description=The default value to use for the variable"` 29 | Prompt bool `json:"prompt,omitempty" jsonschema:"description=Whether to prompt the user for input for this variable"` 30 | } 31 | 32 | // SetVariable tracks internal variables that have been set during this execution run 33 | type SetVariable[T any] struct { 34 | Variable[T] `json:",inline"` 35 | 36 | Value string `json:"value" jsonschema:"description=The value the variable is currently set with"` 37 | } 38 | 39 | // ExtraVariableInfo carries any additional information that may be desired through variables passed and set by actions (available to library users). 40 | type ExtraVariableInfo struct { 41 | } 42 | -------------------------------------------------------------------------------- /src/test/e2e/main_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package test provides e2e tests for the runner 5 | package test 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "path" 11 | "testing" 12 | 13 | "github.com/defenseunicorns/maru-runner/src/test" 14 | ) 15 | 16 | var ( 17 | e2e test.MaruE2ETest //nolint:gochecknoglobals 18 | ) 19 | 20 | const ( 21 | applianceModeEnvVar = "APPLIANCE_MODE" 22 | applianceModeKeepEnvVar = "APPLIANCE_MODE_KEEP" 23 | skipK8sEnvVar = "SKIP_K8S" 24 | ) 25 | 26 | // TestMain lets us customize the test run. See https://medium.com/goingogo/why-use-testmain-for-testing-in-go-dafb52b406bc. 27 | func TestMain(m *testing.M) { 28 | // Work from the root directory of the project 29 | err := os.Chdir("../../../") 30 | if err != nil { 31 | fmt.Println(err) 32 | } 33 | 34 | retCode, err := doAllTheThings(m) 35 | if err != nil { 36 | fmt.Println(err) //nolint:forbidigo 37 | } 38 | 39 | os.Exit(retCode) 40 | } 41 | 42 | // doAllTheThings just wraps what should go in TestMain. It's in its own function so it can 43 | // [a] Not have a bunch of `os.Exit()` calls in it 44 | // [b] Do defers properly 45 | // [c] Handle errors cleanly 46 | // 47 | // It returns the return code passed from `m.Run()` and any error thrown. 48 | func doAllTheThings(m *testing.M) (int, error) { 49 | var err error 50 | 51 | // Set up constants in the global variable that all the tests are able to access 52 | e2e.MaruBinPath = path.Join("build", test.GetCLIName()) 53 | e2e.ApplianceMode = os.Getenv(applianceModeEnvVar) == "true" 54 | e2e.ApplianceModeKeep = os.Getenv(applianceModeKeepEnvVar) == "true" 55 | e2e.RunClusterTests = os.Getenv(skipK8sEnvVar) != "true" 56 | 57 | // Validate that the Maru binary exists. If it doesn't that means the dev hasn't built it, usually by running 58 | // `make build-cli` 59 | _, err = os.Stat(e2e.MaruBinPath) 60 | if err != nil { 61 | return 1, fmt.Errorf("maru binary %s not found", e2e.MaruBinPath) 62 | } 63 | 64 | // Run the tests, with the cluster cleanup being deferred to the end of the function call 65 | returnCode := m.Run() 66 | 67 | return returnCode, nil 68 | } 69 | -------------------------------------------------------------------------------- /src/message/slog.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package message provides a rich set of functions for displaying messages to the user. 5 | package message 6 | 7 | import ( 8 | "context" 9 | "log/slog" 10 | ) 11 | 12 | var ( 13 | // SLog sets the default structured log handler for messages 14 | SLog = slog.New(MaruHandler{}) 15 | ) 16 | 17 | // MaruHandler is a simple handler that implements the slog.Handler interface 18 | type MaruHandler struct{} 19 | 20 | // Enabled determines if the handler is enabled for the given level. This 21 | // function is called for every log message and will compare the level of the 22 | // message to the log level set (default is info). Log levels are defined in 23 | // src/message/logging.go and match the levels used in the underlying log/slog 24 | // package. Logs with a level below the set log level will be ignored. 25 | // 26 | // Examples: 27 | // 28 | // SetLogLevel(TraceLevel) // show everything, with file names and line numbers 29 | // SetLogLevel(DebugLevel) // show everything 30 | // SetLogLevel(InfoLevel) // show info and above (does not show debug logs) 31 | // SetLogLevel(WarnLevel) // show warn and above (does not show debug/info logs) 32 | // SetLogLevel(ErrorLevel) // show only errors (does not show debug/info/warn logs) 33 | func (z MaruHandler) Enabled(_ context.Context, level slog.Level) bool { 34 | // only log if the log level is greater than or equal to the set log level 35 | return int(level) >= int(logLevel) 36 | } 37 | 38 | // WithAttrs is not suppported 39 | func (z MaruHandler) WithAttrs(_ []slog.Attr) slog.Handler { 40 | return z 41 | } 42 | 43 | // WithGroup is not supported 44 | func (z MaruHandler) WithGroup(_ string) slog.Handler { 45 | return z 46 | } 47 | 48 | // Handle prints the respective logging function in Maru 49 | // This function ignores any key pairs passed through the record 50 | func (z MaruHandler) Handle(_ context.Context, record slog.Record) error { 51 | level := record.Level 52 | message := record.Message 53 | 54 | switch level { 55 | case slog.LevelDebug: 56 | debugf("%s", message) 57 | case slog.LevelInfo: 58 | infof("%s", message) 59 | case slog.LevelWarn: 60 | warnf("%s", message) 61 | case slog.LevelError: 62 | errorf("%s", message) 63 | } 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Maru 2 | Welcome :unicorn: to Maru! If you'd like to contribute, please reach out to one of the [CODEOWNERS](CODEOWNERS) and we'll be happy to get you started! 3 | 4 | Below are some notes on our core software design philosophies that should help guide contributors. 5 | 6 | ## Code Quality and Standards 7 | Fundamentally, software engineering is a communication problem; we write code for each other, not a computer. When working on this project (or any project!) keep your fellow humans in mind and write clearly and concisely. Below are some general guidelines for code quality and standards that make Maru :sparkles: 8 | 9 | - **Write tests that give confidence**: Unless there is a technical blocker, every new feature and bug fix should be tested in the project's automated test suite. Although many of our tests are E2E, unit and integration-style tests are also welcomed. Note that unit tests can live in a `*_test.go` file alongside the source code, and E2E tests live in `src/test/e2e` 10 | 11 | - **Prefer readability over being clever**: We have a strong preference for code readability in Maru. Specifically, this means things like: naming variables appropriately, keeping functions to a reasonable size and avoiding complicated solutions when simple ones exist. 12 | 13 | - **User experience is paramount**: Maru doesn't have a pretty UI (yet), but the core user-centered design principles that apply when building a frontend also apply to this CLI tool. First and foremost, features in Maru should enhance workflows and make life easier for end users; if a feature doesn't accomplish this, it will be dropped. 14 | 15 | ### Pre-Commit Hooks and Linting 16 | In this repo you can optionally use [pre-commit](https://pre-commit.com/) hooks for automated validation and linting, but if not CI will run these checks for you. 17 | 18 | ## Continuous Delivery 19 | Continuous Delivery is core to our development philosophy. Check out [https://minimumcd.org](https://minimumcd.org/) for a good baseline agreement on what that means. 20 | 21 | Specifically: 22 | 23 | - We do trunk-based development (`main`) with short-lived feature branches that originate from the trunk, get merged into the trunk, and are deleted after the merge 24 | - We don't merge code into `main` that isn't releasable 25 | - We perform automated testing on all changes before they get merged to `main` 26 | - We create immutable release artifacts 27 | -------------------------------------------------------------------------------- /src/pkg/utils/tempate_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | package utils 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/defenseunicorns/maru-runner/src/config" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/defenseunicorns/maru-runner/src/pkg/variables" 13 | ) 14 | 15 | func Test_TemplateString(t *testing.T) { 16 | 17 | type args struct { 18 | vars variables.SetVariableMap[string] 19 | extraEnv map[string]string 20 | s string 21 | } 22 | tests := []struct { 23 | name string 24 | args args 25 | want string 26 | }{ 27 | { 28 | name: "Test precedence of extraEnv over setVariableMap", 29 | args: args{ 30 | vars: variables.SetVariableMap[string]{"ENV1": {Value: "fromSet1"}, "ENV2": {Value: "fromSet2"}, "ENV3": {Value: "fromSet3"}, "ENV4": {Value: "fromSet4"}}, 31 | extraEnv: map[string]string{"ENV1": "fromExtra1", "ENV3": "fromExtra3", "ENV4": "fromExtra4"}, 32 | s: "${ENV1} ${ENV2} ${ENV3} ${ENV4}", 33 | }, 34 | want: "fromExtra1 fromSet2 fromExtra3 fromExtra4", 35 | }, 36 | { 37 | name: "Test with no setVariableMap", 38 | args: args{ 39 | vars: nil, 40 | extraEnv: map[string]string{"ENV1": "fromExtra1", "ENV3": "fromExtra3", "ENV4": "fromExtra4"}, 41 | s: "${ENV1} ${ENV2} ${ENV3} ${ENV4}", 42 | }, 43 | want: "fromExtra1 ${ENV2} fromExtra3 fromExtra4", 44 | }, 45 | { 46 | name: "Test with no extraEnv", 47 | args: args{ 48 | vars: variables.SetVariableMap[string]{"ENV1": {Value: "fromSet1"}, "ENV3": {Value: "fromSet3"}, "ENV4": {Value: "fromSet4"}}, 49 | extraEnv: nil, 50 | s: "${ENV1} ${ENV2} ${ENV3} ${ENV4}", 51 | }, 52 | want: "fromSet1 ${ENV2} fromSet3 fromSet4", 53 | }, 54 | { 55 | name: "Test no set or extraEnv", 56 | args: args{ 57 | vars: nil, 58 | extraEnv: nil, 59 | s: "${ENV1} ${ENV2} ${ENV3} ${ENV4}", 60 | }, 61 | want: "${ENV1} ${ENV2} ${ENV3} ${ENV4}", 62 | }, 63 | } 64 | 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | config.ClearExtraEnv() 68 | for k, v := range tt.args.extraEnv { 69 | config.AddExtraEnv(k, v) 70 | } 71 | got := TemplateString(tt.args.vars, tt.args.s) 72 | 73 | require.Equal(t, tt.want, got, "The returned string did not match what was wanted") 74 | }) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/test/tasks/inputs/tasks-with-inputs.yaml: -------------------------------------------------------------------------------- 1 | variables: 2 | - name: FOO 3 | default: include-value 4 | - name: BAR 5 | default: default-value 6 | 7 | tasks: 8 | - name: has-default 9 | inputs: 10 | has-default: 11 | default: default 12 | description: has a default 13 | actions: 14 | # ${{ index .inputs "has-default" }} is necessary to use inputs with '-' in the name 15 | # This is a known issue with go text/templates 16 | - cmd: | 17 | echo ${{ index .inputs "has-default" }} 18 | 19 | - name: has-default-and-required 20 | inputs: 21 | has-default-and-required: 22 | default: default 23 | description: has a default and is required 24 | required: true 25 | actions: 26 | - cmd: | 27 | echo $INPUT_HAS_DEFAULT_AND_REQUIRED 28 | 29 | - name: no-default 30 | inputs: 31 | no-default: 32 | description: has no default 33 | actions: 34 | - cmd: | 35 | echo success + ${{ index .inputs "no-default" }} 36 | 37 | - name: no-default-and-required 38 | inputs: 39 | no-default-and-required: 40 | description: has no default and is required 41 | required: true 42 | actions: 43 | - cmd: | 44 | echo ${{ index .inputs "no-default-and-required" }} 45 | 46 | - name: deprecated-message 47 | inputs: 48 | deprecated-message: 49 | description: This task has a deprecated message 50 | deprecatedMessage: This is a deprecated message 51 | actions: 52 | - cmd: | 53 | echo ${{ index .inputs "deprecated-message" }} 54 | 55 | - name: echo-foo 56 | actions: 57 | - cmd: echo $FOO 58 | 59 | - name: echo-bar 60 | actions: 61 | - cmd: echo $BAR 62 | 63 | - name: command-line-with 64 | description: Test task that uses the --with flag on the command line 65 | inputs: 66 | input1: 67 | description: some input 68 | required: true 69 | input2: 70 | description: some input 71 | required: false 72 | default: input2 73 | input3: 74 | description: some input 75 | required: false 76 | default: input3 77 | actions: 78 | - cmd: | 79 | echo "Input1Tmpl: ${{ .inputs.input1 }} Input1Env: $INPUT_INPUT1 Input2Tmpl: ${{ .inputs.input2 }} Input2Env: ${INPUT_INPUT2} Input3Tmpl: ${{ .inputs.input3 }} Input3Env: $INPUT_INPUT3 Var: $FOO" 80 | -------------------------------------------------------------------------------- /docs/adr/0001-runner-migration.md: -------------------------------------------------------------------------------- 1 | # 2. Runner Migration 2 | 3 | Date: 1 March 2024 4 | 5 | ## Status 6 | Accepted 7 | 8 | ## Context 9 | 10 | Due to frustration with current build tooling (ie. Makefiles) and the need for a more custom build system for UDS, we decided to experiment with new a feature in UDS CLI called UDS Runner. This feature allowed users to define and run complex build workflows in a simple, declarative way, based on existing functionality in Zarf Actions. 11 | 12 | ### Migration 13 | 14 | After quickly gaining adoption across the organization, we originally decided to make the UDS Runner a first-class citizen of UDS CLI. However, in an effort to reduce the scope of UDS CLI and experiment with the runner as a new standalone project, the UDS Runner functionality will be migrated to this repo. See original ADR in the UDS CLI repo [here](https://github.com/defenseunicorns/uds-cli/blob/main/adr/0002-runner.md). 15 | 16 | ### Alternatives 17 | 18 | #### Make 19 | Aside from concerns around syntax, maintainability and personal preference, we wanted a tool that we could use in all environments (dev, CI, prod, etc), and could support creating and deploying Zarf packages and UDS bundles. After becoming frustrated with several overly-large and complex Makefiles to perform these tasks, the team decided to explore additional tooling outside of Make. 20 | 21 | #### Task 22 | According to the [official docs](https://taskfile.dev/) "Task is a task runner / build tool that aims to be simpler and easier to use than, for example, GNU Make." This project was evaluated during a company Dash Days event and was found to be a good fit for our needs. However, due to the context of the larger UDS ecosystem, we are largely unable to bring in projects that have primarily non-US contributors. 23 | 24 | ** It is important to note that although the runner takes ideas from [Task](https://taskfile.dev/), Github Actions and Gitlab pipelines, it is not a direct copy of any of these tools, and implementing a particular pattern from one of these tools does not mean that all features from that tool should be implemented. 25 | 26 | ## Decision 27 | The runner will be a standalone project called `maru` that lives in its own repo and can be vendored by other :unicorn: products. 28 | 29 | ## Consequences 30 | 31 | The UDS CLI team will own this product for a short time before eventually handing it off to another team. Furthermore, the UDS CLI team will vendor the runner such that no breaking changes will be introduced for UDS CLI users. 32 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | concurrency: 8 4 | allow-parallel-runners: true 5 | linters: 6 | default: fast 7 | enable: 8 | - revive 9 | disable: 10 | - depguard 11 | - funlen 12 | - lll 13 | - mnd 14 | - wsl 15 | - gochecknoinits # codebase still uses inits 16 | # More linters to never use (not part of fast) 17 | - containedctx 18 | - exhaustive 19 | - exhaustruct 20 | - forbidigo 21 | - gochecknoglobals 22 | - tagliatelle 23 | - varnamelen 24 | - wrapcheck 25 | # linters I disagree with 26 | - whitespace 27 | - testpackage 28 | - nestif 29 | - maintidx 30 | - inamedparam 31 | - godox 32 | - godot 33 | - gocognit 34 | - cyclop 35 | - gocyclo 36 | - nlreturn 37 | settings: 38 | goheader: 39 | template: |- 40 | SPDX-License-Identifier: Apache-2.0 41 | SPDX-FileCopyrightText: 2023-Present the Maru Authors 42 | govet: 43 | disable: 44 | - shadow 45 | - fieldalignment 46 | - unusedwrite 47 | enable-all: true 48 | nolintlint: 49 | require-specific: true 50 | revive: 51 | rules: 52 | - name: blank-imports 53 | - name: context-as-argument 54 | - name: context-keys-type 55 | - name: dot-imports 56 | - name: error-return 57 | - name: error-strings 58 | - name: error-naming 59 | - name: exported 60 | - name: if-return 61 | - name: increment-decrement 62 | - name: var-naming 63 | disabled: true 64 | - name: var-declaration 65 | - name: package-comments 66 | - name: range 67 | - name: receiver-naming 68 | - name: time-naming 69 | - name: unexported-return 70 | - name: indent-error-flow 71 | - name: errorf 72 | - name: empty-block 73 | - name: superfluous-else 74 | - name: unused-parameter 75 | - name: unreachable-code 76 | - name: redefines-builtin-id 77 | testifylint: 78 | enable-all: true 79 | exclusions: 80 | generated: lax 81 | presets: 82 | - common-false-positives 83 | - legacy 84 | - std-error-handling 85 | paths: 86 | - third_party$ 87 | - builtin$ 88 | - examples$ 89 | formatters: 90 | enable: 91 | - gofmt 92 | - goimports 93 | exclusions: 94 | generated: lax 95 | paths: 96 | - third_party$ 97 | - builtin$ 98 | - examples$ 99 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | ARCH ?= amd64 5 | CLI_VERSION ?= $(if $(shell git describe --tags),$(shell git describe --tags),"UnknownVersion") 6 | BUILD_ARGS := -s -w -X 'github.com/defenseunicorns/maru-runner/src/config.CLIVersion=$(CLI_VERSION)' 7 | SRC_FILES ?= $(shell find . -type f -name "*.go") 8 | 9 | BUILD_CLI_FOR_SYSTEM := build-cli 10 | UNAME_S := $(shell uname -s) 11 | UNAME_P := $(shell uname -p) 12 | ifeq ($(UNAME_S),Darwin) 13 | ifeq ($(UNAME_P),i386) 14 | BUILD_CLI_FOR_SYSTEM := $(addsuffix -mac-intel,$(BUILD_CLI_FOR_SYSTEM)) 15 | endif 16 | ifeq ($(UNAME_P),arm) 17 | BUILD_CLI_FOR_SYSTEM := $(addsuffix -mac-apple,$(BUILD_CLI_FOR_SYSTEM)) 18 | endif 19 | else ifeq ($(UNAME_S),Linux) 20 | ifeq ($(UNAME_P),x86_64) 21 | BUILD_CLI_FOR_SYSTEM := $(addsuffix -linux-amd,$(BUILD_CLI_FOR_SYSTEM)) 22 | endif 23 | ifeq ($(UNAME_P),aarch64) 24 | BUILD_CLI_FOR_SYSTEM := $(addsuffix -linux-arm,$(BUILD_CLI_FOR_SYSTEM)) 25 | endif 26 | endif 27 | 28 | .PHONY: help 29 | help: ## Display this help information 30 | @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ 31 | | sort | awk 'BEGIN {FS = ":.*?## "}; \ 32 | {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 33 | 34 | .PHONY: build 35 | build: ## Build the CLI for the current machine's OS and architecture 36 | $(MAKE) $(BUILD_CLI_FOR_SYSTEM) 37 | 38 | build-cli-linux-amd: ## Build the CLI for Linux AMD64 39 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(BUILD_ARGS)" -o build/maru main.go 40 | 41 | build-cli-linux-arm: ## Build the CLI for Linux ARM64 42 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(BUILD_ARGS)" -o build/maru-arm main.go 43 | 44 | build-cli-mac-intel: ## Build the CLI for Mac Intel 45 | GOOS=darwin GOARCH=amd64 go build -ldflags="$(BUILD_ARGS)" -o build/maru-mac-intel main.go 46 | 47 | build-cli-mac-apple: ## Build the CLI for Mac Apple 48 | GOOS=darwin GOARCH=arm64 go build -ldflags="$(BUILD_ARGS)" -o build/maru-mac-apple main.go 49 | 50 | .PHONY: test-unit 51 | test-unit: ## Run unit tests 52 | go test -failfast -v -timeout 30m $$(go list ./... | grep -v '^github.com/defenseunicorns/maru-runner/src/test/e2e') 53 | 54 | 55 | .PHONY: test-e2e 56 | 57 | test-e2e: build ## Run End to End (e2e) tests 58 | cd src/test/e2e && go test -failfast -v -timeout 30m -count=1 59 | 60 | schema: ## Update JSON schema for maru tasks 61 | ./hack/generate-schema.sh 62 | 63 | test-schema: schema ## Test if the schema has been modified 64 | ./hack/test-generate-schema.sh 65 | 66 | clean: ## Clean up build artifacts 67 | rm -rf build 68 | -------------------------------------------------------------------------------- /src/cmd/viper.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package cmd contains the CLI commands for maru. 5 | package cmd 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "strings" 11 | 12 | "github.com/defenseunicorns/maru-runner/src/config" 13 | "github.com/defenseunicorns/maru-runner/src/config/lang" 14 | "github.com/defenseunicorns/maru-runner/src/message" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | // Root config keys 19 | const ( 20 | V_LOG_LEVEL = "options.log_level" 21 | V_ARCHITECTURE = "options.architecture" 22 | V_NO_PROGRESS = "options.no_progress" 23 | V_NO_LOG_FILE = "options.no_log_file" 24 | V_TMP_DIR = "options.tmp_dir" 25 | V_AUTH = "options.auth" 26 | ) 27 | 28 | var ( 29 | // Viper instance used by the cmd package 30 | v *viper.Viper 31 | 32 | // holds any error from reading in Viper config 33 | vConfigError error 34 | ) 35 | 36 | func initViper() { 37 | // Already initialized by some other command 38 | if v != nil { 39 | return 40 | } 41 | 42 | v = viper.New() 43 | 44 | // Specify an alternate config file 45 | cfgFile := os.Getenv("MARU_CONFIG") 46 | 47 | // Don't forget to read config either from cfgFile or from home directory! 48 | if cfgFile != "" { 49 | // Use config file from the flag. 50 | v.SetConfigFile(cfgFile) 51 | } else { 52 | // Search config paths (order matters!) 53 | v.AddConfigPath(".") 54 | v.AddConfigPath("$HOME/.maru") 55 | // todo: make configurable 56 | v.SetConfigName("maru-config") 57 | } 58 | 59 | // we replace 'OPTIONS.' because in a maru-config.yaml, the key is options., but in the environment, it's MARU_ 60 | // e.g. MARU_LOG_LEVEL=debug 61 | v.SetEnvPrefix(config.EnvPrefix) 62 | v.SetEnvKeyReplacer(strings.NewReplacer("OPTIONS.", "")) 63 | v.AutomaticEnv() 64 | 65 | vConfigError = v.ReadInConfig() 66 | if vConfigError != nil { 67 | // Config file not found; ignore 68 | if _, ok := vConfigError.(viper.ConfigFileNotFoundError); !ok { 69 | message.SLog.Debug(vConfigError.Error()) 70 | message.SLog.Warn(fmt.Sprintf("%s - %s", lang.CmdViperErrLoadingConfigFile, vConfigError.Error())) 71 | } 72 | } 73 | } 74 | 75 | func printViperConfigUsed() { 76 | // Optional, so ignore file not found errors 77 | if vConfigError != nil { 78 | // Config file not found; ignore 79 | if _, ok := vConfigError.(viper.ConfigFileNotFoundError); !ok { 80 | message.SLog.Debug(vConfigError.Error()) 81 | message.SLog.Warn(fmt.Sprintf("%s - %s", lang.CmdViperErrLoadingConfigFile, vConfigError.Error())) 82 | } 83 | } else { 84 | message.SLog.Info(fmt.Sprintf(lang.CmdViperInfoUsingConfigFile, v.ConfigFileUsed())) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/message/message.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package message contains functions to print messages to the screen 5 | package message 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "runtime/debug" 11 | "time" 12 | 13 | "github.com/pterm/pterm" 14 | ) 15 | 16 | const ( 17 | // termWidth sets the width of full width elements like progress bars and headers 18 | termWidth = 100 19 | ) 20 | 21 | // Fatalf prints a fatal error message and exits with a 1 with a given format. 22 | func Fatalf(err any, format string, a ...any) { 23 | message := paragraph(format, a...) 24 | debugPrinter(2, err) 25 | errorPrinter(2).Println(message) 26 | debugPrinter(2, string(debug.Stack())) 27 | os.Exit(1) 28 | } 29 | 30 | // Debugf prints a debug message with a given format. 31 | func debugf(format string, a ...any) { 32 | message := fmt.Sprintf(format, a...) 33 | debugPrinter(2, message) 34 | } 35 | 36 | // Warnf prints a warning message with a given format. 37 | func warnf(format string, a ...any) { 38 | pterm.Println() 39 | message := paragraph(format, a...) 40 | pterm.Warning.Println(message) 41 | } 42 | 43 | // Successf prints a success message with a given format. 44 | func successf(format string, a ...any) { 45 | pterm.Println() 46 | message := paragraph(format, a...) 47 | pterm.Success.Println(message) 48 | } 49 | 50 | // Failf prints a fail message with a given format. 51 | func errorf(format string, a ...any) { 52 | pterm.Println() 53 | message := paragraph(format, a...) 54 | pterm.Error.Println(message) 55 | } 56 | 57 | // Successf prints a success message with a given format. 58 | func infof(format string, a ...any) { 59 | pterm.Println() 60 | message := paragraph(format, a...) 61 | pterm.Info.Println(message) 62 | } 63 | 64 | // paragraph formats text into a paragraph matching the TermWidth 65 | func paragraph(format string, a ...any) string { 66 | return pterm.DefaultParagraph.WithMaxWidth(termWidth).Sprintf(format, a...) 67 | } 68 | 69 | func debugPrinter(offset int, a ...any) { 70 | printer := pterm.Debug.WithShowLineNumber(logLevel <= TraceLevel).WithLineNumberOffset(offset) 71 | now := time.Now().Format(time.RFC3339) 72 | // prepend to a 73 | a = append([]any{now, " - "}, a...) 74 | 75 | printer.Println(a...) 76 | 77 | // Always write to the log file 78 | if logFile != nil { 79 | pterm.Debug. 80 | WithShowLineNumber(true). 81 | WithLineNumberOffset(offset). 82 | WithDebugger(false). 83 | WithWriter(logFile). 84 | Println(a...) 85 | } 86 | } 87 | 88 | func errorPrinter(offset int) *pterm.PrefixPrinter { 89 | return pterm.Error.WithShowLineNumber(logLevel <= TraceLevel).WithLineNumberOffset(offset) 90 | } 91 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/defenseunicorns/maru-runner 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/defenseunicorns/pkg/exec v0.0.2 7 | github.com/defenseunicorns/pkg/helpers/v2 v2.0.4 8 | github.com/goccy/go-yaml v1.18.0 9 | github.com/invopop/jsonschema v0.13.0 10 | github.com/pterm/pterm v0.12.79 // pin until https://github.com/pterm/pterm/issues/701 is resolved 11 | github.com/spf13/cobra v1.9.1 12 | github.com/spf13/pflag v1.0.7 13 | github.com/spf13/viper v1.20.1 14 | github.com/stretchr/testify v1.10.0 15 | github.com/zalando/go-keyring v0.2.6 16 | ) 17 | 18 | require ( 19 | al.essio.dev/pkg/shellescape v1.6.0 // indirect 20 | atomicgo.dev/cursor v0.2.0 // indirect 21 | atomicgo.dev/keyboard v0.2.9 // indirect 22 | atomicgo.dev/schedule v0.1.0 // indirect 23 | github.com/bahlo/generic-list-go v0.2.0 // indirect 24 | github.com/buger/jsonparser v1.1.1 // indirect 25 | github.com/containerd/console v1.0.5 // indirect 26 | github.com/danieljoos/wincred v1.2.2 // indirect 27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 28 | github.com/fsnotify/fsnotify v1.9.0 // indirect 29 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 30 | github.com/godbus/dbus/v5 v5.1.0 // indirect 31 | github.com/gookit/color v1.5.4 // indirect 32 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 33 | github.com/klauspost/cpuid/v2 v2.2.5 // indirect 34 | github.com/lithammer/fuzzysearch v1.1.8 // indirect 35 | github.com/mailru/easyjson v0.9.0 // indirect 36 | github.com/mattn/go-runewidth v0.0.16 // indirect 37 | github.com/otiai10/copy v1.14.1 // indirect 38 | github.com/otiai10/mint v1.6.3 // indirect 39 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 40 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 41 | github.com/rivo/uniseg v0.4.7 // indirect 42 | github.com/rogpeppe/go-internal v1.12.0 // indirect 43 | github.com/sagikazarmark/locafero v0.10.0 // indirect 44 | github.com/sergi/go-diff v1.3.1 // indirect 45 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 46 | github.com/spf13/afero v1.14.0 // indirect 47 | github.com/spf13/cast v1.9.2 // indirect 48 | github.com/subosito/gotenv v1.6.0 // indirect 49 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 50 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 51 | golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 // indirect 52 | golang.org/x/sync v0.16.0 // indirect 53 | golang.org/x/sys v0.35.0 // indirect 54 | golang.org/x/term v0.34.0 // indirect 55 | golang.org/x/text v0.28.0 // indirect 56 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 57 | gopkg.in/yaml.v3 v3.0.1 // indirect 58 | oras.land/oras-go/v2 v2.6.0 // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /src/message/logging.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package message contains functions to print messages to the screen 5 | package message 6 | 7 | import ( 8 | "fmt" 9 | "io" 10 | "os" 11 | "time" 12 | 13 | "github.com/pterm/pterm" 14 | ) 15 | 16 | // LogLevel is the level of logging to display. 17 | type LogLevel int 18 | 19 | const ( 20 | // Supported log levels. These are in order of increasing severity, and 21 | // match the constants in the log/slog package. 22 | 23 | // TraceLevel level. Effectively the same as Debug but with line numbers. 24 | // 25 | // NOTE: There currently is no Trace() function in the log/slog package. In 26 | // order to use this level, you must use message.SLog.Log() and specify the 27 | // level. Maru currently uses the Trace level specifically for adding line 28 | // numbers to logs from calls to message.SLog.Debug(). Because of this, 29 | // Trace is effectively the same as Debug but with line numbers. 30 | TraceLevel LogLevel = -8 31 | // DebugLevel level. Usually only enabled when debugging. Very verbose logging. 32 | DebugLevel LogLevel = -4 33 | // InfoLevel level. General operational entries about what's going on inside the 34 | // application. 35 | InfoLevel LogLevel = 0 36 | // WarnLevel level. Non-critical entries that deserve eyes. 37 | WarnLevel LogLevel = 4 38 | // ErrorLevel level. Errors only. 39 | ErrorLevel LogLevel = 8 40 | ) 41 | 42 | // logLevel is the log level for the runner. When set, log messages with a level 43 | // greater than or equal to this level will be logged. Log messages with a level 44 | // lower than this level will be ignored. 45 | var logLevel = InfoLevel 46 | 47 | // logFile acts as a buffer for logFile generation 48 | var logFile *os.File 49 | 50 | // UseLogFile writes output to stderr and a logFile. 51 | func UseLogFile(dir string) (io.Writer, error) { 52 | // Prepend the log filename with a timestamp. 53 | ts := time.Now().Format("2006-01-02-15-04-05") 54 | 55 | var err error 56 | logFile, err = os.CreateTemp(dir, fmt.Sprintf("maru-%s-*.log", ts)) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return logFile, nil 62 | } 63 | 64 | // LogFileLocation returns the location of the log file. 65 | func LogFileLocation() string { 66 | if logFile == nil { 67 | return "" 68 | } 69 | return logFile.Name() 70 | } 71 | 72 | // SetLogLevel sets the log level. 73 | func SetLogLevel(lvl LogLevel) { 74 | logLevel = lvl 75 | // Enable pterm debug messages if the log level is Trace or Debug 76 | if logLevel <= DebugLevel { 77 | pterm.EnableDebugMessages() 78 | } 79 | } 80 | 81 | // GetLogLevel returns the current log level. 82 | func GetLogLevel() LogLevel { 83 | return logLevel 84 | } 85 | -------------------------------------------------------------------------------- /src/pkg/utils/template.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package utils provides utility fns for maru 5 | package utils 6 | 7 | import ( 8 | "regexp" 9 | "strings" 10 | "text/template" 11 | 12 | "github.com/defenseunicorns/maru-runner/src/config" 13 | "github.com/defenseunicorns/maru-runner/src/pkg/variables" 14 | "github.com/defenseunicorns/maru-runner/src/types" 15 | goyaml "github.com/goccy/go-yaml" 16 | ) 17 | 18 | // TemplateTaskAction templates a task's actions with the given inputs and variables 19 | func TemplateTaskAction[T any](action types.Action, withs map[string]string, inputs map[string]types.InputParameter, setVarMap variables.SetVariableMap[T]) (types.Action, error) { 20 | data := map[string]map[string]string{ 21 | "inputs": {}, 22 | "variables": {}, 23 | } 24 | 25 | // get inputs from "with" map 26 | for name := range withs { 27 | data["inputs"][name] = withs[name] 28 | } 29 | 30 | // get vars from "vms" map 31 | for name := range setVarMap { 32 | data["variables"][name] = setVarMap[name].Value 33 | } 34 | 35 | // use default if not populated in data 36 | for name := range inputs { 37 | if current, ok := data["inputs"][name]; !ok || current == "" { 38 | data["inputs"][name] = inputs[name].Default 39 | } 40 | } 41 | 42 | b, err := goyaml.Marshal(action) 43 | if err != nil { 44 | return action, err 45 | } 46 | 47 | t, err := template.New("template task actions").Option("missingkey=error").Delims("${{", "}}").Parse(string(b)) 48 | if err != nil { 49 | return action, err 50 | } 51 | 52 | var templated strings.Builder 53 | 54 | if err := t.Execute(&templated, data); err != nil { 55 | return action, err 56 | } 57 | 58 | result := templated.String() 59 | 60 | var templatedAction types.Action 61 | if err := goyaml.Unmarshal([]byte(result), &templatedAction); err != nil { 62 | return action, err 63 | } 64 | 65 | return templatedAction, nil 66 | } 67 | 68 | // TemplateString replaces ${...} with the value from the template map 69 | func TemplateString[T any](setVariableMap variables.SetVariableMap[T], s string) string { 70 | // Create a regular expression to match ${...} 71 | re := regexp.MustCompile(`\${(.*?)}`) 72 | 73 | // template string using values from the set variable map 74 | result := re.ReplaceAllStringFunc(s, func(matched string) string { 75 | varName := strings.TrimSuffix(strings.TrimPrefix(matched, "${"), "}") 76 | 77 | if value, ok := config.GetExtraEnv()[varName]; ok { 78 | return value 79 | } 80 | 81 | if value, ok := setVariableMap[varName]; ok { 82 | return value.Value 83 | } 84 | return matched // If the key is not found, keep the original substring 85 | }) 86 | 87 | return result 88 | } 89 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | # Build a universal macOS binary 8 | universal_binaries: 9 | - replace: false 10 | 11 | # Build the different combination of goos/arch binaries 12 | builds: 13 | - env: 14 | - CGO_ENABLED=0 15 | goos: 16 | - linux 17 | - darwin 18 | ldflags: 19 | - -s -w -X 'github.com/defenseunicorns/maru-runner/src/config.CLIVersion={{.Tag}}' 20 | goarch: 21 | - amd64 22 | - arm64 23 | binary: maru 24 | 25 | # Save the built artifacts as binaries (instead of wrapping them in a tarball) 26 | archives: 27 | - format: binary 28 | name_template: "{{ .ProjectName }}_{{ .Tag }}_{{- title .Os }}_{{ .Arch }}" 29 | 30 | # generate a sha256 checksum of all release artifacts 31 | checksum: 32 | name_template: "checksums.txt" 33 | algorithm: sha256 34 | 35 | # generate sboms for each binary artifact 36 | sboms: 37 | - artifacts: binary 38 | documents: 39 | - "sbom_{{ .ProjectName }}_{{ .Tag }}_{{- title .Os }}_{{ .Arch }}.sbom" 40 | 41 | snapshot: 42 | name_template: "{{ incpatch .Version }}-snapshot" 43 | 44 | # Use the auto-generated changelog github provides 45 | changelog: 46 | use: github-native 47 | 48 | brews: 49 | - name: "{{ .Env.BREW_NAME }}" 50 | repository: 51 | owner: defenseunicorns 52 | name: homebrew-tap 53 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 54 | branch: "{{ .ProjectName }}-{{ .Tag }}" 55 | pull_request: 56 | enabled: true 57 | base: 58 | branch: main 59 | owner: defenseunicorns 60 | name: homebrew-tap 61 | 62 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 63 | homepage: "https://github.com/defenseunicorns/maru-runner" 64 | description: "The Unicorn Task Runner" 65 | 66 | # NOTE: We are using .Version instead of .Tag because homebrew has weird semver parsing rules and won't be able to 67 | # install versioned releases that has a `v` character before the version number. 68 | - name: "maru@{{ .Version }}" 69 | repository: 70 | owner: defenseunicorns 71 | name: homebrew-tap 72 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 73 | branch: "{{ .ProjectName }}-{{ .Tag }}" 74 | pull_request: 75 | enabled: true 76 | base: 77 | branch: main 78 | owner: defenseunicorns 79 | name: homebrew-tap 80 | 81 | commit_msg_template: "Brew formula update for {{ .ProjectName }} versioned release {{ .Tag }}" 82 | homepage: "https://github.com/defenseunicorns/maru-runner" 83 | description: "The Unicorn Task Runner" 84 | 85 | # Generate a GitHub release and publish the release for the tag 86 | release: 87 | github: 88 | owner: defenseunicorns 89 | name: maru-runner 90 | prerelease: auto 91 | mode: append 92 | draft: false 93 | -------------------------------------------------------------------------------- /src/config/lang/english.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package lang contains the language strings in english used by maru 5 | package lang 6 | 7 | import "errors" 8 | 9 | // Common Error Messages 10 | const ( 11 | ErrDownloading = "failed to download %s: %w" 12 | ErrCreatingDir = "failed to create directory %s: %s" 13 | ErrWritingFile = "failed to write file %s: %s" 14 | ErrFileExtract = "failed to extract filename %s from archive %s: %s" 15 | ) 16 | 17 | // Root 18 | const ( 19 | RootCmdShort = "CLI for the maru runner" 20 | RootCmdFlagSkipLogFile = "Disable log file creation" 21 | RootCmdFlagLogLevel = "Log level for the runner. Valid options are: error, warn, info, debug, trace" 22 | RootCmdFlagNoProgress = "Disable fancy UI progress bars, spinners, logos, etc" 23 | RootCmdErrInvalidLogLevel = "Invalid log level. Valid options are: error, warn, info, debug, trace." 24 | RootCmdFlagArch = "Architecture for the runner" 25 | RootCmdFlagTempDir = "Specify the temporary directory to use for intermediate files" 26 | ) 27 | 28 | // Version 29 | const ( 30 | CmdVersionShort = "Shows the version of the running runner binary" 31 | CmdVersionLong = "Displays the version of the runner release that the current binary was built from." 32 | ) 33 | 34 | // Internal 35 | const ( 36 | CmdInternalShort = "Internal cmds used by the runner" 37 | CmdInternalConfigSchemaShort = "Generates a JSON schema for the tasks.yaml configuration" 38 | CmdInternalConfigSchemaErr = "Unable to generate the tasks.yaml schema" 39 | ) 40 | 41 | // Viper 42 | const ( 43 | CmdViperErrLoadingConfigFile = "failed to load config file: %s" 44 | CmdViperInfoUsingConfigFile = "Using config file %s" 45 | ) 46 | 47 | // Run 48 | const ( 49 | CmdRunShort = "Runs a specified task from a task file" 50 | CmdRunFlag = "Name and location of task file to run" 51 | CmdRunSetVarFlag = "Set a runner variable from the command line (KEY=value)" 52 | CmdRunWithVarFlag = "(experimental) Set the inputs for a task from the command line (KEY=value)" 53 | CmdRunList = "List available tasks in a task file" 54 | CmdRunListAll = "List all available tasks in a task file, including tasks from included files" 55 | CmdRunDryRun = "Validate the task without actually running any commands" 56 | ) 57 | 58 | // Auth 59 | const ( 60 | CmdAuthShort = "[beta] Authentication commands for pulling private remote task files" 61 | CmdLoginShort = "[beta] Adds a token for a given host to your keyring" 62 | CmdLoginTokenFlag = "The personal access token (bearer) you would like to save" 63 | CmdLoginTokenStdInFlag = "Whether to pull the token from standard input" 64 | CmdLogoutShort = "[beta] Removes a token for a given host from your keyring" 65 | ) 66 | 67 | // Common Errors 68 | var ( 69 | ErrInterrupt = errors.New("execution cancelled due to an interrupt") 70 | ) 71 | -------------------------------------------------------------------------------- /src/types/tasks.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package types contains all the types used by the runner. 5 | package types 6 | 7 | import ( 8 | "github.com/defenseunicorns/maru-runner/src/pkg/variables" 9 | ) 10 | 11 | // TasksFile represents the contents of a tasks file 12 | type TasksFile struct { 13 | Includes []map[string]string `json:"includes,omitempty" jsonschema:"description=List of local task files to include"` 14 | Variables []variables.InteractiveVariable[variables.ExtraVariableInfo] `json:"variables,omitempty" jsonschema:"description=Definitions and default values for variables used in run.yaml"` 15 | Tasks []Task `json:"tasks" jsonschema:"description=The list of tasks that can be run"` 16 | } 17 | 18 | // Task represents a single task 19 | type Task struct { 20 | Name string `json:"name" jsonschema:"description=Name of the task"` 21 | Description string `json:"description,omitempty" jsonschema:"description=Description of the task"` 22 | Actions []Action `json:"actions,omitempty" jsonschema:"description=Actions to take when running the task"` 23 | Inputs map[string]InputParameter `json:"inputs,omitempty" jsonschema:"description=Input parameters for the task"` 24 | EnvPath string `json:"envPath,omitempty" jsonschema:"description=Path to file containing environment variables"` 25 | } 26 | 27 | // InputParameter represents a single input parameter for a task, to be used w/ `with` 28 | type InputParameter struct { 29 | Description string `json:"description" jsonschema:"description=Description of the parameter,required"` 30 | DeprecatedMessage string `json:"deprecatedMessage,omitempty" jsonschema:"description=Message to display when the parameter is deprecated"` 31 | Required bool `json:"required,omitempty" jsonschema:"description=Whether the parameter is required,default=true"` 32 | Default string `json:"default,omitempty" jsonschema:"description=Default value for the parameter"` 33 | } 34 | 35 | // Action is a wrapped BaseAction action inside a Task to provide additional functionality 36 | type Action struct { 37 | *BaseAction[variables.ExtraVariableInfo] `json:",inline"` 38 | 39 | TaskReference string `json:"task,omitempty" jsonschema:"description=The task to run, mutually exclusive with cmd and wait"` 40 | With map[string]string `json:"with,omitempty" jsonschema:"description=Input parameters to pass to the task,type=object"` 41 | If string `json:"if,omitempty" jsonschema:"description=Conditional to determine if the action should run"` 42 | } 43 | 44 | // TaskReference references the name of a task 45 | type TaskReference struct { 46 | Name string `json:"name" jsonschema:"description=Name of the task to run"` 47 | } 48 | -------------------------------------------------------------------------------- /src/pkg/variables/variables.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | package variables 5 | 6 | import ( 7 | "fmt" 8 | "regexp" 9 | ) 10 | 11 | // SetVariableMap represents a map of variable names to their set values 12 | type SetVariableMap[T any] map[string]*SetVariable[T] 13 | 14 | // GetSetVariable gets a variable set within a VariableConfig by its name 15 | func (vc *VariableConfig[T]) GetSetVariable(name string) (variable *SetVariable[T], ok bool) { 16 | variable, ok = vc.setVariableMap[name] 17 | return variable, ok 18 | } 19 | 20 | // GetSetVariables gets the variables set within a VariableConfig 21 | func (vc *VariableConfig[T]) GetSetVariables() SetVariableMap[T] { 22 | return vc.setVariableMap 23 | } 24 | 25 | // PopulateVariables handles setting the active variables within a VariableConfig's SetVariableMap 26 | func (vc *VariableConfig[T]) PopulateVariables(variables []InteractiveVariable[T], presetVariables map[string]string) error { 27 | for name, value := range presetVariables { 28 | var extra T 29 | vc.SetVariable(name, value, "", extra) 30 | } 31 | 32 | for _, variable := range variables { 33 | _, present := vc.setVariableMap[variable.Name] 34 | 35 | // Variable is present, no need to continue checking 36 | if present { 37 | vc.setVariableMap[variable.Name].Pattern = variable.Pattern 38 | vc.setVariableMap[variable.Name].Extra = variable.Extra 39 | if err := vc.CheckVariablePattern(variable.Name); err != nil { 40 | return err 41 | } 42 | continue 43 | } 44 | 45 | // First set default (may be overridden by prompt) 46 | vc.SetVariable(variable.Name, variable.Default, variable.Pattern, variable.Extra) 47 | 48 | // Variable is set to prompt the user 49 | if variable.Prompt { 50 | // Prompt the user for the variable 51 | val, err := vc.prompt(variable) 52 | 53 | if err != nil { 54 | return err 55 | } 56 | 57 | vc.SetVariable(variable.Name, val, variable.Pattern, variable.Extra) 58 | } 59 | 60 | if err := vc.CheckVariablePattern(variable.Name); err != nil { 61 | return err 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | 68 | // SetVariable sets a variable in a VariableConfig's SetVariableMap 69 | func (vc *VariableConfig[T]) SetVariable(name, value, pattern string, extra T) { 70 | vc.setVariableMap[name] = &SetVariable[T]{ 71 | Variable: Variable[T]{ 72 | Name: name, 73 | Pattern: pattern, 74 | Extra: extra, 75 | }, 76 | Value: value, 77 | } 78 | } 79 | 80 | // CheckVariablePattern checks to see if a current variable is set to a value that matches its pattern 81 | func (vc *VariableConfig[T]) CheckVariablePattern(name string) error { 82 | if variable, ok := vc.setVariableMap[name]; ok { 83 | if regexp.MustCompile(variable.Pattern).MatchString(variable.Value) { 84 | return nil 85 | } 86 | 87 | return fmt.Errorf("provided value for variable %q does not match pattern %q", name, variable.Pattern) 88 | } 89 | 90 | return fmt.Errorf("variable %q was not found in the current variable map", name) 91 | } 92 | -------------------------------------------------------------------------------- /src/test/common.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package test contains e2e tests for the runner 5 | package test 6 | 7 | import ( 8 | "context" 9 | "os" 10 | "regexp" 11 | "runtime" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/defenseunicorns/pkg/exec" 16 | "github.com/defenseunicorns/pkg/helpers/v2" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | // MaruE2ETest Struct holding common fields most of the tests will utilize. 21 | type MaruE2ETest struct { 22 | MaruBinPath string 23 | ApplianceMode bool 24 | ApplianceModeKeep bool 25 | RunClusterTests bool 26 | CommandLog []string 27 | } 28 | 29 | // GetCLIName looks at the OS and CPU architecture to determine which Maru binary needs to be run. 30 | func GetCLIName() string { 31 | var binaryName string 32 | if runtime.GOOS == "linux" { 33 | binaryName = "maru" 34 | } else if runtime.GOOS == "darwin" { 35 | if runtime.GOARCH == "arm64" { 36 | binaryName = "maru-mac-apple" 37 | } else { 38 | binaryName = "maru-mac-intel" 39 | } 40 | } 41 | return binaryName 42 | } 43 | 44 | var logRegex = regexp.MustCompile(`Saving log file to (?P.*?\.log)`) 45 | 46 | // Maru executes a run command. 47 | func (e2e *MaruE2ETest) Maru(args ...string) (string, string, error) { 48 | e2e.CommandLog = append(e2e.CommandLog, strings.Join(args, " ")) 49 | return exec.CmdWithContext(context.TODO(), exec.Config{Print: true}, e2e.MaruBinPath, args...) 50 | } 51 | 52 | // CleanFiles removes files and directories that have been created during the test. 53 | func (e2e *MaruE2ETest) CleanFiles(files ...string) { 54 | for _, file := range files { 55 | _ = os.RemoveAll(file) 56 | } 57 | } 58 | 59 | // GetLogFileContents gets the log file contents from a given run's std error. 60 | func (e2e *MaruE2ETest) GetLogFileContents(t *testing.T, stdErr string) string { 61 | get, err := helpers.MatchRegex(logRegex, stdErr) 62 | require.NoError(t, err) 63 | logFile := get("logFile") 64 | logContents, err := os.ReadFile(logFile) 65 | require.NoError(t, err) 66 | return string(logContents) 67 | } 68 | 69 | // GetMaruVersion returns the current build version 70 | func (e2e *MaruE2ETest) GetMaruVersion(t *testing.T) string { 71 | // Get the version of the CLI 72 | stdOut, stdErr, err := e2e.Maru("version") 73 | require.NoError(t, err, stdOut, stdErr) 74 | return strings.Trim(stdOut, "\n") 75 | } 76 | 77 | // GetGitRevision returns the current git revision 78 | func (e2e *MaruE2ETest) GetGitRevision() (string, error) { 79 | out, _, err := exec.Cmd(exec.Config{Print: true}, "git", "rev-parse", "--short", "HEAD") 80 | if err != nil { 81 | return "", err 82 | } 83 | 84 | return strings.TrimSpace(out), nil 85 | } 86 | 87 | // HelmDepUpdate runs 'helm dependency update .' on the given path 88 | func (e2e *MaruE2ETest) HelmDepUpdate(t *testing.T, path string) { 89 | cmd := "helm" 90 | args := strings.Split("dependency update .", " ") 91 | cfg := exec.Config{Print: true, Dir: path} 92 | _, _, err := exec.CmdWithContext(context.TODO(), cfg, cmd, args...) 93 | require.NoError(t, err) 94 | } 95 | -------------------------------------------------------------------------------- /src/cmd/login.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package cmd contains the CLI commands for maru. 5 | package cmd 6 | 7 | import ( 8 | "fmt" 9 | "io" 10 | "os" 11 | "strings" 12 | 13 | "github.com/defenseunicorns/maru-runner/src/config" 14 | "github.com/defenseunicorns/maru-runner/src/config/lang" 15 | "github.com/defenseunicorns/maru-runner/src/message" 16 | "github.com/spf13/cobra" 17 | "github.com/zalando/go-keyring" 18 | ) 19 | 20 | // token is the token to save for the given host 21 | var token string 22 | 23 | // tokenStdIn controls whether to pull the token from standard in 24 | var tokenStdIn bool 25 | 26 | var authCmd = &cobra.Command{ 27 | Use: "auth COMMAND", 28 | PersistentPreRun: func(_ *cobra.Command, _ []string) { 29 | exitOnInterrupt() 30 | cliSetup() 31 | }, 32 | Short: lang.CmdAuthShort, 33 | Run: func(cmd *cobra.Command, _ []string) { 34 | _, _ = fmt.Fprintln(os.Stderr) 35 | err := cmd.Help() 36 | if err != nil { 37 | message.Fatalf(err, "error calling help command") 38 | } 39 | }, 40 | } 41 | 42 | var loginCmd = &cobra.Command{ 43 | Use: "login HOST", 44 | PersistentPreRun: func(_ *cobra.Command, _ []string) { 45 | exitOnInterrupt() 46 | cliSetup() 47 | }, 48 | Short: lang.CmdLoginShort, 49 | ValidArgsFunction: ListAutoCompleteTasks, 50 | Args: cobra.ExactArgs(1), 51 | Run: func(_ *cobra.Command, args []string) { 52 | host := args[0] 53 | 54 | if tokenStdIn { 55 | stdin, err := io.ReadAll(os.Stdin) 56 | if err != nil { 57 | message.Fatalf(err, "Unable to read the token from standard input: %s", err.Error()) 58 | } 59 | 60 | token = strings.TrimSuffix(string(stdin), "\n") 61 | token = strings.TrimSuffix(token, "\r") 62 | } 63 | 64 | if token == "" { 65 | message.Fatalf(nil, "Received empty token - did you mean 'maru auth logout'?") 66 | } 67 | 68 | err := keyring.Set(config.KeyringService, host, token) 69 | if err != nil { 70 | message.Fatalf(err, "Unable to set the token for %s in the keyring: %s", host, err.Error()) 71 | } 72 | }, 73 | } 74 | 75 | var logoutCmd = &cobra.Command{ 76 | Use: "logout HOST", 77 | PersistentPreRun: func(_ *cobra.Command, _ []string) { 78 | exitOnInterrupt() 79 | cliSetup() 80 | }, 81 | Short: lang.CmdLoginShort, 82 | ValidArgsFunction: ListAutoCompleteTasks, 83 | Args: cobra.ExactArgs(1), 84 | Run: func(_ *cobra.Command, args []string) { 85 | host := args[0] 86 | 87 | err := keyring.Delete(config.KeyringService, host) 88 | if err != nil { 89 | message.Fatalf(err, "Unable to remove the token for %s in the keyring: %s", host, err.Error()) 90 | } 91 | }, 92 | } 93 | 94 | func init() { 95 | initViper() 96 | rootCmd.AddCommand(authCmd) 97 | authCmd.AddCommand(loginCmd) 98 | loginFlags := loginCmd.Flags() 99 | loginFlags.StringVarP(&token, "token", "t", "", lang.CmdLoginTokenFlag) 100 | loginFlags.BoolVar(&tokenStdIn, "token-stdin", false, lang.CmdLoginTokenStdInFlag) 101 | 102 | authCmd.AddCommand(logoutCmd) 103 | } 104 | -------------------------------------------------------------------------------- /src/message/spinner.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package message provides a rich set of functions for displaying messages to the user. 5 | package message 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "fmt" 11 | "os" 12 | "strings" 13 | 14 | "github.com/defenseunicorns/pkg/helpers/v2" 15 | "github.com/pterm/pterm" 16 | ) 17 | 18 | var activeSpinner *Spinner 19 | 20 | var sequence = []string{" ⬒ ", " ⬔ ", " ◨ ", " ◪ ", " ⬓ ", " ⬕ ", " ◧ ", " ◩ "} 21 | 22 | // NoProgress sets whether the default spinners and progress bars should use fancy animations 23 | var NoProgress bool 24 | 25 | // Spinner is a wrapper around pterm.SpinnerPrinter. 26 | type Spinner struct { 27 | spinner *pterm.SpinnerPrinter 28 | termWidth int 29 | } 30 | 31 | // NewProgressSpinner creates a new progress spinner. 32 | var NewProgressSpinner = func(format string, a ...any) helpers.ProgressWriter { 33 | if activeSpinner != nil { 34 | activeSpinner.Updatef(format, a...) 35 | debugPrinter(2, "Active spinner already exists") 36 | return activeSpinner 37 | } 38 | 39 | var spinner *pterm.SpinnerPrinter 40 | if NoProgress { 41 | infof(format, a...) 42 | } else { 43 | text := pterm.Sprintf(format, a...) 44 | spinner, _ = pterm.DefaultSpinner. 45 | WithRemoveWhenDone(false). 46 | // Src: https://github.com/gernest/wow/blob/master/spin/spinners.go#L335 47 | WithSequence(sequence...). 48 | Start(text) 49 | } 50 | 51 | activeSpinner = &Spinner{ 52 | spinner: spinner, 53 | termWidth: pterm.GetTerminalWidth(), 54 | } 55 | 56 | return activeSpinner 57 | } 58 | 59 | // Write the given text to the spinner. 60 | func (p *Spinner) Write(raw []byte) (int, error) { 61 | size := len(raw) 62 | if NoProgress { 63 | os.Stderr.Write(raw) 64 | 65 | return size, nil 66 | } 67 | 68 | // Split the text into lines and update the spinner for each line. 69 | scanner := bufio.NewScanner(bytes.NewReader(raw)) 70 | scanner.Split(bufio.ScanLines) 71 | for scanner.Scan() { 72 | text := pterm.Sprintf(" %s", scanner.Text()) 73 | // Clear the current line with the ANSI escape code 74 | pterm.Fprinto(p.spinner.Writer, "\033[K") 75 | pterm.Fprintln(p.spinner.Writer, text) 76 | } 77 | 78 | return size, nil 79 | } 80 | 81 | // Updatef updates the spinner text. 82 | func (p *Spinner) Updatef(format string, a ...any) { 83 | if NoProgress { 84 | debugPrinter(2, fmt.Sprintf(format, a...)) 85 | return 86 | } 87 | 88 | pterm.Fprinto(p.spinner.Writer, strings.Repeat(" ", pterm.GetTerminalWidth())) 89 | text := pterm.Sprintf(format, a...) 90 | p.spinner.UpdateText(text) 91 | } 92 | 93 | // Close stops the spinner. 94 | func (p *Spinner) Close() error { 95 | if p.spinner != nil && p.spinner.IsActive { 96 | return p.spinner.Stop() 97 | } 98 | activeSpinner = nil 99 | return nil 100 | } 101 | 102 | // Successf prints a success message with the spinner and stops it. 103 | func (p *Spinner) Successf(format string, a ...any) { 104 | if p.spinner != nil { 105 | text := pterm.Sprintf(format, a...) 106 | p.spinner.Success(text) 107 | } else { 108 | successf(format, a...) 109 | } 110 | p.Close() 111 | } 112 | 113 | // Failf prints an error message with the spinner. 114 | func (p *Spinner) Failf(format string, a ...any) { 115 | if p.spinner != nil { 116 | text := pterm.Sprintf(format, a...) 117 | p.spinner.Fail(text) 118 | } else { 119 | errorf(format, a...) 120 | } 121 | p.Close() 122 | } 123 | -------------------------------------------------------------------------------- /src/cmd/root.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package cmd contains the CLI commands for maru. 5 | package cmd 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | 13 | "github.com/defenseunicorns/maru-runner/src/config" 14 | "github.com/defenseunicorns/maru-runner/src/config/lang" 15 | "github.com/defenseunicorns/maru-runner/src/message" 16 | "github.com/defenseunicorns/maru-runner/src/pkg/utils" 17 | "github.com/pterm/pterm" 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | var logLevelString string 22 | var skipLogFile bool 23 | 24 | var rootCmd = &cobra.Command{ 25 | Use: "maru COMMAND", 26 | PersistentPreRun: func(cmd *cobra.Command, _ []string) { 27 | exitOnInterrupt() 28 | 29 | // Don't add the logo to the help command 30 | if cmd.Parent() == nil { 31 | skipLogFile = true 32 | } 33 | cliSetup() 34 | }, 35 | Short: lang.RootCmdShort, 36 | Run: func(cmd *cobra.Command, _ []string) { 37 | _, _ = fmt.Fprintln(os.Stderr) 38 | err := cmd.Help() 39 | if err != nil { 40 | message.Fatalf(err, "error calling help command") 41 | } 42 | }, 43 | } 44 | 45 | // Execute is the entrypoint for the CLI. 46 | func Execute() { 47 | cobra.CheckErr(rootCmd.Execute()) 48 | } 49 | 50 | // RootCmd returns the root command. 51 | func RootCmd() *cobra.Command { 52 | return rootCmd 53 | } 54 | 55 | func init() { 56 | initViper() 57 | 58 | v.SetDefault(V_LOG_LEVEL, "info") 59 | v.SetDefault(V_ARCHITECTURE, "") 60 | v.SetDefault(V_NO_LOG_FILE, true) 61 | v.SetDefault(V_TMP_DIR, "") 62 | 63 | rootCmd.PersistentFlags().StringVarP(&logLevelString, "log-level", "l", v.GetString(V_LOG_LEVEL), lang.RootCmdFlagLogLevel) 64 | rootCmd.PersistentFlags().BoolVar(&message.NoProgress, "no-progress", v.GetBool(V_NO_PROGRESS), lang.RootCmdFlagNoProgress) 65 | rootCmd.PersistentFlags().BoolVar(&skipLogFile, "no-log-file", v.GetBool(V_NO_LOG_FILE), lang.RootCmdFlagSkipLogFile) 66 | rootCmd.PersistentFlags().StringVar(&config.TempDirectory, "tmpdir", v.GetString(V_TMP_DIR), lang.RootCmdFlagTempDir) 67 | } 68 | 69 | func cliSetup() { 70 | match := map[string]message.LogLevel{ 71 | "warn": message.WarnLevel, 72 | "info": message.InfoLevel, 73 | "debug": message.DebugLevel, 74 | "trace": message.TraceLevel, 75 | "error": message.ErrorLevel, 76 | } 77 | 78 | printViperConfigUsed() 79 | 80 | // No log level set, so use the default 81 | if logLevelString != "" { 82 | if lvl, ok := match[logLevelString]; ok { 83 | message.SetLogLevel(lvl) 84 | message.SLog.Debug(fmt.Sprintf("Log level set to %q", logLevelString)) 85 | } else { 86 | message.SLog.Warn(lang.RootCmdErrInvalidLogLevel) 87 | } 88 | } 89 | 90 | // Intentionally set logging to avoid problems when vendored 91 | if listTasks != listOff || listAllTasks != listOff { 92 | pterm.SetDefaultOutput(os.Stdout) 93 | } else if skipLogFile { 94 | pterm.SetDefaultOutput(os.Stderr) 95 | } else { 96 | if err := utils.UseLogFile(); err != nil { 97 | message.SLog.Warn(fmt.Sprintf("Unable to setup log file: %s", err.Error())) 98 | } 99 | } 100 | 101 | if os.Getenv("CI") == "true" { 102 | message.NoProgress = true 103 | } 104 | } 105 | 106 | // exitOnInterrupt catches an interrupt and exits with fatal error 107 | func exitOnInterrupt() { 108 | c := make(chan os.Signal, 1) 109 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 110 | go func() { 111 | <-c 112 | message.Fatalf(lang.ErrInterrupt, "%s", lang.ErrInterrupt.Error()) 113 | }() 114 | } 115 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release Maru on Tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Checkout the repo and setup the tooling for this job 13 | - name: Checkout 14 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Setup golang 19 | uses: ./.github/actions/golang 20 | 21 | - name: Build CLI 22 | run: | 23 | make build-cli-linux-amd 24 | 25 | # Upload the contents of the build directory for later stages to use 26 | - name: Upload build artifacts 27 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 28 | with: 29 | name: build-artifacts 30 | path: build/ 31 | retention-days: 1 32 | 33 | validate: 34 | runs-on: ubuntu-latest 35 | permissions: 36 | packages: write 37 | needs: build 38 | steps: 39 | # Checkout the repo and setup the tooling for this job 40 | - name: Checkout 41 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 42 | with: 43 | fetch-depth: 0 44 | 45 | - name: Download build artifacts 46 | uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 47 | with: 48 | name: build-artifacts 49 | path: build/ 50 | 51 | - name: Install Zarf 52 | uses: ./.github/actions/zarf 53 | 54 | - name: Setup golang 55 | uses: ./.github/actions/golang 56 | 57 | - name: Make maru executable 58 | run: | 59 | chmod +x build/maru 60 | 61 | - name: Run unit tests 62 | run: | 63 | make test-unit 64 | 65 | - name: Run e2e tests 66 | run: | 67 | make test-e2e 68 | env: 69 | MARU_AUTH: '{"gitlab.com": "${{ secrets.MARU_GITLAB_TOKEN }}"}' 70 | 71 | - name: Save logs 72 | if: always() 73 | uses: ./.github/actions/save-logs 74 | 75 | push: 76 | runs-on: ubuntu-latest 77 | environment: release 78 | needs: validate 79 | permissions: 80 | contents: write 81 | steps: 82 | - name: Checkout 83 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 84 | with: 85 | fetch-depth: 0 86 | 87 | - name: Setup golang 88 | uses: ./.github/actions/golang 89 | 90 | - name: Install tools 91 | uses: ./.github/actions/install-tools 92 | 93 | - name: Download build artifacts 94 | uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 95 | with: 96 | name: build-artifacts 97 | path: build/ 98 | 99 | - name: Skip brew latest for pre-release tags 100 | run: | 101 | if [[ $GITHUB_REF_NAME == *"rc"* ]]; then 102 | echo "BREW_NAME=maru@latest-rc" >> $GITHUB_ENV 103 | else 104 | echo "BREW_NAME=maru" >> $GITHUB_ENV 105 | fi 106 | 107 | - name: Get Brew tap repo token 108 | id: brew-tap-token 109 | uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 110 | with: 111 | app-id: ${{ secrets.HOMEBREW_TAP_WORKFLOW_GITHUB_APP_ID }} 112 | private-key: ${{ secrets.HOMEBREW_TAP_WORKFLOW_GITHUB_APP_SECRET }} 113 | owner: defenseunicorns 114 | repositories: homebrew-tap 115 | 116 | - name: Run GoReleaser 117 | uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 118 | with: 119 | distribution: goreleaser 120 | version: latest 121 | args: release --clean --verbose 122 | env: 123 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 124 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.brew-tap-token.outputs.token }} 125 | -------------------------------------------------------------------------------- /src/test/tasks/conditionals/tasks.yaml: -------------------------------------------------------------------------------- 1 | variables: 2 | - name: FOO 3 | default: default-value 4 | - name: BAR 5 | default: default-value 6 | - name: VAL1 7 | default: "5" 8 | - name: VAL2 9 | default: "10" 10 | 11 | tasks: 12 | 13 | - name: false-conditional-with-var-cmd 14 | actions: 15 | - cmd: echo "This should not run because .variables.BAR != default-value" 16 | description: false-conditional-with-var-cmd 17 | if: ${{ eq .variables.BAR "default-value1" }} 18 | 19 | - name: true-conditional-with-var-cmd 20 | actions: 21 | - cmd: echo "This should run because .variables.BAR = default-value" 22 | description: true-conditional-with-var-cmd 23 | if: ${{ eq .variables.BAR "default-value" }} 24 | 25 | - name: empty-conditional-cmd 26 | actions: 27 | - cmd: echo "This should run because there is no condition" 28 | description: empty-conditional-cmd 29 | 30 | - name: empty-conditional-task 31 | actions: 32 | - task: included-task 33 | 34 | - name: true-conditional-task 35 | actions: 36 | - task: included-task 37 | if: ${{ eq .variables.BAR "default-value" }} 38 | 39 | - name: false-conditional-task 40 | actions: 41 | - task: included-task 42 | if: ${{ eq .variables.BAR "default-value1" }} 43 | 44 | - name: true-conditional-nested-task-comp-var-inputs 45 | actions: 46 | - task: included-task-with-inputs 47 | with: 48 | val: "5" 49 | 50 | - name: false-conditional-nested-task-comp-var-inputs 51 | actions: 52 | - task: included-task-with-inputs 53 | with: 54 | val: "7" 55 | 56 | - name: true-conditional-nested-nested-task-comp-var-inputs 57 | actions: 58 | - task: included-task-with-inputs-and-nested-task 59 | with: 60 | val: "5" 61 | 62 | - name: false-conditional-nested-nested-task-comp-var-inputs 63 | actions: 64 | - task: included-task-with-inputs-and-nested-task 65 | with: 66 | val: "7" 67 | 68 | - name: true-conditional-nested-nested-nested-task-comp-var-inputs 69 | actions: 70 | - task: included-task-with-inputs-and-nested-nested-task 71 | with: 72 | val: "5" 73 | 74 | - name: false-conditional-nested-nested-nested-task-comp-var-inputs 75 | actions: 76 | - task: included-task-with-inputs-and-nested-nested-task 77 | with: 78 | val: "7" 79 | 80 | - name: true-condition-var-as-input-original-syntax-nested-nested-with-comp 81 | actions: 82 | - task: included-task-with-inputs-and-nested-nested-task 83 | with: 84 | val: ${VAL1} 85 | 86 | - name: true-condition-var-as-input-new-syntax-nested-nested-with-comp 87 | actions: 88 | - task: included-task-with-inputs-and-nested-nested-task 89 | with: 90 | val: ${{ .variables.VAL1 }} 91 | 92 | - name: included-task 93 | actions: 94 | - cmd: echo "Task called successfully" 95 | 96 | - name: included-task-with-inputs 97 | inputs: 98 | val: 99 | description: has no default 100 | actions: 101 | - cmd: echo "input val equals ${{ .inputs.val }} and variable VAL1 equals ${{ .variables.VAL1 }}" 102 | description: "included-task-with-inputs" 103 | if: ${{ eq .inputs.val .variables.VAL1 }} 104 | 105 | - name: included-task-with-inputs-and-nested-task 106 | inputs: 107 | val: 108 | description: has no default 109 | actions: 110 | - task: included-task 111 | if: ${{ eq .inputs.val .variables.VAL1 }} 112 | 113 | 114 | - name: included-task-with-inputs-and-nested-nested-task 115 | inputs: 116 | val: 117 | description: has no default 118 | actions: 119 | - task: included-task-nested 120 | with: 121 | val2: ${{ .inputs.val }} 122 | 123 | - name: included-task-nested 124 | inputs: 125 | val2: 126 | description: has no default 127 | actions: 128 | - cmd: echo "input val2 equals ${{ .inputs.val2 }} and variable VAL1 equals ${{ .variables.VAL1 }}" 129 | if: ${{ eq .inputs.val2 .variables.VAL1 }} 130 | -------------------------------------------------------------------------------- /src/message/logging_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package message provides a rich set of functions for displaying messages to the user. 5 | package message 6 | 7 | import ( 8 | "bytes" 9 | "log/slog" 10 | "slices" 11 | "strings" 12 | 13 | "testing" 14 | 15 | "github.com/pterm/pterm" 16 | ) 17 | 18 | func Test_LogLevel_Diff(t *testing.T) { 19 | maruLogger := slog.New(MaruHandler{}) 20 | 21 | cases := map[string]struct { 22 | // the level we're set to log at with SetLogLevel(). We expect logs with 23 | // a lower level to be ignored. 24 | setLevel LogLevel 25 | // the level which we will log, e.g. SLog.Debug(), SLog.Info(), etc. 26 | logLevel LogLevel 27 | // the expected output of the log. We special case DebugLevel as it 28 | // should contain a timestamp. 29 | expected string 30 | }{ 31 | "DebugLevel": { 32 | setLevel: DebugLevel, 33 | logLevel: DebugLevel, 34 | expected: "DEBUG test", 35 | }, 36 | "InfoInfoLevel": { 37 | setLevel: InfoLevel, 38 | logLevel: InfoLevel, 39 | expected: "INFO test", 40 | }, 41 | "InfoWarnLevel": { 42 | setLevel: InfoLevel, 43 | logLevel: WarnLevel, 44 | expected: "WARNING test", 45 | }, 46 | "WarnInfoLevel": { 47 | setLevel: WarnLevel, 48 | logLevel: InfoLevel, 49 | expected: "", 50 | }, 51 | "InfoErrorLevel": { 52 | setLevel: InfoLevel, 53 | logLevel: ErrorLevel, 54 | expected: "ERROR test", 55 | }, 56 | "TraceInfoLevel": { 57 | setLevel: TraceLevel, 58 | logLevel: InfoLevel, 59 | expected: "INFO test", 60 | }, 61 | "TraceDebugLevel": { 62 | setLevel: TraceLevel, 63 | logLevel: DebugLevel, 64 | expected: "DEBUG test", 65 | }, 66 | "TraceErrorLevel": { 67 | setLevel: TraceLevel, 68 | logLevel: ErrorLevel, 69 | expected: "ERROR test", 70 | }, 71 | "ErrorWarnLevel": { 72 | setLevel: ErrorLevel, 73 | logLevel: WarnLevel, 74 | expected: "", 75 | }, 76 | "ErrorErrorLevel": { 77 | setLevel: ErrorLevel, 78 | logLevel: ErrorLevel, 79 | expected: "ERROR test", 80 | }, 81 | "ErrorInfoLevel": { 82 | setLevel: ErrorLevel, 83 | logLevel: InfoLevel, 84 | expected: "", 85 | }, 86 | } 87 | 88 | for name, tc := range cases { 89 | t.Run(name, func(t *testing.T) { 90 | SetLogLevel(tc.setLevel) 91 | 92 | // set the underlying writer, like we do in utils/utils.go 93 | var outBuf bytes.Buffer 94 | pterm.SetDefaultOutput(&outBuf) 95 | 96 | switch tc.logLevel { 97 | case DebugLevel: 98 | maruLogger.Debug("test") 99 | case InfoLevel: 100 | maruLogger.Info("test") 101 | case WarnLevel: 102 | maruLogger.Warn("test") 103 | case ErrorLevel: 104 | maruLogger.Error("test") 105 | } 106 | content := outBuf.String() 107 | // remove color codes 108 | content = pterm.RemoveColorFromString(content) 109 | // remove extra whitespace from the output 110 | content = strings.TrimSpace(content) 111 | parts := strings.Split(tc.expected, " ") 112 | for _, part := range parts { 113 | if !strings.Contains(content, part) { 114 | t.Errorf("Expected debug message to contain '%s', but it didn't: (%s)", part, content) 115 | } 116 | } 117 | // if the set level is Trace and the log level is Debug, then we 118 | // expect extra debug lines to be printed. Conversely, if it's trace 119 | // but not Debug, then we expect no extra debug lines to be printed. 120 | partsOutput := strings.Split(content, " ") 121 | // when debugging with TraceLevel, spliting on spaces will result in a slice 122 | // like so: 123 | // []string{ 124 | // "DEBUG", 125 | // "", 126 | // "", 127 | // "2024-09-19T10:21:16-05:00", 128 | // "", 129 | // "-", 130 | // "", 131 | // "test\n└", 132 | // "(/Users/clint/go/github.com/defenseunicorns/maru-runner/src/message/slog.go:56)", 133 | // } 134 | // 135 | // here we sort the slice to move the timestamp to the front, 136 | // then compact to remove them. The result should be a slice of 137 | // 6 eleements. 138 | // 139 | // While debugging without trace level, we expect the same slice 140 | // except there is no file name and line number, so it would have 5 141 | // elements. 142 | slices.Sort(partsOutput) 143 | partsOutput = slices.Compact(partsOutput) 144 | expectedLen := 3 145 | if tc.logLevel == DebugLevel { 146 | expectedLen = 5 147 | } 148 | if tc.setLevel == TraceLevel && tc.logLevel == DebugLevel { 149 | expectedLen = 6 150 | } 151 | 152 | if len(partsOutput) > expectedLen { 153 | t.Errorf("Expected debug message to contain timestamp, but it didn't: (%s)", content) 154 | } 155 | }) 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /src/types/actions.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package types contains all the types used by the runner. 5 | package types 6 | 7 | import ( 8 | "github.com/defenseunicorns/maru-runner/src/pkg/variables" 9 | "github.com/defenseunicorns/pkg/exec" 10 | ) 11 | 12 | // ActionDefaults sets the default configs for actions and represents an interface shared with Zarf 13 | type ActionDefaults struct { 14 | Env []string `json:"env,omitempty" jsonschema:"description=Additional environment variables for commands"` 15 | Mute bool `json:"mute,omitempty" jsonschema:"description=Hide the output of commands during execution (default false)"` 16 | MaxTotalSeconds int `json:"maxTotalSeconds,omitempty" jsonschema:"description=Default timeout in seconds for commands (default to 0, no timeout)"` 17 | MaxRetries int `json:"maxRetries,omitempty" jsonschema:"description=Retry commands given number of times if they fail (default 0)"` 18 | Dir string `json:"dir,omitempty" jsonschema:"description=Working directory for commands (default CWD)"` 19 | Shell exec.ShellPreference `json:"shell,omitempty" jsonschema:"description=(cmd only) Indicates a preference for a shell for the provided cmd to be executed in on supported operating systems"` 20 | } 21 | 22 | // BaseAction represents a single action to run and represents an interface shared with Zarf 23 | type BaseAction[T any] struct { 24 | Description string `json:"description,omitempty" jsonschema:"description=Description of the action to be displayed during package execution instead of the command"` 25 | Cmd string `json:"cmd,omitempty" jsonschema:"description=The command to run. Must specify either cmd or wait for the action to do anything."` 26 | Wait *ActionWait `json:"wait,omitempty" jsonschema:"description=Wait for a condition to be met before continuing. Must specify either cmd or wait for the action."` 27 | Env []string `json:"env,omitempty" jsonschema:"description=Additional environment variables to set for the command"` 28 | Mute *bool `json:"mute,omitempty" jsonschema:"description=Hide the output of the command during package deployment (default false)"` 29 | MaxTotalSeconds *int `json:"maxTotalSeconds,omitempty" jsonschema:"description=Timeout in seconds for the command (default to 0, no timeout for cmd actions and 300, 5 minutes for wait actions)"` 30 | MaxRetries *int `json:"maxRetries,omitempty" jsonschema:"description=Retry the command if it fails up to given number of times (default 0)"` 31 | Dir *string `json:"dir,omitempty" jsonschema:"description=The working directory to run the command in (default is CWD)"` 32 | Shell *exec.ShellPreference `json:"shell,omitempty" jsonschema:"description=(cmd only) Indicates a preference for a shell for the provided cmd to be executed in on supported operating systems"` 33 | SetVariables []variables.Variable[T] `json:"setVariables,omitempty" jsonschema:"description=(onDeploy/cmd only) An array of variables to update with the output of the command. These variables will be available to all remaining actions and components in the package."` 34 | } 35 | 36 | // ActionWait specifies a condition to wait for before continuing 37 | type ActionWait struct { 38 | Cluster *ActionWaitCluster `json:"cluster,omitempty" jsonschema:"description=Wait for a condition to be met in the cluster before continuing. Only one of cluster or network can be specified."` 39 | Network *ActionWaitNetwork `json:"network,omitempty" jsonschema:"description=Wait for a condition to be met on the network before continuing. Only one of cluster or network can be specified."` 40 | } 41 | 42 | // ActionWaitCluster specifies a condition to wait for before continuing 43 | type ActionWaitCluster struct { 44 | Kind string `json:"kind" jsonschema:"description=The kind of resource to wait for,example=Pod,example=Deployment)"` 45 | Identifier string `json:"name" jsonschema:"description=The name of the resource or selector to wait for,example=podinfo,example=app=podinfo"` 46 | Namespace string `json:"namespace,omitempty" jsonschema:"description=The namespace of the resource to wait for"` 47 | Condition string `json:"condition,omitempty" jsonschema:"description=The condition or jsonpath state to wait for; defaults to exist, a special condition that will wait for the resource to exist,example=Ready,example=Available,'{.status.availableReplicas}'=23"` 48 | } 49 | 50 | // ActionWaitNetwork specifies a condition to wait for before continuing 51 | type ActionWaitNetwork struct { 52 | Protocol string `json:"protocol" jsonschema:"description=The protocol to wait for,enum=tcp,enum=http,enum=https"` 53 | Address string `json:"address" jsonschema:"description=The address to wait for,example=localhost:8080,example=1.1.1.1"` 54 | Code int `json:"code,omitempty" jsonschema:"description=The HTTP status code to wait for if using http or https,example=200,example=404"` 55 | } 56 | -------------------------------------------------------------------------------- /src/test/tasks/tasks.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=../../../tasks.schema.json 2 | includes: 3 | - foo: ./more-tasks/foo.yaml 4 | - intentional: ./loop-task.yaml 5 | - remote: https://raw.githubusercontent.com/defenseunicorns/maru-runner/${GIT_REVISION}/src/test/tasks/remote-import-tasks.yaml 6 | # This tests that Maru uses the correct Accept Header for the GitHub API when that is used 7 | - remote-api: https://api.github.com/repos/defenseunicorns/maru-runner/contents/src/test/tasks/tasks-no-default.yaml?ref=${GIT_REVISION} 8 | # This tests that Maru properly handles authentication and GitLab paths (which are URL encoded) 9 | - remote-gitlab: https://gitlab.com/api/v4/projects/66014760/repository/files/tasks%2Eyaml/raw 10 | 11 | variables: 12 | - name: REPLACE_ME 13 | default: replaced 14 | - name: FOO_VAR 15 | default: default 16 | - name: TO_BE_OVERWRITTEN 17 | default: default 18 | - name: COOL_DIR 19 | default: src/test/tasks 20 | - name: COOL_FILE 21 | default: my-env 22 | 23 | tasks: 24 | - name: default 25 | description: Run Default Task 26 | actions: 27 | - cmd: echo "This is the default task" 28 | - name: echo-env-var 29 | description: Test that env vars take precedence over var defaults 30 | actions: 31 | - cmd: echo "${TO_BE_OVERWRITTEN}" 32 | - name: remote-import 33 | actions: 34 | - task: remote:echo-var 35 | - name: remote-import-to-local 36 | actions: 37 | - task: remote:local-baz 38 | - name: action 39 | actions: 40 | - cmd: echo "specific test string" 41 | - name: interactive 42 | description: Run an interactive task 43 | actions: 44 | - description: Create a spinner that spins 45 | cmd: | 46 | printf '\033[G ⬒ Spinning...' 47 | sleep 0.1 48 | printf '\033[G ⬔ Spinning...' 49 | sleep 0.1 50 | printf '\033[G ◨ Spinning...' 51 | sleep 0.1 52 | printf '\033[G ◪ Spinning...' 53 | sleep 0.1 54 | printf '\033[G ⬓ Spinning...' 55 | sleep 0.1 56 | printf '\033[G ⬕ Spinning...' 57 | sleep 0.1 58 | printf '\033[G ◧ Spinning...' 59 | sleep 0.1 60 | printf '\033[G ◩ Spinning...' 61 | - name: cmd-set-variable 62 | actions: 63 | - cmd: echo unique-value 64 | mute: true 65 | setVariables: 66 | - name: ACTION_VAR 67 | - cmd: echo "I'm set from setVariables - ${ACTION_VAR}" 68 | - cmd: echo "I'm set from a runner var - ${REPLACE_ME}" 69 | - cmd: echo "I'm set from a --set var - ${REPLACE_ME}" 70 | - cmd: echo "I'm set from a --set var - $REPLACE_ME" 71 | - cmd: echo "I'm set from a new --set var - ${UNICORNS}" 72 | - cmd: echo "I'm set automatically - MARU=${MARU}" 73 | - cmd: MARU=hello; echo "I was changed on the command line - MARU=${MARU}" 74 | - name: print-common-env 75 | actions: 76 | - cmd: echo MARU=[$MARU] 77 | - name: reference 78 | actions: 79 | - task: referenced 80 | - name: referenced 81 | actions: 82 | - cmd: echo "other-task" 83 | - name: recursive 84 | actions: 85 | - task: recursed 86 | - name: recursed 87 | actions: 88 | - task: recursed1 89 | - name: recursed1 90 | actions: 91 | - task: recursive 92 | - name: rerun-tasks-child 93 | actions: 94 | - task: rerun-tasks 95 | - name: rerun-tasks 96 | actions: 97 | - task: rerunnable-task 98 | - task: rerunnable-task 99 | - task: rerunnable-task2 100 | - name: rerunnable-task 101 | actions: 102 | - task: rerunnable-echo 103 | - name: rerunnable-task2 104 | actions: 105 | - task: rerunnable-task 106 | - name: rerunnable-echo 107 | actions: 108 | - cmd: echo "I should be able to be called over and over within reason." 109 | - name: rerun-tasks-recursive 110 | actions: 111 | - task: rerunnable-task 112 | - task: rerunnable-task 113 | - task: recursive 114 | - name: foobar 115 | actions: 116 | - task: foo:foobar 117 | - name: more-foobar 118 | actions: 119 | - task: foo:foobar 120 | - task: remote:echo-var 121 | - name: extra-foobar 122 | actions: 123 | - task: more-foobar 124 | - name: more-foo 125 | actions: 126 | - task: foo:fooybar 127 | - task: foo:foobar 128 | - name: wait-success 129 | actions: 130 | - maxTotalSeconds: 1 131 | wait: 132 | network: 133 | protocol: tcp 134 | address: githubstatus.com:443 135 | - name: wait-fail 136 | actions: 137 | - maxTotalSeconds: 1 138 | wait: 139 | network: 140 | cluster: 141 | kind: StatefulSet 142 | name: cool-name 143 | namespace: tasks 144 | - name: include-loop 145 | actions: 146 | - task: intentional:loop 147 | - name: env-from-file 148 | envPath: "./my-env" 149 | actions: 150 | - cmd: echo $SECRET_KEY 151 | - cmd: echo $PORT 152 | - cmd: echo $SPECIAL 153 | - task: pass-env-vars 154 | - task: overwrite-env-path 155 | - name: pass-env-vars 156 | actions: 157 | - cmd: echo env var from calling task - $SECRET_KEY 158 | - name: overwrite-env-path 159 | envPath: "./my-other-env" 160 | actions: 161 | - cmd: echo overwritten env var - $PORT 162 | - name: file-and-dir 163 | description: Tests setting dir from variable 164 | actions: 165 | - cmd: cat ${COOL_FILE} 166 | dir: ${COOL_DIR} 167 | - name: env-templating 168 | description: Tests setting an env var from variable 169 | actions: 170 | - cmd: echo ${HELLO_KITTEH} 171 | env: 172 | - HELLO_KITTEH=hello-${REPLACE_ME} 173 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official email address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | pepr-dev-private@googlegroups.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /src/pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package utils provides utility fns for maru 5 | package utils 6 | 7 | import ( 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "path" 14 | "regexp" 15 | "strings" 16 | 17 | "github.com/defenseunicorns/maru-runner/src/config" 18 | "github.com/defenseunicorns/maru-runner/src/message" 19 | "github.com/defenseunicorns/pkg/helpers/v2" 20 | goyaml "github.com/goccy/go-yaml" 21 | "github.com/pterm/pterm" 22 | "github.com/zalando/go-keyring" 23 | ) 24 | 25 | const ( 26 | tmpPathPrefix = "maru-" 27 | ) 28 | 29 | // Regex to match the GitLab repo files api, test: https://regex101.com/r/mBXuyM/1 30 | var gitlabAPIRegex = regexp.MustCompile(`\/api\/v4\/projects\/(?P\d+)\/repository\/files\/(?P[^\/]+)\/raw`) 31 | 32 | // UseLogFile writes output to stderr and a logFile. 33 | func UseLogFile() error { 34 | writer, err := message.UseLogFile("") 35 | logFile := writer 36 | if err != nil { 37 | return err 38 | } 39 | message.SLog.Info(fmt.Sprintf("Saving log file to %s", message.LogFileLocation())) 40 | logWriter := io.MultiWriter(os.Stderr, logFile) 41 | pterm.SetDefaultOutput(logWriter) 42 | return nil 43 | } 44 | 45 | // MergeEnv merges two environment variable arrays, 46 | // replacing variables found in env2 with variables from env1 47 | // otherwise appending the variable from env1 to env2 48 | func MergeEnv(env1, env2 []string) []string { 49 | envMap := make(map[string]string) 50 | 51 | // First, populate the map with env2's values for quick lookup. 52 | for _, s := range env2 { 53 | split := strings.SplitN(s, "=", 2) 54 | if len(split) == 2 { 55 | envMap[split[0]] = split[1] 56 | } 57 | } 58 | 59 | // Then, update the map with env1's values, effectively merging them. 60 | for _, s := range env1 { 61 | split := strings.SplitN(s, "=", 2) 62 | if len(split) == 2 { 63 | envMap[split[0]] = split[1] 64 | } 65 | } 66 | 67 | result := make([]string, 0, len(envMap)) 68 | // Finally, reconstruct the environment array from the map. 69 | for key, value := range envMap { 70 | result = append(result, key+"="+value) 71 | } 72 | 73 | return result 74 | } 75 | 76 | // FormatEnvVar format environment variables replacing non-alphanumeric characters with underscores and adding INPUT_ prefix 77 | func FormatEnvVar(name, value string) string { 78 | // replace all non-alphanumeric characters with underscores 79 | name = regexp.MustCompile(`[^a-zA-Z0-9]+`).ReplaceAllString(name, "_") 80 | name = strings.ToUpper(name) 81 | // prefix with INPUT_ (same as GitHub Actions) 82 | return fmt.Sprintf("INPUT_%s=%s", name, value) 83 | } 84 | 85 | // ReadYaml reads a yaml file and unmarshals it into a given config. 86 | func ReadYaml(path string, destConfig any) error { 87 | file, err := os.ReadFile(path) 88 | if err != nil { 89 | return fmt.Errorf("cannot %s", err.Error()) 90 | } 91 | 92 | err = goyaml.Unmarshal(file, destConfig) 93 | if err != nil { 94 | errStr := err.Error() 95 | lines := strings.SplitN(errStr, "\n", 2) 96 | return fmt.Errorf("cannot unmarshal %s: %s", path, lines[0]) 97 | } 98 | 99 | return nil 100 | } 101 | 102 | // MakeTempDir creates a temp directory with the maru- prefix. 103 | func MakeTempDir(basePath string) (string, error) { 104 | if basePath != "" { 105 | if err := helpers.CreateDirectory(basePath, helpers.ReadWriteExecuteUser); err != nil { 106 | return "", err 107 | } 108 | } 109 | 110 | tmp, err := os.MkdirTemp(basePath, tmpPathPrefix) 111 | if err != nil { 112 | return "", err 113 | } 114 | 115 | message.SLog.Debug(fmt.Sprintf("Using temporary directory: %s", tmp)) 116 | 117 | return tmp, nil 118 | } 119 | 120 | // JoinURLRepoPath joins a path in a URL (detecting the URL type) 121 | func JoinURLRepoPath(currentURL *url.URL, includeFilePath string) (*url.URL, error) { 122 | currPath := currentURL.Path 123 | if currentURL.RawPath != "" { 124 | currPath = currentURL.RawPath 125 | } 126 | 127 | var joinedPath string 128 | 129 | get, err := helpers.MatchRegex(gitlabAPIRegex, currPath) 130 | if err != nil { 131 | joinedPath = path.Join(path.Dir(currPath), includeFilePath) 132 | if currentURL.RawPath == "" { 133 | currentURL.Path = joinedPath 134 | } else { 135 | currentURL.Path, err = url.PathUnescape(joinedPath) 136 | if err != nil { 137 | return currentURL, err 138 | } 139 | currentURL.RawPath = joinedPath 140 | } 141 | return currentURL, nil 142 | } 143 | 144 | escapedPath := get("path") 145 | repoID := get("repoID") 146 | unescapedPath, err := url.PathUnescape(escapedPath) 147 | if err != nil { 148 | return currentURL, err 149 | } 150 | 151 | joinedPath = path.Join(path.Dir(unescapedPath), includeFilePath) 152 | currentURL.Path = fmt.Sprintf("/api/v4/projects/%s/repository/files/%s/raw", repoID, joinedPath) 153 | currentURL.RawPath = fmt.Sprintf("/api/v4/projects/%s/repository/files/%s/raw", repoID, url.PathEscape(joinedPath)) 154 | 155 | return currentURL, nil 156 | } 157 | 158 | // ReadRemoteYaml makes a get request to retrieve a given file from a URL 159 | func ReadRemoteYaml(location string, destConfig any, auth map[string]string) (err error) { 160 | // Send an HTTP GET request to fetch the content of the remote file 161 | req, err := http.NewRequest(http.MethodGet, location, nil) 162 | if err != nil { 163 | return fmt.Errorf("unable to initialize request for %s: %w", location, err) 164 | } 165 | 166 | parsedLocation, err := url.Parse(location) 167 | if err != nil { 168 | return fmt.Errorf("failed parsing URL %s: %w", location, err) 169 | } 170 | if token, ok := auth[parsedLocation.Host]; ok { 171 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 172 | } else { 173 | token, err := keyring.Get(config.KeyringService, parsedLocation.Host) 174 | if err != nil { 175 | message.SLog.Debug(fmt.Sprintf("unable to lookup host %s in keyring: %s", parsedLocation.Host, err.Error())) 176 | } else { 177 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 178 | } 179 | } 180 | req.Header.Add("Accept", "application/vnd.github.raw+json") 181 | 182 | resp, err := http.DefaultClient.Do(req) 183 | if err != nil { 184 | return fmt.Errorf("unable to make request for %s: %w", location, err) 185 | } 186 | defer resp.Body.Close() 187 | 188 | if resp.StatusCode != http.StatusOK { 189 | return fmt.Errorf("failed getting %s: %s", location, resp.Status) 190 | } 191 | 192 | // Read the content of the response body 193 | body, err := io.ReadAll(resp.Body) 194 | if err != nil { 195 | return fmt.Errorf("failed reading contents of %s: %w", location, err) 196 | } 197 | 198 | // Deserialize the content into the includedTasksFile 199 | err = goyaml.Unmarshal(body, destConfig) 200 | if err != nil { 201 | return fmt.Errorf("failed unmarshalling contents of %s: %w", location, err) 202 | } 203 | 204 | return nil 205 | } 206 | -------------------------------------------------------------------------------- /docs/adr/0002-redesign-of-task-variables-and-or-inputs.md: -------------------------------------------------------------------------------- 1 | # 2. Redesign of Task Variables and/or Inputs 2 | 3 | Date: 2025-03-24 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | Maru Runner currently supports two mechanisms for task configuration: Variables and Inputs. This duality has led to overlapping functionality, inconsistencies in user experience, and increased maintenance overhead. The competing solutions have created ambiguity for both new and existing users regarding which configuration method to use. The goal of this redesign is to simplify the system by removing redundant approaches, ensuring consistency, and reducing technical debt. 12 | 13 | ### Variables 14 | 15 | - The Variables pattern was copied over from Zarf. 16 | - They are defined at the root tasks file level, rather than per task. 17 | - Variables do not have feature parity with Inputs. They currently lack: 18 | - `required:` field, which allows the task file maintainer to require a non-empty value 19 | - `deprecatedMessage:` field, which allows the task file maintainer to set an input as deprecated, which will alert the consumer via a command line message if they use it. 20 | - The ability to use yaml to set variables, similar to Inputs' `with:` yaml syntax. 21 | - The ability to define task-level variables. 22 | 23 | ### Inputs 24 | 25 | - The Inputs pattern was designed and implemented as part of a Dash Days project. 26 | - They are defined per task. 27 | - Inputs do not have feature parity with Variables. They currently lack: 28 | - `pattern:` field, which allows the task file maintainer to define a regex in order to validate the input value 29 | - `prompt:` field, which allows the task file maintainer to tell Maru to prompt the consumer interactively for the value if one isn't already defined, rather than making the task fail by returning an error message and a nonzero exit code. 30 | - The ability to set an input using an environment variable, similar to Variables' `MARU_` env var prefix. 31 | - The ability to set the value of an input from a previous task action, similar to Variables' `setVariable:` yaml syntax. 32 | - The ability to define file-level inputs. 33 | 34 | ## Decision 35 | 36 | After extensive discussion the decision is go to with Option 4: a net-new redesign, likely of the entire `maru` tool. Initial estimate is that this will happen in Q2 2025. 37 | 38 | ## Consequences 39 | 40 | The following outlines the pros and cons of the various solutions considered: 41 | 42 | ### Option 1: Achieve feature parity for Inputs, then deprecate Variables 43 | 44 | **Pros:** 45 | - **Unified Configuration:** Consolidates functionality into a single system (Inputs), simplifying both the user interface and internal codebase. 46 | - **Maintenance Efficiency:** Reduces redundant code paths and minimizes future maintenance by focusing on one mechanism. 47 | - **Clear Migration Path:** Provides a straightforward deprecation plan for Variables, encouraging users to transition gradually. 48 | 49 | **Cons:** 50 | - **Migration Challenges:** Existing users reliant on Variables may face disruption or require additional migration tooling. 51 | - **Implementation Overhead:** Bringing Inputs to full parity with Variables, and adding deprecation notices to Variables, will demand development, testing, and documentation efforts. 52 | - **Backward Compatibility Risks:** There is a risk of breaking changes that could affect current workflows during the transition period. 53 | 54 | ### Option 2: Achieve feature parity for Variables, then deprecate Inputs 55 | 56 | **Pros:** 57 | - **Unified Configuration:** Consolidates functionality into a single system (Variables), simplifying both the user interface and internal codebase. 58 | - **Maintenance Efficiency:** Reduces redundant code paths and minimizes future maintenance by focusing on one mechanism. 59 | - **Clear Migration Path:** Provides a straightforward deprecation plan for Inputs, encouraging users to transition gradually. 60 | 61 | **Cons:** 62 | - **Migration Challenges:** Existing users reliant on Inputs may face disruption or require additional migration tooling. 63 | - **Implementation Overhead:** Bringing Variables to full parity with Inputs, and adding deprecation notices to Inputs, will demand development, testing, and documentation efforts. 64 | - **Backward Compatibility Risks:** There is a risk of breaking changes that could affect current workflows during the transition period. 65 | 66 | 67 | ### Option 3: Maintain both patterns with improved documentation and clear use-cases 68 | 69 | **Pros:** 70 | - **Minimal Disruption:** Keeping both Variables and Inputs avoids immediate breaking changes, preserving current workflows. 71 | - **Flexibility:** Users can choose the system that best suits their needs, which may be beneficial for diverse use cases. 72 | - **Short-term Stability:** Requires fewer code changes, which can be attractive if resources or time are limited. 73 | 74 | **Cons:** 75 | - **Continued Confusion:** Having two overlapping yet distinct patterns may perpetuate user uncertainty and inconsistent usage patterns. 76 | - **Increased Maintenance:** Maintaining parallel solutions leads to duplicated efforts, complicating bug fixes and feature updates. 77 | - **Technical Debt:** The coexistence of both systems may hinder long-term scalability and the implementation of future improvements. 78 | 79 | ### Option 4: Come up with something net-new, then deprecate both Variables and Inputs 80 | 81 | > [!NOTE] 82 | > This option could result in changes to the existing Maru tool. It could also potentially involve a full rewrite, leading to a `maru2`. More investigation is necessary if this option is chosen as the Decision. 83 | 84 | **Pros:** 85 | - **Modern Approach:** A new solution can be designed from the ground up with modern requirements in mind, free from legacy constraints. 86 | - **Unified and Simplified System:** Offers the opportunity to create a cohesive, intuitive configuration system that eliminates past inconsistencies. 87 | - **Lessons Learned:** Can incorporate lessons from the shortcomings of both Variables and Inputs, potentially resulting in a more robust, flexible, and scalable solution. 88 | - **Enhanced Developer Experience:** A well-designed, new API may streamline the configuration process for both users and developers. 89 | 90 | **Cons:** 91 | - **High Development Cost:** Requires significant initial investment in design, development, testing, and documentation. 92 | - **Risk of Unproven System:** Introducing an entirely new configuration system carries risks of unforeseen issues and bugs. 93 | - **Transition Complexity:** Migrating from two existing systems to a new solution may necessitate comprehensive migration tooling and detailed user guidance. 94 | - **Potential Instability:** The transition period may be challenging, with potential disruptions as users adapt to the new system. 95 | 96 | Each option carries trade-offs between user experience, development effort, and long-term maintainability. Further discussion and evaluation of project priorities are necessary before finalizing the chosen path forward. 97 | -------------------------------------------------------------------------------- /src/pkg/variables/variables_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | package variables 5 | 6 | import ( 7 | "errors" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | type testVariableInfo struct { 13 | Sensitive bool 14 | AutoIndent bool 15 | Type VariableType 16 | } 17 | 18 | func TestPopulateVariables(t *testing.T) { 19 | 20 | nonZeroTestVariableInfo := testVariableInfo{Sensitive: true, AutoIndent: true, Type: FileVariableType} 21 | 22 | type test struct { 23 | vc VariableConfig[testVariableInfo] 24 | vars []InteractiveVariable[testVariableInfo] 25 | presets map[string]string 26 | wantErr error 27 | wantVars SetVariableMap[testVariableInfo] 28 | } 29 | 30 | prompt := func(_ InteractiveVariable[testVariableInfo]) (value string, err error) { return "Prompt", nil } 31 | 32 | tests := []test{ 33 | { 34 | vc: VariableConfig[testVariableInfo]{setVariableMap: SetVariableMap[testVariableInfo]{}}, 35 | vars: []InteractiveVariable[testVariableInfo]{ 36 | createInteractiveVariable("NAME", "", "", false, testVariableInfo{}), 37 | }, 38 | presets: map[string]string{}, 39 | wantErr: nil, 40 | wantVars: SetVariableMap[testVariableInfo]{ 41 | "NAME": createSetVariable("NAME", "", "", testVariableInfo{})}, 42 | }, 43 | { 44 | vc: VariableConfig[testVariableInfo]{setVariableMap: SetVariableMap[testVariableInfo]{}}, 45 | vars: []InteractiveVariable[testVariableInfo]{ 46 | createInteractiveVariable("NAME", "", "Default", false, testVariableInfo{}), 47 | }, 48 | presets: map[string]string{}, 49 | wantErr: nil, 50 | wantVars: SetVariableMap[testVariableInfo]{ 51 | "NAME": createSetVariable("NAME", "Default", "", testVariableInfo{}), 52 | }, 53 | }, 54 | { 55 | vc: VariableConfig[testVariableInfo]{setVariableMap: SetVariableMap[testVariableInfo]{}}, 56 | vars: []InteractiveVariable[testVariableInfo]{ 57 | createInteractiveVariable("NAME", "", "Default", false, testVariableInfo{}), 58 | }, 59 | presets: map[string]string{"NAME": "Set"}, 60 | wantErr: nil, 61 | wantVars: SetVariableMap[testVariableInfo]{ 62 | "NAME": createSetVariable("NAME", "Set", "", testVariableInfo{}), 63 | }, 64 | }, 65 | { 66 | vc: VariableConfig[testVariableInfo]{setVariableMap: SetVariableMap[testVariableInfo]{}}, 67 | vars: []InteractiveVariable[testVariableInfo]{ 68 | createInteractiveVariable("NAME", "", "", false, nonZeroTestVariableInfo), 69 | }, 70 | presets: map[string]string{}, 71 | wantErr: nil, 72 | wantVars: SetVariableMap[testVariableInfo]{ 73 | "NAME": createSetVariable("NAME", "", "", nonZeroTestVariableInfo), 74 | }, 75 | }, 76 | { 77 | vc: VariableConfig[testVariableInfo]{setVariableMap: SetVariableMap[testVariableInfo]{}}, 78 | vars: []InteractiveVariable[testVariableInfo]{ 79 | createInteractiveVariable("NAME", "", "", false, nonZeroTestVariableInfo), 80 | }, 81 | presets: map[string]string{"NAME": "Set"}, 82 | wantErr: nil, 83 | wantVars: SetVariableMap[testVariableInfo]{ 84 | "NAME": createSetVariable("NAME", "Set", "", nonZeroTestVariableInfo), 85 | }, 86 | }, 87 | { 88 | vc: VariableConfig[testVariableInfo]{setVariableMap: SetVariableMap[testVariableInfo]{}, prompt: prompt}, 89 | vars: []InteractiveVariable[testVariableInfo]{ 90 | createInteractiveVariable("NAME", "", "", true, testVariableInfo{}), 91 | }, 92 | presets: map[string]string{}, 93 | wantErr: nil, 94 | wantVars: SetVariableMap[testVariableInfo]{ 95 | "NAME": createSetVariable("NAME", "Prompt", "", testVariableInfo{}), 96 | }, 97 | }, 98 | { 99 | vc: VariableConfig[testVariableInfo]{setVariableMap: SetVariableMap[testVariableInfo]{}, prompt: prompt}, 100 | vars: []InteractiveVariable[testVariableInfo]{ 101 | createInteractiveVariable("NAME", "", "Default", true, testVariableInfo{}), 102 | }, 103 | presets: map[string]string{}, 104 | wantErr: nil, 105 | wantVars: SetVariableMap[testVariableInfo]{ 106 | "NAME": createSetVariable("NAME", "Prompt", "", testVariableInfo{}), 107 | }, 108 | }, 109 | { 110 | vc: VariableConfig[testVariableInfo]{setVariableMap: SetVariableMap[testVariableInfo]{}, prompt: prompt}, 111 | vars: []InteractiveVariable[testVariableInfo]{ 112 | createInteractiveVariable("NAME", "", "", true, testVariableInfo{}), 113 | }, 114 | presets: map[string]string{"NAME": "Set"}, 115 | wantErr: nil, 116 | wantVars: SetVariableMap[testVariableInfo]{ 117 | "NAME": createSetVariable("NAME", "Set", "", testVariableInfo{}), 118 | }, 119 | }, 120 | } 121 | 122 | for _, tc := range tests { 123 | gotErr := tc.vc.PopulateVariables(tc.vars, tc.presets) 124 | if gotErr != nil && tc.wantErr != nil { 125 | if gotErr.Error() != tc.wantErr.Error() { 126 | t.Fatalf("wanted err: %s, got err: %s", tc.wantErr, gotErr) 127 | } 128 | } else if gotErr != nil { 129 | t.Fatalf("got unexpected err: %s", gotErr) 130 | } 131 | 132 | gotVars := tc.vc.GetSetVariables() 133 | 134 | if len(gotVars) != len(tc.wantVars) { 135 | t.Fatalf("wanted vars len: %d, got vars len: %d", len(tc.wantVars), len(gotVars)) 136 | } 137 | 138 | for key := range gotVars { 139 | if !reflect.DeepEqual(gotVars[key], tc.wantVars[key]) { 140 | t.Fatalf("for key %s: wanted var: %v, got var: %v", key, tc.wantVars[key], gotVars[key]) 141 | } 142 | } 143 | } 144 | } 145 | 146 | func TestCheckVariablePattern(t *testing.T) { 147 | type test struct { 148 | vc VariableConfig[testVariableInfo] 149 | name string 150 | want error 151 | } 152 | 153 | tests := []test{ 154 | { 155 | vc: VariableConfig[testVariableInfo]{setVariableMap: SetVariableMap[testVariableInfo]{}}, 156 | name: "NAME", 157 | want: errors.New("variable \"NAME\" was not found in the current variable map"), 158 | }, 159 | { 160 | vc: VariableConfig[testVariableInfo]{ 161 | setVariableMap: SetVariableMap[testVariableInfo]{ 162 | "NAME": createSetVariable("NAME", "name", "n[^a]me", testVariableInfo{}), 163 | }, 164 | }, 165 | name: "NAME", 166 | want: errors.New("provided value for variable \"NAME\" does not match pattern \"n[^a]me\""), 167 | }, 168 | { 169 | vc: VariableConfig[testVariableInfo]{ 170 | setVariableMap: SetVariableMap[testVariableInfo]{ 171 | "NAME": createSetVariable("NAME", "name", "n[a-z]me", testVariableInfo{}), 172 | }, 173 | }, 174 | name: "NAME", 175 | want: nil, 176 | }, 177 | } 178 | 179 | for _, tc := range tests { 180 | got := tc.vc.CheckVariablePattern(tc.name) 181 | if got != nil && tc.want != nil { 182 | if got.Error() != tc.want.Error() { 183 | t.Fatalf("wanted err: %s, got err: %s", tc.want, got) 184 | } 185 | } else if got != nil { 186 | t.Fatalf("got unexpected err: %s", got) 187 | } 188 | } 189 | } 190 | 191 | func createSetVariable(name, value, pattern string, extra testVariableInfo) *SetVariable[testVariableInfo] { 192 | return &SetVariable[testVariableInfo]{ 193 | Value: value, 194 | Variable: createVariable(name, pattern, extra), 195 | } 196 | } 197 | 198 | func createInteractiveVariable(name, pattern, def string, prompt bool, extra testVariableInfo) InteractiveVariable[testVariableInfo] { 199 | return InteractiveVariable[testVariableInfo]{ 200 | Prompt: prompt, 201 | Default: def, 202 | Variable: createVariable(name, pattern, extra), 203 | } 204 | } 205 | 206 | func createVariable(name, pattern string, extra testVariableInfo) Variable[testVariableInfo] { 207 | return Variable[testVariableInfo]{ 208 | Name: name, 209 | Pattern: pattern, 210 | Extra: extra, 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/test/e2e/runner_inputs_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package test provides e2e tests for the runner. 5 | package test 6 | 7 | import ( 8 | "os" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestRunnerInputs(t *testing.T) { 15 | t.Run("test that default values for inputs work when not required", func(t *testing.T) { 16 | t.Parallel() 17 | 18 | stdOut, stdErr, err := e2e.Maru("run", "has-default-empty", "--file", "src/test/tasks/inputs/tasks.yaml") 19 | require.NoError(t, err, stdOut, stdErr) 20 | require.Contains(t, stdErr, "default") 21 | require.NotContains(t, stdErr, "{{") 22 | }) 23 | 24 | t.Run("test that default values for inputs work when required", func(t *testing.T) { 25 | t.Parallel() 26 | 27 | stdOut, stdErr, err := e2e.Maru("run", "has-default-and-required-empty", "--file", "src/test/tasks/inputs/tasks.yaml") 28 | require.NoError(t, err, stdOut, stdErr) 29 | require.Contains(t, stdErr, "default") 30 | require.NotContains(t, stdErr, "{{") 31 | 32 | }) 33 | 34 | t.Run("test that default values for inputs work when required and have values supplied", func(t *testing.T) { 35 | t.Parallel() 36 | 37 | stdOut, stdErr, err := e2e.Maru("run", "has-default-and-required-supplied", "--file", "src/test/tasks/inputs/tasks.yaml") 38 | require.NoError(t, err, stdOut, stdErr) 39 | require.Contains(t, stdErr, "supplied-value") 40 | require.NotContains(t, stdErr, "{{") 41 | }) 42 | 43 | t.Run("test that direct calling of task with default values for required inputs work", func(t *testing.T) { 44 | t.Parallel() 45 | 46 | stdOut, stdErr, err := e2e.Maru("run", "has-default-and-required", "--file", "src/test/tasks/inputs/tasks-with-inputs.yaml") 47 | require.NoError(t, err, stdOut, stdErr) 48 | require.Contains(t, stdErr, "Completed \"echo $INPUT_HAS_DEFAULT_AND_REQUIRED; \"") 49 | }) 50 | 51 | t.Run("test that direct calling of task without default values for required inputs fails", func(t *testing.T) { 52 | t.Parallel() 53 | 54 | stdOut, stdErr, err := e2e.Maru("run", "no-default-and-required", "--file", "src/test/tasks/inputs/tasks-with-inputs.yaml") 55 | require.Error(t, err, stdOut, stdErr) 56 | require.Contains(t, stdErr, "Failed to run action: task no-default-and-required is missing required inputs:") 57 | }) 58 | 59 | t.Run("test that inputs that aren't required with no default don't error", func(t *testing.T) { 60 | t.Parallel() 61 | 62 | stdOut, stdErr, err := e2e.Maru("run", "no-default-empty", "--file", "src/test/tasks/inputs/tasks.yaml") 63 | require.NoError(t, err, stdOut, stdErr) 64 | require.NotContains(t, stdErr, "has-no-default") 65 | require.NotContains(t, stdErr, "{{") 66 | }) 67 | 68 | t.Run("test that inputs with no defaults that aren't required don't error when supplied with a value", func(t *testing.T) { 69 | t.Parallel() 70 | 71 | stdOut, stdErr, err := e2e.Maru("run", "no-default-supplied", "--file", "src/test/tasks/inputs/tasks.yaml") 72 | require.NoError(t, err, stdOut, stdErr) 73 | require.Contains(t, stdErr, "success + supplied-value") 74 | require.NotContains(t, stdErr, "{{") 75 | }) 76 | 77 | t.Run("test that tasks that require inputs with no defaults error when called without values", func(t *testing.T) { 78 | t.Parallel() 79 | 80 | stdOut, stdErr, err := e2e.Maru("run", "no-default-and-required-empty", "--file", "src/test/tasks/inputs/tasks.yaml") 81 | require.Error(t, err, stdOut, stdErr) 82 | require.NotContains(t, stdErr, "{{") 83 | }) 84 | 85 | t.Run("test that tasks that require inputs with no defaults run when supplied with a value", func(t *testing.T) { 86 | t.Parallel() 87 | 88 | stdOut, stdErr, err := e2e.Maru("run", "no-default-and-required-supplied", "--file", "src/test/tasks/inputs/tasks.yaml") 89 | require.NoError(t, err, stdOut, stdErr) 90 | require.Contains(t, stdErr, "supplied-value") 91 | require.NotContains(t, stdErr, "{{") 92 | }) 93 | 94 | t.Run("test that when a task is called with extra inputs it warns", func(t *testing.T) { 95 | t.Parallel() 96 | 97 | stdOut, stdErr, err := e2e.Maru("run", "no-default-and-required-supplied-extra", "--file", "src/test/tasks/inputs/tasks.yaml") 98 | require.NoError(t, err, stdOut, stdErr) 99 | require.Contains(t, stdErr, "supplied-value") 100 | require.Contains(t, stdErr, "WARNING") 101 | require.Contains(t, stdErr, "does not have an input named extra") 102 | require.NotContains(t, stdErr, "{{") 103 | }) 104 | 105 | t.Run("test that displays a deprecated message", func(t *testing.T) { 106 | t.Parallel() 107 | 108 | stdOut, stdErr, err := e2e.Maru("run", "deprecated-task", "--file", "src/test/tasks/inputs/tasks.yaml") 109 | require.NoError(t, err, stdOut, stdErr) 110 | require.Contains(t, stdErr, "WARNING") 111 | require.Contains(t, stdErr, "This input has been marked deprecated: This is a deprecated message") 112 | }) 113 | 114 | t.Run("test that variables can be used as inputs", func(t *testing.T) { 115 | t.Parallel() 116 | 117 | stdOut, stdErr, err := e2e.Maru("run", "variable-as-input", "--file", "src/test/tasks/inputs/tasks.yaml", "--set", "foo=im a variable") 118 | require.NoError(t, err, stdOut, stdErr) 119 | require.Contains(t, stdErr, "im a variable") 120 | }) 121 | 122 | t.Run("test that env vars can be used as inputs and take precedence over default vals", func(t *testing.T) { 123 | os.Setenv("MARU_FOO", "im an env var") 124 | stdOut, stdErr, err := e2e.Maru("run", "variable-as-input", "--file", "src/test/tasks/inputs/tasks.yaml") 125 | os.Unsetenv("MARU_FOO") 126 | require.NoError(t, err, stdOut, stdErr) 127 | require.Contains(t, stdErr, "im an env var") 128 | }) 129 | 130 | t.Run("test that a --set var has the greatest precedence for inputs", func(t *testing.T) { 131 | os.Setenv("MARU_FOO", "im an env var") 132 | stdOut, stdErr, err := e2e.Maru("run", "variable-as-input", "--file", "src/test/tasks/inputs/tasks.yaml", "--set", "foo=most specific") 133 | os.Unsetenv("MARU_FOO") 134 | require.NoError(t, err, stdOut, stdErr) 135 | require.Contains(t, stdErr, "most specific") 136 | }) 137 | 138 | t.Run("test that variables in directly called included tasks take the root default", func(t *testing.T) { 139 | stdOut, stdErr, err := e2e.Maru("run", "with:echo-foo", "--file", "src/test/tasks/inputs/tasks.yaml") 140 | require.NoError(t, err, stdOut, stdErr) 141 | require.Contains(t, stdErr, "default-value") 142 | }) 143 | 144 | t.Run("test that variables in directly called included tasks take empty set values", func(t *testing.T) { 145 | stdOut, stdErr, err := e2e.Maru("run", "with:echo-foo", "--file", "src/test/tasks/inputs/tasks.yaml", "--set", "foo=''") 146 | require.NoError(t, err, stdOut, stdErr) 147 | require.NotContains(t, stdErr, "default-value") 148 | }) 149 | 150 | t.Run("test that variables in directly called included tasks pass through even when not in the root", func(t *testing.T) { 151 | stdOut, stdErr, err := e2e.Maru("run", "with:echo-bar", "--file", "src/test/tasks/inputs/tasks.yaml") 152 | require.NoError(t, err, stdOut, stdErr) 153 | require.Contains(t, stdErr, "default-value") 154 | }) 155 | 156 | t.Run("test that using the --with command line flag works", func(t *testing.T) { 157 | stdOut, stdErr, err := e2e.Maru("run", "with:command-line-with", "--file", "src/test/tasks/inputs/tasks.yaml", "--with", "input1=input1", "--with", "input3=notthedefault", "--set", "FOO=baz") 158 | require.NoError(t, err, stdOut, stdErr) 159 | require.Contains(t, stdErr, "Input1Tmpl: input1 Input1Env: input1 Input2Tmpl: input2 Input2Env: input2 Input3Tmpl: notthedefault Input3Env: notthedefault Var: baz") 160 | }) 161 | } 162 | -------------------------------------------------------------------------------- /src/cmd/run.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package cmd contains the CLI commands for maru. 5 | package cmd 6 | 7 | import ( 8 | "flag" 9 | "fmt" 10 | "os" 11 | "regexp" 12 | "strings" 13 | 14 | "github.com/defenseunicorns/maru-runner/src/config" 15 | "github.com/defenseunicorns/maru-runner/src/config/lang" 16 | "github.com/defenseunicorns/maru-runner/src/message" 17 | "github.com/defenseunicorns/maru-runner/src/pkg/runner" 18 | "github.com/defenseunicorns/maru-runner/src/pkg/utils" 19 | "github.com/defenseunicorns/maru-runner/src/types" 20 | "github.com/defenseunicorns/pkg/helpers/v2" 21 | "github.com/pterm/pterm" 22 | "github.com/spf13/cobra" 23 | "github.com/spf13/pflag" 24 | ) 25 | 26 | // listTasks is a flag to print available tasks in a TaskFileLocation (no includes) 27 | var listTasks listFlag 28 | 29 | // listAllTasks is a flag to print available tasks in a TaskFileLocation 30 | var listAllTasks listFlag 31 | 32 | // listFlag defines the flag behavior for task list flags 33 | type listFlag string 34 | 35 | const ( 36 | listOff listFlag = "" 37 | listOn listFlag = "true" // value set by flag package on bool flag 38 | listMd listFlag = "md" 39 | ) 40 | 41 | // IsBoolFlag causes a bare list flag to be set as the string 'true'. This 42 | // allows the use of a bare list flag or setting a string ala '--list=md'. 43 | func (i *listFlag) IsBoolFlag() bool { return true } 44 | func (i *listFlag) String() string { return string(*i) } 45 | 46 | func (i *listFlag) Set(value string) error { 47 | v := listFlag(value) 48 | if v != listOn && v != listMd { 49 | return fmt.Errorf("error: list flags expect '%v' or '%v'", listOn, listMd) 50 | } 51 | *i = v 52 | return nil 53 | } 54 | 55 | // dryRun is a flag to only load / validate tasks without running commands 56 | var dryRun bool 57 | 58 | // setRunnerVariables provides a map of set variables from the command line 59 | var setRunnerVariables map[string]string 60 | 61 | // withRunnerInputs provides a map of --with inputs from the command line 62 | var withRunnerInputs map[string]string 63 | 64 | var runCmd = &cobra.Command{ 65 | Use: "run", 66 | PersistentPreRun: func(_ *cobra.Command, _ []string) { 67 | exitOnInterrupt() 68 | cliSetup() 69 | }, 70 | Short: lang.CmdRunShort, 71 | ValidArgsFunction: ListAutoCompleteTasks, 72 | Args: cobra.MaximumNArgs(1), 73 | Run: func(_ *cobra.Command, args []string) { 74 | var tasksFile types.TasksFile 75 | 76 | err := utils.ReadYaml(config.TaskFileLocation, &tasksFile) 77 | if err != nil { 78 | message.Fatalf(err, "Failed to open file: %s", err.Error()) 79 | } 80 | 81 | // ensure vars are uppercase 82 | setRunnerVariables = helpers.TransformMapKeys(setRunnerVariables, strings.ToUpper) 83 | 84 | // set any env vars that come from the environment (taking MARU_ over VENDOR_) 85 | for _, variable := range tasksFile.Variables { 86 | if _, ok := setRunnerVariables[variable.Name]; !ok { 87 | if value := os.Getenv(fmt.Sprintf("%s_%s", strings.ToUpper(config.EnvPrefix), variable.Name)); value != "" { 88 | setRunnerVariables[variable.Name] = value 89 | } else if config.VendorPrefix != "" { 90 | if value := os.Getenv(fmt.Sprintf("%s_%s", strings.ToUpper(config.VendorPrefix), variable.Name)); value != "" { 91 | setRunnerVariables[variable.Name] = value 92 | } 93 | } 94 | } 95 | } 96 | 97 | auth := v.GetStringMapString(V_AUTH) 98 | 99 | listFormat := listTasks 100 | if listAllTasks != listOff { 101 | listFormat = listAllTasks 102 | } 103 | 104 | if listFormat != listOff { 105 | rows := [][]string{} 106 | for _, task := range tasksFile.Tasks { 107 | rows = append(rows, []string{task.Name, task.Description}) 108 | } 109 | 110 | // If ListAllTasks, add tasks from included files 111 | if listAllTasks != listOff { 112 | err = listTasksFromIncludes(&rows, tasksFile, auth) 113 | if err != nil { 114 | message.Fatalf(err, "Cannot list tasks: %s", err.Error()) 115 | } 116 | } 117 | 118 | switch listFormat { 119 | case listMd: 120 | fmt.Println("| Name | Description |") 121 | fmt.Println("|------|-------------|") 122 | for _, row := range rows { 123 | if len(row) == 2 { 124 | fmt.Printf("| **%s** | %s |\n", row[0], row[1]) 125 | } 126 | } 127 | default: 128 | rows = append([][]string{{"Name", "Description"}}, rows...) 129 | err := pterm.DefaultTable.WithHasHeader().WithData(rows).Render() 130 | if err != nil { 131 | message.Fatalf(err, "Error listing tasks: %s", err.Error()) 132 | } 133 | } 134 | 135 | return 136 | } 137 | 138 | taskName := "default" 139 | if len(args) > 0 { 140 | taskName = args[0] 141 | } 142 | if err := runner.Run(tasksFile, taskName, setRunnerVariables, withRunnerInputs, dryRun, auth); err != nil { 143 | message.Fatalf(err, "Failed to run action: %s", err.Error()) 144 | } 145 | }, 146 | } 147 | 148 | // ListAutoCompleteTasks returns a list of all of the available tasks that can be run 149 | func ListAutoCompleteTasks(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 150 | var tasksFile types.TasksFile 151 | 152 | if _, err := os.Stat(config.TaskFileLocation); os.IsNotExist(err) { 153 | return []string{}, cobra.ShellCompDirectiveNoFileComp 154 | } 155 | 156 | err := utils.ReadYaml(config.TaskFileLocation, &tasksFile) 157 | if err != nil { 158 | return []string{}, cobra.ShellCompDirectiveNoFileComp 159 | } 160 | 161 | taskNames := make([]string, 0, len(tasksFile.Tasks)) 162 | for _, task := range tasksFile.Tasks { 163 | taskNames = append(taskNames, task.Name) 164 | } 165 | return taskNames, cobra.ShellCompDirectiveNoFileComp 166 | } 167 | 168 | func listTasksFromIncludes(rows *[][]string, tasksFile types.TasksFile, auth map[string]string) error { 169 | variableConfig := runner.GetMaruVariableConfig() 170 | err := variableConfig.PopulateVariables(tasksFile.Variables, setRunnerVariables) 171 | if err != nil { 172 | return err 173 | } 174 | 175 | templatePattern := `\${[^}]+}` 176 | re := regexp.MustCompile(templatePattern) 177 | for _, include := range tasksFile.Includes { 178 | // get included TasksFile 179 | for includeName, includeFileLocation := range include { 180 | // check for templated variables in includeFileLocation value 181 | if re.MatchString(includeFileLocation) { 182 | includeFileLocation = utils.TemplateString(variableConfig.GetSetVariables(), includeFileLocation) 183 | } 184 | 185 | _, includedTasksFile, err := runner.LoadIncludeTask(config.TaskFileLocation, includeFileLocation, auth) 186 | if err != nil { 187 | message.Fatalf(err, "Error listing tasks: %s", err.Error()) 188 | } 189 | 190 | for _, task := range includedTasksFile.Tasks { 191 | *rows = append(*rows, []string{fmt.Sprintf("%s:%s", includeName, task.Name), task.Description}) 192 | } 193 | } 194 | } 195 | 196 | return nil 197 | } 198 | 199 | func init() { 200 | initViper() 201 | rootCmd.AddCommand(runCmd) 202 | runFlags := runCmd.Flags() 203 | runFlags.StringVarP(&config.TaskFileLocation, "file", "f", config.TasksYAML, lang.CmdRunFlag) 204 | runFlags.BoolVar(&dryRun, "dry-run", false, lang.CmdRunDryRun) 205 | 206 | // Setup the --list flag 207 | flag.Var(&listTasks, "list", lang.CmdRunList) 208 | listPFlag := pflag.PFlagFromGoFlag(flag.Lookup("list")) 209 | listPFlag.Shorthand = "t" 210 | runFlags.AddFlag(listPFlag) 211 | 212 | // Setup the --list-all flag 213 | flag.Var(&listAllTasks, "list-all", lang.CmdRunList) 214 | listAllPFlag := pflag.PFlagFromGoFlag(flag.Lookup("list-all")) 215 | listAllPFlag.Shorthand = "T" 216 | runFlags.AddFlag(listAllPFlag) 217 | 218 | runFlags.StringToStringVar(&setRunnerVariables, "set", nil, lang.CmdRunSetVarFlag) 219 | 220 | runFlags.StringToStringVar(&withRunnerInputs, "with", nil, lang.CmdRunWithVarFlag) 221 | } 222 | -------------------------------------------------------------------------------- /tasks.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://github.com/defenseunicorns/maru-runner/src/types/tasks-file", 4 | "$ref": "#/$defs/TasksFile", 5 | "$defs": { 6 | "Action": { 7 | "properties": { 8 | "description": { 9 | "type": "string", 10 | "description": "Description of the action to be displayed during package execution instead of the command" 11 | }, 12 | "cmd": { 13 | "type": "string", 14 | "description": "The command to run. Must specify either cmd or wait for the action to do anything." 15 | }, 16 | "wait": { 17 | "$ref": "#/$defs/ActionWait", 18 | "description": "Wait for a condition to be met before continuing. Must specify either cmd or wait for the action." 19 | }, 20 | "env": { 21 | "items": { 22 | "type": "string" 23 | }, 24 | "type": "array", 25 | "description": "Additional environment variables to set for the command" 26 | }, 27 | "mute": { 28 | "type": "boolean", 29 | "description": "Hide the output of the command during package deployment (default false)" 30 | }, 31 | "maxTotalSeconds": { 32 | "type": "integer", 33 | "description": "Timeout in seconds for the command (default to 0" 34 | }, 35 | "maxRetries": { 36 | "type": "integer", 37 | "description": "Retry the command if it fails up to given number of times (default 0)" 38 | }, 39 | "dir": { 40 | "type": "string", 41 | "description": "The working directory to run the command in (default is CWD)" 42 | }, 43 | "shell": { 44 | "$ref": "#/$defs/ShellPreference", 45 | "description": "(cmd only) Indicates a preference for a shell for the provided cmd to be executed in on supported operating systems" 46 | }, 47 | "setVariables": { 48 | "items": { 49 | "$ref": "#/$defs/Variable" 50 | }, 51 | "type": "array", 52 | "description": "(onDeploy/cmd only) An array of variables to update with the output of the command. These variables will be available to all remaining actions and components in the package." 53 | }, 54 | "task": { 55 | "type": "string", 56 | "description": "The task to run" 57 | }, 58 | "with": { 59 | "additionalProperties": { 60 | "type": "string" 61 | }, 62 | "type": "object", 63 | "description": "Input parameters to pass to the task" 64 | }, 65 | "if": { 66 | "type": "string", 67 | "description": "Conditional to determine if the action should run" 68 | } 69 | }, 70 | "additionalProperties": false, 71 | "type": "object", 72 | "patternProperties": { 73 | "^x-": {} 74 | } 75 | }, 76 | "ActionWait": { 77 | "properties": { 78 | "cluster": { 79 | "$ref": "#/$defs/ActionWaitCluster", 80 | "description": "Wait for a condition to be met in the cluster before continuing. Only one of cluster or network can be specified." 81 | }, 82 | "network": { 83 | "$ref": "#/$defs/ActionWaitNetwork", 84 | "description": "Wait for a condition to be met on the network before continuing. Only one of cluster or network can be specified." 85 | } 86 | }, 87 | "additionalProperties": false, 88 | "type": "object", 89 | "patternProperties": { 90 | "^x-": {} 91 | } 92 | }, 93 | "ActionWaitCluster": { 94 | "properties": { 95 | "kind": { 96 | "type": "string", 97 | "description": "The kind of resource to wait for", 98 | "examples": [ 99 | "Pod", 100 | "Deployment)" 101 | ] 102 | }, 103 | "name": { 104 | "type": "string", 105 | "description": "The name of the resource or selector to wait for", 106 | "examples": [ 107 | "podinfo", 108 | "app=podinfo" 109 | ] 110 | }, 111 | "namespace": { 112 | "type": "string", 113 | "description": "The namespace of the resource to wait for" 114 | }, 115 | "condition": { 116 | "type": "string", 117 | "description": "The condition or jsonpath state to wait for; defaults to exist", 118 | "examples": [ 119 | "Ready", 120 | "Available" 121 | ] 122 | } 123 | }, 124 | "additionalProperties": false, 125 | "type": "object", 126 | "required": [ 127 | "kind", 128 | "name" 129 | ], 130 | "patternProperties": { 131 | "^x-": {} 132 | } 133 | }, 134 | "ActionWaitNetwork": { 135 | "properties": { 136 | "protocol": { 137 | "type": "string", 138 | "enum": [ 139 | "tcp", 140 | "http", 141 | "https" 142 | ], 143 | "description": "The protocol to wait for" 144 | }, 145 | "address": { 146 | "type": "string", 147 | "description": "The address to wait for", 148 | "examples": [ 149 | "localhost:8080", 150 | "1.1.1.1" 151 | ] 152 | }, 153 | "code": { 154 | "type": "integer", 155 | "description": "The HTTP status code to wait for if using http or https", 156 | "examples": [ 157 | 200, 158 | 404 159 | ] 160 | } 161 | }, 162 | "additionalProperties": false, 163 | "type": "object", 164 | "required": [ 165 | "protocol", 166 | "address" 167 | ], 168 | "patternProperties": { 169 | "^x-": {} 170 | } 171 | }, 172 | "InputParameter": { 173 | "properties": { 174 | "description": { 175 | "type": "string", 176 | "description": "Description of the parameter" 177 | }, 178 | "deprecatedMessage": { 179 | "type": "string", 180 | "description": "Message to display when the parameter is deprecated" 181 | }, 182 | "required": { 183 | "type": "boolean", 184 | "description": "Whether the parameter is required", 185 | "default": true 186 | }, 187 | "default": { 188 | "type": "string", 189 | "description": "Default value for the parameter" 190 | } 191 | }, 192 | "additionalProperties": false, 193 | "type": "object", 194 | "required": [ 195 | "description" 196 | ], 197 | "patternProperties": { 198 | "^x-": {} 199 | } 200 | }, 201 | "InteractiveVariable": { 202 | "properties": { 203 | "name": { 204 | "type": "string", 205 | "pattern": "^[A-Z0-9_]+$", 206 | "description": "The name to be used for the variable" 207 | }, 208 | "pattern": { 209 | "type": "string", 210 | "description": "An optional regex pattern that a variable value must match before a package deployment can continue." 211 | }, 212 | "description": { 213 | "type": "string", 214 | "description": "A description of the variable to be used when prompting the user a value" 215 | }, 216 | "default": { 217 | "type": "string", 218 | "description": "The default value to use for the variable" 219 | }, 220 | "prompt": { 221 | "type": "boolean", 222 | "description": "Whether to prompt the user for input for this variable" 223 | } 224 | }, 225 | "additionalProperties": false, 226 | "type": "object", 227 | "required": [ 228 | "name" 229 | ], 230 | "patternProperties": { 231 | "^x-": {} 232 | } 233 | }, 234 | "ShellPreference": { 235 | "properties": { 236 | "windows": { 237 | "type": "string", 238 | "description": "(default 'powershell') Indicates a preference for the shell to use on Windows systems (note that choosing 'cmd' will turn off migrations like touch -> New-Item)", 239 | "examples": [ 240 | "powershell", 241 | "cmd", 242 | "pwsh", 243 | "sh", 244 | "bash", 245 | "gsh" 246 | ] 247 | }, 248 | "linux": { 249 | "type": "string", 250 | "description": "(default 'sh') Indicates a preference for the shell to use on Linux systems", 251 | "examples": [ 252 | "sh", 253 | "bash", 254 | "fish", 255 | "zsh", 256 | "pwsh" 257 | ] 258 | }, 259 | "darwin": { 260 | "type": "string", 261 | "description": "(default 'sh') Indicates a preference for the shell to use on macOS systems", 262 | "examples": [ 263 | "sh", 264 | "bash", 265 | "fish", 266 | "zsh", 267 | "pwsh" 268 | ] 269 | } 270 | }, 271 | "additionalProperties": false, 272 | "type": "object", 273 | "patternProperties": { 274 | "^x-": {} 275 | } 276 | }, 277 | "Task": { 278 | "properties": { 279 | "name": { 280 | "type": "string", 281 | "description": "Name of the task" 282 | }, 283 | "description": { 284 | "type": "string", 285 | "description": "Description of the task" 286 | }, 287 | "actions": { 288 | "items": { 289 | "$ref": "#/$defs/Action" 290 | }, 291 | "type": "array", 292 | "description": "Actions to take when running the task" 293 | }, 294 | "inputs": { 295 | "additionalProperties": { 296 | "$ref": "#/$defs/InputParameter" 297 | }, 298 | "type": "object", 299 | "description": "Input parameters for the task" 300 | }, 301 | "envPath": { 302 | "type": "string", 303 | "description": "Path to file containing environment variables" 304 | } 305 | }, 306 | "additionalProperties": false, 307 | "type": "object", 308 | "required": [ 309 | "name" 310 | ], 311 | "patternProperties": { 312 | "^x-": {} 313 | } 314 | }, 315 | "TasksFile": { 316 | "properties": { 317 | "includes": { 318 | "items": { 319 | "additionalProperties": { 320 | "type": "string" 321 | }, 322 | "type": "object" 323 | }, 324 | "type": "array", 325 | "description": "List of local task files to include" 326 | }, 327 | "variables": { 328 | "items": { 329 | "$ref": "#/$defs/InteractiveVariable" 330 | }, 331 | "type": "array", 332 | "description": "Definitions and default values for variables used in run.yaml" 333 | }, 334 | "tasks": { 335 | "items": { 336 | "$ref": "#/$defs/Task" 337 | }, 338 | "type": "array", 339 | "description": "The list of tasks that can be run" 340 | } 341 | }, 342 | "additionalProperties": false, 343 | "type": "object", 344 | "required": [ 345 | "tasks" 346 | ], 347 | "patternProperties": { 348 | "^x-": {} 349 | } 350 | }, 351 | "Variable": { 352 | "properties": { 353 | "name": { 354 | "type": "string", 355 | "pattern": "^[A-Z0-9_]+$", 356 | "description": "The name to be used for the variable" 357 | }, 358 | "pattern": { 359 | "type": "string", 360 | "description": "An optional regex pattern that a variable value must match before a package deployment can continue." 361 | } 362 | }, 363 | "additionalProperties": false, 364 | "type": "object", 365 | "required": [ 366 | "name" 367 | ], 368 | "patternProperties": { 369 | "^x-": {} 370 | } 371 | } 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/pkg/runner/runner.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package runner provides functions for running tasks in a tasks.yaml 5 | package runner 6 | 7 | import ( 8 | "fmt" 9 | "net/url" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/defenseunicorns/maru-runner/src/config" 14 | "github.com/defenseunicorns/maru-runner/src/message" 15 | "github.com/defenseunicorns/maru-runner/src/pkg/utils" 16 | "github.com/defenseunicorns/maru-runner/src/pkg/variables" 17 | "github.com/defenseunicorns/maru-runner/src/types" 18 | "github.com/defenseunicorns/pkg/helpers/v2" 19 | ) 20 | 21 | // Runner holds the necessary data to run tasks from a tasks file 22 | type Runner struct { 23 | tasksFile types.TasksFile 24 | existingTaskIncludeNameLocation map[string]string 25 | auth map[string]string 26 | envFilePath string 27 | variableConfig *variables.VariableConfig[variables.ExtraVariableInfo] 28 | dryRun bool 29 | currStackSize int 30 | } 31 | 32 | // Run runs a task from tasks file 33 | func Run(tasksFile types.TasksFile, taskName string, setVariables map[string]string, withInputs map[string]string, dryRun bool, auth map[string]string) error { 34 | if dryRun { 35 | message.SLog.Info("Dry-run has been set - only printing the commands that would run:") 36 | } 37 | 38 | // Populate the variables loaded in the root task file 39 | rootVariables := tasksFile.Variables 40 | rootVariableConfig := GetMaruVariableConfig() 41 | err := rootVariableConfig.PopulateVariables(rootVariables, setVariables) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | // Check to see if running an included task directly 47 | tasksFile, taskName, err = loadIncludedTaskFile(tasksFile, taskName, rootVariableConfig.GetSetVariables(), auth) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | // Populate the variables from the root and included file (if these are the same it will just use the same list) 53 | combinedVariables := helpers.MergeSlices(rootVariables, tasksFile.Variables, func(a, b variables.InteractiveVariable[variables.ExtraVariableInfo]) bool { 54 | return a.Name == b.Name 55 | }) 56 | combinedVariableConfig := GetMaruVariableConfig() 57 | err = combinedVariableConfig.PopulateVariables(combinedVariables, setVariables) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | // Create the runner client to execute the task file 63 | runner := Runner{ 64 | tasksFile: tasksFile, 65 | existingTaskIncludeNameLocation: map[string]string{}, 66 | auth: auth, 67 | variableConfig: combinedVariableConfig, 68 | dryRun: dryRun, 69 | } 70 | 71 | task, err := runner.getTask(taskName) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | // Check that this task is a valid task we can call (i.e. has defaults for any unset inputs) 77 | if err := validateActionableTaskCall(task.Name, task.Inputs, withInputs); err != nil { 78 | return err 79 | } 80 | 81 | if err = runner.processTaskReferences(task, runner.tasksFile, setVariables); err != nil { 82 | return err 83 | } 84 | 85 | err = runner.executeTask(task, withInputs) 86 | return err 87 | } 88 | 89 | // GetMaruVariableConfig gets the variable configuration for Maru 90 | func GetMaruVariableConfig() *variables.VariableConfig[variables.ExtraVariableInfo] { 91 | prompt := func(_ variables.InteractiveVariable[variables.ExtraVariableInfo]) (value string, err error) { 92 | return "", nil 93 | } 94 | return variables.New[variables.ExtraVariableInfo](prompt, message.SLog) 95 | } 96 | 97 | func (r *Runner) processIncludes(tasksFile types.TasksFile, setVariables map[string]string, action types.Action) error { 98 | if strings.Contains(action.TaskReference, ":") { 99 | taskReferenceName := strings.Split(action.TaskReference, ":")[0] 100 | for _, include := range tasksFile.Includes { 101 | if include[taskReferenceName] != "" { 102 | referencedIncludes := []map[string]string{include} 103 | err := r.importTasks(referencedIncludes, config.TaskFileLocation, setVariables) 104 | if err != nil { 105 | return err 106 | } 107 | break 108 | } 109 | } 110 | } 111 | return nil 112 | } 113 | 114 | func (r *Runner) importTasks(includes []map[string]string, currentFileLocation string, setVariables map[string]string) error { 115 | // iterate through includes, open the file, and unmarshal it into a Task 116 | var includeKey string 117 | var includeLocation string 118 | for _, include := range includes { 119 | if len(include) > 1 { 120 | return fmt.Errorf("included item %s must have only one key", include) 121 | } 122 | // grab first and only value from include map 123 | for k, v := range include { 124 | includeKey = k 125 | includeLocation = v 126 | break 127 | } 128 | 129 | includeLocation = utils.TemplateString(r.variableConfig.GetSetVariables(), includeLocation) 130 | 131 | absIncludeFileLocation, tasksFile, err := LoadIncludeTask(currentFileLocation, includeLocation, r.auth) 132 | if err != nil { 133 | return fmt.Errorf("unable to read included file: %w", err) 134 | } 135 | // If we arrive here we assume this was a new include due to the later check 136 | r.existingTaskIncludeNameLocation[includeKey] = absIncludeFileLocation 137 | 138 | // prefix task names and actions with the includes key 139 | for i, t := range tasksFile.Tasks { 140 | tasksFile.Tasks[i].Name = includeKey + ":" + t.Name 141 | if len(tasksFile.Tasks[i].Actions) > 0 { 142 | for j, a := range tasksFile.Tasks[i].Actions { 143 | if a.TaskReference != "" && !strings.Contains(a.TaskReference, ":") { 144 | tasksFile.Tasks[i].Actions[j].TaskReference = includeKey + ":" + a.TaskReference 145 | } 146 | } 147 | } 148 | } 149 | 150 | r.tasksFile.Tasks = append(r.tasksFile.Tasks, tasksFile.Tasks...) 151 | 152 | r.mergeVariablesFromIncludedTask(tasksFile) 153 | 154 | // recursively import tasks from included files 155 | if tasksFile.Includes != nil { 156 | newIncludes := []map[string]string{} 157 | var newIncludeKey string 158 | var newIncludeLocation string 159 | for _, newInclude := range tasksFile.Includes { 160 | for k, v := range newInclude { 161 | newIncludeKey = k 162 | newIncludeLocation = v 163 | break 164 | } 165 | if existingLocation, exists := r.existingTaskIncludeNameLocation[newIncludeKey]; !exists { 166 | newIncludes = append(newIncludes, map[string]string{newIncludeKey: newIncludeLocation}) 167 | } else { 168 | newIncludeLocation = utils.TemplateString(r.variableConfig.GetSetVariables(), newIncludeLocation) 169 | newAbsIncludeFileLocation, err := includeTaskAbsLocation(absIncludeFileLocation, newIncludeLocation) 170 | if err != nil { 171 | return err 172 | } 173 | if existingLocation != newAbsIncludeFileLocation { 174 | return fmt.Errorf("task include %q attempted to be redefined from %q to %q", newIncludeKey, existingLocation, newAbsIncludeFileLocation) 175 | } 176 | } 177 | } 178 | if err := r.importTasks(newIncludes, absIncludeFileLocation, setVariables); err != nil { 179 | return err 180 | } 181 | } 182 | } 183 | return nil 184 | } 185 | 186 | func (r *Runner) mergeVariablesFromIncludedTask(tasksFile types.TasksFile) { 187 | // grab variables from included file 188 | for _, v := range tasksFile.Variables { 189 | if _, ok := r.variableConfig.GetSetVariable(v.Name); !ok { 190 | r.variableConfig.SetVariable(v.Name, v.Default, v.Pattern, v.Extra) 191 | } 192 | } 193 | } 194 | 195 | func loadIncludedTaskFile(taskFile types.TasksFile, taskName string, setVariables variables.SetVariableMap[variables.ExtraVariableInfo], auth map[string]string) (types.TasksFile, string, error) { 196 | // Check if running task directly from included task file 197 | includedTask := strings.Split(taskName, ":") 198 | if len(includedTask) == 2 { 199 | includeName := includedTask[0] 200 | includeTaskName := includedTask[1] 201 | // Get referenced include file 202 | for _, includes := range taskFile.Includes { 203 | if includeFileLocation, ok := includes[includeName]; ok { 204 | includeFileLocation = utils.TemplateString(setVariables, includeFileLocation) 205 | 206 | absIncludeFileLocation, includedTasksFile, err := LoadIncludeTask(config.TaskFileLocation, includeFileLocation, auth) 207 | config.TaskFileLocation = absIncludeFileLocation 208 | return includedTasksFile, includeTaskName, err 209 | } 210 | } 211 | } else if len(includedTask) > 2 { 212 | return taskFile, taskName, fmt.Errorf("invalid task name: %s", taskName) 213 | } 214 | return taskFile, taskName, nil 215 | } 216 | 217 | func includeTaskAbsLocation(currentFileLocation, includeFileLocation string) (string, error) { 218 | var absIncludeFileLocation string 219 | 220 | if !helpers.IsURL(includeFileLocation) { 221 | if helpers.IsURL(currentFileLocation) { 222 | currentURL, err := url.Parse(currentFileLocation) 223 | if err != nil { 224 | return absIncludeFileLocation, err 225 | } 226 | currentURL, err = utils.JoinURLRepoPath(currentURL, includeFileLocation) 227 | if err != nil { 228 | return "", err 229 | } 230 | absIncludeFileLocation = currentURL.String() 231 | } else { 232 | // Calculate the full path for local (and most remote) references 233 | absIncludeFileLocation = filepath.Join(filepath.Dir(currentFileLocation), includeFileLocation) 234 | } 235 | } else { 236 | absIncludeFileLocation = includeFileLocation 237 | } 238 | 239 | return absIncludeFileLocation, nil 240 | } 241 | 242 | // LoadIncludeTask loads an included task file either from a remote or local file 243 | func LoadIncludeTask(currentFileLocation, includeFileLocation string, auth map[string]string) (string, types.TasksFile, error) { 244 | var includedTasksFile types.TasksFile 245 | 246 | absIncludeFileLocation, err := includeTaskAbsLocation(currentFileLocation, includeFileLocation) 247 | if err != nil { 248 | return absIncludeFileLocation, includedTasksFile, err 249 | } 250 | 251 | // If the file is in fact a URL we need to download and load the YAML 252 | if helpers.IsURL(absIncludeFileLocation) { 253 | err = utils.ReadRemoteYaml(absIncludeFileLocation, &includedTasksFile, auth) 254 | } else { 255 | // Set TasksFile to the local included task file 256 | err = utils.ReadYaml(absIncludeFileLocation, &includedTasksFile) 257 | } 258 | 259 | return absIncludeFileLocation, includedTasksFile, err 260 | } 261 | 262 | func (r *Runner) getTask(taskName string) (types.Task, error) { 263 | for _, task := range r.tasksFile.Tasks { 264 | if task.Name == taskName { 265 | return task, nil 266 | } 267 | } 268 | return types.Task{}, fmt.Errorf("task name %s not found", taskName) 269 | } 270 | 271 | func (r *Runner) executeTask(task types.Task, withs map[string]string) error { 272 | if r.currStackSize > config.MaxStack { 273 | return fmt.Errorf("task looping exceeded max configured task stack of %d", config.MaxStack) 274 | } 275 | 276 | r.currStackSize++ 277 | defer func() { 278 | r.currStackSize-- 279 | }() 280 | 281 | env := []string{} 282 | // Load the withs 283 | for name, value := range withs { 284 | env = append(env, utils.FormatEnvVar(name, value)) 285 | } 286 | // load the default for each input if it has one and it isn't already set from withs 287 | for name, inputParam := range task.Inputs { 288 | if _, ok := withs[name]; ok { 289 | continue 290 | } 291 | d := inputParam.Default 292 | if d == "" { 293 | continue 294 | } 295 | env = append(env, utils.FormatEnvVar(name, d)) 296 | } 297 | 298 | // load the tasks env file into the runner, can override previous task's env files 299 | if task.EnvPath != "" { 300 | r.envFilePath = task.EnvPath 301 | } 302 | 303 | for _, action := range task.Actions { 304 | action.Env = utils.MergeEnv(action.Env, env) 305 | if err := r.performAction(action, withs, task.Inputs); err != nil { 306 | return err 307 | } 308 | } 309 | 310 | return nil 311 | } 312 | 313 | func (r *Runner) processTaskReferences(task types.Task, tasksFile types.TasksFile, setVariables map[string]string) error { 314 | if r.currStackSize > config.MaxStack { 315 | return fmt.Errorf("task looping exceeded max configured task stack of %d", config.MaxStack) 316 | } 317 | 318 | r.currStackSize++ 319 | defer func() { 320 | r.currStackSize-- 321 | }() 322 | 323 | // Filtering unique task actions allows for rerunning tasks in the same execution 324 | uniqueTaskActions := getUniqueTaskActions(task.Actions) 325 | for _, action := range uniqueTaskActions { 326 | if r.processAction(task, action) { 327 | // process includes for action, which will import all tasks for include file 328 | if err := r.processIncludes(tasksFile, setVariables, action); err != nil { 329 | return err 330 | } 331 | 332 | newTask, err := r.getTask(action.TaskReference) 333 | if err != nil { 334 | return err 335 | } 336 | if err = r.processTaskReferences(newTask, tasksFile, setVariables); err != nil { 337 | return err 338 | } 339 | } 340 | } 341 | return nil 342 | } 343 | -------------------------------------------------------------------------------- /src/pkg/runner/actions.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | // Package runner provides functions for running tasks in a tasks.yaml 5 | package runner 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | "github.com/defenseunicorns/maru-runner/src/pkg/variables" 16 | "github.com/defenseunicorns/pkg/exec" 17 | "github.com/defenseunicorns/pkg/helpers/v2" 18 | 19 | "github.com/defenseunicorns/maru-runner/src/config" 20 | "github.com/defenseunicorns/maru-runner/src/message" 21 | "github.com/defenseunicorns/maru-runner/src/pkg/utils" 22 | "github.com/defenseunicorns/maru-runner/src/types" 23 | ) 24 | 25 | func (r *Runner) performAction(action types.Action, withs map[string]string, inputs map[string]types.InputParameter) error { 26 | 27 | message.SLog.Debug(fmt.Sprintf("Evaluating action conditional %s", action.If)) 28 | 29 | action, _ = utils.TemplateTaskAction(action, withs, inputs, r.variableConfig.GetSetVariables()) 30 | if action.If == "false" && action.TaskReference != "" { 31 | message.SLog.Info(fmt.Sprintf("Skipping action %s", action.TaskReference)) 32 | return nil 33 | } else if action.If == "false" && action.Description != "" { 34 | message.SLog.Info(fmt.Sprintf("Skipping action %s", action.Description)) 35 | return nil 36 | } else if action.If == "false" && action.Cmd != "" { 37 | cmdEscaped := helpers.Truncate(action.Cmd, 60, false) 38 | message.SLog.Info(fmt.Sprintf("Skipping action %q", cmdEscaped)) 39 | return nil 40 | } 41 | 42 | if action.TaskReference != "" { 43 | // todo: much of this logic is duplicated in Run, consider refactoring 44 | referencedTask, err := r.getTask(action.TaskReference) 45 | if err != nil { 46 | return err 47 | } 48 | for k, v := range action.With { 49 | action.With[k] = utils.TemplateString(r.variableConfig.GetSetVariables(), v) 50 | } 51 | withEnv := []string{} 52 | for name := range action.With { 53 | withEnv = append(withEnv, utils.FormatEnvVar(name, action.With[name])) 54 | } 55 | if err := validateActionableTaskCall(referencedTask.Name, referencedTask.Inputs, action.With); err != nil { 56 | return err 57 | } 58 | for _, a := range referencedTask.Actions { 59 | a.Env = utils.MergeEnv(withEnv, a.Env) 60 | } 61 | 62 | if err := r.executeTask(referencedTask, action.With); err != nil { 63 | return err 64 | } 65 | } else { 66 | err := RunAction(action.BaseAction, r.envFilePath, r.variableConfig, r.dryRun) 67 | if err != nil { 68 | return err 69 | } 70 | } 71 | return nil 72 | } 73 | 74 | // processAction checks if action needs to be processed for a given task 75 | func (r *Runner) processAction(task types.Task, action types.Action) bool { 76 | 77 | taskReferenceName := strings.Split(task.Name, ":")[0] 78 | actionReferenceName := strings.Split(action.TaskReference, ":")[0] 79 | // don't need to process if the action.TaskReference is empty or if the task and action references are the same since 80 | // that indicates the task and task in the action are in the same file 81 | if action.TaskReference != "" && (taskReferenceName != actionReferenceName) { 82 | for _, task := range r.tasksFile.Tasks { 83 | // check if TasksFile.Tasks already includes tasks with given reference name, which indicates that the 84 | // reference has already been processed. 85 | if strings.Contains(task.Name, taskReferenceName+":") || strings.Contains(task.Name, actionReferenceName+":") { 86 | return false 87 | } 88 | } 89 | return true 90 | } 91 | return false 92 | } 93 | 94 | func getUniqueTaskActions(actions []types.Action) []types.Action { 95 | uniqueMap := make(map[string]bool) 96 | var uniqueArray []types.Action 97 | 98 | for _, action := range actions { 99 | if !uniqueMap[action.TaskReference] { 100 | uniqueMap[action.TaskReference] = true 101 | uniqueArray = append(uniqueArray, action) 102 | } 103 | } 104 | return uniqueArray 105 | } 106 | 107 | // RunAction executes a specific action command, either wait or cmd. It handles variable loading environment variables and manages retries and timeouts 108 | func RunAction[T any](action *types.BaseAction[T], envFilePath string, variableConfig *variables.VariableConfig[T], dryRun bool) error { 109 | var ( 110 | ctx context.Context 111 | cancel context.CancelFunc 112 | cmdEscaped string 113 | out string 114 | err error 115 | 116 | cmd = action.Cmd 117 | ) 118 | 119 | // If the action is a wait, convert it to a command. 120 | if action.Wait != nil { 121 | // If the wait has no timeout, set a default of 5 minutes. 122 | if action.MaxTotalSeconds == nil { 123 | fiveMin := 300 124 | action.MaxTotalSeconds = &fiveMin 125 | } 126 | 127 | // Convert the wait to a command. 128 | if cmd, err = convertWaitToCmd(*action.Wait, action.MaxTotalSeconds); err != nil { 129 | return err 130 | } 131 | 132 | // Mute the output because it will be noisy. 133 | t := true 134 | action.Mute = &t 135 | 136 | // Set the max retries to 0. 137 | z := 0 138 | action.MaxRetries = &z 139 | 140 | // Not used for wait actions. 141 | d := "" 142 | action.Dir = &d 143 | action.Env = []string{} 144 | action.SetVariables = []variables.Variable[T]{} 145 | } 146 | 147 | if action.Description != "" { 148 | cmdEscaped = action.Description 149 | } else { 150 | cmdEscaped = helpers.Truncate(cmd, 60, false) 151 | } 152 | 153 | // if this is a dry run, print the command that would run and return 154 | if dryRun { 155 | message.SLog.Info(fmt.Sprintf("Dry-running %q", cmdEscaped)) 156 | fmt.Println(cmd) 157 | return nil 158 | } 159 | 160 | // load the contents of the env file into the Action 161 | if envFilePath != "" { 162 | envFilePath := filepath.Join(filepath.Dir(config.TaskFileLocation), envFilePath) 163 | envFileContents, err := os.ReadFile(envFilePath) 164 | if err != nil { 165 | return err 166 | } 167 | action.Env = append(action.Env, strings.Split(string(envFileContents), "\n")...) 168 | } 169 | 170 | spinner := message.NewProgressSpinner("Running %q", cmdEscaped) 171 | 172 | cfg := GetBaseActionCfg(types.ActionDefaults{}, *action, variableConfig.GetSetVariables()) 173 | 174 | if cmd = exec.MutateCommand(cmd, cfg.Shell); err != nil { 175 | message.SLog.Debug(err.Error()) 176 | spinner.Failf("Error mutating command: %q", cmdEscaped) 177 | } 178 | 179 | // Template dir string 180 | cfg.Dir = utils.TemplateString(variableConfig.GetSetVariables(), cfg.Dir) 181 | 182 | // Template env strings 183 | for idx := range cfg.Env { 184 | cfg.Env[idx] = utils.TemplateString(variableConfig.GetSetVariables(), cfg.Env[idx]) 185 | } 186 | 187 | duration := time.Duration(cfg.MaxTotalSeconds) * time.Second 188 | timeout := time.After(duration) 189 | 190 | // Keep trying until the max retries is reached. 191 | retryLoop: 192 | for remaining := cfg.MaxRetries + 1; remaining > 0; remaining-- { 193 | 194 | // Perform the action run. 195 | tryCmd := func(ctx context.Context) error { 196 | // Try running the command and continue the retry loop if it fails. 197 | if out, err = ExecAction(ctx, cfg, cmd, cfg.Shell, spinner); err != nil { 198 | return err 199 | } 200 | 201 | out = strings.TrimSpace(out) 202 | 203 | // If an output variable is defined, set it. 204 | for _, v := range action.SetVariables { 205 | variableConfig.SetVariable(v.Name, out, v.Pattern, v.Extra) 206 | if err = variableConfig.CheckVariablePattern(v.Name); err != nil { 207 | message.SLog.Debug(err.Error()) 208 | message.SLog.Warn(err.Error()) 209 | return err 210 | } 211 | } 212 | 213 | // If the action has a wait, change the spinner message to reflect that on success. 214 | if action.Wait != nil { 215 | spinner.Successf("Wait for %q succeeded", cmdEscaped) 216 | } else { 217 | spinner.Successf("Completed %q", cmdEscaped) 218 | } 219 | 220 | // If the command ran successfully, continue to the next action. 221 | return nil 222 | } 223 | 224 | // If no timeout is set, run the command and return or continue retrying. 225 | if cfg.MaxTotalSeconds < 1 { 226 | spinner.Updatef("Waiting for \"%s\" (no timeout)", cmdEscaped) 227 | if err := tryCmd(context.TODO()); err != nil { 228 | continue 229 | } 230 | 231 | return nil 232 | } 233 | 234 | // Run the command on repeat until success or timeout. 235 | spinner.Updatef("Waiting for \"%s\" (timeout: %ds)", cmdEscaped, cfg.MaxTotalSeconds) 236 | select { 237 | // On timeout break the loop to abort. 238 | case <-timeout: 239 | break retryLoop 240 | 241 | // Otherwise, try running the command. 242 | default: 243 | ctx, cancel = context.WithTimeout(context.Background(), duration) 244 | if err := tryCmd(ctx); err != nil { 245 | cancel() // Directly cancel the context after an unsuccessful command attempt. 246 | continue 247 | } 248 | cancel() // Also cancel the context after a successful command attempt. 249 | return nil 250 | } 251 | } 252 | 253 | select { 254 | case <-timeout: 255 | // If we reached this point, the timeout was reached. 256 | return fmt.Errorf("command \"%s\" timed out after %d seconds", cmdEscaped, cfg.MaxTotalSeconds) 257 | 258 | default: 259 | // If we reached this point, the retry limit was reached. 260 | return fmt.Errorf("command \"%s\" failed after %d retries", cmdEscaped, cfg.MaxRetries) 261 | } 262 | } 263 | 264 | // GetBaseActionCfg merges the ActionDefaults with the BaseAction's configuration 265 | func GetBaseActionCfg[T any](cfg types.ActionDefaults, a types.BaseAction[T], vars variables.SetVariableMap[T]) types.ActionDefaults { 266 | if a.Mute != nil { 267 | cfg.Mute = *a.Mute 268 | } 269 | 270 | // Default is no timeout, but add a timeout if one is provided. 271 | if a.MaxTotalSeconds != nil { 272 | cfg.MaxTotalSeconds = *a.MaxTotalSeconds 273 | } 274 | 275 | if a.MaxRetries != nil { 276 | cfg.MaxRetries = *a.MaxRetries 277 | } 278 | 279 | if a.Dir != nil { 280 | cfg.Dir = *a.Dir 281 | } 282 | 283 | if len(a.Env) > 0 { 284 | cfg.Env = append(cfg.Env, a.Env...) 285 | } 286 | 287 | if a.Shell != nil { 288 | cfg.Shell = *a.Shell 289 | } 290 | 291 | // Add variables to the environment. 292 | for k, v := range vars { 293 | cfg.Env = append(cfg.Env, fmt.Sprintf("%s=%s", k, v.Value)) 294 | } 295 | 296 | for k, v := range config.GetExtraEnv() { 297 | cfg.Env = append(cfg.Env, fmt.Sprintf("%s=%s", k, v)) 298 | } 299 | 300 | return cfg 301 | } 302 | 303 | // ExecAction executes the given action configuration with the provided context 304 | func ExecAction(ctx context.Context, cfg types.ActionDefaults, cmd string, shellPref exec.ShellPreference, spinner helpers.ProgressWriter) (string, error) { 305 | shell, shellArgs := exec.GetOSShell(shellPref) 306 | 307 | message.SLog.Debug(fmt.Sprintf("Running command in %s: %s", shell, cmd)) 308 | 309 | execCfg := exec.Config{ 310 | Env: cfg.Env, 311 | Dir: cfg.Dir, 312 | } 313 | 314 | if !cfg.Mute { 315 | execCfg.Stdout = spinner 316 | execCfg.Stderr = spinner 317 | } 318 | 319 | out, errOut, err := exec.CmdWithContext(ctx, execCfg, shell, append(shellArgs, cmd)...) 320 | // Dump final complete output (respect mute to prevent sensitive values from hitting the logs). 321 | if !cfg.Mute { 322 | message.SLog.Debug(fmt.Sprintf("%s %s %s", cmd, out, errOut)) 323 | } 324 | 325 | return out, err 326 | } 327 | 328 | // TODO: (@WSTARR) - this is broken in Maru right now - this should not shell to Kubectl and instead should internally talk to a cluster 329 | // convertWaitToCmd will return the wait command if it exists, otherwise it will return the original command. 330 | func convertWaitToCmd(wait types.ActionWait, timeout *int) (string, error) { 331 | // Build the timeout string. 332 | timeoutString := fmt.Sprintf("--timeout %ds", *timeout) 333 | 334 | // If the action has a wait, build a cmd from that instead. 335 | cluster := wait.Cluster 336 | if cluster != nil { 337 | ns := cluster.Namespace 338 | if ns != "" { 339 | ns = fmt.Sprintf("-n %s", ns) 340 | } 341 | 342 | // Build a call to the zarf wait-for command (uses system Zarf) 343 | cmd := fmt.Sprintf("zarf tools wait-for %s %s %s %s %s", 344 | cluster.Kind, cluster.Identifier, cluster.Condition, ns, timeoutString) 345 | 346 | // config.CmdPrefix is set when vendoring both the runner and Zarf 347 | if config.CmdPrefix != "" { 348 | cmd = fmt.Sprintf("./%s %s", config.CmdPrefix, cmd) 349 | } 350 | return cmd, nil 351 | } 352 | 353 | network := wait.Network 354 | if network != nil { 355 | // Make sure the protocol is lower case. 356 | network.Protocol = strings.ToLower(network.Protocol) 357 | 358 | // If the protocol is http and no code is set, default to 200. 359 | if strings.HasPrefix(network.Protocol, "http") && network.Code == 0 { 360 | network.Code = 200 361 | } 362 | 363 | // Build a call to the zarf wait-for command (uses system Zarf) 364 | cmd := fmt.Sprintf("zarf tools wait-for %s %s %d %s", 365 | network.Protocol, network.Address, network.Code, timeoutString) 366 | 367 | // config.CmdPrefix is set when vendoring both the runner and Zarf 368 | if config.CmdPrefix != "" { 369 | cmd = fmt.Sprintf("./%s %s", config.CmdPrefix, cmd) 370 | } 371 | return cmd, nil 372 | } 373 | 374 | return "", fmt.Errorf("wait action is missing a cluster or network") 375 | } 376 | 377 | // validateActionableTaskCall validates a tasks "withs" and inputs 378 | func validateActionableTaskCall(inputTaskName string, inputs map[string]types.InputParameter, withs map[string]string) error { 379 | missing := []string{} 380 | for inputKey, input := range inputs { 381 | // skip inputs that are not required or have a default value 382 | if !input.Required || input.Default != "" { 383 | continue 384 | } 385 | checked := false 386 | for withKey, withVal := range withs { 387 | // verify that the input is in the with map and the "with" has a value 388 | if inputKey == withKey && withVal != "" { 389 | checked = true 390 | break 391 | } 392 | } 393 | if !checked { 394 | missing = append(missing, inputKey) 395 | } 396 | } 397 | if len(missing) > 0 { 398 | return fmt.Errorf("task %s is missing required inputs: %s", inputTaskName, strings.Join(missing, ", ")) 399 | } 400 | for withKey := range withs { 401 | matched := false 402 | for inputKey, input := range inputs { 403 | if withKey == inputKey { 404 | if input.DeprecatedMessage != "" { 405 | message.SLog.Warn(fmt.Sprintf("This input has been marked deprecated: %s", input.DeprecatedMessage)) 406 | } 407 | matched = true 408 | break 409 | } 410 | } 411 | if !matched { 412 | message.SLog.Warn(fmt.Sprintf("Task %s does not have an input named %s", inputTaskName, withKey)) 413 | } 414 | } 415 | return nil 416 | } 417 | -------------------------------------------------------------------------------- /src/pkg/runner/actions_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present the Maru Authors 3 | 4 | package runner 5 | 6 | import ( 7 | "reflect" 8 | "slices" 9 | "testing" 10 | 11 | "github.com/defenseunicorns/maru-runner/src/config" 12 | "github.com/defenseunicorns/maru-runner/src/types" 13 | 14 | "github.com/defenseunicorns/maru-runner/src/pkg/variables" 15 | 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | func Test_getUniqueTaskActions(t *testing.T) { 20 | t.Parallel() 21 | type args struct { 22 | actions []types.Action 23 | } 24 | tests := []struct { 25 | name string 26 | args args 27 | want []types.Action 28 | }{ 29 | { 30 | name: "No duplicates", 31 | args: args{ 32 | actions: []types.Action{ 33 | {TaskReference: "task1"}, 34 | {TaskReference: "task2"}, 35 | }, 36 | }, 37 | want: []types.Action{ 38 | {TaskReference: "task1"}, 39 | {TaskReference: "task2"}, 40 | }, 41 | }, 42 | { 43 | name: "With duplicates", 44 | args: args{ 45 | actions: []types.Action{ 46 | {TaskReference: "task1"}, 47 | {TaskReference: "task1"}, 48 | {TaskReference: "task2"}, 49 | }, 50 | }, 51 | want: []types.Action{ 52 | {TaskReference: "task1"}, 53 | {TaskReference: "task2"}, 54 | }, 55 | }, 56 | { 57 | name: "All duplicates", 58 | args: args{ 59 | actions: []types.Action{ 60 | {TaskReference: "task1"}, 61 | {TaskReference: "task1"}, 62 | {TaskReference: "task1"}, 63 | }, 64 | }, 65 | want: []types.Action{ 66 | {TaskReference: "task1"}, 67 | }, 68 | }, 69 | { 70 | name: "Empty slice", 71 | args: args{ 72 | actions: nil, 73 | }, 74 | want: nil, 75 | }, 76 | } 77 | for _, tt := range tests { 78 | 79 | t.Run(tt.name, func(t *testing.T) { 80 | t.Parallel() 81 | if got := getUniqueTaskActions(tt.args.actions); !reflect.DeepEqual(got, tt.want) { 82 | t.Errorf("getUniqueTaskActions() = %v, want %v", got, tt.want) 83 | } 84 | }) 85 | } 86 | } 87 | 88 | func Test_convertWaitToCmd(t *testing.T) { 89 | type args struct { 90 | wait types.ActionWait 91 | timeout *int 92 | } 93 | tests := []struct { 94 | name string 95 | args args 96 | want string 97 | wantErr bool 98 | }{ 99 | { 100 | name: "Cluster wait command", 101 | args: args{ 102 | wait: types.ActionWait{ 103 | Cluster: &types.ActionWaitCluster{ 104 | Kind: "pod", 105 | Identifier: "my-pod", 106 | Condition: "Ready", 107 | Namespace: "default", 108 | }, 109 | }, 110 | timeout: IntPtr(300), 111 | }, 112 | want: "zarf tools wait-for pod my-pod Ready -n default --timeout 300s", 113 | wantErr: false, 114 | }, 115 | { 116 | name: "Network wait command", 117 | args: args{ 118 | wait: types.ActionWait{ 119 | Network: &types.ActionWaitNetwork{ 120 | Protocol: "http", 121 | Address: "http://example.com", 122 | Code: 200, 123 | }, 124 | }, 125 | timeout: IntPtr(60), 126 | }, 127 | want: "zarf tools wait-for http http://example.com 200 --timeout 60s", 128 | wantErr: false, 129 | }, 130 | { 131 | name: "Invalid wait action", 132 | args: args{ 133 | wait: types.ActionWait{}, 134 | timeout: IntPtr(30), 135 | }, 136 | want: "", 137 | wantErr: true, 138 | }, 139 | } 140 | for _, tt := range tests { 141 | t.Run(tt.name, func(t *testing.T) { 142 | got, err := convertWaitToCmd(tt.args.wait, tt.args.timeout) 143 | if (err != nil) != tt.wantErr { 144 | t.Errorf("convertWaitToCmd() error = %v, wantErr %v", err, tt.wantErr) 145 | return 146 | } 147 | if got != tt.want { 148 | t.Errorf("convertWaitToCmd() got = %v, want %v", got, tt.want) 149 | } 150 | }) 151 | } 152 | } 153 | 154 | func IntPtr(i int) *int { 155 | return &i 156 | } 157 | 158 | func Test_validateActionableTaskCall(t *testing.T) { 159 | type args struct { 160 | inputTaskName string 161 | inputs map[string]types.InputParameter 162 | withs map[string]string 163 | } 164 | tests := []struct { 165 | name string 166 | args args 167 | wantErr bool 168 | }{ 169 | { 170 | name: "Valid task call with all required inputs", 171 | args: args{ 172 | inputTaskName: "testTask", 173 | inputs: map[string]types.InputParameter{ 174 | "input1": {Required: true, Default: ""}, 175 | "input2": {Required: true, Default: ""}, 176 | }, 177 | withs: map[string]string{ 178 | "input1": "value1", 179 | "input2": "value2", 180 | }, 181 | }, 182 | wantErr: false, 183 | }, 184 | { 185 | name: "Invalid task call with missing required input", 186 | args: args{ 187 | inputTaskName: "testTask", 188 | inputs: map[string]types.InputParameter{ 189 | "input1": {Required: true, Default: ""}, 190 | "input2": {Required: true, Default: ""}, 191 | }, 192 | withs: map[string]string{ 193 | "input1": "value1", 194 | }, 195 | }, 196 | wantErr: true, 197 | }, 198 | { 199 | name: "Valid task call with default value for missing input", 200 | args: args{ 201 | inputTaskName: "testTask", 202 | 203 | inputs: map[string]types.InputParameter{ 204 | "input1": {Required: true, Default: "defaultValue"}, 205 | "input2": {Required: true, Default: ""}, 206 | }, 207 | withs: map[string]string{ 208 | "input2": "value2", 209 | }, 210 | }, 211 | wantErr: false, 212 | }, 213 | } 214 | for _, tt := range tests { 215 | t.Run(tt.name, func(t *testing.T) { 216 | if err := validateActionableTaskCall(tt.args.inputTaskName, tt.args.inputs, tt.args.withs); (err != nil) != tt.wantErr { 217 | t.Errorf("validateActionableTaskCall() error = %v, wantErr %v", err, tt.wantErr) 218 | } 219 | }) 220 | } 221 | } 222 | 223 | func TestRunner_performAction(t *testing.T) { 224 | type fields struct { 225 | TasksFile types.TasksFile 226 | ExistingTaskIncludeNameLocation map[string]string 227 | envFilePath string 228 | variableConfig *variables.VariableConfig[variables.ExtraVariableInfo] 229 | } 230 | type args struct { 231 | action types.Action 232 | inputs map[string]types.InputParameter 233 | withs map[string]string 234 | } 235 | tests := []struct { 236 | name string 237 | fields fields 238 | args args 239 | wantErr bool 240 | }{ 241 | // TODO: Add more test cases 242 | // https://github.com/defenseunicorns/maru-runner/issues/143 243 | { 244 | name: "failed action processing due to invalid command", 245 | fields: fields{ 246 | TasksFile: types.TasksFile{}, 247 | ExistingTaskIncludeNameLocation: make(map[string]string), 248 | envFilePath: "", 249 | variableConfig: GetMaruVariableConfig(), 250 | }, 251 | args: args{ 252 | action: types.Action{ 253 | TaskReference: "", 254 | With: map[string]string{ 255 | "cmd": "exit 1", 256 | }, 257 | BaseAction: &types.BaseAction[variables.ExtraVariableInfo]{ 258 | Description: "Test action for failure scenario", 259 | Wait: nil, 260 | }, 261 | }, 262 | }, 263 | }, 264 | { 265 | name: "Unable to open path", 266 | fields: fields{ 267 | TasksFile: types.TasksFile{}, 268 | ExistingTaskIncludeNameLocation: make(map[string]string), 269 | envFilePath: "test/path", 270 | variableConfig: GetMaruVariableConfig(), 271 | }, 272 | args: args{ 273 | action: types.Action{ 274 | TaskReference: "", 275 | With: map[string]string{ 276 | "cmd": "zarf tools wait-for pod my-pod Running", 277 | }, 278 | BaseAction: &types.BaseAction[variables.ExtraVariableInfo]{ 279 | Description: "Test action for wait command", 280 | Wait: &types.ActionWait{ 281 | Cluster: &types.ActionWaitCluster{ 282 | Kind: "pod", 283 | Identifier: "my-pod", 284 | Condition: "Running", 285 | }, 286 | }, 287 | }, 288 | }, 289 | }, 290 | wantErr: true, 291 | }, 292 | } 293 | for _, tt := range tests { 294 | t.Run(tt.name, func(t *testing.T) { 295 | r := &Runner{ 296 | tasksFile: tt.fields.TasksFile, 297 | existingTaskIncludeNameLocation: tt.fields.ExistingTaskIncludeNameLocation, 298 | envFilePath: tt.fields.envFilePath, 299 | variableConfig: tt.fields.variableConfig, 300 | } 301 | err := r.performAction(tt.args.action, tt.args.withs, tt.args.inputs) 302 | if (err != nil) != tt.wantErr { 303 | t.Errorf("performAction() error = %v, wantErr %v", err, tt.wantErr) 304 | } 305 | }) 306 | } 307 | } 308 | 309 | func TestRunner_processAction(t *testing.T) { 310 | type fields struct { 311 | TasksFile types.TasksFile 312 | ExistingTaskIncludeNameLocation map[string]string 313 | envFilePath string 314 | variableConfig *variables.VariableConfig[variables.ExtraVariableInfo] 315 | } 316 | type args struct { 317 | task types.Task 318 | action types.Action 319 | } 320 | tests := []struct { 321 | name string 322 | fields fields 323 | args args 324 | want bool 325 | }{ 326 | { 327 | name: "successful action processing", 328 | fields: fields{ 329 | TasksFile: types.TasksFile{}, 330 | ExistingTaskIncludeNameLocation: map[string]string{}, 331 | envFilePath: "", 332 | variableConfig: GetMaruVariableConfig(), 333 | }, 334 | args: args{ 335 | task: types.Task{ 336 | Name: "testTask", 337 | }, 338 | action: types.Action{ 339 | TaskReference: "testTaskRef", 340 | }, 341 | }, 342 | want: true, 343 | }, 344 | { 345 | name: "action processing with same task and action reference", 346 | fields: fields{ 347 | TasksFile: types.TasksFile{}, 348 | ExistingTaskIncludeNameLocation: map[string]string{}, 349 | envFilePath: "", 350 | variableConfig: GetMaruVariableConfig(), 351 | }, 352 | args: args{ 353 | task: types.Task{ 354 | Name: "testTask", 355 | }, 356 | action: types.Action{ 357 | TaskReference: "testTask", 358 | }, 359 | }, 360 | want: false, 361 | }, 362 | { 363 | name: "action processing with empty task reference", 364 | fields: fields{ 365 | TasksFile: types.TasksFile{}, 366 | ExistingTaskIncludeNameLocation: map[string]string{}, 367 | envFilePath: "", 368 | variableConfig: GetMaruVariableConfig(), 369 | }, 370 | args: args{ 371 | task: types.Task{ 372 | Name: "testTask", 373 | }, 374 | action: types.Action{ 375 | TaskReference: "", 376 | }, 377 | }, 378 | want: false, 379 | }, 380 | { 381 | name: "action processing with non-empty task reference and different task and action reference names", 382 | fields: fields{ 383 | TasksFile: types.TasksFile{}, 384 | ExistingTaskIncludeNameLocation: map[string]string{}, 385 | envFilePath: "", 386 | variableConfig: GetMaruVariableConfig(), 387 | }, 388 | args: args{ 389 | task: types.Task{ 390 | Name: "testTask", 391 | }, 392 | action: types.Action{ 393 | TaskReference: "differentTaskRef", 394 | }, 395 | }, 396 | want: true, 397 | }, 398 | { 399 | name: "action processing with task reference already processed", 400 | fields: fields{ 401 | TasksFile: types.TasksFile{ 402 | Tasks: []types.Task{ 403 | { 404 | Name: "testTaskRef:subTask", 405 | }, 406 | }, 407 | }, 408 | ExistingTaskIncludeNameLocation: map[string]string{}, 409 | envFilePath: "", 410 | variableConfig: GetMaruVariableConfig(), 411 | }, 412 | args: args{ 413 | task: types.Task{ 414 | Name: "testTask", 415 | }, 416 | action: types.Action{ 417 | TaskReference: "testTaskRef", 418 | }, 419 | }, 420 | want: false, 421 | }, 422 | } 423 | for _, tt := range tests { 424 | t.Run(tt.name, func(t *testing.T) { 425 | r := &Runner{ 426 | tasksFile: tt.fields.TasksFile, 427 | existingTaskIncludeNameLocation: tt.fields.ExistingTaskIncludeNameLocation, 428 | envFilePath: tt.fields.envFilePath, 429 | variableConfig: tt.fields.variableConfig, 430 | } 431 | if got := r.processAction(tt.args.task, tt.args.action); got != tt.want { 432 | t.Errorf("processAction() got = %v, want %v", got, tt.want) 433 | } 434 | }) 435 | } 436 | } 437 | 438 | func TestRunner_GetBaseActionCfg(t *testing.T) { 439 | type args struct { 440 | cfg types.ActionDefaults 441 | a types.BaseAction[string] 442 | vars variables.SetVariableMap[string] 443 | extraEnv map[string]string 444 | } 445 | tests := []struct { 446 | name string 447 | args args 448 | want []string 449 | }{ 450 | { 451 | name: "ActionDefaults used when no overrides", 452 | args: args{ 453 | cfg: types.ActionDefaults{ 454 | Env: []string{"ENV1=fromDefault", "ENV2=xyz"}, 455 | }, 456 | a: types.BaseAction[string]{}, 457 | }, 458 | want: []string{"ENV1=fromDefault", "ENV2=xyz"}, 459 | }, 460 | { 461 | name: "extraEnv overrides defaults", 462 | args: args{ 463 | cfg: types.ActionDefaults{ 464 | Env: []string{"ENV1=fromDefault", "ENV2=xyz1"}, 465 | }, 466 | a: types.BaseAction[string]{}, 467 | vars: variables.SetVariableMap[string]{"ENV1": {Value: "fromSet"}}, 468 | extraEnv: map[string]string{"ENV1": "fromExtra"}, 469 | }, 470 | want: []string{"ENV1=fromDefault", "ENV2=xyz1", "ENV1=fromSet", "ENV1=fromExtra"}, 471 | }, 472 | { 473 | name: "extraEnv adds to defaults", 474 | args: args{ 475 | cfg: types.ActionDefaults{ 476 | Env: []string{"ENV1=fromDefault", "ENV2=xyz1"}, 477 | }, 478 | a: types.BaseAction[string]{}, 479 | vars: variables.SetVariableMap[string]{"ENV1": {Value: "fromSet"}}, 480 | extraEnv: map[string]string{"ENV3": "fromExtra"}, 481 | }, 482 | want: []string{"ENV1=fromDefault", "ENV2=xyz1", "ENV1=fromSet", "ENV3=fromExtra"}, 483 | }, 484 | { 485 | name: "extraEnv adds and overrides defaults", 486 | args: args{ 487 | cfg: types.ActionDefaults{ 488 | Env: []string{"ENV1=fromDefault", "ENV2=xyz1"}, 489 | }, 490 | a: types.BaseAction[string]{}, 491 | vars: variables.SetVariableMap[string]{"ENV4": {Value: "fromSet"}}, 492 | extraEnv: map[string]string{"ENV2": "alsoFromEnv", "ENV3": "fromExtra"}, 493 | }, 494 | want: []string{"ENV1=fromDefault", "ENV2=xyz1", "ENV4=fromSet", "ENV2=alsoFromEnv", "ENV3=fromExtra"}, 495 | }, 496 | } 497 | 498 | for _, tt := range tests { 499 | t.Run(tt.name, func(t *testing.T) { 500 | config.ClearExtraEnv() 501 | for k, v := range tt.args.extraEnv { 502 | config.AddExtraEnv(k, v) 503 | } 504 | 505 | got := GetBaseActionCfg(tt.args.cfg, tt.args.a, tt.args.vars) 506 | slices.Sort(got.Env) 507 | slices.Sort(tt.want) 508 | require.Equal(t, tt.want, got.Env, "The returned Env array did not match what was wanted") 509 | }) 510 | } 511 | 512 | } 513 | --------------------------------------------------------------------------------