diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e040839..a95989e 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 -run '^$' -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 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-'))) || 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/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= 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 diff --git a/pkg/overcommit/fuzz_test.go b/pkg/overcommit/fuzz_test.go new file mode 100644 index 0000000..b37e955 --- /dev/null +++ b/pkg/overcommit/fuzz_test.go @@ -0,0 +1,127 @@ +// 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 ( + "reflect" + "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 +} + +// 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) { + 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) { + in := fuzzInput{cpuMilli, memBytes, cpuRatio, memRatio, withLimits, withRequests} + if !in.inDomain() { + t.Skip() + } + + original, limits := in.container() + containers := []corev1.Container{original} + mutateContainers(containers, in.cpuRatio, in.memRatio) + + assertRequests(t, containers[0].Resources.Requests, original.Resources.Requests, limits, in) + }) +} + +// 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 !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 + } + + 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) + } +}