Skip to content

E2E: the harness shape is generating the bugs — restructure e2e/ around a lifecycle-owning harness #2291

Description

@vyncint

Is your feature request related to a problem? Please describe.

The e2e module has grown to the point where the per-test boilerplate is larger than the tests, and that boilerplate is where the bugs are. Everything below was measured on master @ a252292c.

The module is 7004 lines of Go across 13 suite directories, with 2096 of those in e2e/common/. There are 17 test functions, and every one of them provisions its own KIND cluster (11 suites × 1, volumedrain × 2, fluentd-aggregator × 4).

The entry point every suite goes through is:

func WithCluster(name string, t *testing.T, fn func(*testing.T, Cluster), beforeCleanup func(*testing.T, Cluster) error, opts ...cluster.Option)

common/cluster.go:52. Five positional parameters, two of them differently-shaped anonymous closures. Because setup and teardown are both callback bodies rather than anything reusable, each of the 17 call sites is a self-contained nested block: median 206 lines, longest 699 (TestElasticsearch_MultiVersion), shortest 104.

What that shape has produced, counted across the module:

count
var TestTempDir + init() reading PROJECT_DIR, byte-identical 13 (one renamed TestTempDirUnnamed)
scheme-registration AddToScheme lines 103
beforeCleanup PrintLogs/coverage closures 17, in 3 divergent variants
references to the common.<Image>Repo / Tag constants 186
5*time.Minute, 3*time.Second literal pairs 17
context.Background() 26
t.Context(), though the module is go 1.26.0 0
kubectl shell-outs 22
common/panicobject.go, zero references outside itself 133 lines

Two structural causes, both of which block incremental cleanup:

  1. Inverted package containment. common/cond and common/setup are subpackages of common yet import it — cond/conditions.go:22, setup/loggingoperator.go:29, setup/logproducer.go:30. Anything the three layers share therefore has to live in common, which is why common is a grab-bag and why it cannot be split in place.

  2. The module is not linted. make lint covers the repo root and pkg/sdk; e2e is a separate module and is skipped. Under the pinned golangci-lint 2.12.2 it has 45 findings:

    linter count
    gofumpt 18
    whitespace 16
    gci 4
    staticcheck 3
    goimports 2
    errcheck 1
    ineffassign 1

    A caveat that cost me some time, and will cost the next person the same: golangci-lint run prints only 12 of those 45 by default. max-same-issues defaults to 3 and caps per unique message text — gci, gofumpt and goimports all emit the identical string "File is not properly formatted", so 24 findings collapse into 3 printed lines, and whitespace's 16 collapse into 4. Fixing the 12 you can see just reveals the next batch. The real total only appears with --max-same-issues=0 --max-issues-per-linter=0.

    For the record, I put "8 findings" in E2E: unbounded kind CLI calls turn a stalled cluster build into a 20-minute panic that takes the whole package down #2287. That was wrong, and so is the 12 that the default output suggests — the true count at the commit that issue cites is 46. I'm correcting it there.

The four real bugs this shape is hiding

These are correctness problems, not style, and I verified each one:

  1. A failed helm init is silently swallowed. common/setup/loggingoperator.go:96 assigns the error from actionConfig.Init(...) and never checks it; line 111 overwrites err with the result of LocateChart. Every install therefore proceeds on a possibly half-initialized action config. This is the single ineffassign finding in the table above — the one case where the unlinted module is actively costing something.

  2. t.FailNow() is called from a non-test goroutine, 15 times. go setup.LogProducer(t, ...) appears at 15 call sites. LogProducer ends with two common.RequireNoError calls (common/helpers.go:53-58), which call t.FailNow(). The testing docs are explicit that FailNow must be called from the goroutine running the test. In practice the create error is dropped and the test instead dies several minutes later at a require.Eventually with a message that points at the wrong thing. Nothing about LogProducer needs a goroutine — it issues two Create calls and returns. common/cluster.go:66 has the same pattern around cluster.Start.

  3. Three waits that can never elapse. elasticsearch-multiversion/elasticsearch_multiversion_test.go:445,450,455 each wait 30*time.Minute for an Elasticsearch deployment. The package runs under -timeout 20m (Makefile:55, applied at Makefile:243), so the binary panics before any of those ceilings is reached. The intended "give up and report" path is unreachable.

  4. The coverage-collection policy contradicts itself, and under-reports. Of the 17 clusters, only 12 collect coverage at all — fluentd-aggregator collects from 1 of its 4, volumedrain from 1 of its 2, and fluentd-aggregator-namespacelabel from none — so the reported e2e coverage figure is measuring less than it appears to. Of the 12 that do collect, 9 log the error and continue, while 3 log it and then return err from beforeCleanup (elasticsearch-multiversion:749, watch-selector:193, logging_metrics_monitoring:305), failing a test that otherwise passed, for a reason unrelated to what it was testing.

