Files
panel/controller/internal/db/instances.go
T
dbledeez 4ccccc6fe2 panel v0.9.2 — public release
Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:43:35 -07:00

192 lines
6.5 KiB
Go

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()
}