Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .go-arch-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ components:
shared:
in:
- internal/digest/**
- internal/envtestassets/**
- internal/errors/**
- internal/metrics/**
- internal/pathvalidate/**
Expand Down
5 changes: 5 additions & 0 deletions docs/development/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ Test pyramid (L0–L5), coverage floors, and CI gates:
for controller-runtime envtest. First run may take a minute. Controller tests live under
`internal/controller/` (`suite_test.go` sets up envtest).

The envtest suites fall back to `bin/k8s/<version>-<os>-<arch>` when `KUBEBUILDER_ASSETS` is unset,
so a bare `go test ./internal/...` works too. They select the directory matching the **host**
OS/arch (`internal/envtestassets`), which matters when `bin/` holds downloads for more than one
platform — picking the wrong one fails every suite in `BeforeSuite` with `exec format error`.

E2E scripts, nightly workflows, multi-tenant fixtures, and tenantMode RBAC asserts are documented in
[testing.md](testing.md) and `hack/kind/README.md`.

Expand Down
37 changes: 7 additions & 30 deletions internal/collect/scale_envtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"os"
"path/filepath"
goruntime "runtime"
"strconv"
"sync"
"testing"
Expand All @@ -25,6 +26,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/envtest"

kollectdevv1alpha1 "github.com/platformrelay/kollect/api/v1alpha1"
"github.com/platformrelay/kollect/internal/envtestassets"
)

// scaleTestMaxObjects is the ADR-0603 default synthetic object cap for task test.
Expand Down Expand Up @@ -285,41 +287,16 @@ func stopScaleEnvtest(t *testing.T, testEnv *envtest.Environment) {
}
}

// resolveEnvtestAssetsDir returns KUBEBUILDER_ASSETS when the harness exported it (`task test`,
// hack/coverage.sh), and otherwise the local setup-envtest download built for this host. Selecting
// by host OS/arch matters because a checkout can hold assets for several platforms at once.
func resolveEnvtestAssetsDir() string {
if assets := os.Getenv("KUBEBUILDER_ASSETS"); assets != "" {
if abs, err := filepath.Abs(assets); err == nil {
return abs
}

return assets
}

return scaleEnvtestBinaryDir()
}

func scaleEnvtestBinaryDir() string {
for _, basePath := range []string{
basePaths := []string{
filepath.Join("bin", "k8s"),
filepath.Join("..", "..", "bin", "k8s"),
} {
entries, err := os.ReadDir(basePath)
if err != nil {
continue
}

for _, entry := range entries {
if entry.IsDir() {
abs, err := filepath.Abs(filepath.Join(basePath, entry.Name()))
if err != nil {
continue
}

return abs
}
}
}

return ""
return envtestassets.Resolve(basePaths, goruntime.GOOS, goruntime.GOARCH)
}

func seedScaleNamespace(ctx context.Context, cfg *rest.Config) error {
Expand Down
36 changes: 12 additions & 24 deletions internal/controller/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
"time"

Expand All @@ -21,6 +22,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log/zap"

kollectdevv1alpha1 "github.com/platformrelay/kollect/api/v1alpha1"
"github.com/platformrelay/kollect/internal/envtestassets"
"github.com/platformrelay/kollect/internal/sink"
// +kubebuilder:scaffold:imports
)
Expand Down Expand Up @@ -66,9 +68,10 @@ var _ = BeforeSuite(func() {
ErrorIfCRDPathMissing: true,
}

// Retrieve the first found binary directory to allow running tests from IDEs
if getFirstFoundEnvTestBinaryDir() != "" {
testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir()
// Resolve the binaries built for this host so the suite also runs from an IDE, and on a
// developer machine whose OS/arch differs from CI's.
if dir := envtestassets.Resolve(envtestAssetBasePaths(), runtime.GOOS, runtime.GOARCH); dir != "" {
testEnv.BinaryAssetsDirectory = dir
}

// cfg is defined in this file globally.
Expand Down Expand Up @@ -96,25 +99,10 @@ var _ = AfterSuite(func() {
}, time.Minute, time.Second).Should(Succeed())
})

// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path.
// ENVTEST-based tests depend on specific binaries, usually located in paths set by
// controller-runtime. When running tests directly (e.g., via an IDE) without using
// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured.
//
// This function streamlines the process by finding the required binaries, similar to
// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are
// properly set up, run 'make setup-envtest' beforehand.
func getFirstFoundEnvTestBinaryDir() string {
basePath := filepath.Join("..", "..", "bin", "k8s")
entries, err := os.ReadDir(basePath)
if err != nil {
logf.Log.Error(err, "Failed to read directory", "path", basePath)
return ""
}
for _, entry := range entries {
if entry.IsDir() {
return filepath.Join(basePath, entry.Name())
}
}
return ""
// envtestAssetBasePaths lists the directories that may hold setup-envtest downloads, relative to
// this package. ENVTEST-based tests depend on those binaries, and when the tests run directly
// (e.g. from an IDE) rather than through a Makefile target that exports KUBEBUILDER_ASSETS, the
// 'BinaryAssetsDirectory' must be configured explicitly. Run 'make setup-envtest' beforehand.
func envtestAssetBasePaths() []string {
return []string{filepath.Join("..", "..", "bin", "k8s")}
}
111 changes: 111 additions & 0 deletions internal/envtestassets/assets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Konrad Heimel

// Package envtestassets resolves the envtest control-plane binaries that match the running host.
//
// setup-envtest lays its downloads out as <bin/k8s>/<k8s-version>-<os>-<arch>, and a checkout can
// easily end up holding several of them at once — a bin/ shared between machines, a stale download,
// or a directory left behind by an earlier --bin-dir. The kubebuilder scaffold picks the first
// directory it finds, which on an Apple-silicon host hands envtest linux-amd64 binaries and fails
// every suite in BeforeSuite with "exec format error". Selecting by host OS/arch instead keeps a
// bare `go test ./internal/...` usable off linux without changing what CI resolves.
package envtestassets

import (
"os"
"path/filepath"
"strconv"
"strings"
)

// EnvVar is the environment variable `make test` and hack/coverage.sh export, and the one
// controller-runtime consults before an envtest.Environment's BinaryAssetsDirectory.
const EnvVar = "KUBEBUILDER_ASSETS"

// Dir returns the envtest asset directory built for goos/goarch, or "" when there is none.
//
// basePaths are searched in order and the first one holding a match wins; a base path that cannot
// be read is skipped. Only directories whose name ends in -<goos>-<goarch> are considered, which
// also rules out non-asset directories. Among the matches the highest version wins, compared field
// by numeric field so that 1.36.10 outranks 1.36.2 — plain lexical order gets that backwards.
//
// The result is absolute so it survives a caller changing directory. An empty result means callers
// should leave BinaryAssetsDirectory unset and let controller-runtime fall back to EnvVar or to its
// own default location.
func Dir(basePaths []string, goos, goarch string) string {
suffix := "-" + goos + "-" + goarch

for _, basePath := range basePaths {
entries, err := os.ReadDir(basePath)
if err != nil {
continue
}

best, bestVersion := "", ""

for _, entry := range entries {
name := entry.Name()
if !entry.IsDir() || !strings.HasSuffix(name, suffix) {
continue
}

version := strings.TrimSuffix(name, suffix)
if best == "" || newer(version, bestVersion) {
best, bestVersion = name, version
}
}

if best != "" {
return abs(filepath.Join(basePath, best))
}
}

return ""
}

// Resolve returns EnvVar when it is set and Dir(basePaths, goos, goarch) otherwise.
//
// EnvVar wins because that is the order controller-runtime itself resolves binaries in, so a
// harness that exports it — `make test`, hack/coverage.sh, CI — stays authoritative over whatever
// else happens to sit in bin/k8s.
func Resolve(basePaths []string, goos, goarch string) string {
if assets := os.Getenv(EnvVar); assets != "" {
return abs(assets)
}

return Dir(basePaths, goos, goarch)
}

// newer reports whether version a ranks above version b. Dot-separated fields are compared in
// order, numerically where both parse and lexically where either does not, and a version that
// extends another as a prefix ranks above it.
func newer(a, b string) bool {
fieldsA := strings.Split(a, ".")
fieldsB := strings.Split(b, ".")

for i := 0; i < len(fieldsA) && i < len(fieldsB); i++ {
if fieldsA[i] == fieldsB[i] {
continue
}

numA, errA := strconv.Atoi(fieldsA[i])
numB, errB := strconv.Atoi(fieldsB[i])

if errA != nil || errB != nil {
return fieldsA[i] > fieldsB[i]
}

return numA > numB
}

return len(fieldsA) > len(fieldsB)
}

// abs resolves path against the working directory, falling back to path when that is unavailable.
func abs(path string) string {
if resolved, err := filepath.Abs(path); err == nil {
return resolved
}

return path
}
Loading
Loading