diff --git a/server/internal/scheduler/wiring_provision.go b/server/internal/scheduler/wiring_provision.go new file mode 100644 index 0000000..b26cc6b --- /dev/null +++ b/server/internal/scheduler/wiring_provision.go @@ -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 +} diff --git a/server/internal/scheduler/wiring_provision_test.go b/server/internal/scheduler/wiring_provision_test.go new file mode 100644 index 0000000..ceeab45 --- /dev/null +++ b/server/internal/scheduler/wiring_provision_test.go @@ -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) + } +}