feat: remaining phases — actuator provisioning, transition checks, cleanup
Phase 2: Actuator provisioning - ProvisionLXC: pct create, start, package install, mounts, health check - ProvisionVM: qm create, status check via SSH - sshExecSimple helper for lightweight SSH command execution - resolveHost helper for entity attribute lookups Phase 5: Transition check enforcement - TransitionChecks map with 8 named checks: age-key-enrolled, mesh-joined, health-check-answering, no-inbound-edges, secrets-revoked, backups-verified, ingress-dns-removed, doc-page-complete - All checks accept pool + entity attrs for validation at transition time Phase 6: Cleanup - tools/setup-caveman.sh — npm install + wrapper + templates - tools/setup-hermes-soul.sh — SOUL.md provisioning - CLIENTS.md updated for thin client model (no git clone, API-based) - Old git-sync references replaced with context poller All tests pass, go vet clean.
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
// Package ontology implements the meta-schema logic: the entity-type
|
||||
// hierarchy (is-a with abstract types), relationship endpoint validation,
|
||||
// cardinality enforcement, and lifecycle state checks. Both the seed
|
||||
// ingest and the API mutation paths validate through this package so the
|
||||
// graph can never violate the ontology (plan R3-1).
|
||||
// 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.
|
||||
@@ -110,3 +114,133 @@ func (t *TypeTree) DefaultState(typ string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user