package provision import ( "context" "fmt" "sync" "time" ) // --------------------------------------------------------------------------- // fakeStore — an in-memory Store that records the ordered sequence of status // transitions so tests can assert make-before-break timing. // --------------------------------------------------------------------------- type statusTransition struct { Seq int NodeID int64 Status Status } type fakeStore struct { mu sync.Mutex nodes map[int64]*Node byUUID map[string]int64 providers map[int64]*Provider idem map[string]string replaces map[string]*Replacement events []struct { NodeID int64 Event Event Detail string } audits []string transitions []statusTransition nextNodeID int64 version int64 seq int } func newFakeStore() *fakeStore { return &fakeStore{ nodes: map[int64]*Node{}, byUUID: map[string]int64{}, providers: map[int64]*Provider{}, idem: map[string]string{}, replaces: map[string]*Replacement{}, nextNodeID: 0, version: 1, } } func (f *fakeStore) addProvider(p *Provider) { f.mu.Lock() defer f.mu.Unlock() f.providers[p.ID] = p } func (f *fakeStore) InsertNode(_ context.Context, n *Node) (int64, error) { f.mu.Lock() defer f.mu.Unlock() f.nextNodeID++ id := f.nextNodeID cp := *n cp.ID = id if cp.Status == "" { cp.Status = StatusProvisioning } cp.CreatedAt = time.Now() f.nodes[id] = &cp f.byUUID[cp.UUID] = id f.seq++ f.transitions = append(f.transitions, statusTransition{f.seq, id, cp.Status}) return id, nil } func (f *fakeStore) GetNode(_ context.Context, id int64) (*Node, error) { f.mu.Lock() defer f.mu.Unlock() n, ok := f.nodes[id] if !ok { return nil, nil } cp := *n return &cp, nil } func (f *fakeStore) GetNodeByUUID(_ context.Context, uuid string) (*Node, error) { f.mu.Lock() id, ok := f.byUUID[uuid] f.mu.Unlock() if !ok { return nil, nil } return f.GetNode(context.Background(), id) } func (f *fakeStore) UpdateNodeStatus(_ context.Context, id int64, status Status) error { f.mu.Lock() defer f.mu.Unlock() n, ok := f.nodes[id] if !ok { return fmt.Errorf("fakeStore: node %d not found", id) } n.Status = status f.seq++ f.transitions = append(f.transitions, statusTransition{f.seq, id, status}) return nil } func (f *fakeStore) UpdateNodeEndpoint(_ context.Context, id int64, endpoint string) error { f.mu.Lock() defer f.mu.Unlock() if n, ok := f.nodes[id]; ok { n.Endpoint = endpoint } return nil } func (f *fakeStore) SetNodeInstance(_ context.Context, id int64, instanceID, endpoint string) error { f.mu.Lock() defer f.mu.Unlock() if n, ok := f.nodes[id]; ok { n.ProviderInstanceID = instanceID n.Endpoint = endpoint } return nil } func (f *fakeStore) SetNodeWeight(_ context.Context, id int64, weight int) error { f.mu.Lock() defer f.mu.Unlock() if n, ok := f.nodes[id]; ok { n.Weight = weight } return nil } func (f *fakeStore) ListNodesByPool(_ context.Context, pool Pool, status Status) ([]*Node, error) { f.mu.Lock() defer f.mu.Unlock() var out []*Node for _, n := range f.nodes { p := f.providers[n.ProviderID] if p == nil || p.Pool != pool { continue } if status != "" && n.Status != status { continue } cp := *n out = append(out, &cp) } return out, nil } func (f *fakeStore) ListProviders(_ context.Context, pool Pool) ([]*Provider, error) { f.mu.Lock() defer f.mu.Unlock() var out []*Provider for _, p := range f.providers { if !p.Enabled { continue } if pool != "" && p.Pool != pool { continue } cp := *p out = append(out, &cp) } return out, nil } func (f *fakeStore) GetProvider(_ context.Context, id int64) (*Provider, error) { f.mu.Lock() defer f.mu.Unlock() p, ok := f.providers[id] if !ok { return nil, nil } cp := *p return &cp, nil } func (f *fakeStore) WriteNodeEvent(_ context.Context, nodeID int64, event Event, detail string) error { f.mu.Lock() defer f.mu.Unlock() f.events = append(f.events, struct { NodeID int64 Event Event Detail string }{nodeID, event, detail}) return nil } func (f *fakeStore) WriteAuditLog(_ context.Context, actor, action, target, meta string) error { f.mu.Lock() defer f.mu.Unlock() f.audits = append(f.audits, fmt.Sprintf("%s|%s|%s", actor, action, target)) return nil } func (f *fakeStore) BumpDirectoryVersion(_ context.Context) (int64, error) { f.mu.Lock() defer f.mu.Unlock() f.version++ return f.version, nil } func (f *fakeStore) LookupIdempotency(_ context.Context, key string) (string, bool, error) { f.mu.Lock() defer f.mu.Unlock() v, ok := f.idem[key] return v, ok, nil } func (f *fakeStore) SaveIdempotency(_ context.Context, key, nodeUUID string) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.idem[key]; !ok { f.idem[key] = nodeUUID } return nil } func (f *fakeStore) CreateReplacement(_ context.Context, r *Replacement) error { f.mu.Lock() defer f.mu.Unlock() cp := *r f.replaces[r.UUID] = &cp return nil } func (f *fakeStore) GetReplacement(_ context.Context, uuid string) (*Replacement, error) { f.mu.Lock() defer f.mu.Unlock() r, ok := f.replaces[uuid] if !ok { return nil, nil } cp := *r return &cp, nil } func (f *fakeStore) UpdateReplacement(_ context.Context, r *Replacement) error { f.mu.Lock() defer f.mu.Unlock() cp := *r f.replaces[r.UUID] = &cp return nil } // helpers for assertions func (f *fakeStore) countEvents(e Event) int { f.mu.Lock() defer f.mu.Unlock() n := 0 for _, ev := range f.events { if ev.Event == e { n++ } } return n } // firstSeqForStatus returns the seq of the first transition of nodeID into status, // or -1 if it never happened. func (f *fakeStore) firstSeqForStatus(nodeID int64, status Status) int { f.mu.Lock() defer f.mu.Unlock() for _, t := range f.transitions { if t.NodeID == nodeID && t.Status == status { return t.Seq } } return -1 } // --------------------------------------------------------------------------- // fakeAdapter / fakeFactory // --------------------------------------------------------------------------- type fakeAdapter struct { mu sync.Mutex createCalls int destroyCalls int createErr error elastic bool nextIP int created []string // instance IDs created destroyed []string } func newFakeAdapter() *fakeAdapter { return &fakeAdapter{elastic: true} } func (a *fakeAdapter) Kind() string { return "fake" } func (a *fakeAdapter) SupportsElasticIP() bool { return a.elastic } func (a *fakeAdapter) CreateInstance(_ context.Context, in CreateInput) (*Instance, error) { a.mu.Lock() defer a.mu.Unlock() a.createCalls++ if a.createErr != nil { return nil, a.createErr } a.nextIP++ id := fmt.Sprintf("inst-%d", a.createCalls) a.created = append(a.created, id) return &Instance{ID: id, IP: fmt.Sprintf("203.0.113.%d", a.nextIP), Region: in.Region}, nil } func (a *fakeAdapter) DestroyInstance(_ context.Context, instanceID string) error { a.mu.Lock() defer a.mu.Unlock() a.destroyCalls++ a.destroyed = append(a.destroyed, instanceID) return nil } func (a *fakeAdapter) AttachIP(_ context.Context, _ string) (string, error) { a.mu.Lock() defer a.mu.Unlock() a.nextIP++ return fmt.Sprintf("198.51.100.%d", a.nextIP), nil } func (a *fakeAdapter) ListRegions(context.Context) ([]Region, error) { return []Region{{ID: "hkg", Country: "HK"}}, nil } func (a *fakeAdapter) createCount() int { a.mu.Lock() defer a.mu.Unlock() return a.createCalls } func (a *fakeAdapter) destroyCount() int { a.mu.Lock() defer a.mu.Unlock() return a.destroyCalls } type fakeFactory struct{ a CloudAdapter } func (f fakeFactory) For(*Provider) (CloudAdapter, error) { return f.a, nil } // --------------------------------------------------------------------------- // fakeProber / fakeClock / fakeAlert / fakeTokens // --------------------------------------------------------------------------- type fakeProber struct { err error calls int } func (p *fakeProber) WaitReady(ctx context.Context, _ *Node) error { p.calls++ if p.err != nil { return p.err } return nil } // fakeClock records sleeps without actually blocking. type fakeClock struct { now time.Time slept []time.Duration } func (c *fakeClock) Now() time.Time { return c.now } func (c *fakeClock) Sleep(_ context.Context, d time.Duration) error { c.slept = append(c.slept, d) c.now = c.now.Add(d) return nil } type fakeAlert struct { mu sync.Mutex alerts []Alert } func (a *fakeAlert) Fire(_ context.Context, al Alert) { a.mu.Lock() defer a.mu.Unlock() a.alerts = append(a.alerts, al) } func (a *fakeAlert) count(kind AlertKind) int { a.mu.Lock() defer a.mu.Unlock() n := 0 for _, al := range a.alerts { if al.Kind == kind { n++ } } return n } type fakeTokens struct{ calls int } func (t *fakeTokens) IssueToken(context.Context, string) (string, error) { t.calls++ return fmt.Sprintf("token-%d", t.calls), nil } // --------------------------------------------------------------------------- // test harness builder // --------------------------------------------------------------------------- type harness struct { svc *Service store *fakeStore adapter *fakeAdapter prober *fakeProber clock *fakeClock alert *fakeAlert tokens *fakeTokens } func newHarness(t interface{ Fatalf(string, ...any) }) *harness { store := newFakeStore() // One consumable + one premium provider. store.addProvider(&Provider{ID: 1, Name: "v", APIKind: "fake", Pool: PoolConsumable, Enabled: true}) store.addProvider(&Provider{ID: 2, Name: "h", APIKind: "fake", Pool: PoolPremium, Enabled: true}) adapter := newFakeAdapter() prober := &fakeProber{} clock := &fakeClock{now: time.Unix(1700000000, 0).UTC()} alert := &fakeAlert{} tokens := &fakeTokens{} renderer, err := NewTemplateRendererFromString("uuid={{.NodeUUID}} token={{.BootstrapToken}}") if err != nil { t.Fatalf("renderer: %v", err) } svc, err := NewService(Config{ Store: store, Adapters: fakeFactory{adapter}, Tokens: tokens, Renderer: renderer, Prober: prober, Alert: alert, Clock: clock, ControlPlaneURL: "https://cp.example", DrainTimeout: 30 * time.Minute, ProbeTimeout: time.Minute, }) if err != nil { t.Fatalf("NewService: %v", err) } return &harness{svc, store, adapter, prober, clock, alert, tokens} } func proSpec() NodeSpec { return NodeSpec{Region: "hkg", Tier: TierPro, ProviderID: 2, Plan: "cpx11", RealityPBK: "pbk", RealitySNI: "www.example.com", HY2Port: 443, NameZH: "香港", NameEn: "HK"} } func freeSpec() NodeSpec { return NodeSpec{Region: "hkg", Tier: TierFree, ProviderID: 1, Plan: "vc2-1c-1gb", RealityPBK: "pbk", RealitySNI: "www.example.com", HY2Port: 443, NameZH: "香港", NameEn: "HK"} }