diff --git a/docs/datagen.md b/docs/datagen.md index f29e0ac..b5c59de 100644 --- a/docs/datagen.md +++ b/docs/datagen.md @@ -190,3 +190,84 @@ For generators that need the full identity hierarchy (e.g., Windows Event Log ge user := env.Users[r.Intn(len(env.Users))] system := env.Systems[r.Intn(len(env.Systems))] ``` + +## Appliance Identities + +Alongside the general-purpose `SystemIdentity`, datagen models purpose-built +appliances that run an embedded `ApplianceOS` rather than a general-purpose OS. +The classes modeled so far are storage arrays (`StorageSystemIdentity`) and +network hardware (`NetworkSystemIdentity`); further appliance classes follow the +same pattern (identity struct, vendor pools, `Validate()`, and Environment +composition). + +### `ApplianceOS` taxonomy (`appliance.go`) + +Appliances ship closed-source embedded OSes (NimbleOS, PAN-OS, NX-OS, …) that +are not "Linux" from any consumer's perspective — different kernel, management +plane, lifecycle, and telemetry. They are tracked separately from the +general-purpose `OSType` enum via an `ApplianceOS{Vendor, Family, Version}` +triple. + +The family and version strings, and the OS self-report rendered by +`ApplianceOS.String()`, are the **real forms a device reports**, so generated +records parse the way genuine device output does: + +| Family | `String()` self-report | +|--------|------------------------| +| NimbleOS | `NimbleOS 6.1.2.0` | +| HPE 3PAR OS | `HPE 3PAR OS 3.3.1.410` | +| BIG-IP | `BIG-IP 17.1.0.3` | +| Cisco IOS XE | `Cisco IOS XE Software, Version 17.12.3` | +| Cisco NX-OS | `Cisco Nexus Operating System (NX-OS) Software, Version 10.3(4a)` | +| Arista EOS | `Arista EOS 4.31.2F` | +| Junos | `Junos: 23.4R1` | +| PAN-OS | `PAN-OS 11.1.3` | +| FortiOS | `FortiOS v7.4.3` | + +Each family maps to exactly one vendor, so vendor/family coherence is +structural: a NimbleOS is always HPE and can never carry another vendor's +family. + +### `StorageSystemIdentity` (`storage.go`) + +A first-class storage array: vendor/model/serial, an `ApplianceOS`, storage +fabric identifiers (`WWN`, `WWPN[]`, `NAA`, `IQN`), a capacity model +(raw/usable/effective plus data-reduction ratio), and hardware inventory +(controllers, shelves, drives). The first vendor pool covers HPE Nimble, 3PAR, +Alletra, and StoreOnce. `Validate()` checks fabric-ID formats and capacity +sanity. + +### `NetworkSystemIdentity` (`network_appliance.go`) + +A first-class network device composed from capability facets — any of +`L2SwitchingCapability`, `L3RoutingCapability`, `FirewallCapability`, +`LoadBalancingCapability`, `WirelessCapability` (a nil facet means the device +lacks that capability). Real products compose facets: a Catalyst 9300 is L2 + +L3, a BIG-IP is load-balancing + firewall + L3, a PA-3220 is firewall + L3. The +first vendor pool spans F5, Cisco (IOS-XE and NX-OS), Arista, Juniper, Palo +Alto, and Fortinet. `Validate()` requires vendor/OS coherence and at least one +facet. + +### Appliance hostnames + +`StyleAppliance` produces hostnames like `nimble-core-east-01`, combining a +vendor short-code with a role (`ApplianceRoles`) and site (`ApplianceSites`). + +### Environment composition + +`GenerateEnvironment` composes `StorageSystems` and `NetworkSystems` alongside +the general-purpose `Systems`, each driven by its own seed +(`SeedConfig.StorageSystems` / `SeedConfig.NetworkSystems`, both falling back to +`Shared` when negative). Counts come from `EnvironmentOpts.StorageSystemCount` +(default 2) and `NetworkSystemCount` (default 4). Each appliance's management +interface is bound to the environment's management subnet. + +```go +env := datagen.GenerateEnvironment(seeds, &datagen.EnvironmentOpts{ + StorageSystemCount: 3, + NetworkSystemCount: 5, +}) +for _, array := range env.AllStorageSystems() { + fmt.Println(array.Model, array.OS) // e.g. "Nimble AF40 NimbleOS 6.1.2.0" +} +``` diff --git a/internal/datagen/appliance.go b/internal/datagen/appliance.go new file mode 100644 index 0000000..53dc625 --- /dev/null +++ b/internal/datagen/appliance.go @@ -0,0 +1,152 @@ +package datagen + +import ( + "fmt" + "math/rand" + "regexp" +) + +// ApplianceVendor identifies the maker of an embedded/appliance operating +// system, spelled as the vendor is named in device telemetry. Appliance +// vendors ship closed-source OSes on storage arrays and network hardware that +// are not "Linux" from any consumer's perspective, so they are tracked +// separately from datagen's general-purpose OSType. +type ApplianceVendor string + +const ( + VendorHPE ApplianceVendor = "HPE" + VendorF5 ApplianceVendor = "F5" + VendorCisco ApplianceVendor = "Cisco" + VendorArista ApplianceVendor = "Arista" + VendorJuniper ApplianceVendor = "Juniper" + VendorPaloAlto ApplianceVendor = "Palo Alto Networks" + VendorFortinet ApplianceVendor = "Fortinet" +) + +// ApplianceOSFamily identifies the OS family running on an appliance, spelled +// as the device reports it (e.g. via "show version" / SNMP sysDescr / vendor +// API). Each family maps to exactly one vendor (see applianceOSVendor). +type ApplianceOSFamily string + +const ( + FamilyNimbleOS ApplianceOSFamily = "NimbleOS" // HPE Nimble arrays + Family3PAROS ApplianceOSFamily = "HPE 3PAR OS" // HPE 3PAR arrays + FamilyAlletraOS ApplianceOSFamily = "Array OS" // HPE Alletra 6000 (NimbleOS lineage) + FamilyStoreOnceOS ApplianceOSFamily = "HPE StoreOnce" // HPE StoreOnce backup appliances + FamilyBIGIP ApplianceOSFamily = "BIG-IP" // F5 (TMOS is the underlying OS; devices report "BIG-IP") + FamilyIOSXE ApplianceOSFamily = "Cisco IOS XE" // Cisco Catalyst / IOS-XE routers & switches + FamilyNXOS ApplianceOSFamily = "Cisco NX-OS" // Cisco Nexus data-center switches + FamilyEOS ApplianceOSFamily = "Arista EOS" // Arista switches + FamilyJunos ApplianceOSFamily = "Junos" // Juniper routers, switches, SRX firewalls + FamilyPANOS ApplianceOSFamily = "PAN-OS" // Palo Alto Networks NGFWs + FamilyFortiOS ApplianceOSFamily = "FortiOS" // Fortinet FortiGate NGFWs +) + +// ApplianceOS is the {Vendor, Family, Version} triple describing the embedded +// OS an appliance runs, e.g. {HPE, NimbleOS, 6.1.2.0} or {F5, BIG-IP, +// 17.1.0.3}. String renders the OS the way the device itself reports it. +type ApplianceOS struct { + Vendor ApplianceVendor + Family ApplianceOSFamily + Version string +} + +// applianceVersionRE matches a dotted version with a leading numeric segment +// and at least one more dot-separated segment. It accepts the real formats +// appliance OSes report: pure-numeric (NimbleOS "6.1.2.0", IOS-XE "17.12.3"), +// letter-suffixed (EOS "4.31.2F", Junos "23.4R1"), and parenthesized builds +// (NX-OS "10.3(4a)"), while rejecting non-versions like "latest" and +// single-segment values like "6". +var applianceVersionRE = regexp.MustCompile(`^\d+(\.[0-9A-Za-z()\-]+)+$`) + +// applianceOSSelfReportFmt overrides how specific families render their OS +// self-report. Families not listed fall back to " ", which is +// already correct for NimbleOS, BIG-IP, PAN-OS, Arista EOS, and the HPE storage +// families. The overrides capture the vendor's exact phrasing. +var applianceOSSelfReportFmt = map[ApplianceOSFamily]string{ + FamilyIOSXE: "Cisco IOS XE Software, Version %s", + FamilyNXOS: "Cisco Nexus Operating System (NX-OS) Software, Version %s", + FamilyJunos: "Junos: %s", + FamilyFortiOS: "FortiOS v%s", +} + +// String renders the OS the way the device reports it, e.g. "NimbleOS 6.1.2.0", +// "Junos: 23.4R1", or "FortiOS v7.4.3". +func (a ApplianceOS) String() string { + if f, ok := applianceOSSelfReportFmt[a.Family]; ok { + return fmt.Sprintf(f, a.Version) + } + return fmt.Sprintf("%s %s", a.Family, a.Version) +} + +// Validate reports whether the ApplianceOS is well-formed: non-empty vendor, +// family, and a dotted version in one of the real appliance formats. Returns an +// error rather than panicking, per the datagen error-return convention +// (PIPE-1003). +func (a ApplianceOS) Validate() error { + if a.Vendor == "" { + return fmt.Errorf("appliance OS vendor must not be empty") + } + if a.Family == "" { + return fmt.Errorf("appliance OS family must not be empty") + } + if a.Version == "" { + return fmt.Errorf("appliance OS version must not be empty") + } + if !applianceVersionRE.MatchString(a.Version) { + return fmt.Errorf("appliance OS version %q is not a recognized version format", a.Version) + } + return nil +} + +// applianceOSVendor maps each OS family to its one true vendor. This is what +// makes vendor/family coherence structural: a NimbleOS is always HPE. +var applianceOSVendor = map[ApplianceOSFamily]ApplianceVendor{ + FamilyNimbleOS: VendorHPE, + Family3PAROS: VendorHPE, + FamilyAlletraOS: VendorHPE, + FamilyStoreOnceOS: VendorHPE, + FamilyBIGIP: VendorF5, + FamilyIOSXE: VendorCisco, + FamilyNXOS: VendorCisco, + FamilyEOS: VendorArista, + FamilyJunos: VendorJuniper, + FamilyPANOS: VendorPaloAlto, + FamilyFortiOS: VendorFortinet, +} + +// applianceOSVersions holds real published version strings per family, in the +// format the device reports (note NX-OS's parenthesized build and the +// letter-suffixed network-OS releases). Representative, not exhaustive. +var applianceOSVersions = map[ApplianceOSFamily][]string{ + FamilyNimbleOS: {"6.1.2.0", "6.1.1.100", "6.0.0.400", "5.3.1.0"}, + Family3PAROS: {"3.3.1.410", "3.3.1.485", "3.3.1.215"}, + FamilyAlletraOS: {"6.1.2.502", "6.1.2.400", "6.0.0.900"}, + FamilyStoreOnceOS: {"4.3.13", "4.3.9", "4.2.3"}, + FamilyBIGIP: {"17.1.0.3", "16.1.4", "15.1.10.2"}, + FamilyIOSXE: {"17.12.3", "17.9.4", "17.6.5"}, + FamilyNXOS: {"10.3(4a)", "10.2(5)", "9.3(12)"}, + FamilyEOS: {"4.31.2F", "4.30.4M", "4.29.6M"}, + FamilyJunos: {"23.4R1", "22.4R3", "21.4R3"}, + FamilyPANOS: {"11.1.3", "11.0.4", "10.2.9"}, + FamilyFortiOS: {"7.4.3", "7.2.8", "7.0.14"}, +} + +// GenerateApplianceOS returns a valid ApplianceOS for the given family with a +// random real version drawn from that family's version pool. The vendor is +// derived from the family, so a NimbleOS result is always HPE and can never +// carry another vendor's family. An unknown family yields a zero vendor and a +// "0.0" placeholder version, which Validate rejects — callers should pass a +// known family constant. +func GenerateApplianceOS(r *rand.Rand, family ApplianceOSFamily) ApplianceOS { + versions := applianceOSVersions[family] + version := "0.0" + if len(versions) > 0 { + version = versions[r.Intn(len(versions))] // #nosec G404 + } + return ApplianceOS{ + Vendor: applianceOSVendor[family], + Family: family, + Version: version, + } +} diff --git a/internal/datagen/appliance_test.go b/internal/datagen/appliance_test.go new file mode 100644 index 0000000..f469679 --- /dev/null +++ b/internal/datagen/appliance_test.go @@ -0,0 +1,94 @@ +package datagen + +import ( + "math/rand" + "testing" +) + +func TestApplianceOS_String(t *testing.T) { + tests := []struct { + os ApplianceOS + want string + }{ + {ApplianceOS{VendorHPE, FamilyNimbleOS, "6.1.2.0"}, "NimbleOS 6.1.2.0"}, + {ApplianceOS{VendorHPE, FamilyStoreOnceOS, "4.3.13"}, "HPE StoreOnce 4.3.13"}, + {ApplianceOS{VendorF5, FamilyBIGIP, "17.1.0.3"}, "BIG-IP 17.1.0.3"}, + {ApplianceOS{VendorCisco, FamilyIOSXE, "17.12.3"}, "Cisco IOS XE Software, Version 17.12.3"}, + {ApplianceOS{VendorCisco, FamilyNXOS, "10.3(4a)"}, "Cisco Nexus Operating System (NX-OS) Software, Version 10.3(4a)"}, + {ApplianceOS{VendorJuniper, FamilyJunos, "23.4R1"}, "Junos: 23.4R1"}, + {ApplianceOS{VendorFortinet, FamilyFortiOS, "7.4.3"}, "FortiOS v7.4.3"}, + {ApplianceOS{VendorPaloAlto, FamilyPANOS, "11.1.3"}, "PAN-OS 11.1.3"}, + } + for _, tt := range tests { + if got := tt.os.String(); got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + } +} + +func TestApplianceOS_Validate(t *testing.T) { + tests := []struct { + name string + os ApplianceOS + wantErr bool + }{ + {"valid", ApplianceOS{VendorHPE, FamilyNimbleOS, "6.1.2.0"}, false}, + {"valid three-segment", ApplianceOS{VendorCisco, FamilyIOSXE, "17.12.3"}, false}, + {"valid vendor-suffixed", ApplianceOS{VendorJuniper, FamilyJunos, "23.4R1"}, false}, + {"missing vendor", ApplianceOS{"", FamilyNimbleOS, "6.1.2.0"}, true}, + {"missing family", ApplianceOS{VendorHPE, "", "6.1.2.0"}, true}, + {"missing version", ApplianceOS{VendorHPE, FamilyNimbleOS, ""}, true}, + {"non-numeric version", ApplianceOS{VendorHPE, FamilyNimbleOS, "latest"}, true}, + {"single-segment version", ApplianceOS{VendorHPE, FamilyNimbleOS, "6"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.os.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// knownApplianceFamilies is every family GenerateApplianceOS must support. +var knownApplianceFamilies = []ApplianceOSFamily{ + FamilyNimbleOS, Family3PAROS, FamilyAlletraOS, FamilyStoreOnceOS, + FamilyBIGIP, FamilyIOSXE, FamilyNXOS, FamilyEOS, FamilyJunos, FamilyPANOS, FamilyFortiOS, +} + +func TestGenerateApplianceOS_AllFamiliesValid(t *testing.T) { + r := rand.New(rand.NewSource(42)) + for _, f := range knownApplianceFamilies { + os := GenerateApplianceOS(r, f) + if err := os.Validate(); err != nil { + t.Errorf("GenerateApplianceOS(%q) produced invalid OS %v: %v", f, os, err) + } + if os.Family != f { + t.Errorf("GenerateApplianceOS(%q) family = %q, want %q", f, os.Family, f) + } + if os.Vendor == "" { + t.Errorf("GenerateApplianceOS(%q) has empty vendor", f) + } + } +} + +func TestGenerateApplianceOS_Deterministic(t *testing.T) { + a := GenerateApplianceOS(rand.New(rand.NewSource(7)), FamilyNimbleOS) + b := GenerateApplianceOS(rand.New(rand.NewSource(7)), FamilyNimbleOS) + if a != b { + t.Errorf("same seed produced different results: %v vs %v", a, b) + } +} + +func TestGenerateApplianceOS_VendorCoherence(t *testing.T) { + // A family always resolves to its one true vendor; NimbleOS is HPE and can + // never drift to F5's tmos. + os := GenerateApplianceOS(rand.New(rand.NewSource(1)), FamilyNimbleOS) + if os.Vendor != VendorHPE { + t.Errorf("NimbleOS vendor = %q, want %q", os.Vendor, VendorHPE) + } + if os.Family == FamilyBIGIP { + t.Error("NimbleOS family drifted to BIG-IP") + } +} diff --git a/internal/datagen/environment.go b/internal/datagen/environment.go index 03697b1..c6c27d1 100644 --- a/internal/datagen/environment.go +++ b/internal/datagen/environment.go @@ -18,22 +18,32 @@ import ( // For fully reproducible tests/snapshots, set opts.Now to a fixed // timestamp; the rest of the Environment derives from the seeds. type Environment struct { - Domain *DomainIdentity - Networks []*NetworkIdentity - Users []*UserIdentity - Groups []*GroupIdentity - Systems []*SystemIdentity + Domain *DomainIdentity + Networks []*NetworkIdentity + Users []*UserIdentity + Groups []*GroupIdentity + Systems []*SystemIdentity + StorageSystems []*StorageSystemIdentity + NetworkSystems []*NetworkSystemIdentity } +// AllStorageSystems returns the environment's storage-array identities. +func (e *Environment) AllStorageSystems() []*StorageSystemIdentity { return e.StorageSystems } + +// AllNetworkSystems returns the environment's network-hardware identities. +func (e *Environment) AllNetworkSystems() []*NetworkSystemIdentity { return e.NetworkSystems } + // EnvironmentOpts controls the size and shape of the generated environment. type EnvironmentOpts struct { - DomainName string // e.g., "contoso.com". Default: "blitz.local" - SystemCount int // number of machines. Default: 20 - UserCount int // number of users. Default: 50 - GroupCount int // number of groups. Default: 10 - NetworkCount int // number of subnets. Default: 4. Values beyond the built-in default catalog are synthesized using IdentityNetworks. - DomainAdminsCount int // exact Domain Admins membership; 0 (or negative) = use the user-count-scaled default in the datagen package. - Now time.Time // wall-clock anchor for time-dependent fields (e.g. CertAuthority validity window). Zero value = time.Now() at GenerateEnvironment call; see Environment docstring for determinism implications. + DomainName string // e.g., "contoso.com". Default: "blitz.local" + SystemCount int // number of machines. Default: 20 + UserCount int // number of users. Default: 50 + GroupCount int // number of groups. Default: 10 + NetworkCount int // number of subnets. Default: 4. Values beyond the built-in default catalog are synthesized using IdentityNetworks. + StorageSystemCount int // number of storage arrays. Default: 2. + NetworkSystemCount int // number of network devices. Default: 4. + DomainAdminsCount int // exact Domain Admins membership; 0 (or negative) = use the user-count-scaled default in the datagen package. + Now time.Time // wall-clock anchor for time-dependent fields (e.g. CertAuthority validity window). Zero value = time.Now() at GenerateEnvironment call; see Environment docstring for determinism implications. } // defaultOpts returns EnvironmentOpts with defaults applied. @@ -56,6 +66,12 @@ func defaultOpts(opts *EnvironmentOpts) *EnvironmentOpts { if opts.NetworkCount <= 0 { opts.NetworkCount = 4 } + if opts.StorageSystemCount <= 0 { + opts.StorageSystemCount = 2 + } + if opts.NetworkSystemCount <= 0 { + opts.NetworkSystemCount = 4 + } if opts.Now.IsZero() { opts.Now = time.Now() } @@ -106,13 +122,76 @@ func GenerateEnvironment(seeds *SeedConfig, opts *EnvironmentOpts) *Environment applicationsSeed := seeds.ResolveSeed(IdentityApplications) systems := generateSystems(systemSeed, servicesSeed, applicationsSeed, opts.SystemCount, domain, networks) + // Appliance identities (PIPE-1035): storage arrays and network hardware, + // each with its own seed and a management interface bound to a subnet. + storageSeed := seeds.ResolveSeed(IdentityStorageSystems) + storageSystems := generateStorageSystems(storageSeed, opts.StorageSystemCount, networks) + + networkSystemSeed := seeds.ResolveSeed(IdentityNetworkSystems) + networkSystems := generateNetworkSystems(networkSystemSeed, opts.NetworkSystemCount, networks) + return &Environment{ - Domain: domain, - Networks: networks, - Users: users, - Groups: groups, - Systems: systems, + Domain: domain, + Networks: networks, + Users: users, + Groups: groups, + Systems: systems, + StorageSystems: storageSystems, + NetworkSystems: networkSystems, + } +} + +// managementNetwork returns the subnet to bind appliance management interfaces +// to: the "management" zone if present, else the first network, else nil. +func managementNetwork(networks []*NetworkIdentity) *NetworkIdentity { + for _, n := range networks { + if n.Zone == "management" { + return n + } + } + if len(networks) > 0 { + return networks[0] + } + return nil +} + +// bindManagementInterface points a management interface at a subnet, giving it +// an in-CIDR address and the subnet ID. A nil interface or subnet is left +// untouched. +func bindManagementInterface(r *rand.Rand, iface *NetworkInterface, subnet *NetworkIdentity) { + if iface == nil || subnet == nil { + return + } + iface.IPv4 = RandomIPInCIDR(r, subnet.CIDR) + iface.SubnetID = subnet.ID +} + +// generateStorageSystems builds count storage arrays from seed, binding each +// management interface to a management subnet. +func generateStorageSystems(seed int64, count int, networks []*NetworkIdentity) []*StorageSystemIdentity { + r := rand.New(rand.NewSource(seed)) // #nosec G404 + mgmt := managementNetwork(networks) + out := make([]*StorageSystemIdentity, count) + for i := range out { + s := RandomStorageSystemIdentity(r) + bindManagementInterface(r, s.ManagementInterface, mgmt) + out[i] = s + } + return out +} + +// generateNetworkSystems builds count network devices from seed, binding each +// management interface to a management subnet. +func generateNetworkSystems(seed int64, count int, networks []*NetworkIdentity) []*NetworkSystemIdentity { + r := rand.New(rand.NewSource(seed)) // #nosec G404 + mgmt := managementNetwork(networks) + out := make([]*NetworkSystemIdentity, count) + for i := range out { + n := RandomNetworkSystemIdentity(r) + bindManagementInterface(r, n.ManagementInterface, mgmt) + out[i] = n } + return out } // generateNetworksList returns the requested number of NetworkIdentity entries, diff --git a/internal/datagen/environment_test.go b/internal/datagen/environment_test.go index 8f62ae1..b6bf965 100644 --- a/internal/datagen/environment_test.go +++ b/internal/datagen/environment_test.go @@ -1,6 +1,7 @@ package datagen import ( + "math/rand" "testing" "time" ) @@ -84,6 +85,80 @@ func TestGenerateEnvironment(t *testing.T) { }) } +func TestGenerateEnvironmentComposesAppliances(t *testing.T) { + env := GenerateEnvironment(&SeedConfig{Shared: 42}, &EnvironmentOpts{StorageSystemCount: 3, NetworkSystemCount: 5}) + + if len(env.StorageSystems) != 3 { + t.Fatalf("StorageSystems = %d, want 3", len(env.StorageSystems)) + } + if len(env.NetworkSystems) != 5 { + t.Fatalf("NetworkSystems = %d, want 5", len(env.NetworkSystems)) + } + for _, s := range env.AllStorageSystems() { + if err := s.Validate(); err != nil { + t.Errorf("storage %s invalid: %v", s.Model, err) + } + if s.ManagementInterface.SubnetID == "" { + t.Errorf("storage %s management interface not bound to a subnet", s.Model) + } + } + for _, n := range env.AllNetworkSystems() { + if err := n.Validate(); err != nil { + t.Errorf("network %s invalid: %v", n.Model, err) + } + if n.ManagementInterface.SubnetID == "" { + t.Errorf("network %s management interface not bound to a subnet", n.Model) + } + } +} + +func TestGenerateEnvironmentDeterministicAppliances(t *testing.T) { + mk := func() *Environment { + s := NewSeedConfig() + s.Shared = 7 + return GenerateEnvironment(s, &EnvironmentOpts{StorageSystemCount: 2, NetworkSystemCount: 2}) + } + a, b := mk(), mk() + if a.StorageSystems[0].Serial != b.StorageSystems[0].Serial { + t.Error("storage systems not deterministic across runs") + } + if a.NetworkSystems[0].Serial != b.NetworkSystems[0].Serial { + t.Error("network systems not deterministic across runs") + } +} + +func TestManagementNetwork(t *testing.T) { + mgmt := &NetworkIdentity{ID: "net-mgmt", Zone: "management", CIDR: "10.0.0.0/24"} + trust := &NetworkIdentity{ID: "net-trust", Zone: "trust", CIDR: "10.1.0.0/24"} + if got := managementNetwork([]*NetworkIdentity{trust, mgmt}); got != mgmt { + t.Errorf("want the management-zone network, got %v", got) + } + if got := managementNetwork([]*NetworkIdentity{trust}); got != trust { + t.Errorf("want the first network as fallback, got %v", got) + } + if got := managementNetwork(nil); got != nil { + t.Errorf("want nil for empty networks, got %v", got) + } +} + +func TestBindManagementInterface(t *testing.T) { + r := rand.New(rand.NewSource(1)) + iface := &NetworkInterface{Name: "mgmt0", IPv4: "192.168.1.5"} + + // A nil subnet leaves the interface untouched. + bindManagementInterface(r, iface, nil) + if iface.SubnetID != "" { + t.Errorf("nil subnet should leave SubnetID empty, got %q", iface.SubnetID) + } + // A nil interface is a no-op and must not panic. + bindManagementInterface(r, nil, &NetworkIdentity{ID: "x", CIDR: "10.0.0.0/24"}) + // Binding to a subnet sets the subnet ID and an in-CIDR address. + bindManagementInterface(r, iface, &NetworkIdentity{ID: "net-9", CIDR: "10.9.0.0/24"}) + if iface.SubnetID != "net-9" { + t.Errorf("SubnetID = %q, want net-9", iface.SubnetID) + } +} + func TestGenerateEnvironmentDefaults(t *testing.T) { seeds := NewSeedConfig() seeds.Shared = 42 diff --git a/internal/datagen/hostnames.go b/internal/datagen/hostnames.go index d3f7370..78c12f0 100644 --- a/internal/datagen/hostnames.go +++ b/internal/datagen/hostnames.go @@ -69,6 +69,22 @@ const ( StyleWindows // StyleDC produces DC-style hostnames like "THOR-DC01". StyleDC + // StyleAppliance produces appliance hostnames like "nimble-core-east-01", + // combining a vendor short-code (from the passed name pool) with a role and + // site. Callers pass a vendor short-code pool as the name pool. + StyleAppliance +) + +// ApplianceRoles are role labels used in appliance hostname generation. +var ApplianceRoles = NewPool( + "core", "dist", "access", "edge", "tor", + "spine", "leaf", "agg", "prod", "dr", +) + +// ApplianceSites are site/location labels used in appliance hostname generation. +var ApplianceSites = NewPool( + "east", "west", "north", "south", "dc1", + "dc2", "hq", "colo", "rack14", "row3", ) // GenerateHostname produces a single random hostname using the given style and name pool. @@ -89,6 +105,10 @@ func GenerateHostname(r *rand.Rand, style HostnameStyle, names *Pool[string]) st 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) + case StyleAppliance: + role := ApplianceRoles.Random(r) + site := ApplianceSites.Random(r) + return fmt.Sprintf("%s-%s-%s-%02d", strings.ToLower(name), role, site, num) default: role := Roles.Random(r) return fmt.Sprintf("%s-%s-%02d", strings.ToLower(name), strings.ToLower(role), num) diff --git a/internal/datagen/hostnames_test.go b/internal/datagen/hostnames_test.go index c94effd..2beeffb 100644 --- a/internal/datagen/hostnames_test.go +++ b/internal/datagen/hostnames_test.go @@ -2,6 +2,7 @@ package datagen import ( "math/rand" + "regexp" "strings" "testing" ) @@ -80,6 +81,31 @@ func TestGenerateHostname(t *testing.T) { }) } +func TestGenerateHostname_Appliance(t *testing.T) { + r := rand.New(rand.NewSource(42)) + shorts := NewPool("nimble", "bigip", "cat9k") + re := regexp.MustCompile(`^(nimble|bigip|cat9k)-[a-z0-9]+-[a-z0-9]+-\d{2}$`) + for i := 0; i < 25; i++ { + h := GenerateHostname(r, StyleAppliance, shorts) + if !re.MatchString(h) { + t.Errorf("appliance hostname %q does not match {short}-{role}-{site}-{NN}", h) + } + } +} + +func TestGenerateHostname_UnknownStyleDefaults(t *testing.T) { + // An unrecognized style falls back to the linux-style format rather than + // panicking or returning empty. + r := rand.New(rand.NewSource(7)) + h := GenerateHostname(r, HostnameStyle(99), NorseNames) + if h == "" { + t.Error("unknown style should still produce a non-empty hostname") + } + if h != strings.ToLower(h) { + t.Errorf("default-style hostname %q should be lowercase", h) + } +} + func TestGenerateHostnames(t *testing.T) { t.Run("deterministic with same seed", func(t *testing.T) { h1 := GenerateHostnames(42, 5, StyleLinux, NorseNames) diff --git a/internal/datagen/network_appliance.go b/internal/datagen/network_appliance.go new file mode 100644 index 0000000..b1fcef1 --- /dev/null +++ b/internal/datagen/network_appliance.go @@ -0,0 +1,317 @@ +package datagen + +import ( + "fmt" + "math/rand" +) + +// Capability facets. A NetworkSystemIdentity composes zero or more of these; +// a nil facet pointer means the device lacks that capability. Real products +// compose facets (a BIG-IP does load balancing + firewall + L3; a Catalyst +// 9300 does L2 + limited L3). +// +// Deferred facets (per PIPE-927's architecture: VPN, WAN-optimization, +// forward-proxy, DPI) are intentionally not modeled here. Add them when a +// simulator needs them rather than speculatively. + +// L2SwitchingCapability describes layer-2 switching. +type L2SwitchingCapability struct { + VLANCount int + MACTableSize int + STPMode string // "rstp", "mstp", "pvst+" +} + +// L3RoutingCapability describes layer-3 routing. +type L3RoutingCapability struct { + Protocols []string // "static", "ospf", "bgp", "isis" + FIBSize int + BGPEnabled bool + OSPFEnabled bool +} + +// FirewallCapability describes stateful firewalling. +type FirewallCapability struct { + RuleCount int + NAT bool + VPNTermination bool + StatefulInspection bool +} + +// LoadBalancingCapability describes ADC / load-balancing. +type LoadBalancingCapability struct { + VirtualServers int + PoolMembers int + Persistence string // "source-addr", "cookie", "ssl-sid" + SSLOffload bool +} + +// WirelessCapability describes wireless-LAN controller function. +type WirelessCapability struct { + RadioCount int + ControllerMode string // "embedded", "dedicated" + MaxAPs int +} + +// NetworkSystemIdentity is a first-class network-hardware machine: a +// vendor/model/serial box running an embedded ApplianceOS, with a set of +// composable capability facets, data interfaces, and a management interface. +type NetworkSystemIdentity struct { + Vendor ApplianceVendor + Model string + Serial string + OS *ApplianceOS + + Interfaces []NetworkInterface + ManagementInterface *NetworkInterface + + // Capability facets — nil means the device lacks that capability. + L2Switching *L2SwitchingCapability + L3Routing *L3RoutingCapability + Firewall *FirewallCapability + LoadBalancing *LoadBalancingCapability + Wireless *WirelessCapability + + AdminUserRef *UserIdentity +} + +// facetMask is a bitset of the capability facets a model composes. +type facetMask uint8 + +const ( + facetL2 facetMask = 1 << iota + facetL3 + facetFirewall + facetLB + facetWireless +) + +// networkModelSpec describes a concrete network model: its vendor, OS family, +// the facets it composes, and a data-interface (port) count range. +type networkModelSpec struct { + vendor ApplianceVendor + model string + osFamily ApplianceOSFamily + facets facetMask + minPorts int + maxPorts int +} + +// networkModels is the first vendor pool spanning the seven appliance vendors. +// Facet composition mirrors the real product's role (a Catalyst 9300 is an +// access switch: L2 + limited L3; a PA-3220 is an NGFW: firewall + L3). +var networkModels = []networkModelSpec{ + {VendorCisco, "Catalyst 9300-48P", FamilyIOSXE, facetL2 | facetL3, 48, 48}, + {VendorCisco, "Catalyst 9500-48Y4C", FamilyIOSXE, facetL2 | facetL3, 48, 52}, + {VendorCisco, "Nexus 9336C-FX2", FamilyNXOS, facetL2 | facetL3, 36, 36}, + {VendorCisco, "Catalyst 9800-40 WLC", FamilyIOSXE, facetWireless | facetL3, 4, 8}, + {VendorArista, "DCS-7050SX3-48YC8", FamilyEOS, facetL2 | facetL3, 48, 56}, + {VendorArista, "DCS-7280SR3-48YC8", FamilyEOS, facetL2 | facetL3, 48, 56}, + {VendorJuniper, "EX4300-48T", FamilyJunos, facetL2 | facetL3, 48, 48}, + {VendorJuniper, "SRX1500", FamilyJunos, facetFirewall | facetL3, 16, 16}, + {VendorJuniper, "MX240", FamilyJunos, facetL3, 4, 48}, + {VendorF5, "BIG-IP i5800", FamilyBIGIP, facetLB | facetFirewall | facetL3, 8, 8}, + {VendorPaloAlto, "PA-3220", FamilyPANOS, facetFirewall | facetL3, 12, 12}, + {VendorFortinet, "FortiGate 100F", FamilyFortiOS, facetFirewall | facetL3, 22, 22}, +} + +// Validate reports whether the NetworkSystemIdentity is well-formed: coherent +// vendor/OS, at least one capability facet, and coherent interfaces. Returns an +// error rather than panicking, per the datagen error-return convention. +func (n *NetworkSystemIdentity) Validate() error { + if n.Vendor == "" { + return fmt.Errorf("network system vendor must not be empty") + } + if n.Model == "" { + return fmt.Errorf("network system model must not be empty") + } + if n.Serial == "" { + return fmt.Errorf("network system serial must not be empty") + } + if n.OS == nil { + return fmt.Errorf("network system %q has nil OS", n.Model) + } + if err := n.OS.Validate(); err != nil { + return fmt.Errorf("network system %q OS: %w", n.Model, err) + } + if n.OS.Vendor != n.Vendor { + return fmt.Errorf("network system %q vendor %q does not match OS vendor %q", n.Model, n.Vendor, n.OS.Vendor) + } + if n.L2Switching == nil && n.L3Routing == nil && n.Firewall == nil && n.LoadBalancing == nil && n.Wireless == nil { + return fmt.Errorf("network system %q has no capability facets", n.Model) + } + if len(n.Interfaces) == 0 { + return fmt.Errorf("network system %q has no data interfaces", n.Model) + } + if n.ManagementInterface == nil { + return fmt.Errorf("network system %q has nil management interface", n.Model) + } + return nil +} + +// generateNetworkSystem builds a NetworkSystemIdentity for a specific model +// spec with deterministic output for a given RNG state. +func generateNetworkSystem(r *rand.Rand, spec networkModelSpec) *NetworkSystemIdentity { + os := GenerateApplianceOS(r, spec.osFamily) + + portCount := randRange(r, spec.minPorts, spec.maxPorts) + interfaces := make([]NetworkInterface, portCount) + for i := range interfaces { + interfaces[i] = NetworkInterface{ + Name: interfacePortName(spec.vendor, i), + MACAddress: RandomMAC(r), + } + } + + n := &NetworkSystemIdentity{ + Vendor: spec.vendor, + Model: spec.model, + Serial: networkSerial(r, spec.vendor), + OS: &os, + Interfaces: interfaces, + ManagementInterface: &NetworkInterface{ + Name: "mgmt0", + IPv4: RandomPrivateIPv4(r), + MACAddress: RandomMAC(r), + }, + } + + if spec.facets.has(facetL2) { + n.L2Switching = generateL2Switching(r) + } + if spec.facets.has(facetL3) { + n.L3Routing = generateL3Routing(r) + } + if spec.facets.has(facetFirewall) { + n.Firewall = generateFirewall(r) + } + if spec.facets.has(facetLB) { + n.LoadBalancing = generateLoadBalancing(r) + } + if spec.facets.has(facetWireless) { + n.Wireless = generateWireless(r) + } + return n +} + +// RandomNetworkSystemIdentity returns a network device drawn at random from the +// built-in vendor pool. +func RandomNetworkSystemIdentity(r *rand.Rand) *NetworkSystemIdentity { + spec := networkModels[r.Intn(len(networkModels))] // #nosec G404 + return generateNetworkSystem(r, spec) +} + +func generateL2Switching(r *rand.Rand) *L2SwitchingCapability { + modes := []string{"rstp", "mstp", "pvst+"} + macSizes := []int{16000, 32000, 64000, 96000} + return &L2SwitchingCapability{ + VLANCount: randRange(r, 8, 512), + MACTableSize: macSizes[r.Intn(len(macSizes))], // #nosec G404 + STPMode: modes[r.Intn(len(modes))], // #nosec G404 + } +} + +func generateL3Routing(r *rand.Rand) *L3RoutingCapability { + bgp := r.Intn(2) == 0 // #nosec G404 + ospf := r.Intn(2) == 0 // #nosec G404 + protocols := []string{"static", "connected"} + if ospf { + protocols = append(protocols, "ospf") + } + if bgp { + protocols = append(protocols, "bgp") + } + return &L3RoutingCapability{ + Protocols: protocols, + FIBSize: randRange(r, 4000, 256000), + BGPEnabled: bgp, + OSPFEnabled: ospf, + } +} + +func generateFirewall(r *rand.Rand) *FirewallCapability { + return &FirewallCapability{ + RuleCount: randRange(r, 50, 5000), + NAT: true, + VPNTermination: r.Intn(2) == 0, // #nosec G404 + StatefulInspection: true, + } +} + +func generateLoadBalancing(r *rand.Rand) *LoadBalancingCapability { + modes := []string{"source-addr", "cookie", "ssl-sid"} + return &LoadBalancingCapability{ + VirtualServers: randRange(r, 1, 200), + PoolMembers: randRange(r, 2, 500), + Persistence: modes[r.Intn(len(modes))], // #nosec G404 + SSLOffload: true, + } +} + +func generateWireless(r *rand.Rand) *WirelessCapability { + maxAPs := []int{50, 100, 500, 1000} + return &WirelessCapability{ + RadioCount: randRange(r, 2, 8), + ControllerMode: "embedded", + MaxAPs: maxAPs[r.Intn(len(maxAPs))], // #nosec G404 + } +} + +// randomAlnumUpper returns n uppercase-alphanumeric characters. +func randomAlnumUpper(r *rand.Rand, n int) string { + const cs = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + for i := range b { + b[i] = cs[r.Intn(len(cs))] // #nosec G404 + } + return string(b) +} + +// randomDigits returns n decimal digits. +func randomDigits(r *rand.Rand, n int) string { + const cs = "0123456789" + b := make([]byte, n) + for i := range b { + b[i] = cs[r.Intn(len(cs))] // #nosec G404 + } + return string(b) +} + +// networkSerial returns a vendor-plausible serial number. Formats are +// representative of each vendor's real serials, not authoritative. +func networkSerial(r *rand.Rand, vendor ApplianceVendor) string { + switch vendor { + case VendorCisco: + return "FCW" + randomAlnumUpper(r, 8) + case VendorArista: + return "JPE" + randomDigits(r, 8) + case VendorJuniper: + return "JN" + randomAlnumUpper(r, 10) + case VendorF5: + return "f5-" + randomHex(r, 6) + case VendorPaloAlto: + return randomDigits(r, 12) + case VendorFortinet: + return "FGT" + randomDigits(r, 11) + default: + return randomAlnumUpper(r, 12) + } +} + +// hasFacet reports whether the mask includes the given facet. +func (m facetMask) has(f facetMask) bool { return m&f != 0 } + +// interfacePortName returns a vendor-conventional data-port name for index i. +func interfacePortName(vendor ApplianceVendor, i int) string { + switch vendor { + case VendorCisco: + return fmt.Sprintf("GigabitEthernet1/0/%d", i+1) + case VendorArista: + return fmt.Sprintf("Ethernet%d", i+1) + case VendorJuniper: + return fmt.Sprintf("ge-0/0/%d", i) + case VendorF5: + return fmt.Sprintf("1.%d", i+1) + default: + return fmt.Sprintf("port%d", i+1) + } +} diff --git a/internal/datagen/network_appliance_test.go b/internal/datagen/network_appliance_test.go new file mode 100644 index 0000000..5017b03 --- /dev/null +++ b/internal/datagen/network_appliance_test.go @@ -0,0 +1,107 @@ +package datagen + +import ( + "math/rand" + "reflect" + "testing" +) + +func TestGenerateNetworkSystem_AllModels(t *testing.T) { + r := rand.New(rand.NewSource(42)) + for _, spec := range networkModels { + n := generateNetworkSystem(r, spec) + + if err := n.Validate(); err != nil { + t.Errorf("%s: Validate() = %v, want nil", spec.model, err) + } + if n.Vendor != spec.vendor { + t.Errorf("%s: vendor = %q, want %q", spec.model, n.Vendor, spec.vendor) + } + if n.Model != spec.model { + t.Errorf("model = %q, want %q", n.Model, spec.model) + } + if n.OS == nil || n.OS.Vendor != spec.vendor || n.OS.Family != spec.osFamily { + t.Errorf("%s: OS = %v, want vendor %q family %q", spec.model, n.OS, spec.vendor, spec.osFamily) + } + // Facet presence must match the spec mask exactly. + checks := []struct { + name string + present bool + want bool + }{ + {"L2", n.L2Switching != nil, spec.facets.has(facetL2)}, + {"L3", n.L3Routing != nil, spec.facets.has(facetL3)}, + {"firewall", n.Firewall != nil, spec.facets.has(facetFirewall)}, + {"loadbalancing", n.LoadBalancing != nil, spec.facets.has(facetLB)}, + {"wireless", n.Wireless != nil, spec.facets.has(facetWireless)}, + } + for _, c := range checks { + if c.present != c.want { + t.Errorf("%s: %s facet present = %v, want %v", spec.model, c.name, c.present, c.want) + } + } + if len(n.Interfaces) < spec.minPorts || len(n.Interfaces) > spec.maxPorts { + t.Errorf("%s: %d interfaces, want [%d,%d]", spec.model, len(n.Interfaces), spec.minPorts, spec.maxPorts) + } + if n.ManagementInterface == nil { + t.Errorf("%s: nil management interface", spec.model) + } + } +} + +func TestNetworkSystemIdentity_Validate(t *testing.T) { + good := generateNetworkSystem(rand.New(rand.NewSource(1)), networkModels[0]) // Catalyst 9300: L2+L3 + + mutate := func(fn func(n *NetworkSystemIdentity)) *NetworkSystemIdentity { + cp := *good + fn(&cp) + return &cp + } + + tests := []struct { + name string + n *NetworkSystemIdentity + wantErr bool + }{ + {"valid", good, false}, + {"empty vendor", mutate(func(n *NetworkSystemIdentity) { n.Vendor = "" }), true}, + {"empty model", mutate(func(n *NetworkSystemIdentity) { n.Model = "" }), true}, + {"empty serial", mutate(func(n *NetworkSystemIdentity) { n.Serial = "" }), true}, + {"nil OS", mutate(func(n *NetworkSystemIdentity) { n.OS = nil }), true}, + {"invalid OS", mutate(func(n *NetworkSystemIdentity) { n.OS = &ApplianceOS{} }), true}, + {"vendor/OS mismatch", mutate(func(n *NetworkSystemIdentity) { + n.OS = &ApplianceOS{Vendor: VendorFortinet, Family: FamilyFortiOS, Version: "7.4.3"} + }), true}, + {"no facets", mutate(func(n *NetworkSystemIdentity) { + n.L2Switching, n.L3Routing, n.Firewall, n.LoadBalancing, n.Wireless = nil, nil, nil, nil, nil + }), true}, + {"no interfaces", mutate(func(n *NetworkSystemIdentity) { n.Interfaces = nil }), true}, + {"nil management interface", mutate(func(n *NetworkSystemIdentity) { n.ManagementInterface = nil }), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.n.Validate(); (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestNetworkSerial_UnknownVendor(t *testing.T) { + // An unmapped vendor falls back to a generic uppercase-alphanumeric serial. + got := networkSerial(rand.New(rand.NewSource(3)), ApplianceVendor("acme")) + if len(got) != 12 { + t.Errorf("fallback serial %q has length %d, want 12", got, len(got)) + } +} + +func TestRandomNetworkSystemIdentity_Deterministic(t *testing.T) { + a := RandomNetworkSystemIdentity(rand.New(rand.NewSource(99))) + b := RandomNetworkSystemIdentity(rand.New(rand.NewSource(99))) + if !reflect.DeepEqual(a, b) { + t.Error("same seed produced different network systems") + } + if err := a.Validate(); err != nil { + t.Errorf("RandomNetworkSystemIdentity produced invalid system: %v", err) + } +} diff --git a/internal/datagen/seed.go b/internal/datagen/seed.go index 9674f9f..7ba3a16 100644 --- a/internal/datagen/seed.go +++ b/internal/datagen/seed.go @@ -20,6 +20,9 @@ const ( IdentityApplications IdentityType = "applications" IdentityNetworks IdentityType = "networks" IdentityDomains IdentityType = "domains" + + IdentityStorageSystems IdentityType = "storage_systems" + IdentityNetworkSystems IdentityType = "network_systems" ) // SeedConfig controls deterministic generation across all identity types. @@ -42,6 +45,10 @@ type SeedConfig struct { Applications int64 Networks int64 Domains int64 + + // Appliance identity seeds (PIPE-1035). <0 = fall back to Shared. + StorageSystems int64 + NetworkSystems int64 } // NewSeedConfig returns a SeedConfig with every field set to -1 so that an @@ -49,14 +56,16 @@ type SeedConfig struct { // 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, - Domains: -1, + Shared: -1, + Systems: -1, + Users: -1, + Groups: -1, + Services: -1, + Applications: -1, + Networks: -1, + Domains: -1, + StorageSystems: -1, + NetworkSystems: -1, } } @@ -81,6 +90,10 @@ func (s *SeedConfig) ResolveSeed(identityType IdentityType) int64 { override = s.Networks case IdentityDomains: override = s.Domains + case IdentityStorageSystems: + override = s.StorageSystems + case IdentityNetworkSystems: + override = s.NetworkSystems } if override >= 0 { return override @@ -104,5 +117,7 @@ func (s *SeedConfig) Init(logger *zap.Logger) { zap.Int64("applications", s.ResolveSeed(IdentityApplications)), zap.Int64("networks", s.ResolveSeed(IdentityNetworks)), zap.Int64("domains", s.ResolveSeed(IdentityDomains)), + zap.Int64("storage_systems", s.ResolveSeed(IdentityStorageSystems)), + zap.Int64("network_systems", s.ResolveSeed(IdentityNetworkSystems)), ) } diff --git a/internal/datagen/seed_test.go b/internal/datagen/seed_test.go index a48980a..aefa371 100644 --- a/internal/datagen/seed_test.go +++ b/internal/datagen/seed_test.go @@ -57,6 +57,16 @@ func TestSeedConfigResolveSeed(t *testing.T) { } }) + t.Run("appliance seeds resolve", func(t *testing.T) { + sc := &SeedConfig{Shared: 100, StorageSystems: 7, NetworkSystems: -1} + if got := sc.ResolveSeed(IdentityStorageSystems); got != 7 { + t.Errorf("ResolveSeed(IdentityStorageSystems) = %d, want 7", got) + } + if got := sc.ResolveSeed(IdentityNetworkSystems); got != 100 { + t.Errorf("ResolveSeed(IdentityNetworkSystems) fallback = %d, want 100", got) + } + }) + t.Run("unknown type returns shared", func(t *testing.T) { sc := &SeedConfig{Shared: 42} if got := sc.ResolveSeed(IdentityType("unknown_type")); got != 42 { diff --git a/internal/datagen/storage.go b/internal/datagen/storage.go new file mode 100644 index 0000000..9b5ec51 --- /dev/null +++ b/internal/datagen/storage.go @@ -0,0 +1,310 @@ +package datagen + +import ( + "fmt" + "math/rand" + "regexp" + "strings" +) + +// StorageVendor identifies a storage-array manufacturer. +type StorageVendor string + +const ( + StorageVendorHPE StorageVendor = "hpe" + StorageVendorNetApp StorageVendor = "netapp" + StorageVendorPure StorageVendor = "pure" + StorageVendorDellEMC StorageVendor = "dell-emc" +) + +// StorageDrive is a physical drive in a storage array. +type StorageDrive struct { + Slot string // "shelf1-bay14" + Type string // "ssd", "nvme-ssd", "hdd" + CapacityTB float64 + Model string + Serial string +} + +// StorageShelf is a drive shelf/enclosure holding a set of drives. +type StorageShelf struct { + ID string // "shelf-01" + Model string + DriveBays int + Drives []StorageDrive +} + +// StorageController is an array controller node. +type StorageController struct { + ID string // "ctrl-A" + Serial string + Role string // "active", "standby" + FirmwareVersion string +} + +// StorageCapacity models an array's capacity accounting. Effective capacity is +// usable capacity multiplied by the data-reduction ratio (dedup + compression). +type StorageCapacity struct { + RawCapacityTB float64 + UsableCapacityTB float64 + EffectiveCapacityTB float64 + DataReductionRatio float64 +} + +// StorageSystemIdentity is a first-class storage-array machine in the simulated +// environment: a vendor/model/serial box running an embedded ApplianceOS, with +// storage-fabric identifiers, a capacity model, and hardware inventory. +type StorageSystemIdentity struct { + Vendor StorageVendor + Model string + Serial string + OS *ApplianceOS + + // Storage-fabric identifiers. + WWN string // node World Wide Name (8-byte colon hex) + IQN string // iSCSI Qualified Name + WWPN []string // per-port Fibre Channel World Wide Port Names + NAA string // NAA type-6 identifier for the array's volume namespace + + Capacity StorageCapacity + + Controllers []StorageController + Shelves []StorageShelf + Drives []StorageDrive + + AdminUserRef *UserIdentity + ManagementInterface *NetworkInterface +} + +// storageModelSpec describes a concrete storage model: its vendor, the OS +// family it runs, its predominant drive type, and a raw-capacity range in TB. +type storageModelSpec struct { + vendor StorageVendor + model string + osFamily ApplianceOSFamily + driveType string + minRawTB float64 + maxRawTB float64 +} + +// hpeStorageModels is the first vendor pool: HPE Nimble, 3PAR, Alletra, and +// StoreOnce models. Capacity ranges are representative, not exhaustive. +var hpeStorageModels = []storageModelSpec{ + {StorageVendorHPE, "Nimble HF20", FamilyNimbleOS, "hybrid", 21, 126}, + {StorageVendorHPE, "Nimble HF40", FamilyNimbleOS, "hybrid", 42, 336}, + {StorageVendorHPE, "Nimble AF40", FamilyNimbleOS, "ssd", 23, 184}, + {StorageVendorHPE, "Nimble AF80", FamilyNimbleOS, "ssd", 46, 553}, + {StorageVendorHPE, "3PAR 8200", Family3PAROS, "hybrid", 20, 750}, + {StorageVendorHPE, "3PAR 8440", Family3PAROS, "ssd", 40, 2000}, + {StorageVendorHPE, "3PAR 9450", Family3PAROS, "ssd", 50, 6000}, + {StorageVendorHPE, "Alletra 6010", FamilyAlletraOS, "nvme-ssd", 23, 184}, + {StorageVendorHPE, "Alletra 6030", FamilyAlletraOS, "nvme-ssd", 46, 553}, + {StorageVendorHPE, "Alletra MP B10000", FamilyAlletraOS, "nvme-ssd", 46, 1105}, + {StorageVendorHPE, "Alletra MP X10000", FamilyAlletraOS, "nvme-ssd", 100, 5000}, + {StorageVendorHPE, "StoreOnce 3660", FamilyStoreOnceOS, "hdd", 36, 216}, + {StorageVendorHPE, "StoreOnce 5260", FamilyStoreOnceOS, "hdd", 108, 1080}, +} + +// Storage-fabric identifier formats. WWN/WWPN are 8-byte colon-separated hex; +// NAA is a type-6 (32-hex) name; IQN follows RFC 3720's iqn.YYYY-MM.domain:id. +var ( + storageWWNRE = regexp.MustCompile(`^([0-9a-f]{2}:){7}[0-9a-f]{2}$`) + storageNAARE = regexp.MustCompile(`^naa\.6[0-9a-f]{31}$`) + storageIQNRE = regexp.MustCompile(`^iqn\.\d{4}-\d{2}\.[a-z0-9.-]+:.+$`) +) + +// maxDataReduction caps the plausible effective/usable capacity multiplier used +// by Validate; real dedup+compression rarely exceeds this. +const maxDataReduction = 10.0 + +// storageIQNDomain maps a vendor to its reverse-DNS naming authority for IQNs. +var storageIQNDomain = map[StorageVendor]string{ + StorageVendorHPE: "com.hpe", + StorageVendorNetApp: "com.netapp", + StorageVendorPure: "com.purestorage", + StorageVendorDellEMC: "com.dell", +} + +// randomWWNLike returns an 8-byte colon-hex name with a fixed leading byte +// (0x50 for a node WWN, 0x20 for an FC port WWPN). +func randomWWNLike(r *rand.Rand, first byte) string { + b := make([]byte, 8) + for i := range b { + b[i] = byte(r.Intn(256)) // #nosec G404 + } + b[0] = first + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]) +} + +// randomNAA returns an NAA type-6 identifier: "naa.6" + 31 hex nibbles. +func randomNAA(r *rand.Rand) string { + const hexch = "0123456789abcdef" + var sb strings.Builder + sb.WriteString("naa.6") + for i := 0; i < 31; i++ { + sb.WriteByte(hexch[r.Intn(16)]) // #nosec G404 + } + return sb.String() +} + +// storageSerial returns a vendor-prefixed uppercase serial, e.g. "HPE-1A2B3C4D5E". +func storageSerial(r *rand.Rand, vendor StorageVendor) string { + return fmt.Sprintf("%s-%s", strings.ToUpper(string(vendor)), strings.ToUpper(randomHex(r, 5))) +} + +// iqnFor builds an IQN from the vendor's naming authority and the array serial. +func iqnFor(vendor StorageVendor, serial string) string { + domain := storageIQNDomain[vendor] + if domain == "" { + domain = "com.example" + } + return fmt.Sprintf("iqn.2007-11.%s:%s", domain, strings.ToLower(serial)) +} + +// driveTypeAndCapacity returns the concrete drive type and per-drive capacity +// (TB) for a model's predominant media. +func driveTypeAndCapacity(r *rand.Rand, driveType string) (string, float64) { + switch driveType { + case "nvme-ssd": + caps := []float64{3.84, 7.68, 15.36} + return "nvme-ssd", caps[r.Intn(len(caps))] // #nosec G404 + case "ssd": + caps := []float64{1.92, 3.84, 7.68} + return "ssd", caps[r.Intn(len(caps))] // #nosec G404 + default: // "hdd", "hybrid" + caps := []float64{4, 8, 12, 16} + return "hdd", caps[r.Intn(len(caps))] // #nosec G404 + } +} + +// Validate reports whether the StorageSystemIdentity is well-formed: coherent +// vendor/OS, well-formed WWN/IQN/NAA/WWPN, and a sane capacity model. Returns +// an error rather than panicking, per the datagen error-return convention. +func (s *StorageSystemIdentity) Validate() error { + if s.Vendor == "" { + return fmt.Errorf("storage system vendor must not be empty") + } + if s.Model == "" { + return fmt.Errorf("storage system model must not be empty") + } + if s.Serial == "" { + return fmt.Errorf("storage system serial must not be empty") + } + if s.OS == nil { + return fmt.Errorf("storage system %q has nil OS", s.Model) + } + if err := s.OS.Validate(); err != nil { + return fmt.Errorf("storage system %q OS: %w", s.Model, err) + } + if !storageWWNRE.MatchString(s.WWN) { + return fmt.Errorf("storage system %q has malformed WWN %q", s.Model, s.WWN) + } + if !storageNAARE.MatchString(s.NAA) { + return fmt.Errorf("storage system %q has malformed NAA %q", s.Model, s.NAA) + } + if !storageIQNRE.MatchString(s.IQN) { + return fmt.Errorf("storage system %q has malformed IQN %q", s.Model, s.IQN) + } + for _, p := range s.WWPN { + if !storageWWNRE.MatchString(p) { + return fmt.Errorf("storage system %q has malformed WWPN %q", s.Model, p) + } + } + c := s.Capacity + if c.RawCapacityTB <= 0 || c.UsableCapacityTB <= 0 { + return fmt.Errorf("storage system %q has non-positive capacity", s.Model) + } + if c.UsableCapacityTB > c.RawCapacityTB { + return fmt.Errorf("storage system %q usable %.1fTB exceeds raw %.1fTB", s.Model, c.UsableCapacityTB, c.RawCapacityTB) + } + if c.EffectiveCapacityTB < c.UsableCapacityTB { + return fmt.Errorf("storage system %q effective %.1fTB below usable %.1fTB", s.Model, c.EffectiveCapacityTB, c.UsableCapacityTB) + } + if c.DataReductionRatio < 1 { + return fmt.Errorf("storage system %q data reduction ratio %.2f is below 1", s.Model, c.DataReductionRatio) + } + if c.EffectiveCapacityTB > c.UsableCapacityTB*maxDataReduction { + return fmt.Errorf("storage system %q effective %.1fTB implies reduction above %.0fx", s.Model, c.EffectiveCapacityTB, maxDataReduction) + } + return nil +} + +// generateStorageSystem builds a StorageSystemIdentity for a specific model +// spec with deterministic output for a given RNG state. +func generateStorageSystem(r *rand.Rand, spec storageModelSpec) *StorageSystemIdentity { + os := GenerateApplianceOS(r, spec.osFamily) + serial := storageSerial(r, spec.vendor) + + // Capacity: usable is 72-85% of raw; effective applies a 2-8x reduction. + raw := spec.minRawTB + r.Float64()*(spec.maxRawTB-spec.minRawTB) // #nosec G404 + usable := raw * (0.72 + r.Float64()*0.13) // #nosec G404 + reduction := 2.0 + r.Float64()*6.0 // #nosec G404 + effective := usable * reduction + + // Controllers: active/standby HA pair. + controllers := []StorageController{ + {ID: "ctrl-A", Serial: storageSerial(r, spec.vendor), Role: "active", FirmwareVersion: os.Version}, + {ID: "ctrl-B", Serial: storageSerial(r, spec.vendor), Role: "standby", FirmwareVersion: os.Version}, + } + + // Drives in a single shelf. + const bays = 24 + dType, dCap := driveTypeAndCapacity(r, spec.driveType) + nDrives := randRange(r, 12, bays) + drives := make([]StorageDrive, nDrives) + for i := range drives { + drives[i] = StorageDrive{ + Slot: fmt.Sprintf("shelf1-bay%02d", i+1), + Type: dType, + CapacityTB: dCap, + Model: fmt.Sprintf("%s %.2fTB %s", strings.ToUpper(string(spec.vendor)), dCap, strings.ToUpper(dType)), + Serial: storageSerial(r, spec.vendor), + } + } + shelves := []StorageShelf{{ + ID: "shelf-01", + Model: spec.model + " DBE", + DriveBays: bays, + Drives: drives, + }} + + // FC ports: 2 or 4 WWPNs. + nPorts := 2 * randRange(r, 1, 2) + wwpn := make([]string, nPorts) + for i := range wwpn { + wwpn[i] = randomWWNLike(r, 0x20) + } + + return &StorageSystemIdentity{ + Vendor: spec.vendor, + Model: spec.model, + Serial: serial, + OS: &os, + WWN: randomWWNLike(r, 0x50), + IQN: iqnFor(spec.vendor, serial), + WWPN: wwpn, + NAA: randomNAA(r), + Capacity: StorageCapacity{ + RawCapacityTB: raw, + UsableCapacityTB: usable, + EffectiveCapacityTB: effective, + DataReductionRatio: reduction, + }, + Controllers: controllers, + Shelves: shelves, + Drives: drives, + ManagementInterface: &NetworkInterface{ + Name: "mgmt0", + IPv4: RandomPrivateIPv4(r), + MACAddress: RandomMAC(r), + }, + } +} + +// RandomStorageSystemIdentity returns a storage array drawn at random from the +// built-in vendor pools (currently HPE). +func RandomStorageSystemIdentity(r *rand.Rand) *StorageSystemIdentity { + spec := hpeStorageModels[r.Intn(len(hpeStorageModels))] // #nosec G404 + return generateStorageSystem(r, spec) +} diff --git a/internal/datagen/storage_test.go b/internal/datagen/storage_test.go new file mode 100644 index 0000000..36c6014 --- /dev/null +++ b/internal/datagen/storage_test.go @@ -0,0 +1,127 @@ +package datagen + +import ( + "math/rand" + "reflect" + "testing" +) + +func TestGenerateStorageSystem_AllHPEModels(t *testing.T) { + r := rand.New(rand.NewSource(42)) + for _, spec := range hpeStorageModels { + s := generateStorageSystem(r, spec) + + if err := s.Validate(); err != nil { + t.Errorf("%s: Validate() = %v, want nil", spec.model, err) + } + if s.Vendor != StorageVendorHPE { + t.Errorf("%s: vendor = %q, want hpe", spec.model, s.Vendor) + } + if s.Model != spec.model { + t.Errorf("model = %q, want %q", s.Model, spec.model) + } + if s.OS == nil || s.OS.Vendor != VendorHPE || s.OS.Family != spec.osFamily { + t.Errorf("%s: OS = %v, want vendor hpe family %q", spec.model, s.OS, spec.osFamily) + } + if !storageWWNRE.MatchString(s.WWN) { + t.Errorf("%s: WWN %q is malformed", spec.model, s.WWN) + } + if !storageNAARE.MatchString(s.NAA) { + t.Errorf("%s: NAA %q is malformed", spec.model, s.NAA) + } + if !storageIQNRE.MatchString(s.IQN) { + t.Errorf("%s: IQN %q is malformed", spec.model, s.IQN) + } + if len(s.WWPN) == 0 { + t.Errorf("%s: no WWPNs", spec.model) + } + for _, p := range s.WWPN { + if !storageWWNRE.MatchString(p) { + t.Errorf("%s: WWPN %q is malformed", spec.model, p) + } + } + // Capacity coherence. + c := s.Capacity + if !(c.RawCapacityTB >= c.UsableCapacityTB && c.UsableCapacityTB > 0) { + t.Errorf("%s: raw %.1f must be >= usable %.1f > 0", spec.model, c.RawCapacityTB, c.UsableCapacityTB) + } + if c.EffectiveCapacityTB < c.UsableCapacityTB { + t.Errorf("%s: effective %.1f < usable %.1f", spec.model, c.EffectiveCapacityTB, c.UsableCapacityTB) + } + if c.DataReductionRatio < 1 { + t.Errorf("%s: data reduction ratio %.2f < 1", spec.model, c.DataReductionRatio) + } + if len(s.Controllers) < 2 { + t.Errorf("%s: %d controllers, want >= 2", spec.model, len(s.Controllers)) + } + if len(s.Drives) == 0 { + t.Errorf("%s: no drives", spec.model) + } + if s.ManagementInterface == nil { + t.Errorf("%s: nil management interface", spec.model) + } + } +} + +func TestStorageSystemIdentity_Validate(t *testing.T) { + good := generateStorageSystem(rand.New(rand.NewSource(1)), hpeStorageModels[0]) + + // mutate returns a copy of good with fn applied, for negative cases. + mutate := func(fn func(s *StorageSystemIdentity)) *StorageSystemIdentity { + cp := *good + fn(&cp) + return &cp + } + + tests := []struct { + name string + s *StorageSystemIdentity + wantErr bool + }{ + {"valid", good, false}, + {"empty vendor", mutate(func(s *StorageSystemIdentity) { s.Vendor = "" }), true}, + {"nil OS", mutate(func(s *StorageSystemIdentity) { s.OS = nil }), true}, + {"bad WWN", mutate(func(s *StorageSystemIdentity) { s.WWN = "zz:zz" }), true}, + {"bad NAA", mutate(func(s *StorageSystemIdentity) { s.NAA = "naa.5deadbeef" }), true}, + {"bad IQN", mutate(func(s *StorageSystemIdentity) { s.IQN = "not-an-iqn" }), true}, + {"usable exceeds raw", mutate(func(s *StorageSystemIdentity) { s.Capacity.UsableCapacityTB = s.Capacity.RawCapacityTB + 1 }), true}, + {"effective below usable", mutate(func(s *StorageSystemIdentity) { s.Capacity.EffectiveCapacityTB = s.Capacity.UsableCapacityTB - 1 }), true}, + {"implausible reduction", mutate(func(s *StorageSystemIdentity) { s.Capacity.EffectiveCapacityTB = s.Capacity.UsableCapacityTB * 100 }), true}, + {"empty model", mutate(func(s *StorageSystemIdentity) { s.Model = "" }), true}, + {"empty serial", mutate(func(s *StorageSystemIdentity) { s.Serial = "" }), true}, + {"invalid OS", mutate(func(s *StorageSystemIdentity) { s.OS = &ApplianceOS{} }), true}, + {"malformed WWPN", mutate(func(s *StorageSystemIdentity) { s.WWPN = []string{"zz:zz"} }), true}, + {"non-positive capacity", mutate(func(s *StorageSystemIdentity) { s.Capacity.RawCapacityTB = 0 }), true}, + {"reduction below one", mutate(func(s *StorageSystemIdentity) { s.Capacity.DataReductionRatio = 0.5 }), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.s.Validate(); (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestIQNFor_UnknownVendor(t *testing.T) { + // An unmapped vendor falls back to the com.example naming authority and + // still produces a well-formed IQN. + got := iqnFor(StorageVendor("acme"), "SN-123") + if !storageIQNRE.MatchString(got) { + t.Errorf("iqnFor unknown vendor produced malformed IQN %q", got) + } + if want := "iqn.2007-11.com.example:sn-123"; got != want { + t.Errorf("iqnFor unknown vendor = %q, want %q", got, want) + } +} + +func TestRandomStorageSystemIdentity_Deterministic(t *testing.T) { + a := RandomStorageSystemIdentity(rand.New(rand.NewSource(99))) + b := RandomStorageSystemIdentity(rand.New(rand.NewSource(99))) + if !reflect.DeepEqual(a, b) { + t.Error("same seed produced different storage systems") + } + if err := a.Validate(); err != nil { + t.Errorf("RandomStorageSystemIdentity produced invalid system: %v", err) + } +}