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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,14 @@ You can find older versions on the [Releases Page](https://github.com/gruntwork-
you to run arbitrary scripts.
1. **Cross-platform**: Boilerplate is easy to install (it's a standalone binary) and works on all major platforms (Mac,
Linux, Windows).
1. **Versioned file manifest**: Optionally generate a versioned JSON manifest that tracks all created files across multiple template generations. Each time boilerplate runs with `--output-manifest`, it adds a new version entry to the existing manifest file (or creates one if none exists), preserving the history of all previous generations for integration with other tools.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we preserving all previous versions?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started there based on your previous comment: #225 (comment)

I think using the CLI flag should write the manifest to disk, with some versioned schema that can be used by Boilerplate to do intelligent things like clean up files that were previously generated, but are no longer part of the template, etc

We could add some arbitrary version limit for sure, not sure what we'd consider reasonable though. I use boilerplate exclusively for scaffold, for which this isn't really a required feature. I just need the manifest to address the issue of terragrunt formatting the entire working directory. I can't really comment on other use cases of scaffold and/or what would be reasonable limits to version history.


## Working with boilerplate

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
Expand Down Expand Up @@ -185,6 +186,7 @@ The `boilerplate` binary supports the following options:
* `--no-shell`: If this flag is set, no `shell` helpers will execute. They will instead return the text "replace-me".
* `--disable-dependency-prompt` (optional): Do not prompt for confirmation to include dependencies. Has the same effect as
--non-interactive, without disabling variable prompts. Default: `false`.
* `--output-manifest` (optional): Write a versioned JSON manifest of all generated files to `boilerplate-manifest.json` in the output directory. If a manifest file already exists, a new version entry will be added to track the current generation alongside previous ones.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a better name for this flag be --manifest?

We don't require following these rules here, but if we can, let's go with --<system name> when the name of the system can sufficiently capture what the flag is controlling. Other names could include --manifest-generate or --manifest-file if users can specify what they want the manifest file to be called, and specifying the name also determines that the file is generated.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity, are you suggesting we add multiple flags?
--manifest returns the manifest, and
--manifest-file <file_name> writes the file to <file_name>?

The only problem I see with the latter is that we rely on a consistent file name for the history/version feature. If someone were to enter a file name with a typo you would lose your version history, or it would be incomplete the next time you run the command with the correct file name.

* `--help`: Show the help text and exit.
* `--version`: Show the version and exit.

Expand Down Expand Up @@ -214,6 +216,12 @@ Generate a project in ~/output from the templates in this repo's `include` examp
boilerplate --template-url "git@github.com:gruntwork-io/boilerplate.git//examples/for-learning-and-testing/include?ref=main" --output-folder ~/output --var-file vars.yml
```

Generate a project and create a versioned manifest file listing all generated files:

```
boilerplate --template-url ~/templates --output-folder ~/output --output-manifest
```


#### The boilerplate.yml file

Expand Down
26 changes: 25 additions & 1 deletion cli/boilerplate_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/gruntwork-io/go-commons/version"
"github.com/urfave/cli/v2"

"github.com/gruntwork-io/boilerplate/manifest"
"github.com/gruntwork-io/boilerplate/options"
"github.com/gruntwork-io/boilerplate/templates"
"github.com/gruntwork-io/boilerplate/variables"
Expand Down Expand Up @@ -96,6 +97,10 @@ func CreateBoilerplateCli() *cli.App {
Name: options.OptDisableDependencyPrompt,
Usage: fmt.Sprintf("Do not prompt for confirmation to include dependencies. Has the same effect as --%s, without disabling variable prompts.", options.OptNonInteractive),
},
&cli.BoolFlag{
Name: options.OptOutputManifest,
Usage: "Write a JSON list of all generated files to boilerplate-manifest.json in the current working directory.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did I request that this be in JSON instead of YAML? Seems at odds with the configuration file being in YAML, but it is a more convenient format in general. Maybe the format should be configurable. If we can get the UX to be similar to the Run Report that might be better, so there's an expectation of parity in how the two behave.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That may have just been my default, this can be rewritten to yaml for sure. Will see how easy it is to make this configurable. Will have a look at Run Report.

},
}

// We pass JSON/YAML content to various CLI flags, such as --var, and this JSON/YAML content may contain commas or
Expand Down Expand Up @@ -123,5 +128,24 @@ func runApp(cliContext *cli.Context) error {
// The root boilerplate.yml is not itself a dependency, so we pass an empty Dependency.
emptyDep := variables.Dependency{}

return templates.ProcessTemplate(opts, opts, emptyDep)
generatedFiles, err := templates.ProcessTemplate(opts, opts, emptyDep)
if err != nil {
return err
}

if opts.OutputManifest {
// Convert file paths to GeneratedFile structs
files := make([]manifest.GeneratedFile, 0, len(generatedFiles))
for _, path := range generatedFiles {
files = append(files, manifest.GeneratedFile{Path: path})
}

// Update versioned manifest
err = manifest.UpdateVersionedManifest(opts.OutputFolder, opts.TemplateUrl, opts.Vars, files)
if err != nil {
return err
}
}

return nil
}
48 changes: 48 additions & 0 deletions integration-tests/examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,51 @@ func testExample(t *testing.T, templateFolder string, outputFolder string, varFi
assertDirectoriesEqual(t, expectedOutputFolder, outputFolder)
}
}
func TestExampleWithManifest(t *testing.T) {
t.Parallel()

templateFolder := "../examples/for-learning-and-testing/website"
varFile := "../test-fixtures/examples-var-files/website/vars.yml"

outputFolder, err := os.MkdirTemp("", "boilerplate-manifest-test")
require.NoError(t, err)
defer os.RemoveAll(outputFolder)

// Change to output directory to test manifest file creation
originalDir, err := os.Getwd()
require.NoError(t, err)
defer os.Chdir(originalDir)

err = os.Chdir(outputFolder)
require.NoError(t, err)

app := cli.CreateBoilerplateCli()
args := []string{
"boilerplate",
"--template-url", path.Join(originalDir, templateFolder),
"--output-folder", "output",
"--var-file", path.Join(originalDir, varFile),
"--output-manifest",
"--non-interactive",
}

err = app.Run(args)
require.NoError(t, err)

// Check manifest file was created in output directory
manifestPath := "output/boilerplate-manifest.json"
require.FileExists(t, manifestPath)

// Read and verify manifest content
manifestContent, err := os.ReadFile(manifestPath)
require.NoError(t, err)

manifestStr := string(manifestContent)
// Verify versioned manifest structure
assert.Contains(t, manifestStr, "latest_version")
assert.Contains(t, manifestStr, "versions")
assert.Contains(t, manifestStr, "index.html")
assert.Contains(t, manifestStr, "logo.png")
assert.Contains(t, manifestStr, "template_url")
assert.Contains(t, manifestStr, "boilerplate_version")
}
6 changes: 3 additions & 3 deletions integration-tests/for_production_example_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
package integration_tests

import (
"io/ioutil"
"os"
"path/filepath"
"testing"

Expand All @@ -19,9 +19,9 @@ func TestForProductionTerragruntArchitectureBoilerplateExample(t *testing.T) {

forProductionExamplePath := "../examples/for-production/terragrunt-architecture-catalog"

outputBasePath, err := ioutil.TempDir("", "boilerplate-for-production-output")
outputBasePath, err := os.MkdirTemp("", "boilerplate-for-production-output")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should replace this with t.TempDir if we're going to replace this.

require.NoError(t, err)
//defer os.RemoveAll(outputBasePath)
defer os.RemoveAll(outputBasePath)

templateFolder, err := filepath.Abs(filepath.Join(forProductionExamplePath, "blueprints", "reference-architecture"))
require.NoError(t, err)
Expand Down
5 changes: 2 additions & 3 deletions integration-tests/required_version_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package integration_tests

import (
"io/ioutil"
"os"
"testing"

"github.com/gruntwork-io/boilerplate/cli"
"github.com/gruntwork-io/boilerplate/config"
"github.com/gruntwork-io/go-commons/errors"
"github.com/gruntwork-io/boilerplate/errors"
"github.com/gruntwork-io/go-commons/version"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -58,7 +57,7 @@ func TestRequiredVersionUnderTest(t *testing.T) {
func runRequiredVersionExample(t *testing.T, templateFolder string) error {
app := cli.CreateBoilerplateCli()

outputPath, err := ioutil.TempDir("", "boilerplate-test-output-reqver")
outputPath, err := os.MkdirTemp("", "boilerplate-test-output-reqver")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should replace this with t.TempDir if we're going to replace this.

require.NoError(t, err)
defer os.RemoveAll(outputPath)

Expand Down
75 changes: 75 additions & 0 deletions manifest/manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package manifest

import (
"encoding/json"
"os"
"path/filepath"
"time"

"github.com/gruntwork-io/go-commons/version"
)

// GeneratedFile represents a file that was generated by boilerplate
type GeneratedFile struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to also include the checksums of the files generated?

Path string `json:"path"`
}

// ManifestEntry represents a single version entry in the versioned manifest
type ManifestEntry struct {
Timestamp string `json:"timestamp"`
TemplateURL string `json:"template_url"`
BoilerplateVersion string `json:"boilerplate_version"`
Variables map[string]interface{} `json:"variables"`
OutputDir string `json:"output_dir"`
Files []GeneratedFile `json:"files"`
}

// VersionedManifest represents the complete versioned manifest
type VersionedManifest struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This manifest file should include some information on the version of the manifest schema. That will be important if the schema changes.

LatestVersion string `json:"latest_version"`
Versions map[string]ManifestEntry `json:"versions"`
}



// UpdateVersionedManifest updates the versioned manifest with a new entry
func UpdateVersionedManifest(outputDir, templateURL string, variables map[string]interface{}, files []GeneratedFile) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func UpdateVersionedManifest(outputDir, templateURL string, variables map[string]interface{}, files []GeneratedFile) error {
func UpdateVersionedManifest(outputDir, templateURL string, variables map[string]any, files []GeneratedFile) error {

manifestPath := filepath.Join(outputDir, "boilerplate-manifest.json")

// Read existing manifest or create new one
manifest := &VersionedManifest{
Versions: make(map[string]ManifestEntry),
}

if data, err := os.ReadFile(manifestPath); err == nil {
if err := json.Unmarshal(data, manifest); err != nil {
// If unmarshal fails, start with empty manifest
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, why? The file is corrupted, and we should throw an error, right?

manifest = &VersionedManifest{
Versions: make(map[string]ManifestEntry),
}
}
}

// Create new version entry
timestamp := time.Now().UTC().Format(time.RFC3339)
entry := ManifestEntry{
Timestamp: timestamp,
TemplateURL: templateURL,
BoilerplateVersion: version.GetVersion(),
Variables: variables,
OutputDir: outputDir,
Files: files,
}

// Add to manifest
manifest.Versions[timestamp] = entry
manifest.LatestVersion = timestamp

// Write back to file
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}

return os.WriteFile(manifestPath, data, 0644)
}
77 changes: 77 additions & 0 deletions manifest/manifest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package manifest

import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestUpdateVersionedManifestMultipleVersions(t *testing.T) {
t.Parallel()

// Create temporary directory
tempDir, err := os.MkdirTemp("", "manifest-test")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use t.TempDir

require.NoError(t, err)
defer os.RemoveAll(tempDir)

// First update
err = UpdateVersionedManifest(tempDir, "template1", map[string]interface{}{"var1": "value1"}, []GeneratedFile{{Path: "file1.txt"}})
require.NoError(t, err)

// Wait a moment to ensure different timestamps
time.Sleep(time.Second * 1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this?


// Second update
err = UpdateVersionedManifest(tempDir, "template2", map[string]interface{}{"var2": "value2"}, []GeneratedFile{{Path: "file2.txt"}})
require.NoError(t, err)

// Read manifest
manifestPath := filepath.Join(tempDir, "boilerplate-manifest.json")
data, err := os.ReadFile(manifestPath)
require.NoError(t, err)

var manifest VersionedManifest
err = json.Unmarshal(data, &manifest)
require.NoError(t, err)

// Verify we have 2 versions
assert.Len(t, manifest.Versions, 2)
assert.NotEmpty(t, manifest.LatestVersion)

// Verify latest version points to second update
latestEntry := manifest.Versions[manifest.LatestVersion]
assert.Equal(t, "template2", latestEntry.TemplateURL)
assert.Equal(t, map[string]interface{}{"var2": "value2"}, latestEntry.Variables)
}

func TestUpdateVersionedManifestInvalidExistingFile(t *testing.T) {
t.Parallel()

// Create temporary directory
tempDir, err := os.MkdirTemp("", "manifest-test")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use t.TempDir

require.NoError(t, err)
defer os.RemoveAll(tempDir)

// Create invalid JSON file
manifestPath := filepath.Join(tempDir, "boilerplate-manifest.json")
err = os.WriteFile(manifestPath, []byte("invalid json"), 0644)
require.NoError(t, err)

// Update should still work (creates new manifest)
err = UpdateVersionedManifest(tempDir, "template", map[string]interface{}{}, []GeneratedFile{})
assert.NoError(t, err)

// Verify manifest was recreated
data, err := os.ReadFile(manifestPath)
require.NoError(t, err)

var manifest VersionedManifest
err = json.Unmarshal(data, &manifest)
assert.NoError(t, err)
assert.Len(t, manifest.Versions, 1)
}
3 changes: 3 additions & 0 deletions options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const OptMissingConfigAction = "missing-config-action"
const OptNoHooks = "no-hooks"
const OptNoShell = "no-shell"
const OptDisableDependencyPrompt = "disable-dependency-prompt"
const OptOutputManifest = "output-manifest"

// The command-line options for the boilerplate app
type BoilerplateOptions struct {
Expand All @@ -40,6 +41,7 @@ type BoilerplateOptions struct {
ExecuteAllShellCommands bool
// Track shell command confirmations similar to hooks
ShellCommandAnswers map[string]bool
OutputManifest bool
}

// Validate that the options have reasonable values and return an error if they don't
Expand Down Expand Up @@ -102,6 +104,7 @@ func ParseOptions(cliContext *cli.Context) (*BoilerplateOptions, error) {
DisableDependencyPrompt: cliContext.Bool(OptDisableDependencyPrompt),
ExecuteAllShellCommands: false,
ShellCommandAnswers: make(map[string]bool),
OutputManifest: cliContext.Bool(OptOutputManifest),
}

if err := options.Validate(); err != nil {
Expand Down
Loading