Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ jobs:
- name: Lint
run: make lint

- name: Vulnerability check
run: make vulncheck

build-bink:
uses: $/.github/workflows/build-bink.yaml
with:
Expand Down
19 changes: 19 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: "2"

linters:
default: standard
enable:
- gosec
settings:
staticcheck:
# golangci-lint disables some stylistic checks by default; enable
# the full staticcheck suite.
checks:
- all
exclusions:
rules:
# The e2e suite drives external tools (kubectl, helm, bink) with
# computed arguments by design.
- path: ^test/e2e/
linters:
- gosec
32 changes: 28 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ For end-to-end testing you will also need:
backed by bootc nodes
- [skopeo](https://github.com/containers/skopeo) — container image inspection

Tool dependencies like `controller-gen`, `kustomize`, `golangci-lint`, and
`setup-envtest` are downloaded automatically to `./bin/` by the Makefile when
needed.
Tool dependencies like `controller-gen`, `kustomize`, `golangci-lint`,
`govulncheck`, and `setup-envtest` are downloaded automatically to `./bin/` by
the Makefile when needed.

### Development Environment

Expand Down Expand Up @@ -106,6 +106,26 @@ make unit V=1 # Verbose output
make unit RUN=Foo # Run tests matching "Foo"
```

### Checking for Vulnerabilities

```shell
make vulncheck
```

This runs [govulncheck](https://go.dev/blog/vuln) against all Go packages in
the repository, looking for known vulnerabilities from the
[Go vulnerability database](https://vuln.go.dev/). Both the `govulncheck`
binary and the database are downloaded on demand, so the target requires
network access.

Only reachable findings — vulnerabilities in functions that the code actually
calls — fail the check. Fix them by upgrading the affected dependency or
changing the affected code. The scan may also report informational findings
in packages you import or modules you require whose vulnerable functions are
not called; these do not fail the check.

CI runs this check in the `unit` job.

### Running End-to-End Tests

E2E tests live in `test/e2e/` and run against a real
Expand Down Expand Up @@ -196,7 +216,10 @@ matching the structure described in [Finding Test Logs](#finding-test-logs).
- Run `make fmt` and `make vet` before submitting.
- Run `make lint` to check with
[golangci-lint](https://golangci-lint.run/). Use `make lint-fix` to apply
automatic fixes.
automatic fixes. The configuration in `.golangci.yml` enables the full
staticcheck suite and gosec (except for the e2e tests). If a gosec finding
is a false positive, suppress it with `//nolint:gosec // <rule>` and a
comment explaining why.
- All Go files must include the SPDX license header:

```go
Expand Down Expand Up @@ -247,6 +270,7 @@ Follow a commit style similar to the Linux kernel:
make vet
make lint
make unit
make vulncheck
```

4. Push your branch and open a pull request against `main`.
Expand Down
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ go-lines: get-golines
go-lines-fix: get-golines
"$(GOLINES_BIN)" -w .

.PHONY: vulncheck
vulncheck: govulncheck ## Check Go code for known vulnerabilities (requires network access to the Go vulnerability database).
"$(GOVULNCHECK)" ./...

.PHONY: e2e
e2e: ## Run e2e tests (requires: make deploy-bink). V=1 for verbose. RUN=<regex> to filter.
# NB: we `cd` here instead of passing a package path to `go test` so that `-v`
Expand Down Expand Up @@ -288,11 +292,13 @@ CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
ENVTEST ?= $(LOCALBIN)/setup-envtest
GOLANGCI_LINT = $(LOCALBIN)/golangci-lint
GOLINES_BIN = $(LOCALBIN)/golines
GOVULNCHECK = $(LOCALBIN)/govulncheck
YQ ?= $(LOCALBIN)/yq

KUSTOMIZE_VERSION ?= v5.8.1
CONTROLLER_TOOLS_VERSION ?= v0.20.1
GOLANGCI_LINT_VERSION ?= v2.11.4
GOVULNCHECK_VERSION ?= v1.9.0
YQ_VERSION ?= v4.53.2

# ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20)
Expand Down Expand Up @@ -332,6 +338,11 @@ golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary.
$(GOLANGCI_LINT): $(LOCALBIN)
$(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION))

