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
108 changes: 108 additions & 0 deletions internal/datagen/hostnames.go
Original file line number Diff line number Diff line change
@@ -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
}
135 changes: 135 additions & 0 deletions internal/datagen/hostnames_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
}
102 changes: 102 additions & 0 deletions internal/datagen/seed.go
Original file line number Diff line number Diff line change
@@ -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)),
)
}
Loading
Loading