feat(scheduler): provision 真实接线适配器(1/2) — orchestrate.ProvisionService over provision.Service
把 scheduler 的 stub provision 换成真实 provision.Service(#14)的适配器: - 类型映射:orchestrate.NodeSpec → provision.NodeSpec(ProviderID string→int64、 Role/Tier 转换、reality/name 字段);CreateNode 返回 node.UUID 作 orchestrate ID。 - uuid↔int64:Destroy/RotateIP 用 provision.Store.GetNodeByUUID 解析。 - ListProviders:tier→pool(pro=premium/free=consumable) + region 过滤 + enabled 过滤。 - 依赖小接口(provisionSvc/provisionResolver)以便单测;映射全单测覆盖 (spec 映射/uuid 解析/pool+region+enabled 过滤)。 - e2e(真实创建/销毁节点)待有机群 + 厂商凭证后验证。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/wangjia/pangolin/server/internal/provision"
|
||||||
|
"github.com/wangjia/pangolin/server/internal/scheduler/orchestrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// provisionSvc and provisionResolver are the slices of provision.Service /
|
||||||
|
// provision.Store the adapter needs. Declaring them as interfaces (rather than
|
||||||
|
// taking the concrete types) keeps the type-mapping logic unit-testable with
|
||||||
|
// fakes. *provision.Service satisfies provisionSvc; provision.Store satisfies
|
||||||
|
// provisionResolver.
|
||||||
|
type provisionSvc interface {
|
||||||
|
CreateNode(ctx context.Context, spec provision.NodeSpec, idempotencyKey string) (*provision.Node, error)
|
||||||
|
DestroyNode(ctx context.Context, id int64) error
|
||||||
|
RotateIP(ctx context.Context, id int64) (*provision.Node, error)
|
||||||
|
ListProviders(ctx context.Context, pool provision.Pool) ([]*provision.Provider, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type provisionResolver interface {
|
||||||
|
GetNodeByUUID(ctx context.Context, uuid string) (*provision.Node, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// provisionAdapter implements orchestrate.ProvisionService over the real
|
||||||
|
// provision.Service (#14). The orchestrate interface keys nodes by string ID
|
||||||
|
// (node UUID); provision keys by int64. The adapter bridges that, plus the
|
||||||
|
// NodeSpec / Provider type differences.
|
||||||
|
type provisionAdapter struct {
|
||||||
|
svc provisionSvc
|
||||||
|
store provisionResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProvisionAdapter wires the adapter. svc is the provision service; store
|
||||||
|
// resolves a node UUID → its int64 id (provision.Store).
|
||||||
|
func NewProvisionAdapter(svc provisionSvc, store provisionResolver) orchestrate.ProvisionService {
|
||||||
|
return &provisionAdapter{svc: svc, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNode maps the spec and returns the new node's UUID as the orchestrate ID.
|
||||||
|
func (a *provisionAdapter) CreateNode(ctx context.Context, spec orchestrate.NodeSpec, idempotencyKey string) (string, error) {
|
||||||
|
var providerID int64
|
||||||
|
if spec.ProviderID != "" {
|
||||||
|
providerID, _ = strconv.ParseInt(spec.ProviderID, 10, 64)
|
||||||
|
}
|
||||||
|
n, err := a.svc.CreateNode(ctx, provision.NodeSpec{
|
||||||
|
Region: spec.Region,
|
||||||
|
Role: provision.Role(spec.Role),
|
||||||
|
Tier: provision.Tier(spec.Tier),
|
||||||
|
ProviderID: providerID,
|
||||||
|
NameZH: spec.NameZH,
|
||||||
|
NameEn: spec.NameEn,
|
||||||
|
RealitySNI: spec.RealitySNI,
|
||||||
|
RealityPBK: spec.RealityPBK,
|
||||||
|
HY2Port: spec.HY2Port,
|
||||||
|
}, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return n.UUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestroyNode resolves the UUID to an int64 id, then tears the node down.
|
||||||
|
func (a *provisionAdapter) DestroyNode(ctx context.Context, nodeID string) error {
|
||||||
|
id, err := a.resolveID(ctx, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.svc.DestroyNode(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateIP swaps the node's IP in place; the UUID is unchanged, so it is returned.
|
||||||
|
func (a *provisionAdapter) RotateIP(ctx context.Context, nodeID string) (string, error) {
|
||||||
|
id, err := a.resolveID(ctx, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
n, err := a.svc.RotateIP(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return n.UUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProviders maps tier → pool, lists providers, and filters by region.
|
||||||
|
func (a *provisionAdapter) ListProviders(ctx context.Context, tier, region string) ([]orchestrate.ProviderInfo, error) {
|
||||||
|
ps, err := a.svc.ListProviders(ctx, poolForTier(tier))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]orchestrate.ProviderInfo, 0, len(ps))
|
||||||
|
for _, p := range ps {
|
||||||
|
if !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if region != "" && !regionSupported(p.Regions, region) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, orchestrate.ProviderInfo{
|
||||||
|
ID: strconv.FormatInt(p.ID, 10),
|
||||||
|
Regions: p.Regions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *provisionAdapter) resolveID(ctx context.Context, uuid string) (int64, error) {
|
||||||
|
n, err := a.store.GetNodeByUUID(ctx, uuid)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// poolForTier mirrors provision's tier→pool mapping: pro → premium, else consumable.
|
||||||
|
func poolForTier(tier string) provision.Pool {
|
||||||
|
if provision.Tier(tier) == provision.TierPro {
|
||||||
|
return provision.PoolPremium
|
||||||
|
}
|
||||||
|
return provision.PoolConsumable
|
||||||
|
}
|
||||||
|
|
||||||
|
// regionSupported reports whether region is in regions, treating an empty list
|
||||||
|
// as "all regions supported".
|
||||||
|
func regionSupported(regions []string, region string) bool {
|
||||||
|
if len(regions) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, r := range regions {
|
||||||
|
if r == region {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/wangjia/pangolin/server/internal/provision"
|
||||||
|
"github.com/wangjia/pangolin/server/internal/scheduler/orchestrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeProvSvc records calls and returns canned results.
|
||||||
|
type fakeProvSvc struct {
|
||||||
|
createSpec provision.NodeSpec
|
||||||
|
createKey string
|
||||||
|
created *provision.Node
|
||||||
|
destroyID int64
|
||||||
|
rotateID int64
|
||||||
|
rotated *provision.Node
|
||||||
|
providers []*provision.Provider
|
||||||
|
lastPool provision.Pool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeProvSvc) CreateNode(_ context.Context, spec provision.NodeSpec, key string) (*provision.Node, error) {
|
||||||
|
f.createSpec, f.createKey = spec, key
|
||||||
|
return f.created, f.err
|
||||||
|
}
|
||||||
|
func (f *fakeProvSvc) DestroyNode(_ context.Context, id int64) error { f.destroyID = id; return f.err }
|
||||||
|
func (f *fakeProvSvc) RotateIP(_ context.Context, id int64) (*provision.Node, error) {
|
||||||
|
f.rotateID = id
|
||||||
|
return f.rotated, f.err
|
||||||
|
}
|
||||||
|
func (f *fakeProvSvc) ListProviders(_ context.Context, pool provision.Pool) ([]*provision.Provider, error) {
|
||||||
|
f.lastPool = pool
|
||||||
|
return f.providers, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeResolver maps known UUIDs → ids.
|
||||||
|
type fakeResolver struct{ byUUID map[string]*provision.Node }
|
||||||
|
|
||||||
|
func (f fakeResolver) GetNodeByUUID(_ context.Context, uuid string) (*provision.Node, error) {
|
||||||
|
n, ok := f.byUUID[uuid]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("not found")
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionAdapter_CreateNode_MapsSpecAndReturnsUUID(t *testing.T) {
|
||||||
|
svc := &fakeProvSvc{created: &provision.Node{ID: 7, UUID: "uuid-7"}}
|
||||||
|
a := NewProvisionAdapter(svc, fakeResolver{})
|
||||||
|
|
||||||
|
id, err := a.CreateNode(context.Background(), orchestrate.NodeSpec{
|
||||||
|
Tier: "pro", Region: "hkg", Role: "exit", ProviderID: "42",
|
||||||
|
RealitySNI: "www.apple.com", RealityPBK: "pbk", HY2Port: 443,
|
||||||
|
NameZH: "香港", NameEn: "HK",
|
||||||
|
}, "idem-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateNode: %v", err)
|
||||||
|
}
|
||||||
|
if id != "uuid-7" {
|
||||||
|
t.Errorf("returned id = %q, want node UUID uuid-7", id)
|
||||||
|
}
|
||||||
|
if svc.createKey != "idem-1" {
|
||||||
|
t.Errorf("idempotency key = %q, want idem-1", svc.createKey)
|
||||||
|
}
|
||||||
|
s := svc.createSpec
|
||||||
|
if s.Region != "hkg" || s.Role != provision.Role("exit") || s.Tier != provision.TierPro ||
|
||||||
|
s.ProviderID != 42 || s.RealitySNI != "www.apple.com" || s.RealityPBK != "pbk" ||
|
||||||
|
s.HY2Port != 443 || s.NameZH != "香港" || s.NameEn != "HK" {
|
||||||
|
t.Errorf("spec mapped wrong: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionAdapter_DestroyAndRotate_ResolveUUID(t *testing.T) {
|
||||||
|
svc := &fakeProvSvc{rotated: &provision.Node{ID: 9, UUID: "uuid-9"}}
|
||||||
|
res := fakeResolver{byUUID: map[string]*provision.Node{"uuid-9": {ID: 9, UUID: "uuid-9"}}}
|
||||||
|
a := NewProvisionAdapter(svc, res)
|
||||||
|
|
||||||
|
if err := a.DestroyNode(context.Background(), "uuid-9"); err != nil {
|
||||||
|
t.Fatalf("DestroyNode: %v", err)
|
||||||
|
}
|
||||||
|
if svc.destroyID != 9 {
|
||||||
|
t.Errorf("destroy resolved id = %d, want 9", svc.destroyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
newID, err := a.RotateIP(context.Background(), "uuid-9")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RotateIP: %v", err)
|
||||||
|
}
|
||||||
|
if svc.rotateID != 9 || newID != "uuid-9" {
|
||||||
|
t.Errorf("rotate id=%d newID=%q, want 9 / uuid-9", svc.rotateID, newID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown UUID must error, not panic.
|
||||||
|
if err := a.DestroyNode(context.Background(), "nope"); err == nil {
|
||||||
|
t.Error("DestroyNode with unknown uuid should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionAdapter_ListProviders_PoolAndRegionFilter(t *testing.T) {
|
||||||
|
svc := &fakeProvSvc{providers: []*provision.Provider{
|
||||||
|
{ID: 1, Regions: []string{"hkg", "tyo"}, Enabled: true},
|
||||||
|
{ID: 2, Regions: []string{"sin"}, Enabled: true}, // wrong region
|
||||||
|
{ID: 3, Regions: nil, Enabled: true}, // all regions
|
||||||
|
{ID: 4, Regions: []string{"hkg"}, Enabled: false}, // disabled
|
||||||
|
}}
|
||||||
|
a := NewProvisionAdapter(svc, fakeResolver{})
|
||||||
|
|
||||||
|
got, err := a.ListProviders(context.Background(), "pro", "hkg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProviders: %v", err)
|
||||||
|
}
|
||||||
|
if svc.lastPool != provision.PoolPremium {
|
||||||
|
t.Errorf("pool = %q, want premium (pro tier)", svc.lastPool)
|
||||||
|
}
|
||||||
|
// Expect providers 1 (region match) and 3 (all regions); 2 wrong region, 4 disabled.
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, p := range got {
|
||||||
|
ids[p.ID] = true
|
||||||
|
}
|
||||||
|
if !ids["1"] || !ids["3"] || ids["2"] || ids["4"] {
|
||||||
|
t.Errorf("filtered providers = %v, want {1,3}", ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
// free tier → consumable pool.
|
||||||
|
_, _ = a.ListProviders(context.Background(), "free", "")
|
||||||
|
if svc.lastPool != provision.PoolConsumable {
|
||||||
|
t.Errorf("pool = %q, want consumable (free tier)", svc.lastPool)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user