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 dc16bc2..956b2cd 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=${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/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..a32e8ce --- /dev/null +++ b/internal/httpx/useragent.go @@ -0,0 +1,55 @@ +/* +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" + +// 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. +// Format follows RFC 7231: product/version (comment). +var UserAgent = "decofile-operator/" + version + " (+https://github.com/decocms/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..b8fbf2b --- /dev/null +++ b/internal/httpx/useragent_test.go @@ -0,0 +1,98 @@ +/* +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" + "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) { + 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) + } +}