This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ContractVersion = "v1"
|
||||
DefaultMaxContainers = 250
|
||||
)
|
||||
|
||||
type Limits struct {
|
||||
MaxContainers, MaxPorts, MaxVolumes, MaxNetworks, MaxLabels, MaxPageSize int
|
||||
FreshnessMaxAge time.Duration
|
||||
}
|
||||
|
||||
func (l Limits) withDefaults() Limits {
|
||||
if l.MaxContainers == 0 {
|
||||
// The v1 performance target remains 150 containers. Bounded growth margin
|
||||
// keeps a modestly larger host from invalidating the complete snapshot.
|
||||
l.MaxContainers = DefaultMaxContainers
|
||||
}
|
||||
if l.MaxPorts == 0 {
|
||||
l.MaxPorts = 32
|
||||
}
|
||||
if l.MaxVolumes == 0 {
|
||||
l.MaxVolumes = 32
|
||||
}
|
||||
if l.MaxNetworks == 0 {
|
||||
l.MaxNetworks = 32
|
||||
}
|
||||
if l.MaxLabels == 0 {
|
||||
l.MaxLabels = 64
|
||||
}
|
||||
if l.MaxPageSize == 0 {
|
||||
l.MaxPageSize = 100
|
||||
}
|
||||
if l.FreshnessMaxAge == 0 {
|
||||
l.FreshnessMaxAge = 60 * time.Second
|
||||
}
|
||||
return l
|
||||
}
|
||||
func (l Limits) Validate() error {
|
||||
if l.MaxContainers < 1 || l.MaxContainers > 1000 || l.MaxPorts < 1 || l.MaxPorts > 128 || l.MaxVolumes < 1 || l.MaxVolumes > 128 || l.MaxNetworks < 1 || l.MaxNetworks > 128 || l.MaxLabels < 1 || l.MaxLabels > 256 || l.MaxPageSize < 1 || l.MaxPageSize > 500 || l.FreshnessMaxAge <= 0 || l.FreshnessMaxAge > 24*time.Hour {
|
||||
return errors.New("container limits are outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Freshness string `json:"freshness"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
type Port struct {
|
||||
ContainerPort int `json:"containerPort"`
|
||||
HostPort int `json:"hostPort,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
type RawContainer struct {
|
||||
ID, Name, Image, ImageDigest, State, Health string
|
||||
IntentionalStop bool
|
||||
MetricsAvailable, LifecycleAvailable bool
|
||||
UptimeSeconds float64
|
||||
RestartCount int
|
||||
ExitCode int
|
||||
CPUPercent float64
|
||||
MemoryBytes, MemoryLimitBytes uint64
|
||||
NetworkRxBytes, NetworkTxBytes uint64
|
||||
BlockReadBytes, BlockWriteBytes uint64
|
||||
Ports []Port
|
||||
Volumes, Networks []string
|
||||
Project string
|
||||
Labels map[string]string
|
||||
}
|
||||
type Container struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image,omitempty"`
|
||||
ImageDigest string `json:"imageDigest,omitempty"`
|
||||
State string `json:"state"`
|
||||
Health string `json:"health"`
|
||||
IntentionalStop bool `json:"intentionalStop"`
|
||||
MetricsAvailable bool `json:"metricsAvailable"`
|
||||
LifecycleAvailable bool `json:"lifecycleAvailable"`
|
||||
UptimeSeconds float64 `json:"uptimeSeconds"`
|
||||
RestartCount int `json:"restartCount"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
CPUPercent float64 `json:"cpuPercent"`
|
||||
MemoryBytes uint64 `json:"memoryBytes"`
|
||||
MemoryLimitBytes uint64 `json:"memoryLimitBytes"`
|
||||
NetworkRxBytes uint64 `json:"networkRxBytes"`
|
||||
NetworkTxBytes uint64 `json:"networkTxBytes"`
|
||||
BlockReadBytes uint64 `json:"blockReadBytes"`
|
||||
BlockWriteBytes uint64 `json:"blockWriteBytes"`
|
||||
Ports []Port `json:"ports"`
|
||||
Volumes []string `json:"volumes"`
|
||||
Networks []string `json:"networks"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
type RawSnapshot struct {
|
||||
Source Source
|
||||
Containers []RawContainer
|
||||
ObservedAt, ReceivedAt time.Time
|
||||
}
|
||||
type Snapshot struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
Source Source `json:"source"`
|
||||
Containers []Container `json:"containers"`
|
||||
Total int `json:"total"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
type Provider interface {
|
||||
Snapshot(context.Context) (Snapshot, error)
|
||||
}
|
||||
type Adapter struct {
|
||||
Source interface {
|
||||
Snapshot(context.Context) (RawSnapshot, error)
|
||||
}
|
||||
Limits Limits
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (a Adapter) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if a.Source == nil {
|
||||
return UnknownSnapshot(time.Now().UTC(), "container", "agent", "source_unavailable"), nil
|
||||
}
|
||||
raw, err := a.Source.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if a.Now != nil {
|
||||
now = a.Now()
|
||||
}
|
||||
return Normalize(raw, now, a.Limits)
|
||||
}
|
||||
func UnknownSnapshot(now time.Time, id, typ, reason string) Snapshot {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: id, Type: typ, ReceivedAt: now, Freshness: "unavailable", State: "unknown", Reason: reason}, Containers: []Container{}, Total: 0}
|
||||
}
|
||||
func Normalize(raw RawSnapshot, now time.Time, limits Limits) (Snapshot, error) {
|
||||
limits = limits.withDefaults()
|
||||
if err := limits.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
if raw.ReceivedAt.IsZero() {
|
||||
raw.ReceivedAt = now
|
||||
}
|
||||
if raw.ObservedAt.IsZero() {
|
||||
raw.ObservedAt = raw.ReceivedAt
|
||||
}
|
||||
if raw.ObservedAt.After(now.Add(time.Minute)) {
|
||||
return Snapshot{}, errors.New("container observation is materially in the future")
|
||||
}
|
||||
if len(raw.Containers) > limits.MaxContainers {
|
||||
return Snapshot{}, errors.New("container count exceeds bounds")
|
||||
}
|
||||
source := raw.Source
|
||||
if source.ID == "" {
|
||||
source.ID = "container"
|
||||
}
|
||||
if source.Type == "" {
|
||||
source.Type = "agent"
|
||||
}
|
||||
source.ObservedAt = raw.ObservedAt.UTC()
|
||||
source.ReceivedAt = raw.ReceivedAt.UTC()
|
||||
source.Freshness = "fresh"
|
||||
source.State = "healthy"
|
||||
if now.Sub(raw.ObservedAt) > limits.FreshnessMaxAge {
|
||||
source.Freshness = "stale"
|
||||
source.State = "unknown"
|
||||
source.Reason = "stale_source"
|
||||
}
|
||||
items := make([]Container, 0, len(raw.Containers))
|
||||
for _, r := range raw.Containers {
|
||||
if strings.TrimSpace(r.ID) == "" || strings.TrimSpace(r.Name) == "" || len(r.Name) > 255 || r.RestartCount < 0 || r.UptimeSeconds < 0 || r.CPUPercent < 0 || r.CPUPercent > 10000 || math.IsNaN(r.CPUPercent) || math.IsInf(r.CPUPercent, 0) {
|
||||
return Snapshot{}, errors.New("invalid container identity or metrics")
|
||||
}
|
||||
if len(r.Ports) > limits.MaxPorts || len(r.Volumes) > limits.MaxVolumes || len(r.Networks) > limits.MaxNetworks || len(r.Labels) > limits.MaxLabels {
|
||||
return Snapshot{}, errors.New("container detail exceeds bounds")
|
||||
}
|
||||
ports := append([]Port(nil), r.Ports...)
|
||||
sort.Slice(ports, func(i, j int) bool {
|
||||
if ports[i].ContainerPort != ports[j].ContainerPort {
|
||||
return ports[i].ContainerPort < ports[j].ContainerPort
|
||||
}
|
||||
return ports[i].Protocol < ports[j].Protocol
|
||||
})
|
||||
volumes := sortedStrings(r.Volumes)
|
||||
networks := sortedStrings(r.Networks)
|
||||
labels := make(map[string]string, len(r.Labels))
|
||||
for k, v := range r.Labels {
|
||||
if len(k) <= 128 && len(v) <= 512 {
|
||||
labels[k] = v
|
||||
}
|
||||
}
|
||||
items = append(items, Container{ID: r.ID, Name: r.Name, Image: r.Image, ImageDigest: r.ImageDigest, State: normalizeRuntimeState(r.State), Health: normalizeHealth(r.Health), IntentionalStop: r.IntentionalStop, MetricsAvailable: r.MetricsAvailable, LifecycleAvailable: r.LifecycleAvailable, UptimeSeconds: r.UptimeSeconds, RestartCount: r.RestartCount, ExitCode: r.ExitCode, CPUPercent: r.CPUPercent, MemoryBytes: r.MemoryBytes, MemoryLimitBytes: r.MemoryLimitBytes, NetworkRxBytes: r.NetworkRxBytes, NetworkTxBytes: r.NetworkTxBytes, BlockReadBytes: r.BlockReadBytes, BlockWriteBytes: r.BlockWriteBytes, Ports: ports, Volumes: volumes, Networks: networks, Project: r.Project, Labels: labels})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Name != items[j].Name {
|
||||
return items[i].Name < items[j].Name
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: source, Containers: items, Total: len(items)}, nil
|
||||
}
|
||||
func Page(snapshot Snapshot, limit int, after string, limits Limits) (Snapshot, error) {
|
||||
return FilteredPage(snapshot, limit, after, limits, "", "", "", "name")
|
||||
}
|
||||
|
||||
func FilteredPage(snapshot Snapshot, limit int, after string, limits Limits, query, state, health, order string) (Snapshot, error) {
|
||||
limits = limits.withDefaults()
|
||||
if err := limits.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if limit < 1 || limit > limits.MaxPageSize {
|
||||
return Snapshot{}, errors.New("container page limit is outside bounds")
|
||||
}
|
||||
query, state, health, order = strings.ToLower(strings.TrimSpace(query)), strings.ToLower(strings.TrimSpace(state)), strings.ToLower(strings.TrimSpace(health)), strings.ToLower(strings.TrimSpace(order))
|
||||
if len(query) > 100 || (state != "" && normalizeRuntimeState(state) != state) || (health != "" && normalizeHealth(health) != health) || (order != "name" && order != "cpu" && order != "memory" && order != "state") {
|
||||
return Snapshot{}, errors.New("container filters are invalid")
|
||||
}
|
||||
items := make([]Container, 0, len(snapshot.Containers))
|
||||
for _, item := range snapshot.Containers {
|
||||
if query != "" && !strings.Contains(strings.ToLower(item.Name+" "+item.Project+" "+item.Image), query) {
|
||||
continue
|
||||
}
|
||||
if state != "" && item.State != state {
|
||||
continue
|
||||
}
|
||||
if health != "" && item.Health != health {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
switch order {
|
||||
case "cpu":
|
||||
if items[i].CPUPercent != items[j].CPUPercent {
|
||||
return items[i].CPUPercent > items[j].CPUPercent
|
||||
}
|
||||
case "memory":
|
||||
if items[i].MemoryBytes != items[j].MemoryBytes {
|
||||
return items[i].MemoryBytes > items[j].MemoryBytes
|
||||
}
|
||||
case "state":
|
||||
if items[i].State != items[j].State {
|
||||
return items[i].State < items[j].State
|
||||
}
|
||||
}
|
||||
if items[i].Name != items[j].Name {
|
||||
return items[i].Name < items[j].Name
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
start := 0
|
||||
if after != "" {
|
||||
n, err := strconv.Atoi(after)
|
||||
if err != nil || n < 0 || n > len(items) {
|
||||
return Snapshot{}, errors.New("invalid container cursor")
|
||||
}
|
||||
start = n
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
result := snapshot
|
||||
result.Containers = items[start:end]
|
||||
result.Total = len(items)
|
||||
result.NextCursor = ""
|
||||
if end < len(items) {
|
||||
result.NextCursor = strconv.Itoa(end)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func sortedStrings(values []string) []string {
|
||||
r := append([]string(nil), values...)
|
||||
sort.Strings(r)
|
||||
return r
|
||||
}
|
||||
func normalizeRuntimeState(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "running", "restarting", "paused", "exited", "dead", "stopped", "created", "removing":
|
||||
return value
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHealth(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "healthy", "unhealthy", "starting":
|
||||
return value
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeAcceptsBoundedOperationalContainerHeadroom(t *testing.T) {
|
||||
now := time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC)
|
||||
raw := RawSnapshot{Source: Source{ID: "agent"}, ObservedAt: now, ReceivedAt: now}
|
||||
for index := 0; index < DefaultMaxContainers; index++ {
|
||||
raw.Containers = append(raw.Containers, RawContainer{ID: fmt.Sprintf("id-%03d", index), Name: fmt.Sprintf("container-%03d", index), State: "running"})
|
||||
}
|
||||
if snapshot, err := Normalize(raw, now, Limits{}); err != nil || snapshot.Total != DefaultMaxContainers {
|
||||
t.Fatalf("bounded headroom snapshot total=%d err=%v", snapshot.Total, err)
|
||||
}
|
||||
raw.Containers = append(raw.Containers, RawContainer{ID: "overflow", Name: "overflow", State: "running"})
|
||||
if _, err := Normalize(raw, now, Limits{}); err == nil {
|
||||
t.Fatal("container inventory beyond bounded headroom was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize150ContainerFixtureAndSeparateStateHealth(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
raw := RawSnapshot{Source: Source{ID: "agent-1"}, ObservedAt: now, ReceivedAt: now}
|
||||
for i := 0; i < 150; i++ {
|
||||
raw.Containers = append(raw.Containers, RawContainer{ID: string(rune('a'+i/26)) + string(rune('a'+i%26)), Name: "container-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i/26)), State: "running", Health: "unhealthy", CPUPercent: 1})
|
||||
}
|
||||
raw.Containers[0].IntentionalStop = true
|
||||
raw.Containers[0].State = "exited"
|
||||
raw.Containers[0].Health = "healthy"
|
||||
snapshot, err := Normalize(raw, now, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Total != 150 || len(snapshot.Containers) != 150 || snapshot.Containers[0].State == snapshot.Containers[0].Health {
|
||||
t.Fatalf("container state and health were conflated: %+v", snapshot.Containers[0])
|
||||
}
|
||||
foundStopped := false
|
||||
for _, item := range snapshot.Containers {
|
||||
if item.IntentionalStop {
|
||||
foundStopped = item.State == "exited" && item.Health == "healthy"
|
||||
}
|
||||
}
|
||||
if !foundStopped {
|
||||
t.Fatal("intentional stop was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageIsBoundedAndDeterministic(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
raw := RawSnapshot{Source: Source{ID: "agent"}, ObservedAt: now, ReceivedAt: now, Containers: []RawContainer{{ID: "b", Name: "zeta", State: "running"}, {ID: "a", Name: "alpha", State: "running"}}}
|
||||
snapshot, err := Normalize(raw, now, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := Page(snapshot, 1, "", Limits{})
|
||||
if err != nil || len(page.Containers) != 1 || page.Containers[0].Name != "alpha" || page.NextCursor != "1" {
|
||||
t.Fatalf("page=%+v err=%v", page, err)
|
||||
}
|
||||
if _, err := Page(snapshot, 101, "", Limits{}); err == nil {
|
||||
t.Fatal("oversized page accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredPageFiltersBeforeCursorAndSortsDeterministically(t *testing.T) {
|
||||
snapshot := Snapshot{Containers: []Container{{ID: "b", Name: "Beta", State: "running", Health: "healthy", CPUPercent: 5}, {ID: "a", Name: "Alpha", State: "running", Health: "healthy", CPUPercent: 10}, {ID: "c", Name: "Other", State: "exited", Health: "unknown"}}}
|
||||
page, err := FilteredPage(snapshot, 1, "", Limits{}, "a", "running", "healthy", "cpu")
|
||||
if err != nil || page.Total != 2 || len(page.Containers) != 1 || page.Containers[0].ID != "a" || page.NextCursor != "1" {
|
||||
t.Fatalf("filtered page=%+v err=%v", page, err)
|
||||
}
|
||||
next, err := FilteredPage(snapshot, 1, page.NextCursor, Limits{}, "a", "running", "healthy", "cpu")
|
||||
if err != nil || len(next.Containers) != 1 || next.Containers[0].ID != "b" {
|
||||
t.Fatalf("next filtered page=%+v err=%v", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleSourceAndContextCancellation(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
raw := RawSnapshot{Source: Source{ID: "agent"}, ObservedAt: now.Add(-2 * time.Minute), ReceivedAt: now, Containers: []RawContainer{{ID: "a", Name: "alpha", State: "running"}}}
|
||||
snapshot, err := Normalize(raw, now, Limits{FreshnessMaxAge: time.Minute})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Source.State != "unknown" || snapshot.Source.Freshness != "stale" {
|
||||
t.Fatalf("source=%+v", snapshot.Source)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = (Adapter{}).Snapshot(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCanonicalizesRuntimeAndPreservesAvailability(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC)
|
||||
snapshot, err := Normalize(RawSnapshot{
|
||||
Source: Source{ID: "agent"}, ObservedAt: now, ReceivedAt: now,
|
||||
Containers: []RawContainer{
|
||||
{ID: "a", Name: "alpha", State: " RUNNING ", Health: " HEALTHY ", MetricsAvailable: true, LifecycleAvailable: true},
|
||||
{ID: "b", Name: "beta", State: "RUNNING", Health: "Up 34 hours"},
|
||||
},
|
||||
}, now, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Containers[0].State != "running" || snapshot.Containers[0].Health != "healthy" || !snapshot.Containers[0].MetricsAvailable || !snapshot.Containers[0].LifecycleAvailable {
|
||||
t.Fatalf("canonical container = %+v", snapshot.Containers[0])
|
||||
}
|
||||
if snapshot.Containers[1].State != "running" || snapshot.Containers[1].Health != "unknown" || snapshot.Containers[1].MetricsAvailable || snapshot.Containers[1].LifecycleAvailable {
|
||||
t.Fatalf("missing telemetry was fabricated: %+v", snapshot.Containers[1])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user