Skip to content
Open
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
74 changes: 74 additions & 0 deletions internal/datagen/datagen_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package datagen

import (
"errors"
"math/rand"
"testing"
)

// These tests exercise the developer-error paths converted from panics to error
// returns (PIPE-1003). They double as regression guards: they assert that each
// constructor rejects a nil domain, and that GenerateEnvironment propagates a
// failure from any of its identity-generation stages.

func TestGenerateUserIdentity_NilDomain(t *testing.T) {
u, err := GenerateUserIdentity(rand.New(rand.NewSource(1)), nil)
if err == nil || u != nil {
t.Errorf("GenerateUserIdentity(nil) = (%v, %v), want (nil, error)", u, err)
}
}

func TestGenerateUsers_NilDomain(t *testing.T) {
users, err := GenerateUsers(1, 1, nil)
if err == nil || users != nil {
t.Errorf("GenerateUsers(nil) = (%v, %v), want (nil, error)", users, err)
}
}

func TestGenerateGroups_NilDomain(t *testing.T) {
groups, err := GenerateGroups(1, 5, 0, nil, nil)
if err == nil || groups != nil {
t.Errorf("GenerateGroups(nil) = (%v, %v), want (nil, error)", groups, err)
}
}

func TestGenerateSystems_NilDomainPropagates(t *testing.T) {
systems, err := generateSystems(1, 1, 1, 3, nil, GenerateDefaultNetworks())
if err == nil || systems != nil {
t.Errorf("generateSystems(nil) = (%v, %v), want (nil, error)", systems, err)
}
}

func TestGenerateEnvironment_PropagatesStageErrors(t *testing.T) {
boom := errors.New("boom")
seeds := &SeedConfig{Shared: 1}

t.Run("users stage error", func(t *testing.T) {
orig := genUsers
genUsers = func(int64, int, *DomainIdentity) ([]*UserIdentity, error) { return nil, boom }
defer func() { genUsers = orig }()
if env, err := GenerateEnvironment(seeds, nil); !errors.Is(err, boom) || env != nil {
t.Errorf("want boom + nil env, got (%v, %v)", env, err)
}
})

t.Run("groups stage error", func(t *testing.T) {
orig := genGroups
genGroups = func(int64, int, int, *DomainIdentity, []*UserIdentity) ([]*GroupIdentity, error) { return nil, boom }
defer func() { genGroups = orig }()
if env, err := GenerateEnvironment(seeds, nil); !errors.Is(err, boom) || env != nil {
t.Errorf("want boom + nil env, got (%v, %v)", env, err)
}
})

t.Run("systems stage error", func(t *testing.T) {
orig := genSystems
genSystems = func(_, _, _ int64, _ int, _ *DomainIdentity, _ []*NetworkIdentity) ([]*SystemIdentity, error) {
return nil, boom
}
defer func() { genSystems = orig }()
if env, err := GenerateEnvironment(seeds, nil); !errors.Is(err, boom) || env != nil {
t.Errorf("want boom + nil env, got (%v, %v)", env, err)
}
})
}
40 changes: 32 additions & 8 deletions internal/datagen/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ type osRoleSpec struct {
pool *Pool[string]
}

// Stage seams. GenerateEnvironment calls its identity-generation stages
// through these package-level variables so tests can force a stage to fail and
// assert that GenerateEnvironment propagates the error. Production always uses
// the real implementations; the stages cannot fail today (GenerateEnvironment
// builds a valid domain), but the propagation is kept as a regression guard for
// when a stage gains a real failure mode.
var (
genUsers = GenerateUsers
genGroups = GenerateGroups
genSystems = generateSystems
)

// GenerateEnvironment produces a fully cross-referenced environment from seeds and options.
//
// Per-identity-type seeds drive each independent stage of generation:
Expand All @@ -95,7 +107,7 @@ type osRoleSpec struct {
// IdentityApplications for applications attached to each system. Each stage
// uses a fresh RNG seeded from its own field, so changing one seed only
// re-randomizes that slice of the output.
func GenerateEnvironment(seeds *SeedConfig, opts *EnvironmentOpts) *Environment {
func GenerateEnvironment(seeds *SeedConfig, opts *EnvironmentOpts) (*Environment, error) {
opts = defaultOpts(opts)

// Generate domain
Expand All @@ -108,19 +120,28 @@ func GenerateEnvironment(seeds *SeedConfig, opts *EnvironmentOpts) *Environment

// Generate users
userSeed := seeds.ResolveSeed(IdentityUsers)
users := GenerateUsers(userSeed, opts.UserCount, domain)
users, err := genUsers(userSeed, opts.UserCount, domain)
if err != nil {
return nil, err
}

// Generate groups
groupSeed := seeds.ResolveSeed(IdentityGroups)
groups := GenerateGroups(groupSeed, opts.GroupCount, opts.DomainAdminsCount, domain, users)
groups, err := genGroups(groupSeed, opts.GroupCount, opts.DomainAdminsCount, domain, users)
if err != nil {
return nil, err
}

// Generate systems with a mix of OS/roles. Services and applications
// have their own seeded RNGs so changing IdentityServices or
// IdentityApplications independently re-randomizes those slices.
systemSeed := seeds.ResolveSeed(IdentitySystems)
servicesSeed := seeds.ResolveSeed(IdentityServices)
applicationsSeed := seeds.ResolveSeed(IdentityApplications)
systems := generateSystems(systemSeed, servicesSeed, applicationsSeed, opts.SystemCount, domain, networks)
systems, err := genSystems(systemSeed, servicesSeed, applicationsSeed, opts.SystemCount, domain, networks)
if err != nil {
return nil, err
}

// Appliance identities (PIPE-1035): storage arrays and network hardware,
// each with its own seed and a management interface bound to a subnet.
Expand All @@ -138,7 +159,7 @@ func GenerateEnvironment(seeds *SeedConfig, opts *EnvironmentOpts) *Environment
Systems: systems,
StorageSystems: storageSystems,
NetworkSystems: networkSystems,
}
}, nil
}

// managementNetwork returns the subnet to bind appliance management interfaces
Expand Down Expand Up @@ -234,7 +255,7 @@ func generateNetworksList(seed int64, count int) []*NetworkIdentity {
// resource specs). servicesSeed and applicationsSeed seed independent RNGs
// for service and application generation so changes to either seed only
// re-randomize that slice.
func generateSystems(systemSeed, servicesSeed, applicationsSeed int64, count int, domain *DomainIdentity, networks []*NetworkIdentity) []*SystemIdentity {
func generateSystems(systemSeed, servicesSeed, applicationsSeed int64, count int, domain *DomainIdentity, networks []*NetworkIdentity) ([]*SystemIdentity, error) {
r := rand.New(rand.NewSource(systemSeed)) // #nosec G404
rServices := rand.New(rand.NewSource(servicesSeed)) // #nosec G404
rApplications := rand.New(rand.NewSource(applicationsSeed)) // #nosec G404
Expand All @@ -253,7 +274,10 @@ func generateSystems(systemSeed, servicesSeed, applicationsSeed int64, count int
for i := 0; i < count; i++ {
// Pick OS/role using weighted selection
spec := weightedSelect(r, specs, weights)
sys := GenerateSystemIdentity(r, spec.os, spec.role, domain, spec.pool)
sys, err := GenerateSystemIdentity(r, spec.os, spec.role, domain, spec.pool)
if err != nil {
return nil, err
}

// Services and applications use their own RNGs so the seeds in
// SeedConfig actually drive what's generated, per identity type.
Expand All @@ -277,7 +301,7 @@ func generateSystems(systemSeed, servicesSeed, applicationsSeed int64, count int
systems[i] = sys
}

return systems
return systems, nil
}

// weightedSelect picks an item using weighted random selection.
Expand Down
56 changes: 45 additions & 11 deletions internal/datagen/environment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ func TestGenerateEnvironment(t *testing.T) {
NetworkCount: 4,
}

env := GenerateEnvironment(seeds, opts)
env, err := GenerateEnvironment(seeds, opts)
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}

t.Run("domain set", func(t *testing.T) {
if env.Domain == nil {
Expand Down Expand Up @@ -86,7 +89,10 @@ func TestGenerateEnvironment(t *testing.T) {
}

func TestGenerateEnvironmentComposesAppliances(t *testing.T) {
env := GenerateEnvironment(&SeedConfig{Shared: 42}, &EnvironmentOpts{StorageSystemCount: 3, NetworkSystemCount: 5})
env, err := GenerateEnvironment(&SeedConfig{Shared: 42}, &EnvironmentOpts{StorageSystemCount: 3, NetworkSystemCount: 5})
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}

if len(env.StorageSystems) != 3 {
t.Fatalf("StorageSystems = %d, want 3", len(env.StorageSystems))
Expand Down Expand Up @@ -116,7 +122,11 @@ func TestGenerateEnvironmentDeterministicAppliances(t *testing.T) {
mk := func() *Environment {
s := NewSeedConfig()
s.Shared = 7
return GenerateEnvironment(s, &EnvironmentOpts{StorageSystemCount: 2, NetworkSystemCount: 2})
env, err := GenerateEnvironment(s, &EnvironmentOpts{StorageSystemCount: 2, NetworkSystemCount: 2})
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}
return env
}
a, b := mk(), mk()
if a.StorageSystems[0].Serial != b.StorageSystems[0].Serial {
Expand Down Expand Up @@ -162,7 +172,10 @@ func TestBindManagementInterface(t *testing.T) {
func TestGenerateEnvironmentDefaults(t *testing.T) {
seeds := NewSeedConfig()
seeds.Shared = 42
env := GenerateEnvironment(seeds, nil)
env, err := GenerateEnvironment(seeds, nil)
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}

if env.Domain.Name != "blitz.local" {
t.Errorf("default domain should be 'blitz.local', got %q", env.Domain.Name)
Expand Down Expand Up @@ -190,8 +203,14 @@ func TestGenerateEnvironmentDeterministic(t *testing.T) {
Now: now,
}

env1 := GenerateEnvironment(seeds1, opts)
env2 := GenerateEnvironment(seeds2, opts)
env1, err := GenerateEnvironment(seeds1, opts)
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}
env2, err := GenerateEnvironment(seeds2, opts)
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}

if env1.Domain.DomainSID != env2.Domain.DomainSID {
t.Error("same seed should produce same DomainSID")
Expand Down Expand Up @@ -227,7 +246,10 @@ func TestGenerateEnvironmentExtendsNetworks(t *testing.T) {
NetworkCount: 10,
Now: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
}
env := GenerateEnvironment(seeds, opts)
env, err := GenerateEnvironment(seeds, opts)
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}
if len(env.Networks) != 10 {
t.Errorf("expected 10 networks, got %d", len(env.Networks))
}
Expand All @@ -250,12 +272,18 @@ func TestGenerateEnvironmentPerTypeSeedsIndependent(t *testing.T) {
base.Shared = 42
opts := &EnvironmentOpts{SystemCount: 5, UserCount: 5, GroupCount: 5, NetworkCount: 4, Now: now}

envA := GenerateEnvironment(base, opts)
envA, err := GenerateEnvironment(base, opts)
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}

altered := NewSeedConfig()
altered.Shared = 42
altered.Services = 777 // override only services
envB := GenerateEnvironment(altered, opts)
envB, err := GenerateEnvironment(altered, opts)
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}

// Domain and hostnames must be identical — they don't depend on IdentityServices.
if envA.Domain.DomainSID != envB.Domain.DomainSID {
Expand Down Expand Up @@ -297,8 +325,14 @@ func TestGenerateEnvironmentCARelativeValidityWindow(t *testing.T) {
seeds := NewSeedConfig()
seeds.Shared = 42

envA := GenerateEnvironment(seeds, &EnvironmentOpts{SystemCount: 1, UserCount: 1, GroupCount: 5})
envB := GenerateEnvironment(seeds, &EnvironmentOpts{SystemCount: 1, UserCount: 1, GroupCount: 5})
envA, err := GenerateEnvironment(seeds, &EnvironmentOpts{SystemCount: 1, UserCount: 1, GroupCount: 5})
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}
envB, err := GenerateEnvironment(seeds, &EnvironmentOpts{SystemCount: 1, UserCount: 1, GroupCount: 5})
if err != nil {
t.Fatalf("GenerateEnvironment: %v", err)
}

// Same relative span (both should be 10 years total, within tolerance).
wantSpan := envA.Domain.CA.ValidTo.Sub(envA.Domain.CA.ValidFrom)
Expand Down
7 changes: 5 additions & 2 deletions internal/datagen/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ func defaultDomainAdminsCount(userCount int) int {
// User selection for Domain Admins is sampled without replacement (Fisher-Yates
// partial shuffle), so no user appears twice in domainAdmins.MemberSIDs and
// no user has duplicate Domain Admins references in their GroupSIDs.
func GenerateGroups(seed int64, targetTotal int, adminCount int, domain *DomainIdentity, users []*UserIdentity) []*GroupIdentity {
func GenerateGroups(seed int64, targetTotal int, adminCount int, domain *DomainIdentity, users []*UserIdentity) ([]*GroupIdentity, error) {
if domain == nil {
return nil, fmt.Errorf("datagen: GenerateGroups: domain must not be nil")
}
r := rand.New(rand.NewSource(seed)) // #nosec G404

dcSuffix := domainToDC(domain.Name)
Expand Down Expand Up @@ -152,7 +155,7 @@ func GenerateGroups(seed int64, targetTotal int, adminCount int, domain *DomainI
// Assign users to groups
assignUsersToGroups(r, groups, users, adminCount)

return groups
return groups, nil
}

// assignUsersToGroups distributes users across groups: every user joins
Expand Down
Loading
Loading