panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user