mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 10:52:08 +00:00
2517 lines
81 KiB
Go
2517 lines
81 KiB
Go
package unifiedresources
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// ResourceStore persists identity overrides and manual links.
|
|
type ResourceStore interface {
|
|
AddLink(link ResourceLink) error
|
|
AddExclusion(exclusion ResourceExclusion) error
|
|
GetLinks() ([]ResourceLink, error)
|
|
GetExclusions() ([]ResourceExclusion, error)
|
|
RecordChange(change ResourceChange) error
|
|
GetRecentChanges(canonicalID string, since time.Time, limit int) ([]ResourceChange, error)
|
|
GetRecentChangesFiltered(canonicalID string, since time.Time, limit int, filters ResourceChangeFilters) ([]ResourceChange, error)
|
|
CountRecentChanges(canonicalID string, since time.Time) (int, error)
|
|
CountRecentChangesFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (int, error)
|
|
CountRecentChangesByKind(canonicalID string, since time.Time) (map[ChangeKind]int, error)
|
|
CountRecentChangesByKindFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeKind]int, error)
|
|
CountRecentChangesBySourceType(canonicalID string, since time.Time) (map[ChangeSourceType]int, error)
|
|
CountRecentChangesBySourceTypeFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceType]int, error)
|
|
CountRecentChangesBySourceAdapter(canonicalID string, since time.Time) (map[ChangeSourceAdapter]int, error)
|
|
CountRecentChangesBySourceAdapterFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceAdapter]int, error)
|
|
RecordActionAudit(record ActionAuditRecord) error
|
|
GetActionAudit(actionID string) (ActionAuditRecord, bool, error)
|
|
GetActionAudits(canonicalID string, since time.Time, limit int) ([]ActionAuditRecord, error)
|
|
RecordActionDecision(record ActionAuditRecord, event ActionLifecycleEvent) error
|
|
RecordActionExecutionStart(record ActionAuditRecord, event ActionLifecycleEvent) error
|
|
RecordActionExecutionResult(record ActionAuditRecord, event ActionLifecycleEvent) error
|
|
RecordActionLifecycleEvent(event ActionLifecycleEvent) error
|
|
GetActionLifecycleEvents(actionID string, since time.Time, limit int) ([]ActionLifecycleEvent, error)
|
|
RecordExportAudit(record ExportAuditRecord) error
|
|
GetExportAudits(since time.Time, limit int) ([]ExportAuditRecord, error)
|
|
// Operator-set per-resource state. See ResourceOperatorState.
|
|
GetResourceOperatorState(canonicalID string) (ResourceOperatorState, bool, error)
|
|
SetResourceOperatorState(state ResourceOperatorState) error
|
|
ClearResourceOperatorState(canonicalID string) error
|
|
// ListResourceOperatorStates returns every persisted operator-set
|
|
// state row. Used by background loops (e.g. the maintenance
|
|
// verification sentinel) that need to sweep the full set on each
|
|
// tick. Order is implementation-defined; the caller sorts if it
|
|
// cares.
|
|
ListResourceOperatorStates() ([]ResourceOperatorState, error)
|
|
// Loop reports — durable summaries written by background loops
|
|
// (currently only the maintenance-verification sentinel).
|
|
RecordLoopReport(report LoopReport) error
|
|
GetLoopReport(reportID string) (LoopReport, bool, error)
|
|
ListLoopReportsForResource(reportType LoopReportType, canonicalID string, limit int) ([]LoopReport, error)
|
|
UpdateLoopReportUserOutcome(reportID string, outcome LoopReportUserOutcome, reviewedBy, note string, reviewedAt time.Time) error
|
|
// FindLoopReportByWindow looks up an existing report by the
|
|
// (type, scope, window-end) triple so the sentinel can dedupe
|
|
// on each tick without scanning the whole table.
|
|
FindLoopReportByWindow(reportType LoopReportType, canonicalID string, windowEndedAt time.Time) (LoopReport, bool, error)
|
|
Close() error
|
|
}
|
|
|
|
// ResourceLink represents a manual merge.
|
|
type ResourceLink struct {
|
|
ResourceA string
|
|
ResourceB string
|
|
PrimaryID string
|
|
Reason string
|
|
CreatedBy string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// ResourceExclusion represents a manual split.
|
|
type ResourceExclusion struct {
|
|
ResourceA string
|
|
ResourceB string
|
|
Reason string
|
|
CreatedBy string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// SQLiteResourceStore stores overrides in SQLite.
|
|
type SQLiteResourceStore struct {
|
|
db *sql.DB
|
|
dbPath string
|
|
resourceChangesHasTimestamp bool
|
|
mu sync.Mutex
|
|
}
|
|
|
|
const (
|
|
maxOrgIDLength = 64
|
|
defaultOrgID = "default"
|
|
resourceDBFileName = "unified_resources.db"
|
|
)
|
|
|
|
var resourceStoreOrgIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
|
|
|
|
// NewSQLiteResourceStore opens or creates the SQLite store.
|
|
func NewSQLiteResourceStore(dataDir, orgID string) (*SQLiteResourceStore, error) {
|
|
if dataDir == "" {
|
|
dataDir = utils.GetDataDir()
|
|
}
|
|
|
|
resolvedOrgID, err := normalizeOrgID(orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
path := resourceDBPath(dataDir, resolvedOrgID)
|
|
// Resource-store metadata can include user-authored links/exclusions; keep directory owner-only.
|
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
|
return nil, fmt.Errorf("failed to create resources directory: %w", err)
|
|
}
|
|
|
|
// Backward compatibility: migrate non-default org stores from the legacy
|
|
// shared-path naming scheme into tenant-scoped directories on first access.
|
|
if resolvedOrgID != defaultOrgID {
|
|
if err := migrateLegacyResourceStore(dataDir, resolvedOrgID, path); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
dsn := path + "?" + url.Values{
|
|
"_pragma": []string{
|
|
"busy_timeout(30000)",
|
|
"journal_mode(WAL)",
|
|
"synchronous(NORMAL)",
|
|
"foreign_keys(ON)",
|
|
"cache_size(-64000)",
|
|
},
|
|
}.Encode()
|
|
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open resources db: %w", err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
db.SetConnMaxLifetime(0)
|
|
|
|
store := &SQLiteResourceStore{db: db, dbPath: path}
|
|
if err := store.initSchema(); err != nil {
|
|
wrappedInitErr := fmt.Errorf("initialize resource store schema for %q: %w", path, err)
|
|
if closeErr := db.Close(); closeErr != nil {
|
|
return nil, errors.Join(
|
|
wrappedInitErr,
|
|
fmt.Errorf("close resources db %q after init failure: %w", path, closeErr),
|
|
)
|
|
}
|
|
return nil, wrappedInitErr
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
func normalizeOrgID(orgID string) (string, error) {
|
|
orgID = strings.TrimSpace(orgID)
|
|
if orgID == "" {
|
|
return defaultOrgID, nil
|
|
}
|
|
if orgID == defaultOrgID {
|
|
return defaultOrgID, nil
|
|
}
|
|
if !isValidResourceStoreOrgID(orgID) {
|
|
return "", fmt.Errorf("invalid organization ID: %s", orgID)
|
|
}
|
|
return orgID, nil
|
|
}
|
|
|
|
func isValidResourceStoreOrgID(orgID string) bool {
|
|
if orgID == "" || orgID == "." || orgID == ".." {
|
|
return false
|
|
}
|
|
if filepath.Base(orgID) != orgID {
|
|
return false
|
|
}
|
|
return resourceStoreOrgIDPattern.MatchString(orgID)
|
|
}
|
|
|
|
func resourceDBPath(dataDir, orgID string) string {
|
|
if orgID == defaultOrgID {
|
|
return filepath.Join(dataDir, "resources", resourceDBFileName)
|
|
}
|
|
return filepath.Join(dataDir, "orgs", orgID, "resources", resourceDBFileName)
|
|
}
|
|
|
|
func migrateLegacyResourceStore(dataDir, orgID, newPath string) error {
|
|
legacyPath := filepath.Join(dataDir, "resources", legacyResourceStoreFileName(orgID))
|
|
if legacyPath == newPath {
|
|
return nil
|
|
}
|
|
|
|
if err := copyFileIfMissing(legacyPath, newPath, 0600); err != nil {
|
|
return fmt.Errorf("migrate legacy resource store %q -> %q: %w", legacyPath, newPath, err)
|
|
}
|
|
// Copy SQLite sidecar files when present to preserve uncheckpointed state.
|
|
for _, suffix := range []string{"-wal", "-shm"} {
|
|
legacyCompanion := legacyPath + suffix
|
|
newCompanion := newPath + suffix
|
|
if err := copyFileIfMissing(legacyCompanion, newCompanion, 0600); err != nil {
|
|
return fmt.Errorf("migrate legacy resource store companion %q -> %q: %w", legacyCompanion, newCompanion, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyFileIfMissing(src, dst string, perm os.FileMode) error {
|
|
srcFile, err := os.Open(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
|
|
if err != nil {
|
|
if os.IsExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
_, copyErr := io.Copy(dstFile, srcFile)
|
|
closeErr := dstFile.Close()
|
|
if copyErr != nil {
|
|
_ = os.Remove(dst)
|
|
return copyErr
|
|
}
|
|
if closeErr != nil {
|
|
_ = os.Remove(dst)
|
|
return closeErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) initSchema() error {
|
|
schema := `
|
|
CREATE TABLE IF NOT EXISTS resource_identities (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
canonical_id TEXT NOT NULL,
|
|
resource_type TEXT NOT NULL,
|
|
machine_id TEXT,
|
|
dmi_uuid TEXT,
|
|
primary_hostname TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_resource_identities_machine_id ON resource_identities(machine_id) WHERE machine_id IS NOT NULL;
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_resource_identities_dmi_uuid ON resource_identities(dmi_uuid) WHERE dmi_uuid IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_resource_identities_canonical ON resource_identities(canonical_id);
|
|
CREATE INDEX IF NOT EXISTS idx_resource_identities_hostname ON resource_identities(primary_hostname);
|
|
|
|
CREATE TABLE IF NOT EXISTS resource_hostnames (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
canonical_id TEXT NOT NULL,
|
|
hostname TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
is_primary BOOLEAN DEFAULT FALSE,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(hostname, source)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_resource_hostnames_canonical ON resource_hostnames(canonical_id);
|
|
CREATE INDEX IF NOT EXISTS idx_resource_hostnames_hostname ON resource_hostnames(hostname);
|
|
|
|
CREATE TABLE IF NOT EXISTS resource_ips (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
canonical_id TEXT NOT NULL,
|
|
ip_address TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(ip_address, source)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_resource_ips_canonical ON resource_ips(canonical_id);
|
|
CREATE INDEX IF NOT EXISTS idx_resource_ips_ip ON resource_ips(ip_address);
|
|
|
|
CREATE TABLE IF NOT EXISTS resource_links (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
resource_a TEXT NOT NULL,
|
|
resource_b TEXT NOT NULL,
|
|
primary_id TEXT NOT NULL,
|
|
reason TEXT,
|
|
created_by TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(resource_a, resource_b)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_resource_links_primary ON resource_links(primary_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS resource_exclusions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
resource_a TEXT NOT NULL,
|
|
resource_b TEXT NOT NULL,
|
|
reason TEXT,
|
|
created_by TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(resource_a, resource_b)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_resource_exclusions_a ON resource_exclusions(resource_a);
|
|
CREATE INDEX IF NOT EXISTS idx_resource_exclusions_b ON resource_exclusions(resource_b);
|
|
|
|
CREATE TABLE IF NOT EXISTS resource_metadata (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
canonical_id TEXT NOT NULL UNIQUE,
|
|
custom_url TEXT,
|
|
custom_name TEXT,
|
|
notes TEXT,
|
|
tags TEXT,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_by TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_resource_metadata_canonical ON resource_metadata(canonical_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS resource_changes (
|
|
id TEXT PRIMARY KEY,
|
|
canonical_id TEXT NOT NULL,
|
|
observed_at DATETIME NOT NULL,
|
|
occurred_at DATETIME,
|
|
kind TEXT NOT NULL,
|
|
from_state TEXT,
|
|
to_state TEXT,
|
|
source_type TEXT NOT NULL,
|
|
source_adapter TEXT,
|
|
actor TEXT,
|
|
confidence TEXT NOT NULL,
|
|
reason TEXT,
|
|
related_resources TEXT,
|
|
metadata_json TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS action_audits (
|
|
id TEXT PRIMARY KEY,
|
|
action_id TEXT NOT NULL,
|
|
canonical_id TEXT NOT NULL,
|
|
request_id TEXT NOT NULL,
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NOT NULL,
|
|
state TEXT NOT NULL,
|
|
request_json TEXT NOT NULL,
|
|
plan_json TEXT NOT NULL,
|
|
approvals_json TEXT,
|
|
result_json TEXT,
|
|
verification_outcome_json TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_action_audits_canonical_created ON action_audits(canonical_id, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_action_audits_action_id ON action_audits(action_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS action_lifecycle_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
action_id TEXT NOT NULL,
|
|
timestamp DATETIME NOT NULL,
|
|
state TEXT NOT NULL,
|
|
actor TEXT,
|
|
message TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_action_lifecycle_events_action ON action_lifecycle_events(action_id, timestamp DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS export_audits (
|
|
id TEXT PRIMARY KEY,
|
|
timestamp DATETIME NOT NULL,
|
|
actor TEXT NOT NULL,
|
|
envelope_hash TEXT NOT NULL,
|
|
decision TEXT NOT NULL,
|
|
destination TEXT NOT NULL,
|
|
redactions_json TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_export_audits_timestamp ON export_audits(timestamp DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS resource_operator_state (
|
|
canonical_id TEXT PRIMARY KEY,
|
|
intentionally_offline INTEGER NOT NULL DEFAULT 0,
|
|
never_auto_remediate INTEGER NOT NULL DEFAULT 0,
|
|
maintenance_start_at DATETIME,
|
|
maintenance_end_at DATETIME,
|
|
maintenance_reason TEXT,
|
|
criticality TEXT,
|
|
note TEXT,
|
|
set_at DATETIME NOT NULL,
|
|
set_by TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_resource_operator_state_maintenance ON resource_operator_state(maintenance_end_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS loop_reports (
|
|
id TEXT PRIMARY KEY,
|
|
report_type TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
trigger TEXT NOT NULL,
|
|
goal TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL,
|
|
started_at DATETIME NOT NULL,
|
|
completed_at DATETIME NOT NULL,
|
|
window_started_at DATETIME,
|
|
window_ended_at DATETIME,
|
|
evidence_json TEXT NOT NULL DEFAULT '{}',
|
|
linked_finding_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
linked_alert_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
linked_action_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
linked_patrol_run_id TEXT NOT NULL DEFAULT '',
|
|
recommendation TEXT NOT NULL DEFAULT '',
|
|
user_outcome TEXT NOT NULL DEFAULT '',
|
|
reviewed_at DATETIME,
|
|
reviewed_by TEXT NOT NULL DEFAULT '',
|
|
review_note TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_loop_reports_scope_type_started
|
|
ON loop_reports(report_type, scope, started_at DESC);
|
|
-- Look-up index for FindLoopReportByWindow. Intentionally non-unique:
|
|
-- rerun records share (type, scope, window_ended_at) with the
|
|
-- original under a distinct id suffix. Tick-vs-tick dedup runs at
|
|
-- the sentinel layer (mutex + FindLoopReportByWindow check).
|
|
CREATE INDEX IF NOT EXISTS idx_loop_reports_window_lookup
|
|
ON loop_reports(report_type, scope, window_ended_at)
|
|
WHERE window_ended_at IS NOT NULL;
|
|
`
|
|
|
|
_, err := s.db.Exec(schema)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize resource store schema: %w", err)
|
|
}
|
|
if err := s.migrateResourceChangesSchema(); err != nil {
|
|
return err
|
|
}
|
|
if err := s.ensureResourceChangesIndexes(); err != nil {
|
|
return err
|
|
}
|
|
if err := s.migrateActionAuditsSchema(); err != nil {
|
|
return err
|
|
}
|
|
if err := s.migrateActionAuditRedaction(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// migrateActionAuditsSchema adds the verification_outcome_json column to
|
|
// older action_audits tables so the VerificationOutcome field on
|
|
// ActionAuditRecord persists across restarts. Records written before the
|
|
// migration read back with the default unknown status via the normalizer.
|
|
func (s *SQLiteResourceStore) migrateActionAuditsSchema() error {
|
|
columns, err := s.tableColumns("action_audits")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, ok := columns["verification_outcome_json"]; ok {
|
|
return nil
|
|
}
|
|
if _, err := s.db.Exec("ALTER TABLE action_audits ADD COLUMN verification_outcome_json TEXT NOT NULL DEFAULT ''"); err != nil {
|
|
return fmt.Errorf("add action_audits.verification_outcome_json column: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) migrateActionAuditRedaction() error {
|
|
rows, err := s.db.Query(`
|
|
SELECT id, result_json
|
|
FROM action_audits
|
|
WHERE result_json IS NOT NULL AND result_json != '' AND result_json != 'null'
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("query action audit redaction migration rows: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type redactionUpdate struct {
|
|
id string
|
|
resultJSON string
|
|
}
|
|
var updates []redactionUpdate
|
|
for rows.Next() {
|
|
var id, resultJSON string
|
|
if err := rows.Scan(&id, &resultJSON); err != nil {
|
|
return fmt.Errorf("scan action audit redaction migration row: %w", err)
|
|
}
|
|
var result ExecutionResult
|
|
if err := json.Unmarshal([]byte(resultJSON), &result); err != nil {
|
|
return fmt.Errorf("unmarshal action audit result for redaction migration %q: %w", id, err)
|
|
}
|
|
redactedResult := redactActionExecutionResult(&result)
|
|
redactedJSON, err := json.Marshal(redactedResult)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal action audit result for redaction migration %q: %w", id, err)
|
|
}
|
|
if string(redactedJSON) != resultJSON {
|
|
updates = append(updates, redactionUpdate{id: id, resultJSON: string(redactedJSON)})
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("iterate action audit redaction migration rows: %w", err)
|
|
}
|
|
if len(updates) == 0 {
|
|
return nil
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin action audit redaction migration: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
for _, update := range updates {
|
|
if _, err := tx.Exec(`
|
|
UPDATE action_audits
|
|
SET result_json = ?
|
|
WHERE id = ?
|
|
`, update.resultJSON, update.id); err != nil {
|
|
return fmt.Errorf("update action audit result redaction %q: %w", update.id, err)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit action audit redaction migration: %w", err)
|
|
}
|
|
committed = true
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) migrateResourceChangesSchema() error {
|
|
columns, err := s.tableColumns("resource_changes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, ok := columns["timestamp"]; ok {
|
|
s.resourceChangesHasTimestamp = true
|
|
}
|
|
|
|
if err := s.addResourceChangesColumnIfMissing(columns, "observed_at", "DATETIME"); err != nil {
|
|
return err
|
|
}
|
|
if err := s.addResourceChangesColumnIfMissing(columns, "occurred_at", "DATETIME"); err != nil {
|
|
return err
|
|
}
|
|
if err := s.addResourceChangesColumnIfMissing(columns, "source_type", "TEXT NOT NULL DEFAULT 'pulse_diff'"); err != nil {
|
|
return err
|
|
}
|
|
if err := s.addResourceChangesColumnIfMissing(columns, "source_adapter", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
|
return err
|
|
}
|
|
if err := s.addResourceChangesColumnIfMissing(columns, "actor", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
|
return err
|
|
}
|
|
if err := s.addResourceChangesColumnIfMissing(columns, "related_resources", "TEXT NOT NULL DEFAULT '[]'"); err != nil {
|
|
return err
|
|
}
|
|
if err := s.addResourceChangesColumnIfMissing(columns, "metadata_json", "TEXT NOT NULL DEFAULT '{}'"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.backfillLegacyResourceChangeObservedAt(columns); err != nil {
|
|
return err
|
|
}
|
|
if err := s.normalizeResourceChangeRows(columns); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) ensureResourceChangesIndexes() error {
|
|
indexes := []string{
|
|
`CREATE INDEX IF NOT EXISTS idx_resource_changes_canonical_time ON resource_changes(canonical_id, observed_at DESC)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_resource_changes_kind_time ON resource_changes(kind, observed_at DESC)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_resource_changes_source_type_time ON resource_changes(source_type, observed_at DESC)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_resource_changes_source_adapter_time ON resource_changes(source_adapter, observed_at DESC)`,
|
|
}
|
|
for _, stmt := range indexes {
|
|
if _, err := s.db.Exec(stmt); err != nil {
|
|
return fmt.Errorf("ensure resource_changes index: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) addResourceChangesColumnIfMissing(columns map[string]struct{}, columnName, definition string) error {
|
|
if _, ok := columns[columnName]; ok {
|
|
return nil
|
|
}
|
|
if _, err := s.db.Exec("ALTER TABLE resource_changes ADD COLUMN " + columnName + " " + definition); err != nil {
|
|
return fmt.Errorf("add resource_changes.%s column: %w", columnName, err)
|
|
}
|
|
columns[columnName] = struct{}{}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) tableColumns(tableName string) (map[string]struct{}, error) {
|
|
rows, err := s.db.Query(`PRAGMA table_info(` + tableName + `)`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inspect %s schema: %w", tableName, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
columns := make(map[string]struct{})
|
|
for rows.Next() {
|
|
var (
|
|
cid int
|
|
name string
|
|
typ string
|
|
notNull int
|
|
dflt sql.NullString
|
|
pk int
|
|
)
|
|
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
|
return nil, fmt.Errorf("scan %s schema: %w", tableName, err)
|
|
}
|
|
columns[name] = struct{}{}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate %s schema: %w", tableName, err)
|
|
}
|
|
return columns, nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) normalizeResourceChangeRows(columns map[string]struct{}) error {
|
|
assignments := []string{
|
|
"actor = COALESCE(NULLIF(TRIM(actor), ''), '')",
|
|
"related_resources = COALESCE(NULLIF(TRIM(related_resources), ''), '[]')",
|
|
"metadata_json = COALESCE(NULLIF(TRIM(metadata_json), ''), '{}')",
|
|
}
|
|
if _, ok := columns["source"]; ok {
|
|
assignments = append(assignments,
|
|
"source_adapter = CASE WHEN TRIM(COALESCE(source_adapter, '')) = '' THEN COALESCE(NULLIF(TRIM(source), ''), '') ELSE source_adapter END",
|
|
"source_type = CASE "+
|
|
"WHEN TRIM(COALESCE(source_type, '')) = '' THEN "+
|
|
"CASE WHEN lower(TRIM(COALESCE(source, ''))) IN ('platform_event', 'pulse_diff', 'heuristic', 'user_action', 'agent_action') "+
|
|
"THEN lower(TRIM(source)) ELSE 'pulse_diff' END "+
|
|
"ELSE lower(TRIM(source_type)) END",
|
|
)
|
|
} else {
|
|
assignments = append(assignments,
|
|
"source_adapter = COALESCE(NULLIF(TRIM(source_adapter), ''), '')",
|
|
"source_type = CASE WHEN TRIM(COALESCE(source_type, '')) = '' THEN 'pulse_diff' ELSE lower(TRIM(source_type)) END",
|
|
)
|
|
}
|
|
query := `UPDATE resource_changes SET ` + strings.Join(assignments, ", ")
|
|
if _, err := s.db.Exec(query); err != nil {
|
|
return fmt.Errorf("normalize resource_changes rows: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) backfillLegacyResourceChangeObservedAt(columns map[string]struct{}) error {
|
|
if _, ok := columns["observed_at"]; !ok {
|
|
return nil
|
|
}
|
|
|
|
expressions := []string{"observed_at = COALESCE(observed_at, CURRENT_TIMESTAMP)"}
|
|
if _, ok := columns["timestamp"]; ok {
|
|
expressions[0] = "observed_at = COALESCE(observed_at, timestamp, CURRENT_TIMESTAMP)"
|
|
}
|
|
|
|
query := `UPDATE resource_changes SET ` + expressions[0] + ` WHERE observed_at IS NULL OR TRIM(COALESCE(observed_at, '')) = ''`
|
|
if _, err := s.db.Exec(query); err != nil {
|
|
return fmt.Errorf("backfill resource_changes.observed_at: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) AddLink(link ResourceLink) error {
|
|
if link.ResourceA == "" || link.ResourceB == "" {
|
|
return fmt.Errorf("resource IDs required")
|
|
}
|
|
link.ResourceA = CanonicalResourceID(link.ResourceA)
|
|
link.ResourceB = CanonicalResourceID(link.ResourceB)
|
|
link.PrimaryID = CanonicalResourceID(link.PrimaryID)
|
|
if link.PrimaryID == "" {
|
|
link.PrimaryID = link.ResourceA
|
|
}
|
|
if link.CreatedAt.IsZero() {
|
|
link.CreatedAt = time.Now().UTC()
|
|
}
|
|
|
|
a, b := normalizePair(link.ResourceA, link.ResourceB)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO resource_links (resource_a, resource_b, primary_id, reason, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(resource_a, resource_b) DO UPDATE SET
|
|
primary_id=excluded.primary_id,
|
|
reason=excluded.reason,
|
|
created_by=excluded.created_by,
|
|
created_at=excluded.created_at
|
|
`, a, b, link.PrimaryID, link.Reason, link.CreatedBy, link.CreatedAt)
|
|
if err != nil {
|
|
return fmt.Errorf("upsert resource link %q<->%q: %w", a, b, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) AddExclusion(exclusion ResourceExclusion) error {
|
|
if exclusion.ResourceA == "" || exclusion.ResourceB == "" {
|
|
return fmt.Errorf("resource IDs required")
|
|
}
|
|
exclusion.ResourceA = CanonicalResourceID(exclusion.ResourceA)
|
|
exclusion.ResourceB = CanonicalResourceID(exclusion.ResourceB)
|
|
if exclusion.CreatedAt.IsZero() {
|
|
exclusion.CreatedAt = time.Now().UTC()
|
|
}
|
|
a, b := normalizePair(exclusion.ResourceA, exclusion.ResourceB)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO resource_exclusions (resource_a, resource_b, reason, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(resource_a, resource_b) DO UPDATE SET
|
|
reason=excluded.reason,
|
|
created_by=excluded.created_by,
|
|
created_at=excluded.created_at
|
|
`, a, b, exclusion.Reason, exclusion.CreatedBy, exclusion.CreatedAt)
|
|
if err != nil {
|
|
return fmt.Errorf("upsert resource exclusion %q<->%q: %w", a, b, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) GetLinks() (links []ResourceLink, err error) {
|
|
rows, err := s.db.Query(`SELECT resource_a, resource_b, primary_id, reason, created_by, created_at FROM resource_links`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query resource links: %w", err)
|
|
}
|
|
defer func() {
|
|
if closeErr := rows.Close(); closeErr != nil {
|
|
wrappedCloseErr := fmt.Errorf("close resource links rows: %w", closeErr)
|
|
if err != nil {
|
|
err = errors.Join(err, wrappedCloseErr)
|
|
return
|
|
}
|
|
err = wrappedCloseErr
|
|
}
|
|
}()
|
|
|
|
for rows.Next() {
|
|
var link ResourceLink
|
|
if scanErr := rows.Scan(&link.ResourceA, &link.ResourceB, &link.PrimaryID, &link.Reason, &link.CreatedBy, &link.CreatedAt); scanErr != nil {
|
|
return nil, fmt.Errorf("scan resource link row: %w", scanErr)
|
|
}
|
|
link.ResourceA = CanonicalResourceID(link.ResourceA)
|
|
link.ResourceB = CanonicalResourceID(link.ResourceB)
|
|
link.PrimaryID = CanonicalResourceID(link.PrimaryID)
|
|
links = append(links, link)
|
|
}
|
|
if rowsErr := rows.Err(); rowsErr != nil {
|
|
return nil, fmt.Errorf("iterate resource links rows: %w", rowsErr)
|
|
}
|
|
return links, nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) GetExclusions() (exclusions []ResourceExclusion, err error) {
|
|
rows, err := s.db.Query(`SELECT resource_a, resource_b, reason, created_by, created_at FROM resource_exclusions`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query resource exclusions: %w", err)
|
|
}
|
|
defer func() {
|
|
if closeErr := rows.Close(); closeErr != nil {
|
|
wrappedCloseErr := fmt.Errorf("close resource exclusions rows: %w", closeErr)
|
|
if err != nil {
|
|
err = errors.Join(err, wrappedCloseErr)
|
|
return
|
|
}
|
|
err = wrappedCloseErr
|
|
}
|
|
}()
|
|
|
|
for rows.Next() {
|
|
var exclusion ResourceExclusion
|
|
if scanErr := rows.Scan(&exclusion.ResourceA, &exclusion.ResourceB, &exclusion.Reason, &exclusion.CreatedBy, &exclusion.CreatedAt); scanErr != nil {
|
|
return nil, fmt.Errorf("scan resource exclusion row: %w", scanErr)
|
|
}
|
|
exclusion.ResourceA = CanonicalResourceID(exclusion.ResourceA)
|
|
exclusion.ResourceB = CanonicalResourceID(exclusion.ResourceB)
|
|
exclusions = append(exclusions, exclusion)
|
|
}
|
|
if rowsErr := rows.Err(); rowsErr != nil {
|
|
return nil, fmt.Errorf("iterate resource exclusions rows: %w", rowsErr)
|
|
}
|
|
return exclusions, nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) Close() error {
|
|
if s.db != nil {
|
|
if err := s.db.Close(); err != nil {
|
|
return fmt.Errorf("close resources db %q: %w", s.dbPath, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) RecordChange(change ResourceChange) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return recordChangeSQL(s.db, change, s.resourceChangesHasTimestamp)
|
|
}
|
|
|
|
func recordChangeSQL(execer sqlExecutor, change ResourceChange, includeTimestamp bool) error {
|
|
relJSON, _ := json.Marshal(change.RelatedResources)
|
|
metaJSON, _ := json.Marshal(change.Metadata)
|
|
|
|
columns := []string{"id", "canonical_id", "observed_at"}
|
|
values := []any{change.ID, CanonicalResourceID(change.ResourceID), change.ObservedAt}
|
|
if includeTimestamp {
|
|
columns = append(columns, "timestamp")
|
|
values = append(values, change.ObservedAt)
|
|
}
|
|
columns = append(columns,
|
|
"occurred_at",
|
|
"kind",
|
|
"from_state",
|
|
"to_state",
|
|
"source_type",
|
|
"source_adapter",
|
|
"actor",
|
|
"confidence",
|
|
"reason",
|
|
"related_resources",
|
|
"metadata_json",
|
|
)
|
|
values = append(values,
|
|
change.OccurredAt,
|
|
string(change.Kind),
|
|
change.From,
|
|
change.To,
|
|
change.SourceType,
|
|
change.SourceAdapter,
|
|
change.Actor,
|
|
string(change.Confidence),
|
|
change.Reason,
|
|
string(relJSON),
|
|
string(metaJSON),
|
|
)
|
|
|
|
placeholders := make([]string, len(columns))
|
|
for i := range placeholders {
|
|
placeholders[i] = "?"
|
|
}
|
|
|
|
_, err := execer.Exec(
|
|
`INSERT INTO resource_changes (`+strings.Join(columns, ", ")+`) VALUES (`+strings.Join(placeholders, ", ")+`) ON CONFLICT(id) DO NOTHING`,
|
|
values...,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert resource change: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) GetRecentChanges(canonicalID string, since time.Time, limit int) ([]ResourceChange, error) {
|
|
return s.GetRecentChangesFiltered(canonicalID, since, limit, ResourceChangeFilters{})
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since time.Time, limit int, filters ResourceChangeFilters) ([]ResourceChange, error) {
|
|
query := `
|
|
SELECT id, canonical_id, observed_at, occurred_at, COALESCE(kind, ''), COALESCE(from_state, ''), COALESCE(to_state, ''), COALESCE(source_type, ''), COALESCE(source_adapter, ''), COALESCE(actor, ''), COALESCE(confidence, ''), COALESCE(reason, ''), COALESCE(related_resources, ''), COALESCE(metadata_json, '')
|
|
FROM resource_changes`
|
|
|
|
args := []any{}
|
|
conditions := []string{}
|
|
canonicalID = CanonicalResourceID(canonicalID)
|
|
if canonicalID != "" {
|
|
conditions, args = appendRecentChangeResourceCondition(conditions, args, canonicalID, filters.IncludeRelated)
|
|
} else {
|
|
conditions = append(conditions, "observed_at >= ?")
|
|
args = append(args, since)
|
|
}
|
|
if !since.IsZero() && canonicalID != "" {
|
|
conditions = append(conditions, "observed_at >= ?")
|
|
args = append(args, since)
|
|
}
|
|
if len(filters.Kinds) > 0 {
|
|
placeholders := make([]string, 0, len(filters.Kinds))
|
|
for _, kind := range filters.Kinds {
|
|
placeholders = append(placeholders, "?")
|
|
args = append(args, string(kind))
|
|
}
|
|
conditions = append(conditions, "kind IN ("+strings.Join(placeholders, ", ")+")")
|
|
}
|
|
if len(filters.SourceTypes) > 0 {
|
|
placeholders := make([]string, 0, len(filters.SourceTypes))
|
|
for _, sourceType := range filters.SourceTypes {
|
|
placeholders = append(placeholders, "?")
|
|
args = append(args, string(sourceType))
|
|
}
|
|
conditions = append(conditions, "source_type IN ("+strings.Join(placeholders, ", ")+")")
|
|
}
|
|
if len(filters.SourceAdapters) > 0 {
|
|
placeholders := make([]string, 0, len(filters.SourceAdapters))
|
|
for _, sourceAdapter := range filters.SourceAdapters {
|
|
placeholders = append(placeholders, "?")
|
|
args = append(args, string(sourceAdapter))
|
|
}
|
|
conditions = append(conditions, "source_adapter IN ("+strings.Join(placeholders, ", ")+")")
|
|
}
|
|
if len(conditions) > 0 {
|
|
query += "\n\t\tWHERE " + strings.Join(conditions, " AND ")
|
|
}
|
|
query += `
|
|
ORDER BY observed_at DESC`
|
|
if limit > 0 {
|
|
query += ` LIMIT ?`
|
|
args = append(args, limit)
|
|
}
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query resource changes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var changes []ResourceChange
|
|
for rows.Next() {
|
|
var c ResourceChange
|
|
var conf, kindStr string
|
|
var relText, metaText sql.NullString
|
|
|
|
if err := rows.Scan(&c.ID, &c.ResourceID, &c.ObservedAt, &c.OccurredAt, &kindStr, &c.From, &c.To, &c.SourceType, &c.SourceAdapter, &c.Actor, &conf, &c.Reason, &relText, &metaText); err != nil {
|
|
return nil, fmt.Errorf("scan resource change row: %w", err)
|
|
}
|
|
c.ResourceID = CanonicalResourceID(c.ResourceID)
|
|
|
|
c.Kind = ChangeKind(kindStr)
|
|
c.Confidence = ChangeConfidence(conf)
|
|
|
|
if relText.Valid && relText.String != "" {
|
|
_ = json.Unmarshal([]byte(relText.String), &c.RelatedResources)
|
|
}
|
|
if metaText.Valid && metaText.String != "" {
|
|
_ = json.Unmarshal([]byte(metaText.String), &c.Metadata)
|
|
}
|
|
|
|
changes = append(changes, c)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate resource change rows: %w", err)
|
|
}
|
|
return changes, nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) CountRecentChanges(canonicalID string, since time.Time) (int, error) {
|
|
return s.CountRecentChangesFiltered(canonicalID, since, ResourceChangeFilters{})
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) CountRecentChangesFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (int, error) {
|
|
query, args := buildRecentChangeCountQuery(canonicalID, since, filters, "SELECT COUNT(*) FROM resource_changes")
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var count int
|
|
if err := s.db.QueryRow(query, args...).Scan(&count); err != nil {
|
|
return 0, fmt.Errorf("count resource changes: %w", err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) CountRecentChangesByKind(canonicalID string, since time.Time) (map[ChangeKind]int, error) {
|
|
return s.CountRecentChangesByKindFiltered(canonicalID, since, ResourceChangeFilters{})
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) CountRecentChangesByKindFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeKind]int, error) {
|
|
query, args := buildRecentChangeCountQuery(canonicalID, since, filters, "SELECT COALESCE(kind, ''), COUNT(*) FROM resource_changes")
|
|
query += ` GROUP BY kind`
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query resource change counts by kind: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
counts := make(map[ChangeKind]int)
|
|
for rows.Next() {
|
|
var kind string
|
|
var count int
|
|
if err := rows.Scan(&kind, &count); err != nil {
|
|
return nil, fmt.Errorf("scan resource change kind count row: %w", err)
|
|
}
|
|
if kind == "" {
|
|
continue
|
|
}
|
|
counts[ChangeKind(kind)] = count
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate resource change kind count rows: %w", err)
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) CountRecentChangesBySourceType(canonicalID string, since time.Time) (map[ChangeSourceType]int, error) {
|
|
return s.CountRecentChangesBySourceTypeFiltered(canonicalID, since, ResourceChangeFilters{})
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) CountRecentChangesBySourceTypeFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceType]int, error) {
|
|
query, args := buildRecentChangeCountQuery(canonicalID, since, filters, "SELECT COALESCE(source_type, ''), COUNT(*) FROM resource_changes")
|
|
query += ` GROUP BY source_type`
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query resource change counts by source type: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
counts := make(map[ChangeSourceType]int)
|
|
for rows.Next() {
|
|
var sourceType string
|
|
var count int
|
|
if err := rows.Scan(&sourceType, &count); err != nil {
|
|
return nil, fmt.Errorf("scan resource change source type count row: %w", err)
|
|
}
|
|
if sourceType == "" {
|
|
continue
|
|
}
|
|
counts[ChangeSourceType(sourceType)] = count
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate resource change source type count rows: %w", err)
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapter(canonicalID string, since time.Time) (map[ChangeSourceAdapter]int, error) {
|
|
return s.CountRecentChangesBySourceAdapterFiltered(canonicalID, since, ResourceChangeFilters{})
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapterFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceAdapter]int, error) {
|
|
query, args := buildRecentChangeCountQuery(canonicalID, since, filters, "SELECT COALESCE(source_adapter, ''), COUNT(*) FROM resource_changes")
|
|
query += ` GROUP BY source_adapter`
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query resource change counts by source adapter: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
counts := make(map[ChangeSourceAdapter]int)
|
|
for rows.Next() {
|
|
var sourceAdapter string
|
|
var count int
|
|
if err := rows.Scan(&sourceAdapter, &count); err != nil {
|
|
return nil, fmt.Errorf("scan resource change source adapter count row: %w", err)
|
|
}
|
|
if sourceAdapter == "" {
|
|
continue
|
|
}
|
|
counts[ChangeSourceAdapter(sourceAdapter)] = count
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate resource change source adapter count rows: %w", err)
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
type sqlExecutor interface {
|
|
Exec(query string, args ...any) (sql.Result, error)
|
|
}
|
|
|
|
func recordActionAuditSQL(exec sqlExecutor, record ActionAuditRecord) error {
|
|
requestJSON, err := json.Marshal(record.Request)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal action request: %w", err)
|
|
}
|
|
planJSON, err := json.Marshal(record.Plan)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal action plan: %w", err)
|
|
}
|
|
approvalsJSON, err := json.Marshal(record.Approvals)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal action approvals: %w", err)
|
|
}
|
|
resultJSON, err := json.Marshal(record.Result)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal action result: %w", err)
|
|
}
|
|
verificationOutcomeJSON, err := json.Marshal(record.VerificationOutcome)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal verification outcome: %w", err)
|
|
}
|
|
|
|
_, err = exec.Exec(`
|
|
INSERT INTO action_audits (id, action_id, canonical_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
action_id=excluded.action_id,
|
|
canonical_id=excluded.canonical_id,
|
|
request_id=excluded.request_id,
|
|
created_at=excluded.created_at,
|
|
updated_at=excluded.updated_at,
|
|
state=excluded.state,
|
|
request_json=excluded.request_json,
|
|
plan_json=excluded.plan_json,
|
|
approvals_json=excluded.approvals_json,
|
|
result_json=excluded.result_json,
|
|
verification_outcome_json=excluded.verification_outcome_json
|
|
`, record.ID, record.ID, CanonicalResourceID(record.Request.ResourceID), record.Request.RequestID, record.CreatedAt, record.UpdatedAt, string(record.State), string(requestJSON), string(planJSON), string(approvalsJSON), string(resultJSON), string(verificationOutcomeJSON))
|
|
if err != nil {
|
|
return fmt.Errorf("insert action audit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) RecordActionAudit(record ActionAuditRecord) error {
|
|
normalized, err := NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Redact known secret shapes from operator-authored text and command
|
|
// output before persisting. The audit log is plaintext SQL; raw
|
|
// secrets pasted into a reason field or echoed in command output
|
|
// must not be retained.
|
|
record = RedactAuditRecord(normalized)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return recordActionAuditSQL(s.db, record)
|
|
}
|
|
|
|
type actionAuditScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanActionAuditRecord(scanner actionAuditScanner) (ActionAuditRecord, error) {
|
|
var record ActionAuditRecord
|
|
var stateStr string
|
|
var actionID, requestID string
|
|
var requestJSON, planJSON, approvalsJSON, resultJSON, verificationOutcomeJSON sql.NullString
|
|
if err := scanner.Scan(&record.ID, &actionID, &requestID, &record.CreatedAt, &record.UpdatedAt, &stateStr, &requestJSON, &planJSON, &approvalsJSON, &resultJSON, &verificationOutcomeJSON); err != nil {
|
|
return ActionAuditRecord{}, err
|
|
}
|
|
|
|
record.State = ActionState(stateStr)
|
|
if requestJSON.Valid && requestJSON.String != "" {
|
|
if err := json.Unmarshal([]byte(requestJSON.String), &record.Request); err != nil {
|
|
return ActionAuditRecord{}, fmt.Errorf("unmarshal action request: %w", err)
|
|
}
|
|
}
|
|
if planJSON.Valid && planJSON.String != "" {
|
|
if err := json.Unmarshal([]byte(planJSON.String), &record.Plan); err != nil {
|
|
return ActionAuditRecord{}, fmt.Errorf("unmarshal action plan: %w", err)
|
|
}
|
|
}
|
|
if approvalsJSON.Valid && approvalsJSON.String != "" {
|
|
if err := json.Unmarshal([]byte(approvalsJSON.String), &record.Approvals); err != nil {
|
|
return ActionAuditRecord{}, fmt.Errorf("unmarshal action approvals: %w", err)
|
|
}
|
|
}
|
|
if resultJSON.Valid && resultJSON.String != "" && resultJSON.String != "null" {
|
|
var result ExecutionResult
|
|
if err := json.Unmarshal([]byte(resultJSON.String), &result); err != nil {
|
|
return ActionAuditRecord{}, fmt.Errorf("unmarshal action result: %w", err)
|
|
}
|
|
record.Result = &result
|
|
}
|
|
if verificationOutcomeJSON.Valid && verificationOutcomeJSON.String != "" && verificationOutcomeJSON.String != "null" {
|
|
var outcome VerificationOutcome
|
|
if err := json.Unmarshal([]byte(verificationOutcomeJSON.String), &outcome); err != nil {
|
|
return ActionAuditRecord{}, fmt.Errorf("unmarshal verification outcome: %w", err)
|
|
}
|
|
record.VerificationOutcome = outcome
|
|
}
|
|
record.VerificationOutcome = NormalizeVerificationOutcome(record.VerificationOutcome)
|
|
record.Request.RequestID = requestID
|
|
record.Request.ResourceID = CanonicalResourceID(record.Request.ResourceID)
|
|
_ = actionID
|
|
return redactActionAuditRecordFromStore(record), nil
|
|
}
|
|
|
|
func redactActionAuditRecordFromStore(record ActionAuditRecord) ActionAuditRecord {
|
|
return RedactAuditRecord(normalizeActionAuditRecordFromStore(record))
|
|
}
|
|
|
|
func normalizeActionAuditRecordFromStore(record ActionAuditRecord) ActionAuditRecord {
|
|
normalized, err := NormalizeActionAuditRecord(record)
|
|
if err == nil {
|
|
return normalized
|
|
}
|
|
|
|
if record.Result != nil {
|
|
result := *record.Result
|
|
result.Output = strings.TrimSpace(result.Output)
|
|
result.ErrorMessage = strings.TrimSpace(result.ErrorMessage)
|
|
result.Verification = NormalizeActionVerificationResult(result.Verification)
|
|
record.Result = &result
|
|
}
|
|
record.Verification = NormalizeActionVerificationResult(record.Verification)
|
|
if record.Verification == nil && record.Result != nil {
|
|
record.Verification = cloneActionVerificationResult(record.Result.Verification)
|
|
}
|
|
if record.Verification != nil && record.Result != nil {
|
|
result := *record.Result
|
|
result.Verification = cloneActionVerificationResult(record.Verification)
|
|
record.Result = &result
|
|
}
|
|
record.VerificationOutcome = NormalizeVerificationOutcome(record.VerificationOutcome)
|
|
return record
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) GetActionAudit(actionID string) (ActionAuditRecord, bool, error) {
|
|
return s.getActionAudit(actionID)
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) GetActionAudits(canonicalID string, since time.Time, limit int) ([]ActionAuditRecord, error) {
|
|
query := `
|
|
SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json
|
|
FROM action_audits`
|
|
|
|
args := []any{}
|
|
canonicalID = CanonicalResourceID(canonicalID)
|
|
if canonicalID != "" {
|
|
query += `
|
|
WHERE canonical_id = ? AND created_at >= ?`
|
|
args = append(args, canonicalID, since)
|
|
} else {
|
|
query += `
|
|
WHERE created_at >= ?`
|
|
args = append(args, since)
|
|
}
|
|
query += `
|
|
ORDER BY created_at DESC`
|
|
if limit > 0 {
|
|
query += ` LIMIT ?`
|
|
args = append(args, limit)
|
|
}
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query action audits: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []ActionAuditRecord
|
|
for rows.Next() {
|
|
record, err := scanActionAuditRecord(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan action audit row: %w", err)
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate action audit rows: %w", err)
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
func recordActionLifecycleEventSQL(exec sqlExecutor, event ActionLifecycleEvent) error {
|
|
_, err := exec.Exec(`
|
|
INSERT INTO action_lifecycle_events (action_id, timestamp, state, actor, message)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, event.ActionID, event.Timestamp, string(event.State), event.Actor, event.Message)
|
|
if err != nil {
|
|
return fmt.Errorf("insert action lifecycle event: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) RecordActionDecision(record ActionAuditRecord, event ActionLifecycleEvent) error {
|
|
normalizedRecord, err := NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
normalizedEvent, err := NormalizeActionLifecycleEvent(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if normalizedEvent.ActionID != normalizedRecord.ID {
|
|
return fmt.Errorf("action decision event id %q does not match action audit id %q", normalizedEvent.ActionID, normalizedRecord.ID)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
current, ok, err := s.getActionAudit(normalizedRecord.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("action audit %q not found", normalizedRecord.ID)
|
|
}
|
|
if current.State != ActionStatePending {
|
|
return ErrActionNotPending
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin action decision transaction: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
if err := recordActionAuditSQL(tx, normalizedRecord); err != nil {
|
|
return err
|
|
}
|
|
if err := recordActionLifecycleEventSQL(tx, normalizedEvent); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit action decision transaction: %w", err)
|
|
}
|
|
committed = true
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) RecordActionExecutionStart(record ActionAuditRecord, event ActionLifecycleEvent) error {
|
|
normalizedRecord, err := NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
normalizedEvent, err := NormalizeActionLifecycleEvent(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if normalizedRecord.State != ActionStateExecuting || normalizedEvent.State != ActionStateExecuting {
|
|
return fmt.Errorf("action execution start must persist executing state")
|
|
}
|
|
if normalizedEvent.ActionID != normalizedRecord.ID {
|
|
return fmt.Errorf("action execution start event id %q does not match action audit id %q", normalizedEvent.ActionID, normalizedRecord.ID)
|
|
}
|
|
// Redact secret shapes from operator-authored fields and command
|
|
// output before persisting; see RecordActionAudit for the contract.
|
|
normalizedRecord = RedactAuditRecord(normalizedRecord)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
current, ok, err := s.getActionAudit(normalizedRecord.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("action audit %q not found", normalizedRecord.ID)
|
|
}
|
|
if err := ValidateActionExecutionStart(current, normalizedEvent.Timestamp); err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin action execution start transaction: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
if err := recordActionAuditSQL(tx, normalizedRecord); err != nil {
|
|
return err
|
|
}
|
|
if err := recordActionLifecycleEventSQL(tx, normalizedEvent); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit action execution start transaction: %w", err)
|
|
}
|
|
committed = true
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) RecordActionExecutionResult(record ActionAuditRecord, event ActionLifecycleEvent) error {
|
|
normalizedRecord, err := NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
normalizedEvent, err := NormalizeActionLifecycleEvent(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if normalizedRecord.State != ActionStateCompleted && normalizedRecord.State != ActionStateFailed {
|
|
return fmt.Errorf("action execution result must persist terminal execution state")
|
|
}
|
|
if normalizedEvent.State != normalizedRecord.State {
|
|
return fmt.Errorf("action execution result event state %q does not match audit state %q", normalizedEvent.State, normalizedRecord.State)
|
|
}
|
|
if normalizedEvent.ActionID != normalizedRecord.ID {
|
|
return fmt.Errorf("action execution result event id %q does not match action audit id %q", normalizedEvent.ActionID, normalizedRecord.ID)
|
|
}
|
|
// Redact secret shapes from operator-authored fields and command
|
|
// output before persisting; see RecordActionAudit for the contract.
|
|
normalizedRecord = RedactAuditRecord(normalizedRecord)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
current, ok, err := s.getActionAudit(normalizedRecord.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("action audit %q not found", normalizedRecord.ID)
|
|
}
|
|
if current.State != ActionStateExecuting {
|
|
return ErrActionNotExecuting
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin action execution result transaction: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
if err := recordActionAuditSQL(tx, normalizedRecord); err != nil {
|
|
return err
|
|
}
|
|
if err := recordActionLifecycleEventSQL(tx, normalizedEvent); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit action execution result transaction: %w", err)
|
|
}
|
|
committed = true
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) getActionAudit(actionID string) (ActionAuditRecord, bool, error) {
|
|
actionID = strings.TrimSpace(actionID)
|
|
if actionID == "" {
|
|
return ActionAuditRecord{}, false, nil
|
|
}
|
|
row := s.db.QueryRow(`
|
|
SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json
|
|
FROM action_audits
|
|
WHERE id = ?
|
|
`, actionID)
|
|
record, err := scanActionAuditRecord(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ActionAuditRecord{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return ActionAuditRecord{}, false, fmt.Errorf("query action audit: %w", err)
|
|
}
|
|
return record, true, nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) RecordActionLifecycleEvent(event ActionLifecycleEvent) error {
|
|
normalized, err := NormalizeActionLifecycleEvent(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
event = normalized
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return recordActionLifecycleEventSQL(s.db, event)
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) GetActionLifecycleEvents(actionID string, since time.Time, limit int) ([]ActionLifecycleEvent, error) {
|
|
query := `
|
|
SELECT action_id, timestamp, state, actor, message
|
|
FROM action_lifecycle_events`
|
|
|
|
args := []any{}
|
|
actionID = strings.TrimSpace(actionID)
|
|
if actionID != "" {
|
|
query += `
|
|
WHERE action_id = ? AND timestamp >= ?`
|
|
args = append(args, actionID, since)
|
|
} else {
|
|
query += `
|
|
WHERE timestamp >= ?`
|
|
args = append(args, since)
|
|
}
|
|
query += `
|
|
ORDER BY timestamp DESC`
|
|
if limit > 0 {
|
|
query += ` LIMIT ?`
|
|
args = append(args, limit)
|
|
}
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query action lifecycle events: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var events []ActionLifecycleEvent
|
|
for rows.Next() {
|
|
var event ActionLifecycleEvent
|
|
var stateStr string
|
|
if err := rows.Scan(&event.ActionID, &event.Timestamp, &stateStr, &event.Actor, &event.Message); err != nil {
|
|
return nil, fmt.Errorf("scan action lifecycle row: %w", err)
|
|
}
|
|
event.State = ActionState(stateStr)
|
|
events = append(events, event)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate action lifecycle rows: %w", err)
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) RecordExportAudit(record ExportAuditRecord) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
redactionsJSON, err := json.Marshal(record.Redactions)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal export redactions: %w", err)
|
|
}
|
|
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO export_audits (id, timestamp, actor, envelope_hash, decision, destination, redactions_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
timestamp=excluded.timestamp,
|
|
actor=excluded.actor,
|
|
envelope_hash=excluded.envelope_hash,
|
|
decision=excluded.decision,
|
|
destination=excluded.destination,
|
|
redactions_json=excluded.redactions_json
|
|
`, record.ID, record.Timestamp, record.Actor, record.EnvelopeHash, string(record.Decision), record.Destination, string(redactionsJSON))
|
|
if err != nil {
|
|
return fmt.Errorf("insert export audit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteResourceStore) GetExportAudits(since time.Time, limit int) ([]ExportAuditRecord, error) {
|
|
query := `
|
|
SELECT id, timestamp, actor, envelope_hash, decision, destination, redactions_json
|
|
FROM export_audits
|
|
WHERE timestamp >= ?
|
|
ORDER BY timestamp DESC`
|
|
|
|
args := []any{since}
|
|
if limit > 0 {
|
|
query += ` LIMIT ?`
|
|
args = append(args, limit)
|
|
}
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query export audits: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []ExportAuditRecord
|
|
for rows.Next() {
|
|
var record ExportAuditRecord
|
|
var decisionStr string
|
|
var redactionsJSON sql.NullString
|
|
if err := rows.Scan(&record.ID, &record.Timestamp, &record.Actor, &record.EnvelopeHash, &decisionStr, &record.Destination, &redactionsJSON); err != nil {
|
|
return nil, fmt.Errorf("scan export audit row: %w", err)
|
|
}
|
|
record.Decision = ExportDecision(decisionStr)
|
|
if redactionsJSON.Valid && redactionsJSON.String != "" {
|
|
if err := json.Unmarshal([]byte(redactionsJSON.String), &record.Redactions); err != nil {
|
|
return nil, fmt.Errorf("unmarshal export redactions: %w", err)
|
|
}
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate export audit rows: %w", err)
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
// GetResourceOperatorState reads the operator-set state for the given
|
|
// canonical ID. Returns (zero, false, nil) on no entry; (state, true,
|
|
// nil) on a hit; (zero, false, error) on a query failure. The found
|
|
// signal is meaningful for the API GET path which returns 404 vs
|
|
// returning the default no-state record.
|
|
func (s *SQLiteResourceStore) GetResourceOperatorState(canonicalID string) (ResourceOperatorState, bool, error) {
|
|
return getResourceOperatorStateSQL(s.db, canonicalID)
|
|
}
|
|
|
|
type resourceOperatorStateQueryRower interface {
|
|
QueryRow(query string, args ...any) *sql.Row
|
|
}
|
|
|
|
type resourceOperatorStateScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func getResourceOperatorStateSQL(queryer resourceOperatorStateQueryRower, canonicalID string) (ResourceOperatorState, bool, error) {
|
|
canonicalID = strings.TrimSpace(canonicalID)
|
|
if canonicalID == "" {
|
|
return ResourceOperatorState{}, false, nil
|
|
}
|
|
row := queryer.QueryRow(`
|
|
SELECT canonical_id, intentionally_offline, never_auto_remediate,
|
|
maintenance_start_at, maintenance_end_at, maintenance_reason,
|
|
criticality, note, set_at, set_by
|
|
FROM resource_operator_state WHERE canonical_id = ?`, canonicalID)
|
|
state, err := scanResourceOperatorState(row)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ResourceOperatorState{}, false, nil
|
|
}
|
|
return ResourceOperatorState{}, false, fmt.Errorf("query resource operator state: %w", err)
|
|
}
|
|
return state, true, nil
|
|
}
|
|
|
|
func scanResourceOperatorState(scanner resourceOperatorStateScanner) (ResourceOperatorState, error) {
|
|
var state ResourceOperatorState
|
|
var (
|
|
intentional int
|
|
neverRemediate int
|
|
startAt, endAt sql.NullTime
|
|
reason sql.NullString
|
|
criticality sql.NullString
|
|
note sql.NullString
|
|
setBy sql.NullString
|
|
)
|
|
if err := scanner.Scan(
|
|
&state.CanonicalID,
|
|
&intentional,
|
|
&neverRemediate,
|
|
&startAt,
|
|
&endAt,
|
|
&reason,
|
|
&criticality,
|
|
¬e,
|
|
&state.SetAt,
|
|
&setBy,
|
|
); err != nil {
|
|
return ResourceOperatorState{}, err
|
|
}
|
|
state.IntentionallyOffline = intentional != 0
|
|
state.NeverAutoRemediate = neverRemediate != 0
|
|
if startAt.Valid {
|
|
t := startAt.Time
|
|
state.MaintenanceStartAt = &t
|
|
}
|
|
if endAt.Valid {
|
|
t := endAt.Time
|
|
state.MaintenanceEndAt = &t
|
|
}
|
|
if reason.Valid {
|
|
state.MaintenanceReason = reason.String
|
|
}
|
|
if criticality.Valid {
|
|
state.Criticality = ResourceCriticality(criticality.String)
|
|
}
|
|
if note.Valid {
|
|
state.Note = note.String
|
|
}
|
|
if setBy.Valid {
|
|
state.SetBy = setBy.String
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
// SetResourceOperatorState upserts the state row. Validates and
|
|
// normalizes before persistence; rejects malformed records (empty
|
|
// canonical ID, mismatched maintenance window, unknown criticality)
|
|
// with ErrResourceOperatorStateInvalid so the API boundary can return
|
|
// 400 with a stable error code.
|
|
func (s *SQLiteResourceStore) SetResourceOperatorState(state ResourceOperatorState) error {
|
|
state = NormalizeResourceOperatorState(state)
|
|
if err := ValidateResourceOperatorState(state); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return setResourceOperatorStateSQL(s.db, state)
|
|
}
|
|
|
|
func setResourceOperatorStateSQL(execer sqlExecutor, state ResourceOperatorState) error {
|
|
intentional := 0
|
|
if state.IntentionallyOffline {
|
|
intentional = 1
|
|
}
|
|
neverRemediate := 0
|
|
if state.NeverAutoRemediate {
|
|
neverRemediate = 1
|
|
}
|
|
var (
|
|
startAt, endAt sql.NullTime
|
|
reason sql.NullString
|
|
criticality sql.NullString
|
|
note sql.NullString
|
|
setBy sql.NullString
|
|
)
|
|
if state.MaintenanceStartAt != nil {
|
|
startAt.Time = *state.MaintenanceStartAt
|
|
startAt.Valid = true
|
|
}
|
|
if state.MaintenanceEndAt != nil {
|
|
endAt.Time = *state.MaintenanceEndAt
|
|
endAt.Valid = true
|
|
}
|
|
if state.MaintenanceReason != "" {
|
|
reason.String = state.MaintenanceReason
|
|
reason.Valid = true
|
|
}
|
|
if state.Criticality != "" {
|
|
criticality.String = string(state.Criticality)
|
|
criticality.Valid = true
|
|
}
|
|
if state.Note != "" {
|
|
note.String = state.Note
|
|
note.Valid = true
|
|
}
|
|
if state.SetBy != "" {
|
|
setBy.String = state.SetBy
|
|
setBy.Valid = true
|
|
}
|
|
_, err := execer.Exec(`
|
|
INSERT INTO resource_operator_state (
|
|
canonical_id, intentionally_offline, never_auto_remediate,
|
|
maintenance_start_at, maintenance_end_at, maintenance_reason,
|
|
criticality, note, set_at, set_by
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(canonical_id) DO UPDATE SET
|
|
intentionally_offline = excluded.intentionally_offline,
|
|
never_auto_remediate = excluded.never_auto_remediate,
|
|
maintenance_start_at = excluded.maintenance_start_at,
|
|
maintenance_end_at = excluded.maintenance_end_at,
|
|
maintenance_reason = excluded.maintenance_reason,
|
|
criticality = excluded.criticality,
|
|
note = excluded.note,
|
|
set_at = excluded.set_at,
|
|
set_by = excluded.set_by`,
|
|
state.CanonicalID,
|
|
intentional,
|
|
neverRemediate,
|
|
startAt,
|
|
endAt,
|
|
reason,
|
|
criticality,
|
|
note,
|
|
state.SetAt,
|
|
setBy,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("upsert resource operator state: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetResourceOperatorStateWithMaintenanceLifecycle persists the operator-state
|
|
// source row and the derived maintenance-window timeline projection in a single
|
|
// SQLite transaction.
|
|
func (s *SQLiteResourceStore) SetResourceOperatorStateWithMaintenanceLifecycle(state ResourceOperatorState) (ResourceOperatorState, error) {
|
|
state = NormalizeResourceOperatorState(state)
|
|
if err := ValidateResourceOperatorState(state); err != nil {
|
|
return ResourceOperatorState{}, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return ResourceOperatorState{}, fmt.Errorf("begin resource operator state transaction: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
previous, previousFound, err := getResourceOperatorStateSQL(tx, state.CanonicalID)
|
|
if err != nil {
|
|
return ResourceOperatorState{}, err
|
|
}
|
|
if err := setResourceOperatorStateSQL(tx, state); err != nil {
|
|
return ResourceOperatorState{}, err
|
|
}
|
|
if change, ok := BuildMaintenanceWindowLifecycleChange(previous, previousFound, state, true, state.SetAt, state.SetBy); ok {
|
|
if err := recordChangeSQL(tx, change, s.resourceChangesHasTimestamp); err != nil {
|
|
return ResourceOperatorState{}, fmt.Errorf("record maintenance window lifecycle change: %w", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return ResourceOperatorState{}, fmt.Errorf("commit resource operator state transaction: %w", err)
|
|
}
|
|
committed = true
|
|
return state, nil
|
|
}
|
|
|
|
// ClearResourceOperatorState removes the row for the given canonical
|
|
// ID. Idempotent — returns nil whether or not a row existed.
|
|
func (s *SQLiteResourceStore) ClearResourceOperatorState(canonicalID string) error {
|
|
canonicalID = strings.TrimSpace(canonicalID)
|
|
if canonicalID == "" {
|
|
return nil
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return clearResourceOperatorStateSQL(s.db, canonicalID)
|
|
}
|
|
|
|
func clearResourceOperatorStateSQL(execer sqlExecutor, canonicalID string) error {
|
|
_, err := execer.Exec(`DELETE FROM resource_operator_state WHERE canonical_id = ?`, canonicalID)
|
|
if err != nil {
|
|
return fmt.Errorf("delete resource operator state: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClearResourceOperatorStateWithMaintenanceLifecycle deletes the operator-state
|
|
// source row and the derived maintenance-window timeline projection in a single
|
|
// SQLite transaction.
|
|
func (s *SQLiteResourceStore) ClearResourceOperatorStateWithMaintenanceLifecycle(canonicalID string, observedAt time.Time, actor string) error {
|
|
canonicalID = strings.TrimSpace(canonicalID)
|
|
if canonicalID == "" {
|
|
return nil
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin resource operator state clear transaction: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
previous, previousFound, err := getResourceOperatorStateSQL(tx, canonicalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := clearResourceOperatorStateSQL(tx, canonicalID); err != nil {
|
|
return err
|
|
}
|
|
current := ResourceOperatorState{CanonicalID: canonicalID}
|
|
if change, ok := BuildMaintenanceWindowLifecycleChange(previous, previousFound, current, false, observedAt, actor); ok {
|
|
if err := recordChangeSQL(tx, change, s.resourceChangesHasTimestamp); err != nil {
|
|
return fmt.Errorf("record maintenance window lifecycle change: %w", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit resource operator state clear transaction: %w", err)
|
|
}
|
|
committed = true
|
|
return nil
|
|
}
|
|
|
|
// MemoryStore is an in-memory implementation for tests.
|
|
type MemoryStore struct {
|
|
mu sync.RWMutex
|
|
links []ResourceLink
|
|
exclusions []ResourceExclusion
|
|
changes []ResourceChange
|
|
actionAudits []ActionAuditRecord
|
|
actionLifecycleEvents []ActionLifecycleEvent
|
|
exportAudits []ExportAuditRecord
|
|
resourceOperatorState map[string]ResourceOperatorState
|
|
loopReports map[string]LoopReport
|
|
}
|
|
|
|
func NewMemoryStore() *MemoryStore {
|
|
return &MemoryStore{
|
|
resourceOperatorState: make(map[string]ResourceOperatorState),
|
|
loopReports: make(map[string]LoopReport),
|
|
}
|
|
}
|
|
|
|
func (m *MemoryStore) AddLink(link ResourceLink) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
link.ResourceA = CanonicalResourceID(link.ResourceA)
|
|
link.ResourceB = CanonicalResourceID(link.ResourceB)
|
|
link.PrimaryID = CanonicalResourceID(link.PrimaryID)
|
|
m.links = append(m.links, link)
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) AddExclusion(exclusion ResourceExclusion) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
exclusion.ResourceA = CanonicalResourceID(exclusion.ResourceA)
|
|
exclusion.ResourceB = CanonicalResourceID(exclusion.ResourceB)
|
|
m.exclusions = append(m.exclusions, exclusion)
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetLinks() ([]ResourceLink, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
out := make([]ResourceLink, len(m.links))
|
|
copy(out, m.links)
|
|
return out, nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetExclusions() ([]ResourceExclusion, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
out := make([]ResourceExclusion, len(m.exclusions))
|
|
copy(out, m.exclusions)
|
|
return out, nil
|
|
}
|
|
|
|
func (m *MemoryStore) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) RecordChange(change ResourceChange) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
return m.recordChangeLocked(change)
|
|
}
|
|
|
|
func (m *MemoryStore) recordChangeLocked(change ResourceChange) error {
|
|
for _, existing := range m.changes {
|
|
if existing.ID == change.ID && change.ID != "" {
|
|
return nil
|
|
}
|
|
}
|
|
m.changes = append(m.changes, change)
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetRecentChanges(canonicalID string, since time.Time, limit int) ([]ResourceChange, error) {
|
|
return m.GetRecentChangesFiltered(canonicalID, since, limit, ResourceChangeFilters{})
|
|
}
|
|
|
|
func (m *MemoryStore) GetRecentChangesFiltered(canonicalID string, since time.Time, limit int, filters ResourceChangeFilters) ([]ResourceChange, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
canonicalID = CanonicalResourceID(canonicalID)
|
|
var out []ResourceChange
|
|
for i := len(m.changes) - 1; i >= 0; i-- {
|
|
change := m.changes[i]
|
|
if canonicalID != "" && !changeMatchesResource(change, canonicalID, filters.IncludeRelated) {
|
|
continue
|
|
}
|
|
if !since.IsZero() && change.ObservedAt.Before(since) {
|
|
continue
|
|
}
|
|
if !filters.matches(change) {
|
|
continue
|
|
}
|
|
out = append(out, change)
|
|
if limit > 0 && len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *MemoryStore) CountRecentChanges(canonicalID string, since time.Time) (int, error) {
|
|
return m.CountRecentChangesFiltered(canonicalID, since, ResourceChangeFilters{})
|
|
}
|
|
|
|
func (m *MemoryStore) CountRecentChangesFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (int, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
canonicalID = CanonicalResourceID(canonicalID)
|
|
count := 0
|
|
for _, change := range m.changes {
|
|
if canonicalID != "" && !changeMatchesResource(change, canonicalID, filters.IncludeRelated) {
|
|
continue
|
|
}
|
|
if !since.IsZero() && change.ObservedAt.Before(since) {
|
|
continue
|
|
}
|
|
if !filters.matches(change) {
|
|
continue
|
|
}
|
|
count++
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (m *MemoryStore) CountRecentChangesByKind(canonicalID string, since time.Time) (map[ChangeKind]int, error) {
|
|
return m.CountRecentChangesByKindFiltered(canonicalID, since, ResourceChangeFilters{})
|
|
}
|
|
|
|
func (m *MemoryStore) CountRecentChangesByKindFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeKind]int, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
canonicalID = CanonicalResourceID(canonicalID)
|
|
counts := make(map[ChangeKind]int)
|
|
for _, change := range m.changes {
|
|
if canonicalID != "" && !changeMatchesResource(change, canonicalID, filters.IncludeRelated) {
|
|
continue
|
|
}
|
|
if !since.IsZero() && change.ObservedAt.Before(since) {
|
|
continue
|
|
}
|
|
if !filters.matches(change) {
|
|
continue
|
|
}
|
|
counts[change.Kind]++
|
|
}
|
|
if len(counts) == 0 {
|
|
return nil, nil
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
func (m *MemoryStore) CountRecentChangesBySourceType(canonicalID string, since time.Time) (map[ChangeSourceType]int, error) {
|
|
return m.CountRecentChangesBySourceTypeFiltered(canonicalID, since, ResourceChangeFilters{})
|
|
}
|
|
|
|
func (m *MemoryStore) CountRecentChangesBySourceTypeFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceType]int, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
canonicalID = CanonicalResourceID(canonicalID)
|
|
counts := make(map[ChangeSourceType]int)
|
|
for _, change := range m.changes {
|
|
if canonicalID != "" && !changeMatchesResource(change, canonicalID, filters.IncludeRelated) {
|
|
continue
|
|
}
|
|
if !since.IsZero() && change.ObservedAt.Before(since) {
|
|
continue
|
|
}
|
|
if !filters.matches(change) {
|
|
continue
|
|
}
|
|
counts[change.SourceType]++
|
|
}
|
|
if len(counts) == 0 {
|
|
return nil, nil
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
func (m *MemoryStore) CountRecentChangesBySourceAdapter(canonicalID string, since time.Time) (map[ChangeSourceAdapter]int, error) {
|
|
return m.CountRecentChangesBySourceAdapterFiltered(canonicalID, since, ResourceChangeFilters{})
|
|
}
|
|
|
|
func (m *MemoryStore) CountRecentChangesBySourceAdapterFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceAdapter]int, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
canonicalID = CanonicalResourceID(canonicalID)
|
|
counts := make(map[ChangeSourceAdapter]int)
|
|
for _, change := range m.changes {
|
|
if canonicalID != "" && !changeMatchesResource(change, canonicalID, filters.IncludeRelated) {
|
|
continue
|
|
}
|
|
if !since.IsZero() && change.ObservedAt.Before(since) {
|
|
continue
|
|
}
|
|
if !filters.matches(change) {
|
|
continue
|
|
}
|
|
counts[change.SourceAdapter]++
|
|
}
|
|
if len(counts) == 0 {
|
|
return nil, nil
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
func buildRecentChangeCountQuery(canonicalID string, since time.Time, filters ResourceChangeFilters, selectClause string) (string, []any) {
|
|
query := selectClause
|
|
args := []any{}
|
|
conditions := []string{"observed_at >= ?"}
|
|
args = append(args, since)
|
|
canonicalID = CanonicalResourceID(canonicalID)
|
|
if canonicalID != "" {
|
|
conditions, args = appendRecentChangeResourceCondition(conditions, args, canonicalID, filters.IncludeRelated)
|
|
}
|
|
if len(filters.Kinds) > 0 {
|
|
placeholders := make([]string, 0, len(filters.Kinds))
|
|
for _, kind := range filters.Kinds {
|
|
placeholders = append(placeholders, "?")
|
|
args = append(args, string(kind))
|
|
}
|
|
conditions = append(conditions, "kind IN ("+strings.Join(placeholders, ", ")+")")
|
|
}
|
|
if len(filters.SourceTypes) > 0 {
|
|
placeholders := make([]string, 0, len(filters.SourceTypes))
|
|
for _, sourceType := range filters.SourceTypes {
|
|
placeholders = append(placeholders, "?")
|
|
args = append(args, string(sourceType))
|
|
}
|
|
conditions = append(conditions, "source_type IN ("+strings.Join(placeholders, ", ")+")")
|
|
}
|
|
if len(filters.SourceAdapters) > 0 {
|
|
placeholders := make([]string, 0, len(filters.SourceAdapters))
|
|
for _, sourceAdapter := range filters.SourceAdapters {
|
|
placeholders = append(placeholders, "?")
|
|
args = append(args, string(sourceAdapter))
|
|
}
|
|
conditions = append(conditions, "source_adapter IN ("+strings.Join(placeholders, ", ")+")")
|
|
}
|
|
query += ` WHERE ` + strings.Join(conditions, " AND ")
|
|
return query, args
|
|
}
|
|
|
|
func appendRecentChangeResourceCondition(conditions []string, args []any, canonicalID string, includeRelated bool) ([]string, []any) {
|
|
if !includeRelated {
|
|
return append(conditions, "canonical_id = ?"), append(args, canonicalID)
|
|
}
|
|
return append(conditions, `(canonical_id = ? OR EXISTS (
|
|
SELECT 1
|
|
FROM json_each(CASE WHEN json_valid(resource_changes.related_resources) THEN resource_changes.related_resources ELSE '[]' END)
|
|
WHERE TRIM(json_each.value) = ?
|
|
))`), append(args, canonicalID, canonicalID)
|
|
}
|
|
|
|
func changeMatchesResource(change ResourceChange, canonicalID string, includeRelated bool) bool {
|
|
if CanonicalResourceID(change.ResourceID) == canonicalID {
|
|
return true
|
|
}
|
|
if !includeRelated {
|
|
return false
|
|
}
|
|
for _, relatedID := range change.RelatedResources {
|
|
if CanonicalResourceID(relatedID) == canonicalID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *MemoryStore) RecordActionAudit(record ActionAuditRecord) error {
|
|
normalized, err := NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Redact secret shapes from operator-authored fields and command
|
|
// output before persisting in-memory too. The MemoryStore is used in
|
|
// tests and contract examples; redaction must apply uniformly so test
|
|
// fixtures cannot accidentally exercise an unredacted persistence
|
|
// path that production never sees.
|
|
record = RedactAuditRecord(normalized)
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for i := range m.actionAudits {
|
|
if m.actionAudits[i].ID == record.ID {
|
|
m.actionAudits[i] = record
|
|
return nil
|
|
}
|
|
}
|
|
m.actionAudits = append(m.actionAudits, record)
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetActionAudit(actionID string) (ActionAuditRecord, bool, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
actionID = strings.TrimSpace(actionID)
|
|
if actionID == "" {
|
|
return ActionAuditRecord{}, false, nil
|
|
}
|
|
for i := len(m.actionAudits) - 1; i >= 0; i-- {
|
|
if m.actionAudits[i].ID == actionID {
|
|
return m.actionAudits[i], true, nil
|
|
}
|
|
}
|
|
return ActionAuditRecord{}, false, nil
|
|
}
|
|
|
|
func (m *MemoryStore) RecordActionDecision(record ActionAuditRecord, event ActionLifecycleEvent) error {
|
|
normalizedRecord, err := NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
normalizedEvent, err := NormalizeActionLifecycleEvent(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if normalizedEvent.ActionID != normalizedRecord.ID {
|
|
return fmt.Errorf("action decision event id %q does not match action audit id %q", normalizedEvent.ActionID, normalizedRecord.ID)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
replaced := false
|
|
for i := range m.actionAudits {
|
|
if m.actionAudits[i].ID == normalizedRecord.ID {
|
|
if m.actionAudits[i].State != ActionStatePending {
|
|
return ErrActionNotPending
|
|
}
|
|
m.actionAudits[i] = normalizedRecord
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
return fmt.Errorf("action audit %q not found", normalizedRecord.ID)
|
|
}
|
|
m.actionLifecycleEvents = append(m.actionLifecycleEvents, normalizedEvent)
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) RecordActionExecutionStart(record ActionAuditRecord, event ActionLifecycleEvent) error {
|
|
normalizedRecord, err := NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
normalizedEvent, err := NormalizeActionLifecycleEvent(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if normalizedRecord.State != ActionStateExecuting || normalizedEvent.State != ActionStateExecuting {
|
|
return fmt.Errorf("action execution start must persist executing state")
|
|
}
|
|
if normalizedEvent.ActionID != normalizedRecord.ID {
|
|
return fmt.Errorf("action execution start event id %q does not match action audit id %q", normalizedEvent.ActionID, normalizedRecord.ID)
|
|
}
|
|
// Redact secret shapes from operator-authored fields and command
|
|
// output before persisting; see RecordActionAudit for the contract.
|
|
normalizedRecord = RedactAuditRecord(normalizedRecord)
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
replaced := false
|
|
for i := range m.actionAudits {
|
|
if m.actionAudits[i].ID == normalizedRecord.ID {
|
|
if err := ValidateActionExecutionStart(m.actionAudits[i], normalizedEvent.Timestamp); err != nil {
|
|
return err
|
|
}
|
|
m.actionAudits[i] = normalizedRecord
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
return fmt.Errorf("action audit %q not found", normalizedRecord.ID)
|
|
}
|
|
m.actionLifecycleEvents = append(m.actionLifecycleEvents, normalizedEvent)
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) RecordActionExecutionResult(record ActionAuditRecord, event ActionLifecycleEvent) error {
|
|
normalizedRecord, err := NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
normalizedEvent, err := NormalizeActionLifecycleEvent(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if normalizedRecord.State != ActionStateCompleted && normalizedRecord.State != ActionStateFailed {
|
|
return fmt.Errorf("action execution result must persist terminal execution state")
|
|
}
|
|
if normalizedEvent.State != normalizedRecord.State {
|
|
return fmt.Errorf("action execution result event state %q does not match audit state %q", normalizedEvent.State, normalizedRecord.State)
|
|
}
|
|
if normalizedEvent.ActionID != normalizedRecord.ID {
|
|
return fmt.Errorf("action execution result event id %q does not match action audit id %q", normalizedEvent.ActionID, normalizedRecord.ID)
|
|
}
|
|
// Redact secret shapes from operator-authored fields and command
|
|
// output before persisting; see RecordActionAudit for the contract.
|
|
normalizedRecord = RedactAuditRecord(normalizedRecord)
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
replaced := false
|
|
for i := range m.actionAudits {
|
|
if m.actionAudits[i].ID == normalizedRecord.ID {
|
|
if m.actionAudits[i].State != ActionStateExecuting {
|
|
return ErrActionNotExecuting
|
|
}
|
|
m.actionAudits[i] = normalizedRecord
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
return fmt.Errorf("action audit %q not found", normalizedRecord.ID)
|
|
}
|
|
m.actionLifecycleEvents = append(m.actionLifecycleEvents, normalizedEvent)
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetActionAudits(canonicalID string, since time.Time, limit int) ([]ActionAuditRecord, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
canonicalID = CanonicalResourceID(canonicalID)
|
|
var out []ActionAuditRecord
|
|
for i := len(m.actionAudits) - 1; i >= 0; i-- {
|
|
record := m.actionAudits[i]
|
|
if canonicalID != "" && CanonicalResourceID(record.Request.ResourceID) != canonicalID {
|
|
continue
|
|
}
|
|
if !since.IsZero() && record.CreatedAt.Before(since) {
|
|
continue
|
|
}
|
|
out = append(out, record)
|
|
if limit > 0 && len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *MemoryStore) RecordActionLifecycleEvent(event ActionLifecycleEvent) error {
|
|
normalized, err := NormalizeActionLifecycleEvent(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
event = normalized
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.actionLifecycleEvents = append(m.actionLifecycleEvents, event)
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetActionLifecycleEvents(actionID string, since time.Time, limit int) ([]ActionLifecycleEvent, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var out []ActionLifecycleEvent
|
|
for i := len(m.actionLifecycleEvents) - 1; i >= 0; i-- {
|
|
event := m.actionLifecycleEvents[i]
|
|
if actionID != "" && event.ActionID != actionID {
|
|
continue
|
|
}
|
|
if !since.IsZero() && event.Timestamp.Before(since) {
|
|
continue
|
|
}
|
|
out = append(out, event)
|
|
if limit > 0 && len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *MemoryStore) RecordExportAudit(record ExportAuditRecord) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.exportAudits = append(m.exportAudits, record)
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetExportAudits(since time.Time, limit int) ([]ExportAuditRecord, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var out []ExportAuditRecord
|
|
for i := len(m.exportAudits) - 1; i >= 0; i-- {
|
|
record := m.exportAudits[i]
|
|
if !since.IsZero() && record.Timestamp.Before(since) {
|
|
continue
|
|
}
|
|
out = append(out, record)
|
|
if limit > 0 && len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetResourceOperatorState returns the operator-set state for the given
|
|
// canonical resource ID. The found bool distinguishes "no entry" (the
|
|
// default no-state posture) from "explicit empty entry the operator
|
|
// cleared" — both have the same effective behavior but the latter may
|
|
// be surfaced differently on the audit timeline.
|
|
func (m *MemoryStore) GetResourceOperatorState(canonicalID string) (ResourceOperatorState, bool, error) {
|
|
canonicalID = strings.TrimSpace(canonicalID)
|
|
if canonicalID == "" {
|
|
return ResourceOperatorState{}, false, nil
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
state, ok := m.resourceOperatorState[canonicalID]
|
|
if !ok {
|
|
return ResourceOperatorState{}, false, nil
|
|
}
|
|
return state, true, nil
|
|
}
|
|
|
|
// SetResourceOperatorState upserts the supplied state, validating it
|
|
// before persistence. Callers should pass a fully-populated record;
|
|
// per-field merge is not supported, so reading the existing state and
|
|
// modifying it is the operator's responsibility.
|
|
func (m *MemoryStore) SetResourceOperatorState(state ResourceOperatorState) error {
|
|
state = NormalizeResourceOperatorState(state)
|
|
if err := ValidateResourceOperatorState(state); err != nil {
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.resourceOperatorState == nil {
|
|
m.resourceOperatorState = make(map[string]ResourceOperatorState)
|
|
}
|
|
m.resourceOperatorState[state.CanonicalID] = state
|
|
return nil
|
|
}
|
|
|
|
// SetResourceOperatorStateWithMaintenanceLifecycle updates the in-memory
|
|
// source row and derived timeline projection under one lock.
|
|
func (m *MemoryStore) SetResourceOperatorStateWithMaintenanceLifecycle(state ResourceOperatorState) (ResourceOperatorState, error) {
|
|
state = NormalizeResourceOperatorState(state)
|
|
if err := ValidateResourceOperatorState(state); err != nil {
|
|
return ResourceOperatorState{}, err
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.resourceOperatorState == nil {
|
|
m.resourceOperatorState = make(map[string]ResourceOperatorState)
|
|
}
|
|
previous, previousFound := m.resourceOperatorState[state.CanonicalID]
|
|
m.resourceOperatorState[state.CanonicalID] = state
|
|
if change, ok := BuildMaintenanceWindowLifecycleChange(previous, previousFound, state, true, state.SetAt, state.SetBy); ok {
|
|
if err := m.recordChangeLocked(change); err != nil {
|
|
return ResourceOperatorState{}, fmt.Errorf("record maintenance window lifecycle change: %w", err)
|
|
}
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
// ClearResourceOperatorState removes any operator-set state for the
|
|
// given canonical ID. Returns nil whether or not an entry was present —
|
|
// the operation is idempotent so the API surface can issue
|
|
// fire-and-forget DELETE without round-tripping the existence check.
|
|
func (m *MemoryStore) ClearResourceOperatorState(canonicalID string) error {
|
|
canonicalID = strings.TrimSpace(canonicalID)
|
|
if canonicalID == "" {
|
|
return nil
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.resourceOperatorState, canonicalID)
|
|
return nil
|
|
}
|
|
|
|
// ClearResourceOperatorStateWithMaintenanceLifecycle clears the in-memory
|
|
// source row and derived timeline projection under one lock.
|
|
func (m *MemoryStore) ClearResourceOperatorStateWithMaintenanceLifecycle(canonicalID string, observedAt time.Time, actor string) error {
|
|
canonicalID = strings.TrimSpace(canonicalID)
|
|
if canonicalID == "" {
|
|
return nil
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
previous, previousFound := m.resourceOperatorState[canonicalID]
|
|
delete(m.resourceOperatorState, canonicalID)
|
|
current := ResourceOperatorState{CanonicalID: canonicalID}
|
|
if change, ok := BuildMaintenanceWindowLifecycleChange(previous, previousFound, current, false, observedAt, actor); ok {
|
|
if err := m.recordChangeLocked(change); err != nil {
|
|
return fmt.Errorf("record maintenance window lifecycle change: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizePair(a, b string) (string, string) {
|
|
a = CanonicalResourceID(a)
|
|
b = CanonicalResourceID(b)
|
|
if a > b {
|
|
return b, a
|
|
}
|
|
return a, b
|
|
}
|
|
|
|
func sanitizeOrgID(orgID string) string {
|
|
orgID = strings.TrimSpace(orgID)
|
|
if orgID == "" {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.Grow(len(orgID))
|
|
lastWasUnderscore := false
|
|
for _, r := range orgID {
|
|
if (r >= 'a' && r <= 'z') ||
|
|
(r >= 'A' && r <= 'Z') ||
|
|
(r >= '0' && r <= '9') ||
|
|
r == '-' ||
|
|
r == '_' {
|
|
if b.Len() >= maxOrgIDLength {
|
|
break
|
|
}
|
|
b.WriteRune(r)
|
|
lastWasUnderscore = false
|
|
continue
|
|
}
|
|
|
|
if !lastWasUnderscore {
|
|
if b.Len() >= maxOrgIDLength {
|
|
break
|
|
}
|
|
b.WriteByte('_')
|
|
lastWasUnderscore = true
|
|
}
|
|
}
|
|
|
|
sanitized := strings.Trim(b.String(), "_-")
|
|
if len(sanitized) > maxOrgIDLength {
|
|
sanitized = sanitized[:maxOrgIDLength]
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
func legacyResourceStoreFileName(orgID string) string {
|
|
orgPart := sanitizeOrgID(orgID)
|
|
if orgPart != "" && orgPart != defaultOrgID {
|
|
return fmt.Sprintf("unified_resources_%s.db", orgPart)
|
|
}
|
|
return resourceDBFileName
|
|
}
|