diff --git a/executor.go b/executor.go index 74e8105..46ed9dd 100644 --- a/executor.go +++ b/executor.go @@ -9,6 +9,7 @@ import ( "sync" "text/template" "time" + "unicode" coreerr "dappco.re/go/core/log" ) @@ -44,6 +45,10 @@ type Executor struct { Verbose int } +type commandRunner interface { + Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) +} + // NewExecutor creates a new playbook executor. // // Example: @@ -905,7 +910,16 @@ func (e *Executor) getClient(host string, play *Play) (*SSHClient, error) { // gatherFacts collects facts from a host. func (e *Executor) gatherFacts(ctx context.Context, host string, play *Play) error { - if play.Connection == "local" || host == "localhost" { + client, err := e.getClient(host, play) + if err != nil { + return err + } + + return e.populateFacts(ctx, host, play, client) +} + +func (e *Executor) populateFacts(ctx context.Context, host string, play *Play, client commandRunner) error { + if (play != nil && play.Connection == "local") || host == "localhost" { // Local facts e.facts[host] = &Facts{ Hostname: "localhost", @@ -913,11 +927,6 @@ func (e *Executor) gatherFacts(ctx context.Context, host string, play *Play) err return nil } - client, err := e.getClient(host, play) - if err != nil { - return err - } - // Gather basic facts facts := &Facts{} @@ -938,10 +947,16 @@ func (e *Executor) gatherFacts(ctx context.Context, host string, play *Play) err if corexHasPrefix(line, "ID=") { facts.Distribution = trimCutset(corexTrimPrefix(line, "ID="), "\"") } + if corexHasPrefix(line, "ID_LIKE=") && facts.OS == "" { + facts.OS = titleFactFamily(trimCutset(corexTrimPrefix(line, "ID_LIKE="), "\"")) + } if corexHasPrefix(line, "VERSION_ID=") { facts.Version = trimCutset(corexTrimPrefix(line, "VERSION_ID="), "\"") } } + if facts.OS == "" { + facts.OS = titleFactFamily(facts.Distribution) + } // Architecture stdout, _, _, _ = client.Run(ctx, "uname -m") @@ -951,6 +966,16 @@ func (e *Executor) gatherFacts(ctx context.Context, host string, play *Play) err stdout, _, _, _ = client.Run(ctx, "uname -r") facts.Kernel = corexTrimSpace(stdout) + // Memory, CPU count, and primary IPv4 address + stdout, _, _, _ = client.Run(ctx, "awk '/MemTotal:/ { print int($2 / 1024) }' /proc/meminfo") + facts.Memory = parseFactInt64(stdout) + + stdout, _, _, _ = client.Run(ctx, "nproc --all 2>/dev/null || getconf _NPROCESSORS_ONLN") + facts.CPUs = int(parseFactInt64(stdout)) + + stdout, _, _, _ = client.Run(ctx, "hostname -I 2>/dev/null | awk '{ print $1 }'") + facts.IPv4 = firstNonEmptyField(stdout) + e.mu.Lock() e.facts[host] = facts e.mu.Unlock() @@ -990,6 +1015,60 @@ func normalizeConditions(when any) []string { return nil } +func parseFactInt64(stdout string) int64 { + fields := fields(stdout) + if len(fields) == 0 { + return 0 + } + n, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + return 0 + } + return n +} + +func firstNonEmptyField(stdout string) string { + for _, field := range fields(stdout) { + if field != "" { + return field + } + } + return "" +} + +func titleFactFamily(value string) string { + value = corexTrimSpace(value) + if value == "" { + return "" + } + + fields := fields(value) + if len(fields) == 0 { + return "" + } + + family := lower(fields[0]) + switch family { + case "debian", "ubuntu", "linuxmint": + return "Debian" + case "rhel", "redhat", "centos", "fedora", "rocky", "almalinux", "alma", "ol", "oracle": + return "RedHat" + case "sles", "suse", "opensuse": + return "Suse" + case "arch", "manjaro": + return "Archlinux" + case "alpine": + return "Alpine" + } + + runes := []rune(family) + if len(runes) == 0 { + return "" + } + runes[0] = unicode.ToUpper(runes[0]) + return string(runes) +} + // evalCondition evaluates a single condition. func (e *Executor) evalCondition(cond string, host string) bool { cond = corexTrimSpace(cond) @@ -1119,6 +1198,10 @@ func (e *Executor) lookupTemplateValue(name string, host string, task *Task) (an if facts, ok := e.facts[host]; ok { return facts.Version, true } + case "ansible_os_family": + if facts, ok := e.facts[host]; ok { + return facts.OS, true + } case "ansible_architecture": if facts, ok := e.facts[host]; ok { return facts.Architecture, true @@ -1127,6 +1210,18 @@ func (e *Executor) lookupTemplateValue(name string, host string, task *Task) (an if facts, ok := e.facts[host]; ok { return facts.Kernel, true } + case "ansible_memtotal_mb": + if facts, ok := e.facts[host]; ok { + return facts.Memory, true + } + case "ansible_processor_vcpus": + if facts, ok := e.facts[host]; ok { + return facts.CPUs, true + } + case "ansible_default_ipv4_address": + if facts, ok := e.facts[host]; ok { + return facts.IPv4, true + } } return nil, false @@ -1417,10 +1512,14 @@ func (e *Executor) TemplateFile(src, host string, task *Task) (string, error) { if facts, ok := e.facts[host]; ok { context["ansible_hostname"] = facts.Hostname context["ansible_fqdn"] = facts.FQDN + context["ansible_os_family"] = facts.OS context["ansible_distribution"] = facts.Distribution context["ansible_distribution_version"] = facts.Version context["ansible_architecture"] = facts.Architecture context["ansible_kernel"] = facts.Kernel + context["ansible_memtotal_mb"] = facts.Memory + context["ansible_processor_vcpus"] = facts.CPUs + context["ansible_default_ipv4_address"] = facts.IPv4 } buf := newBuilder() diff --git a/executor_extra_test.go b/executor_extra_test.go index 1ca1693..4fe72d0 100644 --- a/executor_extra_test.go +++ b/executor_extra_test.go @@ -410,18 +410,26 @@ func TestExecutorExtra_ResolveExpr_Good_Facts(t *testing.T) { e.facts["host1"] = &Facts{ Hostname: "web01", FQDN: "web01.example.com", + OS: "Debian", Distribution: "ubuntu", Version: "22.04", Architecture: "x86_64", Kernel: "5.15.0", + Memory: 32768, + CPUs: 16, + IPv4: "10.0.0.10", } assert.Equal(t, "web01", e.resolveExpr("ansible_hostname", "host1", nil)) assert.Equal(t, "web01.example.com", e.resolveExpr("ansible_fqdn", "host1", nil)) + assert.Equal(t, "Debian", e.resolveExpr("ansible_os_family", "host1", nil)) assert.Equal(t, "ubuntu", e.resolveExpr("ansible_distribution", "host1", nil)) assert.Equal(t, "22.04", e.resolveExpr("ansible_distribution_version", "host1", nil)) assert.Equal(t, "x86_64", e.resolveExpr("ansible_architecture", "host1", nil)) assert.Equal(t, "5.15.0", e.resolveExpr("ansible_kernel", "host1", nil)) + assert.Equal(t, "32768", e.resolveExpr("ansible_memtotal_mb", "host1", nil)) + assert.Equal(t, "16", e.resolveExpr("ansible_processor_vcpus", "host1", nil)) + assert.Equal(t, "10.0.0.10", e.resolveExpr("ansible_default_ipv4_address", "host1", nil)) } // --- applyFilter additional coverage --- diff --git a/modules_infra_test.go b/modules_infra_test.go index 09e008a..87c6364 100644 --- a/modules_infra_test.go +++ b/modules_infra_test.go @@ -1,6 +1,7 @@ package ansible import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -812,51 +813,35 @@ func TestModulesInfra_Facts_Good_UbuntuParsing(t *testing.T) { // Mock os-release output for Ubuntu mock.expectCommand(`hostname -f`, "web1.example.com\n", "", 0) mock.expectCommand(`hostname -s`, "web1\n", "", 0) - mock.expectCommand(`cat /etc/os-release`, "ID=ubuntu\nVERSION_ID=\"24.04\"\n", "", 0) + mock.expectCommand(`cat /etc/os-release`, "ID=ubuntu\nVERSION_ID=\"24.04\"\nID_LIKE=debian\n", "", 0) mock.expectCommand(`uname -m`, "x86_64\n", "", 0) mock.expectCommand(`uname -r`, "6.5.0-44-generic\n", "", 0) + mock.expectCommand(`MemTotal`, "16384\n", "", 0) + mock.expectCommand(`nproc`, "8\n", "", 0) + mock.expectCommand(`hostname -I`, "10.0.0.1 10.0.0.2\n", "", 0) - // Simulate fact gathering by directly populating facts - // using the same parsing logic as gatherFacts - facts := &Facts{} - - stdout, _, _, _ := mock.Run(nil, "hostname -f 2>/dev/null || hostname") - facts.FQDN = trimFactSpace(stdout) - - stdout, _, _, _ = mock.Run(nil, "hostname -s 2>/dev/null || hostname") - facts.Hostname = trimFactSpace(stdout) - - stdout, _, _, _ = mock.Run(nil, "cat /etc/os-release 2>/dev/null | grep -E '^(ID|VERSION_ID)=' | head -2") - for _, line := range splitLines(stdout) { - if hasPrefix(line, "ID=") { - facts.Distribution = trimQuotes(trimPrefix(line, "ID=")) - } - if hasPrefix(line, "VERSION_ID=") { - facts.Version = trimQuotes(trimPrefix(line, "VERSION_ID=")) - } - } - - stdout, _, _, _ = mock.Run(nil, "uname -m") - facts.Architecture = trimFactSpace(stdout) - - stdout, _, _, _ = mock.Run(nil, "uname -r") - facts.Kernel = trimFactSpace(stdout) - - e.facts["web1"] = facts + require.NoError(t, e.populateFacts(context.Background(), "web1", &Play{}, mock)) + facts := e.facts["web1"] + require.NotNil(t, facts) assert.Equal(t, "web1.example.com", facts.FQDN) assert.Equal(t, "web1", facts.Hostname) + assert.Equal(t, "Debian", facts.OS) assert.Equal(t, "ubuntu", facts.Distribution) assert.Equal(t, "24.04", facts.Version) assert.Equal(t, "x86_64", facts.Architecture) assert.Equal(t, "6.5.0-44-generic", facts.Kernel) + assert.Equal(t, int64(16384), facts.Memory) + assert.Equal(t, 8, facts.CPUs) + assert.Equal(t, "10.0.0.1", facts.IPv4) // Now verify template resolution with these facts - result := e.templateString("{{ ansible_hostname }}", "web1", nil) - assert.Equal(t, "web1", result) - - result = e.templateString("{{ ansible_distribution }}", "web1", nil) - assert.Equal(t, "ubuntu", result) + assert.Equal(t, "web1", e.templateString("{{ ansible_hostname }}", "web1", nil)) + assert.Equal(t, "ubuntu", e.templateString("{{ ansible_distribution }}", "web1", nil)) + assert.Equal(t, "Debian", e.templateString("{{ ansible_os_family }}", "web1", nil)) + assert.Equal(t, "16384", e.templateString("{{ ansible_memtotal_mb }}", "web1", nil)) + assert.Equal(t, "8", e.templateString("{{ ansible_processor_vcpus }}", "web1", nil)) + assert.Equal(t, "10.0.0.1", e.templateString("{{ ansible_default_ipv4_address }}", "web1", nil)) } func TestModulesInfra_Facts_Good_CentOSParsing(t *testing.T) {