Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion agent/internal/agent/reporting.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
27 changes: 27 additions & 0 deletions agent/internal/container/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand All @@ -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)
Expand All @@ -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"],
Expand All @@ -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 "", "<nil>", "<no value>", "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)
Expand Down
149 changes: 149 additions & 0 deletions agent/internal/container/runtime_test.go
Original file line number Diff line number Diff line change
@@ -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("<no value>"),
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
}
1 change: 1 addition & 0 deletions agent/internal/container/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading