package scheduler import ( "context" "database/sql" "encoding/json" "strconv" "strings" "time" dbx "github.com/wangjia/pangolin/server/internal/db" "github.com/wangjia/pangolin/server/internal/nodes" "github.com/wangjia/pangolin/server/internal/scheduler/detect" "github.com/wangjia/pangolin/server/internal/scheduler/orchestrate" "github.com/wangjia/pangolin/server/internal/store" ) // SQLLifecycle is a MySQL-backed lifecycle store. The detect and orchestrate // LifecycleService interfaces are satisfied by the two thin adapter wrappers at // the bottom of this file (detectLifecycle / orchestrateLifecycle), both of // which delegate to this shared core. // // TransitionStatus is the documented optimistic-lock contract: // // UPDATE nodes SET status=to WHERE id=nodeID AND status=from // // returning rows affected (1 = applied, 0 = lock conflict / no-op). On a 1-row // transition it best-effort writes a node_events row and bumps the global // directory_version so clients re-fetch. // // NOTE: cert revocation on a destroyed transition is intentionally NOT done here // — provision.DestroyNode tears down the VM, and the agent's mTLS cert can be // revoked separately. Wire CRL revocation when the real fleet exists (TODO). type SQLLifecycle struct { db *sql.DB dialect dbx.Dialect loads *nodes.LoadCache } // NewSQLLifecycle wires the store. loads may be nil (load reads then return zero). func NewSQLLifecycle(db *sql.DB, loads *nodes.LoadCache) *SQLLifecycle { return &SQLLifecycle{db: db, dialect: dbx.DialectForDB(db), loads: loads} } const nodeSelectCols = `id, uuid, status, region, tier, role, reality_sni, reality_pbk, hy2_port, provider_id, weight, name_zh, name_en` type lcRow struct { id int64 uuid string status string region string tier string role string sni string pbk string hy2Port int providerID int64 weight int nameZH string nameEn string } func scanNode(rows interface{ Scan(...any) error }) (lcRow, error) { var r lcRow var hy2 sql.NullInt32 err := rows.Scan(&r.id, &r.uuid, &r.status, &r.region, &r.tier, &r.role, &r.sni, &r.pbk, &hy2, &r.providerID, &r.weight, &r.nameZH, &r.nameEn) if hy2.Valid { r.hy2Port = int(hy2.Int32) } return r, err } func (l *SQLLifecycle) list(ctx context.Context, statuses []string) ([]lcRow, int64, error) { q := `SELECT ` + nodeSelectCols + ` FROM nodes` var args []any if len(statuses) > 0 { ph := make([]string, len(statuses)) for i, s := range statuses { ph[i] = "?" args = append(args, s) } q += ` WHERE status IN (` + strings.Join(ph, ",") + `)` } rows, err := l.db.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() var out []lcRow for rows.Next() { r, err := scanNode(rows) if err != nil { return nil, 0, err } out = append(out, r) } return out, l.version(ctx), rows.Err() } func (l *SQLLifecycle) get(ctx context.Context, nodeID string) (*lcRow, error) { row := l.db.QueryRowContext(ctx, `SELECT `+nodeSelectCols+` FROM nodes WHERE id = ?`, nodeID) r, err := scanNode(row) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &r, nil } func (l *SQLLifecycle) transition(ctx context.Context, nodeID, from, to string, detail map[string]any) (int, error) { tx, err := l.db.BeginTx(ctx, nil) if err != nil { return 0, err } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `UPDATE nodes SET status = ? WHERE id = ? AND status = ?`, to, nodeID, from) if err != nil { return 0, err } n, _ := res.RowsAffected() if n == 0 { return 0, nil // optimistic-lock conflict: node already moved → no-op } if ev := eventForStatus(to); ev != "" { var dj any if detail != nil { if b, err := json.Marshal(detail); err == nil { dj = string(b) } } _, _ = tx.ExecContext(ctx, `INSERT INTO node_events (node_id, event, detail) VALUES (?, ?, ?)`, nodeID, ev, dj) } if err := store.BumpDirectoryVersion(ctx, tx, l.dialect); err != nil { return 0, err } if err := tx.Commit(); err != nil { return 0, err } return int(n), nil } func (l *SQLLifecycle) setWeight(ctx context.Context, nodeID string, weight int) error { _, err := l.db.ExecContext(ctx, `UPDATE nodes SET weight = ? WHERE id = ?`, weight, nodeID) return err } func (l *SQLLifecycle) bumpVersion(ctx context.Context) error { return store.BumpDirectoryVersion(ctx, l.db, l.dialect) } func (l *SQLLifecycle) version(ctx context.Context) int64 { var v int64 _ = l.db.QueryRowContext(ctx, `SELECT version FROM directory_version WHERE id = 1`).Scan(&v) return v } // loadFor resolves a node id → uuid → its current load sample (online, Mbps). func (l *SQLLifecycle) loadFor(ctx context.Context, nodeID string) (int, float64) { if l.loads == nil { return 0, 0 } var uuid string if err := l.db.QueryRowContext(ctx, `SELECT uuid FROM nodes WHERE id = ?`, nodeID).Scan(&uuid); err != nil { return 0, 0 } nl, ok, err := l.loads.Get(ctx, uuid) if err != nil || !ok || nl == nil { return 0, 0 } mbps := float64(nl.BandwidthUpBps+nl.BandwidthDownBps) / 1_000_000.0 return int(nl.OnlineCount), mbps } func (l *SQLLifecycle) writeAudit(ctx context.Context, actor, action, target, meta string) error { var metaArg any if meta != "" { metaArg = meta } _, err := l.db.ExecContext(ctx, `INSERT INTO audit_log (actor, action, target, meta) VALUES (?, ?, ?, ?)`, actor, action, target, metaArg) return err } // eventForStatus maps a target node status to the node_events ENUM value, or "" // when the status has no corresponding event (then no event row is written). func eventForStatus(status string) string { switch status { case "provisioning", "probing": return "provisioned" case "up": return "marked_up" case "draining": return "draining" case "blocked_suspect": return "blocked_suspect" case "blocked_confirmed": return "blocked_confirmed" case "destroyed": return "destroyed" default: return "" // e.g. "down" has no event enum } } // ───────────────────────────────────────────────────────────────────────────── // detect.LifecycleService adapter // ───────────────────────────────────────────────────────────────────────────── type detectLifecycle struct{ *SQLLifecycle } // NewDetectLifecycle adapts SQLLifecycle to detect.LifecycleService. func NewDetectLifecycle(l *SQLLifecycle) detect.LifecycleService { return detectLifecycle{l} } func (a detectLifecycle) ListNodes(ctx context.Context, f detect.NodeFilter) ([]detect.NodeInfo, error) { statuses := make([]string, len(f.Statuses)) for i, s := range f.Statuses { statuses[i] = string(s) } rows, ver, err := a.list(ctx, statuses) if err != nil { return nil, err } out := make([]detect.NodeInfo, 0, len(rows)) for _, r := range rows { out = append(out, detect.NodeInfo{ ID: strconv.FormatInt(r.id, 10), UUID: r.uuid, Status: detect.NodeStatus(r.status), Weight: r.weight, Version: ver, }) } return out, nil } func (a detectLifecycle) TransitionStatus(ctx context.Context, nodeID string, from, to detect.NodeStatus, detail map[string]any) (int, error) { return a.transition(ctx, nodeID, string(from), string(to), detail) } func (a detectLifecycle) SetWeight(ctx context.Context, nodeID string, weight int) error { return a.setWeight(ctx, nodeID, weight) } func (a detectLifecycle) BumpVersion(ctx context.Context) error { return a.bumpVersion(ctx) } func (a detectLifecycle) GetLoad(ctx context.Context, nodeID string) (detect.LoadInfo, error) { online, mbps := a.loadFor(ctx, nodeID) return detect.LoadInfo{Online: online, BandwidthMbps: mbps, Timestamp: time.Now().Unix()}, nil } func (a detectLifecycle) GetLoadHistory(ctx context.Context, nodeID string, _ time.Duration) ([]detect.LoadPoint, error) { // No historical load store yet (only the latest sample lives in Redis), so // return the current point. The drop-percentage rule degrades safely (no fire). online, mbps := a.loadFor(ctx, nodeID) if online == 0 && mbps == 0 { return nil, nil } return []detect.LoadPoint{{Timestamp: time.Now().Unix(), Online: online, BandwidthMbps: mbps}}, nil } // ───────────────────────────────────────────────────────────────────────────── // orchestrate.LifecycleService adapter // ───────────────────────────────────────────────────────────────────────────── type orchestrateLifecycle struct{ *SQLLifecycle } // NewOrchestrateLifecycle adapts SQLLifecycle to orchestrate.LifecycleService. func NewOrchestrateLifecycle(l *SQLLifecycle) orchestrate.LifecycleService { return orchestrateLifecycle{l} } func (a orchestrateLifecycle) GetNode(ctx context.Context, nodeID string) (*orchestrate.NodeInfo, error) { r, err := a.get(ctx, nodeID) if err != nil || r == nil { return nil, err } return &orchestrate.NodeInfo{ ID: strconv.FormatInt(r.id, 10), Tier: r.tier, Region: r.region, Role: r.role, ProviderID: strconv.FormatInt(r.providerID, 10), RealitySNI: r.sni, RealityPBK: r.pbk, HY2Port: r.hy2Port, NameZH: r.nameZH, NameEn: r.nameEn, }, nil } func (a orchestrateLifecycle) TransitionStatus(ctx context.Context, nodeID string, from, to string, detail map[string]any) (int, error) { return a.transition(ctx, nodeID, from, to, detail) } func (a orchestrateLifecycle) SetWeight(ctx context.Context, nodeID string, weight int) error { return a.setWeight(ctx, nodeID, weight) } func (a orchestrateLifecycle) BumpVersion(ctx context.Context) error { return a.bumpVersion(ctx) } func (a orchestrateLifecycle) WriteAuditLog(ctx context.Context, actor, action, target, meta string) error { return a.writeAudit(ctx, actor, action, target, meta) } // assert at compile time that the adapters satisfy their interfaces. var ( _ detect.LifecycleService = detectLifecycle{} _ orchestrate.LifecycleService = orchestrateLifecycle{} )