package db import ( "context" "encoding/json" "fmt" "time" "github.com/jackc/pgx/v5" ) // InstanceRow is the persisted form of an instance record. type InstanceRow struct { InstanceID string AgentID string ModuleID string ModuleVersion string RunMode string DataPath string ConfigValues map[string]string // AssignedPorts maps manifest port name → host port the panel // allocated. Persisted so stopped instances still reserve their // slots — net.Listen probing can't see ports for instances that // aren't currently running, and would otherwise hand the same // port to a new instance. AssignedPorts map[string]uint32 Status string // 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'updating' LastExitCode int32 Detail string CreatedAt time.Time UpdatedAt time.Time } // UpsertInstance inserts or updates an instance by instance_id. Used from // CreateInstance — it's idempotent so retry is safe. func (db *DB) UpsertInstance(ctx context.Context, r InstanceRow) error { cfgJSON, err := json.Marshal(r.ConfigValues) if err != nil { return fmt.Errorf("marshal config_values: %w", err) } if r.AssignedPorts == nil { r.AssignedPorts = map[string]uint32{} } portsJSON, err := json.Marshal(r.AssignedPorts) if err != nil { return fmt.Errorf("marshal assigned_ports: %w", err) } _, err = db.pool.Exec(ctx, ` INSERT INTO instances (instance_id, agent_id, module_id, module_version, run_mode, data_path, config_values, assigned_ports, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (instance_id) DO UPDATE SET agent_id = EXCLUDED.agent_id, module_id = EXCLUDED.module_id, module_version = EXCLUDED.module_version, run_mode = EXCLUDED.run_mode, data_path = EXCLUDED.data_path, config_values = EXCLUDED.config_values, assigned_ports = EXCLUDED.assigned_ports, status = EXCLUDED.status, updated_at = NOW() `, r.InstanceID, r.AgentID, r.ModuleID, r.ModuleVersion, r.RunMode, r.DataPath, cfgJSON, portsJSON, r.Status) if err != nil { return fmt.Errorf("upsert instance: %w", err) } return nil } // UpdateInstanceConfigValues replaces ONLY the config_values column. Used by // the durable config-render path, which must not touch status / data_path / // assigned_ports the way the full UpsertInstance does (a config save racing // an instance start could otherwise stomp a live status transition). func (db *DB) UpdateInstanceConfigValues(ctx context.Context, instanceID string, values map[string]string) error { cfgJSON, err := json.Marshal(values) if err != nil { return fmt.Errorf("marshal config_values: %w", err) } _, err = db.pool.Exec(ctx, ` UPDATE instances SET config_values = $2, updated_at = NOW() WHERE instance_id = $1 `, instanceID, cfgJSON) if err != nil { return fmt.Errorf("update instance config_values: %w", err) } return nil } // UpdateInstanceStatus records a status transition from an InstanceStateUpdate. // updated_at is only bumped when the status actually changed — re-announces // from agent reconnects (e.g. controller restart triggering an announce loop) // would otherwise reset the timestamp every time, which breaks the frontend's // "instance has been running for >10 min" heuristic for clearing the // "starting" overlay on log-only modules. func (db *DB) UpdateInstanceStatus(ctx context.Context, instanceID, status string, exitCode int32, detail string) error { _, err := db.pool.Exec(ctx, ` UPDATE instances SET status = $2, last_exit_code = $3, detail = $4, updated_at = CASE WHEN status IS DISTINCT FROM $2 THEN NOW() ELSE updated_at END WHERE instance_id = $1 `, instanceID, status, exitCode, detail) if err != nil { return fmt.Errorf("update instance status: %w", err) } return nil } // DeleteInstance is not yet used; included so the schema doesn't grow a // parallel soft-delete pattern before we need one. Call when an operator // permanently removes an instance. func (db *DB) DeleteInstance(ctx context.Context, instanceID string) error { _, err := db.pool.Exec(ctx, `DELETE FROM instances WHERE instance_id = $1`, instanceID) if err != nil { return fmt.Errorf("delete instance: %w", err) } return nil } // GetInstanceRow returns one instance row by ID, or an error if missing. func (db *DB) GetInstanceRow(ctx context.Context, instanceID string) (InstanceRow, error) { const q = ` SELECT instance_id, agent_id, module_id, COALESCE(module_version, ''), run_mode, COALESCE(data_path, ''), config_values, assigned_ports, status, COALESCE(last_exit_code, 0), COALESCE(detail, ''), created_at, updated_at FROM instances WHERE instance_id = $1 ` var r InstanceRow var cfgJSON, portsJSON []byte err := db.pool.QueryRow(ctx, q, instanceID).Scan( &r.InstanceID, &r.AgentID, &r.ModuleID, &r.ModuleVersion, &r.RunMode, &r.DataPath, &cfgJSON, &portsJSON, &r.Status, &r.LastExitCode, &r.Detail, &r.CreatedAt, &r.UpdatedAt, ) if err != nil { return r, fmt.Errorf("get instance %q: %w", instanceID, err) } if len(cfgJSON) > 0 { _ = json.Unmarshal(cfgJSON, &r.ConfigValues) } if len(portsJSON) > 0 { _ = json.Unmarshal(portsJSON, &r.AssignedPorts) } return r, nil } // ListInstances returns all instances, optionally filtered by agent_id. func (db *DB) ListInstances(ctx context.Context, agentFilter string) ([]InstanceRow, error) { var rows pgx.Rows var err error const selectAll = ` SELECT instance_id, agent_id, module_id, COALESCE(module_version, ''), run_mode, COALESCE(data_path, ''), config_values, assigned_ports, status, COALESCE(last_exit_code, 0), COALESCE(detail, ''), created_at, updated_at FROM instances ` if agentFilter != "" { rows, err = db.pool.Query(ctx, selectAll+` WHERE agent_id = $1 ORDER BY instance_id`, agentFilter) } else { rows, err = db.pool.Query(ctx, selectAll+` ORDER BY instance_id`) } if err != nil { return nil, fmt.Errorf("list instances: %w", err) } defer rows.Close() var out []InstanceRow for rows.Next() { var r InstanceRow var cfgJSON, portsJSON []byte if err := rows.Scan( &r.InstanceID, &r.AgentID, &r.ModuleID, &r.ModuleVersion, &r.RunMode, &r.DataPath, &cfgJSON, &portsJSON, &r.Status, &r.LastExitCode, &r.Detail, &r.CreatedAt, &r.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan instance: %w", err) } if len(cfgJSON) > 0 { _ = json.Unmarshal(cfgJSON, &r.ConfigValues) } if len(portsJSON) > 0 { _ = json.Unmarshal(portsJSON, &r.AssignedPorts) } out = append(out, r) } return out, rows.Err() }