diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 00000000..c9b930e1 --- /dev/null +++ b/.codespellrc @@ -0,0 +1,2 @@ +[codespell] +skip = template_helpers_test.go diff --git a/.github/workflows/base-test.yml b/.github/workflows/base-test.yml new file mode 100644 index 00000000..856d2e72 --- /dev/null +++ b/.github/workflows/base-test.yml @@ -0,0 +1,72 @@ +name: Base Tests + +on: + workflow_call: + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }}-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu, macos] + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Use mise to install dependencies + uses: jdx/mise-action@v2 + with: + version: 2025.8.16 + experimental: true + env: + # Adding token here to reduce the likelihood of hitting rate limit issues. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - id: go-cache-paths + run: | + echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" + echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Go Build Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-build }} + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}-amd64 + + - name: Go Mod Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-mod }} + key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}-amd64 + + - name: Install go-junit-report + run: go install github.com/jstemmer/go-junit-report/v2@latest + + - name: Run Tests + id: run-tests + run: | + set -o pipefail + go test -v ./... -timeout 45m | tee >(go-junit-report -set-exit-code > result.xml) + shell: bash + env: + # Adding token here to reduce the likelihood of hitting rate limit issues. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Report (${{ matrix.os }}) + uses: actions/upload-artifact@v4 + with: + name: test-report-${{ matrix.os }} + path: result.xml + + - name: Display Test Results (${{ matrix.os }}) + uses: mikepenz/action-junit-report@v5 + if: always() + with: + report_paths: result.xml + detailed_summary: 'true' + include_time_in_summary: 'true' + group_suite: 'true' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..0d1ab6ca --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,67 @@ +name: Build + +on: + workflow_call: + +jobs: + build: + name: Build (${{ matrix.os }}/${{ matrix.arch }}) + runs-on: ubuntu-latest + strategy: + matrix: + include: + - os: darwin + arch: amd64 + - os: darwin + arch: arm64 + - os: linux + arch: "386" + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: windows + arch: "386" + - os: windows + arch: amd64 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Use mise to install dependencies + uses: jdx/mise-action@v2 + with: + version: 2025.8.16 + experimental: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - id: go-cache-paths + run: | + echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" + + - name: Go Build Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-build }} + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}-${{ matrix.arch }} + + - name: Build Boilerplate + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + run: | + OUTPUT="bin/boilerplate_${GOOS}_${GOARCH}" + if [[ "${GOOS}" == "windows" ]]; then + OUTPUT="${OUTPUT}.exe" + fi + go build -o "${OUTPUT}" \ + -ldflags "-s -w -X github.com/gruntwork-io/go-commons/version.Version=${GITHUB_REF_NAME} -extldflags '-static'" \ + . + + - name: Upload Build Artifact + uses: actions/upload-artifact@v4 + with: + name: boilerplate_${{ matrix.os }}_${{ matrix.arch }} + path: bin/boilerplate_${{ matrix.os }}_${{ matrix.arch }}* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..10453fc7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + +jobs: + lint: + uses: ./.github/workflows/lint.yml + secrets: inherit + + codespell: + uses: ./.github/workflows/codespell.yml + secrets: inherit + + base_tests: + needs: [lint, codespell] + uses: ./.github/workflows/base-test.yml + permissions: + contents: read + checks: write + secrets: inherit + + build: + needs: [lint, codespell] + uses: ./.github/workflows/build.yml + secrets: inherit diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 00000000..1ea07614 --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,36 @@ +name: Codespell + +on: + workflow_call: + +jobs: + codespell: + name: Check Spelling + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Create default packages file + run: | + cat < .mise-python-default-packages + codespell==2.4.0 + EOF + + echo "MISE_PYTHON_DEFAULT_PACKAGES_FILE=.mise-python-default-packages" >> "$GITHUB_ENV" + + - name: Use mise to install dependencies + uses: jdx/mise-action@v2 + with: + version: 2025.8.16 + experimental: true + mise_toml: | + [tools] + python = "3.13.3" + env: + # Adding token here to reduce the likelihood of hitting rate limit issues. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Run codespell + run: codespell . diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..880e8d64 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,53 @@ +name: Lint + +on: + workflow_call: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - id: go-version + run: | + go version + + - name: Set up mise + uses: jdx/mise-action@v2 + with: + version: 2025.8.16 + experimental: true + env: + # Adding token here to reduce the likelihood of hitting rate limit issues. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - id: go-cache-paths + run: | + echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" + echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + + # TODO: Make this less brittle. + echo "golanci-lint-cache=/home/runner/.cache/golangci-lint" >> "$GITHUB_OUTPUT" + + - name: Go Build Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-build }} + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-linux-amd64 + + - name: Go Mod Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-mod }} + key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}-linux-amd64 + + - name: golangci-lint Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.golanci-lint-cache }} + key: ${{ runner.os }}-golangci-lint-${{ hashFiles('**/go.sum') }}-linux-amd64 + + - name: Lint + run: make lint diff --git a/.golangci.yml b/.golangci.yml index 3d4e18bb..fd5b5254 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,7 @@ # This file is generated using `make update-local-lint` to track the linting used in Terragrunt. Do not edit manually. version: "2" run: - go: "1.24" + go: "1.25" issues-exit-code: 1 tests: true output: @@ -54,7 +54,7 @@ linters: - unparam - usetesting - wastedassign - - wsl + - wsl_v5 - zerologlint disable: - depguard @@ -103,6 +103,9 @@ linters: - -ST1001 unparam: check-exported: false + wsl_v5: + allow-whole-block: false + branch-max-lines: 2 exclusions: generated: lax rules: diff --git a/README.md b/README.md index 3f6536f5..e60c2aa9 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ You can find older versions on the [Releases Page](https://github.com/gruntwork- When you run Boilerplate, it performs the following steps: 1. Read the `boilerplate.yml` file in the folder specified by the `--template-url` option to find all defined - varaibles. + variables. 1. Gather values for the variables from any `--var` and `--var-file` options that were passed in and prompting the user for the rest (unless the `--non-interactive` flag is specified). 1. Copy each file from `--template-url` to `--output-folder`, running each non-binary file through the Go @@ -289,7 +289,7 @@ variables: description: Enter the welcome text used by the website dependency - name: ShowLogo - description: Should the webiste show the logo (true or false)? + description: Should the website show the logo (true or false)? type: bool default: true @@ -688,7 +688,7 @@ Here's an example prompt for a variable with validations that shows how invalid ![Example Boilerplate real-time validation](./docs/bp-validation.png) -Here's an example demonstating how to specify validations when defining your variables: +Here's an example demonstrating how to specify validations when defining your variables: ```yaml variables: @@ -1151,6 +1151,41 @@ variables: With this variable, you can have a template file named `{{ .RootTerragruntFileName }}` which will generate a file named according to the value of RootTerragruntFileName. +## Running tests + +To run all tests: + +```bash +go test ./... +``` + +This will run all tests except AWS-dependent integration tests, which are excluded by default since they require AWS credentials. + +### Running AWS-dependent tests + +Some integration tests require AWS credentials and are tagged with the `aws` build tag. These tests are also prefixed with `TestAWS` for easy targeting. To run these tests: + +1. Set up AWS credentials (e.g., using AWS CLI or environment variables): + + ```bash + export AWS_ACCESS_KEY_ID=your_access_key + export AWS_SECRET_ACCESS_KEY=your_secret_key + ``` + +2. Run tests with the `aws` build tag enabled: + + ```bash + go test -tags=aws ./... + ``` + + Or target AWS tests specifically by name: + + ```bash + go test -tags=aws -run '^TestAWS' ./... + ``` + +These AWS tests validate Terragrunt configurations by running `terragrunt validate-all`, which requires valid AWS credentials to access AWS provider APIs. + ## Alternative project generators Before creating Boilerplate, we tried a number of other project generators, but none of them met all of our diff --git a/cli/boilerplate_cli.go b/cli/boilerplate_cli.go index d6d3f97a..75aeacf6 100644 --- a/cli/boilerplate_cli.go +++ b/cli/boilerplate_cli.go @@ -2,6 +2,7 @@ package cli import ( + "context" "fmt" "github.com/gruntwork-io/go-commons/entrypoint" @@ -120,8 +121,10 @@ func runApp(cliContext *cli.Context) error { return err } + ctx := context.Background() + // The root boilerplate.yml is not itself a dependency, so we pass an empty Dependency. emptyDep := variables.Dependency{} - return templates.ProcessTemplate(opts, opts, emptyDep) + return templates.ProcessTemplateWithContext(ctx, opts, opts, emptyDep) } diff --git a/config/config.go b/config/config.go index 5df51386..5f30de30 100644 --- a/config/config.go +++ b/config/config.go @@ -239,9 +239,28 @@ func BoilerplateConfigPath(templateFolder string) string { return path.Join(templateFolder, BoilerplateConfigFile) } +// VersionProvider is an interface for providing version information. +// This allows for dependency injection in tests. +type VersionProvider interface { + GetVersion() string +} + +// DefaultVersionProvider uses the standard go-commons version package. +type DefaultVersionProvider struct{} + +func (p DefaultVersionProvider) GetVersion() string { + return version.GetVersion() +} + // EnforceRequiredVersion enforces any required_version string that is configured on the boilerplate config by checking // against the current version of the CLI. func EnforceRequiredVersion(boilerplateConfig *BoilerplateConfig) error { + return EnforceRequiredVersionWithProvider(boilerplateConfig, DefaultVersionProvider{}) +} + +// EnforceRequiredVersionWithProvider enforces any required_version string that is configured on the boilerplate config by checking +// against the version provided by the VersionProvider. This allows for dependency injection in tests. +func EnforceRequiredVersionWithProvider(boilerplateConfig *BoilerplateConfig, versionProvider VersionProvider) error { // Base case: if required_version is not set, then there is no version to enforce. if boilerplateConfig == nil || boilerplateConfig.RequiredVersion == nil { return nil @@ -250,7 +269,7 @@ func EnforceRequiredVersion(boilerplateConfig *BoilerplateConfig) error { constraint := *boilerplateConfig.RequiredVersion // Base case: if using a development version, then bypass required version check - currentVersion := version.GetVersion() + currentVersion := versionProvider.GetVersion() if currentVersion == "" { return nil } diff --git a/config/config_test.go b/config/config_test.go index c79005e4..934f6d67 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -750,8 +750,10 @@ func TestMarshalBoilerplateConfig(t *testing.T) { func formatYAMLBytes(t *testing.T, ymlData []byte) []byte { t.Helper() + ymlBuffer := bytes.NewBuffer(ymlData) formattedYml, err := yamlfmt.Format(ymlBuffer) require.NoError(t, err) + return formattedYml } diff --git a/config/get_variables.go b/config/get_variables.go index c13d4b3f..e4b44c7c 100644 --- a/config/get_variables.go +++ b/config/get_variables.go @@ -1,6 +1,7 @@ package config import ( + "context" "errors" "fmt" "log" @@ -25,6 +26,15 @@ const MaxReferenceDepth = 20 // The value for a variable can come from the user (if the non-interactive option isn't set), the default value in the // config, or a command line option. func GetVariables(opts *options.BoilerplateOptions, boilerplateConfig, rootBoilerplateConfig *BoilerplateConfig, thisDep variables.Dependency) (map[string]any, error) { + return GetVariablesWithContext(context.Background(), opts, boilerplateConfig, rootBoilerplateConfig, thisDep) +} + +// GetVariablesWithContext collects variables from the user, variable defaults in the boilerplate.yml config, command line options, and environment +// variables. Variables in Boilerplate can can be used in both the Boilerplate config itself and in the templates. +// +// The value for a variable can come from the user (if the non-interactive option isn't set), the default value in the +// config, or a command line option. +func GetVariablesWithContext(ctx context.Context, opts *options.BoilerplateOptions, boilerplateConfig, rootBoilerplateConfig *BoilerplateConfig, thisDep variables.Dependency) (map[string]any, error) { renderedVariables := map[string]any{} // Add a variable for all variables contained in the root config file. This will allow Golang template users @@ -103,7 +113,7 @@ func GetVariables(opts *options.BoilerplateOptions, boilerplateConfig, rootBoile // Pass all the user provided variables through a rendering pipeline to ensure they are evaluated down to // primitives. - newlyRenderedVariables, err := render.RenderVariables(opts, variablesToRender, renderedVariables) + newlyRenderedVariables, err := render.RenderVariablesWithContext(ctx, opts, variablesToRender, renderedVariables) if err != nil { return nil, err } diff --git a/config/get_variables_test.go b/config/get_variables_test.go index 5e9b827a..5ade58b9 100644 --- a/config/get_variables_test.go +++ b/config/get_variables_test.go @@ -291,6 +291,7 @@ func TestValidateUserInput(t *testing.T) { v = variables.NewIntVariable("foo") m, hasValidationErrs = validateUserInput("bar", v) assert.True(t, hasValidationErrs) + key := maps.Keys(m)[0] assert.Contains(t, key, "Value must be of type int") } diff --git a/examples/for-learning-and-testing/dependencies-dynamic/boilerplate.yml b/examples/for-learning-and-testing/dependencies-dynamic/boilerplate.yml index 5ac25928..5517370d 100644 --- a/examples/for-learning-and-testing/dependencies-dynamic/boilerplate.yml +++ b/examples/for-learning-and-testing/dependencies-dynamic/boilerplate.yml @@ -12,7 +12,7 @@ variables: description: Enter the welcome text used by the website dependency - name: ShowLogo - description: Should the webiste show the logo (true or false)? + description: Should the website show the logo (true or false)? type: bool default: true diff --git a/examples/for-learning-and-testing/dependencies-recursive/boilerplate.yml b/examples/for-learning-and-testing/dependencies-recursive/boilerplate.yml index 06272aef..612c82f9 100644 --- a/examples/for-learning-and-testing/dependencies-recursive/boilerplate.yml +++ b/examples/for-learning-and-testing/dependencies-recursive/boilerplate.yml @@ -12,7 +12,7 @@ variables: description: Enter the welcome text used by the website dependency - name: ShowLogo - description: Should the webiste show the logo (true or false)? + description: Should the website show the logo (true or false)? type: bool default: true diff --git a/examples/for-learning-and-testing/dependencies-remote/boilerplate.yml b/examples/for-learning-and-testing/dependencies-remote/boilerplate.yml index d7c2c81b..8af67ccf 100644 --- a/examples/for-learning-and-testing/dependencies-remote/boilerplate.yml +++ b/examples/for-learning-and-testing/dependencies-remote/boilerplate.yml @@ -12,7 +12,7 @@ variables: description: Enter the welcome text used by the website dependency - name: ShowLogo - description: Should the webiste show the logo (true or false)? + description: Should the website show the logo (true or false)? type: bool default: true @@ -22,14 +22,14 @@ variables: dependencies: - name: docs - template-url: "git@github.com:gruntwork-io/boilerplate.git//examples/for-learning-and-testing/docs?ref={{ .RemoteBranch }}" + template-url: "git::https://github.com/gruntwork-io/boilerplate.git//examples/for-learning-and-testing/docs?ref={{ .RemoteBranch }}" output-folder: ./docs variables: - name: Title description: Enter the title of the docs page - name: website - template-url: "git@github.com:gruntwork-io/boilerplate.git//examples/for-learning-and-testing/website?ref={{ .RemoteBranch }}" + template-url: "git::https://github.com/gruntwork-io/boilerplate.git//examples/for-learning-and-testing/website?ref={{ .RemoteBranch }}" output-folder: ./website variables: - name: Title diff --git a/examples/for-learning-and-testing/dependencies-varfile-precedence/boilerplate.yml b/examples/for-learning-and-testing/dependencies-varfile-precedence/boilerplate.yml index 1d406803..ec24ef47 100644 --- a/examples/for-learning-and-testing/dependencies-varfile-precedence/boilerplate.yml +++ b/examples/for-learning-and-testing/dependencies-varfile-precedence/boilerplate.yml @@ -13,7 +13,7 @@ variables: default: reference name - name: ShowLogo - description: Should the webiste show the logo (true or false)? + description: Should the website show the logo (true or false)? type: bool default: true diff --git a/examples/for-learning-and-testing/dependencies-varfile/boilerplate.yml b/examples/for-learning-and-testing/dependencies-varfile/boilerplate.yml index dfe2e311..5d0983ee 100644 --- a/examples/for-learning-and-testing/dependencies-varfile/boilerplate.yml +++ b/examples/for-learning-and-testing/dependencies-varfile/boilerplate.yml @@ -13,7 +13,7 @@ variables: description: Enter the welcome text used by the website dependency - name: ShowLogo - description: Should the webiste show the logo (true or false)? + description: Should the website show the logo (true or false)? type: bool default: true diff --git a/examples/for-learning-and-testing/dependencies/boilerplate.yml b/examples/for-learning-and-testing/dependencies/boilerplate.yml index 9ebcbd05..32fd3598 100644 --- a/examples/for-learning-and-testing/dependencies/boilerplate.yml +++ b/examples/for-learning-and-testing/dependencies/boilerplate.yml @@ -12,7 +12,7 @@ variables: description: Enter the welcome text used by the website dependency - name: ShowLogo - description: Should the webiste show the logo (true or false)? + description: Should the website show the logo (true or false)? type: bool default: true diff --git a/getterhelper/getter_helper_unix_test.go b/getterhelper/getter_helper_unix_test.go index 66e66b72..2454d87c 100644 --- a/getterhelper/getter_helper_unix_test.go +++ b/getterhelper/getter_helper_unix_test.go @@ -21,12 +21,15 @@ func TestDownloadTemplatesToTempDir(t *testing.T) { pwd, err := os.Getwd() require.NoError(t, err) + examplePath := filepath.Join(pwd, "..", "examples", "for-learning-and-testing", "variables") branch := git.GetCurrentBranchName(t) - templateURL := "git@github.com:gruntwork-io/boilerplate.git//examples/for-learning-and-testing/variables?ref=" + branch + templateURL := "git::https://github.com/gruntwork-io/boilerplate.git//examples/for-learning-and-testing/variables?ref=" + branch + workingDir, workPath, err := getterhelper.DownloadTemplatesToTemporaryFolder(templateURL) defer os.RemoveAll(workingDir) + require.NoError(t, err, errors.PrintErrorWithStackTrace(err)) // Run diff to make sure there are no differences diff --git a/go.mod b/go.mod index 38ae923c..c682595f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gruntwork-io/boilerplate -go 1.24.4 +go 1.25 require ( github.com/AlecAivazis/survey/v2 v2.3.7 @@ -31,6 +31,7 @@ require ( github.com/gabriel-vasile/mimetype v1.4.6 github.com/google/go-cmp v0.6.0 golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c + golang.org/x/text v0.23.0 ) require ( @@ -116,7 +117,6 @@ require ( golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sync v0.12.0 // indirect - golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.7.0 // indirect google.golang.org/api v0.200.0 // indirect google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9 // indirect diff --git a/integration-tests/envvars_test.go b/integration-tests/envvars_test.go index 0e33e10d..b11c2d2b 100644 --- a/integration-tests/envvars_test.go +++ b/integration-tests/envvars_test.go @@ -38,6 +38,7 @@ func TestEnvVarExample(t *testing.T) { assert.Equal(t, "default-value", strings.TrimSpace(string(content))) t.Setenv("BOILERPLATE_ValueFromEnvVar", "env-var-value") + err = app.Run(args) require.NoError(t, err, errors.PrintErrorWithStackTrace(err)) diff --git a/integration-tests/examples_ssh_test.go b/integration-tests/examples_ssh_test.go new file mode 100644 index 00000000..fb8a9a8d --- /dev/null +++ b/integration-tests/examples_ssh_test.go @@ -0,0 +1,75 @@ +//go:build ssh +// +build ssh + +// This file contains tests that require SSH keys to be configured for GitHub access. +// To run these tests, use: go test -tags=ssh + +package integrationtests_test + +import ( + "fmt" + "os" + "path" + "runtime" + "strings" + "testing" + + "github.com/gruntwork-io/boilerplate/options" + "github.com/gruntwork-io/terratest/modules/git" + "github.com/stretchr/testify/require" +) + +func TestSSHExamplesAsRemoteTemplate(t *testing.T) { + t.Parallel() + + branchName := git.GetCurrentBranchName(t) + examplesBasePath := "../examples/for-learning-and-testing" + examplesExpectedOutputBasePath := "../test-fixtures/examples-expected-output" + examplesVarFilesBasePath := "../test-fixtures/examples-var-files" + + outputBasePath := t.TempDir() + + examples, err := os.ReadDir(examplesBasePath) + require.NoError(t, err) + + // Insulate the following parallel tests in a group so that cleanup routines run after all tests are done. + t.Run("group", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { // skip clone test for windows because of invalid file name in git + t.Skip() + return + } + + for _, example := range examples { + if !example.IsDir() { + continue + } + + if strings.Contains(example.Name(), "shell") { + // This is captured in TestExamplesShell + continue + } + + if strings.Contains(example.Name(), "unix") { + // unix specific case + continue + } + + if example.Name() == "variables" { + t.Logf("Skipping example %s because it is implicitly tested via dependencies.", example.Name()) + continue + } + + t.Run(path.Base(example.Name()), func(t *testing.T) { + t.Parallel() + + templateFolder := fmt.Sprintf("git@github.com:gruntwork-io/boilerplate.git//examples/for-learning-and-testing/%s?ref=%s", example.Name(), branchName) + outputFolder := path.Join(outputBasePath, example.Name()) + varFile := path.Join(examplesVarFilesBasePath, example.Name(), "vars.yml") + expectedOutputFolder := path.Join(examplesExpectedOutputBasePath, example.Name()) + testExample(t, templateFolder, outputFolder, varFile, expectedOutputFolder, string(options.ExitWithError)) + }) + } + }) +} diff --git a/integration-tests/examples_ssh_unix_test.go b/integration-tests/examples_ssh_unix_test.go new file mode 100644 index 00000000..38d192b7 --- /dev/null +++ b/integration-tests/examples_ssh_unix_test.go @@ -0,0 +1,47 @@ +//go:build ssh && (aix || darwin || dragonfly || freebsd || (js && wasm) || linux || netbsd || openbsd || solaris) +// +build ssh +// +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris + +// This file contains Unix-specific tests that require SSH keys to be configured for GitHub access. +// To run these tests, use: go test -tags=ssh + +package integrationtests_test + +import ( + "fmt" + "path" + "testing" + + "github.com/gruntwork-io/boilerplate/options" + "github.com/gruntwork-io/terratest/modules/git" +) + +func TestSSHExamplesShellRemote(t *testing.T) { + t.Parallel() + + branchName := git.GetCurrentBranchName(t) + examplesExpectedOutputBasePath := "../test-fixtures/examples-expected-output" + examplesVarFilesBasePath := "../test-fixtures/examples-var-files" + + outputBasePath := t.TempDir() + + shellExamples := []string{"shell", "shell-disabled"} + + // Insulate the following parallel tests in a group so that cleanup routines run after all tests are done. + t.Run("group", func(t *testing.T) { + t.Parallel() + + for _, example := range shellExamples { + outputFolder := path.Join(outputBasePath, example) + varFile := path.Join(examplesVarFilesBasePath, example, "vars.yml") + expectedOutputFolder := path.Join(examplesExpectedOutputBasePath, example) + + t.Run(example+"-remote", func(t *testing.T) { + t.Parallel() + + templateFolder := fmt.Sprintf("git@github.com:gruntwork-io/boilerplate.git//examples/for-learning-and-testing/%s?ref=%s", example, branchName) + testExample(t, templateFolder, outputFolder, varFile, expectedOutputFolder, string(options.ExitWithError)) + }) + } + }) +} diff --git a/integration-tests/examples_test.go b/integration-tests/examples_test.go index 5fa3b461..3622b308 100644 --- a/integration-tests/examples_test.go +++ b/integration-tests/examples_test.go @@ -35,6 +35,7 @@ func TestExamples(t *testing.T) { if !example.IsDir() { continue } + if strings.Contains(example.Name(), "shell") { // This is captured in TestExamplesShell continue @@ -64,59 +65,9 @@ func TestExamples(t *testing.T) { } } -func TestExamplesAsRemoteTemplate(t *testing.T) { - t.Parallel() - - branchName := git.GetCurrentBranchName(t) - examplesBasePath := "../examples/for-learning-and-testing" - examplesExpectedOutputBasePath := "../test-fixtures/examples-expected-output" - examplesVarFilesBasePath := "../test-fixtures/examples-var-files" - - outputBasePath := t.TempDir() - - examples, err := os.ReadDir(examplesBasePath) - require.NoError(t, err) - - // Insulate the following parallel tests in a group so that cleanup routines run after all tests are done. - t.Run("group", func(t *testing.T) { - t.Parallel() - if runtime.GOOS == "windows" { // skip clone test for windows because of invalid file name in git - t.Skip() - return - } - for _, example := range examples { - if !example.IsDir() { - continue - } - if strings.Contains(example.Name(), "shell") { - // This is captured in TestExamplesShell - continue - } - if strings.Contains(example.Name(), "unix") { - // unix specific case - continue - } - - if example.Name() == "variables" { - t.Logf("Skipping example %s because it is implicitly tested via dependencies.", example.Name()) - continue - } - - t.Run(path.Base(example.Name()), func(t *testing.T) { - t.Parallel() - templateFolder := fmt.Sprintf("git@github.com:gruntwork-io/boilerplate.git//examples/for-learning-and-testing/%s?ref=%s", example.Name(), branchName) - outputFolder := path.Join(outputBasePath, example.Name()) - varFile := path.Join(examplesVarFilesBasePath, example.Name(), "vars.yml") - expectedOutputFolder := path.Join(examplesExpectedOutputBasePath, example.Name()) - testExample(t, templateFolder, outputFolder, varFile, expectedOutputFolder, string(options.ExitWithError)) - }) - } - }) - -} - func testExample(t *testing.T, templateFolder string, outputFolder string, varFile string, expectedOutputFolder string, missingKeyAction string) { t.Helper() + app := cli.CreateBoilerplateCli() ref := git.GetCurrentGitRef(t) @@ -142,6 +93,7 @@ func testExample(t *testing.T, templateFolder string, outputFolder string, varFi err := app.Run(args) require.NoError(t, err, errors.PrintErrorWithStackTrace(err)) + if expectedOutputFolder != "" { assertDirectoriesEqual(t, expectedOutputFolder, outputFolder) } diff --git a/integration-tests/examples_unix_test.go b/integration-tests/examples_unix_test.go index c8994f73..0f55ef95 100644 --- a/integration-tests/examples_unix_test.go +++ b/integration-tests/examples_unix_test.go @@ -14,14 +14,12 @@ import ( "github.com/gruntwork-io/terratest/modules/files" "github.com/gruntwork-io/boilerplate/options" - "github.com/gruntwork-io/terratest/modules/git" "github.com/stretchr/testify/require" ) func TestExamplesShell(t *testing.T) { t.Parallel() - branchName := git.GetCurrentBranchName(t) examplesBasePath := "../examples/for-learning-and-testing" examplesExpectedOutputBasePath := "../test-fixtures/examples-expected-output" examplesVarFilesBasePath := "../test-fixtures/examples-var-files" @@ -32,6 +30,7 @@ func TestExamplesShell(t *testing.T) { // Insulate the following parallel tests in a group so that cleanup routines run after all tests are done. t.Run("group", func(t *testing.T) { t.Parallel() + for _, example := range shellExamples { outputFolder := path.Join(outputBasePath, example) varFile := path.Join(examplesVarFilesBasePath, example, "vars.yml") @@ -39,6 +38,7 @@ func TestExamplesShell(t *testing.T) { t.Run(example, func(t *testing.T) { t.Parallel() + templateFolder := path.Join(examplesBasePath, example) for _, missingKeyAction := range options.AllMissingKeyActions { t.Run(fmt.Sprintf("%s-missing-key-%s", example, string(missingKeyAction)), func(t *testing.T) { @@ -48,12 +48,6 @@ func TestExamplesShell(t *testing.T) { }) } }) - - t.Run(example+"-remote", func(t *testing.T) { - t.Parallel() - templateFolder := fmt.Sprintf("git@github.com:gruntwork-io/boilerplate.git//examples/for-learning-and-testing/%s?ref=%s", example, branchName) - testExample(t, templateFolder, outputFolder, varFile, expectedOutputFolder, string(options.ExitWithError)) - }) } }) } @@ -93,12 +87,14 @@ func TestSpecialFileNames(t *testing.T) { // run init logic err = tc.initLogic(testDir) require.NoError(t, err) + examplesVarFilesBasePath := "../test-fixtures/examples-var-files" example := tc.path outputFolder := path.Join(outputBasePath, example) err = os.MkdirAll(outputFolder, 0777) require.NoError(t, err) + varFile := path.Join(examplesVarFilesBasePath, example, "vars.yml") expectedOutputFolder := path.Join("../test-fixtures/examples-expected-output-unix", example) diff --git a/integration-tests/for_production_example_unix_test.go b/integration-tests/for_production_example_unix_test.go index e4c7c541..a6310009 100644 --- a/integration-tests/for_production_example_unix_test.go +++ b/integration-tests/for_production_example_unix_test.go @@ -1,4 +1,5 @@ -//go:build aix || darwin || dragonfly || freebsd || (js && wasm) || linux || netbsd || openbsd || solaris +//go:build aws && (aix || darwin || dragonfly || freebsd || (js && wasm) || linux || netbsd || openbsd || solaris) +// +build aws // +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris package integrationtests_test @@ -13,16 +14,16 @@ import ( "github.com/gruntwork-io/boilerplate/options" ) -func TestForProductionTerragruntArchitectureBoilerplateExample(t *testing.T) { +func TestAWSForProductionTerragruntArchitectureBoilerplateExample(t *testing.T) { t.Parallel() forProductionExamplePath := "../examples/for-production/terragrunt-architecture-catalog" outputBasePath := t.TempDir() - // defer os.RemoveAll(outputBasePath) templateFolder, err := filepath.Abs(filepath.Join(forProductionExamplePath, "blueprints", "reference-architecture")) require.NoError(t, err) + outputFolder := filepath.Join(outputBasePath, "infrastructure-live") varFile, err := filepath.Abs(filepath.Join(forProductionExamplePath, "sample_reference_architecture_vars.yml")) require.NoError(t, err) @@ -32,6 +33,7 @@ func TestForProductionTerragruntArchitectureBoilerplateExample(t *testing.T) { // Make sure it rendered valid terragrunt outputs by running terragrunt validate in each of the relevant folders. t.Run("group", func(t *testing.T) { t.Parallel() + for _, account := range []string{"dev", "stage", "prod"} { opts := &terraform.Options{ TerraformBinary: "terragrunt", diff --git a/integration-tests/required_version_test.go b/integration-tests/required_version_test.go index 9ff036b3..b634f66f 100644 --- a/integration-tests/required_version_test.go +++ b/integration-tests/required_version_test.go @@ -2,11 +2,10 @@ package integrationtests_test import ( "errors" + "os" "testing" - "github.com/gruntwork-io/boilerplate/cli" "github.com/gruntwork-io/boilerplate/config" - "github.com/gruntwork-io/go-commons/version" "github.com/stretchr/testify/require" ) @@ -14,58 +13,63 @@ const ( testVersion = "v1.33.7" ) +// TestVersionProvider implements VersionProvider for testing +type TestVersionProvider struct { + version string +} + +func (p TestVersionProvider) GetVersion() string { + return p.version +} + func TestRequiredVersionMatchCase(t *testing.T) { t.Parallel() - // Make sure that the test is run with the ld flags setting version to our expected test version. - require.Equal(t, testVersion, version.GetVersion()) + // Test the version enforcement logic directly using dependency injection + boilerplateConfig, err := loadTestConfig("../test-fixtures/regression-test/required-version/match/boilerplate.yml") + require.NoError(t, err) + + versionProvider := TestVersionProvider{version: testVersion} - require.NoError( - t, - runRequiredVersionExample(t, "../test-fixtures/regression-test/required-version/match"), - ) + require.NoError(t, config.EnforceRequiredVersionWithProvider(boilerplateConfig, versionProvider)) } func TestRequiredVersionOverTest(t *testing.T) { t.Parallel() - // Make sure that the test is run with the ld flags setting version to our expected test version. - require.Equal(t, testVersion, version.GetVersion()) + // Test the version enforcement logic directly using dependency injection + boilerplateConfig, err := loadTestConfig("../test-fixtures/regression-test/required-version/over-test/boilerplate.yml") + require.NoError(t, err) + + versionProvider := TestVersionProvider{version: testVersion} - err := runRequiredVersionExample(t, "../test-fixtures/regression-test/required-version/over-test") + err = config.EnforceRequiredVersionWithProvider(boilerplateConfig, versionProvider) require.Error(t, err) - errUnwrapped := errors.Unwrap(err) var invalidBoilerplateVersion config.InvalidBoilerplateVersion - isInvalidVersionErr := errors.As(errUnwrapped, &invalidBoilerplateVersion) + + isInvalidVersionErr := errors.As(err, &invalidBoilerplateVersion) require.True(t, isInvalidVersionErr) } func TestRequiredVersionUnderTest(t *testing.T) { t.Parallel() - // Make sure that the test is run with the ld flags setting version to our expected test version. - require.Equal(t, testVersion, version.GetVersion()) - - require.NoError( - t, - runRequiredVersionExample(t, "../test-fixtures/regression-test/required-version/under-test"), - ) -} + // Test the version enforcement logic directly using dependency injection + boilerplateConfig, err := loadTestConfig("../test-fixtures/regression-test/required-version/under-test/boilerplate.yml") + require.NoError(t, err) -func runRequiredVersionExample(t *testing.T, templateFolder string) error { - t.Helper() - app := cli.CreateBoilerplateCli() + versionProvider := TestVersionProvider{version: testVersion} - outputPath := t.TempDir() + require.NoError(t, config.EnforceRequiredVersionWithProvider(boilerplateConfig, versionProvider)) +} - args := []string{ - "boilerplate", - "--template-url", - templateFolder, - "--output-folder", - outputPath, - "--non-interactive", +// loadTestConfig loads a boilerplate config file for testing purposes +func loadTestConfig(configPath string) (*config.BoilerplateConfig, error) { + bytes, err := os.ReadFile(configPath) + if err != nil { + return nil, err } - return app.Run(args) + + return config.ParseBoilerplateConfig(bytes) } diff --git a/mise.toml b/mise.toml index 1aac1b97..9ac36d24 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,8 @@ [tools] -go = "1.24.4" -golangci-lint = "2.1.6" -"go:golang.org/x/tools/gopls" = "v0.18.1" -"go:golang.org/x/tools/cmd/goimports" = "v0.35.0" +go = "1.25.1" +golangci-lint = "2.4.0" +"go:golang.org/x/tools/gopls" = "v0.20.0" +"go:golang.org/x/tools/cmd/goimports" = "v0.36.0" +go-junit-report = "2.1.0" +opentofu = "1.10.6" +terragrunt = "0.87.0" diff --git a/render/render_jsonnet_unix_test.go b/render/render_jsonnet_unix_test.go index 05bb41d1..2e8d0874 100644 --- a/render/render_jsonnet_unix_test.go +++ b/render/render_jsonnet_unix_test.go @@ -44,11 +44,13 @@ func TestRenderJsonnet(t *testing.T) { outputJSON, err := RenderJsonnetTemplate(templateFPath, variables, testBoilerplateOptions) require.NoError(t, err) + var output map[string]any require.NoError(t, json.Unmarshal([]byte(outputJSON), &output)) expectedOutputJSON, err := os.ReadFile(expectedFPath) require.NoError(t, err) + var expectedOutput map[string]any require.NoError(t, json.Unmarshal(expectedOutputJSON, &expectedOutput)) diff --git a/render/render_template.go b/render/render_template.go index 6e9562d5..c6539ad8 100644 --- a/render/render_template.go +++ b/render/render_template.go @@ -2,6 +2,7 @@ package render import ( "bytes" + "context" "fmt" "path" "reflect" @@ -18,7 +19,14 @@ const MaxRenderAttempts = 15 // named by the user on the command line) as well as all of the partials matched by the provided globs using the Go // template engine, passing in the given variables as data. func RenderTemplateWithPartials(templatePath string, partials []string, variables map[string]any, opts *options.BoilerplateOptions) (string, error) { - tmpl, err := getTemplate(templatePath, opts).ParseGlob(templatePath) + return RenderTemplateWithPartialsWithContext(context.Background(), templatePath, partials, variables, opts) +} + +// RenderTemplateWithPartialsWithContext renders the template at templatePath with the contents of the root template (the template +// named by the user on the command line) as well as all of the partials matched by the provided globs using the Go +// template engine, passing in the given variables as data. +func RenderTemplateWithPartialsWithContext(ctx context.Context, templatePath string, partials []string, variables map[string]any, opts *options.BoilerplateOptions) (string, error) { + tmpl, err := getTemplate(ctx, templatePath, opts).ParseGlob(templatePath) if err != nil { return "", errors.WithStackTrace(err) } @@ -31,7 +39,7 @@ func RenderTemplateWithPartials(templatePath string, partials []string, variable // relative to the path passed in by the user relativePath := PathRelativeToTemplate(opts.TemplateFolder, globOfPartials) - parsedTemplate, err := getTemplate(templatePath, opts).ParseGlob(relativePath) + parsedTemplate, err := getTemplate(ctx, templatePath, opts).ParseGlob(relativePath) if err != nil { return "", errors.WithStackTrace(err) } @@ -49,7 +57,20 @@ func RenderTemplateWithPartials(templatePath string, partials []string, variable // RenderTemplateFromString renders the template at templatePath, with contents templateContents, using the Go template engine, passing in the // given variables as data. func RenderTemplateFromString(templatePath string, templateContents string, variables map[string]any, opts *options.BoilerplateOptions) (string, error) { - tmpl := getTemplate(templatePath, opts) + tmpl := getTemplate(context.Background(), templatePath, opts) + + parsedTemplate, err := tmpl.Parse(templateContents) + if err != nil { + return "", errors.WithStackTrace(err) + } + + return executeTemplate(parsedTemplate, variables) +} + +// RenderTemplateFromStringWithContext renders the template at templatePath, with contents templateContents, using the Go template engine, passing in the +// given variables as data. +func RenderTemplateFromStringWithContext(ctx context.Context, templatePath string, templateContents string, variables map[string]any, opts *options.BoilerplateOptions) (string, error) { + tmpl := getTemplate(ctx, templatePath, opts) parsedTemplate, err := tmpl.Parse(templateContents) if err != nil { @@ -60,11 +81,11 @@ func RenderTemplateFromString(templatePath string, templateContents string, vari } // getTemplate returns new template initialized with options and helper functions -func getTemplate(templatePath string, opts *options.BoilerplateOptions) *template.Template { +func getTemplate(ctx context.Context, templatePath string, opts *options.BoilerplateOptions) *template.Template { tmpl := template.New(path.Base(templatePath)) option := "missingkey=" + string(opts.OnMissingKey) - return tmpl.Funcs(CreateTemplateHelpers(templatePath, opts, tmpl)).Option(option) + return tmpl.Funcs(CreateTemplateHelpers(ctx, templatePath, opts, tmpl)).Option(option) } // executeTemplate executes a parsed template with a given set of variable inputs and return the output as a string @@ -96,6 +117,30 @@ func RenderVariables( opts *options.BoilerplateOptions, variablesToRender map[string]any, alreadyRenderedVariables map[string]any, +) (map[string]interface{}, error) { + return RenderVariablesWithContext(context.Background(), opts, variablesToRender, alreadyRenderedVariables) +} + +// RenderVariablesWithContext will render each of the variables that need to be rendered by running it through the go templating +// syntax. Variable values are allowed to use Go templating syntax (e.g. to reference other variables), so this function +// loops over each variable value, renders each one, and returns a new map of rendered variables. +// +// This function supports nested variables references, but uses a heuristic based approach. Ideally, we can parse the Go +// template and build up a graph of variable dependencies to assist with the rendering process, but this takes a lot of +// effort to get right and maintain. +// +// Instead, we opt for a simpler approach of rendering with multiple trials. In this approach, we continuously attempt +// to render the template on the variable until all of them render without errors, or we reach the maximum trials. To +// support this, we ignore the missing key configuration during this evaluation pass and always rely on the template +// erroring for missing variables. Otherwise, all the variables will render on the first pass. +// +// Note that this is NOT a multi pass algorithm - that is, we do NOT attempt to render the template multiple times. +// Instead, we do a single template render on each run and reject any that return with an error. +func RenderVariablesWithContext( + ctx context.Context, + opts *options.BoilerplateOptions, + variablesToRender map[string]any, + alreadyRenderedVariables map[string]any, ) (map[string]interface{}, error) { // Force to use ExitWithError for missing key, because by design this algorithm depends on boilerplate error-ing if // a variable can't be rendered due to a reference that hasn't been rendered yet. If OnMissingKey was invalid or @@ -105,7 +150,7 @@ func RenderVariables( // the leaf variables are handled by the time it gets to this function in the `alreadyRenderedVariables` map that is // passed in. // - // NOTE: here, I am copying by value, not by reference by deferencing the pointer when assigning to optsForRender. + // NOTE: here, I am copying by value, not by reference by dereferencing the pointer when assigning to optsForRender. // This ensures that opts (whatever caller passed in) doesn't change in this routine. optsForRender := *opts optsForRender.OnMissingKey = options.ExitWithError @@ -127,7 +172,7 @@ func RenderVariables( return nil, errors.WithStackTrace(MaxRenderAttemptsErr{}) } - attemptRenderOutput, err := attemptRenderVariables(&optsForRender, unrenderedVariables, renderedVariables, variablesToRender) + attemptRenderOutput, err := attemptRenderVariables(ctx, &optsForRender, unrenderedVariables, renderedVariables, variablesToRender) unrenderedVariables = attemptRenderOutput.unrenderedVariables renderedVariables = attemptRenderOutput.renderedVariables rendered = attemptRenderOutput.variablesWereRendered @@ -148,6 +193,7 @@ func RenderVariables( // - the updated map of rendered variables // - a boolean indicating whether any new variables were rendered func attemptRenderVariables( + ctx context.Context, opts *options.BoilerplateOptions, unrenderedVariables []string, renderedVariables map[string]interface{}, @@ -159,7 +205,7 @@ func attemptRenderVariables( var allRenderErr error for _, variableName := range unrenderedVariables { - rendered, err := attemptRenderVariable(opts, variables[variableName], renderedVariables) + rendered, err := attemptRenderVariable(ctx, opts, variables[variableName], renderedVariables) if err != nil { newUnrenderedVariables = append(newUnrenderedVariables, variableName) allRenderErr = multierror.Append(allRenderErr, err) @@ -182,17 +228,17 @@ func attemptRenderVariables( // references. // NOTE: This function is not responsible for converting the output type to the expected type configured on the // boilerplate config, and will always use string as the primitive output. -func attemptRenderVariable(opts *options.BoilerplateOptions, variable interface{}, renderedVariables map[string]interface{}) (interface{}, error) { +func attemptRenderVariable(ctx context.Context, opts *options.BoilerplateOptions, variable any, renderedVariables map[string]any) (any, error) { valueType := reflect.ValueOf(variable) switch valueType.Kind() { //nolint:exhaustive // TODO: Add missing reflect.Kind cases for exhaustive coverage case reflect.String: - return RenderTemplateFromString(opts.TemplateFolder, variable.(string), renderedVariables, opts) + return RenderTemplateFromStringWithContext(ctx, opts.TemplateFolder, variable.(string), renderedVariables, opts) case reflect.Slice: - values := []interface{}{} + values := []any{} for i := 0; i < valueType.Len(); i++ { - rendered, err := attemptRenderVariable(opts, valueType.Index(i).Interface(), renderedVariables) + rendered, err := attemptRenderVariable(ctx, opts, valueType.Index(i).Interface(), renderedVariables) if err != nil { return nil, err } @@ -202,15 +248,15 @@ func attemptRenderVariable(opts *options.BoilerplateOptions, variable interface{ return values, nil case reflect.Map: - values := map[string]interface{}{} + values := map[string]any{} for _, key := range valueType.MapKeys() { - renderedKey, err := attemptRenderVariable(opts, key.Interface(), renderedVariables) + renderedKey, err := attemptRenderVariable(ctx, opts, key.Interface(), renderedVariables) if err != nil { return nil, err } - renderedValue, err := attemptRenderVariable(opts, valueType.MapIndex(key).Interface(), renderedVariables) + renderedValue, err := attemptRenderVariable(ctx, opts, valueType.MapIndex(key).Interface(), renderedVariables) if err != nil { return nil, err } diff --git a/render/render_template_test.go b/render/render_template_test.go index 346c2d79..fcf5fab4 100644 --- a/render/render_template_test.go +++ b/render/render_template_test.go @@ -48,6 +48,7 @@ func TestRenderTemplate(t *testing.T) { defaultOutputDir := "/output" defaultTemplateDir := "/templates" + if runtime.GOOS == windowsOS { defaultOutputDir = "C:\\output" defaultTemplateDir = "C:\\templates" @@ -64,40 +65,40 @@ func TestRenderTemplate(t *testing.T) { {templateContents: "", variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "", skip: false}, {templateContents: "plain text template", variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "plain text template", skip: false}, {templateContents: "variable lookup: {{.Foo}}", variables: map[string]any{"Foo": "bar"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "variable lookup: bar", skip: false}, - {templateContents: "missing variable lookup, ExitWithError: {{.Foo}}", variables: map[string]interface{}{}, missingKeyAction: options.ExitWithError, expectedErrorText: "map has no entry for key \"Foo\"", expectedOutput: "", skip: false}, - {templateContents: "missing variable lookup, Invalid: {{.Foo}}", variables: map[string]interface{}{}, missingKeyAction: options.Invalid, expectedErrorText: "", expectedOutput: "missing variable lookup, Invalid: ", skip: false}, - // Note: options.ZeroValue does not work correctly with Go templating when you pass in a map[string]interface{}. For some reason, it always prints . - {templateContents: "missing variable lookup, ZeroValue: {{.Foo}}", variables: map[string]interface{}{}, missingKeyAction: options.ZeroValue, expectedErrorText: "", expectedOutput: "missing variable lookup, ZeroValue: ", skip: false}, - {templateContents: embedWholeFileTemplate, variables: map[string]interface{}{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: embedWholeFileTemplateOutput, skip: runtime.GOOS == "windows"}, - {templateContents: embedSnippetTemplate, variables: map[string]interface{}{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: embedSnippetTemplateOutput, skip: false}, - {templateContents: "Invalid template syntax: {{.Foo", variables: map[string]interface{}{}, missingKeyAction: options.ExitWithError, expectedErrorText: "unclosed action", expectedOutput: "", skip: false}, - {templateContents: "Uppercase test: {{ .Foo | upcase }}", variables: map[string]interface{}{"Foo": "some text"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Uppercase test: SOME TEXT", skip: false}, - {templateContents: "Lowercase test: {{ .Foo | downcase }}", variables: map[string]interface{}{"Foo": "SOME TEXT"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Lowercase test: some text", skip: false}, - {templateContents: "Capitalize test: {{ .Foo | capitalize }}", variables: map[string]interface{}{"Foo": "some text"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Capitalize test: Some Text", skip: false}, - {templateContents: "Replace test: {{ .Foo | replace \"foo\" \"bar\" }}", variables: map[string]interface{}{"Foo": "hello foo, how are foo"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Replace test: hello bar, how are foo", skip: false}, - {templateContents: "Replace all test: {{ .Foo | replaceAll \"foo\" \"bar\" }}", variables: map[string]interface{}{"Foo": "hello foo, how are foo"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Replace all test: hello bar, how are bar", skip: false}, - {templateContents: "Trim test: {{ .Foo | trim }}", variables: map[string]interface{}{"Foo": " some text \t"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Trim test: some text", skip: false}, - {templateContents: "Round test: {{ .Foo | round }}", variables: map[string]interface{}{"Foo": "0.45"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Round test: 0", skip: false}, - {templateContents: "Ceil test: {{ .Foo | ceil }}", variables: map[string]interface{}{"Foo": "0.45"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Ceil test: 1", skip: false}, - {templateContents: "Floor test: {{ .Foo | floor }}", variables: map[string]interface{}{"Foo": "0.45"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Floor test: 0", skip: false}, - {templateContents: "Dasherize test: {{ .Foo | dasherize }}", variables: map[string]interface{}{"Foo": "foo BAR baz!"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Dasherize test: foo-bar-baz", skip: false}, - {templateContents: "Snake case test: {{ .Foo | snakeCase }}", variables: map[string]interface{}{"Foo": "foo BAR baz!"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Snake case test: foo_bar_baz", skip: false}, - {templateContents: "Camel case test: {{ .Foo | camelCase }}", variables: map[string]interface{}{"Foo": "foo BAR baz!"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Camel case test: FooBARBaz", skip: false}, - {templateContents: "Camel case lower test: {{ .Foo | camelCaseLower }}", variables: map[string]interface{}{"Foo": "foo BAR baz!"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Camel case lower test: fooBARBaz", skip: false}, - {templateContents: "Plus test: {{ plus .Foo .Bar }}", variables: map[string]interface{}{"Foo": "5", "Bar": "3"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Plus test: 8", skip: false}, - {templateContents: "Minus test: {{ minus .Foo .Bar }}", variables: map[string]interface{}{"Foo": "5", "Bar": "3"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Minus test: 2", skip: false}, - {templateContents: "Times test: {{ times .Foo .Bar }}", variables: map[string]interface{}{"Foo": "5", "Bar": "3"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Times test: 15", skip: false}, - {templateContents: "Divide test: {{ divide .Foo .Bar | printf \"%1.5f\" }}", variables: map[string]interface{}{"Foo": "5", "Bar": "3"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Divide test: 1.66667", skip: false}, - {templateContents: "Mod test: {{ mod .Foo .Bar }}", variables: map[string]interface{}{"Foo": "5", "Bar": "3"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Mod test: 2", skip: false}, - {templateContents: "Slice test: {{ slice 0 5 1 }}", variables: map[string]interface{}{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Slice test: [0 1 2 3 4]", skip: false}, - {templateContents: "Keys test: {{ keys .Map }}", variables: map[string]interface{}{"Map": map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Keys test: [key1 key2 key3]", skip: false}, - {templateContents: "Shell test: {{ shell \"echo\" .Text }}", variables: map[string]interface{}{"Text": "Hello, World"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Shell test: Hello, World\n", skip: runtime.GOOS == "windows"}, - {templateContents: "Shell set env vars test: {{ shell \"printenv\" \"FOO\" \"ENV:FOO=bar\" }}", variables: map[string]interface{}{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Shell set env vars test: bar\n", skip: runtime.GOOS == "windows"}, - {templateContents: "Shell read env vars test: {{ env \"USER\" \"should-not-get-fallback\" }}", variables: map[string]interface{}{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Shell read env vars test: " + userFromEnvVar, skip: runtime.GOOS == "windows"}, - {templateContents: "Shell read env vars test, fallback: {{ env \"not-a-valid-env-var\" \"should-get-fallback\" }}", variables: map[string]interface{}{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Shell read env vars test, fallback: should-get-fallback", skip: false}, - {templateContents: "Template folder test: {{ templateFolder }}", variables: map[string]interface{}{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Template folder test: " + defaultTemplateDir, skip: false}, - {templateContents: "Output folder test: {{ outputFolder }}", variables: map[string]interface{}{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Output folder test: " + defaultOutputDir, skip: false}, - {templateContents: "Filter chain test: {{ .Foo | downcase | replaceAll \" \" \"\" }}", variables: map[string]interface{}{"Foo": "foo BAR baz!"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Filter chain test: foobarbaz!", skip: false}, + {templateContents: "missing variable lookup, ExitWithError: {{.Foo}}", variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "map has no entry for key \"Foo\"", expectedOutput: "", skip: false}, + {templateContents: "missing variable lookup, Invalid: {{.Foo}}", variables: map[string]any{}, missingKeyAction: options.Invalid, expectedErrorText: "", expectedOutput: "missing variable lookup, Invalid: ", skip: false}, + // Note: options.ZeroValue does not work correctly with Go templating when you pass in a map[string]any. For some reason, it always prints . + {templateContents: "missing variable lookup, ZeroValue: {{.Foo}}", variables: map[string]any{}, missingKeyAction: options.ZeroValue, expectedErrorText: "", expectedOutput: "missing variable lookup, ZeroValue: ", skip: false}, + {templateContents: embedWholeFileTemplate, variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: embedWholeFileTemplateOutput, skip: runtime.GOOS == "windows"}, + {templateContents: embedSnippetTemplate, variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: embedSnippetTemplateOutput, skip: false}, + {templateContents: "Invalid template syntax: {{.Foo", variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "unclosed action", expectedOutput: "", skip: false}, + {templateContents: "Uppercase test: {{ .Foo | upcase }}", variables: map[string]any{"Foo": "some text"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Uppercase test: SOME TEXT", skip: false}, + {templateContents: "Lowercase test: {{ .Foo | downcase }}", variables: map[string]any{"Foo": "SOME TEXT"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Lowercase test: some text", skip: false}, + {templateContents: "Capitalize test: {{ .Foo | capitalize }}", variables: map[string]any{"Foo": "some text"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Capitalize test: Some Text", skip: false}, + {templateContents: "Replace test: {{ .Foo | replace \"foo\" \"bar\" }}", variables: map[string]any{"Foo": "hello foo, how are foo"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Replace test: hello bar, how are foo", skip: false}, + {templateContents: "Replace all test: {{ .Foo | replaceAll \"foo\" \"bar\" }}", variables: map[string]any{"Foo": "hello foo, how are foo"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Replace all test: hello bar, how are bar", skip: false}, + {templateContents: "Trim test: {{ .Foo | trim }}", variables: map[string]any{"Foo": " some text \t"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Trim test: some text", skip: false}, + {templateContents: "Round test: {{ .Foo | round }}", variables: map[string]any{"Foo": "0.45"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Round test: 0", skip: false}, + {templateContents: "Ceil test: {{ .Foo | ceil }}", variables: map[string]any{"Foo": "0.45"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Ceil test: 1", skip: false}, + {templateContents: "Floor test: {{ .Foo | floor }}", variables: map[string]any{"Foo": "0.45"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Floor test: 0", skip: false}, + {templateContents: "Dasherize test: {{ .Foo | dasherize }}", variables: map[string]any{"Foo": "foo BAR baz!"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Dasherize test: foo-bar-baz", skip: false}, + {templateContents: "Snake case test: {{ .Foo | snakeCase }}", variables: map[string]any{"Foo": "foo BAR baz!"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Snake case test: foo_bar_baz", skip: false}, + {templateContents: "Camel case test: {{ .Foo | camelCase }}", variables: map[string]any{"Foo": "foo BAR baz!"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Camel case test: FooBARBaz", skip: false}, + {templateContents: "Camel case lower test: {{ .Foo | camelCaseLower }}", variables: map[string]any{"Foo": "foo BAR baz!"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Camel case lower test: fooBARBaz", skip: false}, + {templateContents: "Plus test: {{ plus .Foo .Bar }}", variables: map[string]any{"Foo": "5", "Bar": "3"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Plus test: 8", skip: false}, + {templateContents: "Minus test: {{ minus .Foo .Bar }}", variables: map[string]any{"Foo": "5", "Bar": "3"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Minus test: 2", skip: false}, + {templateContents: "Times test: {{ times .Foo .Bar }}", variables: map[string]any{"Foo": "5", "Bar": "3"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Times test: 15", skip: false}, + {templateContents: "Divide test: {{ divide .Foo .Bar | printf \"%1.5f\" }}", variables: map[string]any{"Foo": "5", "Bar": "3"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Divide test: 1.66667", skip: false}, + {templateContents: "Mod test: {{ mod .Foo .Bar }}", variables: map[string]any{"Foo": "5", "Bar": "3"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Mod test: 2", skip: false}, + {templateContents: "Slice test: {{ slice 0 5 1 }}", variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Slice test: [0 1 2 3 4]", skip: false}, + {templateContents: "Keys test: {{ keys .Map }}", variables: map[string]any{"Map": map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Keys test: [key1 key2 key3]", skip: false}, + {templateContents: "Shell test: {{ shell \"echo\" .Text }}", variables: map[string]any{"Text": "Hello, World"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Shell test: Hello, World\n", skip: runtime.GOOS == "windows"}, + {templateContents: "Shell set env vars test: {{ shell \"printenv\" \"FOO\" \"ENV:FOO=bar\" }}", variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Shell set env vars test: bar\n", skip: runtime.GOOS == "windows"}, + {templateContents: "Shell read env vars test: {{ env \"USER\" \"should-not-get-fallback\" }}", variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Shell read env vars test: " + userFromEnvVar, skip: runtime.GOOS == "windows"}, + {templateContents: "Shell read env vars test, fallback: {{ env \"not-a-valid-env-var\" \"should-get-fallback\" }}", variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Shell read env vars test, fallback: should-get-fallback", skip: false}, + {templateContents: "Template folder test: {{ templateFolder }}", variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Template folder test: " + defaultTemplateDir, skip: false}, + {templateContents: "Output folder test: {{ outputFolder }}", variables: map[string]any{}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Output folder test: " + defaultOutputDir, skip: false}, + {templateContents: "Filter chain test: {{ .Foo | downcase | replaceAll \" \" \"\" }}", variables: map[string]any{"Foo": "foo BAR baz!"}, missingKeyAction: options.ExitWithError, expectedErrorText: "", expectedOutput: "Filter chain test: foobarbaz!", skip: false}, } for _, tc := range testCases { @@ -108,9 +109,11 @@ func TestRenderTemplate(t *testing.T) { t.Skip("Skipping test because of skip flag") return } + opts := testutil.CreateTestOptionsWithOutput("/templates", defaultOutputDir) opts.OnMissingKey = tc.missingKeyAction - actualOutput, err := RenderTemplateFromString(pwd+"/template.txt", tc.templateContents, tc.variables, opts) + + actualOutput, err := RenderTemplateFromStringWithContext(t.Context(), pwd+"/template.txt", tc.templateContents, tc.variables, opts) if tc.expectedErrorText == "" { assert.NoError(t, err, "template = %s, variables = %s, missingKeyAction = %s, err = %v", tc.templateContents, tc.variables, tc.missingKeyAction, err) assert.Equal(t, tc.expectedOutput, actualOutput, "template = %s, variables = %s, missingKeyAction = %s", tc.templateContents, tc.variables, tc.missingKeyAction) diff --git a/render/template_helpers.go b/render/template_helpers.go index 26dabe46..ed547bfb 100644 --- a/render/template_helpers.go +++ b/render/template_helpers.go @@ -2,6 +2,7 @@ package render import ( "bufio" + "context" "crypto/sha256" "fmt" "maps" @@ -53,10 +54,10 @@ var camelCaseRegex = regexp.MustCompile( // TemplateHelper represents all boilerplate template helpers. They get the path of the template they are rendering as // the first arg, the Boilerplate Options as the second arg, and then any arguments the user passed when calling the // helper. -type TemplateHelper func(templatePath string, opts *options.BoilerplateOptions, args ...string) (string, error) +type TemplateHelper func(ctx context.Context, templatePath string, opts *options.BoilerplateOptions, args ...string) (string, error) // CreateTemplateHelpers creates a map of custom template helpers exposed by boilerplate -func CreateTemplateHelpers(templatePath string, opts *options.BoilerplateOptions, tmpl *template.Template) template.FuncMap { +func CreateTemplateHelpers(ctx context.Context, templatePath string, opts *options.BoilerplateOptions, tmpl *template.Template) template.FuncMap { sprigFuncs := sprig.FuncMap() // We rename a few sprig functions that overlap with boilerplate implementations. See DEPRECATED note on boilerplate // functions below for more details. @@ -91,9 +92,9 @@ func CreateTemplateHelpers(templatePath string, opts *options.BoilerplateOptions "numRange": slice, "keysSorted": keys, - "snippet": wrapWithTemplatePath(templatePath, opts, snippet), - "include": wrapIncludeWithTemplatePath(templatePath, opts), - "shell": wrapWithTemplatePath(templatePath, opts, shell), + "snippet": wrapWithTemplatePath(ctx, templatePath, opts, snippet), + "include": wrapIncludeWithTemplatePath(ctx, templatePath, opts), + "shell": wrapWithTemplatePath(ctx, templatePath, opts, shell), "pathExists": util.PathExists, "templateIsDefined": wrapIsDefinedWithTemplate(tmpl), @@ -190,16 +191,16 @@ func CreateTemplateHelpers(templatePath string, opts *options.BoilerplateOptions // issue, this function can be used to wrap boilerplate template helpers to make the path of the template itself // available as the first argument and the BoilerplateOptions as the second argument. The helper can use that path to // relativize other paths, if necessary. -func wrapWithTemplatePath(templatePath string, opts *options.BoilerplateOptions, helper TemplateHelper) func(...string) (string, error) { +func wrapWithTemplatePath(ctx context.Context, templatePath string, opts *options.BoilerplateOptions, helper TemplateHelper) func(...string) (string, error) { return func(args ...string) (string, error) { - return helper(templatePath, opts, args...) + return helper(ctx, templatePath, opts, args...) } } // This works exactly like wrapWithTemplatePath, but it is adapted to the function args for the include helper function. -func wrapIncludeWithTemplatePath(templatePath string, opts *options.BoilerplateOptions) func(string, map[string]any) (string, error) { +func wrapIncludeWithTemplatePath(ctx context.Context, templatePath string, opts *options.BoilerplateOptions) func(string, map[string]any) (string, error) { return func(path string, varData map[string]interface{}) (string, error) { - return include(templatePath, opts, path, varData) + return include(ctx, templatePath, opts, path, varData) } } @@ -230,7 +231,7 @@ func templateIsDefined(tmpl *template.Template, name string) bool { // It returns the contents of PATH, relative to TEMPLATE_PATH, as a string. If SNIPPET_NAME is specified, only the // contents of that snippet with that name will be returned. A snippet is any text in the file surrounded by a line on // each side of the format "boilerplate-snippet: NAME" (typically using the comment syntax for the language). -func snippet(templatePath string, opts *options.BoilerplateOptions, args ...string) (string, error) { +func snippet(ctx context.Context, templatePath string, opts *options.BoilerplateOptions, args ...string) (string, error) { const snippetArgsWithName = 2 switch len(args) { @@ -249,13 +250,13 @@ func snippet(templatePath string, opts *options.BoilerplateOptions, args ...stri // // This helper returns the contents of PATH, relative to TEMPLAT_PATH, but rendered through the boilerplate templating // engine with the given variables. -func include(templatePath string, opts *options.BoilerplateOptions, path string, varData map[string]interface{}) (string, error) { +func include(ctx context.Context, templatePath string, opts *options.BoilerplateOptions, path string, varData map[string]interface{}) (string, error) { templateContents, err := readFile(templatePath, path) if err != nil { return "", err } - return RenderTemplateFromString(templatePath, templateContents, varData, opts) + return RenderTemplateFromStringWithContext(ctx, templatePath, templateContents, varData, opts) } // PathRelativeToTemplate returns the given filePath relative to the given templatePath. If filePath is already an absolute path, returns it @@ -666,7 +667,7 @@ func printShellCommandDetails(args []string, envVars []string, workingDir string // Run the given shell command specified in args in the working dir specified by templatePath and return stdout as a // string. -func shell(templatePath string, opts *options.BoilerplateOptions, rawArgs ...string) (string, error) { +func shell(ctx context.Context, templatePath string, opts *options.BoilerplateOptions, rawArgs ...string) (string, error) { if opts.NoShell { util.Logger.Printf("Shell helpers are disabled. Will not execute shell command '%v'. Returning placeholder value '%s'.", rawArgs, shellDisabledPlaceholder) return shellDisabledPlaceholder, nil @@ -686,7 +687,7 @@ func shell(templatePath string, opts *options.BoilerplateOptions, rawArgs ...str util.Logger.Printf("Executing shell command (non-interactive mode)") - return util.RunShellCommandAndGetOutput(workingDir, envVars, args...) + return util.RunShellCommandAndGetOutputWithContext(ctx, workingDir, envVars, args...) } // Check previous confirmation @@ -698,7 +699,7 @@ func shell(templatePath string, opts *options.BoilerplateOptions, rawArgs ...str util.Logger.Printf("Executing shell command (%s)", "previously confirmed or all confirmed") - return util.RunShellCommandAndGetOutput(workingDir, envVars, args...) + return util.RunShellCommandAndGetOutputWithContext(ctx, workingDir, envVars, args...) } // Handle user confirmation @@ -727,7 +728,7 @@ func shell(templatePath string, opts *options.BoilerplateOptions, rawArgs ...str return shellDisabledPlaceholder, nil } - return util.RunShellCommandAndGetOutput(workingDir, envVars, args...) + return util.RunShellCommandAndGetOutputWithContext(ctx, workingDir, envVars, args...) } // To pass env vars to the shell helper, we use the format ENV:KEY=VALUE. This method goes through the given list of diff --git a/render/template_helpers_test.go b/render/template_helpers_test.go index a8f6b3db..25b4da5e 100644 --- a/render/template_helpers_test.go +++ b/render/template_helpers_test.go @@ -3,6 +3,7 @@ package render //nolint:testpackage import ( "bufio" "bytes" + "context" "fmt" "path/filepath" "reflect" @@ -218,6 +219,7 @@ func TestPathRelativeToTemplate(t *testing.T) { t.Skip() return } + actual := PathRelativeToTemplate(tt.templatePath, tt.path) assert.Equal(t, tt.expected, filepath.ToSlash(actual)) }) @@ -230,15 +232,21 @@ func TestWrapWithTemplatePath(t *testing.T) { expectedPath := "/foo/bar/template.txt" expectedOpts := &options.BoilerplateOptions{NonInteractive: true} - var actualPath string - var actualOpts *options.BoilerplateOptions + var ( + actualPath string + actualOpts *options.BoilerplateOptions + ) - wrappedFunc := wrapWithTemplatePath(expectedPath, expectedOpts, func(templatePath string, opts *options.BoilerplateOptions, args ...string) (string, error) { - actualPath = templatePath - actualOpts = opts + wrappedFunc := wrapWithTemplatePath( + t.Context(), + expectedPath, + expectedOpts, + func(ctx context.Context, templatePath string, opts *options.BoilerplateOptions, args ...string) (string, error) { + actualPath = templatePath + actualOpts = opts - return templatePath, nil - }) + return templatePath, nil + }) returnedPath, err := wrappedFunc() require.NoError(t, err) @@ -446,17 +454,23 @@ func TestLowerFirst(t *testing.T) { func TestShellSuccess(t *testing.T) { t.Parallel() - var output string - var err error - var eol string + + var ( + output string + err error + eol string + ) + opts := testutil.CreateTestOptionsForShell(true, false) + if runtime.GOOS == windowsOS { eol = "\r\n" - output, err = shell(".", opts, "cmd.exe", "/C", "echo", "hi") + output, err = shell(t.Context(), ".", opts, "cmd.exe", "/C", "echo", "hi") } else { eol = "\n" - output, err = shell(".", opts, "echo", "hi") + output, err = shell(t.Context(), ".", opts, "echo", "hi") } + require.NoError(t, err, "Unexpected error: %v", err) assert.Equal(t, "hi"+eol, output) } @@ -465,14 +479,14 @@ func TestShellError(t *testing.T) { t.Parallel() opts := testutil.CreateTestOptionsForShell(true, false) - _, err := shell(".", opts, "not-a-real-command") + + _, err := shell(t.Context(), ".", opts, "not-a-real-command") if assert.Error(t, err) { if runtime.GOOS == windowsOS { assert.Contains(t, err.Error(), "executable file not found in %PATH%", "Unexpected error message: %s", err.Error()) } else { assert.Contains(t, err.Error(), "executable file not found in $PATH", "Unexpected error message: %s", err.Error()) } - } } @@ -480,7 +494,7 @@ func TestShellDisabled(t *testing.T) { t.Parallel() opts := testutil.CreateTestOptionsForShell(true, true) - output, err := shell(".", opts, "echo", "hi") + output, err := shell(t.Context(), ".", opts, "echo", "hi") require.NoError(t, err, "Unexpected error: %v", err) assert.Equal(t, shellDisabledPlaceholder, output) } @@ -511,6 +525,7 @@ func TestTemplateIsDefined(t *testing.T) { // TestToYaml tests that a given value can be correctly encoded to YAML func TestToYaml(t *testing.T) { t.Parallel() + testCases := []struct { input any expected string @@ -529,6 +544,7 @@ func TestToYaml(t *testing.T) { func TestFromYaml(t *testing.T) { t.Parallel() + testCases := []struct { expected any name string diff --git a/templates/engines_processor.go b/templates/engines_processor.go index 8087f21f..b0643463 100644 --- a/templates/engines_processor.go +++ b/templates/engines_processor.go @@ -2,6 +2,7 @@ package templates import ( + "context" "path/filepath" "github.com/gruntwork-io/boilerplate/options" @@ -17,6 +18,7 @@ type ProcessedEngine struct { // processEngines will take the engines list and process them in the current boilerplate context. This is primarily // rendering the glob expression for the Path attribute. func processEngines( + ctx context.Context, engines []variables.Engine, opts *options.BoilerplateOptions, variables map[string]any, @@ -24,7 +26,7 @@ func processEngines( output := []ProcessedEngine{} for _, engine := range engines { - matchedPaths, err := renderGlobPath(opts, engine.Path, variables) + matchedPaths, err := renderGlobPath(ctx, opts, engine.Path, variables) if err != nil { return nil, err } diff --git a/templates/skip_files_processor.go b/templates/skip_files_processor.go index 4765779b..70e6a51e 100644 --- a/templates/skip_files_processor.go +++ b/templates/skip_files_processor.go @@ -1,6 +1,7 @@ package templates import ( + "context" "path/filepath" zglob "github.com/mattn/go-zglob" @@ -27,11 +28,11 @@ type ProcessedSkipFile struct { // processSkipFiles will take the skip_files list and process them in the current boilerplate context. This includes: // - Rendering the glob expression for the Path attribute. // - Rendering the if attribute using the provided variables. -func processSkipFiles(skipFiles []variables.SkipFile, opts *options.BoilerplateOptions, variables map[string]any) ([]ProcessedSkipFile, error) { +func processSkipFiles(ctx context.Context, skipFiles []variables.SkipFile, opts *options.BoilerplateOptions, variables map[string]any) ([]ProcessedSkipFile, error) { output := []ProcessedSkipFile{} for _, skipFile := range skipFiles { - matchedPaths, err := renderGlobPath(opts, skipFile.Path, variables) + matchedPaths, err := renderGlobPath(ctx, opts, skipFile.Path, variables) if err != nil { return nil, errors.WithStackTrace(err) } @@ -40,7 +41,7 @@ func processSkipFiles(skipFiles []variables.SkipFile, opts *options.BoilerplateO debugLogForMatchedPaths(skipFile.Path, matchedPaths, "SkipFile", "Path") } - matchedNotPaths, err := renderGlobPath(opts, skipFile.NotPath, variables) + matchedNotPaths, err := renderGlobPath(ctx, opts, skipFile.NotPath, variables) if err != nil { return nil, errors.WithStackTrace(err) } @@ -49,7 +50,7 @@ func processSkipFiles(skipFiles []variables.SkipFile, opts *options.BoilerplateO debugLogForMatchedPaths(skipFile.NotPath, matchedNotPaths, "SkipFile", "NotPath") } - renderedSkipIf, err := skipFileIfCondition(skipFile, opts, variables) + renderedSkipIf, err := skipFileIfCondition(ctx, skipFile, opts, variables) if err != nil { return nil, err } @@ -66,13 +67,13 @@ func processSkipFiles(skipFiles []variables.SkipFile, opts *options.BoilerplateO } // Return true if the if parameter of the given SkipFile evaluates to a "true" value -func skipFileIfCondition(skipFile variables.SkipFile, opts *options.BoilerplateOptions, variables map[string]any) (bool, error) { +func skipFileIfCondition(ctx context.Context, skipFile variables.SkipFile, opts *options.BoilerplateOptions, variables map[string]any) (bool, error) { // If the "if" attribute of skip_files was not specified, then default to true. if skipFile.If == "" { return true, nil } - rendered, err := render.RenderTemplateFromString(opts.TemplateFolder, skipFile.If, variables, opts) + rendered, err := render.RenderTemplateFromStringWithContext(ctx, opts.TemplateFolder, skipFile.If, variables, opts) if err != nil { return false, err } @@ -101,19 +102,19 @@ func debugLogForMatchedPaths(sourcePath string, paths []string, directiveName st // renderGlobPath will render the glob of the given path in the template folder and return the list of matched paths. // Note that the paths will be canonicalized to unix slashes regardless of OS. -func renderGlobPath(opts *options.BoilerplateOptions, path string, variables map[string]any) ([]string, error) { +func renderGlobPath(ctx context.Context, opts *options.BoilerplateOptions, path string, variables map[string]any) ([]string, error) { if path == "" { return []string{}, nil } - rendered, err := render.RenderTemplateFromString(opts.TemplateFolder, path, variables, opts) + rendered, err := render.RenderTemplateFromStringWithContext(ctx, opts.TemplateFolder, path, variables, opts) if err != nil { return nil, err } globPath := filepath.Join(opts.TemplateFolder, rendered) - rawMatchedPaths, err := zglob.Glob(globPath) + rawMatchedPaths, err := zglob.Glob(globPath) if err != nil { // TODO: logger-debug - switch to debug util.Logger.Printf("ERROR: could not glob %s", globPath) diff --git a/templates/template_processor.go b/templates/template_processor.go index 31c7f859..4ea48f36 100644 --- a/templates/template_processor.go +++ b/templates/template_processor.go @@ -1,6 +1,7 @@ package templates import ( + "context" "crypto/sha256" "fmt" "net/url" @@ -31,10 +32,16 @@ const defaultDirPerm = 0o777 // dependent boilerplate templates, and then execute this template. Note that we pass in rootOptions so that template // dependencies can inspect properties of the root template. func ProcessTemplate(options, rootOpts *options.BoilerplateOptions, thisDep variables.Dependency) error { + return ProcessTemplateWithContext(context.Background(), options, rootOpts, thisDep) +} + +// ProcessTemplateWithContext is like ProcessTemplate but accepts a context for cancellation and timeouts. +func ProcessTemplateWithContext(ctx context.Context, options, rootOpts *options.BoilerplateOptions, thisDep variables.Dependency) error { // If TemplateFolder is already set, use that directly as it is a local template. Otherwise, download to a temporary // working directory. if options.TemplateFolder == "" { workingDir, templateFolder, downloadErr := getterhelper.DownloadTemplatesToTemporaryFolder(options.TemplateURL) + defer func() { util.Logger.Printf("Cleaning up working directory.") @@ -69,7 +76,7 @@ func ProcessTemplate(options, rootOpts *options.BoilerplateOptions, thisDep vari return err } - vars, err := config.GetVariables(options, boilerplateConfig, rootBoilerplateConfig, thisDep) + vars, err := config.GetVariablesWithContext(ctx, options, boilerplateConfig, rootBoilerplateConfig, thisDep) if err != nil { return err } @@ -79,27 +86,27 @@ func ProcessTemplate(options, rootOpts *options.BoilerplateOptions, thisDep vari return errors.WithStackTrace(err) } - err = processHooks(boilerplateConfig.Hooks.BeforeHooks, options, vars) + err = processHooks(ctx, boilerplateConfig.Hooks.BeforeHooks, options, vars) if err != nil { return err } - err = processDependencies(boilerplateConfig.Dependencies, options, boilerplateConfig.GetVariablesMap(), vars) + err = processDependencies(ctx, boilerplateConfig.Dependencies, options, boilerplateConfig.GetVariablesMap(), vars) if err != nil { return err } - partials, err := processPartials(boilerplateConfig.Partials, options, vars) + partials, err := processPartials(ctx, boilerplateConfig.Partials, options, vars) if err != nil { return err } - err = processTemplateFolder(boilerplateConfig, options, vars, partials) + err = processTemplateFolder(ctx, boilerplateConfig, options, vars, partials) if err != nil { return err } - err = processHooks(boilerplateConfig.Hooks.AfterHooks, options, vars) + err = processHooks(ctx, boilerplateConfig.Hooks.AfterHooks, options, vars) if err != nil { return err } @@ -107,11 +114,11 @@ func ProcessTemplate(options, rootOpts *options.BoilerplateOptions, thisDep vari return nil } -func processPartials(partials []string, opts *options.BoilerplateOptions, vars map[string]any) ([]string, error) { +func processPartials(ctx context.Context, partials []string, opts *options.BoilerplateOptions, vars map[string]any) ([]string, error) { renderedPartials := make([]string, 0, len(partials)) for _, partial := range partials { - renderedPartial, err := render.RenderTemplateFromString(config.BoilerplateConfigPath(opts.TemplateFolder), partial, vars, opts) + renderedPartial, err := render.RenderTemplateFromStringWithContext(ctx, config.BoilerplateConfigPath(opts.TemplateFolder), partial, vars, opts) if err != nil { return []string{}, err } @@ -122,8 +129,8 @@ func processPartials(partials []string, opts *options.BoilerplateOptions, vars m return renderedPartials, nil } -// Process the given list of hooks, which are scripts that should be executed at the command-line -func processHooks(hooks []variables.Hook, opts *options.BoilerplateOptions, vars map[string]any) error { +// processHooks processes the given list of hooks, which are scripts that should be executed at the command-line +func processHooks(ctx context.Context, hooks []variables.Hook, opts *options.BoilerplateOptions, vars map[string]any) error { if len(hooks) == 0 || opts.NoHooks { if opts.NoHooks { util.Logger.Printf("Hooks are disabled, skipping %d hook(s)", len(hooks)) @@ -136,7 +143,7 @@ func processHooks(hooks []variables.Hook, opts *options.BoilerplateOptions, vars hookAnswers := make(map[string]bool) for _, hook := range hooks { - skip, err := shouldSkipHook(hook, opts, vars) + skip, err := shouldSkipHook(ctx, hook, opts, vars) if err != nil || skip { if skip { util.Logger.Printf("Skipping hook with command '%s'", hook.Command) @@ -149,7 +156,7 @@ func processHooks(hooks []variables.Hook, opts *options.BoilerplateOptions, vars continue } - hookDetails, err := renderHookDetails(hook, opts, vars) + hookDetails, err := renderHookDetails(ctx, hook, opts, vars) if err != nil { return err } @@ -179,7 +186,7 @@ func processHooks(hooks []variables.Hook, opts *options.BoilerplateOptions, vars } // Execute the hook - if err := processHook(hook, opts, vars); err != nil { + if err := processHook(ctx, hook, opts, vars); err != nil { return err } } @@ -188,10 +195,10 @@ func processHooks(hooks []variables.Hook, opts *options.BoilerplateOptions, vars } // renderHookDetails renders the hook details and returns a pre-rendered string representation -func renderHookDetails(hook variables.Hook, opts *options.BoilerplateOptions, vars map[string]any) (string, error) { +func renderHookDetails(ctx context.Context, hook variables.Hook, opts *options.BoilerplateOptions, vars map[string]any) (string, error) { base := config.BoilerplateConfigPath(opts.TemplateFolder) render := func(s string) (string, error) { - return render.RenderTemplateFromString(base, s, vars, opts) + return render.RenderTemplateFromStringWithContext(ctx, base, s, vars, opts) } cmd, renderErr := render(hook.Command) @@ -317,9 +324,9 @@ func printHookDetails(hookDetails string) { } } -// Process the given hook, which is a script that should be execute at the command-line -func processHook(hook variables.Hook, opts *options.BoilerplateOptions, vars map[string]any) error { - cmd, hookRenderErr := render.RenderTemplateFromString(config.BoilerplateConfigPath(opts.TemplateFolder), hook.Command, vars, opts) +// processHook processes the given hook, which is a script that should be execute at the command-line +func processHook(ctx context.Context, hook variables.Hook, opts *options.BoilerplateOptions, vars map[string]any) error { + cmd, hookRenderErr := render.RenderTemplateFromStringWithContext(ctx, config.BoilerplateConfigPath(opts.TemplateFolder), hook.Command, vars, opts) if hookRenderErr != nil { return hookRenderErr } @@ -327,7 +334,7 @@ func processHook(hook variables.Hook, opts *options.BoilerplateOptions, vars map args := []string{} for _, arg := range hook.Args { - renderedArg, hookRenderErr := render.RenderTemplateFromString(config.BoilerplateConfigPath(opts.TemplateFolder), arg, vars, opts) + renderedArg, hookRenderErr := render.RenderTemplateFromStringWithContext(ctx, config.BoilerplateConfigPath(opts.TemplateFolder), arg, vars, opts) if hookRenderErr != nil { return hookRenderErr } @@ -338,12 +345,12 @@ func processHook(hook variables.Hook, opts *options.BoilerplateOptions, vars map envVars := []string{} for key, value := range hook.Env { - renderedKey, hookRenderErr := render.RenderTemplateFromString(config.BoilerplateConfigPath(opts.TemplateFolder), key, vars, opts) + renderedKey, hookRenderErr := render.RenderTemplateFromStringWithContext(ctx, config.BoilerplateConfigPath(opts.TemplateFolder), key, vars, opts) if hookRenderErr != nil { return hookRenderErr } - renderedValue, hookRenderErr := render.RenderTemplateFromString(config.BoilerplateConfigPath(opts.TemplateFolder), value, vars, opts) + renderedValue, hookRenderErr := render.RenderTemplateFromStringWithContext(ctx, config.BoilerplateConfigPath(opts.TemplateFolder), value, vars, opts) if hookRenderErr != nil { return hookRenderErr } @@ -354,7 +361,8 @@ func processHook(hook variables.Hook, opts *options.BoilerplateOptions, vars map workingDir := opts.TemplateFolder if hook.WorkingDir != "" { - renderedWd, wdErr := render.RenderTemplateFromString( + renderedWd, wdErr := render.RenderTemplateFromStringWithContext( + ctx, config.BoilerplateConfigPath(opts.TemplateFolder), hook.WorkingDir, vars, @@ -367,16 +375,16 @@ func processHook(hook variables.Hook, opts *options.BoilerplateOptions, vars map workingDir = renderedWd } - return util.RunShellCommand(workingDir, envVars, cmd, args...) + return util.RunShellCommandWithContext(ctx, workingDir, envVars, cmd, args...) } // Return true if the "skip" condition of this hook evaluates to true -func shouldSkipHook(hook variables.Hook, opts *options.BoilerplateOptions, vars map[string]interface{}) (bool, error) { +func shouldSkipHook(ctx context.Context, hook variables.Hook, opts *options.BoilerplateOptions, vars map[string]interface{}) (bool, error) { if hook.Skip == "" { return false, nil } - rendered, err := render.RenderTemplateFromString(opts.TemplateFolder, hook.Skip, vars, opts) + rendered, err := render.RenderTemplateFromStringWithContext(ctx, opts.TemplateFolder, hook.Skip, vars, opts) if err != nil { return false, err } @@ -386,15 +394,16 @@ func shouldSkipHook(hook variables.Hook, opts *options.BoilerplateOptions, vars return rendered == "true", nil } -// Execute the boilerplate templates in the given list of dependencies +// processDependencies executes the boilerplate templates in the given list of dependencies func processDependencies( + ctx context.Context, dependencies []variables.Dependency, opts *options.BoilerplateOptions, variablesInConfig map[string]variables.Variable, - variables map[string]interface{}, + variables map[string]any, ) error { for _, dependency := range dependencies { - err := processDependency(dependency, opts, variablesInConfig, variables) + err := processDependency(ctx, dependency, opts, variablesInConfig, variables) if err != nil { return err } @@ -403,34 +412,35 @@ func processDependencies( return nil } -// Execute the boilerplate template in the given dependency +// processDependency is like processDependency but accepts a context for cancellation and timeouts. func processDependency( + ctx context.Context, dependency variables.Dependency, opts *options.BoilerplateOptions, variablesInConfig map[string]variables.Variable, - originalVars map[string]interface{}, + originalVars map[string]any, ) error { - shouldProcess, err := shouldProcessDependency(dependency, opts, originalVars) + shouldProcess, err := shouldProcessDependency(ctx, dependency, opts, originalVars) if err != nil { return err } if shouldProcess { - doProcess := func(updatedVars map[string]interface{}) error { - dependencyOptions, err := cloneOptionsForDependency(dependency, opts, variablesInConfig, updatedVars) + doProcess := func(updatedVars map[string]any) error { + dependencyOptions, err := cloneOptionsForDependency(ctx, dependency, opts, variablesInConfig, updatedVars) if err != nil { return err } util.Logger.Printf("Processing dependency %s, with template folder %s and output folder %s", dependency.Name, dependencyOptions.TemplateFolder, dependencyOptions.OutputFolder) - return ProcessTemplate(dependencyOptions, opts, dependency) + return ProcessTemplateWithContext(ctx, dependencyOptions, opts, dependency) } forEach := dependency.ForEach if len(dependency.ForEachReference) > 0 { - renderedReference, err := render.RenderTemplateFromString(opts.TemplateFolder, dependency.ForEachReference, originalVars, opts) + renderedReference, err := render.RenderTemplateFromStringWithContext(ctx, opts.TemplateFolder, dependency.ForEachReference, originalVars, opts) if err != nil { return err } @@ -464,17 +474,18 @@ func processDependency( // Clone the given options for use when rendering the given dependency. The dependency will get the same options as // the original passed in, except for the template folder, output folder, and command-line vars. func cloneOptionsForDependency( + ctx context.Context, dependency variables.Dependency, originalOpts *options.BoilerplateOptions, variablesInConfig map[string]variables.Variable, - variables map[string]interface{}, + variables map[string]any, ) (*options.BoilerplateOptions, error) { - renderedTemplateURL, err := render.RenderTemplateFromString(originalOpts.TemplateFolder, dependency.TemplateURL, variables, originalOpts) + renderedTemplateURL, err := render.RenderTemplateFromStringWithContext(ctx, originalOpts.TemplateFolder, dependency.TemplateURL, variables, originalOpts) if err != nil { return nil, err } - renderedOutputFolder, err := render.RenderTemplateFromString(originalOpts.TemplateFolder, dependency.OutputFolder, variables, originalOpts) + renderedOutputFolder, err := render.RenderTemplateFromStringWithContext(ctx, originalOpts.TemplateFolder, dependency.OutputFolder, variables, originalOpts) if err != nil { return nil, err } @@ -494,7 +505,7 @@ func cloneOptionsForDependency( renderedVarFiles := []string{} for _, varFilePath := range dependency.VarFiles { - renderedVarFilePath, err := render.RenderTemplateFromString(originalOpts.TemplateFolder, varFilePath, variables, originalOpts) + renderedVarFilePath, err := render.RenderTemplateFromStringWithContext(ctx, originalOpts.TemplateFolder, varFilePath, variables, originalOpts) if err != nil { return nil, err } @@ -502,7 +513,7 @@ func cloneOptionsForDependency( renderedVarFiles = append(renderedVarFiles, renderedVarFilePath) } - vars, err := cloneVariablesForDependency(originalOpts, dependency, variablesInConfig, variables, renderedVarFiles) + vars, err := cloneVariablesForDependency(ctx, originalOpts, dependency, variablesInConfig, variables, renderedVarFiles) if err != nil { return nil, err } @@ -530,10 +541,11 @@ func cloneOptionsForDependency( // - Variables defined from VarFiles set on the dependency. // - Variables defaults set on the dependency. func cloneVariablesForDependency( + ctx context.Context, opts *options.BoilerplateOptions, dependency variables.Dependency, variablesInConfig map[string]variables.Variable, - originalVariables map[string]interface{}, + originalVariables map[string]any, renderedVarFiles []string, ) (map[string]interface{}, error) { // Clone the opts so that we attempt to get the value for the variable, and we can error on any variable that is set @@ -560,7 +572,7 @@ func cloneVariablesForDependency( // variables. // We also filter out any dependency namespaced variables, as those are only passed in from the CLI and will be // handled later. - newVariables := map[string]interface{}{} + newVariables := map[string]any{} if !dependency.DontInheritVariables { for key, value := range originalVariables { @@ -590,7 +602,7 @@ func cloneVariablesForDependency( } // If the value is a string, render it if strValue, ok := varValue.(string); ok { - renderedValue, err := render.RenderTemplateFromString(opts.TemplateFolder, strValue, currentVariables, opts) + renderedValue, err := render.RenderTemplateFromStringWithContext(ctx, opts.TemplateFolder, strValue, currentVariables, opts) if err != nil { return nil, err } @@ -600,7 +612,7 @@ func cloneVariablesForDependency( newVariables[variable.Name()] = varValue // Update currentVariables to include the newly processed variable - currentVariables = util.MergeMaps(currentVariables, map[string]interface{}{ + currentVariables = util.MergeMaps(currentVariables, map[string]any{ variable.Name(): varValue, }) } @@ -635,8 +647,13 @@ func cloneVariablesForDependency( // Prompt the user to verify if the given dependency should be executed and return true if they confirm. If // options.NonInteractive or options.DisableDependencyPrompt are set to true, this function always returns true. -func shouldProcessDependency(dependency variables.Dependency, opts *options.BoilerplateOptions, variables map[string]interface{}) (bool, error) { - shouldSkip, err := shouldSkipDependency(dependency, opts, variables) +func shouldProcessDependency( + ctx context.Context, + dependency variables.Dependency, + opts *options.BoilerplateOptions, + variables map[string]any, +) (bool, error) { + shouldSkip, err := shouldSkipDependency(ctx, dependency, opts, variables) if err != nil { return false, err } @@ -653,12 +670,12 @@ func shouldProcessDependency(dependency variables.Dependency, opts *options.Boil } // Return true if the skip parameter of the given dependency evaluates to a "true" value -func shouldSkipDependency(dependency variables.Dependency, opts *options.BoilerplateOptions, variables map[string]interface{}) (bool, error) { +func shouldSkipDependency(ctx context.Context, dependency variables.Dependency, opts *options.BoilerplateOptions, variables map[string]interface{}) (bool, error) { if dependency.Skip == "" { return false, nil } - rendered, err := render.RenderTemplateFromString(opts.TemplateFolder, dependency.Skip, variables, opts) + rendered, err := render.RenderTemplateFromStringWithContext(ctx, opts.TemplateFolder, dependency.Skip, variables, opts) if err != nil { return false, err } @@ -668,23 +685,24 @@ func shouldSkipDependency(dependency variables.Dependency, opts *options.Boilerp return rendered == "true", nil } -// Copy all the files and folders in templateFolder to outputFolder, passing text files through the Go template engine +// processTemplateFolder copies all the files and folders in templateFolder to outputFolder, passing text files through the Go template engine // with the given set of variables as the data. func processTemplateFolder( + ctx context.Context, config *config.BoilerplateConfig, opts *options.BoilerplateOptions, - variables map[string]interface{}, + variables map[string]any, partials []string, ) error { util.Logger.Printf("Processing templates in %s and outputting generated files to %s", opts.TemplateFolder, opts.OutputFolder) // Process and render skip files and engines before walking so we only do the rendering operation once. - processedSkipFiles, err := processSkipFiles(config.SkipFiles, opts, variables) + processedSkipFiles, err := processSkipFiles(ctx, config.SkipFiles, opts, variables) if err != nil { return err } - processedEngines, err := processEngines(config.Engines, opts, variables) + processedEngines, err := processEngines(ctx, config.Engines, opts, variables) if err != nil { return err } @@ -697,20 +715,21 @@ func processTemplateFolder( util.Logger.Printf("Skipping %s", path) return nil case util.IsDir(path): - return createOutputDir(path, opts, variables) + return createOutputDir(ctx, path, opts, variables) default: engine := determineTemplateEngine(processedEngines, path) - return processFile(path, opts, variables, partials, engine) + return processFile(ctx, path, opts, variables, partials, engine) } }) } -// Copy the given path, which is in the folder templateFolder, to the outputFolder, passing it through the Go template +// processFile copies the given path, which is in the folder templateFolder, to the outputFolder, passing it through the Go template // engine with the given set of variables as the data if it's a text file. func processFile( + ctx context.Context, path string, opts *options.BoilerplateOptions, - variables map[string]interface{}, + variables map[string]any, partials []string, engine variables.TemplateEngineType, ) error { @@ -720,15 +739,15 @@ func processFile( } if isText { - return processTemplate(path, opts, variables, partials, engine) + return processTemplate(ctx, path, opts, variables, partials, engine) } else { - return copyFile(path, opts, variables) + return copyFile(ctx, path, opts, variables) } } // Create the given directory, which is in templateFolder, in the given outputFolder -func createOutputDir(dir string, opts *options.BoilerplateOptions, variables map[string]interface{}) error { - destination, err := outPath(dir, opts, variables) +func createOutputDir(ctx context.Context, dir string, opts *options.BoilerplateOptions, variables map[string]any) error { + destination, err := outPath(ctx, dir, opts, variables) if err != nil { return err } @@ -741,7 +760,7 @@ func createOutputDir(dir string, opts *options.BoilerplateOptions, variables map // Compute the path where the given file, which is in templateFolder, should be copied in outputFolder. If the file // path contains boilerplate syntax, use the given options and variables to render it to determine the final output // path. -func outPath(file string, opts *options.BoilerplateOptions, variables map[string]interface{}) (string, error) { +func outPath(ctx context.Context, file string, opts *options.BoilerplateOptions, variables map[string]any) (string, error) { templateFolderAbsPath, err := filepath.Abs(opts.TemplateFolder) if err != nil { return "", errors.WithStackTrace(err) @@ -754,7 +773,7 @@ func outPath(file string, opts *options.BoilerplateOptions, variables map[string return "", errors.WithStackTrace(err) } - interpolatedFilePath, err := render.RenderTemplateFromString(file, urlDecodedFile, variables, opts) + interpolatedFilePath, err := render.RenderTemplateFromStringWithContext(ctx, file, urlDecodedFile, variables, opts) if err != nil { return "", errors.WithStackTrace(err) } @@ -773,8 +792,8 @@ func outPath(file string, opts *options.BoilerplateOptions, variables map[string } // Copy the given file, which is in options.TemplateFolder, to options.OutputFolder -func copyFile(file string, opts *options.BoilerplateOptions, variables map[string]interface{}) error { - destination, err := outPath(file, opts, variables) +func copyFile(ctx context.Context, file string, opts *options.BoilerplateOptions, variables map[string]interface{}) error { + destination, err := outPath(ctx, file, opts, variables) if err != nil { return err } @@ -784,16 +803,17 @@ func copyFile(file string, opts *options.BoilerplateOptions, variables map[strin return util.CopyFile(file, destination) } -// Run the template at templatePath, which is in templateFolder, through the Go template engine with the given +// processTemplate runs the template at templatePath, which is in templateFolder, through the Go template engine with the given // variables as data and write the result to outputFolder func processTemplate( + ctx context.Context, templatePath string, opts *options.BoilerplateOptions, - vars map[string]interface{}, + vars map[string]any, partials []string, engine variables.TemplateEngineType, ) error { - destination, err := outPath(templatePath, opts, vars) + destination, err := outPath(ctx, templatePath, opts, vars) if err != nil { return err } @@ -802,7 +822,7 @@ func processTemplate( switch engine { case variables.GoTemplate: - out, err = render.RenderTemplateWithPartials(templatePath, partials, vars, opts) + out, err = render.RenderTemplateWithPartialsWithContext(ctx, templatePath, partials, vars, opts) if err != nil { return err } diff --git a/templates/template_processor_test.go b/templates/template_processor_test.go index bea52902..43b3d466 100644 --- a/templates/template_processor_test.go +++ b/templates/template_processor_test.go @@ -41,7 +41,7 @@ func TestOutPath(t *testing.T) { OnMissingKey: options.ExitWithError, OnMissingConfig: options.Exit, } - actual, err := outPath(testCase.file, &opts, testCase.variables) + actual, err := outPath(t.Context(), testCase.file, &opts, testCase.variables) require.NoError(t, err, "Got unexpected error (file = %s, templateFolder = %s, outputFolder = %s, and variables = %s): %v", testCase.file, testCase.templateFolder, testCase.outputFolder, testCase.variables, err) assert.Equal(t, filepath.FromSlash(testCase.expected), filepath.FromSlash(actual), "(file = %s, templateFolder = %s, outputFolder = %s, and variables = %s)", testCase.file, testCase.templateFolder, testCase.outputFolder, testCase.variables) } @@ -87,11 +87,10 @@ func TestCloneOptionsForDependency(t *testing.T) { t.Run(tt.dependency.Name, func(t *testing.T) { t.Parallel() - actualOptions, err := cloneOptionsForDependency(tt.dependency, &tt.opts, nil, tt.variables) + actualOptions, err := cloneOptionsForDependency(t.Context(), tt.dependency, &tt.opts, nil, tt.variables) require.NoError(t, err, "Dependency: %s", tt.dependency) assert.Equal(t, tt.expectedOpts, *actualOptions, "Dependency: %s", tt.dependency) }) - } } @@ -143,11 +142,10 @@ func TestCloneVariablesForDependency(t *testing.T) { opts := testutil.CreateTestOptionsWithOutput("/template/path/", "/output/path/") opts.Vars = tt.optsVars - actualVariables, err := cloneVariablesForDependency(opts, tt.dependency, nil, tt.variables, nil) + actualVariables, err := cloneVariablesForDependency(t.Context(), opts, tt.dependency, nil, tt.variables, nil) require.NoError(t, err) assert.Equal(t, tt.expectedVariables, actualVariables, "Dependency: %s", tt.dependency) }) - } } @@ -186,7 +184,7 @@ func TestForEachReferenceRendersAsTemplate(t *testing.T) { opts := testutil.CreateTestOptionsWithOutput(templateFolder, tempDir) - err = processDependency(dependency, opts, nil, variables) + err = processDependency(t.Context(), dependency, opts, nil, variables) require.NoError(t, err) // Should create directories "a" and "b" from template1 list diff --git a/util/file.go b/util/file.go index cb843f0d..fa79931d 100644 --- a/util/file.go +++ b/util/file.go @@ -1,6 +1,7 @@ package util import ( + "context" "fmt" "os" "os/exec" @@ -61,7 +62,12 @@ func CommandInstalled(command string) bool { // RunCommandAndGetOutput runs the given command and returns its stdout and stderr as a string func RunCommandAndGetOutput(command string, args ...string) (string, error) { - cmd := exec.Command(command, args...) + return RunCommandAndGetOutputWithContext(context.Background(), command, args...) +} + +// RunCommandAndGetOutputWithContext runs the given command and returns its stdout and stderr as a string +func RunCommandAndGetOutputWithContext(ctx context.Context, command string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, command, args...) bytes, err := cmd.Output() if err != nil { diff --git a/util/file_test.go b/util/file_test.go index 9535419d..3f65fe1c 100644 --- a/util/file_test.go +++ b/util/file_test.go @@ -39,6 +39,7 @@ func TestIsTextFile(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.file, func(t *testing.T) { t.Parallel() + actual, err := util.IsTextFile("../test-fixtures/util-test/is-text-file/" + testCase.file) require.NoError(t, err) diff --git a/util/prompt.go b/util/prompt.go index 3984dbf1..06e324b4 100644 --- a/util/prompt.go +++ b/util/prompt.go @@ -41,7 +41,6 @@ func PromptUserForInput(prompt string) (string, error) { // PromptUserForYesNo prompts the user for a yes/no response and returns true if they entered yes. func PromptUserForYesNo(prompt string) (bool, error) { resp, err := PromptUserForInput(prompt + " (y/n) ") - if err != nil { return false, errors.WithStackTrace(err) } @@ -58,7 +57,6 @@ func PromptUserForYesNo(prompt string) (bool, error) { // Returns: UserResponseYes, UserResponseNo, or UserResponseAll. func PromptUserForYesNoAll(prompt string) (UserResponse, error) { resp, err := PromptUserForInput(prompt + " (y/a/n) ") - if err != nil { return UserResponseNo, errors.WithStackTrace(err) } diff --git a/util/shell.go b/util/shell.go index d052ac3f..5a4f298e 100644 --- a/util/shell.go +++ b/util/shell.go @@ -1,6 +1,7 @@ package util import ( + "context" "os" "os/exec" "strings" @@ -10,11 +11,16 @@ import ( // RunShellCommandAndGetOutput runs the given shell command with the given environment variables and arguments in the given working directory func RunShellCommandAndGetOutput(workingDir string, envVars []string, argslist ...string) (string, error) { + return RunShellCommandAndGetOutputWithContext(context.Background(), workingDir, envVars, argslist...) +} + +// RunShellCommandAndGetOutputWithContext runs the given shell command with the given environment variables and arguments in the given working directory +func RunShellCommandAndGetOutputWithContext(ctx context.Context, workingDir string, envVars []string, argslist ...string) (string, error) { command := argslist[0] args := argslist[1:] Logger.Printf("Running command: %s %s", command, strings.Join(args, " ")) - cmd := exec.Command(command, args...) + cmd := exec.CommandContext(ctx, command, args...) cmd.Stdin = os.Stdin cmd.Stderr = os.Stderr @@ -32,9 +38,14 @@ func RunShellCommandAndGetOutput(workingDir string, envVars []string, argslist . // RunShellCommand runs the given shell command with the given environment variables and arguments in the given working directory func RunShellCommand(workingDir string, envVars []string, command string, args ...string) error { + return RunShellCommandWithContext(context.Background(), workingDir, envVars, command, args...) +} + +// RunShellCommandWithContext runs the given shell command with the given environment variables and arguments in the given working directory +func RunShellCommandWithContext(ctx context.Context, workingDir string, envVars []string, command string, args ...string) error { Logger.Printf("Running command: %s %s", command, strings.Join(args, " ")) - cmd := exec.Command(command, args...) + cmd := exec.CommandContext(ctx, command, args...) cmd.Stdin = os.Stdin cmd.Stderr = os.Stderr diff --git a/variables/engines_test.go b/variables/engines_test.go index 81356165..3e285108 100644 --- a/variables/engines_test.go +++ b/variables/engines_test.go @@ -46,11 +46,14 @@ func TestEnginesRequiresSupportedTemplateEngine(t *testing.T) { }, }, } + _, err := variables.UnmarshalEnginesFromBoilerplateConfigYaml(mockFields) if tc.expectError { require.Error(t, err) underlyingErr := errors.Unwrap(err) + var invalidTemplateEngineErr variables.InvalidTemplateEngineErr + hasType := errors.As(underlyingErr, &invalidTemplateEngineErr) require.True(t, hasType) } else { diff --git a/variables/variables_test.go b/variables/variables_test.go index 42bacf25..b16a283d 100644 --- a/variables/variables_test.go +++ b/variables/variables_test.go @@ -32,7 +32,6 @@ func TestParseStringAsList(t *testing.T) { assert.Equal(t, testCase.expectedList, actualList, "For string '%s'", testCase.str) }) } - } func TestConvertType(t *testing.T) { @@ -86,6 +85,7 @@ func TestConvertType(t *testing.T) { t.Parallel() var variable Variable + switch testCase.variableType { case String: variable = NewStringVariable("test-var") @@ -142,5 +142,4 @@ func TestParseStringAsMap(t *testing.T) { assert.Equal(t, testCase.expectedMap, actualMap, "For string '%s'", testCase.str) }) } - } diff --git a/variables/yaml_helpers_test.go b/variables/yaml_helpers_test.go index b5b4bab4..f9c4ca7b 100644 --- a/variables/yaml_helpers_test.go +++ b/variables/yaml_helpers_test.go @@ -40,6 +40,7 @@ func TestParseVariablesFromVarFileContents(t *testing.T) { actualVars, err := parseVariablesFromVarFileContents([]byte(testCase.fileContents)) if testCase.expectYamlTypeError { require.Error(t, err) + typeError := &yaml.TypeError{} isYamlTypeError := errors.As(err, &typeError) assert.True(t, isYamlTypeError, "Expected a YAML type error for an invalid yaml file but got %s", reflect.TypeOf(err))