[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/ansible/RFC.md fully. Find ONE feat... #34

Merged
Virgil merged 1 commit from main into dev 2026-04-01 08:31:20 +00:00
3 changed files with 131 additions and 39 deletions

View file

@ -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()

View file

@ -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 ---

View file

@ -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) {