Describe the solution you'd like

Replace the callback entry point with a harness that owns the lifecycle, and move the shared pieces into real packages under e2e/internal/ — built alongside common/ rather than refactoring it in place, since the inverted imports make in-place impossible. Suites keep one directory each; that is load-bearing, as it gives them independent -timeout and lets CI select a subset.

The shape I have in mind, for a suite that is currently around 200 lines:

func TestMultiWorker(t *testing.T) {
	env := harness.Start(t, harness.Config{Namespace: "testing"})
	// cluster named from t.Name(); scheme built once; operator installed;
	// t.Cleanup registers dump-logs + collect-coverage + delete-cluster; ctx = t.Context()

	out := fixture.HTTPOutput(env.NS, "test-output", env.Receiver.URL("multiworker"))
	env.Create(
		fixture.Logging(env.NS, "multiworker",
			fixture.WithFluentbit(),
			fixture.WithFluentd(fixture.Workers(2), fixture.Drain()),
		),
		out,
		fixture.Flow(env.NS, "test-flow", fixture.MatchProducer(), out),
	)
	env.StartLogProducer()

	env.WaitFor(wait.OperatorReady, wait.ProducerReady, wait.FluentdReady)
	env.Receiver.MustReceive("multiworker")
}

Each element retires a specific count from the table above: t.Cleanup retires the 17 teardown closures and the three divergent coverage policies; a scheme built once retires the 103 AddToScheme lines; t.Context() retires the 26 context.Background() calls and lets cond's ctx *context.Context parameters become plain values; fixtures defaulting to the local images retire the 186 constant references; named wait budgets retire the 17 magic 5m/3s pairs; the harness owning the temp dir retires the 13 init() copies. A synchronous StartLogProducer returning an error retires bug 2.

What I'd like judged, before I write any of it

Not the whole change — one suite. fluentbit-multitenant is the smallest at 174 lines. I would migrate that one alone, as a PR that shows the harness API against a real suite, and stop there until the API has been reviewed. The main risk in this work is not the mechanics; it is committing to an API and then replicating it 13 times. One migrated suite is enough to tell whether the API is right, and cheap to throw away if it isn't.

If that lands, the rest goes one suite per PR.

Describe alternatives you've considered

  • Refactor common/ in place. Blocked by the inverted imports — common cannot be split while its own subpackages import it. Building alongside and deleting common last is what makes each step independently reviewable.
  • Fix only the bugs and leave the structure. This is the fallback, and the bug fixes are worth having on their own (see below). But all four exist because the boilerplate is copy-pasted, so leaving the shape means the next four arrive the same way.
  • One big PR. ~5000 lines across 13 suites will not get a useful review.

Explicitly out of scope

Not proposing any of this here, and I won't fold it into restructure commits:

Additional context

Two things I'd rather say up front than have found in review:

The bug fixes land separately regardless of what happens to this proposal. They need no design agreement, and if the restructure stalls or is declined, they should still be in. I'm opening the first one now: adding e2e to make lint and fixing all 45 findings, with the swallowed helm-init error (bug 1) as its own commit. That one has to be atomic — make lint runs in CI at .github/workflows/ci.yaml:53, so enabling the module without fixing it turns CI red. Bugs 2–4 follow as small independent PRs.

Two smaller things I'll fold into those fixes rather than the restructure:

  • The local image name for the syslog-ng reloader disagrees with itself: common/helpers.go:43 says syslog-ng-reloader, common/setup/loggingoperator.go:46 says syslogng-reload. This is masked today because the Makefile always passes the env var; a bare go test would try to load an image that does not exist.
  • common/panicobject.go is 133 lines with no references outside itself, and still implements GetZZZ_DeprecatedClusterName / SetZZZ_DeprecatedClusterName, which metav1.Object no longer declares (absent from apimachinery v0.36.1 entirely).

One thing I want maintainer input on rather than deciding myself: common.Initialize (common/helpers.go:60-70) implements sharding via a package-level sequence counter. Because the counter is per-test-binary and 11 of the 13 suites contain exactly one test, localSeq is almost always 1 — so SHARDS=2 puts nearly everything in shard 1 and shard 0 runs almost nothing. The CI matrix that would drive it is commented out (.github/workflows/e2e.yaml:81,137). I'd remove it and keep the t.Parallel() call, which is load-bearing, but deleting a facility someone may intend to revive is your call, not mine.

Environment: measurements taken on master @ a252292c, golangci-lint 2.12.2 as pinned at Makefile:11, Go 1.26.5.

/kind feature

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions