Public source validation / validate (push) Failing after 3m8s
238 lines
9.5 KiB
Go
238 lines
9.5 KiB
Go
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}}
|
|
}
|