Skip to content

Instantly share code, notes, and snippets.

@mwarkentin
Created April 10, 2025 20:10
Show Gist options
  • Save mwarkentin/cf9a0458e0d9114b13104bfacf429cfa to your computer and use it in GitHub Desktop.
Save mwarkentin/cf9a0458e0d9114b13104bfacf429cfa to your computer and use it in GitHub Desktop.
package main
import (
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/hcl/v2/hclwrite"
"github.com/zclconf/go-cty/cty"
)
func isIgnoredDir(dir string) bool {
switch dir {
case
".terraform",
".terragrunt-cache",
"_scripts",
"_template":
return true
}
return false
}
func slugifyPath(path string) string {
return strings.ReplaceAll(strings.ReplaceAll(path, "/", "__"), "-", "_")
}
func generateStack(path string, dir fs.DirEntry) error {
parent := filepath.Dir(path)
generateStackHcl(path, dir, parent)
return nil
}
func generateStackName(path string) string {
// Split the string based on slashes
parts := strings.Split(path, "/")
// Check if there are at least two parts
if len(parts) >= 2 {
// Take the last two parts and join them with a slash
stackName := strings.Join(parts[len(parts)-2:], "/")
return stackName
} else {
// Not enough parts in the input string
fmt.Println("Not enough parts in the input string")
}
return ""
}
func generateStackSlug(path string) string {
// No slashes allowed
// GCP OIDC has a limit of 127 characters in the subject, and large stack IDs like terragrunt's are too large often
// So we have these hacks:
// * Replace "terragrunt-regions" with "tg" to save 16 bytes
return strings.ReplaceAll(strings.ReplaceAll(path, "/", "-"), "terragrunt-regions", "tg"))
}
func generateStackHcl(stack string, dir fs.DirEntry, parent string) error {
resource_name := slugifyPath(stack)
f := hclwrite.NewEmptyFile()
rootBody := f.Body()
resourceBlock := rootBody.AppendNewBlock("resource", []string{"spacelift_stack", resource_name})
resourceBody := resourceBlock.Body()
githubEnterpriseBlock := resourceBody.AppendNewBlock("github_enterprise", nil)
githubEnterpriseBody := githubEnterpriseBlock.Body()
githubEnterpriseBody.SetAttributeValue("namespace", cty.StringVal("foo"))
resourceBody.AppendNewline()
stackName := generateStackName(stack)
resourceBody.SetAttributeValue("name", cty.StringVal(stackName))
resourceBody.SetAttributeValue("slug", cty.StringVal(generateStackSlug(stack)))
resourceBody.SetAttributeValue("description", cty.StringVal(fmt.Sprintf("Spacelift stack for https://github.com/foo/bar/tree/main/%s", stack)))
resourceBody.SetAttributeTraversal("space_id", hcl.Traversal{
hcl.TraverseRoot{
Name: "spacelift_space",
},
hcl.TraverseAttr{
Name: "prod_ops",
},
hcl.TraverseAttr{
Name: "id",
},
})
// TODO: Provide a way to override version
// idea: support a .spacelift.json file to customize properties for a specific stack
terragruntBlock := resourceBody.AppendNewBlock("terragrunt", nil)
terragruntBody := terragruntBlock.Body()
terragruntBody.SetAttributeValue("terragrunt_version", cty.StringVal("0.48.6"))
terragruntBody.SetAttributeValue("terraform_version", cty.StringVal("1.5.3"))
terragruntBody.SetAttributeValue("use_smart_sanitization", cty.True)
resourceBody.SetAttributeValue("administrative", cty.False)
resourceBody.SetAttributeValue("autodeploy", cty.False)
resourceBody.SetAttributeValue("manage_state", cty.False)
resourceBody.SetAttributeValue("protect_from_deletion", cty.False)
resourceBody.SetAttributeValue("enable_local_preview", cty.True)
resourceBody.AppendNewline()
resourceBody.SetAttributeValue("repository", cty.StringVal("ops"))
resourceBody.SetAttributeValue("branch", cty.StringVal("master"))
resourceBody.SetAttributeValue("project_root", cty.StringVal(stack))
resourceBody.AppendNewline()
regionConfigPath := ""
tenancyConfigPath := ""
if strings.Contains(parent, "multi-tenant") {
regionConfigPath = "terragrunt/regions/multi-tenant/" + dir.Name() + ".hcl"
tenancyConfigPath = "terragrunt/regions/multi-tenant/tenancy.hcl"
} else {
regionConfigPath = "terragrunt/regions/single-tenant/" + dir.Name() + ".hcl"
tenancyConfigPath = "terragrunt/regions/single-tenant/tenancy.hcl"
}
projectGlobTokens := hclwrite.Tokens{
{
Type: hclsyntax.TokenOBrack,
Bytes: []byte(`[`),
},
{
Type: hclsyntax.TokenIdent,
Bytes: []byte(`"` + parent + `/*.hcl"`),
},
{
Type: hclsyntax.TokenCBrack,
Bytes: []byte(`,`),
},
{
Type: hclsyntax.TokenIdent,
Bytes: []byte(`"` + regionConfigPath + `"`),
},
{
Type: hclsyntax.TokenCBrack,
Bytes: []byte(`,`),
},
{
Type: hclsyntax.TokenIdent,
Bytes: []byte(`"` + tenancyConfigPath + `"`),
},
{
Type: hclsyntax.TokenCBrack,
Bytes: []byte(`,`),
},
{
Type: hclsyntax.TokenIdent,
Bytes: []byte(`"terragrunt/regions/regions.hcl"`),
},
{
Type: hclsyntax.TokenCBrack,
Bytes: []byte(`]`),
},
}
resourceBody.SetAttributeRaw(" additional_project_globs", projectGlobTokens)
resourceBody.AppendNewline()
// TODO: Figure out if there's a more "HCL-native" way to do lists
// This generates a block like: `labels = ["prod-ops-gcp", "sampled-push", "feature:enable_git_checkout"]`
labelTokens := hclwrite.Tokens{
{
Type: hclsyntax.TokenOBrack,
Bytes: []byte(`[`),
},
{
Type: hclsyntax.TokenIdent,
Bytes: []byte(`"prod-ops-gcp"`),
},
{
Type: hclsyntax.TokenCBrack,
Bytes: []byte(`,`),
},
{
Type: hclsyntax.TokenIdent,
Bytes: []byte(`"sampled-push"`),
},
{
Type: hclsyntax.TokenCBrack,
Bytes: []byte(`,`),
},
{
Type: hclsyntax.TokenIdent,
Bytes: []byte(`"terraform-modules-ssh"`),
},
{
Type: hclsyntax.TokenCBrack,
Bytes: []byte(`,`),
},
{
Type: hclsyntax.TokenIdent,
Bytes: []byte(`"feature:enable_git_checkout"`),
},
{
Type: hclsyntax.TokenCBrack,
Bytes: []byte(`,`),
},
{
Type: hclsyntax.TokenCBrack,
Bytes: []byte(`]`),
},
}
resourceBody.SetAttributeRaw(" labels", labelTokens)
fmt.Println("#", stack)
fmt.Printf("%s", f.Bytes())
fmt.Println()
return nil
}
func getDirType(s string) string {
files, err := os.ReadDir(s)
if err != nil {
log.Fatal(err)
}
for _, file := range files {
if strings.HasSuffix(file.Name(), "local.hcl") {
return "stack"
}
}
for _, file := range files {
if file.IsDir() {
// Don't count .terraform as a sub-directory for a space
// Often these are left over after a directory is removed from the repository
if file.Name() == ".terraform" {
continue
}
return "folder"
}
}
return "neither"
}
func walk(s string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if isIgnoredDir(d.Name()) {
return filepath.SkipDir
}
if d.IsDir() {
dir_type := getDirType(s)
if dir_type == "folder" {
}
if dir_type == "stack" {
generateStack(s, d)
}
}
return nil
}
func printHeader() {
fmt.Println("###")
fmt.Println("### GENERATED BY spacelift/spacelift-admin/walk-terragrunt.go")
fmt.Println("### DO NOT MODIFY MANUALLY")
fmt.Println("###")
fmt.Println()
}
func main() {
// Change working directory so paths aren't full of `../../`
err := os.Chdir("../..")
if err != nil {
panic(err)
}
printHeader()
filepath.WalkDir("terragrunt", walk)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment