From d69de519faac14c84242fadf97ad34f3ecccbdee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Andr=C3=A9s=20Villar?= Date: Wed, 4 Nov 2026 14:26:02 +0100 Subject: [PATCH 1/7] fix(deps): bump golang.org/x/net to v0.62.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears the two OSV advisories reported by Scorecard on the current dependency graph: - GO-2026-6860 - GO-2026-6866 Both are fixed in golang.org/x/net v0.62.0. The bump pulls the matching x/sync, x/sys, x/term, x/text and x/tools releases, all required by it. No API change: `go build ./...` and the full test suite pass. Signed-off-by: Enrique Andrés Villar --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index ae853f5..65f3948 100644 --- a/go.mod +++ b/go.mod @@ -80,14 +80,14 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/mod v0.41.0 // indirect - golang.org/x/net v0.60.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/oauth2 v0.37.0 // indirect - golang.org/x/sync v0.23.0 // indirect - golang.org/x/sys v0.48.0 // indirect - golang.org/x/term v0.46.0 // indirect - golang.org/x/text v0.42.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/term v0.47.0 // indirect + golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.49.0 // indirect + golang.org/x/tools v0.51.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect diff --git a/go.sum b/go.sum index 2bc53a7..09993aa 100644 --- a/go.sum +++ b/go.sum @@ -201,22 +201,22 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJk golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= From e2f6797ce47a27d2aaf540f6e7cfa4a67a145552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Andr=C3=A9s=20Villar?= Date: Wed, 4 Nov 2026 14:26:02 +0100 Subject: [PATCH 2/7] build: pin container base images by digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scorecard flags unpinned images because a mutable tag can be re-pointed to different content between builds. Pin both bases to the digest the tag resolves to today: golang:1.27.2-alpine3.23 -> sha256:9e45f0eb... gcr.io/distroless/static:nonroot -> sha256:25ac8f0c... Digests verified against registry-1.docker.io and gcr.io before pinning. Dependabot keeps bumping them. Signed-off-by: Enrique Andrés Villar --- Dockerfile | 4 ++-- hack/tilt/Dockerfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 17e0d61..ad6ceac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM golang:1.27.2-alpine3.23 AS builder +FROM golang:1.27.2-alpine3.23@sha256:9e45f0eb4a63ed37ad6e604950407558b7c738e34e015ae77a5d4ad369cde079 AS builder ARG TARGETOS ARG TARGETARCH @@ -27,7 +27,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o ma # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details -FROM gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329 WORKDIR / COPY --from=builder /workspace/manager . USER 65532:65532 diff --git a/hack/tilt/Dockerfile b/hack/tilt/Dockerfile index 9de4097..5dfb274 100644 --- a/hack/tilt/Dockerfile +++ b/hack/tilt/Dockerfile @@ -5,7 +5,7 @@ # hack/tilt/Dockerfile -FROM gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329 WORKDIR / COPY hack/tilt/bin/manager manager From 95c473ac79b25a7a0b4e133dbc4a4d9b08b1f102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Andr=C3=A9s=20Villar?= Date: Wed, 4 Nov 2026 14:26:03 +0100 Subject: [PATCH 3/7] test: add fuzz target for the container request mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scorecard reports no fuzzer integration. mutateContainers is pure and runs on every admitted pod, so it is the right entry point: the target asserts no panic and that every generated request stays inside [0, limit] for the ratios the OvercommitClass CRD admits (0.0001-1), plus the no-op cases (no limits, ratio 1). Signed-off-by: Enrique Andrés Villar --- pkg/overcommit/fuzz_test.go | 99 +++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 pkg/overcommit/fuzz_test.go diff --git a/pkg/overcommit/fuzz_test.go b/pkg/overcommit/fuzz_test.go new file mode 100644 index 0000000..b9e120b --- /dev/null +++ b/pkg/overcommit/fuzz_test.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// SPDX-FileContributor: enriqueavi@inditex.com +// +// SPDX-License-Identifier: Apache-2.0 + +package overcommit + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// validRatio reports whether r is a ratio the OvercommitClass CRD accepts. +// cpuOvercommit and memoryOvercommit are validated to the [0.0001, 1] range. +func validRatio(r float64) bool { + return r >= 0.0001 && r <= 1 +} + +// FuzzMutateContainers exercises the request rewriting done by the mutating +// webhook: it must never panic and must keep every generated request inside +// [0, limit], for any input the API admits. +func FuzzMutateContainers(f *testing.F) { + // Seed corpus: CRD boundaries, typical ratios and the no-op ratio of 1. + f.Add(int64(1000), int64(1<<30), 0.0001, 0.0001, true, true) + f.Add(int64(1000), int64(1<<30), 1.0, 1.0, true, true) + f.Add(int64(500), int64(256<<20), 0.5, 0.8, true, false) + f.Add(int64(0), int64(0), 0.5, 0.5, true, true) + f.Add(int64(32000), int64(512<<20), 1.0, 0.0001, true, true) + f.Add(int64(2000), int64(64<<20), 0.5, 0.5, false, true) + + f.Fuzz(func(t *testing.T, cpuMilli, memBytes int64, cpuRatio, memRatio float64, withLimits, withRequests bool) { + // Stay inside the CRD-validated domain, and keep limits in a range where + // the float64 -> int64 conversions in mutateContainers cannot overflow. + if !validRatio(cpuRatio) || !validRatio(memRatio) { + t.Skip() + } + if cpuMilli < 0 || cpuMilli > 1_000_000_000 || memBytes < 0 || memBytes > 1<<50 { + t.Skip() + } + + container := corev1.Container{Name: "fuzzed"} + var cpuLimit, memLimit resource.Quantity + if withLimits { + cpuLimit = *resource.NewMilliQuantity(cpuMilli, resource.DecimalSI) + memLimit = *resource.NewQuantity(memBytes, resource.BinarySI) + container.Resources.Limits = corev1.ResourceList{ + corev1.ResourceCPU: cpuLimit, + corev1.ResourceMemory: memLimit, + } + } + if withRequests { + container.Resources.Requests = corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(1, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(1, resource.BinarySI), + } + } + + containers := []corev1.Container{container} + mutateContainers(containers, cpuRatio, memRatio) + + requests := containers[0].Resources.Requests + if !withLimits { + // No limits means no overcommit: requests must be left untouched. + if len(requests) != len(container.Resources.Requests) { + t.Fatalf("requests changed without limits: %v", requests) + } + return + } + + if cpuRatio == 1 && memRatio == 1 { + // A ratio of 1 is a no-op and must not invent request entries. + if _, ok := requests[corev1.ResourceCPU]; ok && !withRequests { + t.Fatalf("cpu request created for ratio 1: %v", requests) + } + return + } + + if cpuRatio != 1 { + cpuReq, ok := requests[corev1.ResourceCPU] + if !ok { + t.Fatalf("cpu request missing for ratio %v", cpuRatio) + } + if got, limit := cpuReq.MilliValue(), cpuLimit.MilliValue(); got < 0 || got > limit { + t.Fatalf("cpu request out of bounds: got %d, limit %d, ratio %v", got, limit, cpuRatio) + } + } + if memRatio != 1 { + memReq, ok := requests[corev1.ResourceMemory] + if !ok { + t.Fatalf("memory request missing for ratio %v", memRatio) + } + if got, limit := memReq.Value(), memLimit.Value(); got < 0 || got > limit { + t.Fatalf("memory request out of bounds: got %d, limit %d, ratio %v", got, limit, memRatio) + } + } + }) +} From 96e3299304b8602ab5351c518e78130ef3351fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Andr=C3=A9s=20Villar?= Date: Wed, 4 Nov 2026 14:26:03 +0100 Subject: [PATCH 4/7] ci: pin actions by SHA and add unit and fuzz jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin checkout, cache and setup-go to commit SHAs (Scorecard PinnedDependencies on ci.yml). - Add a "Unit tests" job running `make test` on every PR. E2E alone meant the suite was only exercised on PRs that touch the webhook. - Add a "Fuzz tests" job running the new target for 60s. Signed-off-by: Enrique Andrés Villar --- .github/workflows/ci.yml | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e040839..87b157f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,21 +4,53 @@ on: permissions: contents: read jobs: + unit-tests: + runs-on: ubuntu-latest + name: Unit tests 🧪 + steps: + - name: Checkout 🧲 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Go ⬇️ + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + + - name: Run unit tests 🧪 + run: make test + + fuzz: + runs-on: ubuntu-latest + name: Fuzz tests 🎲 + steps: + - name: Checkout 🧲 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Go ⬇️ + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + + - name: Fuzz the pod mutation logic 🎲 + run: go test -fuzz=FuzzMutateContainers -fuzztime=60s ./pkg/overcommit + e2e: runs-on: ubuntu-latest name: E2E tests 🔃 steps: - name: Checkout 🧲 - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore go build cache ⏬ - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/go-build key: ${{ runner.os }}-go-build-v1 - name: Setup Go ⬇️ - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: 1.26.8 From 985128744590ff042c58924c210afb7fcad8f8a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Andr=C3=A9s=20Villar?= Date: Wed, 4 Nov 2026 14:26:04 +0100 Subject: [PATCH 5/7] ci(codeql): scan on main by default when DEVELOPMENT_FLOW is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The availability gate only let CodeQL run when the repository variable DEVELOPMENT_FLOW was set, so most pushes and PRs skipped scanning and Scorecard reported SAST coverage of 16/30 commits. Treat an unset variable as trunk-based development, which is what this repo does (default branch main), and keep git-flow untouched. Signed-off-by: Enrique Andrés Villar --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8bd5874..7609116 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,7 +31,7 @@ jobs: if: >- github.event_name == 'schedule' || ( - vars.DEVELOPMENT_FLOW == 'trunk-based-development' && + (vars.DEVELOPMENT_FLOW == '' || vars.DEVELOPMENT_FLOW == 'trunk-based-development') && ( (github.event_name == 'push' && (github.ref_name == 'main' || startsWith(github.ref_name, 'main-'))) || (github.event_name == 'pull_request' && (github.event.pull_request.base.ref == 'main' || startsWith(github.event.pull_request.base.ref, 'main-'))) || From f6e1c51fbf7ff914e162e114afd3e305b0cd09e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Andr=C3=A9s=20Villar?= Date: Wed, 4 Nov 2026 14:31:43 +0100 Subject: [PATCH 6/7] ci(fuzz): keep the envtest suite out of the fuzz run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `go test -fuzz` still runs the package's regular tests, and TestAPIs starts envtest, which needs KUBEBUILDER_ASSETS. Restrict the run to the fuzz target so the job does not need the API server binaries. Signed-off-by: Enrique Andrés Villar --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87b157f..a95989e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: cache: true - name: Fuzz the pod mutation logic 🎲 - run: go test -fuzz=FuzzMutateContainers -fuzztime=60s ./pkg/overcommit + run: go test -run '^$' -fuzz=FuzzMutateContainers -fuzztime=60s ./pkg/overcommit e2e: runs-on: ubuntu-latest From ce6d371ecca743b05e952d2d1448d419f802ac29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Andr=C3=A9s=20Villar?= Date: Wed, 4 Nov 2026 16:01:47 +0100 Subject: [PATCH 7/7] test(fuzz): split the target to lower cognitive complexity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud flagged FuzzMutateContainers with go:S3776 (complexity 40, limit 15) because the corpus, the domain checks, the container builder and the four assertions all lived in the fuzz closure. Split them into fuzzInput.inDomain/container and assertRequests/assertResource: same coverage, same seeds, no behaviour change. Signed-off-by: Enrique Andrés Villar --- pkg/overcommit/fuzz_test.go | 154 +++++++++++++++++++++--------------- 1 file changed, 91 insertions(+), 63 deletions(-) diff --git a/pkg/overcommit/fuzz_test.go b/pkg/overcommit/fuzz_test.go index b9e120b..b37e955 100644 --- a/pkg/overcommit/fuzz_test.go +++ b/pkg/overcommit/fuzz_test.go @@ -6,6 +6,7 @@ package overcommit import ( + "reflect" "testing" corev1 "k8s.io/api/core/v1" @@ -18,82 +19,109 @@ func validRatio(r float64) bool { return r >= 0.0001 && r <= 1 } +// fuzzInput is one generated case for the mutation target. +type fuzzInput struct { + cpuMilli int64 + memBytes int64 + cpuRatio float64 + memRatio float64 + withLimits bool + withRequests bool +} + +// inDomain keeps the target inside the values the API and the CRD admit: +// ratios in [0.0001, 1] and limits small enough that the float64 -> int64 +// conversions in mutateContainers cannot overflow. +func (in fuzzInput) inDomain() bool { + return validRatio(in.cpuRatio) && validRatio(in.memRatio) && + in.cpuMilli >= 0 && in.cpuMilli <= 1_000_000_000 && + in.memBytes >= 0 && in.memBytes <= 1<<50 +} + +// container builds the input container and the limits it declares. +func (in fuzzInput) container() (corev1.Container, corev1.ResourceList) { + limits := corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(in.cpuMilli, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(in.memBytes, resource.BinarySI), + } + + c := corev1.Container{Name: "fuzzed"} + if in.withLimits { + c.Resources.Limits = limits + } + if in.withRequests { + c.Resources.Requests = corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(1, resource.DecimalSI), + corev1.ResourceMemory: *resource.NewQuantity(1, resource.BinarySI), + } + } + return c, limits +} + // FuzzMutateContainers exercises the request rewriting done by the mutating // webhook: it must never panic and must keep every generated request inside // [0, limit], for any input the API admits. func FuzzMutateContainers(f *testing.F) { - // Seed corpus: CRD boundaries, typical ratios and the no-op ratio of 1. - f.Add(int64(1000), int64(1<<30), 0.0001, 0.0001, true, true) - f.Add(int64(1000), int64(1<<30), 1.0, 1.0, true, true) - f.Add(int64(500), int64(256<<20), 0.5, 0.8, true, false) - f.Add(int64(0), int64(0), 0.5, 0.5, true, true) - f.Add(int64(32000), int64(512<<20), 1.0, 0.0001, true, true) - f.Add(int64(2000), int64(64<<20), 0.5, 0.5, false, true) + for _, seed := range []fuzzInput{ + {1000, 1 << 30, 0.0001, 0.0001, true, true}, + {1000, 1 << 30, 1.0, 1.0, true, true}, + {500, 256 << 20, 0.5, 0.8, true, false}, + {0, 0, 0.5, 0.5, true, true}, + {32000, 512 << 20, 1.0, 0.0001, true, true}, + {2000, 64 << 20, 0.5, 0.5, false, true}, + } { + f.Add(seed.cpuMilli, seed.memBytes, seed.cpuRatio, seed.memRatio, seed.withLimits, seed.withRequests) + } f.Fuzz(func(t *testing.T, cpuMilli, memBytes int64, cpuRatio, memRatio float64, withLimits, withRequests bool) { - // Stay inside the CRD-validated domain, and keep limits in a range where - // the float64 -> int64 conversions in mutateContainers cannot overflow. - if !validRatio(cpuRatio) || !validRatio(memRatio) { - t.Skip() - } - if cpuMilli < 0 || cpuMilli > 1_000_000_000 || memBytes < 0 || memBytes > 1<<50 { + in := fuzzInput{cpuMilli, memBytes, cpuRatio, memRatio, withLimits, withRequests} + if !in.inDomain() { t.Skip() } - container := corev1.Container{Name: "fuzzed"} - var cpuLimit, memLimit resource.Quantity - if withLimits { - cpuLimit = *resource.NewMilliQuantity(cpuMilli, resource.DecimalSI) - memLimit = *resource.NewQuantity(memBytes, resource.BinarySI) - container.Resources.Limits = corev1.ResourceList{ - corev1.ResourceCPU: cpuLimit, - corev1.ResourceMemory: memLimit, - } - } - if withRequests { - container.Resources.Requests = corev1.ResourceList{ - corev1.ResourceCPU: *resource.NewMilliQuantity(1, resource.DecimalSI), - corev1.ResourceMemory: *resource.NewQuantity(1, resource.BinarySI), - } - } + original, limits := in.container() + containers := []corev1.Container{original} + mutateContainers(containers, in.cpuRatio, in.memRatio) - containers := []corev1.Container{container} - mutateContainers(containers, cpuRatio, memRatio) + assertRequests(t, containers[0].Resources.Requests, original.Resources.Requests, limits, in) + }) +} - requests := containers[0].Resources.Requests - if !withLimits { - // No limits means no overcommit: requests must be left untouched. - if len(requests) != len(container.Resources.Requests) { - t.Fatalf("requests changed without limits: %v", requests) - } - return - } +// assertRequests checks the invariants of mutateContainers on a single container. +func assertRequests(t *testing.T, got, before, limits corev1.ResourceList, in fuzzInput) { + t.Helper() - if cpuRatio == 1 && memRatio == 1 { - // A ratio of 1 is a no-op and must not invent request entries. - if _, ok := requests[corev1.ResourceCPU]; ok && !withRequests { - t.Fatalf("cpu request created for ratio 1: %v", requests) - } - return + if !in.withLimits { + // Without limits there is nothing to overcommit: requests must not move. + if !reflect.DeepEqual(got, before) { + t.Fatalf("requests changed without limits: got %v, want %v", got, before) } + return + } - if cpuRatio != 1 { - cpuReq, ok := requests[corev1.ResourceCPU] - if !ok { - t.Fatalf("cpu request missing for ratio %v", cpuRatio) - } - if got, limit := cpuReq.MilliValue(), cpuLimit.MilliValue(); got < 0 || got > limit { - t.Fatalf("cpu request out of bounds: got %d, limit %d, ratio %v", got, limit, cpuRatio) - } - } - if memRatio != 1 { - memReq, ok := requests[corev1.ResourceMemory] - if !ok { - t.Fatalf("memory request missing for ratio %v", memRatio) - } - if got, limit := memReq.Value(), memLimit.Value(); got < 0 || got > limit { - t.Fatalf("memory request out of bounds: got %d, limit %d, ratio %v", got, limit, memRatio) - } + assertResource(t, corev1.ResourceCPU, got, before, limits[corev1.ResourceCPU], in.cpuRatio) + assertResource(t, corev1.ResourceMemory, got, before, limits[corev1.ResourceMemory], in.memRatio) +} + +// assertResource checks one resource: a ratio of 1 is a no-op, any other ratio +// produces a request in [0, limit]. +func assertResource(t *testing.T, key corev1.ResourceName, got, before corev1.ResourceList, limit resource.Quantity, ratio float64) { + t.Helper() + + req, present := got[key] + prev, hadPrev := before[key] + + if ratio == 1 { + if hadPrev && (!present || prev.Cmp(req) != 0) { + t.Fatalf("%s request changed for ratio 1: got %v, want %v", key, req.String(), prev.String()) } - }) + return + } + + if !present { + t.Fatalf("%s request missing for ratio %v", key, ratio) + } + if req.Sign() < 0 || req.Cmp(limit) > 0 { + t.Fatalf("%s request out of bounds: got %s, limit %s, ratio %v", key, req.String(), limit.String(), ratio) + } }