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>
214 lines
6.9 KiB
Go
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
|
|
}
|