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:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
243
server/internal/models/agent.go
Normal file
243
server/internal/models/agent.go
Normal 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)
|
||||
}
|
||||
68
server/internal/models/agent_update.go
Normal file
68
server/internal/models/agent_update.go
Normal 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"`
|
||||
}
|
||||
171
server/internal/models/command.go
Normal file
171
server/internal/models/command.go
Normal 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"`
|
||||
}
|
||||
227
server/internal/models/docker.go
Normal file
227
server/internal/models/docker.go
Normal 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"`
|
||||
}
|
||||
58
server/internal/models/inventory.go
Normal file
58
server/internal/models/inventory.go
Normal 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
|
||||
}
|
||||
29
server/internal/models/maintenance_window_model.go
Normal file
29
server/internal/models/maintenance_window_model.go
Normal 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"`
|
||||
}
|
||||
81
server/internal/models/metrics.go
Normal file
81
server/internal/models/metrics.go
Normal 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"`
|
||||
}
|
||||
189
server/internal/models/process.go
Normal file
189
server/internal/models/process.go
Normal 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"`
|
||||
}
|
||||
128
server/internal/models/reconcile_test.go
Normal file
128
server/internal/models/reconcile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
113
server/internal/models/security_event.go
Normal file
113
server/internal/models/security_event.go
Normal 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
|
||||
}
|
||||
32
server/internal/models/security_settings.go
Normal file
32
server/internal/models/security_settings.go
Normal 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"`
|
||||
}
|
||||
20
server/internal/models/signing_key.go
Normal file
20
server/internal/models/signing_key.go
Normal 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"`
|
||||
}
|
||||
40
server/internal/models/storage_metrics.go
Normal file
40
server/internal/models/storage_metrics.go
Normal 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"`
|
||||
}
|
||||
50
server/internal/models/subsystem.go
Normal file
50
server/internal/models/subsystem.go
Normal 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
|
||||
}
|
||||
53
server/internal/models/supply_chain_gate.go
Normal file
53
server/internal/models/supply_chain_gate.go
Normal 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")
|
||||
}
|
||||
89
server/internal/models/system_event.go
Normal file
89
server/internal/models/system_event.go
Normal 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"
|
||||
)
|
||||
149
server/internal/models/tracked_software.go
Normal file
149
server/internal/models/tracked_software.go
Normal 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"`
|
||||
}
|
||||
391
server/internal/models/update.go
Normal file
391
server/internal/models/update.go
Normal 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"`
|
||||
}
|
||||
144
server/internal/models/update_state.go
Normal file
144
server/internal/models/update_state.go
Normal 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")
|
||||
)
|
||||
|
||||
21
server/internal/models/user.go
Normal file
21
server/internal/models/user.go
Normal 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"`
|
||||
}
|
||||
Loading…
Reference in a new issue