Skip to content

Keep * when a ⋯ sits at the same trie node - #48

Open
entlein wants to merge 23 commits into
mainfrom
fix/wildcard-subsumes-dynamic
Open

entlein wants to merge 23 commits into
mainfrom
fix/wildcard-subsumes-dynamic

Conversation

@entlein

@entlein entlein commented Sep 12, 2026

Copy link
Copy Markdown

The bug

matches exactly one segment and * matches zero-or-more, so * is the broader of the two. processSegment routed an explicitly supplied * through an existing child, rewriting it and narrowing the stored profile below what the author wrote.

Minimal reproduction against a live cluster — apply a ContainerProfile declaring four paths, read it back:

applied: /a/b/⋯  /a/b/*  /c/d/*  /e/f/⋯
stored:  /a/b/⋯          /c/d/*  /e/f/⋯     ← /a/b/* silently gone

Order-independent, and it reaches a * in a middle segment too (/a/b/*/c/a/b/⋯/c).

What it cost

A ZITADEL postgres SBoB declared /bitnami/postgresql/data/base/*. Storage kept only the form, so every relation file two levels down — /bitnami/postgresql/data/base/16384/2650 — fell outside the profile. 25 R0002 alerts on a profile that had declared exactly that subtree, with nothing in the applied object to explain why: the file and the stored object differ by one line out of 358.

The fix

An explicit * now promotes the node through createWildcardNode, which already absorbs the accumulated children. consolidateOpens was not involved — its "patterns always survive" invariant holds; the loss happened earlier, during trie insertion.

Tests

Both insertion orders, the middle-segment case, and the single-form cases. The middle-segment assertion is on coverage rather than on the literal string — a trailing * already spans the segments below it, so a broader stored form is fine and a narrower one is not. TestStoredPatternStillMatchesWhatTheAuthorDeclared pins the invariant that actually matters: whatever is stored must still match everything the authored pattern did.

go test ./pkg/registry/... passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MfoMnkruL8WV7Njfk5Mmkj

ConstanzeTU and others added 23 commits August 28, 2026 09:29
…ention

SQLITE_BUSY / SQLITE_LOCKED from the metadata write surfaced to API
clients as a 500 (issue kubescape#365). Since the commit step became atomic —
metadata and payload rename under one savepoint, rolled back together —
a contention failure leaves the staged payload untouched and the step
is safe to retry in place. Retry up to three times (50/100/200ms
backoff, bounded far inside any request budget); every other error
class, including an interrupt from a canceled request context, still
fails fast and cleans up the staged file.

Unit: TestSaveObject_RetriesBusyThenSucceeds pins both directions —
contention retried to success, non-contention surfaced on the first
attempt.
…t sections atomic

- Bind the connection interrupt to the request context instead of the pool
  acquisition context. The acquisition context carried a 5s timeout, so any
  operation still running 5s after taking its connection was killed by its
  own storage layer (the 'sqlite: step: interrupted' / 'clear bindings:
  interrupted' class). Acquisition deadline and execution lifetime are now
  separate.
- Serialize all SQLite write sections on a per-pool in-process write gate.
  This process is the only writer of the database file, so write-lock
  contention between its own writers becomes structurally impossible instead
  of retried. The busy timeout drops from 60s to a 5s backstop (the 60s value
  parked writers on the SQLite lock while holding pool connections, starving
  unrelated reads on pool acquisition) and the bounded busy retry is retained
  as a backstop for extra-process writers only, reacquiring the gate per
  attempt and never sleeping while holding it. Lock ordering is fixed as
  per-key lock -> gate; gate holders acquire no per-key locks.
- Mask the interrupt across the gated commit section: the savepoint spans an
  irreversible filesystem rename (or removal, for delete), and an interrupt
  landing on the savepoint RELEASE rolled the SQL back while the file
  operation persisted, tearing payload against metadata. A context already
  dead on entry fails fast before the savepoint opens; errors surfacing
  after the request context died map to the context error.
- Commit a part profile's time-series row inside the same savepoint as its
  metadata insert and payload rename (PreCommitProcessor). Previously the row
  was written after the object's commit; on failure the part existed without
  its time-series row, invisible to consolidation and expiry forever, while
  client retries failed on KeyExists.
- Make delete atomic: metadata delete, time-series delete and payload removal
  run in one gated savepoint. Metadata-delete errors were previously logged
  and ignored while the payload was removed regardless, leaving list results
  that disagreed with reads.
- Consolidation holds the consolidated key's write lock and the gate around
  its per-key transaction; part payload reads inside the gated transaction
  are lock-free, which keeps the lock ordering acyclic.
- Sweep orphan staged payload files at storage construction.
- Fix a data race in TestFileSystemStorageWatchReturnsDistinctWatchers:
  compare watcher identity instead of deep-walking live watcher internals.
DURESS.md is the systematic failure-mode x operation truth table for the
file/SQLite backend: every cell states its contract (impossible /
fail-fast-clean / retry-in-place / converge / never-starve) and names the
test or fix backing it, plus the architecture decision record for the
in-process write gate.

duress_test.go executes the table against a real file-backed database:
interrupt lifetime across slow operations, part/time-series row atomicity,
atomic delete, orphan staged-file sweep, staging fault injection,
client-cancellation storms, same-key write hammering, a sustained mixed
workload (concurrent creates, updates, deletes, hot-key contention,
continuous readers, part ingestion and consolidation) and consolidation
racing API writers on the same key. After every scenario the suite asserts
the invariants: payload and metadata agree for every key, no key is wedged,
only contracted error classes surface, reads never starve, and no staged
files or orphaned rows remain.
…s ids ignored, goroutine-safe test helper

- writeGate.acquire checks the context before re-entry and acquisition:
  with a free slot both select cases are ready and the runtime may pick
  acquisition for an already-canceled caller, letting it enter a
  savepoint it can no longer complete cleanly.
- PreCommitSQL treats a BLANK report-series-id like an absent one — a
  blank seriesID row would make a non-part profile visible to
  consolidation.
- duressParts no longer fails the test from worker goroutines
  ((*testing.T).FailNow must run on the test goroutine); it returns
  errors, and the storm loop records them through its error channel.
- The 8s mixed-workload storm honors -short.
- DURESS.md: the Create sequence names the time-series row inside the
  single commit savepoint, matching row 8.
Internal + v1beta1 RogueArtifact/RogueArtifactList registered in both schemes.
Generated deepcopy/conversion/openapi/protobuf follow from update-codegen.
…f/client for RogueArtifact

Ran hack/update-codegen.sh + go-to-protobuf on a linux rig per the storage
README (deepcopy interface markers + ListMeta on the List type). Full go build
./... green on the rig.
REST store + strategy + kubectl-get table converter (Name/State/Learning/
Workload/Kind/Phase/Since), registered as rogueartifacts in the endpoint map.
go build ./... + vet green on the rig.
…ion test

kubectl get rogueartifacts reads State/Learning/Workload from labels (List is
metadata-only). TestRogueArtifact_RoundTrip pins Create + label-survives-List.
…art data

The consolidated profile was saved only when a maintenance pass merged
new part data. A lifecycle transition can arrive in a pass of its own:
the completed/full finalization computes after the final part was
already merged in an earlier pass, and the expired finalization by
definition runs with nothing left to merge. In both shapes the pass
mutated the in-memory annotations, deleted or cleared the time-series
rows, and then skipped the save — leaving the served profile stuck in
'ready' forever with nothing left to consolidate (observed in CI: a
profile whose processor logged 'completed/full, skipping further
processing' was still served as not-completed 15 minutes later).

Save now happens when the pass produced new data OR changed the
status/completion annotations.
node-agent stamps kubescape.io/status: failed on containers that exit
non-zero (helpers.Failed in k8s-interface), but ValidateStatusAnnotation
never whitelisted it: every save of such a profile was rejected Invalid and
the client retried the identical object forever, degrading the agent under
crash-looping workloads. A failed container's profile is legitimate
forensics and now persists.
path.Clean("") returns ".": an empty open path in a chunk (a failed
openat("") recorded upstream) became a literal '.' entry in the consolidated
profile. Empty input now stays empty.
Observed: a user-authored NetworkNeighbor with an explicit 'port: 0' literal
returns 'port: null' after create+get (live repro on a fresh cluster; standalone
gob probe: encode/decode of NetworkPort{Port:*0} yields Port=nil). encoding/gob
flattens the pointer and omits zero values, so the port-0 literal — defined as
'restrict to port 0 only' — silently becomes an any-port stanza for consumers
that treat an absent ports entry as a wildcard (node-agent R0011, Test_28
port_zero_is_literal_not_wildcard).

Fix: internal-only NetworkPort.PortZero marker, stamped at the single payload
encode site and consumed at the payload decode sites. Legacy payloads carry no
marker: nonzero ports round-trip unchanged and absent ports are never invented.
Unit gob_portzero_test.go pins survival (spec-level + section-level) and legacy
compatibility; pkg/registry + pkg/apis suites pass unchanged.
…ield

The PortZero marker broke layout parity between internal and v1beta1
NetworkNeighbor, but the generated conversions still unsafe-cast the slices —
consolidation's checksum marshal then read corrupted memory (SIGSEGV in
json.Marshal under ./pkg/registry/file load tests). conversion-gen now emits
field-wise conversion; the internal->v1beta1 direction is manual so the
gob-only marker never leaks to API clients. json:"-" keeps it out of JSON
views (golden fixtures, metadata rows); gob ignores json tags and still
carries it.
Goroutine-dump-proven livelock: the consolidation transaction holds the write
gate; its PreSave refreshes the collapse-settings cache (TTL 10s < 30s
maintenance interval, so expired EVERY cycle); with no CollapseConfiguration
CR applied the refresh Get hits a missing payload and the unconditional gated
self-heal parks the full lockTimeout on the very gate its caller holds (fresh
pool conn, so re-entry cannot match) — cpCtx dies, the whole pass rolls back,
and the same key fails every ~35s cycle indefinitely (observed live: 385
failures at exactly 5.00-5.02s; four component tests starved serially; the
episode stops within one cycle of applying the CR).

Self-heal now (1) runs only when a metadata row actually exists for the key
and (2) try-acquires the gate — best-effort, never waiting. Collapse TTL
raised above the maintenance interval. Regression test pins a Get of an
absent key under a foreign-held gate: red pre-fix at exactly lockTimeout,
green post-fix; heal-when-needed still covered.
…s goroutine

A consolidation save calls PreSave from inside processTimeSeriesInTransaction,
on a connection that already holds SQLite's write lock (the pass has run
ReplaceTimeSeriesContainerEntries). PreSave calls the CollapseSettings
provider; when its 10s cache had expired the provider did a storage Get on a
SECOND pool connection, and with no CollapseConfiguration CR present get()'s
missing-payload DeleteMetadata on that connection needed the write lock the
same goroutine holds. It waited in SQLite's busy handler for the full busy
timeout (DefaultBusyTimeout, 60s), holding the WAL writer lock the whole time:
every shard commit and every other consolidation worker queued behind it.

Nothing else refreshes this cache in production (TS profile Creates return
from PreSave before the provider call) and the consolidation interval (30s)
exceeds the TTL (10s), so this fired on every tick that had data to
consolidate, whenever no CR was applied. Live since 2ea734d (v0.0.291);
6abe45d's cache reduced it from per-save to per-tick.

Make the provider stale-while-revalidate: prime the cache synchronously at
wiring time (no transaction is open there), serve every later call from one
atomic load, and run the refresh in a CAS-guarded background goroutine. The
caller's goroutine never takes a connection, a lock or a statement. Staleness
becomes TTL plus one call; the two tests that asserted "the very next call
reflects the edit" now use Eventually. The TTL is captured once at
construction so the background refresh never reads the package var.

Acceptance (fail before / pass after, real pool, 2s busy timeout):
- TestCRDCollapseSettingsProvider_NoStorageIOUnderHeldWriteLock: 2.004s -> <500ms
- TestCRDCollapseSettingsProvider_NoLockWaitOnCallerGoroutine: 1.000s -> <500ms
- TestConsolidateTimeSeries_DoesNotStallOnCollapseRefresh: 2.005s -> <500ms

Design: .omc/plans/collapse-settings-self-stall.md
Docs: docs/features/collapse-settings-self-stall.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
(cherry picked from commit 957af4c)
…ck (PRE-3)

processTimeSeriesInTransaction called endFn(&err) inline after updateProfile.
A panic raised inside updateProfile -- after the first series' Replace DELETE
had taken SQLite's write lock inside the pass's deferred transaction --
escaped with the transaction open, and the connection went back to the pool
with nobody left to end it. Every later writer then waited on that lock for
the full busy timeout (SQLITE_BUSY).

endFn is now deferred directly: sqlitex's endFn recovers the panic, rolls the
transaction back and re-panics, so the panic still propagates (PRE-3 fixes
the lock, not the crash) but the write lock is released. A second deferred
closure preserves the error wrapping for both a failed updateProfile and a
failed COMMIT.

TestProcessTimeSeriesInTransaction_PanicLeavesNoOpenTransaction injects the
panic from the second series' TS read (after the first series' DELETE) and
asserts a write on another connection succeeds immediately. It fails at
9cb7ce6 with "database is locked" and passes after this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
(cherry picked from commit fd5522b)
…tedData

Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
(cherry picked from commit 3d546e0)
loadOrInitializeProfile's new-profile branch bypasses k8s.io/apiserver's
generic Create path (it writes directly through storage.Interface), so
nothing ever assigned a UID. An empty UID makes the generic PATCH
handler's hasUID check treat the object as nonexistent, so any standard
Kubernetes PATCH (kubectl annotate/label/edit, merge-patch clients)
against an existing ContainerProfile returned 404 NotFound.

Confirmed live against armo-dev-stage via delve, filed as kubescape#385, and
now fixed by generating a UID the same way rest.FillObjectMetaSystemFields
does for a standard REST Create. Audited every other in-repo
fresh-ObjectMeta construction and confirmed none has the same gap.

Deliberately out of scope: backfilling a UID onto ContainerProfile
objects already persisted before this fix -- they remain unpatchable
until naturally recreated by consolidation.

Fixes kubescape#385

Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
(cherry picked from commit f56d126)
…path

The four upstream fixes taken here are production-code fixes that apply
cleanly. Two of the test files they carry do not, because they exercise
upstream's single-writer/shard redesign — a design this fork deliberately has
not adopted, and which upstream is still stabilising (a deadlock fix, a
same-hour revert, a self-stall fix and two panic-containment layers landed in
the nine days before the commit taken here).

collapse_settings_self_stall_test.go called NewPool with a busy-timeout
argument upstream added alongside that redesign. This fork's NewPool takes
(path, size). The argument is dropped; the test's timing assertions are
unchanged and it passes, so the self-stall guard the fix exists for is still
covered.

containerprofile_lane0_test.go is removed rather than adapted. It calls
saveObject with upstream's signature — context, an old-object parameter and a
commit callback — which belongs to the write path we did not take. Adapting it
would mean porting the semantics we declined, not fixing a call site.

That is a real cost and it is stated rather than hidden: the consolidation
transaction's defer-and-rollback fix is taken WITHOUT its regression test. The
fix itself is 26 lines and reviewed; the test that would pin it is coupled to a
different write path and has to wait for the rebase that decides between them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1r7S1rr7wMrTXhnEvjzMg
Reverts 828e305, 420eed5, 7fc16b5, e800f98 and 277af94, returning this
branch to f2a1f02 — the tree is now byte-identical to that commit.

Three component-test subtests began failing on the build that carried these,
all in the authored and signed profile path: an authored profile binding to a
workload, a signed suggestion healing in place, and a signed overlay being
enforced rather than merely accepted. Each failed at a poll cap rather than on
a wrong answer, which is the shape of waiting for something that never arrives.

The same tests passed on the same node-agent image with the previous storage
build, and this bump was the only deliberate change between the two runs. The
run that would have isolated it was killed by an unrelated control-plane
starvation on the test rig; a two-arm rerun on a clean cluster is in progress.

So the picks are reverted rather than fixed, because the deployed chart is
being standardised across the fleet and a suspected regression in the profile
binding path is not something to ship while the question is open. The
suspicion is not proven and may not survive the rerun. If it does not, these
commits can return unchanged — they are individually sound and were verified
before release.

Of the four, the metadata.uid generation for new ContainerProfiles is the one
worth examining first if the rerun implicates this bump, since it changes the
object at create time and the failing tests turn on identity.
/proc/<pid> and /proc/<pid>/task/<tid> carry kernel-assigned numbers with no
meaning past the lifetime of the task that holds them, and the collapse
threshold cannot remove them. Collapse fires on a node's child COUNT, so it
only reaches identifiers a workload produces in bulk: a container with three
threads yields three tids, far below any sane threshold, and those three
literals freeze into the profile. The next run of the same workload draws
different numbers, so every open under /proc/<pid>/task/ is then unprofiled --
a permanent false-positive source that grows with each restart rather than
converging.

Both positions are now collapsed structurally, on first sight, independent of
cardinality. Only all-digit segments qualify (plus an already-collapsed
identifier, since the node agent rewrites /proc/<pid> at report time and the
tid therefore arrives under a dynamic parent). Names stay names: /proc/self,
/proc/thread-self, /proc/net and /proc/sys are unchanged, and in
/proc/self/task/<tid> only the tid moves.

The routing detail matters. These segments do NOT go through the level's
DynamicIdentifier child: that child sets IsNextDynamic, which routes every
sibling through it, so a single numeric pid would drag /proc/self,
/proc/cpuinfo and /proc/sys into one pattern and cost the profile the
distinction between reading a named procfs file and walking a task directory.
They share a reserved trie key instead, unreachable as a real segment because
segments cannot contain '/', and the node it reaches still emits the dynamic
identifier, so the key never appears in a profile. A prefix an operator has
configured as noise (Threshold 1) still wins and yields the wildcard.

Scope is deliberately those two positions. File descriptors under
/proc/<pid>/fd/ are left to the threshold, which does reach them.

Consolidation folds profiles written before this: AnalyzeOpens re-analyses
every stored path on save, so recorded literals converge to the pattern and
their flags merge rather than being dropped.

28 new subtests fail on the pre-fix analyzer and pass after, covering first-
sight collapse, the names that must survive, prefix anchoring (/procfs,
/var/proc, tasks-not-task), the few-threads case the threshold cannot reach,
profile-scope folding with flag merge, idempotence across repeated saves,
non-widening to the zero-or-more wildcard, that the emitted pattern still
matches live events and still rejects the neighbours it should, the
threshold-1 override, and that cardinality collapse still applies below the
tid. The rest of the storage suite passes unchanged.
…eal input

The previous commit described the pid and the tid as one problem. They are
not, and the difference matters for what the fix is actually worth.

The pid ALWAYS collapsed. The node agent rewrites /proc/<pid> to the dynamic
identifier at report time with an anchored regex, unconditionally and with no
threshold involved. Only the tid was ever left literal: the regex is anchored
at the start of the path and replaces one match, so /proc/<pid>/task/<tid>
arrives at storage with the pid already dynamic and the tid still a number.

That input is what made the level-wide dynamic child expensive. A dynamic
child sets IsNextDynamic, which routes every sibling at that level through it,
so the agent's own pid rewrite was by itself enough to swallow the named
procfs reads. On the exact six-path shape the agent emits, the pre-fix
analyzer produced six entries in which /proc/cpuinfo had become /proc/<dyn>,
/proc/self/status had become /proc/<dyn>/status, and
/proc/sys/kernel/randomize_va_space had become
/proc/<dyn>/kernel/randomize_va_space -- a per-task path that cannot exist --
while all three tids stayed literal. It now produces four: the three named
reads intact, and one folded task pattern.

So the routing choice is not defensive. It repairs a loss of profile fidelity
that was already happening in production on every container that reads its own
procfs, which is most of them.

This adds only a test. The analyzer is unchanged and the image already built
from the previous commit is unaffected.
⋯ matches exactly one segment and * matches zero-or-more, so * is the
broader of the two. processSegment routed an explicitly supplied *
through an existing ⋯ child, rewriting it and NARROWING the stored
profile below what the author wrote. It happened in both insertion
orders, and to a * in a middle segment too.

Applying a ContainerProfile that declares

  /bitnami/postgresql/data/base/⋯
  /bitnami/postgresql/data/base/*

stored only the ⋯ form. Every relation file two levels down —
/bitnami/postgresql/data/base/16384/2650 — then fell outside the
profile and raised R0002: 25 alerts on a ZITADEL postgres whose SBoB
had declared exactly that subtree.

An explicit * now promotes the node via createWildcardNode, which
already absorbs the accumulated children.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfoMnkruL8WV7Njfk5Mmkj
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants