diff --git a/embed/host.go b/embed/host.go index 9a5e137..5f202b6 100644 --- a/embed/host.go +++ b/embed/host.go @@ -1,6 +1,10 @@ package embed -import "go.uber.org/zap" +import ( + "go.uber.org/zap" + + "github.com/observiq/blitz/internal/datagen" +) // Host is the bundle of consumers and ambient resources a host process // supplies to an embedded blitz runner. @@ -31,6 +35,21 @@ type Host struct { // treat their own reference as frozen once they hand the Host off. // See cloneResource in this package. Resource map[string]string + + // Environment is the simulated datagen.Environment that generators draw + // their host identities (host.name / os.type) from (PIPE-1036). Nil means + // generators fall back to the running host's os.Hostname(). + // + // Read-only for the lifetime of a single Runner.Start: workers only read + // it, so they share the pointer without synchronization. It is not + // deep-copied the way Resource is; copying the whole identity graph would + // be costly and buys nothing, since the Environment is never mutated in + // place. Reconfiguration swaps it by rebuilding the runner with a fresh + // Host (Stop, New, Start), not by mutating the live value, so a caller + // replaces Environments across a rebuild rather than underneath running + // workers. Callers must treat their reference as read-only once they hand + // the Host off. + Environment *datagen.Environment } // cloneResource returns a defensive copy of m. Runner.Start uses it so diff --git a/generator/resource/resource.go b/generator/resource/resource.go index ff5b3f5..e42ba89 100644 --- a/generator/resource/resource.go +++ b/generator/resource/resource.go @@ -51,8 +51,16 @@ func Hostname() string { // resource.Default("apache", "apache.format", "common") // // → {"host.name": "", "telemetry.source": "apache", "apache.format": "common"} func Default(source string, extras ...string) map[string]string { + return WithHost(Hostname(), source, extras...) +} + +// WithHost returns a Resource map like Default but with an explicit host.name, +// for generators whose hostname comes from a resolved datagen SystemIdentity +// (PIPE-1036) rather than the process's os.Hostname(). extras follow the same +// key/value convention as Default. +func WithHost(hostname, source string, extras ...string) map[string]string { r := map[string]string{ - "host.name": Hostname(), + "host.name": hostname, "telemetry.source": source, } for i := 0; i+1 < len(extras); i += 2 { @@ -60,3 +68,50 @@ func Default(source string, extras ...string) map[string]string { } return r } + +// StaticResources is an immutable set of resource attributes that stay constant +// for a generator worker's lifetime: the host-identity projection (host.name, +// os.type, ...) plus per-generator constants (telemetry.source, format flavor, +// version). Build it once at construction and reuse it for every record the +// worker emits (PIPE-1036). +// +// The model is: Static + Dynamic (per record) = Record. Static carries the +// fields that never change for the worker; Record merges in the few that do. +type StaticResources struct { + attrs map[string]string +} + +// NewStaticResources builds a StaticResources from a base attribute set. The +// map is copied, so a caller that retains and later mutates attrs does not +// affect the constructed value. +func NewStaticResources(attrs map[string]string) *StaticResources { + cp := make(map[string]string, len(attrs)) + for k, v := range attrs { + cp[k] = v + } + return &StaticResources{attrs: cp} +} + +// Record returns the resource attributes for a single emitted record: the +// static set merged with the given dynamic key/value pairs (same even-length +// convention as Default's extras). +// +// When no dynamic pairs are supplied — the common case, since most generators +// vary nothing per record — Record returns the shared static map with no +// allocation. Callers MUST treat that returned map as read-only; mutating it +// corrupts every other record and races concurrent workers. When dynamic pairs +// are supplied, Record returns a fresh merged map that is safe to mutate and +// leaves the static set untouched. +func (s *StaticResources) Record(dynamicKV ...string) map[string]string { + if len(dynamicKV) < 2 { + return s.attrs + } + out := make(map[string]string, len(s.attrs)+len(dynamicKV)/2) + for k, v := range s.attrs { + out[k] = v + } + for i := 0; i+1 < len(dynamicKV); i += 2 { + out[dynamicKV[i]] = dynamicKV[i+1] + } + return out +} diff --git a/generator/resource/resource_static_test.go b/generator/resource/resource_static_test.go new file mode 100644 index 0000000..a19bf2d --- /dev/null +++ b/generator/resource/resource_static_test.go @@ -0,0 +1,76 @@ +package resource + +import ( + "reflect" + "testing" +) + +func mapPtr(m map[string]string) uintptr { return reflect.ValueOf(m).Pointer() } + +func TestStaticResourcesConstructorCopies(t *testing.T) { + base := map[string]string{"host.name": "thor-web-01", "telemetry.source": "apache"} + s := NewStaticResources(base) + + // Mutating the input after construction must not leak into the static set. + base["host.name"] = "mutated" + base["injected"] = "x" + + got := s.Record() + if got["host.name"] != "thor-web-01" { + t.Errorf("host.name = %q, want thor-web-01 (constructor must copy)", got["host.name"]) + } + if _, ok := got["injected"]; ok { + t.Error("post-construction input mutation leaked into the static set") + } +} + +func TestStaticResourcesRecordNoDynamicIsSharedAndReadOnly(t *testing.T) { + s := NewStaticResources(map[string]string{"host.name": "thor-web-01", "telemetry.source": "apache"}) + + a := s.Record() + b := s.Record() + + // Zero-allocation path: repeated no-dynamic calls return the SAME map. + if mapPtr(a) != mapPtr(b) { + t.Error("Record() with no dynamic pairs should return the shared static map, not a fresh copy") + } + if a["telemetry.source"] != "apache" { + t.Errorf("telemetry.source = %q, want apache", a["telemetry.source"]) + } +} + +func TestStaticResourcesRecordWithDynamicMergesWithoutMutatingStatic(t *testing.T) { + s := NewStaticResources(map[string]string{"host.name": "thor-web-01", "telemetry.source": "wel"}) + + rec := s.Record("wel.channel", "Security", "wel.role", "dc") + + // Merged map carries both static and dynamic. + if rec["host.name"] != "thor-web-01" || rec["wel.channel"] != "Security" || rec["wel.role"] != "dc" { + t.Errorf("merged record missing expected keys: %#v", rec) + } + // It must be a distinct map from the shared static one. + if mapPtr(rec) == mapPtr(s.Record()) { + t.Error("Record(dynamic...) must return a fresh map, not the shared static map") + } + // The static set must be untouched by the merge. + if _, ok := s.Record()["wel.channel"]; ok { + t.Error("dynamic pair leaked into the static set") + } +} + +func TestStaticResourcesRecordOddArgsIgnoresTrailing(t *testing.T) { + s := NewStaticResources(map[string]string{"telemetry.source": "json"}) + + // A single unpaired arg is treated as "no complete dynamic pair": shared static. + if mapPtr(s.Record("dangling")) != mapPtr(s.Record()) { + t.Error("a single unpaired dynamic arg should yield the shared static map") + } + // An odd count keeps complete pairs and drops the trailing unpaired key. + rec := s.Record("json.type", "pii", "dangling") + if rec["json.type"] != "pii" { + t.Errorf("json.type = %q, want pii", rec["json.type"]) + } + if _, ok := rec["dangling"]; ok { + t.Error("trailing unpaired key should be dropped") + } +} diff --git a/generator/resource/resource_withhost_test.go b/generator/resource/resource_withhost_test.go new file mode 100644 index 0000000..f4aedbb --- /dev/null +++ b/generator/resource/resource_withhost_test.go @@ -0,0 +1,36 @@ +package resource + +import "testing" + +func TestWithHost(t *testing.T) { + r := WithHost("web-01", "apache", "apache.format", "common") + if r["host.name"] != "web-01" { + t.Errorf(`host.name = %q, want "web-01"`, r["host.name"]) + } + if r["telemetry.source"] != "apache" { + t.Errorf(`telemetry.source = %q, want "apache"`, r["telemetry.source"]) + } + if r["apache.format"] != "common" { + t.Errorf(`apache.format = %q, want "common"`, r["apache.format"]) + } +} + +func TestWithHost_OddExtrasIgnoresDangling(t *testing.T) { + r := WithHost("h", "src", "onlykey") + if _, ok := r["onlykey"]; ok { + t.Error("dangling extra key should be ignored") + } + if r["host.name"] != "h" { + t.Errorf("host.name = %q, want h", r["host.name"]) + } +} + +func TestDefaultUsesProcessHostname(t *testing.T) { + r := Default("apache") + if r["host.name"] != Hostname() { + t.Errorf("Default host.name = %q, want process hostname %q", r["host.name"], Hostname()) + } + if r["telemetry.source"] != "apache" { + t.Errorf("telemetry.source = %q, want apache", r["telemetry.source"]) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b5ad4d3..9456085 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,9 @@ type Config struct { Output Output `yaml:"output,omitempty" mapstructure:"output,omitempty"` // Metrics configuration Metrics Metrics `yaml:"metrics,omitempty" mapstructure:"metrics,omitempty"` + // Environment configures the simulated datagen.Environment identities + // that generators draw their host.name/OS from (PIPE-1036). + Environment EnvironmentConfig `yaml:"environment,omitempty" mapstructure:"environment,omitempty"` // OnFinish controls behavior when finite generation completes. // One of: "exit" (default), "idle" OnFinish string `yaml:"onFinish,omitempty" mapstructure:"onFinish,omitempty"` @@ -38,6 +41,9 @@ func (c *Config) Validate() error { if err := c.Metrics.Validate(); err != nil { return err } + if err := c.Environment.Validate(); err != nil { + return err + } if c.OnFinish != "" && c.OnFinish != "exit" && c.OnFinish != "idle" { return fmt.Errorf("onFinish must be one of: exit, idle, got %q", c.OnFinish) } diff --git a/internal/config/environment.go b/internal/config/environment.go new file mode 100644 index 0000000..70e7afa --- /dev/null +++ b/internal/config/environment.go @@ -0,0 +1,124 @@ +// Package config contains the top level configuration structures and logic +package config + +import ( + "fmt" + + "github.com/observiq/blitz/internal/datagen" + "go.uber.org/zap" +) + +// EnvironmentConfig configures the simulated datagen.Environment that +// generators draw their host identities from. The block is optional; an +// omitted environment yields a randomized default Environment (PIPE-1036). +type EnvironmentConfig struct { + // DomainName is the AD/DNS domain for the environment. Empty = datagen default. + DomainName string `yaml:"domain_name,omitempty" mapstructure:"domain_name,omitempty"` + // SeedConfig controls per-identity-type determinism. + SeedConfig EnvironmentSeedConfig `yaml:"seed_config,omitempty" mapstructure:"seed_config,omitempty"` + // Counts controls how many of each identity type are generated. + Counts EnvironmentCounts `yaml:"counts,omitempty" mapstructure:"counts,omitempty"` +} + +// EnvironmentSeedConfig mirrors datagen.SeedConfig as optional YAML keys. An +// omitted (nil) field randomizes that identity type; an explicit value — +// including 0 — is a deterministic seed, per the datagen seed contract. +type EnvironmentSeedConfig struct { + Shared *int64 `yaml:"shared,omitempty" mapstructure:"shared,omitempty"` + Systems *int64 `yaml:"systems,omitempty" mapstructure:"systems,omitempty"` + Users *int64 `yaml:"users,omitempty" mapstructure:"users,omitempty"` + Groups *int64 `yaml:"groups,omitempty" mapstructure:"groups,omitempty"` + Services *int64 `yaml:"services,omitempty" mapstructure:"services,omitempty"` + Applications *int64 `yaml:"applications,omitempty" mapstructure:"applications,omitempty"` + Networks *int64 `yaml:"networks,omitempty" mapstructure:"networks,omitempty"` + Domains *int64 `yaml:"domains,omitempty" mapstructure:"domains,omitempty"` + StorageSystems *int64 `yaml:"storage_systems,omitempty" mapstructure:"storage_systems,omitempty"` + NetworkSystems *int64 `yaml:"network_systems,omitempty" mapstructure:"network_systems,omitempty"` +} + +// EnvironmentCounts mirrors the count fields of datagen.EnvironmentOpts. A zero +// (omitted) count uses the datagen package default for that type. +type EnvironmentCounts struct { + Systems int `yaml:"systems,omitempty" mapstructure:"systems,omitempty"` + Users int `yaml:"users,omitempty" mapstructure:"users,omitempty"` + Groups int `yaml:"groups,omitempty" mapstructure:"groups,omitempty"` + Networks int `yaml:"networks,omitempty" mapstructure:"networks,omitempty"` + StorageSystems int `yaml:"storage_systems,omitempty" mapstructure:"storage_systems,omitempty"` + NetworkSystems int `yaml:"network_systems,omitempty" mapstructure:"network_systems,omitempty"` + DomainAdmins int `yaml:"domain_admins,omitempty" mapstructure:"domain_admins,omitempty"` +} + +// Validate checks the environment configuration: counts must not be negative. +func (e EnvironmentConfig) Validate() error { + counts := map[string]int{ + "systems": e.Counts.Systems, + "users": e.Counts.Users, + "groups": e.Counts.Groups, + "networks": e.Counts.Networks, + "storage_systems": e.Counts.StorageSystems, + "network_systems": e.Counts.NetworkSystems, + "domain_admins": e.Counts.DomainAdmins, + } + for name, v := range counts { + if v < 0 { + return fmt.Errorf("environment count %q must not be negative, got %d", name, v) + } + } + return nil +} + +// Build resolves the configured Environment, hydrating a datagen.SeedConfig +// (omitted seeds randomize), initializing it (which logs the effective seeds), +// and composing the Environment. A nil logger is treated as a no-op logger. +func (e EnvironmentConfig) Build(logger *zap.Logger) (*datagen.Environment, error) { + if logger == nil { + logger = zap.NewNop() + } + + seeds := datagen.NewSeedConfig() + sc := e.SeedConfig + if sc.Shared != nil { + seeds.Shared = *sc.Shared + } + if sc.Systems != nil { + seeds.Systems = *sc.Systems + } + if sc.Users != nil { + seeds.Users = *sc.Users + } + if sc.Groups != nil { + seeds.Groups = *sc.Groups + } + if sc.Services != nil { + seeds.Services = *sc.Services + } + if sc.Applications != nil { + seeds.Applications = *sc.Applications + } + if sc.Networks != nil { + seeds.Networks = *sc.Networks + } + if sc.Domains != nil { + seeds.Domains = *sc.Domains + } + if sc.StorageSystems != nil { + seeds.StorageSystems = *sc.StorageSystems + } + if sc.NetworkSystems != nil { + seeds.NetworkSystems = *sc.NetworkSystems + } + seeds.Init(logger) + + opts := &datagen.EnvironmentOpts{ + DomainName: e.DomainName, + SystemCount: e.Counts.Systems, + UserCount: e.Counts.Users, + GroupCount: e.Counts.Groups, + NetworkCount: e.Counts.Networks, + StorageSystemCount: e.Counts.StorageSystems, + NetworkSystemCount: e.Counts.NetworkSystems, + DomainAdminsCount: e.Counts.DomainAdmins, + Logger: logger, + } + return datagen.GenerateEnvironment(seeds, opts) +} diff --git a/internal/config/environment_test.go b/internal/config/environment_test.go new file mode 100644 index 0000000..b3551c7 --- /dev/null +++ b/internal/config/environment_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "testing" + + "go.uber.org/zap" +) + +func i64(v int64) *int64 { return &v } + +func TestEnvironmentConfig_Build_Counts(t *testing.T) { + cfg := EnvironmentConfig{ + SeedConfig: EnvironmentSeedConfig{Shared: i64(42)}, + Counts: EnvironmentCounts{Systems: 3, StorageSystems: 2, NetworkSystems: 4}, + } + env, err := cfg.Build(zap.NewNop()) + if err != nil { + t.Fatalf("Build: %v", err) + } + if env == nil { + t.Fatal("Build returned nil environment") + } + if len(env.Systems) != 3 { + t.Errorf("systems = %d, want 3", len(env.Systems)) + } + if len(env.StorageSystems) != 2 { + t.Errorf("storage systems = %d, want 2", len(env.StorageSystems)) + } + if len(env.NetworkSystems) != 4 { + t.Errorf("network systems = %d, want 4", len(env.NetworkSystems)) + } +} + +func TestEnvironmentConfig_Build_DeterministicIncludingSeedZero(t *testing.T) { + // Every per-type seed set (covers all hydration branches), and shared:0 + // must be deterministic rather than randomized. + cfg := EnvironmentConfig{ + SeedConfig: EnvironmentSeedConfig{ + Shared: i64(0), Systems: i64(1), Users: i64(2), Groups: i64(3), + Services: i64(4), Applications: i64(5), Networks: i64(6), + Domains: i64(7), StorageSystems: i64(8), NetworkSystems: i64(9), + }, + Counts: EnvironmentCounts{Systems: 2, StorageSystems: 1, NetworkSystems: 1}, + } + a, err := cfg.Build(zap.NewNop()) + if err != nil { + t.Fatalf("Build: %v", err) + } + b, err := cfg.Build(zap.NewNop()) + if err != nil { + t.Fatalf("Build: %v", err) + } + if a.Systems[0].Hostname != b.Systems[0].Hostname { + t.Error("shared:0 with fixed per-type seeds should be deterministic") + } + if a.StorageSystems[0].Serial != b.StorageSystems[0].Serial { + t.Error("storage systems should be deterministic") + } +} + +func TestEnvironmentConfig_Build_NilLoggerNoPanic(t *testing.T) { + env, err := EnvironmentConfig{Counts: EnvironmentCounts{Systems: 1}}.Build(nil) + if err != nil { + t.Fatalf("Build(nil logger): %v", err) + } + if env == nil { + t.Fatal("Build(nil logger) returned nil environment") + } +} + +func TestEnvironmentConfig_Validate(t *testing.T) { + if err := (EnvironmentConfig{Counts: EnvironmentCounts{Systems: 5, Users: 10}}).Validate(); err != nil { + t.Errorf("valid config should pass: %v", err) + } + if err := (EnvironmentConfig{Counts: EnvironmentCounts{Systems: -1}}).Validate(); err == nil { + t.Error("negative count should fail validation") + } + if err := (EnvironmentConfig{Counts: EnvironmentCounts{StorageSystems: -3}}).Validate(); err == nil { + t.Error("negative storage-systems count should fail validation") + } +} diff --git a/internal/config/generator_hostmetrics.go b/internal/config/generator_hostmetrics.go index 5f3f2bc..ee2c0d4 100644 --- a/internal/config/generator_hostmetrics.go +++ b/internal/config/generator_hostmetrics.go @@ -3,6 +3,8 @@ package config import ( "fmt" "time" + + "github.com/observiq/blitz/internal/datagen" ) // HostMetricsGeneratorConfig contains configuration for host metrics generator @@ -11,7 +13,9 @@ type HostMetricsGeneratorConfig struct { Workers int `yaml:"workers,omitempty" mapstructure:"workers,omitempty"` // Rate is the scrape interval for host metrics Rate time.Duration `yaml:"rate,omitempty" mapstructure:"rate,omitempty"` - // OS is the simulated operating system. One of: linux, windows + // OS is the simulated operating system. One of: linux, windows, macos + // ("darwin" is accepted as an alias for macos). Empty selects the + // default at construction. OS string `yaml:"os,omitempty" mapstructure:"os,omitempty"` // Hostname is the simulated hostname. If empty, a random hostname is generated. Hostname string `yaml:"hostname,omitempty" mapstructure:"hostname,omitempty"` @@ -49,8 +53,10 @@ func (c *HostMetricsGeneratorConfig) Validate() error { return fmt.Errorf("hostmetrics generator rate must be positive, got %v", c.Rate) } - if c.OS != "" && c.OS != "linux" && c.OS != "windows" { - return fmt.Errorf("hostmetrics generator OS must be one of: linux, windows, got %q", c.OS) + if c.OS != "" { + if _, err := datagen.ParseOSType(c.OS); err != nil { + return fmt.Errorf("hostmetrics generator OS invalid: %w", err) + } } for _, s := range c.Scrapers { diff --git a/internal/config/generator_hostmetrics_test.go b/internal/config/generator_hostmetrics_test.go index 59253cc..7106d58 100644 --- a/internal/config/generator_hostmetrics_test.go +++ b/internal/config/generator_hostmetrics_test.go @@ -57,14 +57,30 @@ func TestHostMetricsGeneratorConfig_Validate(t *testing.T) { errMsg: "rate must be positive", }, { - name: "invalid OS", + name: "valid OS macos", config: HostMetricsGeneratorConfig{ Workers: 1, Rate: time.Second, OS: "macos", }, + }, + { + name: "valid OS darwin alias", + config: HostMetricsGeneratorConfig{ + Workers: 1, + Rate: time.Second, + OS: "darwin", + }, + }, + { + name: "invalid OS", + config: HostMetricsGeneratorConfig{ + Workers: 1, + Rate: time.Second, + OS: "solaris", + }, wantErr: true, - errMsg: "OS must be one of", + errMsg: "unsupported OS", }, { name: "invalid scraper", diff --git a/internal/datagen/environment.go b/internal/datagen/environment.go index a3ec7d4..ddcf5c6 100644 --- a/internal/datagen/environment.go +++ b/internal/datagen/environment.go @@ -2,6 +2,7 @@ package datagen import ( "fmt" + "hash/fnv" "math/rand" "time" @@ -35,6 +36,25 @@ func (e *Environment) AllStorageSystems() []*StorageSystemIdentity { return e.St // AllNetworkSystems returns the environment's network-hardware identities. func (e *Environment) AllNetworkSystems() []*NetworkSystemIdentity { return e.NetworkSystems } +// SystemForKey deterministically selects one of the environment's Systems by a +// caller-supplied key (typically a generator component name, or a component +// plus worker index for per-worker host granularity). The same key always maps +// to the same system for a given Environment, so a generator resolves its host +// identity once and attributes every record it emits consistently. Returns nil +// when the environment has no systems. +func (e *Environment) SystemForKey(key string) *SystemIdentity { + if len(e.Systems) == 0 { + return nil + } + h := fnv.New32a() + _, _ = h.Write([]byte(key)) + // int64 throughout: uint32->int64 and int->int64 are widening (never + // negative, never truncating), so this is correct on 32-bit targets and + // avoids an int->uint32 narrowing conversion. + idx := int64(h.Sum32()) % int64(len(e.Systems)) + return e.Systems[idx] +} + // EnvironmentOpts controls the size and shape of the generated environment. type EnvironmentOpts struct { DomainName string // e.g., "contoso.com". Default: "blitz.local" diff --git a/internal/datagen/environment_selector_test.go b/internal/datagen/environment_selector_test.go new file mode 100644 index 0000000..1af5d8d --- /dev/null +++ b/internal/datagen/environment_selector_test.go @@ -0,0 +1,43 @@ +package datagen + +import "testing" + +func TestEnvironmentSystemForKey(t *testing.T) { + env := &Environment{Systems: []*SystemIdentity{ + {Hostname: "a"}, {Hostname: "b"}, {Hostname: "c"}, + }} + + // Non-empty environment resolves to an in-range system. + first := env.SystemForKey("hostmetrics") + if first == nil { + t.Fatal("SystemForKey returned nil for a non-empty environment") + } + + // Deterministic: the same key always maps to the same system, so a + // generator resolves its host once and attributes every record the same way. + for i := 0; i < 5; i++ { + if env.SystemForKey("hostmetrics") != first { + t.Fatal("SystemForKey is not deterministic for a repeated key") + } + } + + // Every key resolves to a real member of Systems (total, in-range mapping). + members := map[*SystemIdentity]bool{} + for _, s := range env.Systems { + members[s] = true + } + for _, k := range []string{"apache", "nginx", "postgres", "wel", "traces", "json", "fix"} { + s := env.SystemForKey(k) + if s == nil { + t.Fatalf("SystemForKey(%q) = nil", k) + } + if !members[s] { + t.Errorf("SystemForKey(%q) returned a system not in Systems", k) + } + } + + // Empty environment resolves to nil rather than panicking. + if (&Environment{}).SystemForKey("x") != nil { + t.Error("SystemForKey on an empty environment should return nil") + } +} diff --git a/internal/datagen/ostype.go b/internal/datagen/ostype.go new file mode 100644 index 0000000..bd1a7d1 --- /dev/null +++ b/internal/datagen/ostype.go @@ -0,0 +1,62 @@ +package datagen + +import ( + "fmt" + "strings" +) + +// OS taxonomy helpers (PIPE-1036). +// +// Two axes are deliberately kept separate: +// - Simulate-as (the fake identity): the bounded set of OSes blitz can render +// a coherent host for — linux, windows, macos. ParseOSType gates the +// user-facing `os:` knob against this set. +// - Run-on (the real host): whatever the process actually runs on, reported +// by runtime.GOOS. OSTypeFromGOOS maps that without rejecting values outside +// the fake set (freebsd, aix, ...), since blitz may run on and truthfully +// report such a host. +// +// SemconvOSType bridges internal naming to the wire: blitz names macOS "macos" +// internally and to the user, but a real OpenTelemetry pipeline stamps +// os.type=darwin, so that is the value emitted on records. + +// ParseOSType maps a user-supplied OS string to an OSType for the fake-identity +// path. It accepts the three simulate-able OSes, treating "darwin" as an alias +// for "macos". Unknown values return an error. +func ParseOSType(s string) (OSType, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "linux": + return OSLinux, nil + case "windows": + return OSWindows, nil + case "macos", "darwin": + return OSMacOS, nil + default: + return "", fmt.Errorf("datagen: unsupported OS %q (want one of: linux, windows, macos)", s) + } +} + +// OSTypeFromGOOS maps a runtime.GOOS value to an OSType for the real-host path. +// The three simulate-able OSes normalize to their constants; any other GOOS +// passes through unchanged rather than being rejected. +func OSTypeFromGOOS(goos string) OSType { + switch goos { + case "linux": + return OSLinux + case "windows": + return OSWindows + case "darwin": + return OSMacOS + default: + return OSType(goos) + } +} + +// SemconvOSType returns the OpenTelemetry semantic-convention os.type value for +// o, which differs from the OSType constant only for macOS (macos -> darwin). +func (o OSType) SemconvOSType() string { + if o == OSMacOS { + return "darwin" + } + return string(o) +} diff --git a/internal/datagen/ostype_test.go b/internal/datagen/ostype_test.go new file mode 100644 index 0000000..c924372 --- /dev/null +++ b/internal/datagen/ostype_test.go @@ -0,0 +1,65 @@ +package datagen + +import "testing" + +func TestParseOSType(t *testing.T) { + cases := map[string]struct { + in string + want OSType + wantErr bool + }{ + "linux": {"linux", OSLinux, false}, + "windows": {"windows", OSWindows, false}, + "macos": {"macos", OSMacOS, false}, + "darwin alias": {"darwin", OSMacOS, false}, + "upper+space": {" MacOS ", OSMacOS, false}, + "unsupported": {"freebsd", "", true}, + "empty": {"", "", true}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + got, err := ParseOSType(c.in) + if c.wantErr { + if err == nil { + t.Fatalf("ParseOSType(%q): want error, got nil", c.in) + } + return + } + if err != nil { + t.Fatalf("ParseOSType(%q): unexpected error: %v", c.in, err) + } + if got != c.want { + t.Errorf("ParseOSType(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestOSTypeFromGOOS(t *testing.T) { + cases := map[string]OSType{ + "linux": OSLinux, + "windows": OSWindows, + "darwin": OSMacOS, + "freebsd": OSType("freebsd"), + "aix": OSType("aix"), + } + for goos, want := range cases { + if got := OSTypeFromGOOS(goos); got != want { + t.Errorf("OSTypeFromGOOS(%q) = %q, want %q", goos, got, want) + } + } +} + +func TestSemconvOSType(t *testing.T) { + cases := map[OSType]string{ + OSLinux: "linux", + OSWindows: "windows", + OSMacOS: "darwin", + OSType("freebsd"): "freebsd", + } + for os, want := range cases { + if got := os.SemconvOSType(); got != want { + t.Errorf("%q.SemconvOSType() = %q, want %q", os, got, want) + } + } +}