Files
wangjia 9a028ab907 feat(backend): admin 手动运维接真实 lifecycle/provision
管理后台节点页的「拉黑/恢复(draining/up)」与「一键换机(replace)」此前
注入的是 StubLifecycle/StubProvision(Ready()=false,UI 置灰、操作返回
服务不可用)。本次接真实实现:

- RealLifecycle:复用 scheduler 的乐观锁状态流转(UPDATE nodes WHERE
  status=from + node_events + directory_version bump),手动运维与调度
  自动循环共用同一条 canonical 路径;读当前状态作 from 守卫,幂等安全。
- RealProvision:委托 provision.Service.Replace 的 make-before-break 换机
  (先拉起新节点再 drain 旧节点,容量不掉)。
- BuildServices:lifecycle 恒为真实;provision 在服务初始化成功时为真实,
  否则回退 not-ready stub(无厂商凭证时点击换机给出明确错误)。

补 services_real_test.go:换机委托(空 UUID 起新编排)、错误透传、
非法目标状态拒绝、Ready 标志。server 全量 23 包测试通过。
e2e(真实换机)待机群 + 厂商凭证。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:24:15 +08:00

61 lines
1.8 KiB
Go

package admin
import (
"context"
"errors"
"testing"
"github.com/wangjia/pangolin/server/internal/provision"
)
// fakeReplacer records the arguments passed to Replace and returns canned values.
type fakeReplacer struct {
gotNodeID int64
gotUUID string
res *provision.ReplaceResult
err error
}
func (f *fakeReplacer) Replace(_ context.Context, nodeID int64, uuid string) (*provision.ReplaceResult, error) {
f.gotNodeID, f.gotUUID = nodeID, uuid
return f.res, f.err
}
func TestRealProvision_Replace_DelegatesWithFreshUUID(t *testing.T) {
f := &fakeReplacer{res: &provision.ReplaceResult{NewNodeID: 99}}
p := NewRealProvision(f)
if err := p.Replace(context.Background(), 42, "operator"); err != nil {
t.Fatalf("Replace: %v", err)
}
if f.gotNodeID != 42 {
t.Errorf("nodeID = %d, want 42", f.gotNodeID)
}
// Admin always starts a fresh orchestration: the service mints its own key.
if f.gotUUID != "" {
t.Errorf("replacementUUID = %q, want empty (fresh start)", f.gotUUID)
}
if !p.Ready() {
t.Error("RealProvision.Ready() = false, want true")
}
}
func TestRealProvision_Replace_PropagatesError(t *testing.T) {
sentinel := errors.New("no vendor credentials")
p := NewRealProvision(&fakeReplacer{err: sentinel})
if err := p.Replace(context.Background(), 7, "operator"); !errors.Is(err, sentinel) {
t.Errorf("Replace error = %v, want %v", err, sentinel)
}
}
func TestRealLifecycle_RejectsUnsupportedTarget(t *testing.T) {
// Target validation happens before any DB access, so a nil db is safe here.
l := &RealLifecycle{}
if err := l.TransitionStatus(context.Background(), 1, "destroyed", "operator"); err == nil {
t.Error("TransitionStatus with target=destroyed should error, got nil")
}
if !l.Ready() {
t.Error("RealLifecycle.Ready() = false, want true")
}
}