This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type DependencySuppressionInput struct {
|
||||
DependencyID string `json:"dependencyId"`
|
||||
ServiceID string `json:"serviceId"`
|
||||
DependsOnServiceID string `json:"dependsOnServiceId"`
|
||||
SourceID string `json:"sourceId,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
UpstreamState string `json:"upstreamState"`
|
||||
Suppress bool `json:"suppress"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ValidateDependencyGraph rejects dependency cycles deterministically. Only
|
||||
// depends_on edges participate in propagation; backs/exposes remain visible
|
||||
// topology links without changing service state.
|
||||
func ValidateDependencyGraph(dependencies []Dependency) error {
|
||||
edges := append([]Dependency(nil), dependencies...)
|
||||
SortDependencies(edges)
|
||||
seen := make(map[string]struct{}, len(edges))
|
||||
adjacency := make(map[string][]string)
|
||||
for _, dependency := range edges {
|
||||
if err := dependency.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
key := dependencyKey(dependency)
|
||||
if _, exists := seen[key]; exists {
|
||||
return fmt.Errorf("duplicate service dependency: %s", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if dependency.RelationType == RelationDependsOn {
|
||||
adjacency[dependency.ServiceID] = append(adjacency[dependency.ServiceID], dependency.DependsOnServiceID)
|
||||
}
|
||||
}
|
||||
for source := range adjacency {
|
||||
sort.Strings(adjacency[source])
|
||||
}
|
||||
colors := make(map[string]uint8)
|
||||
var visit func(string) error
|
||||
visit = func(node string) error {
|
||||
switch colors[node] {
|
||||
case 1:
|
||||
return fmt.Errorf("%w includes %s", ErrDependencyCycle, node)
|
||||
case 2:
|
||||
return nil
|
||||
}
|
||||
colors[node] = 1
|
||||
for _, target := range adjacency[node] {
|
||||
if err := visit(target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
colors[node] = 2
|
||||
return nil
|
||||
}
|
||||
nodes := make([]string, 0, len(adjacency))
|
||||
for node := range adjacency {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
for _, node := range nodes {
|
||||
if err := visit(node); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SortDependencies(dependencies []Dependency) {
|
||||
sort.SliceStable(dependencies, func(i, j int) bool {
|
||||
left, right := dependencies[i], dependencies[j]
|
||||
if left.ServiceID != right.ServiceID {
|
||||
return left.ServiceID < right.ServiceID
|
||||
}
|
||||
if left.DependsOnServiceID != right.DependsOnServiceID {
|
||||
return left.DependsOnServiceID < right.DependsOnServiceID
|
||||
}
|
||||
if left.RelationType != right.RelationType {
|
||||
return left.RelationType < right.RelationType
|
||||
}
|
||||
if left.SourceID != right.SourceID {
|
||||
return left.SourceID < right.SourceID
|
||||
}
|
||||
return left.ID < right.ID
|
||||
})
|
||||
}
|
||||
|
||||
func BuildSuppressionInputs(dependencies []Dependency, states map[string]string) ([]DependencySuppressionInput, error) {
|
||||
if states == nil {
|
||||
states = map[string]string{}
|
||||
}
|
||||
if err := ValidateDependencyGraph(dependencies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ordered := append([]Dependency(nil), dependencies...)
|
||||
SortDependencies(ordered)
|
||||
inputs := make([]DependencySuppressionInput, 0, len(ordered))
|
||||
for _, dependency := range ordered {
|
||||
if dependency.RelationType != RelationDependsOn {
|
||||
continue
|
||||
}
|
||||
state := states[dependency.DependsOnServiceID]
|
||||
input := DependencySuppressionInput{DependencyID: dependency.ID, ServiceID: dependency.ServiceID, DependsOnServiceID: dependency.DependsOnServiceID, SourceID: dependency.SourceID, Confidence: dependency.Confidence, Confirmed: dependency.Confirmed, UpstreamState: state}
|
||||
switch state {
|
||||
case StateDown, StateDegraded:
|
||||
input.Suppress = dependency.Confirmed || dependency.Confidence >= .75
|
||||
if input.Suppress && dependency.Confirmed {
|
||||
input.Reason = "confirmed_dependency_failure"
|
||||
} else if input.Suppress {
|
||||
input.Reason = "inferred_dependency_failure"
|
||||
} else {
|
||||
input.Reason = "low_confidence_dependency_failure"
|
||||
}
|
||||
case StateUnknown:
|
||||
input.Reason = "dependency_unknown"
|
||||
default:
|
||||
input.Reason = "dependency_healthy"
|
||||
}
|
||||
inputs = append(inputs, input)
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
func dependencyKey(dependency Dependency) string {
|
||||
return dependency.ServiceID + "|" + dependency.DependsOnServiceID + "|" + dependency.RelationType + "|" + dependency.SourceID
|
||||
}
|
||||
|
||||
var ErrDependencyCycle = errors.New("service dependency cycle")
|
||||
@@ -0,0 +1,237 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/audit"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type DependencyRepository struct {
|
||||
Pool *pgxpool.Pool
|
||||
Audit audit.Store
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewDependencyRepository(pool *pgxpool.Pool, store audit.Store) (*DependencyRepository, error) {
|
||||
if pool == nil {
|
||||
return nil, errors.New("dependency repository requires a database pool")
|
||||
}
|
||||
return &DependencyRepository{Pool: pool, Audit: store}, nil
|
||||
}
|
||||
|
||||
func (r *DependencyRepository) List(ctx context.Context, serviceID string, limit int) ([]Dependency, error) {
|
||||
if r == nil || r.Pool == nil {
|
||||
return nil, errors.New("dependency repository is unavailable")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("dependency context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit < 1 || limit > 500 {
|
||||
return nil, errors.New("dependency list limit must be between 1 and 500")
|
||||
}
|
||||
return readDependencies(ctx, r.Pool, serviceID, limit)
|
||||
}
|
||||
|
||||
func (r *DependencyRepository) Upsert(ctx context.Context, dependency Dependency, actor, correlationID string) error {
|
||||
return r.InTx(ctx, func(txctx context.Context, tx pgx.Tx) error {
|
||||
if err := ValidateDependencyGraphWithCandidate(txctx, tx, dependency); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertDependency(txctx, tx, dependency); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, dependencyAuditEvent(dependency, actor, correlationID, "dependency.upsert"))
|
||||
}
|
||||
|
||||
func (r *DependencyRepository) PersistDiscovery(ctx context.Context, dependencies []Dependency, sourceID, correlationID string) error {
|
||||
if len(dependencies) > 5000 {
|
||||
return errors.New("dependency discovery batch exceeds bounds")
|
||||
}
|
||||
return r.InTx(ctx, func(txctx context.Context, tx pgx.Tx) error {
|
||||
existing, err := readDependencies(txctx, tx, "", 5000)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidateByKey := make(map[string]Dependency, len(existing)+len(dependencies))
|
||||
for _, item := range existing {
|
||||
candidateByKey[dependencyKey(item)] = item
|
||||
}
|
||||
for _, item := range dependencies {
|
||||
if item.SourceID == "" {
|
||||
item.SourceID = sourceID
|
||||
}
|
||||
if err := item.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
candidateByKey[dependencyKey(item)] = item
|
||||
}
|
||||
all := make([]Dependency, 0, len(candidateByKey))
|
||||
for _, item := range candidateByKey {
|
||||
all = append(all, item)
|
||||
}
|
||||
if err := ValidateDependencyGraph(all); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range dependencies {
|
||||
if item.SourceID == "" {
|
||||
item.SourceID = sourceID
|
||||
}
|
||||
if err := upsertDependency(txctx, tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, audit.Event{Action: "dependency.discovery", ResourceType: "service_dependency", Result: "success", CorrelationID: correlationID, After: map[string]any{"count": len(dependencies), "sourceId": sourceID}})
|
||||
}
|
||||
|
||||
func (r *DependencyRepository) Archive(ctx context.Context, dependency Dependency, actor, correlationID string) error {
|
||||
if err := dependency.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if r.Now != nil {
|
||||
now = r.Now().UTC()
|
||||
}
|
||||
return r.InTx(ctx, func(txctx context.Context, tx pgx.Tx) error {
|
||||
var command pgconn.CommandTag
|
||||
var err error
|
||||
if dependency.SourceID == "" {
|
||||
command, err = tx.Exec(txctx, `UPDATE service_dependencies SET archived_at=$1 WHERE service_id=$2 AND depends_on_service_id=$3 AND relation_type=$4 AND source_id IS NULL AND archived_at IS NULL`, now, dependency.ServiceID, dependency.DependsOnServiceID, dependency.RelationType)
|
||||
} else {
|
||||
command, err = tx.Exec(txctx, `UPDATE service_dependencies SET archived_at=$1 WHERE service_id=$2 AND depends_on_service_id=$3 AND relation_type=$4 AND source_id=$5 AND archived_at IS NULL`, now, dependency.ServiceID, dependency.DependsOnServiceID, dependency.RelationType, dependency.SourceID)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("archive service dependency: %w", err)
|
||||
}
|
||||
if command.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}, dependencyAuditEvent(dependency, actor, correlationID, "dependency.archive"))
|
||||
}
|
||||
|
||||
func (r *DependencyRepository) InTx(ctx context.Context, fn func(context.Context, pgx.Tx) error, event audit.Event) error {
|
||||
if r == nil || r.Pool == nil {
|
||||
return errors.New("dependency repository is unavailable")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("dependency context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin dependency transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := fn(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit dependency transaction: %w", err)
|
||||
}
|
||||
if r.Audit != nil && event.Action != "" {
|
||||
if err := r.Audit.Append(ctx, event); err != nil {
|
||||
return fmt.Errorf("audit dependency change: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateDependencyGraphWithCandidate(ctx context.Context, tx pgx.Tx, candidate Dependency) error {
|
||||
if err := candidate.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := readDependencies(ctx, tx, "", 5000)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byKey := make(map[string]Dependency, len(items)+1)
|
||||
for _, item := range items {
|
||||
byKey[dependencyKey(item)] = item
|
||||
}
|
||||
byKey[dependencyKey(candidate)] = candidate
|
||||
items = items[:0]
|
||||
for _, item := range byKey {
|
||||
items = append(items, item)
|
||||
}
|
||||
return ValidateDependencyGraph(items)
|
||||
}
|
||||
|
||||
func readDependencies(ctx context.Context, q interface {
|
||||
Query(context.Context, string, ...any) (pgx.Rows, error)
|
||||
}, serviceID string, limit int) ([]Dependency, error) {
|
||||
rows, err := q.Query(ctx, `WITH projected_dependencies AS (
|
||||
SELECT id::text AS id, service_id::text AS service_id, depends_on_service_id::text AS depends_on_service_id,
|
||||
COALESCE(source_id::text,'') AS source_id, relation_type, confidence::float8 AS confidence,
|
||||
confirmed, first_seen_at, last_seen_at, archived_at
|
||||
FROM service_dependencies
|
||||
WHERE archived_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'inventory:' || rel.id::text, source_service.id::text, target_service.id::text,
|
||||
rel.source_id::text, rel.relation_type, rel.confidence::float8, rel.confirmed,
|
||||
rel.first_seen_at, rel.last_seen_at, NULL::timestamptz
|
||||
FROM entity_relations rel
|
||||
JOIN services source_service ON source_service.entity_id=rel.source_entity_id AND source_service.archived_at IS NULL
|
||||
JOIN services target_service ON target_service.entity_id=rel.target_entity_id AND target_service.archived_at IS NULL
|
||||
WHERE rel.tombstoned_at IS NULL AND rel.relation_type IN ('depends_on','backs','exposes')
|
||||
)
|
||||
SELECT id, service_id, depends_on_service_id, source_id, relation_type, confidence, confirmed, first_seen_at, last_seen_at, archived_at
|
||||
FROM projected_dependencies
|
||||
WHERE ($1='' OR service_id=$1 OR depends_on_service_id=$1)
|
||||
ORDER BY service_id ASC, depends_on_service_id ASC, relation_type ASC, source_id ASC, id ASC
|
||||
LIMIT $2`, serviceID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list service dependencies: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]Dependency, 0)
|
||||
for rows.Next() {
|
||||
var item Dependency
|
||||
if err := rows.Scan(&item.ID, &item.ServiceID, &item.DependsOnServiceID, &item.SourceID, &item.RelationType, &item.Confidence, &item.Confirmed, &item.FirstSeenAt, &item.LastSeenAt, &item.ArchivedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan service dependency: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read service dependencies: %w", err)
|
||||
}
|
||||
SortDependencies(items)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func upsertDependency(ctx context.Context, q interface {
|
||||
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
|
||||
}, item Dependency) error {
|
||||
if err := item.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.FirstSeenAt.IsZero() {
|
||||
item.FirstSeenAt = time.Now().UTC()
|
||||
}
|
||||
var err error
|
||||
if item.SourceID == "" {
|
||||
_, err = q.Exec(ctx, `INSERT INTO service_dependencies (id,service_id,depends_on_service_id,source_id,relation_type,confidence,confirmed,first_seen_at,last_seen_at,archived_at) VALUES ($1,$2,$3,NULL,$4,$5,$6,$7,$8,NULL) ON CONFLICT (service_id,depends_on_service_id,relation_type) WHERE source_id IS NULL DO UPDATE SET confidence=EXCLUDED.confidence,confirmed=EXCLUDED.confirmed,last_seen_at=EXCLUDED.last_seen_at,archived_at=NULL`, item.ID, item.ServiceID, item.DependsOnServiceID, item.RelationType, item.Confidence, item.Confirmed, item.FirstSeenAt, item.LastSeenAt)
|
||||
} else {
|
||||
_, err = q.Exec(ctx, `INSERT INTO service_dependencies (id,service_id,depends_on_service_id,source_id,relation_type,confidence,confirmed,first_seen_at,last_seen_at,archived_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NULL) ON CONFLICT (service_id,depends_on_service_id,relation_type,source_id) DO UPDATE SET confidence=EXCLUDED.confidence,confirmed=EXCLUDED.confirmed,last_seen_at=EXCLUDED.last_seen_at,archived_at=NULL`, item.ID, item.ServiceID, item.DependsOnServiceID, item.SourceID, item.RelationType, item.Confidence, item.Confirmed, item.FirstSeenAt, item.LastSeenAt)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert service dependency: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dependencyAuditEvent(item Dependency, actor, correlationID, action string) audit.Event {
|
||||
return audit.Event{Actor: actor, Action: action, ResourceType: "service_dependency", ResourceID: item.ID, Result: "success", CorrelationID: correlationID, After: map[string]any{"serviceId": item.ServiceID, "dependsOnServiceId": item.DependsOnServiceID, "relationType": item.RelationType, "sourceId": item.SourceID, "confidence": item.Confidence, "confirmed": item.Confirmed}}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/audit"
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func TestDependencyRepositoryPostgreSQL(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// This test intentionally uses a unique run-scoped namespace and never issues
|
||||
// DELETE/TRUNCATE against the configured test database.
|
||||
run := fmt.Sprintf("%012x", time.Now().UnixNano()&0xffffffffffff)
|
||||
serviceA := "00000000-0000-0000-0000-" + run
|
||||
serviceB := fmt.Sprintf("00000000-0000-0000-0001-%012x", (time.Now().UnixNano()+1)&0xffffffffffff)
|
||||
source := fmt.Sprintf("00000000-0000-0000-0002-%012x", (time.Now().UnixNano()+2)&0xffffffffffff)
|
||||
dependencyID := fmt.Sprintf("00000000-0000-0000-0003-%012x", (time.Now().UnixNano()+3)&0xffffffffffff)
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO services (id,name,state,revision) VALUES ($1,$2,'unknown',1),($3,$4,'unknown',1)`, serviceA, "dependency-test-a-"+run, serviceB, "dependency-test-b-"+run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO data_sources (id,type,name,configuration_ref) VALUES ($1,'test',$2,'none')`, source, "dependency-test-"+run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &audit.MemoryStore{}
|
||||
repo, err := NewDependencyRepository(pool, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
item := Dependency{ID: dependencyID, ServiceID: serviceA, DependsOnServiceID: serviceB, SourceID: source, RelationType: RelationDependsOn, Confidence: .9, FirstSeenAt: now, LastSeenAt: &now}
|
||||
if err := repo.Upsert(ctx, item, "test-user", "corr-dependency-"+run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.PersistDiscovery(ctx, []Dependency{item}, source, "corr-discovery-"+run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := repo.List(ctx, serviceA, 10)
|
||||
if err != nil || len(items) != 1 || items[0].SourceID != source {
|
||||
t.Fatalf("unexpected dependency list: %+v, %v", items, err)
|
||||
}
|
||||
if len(store.Events) < 2 {
|
||||
t.Fatalf("expected audit events, got %d", len(store.Events))
|
||||
}
|
||||
if err := repo.Upsert(ctx, Dependency{ID: fmt.Sprintf("00000000-0000-0000-0004-%012x", (time.Now().UnixNano()+4)&0xffffffffffff), ServiceID: serviceB, DependsOnServiceID: serviceA, RelationType: RelationDependsOn, Confidence: 1, FirstSeenAt: now}, "test-user", "corr-cycle-"+run); err == nil {
|
||||
t.Fatal("expected cycle rejection")
|
||||
}
|
||||
items, err = repo.List(ctx, serviceA, 10)
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("cycle rejection changed persisted graph: %+v, %v", items, err)
|
||||
}
|
||||
if err := repo.InTx(ctx, func(txctx context.Context, tx pgx.Tx) error {
|
||||
candidate := Dependency{ID: fmt.Sprintf("00000000-0000-0000-0005-%012x", (time.Now().UnixNano()+5)&0xffffffffffff), ServiceID: serviceB, DependsOnServiceID: serviceA, RelationType: RelationBacks, Confidence: 1, FirstSeenAt: now}
|
||||
if err := upsertDependency(txctx, tx, candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("force rollback")
|
||||
}, audit.Event{}); err == nil {
|
||||
t.Fatal("expected rollback error")
|
||||
}
|
||||
items, err = repo.List(ctx, serviceA, 10)
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("rolled-back dependency persisted: %+v, %v", items, err)
|
||||
}
|
||||
if err := repo.Archive(ctx, item, "test-user", "corr-archive-"+run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err = repo.List(ctx, serviceA, 10)
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("archived dependency remained active: %+v, %v", items, err)
|
||||
}
|
||||
entityA := fmt.Sprintf("00000000-0000-0000-0007-%012x", (time.Now().UnixNano()+7)&0xffffffffffff)
|
||||
entityB := fmt.Sprintf("00000000-0000-0000-0008-%012x", (time.Now().UnixNano()+8)&0xffffffffffff)
|
||||
relationID := fmt.Sprintf("00000000-0000-0000-0009-%012x", (time.Now().UnixNano()+9)&0xffffffffffff)
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO entities (id,entity_type,canonical_name,display_name,first_seen_at) VALUES ($1,'service',$2,$2,$5),($3,'service',$4,$4,$5)`, entityA, "inventory-service-a-"+run, entityB, "inventory-service-b-"+run, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE services SET entity_id=CASE id WHEN $1::uuid THEN $2::uuid WHEN $3::uuid THEN $4::uuid END WHERE id IN ($1,$3)`, serviceA, entityA, serviceB, entityB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO entity_relations (id,source_entity_id,relation_type,target_entity_id,source_id,confidence,confirmed,first_seen_at,last_seen_at) VALUES ($1,$2,'backs',$3,$4,.75,true,$5,$5)`, relationID, entityA, entityB, source, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err = repo.List(ctx, serviceA, 10)
|
||||
if err != nil || len(items) != 1 || items[0].ID != "inventory:"+relationID || items[0].SourceID != source || items[0].RelationType != RelationBacks || !items[0].Confirmed {
|
||||
t.Fatalf("inventory relation was not projected with provenance: %+v, %v", items, err)
|
||||
}
|
||||
if err := repo.Upsert(ctx, Dependency{ID: fmt.Sprintf("00000000-0000-0000-0006-%012x", (time.Now().UnixNano()+6)&0xffffffffffff), ServiceID: serviceA, DependsOnServiceID: "00000000-0000-0000-0000-0000000000ff", RelationType: RelationDependsOn, Confidence: 1, FirstSeenAt: now}, "test-user", "corr-fk-"+run); err == nil {
|
||||
t.Fatal("expected missing-service foreign key error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func dependency(id, from, to string, confidence float64, confirmed bool) Dependency {
|
||||
return Dependency{ID: id, ServiceID: from, DependsOnServiceID: to, RelationType: RelationDependsOn, Confidence: confidence, Confirmed: confirmed}
|
||||
}
|
||||
|
||||
func TestValidateDependencyGraphRejectsCyclesDeterministically(t *testing.T) {
|
||||
first := []Dependency{dependency("b-a", "b", "a", 1, true), dependency("a-b", "a", "b", 1, true)}
|
||||
second := []Dependency{first[1], first[0]}
|
||||
err1 := ValidateDependencyGraph(first)
|
||||
err2 := ValidateDependencyGraph(second)
|
||||
if err1 == nil || err2 == nil || err1.Error() != err2.Error() || !strings.Contains(err1.Error(), "a") {
|
||||
t.Fatalf("cycle results were not deterministic: %v / %v", err1, err2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSuppressionInputsPreservesConfidenceAndManualConfirmation(t *testing.T) {
|
||||
items, err := BuildSuppressionInputs([]Dependency{
|
||||
dependency("inferred", "app", "db", .8, false),
|
||||
dependency("manual", "worker", "db", .4, true),
|
||||
{ID: "backs", ServiceID: "db", DependsOnServiceID: "app", RelationType: RelationBacks, Confidence: 1, Confirmed: true},
|
||||
}, map[string]string{"db": StateDown})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 || items[0].DependencyID != "inferred" || !items[0].Suppress || items[0].Reason != "inferred_dependency_failure" {
|
||||
t.Fatalf("unexpected inferred input: %+v", items)
|
||||
}
|
||||
if items[1].DependencyID != "manual" || !items[1].Confirmed || items[1].Confidence != .4 || !items[1].Suppress {
|
||||
t.Fatalf("unexpected manual input: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencyGraphRejectsDuplicateEdges(t *testing.T) {
|
||||
items := []Dependency{dependency("one", "app", "db", 1, true), dependency("two", "app", "db", 1, false)}
|
||||
if err := ValidateDependencyGraph(items); err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("expected duplicate edge error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
import "net/http"
|
||||
|
||||
func HealthMux() *http.ServeMux {
|
||||
return HealthMuxWithReadiness(func() bool { return true })
|
||||
}
|
||||
|
||||
func HealthMuxWithReadiness(ready func() bool) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusOK)
|
||||
_, _ = response.Write([]byte("ok\n"))
|
||||
})
|
||||
mux.HandleFunc("/readyz", func(response http.ResponseWriter, _ *http.Request) {
|
||||
if !ready() {
|
||||
response.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = response.Write([]byte("not ready\n"))
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusOK)
|
||||
_, _ = response.Write([]byte("ready\n"))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealthMuxExposesTextHealthContracts(t *testing.T) {
|
||||
server := httptest.NewServer(HealthMux())
|
||||
defer server.Close()
|
||||
|
||||
checks := []struct {
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{path: "/healthz", body: "ok\n"},
|
||||
{path: "/readyz", body: "ready\n"},
|
||||
}
|
||||
for _, check := range checks {
|
||||
response, err := http.Get(server.URL + check.path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", check.path, err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET %s status = %d, want %d", check.path, response.StatusCode, http.StatusOK)
|
||||
}
|
||||
if response.Body == nil {
|
||||
t.Fatalf("GET %s returned no body", check.path)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read GET %s body: %v", check.path, err)
|
||||
}
|
||||
if string(body) != check.body {
|
||||
t.Errorf("GET %s body = %q, want %q", check.path, body, check.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/probe"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type PostgresProvider struct {
|
||||
Pool *pgxpool.Pool
|
||||
Policy StatusPolicy
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewPostgresProvider(pool *pgxpool.Pool, policy StatusPolicy) (*PostgresProvider, error) {
|
||||
if pool == nil {
|
||||
return nil, errors.New("service status provider requires a database pool")
|
||||
}
|
||||
policy = policy.withDefaults()
|
||||
if err := policy.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PostgresProvider{Pool: pool, Policy: policy}, nil
|
||||
}
|
||||
|
||||
func (p *PostgresProvider) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if p == nil || p.Pool == nil {
|
||||
return Snapshot{}, errors.New("service status provider is unavailable")
|
||||
}
|
||||
if ctx == nil {
|
||||
return Snapshot{}, errors.New("service status context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
policy := p.Policy.withDefaults()
|
||||
if err := policy.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if p.Now != nil {
|
||||
now = p.Now().UTC()
|
||||
}
|
||||
rows, err := p.Pool.Query(ctx, `
|
||||
WITH bounded_services AS (
|
||||
SELECT id, entity_id, source_id, name, description, revision, archived_at, created_at, updated_at
|
||||
FROM services
|
||||
WHERE archived_at IS NULL
|
||||
ORDER BY id ASC
|
||||
LIMIT $1
|
||||
), bounded_results AS (
|
||||
SELECT s.id::text AS service_id, s.entity_id::text AS entity_id, s.source_id::text AS source_id,
|
||||
s.name, s.description, s.revision, s.archived_at, s.created_at, s.updated_at,
|
||||
COALESCE((SELECT jsonb_agg(jsonb_build_object('id', pc.id::text, 'name', pc.name, 'type', pc.probe_type, 'intervalSeconds', pc.interval_seconds, 'timeoutSeconds', pc.timeout_seconds, 'enabled', pc.enabled, 'followRedirects', pc.follow_redirects, 'verifyTls', pc.verify_tls, 'revision', pc.revision) ORDER BY pc.id) FROM probes pc WHERE pc.service_id = s.id AND pc.archived_at IS NULL), '[]'::jsonb) AS probe_configs,
|
||||
p.id::text AS probe_id, pr.id::text AS result_id, pr.observed_at, pr.completed_at,
|
||||
pr.state, pr.response_time_ms, pr.status_code, pr.error_class, pr.error_message, pr.attributes,
|
||||
certificate.id::text AS certificate_id, certificate.service_id::text AS certificate_service_id,
|
||||
certificate.endpoint_id::text AS certificate_endpoint_id, certificate.observed_at AS certificate_observed_at,
|
||||
certificate.expires_at AS certificate_expires_at, certificate.issuer AS certificate_issuer,
|
||||
certificate.subject AS certificate_subject, certificate.hostname_valid AS certificate_hostname_valid,
|
||||
certificate.verification_state AS certificate_verification_state,
|
||||
row_number() OVER (PARTITION BY s.id ORDER BY pr.observed_at DESC NULLS LAST, p.id ASC, pr.id ASC) AS result_rank
|
||||
FROM bounded_services s
|
||||
LEFT JOIN probes p ON p.service_id = s.id AND p.archived_at IS NULL
|
||||
LEFT JOIN probe_results pr ON pr.probe_id = p.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT sc.id, sc.service_id, sc.endpoint_id, sc.observed_at, sc.expires_at,
|
||||
sc.issuer, sc.subject, sc.hostname_valid, sc.verification_state
|
||||
FROM service_certificates sc
|
||||
WHERE sc.service_id = s.id
|
||||
ORDER BY sc.observed_at DESC, sc.id ASC
|
||||
LIMIT 1
|
||||
) certificate ON true
|
||||
)
|
||||
SELECT service_id, COALESCE(entity_id, ''), COALESCE(source_id, ''), name, description, revision, archived_at, created_at, updated_at, probe_configs,
|
||||
probe_id, result_id, observed_at, completed_at, state, response_time_ms, status_code, error_class, error_message, attributes,
|
||||
certificate_id, certificate_service_id, certificate_endpoint_id, certificate_observed_at,
|
||||
certificate_expires_at, certificate_issuer, certificate_subject, certificate_hostname_valid,
|
||||
certificate_verification_state
|
||||
FROM bounded_results
|
||||
WHERE result_rank <= $2 OR result_rank IS NULL
|
||||
ORDER BY service_id ASC, observed_at DESC NULLS LAST, probe_id ASC NULLS LAST, result_id ASC NULLS LAST`, policy.MaxServices, policy.MaxHistory)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("query service status: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
inputs := make([]ServiceInput, 0, policy.MaxServices)
|
||||
byID := make(map[string]int, policy.MaxServices)
|
||||
for rows.Next() {
|
||||
var serviceID, entityID, sourceID, name, description string
|
||||
var revision int64
|
||||
var archivedAt, createdAt, updatedAt *time.Time
|
||||
var probeID, resultID *string
|
||||
var observedAt, completedAt *time.Time
|
||||
var state, errorClass, errorMessage *string
|
||||
var responseTimeMS, statusCode *int
|
||||
var certificateID, certificateServiceID, certificateEndpointID *string
|
||||
var certificateObservedAt, certificateExpiresAt *time.Time
|
||||
var certificateIssuer, certificateSubject, certificateVerificationState *string
|
||||
var certificateHostnameValid *bool
|
||||
var attributes []byte
|
||||
var probeConfigs []byte
|
||||
if err := rows.Scan(&serviceID, &entityID, &sourceID, &name, &description, &revision, &archivedAt, &createdAt, &updatedAt, &probeConfigs, &probeID, &resultID, &observedAt, &completedAt, &state, &responseTimeMS, &statusCode, &errorClass, &errorMessage, &attributes, &certificateID, &certificateServiceID, &certificateEndpointID, &certificateObservedAt, &certificateExpiresAt, &certificateIssuer, &certificateSubject, &certificateHostnameValid, &certificateVerificationState); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("scan service status: %w", err)
|
||||
}
|
||||
index, exists := byID[serviceID]
|
||||
if !exists {
|
||||
item := ServiceInput{Service: Service{ID: serviceID, EntityID: entityID, SourceID: sourceID, Name: name, Description: description, State: StateUnknown, Revision: revision, ArchivedAt: archivedAt, CreatedAt: timeValue(createdAt), UpdatedAt: timeValue(updatedAt)}}
|
||||
inputs = append(inputs, item)
|
||||
index = len(inputs) - 1
|
||||
byID[serviceID] = index
|
||||
if len(probeConfigs) > 0 {
|
||||
if err := json.Unmarshal(probeConfigs, &inputs[index].ProbeConfigs); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("decode service probe configuration: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
input := &inputs[index]
|
||||
if input.LatestCertificate == nil && certificateID != nil && certificateServiceID != nil && certificateObservedAt != nil && certificateVerificationState != nil {
|
||||
input.LatestCertificate = &probe.Certificate{
|
||||
ID: *certificateID,
|
||||
ServiceID: *certificateServiceID,
|
||||
EndpointID: stringValue(certificateEndpointID),
|
||||
ObservedAt: certificateObservedAt.UTC(),
|
||||
ExpiresAt: certificateExpiresAt,
|
||||
Issuer: stringValue(certificateIssuer),
|
||||
Subject: stringValue(certificateSubject),
|
||||
HostnameValid: certificateHostnameValid,
|
||||
VerificationState: *certificateVerificationState,
|
||||
}
|
||||
}
|
||||
if probeID == nil || resultID == nil || observedAt == nil || state == nil {
|
||||
continue
|
||||
}
|
||||
result := probe.Result{ID: *resultID, ProbeID: *probeID, ObservedAt: observedAt.UTC(), CompletedAt: timeValue(completedAt).UTC(), State: *state, ResponseTimeMS: responseTimeMS, StatusCode: statusCode}
|
||||
if errorClass != nil {
|
||||
result.ErrorClass = *errorClass
|
||||
}
|
||||
if errorMessage != nil {
|
||||
result.ErrorMessage = *errorMessage
|
||||
}
|
||||
if len(attributes) > 0 {
|
||||
if err := json.Unmarshal(attributes, &result.Attributes); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("decode service result attributes: %w", err)
|
||||
}
|
||||
}
|
||||
appendProbeResult(input, *probeID, result)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("read service status rows: %w", err)
|
||||
}
|
||||
return BuildSnapshot(now, inputs, policy)
|
||||
}
|
||||
|
||||
func stringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func appendProbeResult(input *ServiceInput, probeID string, result probe.Result) {
|
||||
for index := range input.Probes {
|
||||
if input.Probes[index].ProbeID == probeID {
|
||||
input.Probes[index].Results = append(input.Probes[index].Results, result)
|
||||
return
|
||||
}
|
||||
}
|
||||
input.Probes = append(input.Probes, ProbeHistory{ProbeID: probeID, Results: []probe.Result{result}})
|
||||
}
|
||||
|
||||
func timeValue(value *time.Time) time.Time {
|
||||
if value == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
)
|
||||
|
||||
func TestPostgresProviderKeepsLatestCertificateOutsideHistoryWindow(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 4, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := fmt.Sprintf("%012x", time.Now().UnixNano()&0xffffffffffff)
|
||||
serviceID := "00000000-0000-0000-0000-" + run
|
||||
httpProbeID := "00000000-0000-0000-0001-" + run
|
||||
tlsProbeID := "00000000-0000-0000-0002-" + run
|
||||
certificateID := "00000000-0000-0000-0003-" + run
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
certificateObservedAt := now.Add(-10 * time.Minute)
|
||||
expiresAt := now.Add(80 * 24 * time.Hour)
|
||||
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO services (id,name,state,revision) VALUES ($1,$2,'unknown',1)`, serviceID, "certificate-window-"+run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO probes (id,service_id,name,probe_type,target,interval_seconds,timeout_seconds,enabled)
|
||||
VALUES ($1,$3,'HTTP','http','{}'::jsonb,30,10,true),($2,$3,'TLS','tls','{}'::jsonb,21600,10,true)`, httpProbeID, tlsProbeID, serviceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO probe_results (id,probe_id,observed_at,completed_at,state,response_time_ms,attributes)
|
||||
VALUES ($1,$2,$3,$3,'up',25,'{}'::jsonb)`, "00000000-0000-0000-0004-"+run, tlsProbeID, certificateObservedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO service_certificates (id,service_id,observed_at,expires_at,issuer,subject,hostname_valid,verification_state)
|
||||
VALUES ($1,$2,$3,$4,'Pulse test issuer','CN=pulse.test',true,'valid')`, certificateID, serviceID, certificateObservedAt, expiresAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 0; index < 8; index++ {
|
||||
observedAt := now.Add(-time.Duration(index) * time.Second)
|
||||
resultID := fmt.Sprintf("00000000-0000-0000-%04x-%012x", index+5, time.Now().UnixNano()&0xffffffffffff)
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO probe_results (id,probe_id,observed_at,completed_at,state,response_time_ms,status_code,attributes)
|
||||
VALUES ($1,$2,$3,$3,'up',20,200,'{}'::jsonb)`, resultID, httpProbeID, observedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
provider, err := NewPostgresProvider(pool, StatusPolicy{FreshnessMaxAge: time.Minute, MaxServices: 1000, MaxHistory: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := provider.Snapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range snapshot.Services {
|
||||
if item.ID != serviceID {
|
||||
continue
|
||||
}
|
||||
if len(item.History) != 5 {
|
||||
t.Fatalf("history was not bounded: %d", len(item.History))
|
||||
}
|
||||
certificate := item.Certificate
|
||||
if certificate == nil || certificate.ID != certificateID || certificate.ServiceID != serviceID || certificate.VerificationState != "valid" || certificate.ExpiresAt == nil || !certificate.ExpiresAt.Equal(expiresAt) {
|
||||
t.Fatalf("latest certificate was not projected independently of bounded probe history: %+v", certificate)
|
||||
}
|
||||
for _, result := range item.History {
|
||||
if result.Certificate != nil {
|
||||
t.Fatal("latest certificate was duplicated across history rows")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("run-scoped service %s was not projected", serviceID)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/application"
|
||||
"github.com/itworx/pulse/internal/probe"
|
||||
)
|
||||
|
||||
type serviceScenario struct {
|
||||
ID string `json:"id"`
|
||||
Timeline []struct {
|
||||
Action string `json:"action"`
|
||||
Payload struct {
|
||||
Success bool `json:"success"`
|
||||
} `json:"payload"`
|
||||
} `json:"timeline"`
|
||||
}
|
||||
|
||||
func TestServiceDownContainerRunningFixtureProjectsDegradedApplication(t *testing.T) {
|
||||
fixturePath := filepath.Join("..", "..", "fixtures", "scenarios", "service-down-container-running.json")
|
||||
content, err := os.ReadFile(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture serviceScenario
|
||||
if err := json.Unmarshal(content, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fixture.ID != "service-down-container-running" || len(fixture.Timeline) != 2 || fixture.Timeline[0].Action != "set-probe-result" || fixture.Timeline[0].Payload.Success {
|
||||
t.Fatalf("fixture does not describe the service outage: %+v", fixture)
|
||||
}
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
serviceSnapshot, err := BuildSnapshot(now, []ServiceInput{{Service: statusService("fixture-http"), Probes: []ProbeHistory{{ProbeID: "fixture-http", Results: []probe.Result{{ProbeID: "fixture-http", ObservedAt: now.Add(-30 * time.Second), State: StateDown, ErrorClass: "status_not_expected"}}}}}}, StatusPolicy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if serviceSnapshot.Services[0].State != StateDown {
|
||||
t.Fatalf("service outage was not projected: %+v", serviceSnapshot.Services[0])
|
||||
}
|
||||
applicationSnapshot, err := application.BuildSnapshot(application.Source{ID: "fixture", Type: "scenario", ObservedAt: now, ReceivedAt: now}, []application.DiscoveredApplication{{ID: "fixture-app", Name: "Fixture app", Components: []application.ComponentInput{{ID: "fixture-http", Name: "HTTP", Kind: "service", ContainerState: application.StateHealthy, ServiceState: application.StateDown, Critical: true}}}}, nil, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applicationSnapshot.Applications[0].Status != application.StateDegraded || applicationSnapshot.Applications[0].Reasons[0].Code != "service_down" {
|
||||
t.Fatalf("running container hid service outage: %+v", applicationSnapshot.Applications[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
)
|
||||
|
||||
// WaitForStop blocks until the context is cancelled or a signal is received.
|
||||
// It accepts a channel so shutdown behavior can be tested without sending a real OS signal.
|
||||
func WaitForStop(ctx context.Context, signals <-chan os.Signal) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-signals:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWaitForStopReturnsOnSignal(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
signals := make(chan os.Signal, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
WaitForStop(ctx, signals)
|
||||
close(done)
|
||||
}()
|
||||
signals <- os.Interrupt
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("shutdown did not return after signal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/probe"
|
||||
)
|
||||
|
||||
const ContractVersion = "v1"
|
||||
|
||||
const (
|
||||
CapabilityAvailable = "available"
|
||||
CapabilityUnavailable = "unavailable"
|
||||
CapabilityUnsupported = "unsupported"
|
||||
ConfigurationConfigured = "configured"
|
||||
ConfigurationNotConfigured = "not_configured"
|
||||
ConfigurationUnknown = "unknown"
|
||||
)
|
||||
|
||||
type StatusPolicy struct {
|
||||
FreshnessMaxAge time.Duration
|
||||
MaxServices int
|
||||
MaxHistory int
|
||||
}
|
||||
|
||||
func (p StatusPolicy) withDefaults() StatusPolicy {
|
||||
if p.FreshnessMaxAge == 0 {
|
||||
p.FreshnessMaxAge = 2 * time.Minute
|
||||
}
|
||||
if p.MaxServices == 0 {
|
||||
p.MaxServices = 150
|
||||
}
|
||||
if p.MaxHistory == 0 {
|
||||
p.MaxHistory = 100
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p StatusPolicy) Validate() error {
|
||||
p = p.withDefaults()
|
||||
if p.FreshnessMaxAge <= 0 || p.FreshnessMaxAge > 24*time.Hour || p.MaxServices < 1 || p.MaxServices > 1000 || p.MaxHistory < 1 || p.MaxHistory > 500 {
|
||||
return errors.New("service status policy is outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ProbeHistory struct {
|
||||
ProbeID string
|
||||
Results []probe.Result
|
||||
}
|
||||
|
||||
type ProbeConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IntervalSeconds int `json:"intervalSeconds"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
Enabled bool `json:"enabled"`
|
||||
FollowRedirects bool `json:"followRedirects"`
|
||||
VerifyTLS bool `json:"verifyTls"`
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
|
||||
type ServiceInput struct {
|
||||
Service Service
|
||||
Probes []ProbeHistory
|
||||
ProbeConfigs []ProbeConfig
|
||||
LatestCertificate *probe.Certificate
|
||||
}
|
||||
|
||||
type ServiceStatus struct {
|
||||
ID string `json:"id"`
|
||||
EntityID string `json:"entityId,omitempty"`
|
||||
SourceID string `json:"sourceId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
LastResultAt *time.Time `json:"lastResultAt,omitempty"`
|
||||
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
||||
LastFailureAt *time.Time `json:"lastFailureAt,omitempty"`
|
||||
ResponseTimeMS *int `json:"responseTimeMs,omitempty"`
|
||||
AvailabilityPercent *float64 `json:"availabilityPercent,omitempty"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
SuccessfulSampleCount int `json:"successfulSampleCount"`
|
||||
History []probe.Result `json:"history,omitempty"`
|
||||
Probes []ProbeConfig `json:"probes,omitempty"`
|
||||
Certificate *probe.Certificate `json:"certificate,omitempty"`
|
||||
Revision int64 `json:"revision"`
|
||||
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
CapabilityState string `json:"capabilityState"`
|
||||
ConfigurationState string `json:"configurationState"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Services []ServiceStatus `json:"services"`
|
||||
Total int `json:"total"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Snapshot(context.Context) (Snapshot, error)
|
||||
}
|
||||
|
||||
type UnknownProvider struct {
|
||||
Reason string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (p UnknownProvider) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if ctx == nil {
|
||||
return Snapshot{}, errors.New("service status context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if p.Now != nil {
|
||||
now = p.Now().UTC()
|
||||
}
|
||||
reason := p.Reason
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "source_unavailable"
|
||||
}
|
||||
return UnknownSnapshot(now, reason), nil
|
||||
}
|
||||
|
||||
func UnknownSnapshot(now time.Time, reason string) Snapshot {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
capability := CapabilityUnavailable
|
||||
if reason == "unsupported" {
|
||||
capability = CapabilityUnsupported
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, ObservedAt: now.UTC(), CapabilityState: capability, ConfigurationState: ConfigurationUnknown, Reason: boundText(reason, 128), Services: []ServiceStatus{}, Total: 0, Events: []Event{}}
|
||||
}
|
||||
|
||||
func BuildSnapshot(now time.Time, inputs []ServiceInput, policy StatusPolicy) (Snapshot, error) {
|
||||
policy = policy.withDefaults()
|
||||
if err := policy.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
now = now.UTC()
|
||||
if len(inputs) > policy.MaxServices {
|
||||
return Snapshot{}, errors.New("service count exceeds bounds")
|
||||
}
|
||||
statuses := make([]ServiceStatus, 0, len(inputs))
|
||||
seen := make(map[string]struct{}, len(inputs))
|
||||
for _, input := range inputs {
|
||||
if err := input.Service.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if _, exists := seen[input.Service.ID]; exists {
|
||||
return Snapshot{}, errors.New("duplicate service identity")
|
||||
}
|
||||
seen[input.Service.ID] = struct{}{}
|
||||
status := projectStatus(now, input, policy)
|
||||
statuses = append(statuses, status)
|
||||
}
|
||||
sort.Slice(statuses, func(i, j int) bool { return statuses[i].ID < statuses[j].ID })
|
||||
configuration := ConfigurationConfigured
|
||||
reason := ""
|
||||
if len(statuses) == 0 {
|
||||
configuration = ConfigurationNotConfigured
|
||||
reason = "no_services_configured"
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, ObservedAt: now, CapabilityState: CapabilityAvailable, ConfigurationState: configuration, Reason: reason, Services: statuses, Total: len(statuses), Events: []Event{}}, nil
|
||||
}
|
||||
|
||||
func projectStatus(now time.Time, input ServiceInput, policy StatusPolicy) ServiceStatus {
|
||||
status := ServiceStatus{ID: input.Service.ID, EntityID: input.Service.EntityID, SourceID: input.Service.SourceID, Name: input.Service.Name, Description: input.Service.Description, State: StateUnknown, Reason: "no_probe_result", Revision: input.Service.Revision, ArchivedAt: input.Service.ArchivedAt, CreatedAt: input.Service.CreatedAt.UTC(), UpdatedAt: input.Service.UpdatedAt.UTC(), History: make([]probe.Result, 0), Probes: boundProbeConfigs(input.ProbeConfigs), Certificate: boundCertificate(input.LatestCertificate)}
|
||||
if len(status.Probes) == 0 {
|
||||
status.Reason = "no_probe_configured"
|
||||
} else {
|
||||
enabled := false
|
||||
for _, config := range status.Probes {
|
||||
enabled = enabled || config.Enabled
|
||||
}
|
||||
if !enabled {
|
||||
status.Reason = "probes_disabled"
|
||||
}
|
||||
}
|
||||
all := make([]probe.Result, 0)
|
||||
for _, history := range input.Probes {
|
||||
for _, result := range history.Results {
|
||||
if result.ProbeID == "" {
|
||||
result.ProbeID = history.ProbeID
|
||||
}
|
||||
if result.ProbeID == "" || result.ObservedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
all = append(all, boundResult(result))
|
||||
}
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool {
|
||||
if !all[i].ObservedAt.Equal(all[j].ObservedAt) {
|
||||
return all[i].ObservedAt.After(all[j].ObservedAt)
|
||||
}
|
||||
if all[i].ProbeID != all[j].ProbeID {
|
||||
return all[i].ProbeID < all[j].ProbeID
|
||||
}
|
||||
return all[i].ID < all[j].ID
|
||||
})
|
||||
if len(all) > policy.MaxHistory {
|
||||
all = all[:policy.MaxHistory]
|
||||
}
|
||||
status.History = append(status.History, all...)
|
||||
if len(all) == 0 {
|
||||
return status
|
||||
}
|
||||
latest := all[0]
|
||||
lastResultAt := latest.ObservedAt.UTC()
|
||||
status.LastResultAt = &lastResultAt
|
||||
status.State = normalizedState(latest.State)
|
||||
status.Reason = latest.ErrorClass
|
||||
if status.State == StateUp {
|
||||
status.Reason = ""
|
||||
} else if status.State == StateDegraded && status.Reason == "" {
|
||||
status.Reason = "probe_degraded"
|
||||
}
|
||||
if now.Sub(latest.ObservedAt) > policy.FreshnessMaxAge || latest.ObservedAt.After(now.Add(time.Minute)) {
|
||||
status.State = StateUnknown
|
||||
status.Reason = "stale_probe"
|
||||
}
|
||||
if latest.ResponseTimeMS != nil {
|
||||
value := *latest.ResponseTimeMS
|
||||
status.ResponseTimeMS = &value
|
||||
}
|
||||
known := 0
|
||||
successful := 0
|
||||
for _, result := range all {
|
||||
state := normalizedState(result.State)
|
||||
switch state {
|
||||
case StateUp, StateDegraded:
|
||||
known++
|
||||
successful++
|
||||
case StateDown, StateUnknown:
|
||||
known++
|
||||
}
|
||||
if state == StateUp {
|
||||
observed := result.ObservedAt.UTC()
|
||||
if status.LastSuccessAt == nil || observed.After(*status.LastSuccessAt) {
|
||||
status.LastSuccessAt = &observed
|
||||
}
|
||||
}
|
||||
if state == StateDown || state == StateUnknown {
|
||||
observed := result.ObservedAt.UTC()
|
||||
if status.LastFailureAt == nil || observed.After(*status.LastFailureAt) {
|
||||
status.LastFailureAt = &observed
|
||||
}
|
||||
}
|
||||
}
|
||||
status.SampleCount = known
|
||||
status.SuccessfulSampleCount = successful
|
||||
if known > 0 {
|
||||
value := float64(successful) * 100 / float64(known)
|
||||
status.AvailabilityPercent = &value
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func boundProbeConfigs(configs []ProbeConfig) []ProbeConfig {
|
||||
bounded := append([]ProbeConfig(nil), configs...)
|
||||
sort.SliceStable(bounded, func(i, j int) bool {
|
||||
if bounded[i].ID != bounded[j].ID {
|
||||
return bounded[i].ID < bounded[j].ID
|
||||
}
|
||||
return bounded[i].Name < bounded[j].Name
|
||||
})
|
||||
if len(bounded) > 100 {
|
||||
bounded = bounded[:100]
|
||||
}
|
||||
for index := range bounded {
|
||||
bounded[index].ID = boundText(bounded[index].ID, 64)
|
||||
bounded[index].Name = boundText(bounded[index].Name, 160)
|
||||
bounded[index].Type = boundText(bounded[index].Type, 16)
|
||||
if bounded[index].IntervalSeconds < 0 {
|
||||
bounded[index].IntervalSeconds = 0
|
||||
}
|
||||
if bounded[index].TimeoutSeconds < 0 {
|
||||
bounded[index].TimeoutSeconds = 0
|
||||
}
|
||||
}
|
||||
return bounded
|
||||
}
|
||||
func boundResult(result probe.Result) probe.Result {
|
||||
result.ObservedAt = result.ObservedAt.UTC()
|
||||
result.CompletedAt = result.CompletedAt.UTC()
|
||||
result.ErrorClass = boundText(result.ErrorClass, 64)
|
||||
result.ErrorMessage = boundText(result.ErrorMessage, 256)
|
||||
if result.ResponseTimeMS != nil && *result.ResponseTimeMS < 0 {
|
||||
result.ResponseTimeMS = nil
|
||||
}
|
||||
if result.Attributes != nil {
|
||||
keys := make([]string, 0, len(result.Attributes))
|
||||
for key := range result.Attributes {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) > 16 {
|
||||
keys = keys[:16]
|
||||
}
|
||||
bounded := make(map[string]any, len(keys))
|
||||
for _, key := range keys {
|
||||
bounded[boundText(key, 64)] = boundAttribute(result.Attributes[key])
|
||||
}
|
||||
result.Attributes = bounded
|
||||
}
|
||||
if result.Certificate != nil {
|
||||
result.Certificate = boundCertificate(result.Certificate)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func boundCertificate(value *probe.Certificate) *probe.Certificate {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
certificate := *value
|
||||
certificate.ID = boundText(certificate.ID, 128)
|
||||
certificate.ServiceID = boundText(certificate.ServiceID, 128)
|
||||
certificate.EndpointID = boundText(certificate.EndpointID, 128)
|
||||
certificate.ObservedAt = certificate.ObservedAt.UTC()
|
||||
if certificate.ExpiresAt != nil {
|
||||
expiresAt := certificate.ExpiresAt.UTC()
|
||||
certificate.ExpiresAt = &expiresAt
|
||||
}
|
||||
certificate.Issuer = boundText(certificate.Issuer, 256)
|
||||
certificate.Subject = boundText(certificate.Subject, 256)
|
||||
certificate.VerificationState = boundText(certificate.VerificationState, 32)
|
||||
return &certificate
|
||||
}
|
||||
|
||||
func boundAttribute(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return boundText(typed, 256)
|
||||
case bool, int, int32, int64, float32, float64, nil:
|
||||
return typed
|
||||
default:
|
||||
return "[redacted]"
|
||||
}
|
||||
}
|
||||
func normalizedState(state string) string {
|
||||
switch state {
|
||||
case StateUp, StateDegraded, StateDown, StateUnknown:
|
||||
return state
|
||||
default:
|
||||
return StateUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func boundText(value string, max int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > max {
|
||||
return value[:max]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ServiceID string `json:"serviceId"`
|
||||
FromState string `json:"fromState"`
|
||||
ToState string `json:"toState"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
func TransitionEvents(previous, current Snapshot) []Event {
|
||||
previousByID := make(map[string]ServiceStatus, len(previous.Services))
|
||||
for _, item := range previous.Services {
|
||||
previousByID[item.ID] = item
|
||||
}
|
||||
events := make([]Event, 0)
|
||||
for _, item := range current.Services {
|
||||
before, exists := previousByID[item.ID]
|
||||
if !exists || before.State == item.State {
|
||||
continue
|
||||
}
|
||||
eventType := "service.state_changed"
|
||||
if item.State == StateDown {
|
||||
eventType = "service.down"
|
||||
} else if item.State == StateUp && (before.State == StateDown || before.State == StateUnknown) {
|
||||
eventType = "service.recovered"
|
||||
}
|
||||
eventTime := current.ObservedAt.UTC()
|
||||
event := Event{Type: eventType, ServiceID: item.ID, FromState: before.State, ToState: item.State, Reason: boundText(item.Reason, 128), OccurredAt: eventTime}
|
||||
digest := sha256.Sum256([]byte(item.ID + "\x00" + before.State + "\x00" + item.State + "\x00" + eventTime.Format(time.RFC3339Nano)))
|
||||
event.ID = hex.EncodeToString(digest[:])
|
||||
events = append(events, event)
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool { return events[i].ServiceID < events[j].ServiceID })
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/probe"
|
||||
)
|
||||
|
||||
func TestBuildSnapshotDistinguishesConfigurationAndCapabilityStates(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
|
||||
empty, err := BuildSnapshot(now, nil, StatusPolicy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if empty.CapabilityState != CapabilityAvailable || empty.ConfigurationState != ConfigurationNotConfigured || empty.Reason != "no_services_configured" {
|
||||
t.Fatalf("empty configuration was not explicit: %+v", empty)
|
||||
}
|
||||
unavailable := UnknownSnapshot(now, "source_unavailable")
|
||||
if unavailable.CapabilityState != CapabilityUnavailable || unavailable.ConfigurationState != ConfigurationUnknown {
|
||||
t.Fatalf("unavailable capability was presented as configuration: %+v", unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotDistinguishesMissingDisabledAndPendingProbes(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
|
||||
base := Service{ID: "service-a", Name: "A", State: StateUnknown, Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||
inputs := []ServiceInput{
|
||||
{Service: base},
|
||||
{Service: Service{ID: "service-b", Name: "B", State: StateUnknown, Revision: 1, CreatedAt: now, UpdatedAt: now}, ProbeConfigs: []ProbeConfig{{ID: "probe-b", Name: "B", Type: probe.TypeHTTP, Enabled: false}}},
|
||||
{Service: Service{ID: "service-c", Name: "C", State: StateUnknown, Revision: 1, CreatedAt: now, UpdatedAt: now}, ProbeConfigs: []ProbeConfig{{ID: "probe-c", Name: "C", Type: probe.TypeHTTP, Enabled: true}}},
|
||||
}
|
||||
snapshot, err := BuildSnapshot(now, inputs, StatusPolicy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Services[0].Reason != "no_probe_configured" || snapshot.Services[1].Reason != "probes_disabled" || snapshot.Services[2].Reason != "no_probe_result" {
|
||||
t.Fatalf("probe configuration states were conflated: %+v", snapshot.Services)
|
||||
}
|
||||
}
|
||||
|
||||
func statusService(id string) Service {
|
||||
return Service{ID: id, EntityID: "entity-" + id, Name: "Service " + id, State: StateUnknown, Revision: 1}
|
||||
}
|
||||
|
||||
func statusResult(probeID, state string, observed time.Time, latency int) probe.Result {
|
||||
return probe.Result{ID: probeID + "-" + observed.Format("150405"), ProbeID: probeID, ObservedAt: observed, CompletedAt: observed.Add(time.Second), State: state, ResponseTimeMS: &latency}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotDerivesIndependentServiceStateAndHistory(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
first := now.Add(-90 * time.Second)
|
||||
second := now.Add(-30 * time.Second)
|
||||
snapshot, err := BuildSnapshot(now, []ServiceInput{{Service: statusService("service-1"), Probes: []ProbeHistory{{ProbeID: "probe-1", Results: []probe.Result{statusResult("probe-1", StateUp, first, 80), statusResult("probe-1", StateDown, second, 150)}}}}}, StatusPolicy{FreshnessMaxAge: 2 * time.Minute, MaxHistory: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := snapshot.Services[0]
|
||||
if item.State != StateDown || item.LastSuccessAt == nil || !item.LastSuccessAt.Equal(first) || item.LastFailureAt == nil || !item.LastFailureAt.Equal(second) {
|
||||
t.Fatalf("unexpected service status: %+v", item)
|
||||
}
|
||||
if item.SampleCount != 2 || item.SuccessfulSampleCount != 1 || item.AvailabilityPercent == nil || *item.AvailabilityPercent != 50 {
|
||||
t.Fatalf("unexpected availability: %+v", item)
|
||||
}
|
||||
if len(item.History) != 2 || item.History[0].ObservedAt != second || item.ResponseTimeMS == nil || *item.ResponseTimeMS != 150 {
|
||||
t.Fatalf("unexpected history: %+v", item.History)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotMakesStaleProbeUnknownAndBoundsHistory(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
results := make([]probe.Result, 0, 4)
|
||||
for index := 0; index < 4; index++ {
|
||||
results = append(results, statusResult("probe-1", StateUp, now.Add(-time.Duration(index+1)*time.Minute), index))
|
||||
}
|
||||
snapshot, err := BuildSnapshot(now, []ServiceInput{{Service: statusService("service-1"), Probes: []ProbeHistory{{ProbeID: "probe-1", Results: results}}}}, StatusPolicy{FreshnessMaxAge: 30 * time.Second, MaxHistory: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := snapshot.Services[0]
|
||||
if item.State != StateUnknown || item.Reason != "stale_probe" || len(item.History) != 2 {
|
||||
t.Fatalf("stale or history bound incorrect: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionEventsAreDeterministicAndTyped(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
previous := Snapshot{ObservedAt: now.Add(-time.Minute), Services: []ServiceStatus{{ID: "service-1", State: StateUp}}}
|
||||
current := Snapshot{ObservedAt: now, Services: []ServiceStatus{{ID: "service-1", State: StateDown, Reason: "status_not_expected"}}}
|
||||
first := TransitionEvents(previous, current)
|
||||
second := TransitionEvents(previous, current)
|
||||
if len(first) != 1 || first[0].Type != "service.down" || first[0].FromState != StateUp || first[0].ToState != StateDown || first[0].ID == "" || first[0].ID != second[0].ID {
|
||||
t.Fatalf("unexpected transition events: %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatusValidationAndUnknownProviderCancellation(t *testing.T) {
|
||||
if err := (StatusPolicy{MaxHistory: 501}).Validate(); err == nil {
|
||||
t.Fatal("unsafe service policy accepted")
|
||||
}
|
||||
provider := UnknownProvider{Now: func() time.Time { return time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := provider.Snapshot(ctx); err == nil {
|
||||
t.Fatal("canceled context was ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotBoundsAttributesWithoutLeakingComplexValues(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
attributes := make(map[string]any, 18)
|
||||
for index := 0; index < 18; index++ {
|
||||
attributes[string(rune('a'+index))] = map[string]any{"nested": "value"}
|
||||
}
|
||||
result := statusResult("probe-1", StateUp, now.Add(-time.Second), 1)
|
||||
result.Attributes = attributes
|
||||
snapshot, err := BuildSnapshot(now, []ServiceInput{{Service: statusService("service-1"), Probes: []ProbeHistory{{ProbeID: "probe-1", Results: []probe.Result{result}}}}}, StatusPolicy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bounded := snapshot.Services[0].History[0].Attributes
|
||||
if len(bounded) != 16 || bounded["a"] != "[redacted]" {
|
||||
t.Fatalf("attributes were not bounded: %#v", bounded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotTargetScale150Services(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
inputs := make([]ServiceInput, 150)
|
||||
for serviceIndex := range inputs {
|
||||
results := make([]probe.Result, 4)
|
||||
for resultIndex := range results {
|
||||
results[resultIndex] = statusResult(fmt.Sprintf("probe-%03d", serviceIndex), StateUp, now.Add(-time.Duration(resultIndex+1)*time.Second), resultIndex+1)
|
||||
}
|
||||
inputs[serviceIndex] = ServiceInput{Service: statusService(fmt.Sprintf("service-%03d", 150-serviceIndex)), Probes: []ProbeHistory{{ProbeID: results[0].ProbeID, Results: results}}}
|
||||
}
|
||||
started := time.Now()
|
||||
snapshot, err := BuildSnapshot(now, inputs, StatusPolicy{MaxServices: 150, MaxHistory: 4})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Services) != 150 || snapshot.Services[0].ID != "service-001" || snapshot.Services[149].ID != "service-150" || time.Since(started) > 2*time.Second {
|
||||
t.Fatalf("150-service projection exceeded target: count=%d first=%s last=%s duration=%s", len(snapshot.Services), snapshot.Services[0].ID, snapshot.Services[149].ID, time.Since(started))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotBoundsAndSortsProbeConfigsWithoutSecrets(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
snapshot, err := BuildSnapshot(now, []ServiceInput{{
|
||||
Service: statusService("service-1"),
|
||||
ProbeConfigs: []ProbeConfig{
|
||||
{ID: "probe-b", Name: "B", Type: "https", IntervalSeconds: 30, TimeoutSeconds: 5, Enabled: true},
|
||||
{ID: "probe-a", Name: "A", Type: "tcp", IntervalSeconds: 60, TimeoutSeconds: 10, Enabled: false},
|
||||
},
|
||||
}}, StatusPolicy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Services[0].Probes) != 2 || snapshot.Services[0].Probes[0].ID != "probe-a" {
|
||||
t.Fatalf("probe configs were not deterministic: %+v", snapshot.Services[0].Probes)
|
||||
}
|
||||
encoded, err := json.Marshal(snapshot.Services[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "secret") || strings.Contains(string(encoded), "target") {
|
||||
t.Fatalf("probe config exposed forbidden fields: %s", encoded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/reverseproxy"
|
||||
)
|
||||
|
||||
type TopologyNode struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Known bool `json:"known"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
SourceID string `json:"sourceId,omitempty"`
|
||||
}
|
||||
|
||||
type TopologyEdge struct {
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
RelationType string `json:"relationType"`
|
||||
SourceID string `json:"sourceId,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Inferred bool `json:"inferred"`
|
||||
}
|
||||
|
||||
type Topology struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
CapabilityState string `json:"capabilityState"`
|
||||
ConfigurationState string `json:"configurationState"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Nodes []TopologyNode `json:"nodes"`
|
||||
Edges []TopologyEdge `json:"edges"`
|
||||
TotalNodes int `json:"totalNodes"`
|
||||
TotalEdges int `json:"totalEdges"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
func BuildTopology(snapshot Snapshot, dependencies []Dependency, maxNodes, maxEdges int) (Topology, error) {
|
||||
return BuildTopologyWithRoutes(snapshot, dependencies, nil, maxNodes, maxEdges)
|
||||
}
|
||||
|
||||
func BuildTopologyWithRoutes(snapshot Snapshot, dependencies []Dependency, routes []reverseproxy.Route, maxNodes, maxEdges int) (Topology, error) {
|
||||
if maxNodes < 1 || maxNodes > 1000 || maxEdges < 1 || maxEdges > 2000 {
|
||||
return Topology{}, errors.New("topology bounds are outside safe limits")
|
||||
}
|
||||
if err := ValidateDependencyGraph(dependencies); err != nil {
|
||||
return Topology{}, err
|
||||
}
|
||||
services := append([]ServiceStatus(nil), snapshot.Services...)
|
||||
sort.SliceStable(services, func(i, j int) bool { return services[i].ID < services[j].ID })
|
||||
dependencies = append([]Dependency(nil), dependencies...)
|
||||
SortDependencies(dependencies)
|
||||
|
||||
nodes := make([]TopologyNode, 0, minInt(maxNodes, len(services)))
|
||||
byID := make(map[string]struct{}, maxNodes)
|
||||
for _, item := range services {
|
||||
if strings.TrimSpace(item.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if len(nodes) >= maxNodes {
|
||||
break
|
||||
}
|
||||
nodes = append(nodes, TopologyNode{ID: item.ID, Label: item.Name, State: item.State, Reason: item.Reason, Known: true, Kind: "service", SourceID: item.SourceID})
|
||||
byID[item.ID] = struct{}{}
|
||||
}
|
||||
|
||||
truncated := len(services) > len(nodes)
|
||||
edges := make([]TopologyEdge, 0, minInt(maxEdges, len(dependencies)+len(routes)))
|
||||
for _, dependency := range dependencies {
|
||||
if len(edges) >= maxEdges {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
for _, id := range []string{dependency.ServiceID, dependency.DependsOnServiceID} {
|
||||
if _, exists := byID[id]; exists {
|
||||
continue
|
||||
}
|
||||
if len(nodes) >= maxNodes {
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
nodes = append(nodes, TopologyNode{ID: id, Label: id, State: StateUnknown, Reason: "service_not_in_current_snapshot", Known: false, Kind: "service"})
|
||||
byID[id] = struct{}{}
|
||||
}
|
||||
if _, exists := byID[dependency.ServiceID]; !exists {
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
if _, exists := byID[dependency.DependsOnServiceID]; !exists {
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
edges = append(edges, TopologyEdge{ID: dependency.ID, From: dependency.ServiceID, To: dependency.DependsOnServiceID, RelationType: dependency.RelationType, SourceID: dependency.SourceID, Confidence: dependency.Confidence, Confirmed: dependency.Confirmed, Inferred: !dependency.Confirmed})
|
||||
}
|
||||
routeItems := append([]reverseproxy.Route(nil), routes...)
|
||||
sort.SliceStable(routeItems, func(i, j int) bool {
|
||||
if routeItems[i].ID != routeItems[j].ID {
|
||||
return routeItems[i].ID < routeItems[j].ID
|
||||
}
|
||||
return routeItems[i].Hostname < routeItems[j].Hostname
|
||||
})
|
||||
for _, route := range routeItems {
|
||||
if len(edges) >= maxEdges {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
routeNodeID := "reverse-proxy:" + route.ID
|
||||
if _, exists := byID[routeNodeID]; !exists {
|
||||
if len(nodes) >= maxNodes {
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
reason := "reverse_proxy_route"
|
||||
if !route.Enabled {
|
||||
reason = "reverse_proxy_route_disabled"
|
||||
}
|
||||
nodes = append(nodes, TopologyNode{ID: routeNodeID, Label: route.URL, State: StateUnknown, Reason: reason, Known: true, Kind: "reverse_proxy", SourceID: route.SourceID})
|
||||
byID[routeNodeID] = struct{}{}
|
||||
}
|
||||
if strings.TrimSpace(route.TargetServiceID) == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := byID[route.TargetServiceID]; !exists {
|
||||
if len(nodes) >= maxNodes {
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
nodes = append(nodes, TopologyNode{ID: route.TargetServiceID, Label: route.TargetServiceID, State: StateUnknown, Reason: "service_not_in_current_snapshot", Known: false, Kind: "service"})
|
||||
byID[route.TargetServiceID] = struct{}{}
|
||||
}
|
||||
edges = append(edges, TopologyEdge{ID: "reverse-proxy:" + route.ID, From: routeNodeID, To: route.TargetServiceID, RelationType: RelationExposes, SourceID: route.SourceID, Confidence: 1, Confirmed: route.Overridden, Inferred: !route.Overridden})
|
||||
}
|
||||
sort.SliceStable(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID })
|
||||
return Topology{ContractVersion: ContractVersion, ObservedAt: snapshot.ObservedAt.UTC(), CapabilityState: snapshot.CapabilityState, ConfigurationState: snapshot.ConfigurationState, Reason: snapshot.Reason, Nodes: nodes, Edges: edges, TotalNodes: len(nodes), TotalEdges: len(edges), Truncated: truncated}, nil
|
||||
}
|
||||
|
||||
func minInt(left, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/reverseproxy"
|
||||
)
|
||||
|
||||
func TestBuildTopologyWithRoutesPreservesRouteSourceAndConfirmedOverride(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
topology, err := BuildTopologyWithRoutes(Snapshot{ObservedAt: now, Services: []ServiceStatus{{ID: "svc-a", Name: "API", State: StateUp}}}, nil, []reverseproxy.Route{{ID: "host-1", URL: "https://pulse.example.test", SourceID: "npm", TargetServiceID: "svc-a", Enabled: true, Overridden: true}}, 10, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(topology.Nodes) != 2 || len(topology.Edges) != 1 {
|
||||
t.Fatalf("unexpected route topology: %+v", topology)
|
||||
}
|
||||
if topology.Edges[0].SourceID != "npm" || !topology.Edges[0].Confirmed || topology.Edges[0].RelationType != RelationExposes {
|
||||
t.Fatalf("route provenance was not preserved: %+v", topology.Edges[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildTopologyIsBoundedAndPreservesUnknownNodes(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
snapshot := Snapshot{ObservedAt: now, Services: []ServiceStatus{{ID: "service-b", Name: "Backend", State: StateDown}, {ID: "service-a", Name: "Frontend", State: StateUp}}}
|
||||
dependencies := []Dependency{{ID: "edge-2", ServiceID: "service-a", DependsOnServiceID: "missing", RelationType: RelationDependsOn, SourceID: "compose", Confidence: .6}, {ID: "edge-1", ServiceID: "service-a", DependsOnServiceID: "service-b", RelationType: RelationDependsOn, SourceID: "manual", Confidence: .9, Confirmed: true}}
|
||||
topology, err := BuildTopology(snapshot, dependencies, 3, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if topology.ObservedAt != now || len(topology.Nodes) != 3 || len(topology.Edges) != 2 || topology.TotalEdges != 2 {
|
||||
t.Fatalf("unexpected topology: %+v", topology)
|
||||
}
|
||||
if topology.Nodes[0].ID != "missing" || topology.Nodes[0].Known {
|
||||
t.Fatalf("nodes are not deterministic or missing node is known: %+v", topology.Nodes)
|
||||
}
|
||||
if topology.Edges[0].ID != "edge-2" || topology.Edges[0].Confirmed || !topology.Edges[0].Inferred {
|
||||
t.Fatalf("inferred edge was not preserved: %+v", topology.Edges[0])
|
||||
}
|
||||
if topology.Edges[1].ID != "edge-1" || !topology.Edges[1].Confirmed || topology.Edges[1].Inferred {
|
||||
t.Fatalf("confirmed edge was not preserved: %+v", topology.Edges[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTopologyPreservesSourceState(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
|
||||
topology, err := BuildTopology(Snapshot{ObservedAt: now, CapabilityState: CapabilityAvailable, ConfigurationState: ConfigurationNotConfigured, Reason: "no_services_configured"}, nil, 10, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if topology.CapabilityState != CapabilityAvailable || topology.ConfigurationState != ConfigurationNotConfigured || topology.Reason != "no_services_configured" {
|
||||
t.Fatalf("topology source state was lost: %+v", topology)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTopologyRejectsUnsafeBoundsAndCycles(t *testing.T) {
|
||||
if _, err := BuildTopology(Snapshot{}, nil, 0, 1); err == nil {
|
||||
t.Fatal("expected bound error")
|
||||
}
|
||||
cycle := []Dependency{{ID: "a", ServiceID: "one", DependsOnServiceID: "two", RelationType: RelationDependsOn}, {ID: "b", ServiceID: "two", DependsOnServiceID: "one", RelationType: RelationDependsOn}}
|
||||
_, err := BuildTopology(Snapshot{}, cycle, 10, 10)
|
||||
if err == nil || !strings.Contains(err.Error(), "cycle") {
|
||||
t.Fatalf("expected cycle error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTopologyTruncatesEdgesDeterministically(t *testing.T) {
|
||||
snapshot := Snapshot{ObservedAt: time.Now().UTC(), Services: []ServiceStatus{{ID: "a", Name: "A", State: StateUnknown}, {ID: "b", Name: "B", State: StateUnknown}, {ID: "c", Name: "C", State: StateUnknown}}}
|
||||
dependencies := []Dependency{{ID: "z", ServiceID: "a", DependsOnServiceID: "c", RelationType: RelationBacks}, {ID: "a", ServiceID: "a", DependsOnServiceID: "b", RelationType: RelationExposes}}
|
||||
topology, err := BuildTopology(snapshot, dependencies, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !topology.Truncated || len(topology.Edges) != 1 || topology.Edges[0].ID != "a" {
|
||||
t.Fatalf("unexpected truncation: %+v", topology)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTopologyTargetScaleRemainsBounded(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
services := make([]ServiceStatus, 0, 300)
|
||||
dependencies := make([]Dependency, 0, 600)
|
||||
for index := 0; index < 300; index++ {
|
||||
id := fmt.Sprintf("service-%03d", index)
|
||||
services = append(services, ServiceStatus{ID: id, Name: id, State: StateUnknown})
|
||||
if index < 299 {
|
||||
dependencies = append(dependencies, Dependency{ID: fmt.Sprintf("edge-%03d", index), ServiceID: id, DependsOnServiceID: fmt.Sprintf("service-%03d", index+1), RelationType: RelationBacks})
|
||||
}
|
||||
if index < 298 {
|
||||
dependencies = append(dependencies, Dependency{ID: fmt.Sprintf("edge-extra-%03d", index), ServiceID: id, DependsOnServiceID: fmt.Sprintf("service-%03d", index+2), RelationType: RelationExposes})
|
||||
}
|
||||
}
|
||||
topology, err := BuildTopology(Snapshot{ObservedAt: now, Services: services}, dependencies, 100, 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(topology.Nodes) > 100 || len(topology.Edges) > 200 || !topology.Truncated {
|
||||
t.Fatalf("topology exceeded target bounds: nodes=%d edges=%d truncated=%v", len(topology.Nodes), len(topology.Edges), topology.Truncated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
StateUp = "up"
|
||||
StateDegraded = "degraded"
|
||||
StateDown = "down"
|
||||
StateUnknown = "unknown"
|
||||
RelationDependsOn = "depends_on"
|
||||
RelationBacks = "backs"
|
||||
RelationExposes = "exposes"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
ID string `json:"id"`
|
||||
EntityID string `json:"entityId,omitempty"`
|
||||
SourceID string `json:"sourceId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
State string `json:"state"`
|
||||
Revision int64 `json:"revision"`
|
||||
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Dependency struct {
|
||||
ID string `json:"id"`
|
||||
ServiceID string `json:"serviceId"`
|
||||
DependsOnServiceID string `json:"dependsOnServiceId"`
|
||||
SourceID string `json:"sourceId,omitempty"`
|
||||
RelationType string `json:"relationType"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
FirstSeenAt time.Time `json:"firstSeenAt"`
|
||||
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
|
||||
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
|
||||
}
|
||||
|
||||
func (s Service) Validate() error {
|
||||
if strings.TrimSpace(s.ID) == "" || len(s.ID) > 64 || strings.TrimSpace(s.Name) == "" || len(s.Name) > 160 {
|
||||
return errors.New("service identity is invalid")
|
||||
}
|
||||
if s.State != StateUp && s.State != StateDegraded && s.State != StateDown && s.State != StateUnknown {
|
||||
return errors.New("service state is invalid")
|
||||
}
|
||||
if s.Revision < 1 {
|
||||
return errors.New("service revision is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Dependency) Validate() error {
|
||||
if strings.TrimSpace(d.ID) == "" || strings.TrimSpace(d.ServiceID) == "" || strings.TrimSpace(d.DependsOnServiceID) == "" || d.ServiceID == d.DependsOnServiceID {
|
||||
return errors.New("service dependency identity is invalid")
|
||||
}
|
||||
if d.RelationType != RelationDependsOn && d.RelationType != RelationBacks && d.RelationType != RelationExposes {
|
||||
return errors.New("service dependency relation is invalid")
|
||||
}
|
||||
if d.Confidence < 0 || d.Confidence > 1 {
|
||||
return errors.New("service dependency confidence is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestServiceAndDependencyValidation(t *testing.T) {
|
||||
if err := (Service{ID: "svc", Name: "Web", State: StateUnknown, Revision: 1}).Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (Service{ID: "svc", Name: "Web", State: "healthy", Revision: 1}).Validate(); err == nil {
|
||||
t.Fatal("expected invalid service state")
|
||||
}
|
||||
if err := (Dependency{ID: "edge", ServiceID: "svc", DependsOnServiceID: "db", RelationType: RelationDependsOn, Confidence: .8}).Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (Dependency{ID: "edge", ServiceID: "svc", DependsOnServiceID: "svc", RelationType: RelationDependsOn}).Validate(); err == nil {
|
||||
t.Fatal("expected self dependency rejection")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user