Skip to content

feat(e2e): add internal/harness and move fluentbit-multitenant onto it - #2304

Merged
csatib02 merged 11 commits into
kube-logging:masterfrom
vyncint:e2e-harness-pilot
Aug 4, 2026
Merged

csatib02 merged 11 commits into
kube-logging:masterfrom
vyncint:e2e-harness-pilot

Conversation

@vyncint

@vyncint vyncint commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

internal/fixture landed in #2299 and nothing has imported it since — 655 lines carried by the module with no consumer. This adds the lifecycle package the suites were meant to sit on, and moves one suite onto both. fluentbit-multitenant, 176 lines to 66.

env := harness.New(t).
    WithCluster(release).
    WithRelease(release).
    WithControlNamespace(nsInfra).
    WithNamespaces(nsTenant).
    Start()

env.Create(fixture.LoggingInfra(nsInfra, release, tagInfra, buffer, producerLabels)...)
env.StartLogProducer(nsTenant, producerLabels)

env.WaitForRunning(
    wait.Operator(release),
    wait.Producer(producerLabels),
    wait.Aggregator(nsInfra),
    wait.Aggregator(nsTenant),
)
env.WaitForReceiverLogs(tagInfra, tagTenant)

Start marks the test parallel, builds the scheme, brings up the cluster, installs the operator, creates the extra namespaces and registers teardown. What that retires per suite, still present in the other twelve: an init() building build/_test (12 files), the beforeCleanup closure that dumps logs and collects coverage (12), the scheme closure (102 AddToScheme lines in total), context.Background() (19) and common.Initialize (16).

Net lines are up here — the harness and its tests against 110 removed from one suite. It only pays from the second suite on, which is why one moves rather than thirteen.

Waiting

wait.Condition carries no testing.T and no client, so a condition is a value a suite names rather than one it assembles; the harness supplies both. The budget comes from the harness — min(5m, remaining − 90s) — so the numbers the suites spend today do not move, but a wait cannot take the package's whole budget with it. On timeout the assertion names what was outstanding: still waiting for the aggregator in tenant, rather than Condition never satisfied.

The receiver tail is no longer echoed on every poll. It was 24 identical banners in a local run, and the archived cluster dump already carries it.

Teardown

Writing the test for the ordering turned up a second problem. Go abandons the remaining cleanups at the first panic, so a log dump that panicked would have left the kind cluster running for the rest of the job. The order is one list now, registered from it, and each step recovers so an early failure still reaches the delete.

Both are covered without a cluster. Registering forward reverses the observed order; removing the recover lets the panic through before the delete runs.

Scope

Only internal/harness, so it imports common and common/setup for now. Giving common/cluster.go and common/setup their own packages is a move with no behaviour in it, and doing it now would leave two implementations live while twelve suites still use the old one. That layering inverts when common/ goes.

Semantic drift

fluentbit-multitenant is the only suite whose buffer leaves Type unset; the other twelve set "file", and so does fixture.Buffer. Taking the fixture default would have switched both outputs from memory to file buffering — the suite would still pass, for a different reason. The literal stays in the suite. Worth deciding separately whether "file" was meant all along.

Nothing pinned that fixture produces what common does: the tests added with it assert the shape that package chose, not that the choice matches the live path. TestBuildersMatchCommon records what common.LoggingInfra, LoggingTenant and LoggingRoute hand to Create and requires the builders to equal it, object for object and in order. It passes as written, so this migration changes no object.

That test is scaffolding, not a fixture: it makes common the oracle, so common cannot be deleted while it stands. The condition for removing it — the last suite that still uses common — is written next to it.

Verification

make check passes, golangci-lint run --max-same-issues=0 --max-issues-per-linter=0 in e2e/ reports 0 issues, and all seven commits build, vet and test on their own. The migrated suite passes against a real cluster: make test-e2e-nodeps E2E_TEST=fluentbit-multitenant, 67.6s, with tag_infra and tag_tenant both reaching the receiver.

Negative controls: moving the tenancy fluentd memory request from 50M to 100M fails TestBuildersMatchCommon on both infra and tenant; dropping the namespace de-duplication fails repeats are dropped; the two teardown ones above. Harness unit tests are pure Go, no stub binaries.

vyncint added 3 commits August 3, 2026 20:31
The builders landed in kube-logging#2299 with tests that assert the shape this package
chose, not that the choice matches what common creates. A suite moving onto
them could stop testing what it used to and still pass.This records what common.LoggingInfra, LoggingTenant and LoggingRoute hand to
Create and requires the builders to equal it, object for object and in order.
It passes as written, so the builders are a faithful stand-in today.

Signed-off-by: Vyncint Ng <[email protected]>
Start marks the test parallel, builds the scheme, brings up the cluster,
installs the operator, creates the extra namespaces and registers teardown.
Teardown keeps the order common.WithCluster had: artifacts, kubeconfig,
context cancel with the bounded Start check, delete cluster. t.Cleanup is
LIFO, so they are registered back to front.Per suite this replaces an init() building build/_test, the beforeCleanup
closure that dumps logs and collects coverage, and the scheme closure.

Signed-off-by: Vyncint Ng <[email protected]>
176 lines to 93, and the first consumer internal/fixture has had.The buffer stays written out here. This is the only suite that leaves Type
unset; taking fixture.Buffer's "file" default would switch both outputs from
memory buffering, so the suite would pass for a different reason.

Signed-off-by: Vyncint Ng <[email protected]>
@csatib02

csatib02 commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

Good shape overall, and §5.5 is the right place to stop for review. Three things.

Start vs a harness-led builder. I would rather the harness led the configuration than take a Config struct, since it now owns the whole lifecycle. harness.New(t).WithNamespace(...).Start() reads better at 17 call sites than a struct literal, and it lets a suite add one option without touching Config.

On the verb, though: Build would misname it. Start creates a KIND cluster, helm-installs the operator and registers teardown. Build reads as pure construction, so a reader would not expect a cluster to exist afterwards. Builder for the config, Start for the side effect.

The teardown order is the one thing worth a unit test, and it is the one thing untested. harness.go:128 says the order is load-bearing and registers four t.Cleanups back to front. That order is exactly what leaked clusters before, and harness_test.go covers buildScheme, dumpNamespaces and artifactPath instead — private helpers that were never going to break.

Worth extracting the registration so it takes a recorder, then asserting two things: the order, and that the cluster delete still runs when an earlier step fails. The cluster and helm paths genuinely need a real cluster, so leaving those to the e2e run is right.

equivalence_test.go earns its place, but needs an expiry. It is the §6 safety net from the plan, and #2299 shipped two drift bugs that this would have caught at unit speed rather than after a 20 minute cluster run. So I would keep it.

The cost is that it pins common as the oracle, so common cannot be deleted while it exists — the same coupling as fixture_test.go importing common. That is fine as scaffolding and not fine as a permanent fixture. The plan says "delete these once the suite is migrated"; worth stating that condition in the PR body so it does not quietly become permanent.

@csatib02

csatib02 commented Aug 3, 2026

Copy link
Copy Markdown
Member

One more, and it is the same argument as the lifecycle one: the readiness ladder should move onto the harness too.

running := []struct{ what string; cond func() bool }{ ... }
require.Eventually(t, func() bool { for _, step := range running { ... } })

That shape is in 14 of the 13 suites' tests today (fluentd-aggregator twice), and if it stays in the suite it gets reimplemented 12 more times as they migrate. env.WaitForRunning(...) writes the short-circuit, the per-step logging and the budget once.

Three things it should fix while moving, not just relocate:

Conditions should be values, not pre-bound closures. wait.AnyPodShouldBeRunning(t, env.Client, ...) takes t and the client at construction, so a condition cannot be declared as a reusable package-level value — every suite must rebuild it. If it returned a spec that the harness evaluates with its own t and client, then wait.OperatorRunning(release), wait.ProducerRunning(labels) and wait.AggregatorRunning(ns) become named things suites reference rather than assemble.

The harness should own the budget. The census counts 17 hand-written 5*time.Minute, 3*time.Second pairs. Env already knows t, so it can derive from t.Deadline() and divide across steps, which is also the esReadyBudget bug we are fixing separately: one wait must not be allowed to spend the whole package budget.

Name the outstanding step in the failure, not just the log. On timeout require.Eventually reports Condition never satisfied, and which of the four was unmet only exists in a t.Logf further up the output. Have the harness keep the last unmet step and put it in the assertion message, so a red run names the aggregator that never came up.

Rough target, for discussion rather than as a spec:

env.WaitForRunning(
    wait.Operator(release),
    wait.Producer(producerLabels),
    wait.Aggregator(nsInfra),
    wait.Aggregator(nsTenant),
)

Worth settling now, since §5.5 exists to get this API right before the other twelve suites copy it.

vyncint added 5 commits August 4, 2026 05:36
AnyPodShouldBeRunning takes the testing.T and the client at construction, so
a condition cannot be declared as a reusable value: every suite rebuilds its
own. Condition carries neither, and whoever evaluates it supplies both, so
Operator, Producer and Aggregator become things a suite references.

Signed-off-by: Vyncint Ng <[email protected]>
The four Cleanup calls were registered back to front inline, with a comment
saying the order was load-bearing and nothing checking it. Go also abandons the
remaining cleanups at the first panic, so a log dump that panicked would leave
the kind cluster running for the rest of the job.The order is one list now, registered from it, and each step recovers so a
failure in an early one still reaches the delete. Both are covered without a
cluster: registering forward reverses the observed order, and removing the
recover lets the panic through before the delete runs.

