diff --git a/internal/datagen/hostnames.go b/internal/datagen/hostnames.go new file mode 100644 index 0000000..d3f7370 --- /dev/null +++ b/internal/datagen/hostnames.go @@ -0,0 +1,108 @@ +package datagen + +import ( + "fmt" + "math/rand" + "strings" +) + +// Mythology name pools — each with 26 names from their respective pantheon. +var ( + // NorseNames contains names from Norse mythology. Convention: Linux servers. + NorseNames = NewPool( + "odin", "thor", "freya", "loki", "tyr", "heimdall", "baldur", + "frigg", "sif", "bragi", "idun", "njord", "skadi", "vidar", "vali", + "forseti", "hermod", "hod", "mimir", "ran", "aegir", "fenrir", + "jormungandr", "hel", "surtr", "ymir", + ) + + // GreekNames contains names from Greek mythology. Convention: Domain Controllers. + GreekNames = NewPool( + "zeus", "athena", "apollo", "artemis", "hermes", "hera", + "poseidon", "demeter", "ares", "aphrodite", "hephaestus", "dionysus", + "hades", "persephone", "hecate", "nike", "iris", "eos", "selene", + "helios", "atlas", "prometheus", "pandora", "orpheus", "icarus", "chronos", + ) + + // RomanNames contains names from Roman mythology. Convention: Windows servers. + RomanNames = NewPool( + "jupiter", "minerva", "mars", "venus", "mercury", "diana", + "neptune", "ceres", "vulcan", "juno", "pluto", "bacchus", "saturn", + "aurora", "flora", "fortuna", "luna", "sol", "terra", "victoria", + "bellona", "faunus", "janus", "pax", "trivia", "vesta", + ) + + // EgyptianNames contains names from Egyptian mythology. Convention: Network appliances. + EgyptianNames = NewPool( + "ra", "isis", "osiris", "anubis", "horus", "thoth", + "bastet", "sekhmet", "ptah", "hathor", "sobek", "maat", "nut", + "geb", "tefnut", "shu", "nephthys", "set", "khonsu", "amon", + "wadjet", "neith", "serket", "khnum", "taweret", "bes", + ) + + // CelticNames contains names from Celtic mythology. Convention: macOS / dev workstations. + CelticNames = NewPool( + "brigid", "cernunnos", "morrigan", "lugh", "danu", "dagda", + "nuada", "ogma", "aengus", "boann", "manannan", "rhiannon", "arawn", + "belenus", "epona", "taranis", "mabon", "cerridwen", "gwydion", + "llyr", "blodeuwedd", "govannon", "arianrhod", "diancecht", "midir", "cliodhna", + ) + + // AllMythologyNames combines all mythology pools for general use. + AllMythologyNames = Merge(NorseNames, GreekNames, RomanNames, EgyptianNames, CelticNames) + + // Roles are server/machine role labels used in hostname generation. + Roles = NewPool( + "web", "db", "app", "api", "cache", + "worker", "proxy", "monitor", "log", "queue", + "mail", "dns", "auth", "vault", "ci", + ) +) + +// HostnameStyle controls the naming convention for generated hostnames. +type HostnameStyle int + +const ( + // StyleLinux produces hostnames like "thor-web-01". + StyleLinux HostnameStyle = iota + // StyleWindows produces hostnames like "THOR-WEB01". + StyleWindows + // StyleDC produces DC-style hostnames like "THOR-DC01". + StyleDC +) + +// GenerateHostname produces a single random hostname using the given style and name pool. +// If names is nil, defaults to AllMythologyNames. +func GenerateHostname(r *rand.Rand, style HostnameStyle, names *Pool[string]) string { + if names == nil { + names = AllMythologyNames + } + name := names.Random(r) + num := r.Intn(20) + 1 // #nosec G404 + + switch style { + case StyleLinux: + role := Roles.Random(r) + return fmt.Sprintf("%s-%s-%02d", strings.ToLower(name), strings.ToLower(role), num) + case StyleWindows: + role := Roles.Random(r) + return fmt.Sprintf("%s-%s%02d", strings.ToUpper(name), strings.ToUpper(role), num) + case StyleDC: + return fmt.Sprintf("%s-DC%02d", strings.ToUpper(name), num) + default: + role := Roles.Random(r) + return fmt.Sprintf("%s-%s-%02d", strings.ToLower(name), strings.ToLower(role), num) + } +} + +// GenerateHostnames produces a deterministic set of hostnames from a seed. +// The same seed + pool always produces the same set. +// If names is nil, defaults to AllMythologyNames. +func GenerateHostnames(seed int64, count int, style HostnameStyle, names *Pool[string]) []string { + r := rand.New(rand.NewSource(seed)) // #nosec G404 + hostnames := make([]string, count) + for i := range hostnames { + hostnames[i] = GenerateHostname(r, style, names) + } + return hostnames +} diff --git a/internal/datagen/hostnames_test.go b/internal/datagen/hostnames_test.go new file mode 100644 index 0000000..c94effd --- /dev/null +++ b/internal/datagen/hostnames_test.go @@ -0,0 +1,135 @@ +package datagen + +import ( + "math/rand" + "strings" + "testing" +) + +func TestMythologyPoolSizes(t *testing.T) { + pools := map[string]*Pool[string]{ + "Norse": NorseNames, + "Greek": GreekNames, + "Roman": RomanNames, + "Egyptian": EgyptianNames, + "Celtic": CelticNames, + } + for name, p := range pools { + if p.Len() < 25 { + t.Errorf("%s pool has %d names, want at least 25", name, p.Len()) + } + } +} + +func TestAllMythologyNames(t *testing.T) { + expected := NorseNames.Len() + GreekNames.Len() + RomanNames.Len() + + EgyptianNames.Len() + CelticNames.Len() + if AllMythologyNames.Len() != expected { + t.Errorf("AllMythologyNames has %d names, want %d", AllMythologyNames.Len(), expected) + } +} + +func TestRolePool(t *testing.T) { + if Roles.Len() < 10 { + t.Errorf("Roles pool has %d items, want at least 10", Roles.Len()) + } +} + +func TestGenerateHostname(t *testing.T) { + r := rand.New(rand.NewSource(42)) + + t.Run("linux style", func(t *testing.T) { + h := GenerateHostname(r, StyleLinux, NorseNames) + // Should match pattern: name-role-nn + parts := strings.Split(h, "-") + if len(parts) != 3 { + t.Errorf("linux hostname %q should have 3 dash-separated parts", h) + } + if h != strings.ToLower(h) { + t.Errorf("linux hostname %q should be lowercase", h) + } + }) + + t.Run("windows style", func(t *testing.T) { + h := GenerateHostname(r, StyleWindows, RomanNames) + // Should match pattern: NAME-ROLENN + if h != strings.ToUpper(h) { + t.Errorf("windows hostname %q should be uppercase", h) + } + if !strings.Contains(h, "-") { + t.Errorf("windows hostname %q should contain a dash", h) + } + }) + + t.Run("dc style", func(t *testing.T) { + h := GenerateHostname(r, StyleDC, GreekNames) + // Should match pattern: NAME-DCnn + if h != strings.ToUpper(h) { + t.Errorf("dc hostname %q should be uppercase", h) + } + if !strings.Contains(h, "-DC") { + t.Errorf("dc hostname %q should contain -DC", h) + } + }) + + t.Run("nil names defaults to AllMythologyNames", func(t *testing.T) { + h := GenerateHostname(r, StyleLinux, nil) + if h == "" { + t.Error("hostname should not be empty with nil names pool") + } + }) +} + +func TestGenerateHostnames(t *testing.T) { + t.Run("deterministic with same seed", func(t *testing.T) { + h1 := GenerateHostnames(42, 5, StyleLinux, NorseNames) + h2 := GenerateHostnames(42, 5, StyleLinux, NorseNames) + if len(h1) != len(h2) { + t.Fatalf("different lengths: %d vs %d", len(h1), len(h2)) + } + for i := range h1 { + if h1[i] != h2[i] { + t.Errorf("hostname[%d]: %q != %q", i, h1[i], h2[i]) + } + } + }) + + t.Run("different seeds produce different hostnames", func(t *testing.T) { + h1 := GenerateHostnames(1, 10, StyleLinux, NorseNames) + h2 := GenerateHostnames(2, 10, StyleLinux, NorseNames) + allSame := true + for i := range h1 { + if h1[i] != h2[i] { + allSame = false + break + } + } + if allSame { + t.Error("different seeds produced identical hostnames") + } + }) + + t.Run("requested count is honored", func(t *testing.T) { + h := GenerateHostnames(42, 7, StyleWindows, RomanNames) + if len(h) != 7 { + t.Errorf("expected 7 hostnames, got %d", len(h)) + } + }) +} + +func TestHostnameFormats(t *testing.T) { + r := rand.New(rand.NewSource(1)) + + // Generate a bunch and check they aren't empty + for i := 0; i < 50; i++ { + for _, style := range []HostnameStyle{StyleLinux, StyleWindows, StyleDC} { + h := GenerateHostname(r, style, AllMythologyNames) + if h == "" { + t.Errorf("empty hostname for style %d", style) + } + if len(h) > 30 { + t.Errorf("hostname %q is too long (%d chars)", h, len(h)) + } + } + } +} diff --git a/internal/datagen/seed.go b/internal/datagen/seed.go new file mode 100644 index 0000000..bdcd112 --- /dev/null +++ b/internal/datagen/seed.go @@ -0,0 +1,102 @@ +package datagen + +import ( + "math/rand" + "time" + + "go.uber.org/zap" +) + +// IdentityType is a typed identifier for per-identity-type seed overrides. +// Use the exported constants below; callers passing an unrecognized value +// fall back to SeedConfig.Shared. +type IdentityType string + +const ( + IdentitySystems IdentityType = "systems" + IdentityUsers IdentityType = "users" + IdentityGroups IdentityType = "groups" + IdentityServices IdentityType = "services" + IdentityApplications IdentityType = "applications" + IdentityNetworks IdentityType = "networks" +) + +// SeedConfig controls deterministic generation across all identity types. +// +// Contract: any negative value (e.g. -1) means "randomize"; 0 and positive +// values are used verbatim as deterministic seeds. Use NewSeedConfig to +// obtain a config whose fields default to -1 (randomize), so that omitted +// YAML/config keys produce randomized runs. A bare &SeedConfig{} struct +// literal yields all zero values, which the contract interprets as +// deterministic seed 0 across the board. +type SeedConfig struct { + // Shared seed for all identity types. <0 = randomize at Init. + Shared int64 + + // Per-identity-type overrides. <0 = fall back to Shared for that type. + Systems int64 + Users int64 + Groups int64 + Services int64 + Applications int64 + Networks int64 +} + +// NewSeedConfig returns a SeedConfig with every field set to -1 so that an +// uninitialized configuration randomizes at Init. Callers that hydrate +// SeedConfig from YAML/viper should set the same -1 default per field. +func NewSeedConfig() *SeedConfig { + return &SeedConfig{ + Shared: -1, + Systems: -1, + Users: -1, + Groups: -1, + Services: -1, + Applications: -1, + Networks: -1, + } +} + +// ResolveSeed returns the effective seed for a given identity type. +// A non-negative per-type override wins; negative overrides fall back to +// Shared. Init must be called first if Shared was negative, so callers always +// observe a non-negative Shared value. +func (s *SeedConfig) ResolveSeed(identityType IdentityType) int64 { + var override int64 = -1 + switch identityType { + case IdentitySystems: + override = s.Systems + case IdentityUsers: + override = s.Users + case IdentityGroups: + override = s.Groups + case IdentityServices: + override = s.Services + case IdentityApplications: + override = s.Applications + case IdentityNetworks: + override = s.Networks + } + if override >= 0 { + return override + } + return s.Shared +} + +// Init generates a random Shared seed if Shared is negative and logs all +// effective seeds. +func (s *SeedConfig) Init(logger *zap.Logger) { + if s.Shared < 0 { + s.Shared = rand.New(rand.NewSource(time.Now().UnixNano())).Int63() // #nosec G404 + } + + logger.Info("datagen seeds initialized", + zap.Int64("shared", s.Shared), + zap.Int64("systems", s.ResolveSeed(IdentitySystems)), + zap.Int64("users", s.ResolveSeed(IdentityUsers)), + zap.Int64("groups", s.ResolveSeed(IdentityGroups)), + zap.Int64("services", s.ResolveSeed(IdentityServices)), + zap.Int64("applications", s.ResolveSeed(IdentityApplications)), + zap.Int64("networks", s.ResolveSeed(IdentityNetworks)), + ) +} diff --git a/internal/datagen/seed_test.go b/internal/datagen/seed_test.go new file mode 100644 index 0000000..a48980a --- /dev/null +++ b/internal/datagen/seed_test.go @@ -0,0 +1,127 @@ +package datagen + +import ( + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest" +) + +func TestSeedConfigResolveSeed(t *testing.T) { + t.Run("returns shared seed when no override", func(t *testing.T) { + sc := &SeedConfig{Shared: 12345, Systems: -1} + if got := sc.ResolveSeed(IdentitySystems); got != 12345 { + t.Errorf("ResolveSeed(IdentitySystems) = %d, want 12345", got) + } + }) + + t.Run("returns per-type override when set", func(t *testing.T) { + sc := &SeedConfig{Shared: 12345, Systems: 99999, Users: -1} + if got := sc.ResolveSeed(IdentitySystems); got != 99999 { + t.Errorf("ResolveSeed(IdentitySystems) = %d, want 99999", got) + } + if got := sc.ResolveSeed(IdentityUsers); got != 12345 { + t.Errorf("ResolveSeed(IdentityUsers) with override=-1 = %d, want fallback to 12345", got) + } + }) + + t.Run("override of 0 is a deterministic seed, not a fallback", func(t *testing.T) { + sc := &SeedConfig{Shared: 12345, Systems: 0} + if got := sc.ResolveSeed(IdentitySystems); got != 0 { + t.Errorf("ResolveSeed(IdentitySystems) with override=0 = %d, want 0 (deterministic)", got) + } + }) + + t.Run("all per-type overrides work", func(t *testing.T) { + sc := &SeedConfig{ + Shared: 100, + Systems: 1, + Users: 2, + Groups: 3, + Services: 4, + Applications: 5, + Networks: 6, + } + expected := map[IdentityType]int64{ + IdentitySystems: 1, + IdentityUsers: 2, + IdentityGroups: 3, + IdentityServices: 4, + IdentityApplications: 5, + IdentityNetworks: 6, + } + for name, want := range expected { + if got := sc.ResolveSeed(name); got != want { + t.Errorf("ResolveSeed(%s) = %d, want %d", name, got, want) + } + } + }) + + t.Run("unknown type returns shared", func(t *testing.T) { + sc := &SeedConfig{Shared: 42} + if got := sc.ResolveSeed(IdentityType("unknown_type")); got != 42 { + t.Errorf("ResolveSeed(unknown_type) = %d, want 42", got) + } + }) + + t.Run("zero-value struct yields deterministic seed 0", func(t *testing.T) { + sc := &SeedConfig{} + if got := sc.ResolveSeed(IdentitySystems); got != 0 { + t.Errorf("zero-value SeedConfig ResolveSeed = %d, want 0 (deterministic)", got) + } + }) +} + +func TestSeedConfigInit(t *testing.T) { + t.Run("randomizes when shared is negative", func(t *testing.T) { + sc := &SeedConfig{Shared: -1} + logger := zaptest.NewLogger(t) + sc.Init(logger) + if sc.Shared < 0 { + t.Errorf("Init() should set Shared to a non-negative random value, got %d", sc.Shared) + } + }) + + t.Run("preserves explicit shared seed of 0 as deterministic", func(t *testing.T) { + sc := &SeedConfig{Shared: 0} + logger := zaptest.NewLogger(t) + sc.Init(logger) + if sc.Shared != 0 { + t.Errorf("Init() changed deterministic Shared=0 to %d", sc.Shared) + } + }) + + t.Run("preserves explicit positive shared seed", func(t *testing.T) { + sc := &SeedConfig{Shared: 42} + logger := zaptest.NewLogger(t) + sc.Init(logger) + if sc.Shared != 42 { + t.Errorf("Init() changed Shared from 42 to %d", sc.Shared) + } + }) + + t.Run("randomizes for any negative shared value", func(t *testing.T) { + sc := &SeedConfig{Shared: -42} + logger := zaptest.NewLogger(t) + sc.Init(logger) + if sc.Shared < 0 { + t.Errorf("Init() should randomize when Shared=-42, got %d", sc.Shared) + } + }) + + t.Run("nop logger does not panic", func(t *testing.T) { + sc := &SeedConfig{Shared: 1} + sc.Init(zap.NewNop()) + }) +} + +func TestNewSeedConfig(t *testing.T) { + sc := NewSeedConfig() + if sc.Shared != -1 { + t.Errorf("NewSeedConfig().Shared = %d, want -1", sc.Shared) + } + if sc.Systems != -1 || sc.Users != -1 || sc.Groups != -1 || + sc.Services != -1 || sc.Applications != -1 || sc.Networks != -1 { + t.Errorf("NewSeedConfig() did not initialize all per-type fields to -1: %+v", sc) + } +}