feat(provision): 弹性节点基建 Terraform + 一键更换 (tsk_6u0FxmbC7Yeq)
IaC 面 (infra/) 与控制面 (server/internal/provision) 双产出,落地 doc/04 §4 「节点是牲口」弹性拓扑与 make-before-break 一键更换。 server/internal/provision: - CloudAdapter 适配层 + Registry(首发 vultr 消耗品池 / hetzner 精品池各一); 厂商凭证仅从 PROVISION_<VENDOR>_* env 注入,不入库不入 git。 - ProvisionService:CreateNode(幂等键重放不重复开机)、DestroyNode(幂等)、 RotateIP(换 IP 不换机 + version bump)、ListProviders。 - Replace 一键更换:先建后拆,新机 up 先于旧机 draining(容量不下降), replacement_uuid 幂等键 + replacements 表分步记录,崩溃可续跑不重复。 - RotatePool:池内滚动轮换,并发度 1–2。 - cmd/nodectl CLI:create/destroy/rotate-ip/replace/rotate-pool/providers。 - 单测(mock 厂商 API + 内存 Store):幂等重放、make-before-break 时序断言、 开机失败/探活超时→destroyed+失败计数+告警钩子、崩溃续跑、RotatePool。 infra/: - terraform/:探针机 + 控制面基线模块化(probe / control-plane)+ README, 低频基线进 state,节点不进 Terraform。 - cloud-init/node.yaml.tmpl:节点引导模板(注入一次性 bootstrap token,task #5)。 - identity-isolation.md:身份隔离登记表(doc/06 §2 红线,无任何凭证)。 migrations/000008:nodes 增 provider_instance_id/elastic_ip_id、node_events 增 ip_rotated、provision_idempotency / replacements 表(附加式,不动现网)。 红线:仅面向新厂商池,绝不纳管现网生产 EC2(deploy/ marzban)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Region is a vendor region/datacenter descriptor.
|
||||
type Region struct {
|
||||
// ID is the vendor-native region identifier (e.g. "hkg", "nbg1").
|
||||
ID string
|
||||
// Country is the ISO-3166 alpha-2 country code (e.g. "HK", "JP").
|
||||
Country string
|
||||
// City is a human-readable city name (optional).
|
||||
City string
|
||||
}
|
||||
|
||||
// Instance is the vendor-side view of a freshly created machine.
|
||||
type Instance struct {
|
||||
// ID is the vendor-native instance identifier (used to destroy / rotate).
|
||||
ID string
|
||||
// IP is the public IPv4 address.
|
||||
IP string
|
||||
// Region echoes the region the instance was created in.
|
||||
Region string
|
||||
}
|
||||
|
||||
// CreateInput carries everything an adapter needs to boot one node.
|
||||
type CreateInput struct {
|
||||
// Region is the vendor-native region ID.
|
||||
Region string
|
||||
// Plan is the vendor-native instance size ID.
|
||||
Plan string
|
||||
// Label is a vendor-side label/hostname (we pass the node UUID).
|
||||
Label string
|
||||
// UserData is the rendered cloud-init document (base64-encoded by the
|
||||
// adapter if the vendor requires it). It carries the one-time bootstrap
|
||||
// token, so it MUST NOT be logged.
|
||||
UserData string
|
||||
// SSHKeyIDs are optional vendor-registered SSH key IDs.
|
||||
SSHKeyIDs []string
|
||||
// Tags are optional vendor-side tags.
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// CloudAdapter is the unified interface every vendor adapter implements
|
||||
// (doc/04 §4: "Terraform + 厂商 API 适配层"). Implementations live in
|
||||
// providers/ and read their credentials only from independent secrets.
|
||||
type CloudAdapter interface {
|
||||
// Kind returns the adapter key matching providers.api_kind.
|
||||
Kind() string
|
||||
// CreateInstance boots a node and returns once the vendor has assigned an
|
||||
// instance ID and IP (not necessarily once the OS is up).
|
||||
CreateInstance(ctx context.Context, in CreateInput) (*Instance, error)
|
||||
// DestroyInstance tears down the instance and releases its primary IP.
|
||||
// It must be idempotent: destroying an already-gone instance is a no-op.
|
||||
DestroyInstance(ctx context.Context, instanceID string) error
|
||||
// AttachIP allocates a fresh elastic IP, binds it to the instance, and
|
||||
// returns the new public IP. Only meaningful when SupportsElasticIP.
|
||||
AttachIP(ctx context.Context, instanceID string) (newIP string, err error)
|
||||
// ListRegions enumerates the vendor's regions.
|
||||
ListRegions(ctx context.Context) ([]Region, error)
|
||||
// SupportsElasticIP reports whether RotateIP (change IP, keep the box) is
|
||||
// available for this vendor.
|
||||
SupportsElasticIP() bool
|
||||
}
|
||||
|
||||
// AdapterFactory resolves the CloudAdapter for a given provider row.
|
||||
// providers/Registry implements it; tests inject a fake.
|
||||
type AdapterFactory interface {
|
||||
For(p *Provider) (CloudAdapter, error)
|
||||
}
|
||||
|
||||
// ErrElasticIPUnsupported is returned by RotateIP when the provider's adapter
|
||||
// does not support elastic IPs.
|
||||
var ErrElasticIPUnsupported = errors.New("provision: provider does not support elastic IP")
|
||||
|
||||
// ErrNoCredentials signals an adapter could not find its credentials in the
|
||||
// configured secrets (env/file).
|
||||
var ErrNoCredentials = errors.New("provision: vendor credentials not configured")
|
||||
@@ -0,0 +1,66 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// newUUID returns a RFC-4122 v4 UUID string. Self-contained (crypto/rand) so
|
||||
// the package does not depend on the not-yet-implemented idgen package.
|
||||
func newUUID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("provision: uuid entropy: %w", err)
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
|
||||
// TemplateRenderer renders the node cloud-init document from a Go text/template
|
||||
// file (infra/cloud-init/node.yaml.tmpl). It satisfies CloudInitRenderer.
|
||||
//
|
||||
// The rendered output carries the one-time bootstrap token and MUST NOT be
|
||||
// logged. text/template (not html/template) is used because the output is YAML.
|
||||
type TemplateRenderer struct {
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// NewTemplateRenderer parses the cloud-init template file at path.
|
||||
func NewTemplateRenderer(path string) (*TemplateRenderer, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provision: read cloud-init template: %w", err)
|
||||
}
|
||||
return NewTemplateRendererFromString(string(raw))
|
||||
}
|
||||
|
||||
// NewTemplateRendererFromString parses an in-memory template (used in tests).
|
||||
func NewTemplateRendererFromString(body string) (*TemplateRenderer, error) {
|
||||
t, err := template.New("node-cloud-init").Option("missingkey=error").Parse(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provision: parse cloud-init template: %w", err)
|
||||
}
|
||||
return &TemplateRenderer{tmpl: t}, nil
|
||||
}
|
||||
|
||||
// Render executes the template against data.
|
||||
func (r *TemplateRenderer) Render(data CloudInitData) (string, error) {
|
||||
var sb stringsBuilder
|
||||
if err := r.tmpl.Execute(&sb, data); err != nil {
|
||||
return "", fmt.Errorf("provision: render cloud-init: %w", err)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// stringsBuilder is a tiny alias to avoid importing strings just for Builder
|
||||
// alongside the strings import elsewhere; kept local for clarity.
|
||||
type stringsBuilder struct{ buf []byte }
|
||||
|
||||
func (s *stringsBuilder) Write(p []byte) (int, error) {
|
||||
s.buf = append(s.buf, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
func (s *stringsBuilder) String() string { return string(s.buf) }
|
||||
@@ -0,0 +1,84 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRealCloudInitTemplate ensures the shipped infra/cloud-init/node.yaml.tmpl
|
||||
// stays a valid Go template and renders the bootstrap token + node identity.
|
||||
func TestRealCloudInitTemplate(t *testing.T) {
|
||||
const path = "../../../infra/cloud-init/node.yaml.tmpl"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skipf("template not found at %s: %v", path, err)
|
||||
}
|
||||
r, err := NewTemplateRenderer(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parse real template: %v", err)
|
||||
}
|
||||
out, err := r.Render(CloudInitData{
|
||||
NodeUUID: "11111111-2222-4333-8444-555555555555",
|
||||
BootstrapToken: "deadbeef",
|
||||
Region: "hkg",
|
||||
Role: RoleEntry,
|
||||
Tier: TierFree,
|
||||
ControlPlaneURL: "https://cp.example",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("render real template: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"PANGOLIN_NODE_UUID=11111111-2222-4333-8444-555555555555",
|
||||
"PANGOLIN_BOOTSTRAP_TOKEN=deadbeef",
|
||||
"PANGOLIN_CONTROL_PLANE_URL=https://cp.example",
|
||||
"#cloud-config",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("rendered cloud-init missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUUID_Format(t *testing.T) {
|
||||
u, err := newUUID()
|
||||
if err != nil {
|
||||
t.Fatalf("newUUID: %v", err)
|
||||
}
|
||||
if len(u) != 36 || strings.Count(u, "-") != 4 {
|
||||
t.Errorf("uuid format wrong: %q", u)
|
||||
}
|
||||
if u[14] != '4' {
|
||||
t.Errorf("not a v4 uuid: %q", u)
|
||||
}
|
||||
u2, _ := newUUID()
|
||||
if u == u2 {
|
||||
t.Error("uuid not unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateRenderer(t *testing.T) {
|
||||
r, err := NewTemplateRendererFromString("id={{.NodeUUID}}\ntoken={{.BootstrapToken}}\ncp={{.ControlPlaneURL}}")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out, err := r.Render(CloudInitData{NodeUUID: "abc", BootstrapToken: "tok", ControlPlaneURL: "https://cp"})
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
for _, want := range []string{"id=abc", "token=tok", "cp=https://cp"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("rendered output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateRenderer_MissingKeyErrors(t *testing.T) {
|
||||
r, err := NewTemplateRendererFromString("{{.NoSuchField}}")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if _, err := r.Render(CloudInitData{}); err == nil {
|
||||
t.Error("expected error for missing template key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Clock abstracts time for testability (Replace drains on a timer).
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
// Sleep blocks for d or until ctx is done, returning ctx.Err() on cancel.
|
||||
Sleep(ctx context.Context, d time.Duration) error
|
||||
}
|
||||
|
||||
// realClock is the production Clock.
|
||||
type realClock struct{}
|
||||
|
||||
func (realClock) Now() time.Time { return time.Now().UTC() }
|
||||
|
||||
func (realClock) Sleep(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Prober verifies a node is reachable before it joins the directory.
|
||||
//
|
||||
// The full version (three-way probe cross-check, doc/04 §4.2) depends on the
|
||||
// probe fleet (task #15). Until then a simplified Prober is injected:
|
||||
// "heartbeat present + an overseas reachability check". Replace calls WaitReady
|
||||
// after a node is created; on success the node is promoted to up.
|
||||
type Prober interface {
|
||||
// WaitReady blocks until the node passes simplified probing, or returns an
|
||||
// error on timeout / ctx cancellation / probe failure.
|
||||
WaitReady(ctx context.Context, node *Node) error
|
||||
}
|
||||
|
||||
// AlertHook receives operational alerts (doc/04 §5.3 — TG bot in production).
|
||||
// Replace / CreateNode call Fire on boot failure, probe timeout, and capacity
|
||||
// protection breaches.
|
||||
type AlertHook interface {
|
||||
Fire(ctx context.Context, alert Alert)
|
||||
}
|
||||
|
||||
// AlertKind classifies an alert.
|
||||
type AlertKind string
|
||||
|
||||
const (
|
||||
AlertBootFailed AlertKind = "boot_failed"
|
||||
AlertProbeTimeout AlertKind = "probe_timeout"
|
||||
AlertReplaceFail AlertKind = "replace_failed"
|
||||
AlertCapacity AlertKind = "capacity_low"
|
||||
)
|
||||
|
||||
// Alert is a single operational alert payload.
|
||||
type Alert struct {
|
||||
Kind AlertKind
|
||||
NodeUUID string
|
||||
Pool Pool
|
||||
Message string
|
||||
// FailCount is the running failure counter for capacity-protection alerts.
|
||||
FailCount int
|
||||
}
|
||||
|
||||
// noopAlert discards alerts; used when no hook is configured.
|
||||
type noopAlert struct{}
|
||||
|
||||
func (noopAlert) Fire(context.Context, Alert) {}
|
||||
|
||||
// BootstrapIssuer issues one-time enrollment tokens (task #5).
|
||||
// *mtls.BootstrapTokenManager satisfies this interface.
|
||||
type BootstrapIssuer interface {
|
||||
IssueToken(ctx context.Context, nodeUUID string) (string, error)
|
||||
}
|
||||
|
||||
// CloudInitRenderer renders the node cloud-init document from a template,
|
||||
// injecting the node UUID and bootstrap token (template lives at
|
||||
// infra/cloud-init/node.yaml.tmpl, task #6).
|
||||
type CloudInitRenderer interface {
|
||||
Render(data CloudInitData) (string, error)
|
||||
}
|
||||
|
||||
// CloudInitData is the template context for the node cloud-init document.
|
||||
type CloudInitData struct {
|
||||
NodeUUID string
|
||||
BootstrapToken string
|
||||
Region string
|
||||
Role Role
|
||||
Tier Tier
|
||||
// ControlPlaneURL is the mTLS enrollment endpoint the agent registers to.
|
||||
ControlPlaneURL string
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package provision owns the elastic-node control plane: it turns the
|
||||
// "nodes are cattle, not pets" principle (doc/04 §2) into running code.
|
||||
//
|
||||
// Responsibility split (doc/04 §4):
|
||||
//
|
||||
// - Terraform (infra/terraform) manages low-frequency baseline resources:
|
||||
// probe machines and the control-plane environment. Those live in
|
||||
// Terraform state.
|
||||
// - This package drives high-frequency, minute-scale node lifecycle through
|
||||
// vendor APIs. Disposable nodes are NOT in Terraform state.
|
||||
//
|
||||
// The package exposes a ProvisionService with idempotent operations:
|
||||
//
|
||||
// - CreateNode — insert nodes(status=provisioning) → vendor API boot →
|
||||
// render cloud-init (injecting a one-time bootstrap token, task #5) →
|
||||
// return. The agent self-registers (task #6) and flips the node to up.
|
||||
// - DestroyNode — vendor destroy + IP release + nodes→destroyed.
|
||||
// - RotateIP — swap the elastic IP without re-creating the machine
|
||||
// (change IP, keep the box), then bump the directory version.
|
||||
// - Replace — make-before-break one-click replacement: bring a fresh
|
||||
// node up BEFORE draining/destroying the old one, so capacity never dips.
|
||||
// - RotatePool — rolling Replace across a whole pool, concurrency 1–2.
|
||||
//
|
||||
// Vendor adapters live in providers/ behind the CloudAdapter interface.
|
||||
// Vendor credentials are injected ONLY from independent secrets (env/file) —
|
||||
// never stored in the database and never committed to git. The providers table
|
||||
// holds just name/api_kind/regions/pool/enabled.
|
||||
//
|
||||
// Hard red line (doc/06 §2): every operation here targets the new vendor pools.
|
||||
// It MUST NOT ever manage the production EC2 host (deploy/ marzban machine).
|
||||
package provision
|
||||
@@ -0,0 +1,462 @@
|
||||
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"}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MySQLStore is the production Store backed by the shared *sql.DB pool.
|
||||
type MySQLStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewMySQLStore wraps a MySQL connection pool.
|
||||
func NewMySQLStore(db *sql.DB) *MySQLStore { return &MySQLStore{db: db} }
|
||||
|
||||
var _ Store = (*MySQLStore)(nil)
|
||||
|
||||
func marshalJSONList(v []string) string {
|
||||
if v == nil {
|
||||
v = []string{}
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func unmarshalJSONList(s sql.NullString) []string {
|
||||
if !s.Valid || s.String == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
_ = json.Unmarshal([]byte(s.String), &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// --- nodes ---
|
||||
|
||||
func (s *MySQLStore) InsertNode(ctx context.Context, n *Node) (int64, error) {
|
||||
endpoint := n.Endpoint
|
||||
if endpoint == "" {
|
||||
endpoint = pendingEndpoint
|
||||
}
|
||||
status := n.Status
|
||||
if status == "" {
|
||||
status = StatusProvisioning
|
||||
}
|
||||
weight := n.Weight
|
||||
if weight == 0 {
|
||||
weight = 100
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO nodes
|
||||
(uuid, region, name_zh, name_en, role, tier, endpoint, hy2_port,
|
||||
reality_pbk, reality_sni, provider_id, tags, status, weight, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(6))`,
|
||||
n.UUID, n.Region, n.NameZH, n.NameEn, string(n.Role), string(n.Tier),
|
||||
endpoint, nullInt(n.HY2Port), n.RealityPBK, n.RealitySNI, n.ProviderID,
|
||||
marshalJSONList(n.Tags), string(status), weight)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store.InsertNode: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store.InsertNode last id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
const nodeColumns = `id, uuid, region, name_zh, name_en, role, tier, endpoint,
|
||||
hy2_port, reality_pbk, reality_sni, provider_id, provider_instance_id,
|
||||
elastic_ip_id, tags, status, weight, created_at`
|
||||
|
||||
func scanNode(row interface{ Scan(...any) error }) (*Node, error) {
|
||||
var (
|
||||
n Node
|
||||
hy2 sql.NullInt64
|
||||
instanceID sql.NullString
|
||||
elasticID sql.NullString
|
||||
tags sql.NullString
|
||||
)
|
||||
if err := row.Scan(
|
||||
&n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEn, &n.Role, &n.Tier,
|
||||
&n.Endpoint, &hy2, &n.RealityPBK, &n.RealitySNI, &n.ProviderID,
|
||||
&instanceID, &elasticID, &tags, &n.Status, &n.Weight, &n.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.HY2Port = int(hy2.Int64)
|
||||
n.ProviderInstanceID = instanceID.String
|
||||
n.ElasticIPID = elasticID.String
|
||||
n.Tags = unmarshalJSONList(tags)
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) GetNode(ctx context.Context, id int64) (*Node, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+nodeColumns+` FROM nodes WHERE id=?`, id)
|
||||
n, err := scanNode(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.GetNode: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) GetNodeByUUID(ctx context.Context, uuid string) (*Node, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+nodeColumns+` FROM nodes WHERE uuid=?`, uuid)
|
||||
n, err := scanNode(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.GetNodeByUUID: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) UpdateNodeStatus(ctx context.Context, id int64, status Status) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE nodes SET status=? WHERE id=?`, string(status), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.UpdateNodeStatus: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) UpdateNodeEndpoint(ctx context.Context, id int64, endpoint string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE nodes SET endpoint=? WHERE id=?`, endpoint, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.UpdateNodeEndpoint: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) SetNodeInstance(ctx context.Context, id int64, instanceID, endpoint string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE nodes SET provider_instance_id=?, endpoint=? WHERE id=?`,
|
||||
instanceID, endpoint, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.SetNodeInstance: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) SetNodeWeight(ctx context.Context, id int64, weight int) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE nodes SET weight=? WHERE id=?`, weight, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.SetNodeWeight: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) ListNodesByPool(ctx context.Context, pool Pool, status Status) ([]*Node, error) {
|
||||
q := `SELECT ` + qualify(nodeColumns, "n") + `
|
||||
FROM nodes n JOIN providers p ON p.id = n.provider_id
|
||||
WHERE p.pool = ?`
|
||||
args := []any{string(pool)}
|
||||
if status != "" {
|
||||
q += ` AND n.status = ?`
|
||||
args = append(args, string(status))
|
||||
}
|
||||
q += ` ORDER BY n.id`
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.ListNodesByPool: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Node
|
||||
for rows.Next() {
|
||||
n, err := scanNode(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.ListNodesByPool scan: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- providers ---
|
||||
|
||||
func (s *MySQLStore) ListProviders(ctx context.Context, pool Pool) ([]*Provider, error) {
|
||||
q := `SELECT id, name, api_kind, regions, pool, enabled FROM providers WHERE enabled=TRUE`
|
||||
var args []any
|
||||
if pool != "" {
|
||||
q += ` AND pool=?`
|
||||
args = append(args, string(pool))
|
||||
}
|
||||
q += ` ORDER BY id`
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.ListProviders: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Provider
|
||||
for rows.Next() {
|
||||
p, err := scanProvider(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.ListProviders scan: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *MySQLStore) GetProvider(ctx context.Context, id int64) (*Provider, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, api_kind, regions, pool, enabled FROM providers WHERE id=?`, id)
|
||||
p, err := scanProvider(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.GetProvider: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func scanProvider(row interface{ Scan(...any) error }) (*Provider, error) {
|
||||
var (
|
||||
p Provider
|
||||
regions sql.NullString
|
||||
)
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.APIKind, ®ions, &p.Pool, &p.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Regions = unmarshalJSONList(regions)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// --- events / audit / directory ---
|
||||
|
||||
func (s *MySQLStore) WriteNodeEvent(ctx context.Context, nodeID int64, event Event, detailJSON string) error {
|
||||
if detailJSON == "" {
|
||||
detailJSON = "null"
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO node_events (node_id, event, detail, at) VALUES (?, ?, ?, UTC_TIMESTAMP(6))`,
|
||||
nodeID, string(event), detailJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.WriteNodeEvent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) WriteAuditLog(ctx context.Context, actor, action, target, metaJSON string) error {
|
||||
if metaJSON == "" {
|
||||
metaJSON = "null"
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6))`,
|
||||
actor, action, target, metaJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.WriteAuditLog: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) BumpDirectoryVersion(ctx context.Context) (int64, error) {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE directory_version SET version = version + 1 WHERE id = 1`); err != nil {
|
||||
return 0, fmt.Errorf("store.BumpDirectoryVersion: %w", err)
|
||||
}
|
||||
var v int64
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT version FROM directory_version WHERE id = 1`).Scan(&v); err != nil {
|
||||
return 0, fmt.Errorf("store.BumpDirectoryVersion read: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// --- idempotency ---
|
||||
|
||||
func (s *MySQLStore) LookupIdempotency(ctx context.Context, key string) (string, bool, error) {
|
||||
var uuid string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT node_uuid FROM provision_idempotency WHERE idempotency_key=?`, key).Scan(&uuid)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("store.LookupIdempotency: %w", err)
|
||||
}
|
||||
return uuid, true, nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) SaveIdempotency(ctx context.Context, key, nodeUUID string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO provision_idempotency (idempotency_key, node_uuid, created_at)
|
||||
VALUES (?, ?, UTC_TIMESTAMP(6))
|
||||
ON DUPLICATE KEY UPDATE idempotency_key = idempotency_key`,
|
||||
key, nodeUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.SaveIdempotency: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- replacements ---
|
||||
|
||||
func (s *MySQLStore) CreateReplacement(ctx context.Context, r *Replacement) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO replacements (uuid, old_node_id, new_node_id, pool, status, step)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
r.UUID, r.OldNodeID, nullInt64(r.NewNodeID), string(r.Pool),
|
||||
string(r.Status), string(r.Step))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.CreateReplacement: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) GetReplacement(ctx context.Context, uuid string) (*Replacement, error) {
|
||||
var (
|
||||
r Replacement
|
||||
newID sql.NullInt64
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT uuid, old_node_id, new_node_id, pool, status, step, created_at, updated_at
|
||||
FROM replacements WHERE uuid=?`, uuid).
|
||||
Scan(&r.UUID, &r.OldNodeID, &newID, &r.Pool, &r.Status, &r.Step, &r.CreatedAt, &r.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.GetReplacement: %w", err)
|
||||
}
|
||||
r.NewNodeID = newID.Int64
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *MySQLStore) UpdateReplacement(ctx context.Context, r *Replacement) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE replacements SET new_node_id=?, status=?, step=? WHERE uuid=?`,
|
||||
nullInt64(r.NewNodeID), string(r.Status), string(r.Step), r.UUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.UpdateReplacement: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func nullInt(v int) interface{} {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func nullInt64(v int64) interface{} {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// qualify prefixes every comma-separated column in cols with the alias.
|
||||
func qualify(cols, alias string) string {
|
||||
parts := strings.Split(cols, ",")
|
||||
for i, p := range parts {
|
||||
parts[i] = alias + "." + strings.TrimSpace(p)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Package providers holds the concrete CloudAdapter implementations behind the
|
||||
// provision.AdapterFactory boundary (doc/04 §4: "厂商 API 适配层").
|
||||
//
|
||||
// First launch ships two vendors — one per pool (doc/04 §5.2):
|
||||
//
|
||||
// - vultr — consumable pool (entry/free): cheap, hourly-billed small vendor.
|
||||
// - hetzner — premium pool (exit / pro entry): stable, good native IPs.
|
||||
//
|
||||
// CREDENTIAL RED LINE (doc/06 §2): adapters read their API credentials ONLY
|
||||
// from independent secrets (environment variables here). Credentials are never
|
||||
// stored in the providers table, never logged, and never committed to git. The
|
||||
// Registry binds providers.api_kind → adapter at wiring time.
|
||||
//
|
||||
// Identity isolation: each vendor MUST use a fully independent account / email /
|
||||
// crypto payment, registered in infra/identity-isolation.md.
|
||||
package providers
|
||||
@@ -0,0 +1,154 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/provision"
|
||||
)
|
||||
|
||||
// hetznerAdapter implements provision.CloudAdapter against the Hetzner Cloud
|
||||
// API v1. Premium pool: stable vendor, good native IPs, floating-IP capable.
|
||||
//
|
||||
// Credentials: PROVISION_HETZNER_API_TOKEN (env, independent secret).
|
||||
// Default image: PROVISION_HETZNER_IMAGE (defaults to "debian-12").
|
||||
type hetznerAdapter struct {
|
||||
c *httpClient
|
||||
image string
|
||||
}
|
||||
|
||||
func newHetznerFromEnv() (provision.CloudAdapter, error) {
|
||||
token, err := secret("PROVISION_HETZNER_API_TOKEN")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
image := os.Getenv("PROVISION_HETZNER_IMAGE")
|
||||
if image == "" {
|
||||
image = "debian-12"
|
||||
}
|
||||
return &hetznerAdapter{
|
||||
c: newHTTPClient("https://api.hetzner.cloud/v1", token),
|
||||
image: image,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *hetznerAdapter) Kind() string { return "hetzner" }
|
||||
func (a *hetznerAdapter) SupportsElasticIP() bool { return true } // floating IPs
|
||||
|
||||
func (a *hetznerAdapter) CreateInstance(ctx context.Context, in provision.CreateInput) (*provision.Instance, error) {
|
||||
body := map[string]any{
|
||||
"name": sanitizeName(in.Label),
|
||||
"server_type": in.Plan,
|
||||
"image": a.image,
|
||||
"location": in.Region,
|
||||
"user_data": in.UserData, // Hetzner accepts raw cloud-init
|
||||
"start_after_create": true,
|
||||
"labels": labelsFromTags(in.Tags),
|
||||
}
|
||||
if len(in.SSHKeyIDs) > 0 {
|
||||
body["ssh_keys"] = in.SSHKeyIDs
|
||||
}
|
||||
var resp struct {
|
||||
Server struct {
|
||||
ID int64 `json:"id"`
|
||||
PublicNet struct {
|
||||
IPv4 struct {
|
||||
IP string `json:"ip"`
|
||||
} `json:"ipv4"`
|
||||
} `json:"public_net"`
|
||||
Datacenter struct {
|
||||
Location struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"location"`
|
||||
} `json:"datacenter"`
|
||||
} `json:"server"`
|
||||
}
|
||||
if err := a.c.do(ctx, "POST", "/servers", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &provision.Instance{
|
||||
ID: strconv.FormatInt(resp.Server.ID, 10),
|
||||
IP: resp.Server.PublicNet.IPv4.IP,
|
||||
Region: resp.Server.Datacenter.Location.Name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *hetznerAdapter) DestroyInstance(ctx context.Context, instanceID string) error {
|
||||
err := a.c.do(ctx, "DELETE", "/servers/"+instanceID, nil, nil)
|
||||
if err != nil && isNotFound(err) {
|
||||
return nil // idempotent
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *hetznerAdapter) AttachIP(ctx context.Context, instanceID string) (string, error) {
|
||||
id, err := strconv.ParseInt(instanceID, 10, 64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp struct {
|
||||
FloatingIP struct {
|
||||
ID int64 `json:"id"`
|
||||
IP string `json:"ip"`
|
||||
} `json:"floating_ip"`
|
||||
}
|
||||
if err := a.c.do(ctx, "POST", "/floating_ips", map[string]any{
|
||||
"type": "ipv4",
|
||||
"server": id,
|
||||
"description": "rotate-" + instanceID,
|
||||
}, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.FloatingIP.IP, nil
|
||||
}
|
||||
|
||||
func (a *hetznerAdapter) ListRegions(ctx context.Context) ([]provision.Region, error) {
|
||||
var resp struct {
|
||||
Locations []struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
} `json:"locations"`
|
||||
}
|
||||
if err := a.c.do(ctx, "GET", "/locations", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]provision.Region, 0, len(resp.Locations))
|
||||
for _, l := range resp.Locations {
|
||||
out = append(out, provision.Region{ID: l.Name, Country: l.Country, City: l.City})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- helpers shared with vultr ---
|
||||
|
||||
func atoiDefault(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
return err != nil && (strings.Contains(err.Error(), "status 404") || strings.Contains(err.Error(), "not_found"))
|
||||
}
|
||||
|
||||
// sanitizeName makes a UUID acceptable as a Hetzner server name (RFC1123-ish).
|
||||
func sanitizeName(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
return "node-" + s
|
||||
}
|
||||
|
||||
func labelsFromTags(tags []string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for i, t := range tags {
|
||||
m["tag"+strconv.Itoa(i)] = t
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/provision"
|
||||
)
|
||||
|
||||
// Factory builds a CloudAdapter, reading credentials from independent secrets
|
||||
// (env). It returns provision.ErrNoCredentials when the required secret is unset.
|
||||
type Factory func() (provision.CloudAdapter, error)
|
||||
|
||||
// builtins maps api_kind → Factory. Extend here when onboarding a vendor.
|
||||
var builtins = map[string]Factory{
|
||||
"vultr": newVultrFromEnv,
|
||||
"hetzner": newHetznerFromEnv,
|
||||
}
|
||||
|
||||
// Registry resolves provision.Provider rows to live adapters. It satisfies
|
||||
// provision.AdapterFactory and caches one adapter per api_kind.
|
||||
type Registry struct {
|
||||
factories map[string]Factory
|
||||
cache map[string]provision.CloudAdapter
|
||||
}
|
||||
|
||||
// NewRegistry returns a Registry backed by the built-in vendor factories.
|
||||
func NewRegistry() *Registry {
|
||||
fs := make(map[string]Factory, len(builtins))
|
||||
for k, v := range builtins {
|
||||
fs[k] = v
|
||||
}
|
||||
return &Registry{factories: fs, cache: map[string]provision.CloudAdapter{}}
|
||||
}
|
||||
|
||||
// Register adds or overrides a factory for api_kind (used in tests / extension).
|
||||
func (r *Registry) Register(apiKind string, f Factory) { r.factories[apiKind] = f }
|
||||
|
||||
// For resolves the adapter for a provider row.
|
||||
func (r *Registry) For(p *provision.Provider) (provision.CloudAdapter, error) {
|
||||
if a, ok := r.cache[p.APIKind]; ok {
|
||||
return a, nil
|
||||
}
|
||||
f, ok := r.factories[p.APIKind]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("providers: no adapter registered for api_kind %q", p.APIKind)
|
||||
}
|
||||
a, err := f()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.cache[p.APIKind] = a
|
||||
return a, nil
|
||||
}
|
||||
|
||||
var _ provision.AdapterFactory = (*Registry)(nil)
|
||||
|
||||
// --- shared HTTP helper ---
|
||||
|
||||
// httpClient is a small JSON REST helper shared by the adapters.
|
||||
type httpClient struct {
|
||||
base string
|
||||
bearer string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func newHTTPClient(base, bearer string) *httpClient {
|
||||
return &httpClient{base: base, bearer: bearer, hc: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
// do issues an authenticated JSON request and decodes the response into out
|
||||
// (out may be nil). It returns an error on any non-2xx status.
|
||||
func (c *httpClient) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("providers: marshal body: %w", err)
|
||||
}
|
||||
rdr = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("providers: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearer)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("providers: %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// Never echo credentials; only status + vendor body (no auth header).
|
||||
return fmt.Errorf("providers: %s %s: status %d: %s", method, path, resp.StatusCode, string(data))
|
||||
}
|
||||
if out != nil && len(data) > 0 {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return fmt.Errorf("providers: decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// secret reads an env-injected credential, returning ErrNoCredentials if unset.
|
||||
func secret(env string) (string, error) {
|
||||
v := os.Getenv(env)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("%w (env %s)", provision.ErrNoCredentials, env)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/provision"
|
||||
)
|
||||
|
||||
func TestRegistry_UnknownKind(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
_, err := r.For(&provision.Provider{APIKind: "does-not-exist"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown api_kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_NoCredentials(t *testing.T) {
|
||||
// Ensure the env vars are unset for a clean assertion.
|
||||
t.Setenv("PROVISION_VULTR_API_KEY", "")
|
||||
t.Setenv("PROVISION_HETZNER_API_TOKEN", "")
|
||||
|
||||
r := NewRegistry()
|
||||
if _, err := r.For(&provision.Provider{APIKind: "vultr"}); !errors.Is(err, provision.ErrNoCredentials) {
|
||||
t.Errorf("vultr without creds: err = %v, want ErrNoCredentials", err)
|
||||
}
|
||||
if _, err := r.For(&provision.Provider{APIKind: "hetzner"}); !errors.Is(err, provision.ErrNoCredentials) {
|
||||
t.Errorf("hetzner without creds: err = %v, want ErrNoCredentials", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_RegisterAndCache(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
built := 0
|
||||
r.Register("fake", func() (provision.CloudAdapter, error) {
|
||||
built++
|
||||
return stubAdapter{}, nil
|
||||
})
|
||||
p := &provision.Provider{APIKind: "fake"}
|
||||
if _, err := r.For(p); err != nil {
|
||||
t.Fatalf("For: %v", err)
|
||||
}
|
||||
if _, err := r.For(p); err != nil {
|
||||
t.Fatalf("For (cached): %v", err)
|
||||
}
|
||||
if built != 1 {
|
||||
t.Errorf("factory built %d times, want 1 (cached)", built)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_CredentialsFromEnv(t *testing.T) {
|
||||
t.Setenv("PROVISION_VULTR_API_KEY", "secret-key")
|
||||
r := NewRegistry()
|
||||
a, err := r.For(&provision.Provider{APIKind: "vultr"})
|
||||
if err != nil {
|
||||
t.Fatalf("vultr with creds: %v", err)
|
||||
}
|
||||
if a.Kind() != "vultr" {
|
||||
t.Errorf("kind = %s, want vultr", a.Kind())
|
||||
}
|
||||
if !a.SupportsElasticIP() {
|
||||
t.Error("vultr should support elastic IP (reserved IPs)")
|
||||
}
|
||||
}
|
||||
|
||||
type stubAdapter struct{}
|
||||
|
||||
func (stubAdapter) Kind() string { return "fake" }
|
||||
func (stubAdapter) SupportsElasticIP() bool { return false }
|
||||
func (stubAdapter) CreateInstance(context.Context, provision.CreateInput) (*provision.Instance, error) {
|
||||
return &provision.Instance{ID: "x"}, nil
|
||||
}
|
||||
func (stubAdapter) DestroyInstance(context.Context, string) error { return nil }
|
||||
func (stubAdapter) AttachIP(context.Context, string) (string, error) { return "", nil }
|
||||
func (stubAdapter) ListRegions(context.Context) ([]provision.Region, error) { return nil, nil }
|
||||
@@ -0,0 +1,110 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/provision"
|
||||
)
|
||||
|
||||
// vultrAdapter implements provision.CloudAdapter against the Vultr API v2.
|
||||
// Consumable pool: cheap, hourly-billed, reserved-IP capable.
|
||||
//
|
||||
// Credentials: PROVISION_VULTR_API_KEY (env, independent secret).
|
||||
// Default OS image: PROVISION_VULTR_OS_ID (defaults to a current Debian image).
|
||||
type vultrAdapter struct {
|
||||
c *httpClient
|
||||
osID int
|
||||
}
|
||||
|
||||
func newVultrFromEnv() (provision.CloudAdapter, error) {
|
||||
key, err := secret("PROVISION_VULTR_API_KEY")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
osID := atoiDefault(os.Getenv("PROVISION_VULTR_OS_ID"), 2136) // 2136 = Debian 12 x64
|
||||
return &vultrAdapter{
|
||||
c: newHTTPClient("https://api.vultr.com/v2", key),
|
||||
osID: osID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *vultrAdapter) Kind() string { return "vultr" }
|
||||
func (a *vultrAdapter) SupportsElasticIP() bool { return true } // reserved IPs
|
||||
|
||||
func (a *vultrAdapter) CreateInstance(ctx context.Context, in provision.CreateInput) (*provision.Instance, error) {
|
||||
body := map[string]any{
|
||||
"region": in.Region,
|
||||
"plan": in.Plan,
|
||||
"os_id": a.osID,
|
||||
"label": in.Label,
|
||||
"hostname": in.Label,
|
||||
"user_data": base64.StdEncoding.EncodeToString([]byte(in.UserData)),
|
||||
"tags": in.Tags,
|
||||
"backups": "disabled",
|
||||
}
|
||||
if len(in.SSHKeyIDs) > 0 {
|
||||
body["sshkey_id"] = in.SSHKeyIDs
|
||||
}
|
||||
var resp struct {
|
||||
Instance struct {
|
||||
ID string `json:"id"`
|
||||
MainIP string `json:"main_ip"`
|
||||
Region string `json:"region"`
|
||||
} `json:"instance"`
|
||||
}
|
||||
if err := a.c.do(ctx, "POST", "/instances", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &provision.Instance{
|
||||
ID: resp.Instance.ID,
|
||||
IP: resp.Instance.MainIP,
|
||||
Region: resp.Instance.Region,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *vultrAdapter) DestroyInstance(ctx context.Context, instanceID string) error {
|
||||
err := a.c.do(ctx, "DELETE", "/instances/"+instanceID, nil, nil)
|
||||
if err != nil && isNotFound(err) {
|
||||
return nil // idempotent: already gone
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *vultrAdapter) AttachIP(ctx context.Context, instanceID string) (string, error) {
|
||||
// Allocate a reserved IPv4, then attach it to the instance.
|
||||
var created struct {
|
||||
ReservedIP struct {
|
||||
ID string `json:"id"`
|
||||
Subnet string `json:"subnet"`
|
||||
} `json:"reserved_ip"`
|
||||
}
|
||||
if err := a.c.do(ctx, "POST", "/reserved-ips", map[string]any{
|
||||
"region": "",
|
||||
"ip_type": "v4",
|
||||
"label": "rotate-" + instanceID,
|
||||
"instance_id": instanceID,
|
||||
}, &created); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return created.ReservedIP.Subnet, nil
|
||||
}
|
||||
|
||||
func (a *vultrAdapter) ListRegions(ctx context.Context) ([]provision.Region, error) {
|
||||
var resp struct {
|
||||
Regions []struct {
|
||||
ID string `json:"id"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
} `json:"regions"`
|
||||
}
|
||||
if err := a.c.do(ctx, "GET", "/regions", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]provision.Region, 0, len(resp.Regions))
|
||||
for _, r := range resp.Regions {
|
||||
out = append(out, provision.Region{ID: r.ID, Country: r.Country, City: r.City})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stepRank orders ReplaceStep so resume logic can compare progress.
|
||||
var stepRank = map[ReplaceStep]int{
|
||||
StepOpenNew: 0,
|
||||
StepProbing: 1,
|
||||
StepNewUp: 2,
|
||||
StepDraining: 3,
|
||||
StepDestroyOld: 4,
|
||||
StepDone: 5,
|
||||
}
|
||||
|
||||
// ReplaceResult reports the outcome of one Replace.
|
||||
type ReplaceResult struct {
|
||||
ReplacementUUID string
|
||||
OldNodeID int64
|
||||
NewNodeID int64
|
||||
NewNode *Node
|
||||
}
|
||||
|
||||
// Replace performs a make-before-break one-click replacement of nodeID
|
||||
// (doc/04 §4.1). A fresh node is brought UP before the old one is drained and
|
||||
// destroyed, so capacity never dips.
|
||||
//
|
||||
// replacementUUID is the idempotency key for the whole orchestration. Pass ""
|
||||
// to start a new replacement; pass an existing UUID to RESUME after a crash —
|
||||
// progress is persisted in the replacements table and CreateNode's own
|
||||
// idempotency guarantees no duplicate boot (validation: "崩溃重启续跑不重复").
|
||||
func (s *Service) Replace(ctx context.Context, nodeID int64, replacementUUID string) (*ReplaceResult, error) {
|
||||
old, err := s.store.GetNode(ctx, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if old == nil {
|
||||
return nil, fmt.Errorf("provision: node %d not found", nodeID)
|
||||
}
|
||||
pool := poolForTier(old.Tier)
|
||||
|
||||
// Load or create the orchestration record.
|
||||
var rec *Replacement
|
||||
if replacementUUID != "" {
|
||||
rec, err = s.store.GetReplacement(ctx, replacementUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if rec == nil {
|
||||
if replacementUUID == "" {
|
||||
if replacementUUID, err = newUUID(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rec = &Replacement{
|
||||
UUID: replacementUUID,
|
||||
OldNodeID: nodeID,
|
||||
Pool: pool,
|
||||
Status: ReplaceRunning,
|
||||
Step: StepOpenNew,
|
||||
}
|
||||
if err := s.store.CreateReplacement(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if rec.Status == ReplaceDone {
|
||||
// Already finished; return the recorded result.
|
||||
newNode, _ := s.store.GetNode(ctx, rec.NewNodeID)
|
||||
return &ReplaceResult{ReplacementUUID: rec.UUID, OldNodeID: rec.OldNodeID, NewNodeID: rec.NewNodeID, NewNode: newNode}, nil
|
||||
}
|
||||
|
||||
return s.runReplace(ctx, rec, old, pool)
|
||||
}
|
||||
|
||||
// runReplace drives the replacement state machine forward from rec.Step,
|
||||
// persisting after each transition so a crash resumes cleanly.
|
||||
func (s *Service) runReplace(ctx context.Context, rec *Replacement, old *Node, pool Pool) (*ReplaceResult, error) {
|
||||
atLeast := func(step ReplaceStep) bool { return stepRank[rec.Step] >= stepRank[step] }
|
||||
advance := func(step ReplaceStep) error {
|
||||
rec.Step = step
|
||||
return s.store.UpdateReplacement(ctx, rec)
|
||||
}
|
||||
|
||||
var newNode *Node
|
||||
|
||||
// Step 1: open the new node (same region, pool may switch vendor).
|
||||
if !atLeast(StepProbing) {
|
||||
spec, err := s.replacementSpec(ctx, old, pool)
|
||||
if err != nil {
|
||||
return s.failReplace(ctx, rec, old, pool, err)
|
||||
}
|
||||
nn, err := s.CreateNode(ctx, spec, rec.UUID+":create")
|
||||
if err != nil {
|
||||
return s.failReplace(ctx, rec, old, pool, fmt.Errorf("open new node: %w", err))
|
||||
}
|
||||
newNode = nn
|
||||
rec.NewNodeID = nn.ID
|
||||
if err := advance(StepProbing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if newNode == nil && rec.NewNodeID != 0 {
|
||||
if newNode, _ = s.store.GetNode(ctx, rec.NewNodeID); newNode == nil {
|
||||
return s.failReplace(ctx, rec, old, pool, fmt.Errorf("new node %d vanished", rec.NewNodeID))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: wait for agent self-register + simplified probing.
|
||||
if !atLeast(StepNewUp) {
|
||||
if s.prober != nil {
|
||||
pctx, cancel := context.WithTimeout(ctx, s.probeTimeout)
|
||||
err := s.prober.WaitReady(pctx, newNode)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = s.store.WriteNodeEvent(ctx, newNode.ID, EventProbeFail, jsonDetail(map[string]any{"error": err.Error()}))
|
||||
// Probe timeout: destroy the failed new node, count + alert.
|
||||
_ = s.DestroyNode(ctx, newNode.ID)
|
||||
count := s.bumpFail(pool)
|
||||
s.alert.Fire(ctx, Alert{Kind: AlertProbeTimeout, NodeUUID: newNode.UUID, Pool: pool, Message: err.Error(), FailCount: count})
|
||||
rec.Status = ReplaceFailed
|
||||
_ = s.store.UpdateReplacement(ctx, rec)
|
||||
return nil, fmt.Errorf("provision: replace probe failed: %w", err)
|
||||
}
|
||||
}
|
||||
_ = s.store.WriteNodeEvent(ctx, newNode.ID, EventProbePass, "")
|
||||
if err := advance(StepNewUp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: promote the new node UP and publish (capacity is now restored
|
||||
// BEFORE the old node leaves the directory — make-before-break).
|
||||
if !atLeast(StepDraining) {
|
||||
if err := s.store.UpdateNodeStatus(ctx, newNode.ID, StatusUp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.store.WriteNodeEvent(ctx, newNode.ID, EventMarkedUp, "")
|
||||
if _, err := s.store.BumpDirectoryVersion(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.resetFail(pool)
|
||||
if err := advance(StepDraining); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: drain the old node. Free/consumable nodes hard-cut.
|
||||
if !atLeast(StepDestroyOld) {
|
||||
if err := s.store.UpdateNodeStatus(ctx, old.ID, StatusDraining); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.store.WriteNodeEvent(ctx, old.ID, EventDraining, "")
|
||||
if _, err := s.store.BumpDirectoryVersion(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pool != PoolConsumable {
|
||||
if err := s.clock.Sleep(ctx, s.drainTimeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := advance(StepDestroyOld); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: destroy the old node + release IP.
|
||||
if !atLeast(StepDone) {
|
||||
if err := s.DestroyNode(ctx, old.ID); err != nil {
|
||||
return s.failReplace(ctx, rec, old, pool, fmt.Errorf("destroy old node: %w", err))
|
||||
}
|
||||
_ = s.store.WriteNodeEvent(ctx, old.ID, EventReplaced, jsonDetail(map[string]any{
|
||||
"replacement_uuid": rec.UUID,
|
||||
"new_node_id": rec.NewNodeID,
|
||||
}))
|
||||
if err := advance(StepDone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rec.Status = ReplaceDone
|
||||
if err := s.store.UpdateReplacement(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.store.WriteAuditLog(ctx, "provision", "replace", "node:"+old.UUID,
|
||||
jsonDetail(map[string]any{"replacement_uuid": rec.UUID, "new_node_id": rec.NewNodeID}))
|
||||
|
||||
if newNode == nil && rec.NewNodeID != 0 {
|
||||
newNode, _ = s.store.GetNode(ctx, rec.NewNodeID)
|
||||
}
|
||||
return &ReplaceResult{
|
||||
ReplacementUUID: rec.UUID,
|
||||
OldNodeID: rec.OldNodeID,
|
||||
NewNodeID: rec.NewNodeID,
|
||||
NewNode: newNode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// failReplace records a fatal replacement failure (alert + audit).
|
||||
func (s *Service) failReplace(ctx context.Context, rec *Replacement, old *Node, pool Pool, cause error) (*ReplaceResult, error) {
|
||||
rec.Status = ReplaceFailed
|
||||
_ = s.store.UpdateReplacement(ctx, rec)
|
||||
count := s.bumpFail(pool)
|
||||
_ = s.store.WriteAuditLog(ctx, "provision", "replace_failed", "node:"+old.UUID,
|
||||
jsonDetail(map[string]any{"replacement_uuid": rec.UUID, "error": cause.Error(), "fail_count": count}))
|
||||
s.alert.Fire(ctx, Alert{Kind: AlertReplaceFail, NodeUUID: old.UUID, Pool: pool, Message: cause.Error(), FailCount: count})
|
||||
return nil, fmt.Errorf("provision: replace failed: %w", cause)
|
||||
}
|
||||
|
||||
// replacementSpec derives the spec for the new node from the old one, keeping
|
||||
// the same region/tier/role but allowing a different vendor in the same pool
|
||||
// (doc/04 §4.1: "同 region · 厂商池内可换家").
|
||||
func (s *Service) replacementSpec(ctx context.Context, old *Node, pool Pool) (NodeSpec, error) {
|
||||
providerID := old.ProviderID
|
||||
providers, err := s.store.ListProviders(ctx, pool)
|
||||
if err != nil {
|
||||
return NodeSpec{}, err
|
||||
}
|
||||
// Prefer a different enabled provider in the pool to spread exposure.
|
||||
for _, p := range providers {
|
||||
if p.ID != old.ProviderID && providerSupportsRegion(p, old.Region) {
|
||||
providerID = p.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
return NodeSpec{
|
||||
Region: old.Region,
|
||||
Role: old.Role,
|
||||
Tier: old.Tier,
|
||||
ProviderID: providerID,
|
||||
NameZH: old.NameZH,
|
||||
NameEn: old.NameEn,
|
||||
RealityPBK: old.RealityPBK,
|
||||
RealitySNI: old.RealitySNI,
|
||||
HY2Port: old.HY2Port,
|
||||
Weight: old.Weight,
|
||||
Tags: old.Tags,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func providerSupportsRegion(p *Provider, region string) bool {
|
||||
if len(p.Regions) == 0 {
|
||||
return true // unconstrained
|
||||
}
|
||||
for _, r := range p.Regions {
|
||||
if r == region {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RotatePool rolls Replace across every up node in a pool with bounded
|
||||
// concurrency (doc/04 §4: 并发度 1–2). Used for routine rotation or large-scale
|
||||
// event rebuilds.
|
||||
func (s *Service) RotatePool(ctx context.Context, pool Pool, concurrency int) ([]ReplaceResult, error) {
|
||||
if concurrency < 1 {
|
||||
concurrency = 1
|
||||
}
|
||||
if concurrency > 2 {
|
||||
concurrency = 2
|
||||
}
|
||||
nodes, err := s.store.ListNodesByPool(ctx, pool, StatusUp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []ReplaceResult
|
||||
firstEr error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
sem := make(chan struct{}, concurrency)
|
||||
for _, n := range nodes {
|
||||
n := n
|
||||
if firstErrSet(&mu, &firstEr) {
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
res, err := s.Replace(ctx, n.ID, "")
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
if firstEr == nil {
|
||||
firstEr = err
|
||||
}
|
||||
return
|
||||
}
|
||||
results = append(results, *res)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return results, firstEr
|
||||
}
|
||||
|
||||
func firstErrSet(mu *sync.Mutex, e *error) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return *e != nil
|
||||
}
|
||||
|
||||
// drainDeadline is exposed for tests/monitoring of the configured drain window.
|
||||
func (s *Service) drainDeadline(start time.Time) time.Time { return start.Add(s.drainTimeout) }
|
||||
@@ -0,0 +1,213 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestReplace_MakeBeforeBreak asserts the full state-machine migration and that
|
||||
// capacity never dips: the new node reaches `up` BEFORE the old node leaves the
|
||||
// directory (enters `draining`).
|
||||
func TestReplace_MakeBeforeBreak(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
old, err := h.svc.CreateNode(ctx, proSpec(), "old-node")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateNode old: %v", err)
|
||||
}
|
||||
if err := h.store.UpdateNodeStatus(ctx, old.ID, StatusUp); err != nil {
|
||||
t.Fatalf("seed up: %v", err)
|
||||
}
|
||||
bootsBefore := h.adapter.createCount()
|
||||
|
||||
res, err := h.svc.Replace(ctx, old.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Replace: %v", err)
|
||||
}
|
||||
if res.NewNodeID == 0 || res.NewNodeID == old.ID {
|
||||
t.Fatalf("bad new node id: %d", res.NewNodeID)
|
||||
}
|
||||
if h.adapter.createCount() != bootsBefore+1 {
|
||||
t.Errorf("expected exactly one new boot, got delta %d", h.adapter.createCount()-bootsBefore)
|
||||
}
|
||||
|
||||
// Capacity invariant: new node `up` seq < old node `draining` seq.
|
||||
newUpSeq := h.store.firstSeqForStatus(res.NewNodeID, StatusUp)
|
||||
oldDrainSeq := h.store.firstSeqForStatus(old.ID, StatusDraining)
|
||||
if newUpSeq < 0 {
|
||||
t.Fatal("new node never reached up")
|
||||
}
|
||||
if oldDrainSeq < 0 {
|
||||
t.Fatal("old node never drained")
|
||||
}
|
||||
if !(newUpSeq < oldDrainSeq) {
|
||||
t.Errorf("capacity dipped: new up seq %d not before old draining seq %d", newUpSeq, oldDrainSeq)
|
||||
}
|
||||
|
||||
// Old node finally destroyed.
|
||||
oldNode, _ := h.store.GetNode(ctx, old.ID)
|
||||
if oldNode.Status != StatusDestroyed {
|
||||
t.Errorf("old node status = %s, want destroyed", oldNode.Status)
|
||||
}
|
||||
newNode, _ := h.store.GetNode(ctx, res.NewNodeID)
|
||||
if newNode.Status != StatusUp {
|
||||
t.Errorf("new node status = %s, want up", newNode.Status)
|
||||
}
|
||||
// pro pool drains on a timer.
|
||||
if len(h.clock.slept) == 0 {
|
||||
t.Error("expected a drain sleep for premium pool")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplace_FreeHardCut: consumable/free pool drains by hard cut (no sleep).
|
||||
func TestReplace_FreeHardCut(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
old, _ := h.svc.CreateNode(ctx, freeSpec(), "old-free")
|
||||
_ = h.store.UpdateNodeStatus(ctx, old.ID, StatusUp)
|
||||
|
||||
if _, err := h.svc.Replace(ctx, old.ID, ""); err != nil {
|
||||
t.Fatalf("Replace: %v", err)
|
||||
}
|
||||
if len(h.clock.slept) != 0 {
|
||||
t.Errorf("free pool should hard-cut, but slept %v", h.clock.slept)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplace_ProbeTimeout: probing fails → new node destroyed, fail counted,
|
||||
// alert fired, replacement marked failed, old node untouched.
|
||||
func TestReplace_ProbeTimeout(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
h.prober.err = errors.New("probe timeout")
|
||||
|
||||
old, _ := h.svc.CreateNode(ctx, proSpec(), "old-pt")
|
||||
_ = h.store.UpdateNodeStatus(ctx, old.ID, StatusUp)
|
||||
|
||||
_, err := h.svc.Replace(ctx, old.ID, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected probe timeout error")
|
||||
}
|
||||
if h.svc.FailCount(PoolPremium) != 1 {
|
||||
t.Errorf("fail count = %d, want 1", h.svc.FailCount(PoolPremium))
|
||||
}
|
||||
if h.alert.count(AlertProbeTimeout) != 1 {
|
||||
t.Errorf("probe_timeout alerts = %d, want 1", h.alert.count(AlertProbeTimeout))
|
||||
}
|
||||
// Old node must still be up (capacity protected — we did not drain it).
|
||||
oldNode, _ := h.store.GetNode(ctx, old.ID)
|
||||
if oldNode.Status != StatusUp {
|
||||
t.Errorf("old node status = %s, want up (untouched)", oldNode.Status)
|
||||
}
|
||||
// The failed new node should be destroyed (bad IP not left running).
|
||||
if h.adapter.destroyCount() != 1 {
|
||||
t.Errorf("failed new node not destroyed: destroy count %d", h.adapter.destroyCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplace_CrashResume: a replacement that crashed after booting the new node
|
||||
// (idempotency key already saved) must resume WITHOUT a second boot.
|
||||
func TestReplace_CrashResume(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
old, _ := h.svc.CreateNode(ctx, freeSpec(), "old-cr")
|
||||
_ = h.store.UpdateNodeStatus(ctx, old.ID, StatusUp)
|
||||
|
||||
ruuid := "fixed-replacement-uuid"
|
||||
// Simulate the pre-crash state: the new node was already booted under the
|
||||
// orchestration's idempotency key, and a running replacement record exists
|
||||
// at the open_new step (new_node_id not yet persisted).
|
||||
spec, err := h.svc.replacementSpec(ctx, mustNode(t, h, old.ID), PoolConsumable)
|
||||
if err != nil {
|
||||
t.Fatalf("replacementSpec: %v", err)
|
||||
}
|
||||
preNode, err := h.svc.CreateNode(ctx, spec, ruuid+":create")
|
||||
if err != nil {
|
||||
t.Fatalf("pre-boot new node: %v", err)
|
||||
}
|
||||
if err := h.store.CreateReplacement(ctx, &Replacement{
|
||||
UUID: ruuid, OldNodeID: old.ID, Pool: PoolConsumable,
|
||||
Status: ReplaceRunning, Step: StepOpenNew,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed replacement: %v", err)
|
||||
}
|
||||
bootsBefore := h.adapter.createCount() // old + pre-booted new = 2
|
||||
|
||||
res, err := h.svc.Replace(ctx, old.ID, ruuid)
|
||||
if err != nil {
|
||||
t.Fatalf("resume Replace: %v", err)
|
||||
}
|
||||
if h.adapter.createCount() != bootsBefore {
|
||||
t.Errorf("resume re-booted a node: delta %d (want 0)", h.adapter.createCount()-bootsBefore)
|
||||
}
|
||||
if res.NewNodeID != preNode.ID {
|
||||
t.Errorf("resume used a different new node: got %d, want %d", res.NewNodeID, preNode.ID)
|
||||
}
|
||||
oldNode, _ := h.store.GetNode(ctx, old.ID)
|
||||
if oldNode.Status != StatusDestroyed {
|
||||
t.Errorf("old node not destroyed after resume: %s", oldNode.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplace_ReplayCompleted: replaying a finished replacement is a no-op.
|
||||
func TestReplace_ReplayCompleted(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
old, _ := h.svc.CreateNode(ctx, freeSpec(), "old-rc")
|
||||
_ = h.store.UpdateNodeStatus(ctx, old.ID, StatusUp)
|
||||
|
||||
res1, err := h.svc.Replace(ctx, old.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Replace: %v", err)
|
||||
}
|
||||
boots := h.adapter.createCount()
|
||||
destroys := h.adapter.destroyCount()
|
||||
|
||||
res2, err := h.svc.Replace(ctx, old.ID, res1.ReplacementUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("replay Replace: %v", err)
|
||||
}
|
||||
if res2.NewNodeID != res1.NewNodeID {
|
||||
t.Errorf("replay new node id mismatch: %d vs %d", res2.NewNodeID, res1.NewNodeID)
|
||||
}
|
||||
if h.adapter.createCount() != boots || h.adapter.destroyCount() != destroys {
|
||||
t.Errorf("replay caused vendor side effects: boots %d->%d destroys %d->%d",
|
||||
boots, h.adapter.createCount(), destroys, h.adapter.destroyCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotatePool replaces every up node in a pool with bounded concurrency.
|
||||
func TestRotatePool(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
a, _ := h.svc.CreateNode(ctx, freeSpec(), "rp-a")
|
||||
b, _ := h.svc.CreateNode(ctx, freeSpec(), "rp-b")
|
||||
_ = h.store.UpdateNodeStatus(ctx, a.ID, StatusUp)
|
||||
_ = h.store.UpdateNodeStatus(ctx, b.ID, StatusUp)
|
||||
|
||||
results, err := h.svc.RotatePool(ctx, PoolConsumable, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("RotatePool: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %d, want 2", len(results))
|
||||
}
|
||||
for _, id := range []int64{a.ID, b.ID} {
|
||||
n, _ := h.store.GetNode(ctx, id)
|
||||
if n.Status != StatusDestroyed {
|
||||
t.Errorf("node %d status = %s, want destroyed", id, n.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustNode(t *testing.T, h *harness, id int64) *Node {
|
||||
t.Helper()
|
||||
n, err := h.store.GetNode(context.Background(), id)
|
||||
if err != nil || n == nil {
|
||||
t.Fatalf("get node %d: %v", id, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config wires a Service together. Only Store and Adapters are mandatory;
|
||||
// everything else falls back to a safe default.
|
||||
type Config struct {
|
||||
Store Store
|
||||
Adapters AdapterFactory
|
||||
Tokens BootstrapIssuer
|
||||
Renderer CloudInitRenderer
|
||||
Prober Prober
|
||||
Alert AlertHook
|
||||
Clock Clock
|
||||
|
||||
// ControlPlaneURL is injected into cloud-init so the agent knows where to
|
||||
// enroll (task #6).
|
||||
ControlPlaneURL string
|
||||
// DrainTimeout is how long a draining node is given before forced destroy
|
||||
// (doc/04 §3: default 30 minutes).
|
||||
DrainTimeout time.Duration
|
||||
// ProbeTimeout bounds the simplified probing wait during Replace.
|
||||
ProbeTimeout time.Duration
|
||||
}
|
||||
|
||||
// Service is the ProvisionService. All operations are idempotent and write
|
||||
// node_events + audit_log (doc/04 §4).
|
||||
type Service struct {
|
||||
store Store
|
||||
adapters AdapterFactory
|
||||
tokens BootstrapIssuer
|
||||
renderer CloudInitRenderer
|
||||
prober Prober
|
||||
alert AlertHook
|
||||
clock Clock
|
||||
controlPlaneURL string
|
||||
drainTimeout time.Duration
|
||||
probeTimeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
failPool map[Pool]int // consecutive boot/probe failures per pool
|
||||
}
|
||||
|
||||
// NewService builds a Service, applying defaults for optional dependencies.
|
||||
func NewService(cfg Config) (*Service, error) {
|
||||
if cfg.Store == nil {
|
||||
return nil, fmt.Errorf("provision: Store is required")
|
||||
}
|
||||
if cfg.Adapters == nil {
|
||||
return nil, fmt.Errorf("provision: Adapters factory is required")
|
||||
}
|
||||
s := &Service{
|
||||
store: cfg.Store,
|
||||
adapters: cfg.Adapters,
|
||||
tokens: cfg.Tokens,
|
||||
renderer: cfg.Renderer,
|
||||
prober: cfg.Prober,
|
||||
alert: cfg.Alert,
|
||||
clock: cfg.Clock,
|
||||
controlPlaneURL: cfg.ControlPlaneURL,
|
||||
drainTimeout: cfg.DrainTimeout,
|
||||
probeTimeout: cfg.ProbeTimeout,
|
||||
failPool: make(map[Pool]int),
|
||||
}
|
||||
if s.alert == nil {
|
||||
s.alert = noopAlert{}
|
||||
}
|
||||
if s.clock == nil {
|
||||
s.clock = realClock{}
|
||||
}
|
||||
if s.drainTimeout == 0 {
|
||||
s.drainTimeout = 30 * time.Minute
|
||||
}
|
||||
if s.probeTimeout == 0 {
|
||||
s.probeTimeout = 5 * time.Minute
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// resolveAdapter loads the provider row and its adapter.
|
||||
func (s *Service) resolveAdapter(ctx context.Context, providerID int64) (*Provider, CloudAdapter, error) {
|
||||
p, err := s.store.GetProvider(ctx, providerID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if p == nil {
|
||||
return nil, nil, fmt.Errorf("provision: provider %d not found", providerID)
|
||||
}
|
||||
a, err := s.adapters.For(p)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("provision: resolve adapter for %s: %w", p.APIKind, err)
|
||||
}
|
||||
return p, a, nil
|
||||
}
|
||||
|
||||
// CreateNode provisions one node. It is idempotent on idempotencyKey: replaying
|
||||
// the same key returns the already-created node without booting a second
|
||||
// machine (validation: "CreateNode 幂等键重放不重复开机").
|
||||
//
|
||||
// Flow: idempotency check → bind key→uuid → insert nodes(provisioning) →
|
||||
// issue bootstrap token (task #5) → render cloud-init (task #6) → vendor boot →
|
||||
// record instance ID + endpoint → node_event(provisioned) + audit_log.
|
||||
// The node stays in provisioning until the agent self-registers and probing
|
||||
// promotes it to up.
|
||||
func (s *Service) CreateNode(ctx context.Context, spec NodeSpec, idempotencyKey string) (*Node, error) {
|
||||
if idempotencyKey == "" {
|
||||
return nil, fmt.Errorf("provision: idempotencyKey is required")
|
||||
}
|
||||
|
||||
// 1. Idempotency replay.
|
||||
if uuid, found, err := s.store.LookupIdempotency(ctx, idempotencyKey); err != nil {
|
||||
return nil, err
|
||||
} else if found {
|
||||
return s.store.GetNodeByUUID(ctx, uuid)
|
||||
}
|
||||
|
||||
pool := poolForTier(spec.Tier)
|
||||
|
||||
// 2. Allocate UUID and bind the idempotency key BEFORE any vendor call, so a
|
||||
// crash after this point resumes onto the same node instead of booting a
|
||||
// duplicate.
|
||||
uuid, err := newUUID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.SaveIdempotency(ctx, idempotencyKey, uuid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Re-read in case of a race: another caller may have won the key.
|
||||
if winner, found, err := s.store.LookupIdempotency(ctx, idempotencyKey); err == nil && found && winner != uuid {
|
||||
return s.store.GetNodeByUUID(ctx, winner)
|
||||
}
|
||||
|
||||
// 3. Insert the node in provisioning state.
|
||||
n := &Node{
|
||||
UUID: uuid,
|
||||
Region: spec.Region,
|
||||
NameZH: spec.NameZH,
|
||||
NameEn: spec.NameEn,
|
||||
Role: orDefaultRole(spec.Role),
|
||||
Tier: spec.Tier,
|
||||
Endpoint: pendingEndpoint,
|
||||
HY2Port: spec.HY2Port,
|
||||
RealityPBK: spec.RealityPBK,
|
||||
RealitySNI: spec.RealitySNI,
|
||||
ProviderID: spec.ProviderID,
|
||||
Tags: spec.Tags,
|
||||
Status: StatusProvisioning,
|
||||
Weight: spec.Weight,
|
||||
}
|
||||
id, err := s.store.InsertNode(ctx, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.ID = id
|
||||
|
||||
// 4. Resolve adapter.
|
||||
_, adapter, err := s.resolveAdapter(ctx, spec.ProviderID)
|
||||
if err != nil {
|
||||
s.failBoot(ctx, n, pool, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Bootstrap token + cloud-init.
|
||||
userData := ""
|
||||
if s.renderer != nil {
|
||||
token := ""
|
||||
if s.tokens != nil {
|
||||
token, err = s.tokens.IssueToken(ctx, uuid)
|
||||
if err != nil {
|
||||
s.failBoot(ctx, n, pool, fmt.Errorf("issue bootstrap token: %w", err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
userData, err = s.renderer.Render(CloudInitData{
|
||||
NodeUUID: uuid,
|
||||
BootstrapToken: token,
|
||||
Region: spec.Region,
|
||||
Role: n.Role,
|
||||
Tier: spec.Tier,
|
||||
ControlPlaneURL: s.controlPlaneURL,
|
||||
})
|
||||
if err != nil {
|
||||
s.failBoot(ctx, n, pool, fmt.Errorf("render cloud-init: %w", err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Vendor boot.
|
||||
inst, err := adapter.CreateInstance(ctx, CreateInput{
|
||||
Region: spec.Region,
|
||||
Plan: spec.Plan,
|
||||
Label: uuid,
|
||||
UserData: userData,
|
||||
Tags: spec.Tags,
|
||||
})
|
||||
if err != nil {
|
||||
s.failBoot(ctx, n, pool, fmt.Errorf("vendor boot: %w", err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 7. Record instance ID + endpoint.
|
||||
endpoint := joinEndpoint(inst.IP, spec.HY2Port)
|
||||
if err := s.store.SetNodeInstance(ctx, id, inst.ID, endpoint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.ProviderInstanceID = inst.ID
|
||||
n.Endpoint = endpoint
|
||||
|
||||
// 8. Audit trail + reset failure counter.
|
||||
_ = s.store.WriteNodeEvent(ctx, id, EventProvisioned, jsonDetail(map[string]any{
|
||||
"instance_id": inst.ID,
|
||||
"region": inst.Region,
|
||||
}))
|
||||
_ = s.store.WriteAuditLog(ctx, "provision", "create_node", "node:"+uuid,
|
||||
jsonDetail(map[string]any{"provider_id": spec.ProviderID, "region": spec.Region, "pool": pool}))
|
||||
s.resetFail(pool)
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// failBoot marks the node destroyed, bumps the per-pool failure counter, and
|
||||
// fires a boot-failed alert (validation: "开机失败 → destroyed + 失败计数 + 告警钩子").
|
||||
func (s *Service) failBoot(ctx context.Context, n *Node, pool Pool, cause error) {
|
||||
_ = s.store.UpdateNodeStatus(ctx, n.ID, StatusDestroyed)
|
||||
_ = s.store.WriteNodeEvent(ctx, n.ID, EventDestroyed, jsonDetail(map[string]any{
|
||||
"reason": "boot_failed",
|
||||
"error": cause.Error(),
|
||||
}))
|
||||
count := s.bumpFail(pool)
|
||||
_ = s.store.WriteAuditLog(ctx, "provision", "create_node_failed", "node:"+n.UUID,
|
||||
jsonDetail(map[string]any{"error": cause.Error(), "pool": pool, "fail_count": count}))
|
||||
s.alert.Fire(ctx, Alert{
|
||||
Kind: AlertBootFailed,
|
||||
NodeUUID: n.UUID,
|
||||
Pool: pool,
|
||||
Message: cause.Error(),
|
||||
FailCount: count,
|
||||
})
|
||||
}
|
||||
|
||||
// DestroyNode tears down a node: vendor destroy → IP release → nodes→destroyed.
|
||||
// Idempotent: destroying an already-destroyed node is a no-op success.
|
||||
func (s *Service) DestroyNode(ctx context.Context, nodeID int64) error {
|
||||
n, err := s.store.GetNode(ctx, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == nil {
|
||||
return fmt.Errorf("provision: node %d not found", nodeID)
|
||||
}
|
||||
if n.Status == StatusDestroyed {
|
||||
return nil
|
||||
}
|
||||
if n.ProviderInstanceID != "" {
|
||||
_, adapter, err := s.resolveAdapter(ctx, n.ProviderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := adapter.DestroyInstance(ctx, n.ProviderInstanceID); err != nil {
|
||||
return fmt.Errorf("provision: destroy instance: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.store.UpdateNodeStatus(ctx, nodeID, StatusDestroyed); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.store.WriteNodeEvent(ctx, nodeID, EventDestroyed, jsonDetail(map[string]any{"reason": "destroy"}))
|
||||
_ = s.store.WriteAuditLog(ctx, "provision", "destroy_node", "node:"+n.UUID, "")
|
||||
if _, err := s.store.BumpDirectoryVersion(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateIP swaps the elastic IP without re-creating the machine (doc/04 §4:
|
||||
// "换 IP 不换机"). Flow: vendor IP re-bind → probe → update endpoint →
|
||||
// bump directory version → node_event(ip_rotated).
|
||||
func (s *Service) RotateIP(ctx context.Context, nodeID int64) (*Node, error) {
|
||||
n, err := s.store.GetNode(ctx, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == nil {
|
||||
return nil, fmt.Errorf("provision: node %d not found", nodeID)
|
||||
}
|
||||
_, adapter, err := s.resolveAdapter(ctx, n.ProviderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !adapter.SupportsElasticIP() {
|
||||
return nil, ErrElasticIPUnsupported
|
||||
}
|
||||
oldEndpoint := n.Endpoint
|
||||
newIP, err := adapter.AttachIP(ctx, n.ProviderInstanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provision: attach IP: %w", err)
|
||||
}
|
||||
newEndpoint := joinEndpoint(newIP, n.HY2Port)
|
||||
|
||||
// Probe the new endpoint before publishing it.
|
||||
probed := *n
|
||||
probed.Endpoint = newEndpoint
|
||||
if s.prober != nil {
|
||||
if err := s.prober.WaitReady(ctx, &probed); err != nil {
|
||||
s.alert.Fire(ctx, Alert{Kind: AlertProbeTimeout, NodeUUID: n.UUID, Message: err.Error()})
|
||||
return nil, fmt.Errorf("provision: probe rotated IP: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.UpdateNodeEndpoint(ctx, nodeID, newEndpoint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version, err := s.store.BumpDirectoryVersion(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.store.WriteNodeEvent(ctx, nodeID, EventIPRotated, jsonDetail(map[string]any{
|
||||
"old_endpoint": oldEndpoint,
|
||||
"new_endpoint": newEndpoint,
|
||||
"version": version,
|
||||
}))
|
||||
_ = s.store.WriteAuditLog(ctx, "provision", "rotate_ip", "node:"+n.UUID,
|
||||
jsonDetail(map[string]any{"old": oldEndpoint, "new": newEndpoint}))
|
||||
n.Endpoint = newEndpoint
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListProviders returns enabled providers in a pool (empty pool = all pools).
|
||||
func (s *Service) ListProviders(ctx context.Context, pool Pool) ([]*Provider, error) {
|
||||
return s.store.ListProviders(ctx, pool)
|
||||
}
|
||||
|
||||
// --- failure-counter helpers (capacity protection, doc/04 §4.2) ---
|
||||
|
||||
func (s *Service) bumpFail(pool Pool) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failPool[pool]++
|
||||
return s.failPool[pool]
|
||||
}
|
||||
|
||||
func (s *Service) resetFail(pool Pool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.failPool, pool)
|
||||
}
|
||||
|
||||
// FailCount exposes the current consecutive-failure count for a pool (tests /
|
||||
// monitoring).
|
||||
func (s *Service) FailCount(pool Pool) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.failPool[pool]
|
||||
}
|
||||
|
||||
// --- small helpers ---
|
||||
|
||||
func orDefaultRole(r Role) Role {
|
||||
if r == "" {
|
||||
return RoleEntry
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func joinEndpoint(ip string, port int) string {
|
||||
if port <= 0 {
|
||||
port = 443
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", ip, port)
|
||||
}
|
||||
|
||||
func jsonDetail(m map[string]any) string {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "null"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateNode_Success(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
n, err := h.svc.CreateNode(ctx, freeSpec(), "key-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateNode: %v", err)
|
||||
}
|
||||
if n.Status != StatusProvisioning {
|
||||
t.Errorf("status = %s, want provisioning", n.Status)
|
||||
}
|
||||
if n.ProviderInstanceID == "" {
|
||||
t.Error("instance ID not recorded")
|
||||
}
|
||||
if n.Endpoint == pendingEndpoint || n.Endpoint == "" {
|
||||
t.Errorf("endpoint not updated: %q", n.Endpoint)
|
||||
}
|
||||
if h.tokens.calls != 1 {
|
||||
t.Errorf("bootstrap token issued %d times, want 1", h.tokens.calls)
|
||||
}
|
||||
if h.adapter.createCount() != 1 {
|
||||
t.Errorf("boot calls = %d, want 1", h.adapter.createCount())
|
||||
}
|
||||
if h.store.countEvents(EventProvisioned) != 1 {
|
||||
t.Errorf("provisioned events = %d, want 1", h.store.countEvents(EventProvisioned))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateNode_IdempotentReplay(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
n1, err := h.svc.CreateNode(ctx, freeSpec(), "same-key")
|
||||
if err != nil {
|
||||
t.Fatalf("first CreateNode: %v", err)
|
||||
}
|
||||
n2, err := h.svc.CreateNode(ctx, freeSpec(), "same-key")
|
||||
if err != nil {
|
||||
t.Fatalf("replay CreateNode: %v", err)
|
||||
}
|
||||
if n1.UUID != n2.UUID {
|
||||
t.Errorf("replay returned different node: %s vs %s", n1.UUID, n2.UUID)
|
||||
}
|
||||
if h.adapter.createCount() != 1 {
|
||||
t.Errorf("idempotency violated: boot called %d times, want 1", h.adapter.createCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateNode_BootFailure(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
h.adapter.createErr = errors.New("vendor 500")
|
||||
|
||||
_, err := h.svc.CreateNode(ctx, freeSpec(), "key-boom")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on boot failure")
|
||||
}
|
||||
// Node should be destroyed.
|
||||
n, _ := h.store.GetNodeByUUID(ctx, h.store.idem["key-boom"])
|
||||
if n == nil || n.Status != StatusDestroyed {
|
||||
t.Errorf("node not destroyed after boot failure: %+v", n)
|
||||
}
|
||||
if got := h.svc.FailCount(PoolConsumable); got != 1 {
|
||||
t.Errorf("fail count = %d, want 1", got)
|
||||
}
|
||||
if h.alert.count(AlertBootFailed) != 1 {
|
||||
t.Errorf("boot_failed alerts = %d, want 1", h.alert.count(AlertBootFailed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestroyNode_Idempotent(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
n, err := h.svc.CreateNode(ctx, freeSpec(), "key-d")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateNode: %v", err)
|
||||
}
|
||||
|
||||
if err := h.svc.DestroyNode(ctx, n.ID); err != nil {
|
||||
t.Fatalf("DestroyNode: %v", err)
|
||||
}
|
||||
if err := h.svc.DestroyNode(ctx, n.ID); err != nil {
|
||||
t.Fatalf("second DestroyNode should be no-op: %v", err)
|
||||
}
|
||||
if h.adapter.destroyCount() != 1 {
|
||||
t.Errorf("vendor destroy called %d times, want 1 (idempotent)", h.adapter.destroyCount())
|
||||
}
|
||||
got, _ := h.store.GetNode(ctx, n.ID)
|
||||
if got.Status != StatusDestroyed {
|
||||
t.Errorf("status = %s, want destroyed", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateIP(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
n, err := h.svc.CreateNode(ctx, freeSpec(), "key-ip")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateNode: %v", err)
|
||||
}
|
||||
before := h.store.version
|
||||
oldEndpoint := n.Endpoint
|
||||
|
||||
updated, err := h.svc.RotateIP(ctx, n.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RotateIP: %v", err)
|
||||
}
|
||||
if updated.Endpoint == oldEndpoint {
|
||||
t.Errorf("endpoint not changed: still %s", updated.Endpoint)
|
||||
}
|
||||
if h.store.version <= before {
|
||||
t.Errorf("directory version not bumped: %d <= %d", h.store.version, before)
|
||||
}
|
||||
if h.store.countEvents(EventIPRotated) != 1 {
|
||||
t.Errorf("ip_rotated events = %d, want 1", h.store.countEvents(EventIPRotated))
|
||||
}
|
||||
stored, _ := h.store.GetNode(ctx, n.ID)
|
||||
if stored.Endpoint != updated.Endpoint {
|
||||
t.Errorf("stored endpoint %s != returned %s", stored.Endpoint, updated.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateIP_Unsupported(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
h.adapter.elastic = false
|
||||
n, err := h.svc.CreateNode(ctx, freeSpec(), "key-ip2")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateNode: %v", err)
|
||||
}
|
||||
_, err = h.svc.RotateIP(ctx, n.ID)
|
||||
if !errors.Is(err, ErrElasticIPUnsupported) {
|
||||
t.Errorf("err = %v, want ErrElasticIPUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProviders(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
ps, err := h.svc.ListProviders(ctx, PoolPremium)
|
||||
if err != nil {
|
||||
t.Fatalf("ListProviders: %v", err)
|
||||
}
|
||||
if len(ps) != 1 || ps[0].Pool != PoolPremium {
|
||||
t.Errorf("premium providers = %+v, want exactly 1 premium", ps)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReplaceStep enumerates the resumable steps of a Replace orchestration.
|
||||
// Persisted in replacements.step so a crashed process can continue without
|
||||
// repeating a boot or a destroy.
|
||||
type ReplaceStep string
|
||||
|
||||
const (
|
||||
StepOpenNew ReplaceStep = "open_new" // create the replacement node
|
||||
StepProbing ReplaceStep = "probing" // wait for self-register + probe
|
||||
StepNewUp ReplaceStep = "new_up" // promote new node, bump version
|
||||
StepDraining ReplaceStep = "draining" // drain the old node
|
||||
StepDestroyOld ReplaceStep = "destroy_old" // destroy old node + release IP
|
||||
StepDone ReplaceStep = "done"
|
||||
)
|
||||
|
||||
// ReplaceStatus is the terminal/running state of a replacement record.
|
||||
type ReplaceStatus string
|
||||
|
||||
const (
|
||||
ReplaceRunning ReplaceStatus = "running"
|
||||
ReplaceDone ReplaceStatus = "done"
|
||||
ReplaceFailed ReplaceStatus = "failed"
|
||||
)
|
||||
|
||||
// Replacement mirrors a replacements row (crash-recoverable orchestration).
|
||||
type Replacement struct {
|
||||
UUID string
|
||||
OldNodeID int64
|
||||
NewNodeID int64 // 0 until the new node is created
|
||||
Pool Pool
|
||||
Status ReplaceStatus
|
||||
Step ReplaceStep
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Store is the persistence boundary for the provision package. Both the MySQL
|
||||
// implementation (mysqlStore) and the in-memory test fake satisfy it, keeping
|
||||
// the service / orchestration logic database-agnostic and unit-testable.
|
||||
type Store interface {
|
||||
// --- nodes ---
|
||||
InsertNode(ctx context.Context, n *Node) (int64, error)
|
||||
GetNode(ctx context.Context, id int64) (*Node, error)
|
||||
GetNodeByUUID(ctx context.Context, uuid string) (*Node, error)
|
||||
UpdateNodeStatus(ctx context.Context, id int64, status Status) error
|
||||
UpdateNodeEndpoint(ctx context.Context, id int64, endpoint string) error
|
||||
// SetNodeInstance records the vendor instance ID and IP-derived endpoint.
|
||||
SetNodeInstance(ctx context.Context, id int64, instanceID, endpoint string) error
|
||||
SetNodeWeight(ctx context.Context, id int64, weight int) error
|
||||
// ListNodesByPool returns nodes whose provider belongs to pool, optionally
|
||||
// filtered to a single status (empty = all statuses).
|
||||
ListNodesByPool(ctx context.Context, pool Pool, status Status) ([]*Node, error)
|
||||
|
||||
// --- providers ---
|
||||
ListProviders(ctx context.Context, pool Pool) ([]*Provider, error)
|
||||
GetProvider(ctx context.Context, id int64) (*Provider, error)
|
||||
|
||||
// --- events / audit / directory ---
|
||||
WriteNodeEvent(ctx context.Context, nodeID int64, event Event, detailJSON string) error
|
||||
WriteAuditLog(ctx context.Context, actor, action, target, metaJSON string) error
|
||||
BumpDirectoryVersion(ctx context.Context) (int64, error)
|
||||
|
||||
// --- idempotency ---
|
||||
// LookupIdempotency returns the node UUID previously bound to key, or
|
||||
// (\"\", false, nil) if unseen.
|
||||
LookupIdempotency(ctx context.Context, key string) (string, bool, error)
|
||||
// SaveIdempotency binds key→nodeUUID. It is a no-op if the key already
|
||||
// exists (first write wins).
|
||||
SaveIdempotency(ctx context.Context, key, nodeUUID string) error
|
||||
|
||||
// --- replacements (crash recovery) ---
|
||||
CreateReplacement(ctx context.Context, r *Replacement) error
|
||||
GetReplacement(ctx context.Context, uuid string) (*Replacement, error)
|
||||
UpdateReplacement(ctx context.Context, r *Replacement) error
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package provision
|
||||
|
||||
import "time"
|
||||
|
||||
// Pool is a vendor pool (doc/04 §5.2). Consumable = cheap small vendors for
|
||||
// entry/free nodes; premium = stable vendors for exit / pro entry.
|
||||
type Pool string
|
||||
|
||||
const (
|
||||
PoolConsumable Pool = "consumable"
|
||||
PoolPremium Pool = "premium"
|
||||
)
|
||||
|
||||
// Tier mirrors nodes.tier (free = consumable pool, pro = premium pool).
|
||||
type Tier string
|
||||
|
||||
const (
|
||||
TierFree Tier = "free"
|
||||
TierPro Tier = "pro"
|
||||
)
|
||||
|
||||
// Role mirrors nodes.role.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleEntry Role = "entry"
|
||||
RoleRelay Role = "relay"
|
||||
RoleExit Role = "exit"
|
||||
)
|
||||
|
||||
// Status mirrors the nodes.status lifecycle state machine (doc/04 §3).
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusProvisioning Status = "provisioning"
|
||||
StatusProbing Status = "probing"
|
||||
StatusUp Status = "up"
|
||||
StatusDraining Status = "draining"
|
||||
StatusDown Status = "down"
|
||||
StatusDestroyed Status = "destroyed"
|
||||
)
|
||||
|
||||
// Event mirrors the node_events.event enum.
|
||||
type Event string
|
||||
|
||||
const (
|
||||
EventProvisioned Event = "provisioned"
|
||||
EventProbePass Event = "probe_pass"
|
||||
EventProbeFail Event = "probe_fail"
|
||||
EventMarkedUp Event = "marked_up"
|
||||
EventDraining Event = "draining"
|
||||
EventBlockedSuspect Event = "blocked_suspect"
|
||||
EventBlockedConfirmed Event = "blocked_confirmed"
|
||||
EventReplaced Event = "replaced"
|
||||
EventDestroyed Event = "destroyed"
|
||||
EventIPRotated Event = "ip_rotated"
|
||||
)
|
||||
|
||||
// Provider mirrors a providers row. Credentials are intentionally absent:
|
||||
// they live only in independent secrets (env/file), never in this struct
|
||||
// nor in the database (doc/06 §2 red line).
|
||||
type Provider struct {
|
||||
ID int64
|
||||
Name string
|
||||
APIKind string // adapter key, e.g. "vultr", "hetzner"
|
||||
Regions []string
|
||||
Pool Pool
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// NodeSpec is the desired shape of a node, the input to CreateNode.
|
||||
type NodeSpec struct {
|
||||
Region string
|
||||
Role Role
|
||||
Tier Tier
|
||||
ProviderID int64
|
||||
// Plan is the vendor-side instance size identifier (e.g. "vc2-1c-1gb").
|
||||
Plan string
|
||||
NameZH string
|
||||
NameEn string
|
||||
RealityPBK string
|
||||
RealitySNI string
|
||||
HY2Port int
|
||||
Weight int
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// Node mirrors a nodes row plus the provider resource identifiers added by
|
||||
// migration 000008.
|
||||
type Node struct {
|
||||
ID int64
|
||||
UUID string
|
||||
Region string
|
||||
NameZH string
|
||||
NameEn string
|
||||
Role Role
|
||||
Tier Tier
|
||||
Endpoint string // ip:port
|
||||
HY2Port int
|
||||
RealityPBK string
|
||||
RealitySNI string
|
||||
ProviderID int64
|
||||
ProviderInstanceID string
|
||||
ElasticIPID string
|
||||
Tags []string
|
||||
Status Status
|
||||
Weight int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// pendingEndpoint is the placeholder written at insert time, before the vendor
|
||||
// returns an IP. nodes.endpoint is NOT NULL, so we cannot leave it empty.
|
||||
const pendingEndpoint = "0.0.0.0:0"
|
||||
|
||||
// poolForTier maps a node tier onto its vendor pool.
|
||||
func poolForTier(t Tier) Pool {
|
||||
if t == TierPro {
|
||||
return PoolPremium
|
||||
}
|
||||
return PoolConsumable
|
||||
}
|
||||
Reference in New Issue
Block a user