From 521215d4dfe8b9cc23860b677caf055f697ac9ce Mon Sep 17 00:00:00 2001 From: igoramf Date: Tue, 9 Jun 2026 16:24:26 -0300 Subject: [PATCH 1/3] feat(httpx): inject deco-operator User-Agent on outbound HTTP Wraps the transport for the notifier, GitHub downloader, and DecoRedirect DNS probe with a shared RoundTripper so every request the operator makes identifies itself as deco-operator instead of Go's default User-Agent. Co-Authored-By: Claude Opus 4.7 --- .../controller/decoredirect_controller.go | 2 + internal/controller/notifier.go | 4 +- internal/github/downloader.go | 6 +- internal/httpx/useragent.go | 47 ++++++++++ internal/httpx/useragent_test.go | 90 +++++++++++++++++++ 5 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 internal/httpx/useragent.go create mode 100644 internal/httpx/useragent_test.go diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index 16a4482..e4759a4 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -24,6 +24,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + "github.com/deco-sites/decofile-operator/internal/httpx" ) // DecoRedirectReconciler reconciles a DecoRedirect object. @@ -265,6 +266,7 @@ func (r *DecoRedirectReconciler) isDNSReady(ctx context.Context, domain string) httpClient := &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Timeout: 5 * time.Second, + Transport: httpx.WithUserAgent(nil), } req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+domain+"/", nil) if err != nil { diff --git a/internal/controller/notifier.go b/internal/controller/notifier.go index 4fab2a8..52ef180 100644 --- a/internal/controller/notifier.go +++ b/internal/controller/notifier.go @@ -28,6 +28,8 @@ import ( corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/deco-sites/decofile-operator/internal/httpx" ) const ( @@ -57,7 +59,7 @@ func NewHTTPClient() *http.Client { } return &http.Client{ Timeout: reloadTimeout, - Transport: transport, + Transport: httpx.WithUserAgent(transport), } } diff --git a/internal/github/downloader.go b/internal/github/downloader.go index b7cddad..d39895d 100644 --- a/internal/github/downloader.go +++ b/internal/github/downloader.go @@ -25,6 +25,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/deco-sites/decofile-operator/internal/httpx" ) const ( @@ -45,11 +47,11 @@ func BuildZipURL(org, repo, commit string) string { // httpClient is a shared HTTP client with timeout for GitHub downloads var httpClient = &http.Client{ Timeout: downloadTimeout, - Transport: &http.Transport{ + Transport: httpx.WithUserAgent(&http.Transport{ MaxIdleConns: 10, MaxIdleConnsPerHost: 5, IdleConnTimeout: 90 * time.Second, - }, + }), } // DownloadAndExtract downloads ZIP from GitHub and extracts files from specified path diff --git a/internal/httpx/useragent.go b/internal/httpx/useragent.go new file mode 100644 index 0000000..3f2acde --- /dev/null +++ b/internal/httpx/useragent.go @@ -0,0 +1,47 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package httpx + +import "net/http" + +// UserAgent is sent on every outbound HTTP request made by the operator. +const UserAgent = "deco-operator" + +type userAgentTransport struct { + base http.RoundTripper +} + +// RoundTrip injects UserAgent into the request when the caller did not set +// one. The request is cloned to honor the http.RoundTripper contract that +// implementations must not modify the original request. +func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Header.Get("User-Agent") != "" { + return t.base.RoundTrip(req) + } + r := req.Clone(req.Context()) + r.Header.Set("User-Agent", UserAgent) + return t.base.RoundTrip(r) +} + +// WithUserAgent wraps base so every request carries the operator's User-Agent. +// When base is nil, http.DefaultTransport is used. +func WithUserAgent(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return &userAgentTransport{base: base} +} diff --git a/internal/httpx/useragent_test.go b/internal/httpx/useragent_test.go new file mode 100644 index 0000000..576ece1 --- /dev/null +++ b/internal/httpx/useragent_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package httpx + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWithUserAgent_InjectsDefault(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("User-Agent") + })) + defer srv.Close() + + client := &http.Client{Transport: WithUserAgent(nil)} + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("request failed: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + if got != UserAgent { + t.Fatalf("expected User-Agent %q, got %q", UserAgent, got) + } +} + +func TestWithUserAgent_PreservesExisting(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("User-Agent") + })) + defer srv.Close() + + client := &http.Client{Transport: WithUserAgent(nil)} + req, err := http.NewRequest(http.MethodGet, srv.URL, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("User-Agent", "caller-set/9.9") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + if got != "caller-set/9.9" { + t.Fatalf("expected caller-set User-Agent preserved, got %q", got) + } +} + +func TestWithUserAgent_DoesNotMutateOriginalRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer srv.Close() + + client := &http.Client{Transport: WithUserAgent(nil)} + req, err := http.NewRequest(http.MethodGet, srv.URL, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + if got := req.Header.Get("User-Agent"); got != "" { + t.Fatalf("original request mutated: User-Agent=%q", got) + } +} From 3f99d28fc4c9573cffacba3351e18851a11cafdc Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 10 Jun 2026 10:59:03 -0300 Subject: [PATCH 2/3] feat(httpx): use RFC 7231 User-Agent with ldflag-injected version Adopts the standard product/version (comment) format used by terraform, curl, and kubectl. The version slot is overridden at build time via -ldflags "-X .../httpx.version=v$VERSION" and forwarded through the Dockerfile via a VERSION build arg. Local builds fall back to "dev". Example: decofile-operator/v0.4.0-beta.3 (+https://github.com/decocms/operator) Co-Authored-By: Claude Opus 4.7 --- Dockerfile | 3 ++- Makefile | 6 +++--- internal/httpx/useragent.go | 10 +++++++++- internal/httpx/useragent_test.go | 8 ++++++++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index dc16bc2..4aa4c0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM --platform=$BUILDPLATFORM golang:1.24 AS builder ARG TARGETOS ARG TARGETARCH +ARG VERSION=dev WORKDIR /workspace # Copy the Go Modules manifests @@ -21,7 +22,7 @@ COPY internal/ internal/ # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager ./cmd +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -ldflags "-X github.com/deco-sites/decofile-operator/internal/httpx.version=v${VERSION}" -o manager ./cmd # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details diff --git a/Makefile b/Makefile index 73e3104..e12b171 100644 --- a/Makefile +++ b/Makefile @@ -202,7 +202,7 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration .PHONY: build build: manifests generate fmt vet ## Build manager binary. - go build -o bin/manager ./cmd + go build -ldflags "-X github.com/deco-sites/decofile-operator/internal/httpx.version=v$(VERSION)" -o bin/manager ./cmd .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. @@ -213,7 +213,7 @@ run: manifests generate fmt vet ## Run a controller from your host. # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: ## Build docker image with the manager. - $(CONTAINER_TOOL) build -t ${IMG} . + $(CONTAINER_TOOL) build --build-arg VERSION=$(VERSION) -t ${IMG} . .PHONY: docker-push docker-push: ## Push docker image with the manager. @@ -232,7 +232,7 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - $(CONTAINER_TOOL) buildx create --name operator-builder $(CONTAINER_TOOL) buildx use operator-builder - - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + - $(CONTAINER_TOOL) buildx build --push --build-arg VERSION=$(VERSION) --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . - $(CONTAINER_TOOL) buildx rm operator-builder rm Dockerfile.cross diff --git a/internal/httpx/useragent.go b/internal/httpx/useragent.go index 3f2acde..a32e8ce 100644 --- a/internal/httpx/useragent.go +++ b/internal/httpx/useragent.go @@ -18,8 +18,16 @@ package httpx import "net/http" +// version is overridden at build time via +// +// -ldflags "-X github.com/deco-sites/decofile-operator/internal/httpx.version=v0.4.0" +// +// In local/dev builds it remains "dev". +var version = "dev" + // UserAgent is sent on every outbound HTTP request made by the operator. -const UserAgent = "deco-operator" +// Format follows RFC 7231: product/version (comment). +var UserAgent = "decofile-operator/" + version + " (+https://github.com/decocms/operator)" type userAgentTransport struct { base http.RoundTripper diff --git a/internal/httpx/useragent_test.go b/internal/httpx/useragent_test.go index 576ece1..b8fbf2b 100644 --- a/internal/httpx/useragent_test.go +++ b/internal/httpx/useragent_test.go @@ -20,9 +20,17 @@ import ( "io" "net/http" "net/http/httptest" + "regexp" "testing" ) +func TestUserAgentFormat(t *testing.T) { + re := regexp.MustCompile(`^decofile-operator/\S+ \(\+https://github\.com/decocms/operator\)$`) + if !re.MatchString(UserAgent) { + t.Fatalf("UserAgent %q does not match expected RFC 7231 format", UserAgent) + } +} + func TestWithUserAgent_InjectsDefault(t *testing.T) { var got string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From c43080a0f12e4ba3eeb98c6333b4e1ef8239a779 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 10 Jun 2026 11:31:33 -0300 Subject: [PATCH 3/3] ci(httpx): source User-Agent version from git tag, drop Makefile detour Lets the release workflow forward github.ref_name (e.g. v0.4.0-beta.3) into the Dockerfile VERSION build arg, which ldflags into the binary. Removes the Makefile/local build-arg plumbing that diverged from the single CI path; local builds keep the "dev" default. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build-and-push.yaml | 2 ++ Dockerfile | 2 +- Makefile | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-and-push.yaml b/.github/workflows/build-and-push.yaml index 666d350..78b1eae 100644 --- a/.github/workflows/build-and-push.yaml +++ b/.github/workflows/build-and-push.yaml @@ -88,6 +88,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 4aa4c0d..956b2cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ COPY internal/ internal/ # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -ldflags "-X github.com/deco-sites/decofile-operator/internal/httpx.version=v${VERSION}" -o manager ./cmd +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -ldflags "-X github.com/deco-sites/decofile-operator/internal/httpx.version=${VERSION}" -o manager ./cmd # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details diff --git a/Makefile b/Makefile index e12b171..73e3104 100644 --- a/Makefile +++ b/Makefile @@ -202,7 +202,7 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration .PHONY: build build: manifests generate fmt vet ## Build manager binary. - go build -ldflags "-X github.com/deco-sites/decofile-operator/internal/httpx.version=v$(VERSION)" -o bin/manager ./cmd + go build -o bin/manager ./cmd .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. @@ -213,7 +213,7 @@ run: manifests generate fmt vet ## Run a controller from your host. # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: ## Build docker image with the manager. - $(CONTAINER_TOOL) build --build-arg VERSION=$(VERSION) -t ${IMG} . + $(CONTAINER_TOOL) build -t ${IMG} . .PHONY: docker-push docker-push: ## Push docker image with the manager. @@ -232,7 +232,7 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - $(CONTAINER_TOOL) buildx create --name operator-builder $(CONTAINER_TOOL) buildx use operator-builder - - $(CONTAINER_TOOL) buildx build --push --build-arg VERSION=$(VERSION) --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . - $(CONTAINER_TOOL) buildx rm operator-builder rm Dockerfile.cross