projection: begin the exact-path public epoch

The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally.

Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
Fimeg 2026-09-08 21:59:33 -04:00
commit 67e26be2d9
838 changed files with 157302 additions and 0 deletions

View file

@ -0,0 +1,243 @@
package models
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/gofrs/uuid/v5"
)
// Agent represents a registered update agent
type Agent struct {
ID uuid.UUID `json:"id" db:"id"`
Hostname string `json:"hostname" db:"hostname"`
OSType string `json:"os_type" db:"os_type"`
OSVersion string `json:"os_version" db:"os_version"`
OSArchitecture string `json:"os_architecture" db:"os_architecture"`
AgentVersion string `json:"agent_version" db:"agent_version"` // Version at registration
CurrentVersion string `json:"current_version" db:"current_version"` // Current running version
UpdateAvailable bool `json:"update_available" db:"update_available"` // Whether update is available
LastVersionCheck time.Time `json:"last_version_check" db:"last_version_check"` // Last time version was checked
MachineID *string `json:"machine_id,omitempty" db:"machine_id"` // Unique machine identifier
PublicKeyFingerprint *string `json:"public_key_fingerprint,omitempty" db:"public_key_fingerprint"` // Public key fingerprint
IsUpdating bool `json:"is_updating" db:"is_updating"` // Whether agent is currently updating
UpdatingToVersion *string `json:"updating_to_version,omitempty" db:"updating_to_version"` // Target version for ongoing update
UpdateInitiatedAt *time.Time `json:"update_initiated_at,omitempty" db:"update_initiated_at"` // When update process started
LastSeen time.Time `json:"last_seen" db:"last_seen"`
Status string `json:"status" db:"status"`
Metadata JSONB `json:"metadata" db:"metadata"`
RebootRequired bool `json:"reboot_required" db:"reboot_required"`
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
DockerVersion string `json:"docker_version" db:"docker_version"`
DeviceType string `json:"device_type" db:"device_type"` // Agent-detected form factor (DEVICE-001)
DeviceTypeManual *string `json:"device_type_manual,omitempty" db:"device_type_manual"` // Operator override, nil = use auto (SERVER-002)
DeviceModel *string `json:"device_model,omitempty" db:"device_model"` // Hardware model string
OSDistro *string `json:"os_distro,omitempty" db:"os_distro"` // Distro ID from /etc/os-release
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ValidDeviceType reports whether s is a recognized device form factor.
// Mirrors the CHECK constraint from migration 062 — validate before writing
// so a bad agent report degrades to the default instead of failing the row.
func ValidDeviceType(s string) bool {
switch s {
case "server", "desktop", "laptop", "phone", "tablet", "vm", "container":
return true
}
return false
}
// EffectiveDeviceType returns the operator override when set, else the
// agent-detected type. All display and policy decisions use this.
func (a *Agent) EffectiveDeviceType() string {
if a.DeviceTypeManual != nil && *a.DeviceTypeManual != "" {
return *a.DeviceTypeManual
}
return a.DeviceType
}
// AgentWithLastScan extends Agent with last scan information
type AgentWithLastScan struct {
ID uuid.UUID `json:"id" db:"id"`
Hostname string `json:"hostname" db:"hostname"`
OSType string `json:"os_type" db:"os_type"`
OSVersion string `json:"os_version" db:"os_version"`
OSArchitecture string `json:"os_architecture" db:"os_architecture"`
AgentVersion string `json:"agent_version" db:"agent_version"` // Version at registration
CurrentVersion string `json:"current_version" db:"current_version"` // Current running version
UpdateAvailable bool `json:"update_available" db:"update_available"` // Whether update is available
LastVersionCheck time.Time `json:"last_version_check" db:"last_version_check"` // Last time version was checked
MachineID *string `json:"machine_id,omitempty" db:"machine_id"` // Unique machine identifier
PublicKeyFingerprint *string `json:"public_key_fingerprint,omitempty" db:"public_key_fingerprint"` // Public key fingerprint
IsUpdating bool `json:"is_updating" db:"is_updating"` // Whether agent is currently updating
UpdatingToVersion *string `json:"updating_to_version,omitempty" db:"updating_to_version"` // Target version for ongoing update
UpdateInitiatedAt *time.Time `json:"update_initiated_at,omitempty" db:"update_initiated_at"` // When update process started
LastSeen time.Time `json:"last_seen" db:"last_seen"`
Status string `json:"status" db:"status"`
Metadata JSONB `json:"metadata" db:"metadata"`
RebootRequired bool `json:"reboot_required" db:"reboot_required"`
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
DockerVersion string `json:"docker_version" db:"docker_version"`
DeviceType string `json:"device_type" db:"device_type"`
DeviceTypeManual *string `json:"device_type_manual,omitempty" db:"device_type_manual"`
DeviceModel *string `json:"device_model,omitempty" db:"device_model"`
OSDistro *string `json:"os_distro,omitempty" db:"os_distro"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
LastScan *time.Time `json:"last_scan" db:"last_scan"`
}
// EffectiveDeviceType mirrors Agent.EffectiveDeviceType.
func (a *AgentWithLastScan) EffectiveDeviceType() string {
if a.DeviceTypeManual != nil && *a.DeviceTypeManual != "" {
return *a.DeviceTypeManual
}
return a.DeviceType
}
// MarshalJSON adds the computed effective_device_type to every serialized
// agent, so list and detail responses carry it without per-handler wiring.
func (a Agent) MarshalJSON() ([]byte, error) {
type alias Agent
return json.Marshal(struct {
alias
EffectiveDeviceType string `json:"effective_device_type"`
}{alias(a), a.EffectiveDeviceType()})
}
// MarshalJSON mirrors Agent.MarshalJSON.
func (a AgentWithLastScan) MarshalJSON() ([]byte, error) {
type alias AgentWithLastScan
return json.Marshal(struct {
alias
EffectiveDeviceType string `json:"effective_device_type"`
}{alias(a), a.EffectiveDeviceType()})
}
// AgentSpecs represents system specifications for an agent
type AgentSpecs struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
CPUModel string `json:"cpu_model" db:"cpu_model"`
CPUCores int `json:"cpu_cores" db:"cpu_cores"`
MemoryTotalMB int `json:"memory_total_mb" db:"memory_total_mb"`
DiskTotalGB int `json:"disk_total_gb" db:"disk_total_gb"`
DiskFreeGB int `json:"disk_free_gb" db:"disk_free_gb"`
NetworkInterfaces JSONB `json:"network_interfaces" db:"network_interfaces"`
DockerInstalled bool `json:"docker_installed" db:"docker_installed"`
DockerVersion string `json:"docker_version" db:"docker_version"`
PackageManagers StringArray `json:"package_managers" db:"package_managers"`
CollectedAt time.Time `json:"collected_at" db:"collected_at"`
}
// AgentRegistrationRequest is the payload for agent registration
type AgentRegistrationRequest struct {
Hostname string `json:"hostname" binding:"required"`
OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version" binding:"required"`
RegistrationToken string `json:"registration_token"` // Optional, for fallback method
MachineID string `json:"machine_id"` // Unique machine identifier
PublicKeyFingerprint string `json:"public_key_fingerprint"` // Embedded public key fingerprint
Metadata map[string]string `json:"metadata"`
AvailableScanners []string `json:"available_scanners"` // Platform-specific scanners (apt, dnf, winget, windows, docker)
DeviceType string `json:"device_type"` // Auto-detected form factor (DEVICE-001)
DeviceModel string `json:"device_model"` // Hardware model string
OSDistro string `json:"os_distro"` // Distro ID from /etc/os-release
}
// AgentRegistrationResponse is returned after successful registration
type AgentRegistrationResponse struct {
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"` // Short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
Config map[string]interface{} `json:"config"`
}
// TokenRenewalRequest is the payload for token renewal using refresh token
type TokenRenewalRequest struct {
AgentID uuid.UUID `json:"agent_id" binding:"required"`
RefreshToken string `json:"refresh_token" binding:"required"`
AgentVersion string `json:"agent_version,omitempty"` // Optional: agent's current version for upgrade tracking
}
// TokenRenewalResponse is returned after successful token renewal
type TokenRenewalResponse struct {
Token string `json:"token"` // New short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Rotated refresh token (migration 045) — agent must persist it
}
// UTCTime is a time.Time that marshals to ISO format with UTC timezone
type UTCTime time.Time
// MarshalJSON implements json.Marshaler for UTCTime
func (t UTCTime) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Time(t).UTC().Format("2006-01-02T15:04:05.000Z"))
}
// UnmarshalJSON implements json.Unmarshaler for UTCTime
func (t *UTCTime) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, err := time.Parse("2006-01-02T15:04:05.000Z", s)
if err != nil {
return err
}
*t = UTCTime(parsed)
return nil
}
// JSONB type for PostgreSQL JSONB columns
type JSONB map[string]interface{}
// Value implements driver.Valuer for database storage
func (j JSONB) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
return json.Marshal(j)
}
// Scan implements sql.Scanner for database retrieval
func (j *JSONB) Scan(value interface{}) error {
if value == nil {
*j = nil
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, j)
}
// StringArray type for PostgreSQL text[] columns
type StringArray []string
// Value implements driver.Valuer
func (s StringArray) Value() (driver.Value, error) {
if s == nil {
return nil, nil
}
return json.Marshal(s)
}
// Scan implements sql.Scanner
func (s *StringArray) Scan(value interface{}) error {
if value == nil {
*s = nil
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, s)
}

View file

@ -0,0 +1,68 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// AgentUpdatePackage represents a signed agent binary package
type AgentUpdatePackage struct {
ID uuid.UUID `json:"id" db:"id"`
Version string `json:"version" db:"version"`
Platform string `json:"platform" db:"platform"`
Architecture string `json:"architecture" db:"architecture"`
BinaryPath string `json:"binary_path" db:"binary_path"`
Signature string `json:"signature" db:"signature"`
Checksum string `json:"checksum" db:"checksum"`
FileSize int64 `json:"file_size" db:"file_size"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
CreatedBy string `json:"created_by" db:"created_by"`
IsActive bool `json:"is_active" db:"is_active"`
}
// AgentUpdateRequest represents a request to update an agent
type AgentUpdateRequest struct {
AgentID uuid.UUID `json:"agent_id,omitempty"` // Optional when agent ID is in URL path
Version string `json:"version" binding:"required"`
Platform string `json:"platform" binding:"required"`
Scheduled *string `json:"scheduled_at,omitempty"`
Nonce string `json:"nonce" binding:"required"` // Required security nonce to prevent replay attacks
}
// BulkAgentUpdateRequest represents a bulk update request
type BulkAgentUpdateRequest struct {
AgentIDs []uuid.UUID `json:"agent_ids" binding:"required"`
Version string `json:"version" binding:"required"`
Platform string `json:"platform" binding:"required"`
Scheduled *string `json:"scheduled_at,omitempty"`
}
// AgentUpdateResponse represents the response for an update request
type AgentUpdateResponse struct {
Message string `json:"message"`
UpdateID string `json:"update_id,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
Signature string `json:"signature,omitempty"`
Checksum string `json:"checksum,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
EstimatedTime int `json:"estimated_time_seconds,omitempty"`
}
// SignatureVerificationRequest represents a request to verify an agent's binary signature
type SignatureVerificationRequest struct {
AgentID uuid.UUID `json:"agent_id" binding:"required"`
BinaryPath string `json:"binary_path" binding:"required"`
MachineID string `json:"machine_id" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
Signature string `json:"signature" binding:"required"`
}
// SignatureVerificationResponse represents the response for signature verification
type SignatureVerificationResponse struct {
Valid bool `json:"valid"`
AgentID string `json:"agent_id"`
MachineID string `json:"machine_id"`
Fingerprint string `json:"fingerprint"`
Message string `json:"message"`
}

View file

@ -0,0 +1,171 @@
package models
import (
"errors"
"time"
"github.com/gofrs/uuid/v5"
)
// AgentCommand represents a command to be executed by an agent
type AgentCommand struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
CommandType string `json:"command_type" db:"command_type"`
Params JSONB `json:"params" db:"params"`
Status string `json:"status" db:"status"`
Source string `json:"source" db:"source"`
Signature string `json:"signature,omitempty" db:"signature"`
KeyID string `json:"key_id,omitempty" db:"key_id"`
SignedAt *time.Time `json:"signed_at,omitempty" db:"signed_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
IdempotencyKey *string `json:"idempotency_key,omitempty" db:"idempotency_key"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
ReceivedAt *time.Time `json:"received_at,omitempty" db:"received_at"`
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
Result JSONB `json:"result,omitempty" db:"result"`
RetriedFromID *uuid.UUID `json:"retried_from_id,omitempty" db:"retried_from_id"`
RetryCount int `json:"retry_count" db:"retry_count"`
}
// Validate checks if the command has all required fields
func (c *AgentCommand) Validate() error {
if c.ID == uuid.Nil {
return ErrCommandIDRequired
}
if c.AgentID == uuid.Nil {
return ErrAgentIDRequired
}
if c.CommandType == "" {
return ErrCommandTypeRequired
}
if c.Status == "" {
return ErrStatusRequired
}
if c.Source != "manual" && c.Source != "system" {
return ErrInvalidSource
}
return nil
}
// IsTerminal returns true if the command is in a terminal state
func (c *AgentCommand) IsTerminal() bool {
return c.Status == "completed" || c.Status == "failed" || c.Status == "cancelled"
}
// CanRetry returns true if the command can be retried
func (c *AgentCommand) CanRetry() bool {
return c.Status == "failed" && c.RetriedFromID == nil
}
// Predefined errors for validation
var (
ErrCommandIDRequired = errors.New("command ID cannot be zero UUID")
ErrAgentIDRequired = errors.New("agent ID is required")
ErrCommandTypeRequired = errors.New("command type is required")
ErrStatusRequired = errors.New("status is required")
ErrInvalidSource = errors.New("source must be 'manual' or 'system'")
)
// CommandsResponse is returned when an agent checks in for commands
type CommandsResponse struct {
Commands []CommandItem `json:"commands"`
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server transitioned sent->received this turn
ConfirmedCommandIDs []string `json:"confirmed_command_ids,omitempty"` // Command IDs server has confirmed completed via ReportLog
}
// RapidPollingConfig contains rapid polling configuration for the agent
type RapidPollingConfig struct {
Enabled bool `json:"enabled"`
Until string `json:"until"` // ISO 8601 timestamp
}
// CommandItem represents a command in the response
type CommandItem struct {
ID string `json:"id"`
Type string `json:"type"`
Params JSONB `json:"params"`
Signature string `json:"signature,omitempty"`
KeyID string `json:"key_id,omitempty"`
SignedAt *time.Time `json:"signed_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
AgentID string `json:"agent_id,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
// Command types
const (
CommandTypeCollectSpecs = "collect_specs"
CommandTypeInstallUpdate = "install_updates"
CommandTypeDryRunUpdate = "dry_run_update"
CommandTypeConfirmDependencies = "confirm_dependencies"
CommandTypeRollback = "rollback_update"
CommandTypeUpdateAgent = "update_agent"
CommandTypeEnableHeartbeat = "enable_heartbeat"
CommandTypeDisableHeartbeat = "disable_heartbeat"
CommandTypeReboot = "reboot"
CommandTypeCaptureScreenshot = "capture_screenshot"
CommandTypeScanProcesses = "scan_processes"
)
// Command statuses
const (
CommandStatusPending = "pending"
CommandStatusSent = "sent"
CommandStatusReceived = "received" // Agent confirmed receipt; not yet completed
CommandStatusCompleted = "completed"
CommandStatusFailed = "failed"
CommandStatusTimedOut = "timed_out"
CommandStatusCancelled = "cancelled"
CommandStatusRunning = "running"
)
// Command sources
const (
CommandSourceManual = "manual" // User-initiated via UI
CommandSourceSystem = "system" // Auto-triggered by system operations
)
// RequiresRapidPolling returns true when a command needs the agent in
// rapid-polling mode for fast turnaround. The dispatch chokepoint
// (AgentHandler.signAndCreateCommand) auto-queues enable_heartbeat
// (Source=system) ahead of these. A new command type that needs rapid polling
// must be added here; do not paper over it at the per-handler call site.
func RequiresRapidPolling(commandType string) bool {
switch commandType {
case CommandTypeUpdateAgent,
CommandTypeInstallUpdate,
CommandTypeDryRunUpdate,
CommandTypeConfirmDependencies,
CommandTypeRollback,
CommandTypeReboot:
return true
}
return false
}
// ActiveCommandInfo represents information about an active command for UI display
type ActiveCommandInfo struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
CommandType string `json:"command_type" db:"command_type"`
Params JSONB `json:"params" db:"params"`
Status string `json:"status" db:"status"`
Source string `json:"source" db:"source"`
Signature string `json:"signature,omitempty" db:"signature"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
Result JSONB `json:"result,omitempty" db:"result"`
AgentHostname string `json:"agent_hostname" db:"agent_hostname"`
PackageName string `json:"package_name" db:"package_name"`
PackageType string `json:"package_type" db:"package_type"`
RetriedFromID *uuid.UUID `json:"retried_from_id,omitempty" db:"retried_from_id"`
IsRetry bool `json:"is_retry" db:"is_retry"`
HasBeenRetried bool `json:"has_been_retried" db:"has_been_retried"`
RetryCount int `json:"retry_count" db:"retry_count"`
}

View file

@ -0,0 +1,227 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// DockerPort represents a port mapping in a Docker container
type DockerPort struct {
ContainerPort int `json:"container_port"`
HostPort *int `json:"host_port,omitempty"`
Protocol string `json:"protocol"`
HostIP string `json:"host_ip"`
}
// DockerContainer represents a Docker container with its image information
type DockerContainer struct {
ID string `json:"id"`
ContainerID string `json:"container_id"`
Image string `json:"image"`
Tag string `json:"tag"`
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name,omitempty"`
AgentHostname string `json:"agent_hostname,omitempty"`
Status string `json:"status"`
Severity string `json:"severity,omitempty"`
State string `json:"state,omitempty"`
Ports []DockerPort `json:"ports,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UpdateAvailable bool `json:"update_available"`
CurrentVersion string `json:"current_version,omitempty"`
AvailableVersion string `json:"available_version,omitempty"`
}
// DockerContainerListResponse represents the response for container listing
type DockerContainerListResponse struct {
Containers []DockerContainer `json:"containers"`
Images []DockerImage `json:"images"`
TotalImages int `json:"total_images"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// DockerImage represents a Docker image
type DockerImage struct {
ID string `json:"id"`
Repository string `json:"repository"`
Tag string `json:"tag"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name,omitempty"`
Severity string `json:"severity,omitempty"`
UpdateAvailable bool `json:"update_available"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version"`
}
// DockerStats represents Docker statistics across all agents
type DockerStats struct {
TotalContainers int `json:"total_containers"`
TotalImages int `json:"total_images"`
UpdatesAvailable int `json:"updates_available"`
PendingApproval int `json:"pending_approval"`
CriticalUpdates int `json:"critical_updates"`
AgentsWithContainers int `json:"agents_with_containers"`
}
// DockerUpdateRequest represents a request to update Docker images
type DockerUpdateRequest struct {
ContainerID string `json:"container_id" binding:"required"`
ImageID string `json:"image_id" binding:"required"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
}
// BulkDockerUpdateRequest represents a bulk update request for Docker images
type BulkDockerUpdateRequest struct {
Updates []struct {
ContainerID string `json:"container_id" binding:"required"`
ImageID string `json:"image_id" binding:"required"`
} `json:"updates" binding:"required"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
}
// AgentDockerImage represents a Docker image as sent by the agent.
// Fields match the agent's client.DockerReportItem JSON tags so the
// payload deserializes correctly.
type AgentDockerImage struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version"`
Severity string `json:"severity"`
RepositorySource string `json:"repository_source"`
Metadata map[string]interface{} `json:"metadata"`
}
// DockerReportRequest is sent by agents when reporting Docker image updates
type DockerReportRequest struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Images []AgentDockerImage `json:"images"`
Containers []AgentDockerContainer `json:"containers,omitempty"`
Stacks []AgentDockerStack `json:"stacks,omitempty"`
EngineVersion string `json:"engine_version,omitempty"`
}
// AgentDockerContainer represents a container reported by an agent.
type AgentDockerContainer struct {
ContainerID string `json:"container_id"`
Name string `json:"name"`
Image string `json:"image"`
ImageID string `json:"image_id"`
State string `json:"state"`
Health string `json:"health"`
StackName string `json:"stack_name"`
Ports string `json:"ports"`
CreatedAt int64 `json:"created_at"`
Labels map[string]string `json:"labels"`
}
// AgentDockerStack represents a compose stack derived from container labels.
type AgentDockerStack struct {
Name string `json:"name"`
ContainerCount int `json:"container_count"`
RunningCount int `json:"running_count"`
}
// StoredDockerContainer is a container row persisted in the docker_containers table.
type StoredDockerContainer struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
ContainerID string `json:"container_id" db:"container_id"`
Name string `json:"name" db:"name"`
Image string `json:"image" db:"image"`
ImageID string `json:"image_id" db:"image_id"`
State string `json:"state" db:"state"`
Health string `json:"health" db:"health"`
StackName string `json:"stack_name" db:"stack_name"`
Ports string `json:"ports" db:"ports"`
CreatedAt int64 `json:"created_at_epoch" db:"created_at_epoch"`
Labels JSONB `json:"labels" db:"labels"`
LastSeenAt time.Time `json:"last_seen_at" db:"last_seen_at"`
}
// StoredDockerStack is a stack row persisted in the docker_stacks table.
type StoredDockerStack struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
Name string `json:"name" db:"name"`
ContainerCount int `json:"container_count" db:"container_count"`
RunningCount int `json:"running_count" db:"running_count"`
LastSeenAt time.Time `json:"last_seen_at" db:"last_seen_at"`
}
// DockerImageInfo represents detailed Docker image information for API responses
type DockerImageInfo struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
ImageID string `json:"image_id"`
RepositorySource string `json:"repository_source"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
HasUpdate bool `json:"has_update"`
LatestImageID string `json:"latest_image_id"`
Severity string `json:"severity"`
Labels map[string]string `json:"labels"`
Metadata map[string]interface{} `json:"metadata"`
PackageType string `json:"package_type"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version"`
EventType string `json:"event_type"`
CreatedAtTime time.Time `json:"created_at_time"`
Ports []DockerPort `json:"ports,omitempty"`
ContainerName string `json:"container_name,omitempty"`
}
// DockerImageUpdate represents a Docker image update from agent scans
type DockerImageUpdate struct {
PackageType string `json:"package_type"` // "docker_image"
PackageName string `json:"package_name"` // image name:tag
CurrentVersion string `json:"current_version"` // current image ID
AvailableVersion string `json:"available_version"` // latest image ID
Severity string `json:"severity"` // "low", "moderate", "high", "critical"
RepositorySource string `json:"repository_source"` // registry URL
Metadata map[string]string `json:"metadata"`
}
// StoredDockerImage represents a Docker image update in the database
type StoredDockerImage struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
CurrentVersion string `json:"current_version" db:"current_version"`
AvailableVersion string `json:"available_version" db:"available_version"`
Severity string `json:"severity" db:"severity"`
RepositorySource string `json:"repository_source" db:"repository_source"`
Metadata JSONB `json:"metadata" db:"metadata"`
EventType string `json:"event_type" db:"event_type"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// DockerFilter represents filtering options for Docker image queries
type DockerFilter struct {
AgentID *uuid.UUID `json:"agent_id,omitempty"`
ImageName *string `json:"image_name,omitempty"`
Registry *string `json:"registry,omitempty"`
Severity *string `json:"severity,omitempty"`
HasUpdates *bool `json:"has_updates,omitempty"`
Limit *int `json:"limit,omitempty"`
Offset *int `json:"offset,omitempty"`
}
// DockerResult represents the result of a Docker image query
type DockerResult struct {
Images []StoredDockerImage `json:"images"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}

