Monitoring coverage was 3 of 89 active entities. Three bugs, each hidden by discarded errors in checkdefaults: - writeCheck generated a fresh uuid, inserted the check entity ON CONFLICT (slug) DO NOTHING, then wrote a check_defs row referencing it. On any re-seed the slug already existed, the entity insert no-oped, and the FK violated — aborting the ingest transaction and surfacing as an unrelated failure several entities later. Re-seeding has been broken since; prod's coverage was frozen at its first successful seed. This is what TestSeedIngestIdempotentAndNoDuplicateEdges had been reporting. - shortSlug truncated to the last 8 chars, so all 21 ingress routes collapsed to ".network" and overwrote each other; service:jellyfin collided with lxc:jellyfin. - The ssh-script checker never read the `args` config checkdefaults wrote, so process_check.sh always ran without its unit name and returned "unknown". Coverage is now 75/89. Monitoring is declared per entity type in seeds/ontology.yaml and resolved through the is-a hierarchy, so a type can say it warrants nothing (site, lan, mesh, cluster) and never be reported as a gap. coverageSweep raises an `unmonitored` signal only where a type declares monitoring it lacks — 8 real gaps, no false positives. Also: - entity_types.attribute_schema was never ingested: the seed loader read "attribute_schema" but the YAML says "attributes", so all 60 types stored JSON null. - ListExecutions ignored its declared target/action/correlation_id filters and paginated on a non-unique target slug, dropping and repeating rows. - started_at was captured but only written at terminal state, so a running execution reported NULL for its whole life. The three MCP auto-run copies wrote no timing at all; they are now one autoRun helper. - SSH output was buffered to completion and discarded entirely on timeout. Both sshExec copies now stream through a shared execlog sink into execution_logs, and keep partial output when a command is cancelled. - executions.correlation_id was a random per-execution uuid that correlated nothing; it is now the chat session id, which is what lets the chat tail live output. - reversible_low had no auto-run branch despite policy declaring it unattended. Since computeCommandRisk never returns it, the class only arises when an agent declares it over a read_only command — so gating it penalised candor without adding safety. - backup-target gains a backup-freshness checker (portable find -mmin, since the first target is on macOS), resolving its host by walking backs-up-to backwards. The pre-deploy pg_dump is now a tracked backup target. UI: an Executions section on entity detail with live output tailing, and streamed output under a running `run` call in the chat timeline. Migrations 022-024. Ops.svelte and context.ts exclude execution.output from their refetch triggers, which would otherwise fire once a second per command. Co-Authored-By: Claude <noreply@anthropic.com>
254 lines
8.6 KiB
Go
254 lines
8.6 KiB
Go
// Package ontology implements the meta-schema logic: the entity-type
|
|
// hierarchy (is-a with abstract types), relationship endpoint validation,
|
|
// cardinality enforcement, lifecycle state checks, and transition
|
|
// requirement enforcement. Both the seed ingest and the API mutation paths
|
|
// validate through this package so the graph can never violate the
|
|
// ontology (plan R3-1).
|
|
package ontology
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/dtoro/oikos/internal/domain"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// TypeInfo is the subset of an entity type the validator needs.
|
|
type TypeInfo struct {
|
|
Parent string
|
|
IsAbstract bool
|
|
LifecycleID string
|
|
Layer string
|
|
|
|
// Monitoring is this type's own `monitoring:` declaration, or nil if it
|
|
// declared nothing (in which case the answer comes from an ancestor, or
|
|
// from the layer default). A non-nil pointer to an empty slice means
|
|
// "explicitly unmonitorable" — see TypeTree.Monitoring.
|
|
Monitoring *[]string
|
|
}
|
|
|
|
// RelTypeInfo is the subset of a relationship type the validator needs.
|
|
type RelTypeInfo struct {
|
|
SourceType string
|
|
TargetType string
|
|
Cardinality string
|
|
}
|
|
|
|
// LifecycleInfo is the subset of a lifecycle the validator needs.
|
|
type LifecycleInfo struct {
|
|
States map[string]bool
|
|
DefaultState string
|
|
}
|
|
|
|
// TypeTree holds the loaded ontology meta-schema for validation.
|
|
type TypeTree struct {
|
|
Types map[string]TypeInfo
|
|
RelTypes map[string]RelTypeInfo
|
|
Lifecycles map[string]LifecycleInfo
|
|
}
|
|
|
|
// IsA reports whether typ is target or a descendant of it.
|
|
func (t *TypeTree) IsA(typ, target string) bool {
|
|
seen := map[string]bool{}
|
|
for cur := typ; cur != ""; cur = t.Types[cur].Parent {
|
|
if cur == target {
|
|
return true
|
|
}
|
|
if seen[cur] {
|
|
return false // cycle guard — ingest rejects cycles, belt and braces
|
|
}
|
|
seen[cur] = true
|
|
if _, ok := t.Types[cur]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidateEntity checks that typ exists, is not abstract, and that state
|
|
// (if set) is legal for the type's lifecycle.
|
|
func (t *TypeTree) ValidateEntity(typ, state string) error {
|
|
info, ok := t.Types[typ]
|
|
if !ok {
|
|
return fmt.Errorf("%w: entity type %q", domain.ErrNotFound, typ)
|
|
}
|
|
if info.IsAbstract {
|
|
return fmt.Errorf("%w: %q", domain.ErrAbstractType, typ)
|
|
}
|
|
if state == "" {
|
|
return nil
|
|
}
|
|
if info.LifecycleID == "" {
|
|
return fmt.Errorf("%w: type %q has no lifecycle but state %q given",
|
|
domain.ErrInvalidTransition, typ, state)
|
|
}
|
|
lc, ok := t.Lifecycles[info.LifecycleID]
|
|
if !ok {
|
|
return fmt.Errorf("%w: lifecycle %q", domain.ErrNotFound, info.LifecycleID)
|
|
}
|
|
if !lc.States[state] {
|
|
return fmt.Errorf("%w: state %q not in lifecycle %q",
|
|
domain.ErrInvalidTransition, state, info.LifecycleID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateEdge checks that relType exists and that the endpoint entity
|
|
// types are the declared source/target types or descendants of them.
|
|
func (t *TypeTree) ValidateEdge(relType, sourceEntityType, targetEntityType string) error {
|
|
rt, ok := t.RelTypes[relType]
|
|
if !ok {
|
|
return fmt.Errorf("%w: relationship type %q", domain.ErrNotFound, relType)
|
|
}
|
|
if !t.IsA(sourceEntityType, rt.SourceType) {
|
|
return fmt.Errorf("%w: %s source %q is not a %q",
|
|
domain.ErrInvalidEdge, relType, sourceEntityType, rt.SourceType)
|
|
}
|
|
if !t.IsA(targetEntityType, rt.TargetType) {
|
|
return fmt.Errorf("%w: %s target %q is not a %q",
|
|
domain.ErrInvalidEdge, relType, targetEntityType, rt.TargetType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DefaultState returns the default lifecycle state for a type ("" if none).
|
|
func (t *TypeTree) DefaultState(typ string) string {
|
|
info, ok := t.Types[typ]
|
|
if !ok || info.LifecycleID == "" {
|
|
return ""
|
|
}
|
|
return t.Lifecycles[info.LifecycleID].DefaultState
|
|
}
|
|
|
|
// ─── Transition check enforcement ────────────────────────────────────
|
|
|
|
// Validated transitions in seeds/ontology.yaml carry a "requires:" list
|
|
// of named checks. Each check name maps to one of the functions below.
|
|
// The Go runtime enforces these before allowing a state transition.
|
|
|
|
// CheckFn validates a single named transition requirement.
|
|
type CheckFn func(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error
|
|
|
|
// TransitionChecks maps named check IDs to their implementation.
|
|
var TransitionChecks = map[string]CheckFn{
|
|
"age-key-enrolled-if-needed": checkAgeKeyEnrolled,
|
|
"mesh-joined-if-needed": checkMeshJoined,
|
|
"health-check-answering": checkHealthCheckAnswering,
|
|
"no-inbound-edges": checkNoInboundEdges,
|
|
"secrets-revoked-and-rekeyed": checkSecretsRevoked,
|
|
"backups-verified": checkBackupsVerified,
|
|
"ingress-and-dns-removed": checkIngressDNSRemoved,
|
|
"doc-page-complete": checkDocPageComplete,
|
|
}
|
|
|
|
func checkAgeKeyEnrolled(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
|
// Workstations and servers need age keys; compute entities (LXC/VM) don't.
|
|
if entityType == "lxc" || entityType == "vm" || entityType == "docker-container" {
|
|
return nil
|
|
}
|
|
if _, ok := attrs["age_pubkey"]; !ok {
|
|
return fmt.Errorf("%w: age_pubkey not set", domain.ErrInvalidTransition)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkMeshJoined(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
|
if entityType == "lxc" || entityType == "vm" || entityType == "docker-container" {
|
|
return nil
|
|
}
|
|
if _, ok := attrs["mesh_ip"]; !ok {
|
|
return fmt.Errorf("%w: mesh_ip not set", domain.ErrInvalidTransition)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkHealthCheckAnswering(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
|
var health string
|
|
err := pool.QueryRow(ctx,
|
|
"SELECT COALESCE(health, 'unknown') FROM entity_status WHERE entity_id = $1", entityID).Scan(&health)
|
|
if err != nil {
|
|
return nil // entity_status row may not exist yet; non-blocking
|
|
}
|
|
if health == "down" {
|
|
return fmt.Errorf("%w: health is down", domain.ErrInvalidTransition)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkNoInboundEdges(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
|
var count int
|
|
err := pool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM relationships
|
|
WHERE target_id = $1 AND valid_to IS NULL
|
|
AND type IN ('depends-on', 'hosts', 'provides', 'mounts', 'routes-to', 'stores-on')`,
|
|
entityID).Scan(&count)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return fmt.Errorf("%w: %d inbound edges still exist", domain.ErrInvalidTransition, count)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkSecretsRevoked(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
|
// Application-level check: the API caller must have already revoked
|
|
// Infisical identity and removed age key. We verify age_pubkey is
|
|
// still present as a guard — if it's gone, secrets were revoked.
|
|
if _, ok := attrs["age_pubkey"]; ok {
|
|
return fmt.Errorf("%w: age_pubkey still present; revoke secrets first", domain.ErrInvalidTransition)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkBackupsVerified(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
|
// Check for a recent audit log entry confirming backup verification.
|
|
var count int
|
|
err := pool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM audit_log
|
|
WHERE entity_id = $1 AND action = 'backup-verified'
|
|
AND timestamp > NOW() - INTERVAL '30 days'`,
|
|
entityID).Scan(&count)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return fmt.Errorf("%w: backup not verified in last 30 days", domain.ErrInvalidTransition)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkIngressDNSRemoved(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
|
// Check for remaining ingress or DNS relationships.
|
|
var count int
|
|
err := pool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM relationships
|
|
WHERE source_id = $1 AND valid_to IS NULL
|
|
AND type IN ('routes-to', 'provides', 'hosts')`,
|
|
entityID).Scan(&count)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return fmt.Errorf("%w: %d ingress/DNS relationships still exist", domain.ErrInvalidTransition, count)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkDocPageComplete(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
|
var count int
|
|
err := pool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM relationships
|
|
WHERE (source_id = $1 OR target_id = $1) AND valid_to IS NULL AND type = 'documents'`,
|
|
entityID).Scan(&count)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return fmt.Errorf("%w: no document edge found", domain.ErrInvalidTransition)
|
|
}
|
|
return nil
|
|
}
|