.PHONY: govulncheck
govulncheck: $(GOVULNCHECK) ## Download govulncheck locally if necessary.
$(GOVULNCHECK): $(LOCALBIN)
$(call go-install-tool,$(GOVULNCHECK),golang.org/x/vuln/cmd/govulncheck,$(GOVULNCHECK_VERSION))

.PHONY: get-golines
get-golines: $(GOLINES_BIN) ## Download golines locally if necessary.
$(GOLINES_BIN): $(LOCALBIN)
Expand Down
4 changes: 4 additions & 0 deletions api/v1alpha1/bootcnodepool_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,19 +200,23 @@ type BootcNodePoolStatus struct {

// nodeCount is the total number of nodes in this pool.
// +optional
// +kubebuilder:validation:Minimum=0
NodeCount int32 `json:"nodeCount,omitempty"`

// updatedCount is the number of nodes running the target digest.
// +optional
// +kubebuilder:validation:Minimum=0
UpdatedCount int32 `json:"updatedCount,omitempty"`

// updatingCount is the number of nodes actively being updated
// (staging, staged, or rebooting).
// +optional
// +kubebuilder:validation:Minimum=0
UpdatingCount int32 `json:"updatingCount,omitempty"`

// degradedCount is the number of nodes in a degraded state.
// +optional
// +kubebuilder:validation:Minimum=0
DegradedCount int32 `json:"degradedCount,omitempty"`

// conditions represent the current state of the pool.
Expand Down
4 changes: 4 additions & 0 deletions config/crd/bases/node.bootc.dev_bootcnodepools.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ spec:
degradedCount:
description: degradedCount is the number of nodes in a degraded state.
format: int32
minimum: 0
type: integer
deployedDigest:
description: |-
Expand All @@ -278,6 +279,7 @@ spec:
nodeCount:
description: nodeCount is the total number of nodes in this pool.
format: int32
minimum: 0
type: integer
observedGeneration:
description: |-
Expand All @@ -300,12 +302,14 @@ spec:
description: updatedCount is the number of nodes running the target
digest.
format: int32
minimum: 0
type: integer
updatingCount:
description: |-
updatingCount is the number of nodes actively being updated
(staging, staged, or rebooting).
format: int32
minimum: 0
type: integer
type: object
required:
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/bootc-dev/bootc-operator

go 1.26.0
go 1.26.10

require (
github.com/aws/aws-sdk-go-v2 v1.47.3
Expand Down Expand Up @@ -95,7 +95,7 @@ require (
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/net v0.60.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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ 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.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
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=
Expand Down
6 changes: 5 additions & 1 deletion internal/bootc/executor.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0

// Package bootc runs bootc commands on the host and parses their output.
package bootc

import (
Expand Down Expand Up @@ -60,7 +61,10 @@ func (e *HostExecutor) nsenterCmd(ctx context.Context, args ...string) *exec.Cmd
"--setuid", "0", "--setgid", "0",
"--env", "--",
}
return exec.CommandContext(ctx, "nsenter", append(base, args...)...)
// Callers in this package pass fixed program names; the only external
// input is a validated, digested image reference. Arguments are not
// passed through a shell.
return exec.CommandContext(ctx, "nsenter", append(base, args...)...) //nolint:gosec // G204
}

func (e *HostExecutor) Status(ctx context.Context) ([]byte, error) {
Expand Down
2 changes: 2 additions & 0 deletions internal/controller/bootcnodepool_controller.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: Apache-2.0

// Package controller implements the BootcNodePool controller, which
// resolves a pool's target image and rolls it out across member nodes.
package controller

import (
Expand Down
10 changes: 7 additions & 3 deletions internal/controller/rollout.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,8 @@ func (r *BootcNodePoolReconciler) ensureDrain(

log.Info("Starting drain", "node", bn.Name)

drainCtx, cancel := context.WithCancel(ctx)
// cancel is stored in r.drains and called when the drain is collected.
drainCtx, cancel := context.WithCancel(ctx) //nolint:gosec // G118
status := &drainStatus{
result: make(chan error, 1),
ctx: drainCtx,
Expand Down Expand Up @@ -371,11 +372,14 @@ func (r *BootcNodePoolReconciler) collectDrainResults(
continue
}

// Drain finished; remove from the map.
// Drain finished; remove from the map and release its context,
// which would otherwise stay registered with the parent.
delete(r.drains, nodeName)
cancelled := ds.ctx.Err() != nil
ds.cancel()

if drainErr != nil {
if ds.ctx.Err() != nil {
if cancelled {
// Context was cancelled (node left pool or controller
// shutdown). The canceller is responsible for cleanup.
log.V(1).Info("Drain cancelled", "node", nodeName)
Expand Down
22 changes: 22 additions & 0 deletions internal/controller/rollout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
package controller

import (
"context"
"errors"
"testing"
"time"

Expand Down Expand Up @@ -522,3 +524,23 @@ func TestFindUnhealthySlots(t *testing.T) {
g.Expect(findUnhealthySlots(rs, targetDigest)).To(BeEmpty())
})
}

// Verifies that collecting a finished drain cancels its context, so it
// does not stay registered with the long-lived parent context.
func TestCollectDrainResultsReleasesContext(t *testing.T) {
g := NewWithT(t)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
drainCtx, drainCancel := context.WithCancel(ctx)
defer drainCancel()
ds := &drainStatus{result: make(chan error, 1), ctx: drainCtx, cancel: drainCancel}
ds.result <- errors.New("drain failed")

r := &BootcNodePoolReconciler{drains: map[string]*drainStatus{"n1": ds}}
pool := testutil.NewPool("drain-release", testImageDigestRefA, testutil.WithWorkerSelector())

g.Expect(r.collectDrainResults(ctx, pool, nil)).To(Succeed())
g.Expect(r.drains).To(BeEmpty())
g.Expect(drainCtx.Err()).To(MatchError(context.Canceled))
}
21 changes: 17 additions & 4 deletions internal/controller/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package controller

import (
"fmt"
"math"
"strings"

apimeta "k8s.io/apimachinery/pkg/api/meta"
Expand All @@ -19,12 +20,12 @@ func syncPoolStatus(pool *bootcv1alpha1.BootcNodePool, rs *rolloutState) {
return
}
pool.Status.ObservedGeneration = pool.Generation
pool.Status.NodeCount = int32(rs.nodeCount())
pool.Status.UpdatedCount = int32(len(rs.upToDate))
pool.Status.UpdatingCount = int32(
pool.Status.NodeCount = clampCount(rs.nodeCount())
pool.Status.UpdatedCount = clampCount(len(rs.upToDate))
pool.Status.UpdatingCount = clampCount(
len(rs.pending) + len(rs.staging) + len(rs.staged) + len(rs.rebooting),
)
pool.Status.DegradedCount = int32(len(rs.degraded))
pool.Status.DegradedCount = clampCount(len(rs.degraded))

if pool.Status.NodeCount == pool.Status.UpdatedCount {
pool.Status.DeployedDigest = pool.Status.TargetDigest
Expand All @@ -34,6 +35,18 @@ func syncPoolStatus(pool *bootcv1alpha1.BootcNodePool, rs *rolloutState) {
syncUpToDateCondition(pool, rs)
}

// clampCount converts a count to int32 for the status API, saturating to
// [0, math.MaxInt32] to match the schema instead of wrapping.
func clampCount(n int) int32 {
if n < 0 {
return 0
}
if n > math.MaxInt32 {
return math.MaxInt32
}
return int32(n)
}

func syncUpToDateCondition(pool *bootcv1alpha1.BootcNodePool, rs *rolloutState) {
if pool == nil || rs == nil {
return
Expand Down
13 changes: 13 additions & 0 deletions internal/controller/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package controller

import (
"math"
"testing"

. "github.com/onsi/gomega"
Expand Down Expand Up @@ -170,3 +171,15 @@ func TestSyncPoolStatusDeployedDigestPreserved(t *testing.T) {
g.Expect(pool.Status.DeployedDigest).To(Equal(testDigestA))
g.Expect(pool.Status.UpdateAvailable).To(BeTrue())
}

// Verifies that clampCount passes in-range counts through and saturates
// instead of wrapping when a count does not fit in int32.
func TestClampCount(t *testing.T) {
g := NewWithT(t)

g.Expect(clampCount(0)).To(Equal(int32(0)))
g.Expect(clampCount(42)).To(Equal(int32(42)))
g.Expect(clampCount(math.MaxInt32)).To(Equal(int32(math.MaxInt32)))
g.Expect(clampCount(math.MaxInt32 + 1)).To(Equal(int32(math.MaxInt32)))
g.Expect(clampCount(-1)).To(Equal(int32(0)))
}
Loading
Loading