View file

@ -0,0 +1,58 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// AgentInventoryItem represents a current-state inventory record for an agent.
// Distinct from current_package_state (which tracks available updates) and
// metrics (which track point-in-time measurements).
type AgentInventoryItem struct {
ID uuid.UUID `db:"id" json:"id"`
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
InventoryEcosystem string `db:"inventory_ecosystem" json:"inventory_ecosystem"`
ItemName string `db:"item_name" json:"item_name"`
ItemVersion string `db:"item_version" json:"item_version"`
Description string `db:"description" json:"description,omitempty"`
Arch string `db:"arch" json:"arch,omitempty"`
InstallTime *time.Time `db:"install_time" json:"install_time,omitempty"`
SizeBytes int64 `db:"size_bytes" json:"size_bytes,omitempty"`
Vendor string `db:"vendor" json:"vendor,omitempty"`
Metadata JSONB `db:"metadata" json:"metadata,omitempty"`
LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"`
FirstSeenAt time.Time `db:"first_seen_at" json:"first_seen_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// InventoryReportRequest is the JSON body for agent inventory reports.
type InventoryReportRequest struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Ecosystem string `json:"inventory_ecosystem"`
Items []AgentInventoryItemRequest `json:"items"`
ScanSucceeded bool `json:"scan_succeeded"`
}
// AgentInventoryItemRequest is a single inventory item from the agent wire format.
type AgentInventoryItemRequest struct {
Ecosystem string `json:"inventory_ecosystem"`
ItemName string `json:"item_name"`
ItemVersion string `json:"item_version"`
Description string `json:"description,omitempty"`
Arch string `json:"arch,omitempty"`
InstallTime string `json:"install_time,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Vendor string `json:"vendor,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// InventoryFilter supports paginated, filtered queries for inventory data.
type InventoryFilter struct {
AgentID *uuid.UUID
InventoryEcosystem *string
Limit *int
Offset *int
}

View file

@ -0,0 +1,29 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// MaintenanceWindow represents a recurring weekly maintenance window
// for gating update installations. DayOfWeek uses Go convention (0=Sunday).
type MaintenanceWindow struct {
ID uuid.UUID `json:"id" db:"id"`
DayOfWeek int `json:"day_of_week" db:"day_of_week"` // 0=Sunday, 6=Saturday
StartTime string `json:"start_time" db:"start_time"` // HH:MM (TIME column)
EndTime string `json:"end_time" db:"end_time"` // HH:MM (TIME column)
Description string `json:"description" db:"description"`
Enabled bool `json:"enabled" db:"enabled"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// MaintenanceWindowInput is the request body for create/update operations.
type MaintenanceWindowInput struct {
DayOfWeek int `json:"day_of_week"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Description string `json:"description"`
Enabled *bool `json:"enabled"`
}

View file

@ -0,0 +1,81 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// MetricsReportRequest is sent by agents when reporting system/storage metrics
type MetricsReportRequest struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Metrics []Metric `json:"metrics"`
}
// Metric represents a system or storage metric
type Metric struct {
PackageType string `json:"package_type"` // "storage", "system", "cpu", "memory"
PackageName string `json:"package_name"` // mount point, metric name
CurrentVersion string `json:"current_version"` // current usage, value
AvailableVersion string `json:"available_version"` // available space, threshold
Severity string `json:"severity"` // "low", "moderate", "high"
RepositorySource string `json:"repository_source"`
Metadata map[string]string `json:"metadata"`
}
// Metric represents a stored metric in the database
type StoredMetric struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
CurrentVersion string `json:"current_version" db:"current_version"`
AvailableVersion string `json:"available_version" db:"available_version"`
Severity string `json:"severity" db:"severity"`
RepositorySource string `json:"repository_source" db:"repository_source"`
Metadata JSONB `json:"metadata" db:"metadata"`
EventType string `json:"event_type" db:"event_type"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// MetricFilter represents filtering options for metrics queries
type MetricFilter struct {
AgentID *uuid.UUID `json:"agent_id,omitempty"`
PackageType *string `json:"package_type,omitempty"`
Severity *string `json:"severity,omitempty"`
Limit *int `json:"limit,omitempty"`
Offset *int `json:"offset,omitempty"`
}
// MetricResult represents the result of a metrics query
type MetricResult struct {
Metrics []StoredMetric `json:"metrics"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// StorageMetrics represents storage-specific metrics for easier consumption
type StorageMetrics struct {
MountPoint string `json:"mount_point"`
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
AvailableBytes int64 `json:"available_bytes"`
UsedPercent float64 `json:"used_percent"`
Status string `json:"status"` // "low", "moderate", "high", "critical"
LastUpdated time.Time `json:"last_updated"`
}
// SystemMetrics represents system-specific metrics for easier consumption
type SystemMetrics struct {
CPUModel string `json:"cpu_model"`
CPUCores int `json:"cpu_cores"`
CPUThreads int `json:"cpu_threads"`
MemoryTotal int64 `json:"memory_total"`
MemoryUsed int64 `json:"memory_used"`
MemoryPercent float64 `json:"memory_percent"`
Processes int `json:"processes"`
Uptime string `json:"uptime"`
LoadAverage []float64 `json:"load_average"`
LastUpdated time.Time `json:"last_updated"`
}

View file

