This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ContractVersion = "v1"
|
||||
|
||||
type Limits struct {
|
||||
MaxRows int
|
||||
MaxPageSize int
|
||||
}
|
||||
|
||||
func (l Limits) withDefaults() Limits {
|
||||
if l.MaxRows == 0 {
|
||||
l.MaxRows = 1000
|
||||
}
|
||||
if l.MaxPageSize == 0 {
|
||||
l.MaxPageSize = 100
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l Limits) Validate() error {
|
||||
if l.MaxRows < 1 || l.MaxRows > 5000 || l.MaxPageSize < 1 || l.MaxPageSize > 500 {
|
||||
return errors.New("process limits are outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CapabilityVersion string `json:"capabilityVersion"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type RawProcess struct {
|
||||
PID int `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
RuntimeSeconds float64 `json:"runtimeSeconds"`
|
||||
CPUPercent float64 `json:"cpuPercent"`
|
||||
MemoryBytes uint64 `json:"memoryBytes"`
|
||||
ContainerID string `json:"containerId,omitempty"`
|
||||
ContainerName string `json:"containerName,omitempty"`
|
||||
}
|
||||
|
||||
type Process struct {
|
||||
PID int `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
RuntimeSeconds float64 `json:"runtimeSeconds"`
|
||||
CPUPercent float64 `json:"cpuPercent"`
|
||||
MemoryBytes uint64 `json:"memoryBytes"`
|
||||
ContainerID string `json:"containerId,omitempty"`
|
||||
ContainerName string `json:"containerName,omitempty"`
|
||||
}
|
||||
|
||||
type RawSnapshot struct {
|
||||
Source Source `json:"source"`
|
||||
Processes []RawProcess `json:"processes"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
Source Source `json:"source"`
|
||||
Processes []Process `json:"processes"`
|
||||
Total int `json:"total"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Snapshot(context.Context) (RawSnapshot, error)
|
||||
}
|
||||
|
||||
type Adapter struct {
|
||||
Source Provider
|
||||
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(), "process", "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, sourceID, sourceType, reason string) Snapshot {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
if reason == "" {
|
||||
reason = "source_unavailable"
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: sourceID, Type: sourceType, CapabilityVersion: ContractVersion, ReceivedAt: now, State: "unknown", Reason: reason}, Processes: []Process{}, 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("process observation is materially in the future")
|
||||
}
|
||||
if len(raw.Processes) > limits.MaxRows {
|
||||
return Snapshot{}, errors.New("process rows exceed bounds")
|
||||
}
|
||||
source := raw.Source
|
||||
if source.ID == "" {
|
||||
source.ID = "process"
|
||||
}
|
||||
if source.Type == "" {
|
||||
source.Type = "agent"
|
||||
}
|
||||
if source.CapabilityVersion == "" {
|
||||
source.CapabilityVersion = ContractVersion
|
||||
}
|
||||
source.ObservedAt = raw.ObservedAt.UTC()
|
||||
source.ReceivedAt = raw.ReceivedAt.UTC()
|
||||
source.State = "healthy"
|
||||
processes := make([]Process, 0, len(raw.Processes))
|
||||
for _, item := range raw.Processes {
|
||||
if item.PID < 1 || item.PID > 2147483647 || strings.TrimSpace(item.Name) == "" || len(item.Name) > 512 || strings.ContainsRune(item.Name, '\x00') {
|
||||
return Snapshot{}, errors.New("invalid process identity")
|
||||
}
|
||||
if item.RuntimeSeconds < 0 || item.RuntimeSeconds > 100*365*24*60*60 || math.IsNaN(item.RuntimeSeconds) || math.IsInf(item.RuntimeSeconds, 0) || item.CPUPercent < 0 || item.CPUPercent > 10000 || math.IsNaN(item.CPUPercent) || math.IsInf(item.CPUPercent, 0) {
|
||||
return Snapshot{}, errors.New("invalid process metrics")
|
||||
}
|
||||
name := privacyName(item.Name)
|
||||
if name == "" {
|
||||
return Snapshot{}, errors.New("process name is empty after privacy normalization")
|
||||
}
|
||||
processes = append(processes, Process{PID: item.PID, Name: name, State: boundedState(item.State), RuntimeSeconds: item.RuntimeSeconds, CPUPercent: item.CPUPercent, MemoryBytes: item.MemoryBytes, ContainerID: boundedOptional(item.ContainerID, 128), ContainerName: boundedOptional(item.ContainerName, 255)})
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: source, Processes: processes, Total: len(processes)}, nil
|
||||
}
|
||||
|
||||
type SortMode string
|
||||
|
||||
const (
|
||||
SortCPU SortMode = "cpu"
|
||||
SortMemory SortMode = "memory"
|
||||
)
|
||||
|
||||
func Page(snapshot Snapshot, mode SortMode, limit int, after string, limits Limits) (Snapshot, error) {
|
||||
return FilteredPage(snapshot, mode, limit, after, limits, "", "")
|
||||
}
|
||||
|
||||
func FilteredPage(snapshot Snapshot, mode SortMode, limit int, after string, limits Limits, query, containerName 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("process page limit is outside bounds")
|
||||
}
|
||||
if mode != SortCPU && mode != SortMemory {
|
||||
return Snapshot{}, errors.New("process sort must be cpu or memory")
|
||||
}
|
||||
query, containerName = strings.ToLower(strings.TrimSpace(query)), strings.ToLower(strings.TrimSpace(containerName))
|
||||
if len(query) > 100 || len(containerName) > 100 {
|
||||
return Snapshot{}, errors.New("process filters are invalid")
|
||||
}
|
||||
items := make([]Process, 0, len(snapshot.Processes))
|
||||
for _, item := range snapshot.Processes {
|
||||
if query != "" && !strings.Contains(strings.ToLower(item.Name), query) {
|
||||
continue
|
||||
}
|
||||
if containerName != "" && !strings.Contains(strings.ToLower(item.ContainerName), containerName) {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
start := 0
|
||||
if after != "" {
|
||||
parsed, err := strconv.Atoi(after)
|
||||
if err != nil || parsed < 0 || parsed > len(items) {
|
||||
return Snapshot{}, errors.New("invalid process cursor")
|
||||
}
|
||||
start = parsed
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if mode == SortMemory && items[i].MemoryBytes != items[j].MemoryBytes {
|
||||
return items[i].MemoryBytes > items[j].MemoryBytes
|
||||
}
|
||||
if mode == SortCPU && items[i].CPUPercent != items[j].CPUPercent {
|
||||
return items[i].CPUPercent > items[j].CPUPercent
|
||||
}
|
||||
if items[i].PID != items[j].PID {
|
||||
return items[i].PID < items[j].PID
|
||||
}
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
if start > len(items) {
|
||||
start = len(items)
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
result := snapshot
|
||||
result.Processes = items[start:end]
|
||||
result.Total = len(items)
|
||||
result.NextCursor = ""
|
||||
if end < len(items) {
|
||||
result.NextCursor = strconv.Itoa(end)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func privacyName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if index := strings.IndexAny(value, " "+string(rune(9))); index >= 0 {
|
||||
value = value[:index]
|
||||
}
|
||||
if index := strings.LastIndexAny(value, "/\\"); index >= 0 {
|
||||
value = value[index+1:]
|
||||
}
|
||||
if len(value) > 255 {
|
||||
value = value[:255]
|
||||
}
|
||||
return value
|
||||
}
|
||||
func boundedState(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > 40 {
|
||||
return value[:40]
|
||||
}
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return value
|
||||
}
|
||||
func boundedOptional(value string, max int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > max {
|
||||
return value[:max]
|
||||
}
|
||||
return value
|
||||
}
|
||||
func (s Snapshot) String() string { return fmt.Sprintf("%s/%d", s.Source.ID, s.Total) }
|
||||
@@ -0,0 +1,89 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func processFixture(now time.Time) RawSnapshot {
|
||||
return RawSnapshot{Source: Source{ID: "agent-1", Type: "agent"}, Processes: []RawProcess{{PID: 2, Name: "/usr/bin/worker --secret-token=redacted", State: "running", RuntimeSeconds: 20, CPUPercent: 40, MemoryBytes: 200}, {PID: 1, Name: "init", State: "sleeping", RuntimeSeconds: 100, CPUPercent: 40, MemoryBytes: 500}}, ObservedAt: now, ReceivedAt: now}
|
||||
}
|
||||
|
||||
func TestNormalizeMinimizesSensitiveProcessNameAndPreservesMetrics(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
snapshot, err := Normalize(processFixture(now), now, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Processes[0].Name != "worker" || snapshot.Processes[0].MemoryBytes != 200 || snapshot.Processes[0].CPUPercent != 40 {
|
||||
t.Fatalf("unexpected process normalization: %+v", snapshot.Processes[0])
|
||||
}
|
||||
if snapshot.Processes[0].Name == "init" {
|
||||
t.Fatal("unexpected fixture ordering assumption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageIsBoundedAndDeterministic(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
snapshot, err := Normalize(processFixture(now), now, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := Page(snapshot, SortCPU, 1, "", Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Processes) != 1 || page.Processes[0].PID != 1 || page.NextCursor != "1" || page.Total != 2 {
|
||||
t.Fatalf("page=%+v", page)
|
||||
}
|
||||
next, err := Page(snapshot, SortCPU, 1, page.NextCursor, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(next.Processes) != 1 || next.Processes[0].PID != 2 || next.NextCursor != "" {
|
||||
t.Fatalf("next=%+v", next)
|
||||
}
|
||||
if _, err := Page(snapshot, SortCPU, 101, "", Limits{MaxPageSize: 100}); err == nil {
|
||||
t.Fatal("unbounded page accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredPageFiltersBeforePagination(t *testing.T) {
|
||||
snapshot := Snapshot{Processes: []Process{{PID: 1, Name: "postgres", ContainerName: "db", CPUPercent: 2}, {PID: 2, Name: "worker", ContainerName: "pulse", CPUPercent: 5}, {PID: 3, Name: "api", ContainerName: "pulse", CPUPercent: 4}}}
|
||||
page, err := FilteredPage(snapshot, SortCPU, 1, "", Limits{}, "", "pulse")
|
||||
if err != nil || page.Total != 2 || page.Processes[0].PID != 2 || page.NextCursor != "1" {
|
||||
t.Fatalf("filtered page=%+v err=%v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsUnboundedRowsAndDoesNotExposeControlFields(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
raw := processFixture(now)
|
||||
raw.Processes = make([]RawProcess, 1001)
|
||||
if _, err := Normalize(raw, now, Limits{}); err == nil {
|
||||
t.Fatal("unbounded rows accepted")
|
||||
}
|
||||
if _, ok := interface{}(Process{}).(interface{ Kill() }); ok {
|
||||
t.Fatal("process model exposes kill capability")
|
||||
}
|
||||
}
|
||||
|
||||
type source struct{ value RawSnapshot }
|
||||
|
||||
func (s source) Snapshot(ctx context.Context) (RawSnapshot, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RawSnapshot{}, err
|
||||
}
|
||||
return s.value, nil
|
||||
}
|
||||
|
||||
func TestAdapterHonorsCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := (Adapter{Source: source{value: processFixture(time.Now().UTC())}}).Snapshot(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user