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
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@
!pkg/**
!internal/
!internal/**

!scripts/
!scripts/verify-controller-architecture/
!scripts/verify-controller-architecture/**
11 changes: 9 additions & 2 deletions .github/workflows/controller-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,12 @@ jobs:
run: git diff --exit-code -- deploy/charts/dns-api/crds
- name: Check Helm chart
run: devbox run -- task controller-chart:check
- name: Build controller image
run: docker build -t dns-api-controller:ci .
- uses: docker/setup-buildx-action@v3
- name: Build and verify both controller architectures
uses: docker/build-push-action@v6
with:
context: .
push: false
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
10 changes: 6 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ RUN go mod download

COPY . .

ARG BUILDPLATFORM
ARG TARGETOS=linux
ARG TARGETARCH=amd64
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /manager ./app/operator/cmd/manager
# Inherit BuildKit's target instead of overriding ARM64 with an AMD64 default.
ARG TARGETOS
ARG TARGETARCH
ARG TARGETPLATFORM
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /manager ./app/operator/cmd/manager \
&& go run ./scripts/verify-controller-architecture /manager "${TARGETPLATFORM}"

FROM gcr.io/distroless/static:nonroot

Expand Down
13 changes: 12 additions & 1 deletion docs/design/development-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ The development environment is provided by devbox. Go, kind, kubectl, kustomize,

The Go version must match across `go.mod`, devbox, and the controller image build. The initial implementation uses Go `1.26.2`. The `go` directive in `go.mod` is `1.26.2`, the Go package in `devbox.json` is `go@1.26.2`, and the Dockerfile builder image is `golang:1.26.2`. The Go version in the Dockerfile must not be older than the version required by `go.mod`. If adding or updating a Go tool dependency such as `local-irsa` raises the required Go version, update `go.mod`, `devbox.json`, and `Dockerfile` in the same change.

Controller images support `linux/amd64` and `linux/arm64`. The builder runs on
`BUILDPLATFORM` and inherits BuildKit's `TARGETOS` and `TARGETARCH` without
defaults. Do not set `TARGETARCH=amd64`: it overrides an ARM64 target while the
final image can still be labeled ARM64.

Every image build runs `scripts/verify-controller-architecture` on `/manager`
before copying it into the runtime stage. The guard compares the ELF machine,
class, and byte order against the independent `TARGETPLATFORM`, not the compiler's
`GOARCH` setting. A mislabeled executable must fail the build before publication.

Repository development commands are centralized in Taskfile. Do not use a Makefile. The development environment entry point is `task up`. The repository does not provide a development mode such as `task run` that runs the controller as a host-side process.

Kubernetes controller implementation uses controller-runtime. API types, webhooks, reconcilers, and manager setup follow controller-runtime conventions.
Expand All @@ -32,6 +42,7 @@ The Docker build context and Tilt watch scope for the `dns-api-controller` image
!app/operator/cmd/**
!pkg/go/api/**
!internal/**
!scripts/verify-controller-architecture/**
```

`docs/design/`, `docs/manual/`, `docs/design-feedback/`, UI packages, the Headlamp plugin package, package-manager artifacts, local state, test output, `.git/`, `.devbox/`, `.task/`, and `tmp/` are not inputs to the controller image. Changes only in these paths must not rebuild the `dns-api-controller` image or redeploy the `dns-api-controller-manager` Deployment.
Expand Down Expand Up @@ -261,7 +272,7 @@ The always-on controller check workflow runs on pull requests and pushes to `mai
3. Run `task test`.
4. Run `go vet ./...`.
5. Build the default Kubernetes manifests with `kustomize build app/operator/config/default`.
6. Build the controller image with `docker build`.
6. Build both controller platforms with Buildx, including the executable architecture guard in each build.

The always-on controller check workflow covers code generation, Go unit tests, controller-runtime fake-client or envtest-style tests, webhook validation tests, manifest rendering, and controller image buildability. It does not create hosted zones and does not call AWS APIs.

Expand Down
51 changes: 51 additions & 0 deletions scripts/verify-controller-architecture/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Command verify-controller-architecture checks the executable, not the OCI
// platform label, before the controller can enter a published runtime image.
package main

import (
"debug/elf"
"fmt"
"os"
)

func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: verify-controller-architecture <binary> <target-platform>")
os.Exit(2)
}
if err := verify(os.Args[1], os.Args[2]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("verified controller executable for %s\n", os.Args[2])
}

func verify(path, target string) (err error) {
var machine elf.Machine
switch target {
case "linux/amd64":
machine = elf.EM_X86_64
case "linux/arm64":
machine = elf.EM_AARCH64
default:
return fmt.Errorf("unsupported controller target platform %q", target)
}

file, err := elf.Open(path)
if err != nil {
return fmt.Errorf("read controller ELF: %w", err)
}
defer func() {
if closeErr := file.Close(); closeErr != nil && err == nil {
err = fmt.Errorf("close controller ELF: %w", closeErr)
}
}()

// TARGETPLATFORM is independent of the GOARCH build argument: checking
// against GOARCH would accept the original accidental AMD64 override.
if file.Class != elf.ELFCLASS64 || file.Data != elf.ELFDATA2LSB || file.Machine != machine {
return fmt.Errorf("controller architecture mismatch for %s: got %s/%s/%s, want ELFCLASS64/ELFDATA2LSB/%s",
target, file.Class, file.Data, file.Machine, machine)
}
return nil
}
59 changes: 59 additions & 0 deletions scripts/verify-controller-architecture/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package main

import (
"debug/elf"
"encoding/binary"
"os"
"path/filepath"
"testing"
)

func TestVerifyControllerArchitecture(t *testing.T) {
amd64 := writeELFHeader(t, elf.EM_X86_64)
arm64 := writeELFHeader(t, elf.EM_AARCH64)
for _, tc := range []struct {
name, path, target string
wantErr bool
}{
{name: "amd64 executable", path: amd64, target: "linux/amd64"},
{name: "arm64 executable", path: arm64, target: "linux/arm64"},
{name: "amd64 mislabeled as arm64", path: amd64, target: "linux/arm64", wantErr: true},
{name: "arm64 mislabeled as amd64", path: arm64, target: "linux/amd64", wantErr: true},
{name: "unsupported target", path: amd64, target: "darwin/amd64", wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
err := verify(tc.path, tc.target)
if (err != nil) != tc.wantErr {
t.Fatalf("verify(%s, %s) = %v, want error %v", tc.path, tc.target, err, tc.wantErr)
}
})
}
}

func TestVerifyRejectsNonELFController(t *testing.T) {
path := filepath.Join(t.TempDir(), "manager")
if err := os.WriteFile(path, []byte("not an executable"), 0600); err != nil {
t.Fatal(err)
}
if err := verify(path, "linux/amd64"); err == nil {
t.Fatal("non-ELF controller was accepted")
}
}

func writeELFHeader(t *testing.T, machine elf.Machine) string {
t.Helper()
var header [64]byte
copy(header[:], elf.ELFMAG)
header[elf.EI_CLASS] = byte(elf.ELFCLASS64)
header[elf.EI_DATA] = byte(elf.ELFDATA2LSB)
header[elf.EI_VERSION] = byte(elf.EV_CURRENT)
binary.LittleEndian.PutUint16(header[16:18], uint16(elf.ET_EXEC))
binary.LittleEndian.PutUint16(header[18:20], uint16(machine))
binary.LittleEndian.PutUint32(header[20:24], uint32(elf.EV_CURRENT))
binary.LittleEndian.PutUint16(header[52:54], uint16(len(header)))
path := filepath.Join(t.TempDir(), "manager")
if err := os.WriteFile(path, header[:], 0600); err != nil {
t.Fatal(err)
}
return path
}
Loading