9a028ab907
管理后台节点页的「拉黑/恢复(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>
251 lines
8.5 KiB
Go
251 lines
8.5 KiB
Go
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
|
|
}
|