diff --git a/ad/GOAD-variant-1/data/inventory b/ad/GOAD-variant-1/data/inventory index dcbe0a83..e6a7fc57 100644 --- a/ad/GOAD-variant-1/data/inventory +++ b/ad/GOAD-variant-1/data/inventory @@ -1,7 +1,7 @@ ; GLOBAL CONFIG [all:vars] ; domain_name : folder inside ad/ -domain_name=GOAD +domain_name=GOAD-variant-1 ; administrator user admin_user=administrator diff --git a/ad/GOAD-variant-1/data/inventory_disable_vagrant b/ad/GOAD-variant-1/data/inventory_disable_vagrant index fe857f3e..2ef09553 100644 --- a/ad/GOAD-variant-1/data/inventory_disable_vagrant +++ b/ad/GOAD-variant-1/data/inventory_disable_vagrant @@ -18,7 +18,7 @@ srv03 ansible_host={{ip_range}}.23 dns_domain=dc03 dict_key=srv03 ansible_user=a [all:vars] ; domain_name : folder inside ad/ -domain_name=GOAD +domain_name=GOAD-variant-1 ; winrm connection (windows) ansible_winrm_transport=ntlm diff --git a/ad/GOAD-variant-1/providers/aws/inventory b/ad/GOAD-variant-1/providers/aws/inventory index b3488171..a3c14a1a 100644 --- a/ad/GOAD-variant-1/providers/aws/inventory +++ b/ad/GOAD-variant-1/providers/aws/inventory @@ -17,4 +17,5 @@ dc03 ansible_host={{ip_range}}.12 dns_domain=dc03 dict_key=dc03 ansible_user=ans srv03 ansible_host={{ip_range}}.23 dns_domain=dc03 dict_key=srv03 ansible_user=ansible ansible_password=978i2pF43UJ- [all:vars] +domain_name=GOAD-variant-1 admin_user=goadmin diff --git a/ad/GOAD-variant-1/providers/azure/inventory b/ad/GOAD-variant-1/providers/azure/inventory index b3488171..a3c14a1a 100644 --- a/ad/GOAD-variant-1/providers/azure/inventory +++ b/ad/GOAD-variant-1/providers/azure/inventory @@ -17,4 +17,5 @@ dc03 ansible_host={{ip_range}}.12 dns_domain=dc03 dict_key=dc03 ansible_user=ans srv03 ansible_host={{ip_range}}.23 dns_domain=dc03 dict_key=srv03 ansible_user=ansible ansible_password=978i2pF43UJ- [all:vars] +domain_name=GOAD-variant-1 admin_user=goadmin diff --git a/ad/GOAD-variant-1/providers/ludus/inventory b/ad/GOAD-variant-1/providers/ludus/inventory index f7a05213..b4d232f7 100644 --- a/ad/GOAD-variant-1/providers/ludus/inventory +++ b/ad/GOAD-variant-1/providers/ludus/inventory @@ -17,6 +17,7 @@ dc03 ansible_host={{ip_range}}.12 dns_domain=dc03 dict_key=dc03 srv03 ansible_host={{ip_range}}.23 dns_domain=dc03 dict_key=srv03 [all:vars] +domain_name=GOAD-variant-1 force_dns_server=no dns_server={{ip_range}}.254 diff --git a/ad/GOAD-variant-1/providers/proxmox/inventory b/ad/GOAD-variant-1/providers/proxmox/inventory index ac06df41..7113be37 100644 --- a/ad/GOAD-variant-1/providers/proxmox/inventory +++ b/ad/GOAD-variant-1/providers/proxmox/inventory @@ -17,5 +17,6 @@ dc03 ansible_host={{ip_range}}.12 dns_domain=dc03 dict_key=dc03 srv03 ansible_host={{ip_range}}.23 dns_domain=dc03 dict_key=srv03 [all:vars] +domain_name=GOAD-variant-1 force_dns_server=yes dns_server={{ip_range}}.1 diff --git a/ad/GOAD-variant-1/providers/virtualbox/inventory b/ad/GOAD-variant-1/providers/virtualbox/inventory index 50cbae91..ae4c5910 100644 --- a/ad/GOAD-variant-1/providers/virtualbox/inventory +++ b/ad/GOAD-variant-1/providers/virtualbox/inventory @@ -15,3 +15,6 @@ srv02 ansible_host={{ip_range}}.22 dns_domain=dc02 dict_key=srv02 ; ------------------------------------------------ dc03 ansible_host={{ip_range}}.12 dns_domain=dc03 dict_key=dc03 srv03 ansible_host={{ip_range}}.23 dns_domain=dc03 dict_key=srv03 + +[all:vars] +domain_name=GOAD-variant-1 diff --git a/ad/GOAD-variant-1/providers/vmware/inventory b/ad/GOAD-variant-1/providers/vmware/inventory index 50cbae91..ae4c5910 100644 --- a/ad/GOAD-variant-1/providers/vmware/inventory +++ b/ad/GOAD-variant-1/providers/vmware/inventory @@ -15,3 +15,6 @@ srv02 ansible_host={{ip_range}}.22 dns_domain=dc02 dict_key=srv02 ; ------------------------------------------------ dc03 ansible_host={{ip_range}}.12 dns_domain=dc03 dict_key=dc03 srv03 ansible_host={{ip_range}}.23 dns_domain=dc03 dict_key=srv03 + +[all:vars] +domain_name=GOAD-variant-1 diff --git a/ad/GOAD-variant-1/providers/vmware_esxi/inventory b/ad/GOAD-variant-1/providers/vmware_esxi/inventory index 50cbae91..ae4c5910 100644 --- a/ad/GOAD-variant-1/providers/vmware_esxi/inventory +++ b/ad/GOAD-variant-1/providers/vmware_esxi/inventory @@ -15,3 +15,6 @@ srv02 ansible_host={{ip_range}}.22 dns_domain=dc02 dict_key=srv02 ; ------------------------------------------------ dc03 ansible_host={{ip_range}}.12 dns_domain=dc03 dict_key=dc03 srv03 ansible_host={{ip_range}}.23 dns_domain=dc03 dict_key=srv03 + +[all:vars] +domain_name=GOAD-variant-1 diff --git a/cli/cmd/bootstrap_test.go b/cli/cmd/bootstrap_test.go index d76de620..8e91a80f 100644 --- a/cli/cmd/bootstrap_test.go +++ b/cli/cmd/bootstrap_test.go @@ -144,3 +144,45 @@ func TestBootstrapInventory(t *testing.T) { } }) } + +func TestBootstrapFromProviderTemplateUsesDefaultVariantTarget(t *testing.T) { + dir := t.TempDir() + templatePath := filepath.Join( + dir, + "ad", + "GOAD-variant-1", + "providers", + "proxmox", + "inventory", + ) + if err := os.MkdirAll(filepath.Dir(templatePath), 0o755); err != nil { + t.Fatal(err) + } + template := "[default]\ndc01 ansible_host={{ip_range}}.10\n\n[all:vars]\ndomain_name=GOAD-variant-1\n" + if err := os.WriteFile(templatePath, []byte(template), 0o644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + Env: "dev", + Provider: "proxmox", + ProjectRoot: dir, + Environments: map[string]config.EnvironmentConfig{ + "dev": {Variant: true}, + }, + Proxmox: config.ProxmoxConfig{IPRange: "10.20.30"}, + } + invPath := filepath.Join(dir, "dev-inventory") + if err := bootstrapFromProviderTemplate(invPath, cfg); err != nil { + t.Fatalf("bootstrapFromProviderTemplate() error: %v", err) + } + + got, err := os.ReadFile(invPath) + if err != nil { + t.Fatal(err) + } + want := "[default]\ndc01 ansible_host=10.20.30.10\n\n[all:vars]\ndomain_name=GOAD-variant-1\n" + if string(got) != want { + t.Fatalf("bootstrapped inventory = %q, want %q", got, want) + } +} diff --git a/cli/cmd/provision.go b/cli/cmd/provision.go index 62b35e77..aadcef02 100644 --- a/cli/cmd/provision.go +++ b/cli/cmd/provision.go @@ -209,14 +209,26 @@ func bootstrapInventory(invPath string) error { } func bootstrapFromProviderTemplate(invPath string, cfg *config.Config) error { - labName := "GOAD" - if ec := cfg.ActiveEnvironment(); ec.Variant && ec.VariantTarget != "" { - labName = filepath.Base(ec.VariantTarget) - } else if cfg.ResolvedProvider() == "proxmox" { - labName = cfg.ProxmoxLab() - } providerName := cfg.ResolvedProvider() - templatePath := filepath.Join(cfg.ProjectRoot, "ad", labName, "providers", providerName, "inventory") + + // Resolve the lab tree that holds the provider inventory template. For a + // variant environment, read from the variant target tree so the + // bootstrapped inventory (which carries domain_name and the asset layout) + // points at the variant's ad// assets rather than the stock + // ad/GOAD/ tree. Falls back to the stock/proxmox path for non-variants. + var templatePath string + if ec := cfg.ActiveEnvironment(); ec.Variant { + if _, target := cfg.ResolvedVariantPaths(); target != "" { + templatePath = filepath.Join(target, "providers", providerName, "inventory") + } + } + if templatePath == "" { + labName := "GOAD" + if providerName == "proxmox" { + labName = cfg.ProxmoxLab() + } + templatePath = filepath.Join(cfg.ProjectRoot, "ad", labName, "providers", providerName, "inventory") + } data, err := os.ReadFile(templatePath) if err != nil { diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go index 9bbeef93..33f264c2 100644 --- a/cli/internal/variant/generator.go +++ b/cli/internal/variant/generator.go @@ -1045,6 +1045,9 @@ func (g *Generator) transformFile(srcPath, relPath string) (transformed bool, er newContent = g.fixSecureStrings(newContent) } newContent = g.transformConfigJSON(base, newContent) + if strings.HasPrefix(base, "inventory") { + newContent = g.repointDomainName(newContent) + } if err := os.WriteFile(targetFile, []byte(newContent), 0o644); err != nil { return false, fmt.Errorf("write %s: %w", targetFile, err) @@ -1052,6 +1055,61 @@ func (g *Generator) transformFile(srcPath, relPath string) (transformed bool, er return true, nil } +// repointDomainName rewrites the Ansible `domain_name` inventory variable to +// the variant target folder basename. Playbooks resolve vuln scripts/files as +// ad/{{ domain_name }}/..., so for a variant this must point at the variant's +// own ad// tree rather than the stock ad/GOAD/ tree. Existing values +// are replaced in place; missing values are inserted under [all:vars], with +// the section appended when the provider template does not define it. +func (g *Generator) repointDomainName(content string) string { + target := filepath.Base(g.TargetPath) + re := regexp.MustCompile(`(?m)^(\s*domain_name\s*=\s*).*$`) + if re.MatchString(content) { + return re.ReplaceAllStringFunc(content, func(line string) string { + if idx := strings.Index(line, "="); idx >= 0 { + valueStart := idx + 1 + for valueStart < len(line) && (line[valueStart] == ' ' || line[valueStart] == '\t') { + valueStart++ + } + return line[:valueStart] + target + } + return line + }) + } + + header := regexp.MustCompile(`(?m)^\[all:vars\][ \t]*`) + loc := header.FindStringIndex(content) + lineEnding := "\n" + if strings.Contains(content, "\r\n") { + lineEnding = "\r\n" + } + if loc == nil { + separator := "" + if content != "" { + separator = lineEnding + lineEnding + if strings.HasSuffix(content, lineEnding) { + separator = lineEnding + } + if strings.HasSuffix(content, lineEnding+lineEnding) { + separator = "" + } + } + return content + separator + "[all:vars]" + lineEnding + "domain_name=" + target + lineEnding + } + + insertAt := loc[1] + switch { + case strings.HasPrefix(content[insertAt:], "\r\n"): + insertAt += 2 + case strings.HasPrefix(content[insertAt:], "\n"): + insertAt++ + default: + return content[:insertAt] + lineEnding + "domain_name=" + target + content[insertAt:] + } + + return content[:insertAt] + "domain_name=" + target + lineEnding + content[insertAt:] +} + // copyAndTransform copies the source directory, transforming text files. // transformConfigJSON applies structural transformations to GOAD config JSON // files (firstname/surname fixup, password remapping, ACL/share key rebuilds). @@ -1092,12 +1150,13 @@ func (g *Generator) copyAndTransform() error { return nil } - // Skip .git files and completion markers inherited from a variant source. + // Skip git metadata and inherited completion markers, but preserve + // .gitkeep placeholders required to retain otherwise-empty directories. rel, err := filepath.Rel(g.SourcePath, path) if err != nil { return fmt.Errorf("resolve relative path for %s: %w", path, err) } - if strings.Contains(rel, ".git") { + if strings.Contains(rel, ".git") && d.Name() != ".gitkeep" { return nil } if d.Name() == CompletionMarkerName || d.Name() == CompletionMarkerName+".tmp" { diff --git a/cli/internal/variant/generator_test.go b/cli/internal/variant/generator_test.go index 8415dc12..24150bf0 100644 --- a/cli/internal/variant/generator_test.go +++ b/cli/internal/variant/generator_test.go @@ -75,6 +75,79 @@ func setupTestSource(t *testing.T) (sourceDir, targetDir string) { return sourceDir, targetDir } +func TestTransformFileRepointsInventoryDomainName(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "replaces existing value", + content: "[all:vars]\n domain_name = GOAD\nadmin_user=administrator\n", + want: "[all:vars]\n domain_name = review-variant\nadmin_user=administrator\n", + }, + { + name: "inserts missing value", + content: "[all:vars]\nadmin_user=administrator\n", + want: "[all:vars]\ndomain_name=review-variant\nadmin_user=administrator\n", + }, + { + name: "adds missing all vars section", + content: "[default]\ndc01 ansible_host=10.0.0.10\n", + want: "[default]\ndc01 ansible_host=10.0.0.10\n\n[all:vars]\ndomain_name=review-variant\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sourceDir := t.TempDir() + targetDir := filepath.Join(t.TempDir(), "review-variant") + sourcePath := filepath.Join(sourceDir, "inventory_disable_vagrant") + if err := os.WriteFile(sourcePath, []byte(tt.content), 0o644); err != nil { + t.Fatal(err) + } + + gen := NewGenerator(sourceDir, targetDir, "test") + transformed, err := gen.transformFile(sourcePath, filepath.Join("data", "inventory_disable_vagrant")) + if err != nil { + t.Fatalf("transformFile() error: %v", err) + } + if !transformed { + t.Fatal("transformFile() reported inventory was copied without transformation") + } + + got, err := os.ReadFile(filepath.Join(targetDir, "data", "inventory_disable_vagrant")) + if err != nil { + t.Fatal(err) + } + if string(got) != tt.want { + t.Fatalf("transformed inventory = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCopyAndTransformPreservesGitkeep(t *testing.T) { + sourceDir := t.TempDir() + targetDir := filepath.Join(t.TempDir(), "target") + relPath := filepath.Join("files", "srv02", "wwwroot", "upload", ".gitkeep") + sourcePath := filepath.Join(sourceDir, relPath) + if err := os.MkdirAll(filepath.Dir(sourcePath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sourcePath, nil, 0o644); err != nil { + t.Fatal(err) + } + + gen := NewGenerator(sourceDir, targetDir, "test") + if err := gen.copyAndTransform(); err != nil { + t.Fatalf("copyAndTransform() error: %v", err) + } + if _, err := os.Stat(filepath.Join(targetDir, relPath)); err != nil { + t.Fatalf(".gitkeep was not preserved: %v", err) + } +} + func testConfig() *LabConfig { config := &LabConfig{} config.Lab.Hosts = map[string]*HostConfig{