@ -0,0 +1,189 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// ProcessSnapshot represents one on-demand process scan for an agent.
type ProcessSnapshot struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
CommandID string `json:"command_id" db:"command_id"`
ProcessCount int `json:"process_count" db:"process_count"`
ScannedAt time.Time `json:"scanned_at" db:"scanned_at"`
ScanDurationMs int `json:"scan_duration_ms" db:"scan_duration_ms"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Process represents a single process within a snapshot.
type Process struct {
ID uuid.UUID `json:"id" db:"id"`
SnapshotID uuid.UUID `json:"snapshot_id" db:"snapshot_id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PID int `json:"pid" db:"pid"`
Name string `json:"name" db:"name"`
Path string `json:"path" db:"path"`
Cmdline string `json:"cmdline" db:"cmdline"`
Cwd string `json:"cwd" db:"cwd"`
State string `json:"state" db:"state"`
UID int `json:"uid" db:"uid"`
GID int `json:"gid" db:"gid"`
EUID int `json:"euid" db:"euid"`
EGID int `json:"egid" db:"egid"`
User string `json:"user" db:"user"`
Group string `json:"group" db:"group"`
TTY int `json:"tty" db:"tty"`
TTYName string `json:"tty_name" db:"tty_name"`
CPUSecondsUser float64 `json:"cpu_seconds_user" db:"cpu_seconds_user"`
CPUSecondsSystem float64 `json:"cpu_seconds_system" db:"cpu_seconds_system"`
CPUPercent float64 `json:"cpu_percent" db:"cpu_percent"`
RSSBytes int64 `json:"rss_bytes" db:"rss_bytes"`
VMSBytes int64 `json:"vms_bytes" db:"vms_bytes"`
MemPercent float64 `json:"mem_percent" db:"mem_percent"`
Threads int `json:"threads" db:"threads"`
Nice int `json:"nice" db:"nice"`
StartTimeSeconds int64 `json:"start_time_seconds" db:"start_time_seconds"`
ParentPID int `json:"parent_pid" db:"parent_pid"`
ProcessGroupID int `json:"process_group_id" db:"process_group_id"`
ElevationStatus string `json:"elevation_status" db:"elevation_status"`
OnDisk int `json:"on_disk" db:"on_disk"`
DiskBytesRead int64 `json:"disk_bytes_read" db:"disk_bytes_read"`
DiskBytesWritten int64 `json:"disk_bytes_written" db:"disk_bytes_written"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ProcessRelated represents related data (files, sockets, etc.) for a process.
type ProcessRelated struct {
ID uuid.UUID `json:"id" db:"id"`
ProcessID uuid.UUID `json:"process_id" db:"process_id"`
RelationType string `json:"relation_type" db:"relation_type"`
Data JSONB `json:"data" db:"data"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ProcessScanRequest is the payload from the agent's process scan report.
type ProcessScanRequest struct {
AgentID uuid.UUID `json:"agent_id"`
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Snapshot ProcessSnapshotData `json:"snapshot"`
}
// ProcessSnapshotData mirrors the agent's FullProcessSnapshot for deserialization.
type ProcessSnapshotData struct {
Processes []ProcessData `json:"processes"`
ProcessCount int `json:"process_count"`
ScannedAt time.Time `json:"scanned_at"`
DurationMs int64 `json:"duration_ms"`
}
// ProcessData mirrors a single FullProcess from the agent.
type ProcessData struct {
PID int `json:"pid"`
Name string `json:"name"`
Path string `json:"path,omitempty"`
Cmdline string `json:"cmdline"`
Cwd string `json:"cwd,omitempty"`
State string `json:"state"`
UID uint32 `json:"uid"`
GID uint32 `json:"gid"`
EUID uint32 `json:"euid"`
EGID uint32 `json:"egid"`
User string `json:"user"`
Group string `json:"group"`
TTY int `json:"tty"`
TTYName string `json:"tty_name,omitempty"`
CPUSecondsUser float64 `json:"cpu_seconds_user"`
CPUSecondsSystem float64 `json:"cpu_seconds_system"`
CPUPercent float64 `json:"cpu_percent"`
RSSBytes uint64 `json:"rss_bytes"`
VMSBytes uint64 `json:"vms_bytes"`
MemPercent float64 `json:"mem_percent"`
Threads int `json:"threads"`
Nice int `json:"nice"`
StartTimeSeconds uint64 `json:"start_time_seconds"`
ParentPID int `json:"parent_pid"`
ProcessGroupID int `json:"process_group_id"`
OnDisk int `json:"on_disk"`
ElevationStatus string `json:"elevation_status,omitempty"`
DiskBytesRead uint64 `json:"disk_bytes_read,omitempty"`
DiskBytesWritten uint64 `json:"disk_bytes_written,omitempty"`
OpenFiles []ProcessOpenFileData `json:"open_files,omitempty"`
OpenSockets []ProcessOpenSocketData `json:"open_sockets,omitempty"`
OpenPipes []ProcessOpenPipeData `json:"open_pipes,omitempty"`
EnvironmentVars []string `json:"environment_vars,omitempty"`
MemoryMap []ProcessMemoryMapData `json:"memory_map,omitempty"`
Namespaces []ProcessNamespaceData `json:"namespaces,omitempty"`
ListeningPorts []ProcessListeningPortData `json:"listening_ports,omitempty"`
}
// Related data types for deserialization.
type ProcessOpenFileData struct {
FD int `json:"fd"`
Path string `json:"path"`
Type string `json:"type,omitempty"`
}
type ProcessOpenSocketData struct {
FD int `json:"fd"`
Family string `json:"family"`
Protocol string `json:"protocol"`
LocalAddr string `json:"local_addr"`
LocalPort int `json:"local_port"`
RemoteAddr string `json:"remote_addr"`
RemotePort int `json:"remote_port"`
State string `json:"state"`
Inode uint64 `json:"inode"`
Path string `json:"path,omitempty"`
}
type ProcessOpenPipeData struct {
FD int `json:"fd"`
Inode uint64 `json:"inode"`
Mode string `json:"mode,omitempty"`
Type string `json:"type,omitempty"`
}
type ProcessMemoryMapData struct {
Start uint64 `json:"start"`
End uint64 `json:"end"`
Permissions string `json:"permissions"`
Offset uint64 `json:"offset"`
Device string `json:"device"`
Inode uint64 `json:"inode"`
Path string `json:"path,omitempty"`
}
type ProcessNamespaceData struct {
Type string `json:"type"`
Inode string `json:"inode"`
}
type ProcessListeningPortData struct {
Protocol string `json:"protocol"`
LocalAddr string `json:"local_addr"`
LocalPort int `json:"local_port"`
FD int `json:"fd,omitempty"`
Socket uint64 `json:"socket,omitempty"`
}
// ProcessSnapshotResponse is the API response for the latest snapshot.
type ProcessSnapshotResponse struct {
Snapshot *ProcessSnapshot `json:"snapshot"`
Processes []Process `json:"processes"`
}
// ProcessDetailResponse is the API response for a single process with related data.
type ProcessDetailResponse struct {
Process Process `json:"process"`
OpenFiles []ProcessRelated `json:"open_files,omitempty"`
OpenSockets []ProcessRelated `json:"open_sockets,omitempty"`
OpenPipes []ProcessRelated `json:"open_pipes,omitempty"`
Environment []ProcessRelated `json:"environment,omitempty"`
MemoryMap []ProcessRelated `json:"memory_map,omitempty"`
Namespaces []ProcessRelated `json:"namespaces,omitempty"`
ListeningPorts []ProcessRelated `json:"listening_ports,omitempty"`
}

View file

@ -0,0 +1,128 @@
package models_test
// reconcile_test.go — Unit tests for RECONCILE-001: scan-set closure.
//
// Coverage:
// - ReconcileFromScan state rules (all eight states)
// - ValidateTransition: installed → pending is now permitted
// - ValidateTransition: ignored is still locked (cannot reopen)
// - IsTerminal: resting states do not auto-advance from scan signals
import (
"testing"
"github.com/Fimeg/RedFlag/server/internal/models"
)
// TestReconcileFromScan verifies the re-discovery rules defined in
// RECONCILE-001 Target Model. This is the Go twin of the SQL CASE in
// UpdateCurrentStateInTx — they must agree.
func TestReconcileFromScan(t *testing.T) {
cases := []struct {
current models.PackageStatus
expected models.PackageStatus
label string
}{
// Terminal-resting states that scan must NOT auto-advance.
{models.StatusIgnored, models.StatusIgnored, "ignored is preserved (operator decision)"},
{models.StatusFailed, models.StatusFailed, "failed is preserved (operator recovery path)"},
// installed reappears in scan → reopen (new version available).
// This is the deliberate reactivation path introduced by RECONCILE-001.
{models.StatusInstalled, models.StatusPending, "installed reappears → reopen to pending"},
// All waiting/active states reset to pending on re-discovery.
{models.StatusPending, models.StatusPending, "pending → pending (no-op)"},
{models.StatusApproved, models.StatusPending, "approved → pending on re-discovery"},
{models.StatusCheckingDependencies, models.StatusPending, "checking_dependencies → pending on re-discovery"},
{models.StatusPendingDependencies, models.StatusPending, "pending_dependencies → pending on re-discovery"},
{models.StatusInstalling, models.StatusPending, "installing → pending on re-discovery"},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
got := models.ReconcileFromScan(tc.current)
if got != tc.expected {
t.Errorf("ReconcileFromScan(%q) = %q, want %q", tc.current, got, tc.expected)
}
})
}
}
// TestInstalledToPendingTransitionAllowed verifies that the state machine now
// permits installed → pending (RECONCILE-001: deliberate reactivation when a
// package reappears in a later scan). This is load-bearing: if the machine
// blocks this move, the reopen code path in UpdateCurrentStateInTx will fail.
func TestInstalledToPendingTransitionAllowed(t *testing.T) {
if err := models.ValidateTransition(models.StatusInstalled, models.StatusPending); err != nil {
t.Errorf("installed → pending must be permitted after RECONCILE-001: %v", err)
}
}
// TestWaitingStatesResolveOutOfBand verifies the out-of-band resolution edge added by
// RECONCILE-001: a waiting update (pending/approved) that vanishes from a successful scan
// can be closed to installed. This is load-bearing — transitionStatus validates against
// the map, so without this edge closeScanAbsentRows hits the validation wall and silently
// skips every out-of-band patch (the exact case it exists to handle).
func TestWaitingStatesResolveOutOfBand(t *testing.T) {
for _, from := range []models.PackageStatus{models.StatusPending, models.StatusApproved} {
if err := models.ValidateTransition(from, models.StatusInstalled); err != nil {
t.Errorf("%q → installed must be permitted (out-of-band resolution): %v", from, err)
}
}
// The in-flight states are deliberately NOT given a direct edge to installed: they
// reach installed only via the receipt path (installing → installed) or are recovered
// by the orchestrator. The scan-set reconciler excludes them (GetTrackedNonResting).
for _, from := range []models.PackageStatus{
models.StatusCheckingDependencies, models.StatusPendingDependencies,
} {
if err := models.ValidateTransition(from, models.StatusInstalled); err == nil {
t.Errorf("%q → installed must NOT be a direct edge (orchestrator/receipt-owned)", from)
}
}
}
// TestIgnoredTransitionLocked verifies that the ignored state remains fully
// terminal. A scan cannot reopen an operator-rejected package — that decision
// is preserved indefinitely.
func TestIgnoredTransitionLocked(t *testing.T) {
targets := []models.PackageStatus{
models.StatusPending,
models.StatusApproved,
models.StatusInstalled,
models.StatusFailed,
models.StatusInstalling,
}
for _, to := range targets {
if err := models.ValidateTransition(models.StatusIgnored, to); err == nil {
t.Errorf("ignored → %q must be forbidden, but ValidateTransition returned nil", to)
}
}
}
// TestInstalledRemainsTerminalForScanClosure verifies IsTerminal semantics
// (scan-stability): installed/failed/ignored are terminal, the rest are not.
// NOTE: IsTerminal is about scan-stability, NOT closure scope. The scan-set
// reconciler's actual closure candidates are narrower still — only the waiting
// states (pending, approved); see GetTrackedNonResting. In-flight states are
// non-terminal here but are excluded from closure (orchestrator/receipt-owned).
func TestInstalledRemainsTerminalForScanClosure(t *testing.T) {
for _, s := range []models.PackageStatus{
models.StatusInstalled, models.StatusFailed, models.StatusIgnored,
} {
if !s.IsTerminal() {
t.Errorf("status %q should be terminal (scan-set reconciler must skip it)", s)
}
}
for _, s := range []models.PackageStatus{
models.StatusPending, models.StatusApproved,
models.StatusCheckingDependencies, models.StatusPendingDependencies,
models.StatusInstalling,
} {
if s.IsTerminal() {
t.Errorf("status %q must NOT be terminal (scan-set reconciler must consider it)", s)
}
}
}

View file

@ -0,0 +1,113 @@
package models
import (
"crypto/sha256"
"fmt"
"time"
"github.com/gofrs/uuid/v5"
)
// SecurityEvent represents a security-related event that occurred
type SecurityEvent struct {
Timestamp time.Time `json:"timestamp" db:"timestamp"`
Level string `json:"level" db:"level"` // CRITICAL, WARNING, INFO, DEBUG
EventType string `json:"event_type" db:"event_type"`
AgentID uuid.UUID `json:"agent_id,omitempty" db:"agent_id"`
Message string `json:"message" db:"message"`
TraceID string `json:"trace_id,omitempty" db:"trace_id"`
IPAddress string `json:"ip_address,omitempty" db:"ip_address"`
Details map[string]interface{} `json:"details,omitempty" db:"details"` // JSON encoded
Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` // JSON encoded
}
// SecurityEventTypes defines all possible security event types
var SecurityEventTypes = struct {
CmdSigned string
CmdSignatureVerificationFailed string
CmdSignatureVerificationSuccess string
UpdateNonceInvalid string
UpdateSignatureVerificationFailed string
MachineIDMismatch string
AuthJWTValidationFailed string
PrivateKeyNotConfigured string
AgentRegistrationFailed string
UnauthorizedAccessAttempt string
ConfigTamperingDetected string
AnomalousBehavior string
SecurityAdvisoryCleared string
}{
CmdSigned: "CMD_SIGNED",
CmdSignatureVerificationFailed: "CMD_SIGNATURE_VERIFICATION_FAILED",
CmdSignatureVerificationSuccess: "CMD_SIGNATURE_VERIFICATION_SUCCESS",
UpdateNonceInvalid: "UPDATE_NONCE_INVALID",
UpdateSignatureVerificationFailed: "UPDATE_SIGNATURE_VERIFICATION_FAILED",
MachineIDMismatch: "MACHINE_ID_MISMATCH",
AuthJWTValidationFailed: "AUTH_JWT_VALIDATION_FAILED",
PrivateKeyNotConfigured: "PRIVATE_KEY_NOT_CONFIGURED",
AgentRegistrationFailed: "AGENT_REGISTRATION_FAILED",
UnauthorizedAccessAttempt: "UNAUTHORIZED_ACCESS_ATTEMPT",
ConfigTamperingDetected: "CONFIG_TAMPERING_DETECTED",
AnomalousBehavior: "ANOMALOUS_BEHAVIOR",
SecurityAdvisoryCleared: "SECURITY_ADVISORY_CLEARED",
}
// IsCritical returns true if the event is of critical severity
func (e *SecurityEvent) IsCritical() bool {
return e.Level == "CRITICAL"
}
// IsWarning returns true if the event is a warning
func (e *SecurityEvent) IsWarning() bool {
return e.Level == "WARNING"
}
// ShouldLogToDatabase determines if this event should be stored in the database
func (e *SecurityEvent) ShouldLogToDatabase(logToDatabase bool) bool {
return logToDatabase && (e.IsCritical() || e.IsWarning())
}
// HashIPAddress hashes the IP address for privacy
func (e *SecurityEvent) HashIPAddress() {
if e.IPAddress != "" {
hash := sha256.Sum256([]byte(e.IPAddress))
e.IPAddress = fmt.Sprintf("hashed:%x", hash[:8]) // Store first 8 bytes of hash
}
}
// NewSecurityEvent creates a new security event with current timestamp
func NewSecurityEvent(level, eventType string, agentID uuid.UUID, message string) *SecurityEvent {
return &SecurityEvent{
Timestamp: time.Now().UTC(),
Level: level,
EventType: eventType,
AgentID: agentID,
Message: message,
Details: make(map[string]interface{}),
Metadata: make(map[string]interface{}),
}
}
// WithTrace adds a trace ID to the event
func (e *SecurityEvent) WithTrace(traceID string) *SecurityEvent {
e.TraceID = traceID
return e
}
// WithIPAddress adds an IP address to the event
func (e *SecurityEvent) WithIPAddress(ip string) *SecurityEvent {
e.IPAddress = ip
return e
}
// WithDetail adds a key-value detail to the event
func (e *SecurityEvent) WithDetail(key string, value interface{}) *SecurityEvent {
e.Details[key] = value
return e
}
// WithMetadata adds a key-value metadata to the event
func (e *SecurityEvent) WithMetadata(key string, value interface{}) *SecurityEvent {
e.Metadata[key] = value
return e
}

View file

@ -0,0 +1,32 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// SecuritySetting represents a user-configurable security setting
type SecuritySetting struct {
ID uuid.UUID `json:"id" db:"id"`
Category string `json:"category" db:"category"`
Key string `json:"key" db:"key"`
Value string `json:"value" db:"value"`
IsEncrypted bool `json:"is_encrypted" db:"is_encrypted"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt *time.Time `json:"updated_at" db:"updated_at"`
CreatedBy *uuid.UUID `json:"created_by" db:"created_by"`
UpdatedBy *uuid.UUID `json:"updated_by" db:"updated_by"`
}
// SecuritySettingAudit represents an audit log entry for security setting changes
type SecuritySettingAudit struct {
ID uuid.UUID `json:"id" db:"id"`
SettingID uuid.UUID `json:"setting_id" db:"setting_id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Action string `json:"action" db:"action"` // create, update, delete
OldValue *string `json:"old_value" db:"old_value"`
NewValue *string `json:"new_value" db:"new_value"`
Reason string `json:"reason" db:"reason"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

View file

@ -0,0 +1,20 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// SigningKey represents a signing key record in the database
type SigningKey struct {
ID uuid.UUID `json:"id" db:"id"`
KeyID string `json:"key_id" db:"key_id"`
PublicKey string `json:"public_key" db:"public_key"`
Algorithm string `json:"algorithm" db:"algorithm"`
IsActive bool `json:"is_active" db:"is_active"`
IsPrimary bool `json:"is_primary" db:"is_primary"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
DeprecatedAt *time.Time `json:"deprecated_at,omitempty" db:"deprecated_at"`
Version int `json:"version" db:"version"`
}

View file

@ -0,0 +1,40 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// StorageMetric represents a storage metric from an agent
type StorageMetric struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
Mountpoint string `json:"mountpoint" db:"mountpoint"`
Device string `json:"device" db:"device"`
DiskType string `json:"disk_type" db:"disk_type"`
Filesystem string `json:"filesystem" db:"filesystem"`
TotalBytes int64 `json:"total_bytes" db:"total_bytes"`
UsedBytes int64 `json:"used_bytes" db:"used_bytes"`
AvailableBytes int64 `json:"available_bytes" db:"available_bytes"`
UsedPercent float64 `json:"used_percent" db:"used_percent"`
Severity string `json:"severity" db:"severity"`
Metadata JSONB `json:"metadata,omitempty" db:"metadata"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// StorageMetricRequest represents the request payload for storage metrics
type StorageMetricRequest struct {
AgentID uuid.UUID `json:"agent_id"`
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Metrics []StorageMetric `json:"metrics"`
}
// StorageMetricsList represents a list of storage metrics with pagination
type StorageMetricsList struct {
Metrics []StorageMetric `json:"metrics"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}

View file

@ -0,0 +1,50 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// AgentSubsystem represents a subsystem configuration for an agent
type AgentSubsystem struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
Subsystem string `json:"subsystem" db:"subsystem"`
Enabled bool `json:"enabled" db:"enabled"`
IntervalMinutes int `json:"interval_minutes" db:"interval_minutes"`
AutoRun bool `json:"auto_run" db:"auto_run"`
LastRunAt *time.Time `json:"last_run_at,omitempty" db:"last_run_at"`
NextRunAt *time.Time `json:"next_run_at,omitempty" db:"next_run_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// SubsystemType represents the type of subsystem
type SubsystemType string
const (
SubsystemStorage SubsystemType = "storage"
SubsystemSystem SubsystemType = "system"
SubsystemDocker SubsystemType = "docker"
)
// SubsystemConfig represents the configuration for updating a subsystem
type SubsystemConfig struct {
Enabled *bool `json:"enabled,omitempty"`
IntervalMinutes *int `json:"interval_minutes,omitempty"`
AutoRun *bool `json:"auto_run,omitempty"`
}
// SubsystemStats provides statistics about a subsystem's execution
type SubsystemStats struct {
Subsystem string `json:"subsystem"`
Enabled bool `json:"enabled"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
NextRunAt *time.Time `json:"next_run_at,omitempty"`
IntervalMinutes int `json:"interval_minutes"`
AutoRun bool `json:"auto_run"`
RunCount int `json:"run_count"` // Total runs
LastStatus string `json:"last_status"` // Last command status
LastDuration int `json:"last_duration"` // Last run duration in seconds
}

View file

@ -0,0 +1,53 @@
package models
import "strings"
// Supply-chain gate predicates. These are the single source of truth for "is this
// package's supply-chain posture clean enough to mint a capability token over it?"
// Both the automatic confirmation sweep (orchestrator) and the manual operator
// approval path (handlers) call them, so the two enforcement points can never drift.
// MetadataHasVulns reports whether metadata[key] records a non-empty vulnerability
// set. An unexpected shape is treated as vulnerable, conservatively.
func MetadataHasVulns(pkg UpdateState, key string) bool {
if pkg.Metadata == nil {
return false
}
v, ok := pkg.Metadata[key]
if !ok || v == nil {
return false
}
switch t := v.(type) {
case []interface{}:
return len(t) > 0
case string:
s := strings.TrimSpace(t)
return s != "" && s != "[]" && s != "null"
default:
return true
}
}
// ClosureChecked reports whether the resolved dependency closure was OSV-checked at
// all (closure_checked_at present and non-empty). False means OSV could not vet the
// closure — the "trust the void" case the operator must consciously accept.
func ClosureChecked(pkg UpdateState) bool {
if pkg.Metadata == nil {
return false
}
v, ok := pkg.Metadata["closure_checked_at"]
if !ok || v == nil {
return false
}
if s, isStr := v.(string); isStr && strings.TrimSpace(s) == "" {
return false
}
return true
}
// ClosureCleared reports whether the resolved dependency closure was OSV-checked
// AND came back clean. A token is auto-minted over the closure only when this holds;
// the manual path requires an explicit operator override to proceed without it.
func ClosureCleared(pkg UpdateState) bool {
return ClosureChecked(pkg) && !MetadataHasVulns(pkg, "closure_vulns")
}

View file

@ -0,0 +1,89 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// SystemEvent represents a unified event log entry for all system events
// This implements the unified event logging system from docs/ERROR_FLOW_AUDIT.md
type SystemEvent struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID *uuid.UUID `json:"agent_id,omitempty" db:"agent_id"` // Pointer to allow NULL for server events
EventType string `json:"event_type" db:"event_type"` // e.g., 'agent_update', 'agent_startup', 'server_build'
EventSubtype string `json:"event_subtype" db:"event_subtype"` // e.g., 'success', 'failed', 'info', 'warning'
Severity string `json:"severity" db:"severity"` // 'info', 'warning', 'error', 'critical'
Component string `json:"component" db:"component"` // 'agent', 'server', 'build', 'download', 'config', etc.
Message string `json:"message" db:"message"`
Metadata JSONB `json:"metadata,omitempty" db:"metadata"` // JSONB; Value/Scan defined in agent.go
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Narrative is a server-rendered, operator-facing summary of the event.
// Populated by handlers before returning to the UI; never persisted.
// See services/event_renderer.go for the renderer.
Narrative string `json:"narrative,omitempty" db:"-"`
}
// Event type constants
const (
EventTypeAgentStartup = "agent_startup"
EventTypeAgentRegistration = "agent_registration"
EventTypeAgentUnregistration = "agent_unregistration"
EventTypeAgentDeleted = "agent_deleted"
EventTypeAgentCheckIn = "agent_checkin"
EventTypeAgentScan = "agent_scan"
EventTypeAgentUpdate = "agent_update"
EventTypeAgentConfig = "agent_config"
EventTypeAgentMigration = "agent_migration"
EventTypeAgentShutdown = "agent_shutdown"
EventTypeRegistrationToken = "registration_token"
EventTypeCommandCreated = "command_created"
EventTypeCommandCompleted = "command_completed"
EventTypeCommandFailed = "command_failed"
EventTypeServerBuild = "server_build"
EventTypeServerDownload = "server_download"
EventTypeServerConfig = "server_config"
EventTypeServerAuth = "server_auth"
EventTypeDownload = "download"
EventTypeMigration = "migration"
EventTypeError = "error"
)
// Event subtype constants
const (
SubtypeSuccess = "success"
SubtypeFailed = "failed"
SubtypeInfo = "info"
SubtypeWarning = "warning"
SubtypeCritical = "critical"
SubtypeDownloadFailed = "download_failed"
SubtypeValidationFailed = "validation_failed"
SubtypeConfigCorrupted = "config_corrupted"
SubtypeMigrationNeeded = "migration_needed"
SubtypePanicRecovered = "panic_recovered"
SubtypeTokenExpired = "token_expired"
SubtypeNetworkTimeout = "network_timeout"
SubtypePermissionDenied = "permission_denied"
SubtypeServiceUnavailable = "service_unavailable"
)
// Severity constants
const (
SeverityInfo = "info"
SeverityWarning = "warning"
SeverityError = "error"
SeverityCritical = "critical"
)
// Component constants
const (
ComponentAgent = "agent"
ComponentServer = "server"
ComponentBuild = "build"
ComponentDownload = "download"
ComponentConfig = "config"
ComponentDatabase = "database"
ComponentNetwork = "network"
ComponentSecurity = "security"
ComponentMigration = "migration"
)

View file

@ -0,0 +1,149 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
// TrackedSoftware is a single piece of software the operator wants the
// server to keep an eye on. The (source, source_ref) pair uniquely
// identifies what upstream registry to ask and what to ask it for.
type TrackedSoftware struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Ecosystem string `db:"ecosystem" json:"ecosystem"`
Source string `db:"source" json:"source"`
SourceRef string `db:"source_ref" json:"source_ref"`
CurrentVersion *string `db:"current_version" json:"current_version,omitempty"`
LatestVersion *string `db:"latest_version" json:"latest_version,omitempty"`
LatestAt *time.Time `db:"latest_at" json:"latest_at,omitempty"`
EOLAt *time.Time `db:"eol_at" json:"eol_at,omitempty"`
LastCheckedAt *time.Time `db:"last_checked_at" json:"last_checked_at,omitempty"`
LastSyncedAt *time.Time `db:"last_synced_at" json:"last_synced_at,omitempty"`
LastError *string `db:"last_error" json:"last_error,omitempty"`
Enabled bool `db:"enabled" json:"enabled"`
TrackPrereleases bool `db:"track_prereleases" json:"track_prereleases"`
RepologySlug *string `db:"repology_slug" json:"repology_slug,omitempty"`
ContainerImagePattern *string `db:"container_image_pattern" json:"container_image_pattern,omitempty"`
BinaryProbe *string `db:"binary_probe" json:"binary_probe,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// TrackedSoftwareInput is the create/update payload from the admin API.
type TrackedSoftwareInput struct {
Name string `json:"name" binding:"required"`
Ecosystem string `json:"ecosystem" binding:"required"`
Source string `json:"source" binding:"required"`
SourceRef string `json:"source_ref" binding:"required"`
CurrentVersion *string `json:"current_version,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
TrackPrereleases *bool `json:"track_prereleases,omitempty"`
RepologySlug *string `json:"repology_slug,omitempty"`
ContainerImagePattern *string `json:"container_image_pattern,omitempty"`
BinaryProbe *string `json:"binary_probe,omitempty"`
}
// TrackedSoftwareSettingsInput is the narrow admin patch payload for
// operator-owned toggles that should not require recreating the tracked row.
type TrackedSoftwareSettingsInput struct {
Enabled *bool `json:"enabled,omitempty"`
TrackPrereleases *bool `json:"track_prereleases,omitempty"`
}
// UpstreamDriftEvent records a point-in-time observation that latest_version
// moved (or that an EOL date passed). Append-only.
type UpstreamDriftEvent struct {
ID uuid.UUID `db:"id" json:"id"`
TrackedSoftwareID uuid.UUID `db:"tracked_software_id" json:"tracked_software_id"`
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
DriftSeverity string `db:"drift_severity" json:"drift_severity"`
FromVersion *string `db:"from_version" json:"from_version,omitempty"`
ToVersion *string `db:"to_version" json:"to_version,omitempty"`
Note *string `db:"note" json:"note,omitempty"`
}
// AgentTrackedSoftware is the binding row that says "agent X has the
// software described by tracked_software_id installed at this version
// at this path." Unique on (agent_id, tracked_software_id) — re-binding
// the same pair upserts. See migration 039.
type AgentTrackedSoftware struct {
ID uuid.UUID `db:"id" json:"id"`
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
TrackedSoftwareID uuid.UUID `db:"tracked_software_id" json:"tracked_software_id"`
InstalledVersion string `db:"installed_version" json:"installed_version"`
InstallPath *string `db:"install_path" json:"install_path,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
MatchMethod *string `db:"match_method" json:"match_method,omitempty"`
PackageName *string `db:"package_name" json:"package_name,omitempty"`
LastObservedAt time.Time `db:"last_observed_at" json:"last_observed_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// AgentTrackedSoftwareInput is the create/upsert payload.
type AgentTrackedSoftwareInput struct {
TrackedSoftwareID uuid.UUID `json:"tracked_software_id" binding:"required"`
InstalledVersion string `json:"installed_version" binding:"required"`
InstallPath *string `json:"install_path,omitempty"`
Notes *string `json:"notes,omitempty"`
}
// AgentTrackedSoftwareView joins a binding with its parent tracked_software
// row so the API can return one shape per binding without round-trips on
// the client. Drift booleans are derived server-side so the UI can render
// indicators without re-doing the comparison.
type AgentTrackedSoftwareView struct {
BindingID uuid.UUID `db:"binding_id" json:"binding_id"`
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
TrackedSoftwareID uuid.UUID `db:"tracked_software_id" json:"tracked_software_id"`
Name string `db:"name" json:"name"`
Ecosystem string `db:"ecosystem" json:"ecosystem"`
Source string `db:"source" json:"source"`
SourceRef string `db:"source_ref" json:"source_ref"`
InstalledVersion string `db:"installed_version" json:"installed_version"`
LatestVersion *string `db:"latest_version" json:"latest_version,omitempty"`
LatestAt *time.Time `db:"latest_at" json:"latest_at,omitempty"`
EOLAt *time.Time `db:"eol_at" json:"eol_at,omitempty"`
InstallPath *string `db:"install_path" json:"install_path,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
LastObservedAt time.Time `db:"last_observed_at" json:"last_observed_at"`
LastSyncedAt *time.Time `db:"last_synced_at" json:"last_synced_at,omitempty"`
LastError *string `db:"last_error" json:"last_error,omitempty"`
Drifted bool `db:"drifted" json:"drifted"`
PastEOL bool `db:"past_eol" json:"past_eol"`
}
// RepologyAlias is a cached row from the Repology /packages endpoint mapping
// a Repology project slug to an ecosystem-specific package name.
type RepologyAlias struct {
ID uuid.UUID `db:"id" json:"id"`
Slug string `db:"slug" json:"slug"`
Ecosystem string `db:"ecosystem" json:"ecosystem"`
PkgNames []string `db:"pkg_names" json:"pkg_names"`
RepoRaw *string `db:"repo_raw" json:"repo_raw,omitempty"`
FetchedAt time.Time `db:"fetched_at" json:"fetched_at"`
}
// MatchResult represents a reconciliation hit: an agent's reported package
// matched a tracked_software entry via one of the discovery methods.
type MatchResult struct {
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
TrackedID uuid.UUID `db:"tracked_software_id" json:"tracked_software_id"`
PackageName string `db:"package_name" json:"package_name"`
Version string `db:"version" json:"version"`
PackageType string `db:"package_type" json:"package_type"`
}
// AgentInstallation is the symmetric view: "who runs this tracked software?"
// Used by the upstream-tracking settings page so an operator can see which
// hosts have each entry installed.
type AgentInstallation struct {
BindingID uuid.UUID `db:"binding_id" json:"binding_id"`
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
Hostname string `db:"hostname" json:"hostname"`
InstalledVersion string `db:"installed_version" json:"installed_version"`
InstallPath *string `db:"install_path" json:"install_path,omitempty"`
LastObservedAt time.Time `db:"last_observed_at" json:"last_observed_at"`
}

View file

@ -0,0 +1,391 @@
package models
import (
"encoding/json"
"time"
"github.com/gofrs/uuid/v5"
)
// UpdateReportRequest is sent by agents when reporting discovered updates.
// RECONCILE-001 contract extension (2026-06-06):
// - Ecosystem: the package manager that produced this scan (e.g. "dnf", "apt").
// Required when ScanSucceeded is true. The server uses this to scope the
// set-diff closure to a single (agent, ecosystem) pair.
// - ScanSucceeded: true only when the scanner returned exit 0 and a complete
// result. MUST be false (or absent) for partial, failed, or error scans.
// The server NEVER closes rows by absence unless this is true.
type UpdateReportRequest struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Updates []UpdateReportItem `json:"updates"`
Ecosystem string `json:"ecosystem,omitempty"` // RECONCILE-001: package manager ("dnf", "apt", …)
ScanSucceeded bool `json:"scan_succeeded,omitempty"` // RECONCILE-001: exit 0, complete scan
}
// UpdateReportItem represents a single update discovered by an agent
type UpdateReportItem struct {
PackageType string `json:"package_type" binding:"required"`
PackageName string `json:"package_name" binding:"required"`
PackageDescription string `json:"package_description"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version" binding:"required"`
Severity string `json:"severity"`
CVEList []string `json:"cve_list"`
KBID string `json:"kb_id"`
RepositorySource string `json:"repository_source"`
SizeBytes int64 `json:"size_bytes"`
Metadata JSONB `json:"metadata"`
}
// UpdateLog represents an execution log entry
type UpdateLog struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
UpdatePackageID *uuid.UUID `json:"update_package_id,omitempty" db:"update_package_id"`
Action string `json:"action" db:"action"`
Subsystem string `json:"subsystem,omitempty" db:"subsystem"`
Result string `json:"result" db:"result"`
Stdout string `json:"stdout" db:"stdout"`
Stderr string `json:"stderr" db:"stderr"`
ExitCode int `json:"exit_code" db:"exit_code"`
DurationSeconds int `json:"duration_seconds" db:"duration_seconds"`
ExecutedAt time.Time `json:"executed_at" db:"executed_at"`
// Narrative is a server-rendered, operator-facing summary of the log row.
// Populated by handlers before returning to the UI; never persisted.
// See services/event_renderer.go for the renderer.
Narrative string `json:"narrative,omitempty" db:"-"`
}
// UpdateLogRequest is sent by agents when reporting execution results
type UpdateLogRequest struct {
CommandID string `json:"command_id"`
Action string `json:"action" binding:"required"`
Subsystem string `json:"subsystem,omitempty"`
Result string `json:"result" binding:"required"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
}
// DependencyReportRequest is used by agents to report dependencies after dry run
type DependencyReportRequest struct {
PackageName string `json:"package_name" binding:"required"`
PackageType string `json:"package_type" binding:"required"`
TargetVersion string `json:"target_version,omitempty"`
Dependencies []string `json:"dependencies" binding:"required"`
UpdateID string `json:"update_id" binding:"required"`
DryRunResult *InstallResult `json:"dry_run_result,omitempty"`
// Closure carries the resolved artifacts (top-level + dependencies) with the
// SHA256 the agent's signed repo metadata anchors. Present for OS package
// managers (dnf/apt) whose hash is sourced agent-side; the server pins the
// top-level hash and mints the capability token over this set.
Closure []ClosureItem `json:"closure,omitempty"`
}
// ClosureItem is one resolved artifact reported by an agent. JSON tags match the
// capability.ClosureEntry so the reported set maps directly onto a minted token.
type ClosureItem struct {
Name string `json:"name"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
Source string `json:"source"`
}
// InstallResult represents the result of a package installation attempt (from agent)
type InstallResult struct {
Success bool `json:"success"`
ErrorMessage string `json:"error_message,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
Action string `json:"action,omitempty"` // "install", "upgrade", "dry_run", etc.
PackagesInstalled []string `json:"packages_installed,omitempty"`
ContainersUpdated []string `json:"containers_updated,omitempty"`
Dependencies []string `json:"dependencies,omitempty"` // List of dependency packages found during dry run
IsDryRun bool `json:"is_dry_run"` // Whether this is a dry run result
}
// UpdateFilters for querying updates
type UpdateFilters struct {
AgentID uuid.UUID
Status PackageStatus
Severity string
PackageType string
Page int
PageSize int
}
// EVENT SOURCING MODELS
// UpdateEvent represents a single update event in the event sourcing system
type UpdateEvent struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
VersionFrom string `json:"version_from" db:"version_from"`
VersionTo string `json:"version_to" db:"version_to"`
Severity string `json:"severity" db:"severity"`
RepositorySource string `json:"repository_source" db:"repository_source"`
Metadata JSONB `json:"metadata" db:"metadata"`
EventType string `json:"event_type" db:"event_type"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// UpdateState represents the current state of a package (denormalized for queries)
type UpdateState struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
CurrentVersion string `json:"current_version" db:"current_version"`
AvailableVersion string `json:"available_version" db:"available_version"`
Severity string `json:"severity" db:"severity"`
RepositorySource string `json:"repository_source" db:"repository_source"`
Metadata JSONB `json:"metadata" db:"metadata"`
LastDiscoveredAt time.Time `json:"last_discovered_at" db:"last_discovered_at"`
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
Status PackageStatus `json:"status" db:"status"`
ExpectedSHA256 *string `json:"expected_sha256" db:"expected_sha256"` // Layer 1: Hash Registry
SelectedVersion *string `json:"selected_version,omitempty" db:"selected_version"` // GATE-005: soak-gate target
// Enrichment fields — populated from Metadata by EnrichFromMetadata().
// Not persisted; db:"-" excludes them from SQL scans.
PackageDescription string `json:"package_description" db:"-"`
HomepageURL string `json:"homepage_url" db:"-"`
SizeBytes int64 `json:"size_bytes" db:"-"`
CVEList []string `json:"cve_list" db:"-"`
KBID string `json:"kb_id" db:"-"`
Vulnerabilities []VulnerabilityEntry `json:"vulnerabilities" db:"-"`
DisplayMetadata map[string]interface{} `json:"display_metadata,omitempty" db:"-"`
}
// PackageVersion is one row of the version timeline catalog: a single
// (package_type, package_name, version) RedFlag has seen across the fleet, with the
// provenance it accumulated. OSVVulns is raw JSON text (parsed client-side, same as
// the supply_chain_vulns metadata field). Nullable fields are filled in over time as
// scans and approvals enrich the row.
type PackageVersion struct {
ID uuid.UUID `json:"id" db:"id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
Version string `json:"version" db:"version"`
PublishedAt *time.Time `json:"published_at" db:"published_at"`
FirstScannedAt time.Time `json:"first_scanned_at" db:"first_scanned_at"`
LastSeenAt time.Time `json:"last_seen_at" db:"last_seen_at"`
SHA256 *string `json:"sha256" db:"sha256"`
OSVStatus *string `json:"osv_status" db:"osv_status"`
OSVVulns *string `json:"osv_vulns" db:"osv_vulns"`
Source *string `json:"source" db:"source"`
}
// PackageSummary is the package-centric aggregate view returned by
// GET /updates/package/:type/:name. It carries metadata from one representative
// agent row, fleet-wide status counts, and version extremes.
type PackageSummary struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
Severity string `json:"severity"`
PackageDescription string `json:"package_description"`
HomepageURL string `json:"homepage_url"`
SizeBytes int64 `json:"size_bytes"`
CVEList []string `json:"cve_list"`
Vulnerabilities []VulnerabilityEntry `json:"vulnerabilities"`
TotalAgents int `json:"total_agents"`
PendingCount int `json:"pending_count"`
ApprovedCount int `json:"approved_count"`
ActiveCount int `json:"active_count"`
InstalledCount int `json:"installed_count"`
FailedCount int `json:"failed_count"`
IgnoredCount int `json:"ignored_count"`
LatestAvailable string `json:"latest_available"`
LatestInstalled *string `json:"latest_installed,omitempty"`
}
// UpdateHistory represents the version history of a package
type UpdateHistory struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
VersionFrom string `json:"version_from" db:"version_from"`
VersionTo string `json:"version_to" db:"version_to"`
Severity string `json:"severity" db:"severity"`
RepositorySource string `json:"repository_source" db:"repository_source"`
Metadata JSONB `json:"metadata" db:"metadata"`
UpdateInitiatedAt *time.Time `json:"update_initiated_at" db:"update_initiated_at"`
UpdateCompletedAt time.Time `json:"update_completed_at" db:"update_completed_at"`
UpdateStatus HistoryStatus `json:"update_status" db:"update_status"`
FailureReason *string `json:"failure_reason" db:"failure_reason"`
}
// UpdateBatch represents a batch of update events
type UpdateBatch struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
BatchSize int `json:"batch_size" db:"batch_size"`
ProcessedCount int `json:"processed_count" db:"processed_count"`
FailedCount int `json:"failed_count" db:"failed_count"`
Status string `json:"status" db:"status"`
ErrorDetails JSONB `json:"error_details" db:"error_details"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
}
// UpdateStats represents statistics about updates
type UpdateStats struct {
TotalUpdates int `json:"total_updates" db:"total_updates"`
PendingUpdates int `json:"pending_updates" db:"pending_updates"`
ApprovedUpdates int `json:"approved_updates" db:"approved_updates"`
InstalledUpdates int `json:"installed_updates" db:"installed_updates"`
FailedUpdates int `json:"failed_updates" db:"failed_updates"`
CriticalUpdates int `json:"critical_updates" db:"critical_updates"`
HighUpdates int `json:"high_updates" db:"high_updates"`
ImportantUpdates int `json:"important_updates" db:"important_updates"`
ModerateUpdates int `json:"moderate_updates" db:"moderate_updates"`
LowUpdates int `json:"low_updates" db:"low_updates"`
}
// VulnerabilityEntry represents a single CVE/vulnerability from agent-reported or
// OSV-sourced data.
type VulnerabilityEntry struct {
ID string `json:"id"`
Summary string `json:"summary,omitempty"`
Severity string `json:"severity,omitempty"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"` // "osv" or "agent"
FixedVersion string `json:"fixed_version,omitempty"`
Aliases []string `json:"aliases,omitempty"`
CVSSVector string `json:"cvss_vector,omitempty"`
CVSSScore float64 `json:"cvss_score,omitempty"`
Published string `json:"published,omitempty"`
AdvisoryType string `json:"advisory_type,omitempty"`
AffectedRanges []string `json:"affected_ranges,omitempty"`
KnownExploited bool `json:"known_exploited,omitempty"`
}
// reservedMetadataKeys are internal keys excluded from DisplayMetadata.
var reservedMetadataKeys = map[string]bool{
"description": true, "homepage_url": true, "kb_id": true,
"size_bytes": true, "cve_list": true,
"supply_chain_vulns": true, "supply_chain_checked_at": true,
"supply_chain_checked_version": true, "supply_chain_check_error": true,
"supply_chain_error_at": true, "installed_vulns": true,
"installed_checked_at": true, "installed_checked_version": true,
"installed_check_error": true,
"selected_version_source": true,
"resolved_closure": true, "dependencies": true,
"failure_reason": true, "capability_token_id": true,
"capability_decision": true, "capability_reason": true,
"package_published_at": true, "package_age_hours": true,
"supply_chain_age_check": true,
}
// EnrichFromMetadata populates the enrichment fields from Metadata.
func (u *UpdateState) EnrichFromMetadata() {
if u.Metadata == nil {
return
}
if v, ok := u.Metadata["description"].(string); ok {
u.PackageDescription = v
}
if v, ok := u.Metadata["homepage_url"].(string); ok {
u.HomepageURL = v
}
if v, ok := u.Metadata["kb_id"].(string); ok {
u.KBID = v
}
switch v := u.Metadata["size_bytes"].(type) {
case float64:
u.SizeBytes = int64(v)
case int64:
u.SizeBytes = v
}
switch v := u.Metadata["cve_list"].(type) {
case []interface{}:
u.CVEList = make([]string, 0, len(v))
for _, entry := range v {
if s, ok := entry.(string); ok {
u.CVEList = append(u.CVEList, s)
}
}
}
u.Vulnerabilities = u.mergedVulnerabilities()
// Build display metadata from non-reserved keys.
dm := make(map[string]interface{})
for k, v := range u.Metadata {
if !reservedMetadataKeys[k] {
dm[k] = v
}
}
if len(dm) > 0 {
u.DisplayMetadata = dm
}
}
// mergedVulnerabilities combines OSV-sourced and agent-reported vulnerabilities.
func (u *UpdateState) mergedVulnerabilities() []VulnerabilityEntry {
seen := make(map[string]bool)
var result []VulnerabilityEntry
for _, id := range u.CVEList {
if seen[id] {
continue
}
seen[id] = true
result = append(result, VulnerabilityEntry{ID: id, Source: "agent"})
}
if raw, ok := u.Metadata["supply_chain_vulns"].(string); ok && raw != "" && raw != "[]" {
var osvVulns []VulnerabilityEntry
if err := json.Unmarshal([]byte(raw), &osvVulns); err == nil {
for _, v := range osvVulns {
if seen[v.ID] {
continue
}
seen[v.ID] = true
v.Source = "osv"
result = append(result, v)
}
}
}
return result
}
// InstallVersionRequest is the body for POST /updates/:id/install-version.
type InstallVersionRequest struct {
Version string `json:"version" binding:"required"`
OverrideReason string `json:"override_reason"`
}
// LogFilters for querying logs across all agents
type LogFilters struct {
AgentID uuid.UUID
Action string
Result string
Type string // "command", "log", "package_event", "install_transition", "system_event"
Severity string // "info", "warning", "error", "critical"
Since *time.Time
Page int
PageSize int
}
// ActiveOperation represents a currently running operation
type ActiveOperation struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
CurrentVersion string `json:"current_version" db:"current_version"`
AvailableVersion string `json:"available_version" db:"available_version"`
Severity string `json:"severity" db:"severity"`
Status PackageStatus `json:"status" db:"status"`
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
Metadata JSONB `json:"metadata" db:"metadata"`
ActiveTokenID *string `json:"active_token_id,omitempty" db:"active_token_id"`
ActiveTokenStatus *string `json:"active_token_status,omitempty" db:"active_token_status"`
}

View file

@ -0,0 +1,144 @@
package models
import (
"fmt"
)
// PackageStatus is the lifecycle status of a package update, backed by the
// current_package_state.status SQL column with a CHECK constraint.
type PackageStatus string
const (
StatusPending PackageStatus = "pending"
StatusApproved PackageStatus = "approved"
StatusCheckingDependencies PackageStatus = "checking_dependencies"
StatusPendingDependencies PackageStatus = "pending_dependencies"
StatusInstalling PackageStatus = "installing"
StatusInstalled PackageStatus = "installed"
StatusFailed PackageStatus = "failed"
StatusIgnored PackageStatus = "ignored"
)
// PackageStatusTransitions encodes every permitted (from -> to) move in the
// state machine. The map is read-only after init; access through ValidateTransition.
var PackageStatusTransitions = map[PackageStatus][]PackageStatus{
// pending/approved -> installed is the out-of-band resolution edge (RECONCILE-001):
// a *waiting* update that gets patched outside RedFlag (dnf-automatic, a sysadmin) is
// closed by the scan-set reconciler when it vanishes from a successful scan. The
// in-flight states (checking_dependencies, pending_dependencies, installing) are NOT
// given this edge — they are owned by the orchestrator + capability-receipt path, and
// the reconciler deliberately excludes them (see GetTrackedNonResting closure scope).
StatusPending: {StatusApproved, StatusInstalled, StatusIgnored},
StatusApproved: {StatusCheckingDependencies, StatusInstalling, StatusInstalled, StatusIgnored, StatusFailed},
StatusCheckingDependencies: {StatusPendingDependencies, StatusInstalling, StatusFailed},
StatusPendingDependencies: {StatusInstalling, StatusFailed},
StatusInstalling: {StatusInstalled, StatusFailed, StatusPendingDependencies},
// installed is scan-stable but not transition-locked. When a scan-set reconciler
// closes a row by absence (out-of-band patch) and that same package reappears in
// a later scan (new version available), the row reopens to pending. This matches
// the DefectDojo reimport model: a resolved finding that resurfaces is reactivated,
// not silently ignored. The reopen path is: scan-set reconciler → UpdateCurrentStateInTx.
StatusInstalled: {StatusPending},
// Failed is a *recoverable* resting state, not a dead end. An operator can
// re-open it (failed -> pending) to re-run the lifecycle, or close it out
// (failed -> installed when it turns out the update no longer applies /
// was resolved out of band, failed -> ignored to stop tracking it). The
// failure itself is preserved in update_version_history regardless.
StatusFailed: {StatusPending, StatusInstalled, StatusIgnored},
StatusIgnored: {}, // terminal
}
var allPackageStatuses = func() map[PackageStatus]struct{} {
m := make(map[PackageStatus]struct{}, len(PackageStatusTransitions))
for s := range PackageStatusTransitions {
m[s] = struct{}{}
}
return m
}()
// ValidateTransition checks whether moving from `from` to `to` is permitted
// by the state machine. It is idempotent (from == to always passes).
// Returns an error naming both states when the transition is invalid.
func ValidateTransition(from, to PackageStatus) error {
if from == to {
return nil
}
allowed, ok := PackageStatusTransitions[from]
if !ok {
return fmt.Errorf("unknown source state %q", from)
}
for _, a := range allowed {
if a == to {
return nil
}
}
return fmt.Errorf("transition %q -> %q is not permitted", from, to)
}
// IsTerminal returns true for resting states that scan-set reconciliation must
// not automatically advance. Note:
// - `installed` is scan-stable by default but CAN reopen to pending when the
// same package reappears in a later scan (new version available). That reopen
// happens deliberately in UpdateCurrentStateInTx — not here. IsTerminal being
// true simply means the scan-set reconciler will not close an already-installed
// row a second time.
// - `failed` is resting but recoverable — operator transitions it explicitly.
// - `ignored` is transition-locked.
func (s PackageStatus) IsTerminal() bool {
switch s {
case StatusInstalled, StatusFailed, StatusIgnored:
return true
}
return false
}
// IsActive returns true for states that represent an in-flight operation.
func (s PackageStatus) IsActive() bool {
switch s {
case StatusCheckingDependencies, StatusPendingDependencies, StatusInstalling:
return true
}
return false
}
// StatusFromString parses a raw status string into a PackageStatus constant.
// Returns an error if the string is not a known state.
func StatusFromString(s string) (PackageStatus, error) {
st := PackageStatus(s)
if _, ok := allPackageStatuses[st]; ok {
return st, nil
}
return "", fmt.Errorf("unknown package status %q", s)
}
// ReconcileFromScan returns the status to set after an agent re-discovers a
// package that already has a row in current_package_state. This is the
// SQL twin of the CASE in UpdateCurrentStateInTx — keep them in sync.
//
// Rules (Target Model, RECONCILE-001):
// - ignored: preserved (operator decision, scan cannot override)
// - failed: preserved (operator recovery path; auto-reopen would mask failures)
// - installed + reappears in scan: reopen to pending (a new version is available;
// this is deliberate reactivation, not a silent terminal-preserve)
// - all active/waiting states: reset to pending (stale in-flight state)
func ReconcileFromScan(current PackageStatus) PackageStatus {
switch current {
case StatusIgnored, StatusFailed:
return current
default:
// installed → pending is now allowed (state machine updated).
// All other states (pending, approved, checking_dependencies,
// pending_dependencies, installing) also reset to pending.
return StatusPending
}
}
// HistoryStatus are the valid values for update_version_history.update_status.
type HistoryStatus string
const (
HistoryInstalled = HistoryStatus("installed")
HistoryFailed = HistoryStatus("failed")
HistoryRollback = HistoryStatus("rollback")
)

View file

@ -0,0 +1,21 @@
package models
import (
"time"
"github.com/gofrs/uuid/v5"
)
type User struct {
ID uuid.UUID `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"` // Don't include in JSON
CreatedAt time.Time `json:"created_at" db:"created_at"`
LastLogin *time.Time `json:"last_login" db:"last_login"`
}
type UserCredentials struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}