From 6e4cd5fb33c17cc283006e70f443dc8105259651 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:15:41 +1000 Subject: [PATCH] Use Podman list health status --- agent/internal/agent/reporting.go | 8 +- agent/internal/container/runtime.go | 27 ++++ agent/internal/container/runtime_test.go | 149 +++++++++++++++++++++++ agent/internal/container/types.go | 1 + 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 agent/internal/container/runtime_test.go diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go index e7459e0d..111dda71 100644 --- a/agent/internal/agent/reporting.go +++ b/agent/internal/agent/reporting.go @@ -78,7 +78,13 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport healthStatus := "none" if c.State == "running" { - healthStatus = container.GetHealthStatus(c.ID) + healthStatus = c.HealthStatus + if healthStatus == "" { + healthStatus = container.GetHealthStatus(c.ID) + } + } + if healthStatus == "" { + healthStatus = "none" } report.Containers = append(report.Containers, agenthttp.ContainerStatus{ diff --git a/agent/internal/container/runtime.go b/agent/internal/container/runtime.go index 21a8ba5d..788b59eb 100644 --- a/agent/internal/container/runtime.go +++ b/agent/internal/container/runtime.go @@ -465,6 +465,7 @@ type podmanContainer struct { Names []string `json:"Names"` Image string `json:"Image"` State string `json:"State"` + Status *string `json:"Status"` Created int64 `json:"Created"` Labels map[string]string `json:"Labels"` } @@ -476,6 +477,15 @@ func List() ([]Container, error) { return nil, fmt.Errorf("failed to list containers: %s: %w", string(output), err) } + containers, err := parseContainerList(output) + if err != nil { + return nil, err + } + + return containers, nil +} + +func parseContainerList(output []byte) ([]Container, error) { var podmanContainers []podmanContainer if err := json.Unmarshal(output, &podmanContainers); err != nil { return nil, fmt.Errorf("failed to parse container list: %w", err) @@ -492,6 +502,7 @@ func List() ([]Container, error) { Name: name, Image: pc.Image, State: pc.State, + HealthStatus: normalizeHealthStatus(pc.Status), Created: pc.Created, Labels: pc.Labels, DeploymentID: pc.Labels["techulus.deployment.id"], @@ -502,6 +513,22 @@ func List() ([]Container, error) { return containers, nil } +func normalizeHealthStatus(status *string) string { + if status == nil { + return "" + } + + normalized := strings.ToLower(strings.TrimSpace(*status)) + switch normalized { + case "healthy", "unhealthy", "starting": + return normalized + case "", "", "", "none", "null", "unconfigured": + return "none" + default: + return "" + } +} + func EnsureNetwork(subnetId int) error { subnet := fmt.Sprintf("10.200.%d.0/24", subnetId) gateway := fmt.Sprintf("10.200.%d.1", subnetId) diff --git a/agent/internal/container/runtime_test.go b/agent/internal/container/runtime_test.go new file mode 100644 index 00000000..24018db0 --- /dev/null +++ b/agent/internal/container/runtime_test.go @@ -0,0 +1,149 @@ +package container + +import "testing" + +func TestParseContainerListReadsStatusHealth(t *testing.T) { + output := []byte(`[ + { + "Id": "abc123", + "Names": ["svc-web"], + "Image": "registry.example.com/web:latest", + "State": "running", + "Status": "healthy", + "Created": 1710000000, + "Labels": { + "techulus.deployment.id": "dep_123", + "techulus.service.id": "svc_123" + } + } + ]`) + + containers, err := parseContainerList(output) + if err != nil { + t.Fatalf("parseContainerList returned error: %v", err) + } + if len(containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(containers)) + } + + got := containers[0] + if got.Name != "svc-web" { + t.Fatalf("expected name svc-web, got %q", got.Name) + } + if got.HealthStatus != "healthy" { + t.Fatalf("expected health healthy, got %q", got.HealthStatus) + } + if got.DeploymentID != "dep_123" { + t.Fatalf("expected deployment dep_123, got %q", got.DeploymentID) + } + if got.ServiceID != "svc_123" { + t.Fatalf("expected service svc_123, got %q", got.ServiceID) + } +} + +func TestParseContainerListLeavesMissingStatusEmpty(t *testing.T) { + output := []byte(`[ + { + "Id": "ghi789", + "Names": ["svc-legacy"], + "Image": "registry.example.com/legacy:latest", + "State": "running", + "Created": 1710000002, + "Labels": { + "techulus.deployment.id": "dep_789", + "techulus.service.id": "svc_789" + } + } + ]`) + + containers, err := parseContainerList(output) + if err != nil { + t.Fatalf("parseContainerList returned error: %v", err) + } + if len(containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(containers)) + } + if containers[0].HealthStatus != "" { + t.Fatalf("expected missing status to stay empty for inspect fallback, got %q", containers[0].HealthStatus) + } +} + +func TestParseContainerListNormalizesEmptyStatusToNone(t *testing.T) { + output := []byte(`[ + { + "Id": "jkl012", + "Names": ["svc-no-healthcheck"], + "Image": "registry.example.com/no-healthcheck:latest", + "State": "running", + "Status": "", + "Created": 1710000003, + "Labels": { + "techulus.deployment.id": "dep_012", + "techulus.service.id": "svc_012" + } + } + ]`) + + containers, err := parseContainerList(output) + if err != nil { + t.Fatalf("parseContainerList returned error: %v", err) + } + if len(containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(containers)) + } + if containers[0].HealthStatus != "none" { + t.Fatalf("expected empty status to normalize to none, got %q", containers[0].HealthStatus) + } +} + +func TestNormalizeHealthStatus(t *testing.T) { + tests := []struct { + name string + raw *string + want string + }{ + { + name: "missing status stays empty for inspect fallback", + raw: nil, + want: "", + }, + { + name: "empty status means no healthcheck", + raw: stringPtr(""), + want: "none", + }, + { + name: "starting string is preserved", + raw: stringPtr("starting"), + want: "starting", + }, + { + name: "case and whitespace are normalized", + raw: stringPtr(" Healthy "), + want: "healthy", + }, + { + name: "unknown string stays empty for inspect fallback", + raw: stringPtr("degraded"), + want: "", + }, + { + name: "template no-value sentinel is treated as no healthcheck", + raw: stringPtr(""), + want: "none", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeHealthStatus(tt.raw) + if got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func stringPtr(value string) *string { + return &value +} diff --git a/agent/internal/container/types.go b/agent/internal/container/types.go index 53f1a03c..7cecacf8 100644 --- a/agent/internal/container/types.go +++ b/agent/internal/container/types.go @@ -52,6 +52,7 @@ type Container struct { Name string `json:"Name"` Image string `json:"Image"` State string `json:"State"` + HealthStatus string `json:"HealthStatus"` Created int64 `json:"Created"` Labels map[string]string `json:"Labels"` DeploymentID string