From b240e321c4b73742a2f26adc3803d490a7223622 Mon Sep 17 00:00:00 2001 From: reoring Date: Sun, 6 Sep 2026 18:03:03 +0900 Subject: [PATCH] fix(build): verify controller binaries match target architecture --- .dockerignore | 4 ++ .github/workflows/controller-ci.yml | 11 +++- Dockerfile | 10 ++-- docs/design/development-environment.md | 13 +++- .../verify-controller-architecture/main.go | 51 ++++++++++++++++ .../main_test.go | 59 +++++++++++++++++++ 6 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 scripts/verify-controller-architecture/main.go create mode 100644 scripts/verify-controller-architecture/main_test.go diff --git a/.dockerignore b/.dockerignore index c578346..b94db9a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,7 @@ !pkg/** !internal/ !internal/** + +!scripts/ +!scripts/verify-controller-architecture/ +!scripts/verify-controller-architecture/** diff --git a/.github/workflows/controller-ci.yml b/.github/workflows/controller-ci.yml index 64a0c7f..9fe7e07 100644 --- a/.github/workflows/controller-ci.yml +++ b/.github/workflows/controller-ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 927272f..e36384b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docs/design/development-environment.md b/docs/design/development-environment.md index 712137f..c2d12f3 100644 --- a/docs/design/development-environment.md +++ b/docs/design/development-environment.md @@ -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. @@ -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. @@ -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. diff --git a/scripts/verify-controller-architecture/main.go b/scripts/verify-controller-architecture/main.go new file mode 100644 index 0000000..45d8709 --- /dev/null +++ b/scripts/verify-controller-architecture/main.go @@ -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 ") + 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 +} diff --git a/scripts/verify-controller-architecture/main_test.go b/scripts/verify-controller-architecture/main_test.go new file mode 100644 index 0000000..b9da93c --- /dev/null +++ b/scripts/verify-controller-architecture/main_test.go @@ -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 +}