Signed-off-by: Vyncint Ng <[email protected]>
Config was a struct literal at what will become 17 call sites, and adding an
option meant touching the struct. New(t).With...().Start() reads better and
takes one line per option. Start keeps its name: it creates a cluster and
installs the operator, which Build would not suggest.WaitForRunning and WaitForReceiverLogs move the readiness ladder off the
suites. The budget comes from the harness rather than a hand-written
5m/3s pair, capped by what is left of the binary's deadline so one wait
cannot spend the package's. On timeout the assertion names the condition
still outstanding instead of reporting Condition never satisfied.The receiver tail is no longer echoed on every poll; the archived cluster
dump already carries it.

Signed-off-by: Vyncint Ng <[email protected]>
It makes common the oracle, so common cannot be deleted while it stands. That
is fine as scaffolding and not fine as a permanent fixture, so the condition
for removing it is written down next to it.

Signed-off-by: Vyncint Ng <[email protected]>
The previous run failed in three suites this branch does not touch:
elasticsearch-multiversion on its own readiness budget, one on "docker rm"
during kind teardown and one on kubeadm during cluster creation. Suite time was
1.6x the previous run on this branch and 3.4x on one of them, so the runner was
degraded. fluentbit-multitenant passed on it.

Signed-off-by: Vyncint Ng <[email protected]>
@csatib02

csatib02 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Four things, all on the new API rather than the behaviour.

Drop the display string from the conditions. PodRunning("the operator", client.MatchingLabels{types.NameLabel: release}) makes every call site pass prose plus a selector, and the prose reads badly in the failure. Let each condition know its own name and its own selector, and take only the part that varies:

env.WaitForRunning(
    wait.Operator(release),
    wait.Producer(producerLabels),
    wait.FluentdAggregator(nsInfra),
    wait.FluentdAggregator(nsTenant),
)

Name then comes from the constructor, not the caller: operator, producer, fluentd aggregator in infra. No suite can word it differently, and still waiting for fluentd aggregator in infra is already the sentence you want. PodRunning(name, opts...) stays as the escape hatch for one-offs.

That also removes the comment you could not parse. This one:

// Aggregator matches the fluentd statefulset's pods in one namespace, which is
// what a multi-tenant suite waits on per tenant.

is hard to read because Aggregator is ambiguous — this repo has two, and the comment is carrying the disambiguation the name should. Split it into wait.FluentdAggregator(ns) and wait.SyslogNGAggregator(ns), each with the right labels baked in, and the comment is unnecessary. A name that needs a sentence to explain which of two things it means is the wrong name.

registerTeardown is missing a type, which is why it takes two functions. cleanup func(func()) and onPanic func(step string, v any) are both injected only so the test can observe them. Give the steps a type and the injection disappears:

type teardown []step

func (td teardown) register(t *testing.T) {
    for _, s := range slices.Backward(td) {
        t.Cleanup(func() {
            defer func() {
                if v := recover(); v != nil {
                    assert.Fail(t, fmt.Sprintf("teardown step %q panicked: %v", s.name, v))
                }
            }()
            s.run()
        })
    }
}

One parameter, no seams invented for the test. Both current tests still work: register inside a t.Run subtest, let it finish, then assert the recorded order and that the panicking step did not stop the rest. You are testing t.Cleanup's real LIFO behaviour rather than a stand-in for it, which is strictly better since LIFO is the thing the comment calls load-bearing.

More generally, the function passing through this file is a symptom worth watching: cleanup, onPanic, teardownSteps(...) variadic funcs, and the setup.LoggingOperatorOptionFunc wrappers. Each one is a small hole punched for a test. A type usually closes several at once.

Comments are over the line again. Same note as before: a terse why only, and only where the code cannot say it. Several of the new ones restate the next statement, and two of them (the aggregator one above, the LIFO one) are compensating for names rather than explaining a trade-off.

vyncint added 3 commits August 4, 2026 15:52
PodRunning("the operator", ...) made every call site pass prose next to a
selector, and no two suites would word it the same. The constructors own both
now, so a call site passes only what varies and the failure reads
"still waiting for fluentd aggregator in infra".Aggregator is split in two: this repo has a fluentd one and a syslog-ng one,
and the name was carrying a comment to say which. PodRunning stays for
one-offs.

Signed-off-by: Vyncint Ng <[email protected]>
registerTeardown took a cleanup func and an onPanic func, both injected only so
the test could watch them. The steps are a type now, the list is literal at the
call site, and register takes one argument.The ordering test registers inside a subtest, so it exercises testing's own
LIFO rather than a stand-in. The panic test still needs a stub: register
reports through assert.Fail, and a failing subtest fails its parent, so a real
one could not assert that the delete still ran.

Signed-off-by: Vyncint Ng <[email protected]>
Dropped the ones restating the signature above them or the statement below,
and the two compensating for a name rather than explaining a trade-off. 47
lines to 27 in harness.go, 76 to 49 across the series.

Signed-off-by: Vyncint Ng <[email protected]>
@csatib02
csatib02 merged commit 96aea1c into kube-logging:master Aug 4, 2026
29 checks passed
@csatib02 csatib02 added the enhancement New feature or request label Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants