v0.2.2.0: enforce package state machine across all transitions, vuln dashboard
Route every current_package_state status change through one transitionStatus path: read the observed status, validate against PackageStatusTransitions, run a status-guarded UPDATE, record terminal history. Replaces ten raw-SQL transition functions whose WHERE guards validated nothing and silently no-op'd on an illegal state. ApproveUpdate, the Reject/Install/Set* family, BulkApprove and UpdatePackageStatus now share the core; illegal moves return a named from->to error instead of a silent miss, and concurrent callers are caught by the guarded row count. Migration 047 renames the terminal success state updated -> installed in current_package_state and update_version_history, realigning both CHECK constraints with the Go PackageStatus/HistoryStatus constants. UpdateStats updated_updates -> installed_updates to match. UpdateCurrentStateInTx documents its reconcile CASE as the SQL twin of models.ReconcileFromScan so the two stay in lockstep. Dashboard: vulnerable-package count surfaced in AttentionPanel, plus a Vulnerable quick-filter on the Updates view.
This commit is contained in:
parent
6c5c3cb6c0
commit
2a0c800659
16 changed files with 495 additions and 204 deletions
|
|
@ -21,7 +21,7 @@ services:
|
|||
context: .
|
||||
dockerfile: ./server/Dockerfile
|
||||
args:
|
||||
BUILD_VERSION: ${BUILD_VERSION:-0.2.1.3}
|
||||
BUILD_VERSION: ${BUILD_VERSION:-0.2.2.0}
|
||||
container_name: redflag-server
|
||||
volumes:
|
||||
- server-config:/app/config
|
||||
|
|
|
|||
|
|
@ -372,7 +372,7 @@ func (h *DockerHandler) RejectUpdate(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, "ignored", nil, nil); err != nil {
|
||||
if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, models.StatusIgnored, nil, nil); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject Docker update"})
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,16 +24,17 @@ func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.
|
|||
|
||||
// DashboardStats represents dashboard statistics
|
||||
type DashboardStats struct {
|
||||
TotalAgents int `json:"total_agents"`
|
||||
OnlineAgents int `json:"online_agents"`
|
||||
OfflineAgents int `json:"offline_agents"`
|
||||
PendingUpdates int `json:"pending_updates"`
|
||||
FailedUpdates int `json:"failed_updates"`
|
||||
CriticalUpdates int `json:"critical_updates"`
|
||||
ImportantUpdates int `json:"high_updates"`
|
||||
ModerateUpdates int `json:"medium_updates"`
|
||||
LowUpdates int `json:"low_updates"`
|
||||
UpdatesByType map[string]int `json:"updates_by_type"`
|
||||
TotalAgents int `json:"total_agents"`
|
||||
OnlineAgents int `json:"online_agents"`
|
||||
OfflineAgents int `json:"offline_agents"`
|
||||
PendingUpdates int `json:"pending_updates"`
|
||||
FailedUpdates int `json:"failed_updates"`
|
||||
VulnerablePackages int `json:"vulnerable_packages"`
|
||||
CriticalUpdates int `json:"critical_updates"`
|
||||
ImportantUpdates int `json:"high_updates"`
|
||||
ModerateUpdates int `json:"medium_updates"`
|
||||
LowUpdates int `json:"low_updates"`
|
||||
UpdatesByType map[string]int `json:"updates_by_type"`
|
||||
}
|
||||
|
||||
// GetDashboardStats returns dashboard statistics using aggregate queries (F-B1-6 fix)
|
||||
|
|
@ -70,5 +71,11 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
|
|||
stats.LowUpdates = updateStats.LowUpdates
|
||||
}
|
||||
|
||||
// Vulnerable packages (OSV.dev findings on non-terminal updates)
|
||||
vulnCount, err := h.updateQueries.GetVulnerablePackageCount()
|
||||
if err == nil {
|
||||
stats.VulnerablePackages = vulnCount
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
|
@ -221,8 +221,15 @@ func enqueueOSVChecks(events []models.UpdateEvent, q *queries.UpdateQueries) {
|
|||
|
||||
// ListUpdates retrieves updates with filtering using the new state table
|
||||
func (h *UpdateHandler) ListUpdates(c *gin.Context) {
|
||||
// Parse and validate optional status filter
|
||||
var statusFilter models.PackageStatus
|
||||
if s := c.Query("status"); s != "" {
|
||||
if parsed, err := models.StatusFromString(s); err == nil {
|
||||
statusFilter = parsed
|
||||
}
|
||||
}
|
||||
filters := &models.UpdateFilters{
|
||||
Status: c.Query("status"),
|
||||
Status: statusFilter,
|
||||
Severity: c.Query("severity"),
|
||||
PackageType: c.Query("package_type"),
|
||||
}
|
||||
|
|
@ -769,7 +776,7 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Update package status to 'updated' with actual completion timestamp
|
||||
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, "updated", nil, completionTime); err != nil {
|
||||
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, models.StatusInstalled, nil, completionTime); err != nil {
|
||||
log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
|
||||
} else {
|
||||
log.Printf("[INFO] [server] [updates] package_updated package=%s type=%s", packageName, packageType)
|
||||
|
|
@ -788,7 +795,7 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
|||
if err == nil && command.CommandType == models.CommandTypeConfirmDependencies {
|
||||
if packageName, ok := command.Params["package_name"].(string); ok {
|
||||
if packageType, ok := command.Params["package_type"].(string); ok {
|
||||
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, "failed", nil, nil); err != nil {
|
||||
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, models.StatusFailed, nil, nil); err != nil {
|
||||
log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
|
||||
} else {
|
||||
log.Printf("[INFO] [server] [updates] package_failed package=%s type=%s", packageName, packageType)
|
||||
|
|
@ -921,7 +928,7 @@ func (h *UpdateHandler) UpdatePackageStatus(c *gin.Context) {
|
|||
var req struct {
|
||||
PackageType string `json:"package_type" binding:"required"`
|
||||
PackageName string `json:"package_name" binding:"required"`
|
||||
Status string `json:"status" binding:"required"`
|
||||
Status models.PackageStatus `json:"status" binding:"required"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
|
|
@ -1268,7 +1275,7 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
|
|||
if _, err := h.mintResolvedClosure(update); err != nil {
|
||||
log.Printf("[SECURITY] [server] [capability] auto_mint_failed update_id=%s pkg=%s error=%v",
|
||||
update.ID, update.PackageName, err)
|
||||
if statusErr := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, "failed", models.JSONB{
|
||||
if statusErr := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, models.StatusFailed, models.JSONB{
|
||||
"capability_authorization_error": err.Error(),
|
||||
}, nil); statusErr != nil {
|
||||
log.Printf("[ERROR] [server] [capability] auto_mint_failed_status_update_failed update_id=%s error=%v",
|
||||
|
|
@ -1539,7 +1546,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
|
|||
if _, err := h.mintResolvedClosure(update); err != nil {
|
||||
log.Printf("[SECURITY] [server] [capability] confirm_mint_failed update_id=%s pkg=%s error=%v",
|
||||
update.ID, update.PackageName, err)
|
||||
if statusErr := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, "failed", models.JSONB{
|
||||
if statusErr := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, models.StatusFailed, models.JSONB{
|
||||
"capability_authorization_error": err.Error(),
|
||||
}, nil); statusErr != nil {
|
||||
log.Printf("[ERROR] [server] [capability] confirm_mint_failed_status_update_failed update_id=%s error=%v",
|
||||
|
|
@ -1981,9 +1988,9 @@ func (h *UpdateHandler) ReportCapabilityResult(c *gin.Context) {
|
|||
log.Printf("[ERROR] [server] [capability] receipt_update_load_failed token_id=%s update_id=%s error=%v",
|
||||
tokenID, updateID, err)
|
||||
} else {
|
||||
status := "failed"
|
||||
status := models.StatusFailed
|
||||
if body.Decision == "executed" && body.ExitCode == 0 {
|
||||
status = "updated"
|
||||
status = models.StatusInstalled
|
||||
}
|
||||
metadata := models.JSONB{
|
||||
"capability_token_id": tokenID.String(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
-- Revert: rename 'installed' back to 'updated'.
|
||||
|
||||
UPDATE current_package_state SET status = 'updated' WHERE status = 'installed';
|
||||
|
||||
ALTER TABLE current_package_state
|
||||
DROP CONSTRAINT IF EXISTS current_package_state_status_check;
|
||||
|
||||
ALTER TABLE current_package_state
|
||||
ADD CONSTRAINT current_package_state_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'checking_dependencies', 'pending_dependencies',
|
||||
'installing', 'updated', 'failed', 'ignored'));
|
||||
|
||||
UPDATE update_version_history SET update_status = 'updated' WHERE update_status = 'installed';
|
||||
|
||||
ALTER TABLE update_version_history
|
||||
DROP CONSTRAINT IF EXISTS update_version_history_update_status_check;
|
||||
|
||||
ALTER TABLE update_version_history
|
||||
ADD CONSTRAINT update_version_history_update_status_check
|
||||
CHECK (update_status IN ('updated', 'failed', 'rollback'));
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
-- LIFECYCLE-001: Rename terminal success state from 'updated' to 'installed'.
|
||||
-- This matches the Go PackageStatus constant StatusInstalled and what the UI expects.
|
||||
|
||||
-- Step 1: Migrate existing rows.
|
||||
UPDATE current_package_state SET status = 'installed' WHERE status = 'updated';
|
||||
|
||||
-- Step 2: Drop and recreate the CHECK constraint.
|
||||
ALTER TABLE current_package_state
|
||||
DROP CONSTRAINT IF EXISTS current_package_state_status_check;
|
||||
|
||||
ALTER TABLE current_package_state
|
||||
ADD CONSTRAINT current_package_state_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'checking_dependencies', 'pending_dependencies',
|
||||
'installing', 'installed', 'failed', 'ignored'));
|
||||
|
||||
-- Step 3: Migrate update_version_history rows.
|
||||
UPDATE update_version_history SET update_status = 'installed' WHERE update_status = 'updated';
|
||||
|
||||
-- Step 4: Update the update_version_history constraint (added by migration 041).
|
||||
ALTER TABLE update_version_history
|
||||
DROP CONSTRAINT IF EXISTS update_version_history_update_status_check;
|
||||
|
||||
ALTER TABLE update_version_history
|
||||
ADD CONSTRAINT update_version_history_update_status_check
|
||||
CHECK (update_status IN ('installed', 'failed', 'rollback'));
|
||||
|
|
@ -312,36 +312,159 @@ func (q *UpdateQueries) GetPackageFleet(packageType, packageName string) ([]Pack
|
|||
}
|
||||
|
||||
// ApproveUpdate marks an update as approved in the new event sourcing system
|
||||
// --- State machine: the single transition path ---------------------------
|
||||
//
|
||||
// Every status change on current_package_state routes through transitionStatus.
|
||||
// It reads the observed status, validates the move against models.PackageStatusTransitions,
|
||||
// performs a status-guarded UPDATE (so a concurrent caller cannot double-consume the
|
||||
// transition), and records version history on terminal moves. The raw-SQL WHERE guards
|
||||
// that used to live in each function were a half-measure: no named error, no idempotent
|
||||
// self-check, and silently opt-out for any future caller. This is the one path now.
|
||||
|
||||
// statusSelector identifies which current_package_state row to transition: either by
|
||||
// primary key (id) or by the (agent_id, package_type, package_name) natural key.
|
||||
type statusSelector struct {
|
||||
byID bool
|
||||
id uuid.UUID
|
||||
agentID uuid.UUID
|
||||
packageType string
|
||||
packageName string
|
||||
}
|
||||
|
||||
func selByID(id uuid.UUID) statusSelector { return statusSelector{byID: true, id: id} }
|
||||
|
||||
func selByPackage(agentID uuid.UUID, packageType, packageName string) statusSelector {
|
||||
return statusSelector{agentID: agentID, packageType: packageType, packageName: packageName}
|
||||
}
|
||||
|
||||
// where renders the row-matching predicate using positional placeholders starting at
|
||||
// $start, returning the SQL fragment and its arguments in order.
|
||||
func (s statusSelector) where(start int) (string, []interface{}) {
|
||||
if s.byID {
|
||||
return fmt.Sprintf("id = $%d", start), []interface{}{s.id}
|
||||
}
|
||||
return fmt.Sprintf("agent_id = $%d AND package_type = $%d AND package_name = $%d", start, start+1, start+2),
|
||||
[]interface{}{s.agentID, s.packageType, s.packageName}
|
||||
}
|
||||
|
||||
func (s statusSelector) String() string {
|
||||
if s.byID {
|
||||
return "id=" + s.id.String()
|
||||
}
|
||||
return s.packageType + "/" + s.packageName
|
||||
}
|
||||
|
||||
// transitionOpts carries the per-call extras the gold path (UpdatePackageStatus) needs:
|
||||
// a caller-supplied completion timestamp and the metadata to stamp into version history.
|
||||
// Both are zero-valued for the routine transition functions.
|
||||
type transitionOpts struct {
|
||||
completedAt *time.Time
|
||||
historyMeta models.JSONB // nil → fall back to the row's current metadata
|
||||
}
|
||||
|
||||
// transitionStatus is the validated, race-guarded core. It loads the selected row,
|
||||
// validates current -> to via the state machine, performs the guarded UPDATE, and
|
||||
// records terminal history. Returns the pre-transition row so callers can attach
|
||||
// follow-up writes (metadata, dependency closures) within the same transaction.
|
||||
func (q *UpdateQueries) transitionStatus(tx *sqlx.Tx, sel statusSelector, to models.PackageStatus, opts transitionOpts) (models.UpdateState, error) {
|
||||
var cur models.UpdateState
|
||||
selWhere, selArgs := sel.where(1)
|
||||
if err := tx.Get(&cur, `SELECT * FROM current_package_state WHERE `+selWhere, selArgs...); err != nil {
|
||||
return cur, fmt.Errorf("transition (%s): load current state: %w", sel, err)
|
||||
}
|
||||
|
||||
if err := models.ValidateTransition(cur.Status, to); err != nil {
|
||||
return cur, fmt.Errorf("transition %s: %w", sel, err)
|
||||
}
|
||||
|
||||
// Guarded UPDATE — $1 is the new status, the trailing placeholder re-checks the
|
||||
// status we observed so a racing caller that already advanced the row yields rows == 0.
|
||||
updWhere, updArgs := sel.where(2)
|
||||
args := append([]interface{}{to}, updArgs...)
|
||||
args = append(args, cur.Status)
|
||||
updQuery := fmt.Sprintf(
|
||||
`UPDATE current_package_state SET status = $1, last_updated_at = NOW() WHERE %s AND status = $%d`,
|
||||
updWhere, len(args))
|
||||
res, err := tx.Exec(updQuery, args...)
|
||||
if err != nil {
|
||||
return cur, fmt.Errorf("transition %s: update: %w", sel, err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return cur, fmt.Errorf("transition %s -> %q: row changed concurrently", sel, to)
|
||||
}
|
||||
|
||||
// Record terminal history only on an actual change of status (idempotent re-entry
|
||||
// at the same terminal state must not double-write history).
|
||||
if (to == models.StatusInstalled || to == models.StatusFailed) && cur.Status != to {
|
||||
completedAt := time.Now().UTC()
|
||||
if opts.completedAt != nil {
|
||||
completedAt = *opts.completedAt
|
||||
}
|
||||
meta := opts.historyMeta
|
||||
if meta == nil {
|
||||
meta = cur.Metadata
|
||||
}
|
||||
if err := q.recordTerminalHistory(tx, cur, to, completedAt, meta); err != nil {
|
||||
return cur, err
|
||||
}
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
// recordTerminalHistory appends a row to update_version_history for a terminal transition.
|
||||
func (q *UpdateQueries) recordTerminalHistory(tx *sqlx.Tx, cur models.UpdateState, to models.PackageStatus, completedAt time.Time, meta models.JSONB) error {
|
||||
const historyQuery = `
|
||||
INSERT INTO update_version_history (
|
||||
agent_id, package_type, package_name, version_from, version_to,
|
||||
severity, repository_source, metadata, update_completed_at, update_status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
|
||||
_, err := tx.Exec(historyQuery,
|
||||
cur.AgentID, cur.PackageType, cur.PackageName, cur.CurrentVersion,
|
||||
cur.AvailableVersion, cur.Severity, cur.RepositorySource, meta, completedAt, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record version history: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runTransition opens a transaction, applies a single transition, runs an optional
|
||||
// follow-up write in the same transaction, and commits.
|
||||
func (q *UpdateQueries) runTransition(sel statusSelector, to models.PackageStatus, after func(tx *sqlx.Tx, cur models.UpdateState) error) error {
|
||||
tx, err := q.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
cur, err := q.transitionStatus(tx, sel, to, transitionOpts{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if after != nil {
|
||||
if err := after(tx, cur); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (q *UpdateQueries) ApproveUpdate(id uuid.UUID, approvedBy string) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'approved', last_updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
`
|
||||
_, err := q.db.Exec(query, id)
|
||||
return err
|
||||
return q.runTransition(selByID(id), models.StatusApproved, nil)
|
||||
}
|
||||
|
||||
// ApproveUpdateWithVulns approves an update and stores supply chain metadata.
|
||||
func (q *UpdateQueries) ApproveUpdateWithVulns(id uuid.UUID, approvedBy string, metadata models.JSONB) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'approved', last_updated_at = NOW(), metadata = $2::jsonb
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
`
|
||||
_, err := q.db.Exec(query, id, metadata)
|
||||
return err
|
||||
return q.runTransition(selByID(id), models.StatusApproved, func(tx *sqlx.Tx, cur models.UpdateState) error {
|
||||
if _, err := tx.Exec(`UPDATE current_package_state SET metadata = $2::jsonb WHERE id = $1`, id, metadata); err != nil {
|
||||
return fmt.Errorf("store supply chain metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ApproveUpdateByPackage approves an update by agent_id, package_type, and package_name
|
||||
func (q *UpdateQueries) ApproveUpdateByPackage(agentID uuid.UUID, packageType, packageName, approvedBy string) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'approved', last_updated_at = NOW()
|
||||
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3 AND status = 'pending'
|
||||
`
|
||||
_, err := q.db.Exec(query, agentID, packageType, packageName)
|
||||
return err
|
||||
return q.runTransition(selByPackage(agentID, packageType, packageName), models.StatusApproved, nil)
|
||||
}
|
||||
|
||||
// BulkApproveUpdates approves multiple updates by their IDs
|
||||
|
|
@ -357,15 +480,8 @@ func (q *UpdateQueries) BulkApproveUpdates(updateIDs []uuid.UUID, approvedBy str
|
|||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Update each update
|
||||
for _, id := range updateIDs {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'approved', last_updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
`
|
||||
_, err := tx.Exec(query, id)
|
||||
if err != nil {
|
||||
if _, err := q.transitionStatus(tx, selByID(id), models.StatusApproved, transitionOpts{}); err != nil {
|
||||
return fmt.Errorf("failed to approve update %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -375,46 +491,22 @@ func (q *UpdateQueries) BulkApproveUpdates(updateIDs []uuid.UUID, approvedBy str
|
|||
|
||||
// RejectUpdate marks an update as rejected/ignored
|
||||
func (q *UpdateQueries) RejectUpdate(id uuid.UUID, rejectedBy string) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'ignored', last_updated_at = NOW()
|
||||
WHERE id = $1 AND status IN ('pending', 'approved')
|
||||
`
|
||||
_, err := q.db.Exec(query, id)
|
||||
return err
|
||||
return q.runTransition(selByID(id), models.StatusIgnored, nil)
|
||||
}
|
||||
|
||||
// RejectUpdateByPackage rejects an update by agent_id, package_type, and package_name
|
||||
func (q *UpdateQueries) RejectUpdateByPackage(agentID uuid.UUID, packageType, packageName, rejectedBy string) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'ignored', last_updated_at = NOW()
|
||||
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3 AND status IN ('pending', 'approved')
|
||||
`
|
||||
_, err := q.db.Exec(query, agentID, packageType, packageName)
|
||||
return err
|
||||
return q.runTransition(selByPackage(agentID, packageType, packageName), models.StatusIgnored, nil)
|
||||
}
|
||||
|
||||
// InstallUpdate marks an update as ready for installation
|
||||
func (q *UpdateQueries) InstallUpdate(id uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'installing', last_updated_at = NOW()
|
||||
WHERE id = $1 AND status IN ('approved', 'pending_dependencies')
|
||||
`
|
||||
_, err := q.db.Exec(query, id)
|
||||
return err
|
||||
return q.runTransition(selByID(id), models.StatusInstalling, nil)
|
||||
}
|
||||
|
||||
// SetCheckingDependencies marks an update as being checked for dependencies
|
||||
func (q *UpdateQueries) SetCheckingDependencies(id uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'checking_dependencies', last_updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'approved'
|
||||
`
|
||||
_, err := q.db.Exec(query, id)
|
||||
return err
|
||||
return q.runTransition(selByID(id), models.StatusCheckingDependencies, nil)
|
||||
}
|
||||
|
||||
// SetPendingDependencies stores dependency information and sets status based on whether dependencies exist
|
||||
|
|
@ -431,20 +523,20 @@ func (q *UpdateQueries) SetPendingDependencies(agentID uuid.UUID, packageType, p
|
|||
// Note: When dependencies array is empty, the handler should bypass this status change
|
||||
// and proceed directly to installation. This function still records the empty array
|
||||
// in metadata for audit purposes before the handler transitions to 'installing'.
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'pending_dependencies',
|
||||
metadata = jsonb_set(
|
||||
jsonb_set(metadata, '{dependencies}', $4::jsonb),
|
||||
'{dependencies_reported_at}',
|
||||
to_jsonb(NOW())
|
||||
),
|
||||
last_updated_at = NOW()
|
||||
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3
|
||||
AND status IN ('checking_dependencies', 'installing')
|
||||
`
|
||||
_, err = q.db.Exec(query, agentID, packageType, packageName, depsJSON)
|
||||
return err
|
||||
return q.runTransition(selByPackage(agentID, packageType, packageName), models.StatusPendingDependencies,
|
||||
func(tx *sqlx.Tx, cur models.UpdateState) error {
|
||||
_, err := tx.Exec(`
|
||||
UPDATE current_package_state
|
||||
SET metadata = jsonb_set(
|
||||
jsonb_set(metadata, '{dependencies}', $4::jsonb),
|
||||
'{dependencies_reported_at}', to_jsonb(NOW()))
|
||||
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3`,
|
||||
agentID, packageType, packageName, depsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store reported dependencies: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetInstallingWithNoDependencies records zero dependencies and transitions directly to installing
|
||||
|
|
@ -455,19 +547,18 @@ func (q *UpdateQueries) SetInstallingWithNoDependencies(id uuid.UUID, dependenci
|
|||
return fmt.Errorf("failed to marshal dependencies: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE current_package_state
|
||||
SET status = 'installing',
|
||||
metadata = jsonb_set(
|
||||
jsonb_set(metadata, '{dependencies}', $2::jsonb),
|
||||
'{dependencies_reported_at}',
|
||||
to_jsonb(NOW())
|
||||
),
|
||||
last_updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'checking_dependencies'
|
||||
`
|
||||
_, err = q.db.Exec(query, id, depsJSON)
|
||||
return err
|
||||
return q.runTransition(selByID(id), models.StatusInstalling, func(tx *sqlx.Tx, cur models.UpdateState) error {
|
||||
_, err := tx.Exec(`
|
||||
UPDATE current_package_state
|
||||
SET metadata = jsonb_set(
|
||||
jsonb_set(metadata, '{dependencies}', $2::jsonb),
|
||||
'{dependencies_reported_at}', to_jsonb(NOW()))
|
||||
WHERE id = $1`, id, depsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store dependency closure: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateUpdateLog inserts an update log entry
|
||||
|
|
@ -605,8 +696,13 @@ func (q *UpdateQueries) UpdateCurrentStateInTx(tx *sqlx.Tx, event *models.Update
|
|||
repository_source = EXCLUDED.repository_source,
|
||||
metadata = EXCLUDED.metadata,
|
||||
last_discovered_at = EXCLUDED.last_discovered_at,
|
||||
-- Re-discovery reconciliation: this CASE is the SQL twin of
|
||||
-- models.ReconcileFromScan — terminal states (installed/ignored/failed)
|
||||
-- are preserved, everything else resets to pending. It is an upsert that
|
||||
-- may create the row, so it cannot route through transitionStatus; keep
|
||||
-- the two in lockstep if the terminal set changes.
|
||||
status = CASE
|
||||
WHEN current_package_state.status IN ('updated', 'ignored')
|
||||
WHEN current_package_state.status IN ('installed', 'ignored', 'failed')
|
||||
THEN current_package_state.status
|
||||
ELSE 'pending'
|
||||
END
|
||||
|
|
@ -656,14 +752,14 @@ func (q *UpdateQueries) ListUpdatesFromState(filters *models.UpdateFilters) ([]m
|
|||
sd = sd.Where(goqu.Ex{"severity": filters.Severity})
|
||||
}
|
||||
if filters.Status != "" {
|
||||
statuses := strings.Split(filters.Status, ",")
|
||||
statuses := strings.Split(string(filters.Status), ",")
|
||||
if len(statuses) == 1 {
|
||||
sd = sd.Where(goqu.Ex{"status": statuses[0]})
|
||||
} else {
|
||||
sd = sd.Where(goqu.Ex{"status": statuses})
|
||||
}
|
||||
} else {
|
||||
sd = sd.Where(goqu.C("status").NotIn("updated", "ignored"))
|
||||
sd = sd.Where(goqu.C("status").NotIn("installed", "ignored"))
|
||||
}
|
||||
|
||||
total, err := Paginated(q.db, sd, uint(filters.Page), uint(filters.PageSize),
|
||||
|
|
@ -702,55 +798,20 @@ func (q *UpdateQueries) GetPackageHistory(agentID uuid.UUID, packageType, packag
|
|||
return history, nil
|
||||
}
|
||||
|
||||
// UpdatePackageStatus updates the status of a package and records history
|
||||
// UpdatePackageStatus updates the status of a package and records history.
|
||||
// Transition validation is performed via models.ValidateTransition; the SQL
|
||||
// UPDATE is guarded on the current status to prevent races.
|
||||
// completedAt is optional - if nil, uses time.Now().UTC(). Pass actual completion time for accurate audit trails.
|
||||
func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName, status string, metadata models.JSONB, completedAt *time.Time) error {
|
||||
func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName string, status models.PackageStatus, metadata models.JSONB, completedAt *time.Time) error {
|
||||
tx, err := q.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Get current state
|
||||
var currentState models.UpdateState
|
||||
query := `SELECT * FROM current_package_state WHERE agent_id = $1 AND package_type = $2 AND package_name = $3`
|
||||
err = tx.Get(¤tState, query, agentID, packageType, packageName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current state: %w", err)
|
||||
}
|
||||
|
||||
// Use provided timestamp or fall back to server time
|
||||
timestamp := time.Now().UTC()
|
||||
if completedAt != nil {
|
||||
timestamp = *completedAt
|
||||
}
|
||||
|
||||
// Update status
|
||||
updateQuery := `
|
||||
UPDATE current_package_state
|
||||
SET status = $1, last_updated_at = $2
|
||||
WHERE agent_id = $3 AND package_type = $4 AND package_name = $5
|
||||
`
|
||||
_, err = tx.Exec(updateQuery, status, timestamp, agentID, packageType, packageName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update package status: %w", err)
|
||||
}
|
||||
|
||||
// Record in history if this is an update completion
|
||||
if status == "updated" || status == "failed" {
|
||||
historyQuery := `
|
||||
INSERT INTO update_version_history (
|
||||
agent_id, package_type, package_name, version_from, version_to,
|
||||
severity, repository_source, metadata, update_completed_at, update_status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`
|
||||
_, err = tx.Exec(historyQuery,
|
||||
agentID, packageType, packageName, currentState.CurrentVersion,
|
||||
currentState.AvailableVersion, currentState.Severity,
|
||||
currentState.RepositorySource, metadata, timestamp, status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record version history: %w", err)
|
||||
}
|
||||
if _, err := q.transitionStatus(tx, selByPackage(agentID, packageType, packageName), status,
|
||||
transitionOpts{completedAt: completedAt, historyMeta: metadata}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
|
|
@ -798,7 +859,7 @@ func (q *UpdateQueries) GetUpdateStatsFromState(agentID uuid.UUID) (*models.Upda
|
|||
SELECT
|
||||
COUNT(*) as total_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'updated') as updated_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'installed') as installed_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'critical') as critical_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'important') as important_updates,
|
||||
|
|
@ -825,7 +886,7 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
|
|||
COUNT(*) as total_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'approved') as approved_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'updated') as updated_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'installed') as installed_updates,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'critical') as critical_updates,
|
||||
COUNT(*) FILTER (WHERE severity = 'important') as high_updates,
|
||||
|
|
@ -842,6 +903,28 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
|
|||
return stats, nil
|
||||
}
|
||||
|
||||
// GetVulnerablePackageCount returns the number of distinct packages that have
|
||||
// known CVEs from OSV.dev. A package is counted if its metadata contains a
|
||||
// non-empty, non-null supply_chain_vulns field.
|
||||
func (q *UpdateQueries) GetVulnerablePackageCount() (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT DISTINCT package_type, package_name
|
||||
FROM current_package_state
|
||||
WHERE metadata IS NOT NULL
|
||||
AND metadata ? 'supply_chain_vulns'
|
||||
AND metadata->>'supply_chain_vulns' != '[]'
|
||||
AND metadata->>'supply_chain_vulns' != ''
|
||||
AND status NOT IN ('installed', 'ignored', 'failed')
|
||||
) vuln_packages
|
||||
`
|
||||
var count int
|
||||
if err := q.db.Get(&count, query); err != nil {
|
||||
return 0, fmt.Errorf("failed to get vulnerable package count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetUpdateLogs retrieves installation logs for a specific update
|
||||
func (q *UpdateQueries) GetUpdateLogs(updateID uuid.UUID, limit int) ([]models.UpdateLog, error) {
|
||||
var logs []models.UpdateLog
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ type InstallResult struct {
|
|||
// UpdateFilters for querying updates
|
||||
type UpdateFilters struct {
|
||||
AgentID uuid.UUID
|
||||
Status string
|
||||
Status PackageStatus
|
||||
Severity string
|
||||
PackageType string
|
||||
Page int
|
||||
|
|
@ -127,19 +127,19 @@ type UpdateEvent struct {
|
|||
|
||||
// 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 string `json:"status" db:"status"`
|
||||
ExpectedSHA256 *string `json:"expected_sha256" db:"expected_sha256"` // Layer 1: Hash Registry
|
||||
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
|
||||
}
|
||||
|
||||
// PackageVersion is one row of the version timeline catalog: a single
|
||||
|
|
@ -163,19 +163,19 @@ type PackageVersion struct {
|
|||
|
||||
// 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 string `json:"update_status" db:"update_status"`
|
||||
FailureReason string `json:"failure_reason" db:"failure_reason"`
|
||||
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
|
||||
|
|
@ -196,7 +196,7 @@ 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"`
|
||||
UpdatedUpdates int `json:"updated_updates" db:"updated_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"`
|
||||
|
|
@ -217,14 +217,14 @@ type LogFilters struct {
|
|||
|
||||
// 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 string `json:"status" db:"status"`
|
||||
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
|
||||
Metadata JSONB `json:"metadata" db:"metadata"`
|
||||
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"`
|
||||
}
|
||||
|
|
|
|||
108
server/internal/models/update_state.go
Normal file
108
server/internal/models/update_state.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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{
|
||||
StatusPending: {StatusApproved, StatusIgnored},
|
||||
StatusApproved: {StatusCheckingDependencies, StatusInstalling, StatusIgnored, StatusFailed},
|
||||
StatusCheckingDependencies: {StatusPendingDependencies, StatusInstalling, StatusFailed},
|
||||
StatusPendingDependencies: {StatusInstalling, StatusFailed},
|
||||
StatusInstalling: {StatusInstalled, StatusFailed, StatusPendingDependencies},
|
||||
StatusInstalled: {}, // terminal
|
||||
StatusFailed: {}, // terminal
|
||||
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 states that cannot transition away from.
|
||||
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. Terminal states (installed, failed, ignored)
|
||||
// are preserved; everything else resets to pending.
|
||||
func ReconcileFromScan(current PackageStatus) PackageStatus {
|
||||
if current.IsTerminal() {
|
||||
return current
|
||||
}
|
||||
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")
|
||||
)
|
||||
|
||||
|
|
@ -235,7 +235,7 @@ func (ts *TimeoutService) updateRelatedPackageStatus(command *models.AgentComman
|
|||
return ts.updateQueries.UpdatePackageStatus(command.AgentID,
|
||||
command.Params["package_type"].(string),
|
||||
command.Params["package_name"].(string),
|
||||
"failed",
|
||||
models.StatusFailed,
|
||||
metadata,
|
||||
nil) // nil = use time.Now().UTC() for timeout operations
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import (
|
|||
|
||||
// Build-time injected version information (SERVER AUTHORITY)
|
||||
var (
|
||||
AgentVersion = "0.2.1.3"
|
||||
ConfigVersion = "0.2.1.3"
|
||||
AgentVersion = "0.2.2.0"
|
||||
ConfigVersion = "0.2.2.0"
|
||||
MinAgentVersion = "0.1.22"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ interface LogResponse {
|
|||
const TAB_GROUPS: { key: string; label: string; statuses: string }[] = [
|
||||
{ key: 'needs-review', label: 'Needs Review', statuses: 'pending' },
|
||||
{ key: 'in-progress', label: 'In Progress', statuses: 'approved,checking_dependencies,pending_dependencies,installing' },
|
||||
{ key: 'installed', label: 'Installed', statuses: 'updated' },
|
||||
{ key: 'installed', label: 'Installed', statuses: 'installed' },
|
||||
{ key: 'failed-ignored', label: 'Failed / Ignored', statuses: 'failed,ignored' },
|
||||
];
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ const STATUS_META: Record<string, { label: string; icon: React.ReactNode; class:
|
|||
checking_dependencies: { label: 'Checking Deps', icon: <Loader2 className="h-3 w-3 animate-spin" />, class: 'text-yellow-600 bg-yellow-100' },
|
||||
pending_dependencies: { label: 'Deps Pending', icon: <AlertTriangle className="h-3 w-3" />, class: 'text-orange-600 bg-orange-100' },
|
||||
installing: { label: 'Installing', icon: <RefreshCw className="h-3 w-3 animate-spin" />, class: 'text-purple-600 bg-purple-100' },
|
||||
updated: { label: 'Installed', icon: <CheckCircle className="h-3 w-3" />, class: 'text-green-600 bg-green-100' },
|
||||
installed: { label: 'Installed', icon: <CheckCircle className="h-3 w-3" />, class: 'text-green-600 bg-green-100' },
|
||||
failed: { label: 'Failed', icon: <XCircle className="h-3 w-3" />, class: 'text-red-600 bg-red-100' },
|
||||
ignored: { label: 'Ignored', icon: <X className="h-3 w-3" />, class: 'text-gray-500 bg-gray-50' },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
|||
import {
|
||||
AlertTriangle,
|
||||
AlertOctagon,
|
||||
ShieldAlert,
|
||||
XCircle,
|
||||
GitBranch,
|
||||
ChevronRight,
|
||||
|
|
@ -10,9 +11,10 @@ import {
|
|||
import { useDashboardStats } from '@/hooks/useStats';
|
||||
import { useDriftedSoftware, useRecentDriftEvents } from '@/hooks/useUpstream';
|
||||
|
||||
// Severity ranks: eol (100) > failed (80) > major drift (70) > minor drift (40) > patch (20) > metadata (10)
|
||||
// Severity ranks: eol (100) > vuln (90) > failed (80) > major drift (70) > minor drift (40) > patch (20) > metadata (10)
|
||||
const SEVERITY_RANK: Record<string, number> = {
|
||||
eol: 100,
|
||||
vuln: 90,
|
||||
failed: 80,
|
||||
major: 70,
|
||||
minor: 40,
|
||||
|
|
@ -48,6 +50,18 @@ const AttentionPanel: React.FC = () => {
|
|||
});
|
||||
}
|
||||
|
||||
// 1b. Packages with known CVEs (OSV.dev findings)
|
||||
if (stats && stats.vulnerable_packages > 0) {
|
||||
alerts.push({
|
||||
key: 'vulnerable-packages',
|
||||
severity: 'vuln',
|
||||
title: `${stats.vulnerable_packages} package${stats.vulnerable_packages === 1 ? '' : 's'} with known CVEs`,
|
||||
detail: 'Review vulnerabilities before approving updates',
|
||||
href: '/updates?vuln=true',
|
||||
icon: ShieldAlert,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. EOL software (drifted + past EOL date) - highest priority
|
||||
if (drifted) {
|
||||
const eolPassed = drifted.filter((s) => s.eol_at && new Date(s.eol_at) < new Date());
|
||||
|
|
@ -100,24 +114,26 @@ const AttentionPanel: React.FC = () => {
|
|||
{top.map((a) => {
|
||||
const Icon = a.icon;
|
||||
const eol = a.severity === 'eol';
|
||||
const vuln = a.severity === 'vuln';
|
||||
const critical = eol || vuln;
|
||||
return (
|
||||
<li key={a.key}>
|
||||
<Link
|
||||
to={a.href ?? '#'}
|
||||
className={`flex items-start gap-3 p-2 rounded hover:bg-amber-100 ${eol ? 'bg-red-50' : ''}`}
|
||||
className={`flex items-start gap-3 p-2 rounded hover:bg-amber-100 ${eol ? 'bg-red-50' : vuln ? 'bg-orange-50' : ''}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${eol ? 'text-red-600' : 'text-amber-700'}`} />
|
||||
<Icon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${eol ? 'text-red-600' : vuln ? 'text-orange-600' : 'text-amber-700'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${eol ? 'text-red-900' : 'text-amber-900'}`}>
|
||||
<p className={`text-sm font-medium ${eol ? 'text-red-900' : vuln ? 'text-orange-900' : 'text-amber-900'}`}>
|
||||
{a.title}
|
||||
</p>
|
||||
{a.detail && (
|
||||
<p className={`text-xs ${eol ? 'text-red-700' : 'text-amber-700'} truncate`}>
|
||||
<p className={`text-xs ${eol ? 'text-red-700' : vuln ? 'text-orange-700' : 'text-amber-700'} truncate`}>
|
||||
{a.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={`w-4 h-4 flex-shrink-0 mt-1 ${eol ? 'text-red-400' : 'text-amber-400'}`} />
|
||||
<ChevronRight className={`w-4 h-4 flex-shrink-0 mt-1 ${eol ? 'text-red-400' : vuln ? 'text-orange-400' : 'text-amber-400'}`} />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -128,7 +128,6 @@ export const getStatusColor = (status: string): string => {
|
|||
case 'pending_dependencies':
|
||||
return 'text-orange-600 bg-orange-100';
|
||||
case 'approved':
|
||||
case 'scheduled':
|
||||
return 'text-info-600 bg-info-100';
|
||||
case 'installing':
|
||||
return 'text-indigo-600 bg-indigo-100';
|
||||
|
|
@ -136,6 +135,8 @@ export const getStatusColor = (status: string): string => {
|
|||
return 'text-success-600 bg-success-100';
|
||||
case 'failed':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
case 'ignored':
|
||||
return 'text-gray-500 bg-gray-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
ArrowUp,
|
||||
ArrowDown,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
GitBranch,
|
||||
Activity,
|
||||
HardDrive,
|
||||
|
|
@ -46,6 +47,7 @@ const Updates: React.FC = () => {
|
|||
const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || '');
|
||||
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
||||
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
|
||||
const [vulnFilter, setVulnFilter] = useState(searchParams.get('vuln') === 'true');
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || '');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(searchParams.get('sort_order') as 'asc' | 'desc' || 'desc');
|
||||
|
||||
|
|
@ -80,6 +82,7 @@ const Updates: React.FC = () => {
|
|||
if (severityFilter) params.set('severity', severityFilter);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
if (agentFilter) params.set('agent', agentFilter);
|
||||
if (vulnFilter) params.set('vuln', 'true');
|
||||
if (sortBy) params.set('sort_by', sortBy);
|
||||
if (sortOrder) params.set('sort_order', sortOrder);
|
||||
if (currentPage > 1) params.set('page', currentPage.toString());
|
||||
|
|
@ -147,6 +150,7 @@ const Updates: React.FC = () => {
|
|||
const packageTotal = packagesData?.total || 0;
|
||||
const displayedPackages = packages.filter((p) => {
|
||||
if (severityFilter && p.max_severity !== severityFilter) return false;
|
||||
if (vulnFilter && !p.has_vulns) return false;
|
||||
return true;
|
||||
});
|
||||
const packageTotalPages = Math.ceil(packageTotal / pageSize);
|
||||
|
|
@ -316,12 +320,18 @@ const Updates: React.FC = () => {
|
|||
setStatusFilter('pending_dependencies');
|
||||
setSeverityFilter('');
|
||||
break;
|
||||
case 'vuln':
|
||||
setVulnFilter(true);
|
||||
setStatusFilter('');
|
||||
setSeverityFilter('');
|
||||
break;
|
||||
default:
|
||||
// Clear all filters
|
||||
setStatusFilter('');
|
||||
setSeverityFilter('');
|
||||
setTypeFilter('');
|
||||
setAgentFilter('');
|
||||
setVulnFilter(false);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
|
|
@ -438,7 +448,8 @@ const Updates: React.FC = () => {
|
|||
if (selectedUpdate.status === 'installed') return 3;
|
||||
if (selectedUpdate.status === 'installing') return 2;
|
||||
if (selectedUpdate.status === 'failed') return 2; // failed during install
|
||||
if (['approved', 'scheduled', 'checking_dependencies', 'pending_dependencies'].includes(selectedUpdate.status)) return 1;
|
||||
if (['approved', 'checking_dependencies', 'pending_dependencies'].includes(selectedUpdate.status)) return 1;
|
||||
if (selectedUpdate.status === 'ignored') return -1; // terminal, not highlighted
|
||||
return 0;
|
||||
})();
|
||||
const isFailed = selectedUpdate.status === 'failed';
|
||||
|
|
@ -1444,7 +1455,7 @@ const Updates: React.FC = () => {
|
|||
onClick={() => handleQuickFilter('all')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
!statusFilter && !severityFilter && !typeFilter && !agentFilter
|
||||
!statusFilter && !severityFilter && !typeFilter && !agentFilter && !vulnFilter
|
||||
? "bg-primary-100 border-primary-300 text-primary-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
|
|
@ -1535,6 +1546,18 @@ const Updates: React.FC = () => {
|
|||
<AlertTriangle className="h-4 w-4 mr-1 inline" />
|
||||
Dependencies
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickFilter('vuln')}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
|
||||
vulnFilter
|
||||
? "bg-orange-100 border-orange-300 text-orange-700"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<ShieldAlert className="h-4 w-4 mr-1 inline" />
|
||||
Vulnerable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export interface UpdatePackage {
|
|||
current_version: string;
|
||||
available_version: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'pending' | 'approved' | 'scheduled' | 'installing' | 'installed' | 'failed' | 'checking_dependencies' | 'pending_dependencies';
|
||||
status: 'pending' | 'approved' | 'checking_dependencies' | 'pending_dependencies' | 'installing' | 'installed' | 'failed' | 'ignored';
|
||||
// Timestamp fields - matching backend API response
|
||||
last_discovered_at: string; // When package was first discovered
|
||||
last_updated_at: string; // When package status was last updated
|
||||
|
|
@ -232,6 +232,7 @@ export interface DashboardStats {
|
|||
approved_updates: number;
|
||||
installed_updates: number;
|
||||
failed_updates: number;
|
||||
vulnerable_packages: number;
|
||||
critical_updates: number;
|
||||
high_updates: number;
|
||||
medium_updates: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue