Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .github/workflows/pre-commit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ jobs:
ansible-galaxy collection build --force
ansible-galaxy collection install dreadnode-goad-*.tar.gz -p ~/.ansible/collections --force --pre

- name: Set up Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3
with:
terraform_version: "1.9.7"

- name: Set up TFLint
uses: terraform-linters/setup-tflint@90f302c255ef959cbfb4bd10581afecdb7ece3e6 # v4
with:
tflint_version: latest

- name: Init TFLint
run: tflint --init --config .hooks/linters/.tflint.hcl

- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ ad/*/providers/*/ssh_keys/*id_rsa*
ad/*/providers/*/ssh_keys/*.pub
ad/*/providers/*/extensions/*.rb

# Terraform
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup
*.tfplan

# IDE / OS
.vscode
temp/
21 changes: 21 additions & 0 deletions .hooks/linters/.tflint.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
plugin "aws" {
enabled = true
version = "0.36.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}

rule "terraform_naming_convention" {
enabled = true
}

rule "terraform_documented_outputs" {
enabled = true
}

rule "terraform_documented_variables" {
enabled = true
}

rule "terraform_unused_declarations" {
enabled = true
}
14 changes: 14 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ repos:
# version of Ansible you want to install as a dependency.
additional_dependencies: [".[community]"]

- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.99.2
hooks:
- id: terraform_fmt
files: ^(modules|infra)/
- id: terraform_validate
files: ^modules/
args:
- --hook-config=--retry-once-with-cleanup=true
- id: terraform_tflint
files: ^(modules|infra)/
args:
- --args=--config=__GIT_WORKING_DIR__/.hooks/linters/.tflint.hcl

- repo: local
hooks:
- id: prettier
Expand Down
271 changes: 271 additions & 0 deletions cli/cmd/infra_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package cmd

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/dreadnode/dreadgoad/internal/config"
"github.com/dreadnode/dreadgoad/internal/terragrunt"
"github.com/fatih/color"
"github.com/spf13/cobra"
)

var infraCmd = &cobra.Command{
Use: "infra",
Short: "Manage GOAD infrastructure via Terragrunt",
Long: `Manage the GOAD 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.).

By default, commands operate on all modules (run-all). Use --module to
target a specific module (e.g. network, goad/dc01).`,
}

var infraInitCmd = &cobra.Command{
Use: "init",
Short: "Initialize Terragrunt modules",
RunE: runInfraAction("init"),
}

var infraPlanCmd = &cobra.Command{
Use: "plan",
Short: "Plan infrastructure changes",
RunE: runInfraAction("plan"),
}

var infraApplyCmd = &cobra.Command{
Use: "apply",
Short: "Apply infrastructure changes",
RunE: runInfraAction("apply"),
}

var infraDestroyCmd = &cobra.Command{
Use: "destroy",
Short: "Destroy infrastructure",
RunE: runInfraAction("destroy"),
}

var infraOutputCmd = &cobra.Command{
Use: "output",
Short: "Show Terragrunt outputs (JSON)",
RunE: runInfraOutput,
}

var infraValidateCmd = &cobra.Command{
Use: "validate",
Short: "Validate environment configuration",
RunE: runInfraValidate,
}

func init() {
rootCmd.AddCommand(infraCmd)
infraCmd.AddCommand(infraInitCmd)
infraCmd.AddCommand(infraPlanCmd)
infraCmd.AddCommand(infraApplyCmd)
infraCmd.AddCommand(infraDestroyCmd)
infraCmd.AddCommand(infraOutputCmd)
infraCmd.AddCommand(infraValidateCmd)

// Shared flags for action commands
for _, cmd := range []*cobra.Command{infraInitCmd, infraPlanCmd, infraApplyCmd, infraDestroyCmd, infraOutputCmd} {
cmd.Flags().StringP("module", "m", "", "Target module path (e.g. network, goad/dc01)")
cmd.Flags().String("exclude", "", "Exclude modules (comma-separated, e.g. goad/dc01,goad/dc02)")
}

infraApplyCmd.Flags().Bool("auto-approve", false, "Skip confirmation prompt")
infraApplyCmd.Flags().Bool("individual", false, "Apply each subdirectory individually (for module groups like goad/)")
infraDestroyCmd.Flags().Bool("auto-approve", false, "Skip confirmation prompt")

infraCmd.PersistentFlags().StringP("deployment", "d", "", "Deployment name (default: from config)")
}

func runInfraAction(action string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
cfg, err := config.Get()
if err != nil {
return err
}

module, _ := cmd.Flags().GetString("module")
exclude, _ := cmd.Flags().GetString("exclude")
deployment := resolveDeployment(cmd, cfg)

region := cfg.Region
if region == "" {
region = "us-west-1"
}

opts := terragrunt.Options{
Action: action,
TerragruntBinary: cfg.Infra.TerragruntBinary,
TerraformBinary: cfg.Infra.TerraformBinary,
NonInteractive: true,
ExcludeDirs: exclude,
Debug: cfg.Debug,
}

if action == "apply" || action == "destroy" {
autoApprove, _ := cmd.Flags().GetBool("auto-approve")
opts.AutoApprove = autoApprove
}

// Build working directory
basePath := filepath.Join(cfg.ProjectRoot, "infra", deployment)
workDir := filepath.Join(basePath, cfg.Env, region)

if _, err := os.Stat(workDir); os.IsNotExist(err) {
return fmt.Errorf("infra working directory not found: %s\nRun 'dreadgoad infra validate' to check your setup", workDir)
}

// Set up log file
logDir := cfg.LogDir
if logDir == "" {
home, _ := os.UserHomeDir()
logDir = filepath.Join(home, ".ansible", "logs", "goad")
}
timestamp := time.Now().Format("20060102_150405")
moduleSlug := "all"
if module != "" {
moduleSlug = strings.ReplaceAll(module, "/", "_")
}
opts.LogFile = filepath.Join(logDir, fmt.Sprintf("infra_%s_%s_%s_%s_%s.log",
action, deployment, cfg.Env, moduleSlug, timestamp))

fmt.Printf("Infra %s (%s/%s)\n", action, cfg.Env, region)
if module != "" {
fmt.Printf("Module: %s\n", module)
}
fmt.Printf("Log: %s\n\n", opts.LogFile)

ctx := context.Background()

if module != "" {
modulePath := filepath.Join(workDir, module)
if _, err := os.Stat(modulePath); os.IsNotExist(err) {
return fmt.Errorf("module not found: %s", modulePath)
}

// Check if --individual flag is set and module has subdirectories
if action == "apply" {
individual, _ := cmd.Flags().GetBool("individual")
if individual {
var excludeList []string
if exclude != "" {
excludeList = strings.Split(exclude, ",")
}
results, err := terragrunt.RunIndividual(ctx, opts, modulePath, excludeList)
if err != nil {
return err
}
return printIndividualResults(results)
}
}

opts.WorkDir = modulePath
return terragrunt.Run(ctx, opts)
}

// Full stack: run-all
opts.WorkDir = workDir
return terragrunt.RunAll(ctx, opts)
}
}

func runInfraOutput(cmd *cobra.Command, args []string) error {
cfg, err := config.Get()
if err != nil {
return err
}

module, _ := cmd.Flags().GetString("module")
deployment := resolveDeployment(cmd, cfg)

region := cfg.Region
if region == "" {
region = "us-west-1"
}

workDir := filepath.Join(cfg.ProjectRoot, "infra", deployment, cfg.Env, region)
if module != "" {
workDir = filepath.Join(workDir, module)
}

if _, err := os.Stat(workDir); os.IsNotExist(err) {
return fmt.Errorf("directory not found: %s", workDir)
}

opts := terragrunt.Options{
Action: "output",
WorkDir: workDir,
TerragruntBinary: cfg.Infra.TerragruntBinary,
TerraformBinary: cfg.Infra.TerraformBinary,
}

ctx := context.Background()
out, err := terragrunt.Output(ctx, opts)
if err != nil {
return err
}

fmt.Println(string(out))
return nil
}

func runInfraValidate(cmd *cobra.Command, args []string) error {
cfg, err := config.Get()
if err != nil {
return err
}

deployment := resolveDeployment(cmd, cfg)

region := cfg.Region
if region == "" {
region = "us-west-1"
}

basePath := filepath.Join(cfg.ProjectRoot, "infra", deployment)
result := terragrunt.ValidateEnvironment(basePath, cfg.Env, region)
terragrunt.PrintValidationResult(result, cfg.Env, region)

if !result.OK() {
return fmt.Errorf("validation failed")
}
return nil
}

func resolveDeployment(cmd *cobra.Command, cfg *config.Config) string {
if d, _ := cmd.Flags().GetString("deployment"); d != "" {
return d
}
return cfg.Infra.Deployment
}

func printIndividualResults(results []terragrunt.Result) error {
fmt.Printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
fmt.Println("Summary")
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")

var failed []string
for _, r := range results {
if r.Success {
color.Green(" OK: %s", r.Module)
} else {
color.Red(" FAIL: %s", r.Module)
failed = append(failed, r.Module)
}
}

fmt.Printf("\nTotal: %d, Succeeded: %d, Failed: %d\n",
len(results), len(results)-len(failed), len(failed))

if len(failed) > 0 {
return fmt.Errorf("failed modules: %s", strings.Join(failed, ", "))
}
return nil
}
Loading
Loading