3d5bac66b4
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>
385 lines
11 KiB
Go
385 lines
11 KiB
Go
package provision
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Config wires a Service together. Only Store and Adapters are mandatory;
|
|
// everything else falls back to a safe default.
|
|
type Config struct {
|
|
Store Store
|
|
Adapters AdapterFactory
|
|
Tokens BootstrapIssuer
|
|
Renderer CloudInitRenderer
|
|
Prober Prober
|
|
Alert AlertHook
|
|
Clock Clock
|
|
|
|
// ControlPlaneURL is injected into cloud-init so the agent knows where to
|
|
// enroll (task #6).
|
|
ControlPlaneURL string
|
|
// DrainTimeout is how long a draining node is given before forced destroy
|
|
// (doc/04 §3: default 30 minutes).
|
|
DrainTimeout time.Duration
|
|
// ProbeTimeout bounds the simplified probing wait during Replace.
|
|
ProbeTimeout time.Duration
|
|
}
|
|
|
|
// Service is the ProvisionService. All operations are idempotent and write
|
|
// node_events + audit_log (doc/04 §4).
|
|
type Service struct {
|
|
store Store
|
|
adapters AdapterFactory
|
|
tokens BootstrapIssuer
|
|
renderer CloudInitRenderer
|
|
prober Prober
|
|
alert AlertHook
|
|
clock Clock
|
|
controlPlaneURL string
|
|
drainTimeout time.Duration
|
|
probeTimeout time.Duration
|
|
|
|
mu sync.Mutex
|
|
failPool map[Pool]int // consecutive boot/probe failures per pool
|
|
}
|
|
|
|
// NewService builds a Service, applying defaults for optional dependencies.
|
|
func NewService(cfg Config) (*Service, error) {
|
|
if cfg.Store == nil {
|
|
return nil, fmt.Errorf("provision: Store is required")
|
|
}
|
|
if cfg.Adapters == nil {
|
|
return nil, fmt.Errorf("provision: Adapters factory is required")
|
|
}
|
|
s := &Service{
|
|
store: cfg.Store,
|
|
adapters: cfg.Adapters,
|
|
tokens: cfg.Tokens,
|
|
renderer: cfg.Renderer,
|
|
prober: cfg.Prober,
|
|
alert: cfg.Alert,
|
|
clock: cfg.Clock,
|
|
controlPlaneURL: cfg.ControlPlaneURL,
|
|
drainTimeout: cfg.DrainTimeout,
|
|
probeTimeout: cfg.ProbeTimeout,
|
|
failPool: make(map[Pool]int),
|
|
}
|
|
if s.alert == nil {
|
|
s.alert = noopAlert{}
|
|
}
|
|
if s.clock == nil {
|
|
s.clock = realClock{}
|
|
}
|
|
if s.drainTimeout == 0 {
|
|
s.drainTimeout = 30 * time.Minute
|
|
}
|
|
if s.probeTimeout == 0 {
|
|
s.probeTimeout = 5 * time.Minute
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// resolveAdapter loads the provider row and its adapter.
|
|
func (s *Service) resolveAdapter(ctx context.Context, providerID int64) (*Provider, CloudAdapter, error) {
|
|
p, err := s.store.GetProvider(ctx, providerID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if p == nil {
|
|
return nil, nil, fmt.Errorf("provision: provider %d not found", providerID)
|
|
}
|
|
a, err := s.adapters.For(p)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("provision: resolve adapter for %s: %w", p.APIKind, err)
|
|
}
|
|
return p, a, nil
|
|
}
|
|
|
|
// CreateNode provisions one node. It is idempotent on idempotencyKey: replaying
|
|
// the same key returns the already-created node without booting a second
|
|
// machine (validation: "CreateNode 幂等键重放不重复开机").
|
|
//
|
|
// Flow: idempotency check → bind key→uuid → insert nodes(provisioning) →
|
|
// issue bootstrap token (task #5) → render cloud-init (task #6) → vendor boot →
|
|
// record instance ID + endpoint → node_event(provisioned) + audit_log.
|
|
// The node stays in provisioning until the agent self-registers and probing
|
|
// promotes it to up.
|
|
func (s *Service) CreateNode(ctx context.Context, spec NodeSpec, idempotencyKey string) (*Node, error) {
|
|
if idempotencyKey == "" {
|
|
return nil, fmt.Errorf("provision: idempotencyKey is required")
|
|
}
|
|
|
|
// 1. Idempotency replay.
|
|
if uuid, found, err := s.store.LookupIdempotency(ctx, idempotencyKey); err != nil {
|
|
return nil, err
|
|
} else if found {
|
|
return s.store.GetNodeByUUID(ctx, uuid)
|
|
}
|
|
|
|
pool := poolForTier(spec.Tier)
|
|
|
|
// 2. Allocate UUID and bind the idempotency key BEFORE any vendor call, so a
|
|
// crash after this point resumes onto the same node instead of booting a
|
|
// duplicate.
|
|
uuid, err := newUUID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.store.SaveIdempotency(ctx, idempotencyKey, uuid); err != nil {
|
|
return nil, err
|
|
}
|
|
// Re-read in case of a race: another caller may have won the key.
|
|
if winner, found, err := s.store.LookupIdempotency(ctx, idempotencyKey); err == nil && found && winner != uuid {
|
|
return s.store.GetNodeByUUID(ctx, winner)
|
|
}
|
|
|
|
// 3. Insert the node in provisioning state.
|
|
n := &Node{
|
|
UUID: uuid,
|
|
Region: spec.Region,
|
|
NameZH: spec.NameZH,
|
|
NameEn: spec.NameEn,
|
|
Role: orDefaultRole(spec.Role),
|
|
Tier: spec.Tier,
|
|
Endpoint: pendingEndpoint,
|
|
HY2Port: spec.HY2Port,
|
|
RealityPBK: spec.RealityPBK,
|
|
RealitySNI: spec.RealitySNI,
|
|
ProviderID: spec.ProviderID,
|
|
Tags: spec.Tags,
|
|
Status: StatusProvisioning,
|
|
Weight: spec.Weight,
|
|
}
|
|
id, err := s.store.InsertNode(ctx, n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n.ID = id
|
|
|
|
// 4. Resolve adapter.
|
|
_, adapter, err := s.resolveAdapter(ctx, spec.ProviderID)
|
|
if err != nil {
|
|
s.failBoot(ctx, n, pool, err)
|
|
return nil, err
|
|
}
|
|
|
|
// 5. Bootstrap token + cloud-init.
|
|
userData := ""
|
|
if s.renderer != nil {
|
|
token := ""
|
|
if s.tokens != nil {
|
|
token, err = s.tokens.IssueToken(ctx, uuid)
|
|
if err != nil {
|
|
s.failBoot(ctx, n, pool, fmt.Errorf("issue bootstrap token: %w", err))
|
|
return nil, err
|
|
}
|
|
}
|
|
userData, err = s.renderer.Render(CloudInitData{
|
|
NodeUUID: uuid,
|
|
BootstrapToken: token,
|
|
Region: spec.Region,
|
|
Role: n.Role,
|
|
Tier: spec.Tier,
|
|
ControlPlaneURL: s.controlPlaneURL,
|
|
})
|
|
if err != nil {
|
|
s.failBoot(ctx, n, pool, fmt.Errorf("render cloud-init: %w", err))
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// 6. Vendor boot.
|
|
inst, err := adapter.CreateInstance(ctx, CreateInput{
|
|
Region: spec.Region,
|
|
Plan: spec.Plan,
|
|
Label: uuid,
|
|
UserData: userData,
|
|
Tags: spec.Tags,
|
|
})
|
|
if err != nil {
|
|
s.failBoot(ctx, n, pool, fmt.Errorf("vendor boot: %w", err))
|
|
return nil, err
|
|
}
|
|
|
|
// 7. Record instance ID + endpoint.
|
|
endpoint := joinEndpoint(inst.IP, spec.HY2Port)
|
|
if err := s.store.SetNodeInstance(ctx, id, inst.ID, endpoint); err != nil {
|
|
return nil, err
|
|
}
|
|
n.ProviderInstanceID = inst.ID
|
|
n.Endpoint = endpoint
|
|
|
|
// 8. Audit trail + reset failure counter.
|
|
_ = s.store.WriteNodeEvent(ctx, id, EventProvisioned, jsonDetail(map[string]any{
|
|
"instance_id": inst.ID,
|
|
"region": inst.Region,
|
|
}))
|
|
_ = s.store.WriteAuditLog(ctx, "provision", "create_node", "node:"+uuid,
|
|
jsonDetail(map[string]any{"provider_id": spec.ProviderID, "region": spec.Region, "pool": pool}))
|
|
s.resetFail(pool)
|
|
|
|
return n, nil
|
|
}
|
|
|
|
// failBoot marks the node destroyed, bumps the per-pool failure counter, and
|
|
// fires a boot-failed alert (validation: "开机失败 → destroyed + 失败计数 + 告警钩子").
|
|
func (s *Service) failBoot(ctx context.Context, n *Node, pool Pool, cause error) {
|
|
_ = s.store.UpdateNodeStatus(ctx, n.ID, StatusDestroyed)
|
|
_ = s.store.WriteNodeEvent(ctx, n.ID, EventDestroyed, jsonDetail(map[string]any{
|
|
"reason": "boot_failed",
|
|
"error": cause.Error(),
|
|
}))
|
|
count := s.bumpFail(pool)
|
|
_ = s.store.WriteAuditLog(ctx, "provision", "create_node_failed", "node:"+n.UUID,
|
|
jsonDetail(map[string]any{"error": cause.Error(), "pool": pool, "fail_count": count}))
|
|
s.alert.Fire(ctx, Alert{
|
|
Kind: AlertBootFailed,
|
|
NodeUUID: n.UUID,
|
|
Pool: pool,
|
|
Message: cause.Error(),
|
|
FailCount: count,
|
|
})
|
|
}
|
|
|
|
// DestroyNode tears down a node: vendor destroy → IP release → nodes→destroyed.
|
|
// Idempotent: destroying an already-destroyed node is a no-op success.
|
|
func (s *Service) DestroyNode(ctx context.Context, nodeID int64) error {
|
|
n, err := s.store.GetNode(ctx, nodeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == nil {
|
|
return fmt.Errorf("provision: node %d not found", nodeID)
|
|
}
|
|
if n.Status == StatusDestroyed {
|
|
return nil
|
|
}
|
|
if n.ProviderInstanceID != "" {
|
|
_, adapter, err := s.resolveAdapter(ctx, n.ProviderID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := adapter.DestroyInstance(ctx, n.ProviderInstanceID); err != nil {
|
|
return fmt.Errorf("provision: destroy instance: %w", err)
|
|
}
|
|
}
|
|
if err := s.store.UpdateNodeStatus(ctx, nodeID, StatusDestroyed); err != nil {
|
|
return err
|
|
}
|
|
_ = s.store.WriteNodeEvent(ctx, nodeID, EventDestroyed, jsonDetail(map[string]any{"reason": "destroy"}))
|
|
_ = s.store.WriteAuditLog(ctx, "provision", "destroy_node", "node:"+n.UUID, "")
|
|
if _, err := s.store.BumpDirectoryVersion(ctx); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RotateIP swaps the elastic IP without re-creating the machine (doc/04 §4:
|
|
// "换 IP 不换机"). Flow: vendor IP re-bind → probe → update endpoint →
|
|
// bump directory version → node_event(ip_rotated).
|
|
func (s *Service) RotateIP(ctx context.Context, nodeID int64) (*Node, error) {
|
|
n, err := s.store.GetNode(ctx, nodeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if n == nil {
|
|
return nil, fmt.Errorf("provision: node %d not found", nodeID)
|
|
}
|
|
_, adapter, err := s.resolveAdapter(ctx, n.ProviderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !adapter.SupportsElasticIP() {
|
|
return nil, ErrElasticIPUnsupported
|
|
}
|
|
oldEndpoint := n.Endpoint
|
|
newIP, err := adapter.AttachIP(ctx, n.ProviderInstanceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("provision: attach IP: %w", err)
|
|
}
|
|
newEndpoint := joinEndpoint(newIP, n.HY2Port)
|
|
|
|
// Probe the new endpoint before publishing it.
|
|
probed := *n
|
|
probed.Endpoint = newEndpoint
|
|
if s.prober != nil {
|
|
if err := s.prober.WaitReady(ctx, &probed); err != nil {
|
|
s.alert.Fire(ctx, Alert{Kind: AlertProbeTimeout, NodeUUID: n.UUID, Message: err.Error()})
|
|
return nil, fmt.Errorf("provision: probe rotated IP: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := s.store.UpdateNodeEndpoint(ctx, nodeID, newEndpoint); err != nil {
|
|
return nil, err
|
|
}
|
|
version, err := s.store.BumpDirectoryVersion(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = s.store.WriteNodeEvent(ctx, nodeID, EventIPRotated, jsonDetail(map[string]any{
|
|
"old_endpoint": oldEndpoint,
|
|
"new_endpoint": newEndpoint,
|
|
"version": version,
|
|
}))
|
|
_ = s.store.WriteAuditLog(ctx, "provision", "rotate_ip", "node:"+n.UUID,
|
|
jsonDetail(map[string]any{"old": oldEndpoint, "new": newEndpoint}))
|
|
n.Endpoint = newEndpoint
|
|
return n, nil
|
|
}
|
|
|
|
// ListProviders returns enabled providers in a pool (empty pool = all pools).
|
|
func (s *Service) ListProviders(ctx context.Context, pool Pool) ([]*Provider, error) {
|
|
return s.store.ListProviders(ctx, pool)
|
|
}
|
|
|
|
// --- failure-counter helpers (capacity protection, doc/04 §4.2) ---
|
|
|
|
func (s *Service) bumpFail(pool Pool) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.failPool[pool]++
|
|
return s.failPool[pool]
|
|
}
|
|
|
|
func (s *Service) resetFail(pool Pool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.failPool, pool)
|
|
}
|
|
|
|
// FailCount exposes the current consecutive-failure count for a pool (tests /
|
|
// monitoring).
|
|
func (s *Service) FailCount(pool Pool) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.failPool[pool]
|
|
}
|
|
|
|
// --- small helpers ---
|
|
|
|
func orDefaultRole(r Role) Role {
|
|
if r == "" {
|
|
return RoleEntry
|
|
}
|
|
return r
|
|
}
|
|
|
|
func joinEndpoint(ip string, port int) string {
|
|
if port <= 0 {
|
|
port = 443
|
|
}
|
|
return fmt.Sprintf("%s:%d", ip, port)
|
|
}
|
|
|
|
func jsonDetail(m map[string]any) string {
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
return "null"
|
|
}
|
|
return string(b)
|
|
}
|