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
1 change: 1 addition & 0 deletions ad/GOAD-variant-1/.dreadgoad-variant-complete
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
complete
23 changes: 10 additions & 13 deletions cli/cmd/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ func init() {
extensionListCmd.Flags().String("lab", "", "Filter by lab compatibility (e.g. GOAD, GOAD-Light)")

extensionProvisionCmd.Flags().String("limit", "", "Limit execution to specific hosts")
extensionProvisionCmd.Flags().Int("max-retries", 0, "Max retry attempts (default: from config)")
extensionProvisionCmd.Flags().Int("retry-delay", 0, "Delay between retries in seconds")
extensionProvisionCmd.Flags().Int("max-retries", 0, "Max retry attempts (default: from config; 0 disables retries)")
extensionProvisionCmd.Flags().Int("retry-delay", 0, "Delay between retries in seconds (default: from config; 0 disables delay)")
}

func runExtensionList(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -111,10 +111,12 @@ func runExtensionProvision(cmd *cobra.Command, args []string) error {
}

limit, _ := cmd.Flags().GetString("limit")
maxRetries, _ := cmd.Flags().GetInt("max-retries")
retryDelay, _ := cmd.Flags().GetInt("retry-delay")
retry, err := retryOverridesFromFlags(cmd)
if err != nil {
return err
}

return provisionExtension(cfg, name, ext, limit, maxRetries, retryDelay)
return provisionExtension(cfg, name, ext, limit, retry)
}

