Files
pangolin/server/internal/provision/replace_test.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

214 lines
6.9 KiB
Go

package provision
import (
"context"
"errors"
"testing"
)
// TestReplace_MakeBeforeBreak asserts the full state-machine migration and that
// capacity never dips: the new node reaches `up` BEFORE the old node leaves the
// directory (enters `draining`).
func TestReplace_MakeBeforeBreak(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
old, err := h.svc.CreateNode(ctx, proSpec(), "old-node")
if err != nil {
t.Fatalf("CreateNode old: %v", err)
}
if err := h.store.UpdateNodeStatus(ctx, old.ID, StatusUp); err != nil {
t.Fatalf("seed up: %v", err)
}
bootsBefore := h.adapter.createCount()
res, err := h.svc.Replace(ctx, old.ID, "")
if err != nil {
t.Fatalf("Replace: %v", err)
}
if res.NewNodeID == 0 || res.NewNodeID == old.ID {
t.Fatalf("bad new node id: %d", res.NewNodeID)
}
if h.adapter.createCount() != bootsBefore+1 {
t.Errorf("expected exactly one new boot, got delta %d", h.adapter.createCount()-bootsBefore)
}
// Capacity invariant: new node `up` seq < old node `draining` seq.
newUpSeq := h.store.firstSeqForStatus(res.NewNodeID, StatusUp)
oldDrainSeq := h.store.firstSeqForStatus(old.ID, StatusDraining)
if newUpSeq < 0 {
t.Fatal("new node never reached up")
}
if oldDrainSeq < 0 {
t.Fatal("old node never drained")
}
if !(newUpSeq < oldDrainSeq) {
t.Errorf("capacity dipped: new up seq %d not before old draining seq %d", newUpSeq, oldDrainSeq)
}
// Old node finally destroyed.
oldNode, _ := h.store.GetNode(ctx, old.ID)
if oldNode.Status != StatusDestroyed {
t.Errorf("old node status = %s, want destroyed", oldNode.Status)
}
newNode, _ := h.store.GetNode(ctx, res.NewNodeID)
if newNode.Status != StatusUp {
t.Errorf("new node status = %s, want up", newNode.Status)
}
// pro pool drains on a timer.
if len(h.clock.slept) == 0 {
t.Error("expected a drain sleep for premium pool")
}
}
// TestReplace_FreeHardCut: consumable/free pool drains by hard cut (no sleep).
func TestReplace_FreeHardCut(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
old, _ := h.svc.CreateNode(ctx, freeSpec(), "old-free")
_ = h.store.UpdateNodeStatus(ctx, old.ID, StatusUp)
if _, err := h.svc.Replace(ctx, old.ID, ""); err != nil {
t.Fatalf("Replace: %v", err)
}
if len(h.clock.slept) != 0 {
t.Errorf("free pool should hard-cut, but slept %v", h.clock.slept)
}
}
// TestReplace_ProbeTimeout: probing fails → new node destroyed, fail counted,
// alert fired, replacement marked failed, old node untouched.
func TestReplace_ProbeTimeout(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
h.prober.err = errors.New("probe timeout")
old, _ := h.svc.CreateNode(ctx, proSpec(), "old-pt")
_ = h.store.UpdateNodeStatus(ctx, old.ID, StatusUp)
_, err := h.svc.Replace(ctx, old.ID, "")
if err == nil {
t.Fatal("expected probe timeout error")
}
if h.svc.FailCount(PoolPremium) != 1 {
t.Errorf("fail count = %d, want 1", h.svc.FailCount(PoolPremium))
}
if h.alert.count(AlertProbeTimeout) != 1 {
t.Errorf("probe_timeout alerts = %d, want 1", h.alert.count(AlertProbeTimeout))
}
// Old node must still be up (capacity protected — we did not drain it).
oldNode, _ := h.store.GetNode(ctx, old.ID)
if oldNode.Status != StatusUp {
t.Errorf("old node status = %s, want up (untouched)", oldNode.Status)
}
// The failed new node should be destroyed (bad IP not left running).
if h.adapter.destroyCount() != 1 {
t.Errorf("failed new node not destroyed: destroy count %d", h.adapter.destroyCount())
}
}
// TestReplace_CrashResume: a replacement that crashed after booting the new node
// (idempotency key already saved) must resume WITHOUT a second boot.
func TestReplace_CrashResume(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
old, _ := h.svc.CreateNode(ctx, freeSpec(), "old-cr")
_ = h.store.UpdateNodeStatus(ctx, old.ID, StatusUp)
ruuid := "fixed-replacement-uuid"
// Simulate the pre-crash state: the new node was already booted under the
// orchestration's idempotency key, and a running replacement record exists
// at the open_new step (new_node_id not yet persisted).
spec, err := h.svc.replacementSpec(ctx, mustNode(t, h, old.ID), PoolConsumable)
if err != nil {
t.Fatalf("replacementSpec: %v", err)
}
preNode, err := h.svc.CreateNode(ctx, spec, ruuid+":create")
if err != nil {
t.Fatalf("pre-boot new node: %v", err)
}
if err := h.store.CreateReplacement(ctx, &Replacement{
UUID: ruuid, OldNodeID: old.ID, Pool: PoolConsumable,
Status: ReplaceRunning, Step: StepOpenNew,
}); err != nil {
t.Fatalf("seed replacement: %v", err)
}
bootsBefore := h.adapter.createCount() // old + pre-booted new = 2
res, err := h.svc.Replace(ctx, old.ID, ruuid)
if err != nil {
t.Fatalf("resume Replace: %v", err)
}
if h.adapter.createCount() != bootsBefore {
t.Errorf("resume re-booted a node: delta %d (want 0)", h.adapter.createCount()-bootsBefore)
}
if res.NewNodeID != preNode.ID {
t.Errorf("resume used a different new node: got %d, want %d", res.NewNodeID, preNode.ID)
}
oldNode, _ := h.store.GetNode(ctx, old.ID)
if oldNode.Status != StatusDestroyed {
t.Errorf("old node not destroyed after resume: %s", oldNode.Status)
}
}
// TestReplace_ReplayCompleted: replaying a finished replacement is a no-op.
func TestReplace_ReplayCompleted(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
old, _ := h.svc.CreateNode(ctx, freeSpec(), "old-rc")
_ = h.store.UpdateNodeStatus(ctx, old.ID, StatusUp)
res1, err := h.svc.Replace(ctx, old.ID, "")
if err != nil {
t.Fatalf("Replace: %v", err)
}
boots := h.adapter.createCount()
destroys := h.adapter.destroyCount()
res2, err := h.svc.Replace(ctx, old.ID, res1.ReplacementUUID)
if err != nil {
t.Fatalf("replay Replace: %v", err)
}
if res2.NewNodeID != res1.NewNodeID {
t.Errorf("replay new node id mismatch: %d vs %d", res2.NewNodeID, res1.NewNodeID)
}
if h.adapter.createCount() != boots || h.adapter.destroyCount() != destroys {
t.Errorf("replay caused vendor side effects: boots %d->%d destroys %d->%d",
boots, h.adapter.createCount(), destroys, h.adapter.destroyCount())
}
}
// TestRotatePool replaces every up node in a pool with bounded concurrency.
func TestRotatePool(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
a, _ := h.svc.CreateNode(ctx, freeSpec(), "rp-a")
b, _ := h.svc.CreateNode(ctx, freeSpec(), "rp-b")
_ = h.store.UpdateNodeStatus(ctx, a.ID, StatusUp)
_ = h.store.UpdateNodeStatus(ctx, b.ID, StatusUp)
results, err := h.svc.RotatePool(ctx, PoolConsumable, 2)
if err != nil {
t.Fatalf("RotatePool: %v", err)
}
if len(results) != 2 {
t.Fatalf("results = %d, want 2", len(results))
}
for _, id := range []int64{a.ID, b.ID} {
n, _ := h.store.GetNode(ctx, id)
if n.Status != StatusDestroyed {
t.Errorf("node %d status = %s, want destroyed", id, n.Status)
}
}
}
func mustNode(t *testing.T, h *harness, id int64) *Node {
t.Helper()
n, err := h.store.GetNode(context.Background(), id)
if err != nil || n == nil {
t.Fatalf("get node %d: %v", id, err)
}
return n
}