86b67c557b
- db.Dialect:LockForUpdate(mysql "FOR UPDATE" / sqlite "")、Upsert(中性 EXCLUDED.col → mysql VALUES()/ sqlite excluded.);DialectForDB 从连接驱动推导 - 9 处 ON DUPLICATE KEY、11 处 FOR UPDATE 全走 dialect;sqlite 靠 _txlock= immediate 取得 BEGIN IMMEDIATE 悲观锁等价语义 - directory_version 三处重复合并为 store.BumpDirectoryVersion(dialect 感知) - 这些文件同时含(2/4)的 UTC→Go 改动(与 upsert/锁同语句交错,无法拆分) - 顺带:usage 的 FIELD()、codes 的 DATE_ADD/GREATEST 续期、nodes 的 UNIX_TIMESTAMP、NULLIF 等 MySQL 专属构造一并退回 Go/可移植写法 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
374 lines
10 KiB
Go
374 lines
10 KiB
Go
package provision
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
dbx "github.com/wangjia/pangolin/server/internal/db"
|
|
)
|
|
|
|
// MySQLStore is the production Store backed by the shared *sql.DB pool.
|
|
// (Despite the name it is dialect-aware and also works against SQLite.)
|
|
type MySQLStore struct {
|
|
db *sql.DB
|
|
dialect dbx.Dialect
|
|
}
|
|
|
|
// NewMySQLStore wraps a database connection pool (MySQL or SQLite).
|
|
func NewMySQLStore(db *sql.DB) *MySQLStore {
|
|
return &MySQLStore{db: db, dialect: dbx.DialectForDB(db)}
|
|
}
|
|
|
|
var _ Store = (*MySQLStore)(nil)
|
|
|
|
func marshalJSONList(v []string) string {
|
|
if v == nil {
|
|
v = []string{}
|
|
}
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
}
|
|
|
|
func unmarshalJSONList(s sql.NullString) []string {
|
|
if !s.Valid || s.String == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
_ = json.Unmarshal([]byte(s.String), &out)
|
|
return out
|
|
}
|
|
|
|
// --- nodes ---
|
|
|
|
func (s *MySQLStore) InsertNode(ctx context.Context, n *Node) (int64, error) {
|
|
endpoint := n.Endpoint
|
|
if endpoint == "" {
|
|
endpoint = pendingEndpoint
|
|
}
|
|
status := n.Status
|
|
if status == "" {
|
|
status = StatusProvisioning
|
|
}
|
|
weight := n.Weight
|
|
if weight == 0 {
|
|
weight = 100
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO nodes
|
|
(uuid, region, name_zh, name_en, role, tier, endpoint, hy2_port,
|
|
reality_pbk, reality_sni, provider_id, tags, status, weight, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
n.UUID, n.Region, n.NameZH, n.NameEn, string(n.Role), string(n.Tier),
|
|
endpoint, nullInt(n.HY2Port), n.RealityPBK, n.RealitySNI, n.ProviderID,
|
|
marshalJSONList(n.Tags), string(status), weight, time.Now().UTC())
|
|
if err != nil {
|
|
return 0, fmt.Errorf("store.InsertNode: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("store.InsertNode last id: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
const nodeColumns = `id, uuid, region, name_zh, name_en, role, tier, endpoint,
|
|
hy2_port, reality_pbk, reality_sni, provider_id, provider_instance_id,
|
|
elastic_ip_id, tags, status, weight, created_at`
|
|
|
|
func scanNode(row interface{ Scan(...any) error }) (*Node, error) {
|
|
var (
|
|
n Node
|
|
hy2 sql.NullInt64
|
|
instanceID sql.NullString
|
|
elasticID sql.NullString
|
|
tags sql.NullString
|
|
)
|
|
if err := row.Scan(
|
|
&n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEn, &n.Role, &n.Tier,
|
|
&n.Endpoint, &hy2, &n.RealityPBK, &n.RealitySNI, &n.ProviderID,
|
|
&instanceID, &elasticID, &tags, &n.Status, &n.Weight, &n.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
n.HY2Port = int(hy2.Int64)
|
|
n.ProviderInstanceID = instanceID.String
|
|
n.ElasticIPID = elasticID.String
|
|
n.Tags = unmarshalJSONList(tags)
|
|
return &n, nil
|
|
}
|
|
|
|
func (s *MySQLStore) GetNode(ctx context.Context, id int64) (*Node, error) {
|
|
row := s.db.QueryRowContext(ctx, `SELECT `+nodeColumns+` FROM nodes WHERE id=?`, id)
|
|
n, err := scanNode(row)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.GetNode: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (s *MySQLStore) GetNodeByUUID(ctx context.Context, uuid string) (*Node, error) {
|
|
row := s.db.QueryRowContext(ctx, `SELECT `+nodeColumns+` FROM nodes WHERE uuid=?`, uuid)
|
|
n, err := scanNode(row)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.GetNodeByUUID: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (s *MySQLStore) UpdateNodeStatus(ctx context.Context, id int64, status Status) error {
|
|
_, err := s.db.ExecContext(ctx, `UPDATE nodes SET status=? WHERE id=?`, string(status), id)
|
|
if err != nil {
|
|
return fmt.Errorf("store.UpdateNodeStatus: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *MySQLStore) UpdateNodeEndpoint(ctx context.Context, id int64, endpoint string) error {
|
|
_, err := s.db.ExecContext(ctx, `UPDATE nodes SET endpoint=? WHERE id=?`, endpoint, id)
|
|
if err != nil {
|
|
return fmt.Errorf("store.UpdateNodeEndpoint: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *MySQLStore) SetNodeInstance(ctx context.Context, id int64, instanceID, endpoint string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE nodes SET provider_instance_id=?, endpoint=? WHERE id=?`,
|
|
instanceID, endpoint, id)
|
|
if err != nil {
|
|
return fmt.Errorf("store.SetNodeInstance: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *MySQLStore) SetNodeWeight(ctx context.Context, id int64, weight int) error {
|
|
_, err := s.db.ExecContext(ctx, `UPDATE nodes SET weight=? WHERE id=?`, weight, id)
|
|
if err != nil {
|
|
return fmt.Errorf("store.SetNodeWeight: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *MySQLStore) ListNodesByPool(ctx context.Context, pool Pool, status Status) ([]*Node, error) {
|
|
q := `SELECT ` + qualify(nodeColumns, "n") + `
|
|
FROM nodes n JOIN providers p ON p.id = n.provider_id
|
|
WHERE p.pool = ?`
|
|
args := []any{string(pool)}
|
|
if status != "" {
|
|
q += ` AND n.status = ?`
|
|
args = append(args, string(status))
|
|
}
|
|
q += ` ORDER BY n.id`
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.ListNodesByPool: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []*Node
|
|
for rows.Next() {
|
|
n, err := scanNode(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.ListNodesByPool scan: %w", err)
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// --- providers ---
|
|
|
|
func (s *MySQLStore) ListProviders(ctx context.Context, pool Pool) ([]*Provider, error) {
|
|
q := `SELECT id, name, api_kind, regions, pool, enabled FROM providers WHERE enabled=TRUE`
|
|
var args []any
|
|
if pool != "" {
|
|
q += ` AND pool=?`
|
|
args = append(args, string(pool))
|
|
}
|
|
q += ` ORDER BY id`
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.ListProviders: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []*Provider
|
|
for rows.Next() {
|
|
p, err := scanProvider(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.ListProviders scan: %w", err)
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *MySQLStore) GetProvider(ctx context.Context, id int64) (*Provider, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT id, name, api_kind, regions, pool, enabled FROM providers WHERE id=?`, id)
|
|
p, err := scanProvider(row)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.GetProvider: %w", err)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func scanProvider(row interface{ Scan(...any) error }) (*Provider, error) {
|
|
var (
|
|
p Provider
|
|
regions sql.NullString
|
|
)
|
|
if err := row.Scan(&p.ID, &p.Name, &p.APIKind, ®ions, &p.Pool, &p.Enabled); err != nil {
|
|
return nil, err
|
|
}
|
|
p.Regions = unmarshalJSONList(regions)
|
|
return &p, nil
|
|
}
|
|
|
|
// --- events / audit / directory ---
|
|
|
|
func (s *MySQLStore) WriteNodeEvent(ctx context.Context, nodeID int64, event Event, detailJSON string) error {
|
|
if detailJSON == "" {
|
|
detailJSON = "null"
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO node_events (node_id, event, detail, at) VALUES (?, ?, ?, ?)`,
|
|
nodeID, string(event), detailJSON, time.Now().UTC())
|
|
if err != nil {
|
|
return fmt.Errorf("store.WriteNodeEvent: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *MySQLStore) WriteAuditLog(ctx context.Context, actor, action, target, metaJSON string) error {
|
|
if metaJSON == "" {
|
|
metaJSON = "null"
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, ?)`,
|
|
actor, action, target, metaJSON, time.Now().UTC())
|
|
if err != nil {
|
|
return fmt.Errorf("store.WriteAuditLog: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *MySQLStore) BumpDirectoryVersion(ctx context.Context) (int64, error) {
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`UPDATE directory_version SET version = version + 1 WHERE id = 1`); err != nil {
|
|
return 0, fmt.Errorf("store.BumpDirectoryVersion: %w", err)
|
|
}
|
|
var v int64
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT version FROM directory_version WHERE id = 1`).Scan(&v); err != nil {
|
|
return 0, fmt.Errorf("store.BumpDirectoryVersion read: %w", err)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// --- idempotency ---
|
|
|
|
func (s *MySQLStore) LookupIdempotency(ctx context.Context, key string) (string, bool, error) {
|
|
var uuid string
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT node_uuid FROM provision_idempotency WHERE idempotency_key=?`, key).Scan(&uuid)
|
|
if err == sql.ErrNoRows {
|
|
return "", false, nil
|
|
}
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("store.LookupIdempotency: %w", err)
|
|
}
|
|
return uuid, true, nil
|
|
}
|
|
|
|
func (s *MySQLStore) SaveIdempotency(ctx context.Context, key, nodeUUID string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO provision_idempotency (idempotency_key, node_uuid, created_at)
|
|
VALUES (?, ?, ?) `+
|
|
s.dialect.Upsert([]string{"idempotency_key"}),
|
|
key, nodeUUID, time.Now().UTC())
|
|
if err != nil {
|
|
return fmt.Errorf("store.SaveIdempotency: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- replacements ---
|
|
|
|
func (s *MySQLStore) CreateReplacement(ctx context.Context, r *Replacement) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO replacements (uuid, old_node_id, new_node_id, pool, status, step)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
r.UUID, r.OldNodeID, nullInt64(r.NewNodeID), string(r.Pool),
|
|
string(r.Status), string(r.Step))
|
|
if err != nil {
|
|
return fmt.Errorf("store.CreateReplacement: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *MySQLStore) GetReplacement(ctx context.Context, uuid string) (*Replacement, error) {
|
|
var (
|
|
r Replacement
|
|
newID sql.NullInt64
|
|
)
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT uuid, old_node_id, new_node_id, pool, status, step, created_at, updated_at
|
|
FROM replacements WHERE uuid=?`, uuid).
|
|
Scan(&r.UUID, &r.OldNodeID, &newID, &r.Pool, &r.Status, &r.Step, &r.CreatedAt, &r.UpdatedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.GetReplacement: %w", err)
|
|
}
|
|
r.NewNodeID = newID.Int64
|
|
return &r, nil
|
|
}
|
|
|
|
func (s *MySQLStore) UpdateReplacement(ctx context.Context, r *Replacement) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE replacements SET new_node_id=?, status=?, step=? WHERE uuid=?`,
|
|
nullInt64(r.NewNodeID), string(r.Status), string(r.Step), r.UUID)
|
|
if err != nil {
|
|
return fmt.Errorf("store.UpdateReplacement: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func nullInt(v int) interface{} {
|
|
if v == 0 {
|
|
return nil
|
|
}
|
|
return v
|
|
}
|
|
|
|
func nullInt64(v int64) interface{} {
|
|
if v == 0 {
|
|
return nil
|
|
}
|
|
return v
|
|
}
|
|
|
|
// qualify prefixes every comma-separated column in cols with the alias.
|
|
func qualify(cols, alias string) string {
|
|
parts := strings.Split(cols, ",")
|
|
for i, p := range parts {
|
|
parts[i] = alias + "." + strings.TrimSpace(p)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|