mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Bound audit overhead and preserve the published release source mapping (#2412)
## What changed and why Reduce configuration-fetch audit overhead without hiding access events (#2320). The suppression tracker now remembers at most 4,096 organisation/agent pairs and uses an expiry index instead of repeated full-map scans. Existing agents keep daily suppression; unremembered agents at capacity are conservatively audited on every fetch. Token/configuration changes and every failure remain audited. No audit history is deleted. The Apprise confidentiality test now observes the autonomous queue dispatcher instead of starting a competing manual batch. Its lifecycle, retry, configuration and confidentiality assertions remain intact. Preserve the exact 6.4.6 release-line mapping for the already-published packet. The fallback to main incorrectly rejected its immutable source during convergence. This maps that version only, not future patches, and changes no candidate product source. ## Included source and upstream reconciliation Complete new publication range: `e6540da45da4de4bcb954463373d8ea9f196eb56..35d8c7c13d3665b2ffe2fe4fd61e8952c46e21b1`. - Reviewed Delivery correction: `3578b0044740087e7589f19bca450245f610aced`. - Reviewed Core changes: `e225bf2abcb4189dcedd457292248e72a945dc74` and `6ab5383412f156f286c38623f9b86f2b0d3151f0`, integrated by `94ce8496b7d79e9b77d43493adc432c954d5e8b7`. - Additive upstream merge: `35d8c7c13d3665b2ffe2fe4fd61e8952c46e21b1`, incorporating published Proxmox table repairs at `e6540da45da4de4bcb954463373d8ea9f196eb56`. Every reviewed commit identity and ancestry is preserved. No upstream frontend source or dependency manifest changes in this proposal. ## Validation and limits Recorded independent-review evidence: Core returned four complete source-proof receipts covering connected configuration-fetch/authorization tests, five race repeats, notifications correctness/races and ten queue-fixture repeats, vet/builds, 135 governance tests, 23 docs-sync tests and frontend production build/budgets. Its fixed controls retained a maximum of 4,096 keys versus 12,288 in the predecessor; saturated in-memory lookup measurements are not installed write-rate or CPU acceptance. Delivery returned a complete offline source proof of 947 release-control tests (8 skipped), with guest shutdown. The exact-version tests reject unrelated versions. These are reviewed owner returns, not new qualification executions by this publication gate. On the combined committed source, provenance, whitespace, ancestry, canonical completion, browser-verification scope, control-plane audit, all 62 shipped-document mirrors and the 211-file public-document check pass. Core runtime and Go manifests are byte-identical to the reviewed Core candidate; release-control source/policy is byte-identical to the reviewed Delivery candidate; frontend runtime and graph are byte-identical to current upstream. Sufficient heavy/browser evidence was therefore not replayed. Core used Go 1.26.7 rather than declared 1.26.8, with an explicit backend embed fixture and a separately proved frontend build. Required exact-head CI remains necessary before landing. This proposal does not establish installed outcomes, release readiness, successful convergence or stable delivery, and does not replace or recut the published v6.4.6 candidate. [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
+10
-1
@@ -36,7 +36,16 @@ Pulse automatically captures the following events:
|
||||
| `agent_profile_assigned` | Agent profile assignments | Profile `production` assigned to agent |
|
||||
| `agent_profile_unassigned` | Agent profile removals | Profile removed from agent |
|
||||
| `user_roles_updated` | RBAC role assignments changed | Updated roles for user jane: [operator] |
|
||||
| `agent_config_fetch` | Every failed agent configuration fetch. A successful fetch is recorded on the agent's first delivery after Pulse starts, when its token or delivered configuration changes, and otherwise once a day; agents poll every minute, so repeat polls are not recorded | `agent_id=… token_id=… config=sha256:… reason=config_changed` |
|
||||
| `agent_config_fetch` | Every failed agent configuration fetch. A successful fetch is normally recorded on the agent's first delivery after Pulse starts, when its token or delivered configuration changes, and otherwise once a day; unchanged minute polls are suppressed while the agent is remembered (see below) | `agent_id=… token_id=… config=sha256:… reason=config_changed` |
|
||||
|
||||
Config-fetch suppression remembers up to 4,096 organisation/agent pairs per
|
||||
Pulse process. New agents can replace entries last recorded at least 24 hours
|
||||
ago; a forgotten agent's next successful fetch is recorded again. If all slots
|
||||
are recent, additional agents' successful fetches are recorded on every poll
|
||||
with `reason=capacity` until a slot expires. This keeps memory bounded without
|
||||
hiding access events or displacing recent agents' suppression. It does not
|
||||
limit enrolment or configuration delivery, and every failed fetch is still
|
||||
recorded. No existing audit rows are removed by this tracker.
|
||||
|
||||
Each event includes:
|
||||
- **Timestamp** (UTC)
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"prerelease_branch": "release/v6.4",
|
||||
"stable_branch": "release/v6.4"
|
||||
},
|
||||
{
|
||||
"version_prefix": "6.4.6",
|
||||
"prerelease_branch": "release/v6.4",
|
||||
"stable_branch": "release/v6.4"
|
||||
},
|
||||
{
|
||||
"version_prefix": "6.5.",
|
||||
"prerelease_branch": "release/v6.5",
|
||||
|
||||
@@ -36,6 +36,9 @@ model that active and future release profiles reuse.
|
||||
support after the active profile has moved on, that branch mapping must stay
|
||||
explicit in `control_plane.json` as a legacy release-line override rather
|
||||
than being inferred from the active profile or smuggled into one workflow.
|
||||
The selected and published `6.4.6` packet remains bound to `release/v6.4`
|
||||
for convergence and exact-RC promotion. Its complete-version override does
|
||||
not map future `6.4.7` or similarly prefixed `6.4.60` releases to that line.
|
||||
4. Profile changes must reuse the same control-plane machinery.
|
||||
Future releases should switch or add profiles rather than fork the guardrail
|
||||
system.
|
||||
|
||||
@@ -5336,8 +5336,11 @@ pending or unknown rather than in-sync.
|
||||
|
||||
Routine config polling is not a per-poll audit event:
|
||||
`/api/agents/agent/{id}/config` audits every failed fetch, and a successful one
|
||||
only on the agent's first delivery after start, a token or desired-config
|
||||
change, or once a day (see the API contract).
|
||||
normally on the agent's first delivery after start, a token or desired-config
|
||||
change, or once a day (see the API contract). Suppression is bounded to 4,096
|
||||
organisation/agent pairs. New keys replace only expired suppression entries;
|
||||
overflow deliveries are audited with `reason=capacity`, without evicting
|
||||
recent agents or restricting enrolment, config access, or command authority.
|
||||
That same canonical /api/auto-register path must also complete the live
|
||||
post-registration contract after persistence: it must trigger discovery refresh
|
||||
and emit the canonical `node_auto_registered` WebSocket payload instead of
|
||||
|
||||
@@ -8776,8 +8776,20 @@ unchanged delivery last recorded 24 hours earlier. Agents poll every minute,
|
||||
and auditing each poll made this event over 99.9% of audit rows. Recorded
|
||||
successes carry `config=<desiredConfig hash>` and
|
||||
`reason=first_since_start|token_changed|config_changed|daily` in their details.
|
||||
The suppression tracker remembers at most 4,096 organisation/agent pairs.
|
||||
An expiry index replaces only entries last audited at least 24 hours ago,
|
||||
with no full-map scan on new-key admission. Recent entries are never evicted
|
||||
by key churn, so their unchanged minute polls stay suppressed. If every slot
|
||||
is recent, unremembered agents' successful fetches are recorded on every poll
|
||||
with `reason=capacity` until an expired slot is available. This conservative
|
||||
overflow can produce extra audit writes but must never suppress access events,
|
||||
change config authorisation/delivery, or grow either map or expiry index beyond
|
||||
the limit. Token, config and daily changes update the entry's expiry position;
|
||||
concurrent and out-of-order observations preserve that index.
|
||||
`TestAgentConfigFetchAuditsNewDeliveriesAndEveryFailure` and
|
||||
`TestAgentConfigFetchAuditTrackerRecordsOnlyNewDeliveries` pin the rule.
|
||||
`TestAgentConfigFetchAuditTrackerRecordsOnlyNewDeliveries` pin the rule;
|
||||
`TestAgentConfigFetchAuditCapacityContract` and the tracker capacity, expiry,
|
||||
churn and concurrency controls pin bounded state and conservative overflow.
|
||||
Agent profile delete and unassign clients must now also route canonical `204`
|
||||
success handling through shared allowed-status helpers in
|
||||
`frontend-modern/src/api/responseUtils.ts` instead of open-coding local
|
||||
|
||||
@@ -2687,6 +2687,13 @@ vdev layout is reported` in
|
||||
|
||||
## Current State
|
||||
|
||||
The agent config-fetch audit suppression cache is bounded to 4,096
|
||||
organisation/agent pairs, independently of persisted security audit history.
|
||||
Expiry-index pruning forgets only suppression state, never audit rows or
|
||||
recovery evidence. Unremembered overflow deliveries remain auditable with
|
||||
`reason=capacity`; cache expiry, overflow, or restart is not a successful
|
||||
storage operation or a reason to delete historical events.
|
||||
|
||||
### Container diagnostics shares private bootstrap transport (1 October 2026)
|
||||
|
||||
Recovery-adjacent diagnostics now reuse the canonical complete installer
|
||||
|
||||
@@ -36,7 +36,16 @@ Pulse automatically captures the following events:
|
||||
| `agent_profile_assigned` | Agent profile assignments | Profile `production` assigned to agent |
|
||||
| `agent_profile_unassigned` | Agent profile removals | Profile removed from agent |
|
||||
| `user_roles_updated` | RBAC role assignments changed | Updated roles for user jane: [operator] |
|
||||
| `agent_config_fetch` | Every failed agent configuration fetch. A successful fetch is recorded on the agent's first delivery after Pulse starts, when its token or delivered configuration changes, and otherwise once a day; agents poll every minute, so repeat polls are not recorded | `agent_id=… token_id=… config=sha256:… reason=config_changed` |
|
||||
| `agent_config_fetch` | Every failed agent configuration fetch. A successful fetch is normally recorded on the agent's first delivery after Pulse starts, when its token or delivered configuration changes, and otherwise once a day; unchanged minute polls are suppressed while the agent is remembered (see below) | `agent_id=… token_id=… config=sha256:… reason=config_changed` |
|
||||
|
||||
Config-fetch suppression remembers up to 4,096 organisation/agent pairs per
|
||||
Pulse process. New agents can replace entries last recorded at least 24 hours
|
||||
ago; a forgotten agent's next successful fetch is recorded again. If all slots
|
||||
are recent, additional agents' successful fetches are recorded on every poll
|
||||
with `reason=capacity` until a slot expires. This keeps memory bounded without
|
||||
hiding access events or displacing recent agents' suppression. It does not
|
||||
limit enrolment or configuration delivery, and every failed fetch is still
|
||||
recorded. No existing audit rows are removed by this tracker.
|
||||
|
||||
Each event includes:
|
||||
- **Timestamp** (UTC)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -10,9 +11,9 @@ import (
|
||||
// continued access.
|
||||
const agentConfigFetchAuditInterval = 24 * time.Hour
|
||||
|
||||
// maxAgentConfigFetchAudits bounds remembered deliveries. Beyond it, entries
|
||||
// older than agentConfigFetchAuditInterval are dropped; they would be
|
||||
// re-recorded on their next fetch anyway.
|
||||
// maxAgentConfigFetchAudits bounds remembered deliveries. At capacity, only an
|
||||
// expired entry can be replaced. An unremembered delivery is always audited;
|
||||
// churn must not evict recent agents and turn their minute polls into writes.
|
||||
const maxAgentConfigFetchAudits = 4096
|
||||
|
||||
// agentConfigFetchAuditTracker decides which successful agent config fetches
|
||||
@@ -21,8 +22,9 @@ const maxAgentConfigFetchAudits = 4096
|
||||
// agent), burying the logins and failures the log exists to show. Failed
|
||||
// fetches are always audited and never pass through here.
|
||||
type agentConfigFetchAuditTracker struct {
|
||||
mu sync.Mutex
|
||||
last map[agentConfigFetchAuditKey]agentConfigFetchAuditEntry
|
||||
mu sync.Mutex
|
||||
last map[agentConfigFetchAuditKey]*agentConfigFetchAuditEntry
|
||||
oldest agentConfigFetchAuditHeap
|
||||
}
|
||||
|
||||
type agentConfigFetchAuditKey struct {
|
||||
@@ -31,26 +33,55 @@ type agentConfigFetchAuditKey struct {
|
||||
}
|
||||
|
||||
type agentConfigFetchAuditEntry struct {
|
||||
key agentConfigFetchAuditKey
|
||||
tokenID string
|
||||
configHash string
|
||||
auditedAt time.Time
|
||||
index int
|
||||
}
|
||||
|
||||
// The expiry index avoids a full-map scan for every new key at capacity. It
|
||||
// contains exactly the remembered entries, including after token/config/daily
|
||||
// updates. Ordering by audit time also handles out-of-order observations.
|
||||
type agentConfigFetchAuditHeap []*agentConfigFetchAuditEntry
|
||||
|
||||
func (h agentConfigFetchAuditHeap) Len() int { return len(h) }
|
||||
func (h agentConfigFetchAuditHeap) Less(i, j int) bool {
|
||||
return h[i].auditedAt.Before(h[j].auditedAt)
|
||||
}
|
||||
func (h agentConfigFetchAuditHeap) Swap(i, j int) {
|
||||
h[i], h[j] = h[j], h[i]
|
||||
h[i].index, h[j].index = i, j
|
||||
}
|
||||
func (h *agentConfigFetchAuditHeap) Push(value any) {
|
||||
entry := value.(*agentConfigFetchAuditEntry)
|
||||
entry.index = len(*h)
|
||||
*h = append(*h, entry)
|
||||
}
|
||||
func (h *agentConfigFetchAuditHeap) Pop() any {
|
||||
last := len(*h) - 1
|
||||
entry := (*h)[last]
|
||||
(*h)[last] = nil
|
||||
*h = (*h)[:last]
|
||||
entry.index = -1
|
||||
return entry
|
||||
}
|
||||
|
||||
func newAgentConfigFetchAuditTracker() *agentConfigFetchAuditTracker {
|
||||
return &agentConfigFetchAuditTracker{last: make(map[agentConfigFetchAuditKey]agentConfigFetchAuditEntry)}
|
||||
return &agentConfigFetchAuditTracker{last: make(map[agentConfigFetchAuditKey]*agentConfigFetchAuditEntry)}
|
||||
}
|
||||
|
||||
// observe records a successful delivery and reports whether it is new audit
|
||||
// information, with the reason: the agent's first delivery since startup, a
|
||||
// different token or delivered config, or an unchanged delivery last audited
|
||||
// at least agentConfigFetchAuditInterval ago. A nil tracker audits every
|
||||
// delivery.
|
||||
// at least agentConfigFetchAuditInterval ago. At capacity, unremembered agents
|
||||
// are audited with reason "capacity" on every delivery until a slot expires.
|
||||
// A nil tracker audits every delivery.
|
||||
func (t *agentConfigFetchAuditTracker) observe(orgID, agentID, tokenID, configHash string, now time.Time) (string, bool) {
|
||||
if t == nil {
|
||||
return "", true
|
||||
}
|
||||
key := agentConfigFetchAuditKey{orgID: orgID, agentID: agentID}
|
||||
entry := agentConfigFetchAuditEntry{tokenID: tokenID, configHash: configHash, auditedAt: now}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
@@ -68,13 +99,24 @@ func (t *agentConfigFetchAuditTracker) observe(orgID, agentID, tokenID, configHa
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
if !seen && len(t.last) >= maxAgentConfigFetchAudits {
|
||||
for staleKey, stale := range t.last {
|
||||
if now.Sub(stale.auditedAt) >= agentConfigFetchAuditInterval {
|
||||
delete(t.last, staleKey)
|
||||
}
|
||||
}
|
||||
if seen {
|
||||
previous.tokenID, previous.configHash, previous.auditedAt = tokenID, configHash, now
|
||||
heap.Fix(&t.oldest, previous.index)
|
||||
return reason, true
|
||||
}
|
||||
if len(t.last) >= maxAgentConfigFetchAudits {
|
||||
stale := t.oldest[0]
|
||||
if now.Sub(stale.auditedAt) < agentConfigFetchAuditInterval {
|
||||
return "capacity", true
|
||||
}
|
||||
heap.Pop(&t.oldest)
|
||||
delete(t.last, stale.key)
|
||||
}
|
||||
if t.last == nil {
|
||||
t.last = make(map[agentConfigFetchAuditKey]*agentConfigFetchAuditEntry)
|
||||
}
|
||||
entry := &agentConfigFetchAuditEntry{key: key, tokenID: tokenID, configHash: configHash, auditedAt: now}
|
||||
heap.Push(&t.oldest, entry)
|
||||
t.last[key] = entry
|
||||
return reason, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func assertAgentConfigFetchAuditIndex(t *testing.T, tracker *agentConfigFetchAuditTracker) {
|
||||
t.Helper()
|
||||
if len(tracker.last) > maxAgentConfigFetchAudits || len(tracker.oldest) != len(tracker.last) {
|
||||
t.Fatalf("audit state: map=%d index=%d limit=%d", len(tracker.last), len(tracker.oldest), maxAgentConfigFetchAudits)
|
||||
}
|
||||
for i, entry := range tracker.oldest {
|
||||
if entry.index != i || tracker.last[entry.key] != entry {
|
||||
t.Fatalf("expiry index lost entry %d", i)
|
||||
}
|
||||
if i > 0 && entry.auditedAt.Before(tracker.oldest[(i-1)/2].auditedAt) {
|
||||
t.Fatalf("expiry index out of order at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fillAgentConfigFetchAuditTracker(t *testing.T, tracker *agentConfigFetchAuditTracker, start time.Time) {
|
||||
t.Helper()
|
||||
for i := 0; i < maxAgentConfigFetchAudits; i++ {
|
||||
if reason, audit := tracker.observe("org", fmt.Sprint(i), "token", "hash", start.Add(time.Duration(i)*time.Second)); !audit || reason != "first_since_start" {
|
||||
t.Fatalf("first delivery %d: audit=%v reason=%q", i, audit, reason)
|
||||
}
|
||||
}
|
||||
assertAgentConfigFetchAuditIndex(t, tracker)
|
||||
}
|
||||
|
||||
func TestAgentConfigFetchAuditTrackerBoundsRecentKeys(t *testing.T) {
|
||||
tracker := newAgentConfigFetchAuditTracker()
|
||||
start := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
fillAgentConfigFetchAuditTracker(t, tracker, start)
|
||||
now := start.Add(2 * time.Hour)
|
||||
for i := 0; i < 2*maxAgentConfigFetchAudits; i++ {
|
||||
if reason, audit := tracker.observe("overflow-org", fmt.Sprint(i), "token", "hash", now); !audit || reason != "capacity" {
|
||||
t.Fatalf("overflow delivery %d: audit=%v reason=%q, want capacity audit", i, audit, reason)
|
||||
}
|
||||
}
|
||||
// Overflow is fail-open for audit, not for state growth or cache churn.
|
||||
// Repeat, token and config changes for an unremembered agent all remain
|
||||
// visible; none displaces a remembered agent's useful daily suppression.
|
||||
for _, step := range []struct{ token, hash string }{{"token", "hash"}, {"rotated", "hash"}, {"rotated", "changed"}} {
|
||||
if reason, audit := tracker.observe("overflow-org", "0", step.token, step.hash, now); !audit || reason != "capacity" {
|
||||
t.Fatalf("unremembered delivery: audit=%v reason=%q", audit, reason)
|
||||
}
|
||||
}
|
||||
for minute := 0; minute < 1440; minute++ {
|
||||
if reason, audit := tracker.observe("org", "0", "token", "hash", start.Add(time.Duration(minute)*time.Minute)); audit || reason != "" {
|
||||
t.Fatalf("remembered minute poll %d was re-audited: %q", minute, reason)
|
||||
}
|
||||
}
|
||||
assertAgentConfigFetchAuditIndex(t, tracker)
|
||||
if len(tracker.last) != maxAgentConfigFetchAudits {
|
||||
t.Fatal("recent agents were evicted by new-key churn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfigFetchAuditTrackerReusesOnlyExpiredSlots(t *testing.T) {
|
||||
tracker := newAgentConfigFetchAuditTracker()
|
||||
start := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
fillAgentConfigFetchAuditTracker(t, tracker, start)
|
||||
before := start.Add(agentConfigFetchAuditInterval - time.Nanosecond)
|
||||
if reason, audit := tracker.observe("new-org", "0", "token", "hash", before); !audit || reason != "capacity" {
|
||||
t.Fatal("a recent entry was replaced before its daily interval")
|
||||
}
|
||||
atExpiry := start.Add(agentConfigFetchAuditInterval)
|
||||
if reason, audit := tracker.observe("new-org", "0", "token", "hash", atExpiry); !audit || reason != "first_since_start" {
|
||||
t.Fatalf("expired slot was not reused: audit=%v reason=%q", audit, reason)
|
||||
}
|
||||
if tracker.last[agentConfigFetchAuditKey{"org", "0"}] != nil || tracker.last[agentConfigFetchAuditKey{"org", "1"}] == nil {
|
||||
t.Fatal("replacement removed a recent entry instead of the oldest expired entry")
|
||||
}
|
||||
if _, audit := tracker.observe("new-org", "0", "token", "hash", atExpiry.Add(time.Minute)); audit {
|
||||
t.Fatal("admitted overflow agent did not gain repeat-poll suppression")
|
||||
}
|
||||
// Forgetting a stale slot never suppresses that agent's next fetch.
|
||||
if reason, audit := tracker.observe("org", "0", "token", "hash", atExpiry); !audit || reason != "capacity" {
|
||||
t.Fatal("an expired, displaced agent lost its next audit")
|
||||
}
|
||||
assertAgentConfigFetchAuditIndex(t, tracker)
|
||||
}
|
||||
|
||||
func TestAgentConfigFetchAuditTrackerReordersChangedEntries(t *testing.T) {
|
||||
for _, change := range []string{"token", "config", "daily", "backward-clock"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
tracker := newAgentConfigFetchAuditTracker()
|
||||
start := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
fillAgentConfigFetchAuditTracker(t, tracker, start)
|
||||
agent, token, hash, reason := "0", "token", "hash", "token_changed"
|
||||
changedAt := start.Add(2 * time.Hour)
|
||||
oldest := "1"
|
||||
switch change {
|
||||
case "token":
|
||||
token = "rotated"
|
||||
case "config":
|
||||
hash, reason = "changed", "config_changed"
|
||||
case "daily":
|
||||
changedAt, reason = start.Add(agentConfigFetchAuditInterval), "daily"
|
||||
case "backward-clock":
|
||||
agent, token, changedAt, oldest = "10", "rotated", start.Add(-time.Hour), "10"
|
||||
}
|
||||
if got, audit := tracker.observe("org", agent, token, hash, changedAt); !audit || got != reason {
|
||||
t.Fatalf("changed delivery: audit=%v reason=%q, want %q", audit, got, reason)
|
||||
}
|
||||
assertAgentConfigFetchAuditIndex(t, tracker)
|
||||
if tracker.oldest[0].key.agentID != oldest {
|
||||
t.Fatalf("oldest agent=%s, want %s", tracker.oldest[0].key.agentID, oldest)
|
||||
}
|
||||
if _, audit := tracker.observe("other", "new", "token", "hash", start.Add(agentConfigFetchAuditInterval+time.Second)); !audit {
|
||||
t.Fatal("replacement lost a delivery")
|
||||
}
|
||||
if tracker.last[agentConfigFetchAuditKey{"org", oldest}] != nil {
|
||||
t.Fatal("replacement ignored reordered expiry")
|
||||
}
|
||||
assertAgentConfigFetchAuditIndex(t, tracker)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfigFetchAuditTrackerConcurrentCapacityAndRestart(t *testing.T) {
|
||||
tracker := newAgentConfigFetchAuditTracker()
|
||||
start := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
var audits atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for worker := 0; worker < 16; worker++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < maxAgentConfigFetchAudits/8; i++ {
|
||||
if _, audit := tracker.observe(fmt.Sprint(worker), fmt.Sprint(i), "token", "hash", start); audit {
|
||||
audits.Add(1)
|
||||
}
|
||||
}
|
||||
}(worker)
|
||||
}
|
||||
wg.Wait()
|
||||
if got := audits.Load(); got != 2*maxAgentConfigFetchAudits {
|
||||
t.Fatalf("concurrent first deliveries audited=%d, want %d", got, 2*maxAgentConfigFetchAudits)
|
||||
}
|
||||
assertAgentConfigFetchAuditIndex(t, tracker)
|
||||
if len(tracker.last) != maxAgentConfigFetchAudits {
|
||||
t.Fatal("concurrent delivery did not fill the bounded state")
|
||||
}
|
||||
// A restart forgets suppression, never the persisted audit trail.
|
||||
restarted := newAgentConfigFetchAuditTracker()
|
||||
for _, org := range []string{"org", "other-org"} {
|
||||
if reason, audit := restarted.observe(org, "0", "token", "hash", start); !audit || reason != "first_since_start" {
|
||||
t.Fatal("restart or organisation isolation suppressed a first delivery")
|
||||
}
|
||||
}
|
||||
var zero agentConfigFetchAuditTracker
|
||||
if _, audit := zero.observe("org", "0", "token", "hash", start); !audit {
|
||||
t.Fatal("zero-value tracker suppressed a first delivery")
|
||||
}
|
||||
}
|
||||
|
||||
// Run with -benchtime=8192x for a fixed recent-key-churn comparison. This is
|
||||
// an in-memory tracker measurement, not an installed write/CPU claim.
|
||||
func BenchmarkAgentConfigFetchAuditTrackerRecentKeyChurn(b *testing.B) {
|
||||
tracker := newAgentConfigFetchAuditTracker()
|
||||
start := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
for i := 0; i < maxAgentConfigFetchAudits; i++ {
|
||||
tracker.observe("org", fmt.Sprint(i), "token", "hash", start)
|
||||
}
|
||||
keys := make([]string, b.N)
|
||||
for i := range keys {
|
||||
keys[i] = fmt.Sprint(i)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for _, key := range keys {
|
||||
tracker.observe("overflow-org", key, "token", "hash", start.Add(time.Hour))
|
||||
}
|
||||
b.StopTimer()
|
||||
b.ReportMetric(float64(len(tracker.last)), "remembered-keys")
|
||||
}
|
||||
@@ -72,6 +72,55 @@ import (
|
||||
tmock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestAgentConfigFetchAuditCapacityContract(t *testing.T) {
|
||||
capture := &auditCaptureLogger{}
|
||||
previousLogger, previousManager := audit.GetLogger(), GetTenantAuditManager()
|
||||
audit.SetLogger(capture)
|
||||
SetTenantAuditManager(nil)
|
||||
t.Cleanup(func() {
|
||||
audit.SetLogger(previousLogger)
|
||||
SetTenantAuditManager(previousManager)
|
||||
})
|
||||
handler, monitor := newUnifiedAgentHandlers(t, nil)
|
||||
hostID := seedUnifiedAgentHost(t, monitor)
|
||||
monitorState(t, monitor).UpsertHost(models.Host{ID: hostID, Hostname: "node-1", TokenID: "runtime-token"})
|
||||
for i := 0; i < maxAgentConfigFetchAudits; i++ {
|
||||
handler.configFetchAudits.observe("other-org", fmt.Sprint(i), "token", "hash", time.Now())
|
||||
}
|
||||
for _, scopes := range [][]string{
|
||||
{config.ScopeAgentConfigRead, config.ScopeAgentReport},
|
||||
{config.ScopeAgentConfigRead, config.ScopeAgentReport},
|
||||
{config.ScopeMonitoringRead},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agents/agent/"+hostID+"/config", nil)
|
||||
attachAPITokenRecord(req, &config.APITokenRecord{ID: "runtime-token", Scopes: scopes})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleConfig(rec, req)
|
||||
if (rec.Code == http.StatusOK) != (scopes[0] == config.ScopeAgentConfigRead) {
|
||||
t.Fatalf("capacity changed config authorisation: status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
capture.mu.Lock()
|
||||
defer capture.mu.Unlock()
|
||||
successes, failures := 0, 0
|
||||
for _, event := range capture.events {
|
||||
if event.EventType != "agent_config_fetch" {
|
||||
continue
|
||||
}
|
||||
if event.Success {
|
||||
successes++
|
||||
if !strings.Contains(event.Details, "reason=capacity") || !strings.Contains(event.Details, "config=sha256:") {
|
||||
t.Fatal("unremembered config delivery lost its capacity reason or config hash")
|
||||
}
|
||||
} else {
|
||||
failures++
|
||||
}
|
||||
}
|
||||
if successes != 2 || failures != 1 || len(handler.configFetchAudits.last) != maxAgentConfigFetchAudits {
|
||||
t.Fatalf("capacity audit contract: successes=%d failures=%d remembered=%d", successes, failures, len(handler.configFetchAudits.last))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVersionBuildIdentityContract(t *testing.T) {
|
||||
oldBuildVersion := updates.BuildVersion
|
||||
t.Cleanup(func() { updates.BuildVersion = oldBuildVersion })
|
||||
|
||||
@@ -305,10 +305,10 @@ func TestAppriseQueueConfidentiality(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q.SetProcessor(n.ProcessQueuedNotification)
|
||||
q.processBatch()
|
||||
// The autonomous worker can win the atomic claim. Its committed
|
||||
// attempt audit, not the return from our stale snapshot, is proof
|
||||
// the send completed. Stop processing before the retry wake.
|
||||
// SetProcessor wakes the autonomous dispatcher. Do not also run
|
||||
// a manual batch against the same pending snapshot: that can
|
||||
// claim a retry before its scheduled wake. Observe the worker's
|
||||
// committed audit, then pause retries before checking the row.
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
var count int
|
||||
@@ -337,7 +337,7 @@ func TestAppriseQueueConfidentiality(t *testing.T) {
|
||||
wantStatus = QueueStatusDLQ
|
||||
}
|
||||
if gotStatus != string(wantStatus) || attempts != 1 || rawConfig != string(config) {
|
||||
t.Error("queue lifecycle, attempt budget or admitted credentials changed")
|
||||
t.Errorf("queue lifecycle: status=%s want=%s attempts=%d want=1 configUnchanged=%t", gotStatus, wantStatus, attempts, rawConfig == string(config))
|
||||
}
|
||||
if status == http.StatusUnauthorized && (!lastError.Valid || !strings.Contains(lastError.String, "HTTP 401")) {
|
||||
t.Error("DLQ error observation was lost")
|
||||
|
||||
@@ -248,10 +248,11 @@ class ControlPlaneAuditTest(unittest.TestCase):
|
||||
for version in ("6.4.4-beta.1", "v6.4.4-beta.2", "6.4.4-rc.1",
|
||||
"6.4.4", "v6.4.4+build.1", "6.4.3-rc.1", "6.4.3",
|
||||
"6.4.5-beta.1", "v6.4.5-beta.2", "6.4.5-rc.1",
|
||||
"6.4.5", "v6.4.5+build.1"):
|
||||
"6.4.5", "v6.4.5+build.1", "6.4.6-beta.1",
|
||||
"v6.4.6-rc.1", "6.4.6", "v6.4.6+build.1"):
|
||||
with self.subTest(version=version):
|
||||
self.assertEqual(release_branch_for_version(version), "release/v6.4")
|
||||
for version in ("6.4.1", "6.4.2", "6.4.6-beta.1", "6.4.50-beta.1", "6.4.40-beta.1",
|
||||
for version in ("6.4.1", "6.4.2", "6.4.7-rc.1", "6.4.60-rc.1", "6.4.50-beta.1", "6.4.40-beta.1",
|
||||
"6.4.30", "6.3.20", "6.6.0-beta.1"):
|
||||
with self.subTest(version=version):
|
||||
self.assertEqual(release_branch_for_version(version), "main")
|
||||
@@ -267,7 +268,8 @@ class ControlPlaneAuditTest(unittest.TestCase):
|
||||
)
|
||||
self.assertIsNotNone(match)
|
||||
script = textwrap.dedent(match.group(1))
|
||||
for version in ("6.4.4-beta.1", "6.4.5-beta.1", "6.4.5-rc.1", "6.4.5"):
|
||||
for version in ("6.4.4-beta.1", "6.4.5-beta.1", "6.4.5-rc.1", "6.4.5",
|
||||
"6.4.6-rc.1", "6.4.6"):
|
||||
for branch in ("main", "release/v6.4"):
|
||||
with self.subTest(workflow=workflow, branch=branch, version=version), tempfile.TemporaryDirectory() as tmp:
|
||||
output = os.path.join(tmp, "output")
|
||||
|
||||
@@ -9,6 +9,32 @@ import validate_artifact_release_line as validator
|
||||
|
||||
|
||||
class ValidateArtifactReleaseLineTest(unittest.TestCase):
|
||||
def test_published_646_packet_uses_its_selected_line_not_main(self) -> None:
|
||||
# This is the real control-plane lookup used by convergence, not an
|
||||
# injected branch answer. The frozen tag need not be an ancestor of main.
|
||||
source = "1abc97cbc1fd7aefa052b4607a531af3e3b97eb6"
|
||||
fetched = []
|
||||
refs = {"origin/release/v6.4": "line", "origin/main": "main"}
|
||||
result = validator.validate_artifact_release_line(
|
||||
tag="v6.4.6-rc.1", purpose="floating-tag promotion",
|
||||
fetch_refs_fn=fetched.append,
|
||||
tag_exists_fn=lambda tag: tag == "v6.4.6-rc.1",
|
||||
tag_commit_fn=lambda tag: source,
|
||||
ref_commit_fn=refs.__getitem__,
|
||||
ref_is_ancestor_fn=lambda a, b: (a, b) == (source, "line"),
|
||||
)
|
||||
self.assertEqual(fetched, ["release/v6.4"])
|
||||
self.assertEqual(result["required_branch"], "release/v6.4")
|
||||
with self.assertRaisesRegex(ValueError, "not reachable from origin/release/v6.4"):
|
||||
validator.validate_artifact_release_line(
|
||||
tag="v6.4.6-rc.1", purpose="floating-tag promotion",
|
||||
fetch_refs_fn=lambda branch: None,
|
||||
tag_exists_fn=lambda tag: True,
|
||||
tag_commit_fn=lambda tag: "unrelated",
|
||||
ref_commit_fn=refs.__getitem__,
|
||||
ref_is_ancestor_fn=lambda a, b: False,
|
||||
)
|
||||
|
||||
def validate(
|
||||
self,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user