feat(panel): coalesce binding-class rebuilds in a fixed 10s window (item 23)
Assign/unassign, customer-form alignment, suspend/resume and credential rotation no longer rebuild (and restart the node's sing-box) per change. They mark the node rebuild-pending; a 1s background flusher applies exactly one rebuild with the final state when the FIXED window — 10s from the first pending change, never extended by later changes — elapses. Manual deploy, repair, node edits and server reconciles stay immediate and consume the mark. Removal clears it, and the flusher discards marks on deleted / removing / draft nodes, so a pending apply can never resurrect a node. The mark is persisted in the data file, so a panel restart inside the window cannot strand a node: the first post-boot pass flushes whatever is due.
This commit is contained in:
@@ -2319,9 +2319,10 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rebuilt[node.ID] = true
|
rebuilt[node.ID] = true
|
||||||
if err := RebuildNode(d, node, false); err != nil {
|
// Credential rotation is a binding-class change: it goes
|
||||||
return nil, err
|
// through the coalesced window (item 23), not an
|
||||||
}
|
// immediate per-node restart.
|
||||||
|
markRebuildPending(node, false)
|
||||||
}
|
}
|
||||||
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)})
|
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)})
|
||||||
view = customerCredentialsView(d, customer)
|
view = customerCredentialsView(d, customer)
|
||||||
@@ -2620,6 +2621,9 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
|||||||
item.ExitReady = false
|
item.ExitReady = false
|
||||||
}
|
}
|
||||||
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
|
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
|
||||||
|
// A node edit ships immediately and consumes any pending
|
||||||
|
// coalesced mark (item 23).
|
||||||
|
clearRebuildPending(item)
|
||||||
if err := RebuildNode(d, item, true); err != nil {
|
if err := RebuildNode(d, item, true); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -407,6 +407,17 @@ func run() error {
|
|||||||
app.housekeeping()
|
app.housekeeping()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
// Coalesced rebuild flusher (round-3 item 23): applies pending
|
||||||
|
// binding-class rebuilds once their fixed window elapses. Pending
|
||||||
|
// marks are persisted, so this also drains whatever a previous
|
||||||
|
// process left mid-window.
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
_, _ = app.orchestrator.FlushPendingRebuilds(time.Now())
|
||||||
|
}
|
||||||
|
}()
|
||||||
fmt.Printf("NexusGate v%s listening on http://%s:%d\n", VERSION, host, port)
|
fmt.Printf("NexusGate v%s listening on http://%s:%d\n", VERSION, host, port)
|
||||||
return server.ListenAndServe()
|
return server.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-15
@@ -388,6 +388,75 @@ func DeriveNodeStatus(node *Node) {
|
|||||||
node.Error = nil
|
node.Error = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- coalesced rebuilds (round-3 item 23) ----------
|
||||||
|
|
||||||
|
// RebuildCoalesceWindow is the FIXED coalescing window for binding-class
|
||||||
|
// rebuilds: it starts at a node's first pending change and is never
|
||||||
|
// extended by later changes inside it, so a burst of assign/unassign
|
||||||
|
// operations can never postpone the dispatch indefinitely. When the
|
||||||
|
// window elapses the flusher applies exactly one rebuild with the
|
||||||
|
// node's final state.
|
||||||
|
const RebuildCoalesceWindow = 10 * time.Second
|
||||||
|
|
||||||
|
// markRebuildPending records a binding-class change for the coalesced
|
||||||
|
// flusher instead of rebuilding (and restarting the node's sing-box)
|
||||||
|
// immediately. The window is anchored at the first pending change:
|
||||||
|
// re-marking inside the window only widens the exit flag.
|
||||||
|
func markRebuildPending(node *Node, includeExit bool) {
|
||||||
|
if !node.RebuildPending {
|
||||||
|
node.RebuildPending = true
|
||||||
|
node.RebuildPendingAt = NowISO()
|
||||||
|
}
|
||||||
|
if includeExit {
|
||||||
|
node.RebuildPendingExit = true
|
||||||
|
}
|
||||||
|
node.UpdatedAt = NowISO()
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearRebuildPending drops a pending coalesced rebuild — used when an
|
||||||
|
// immediate action (manual deploy, repair, node edit, removal, server
|
||||||
|
// reconcile) already ships the node's current state or ends its life.
|
||||||
|
func clearRebuildPending(node *Node) {
|
||||||
|
node.RebuildPending = false
|
||||||
|
node.RebuildPendingAt = ""
|
||||||
|
node.RebuildPendingExit = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushPendingRebuilds applies one rebuild per node whose fixed window
|
||||||
|
// has elapsed, with the node's state as of now. Pending marks on nodes
|
||||||
|
// that were deleted, are being removed, or fell back to draft are
|
||||||
|
// discarded — a pending apply must never resurrect a node. Called once
|
||||||
|
// per second from the panel's background loop; because the mark is
|
||||||
|
// persisted in the data file, a panel restart inside the window simply
|
||||||
|
// means the first post-boot pass flushes whatever is due.
|
||||||
|
func (o *Orchestrator) FlushPendingRebuilds(now time.Time) (int, error) {
|
||||||
|
flushed := 0
|
||||||
|
err := o.tx(func(d *Data) error {
|
||||||
|
for _, node := range d.Nodes {
|
||||||
|
if !node.RebuildPending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at, ok := ParseISO(node.RebuildPendingAt)
|
||||||
|
if !ok || now.Sub(at) < RebuildCoalesceWindow {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
includeExit := node.RebuildPendingExit
|
||||||
|
clearRebuildPending(node)
|
||||||
|
if node.PendingDelete || node.Status == "removing" || node.Status == "draft" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := RebuildNode(d, node, includeExit); err != nil {
|
||||||
|
node.Error = StrPtr(err.Error())
|
||||||
|
Audit(d, "system", "rebuild_flush_failed", node.ID, map[string]any{"error": err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flushed++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return flushed, err
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- customer suspend / resume ----------
|
// ---------- customer suspend / resume ----------
|
||||||
|
|
||||||
func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, error) {
|
func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, error) {
|
||||||
@@ -409,9 +478,7 @@ func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, e
|
|||||||
for _, nodeID := range affected {
|
for _, nodeID := range affected {
|
||||||
node := orchFindNode(d, nodeID)
|
node := orchFindNode(d, nodeID)
|
||||||
if node != nil && node.Status != "draft" && node.Status != "removing" {
|
if node != nil && node.Status != "draft" && node.Status != "removing" {
|
||||||
if err := RebuildNode(d, node, false); err != nil {
|
markRebuildPending(node, false)
|
||||||
return len(affected), err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Audit(d, actor, "suspend_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
|
Audit(d, actor, "suspend_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
|
||||||
@@ -462,9 +529,7 @@ func ResumeCustomerResources(d *Data, customer *Customer, actor string) (int, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
includeExit := forwardNode(node) && !node.ExitReady
|
includeExit := forwardNode(node) && !node.ExitReady
|
||||||
if err := RebuildNode(d, node, includeExit); err != nil {
|
markRebuildPending(node, includeExit)
|
||||||
return len(affected), err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Audit(d, actor, "resume_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
|
Audit(d, actor, "resume_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
|
||||||
return len(affected), nil
|
return len(affected), nil
|
||||||
@@ -815,6 +880,9 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
|
|||||||
}
|
}
|
||||||
QueueJob(d, serverID, node.ID, "apply_resource", payloadWithResource(resource, "node-exit", nil))
|
QueueJob(d, serverID, node.ID, "apply_resource", payloadWithResource(resource, "node-exit", nil))
|
||||||
}
|
}
|
||||||
|
// The reconcile just shipped the node's current state; a
|
||||||
|
// coalesced pending mark for it is satisfied.
|
||||||
|
clearRebuildPending(node)
|
||||||
node.Status = "deploying"
|
node.Status = "deploying"
|
||||||
}
|
}
|
||||||
node.UpdatedAt = NowISO()
|
node.UpdatedAt = NowISO()
|
||||||
@@ -898,6 +966,9 @@ func (o *Orchestrator) DeployNode(nodeID, actor string) (*Node, error) {
|
|||||||
if err := ValidateNodeDeploy(d, node); err != nil {
|
if err := ValidateNodeDeploy(d, node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Manual deploy is immediate (item 23 constraint 1): it ships the
|
||||||
|
// current state now and consumes any coalesced pending mark.
|
||||||
|
clearRebuildPending(node)
|
||||||
if err := RebuildNode(d, node, true); err != nil {
|
if err := RebuildNode(d, node, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -919,6 +990,9 @@ func (o *Orchestrator) RemoveNode(nodeID, actor string, deleteRecord bool) (bool
|
|||||||
}
|
}
|
||||||
node.PendingDelete = deleteRecord
|
node.PendingDelete = deleteRecord
|
||||||
node.AutoRemoving = BoolPtr(false)
|
node.AutoRemoving = BoolPtr(false)
|
||||||
|
// A pending coalesced apply must never resurrect a node that is
|
||||||
|
// being removed (item 23 constraint 2).
|
||||||
|
clearRebuildPending(node)
|
||||||
if node.Status != "draft" {
|
if node.Status != "draft" {
|
||||||
QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
|
QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
|
||||||
if forwardNode(node) && node.ExitResourceID != "" {
|
if forwardNode(node) && node.ExitResourceID != "" {
|
||||||
@@ -966,6 +1040,7 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
|
|||||||
if node.Status != "failed" && node.Status != "degraded" {
|
if node.Status != "failed" && node.Status != "degraded" {
|
||||||
return httpErr(409, "节点状态正常,无需修复")
|
return httpErr(409, "节点状态正常,无需修复")
|
||||||
}
|
}
|
||||||
|
clearRebuildPending(node)
|
||||||
if err := RebuildNode(d, node, true); err != nil {
|
if err := RebuildNode(d, node, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1022,9 +1097,7 @@ func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient,
|
|||||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||||
node.AutoRemoving = BoolPtr(false)
|
node.AutoRemoving = BoolPtr(false)
|
||||||
}
|
}
|
||||||
if err := RebuildNode(d, node, false); err != nil {
|
markRebuildPending(node, false)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
|
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
|
||||||
return nc, nil
|
return nc, nil
|
||||||
@@ -1047,9 +1120,7 @@ func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
|
|||||||
}
|
}
|
||||||
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
|
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
|
||||||
if node.Status != "draft" && node.Status != "removing" {
|
if node.Status != "draft" && node.Status != "removing" {
|
||||||
if err := RebuildNode(d, node, false); err != nil {
|
markRebuildPending(node, false)
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
|
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
|
||||||
return nil
|
return nil
|
||||||
@@ -1130,9 +1201,7 @@ func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor
|
|||||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||||
node.AutoRemoving = BoolPtr(false)
|
node.AutoRemoving = BoolPtr(false)
|
||||||
}
|
}
|
||||||
if err := RebuildNode(d, node, false); err != nil {
|
markRebuildPending(node, false)
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Tests for round-3 item 23: binding-class rebuilds are coalesced in a
|
||||||
|
// FIXED 10-second window (anchored at the first pending change, never
|
||||||
|
// extended), manual deploy stays immediate, and a pending apply never
|
||||||
|
// resurrects a node removed inside the window.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func coalesceFixture(t *testing.T) (*Store, *Orchestrator, *Node) {
|
||||||
|
t.Helper()
|
||||||
|
store, _ := newTestStore(t)
|
||||||
|
d := store.Data
|
||||||
|
d.Servers = []*Server{{ID: "srv_1", Name: "S1", PublicAddress: "entry.example.com",
|
||||||
|
PortRangeStart: 20000, PortRangeEnd: 50000, Status: "online", LastSeenAt: StrPtr(NowISO())}}
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
d.Customers = append(d.Customers, &Customer{
|
||||||
|
ID: "cus_" + string(rune('0'+i)), Name: "C", Status: "active",
|
||||||
|
Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-00000000000" + string(rune('0'+i)), RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd000" + string(rune('0'+i))},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
node := &Node{ID: "nod_1", Name: "N1", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct",
|
||||||
|
Status: "active", Port: 23456, ResourceID: "res_1", EntryReady: true,
|
||||||
|
Reality: &RealityConfig{KeyID: "reality-nod_1", ServerName: "www.tesla.com", DestPort: 443}}
|
||||||
|
d.Nodes = []*Node{node}
|
||||||
|
return store, NewOrchestrator(store), node
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyJobsFor(d *Data, nodeID string) []*Job {
|
||||||
|
out := []*Job{}
|
||||||
|
for _, job := range d.Jobs {
|
||||||
|
if job.NodeID == nodeID && job.Action == "apply_resource" {
|
||||||
|
out = append(out, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceUsers(t *testing.T, job *Job) []any {
|
||||||
|
t.Helper()
|
||||||
|
var resource struct {
|
||||||
|
Inbounds []struct {
|
||||||
|
Users []any `json:"users"`
|
||||||
|
} `json:"inbounds"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(job.Payload.Resource, &resource); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(resource.Inbounds) != 1 {
|
||||||
|
t.Fatalf("inbounds = %d, want 1", len(resource.Inbounds))
|
||||||
|
}
|
||||||
|
return resource.Inbounds[0].Users
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoalescedAssignsFlushOnceWithFinalState(t *testing.T) {
|
||||||
|
store, orch, node := coalesceFixture(t)
|
||||||
|
var firstMark string
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
if _, err := orch.AssignCustomer(node.ID, "cus_"+string(rune('0'+i)), "admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snap := store.Snapshot()
|
||||||
|
n := snap.Nodes[0]
|
||||||
|
if !n.RebuildPending {
|
||||||
|
t.Fatal("assign must mark the node pending, not rebuild immediately")
|
||||||
|
}
|
||||||
|
if i == 1 {
|
||||||
|
firstMark = n.RebuildPendingAt
|
||||||
|
} else if n.RebuildPendingAt != firstMark {
|
||||||
|
t.Fatalf("fixed window violated: mark moved from %s to %s", firstMark, n.RebuildPendingAt)
|
||||||
|
}
|
||||||
|
if got := applyJobsFor(snap, node.ID); len(got) != 0 {
|
||||||
|
t.Fatalf("after assign %d: %d apply jobs, want 0 inside the window", i, len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flushing before the window elapses is a no-op.
|
||||||
|
markTime, ok := ParseISO(firstMark)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unparseable pending mark %q", firstMark)
|
||||||
|
}
|
||||||
|
if n, err := orch.FlushPendingRebuilds(markTime.Add(5 * time.Second)); err != nil || n != 0 {
|
||||||
|
t.Fatalf("early flush = %d, %v; want 0, nil", n, err)
|
||||||
|
}
|
||||||
|
// At window end exactly one apply ships, carrying all five users.
|
||||||
|
flushed, err := orch.FlushPendingRebuilds(markTime.Add(RebuildCoalesceWindow + time.Second))
|
||||||
|
if err != nil || flushed != 1 {
|
||||||
|
t.Fatalf("flush = %d, %v; want 1, nil", flushed, err)
|
||||||
|
}
|
||||||
|
snap := store.Snapshot()
|
||||||
|
jobs := applyJobsFor(snap, node.ID)
|
||||||
|
if len(jobs) != 1 {
|
||||||
|
t.Fatalf("apply jobs = %d, want exactly 1", len(jobs))
|
||||||
|
}
|
||||||
|
if users := resourceUsers(t, jobs[0]); len(users) != 5 {
|
||||||
|
t.Fatalf("flushed resource has %d users, want all 5", len(users))
|
||||||
|
}
|
||||||
|
if snap.Nodes[0].RebuildPending {
|
||||||
|
t.Fatal("pending mark must be consumed by the flush")
|
||||||
|
}
|
||||||
|
// A second flush ships nothing more.
|
||||||
|
if n, _ := orch.FlushPendingRebuilds(markTime.Add(time.Hour)); n != 0 {
|
||||||
|
t.Fatalf("second flush = %d, want 0", n)
|
||||||
|
}
|
||||||
|
if got := applyJobsFor(store.Snapshot(), node.ID); len(got) != 1 {
|
||||||
|
t.Fatalf("apply jobs after second flush = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFixedWindowDoesNotSlide(t *testing.T) {
|
||||||
|
store, orch, node := coalesceFixture(t)
|
||||||
|
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
firstMark := store.Snapshot().Nodes[0].RebuildPendingAt
|
||||||
|
markTime, _ := ParseISO(firstMark)
|
||||||
|
// A change late in the window must not push the deadline out: the
|
||||||
|
// flush still happens at first-mark + window.
|
||||||
|
if _, err := orch.AssignCustomer(node.ID, "cus_2", "admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
flushed, err := orch.FlushPendingRebuilds(markTime.Add(RebuildCoalesceWindow + 100*time.Millisecond))
|
||||||
|
if err != nil || flushed != 1 {
|
||||||
|
t.Fatalf("flush at first-mark+window = %d, %v; want 1 (fixed window)", flushed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingApplyDiscardedWhenNodeRemoved(t *testing.T) {
|
||||||
|
store, orch, node := coalesceFixture(t)
|
||||||
|
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
markTime, _ := ParseISO(store.Snapshot().Nodes[0].RebuildPendingAt)
|
||||||
|
if _, err := orch.RemoveNode(node.ID, "admin", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
flushed, err := orch.FlushPendingRebuilds(markTime.Add(time.Hour))
|
||||||
|
if err != nil || flushed != 0 {
|
||||||
|
t.Fatalf("flush after removal = %d, %v; want 0", flushed, err)
|
||||||
|
}
|
||||||
|
if got := applyJobsFor(store.Snapshot(), node.ID); len(got) != 0 {
|
||||||
|
t.Fatalf("removed node got %d apply jobs; a pending apply must never resurrect it", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualDeployImmediateAndConsumesPending(t *testing.T) {
|
||||||
|
store, orch, node := coalesceFixture(t)
|
||||||
|
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := orch.DeployNode(node.ID, "admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snap := store.Snapshot()
|
||||||
|
if got := applyJobsFor(snap, node.ID); len(got) != 1 {
|
||||||
|
t.Fatalf("manual deploy must apply immediately: %d jobs", len(got))
|
||||||
|
}
|
||||||
|
if snap.Nodes[0].RebuildPending {
|
||||||
|
t.Fatal("manual deploy must consume the pending mark")
|
||||||
|
}
|
||||||
|
if n, _ := orch.FlushPendingRebuilds(time.Now().Add(time.Hour)); n != 0 {
|
||||||
|
t.Fatalf("flush after manual deploy = %d, want 0", n)
|
||||||
|
}
|
||||||
|
if got := applyJobsFor(store.Snapshot(), node.ID); len(got) != 1 {
|
||||||
|
t.Fatalf("apply jobs = %d, want still 1", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingMarkSurvivesStoreReload(t *testing.T) {
|
||||||
|
store, orch, node := coalesceFixture(t)
|
||||||
|
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Simulate a panel restart inside the window: a fresh Store over the
|
||||||
|
// same file must still see the pending mark and flush it when due —
|
||||||
|
// the node can never be stranded un-deployed.
|
||||||
|
reloaded := NewStore(store.File)
|
||||||
|
if err := reloaded.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n := reloaded.Data.Nodes[0]
|
||||||
|
if !n.RebuildPending {
|
||||||
|
t.Fatal("pending mark must be persisted in the data file")
|
||||||
|
}
|
||||||
|
markTime, _ := ParseISO(n.RebuildPendingAt)
|
||||||
|
orch2 := NewOrchestrator(reloaded)
|
||||||
|
flushed, err := orch2.FlushPendingRebuilds(markTime.Add(RebuildCoalesceWindow + time.Second))
|
||||||
|
if err != nil || flushed != 1 {
|
||||||
|
t.Fatalf("post-restart flush = %d, %v; want 1", flushed, err)
|
||||||
|
}
|
||||||
|
if got := applyJobsFor(reloaded.Snapshot(), node.ID); len(got) != 1 {
|
||||||
|
t.Fatalf("apply jobs after restart flush = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -742,6 +742,14 @@ type Node struct {
|
|||||||
// (MigrateSocksNodeCredentials) mints a pair.
|
// (MigrateSocksNodeCredentials) mints a pair.
|
||||||
SocksUser string `json:"socksUser,omitempty"`
|
SocksUser string `json:"socksUser,omitempty"`
|
||||||
SocksPassword string `json:"socksPassword,omitempty"`
|
SocksPassword string `json:"socksPassword,omitempty"`
|
||||||
|
// RebuildPending marks a node whose binding-class changes are being
|
||||||
|
// coalesced (round-3 item 23): the flusher applies ONE rebuild with
|
||||||
|
// the final state once the fixed window (from RebuildPendingAt, the
|
||||||
|
// FIRST pending change) elapses. Persisted so a panel restart inside
|
||||||
|
// the window cannot strand the node un-deployed.
|
||||||
|
RebuildPending bool `json:"rebuildPending,omitempty"`
|
||||||
|
RebuildPendingAt string `json:"rebuildPendingAt,omitempty"`
|
||||||
|
RebuildPendingExit bool `json:"rebuildPendingExit,omitempty"`
|
||||||
Reality *RealityConfig `json:"reality,omitempty"`
|
Reality *RealityConfig `json:"reality,omitempty"`
|
||||||
ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"`
|
ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"`
|
||||||
UsageCounters *UsageCounters `json:"usageCounters"`
|
UsageCounters *UsageCounters `json:"usageCounters"`
|
||||||
|
|||||||
Reference in New Issue
Block a user