From fb4b0c42715cdc3457be275736d4a933e8548197 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Mon, 6 Apr 2026 19:32:20 -0600 Subject: [PATCH 1/5] feat: add AMI and environment CLI commands with warpgate integration **Added:** - Introduced new `ami` CLI command for AMI image management, including subcommands for building, purging, and listing EC2 Image Builder pipeline resources (`cli/cmd/ami.go`) - Added `env` CLI command for managing deployment environments, supporting creation (with variant config generation) and listing of environments (`cli/cmd/env_cmd.go`) **Changed:** - Updated `go.mod` and `go.sum` to include dependencies for warpgate v3, AWS Image Builder, and related tooling - Upgraded various indirect dependencies to match requirements for AMI and variant config features - Modified `terragrunt/runner.go` to: - Change run-all invocation to `terragrunt run --all` instead of `run-all` - Use `TG_TF_PATH` environment variable for Terraform binary override - Remove redundant or outdated comments for clarity - Improve argument handling and logging for consistency with new commands --- cli/cmd/ami.go | 495 ++++++++++++++++++++++++++++++ cli/cmd/env_cmd.go | 336 ++++++++++++++++++++ cli/go.mod | 32 +- cli/go.sum | 74 +++-- cli/internal/terragrunt/runner.go | 40 +-- 5 files changed, 925 insertions(+), 52 deletions(-) create mode 100644 cli/cmd/ami.go create mode 100644 cli/cmd/env_cmd.go diff --git a/cli/cmd/ami.go b/cli/cmd/ami.go new file mode 100644 index 00000000..b8fe6d08 --- /dev/null +++ b/cli/cmd/ami.go @@ -0,0 +1,495 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/cowdogmoo/warpgate/v3/builder" + "github.com/cowdogmoo/warpgate/v3/builder/ami" + warplog "github.com/cowdogmoo/warpgate/v3/logging" + "github.com/cowdogmoo/warpgate/v3/progress" + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +var amiCmd = &cobra.Command{ + Use: "ami", + Short: "AMI image management", +} + +var amiPurgeCmd = &cobra.Command{ + Use: "purge [template]", + Short: "Remove Image Builder pipeline resources (not AMIs)", + Long: `Delete EC2 Image Builder pipeline resources (components, recipes, pipelines, +infrastructure configs, distribution configs) left behind by warpgate builds. +Does NOT delete the built AMIs themselves. + +Without arguments, removes all warpgate pipeline resources. +With a template name, only removes resources for that specific build.`, + RunE: runAMIPurge, +} + +var amiListResourcesCmd = &cobra.Command{ + Use: "list-resources", + Short: "List Image Builder pipeline resources created by warpgate", + Long: `Lists all EC2 Image Builder pipeline resources tagged with warpgate metadata. +These are the intermediate build resources (components, recipes, pipelines), +not the resulting AMIs.`, + RunE: runAMIListResources, +} + +var amiBuildCmd = &cobra.Command{ + Use: "build [template]", + Short: "Build an AMI from a warpgate template", + Long: `Build an AMI using EC2 Image Builder from a warpgate template. + +Template can be: + - A template name (e.g. "goad-dc-base") from warpgate-templates/ + - A path to a warpgate.yaml file or directory containing one + - Omitted with --all to build all templates in warpgate-templates/ + +With --all, builds run in parallel. Shows a progress bar per build +by default. Use --debug for detailed build output.`, + RunE: runAMIBuild, +} + +func init() { + rootCmd.AddCommand(amiCmd) + amiCmd.AddCommand(amiBuildCmd) + amiCmd.AddCommand(amiPurgeCmd) + amiCmd.AddCommand(amiListResourcesCmd) + + amiBuildCmd.Flags().String("region", "", "AWS region (overrides template)") + amiBuildCmd.Flags().String("instance-type", "", "EC2 instance type (overrides template)") + amiBuildCmd.Flags().String("profile", "", "AWS profile") + amiBuildCmd.Flags().String("instance-profile", "", "IAM instance profile for EC2 Image Builder") + amiBuildCmd.Flags().Bool("reuse-resources", false, "Reuse existing Image Builder resources instead of recreating") + amiBuildCmd.Flags().Bool("all", false, "Build all templates in warpgate-templates/") + + amiPurgeCmd.Flags().String("region", "", "AWS region") + amiPurgeCmd.Flags().String("profile", "", "AWS profile") + amiPurgeCmd.Flags().Bool("yes", false, "Skip confirmation prompt") + + amiListResourcesCmd.Flags().String("region", "", "AWS region") + amiListResourcesCmd.Flags().String("profile", "", "AWS profile") +} + +func resolveTemplates(cfg *config.Config, args []string, buildAll bool) ([]string, error) { + if !buildAll && len(args) == 0 { + return nil, fmt.Errorf("requires a template argument or --all flag") + } + if buildAll && len(args) > 0 { + return nil, fmt.Errorf("--all flag cannot be used with a template argument") + } + if buildAll { + templates, err := discoverWarpgateTemplates(cfg.ProjectRoot) + if err != nil { + return nil, err + } + if len(templates) == 0 { + return nil, fmt.Errorf("no templates found in warpgate-templates/") + } + return templates, nil + } + p, err := resolveTemplatePath(cfg, args[0]) + if err != nil { + return nil, err + } + return []string{p}, nil +} + +func runAMIBuild(cmd *cobra.Command, args []string) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + cfg, err := config.Get() + if err != nil { + return err + } + + buildAll, _ := cmd.Flags().GetBool("all") + templates, err := resolveTemplates(cfg, args, buildAll) + if err != nil { + return err + } + + verbose := viper.GetBool("debug") + + bf := buildFlags{ + region: getFlagString(cmd, "region", cfg.Region, "us-west-1"), + instanceType: getFlagStringOpt(cmd, "instance-type"), + profile: getFlagStringOpt(cmd, "profile"), + instanceProfile: getFlagStringOpt(cmd, "instance-profile"), + reuseResources: getFlagBool(cmd, "reuse-resources"), + } + + // Set up progress display — add all bars before starting the render loop + // to avoid partial renders with mismatched ANSI cursor-up counts. + display := progress.NewDisplay(os.Stderr) + bars := make([]*progress.Bar, len(templates)) + for i, tmplPath := range templates { + bars[i] = display.AddBar(templateName(tmplPath), i+1, len(templates)) + } + if !verbose { + display.Start(500 * time.Millisecond) + } + + results := make([]amiBuildResult, len(templates)) + var wg sync.WaitGroup + + for i, tmplPath := range templates { + wg.Add(1) + + go func(idx int, path string, bar *progress.Bar) { + defer wg.Done() + result, buildErr := buildSingleAMI(ctx, cfg, path, bf, bar, verbose) + if buildErr != nil { + results[idx] = amiBuildResult{template: path, err: buildErr} + } else { + results[idx] = *result + } + }(i, tmplPath, bars[i]) + } + + wg.Wait() + + if !verbose { + display.Stop() + } + + fmt.Fprintln(os.Stderr) + printBuildSummary(results) + + for _, r := range results { + if r.err != nil { + return fmt.Errorf("one or more builds failed") + } + } + return nil +} + +type buildFlags struct { + region string + instanceType string + profile string + instanceProfile string + reuseResources bool +} + +type amiBuildResult struct { + template string + amiID string + region string + duration string + err error +} + +func buildSingleAMI(ctx context.Context, cfg *config.Config, templatePath string, bf buildFlags, bar *progress.Bar, verbose bool) (*amiBuildResult, error) { + tmplName := templateName(templatePath) + buildCfg, err := loadWarpgateTemplate(templatePath, cfg.ProjectRoot) + if err != nil { + bar.Fail() + return nil, fmt.Errorf("load template %s: %w", tmplName, err) + } + + for i := range buildCfg.Targets { + if buildCfg.Targets[i].Type != "ami" { + continue + } + if bf.region != "" { + buildCfg.Targets[i].Region = bf.region + } + if bf.instanceType != "" { + buildCfg.Targets[i].InstanceType = bf.instanceType + } + if bf.instanceProfile != "" { + buildCfg.Targets[i].InstanceProfileName = bf.instanceProfile + } + } + + clientCfg := ami.ClientConfig{ + Region: bf.region, + Profile: bf.profile, + } + + forceRecreate := !bf.reuseResources + + // Set up warpgate logger — quiet mode suppresses info logs that break progress bars + var warpLogger *warplog.CustomLogger + if verbose { + warpLogger = warplog.NewCustomLoggerWithOptions("debug", "color", false, true) + warpLogger.ConsoleWriter = os.Stderr + } else { + warpLogger = warplog.NewCustomLoggerWithOptions("error", "plain", true, false) + warpLogger.ConsoleWriter = os.Stderr + bar.Update("Initializing", 0.01, 0, 0) + } + + ctx = warplog.WithLogger(ctx, warpLogger) + + monitorCfg := ami.MonitorConfig{ + StreamLogs: verbose, + ShowEC2Status: verbose, + StatusCallback: func(update ami.StatusUpdate) { + if !verbose { + bar.Update(update.Stage, update.Progress, update.Elapsed, update.EstimatedRemaining) + } + }, + } + + imgBuilder, err := ami.NewImageBuilderWithAllOptions(ctx, clientCfg, forceRecreate, monitorCfg) + if err != nil { + bar.Fail() + return nil, fmt.Errorf("create AMI builder for %s: %w", tmplName, err) + } + defer imgBuilder.Close() + + result, err := imgBuilder.Build(ctx, *buildCfg) + if err != nil { + bar.Fail() + return nil, fmt.Errorf("%s failed: %w", tmplName, err) + } + + bar.CompleteWithMessage(result.AMIID) + + return &amiBuildResult{ + template: templatePath, + amiID: result.AMIID, + region: result.Region, + duration: result.Duration, + }, nil +} + +func templateName(path string) string { + return filepath.Base(filepath.Dir(path)) +} + +func getFlagString(cmd *cobra.Command, name, fallback1, fallback2 string) string { + if v, _ := cmd.Flags().GetString(name); v != "" { + return v + } + if fallback1 != "" { + return fallback1 + } + return fallback2 +} + +func getFlagStringOpt(cmd *cobra.Command, name string) string { + v, _ := cmd.Flags().GetString(name) + return v +} + +func getFlagBool(cmd *cobra.Command, name string) bool { + v, _ := cmd.Flags().GetBool(name) + return v +} + +func newAWSClients(cmd *cobra.Command, cfg *config.Config) (*ami.AWSClients, error) { + region := getFlagString(cmd, "region", cfg.Region, "us-west-1") + profile := getFlagStringOpt(cmd, "profile") + return ami.NewAWSClients(context.Background(), ami.ClientConfig{ + Region: region, + Profile: profile, + }) +} + +func runAMIListResources(cmd *cobra.Command, args []string) error { + cfg, err := config.Get() + if err != nil { + return err + } + + clients, err := newAWSClients(cmd, cfg) + if err != nil { + return fmt.Errorf("create AWS clients: %w", err) + } + + cleaner := ami.NewResourceCleaner(clients) + ctx := context.Background() + + resources, err := cleaner.ListWarpgateResources(ctx) + if err != nil { + return fmt.Errorf("list resources: %w", err) + } + + if len(resources) == 0 { + color.Green("No warpgate pipeline resources found.") + return nil + } + + fmt.Printf("\nFound %d pipeline resources:\n\n", len(resources)) + fmt.Printf(" %-30s %-25s %-10s %s\n", "TYPE", "NAME", "VERSION", "BUILD") + fmt.Printf(" %-30s %-25s %-10s %s\n", "----", "----", "-------", "-----") + for _, r := range resources { + version := r.Version + if version == "" { + version = "-" + } + buildName := r.BuildName + if buildName == "" { + buildName = "-" + } + fmt.Printf(" %-30s %-25s %-10s %s\n", r.Type, r.Name, version, buildName) + } + fmt.Println() + return nil +} + +func runAMIPurge(cmd *cobra.Command, args []string) error { + cfg, err := config.Get() + if err != nil { + return err + } + + clients, err := newAWSClients(cmd, cfg) + if err != nil { + return fmt.Errorf("create AWS clients: %w", err) + } + + cleaner := ami.NewResourceCleaner(clients) + ctx := context.Background() + + var resources []ami.ResourceInfo + if len(args) > 0 { + resources, err = cleaner.ListResourcesForBuild(ctx, args[0]) + } else { + resources, err = cleaner.ListWarpgateResources(ctx) + } + if err != nil { + return fmt.Errorf("list resources: %w", err) + } + + if len(resources) == 0 { + color.Green("No warpgate pipeline resources found.") + return nil + } + + fmt.Printf("\nPipeline resources to delete (%d):\n\n", len(resources)) + for _, r := range resources { + fmt.Printf(" %-30s %s\n", r.Type, r.Name) + } + fmt.Println() + color.Yellow("NOTE: This deletes pipeline resources only, NOT the built AMIs.") + + skipConfirm, _ := cmd.Flags().GetBool("yes") + if !skipConfirm { + fmt.Print("\nProceed? [y/N] ") + var answer string + if _, err := fmt.Scanln(&answer); err != nil { + return fmt.Errorf("read input: %w", err) + } + if strings.ToLower(strings.TrimSpace(answer)) != "y" { + fmt.Println("Aborted.") + return nil + } + } + + fmt.Println() + if err := cleaner.DeleteResources(ctx, resources); err != nil { + return fmt.Errorf("purge failed: %w", err) + } + + color.Green("Purge complete.") + return nil +} + +func resolveTemplatePath(cfg *config.Config, arg string) (string, error) { + if info, err := os.Stat(arg); err == nil { + if info.IsDir() { + p := filepath.Join(arg, "warpgate.yaml") + if _, err := os.Stat(p); err == nil { + return p, nil + } + return "", fmt.Errorf("no warpgate.yaml in directory: %s", arg) + } + return arg, nil + } + + p := filepath.Join(cfg.ProjectRoot, "warpgate-templates", arg, "warpgate.yaml") + if _, err := os.Stat(p); err == nil { + return p, nil + } + + return "", fmt.Errorf("template not found: %s (tried as path and in warpgate-templates/)", arg) +} + +func discoverWarpgateTemplates(projectRoot string) ([]string, error) { + dir := filepath.Join(projectRoot, "warpgate-templates") + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read warpgate-templates: %w", err) + } + + var templates []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + p := filepath.Join(dir, entry.Name(), "warpgate.yaml") + if _, err := os.Stat(p); err == nil { + templates = append(templates, p) + } + } + return templates, nil +} + +type templateWithVars struct { + Variables map[string]string `yaml:"variables"` +} + +func loadWarpgateTemplate(path, projectRoot string) (*builder.Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var tmpl templateWithVars + _ = yaml.Unmarshal(data, &tmpl) + + content := string(data) + + for k, v := range tmpl.Variables { + content = strings.ReplaceAll(content, "${"+k+"}", v) + } + + if _, ok := os.LookupEnv("PROVISION_REPO_PATH"); !ok && projectRoot != "" { + os.Setenv("PROVISION_REPO_PATH", projectRoot) + } + + varPattern := regexp.MustCompile(`\$\{([^}]+)\}`) + content = varPattern.ReplaceAllStringFunc(content, func(match string) string { + varName := match[2 : len(match)-1] + if val, ok := os.LookupEnv(varName); ok { + return val + } + return match + }) + + var cfg builder.Config + if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { + return nil, fmt.Errorf("parse template: %w", err) + } + + return &cfg, nil +} + +func printBuildSummary(results []amiBuildResult) { + for _, r := range results { + name := filepath.Base(filepath.Dir(r.template)) + if r.err != nil { + color.New(color.FgRed).Fprintf(os.Stderr, " x %-25s FAILED: %s\n", name, r.err) + } else { + color.New(color.FgGreen).Fprintf(os.Stderr, " + %-25s %s (%s)\n", name, r.amiID, r.duration) + } + } +} diff --git a/cli/cmd/env_cmd.go b/cli/cmd/env_cmd.go new file mode 100644 index 00000000..1fdff768 --- /dev/null +++ b/cli/cmd/env_cmd.go @@ -0,0 +1,336 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/variant" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var envCmd = &cobra.Command{ + Use: "env", + Short: "Manage deployment environments", +} + +var envCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a new deployment environment", + Long: `Scaffold a new deployment environment with all required infrastructure +and configuration files. + +Creates: + - infra/goad-deployment/{env}/env.hcl + - infra/goad-deployment/{env}/{region}/region.hcl + - infra/goad-deployment/{env}/{region}/network/terragrunt.hcl + - infra/goad-deployment/{env}/{region}/goad/{host}/terragrunt.hcl + templates + - ad/GOAD/data/{env}-config.json + +Use --variant to generate randomized entity names for the environment config. +Without --variant, the base config (dev-config.json) is copied as-is.`, + Args: cobra.ExactArgs(1), + RunE: runEnvCreate, +} + +var envListCmd = &cobra.Command{ + Use: "list", + Short: "List available environments", + RunE: runEnvList, +} + +func init() { + rootCmd.AddCommand(envCmd) + envCmd.AddCommand(envCreateCmd) + envCmd.AddCommand(envListCmd) + + envCreateCmd.Flags().String("region", "us-east-1", "AWS region for the environment") + envCreateCmd.Flags().String("vpc-cidr", "", "VPC CIDR block (default: auto-assigned)") + envCreateCmd.Flags().String("reference", "staging", "Reference environment to copy infrastructure from") + envCreateCmd.Flags().Bool("variant", false, "Generate randomized variant config") + envCreateCmd.Flags().Bool("force", false, "Overwrite existing environment") +} + +func vpcCIDRForEnv(envName string) string { + knownCIDRs := map[string]string{ + "dev": "10.0.0.0/16", + "staging": "10.1.0.0/16", + "prod": "10.2.0.0/16", + "test": "10.8.0.0/16", + } + if cidr, ok := knownCIDRs[envName]; ok { + return cidr + } + // Generate a deterministic second octet from env name (range 10-250) + var hash byte + for _, c := range envName { + hash = hash*31 + byte(c) + } + octet := int(hash)%240 + 10 + return fmt.Sprintf("10.%d.0.0/16", octet) +} + +func runEnvCreate(cmd *cobra.Command, args []string) error { + envName := strings.TrimSpace(args[0]) + if envName == "" { + return fmt.Errorf("environment name cannot be empty") + } + + cfg, err := config.Get() + if err != nil { + return err + } + + region, _ := cmd.Flags().GetString("region") + vpcCIDR, _ := cmd.Flags().GetString("vpc-cidr") + reference, _ := cmd.Flags().GetString("reference") + useVariant, _ := cmd.Flags().GetBool("variant") + force, _ := cmd.Flags().GetBool("force") + + if vpcCIDR == "" { + vpcCIDR = vpcCIDRForEnv(envName) + } + + deployment := cfg.Infra.Deployment + infraBase := filepath.Join(cfg.ProjectRoot, "infra", deployment) + envDir := filepath.Join(infraBase, envName) + regionDir := filepath.Join(envDir, region) + + if _, err := os.Stat(envDir); err == nil && !force { + return fmt.Errorf("environment %q already exists at %s\nUse --force to overwrite", envName, envDir) + } + + refRegionDir := findReferenceRegion(infraBase, reference) + if refRegionDir == "" { + return fmt.Errorf("reference environment %q not found in %s", reference, infraBase) + } + + color.Cyan("Creating environment: %s", envName) + fmt.Printf(" %-14s %s\n", "Region:", region) + fmt.Printf(" %-14s %s\n", "VPC CIDR:", vpcCIDR) + fmt.Printf(" %-14s %s\n", "Reference:", reference) + fmt.Printf(" %-14s %v\n", "Variant:", useVariant) + fmt.Println() + + if err := createEnvHCL(envDir, envName, vpcCIDR); err != nil { + return fmt.Errorf("create env.hcl: %w", err) + } + color.Green(" Created env.hcl") + + if err := createRegionHCL(regionDir, region); err != nil { + return fmt.Errorf("create region.hcl: %w", err) + } + color.Green(" Created %s/region.hcl", region) + + if err := copyInfrastructure(refRegionDir, regionDir); err != nil { + return fmt.Errorf("copy infrastructure: %w", err) + } + color.Green(" Copied infrastructure from %s", reference) + + configPath := filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data", envName+"-config.json") + if useVariant { + if err := generateVariantConfig(cfg.ProjectRoot, envName); err != nil { + return fmt.Errorf("generate variant config: %w", err) + } + color.Green(" Generated variant config: %s-config.json", envName) + } else { + if err := copyBaseConfig(cfg.ProjectRoot, envName); err != nil { + return fmt.Errorf("copy base config: %w", err) + } + color.Green(" Created config: %s-config.json", envName) + } + + fmt.Println() + color.Green("Environment %q created successfully!", envName) + fmt.Println() + fmt.Println("Next steps:") + fmt.Printf(" 1. Review: %s\n", envDir) + fmt.Printf(" 2. Review: %s\n", configPath) + fmt.Printf(" 3. Initialize: dreadgoad --env %s --region %s infra init\n", envName, region) + fmt.Printf(" 4. Plan: dreadgoad --env %s --region %s infra plan\n", envName, region) + fmt.Printf(" 5. Apply: dreadgoad --env %s --region %s infra apply --auto-approve\n", envName, region) + + return nil +} + +func runEnvList(cmd *cobra.Command, args []string) error { + cfg, err := config.Get() + if err != nil { + return err + } + + deployment := cfg.Infra.Deployment + infraBase := filepath.Join(cfg.ProjectRoot, "infra", deployment) + + entries, err := os.ReadDir(infraBase) + if err != nil { + return fmt.Errorf("read deployment directory: %w", err) + } + + color.Cyan("Available environments:") + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + envHCL := filepath.Join(infraBase, name, "env.hcl") + if _, err := os.Stat(envHCL); err != nil { + continue + } + + var regions []string + regionEntries, _ := os.ReadDir(filepath.Join(infraBase, name)) + for _, re := range regionEntries { + if !re.IsDir() { + continue + } + regionHCL := filepath.Join(infraBase, name, re.Name(), "region.hcl") + if _, err := os.Stat(regionHCL); err == nil { + regions = append(regions, re.Name()) + } + } + + configFile := filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data", name+"-config.json") + hasConfig := false + if _, err := os.Stat(configFile); err == nil { + hasConfig = true + } + + marker := " " + if name == cfg.Env { + marker = "*" + } + + configStatus := color.RedString("no config") + if hasConfig { + configStatus = color.GreenString("config OK") + } + + fmt.Printf(" %s %-12s regions: %-20s %s\n", + marker, name, strings.Join(regions, ", "), configStatus) + } + + return nil +} + +func findReferenceRegion(infraBase, reference string) string { + refDir := filepath.Join(infraBase, reference) + entries, err := os.ReadDir(refDir) + if err != nil { + return "" + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + regionHCL := filepath.Join(refDir, entry.Name(), "region.hcl") + if _, err := os.Stat(regionHCL); err == nil { + return filepath.Join(refDir, entry.Name()) + } + } + return "" +} + +func createEnvHCL(envDir, envName, vpcCIDR string) error { + if err := os.MkdirAll(envDir, 0o755); err != nil { + return err + } + content := fmt.Sprintf(`# Set common variables for the environment. +# This is automatically pulled in by the root terragrunt.hcl configuration. +locals { + deployment_name = "goad" # Change to your deployment name + aws_account_id = get_aws_account_id() + env = %q + vpc_cidr = %q +} +`, envName, vpcCIDR) + return os.WriteFile(filepath.Join(envDir, "env.hcl"), []byte(content), 0o644) +} + +func createRegionHCL(regionDir, region string) error { + if err := os.MkdirAll(regionDir, 0o755); err != nil { + return err + } + content := fmt.Sprintf(`locals { + aws_region = %q +} +`, region) + return os.WriteFile(filepath.Join(regionDir, "region.hcl"), []byte(content), 0o644) +} + +func copyInfrastructure(srcRegionDir, dstRegionDir string) error { + return filepath.WalkDir(srcRegionDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(srcRegionDir, path) + if err != nil { + return err + } + + if strings.Contains(relPath, ".terragrunt-cache") || + strings.Contains(relPath, ".terraform") || + strings.HasSuffix(relPath, ".terraform.lock.hcl") || + relPath == "region.hcl" { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + dstPath := filepath.Join(dstRegionDir, relPath) + + if d.IsDir() { + return os.MkdirAll(dstPath, 0o755) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(dstPath, data, 0o644) + }) +} + +func copyBaseConfig(projectRoot, envName string) error { + srcPath := filepath.Join(projectRoot, "ad", "GOAD", "data", "dev-config.json") + dstPath := filepath.Join(projectRoot, "ad", "GOAD", "data", envName+"-config.json") + + data, err := os.ReadFile(srcPath) + if err != nil { + return fmt.Errorf("read base config: %w", err) + } + + var parsed interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + return fmt.Errorf("invalid base config JSON: %w", err) + } + + return os.WriteFile(dstPath, data, 0o644) +} + +func generateVariantConfig(projectRoot, envName string) error { + source := filepath.Join(projectRoot, "ad", "GOAD") + target := filepath.Join(projectRoot, "ad", "GOAD-"+envName) + + gen := variant.NewGenerator(source, target, envName) + if err := gen.Run(); err != nil { + return fmt.Errorf("variant generation: %w", err) + } + + srcConfig := filepath.Join(target, "data", "config.json") + dstConfig := filepath.Join(projectRoot, "ad", "GOAD", "data", envName+"-config.json") + + data, err := os.ReadFile(srcConfig) + if err != nil { + return fmt.Errorf("read generated variant config: %w", err) + } + + return os.WriteFile(dstConfig, data, 0o644) +} diff --git a/cli/go.mod b/cli/go.mod index dc71b91c..808f877c 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -8,36 +8,56 @@ require ( github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2 github.com/aws/aws-sdk-go-v2/service/ssm v1.68.4 github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 + github.com/cowdogmoo/warpgate/v3 v3.2.1-0.20260407013053-5aed3c62eb90 github.com/fatih/color v1.19.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 go.yaml.in/yaml/v3 v3.0.4 + gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0 // indirect + github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 // indirect + github.com/aws/aws-sdk-go-v2/service/imagebuilder v1.51.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect - github.com/aws/smithy-go v1.24.2 // indirect + github.com/aws/smithy-go v1.24.3 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect + github.com/docker/cli v29.3.1+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/google/go-containerregistry v0.21.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/vbatts/tar-split v0.12.2 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index 935c135f..db103e91 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,5 +1,7 @@ github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= @@ -12,8 +14,14 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgq github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0 h1:+/lmB/+i2oqkzbmlQxsW0kr/+wmJgmyiEF9VDJicX34= +github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0/go.mod h1:PobeppEnIjw4pcgjFryNDZCTH7AiqZw0yb5r98Gvf9c= github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2 h1:Ytu50ChAxCiDsOlBcBq8jbczXy6+QLb07T65DBJASRs= github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2/go.mod h1:R+2BNtUfTfhPY0RH18oL02q116bakeBWjanrbnVBqkM= +github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 h1:n9YLiWtX3+6pTLZWvRJmtq5JIB9NA/KFelyCg5fOlTU= +github.com/aws/aws-sdk-go-v2/service/iam v1.53.7/go.mod h1:sP46Vo6MeJcM4s0ZXcG2PFmfiSyixhIuC/74W52yKuk= +github.com/aws/aws-sdk-go-v2/service/imagebuilder v1.51.4 h1:61NE9tKMXuMSWOENligQ6jchSYfh8wYjBxaWDZaJn+o= +github.com/aws/aws-sdk-go-v2/service/imagebuilder v1.51.4/go.mod h1:MmJet6SEjFAY+iKome2cLNYrK4yNbdclhBDkehrpNkE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= @@ -28,23 +36,37 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6f github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg= +github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= +github.com/cowdogmoo/warpgate/v3 v3.2.1-0.20260407013053-5aed3c62eb90 h1:Ei9lwtqln9ftktbxx0b2Bj66DHCtmDaew320TpJIyoE= +github.com/cowdogmoo/warpgate/v3 v3.2.1-0.20260407013053-5aed3c62eb90/go.mod h1:a6SUrGZAU4RWqVttY0ZZkD8E8GFwUco4JlFv/HM9Vl0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v29.3.1+incompatible h1:M04FDj2TRehDacrosh7Vlkgc7AuQoWloQkf1PA5hmoI= +github.com/docker/cli v29.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.3 h1:Xr+yt3VvwOOn/5nJzd7UoOhwPGiPkYW0zWDLLUXqAi4= +github.com/google/go-containerregistry v0.21.3/go.mod h1:D5ZrJF1e6dMzvInpBPuMCX0FxURz7GLq2rV3Us9aPkc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -53,17 +75,23 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -75,19 +103,29 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= +github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/cli/internal/terragrunt/runner.go b/cli/internal/terragrunt/runner.go index bee305c0..fb91bd8a 100644 --- a/cli/internal/terragrunt/runner.go +++ b/cli/internal/terragrunt/runner.go @@ -12,36 +12,24 @@ import ( "strings" ) -// Options configures a terragrunt execution. type Options struct { - // Action is the terraform action: init, plan, apply, destroy, output. - Action string - // WorkDir is the directory to run terragrunt in. - WorkDir string - // TerragruntBinary is the path to the terragrunt binary. + Action string + WorkDir string TerragruntBinary string - // TerraformBinary is the path to the terraform/tofu binary. - TerraformBinary string - // AutoApprove skips confirmation prompts (apply/destroy). - AutoApprove bool - // NonInteractive disables interactive prompts. - NonInteractive bool - // ExcludeDirs is a comma-separated list of dirs to exclude from run-all. - ExcludeDirs string - // LogFile is an optional path to write output to. - LogFile string - // Debug enables verbose output. - Debug bool + TerraformBinary string + AutoApprove bool + NonInteractive bool + ExcludeDirs string + LogFile string + Debug bool } -// Result holds the outcome of a terragrunt execution. type Result struct { Module string Success bool Error error } -// Run executes a single terragrunt command in the given working directory. func Run(ctx context.Context, opts Options) error { args := buildArgs(opts) @@ -70,9 +58,8 @@ func Run(ctx context.Context, opts Options) error { return nil } -// RunAll executes `terragrunt run-all ` across all modules in the working directory. func RunAll(ctx context.Context, opts Options) error { - args := []string{"run-all", opts.Action} + args := []string{"run", "--all", opts.Action} if opts.AutoApprove && (opts.Action == "apply" || opts.Action == "destroy") { args = append(args, "-auto-approve") } @@ -80,7 +67,7 @@ func RunAll(ctx context.Context, opts Options) error { args = append(args, "--non-interactive") } - slog.Info("running terragrunt run-all", + slog.Info("running terragrunt run --all", "action", opts.Action, "dir", opts.WorkDir, ) @@ -103,13 +90,11 @@ func RunAll(ctx context.Context, opts Options) error { cmd.Stderr = writer if err := cmd.Run(); err != nil { - return fmt.Errorf("terragrunt run-all %s failed: %w", opts.Action, err) + return fmt.Errorf("terragrunt run --all %s failed: %w", opts.Action, err) } return nil } -// RunIndividual iterates subdirectories of modulePath and runs terragrunt -// in each one individually. Returns results for each subdirectory. func RunIndividual(ctx context.Context, opts Options, modulePath string, exclude []string) ([]Result, error) { entries, err := os.ReadDir(modulePath) if err != nil { @@ -172,7 +157,6 @@ func RunIndividual(ctx context.Context, opts Options, modulePath string, exclude return results, nil } -// Output runs `terragrunt output -json` and returns the raw JSON bytes. func Output(ctx context.Context, opts Options) ([]byte, error) { args := []string{"output", "-json"} @@ -201,7 +185,7 @@ func buildArgs(opts Options) []string { func buildEnv(opts Options) []string { env := os.Environ() if opts.TerraformBinary != "" { - env = append(env, "TERRAGRUNT_TFPATH="+opts.TerraformBinary) + env = append(env, "TG_TF_PATH="+opts.TerraformBinary) } return env } From b88fc946ce5c13a03ad5aebc52206939a9e20a33 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Mon, 6 Apr 2026 19:49:06 -0600 Subject: [PATCH 2/5] refactor: update naming and AMI filter logic for dreadgoad and aws instance modules **Changed:** - Standardized all user-facing references from "GOAD" to "DreadGOAD" in CLI commands, help texts, config defaults, and code comments to reflect new branding - Updated Exchange extension description to reference "DreadGOAD lab" for consistency in configuration defaults - Adjusted logic for AMI lookup in the terraform-aws-instance-factory module: now considers "tag:Name" in addition to "name" and "image-id" when checking for custom filters, improving flexibility in AMI selection for Linux, Windows, and macOS instances - Modified Terragrunt configurations for staging and test environments to use "tag:Name" with specific values (e.g., "goad-dc-base") in `additional_windows_ami_filters` to align with updated AMI filter logic - Removed references to the old `.ansible-lint` config file and ensured Ansible linting excludes the `infra/` directory through the central linter YAML configuration **Removed:** - Deleted the legacy `.ansible-lint` configuration file, consolidating linting paths and exclusions in `.hooks/linters/ansible-lint.yaml` --- .ansible-lint | 6 ------ .hooks/linters/ansible-lint.yaml | 1 + cli/cmd/infra_cmd.go | 6 +++--- cli/cmd/lab.go | 2 +- cli/cmd/lab_list.go | 2 +- cli/internal/config/defaults.go | 2 +- cli/internal/lab/discovery.go | 2 +- .../staging/us-west-1/goad/dc01/terragrunt.hcl | 4 ++-- .../staging/us-west-1/goad/dc02/terragrunt.hcl | 4 ++-- .../staging/us-west-1/goad/dc03/terragrunt.hcl | 4 ++-- .../staging/us-west-1/goad/srv02/terragrunt.hcl | 4 ++-- .../staging/us-west-1/goad/srv03/terragrunt.hcl | 4 ++-- .../test/us-east-2/goad/dc01/terragrunt.hcl | 4 ++-- .../test/us-east-2/goad/dc02/terragrunt.hcl | 4 ++-- .../test/us-east-2/goad/dc03/terragrunt.hcl | 4 ++-- .../test/us-east-2/goad/srv02/terragrunt.hcl | 4 ++-- .../test/us-east-2/goad/srv03/terragrunt.hcl | 4 ++-- modules/terraform-aws-instance-factory/data.tf | 10 +++++----- 18 files changed, 33 insertions(+), 38 deletions(-) delete mode 100644 .ansible-lint diff --git a/.ansible-lint b/.ansible-lint deleted file mode 100644 index 1fc8ac67..00000000 --- a/.ansible-lint +++ /dev/null @@ -1,6 +0,0 @@ ---- -exclude_paths: - - .terragrunt-cache - - infra/ - - packer/ - - cli/ diff --git a/.hooks/linters/ansible-lint.yaml b/.hooks/linters/ansible-lint.yaml index f4a2ab0e..39671957 100644 --- a/.hooks/linters/ansible-lint.yaml +++ b/.hooks/linters/ansible-lint.yaml @@ -5,6 +5,7 @@ exclude_paths: - .claude/ - .github/ + - infra/ # Files with unskippable load-failure (unicode/runtime) or syntax-check issues - ad/GOAD-Light/files/dc01/templates/ - ad/GOAD-Mini/files/dc01/templates/ diff --git a/cli/cmd/infra_cmd.go b/cli/cmd/infra_cmd.go index 2901d22c..0d73225b 100644 --- a/cli/cmd/infra_cmd.go +++ b/cli/cmd/infra_cmd.go @@ -16,11 +16,11 @@ import ( var infraCmd = &cobra.Command{ Use: "infra", - Short: "Manage GOAD infrastructure via Terragrunt", - Long: `Manage the GOAD lab infrastructure lifecycle using Terragrunt. + Short: "Manage DreadGOAD infrastructure via Terragrunt", + Long: `Manage the DreadGOAD lab infrastructure lifecycle using Terragrunt. Operates on the infra/ directory which contains Terragrunt configurations -for deploying the GOAD lab (VPC, EC2 instances, security groups, etc.). +for deploying the DreadGOAD lab (VPC, EC2 instances, security groups, etc.). By default, commands operate on all modules (run-all). Use --module to target a specific module (e.g. network, goad/dc01).`, diff --git a/cli/cmd/lab.go b/cli/cmd/lab.go index 4252f938..f7b64a60 100644 --- a/cli/cmd/lab.go +++ b/cli/cmd/lab.go @@ -12,7 +12,7 @@ import ( var labCmd = &cobra.Command{ Use: "lab", - Short: "Manage GOAD lab lifecycle", + Short: "Manage DreadGOAD lab lifecycle", } var labStatusCmd = &cobra.Command{ diff --git a/cli/cmd/lab_list.go b/cli/cmd/lab_list.go index 1342aa90..30b928be 100644 --- a/cli/cmd/lab_list.go +++ b/cli/cmd/lab_list.go @@ -12,7 +12,7 @@ import ( var labListCmd = &cobra.Command{ Use: "list", - Short: "List available GOAD labs and their providers", + Short: "List available DreadGOAD labs and their providers", RunE: runLabList, } diff --git a/cli/internal/config/defaults.go b/cli/internal/config/defaults.go index 587a4bda..a0cf34d5 100644 --- a/cli/internal/config/defaults.go +++ b/cli/internal/config/defaults.go @@ -46,7 +46,7 @@ func setDefaults() { viper.SetDefault("extensions.elk.impact", "add a linux machine and add a logbeat agent on all windows machines") viper.SetDefault("extensions.elk.playbook", "ext-elk.yml") - viper.SetDefault("extensions.exchange.description", "Add an Exchange server to the GOAD lab") + viper.SetDefault("extensions.exchange.description", "Add an Exchange server to the DreadGOAD lab") viper.SetDefault("extensions.exchange.machines", []string{"srv01"}) viper.SetDefault("extensions.exchange.compatibility", []string{"GOAD", "GOAD-Light", "GOAD-Mini"}) viper.SetDefault("extensions.exchange.impact", "modifies AD schema and adds a server (heavy)") diff --git a/cli/internal/lab/discovery.go b/cli/internal/lab/discovery.go index f0273f10..86ba7198 100644 --- a/cli/internal/lab/discovery.go +++ b/cli/internal/lab/discovery.go @@ -11,7 +11,7 @@ import ( "go.yaml.in/yaml/v3" ) -// Lab represents a discovered GOAD lab definition. +// Lab represents a discovered DreadGOAD lab definition. type Lab struct { Name string `json:"name"` Path string `json:"path"` diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc01/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/goad/dc01/terragrunt.hcl index 0441a2d8..bc33657d 100644 --- a/infra/goad-deployment/staging/us-west-1/goad/dc01/terragrunt.hcl +++ b/infra/goad-deployment/staging/us-west-1/goad/dc01/terragrunt.hcl @@ -70,8 +70,8 @@ inputs = { additional_windows_ami_filters = [ { - name = "name" - values = ["goad-dc-base-*"] # warpgate-templates/goad-dc-base + name = "tag:Name" + values = ["goad-dc-base"] } ] diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc02/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/goad/dc02/terragrunt.hcl index 5c073bbe..ddb7da88 100644 --- a/infra/goad-deployment/staging/us-west-1/goad/dc02/terragrunt.hcl +++ b/infra/goad-deployment/staging/us-west-1/goad/dc02/terragrunt.hcl @@ -69,8 +69,8 @@ inputs = { additional_windows_ami_filters = [ { - name = "name" - values = ["goad-dc-base-*"] # warpgate-templates/goad-dc-base + name = "tag:Name" + values = ["goad-dc-base"] } ] diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc03/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/goad/dc03/terragrunt.hcl index 668f058a..d79f443e 100644 --- a/infra/goad-deployment/staging/us-west-1/goad/dc03/terragrunt.hcl +++ b/infra/goad-deployment/staging/us-west-1/goad/dc03/terragrunt.hcl @@ -69,8 +69,8 @@ inputs = { additional_windows_ami_filters = [ { - name = "name" - values = ["goad-dc-base-2016-*"] # warpgate-templates/goad-dc-base-2016 + name = "tag:Name" + values = ["goad-dc-base-2016"] } ] diff --git a/infra/goad-deployment/staging/us-west-1/goad/srv02/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/goad/srv02/terragrunt.hcl index 11385662..2909b3f7 100644 --- a/infra/goad-deployment/staging/us-west-1/goad/srv02/terragrunt.hcl +++ b/infra/goad-deployment/staging/us-west-1/goad/srv02/terragrunt.hcl @@ -69,8 +69,8 @@ inputs = { additional_windows_ami_filters = [ { - name = "name" - values = ["goad-mssql-base-*"] # warpgate-templates/goad-mssql-base + name = "tag:Name" + values = ["goad-mssql-base"] } ] diff --git a/infra/goad-deployment/staging/us-west-1/goad/srv03/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/goad/srv03/terragrunt.hcl index 18a6421a..a29025e1 100644 --- a/infra/goad-deployment/staging/us-west-1/goad/srv03/terragrunt.hcl +++ b/infra/goad-deployment/staging/us-west-1/goad/srv03/terragrunt.hcl @@ -69,8 +69,8 @@ inputs = { additional_windows_ami_filters = [ { - name = "name" - values = ["goad-member-base-2016-*"] # warpgate-templates/goad-member-base-2016 + name = "tag:Name" + values = ["goad-member-base-2016"] } ] diff --git a/infra/goad-deployment/test/us-east-2/goad/dc01/terragrunt.hcl b/infra/goad-deployment/test/us-east-2/goad/dc01/terragrunt.hcl index 0441a2d8..bc33657d 100644 --- a/infra/goad-deployment/test/us-east-2/goad/dc01/terragrunt.hcl +++ b/infra/goad-deployment/test/us-east-2/goad/dc01/terragrunt.hcl @@ -70,8 +70,8 @@ inputs = { additional_windows_ami_filters = [ { - name = "name" - values = ["goad-dc-base-*"] # warpgate-templates/goad-dc-base + name = "tag:Name" + values = ["goad-dc-base"] } ] diff --git a/infra/goad-deployment/test/us-east-2/goad/dc02/terragrunt.hcl b/infra/goad-deployment/test/us-east-2/goad/dc02/terragrunt.hcl index 5c073bbe..ddb7da88 100644 --- a/infra/goad-deployment/test/us-east-2/goad/dc02/terragrunt.hcl +++ b/infra/goad-deployment/test/us-east-2/goad/dc02/terragrunt.hcl @@ -69,8 +69,8 @@ inputs = { additional_windows_ami_filters = [ { - name = "name" - values = ["goad-dc-base-*"] # warpgate-templates/goad-dc-base + name = "tag:Name" + values = ["goad-dc-base"] } ] diff --git a/infra/goad-deployment/test/us-east-2/goad/dc03/terragrunt.hcl b/infra/goad-deployment/test/us-east-2/goad/dc03/terragrunt.hcl index 668f058a..d79f443e 100644 --- a/infra/goad-deployment/test/us-east-2/goad/dc03/terragrunt.hcl +++ b/infra/goad-deployment/test/us-east-2/goad/dc03/terragrunt.hcl @@ -69,8 +69,8 @@ inputs = { additional_windows_ami_filters = [ { - name = "name" - values = ["goad-dc-base-2016-*"] # warpgate-templates/goad-dc-base-2016 + name = "tag:Name" + values = ["goad-dc-base-2016"] } ] diff --git a/infra/goad-deployment/test/us-east-2/goad/srv02/terragrunt.hcl b/infra/goad-deployment/test/us-east-2/goad/srv02/terragrunt.hcl index 11385662..2909b3f7 100644 --- a/infra/goad-deployment/test/us-east-2/goad/srv02/terragrunt.hcl +++ b/infra/goad-deployment/test/us-east-2/goad/srv02/terragrunt.hcl @@ -69,8 +69,8 @@ inputs = { additional_windows_ami_filters = [ { - name = "name" - values = ["goad-mssql-base-*"] # warpgate-templates/goad-mssql-base + name = "tag:Name" + values = ["goad-mssql-base"] } ] diff --git a/infra/goad-deployment/test/us-east-2/goad/srv03/terragrunt.hcl b/infra/goad-deployment/test/us-east-2/goad/srv03/terragrunt.hcl index 18a6421a..a29025e1 100644 --- a/infra/goad-deployment/test/us-east-2/goad/srv03/terragrunt.hcl +++ b/infra/goad-deployment/test/us-east-2/goad/srv03/terragrunt.hcl @@ -69,8 +69,8 @@ inputs = { additional_windows_ami_filters = [ { - name = "name" - values = ["goad-member-base-2016-*"] # warpgate-templates/goad-member-base-2016 + name = "tag:Name" + values = ["goad-member-base-2016"] } ] diff --git a/modules/terraform-aws-instance-factory/data.tf b/modules/terraform-aws-instance-factory/data.tf index d30d726a..2abe3e71 100644 --- a/modules/terraform-aws-instance-factory/data.tf +++ b/modules/terraform-aws-instance-factory/data.tf @@ -28,9 +28,9 @@ data "aws_ami" "linux" { } } - # Only apply default name filter if no name filter exists in additional_linux_ami_filters + # Only apply default name filter if no name/tag:Name/image-id filter exists in additional_linux_ami_filters dynamic "filter" { - for_each = length([for f in var.additional_linux_ami_filters : f if f.name == "name" || f.name == "image-id"]) > 0 ? [] : [1] + for_each = length([for f in var.additional_linux_ami_filters : f if f.name == "name" || f.name == "image-id" || f.name == "tag:Name"]) > 0 ? [] : [1] content { name = "name" values = ["${var.linux_os}*${var.linux_os_version}*"] @@ -77,9 +77,9 @@ data "aws_ami" "windows" { } } - # Only apply default name filter if no name filter exists in additional_windows_ami_filters + # Only apply default name filter if no name/tag:Name/image-id filter exists in additional_windows_ami_filters dynamic "filter" { - for_each = length([for f in var.additional_windows_ami_filters : f if f.name == "name" || f.name == "image-id"]) > 0 ? [] : [1] + for_each = length([for f in var.additional_windows_ami_filters : f if f.name == "name" || f.name == "image-id" || f.name == "tag:Name"]) > 0 ? [] : [1] content { name = "name" values = ["${var.windows_os}-${var.windows_os_version}*"] @@ -128,7 +128,7 @@ data "aws_ami" "macos" { # Only apply default name filter if no name filter exists in additional_macos_ami_filters dynamic "filter" { - for_each = length([for f in var.additional_macos_ami_filters : f if f.name == "name" || f.name == "image-id"]) > 0 ? [] : [1] + for_each = length([for f in var.additional_macos_ami_filters : f if f.name == "name" || f.name == "image-id" || f.name == "tag:Name"]) > 0 ? [] : [1] content { name = "name" values = ["${var.macos_os}*${var.macos_os_version}*"] From 8a8a8bfbc04a408ea3301443b63d3e5cd69a268a Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Mon, 6 Apr 2026 20:50:14 -0600 Subject: [PATCH 3/5] feat: add environment-specific ansible inventory generation to env create **Added:** - Generate an Ansible inventory file (`{env}-inventory`) when creating a new environment, using a reference inventory as a template and updating variables - Introduced `generateInventory` function to handle inventory customization, replacing env, region, bucket, and instance IDs, and stripping IP fields **Changed:** - Updated help text and next steps output to document creation and review of the new inventory file as part of the environment provisioning workflow - Adjusted step numbers and instructions in the environment creation output to include inventory review and syncing instance IDs after infra apply --- cli/cmd/env_cmd.go | 66 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/cli/cmd/env_cmd.go b/cli/cmd/env_cmd.go index 1fdff768..26c13aa1 100644 --- a/cli/cmd/env_cmd.go +++ b/cli/cmd/env_cmd.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "strings" "github.com/dreadnode/dreadgoad/internal/config" @@ -31,6 +32,7 @@ Creates: - infra/goad-deployment/{env}/{region}/network/terragrunt.hcl - infra/goad-deployment/{env}/{region}/goad/{host}/terragrunt.hcl + templates - ad/GOAD/data/{env}-config.json + - {env}-inventory (Ansible inventory with PENDING instance IDs) Use --variant to generate randomized entity names for the environment config. Without --variant, the base config (dev-config.json) is copied as-is.`, @@ -145,15 +147,23 @@ func runEnvCreate(cmd *cobra.Command, args []string) error { color.Green(" Created config: %s-config.json", envName) } + invPath := filepath.Join(cfg.ProjectRoot, envName+"-inventory") + if err := generateInventory(cfg.ProjectRoot, envName, region, reference); err != nil { + return fmt.Errorf("generate inventory: %w", err) + } + color.Green(" Created inventory: %s", filepath.Base(invPath)) + fmt.Println() color.Green("Environment %q created successfully!", envName) fmt.Println() fmt.Println("Next steps:") fmt.Printf(" 1. Review: %s\n", envDir) fmt.Printf(" 2. Review: %s\n", configPath) - fmt.Printf(" 3. Initialize: dreadgoad --env %s --region %s infra init\n", envName, region) - fmt.Printf(" 4. Plan: dreadgoad --env %s --region %s infra plan\n", envName, region) - fmt.Printf(" 5. Apply: dreadgoad --env %s --region %s infra apply --auto-approve\n", envName, region) + fmt.Printf(" 3. Review: %s\n", invPath) + fmt.Printf(" 4. Initialize: dreadgoad --env %s --region %s infra init\n", envName, region) + fmt.Printf(" 5. Plan: dreadgoad --env %s --region %s infra plan\n", envName, region) + fmt.Printf(" 6. Apply: dreadgoad --env %s --region %s infra apply --auto-approve\n", envName, region) + fmt.Printf(" 7. Sync IDs: dreadgoad --env %s --region %s inventory sync\n", envName, region) return nil } @@ -315,6 +325,56 @@ func copyBaseConfig(projectRoot, envName string) error { return os.WriteFile(dstPath, data, 0o644) } +func generateInventory(projectRoot, envName, region, reference string) error { + refInvPath := filepath.Join(projectRoot, reference+"-inventory") + dstInvPath := filepath.Join(projectRoot, envName+"-inventory") + + data, err := os.ReadFile(refInvPath) + if err != nil { + return fmt.Errorf("read reference inventory %s: %w", filepath.Base(refInvPath), err) + } + content := string(data) + + // Extract reference env and region from the inventory vars + envRe := regexp.MustCompile(`(?m)^(\s*env=)(.+)$`) + regionRe := regexp.MustCompile(`(?m)^(\s*ansible_aws_ssm_region=)(.+)$`) + bucketRe := regexp.MustCompile(`(?m)^(\s*ansible_aws_ssm_bucket_name=)(.+)$`) + instanceRe := regexp.MustCompile(`(ansible_host=)i-[0-9a-f]+`) + ipFieldRe := regexp.MustCompile(`\s+(?:dc_ipv4|host_ipv4)=\S+`) + + refEnv := reference + if m := envRe.FindStringSubmatch(content); len(m) > 2 { + refEnv = strings.TrimSpace(m[2]) + } + refRegion := "" + if m := regionRe.FindStringSubmatch(content); len(m) > 2 { + refRegion = strings.TrimSpace(m[2]) + } + + // Replace env + content = envRe.ReplaceAllString(content, "${1}"+envName) + + // Replace region + content = regionRe.ReplaceAllString(content, "${1}"+region) + + // Replace bucket name: swap ref env/region for new env/region + if refRegion != "" { + if m := bucketRe.FindStringSubmatch(content); len(m) > 2 { + oldBucket := strings.TrimSpace(m[2]) + newBucket := strings.Replace(oldBucket, refEnv+"-"+refRegion, envName+"-"+region, 1) + content = bucketRe.ReplaceAllString(content, "${1}"+newBucket) + } + } + + // Replace instance IDs with PENDING placeholder + content = instanceRe.ReplaceAllString(content, "${1}PENDING") + + // Strip dc_ipv4/host_ipv4 fields (will be populated after infra apply) + content = ipFieldRe.ReplaceAllString(content, "") + + return os.WriteFile(dstInvPath, []byte(content), 0o644) +} + func generateVariantConfig(projectRoot, envName string) error { source := filepath.Join(projectRoot, "ad", "GOAD") target := filepath.Join(projectRoot, "ad", "GOAD-"+envName) From 854457f1685ebd212ad3ffe9f818f36220766332 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Mon, 6 Apr 2026 20:56:30 -0600 Subject: [PATCH 4/5] fix: handle errcheck lint violations in ami command --- cli/cmd/ami.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/cli/cmd/ami.go b/cli/cmd/ami.go index b8fe6d08..ef2ab7bf 100644 --- a/cli/cmd/ami.go +++ b/cli/cmd/ami.go @@ -22,6 +22,8 @@ import ( "gopkg.in/yaml.v3" ) +var envVarPattern = regexp.MustCompile(`\$\{([^}]+)\}`) + var amiCmd = &cobra.Command{ Use: "ami", Short: "AMI image management", @@ -252,7 +254,7 @@ func buildSingleAMI(ctx context.Context, cfg *config.Config, templatePath string bar.Fail() return nil, fmt.Errorf("create AMI builder for %s: %w", tmplName, err) } - defer imgBuilder.Close() + defer func() { _ = imgBuilder.Close() }() result, err := imgBuilder.Build(ctx, *buildCfg) if err != nil { @@ -463,11 +465,12 @@ func loadWarpgateTemplate(path, projectRoot string) (*builder.Config, error) { } if _, ok := os.LookupEnv("PROVISION_REPO_PATH"); !ok && projectRoot != "" { - os.Setenv("PROVISION_REPO_PATH", projectRoot) + if err := os.Setenv("PROVISION_REPO_PATH", projectRoot); err != nil { + return nil, fmt.Errorf("set PROVISION_REPO_PATH: %w", err) + } } - varPattern := regexp.MustCompile(`\$\{([^}]+)\}`) - content = varPattern.ReplaceAllStringFunc(content, func(match string) string { + content = envVarPattern.ReplaceAllStringFunc(content, func(match string) string { varName := match[2 : len(match)-1] if val, ok := os.LookupEnv(varName); ok { return val @@ -487,9 +490,9 @@ func printBuildSummary(results []amiBuildResult) { for _, r := range results { name := filepath.Base(filepath.Dir(r.template)) if r.err != nil { - color.New(color.FgRed).Fprintf(os.Stderr, " x %-25s FAILED: %s\n", name, r.err) + _, _ = color.New(color.FgRed).Fprintf(os.Stderr, " x %-25s FAILED: %s\n", name, r.err) } else { - color.New(color.FgGreen).Fprintf(os.Stderr, " + %-25s %s (%s)\n", name, r.amiID, r.duration) + _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, " + %-25s %s (%s)\n", name, r.amiID, r.duration) } } } From 9f40d67a1b8e0ec87538a4bade16d71b6b192f82 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Mon, 6 Apr 2026 21:08:45 -0600 Subject: [PATCH 5/5] fix: extend ansible log ignore check to handle longer multi-line outputs **Changed:** - Increased the range from 10 to 20 lines when scanning for "...ignoring" after a "fatal:" log line, improving handling of lengthy multi-line YAML outputs in ansible log parsing logic (logparser.go) --- cli/internal/ansible/logparser.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/internal/ansible/logparser.go b/cli/internal/ansible/logparser.go index 4e4b433f..b4e7a1a2 100644 --- a/cli/internal/ansible/logparser.go +++ b/cli/internal/ansible/logparser.go @@ -26,8 +26,8 @@ func CheckAnsibleSuccess(output string) bool { lines := strings.Split(output, "\n") for i, line := range lines { if strings.HasPrefix(line, "fatal:") { - // Check next 10 lines for "...ignoring" - end := i + 11 + // Check next 20 lines for "...ignoring" (multi-line YAML output can be long) + end := i + 21 if end > len(lines) { end = len(lines) }