func runExtensionProvisionAll(cmd *cobra.Command, args []string) error {
Expand All @@ -141,7 +143,7 @@ func runExtensionProvisionAll(cmd *cobra.Command, args []string) error {
if !ok {
return fmt.Errorf("enabled extension %q not found in config", name)
}
if err := provisionExtension(cfg, name, ext, "", 0, 0); err != nil {
if err := provisionExtension(cfg, name, ext, "", retryOverrides{}); err != nil {
return fmt.Errorf("extension %s failed: %w", name, err)
}
}
Expand All @@ -150,7 +152,7 @@ func runExtensionProvisionAll(cmd *cobra.Command, args []string) error {
return nil
}

func provisionExtension(cfg *config.Config, name string, ext config.ExtensionConfig, limit string, maxRetries, retryDelay int) error {
func provisionExtension(cfg *config.Config, name string, ext config.ExtensionConfig, limit string, retry retryOverrides) error {
ctx := context.Background()

_ = os.MkdirAll(cfg.LogDir, 0o755)
Expand Down Expand Up @@ -198,12 +200,7 @@ func provisionExtension(cfg *config.Config, name string, ext config.ExtensionCon
Debug: cfg.Debug,
LogFile: logFile,
}
if maxRetries > 0 {
opts.MaxRetries = maxRetries
}
if retryDelay > 0 {
opts.RetryDelay = time.Duration(retryDelay) * time.Second
}
retry.apply(&opts)

if err := ansible.RunPlaybookWithRetry(ctx, opts); err != nil {
return err
Expand Down
16 changes: 14 additions & 2 deletions cli/cmd/infra_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package cmd

import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -106,7 +108,11 @@ func init() {
func materializeLabConfig(cfg *config.Config) error {
resolved, err := cfg.ResolvedLabConfigPath()
if err != nil {
return nil // no config to materialize -- let terragrunt surface the error
if errors.Is(err, config.ErrLabConfigNotFound) {
slog.Debug("no lab config to materialize; continuing for standalone infrastructure", "error", err)
return nil
}
return fmt.Errorf("resolve lab config: %w", err)
}

dataDir := filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data")
Expand All @@ -121,7 +127,13 @@ func materializeLabConfig(cfg *config.Config) error {
if err != nil {
return fmt.Errorf("read resolved config: %w", err)
}
return os.WriteFile(expected, data, 0o644)
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return fmt.Errorf("create lab config directory: %w", err)
}
if err := os.WriteFile(expected, data, 0o644); err != nil {
return fmt.Errorf("write lab config: %w", err)
}
return nil
}

func runInfraAction(action string) func(*cobra.Command, []string) error {
Expand Down
168 changes: 168 additions & 0 deletions cli/cmd/infra_cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package cmd

import (
"errors"
"os"
"path/filepath"
"strings"
"testing"

"github.com/dreadnode/dreadgoad/internal/config"
)

func TestMaterializeLabConfigAllowsMissingOptionalConfig(t *testing.T) {
cfg := &config.Config{ProjectRoot: t.TempDir(), Env: "dev"}

if err := materializeLabConfig(cfg); err != nil {
t.Fatalf("materializeLabConfig() error = %v, want nil", err)
}
}

func TestMaterializeLabConfigSurfacesResolutionFailure(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "ad", "GOAD", "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "config.json"), []byte(`{"base":true}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "dev-overlay.json"), []byte(`{"broken":`), 0o644); err != nil {
t.Fatal(err)
}

err := materializeLabConfig(&config.Config{ProjectRoot: root, Env: "dev"})
if err == nil || !strings.Contains(err.Error(), "resolve lab config: merge config") {
t.Fatalf("materializeLabConfig() error = %v, want merge resolution error", err)
}
if errors.Is(err, config.ErrLabConfigNotFound) {
t.Fatalf("malformed config was misclassified as missing: %v", err)
}
}

func TestMaterializeLabConfigRejectsOverlayWithoutBase(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "ad", "GOAD", "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "dev-overlay.json"), []byte(`{"present":true}`), 0o644); err != nil {
t.Fatal(err)
}

err := materializeLabConfig(&config.Config{ProjectRoot: root, Env: "dev"})
if err == nil || !strings.Contains(err.Error(), "overlay") || !strings.Contains(err.Error(), "requires base config") {
t.Fatalf("materializeLabConfig() error = %v, want missing base config error", err)
}
if errors.Is(err, config.ErrLabConfigNotFound) {
t.Fatalf("orphaned overlay was misclassified as missing: %v", err)
}
}

func TestMaterializeLabConfigCreatesDestinationDirectory(t *testing.T) {
root := t.TempDir()
variantData := filepath.Join(root, "ad", "custom-variant", "data")
if err := os.MkdirAll(variantData, 0o755); err != nil {
t.Fatal(err)
}
want := []byte(`{"variant":true}`)
if err := os.WriteFile(filepath.Join(variantData, "config.json"), want, 0o644); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
ProjectRoot: root,
Env: "dev",
Environments: map[string]config.EnvironmentConfig{
"dev": {Variant: true, VariantTarget: "ad/custom-variant"},
},
}

if err := materializeLabConfig(cfg); err != nil {
t.Fatalf("materializeLabConfig() error: %v", err)
}
destination := filepath.Join(root, "ad", "GOAD", "data", "dev-config.json")
got, err := os.ReadFile(destination)
if err != nil {
t.Fatalf("read materialized config: %v", err)
}
if string(got) != string(want) {
t.Errorf("materialized config = %s, want %s", got, want)
}
}

func TestMaterializeLabConfigLeavesLegacyDestinationUntouched(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "ad", "GOAD", "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
destination := filepath.Join(dataDir, "dev-config.json")
want := []byte(`{"legacy":true}`)
if err := os.WriteFile(destination, want, 0o644); err != nil {
t.Fatal(err)
}

if err := materializeLabConfig(&config.Config{ProjectRoot: root, Env: "dev"}); err != nil {
t.Fatalf("materializeLabConfig() error: %v", err)
}
got, err := os.ReadFile(destination)
if err != nil {
t.Fatal(err)
}
if string(got) != string(want) {
t.Errorf("legacy config changed: got %s, want %s", got, want)
}
}

func TestMaterializeLabConfigReportsDirectoryCreationFailure(t *testing.T) {
root := t.TempDir()
variantData := filepath.Join(root, "ad", "custom-variant", "data")
if err := os.MkdirAll(variantData, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(variantData, "config.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "ad", "GOAD"), []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
ProjectRoot: root,
Env: "dev",
Environments: map[string]config.EnvironmentConfig{
"dev": {Variant: true, VariantTarget: "ad/custom-variant"},
},
}

err := materializeLabConfig(cfg)
if err == nil || !strings.Contains(err.Error(), "create lab config directory") {
t.Fatalf("materializeLabConfig() error = %v, want directory creation error", err)
}
}

func TestMaterializeLabConfigReportsWriteFailure(t *testing.T) {
root := t.TempDir()
variantData := filepath.Join(root, "ad", "custom-variant", "data")
if err := os.MkdirAll(variantData, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(variantData, "config.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
destination := filepath.Join(root, "ad", "GOAD", "data", "dev-config.json")
if err := os.MkdirAll(destination, 0o755); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
ProjectRoot: root,
Env: "dev",
Environments: map[string]config.EnvironmentConfig{
"dev": {Variant: true, VariantTarget: "ad/custom-variant"},
},
}

err := materializeLabConfig(cfg)
if err == nil || !strings.Contains(err.Error(), "write lab config") {
t.Fatalf("materializeLabConfig() error = %v, want write error", err)
}
}
12 changes: 7 additions & 5 deletions cli/cmd/lab_reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ func init() {
labResetCmd.Flags().Bool("skip-provision", false, "Skip the AD-state playbook stage")
labResetCmd.Flags().String("plays", "", "Comma-separated playbooks (default: AD-state set)")
labResetCmd.Flags().String("limit", "", "Limit playbook execution to specific hosts")
labResetCmd.Flags().Int("max-retries", 0, "Max retry attempts (default: from config)")
labResetCmd.Flags().Int("retry-delay", 0, "Delay between retries in seconds (default: from config)")
labResetCmd.Flags().Int("max-retries", 0, "Max retry attempts (default: from config; 0 disables retries)")
labResetCmd.Flags().Int("retry-delay", 0, "Delay between retries in seconds (default: from config; 0 disables delay)")
labResetCmd.Flags().Bool("skip-creator-check", false, "Skip the admin creator-SID safety belt during purge")
labResetCmd.Flags().StringArrayP("extra-vars", "E", nil, extraVarsUsage)
}
Expand Down Expand Up @@ -496,8 +496,10 @@ func runLabReset(cmd *cobra.Command, args []string) error {
skipProvision, _ := cmd.Flags().GetBool("skip-provision")
playsFlag, _ := cmd.Flags().GetString("plays")
limit, _ := cmd.Flags().GetString("limit")
maxRetries, _ := cmd.Flags().GetInt("max-retries")
retryDelay, _ := cmd.Flags().GetInt("retry-delay")
retry, err := retryOverridesFromFlags(cmd)
if err != nil {
return err
}
skipCreator, _ := cmd.Flags().GetBool("skip-creator-check")
extraVars, err := parseExtraVars(cmd)
if err != nil {
Expand All @@ -524,7 +526,7 @@ func runLabReset(cmd *cobra.Command, args []string) error {

if !skipProvision {
fmt.Println("--- Stage 2: restore AD baseline state ---")
if err := provisionPlaybooks(ctx, cfg, playbooks, limit, maxRetries, retryDelay, extraVars); err != nil {
if err := provisionPlaybooks(ctx, cfg, playbooks, limit, retry, extraVars); err != nil {
return err
}
}
Expand Down
Loading
Loading