Files
pangolin/server/internal/provision/mysqlstore.go
T
wangjia 3d5bac66b4 feat(provision): 弹性节点基建 Terraform + 一键更换 (tsk_6u0FxmbC7Yeq)
IaC 面 (infra/) 与控制面 (server/internal/provision) 双产出,落地 doc/04 §4
「节点是牲口」弹性拓扑与 make-before-break 一键更换。

server/internal/provision:
- CloudAdapter 适配层 + Registry(首发 vultr 消耗品池 / hetzner 精品池各一);
  厂商凭证仅从 PROVISION_<VENDOR>_* env 注入,不入库不入 git。
- ProvisionService:CreateNode(幂等键重放不重复开机)、DestroyNode(幂等)、
  RotateIP(换 IP 不换机 + version bump)、ListProviders。
- Replace 一键更换:先建后拆,新机 up 先于旧机 draining(容量不下降),
  replacement_uuid 幂等键 + replacements 表分步记录,崩溃可续跑不重复。
- RotatePool:池内滚动轮换,并发度 1–2。
- cmd/nodectl CLI:create/destroy/rotate-ip/replace/rotate-pool/providers。
- 单测(mock 厂商 API + 内存 Store):幂等重放、make-before-break 时序断言、
  开机失败/探活超时→destroyed+失败计数+告警钩子、崩溃续跑、RotatePool。

infra/:
- terraform/:探针机 + 控制面基线模块化(probe / control-plane)+ README,
  低频基线进 state,节点不进 Terraform。
- cloud-init/node.yaml.tmpl:节点引导模板(注入一次性 bootstrap token,task #5)。
- identity-isolation.md:身份隔离登记表(doc/06 §2 红线,无任何凭证)。

migrations/000008:nodes 增 provider_instance_id/elastic_ip_id、node_events
增 ip_rotated、provision_idempotency / replacements 表(附加式,不动现网)。

红线:仅面向新厂商池,绝不纳管现网生产 EC2(deploy/ marzban)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:23:39 +08:00

367 lines
10 KiB
Go

package provision
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
)
// MySQLStore is the production Store backed by the shared *sql.DB pool.
type MySQLStore struct {
db *sql.DB
}
// NewMySQLStore wraps a MySQL connection pool.
func NewMySQLStore(db *sql.DB) *MySQLStore { return &MySQLStore{db: 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(6))`,
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)
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, &regions, &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 (?, ?, ?, UTC_TIMESTAMP(6))`,
nodeID, string(event), detailJSON)
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 (?, ?, ?, ?, UTC_TIMESTAMP(6))`,
actor, action, target, metaJSON)
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 (?, ?, UTC_TIMESTAMP(6))
ON DUPLICATE KEY UPDATE idempotency_key = idempotency_key`,
key, nodeUUID)
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, ", ")
}