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>
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
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, ®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 (?, ?, ?, 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, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user