package admin import ( "context" "database/sql" "errors" "fmt" "strconv" "time" "github.com/wangjia/pangolin/server/internal/codes" "github.com/wangjia/pangolin/server/internal/provision" "github.com/wangjia/pangolin/server/internal/scheduler" "github.com/wangjia/pangolin/server/internal/scheduler/orchestrate" ) // ErrServiceUnavailable is returned by stub services whose real implementation // (#5 lifecycle, #14 provisioning) is not yet wired in. The UI greys out the // corresponding controls when Ready() is false. var ErrServiceUnavailable = errors.New("admin: service not available yet") // -------------------------------------------------------------------------- // Code-batch service (#3 codes — implemented) // -------------------------------------------------------------------------- // CodeBatchParams are the inputs to a batch generation. type CodeBatchParams struct { Plan string DurationDays int Count int Channel string Note string CreatedBy string } // GeneratedBatch carries the one-and-only plaintext output of a generation. type GeneratedBatch struct { BatchID int64 Plan string DurationDays int Channel string Codes []string // plaintext, streamed to CSV once and never stored GeneratedAt time.Time } // CodesService is the subset of the #3 codes service the admin UI consumes. type CodesService interface { CreateBatch(ctx context.Context, p CodeBatchParams) (*GeneratedBatch, error) ListBatches(ctx context.Context, limit, offset int) ([]BatchSummary, int, error) VoidBatch(ctx context.Context, batchID int64) (int64, error) } // CodesAdapter adapts the real codes.Service / codes.Store to CodesService. type CodesAdapter struct { svc *codes.Service store *codes.Store } // NewCodesAdapter wires the real codes implementation. func NewCodesAdapter(svc *codes.Service, store *codes.Store) *CodesAdapter { return &CodesAdapter{svc: svc, store: store} } // CreateBatch generates a batch and returns the plaintext codes. func (a *CodesAdapter) CreateBatch(ctx context.Context, p CodeBatchParams) (*GeneratedBatch, error) { res, err := a.svc.CreateBatch(ctx, codes.BatchRequest{ PlanCode: codes.PlanCode(p.Plan), DurationDays: p.DurationDays, Count: p.Count, Channel: codes.BatchChannel(p.Channel), Note: p.Note, CreatedBy: p.CreatedBy, }) if err != nil { return nil, err } return &GeneratedBatch{ BatchID: res.BatchID, Plan: string(res.PlanCode), DurationDays: res.DurationDays, Channel: string(res.Channel), Codes: res.Codes, GeneratedAt: time.Now().UTC(), }, nil } // ListBatches returns paginated batch summaries. func (a *CodesAdapter) ListBatches(ctx context.Context, limit, offset int) ([]BatchSummary, int, error) { infos, total, err := a.store.ListBatches(ctx, limit, offset) if err != nil { return nil, 0, err } out := make([]BatchSummary, len(infos)) for i, b := range infos { out[i] = BatchSummary{ ID: b.ID, Channel: string(b.Channel), CreatedBy: b.CreatedBy, Note: b.Note, CreatedAt: b.CreatedAt, Total: b.Total, Redeemed: b.Redeemed, Void: b.Void, Unused: b.Unused, } } return out, total, nil } // VoidBatch voids all unused codes in a batch. func (a *CodesAdapter) VoidBatch(ctx context.Context, batchID int64) (int64, error) { return a.store.VoidBatch(ctx, batchID) } // -------------------------------------------------------------------------- // Node lifecycle service (#5) — not yet implemented // -------------------------------------------------------------------------- // LifecycleService transitions a node's status (draining / up). Backed by the // #5 lifecycle module once available. type LifecycleService interface { // TransitionStatus moves nodeID to target ("draining" | "up"). TransitionStatus(ctx context.Context, nodeID int64, target, actor string) error // Ready reports whether the real implementation is wired (UI greys out // controls when false). Ready() bool } // StubLifecycle is the placeholder used until #5 lands. type StubLifecycle struct{} // NewStubLifecycle returns a not-ready lifecycle service. func NewStubLifecycle() *StubLifecycle { return &StubLifecycle{} } // TransitionStatus always fails until #5 is wired. func (StubLifecycle) TransitionStatus(context.Context, int64, string, string) error { return ErrServiceUnavailable } // Ready reports false. func (StubLifecycle) Ready() bool { return false } // RealLifecycle is the SQL-backed lifecycle service. It reuses the scheduler's // canonical optimistic-lock transition (UPDATE nodes ... WHERE status=from, plus // a node_events row and a directory_version bump so clients re-fetch), so manual // admin transitions and the autonomous scheduler loop share one code path. type RealLifecycle struct { db *sql.DB lc orchestrate.LifecycleService } // NewRealLifecycle wires the lifecycle adapter over db. func NewRealLifecycle(db *sql.DB) *RealLifecycle { return &RealLifecycle{ db: db, lc: scheduler.NewOrchestrateLifecycle(scheduler.NewSQLLifecycle(db, nil)), } } // TransitionStatus moves nodeID to target ("draining" | "up"). It reads the // current status as the optimistic-lock guard, so the transition is a safe // no-op if the node already moved. func (l *RealLifecycle) TransitionStatus(ctx context.Context, nodeID int64, target, actor string) error { if target != "draining" && target != "up" { return fmt.Errorf("admin: unsupported target status %q", target) } var from string switch err := l.db.QueryRowContext(ctx, `SELECT status FROM nodes WHERE id = ?`, nodeID).Scan(&from); err { case nil: case sql.ErrNoRows: return fmt.Errorf("admin: node %d not found", nodeID) default: return err } if from == target { return nil // already in target state — idempotent no-op } n, err := l.lc.TransitionStatus(ctx, strconv.FormatInt(nodeID, 10), from, target, map[string]any{"actor": actor, "source": "admin"}) if err != nil { return err } if n == 0 { return fmt.Errorf("admin: node %d status changed concurrently", nodeID) } return nil } // Ready reports true — the lifecycle service only needs the database. func (*RealLifecycle) Ready() bool { return true } // -------------------------------------------------------------------------- // Node provisioning service (#14) — not yet implemented // -------------------------------------------------------------------------- // ProvisionService replaces a node by provisioning a fresh one. Backed by the // #14 ProvisionService once available. type ProvisionService interface { // Replace decommissions nodeID and provisions a replacement. Replace(ctx context.Context, nodeID int64, actor string) error // Ready reports whether the real implementation is wired. Ready() bool } // StubProvision is the placeholder used until #14 lands. type StubProvision struct{} // NewStubProvision returns a not-ready provision service. func NewStubProvision() *StubProvision { return &StubProvision{} } // Replace always fails until #14 is wired. func (StubProvision) Replace(context.Context, int64, string) error { return ErrServiceUnavailable } // Ready reports false. func (StubProvision) Ready() bool { return false } // provReplacer is the slice of provision.Service the admin replace control needs // (declared as an interface so tests can fake it without a database). type provReplacer interface { Replace(ctx context.Context, nodeID int64, replacementUUID string) (*provision.ReplaceResult, error) } // RealProvision drives a one-click make-before-break node replacement via the // real provision.Service (doc/04 §4.1): a fresh node is brought UP before the // old one drains, so capacity never dips. type RealProvision struct { svc provReplacer } // NewRealProvision wires the provision adapter over the real service. func NewRealProvision(svc provReplacer) *RealProvision { return &RealProvision{svc: svc} } // Replace provisions a replacement for nodeID. An empty replacementUUID starts a // fresh orchestration (the service mints its own idempotency key). The actor is // recorded in the admin audit log by the caller. func (p *RealProvision) Replace(ctx context.Context, nodeID int64, _ string) error { _, err := p.svc.Replace(ctx, nodeID, "") return err } // Ready reports true — the provision service is wired. Without vendor // credentials the Replace call fails with a clear error when invoked. func (*RealProvision) Ready() bool { return true } // Services bundles the three downstream services the admin UI depends on. type Services struct { Codes CodesService Lifecycle LifecycleService Provision ProvisionService }