Created
April 10, 2025 20:10
-
-
Save mwarkentin/cf9a0458e0d9114b13104bfacf429cfa to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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