diff --git a/.github/workflows/agentgateway-image.yaml b/.github/workflows/agentgateway-image.yaml new file mode 100644 index 000000000..885bf181c --- /dev/null +++ b/.github/workflows/agentgateway-image.yaml @@ -0,0 +1,141 @@ +# Copyright 2026 Google LLC +# +# 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. + +name: Build AgentGateway image + +on: + workflow_dispatch: + inputs: + repository: + description: AgentGateway repository + required: true + default: agentgateway/agentgateway + sha: + description: AgentGateway commit SHA + required: true + +permissions: + contents: read + packages: write + +jobs: + source: + runs-on: ubuntu-24.04 + outputs: + sha: ${{ steps.source.outputs.sha }} + short_sha: ${{ steps.source.outputs.short_sha }} + steps: + - name: Checkout AgentGateway + uses: actions/checkout@v4 + with: + repository: ${{ inputs.repository }} + ref: ${{ inputs.sha }} + persist-credentials: false + + - name: Resolve source revision + id: source + run: | + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "short_sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT" + + build: + needs: source + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + artifact: linux-amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + artifact: linux-arm64 + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout AgentGateway + uses: actions/checkout@v4 + with: + repository: ${{ inputs.repository }} + ref: ${{ needs.source.outputs.sha }} + persist-credentials: false + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + tags: ghcr.io/${{ github.repository }}/agentgateway + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + build-args: | + VERSION=0.0.0-alpha.${{ needs.source.outputs.short_sha }} + GIT_REVISION=${{ needs.source.outputs.sha }} + + - name: Export digest + run: | + mkdir -p "$RUNNER_TEMP/digests" + digest='${{ steps.build.outputs.digest }}' + touch "$RUNNER_TEMP/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.artifact }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + push: + needs: + - source + - build + runs-on: ubuntu-24.04 + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digest-linux-* + merge-multiple: true + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Push multi-architecture image + working-directory: ${{ runner.temp }}/digests + env: + IMAGE: ghcr.io/${{ github.repository }}/agentgateway:${{ needs.source.outputs.short_sha }} + run: | + docker buildx imagetools create \ + --tag "$IMAGE" \ + $(printf 'ghcr.io/${{ github.repository }}/agentgateway@sha256:%s ' *) + digest=$(docker buildx imagetools inspect "$IMAGE" --format '{{json .}}' | jq -r '.manifest.digest') + echo "$IMAGE@$digest" >> "$GITHUB_STEP_SUMMARY" diff --git a/cmd/atenet/internal/router/ingress/ingress.go b/cmd/atenet/internal/router/ingress/ingress.go index 09a7ebb95..6c3e6d714 100644 --- a/cmd/atenet/internal/router/ingress/ingress.go +++ b/cmd/atenet/internal/router/ingress/ingress.go @@ -47,15 +47,20 @@ import ( // defaultActorPort is the actor's port when a request names no other one. const defaultActorPort = 80 +const connectProxyPort = "444" + const ( // OriginalDstMetadataKey is the dynamic-metadata namespace carrying the - // resolved worker address and port. xds.go's ORIGINAL_DST cluster reads - // it to pick the upstream. + // resolved Envoy and AgentGateway routing targets. OriginalDstMetadataKey = "envoy.filters.listener.original_dst" // OriginalDstAddressKey is the resolved worker atunnel address (IP:443). OriginalDstAddressKey = "local" // OriginalDstPortKey is the actor's target port. OriginalDstPortKey = "port" + // ConnectProxyAddressKey is the worker atunnel CONNECT endpoint. + ConnectProxyAddressKey = "connect_proxy" + // ConnectDestinationAddressKey is the actor authority reached through the CONNECT proxy. + ConnectDestinationAddressKey = "connect_destination" // AuthorityFilterStateKey is the filter-state key holding the request's // :authority, set by xds.go's authorityFilterStateFilter. @@ -156,15 +161,19 @@ func (h *Handler) HandleRequestHeaders(ctx context.Context, md *extproc.RequestM // targetPort on the actor; the router's client cert comes from the // ORIGINAL_DST cluster's upstream TLS context (xds.go). targetAddr := net.JoinHostPort(workerIP, "443") + connectProxyAddr := net.JoinHostPort(workerIP, connectProxyPort) + connectDestinationAddr := net.JoinHostPort(resources.ActorDNSName(actorRef), strconv.Itoa(targetPort)) slog.InfoContext(ctx, "Route ok", slog.Any("actor", actorRef), slog.String("targetAddr", targetAddr)) - // Envoy and agentgateway both pick the upstream from dynamic metadata, - // so the resolved address and port go there. + // Envoy reads local and port; AgentGateway reads the CONNECT proxy and + // destination targets. dynamicMetadata, err := structpb.NewStruct(map[string]any{ OriginalDstMetadataKey: map[string]any{ - OriginalDstAddressKey: targetAddr, - OriginalDstPortKey: strconv.Itoa(targetPort), + OriginalDstAddressKey: targetAddr, + OriginalDstPortKey: strconv.Itoa(targetPort), + ConnectProxyAddressKey: connectProxyAddr, + ConnectDestinationAddressKey: connectDestinationAddr, }, }) if err != nil { diff --git a/cmd/atenet/internal/router/ingress/ingress_test.go b/cmd/atenet/internal/router/ingress/ingress_test.go index 6b00a2548..63a9ee92c 100644 --- a/cmd/atenet/internal/router/ingress/ingress_test.go +++ b/cmd/atenet/internal/router/ingress/ingress_test.go @@ -85,6 +85,10 @@ func dynamicMetadataPort(dynamicMetadata *structpb.Struct) string { return dynamicMetadata.GetFields()[OriginalDstMetadataKey].GetStructValue().GetFields()[OriginalDstPortKey].GetStringValue() } +func dynamicMetadataAddress(dynamicMetadata *structpb.Struct, key string) string { + return dynamicMetadata.GetFields()[OriginalDstMetadataKey].GetStructValue().GetFields()[key].GetStringValue() +} + func TestHandleRequestHeadersDoesNotLogSensitiveData(t *testing.T) { const testUUID = "123e4567-e89b-12d3-a456-426614174000" const secret = "do-not-log-me" @@ -292,6 +296,12 @@ func TestHandleRequestHeaders(t *testing.T) { if got := dynamicMetadataPort(res.DynamicMetadata); got != tc.expectedTargetPort { t.Errorf("dynamic metadata port = %q, want %q", got, tc.expectedTargetPort) } + if got, want := dynamicMetadataAddress(res.DynamicMetadata, ConnectProxyAddressKey), "10.0.0.52:444"; got != want { + t.Errorf("CONNECT proxy = %q, want %q", got, want) + } + if got, want := dynamicMetadataAddress(res.DynamicMetadata, ConnectDestinationAddressKey), tc.authority+":"+tc.expectedTargetPort; got != want { + t.Errorf("CONNECT destination = %q, want %q", got, want) + } }) } } @@ -336,6 +346,12 @@ func TestHandleRequestHeadersHandlesConnectMethod(t *testing.T) { if got := dynamicMetadataPort(res.DynamicMetadata); got != "9090" { t.Errorf("dynamic metadata port = %q, want %q", got, "9090") } + if got, want := dynamicMetadataAddress(res.DynamicMetadata, ConnectProxyAddressKey), "10.0.0.52:444"; got != want { + t.Errorf("CONNECT proxy = %q, want %q", got, want) + } + if got := dynamicMetadataAddress(res.DynamicMetadata, ConnectDestinationAddressKey); got != authority { + t.Errorf("CONNECT destination = %q, want %q", got, authority) + } } // TestHandleRequestHeaders_ParkingLotFull verifies that when the parking lot is at capacity diff --git a/manifests/ate-install/components/agentgateway/configmap.yaml b/manifests/ate-install/components/agentgateway/configmap.yaml index 157f2c33b..1ec4614e5 100644 --- a/manifests/ate-install/components/agentgateway/configmap.yaml +++ b/manifests/ate-install/components/agentgateway/configmap.yaml @@ -34,6 +34,17 @@ data: # OTEL_TRACES_SAMPLER override must adjust it in step. randomSampling: 0.01 + backends: + - name: worker-connect-proxy + dynamic: + target: extproc["envoy.filters.listener.original_dst"]["connect_proxy"] + policies: + backendTLS: + cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem + key: /run/podidentity.podcert.ate.dev/credential-bundle.pem + root: /run/podidentity.podcert.ate.dev/trust-bundle.pem + insecureHost: true + # http/https serve direct (non-CONNECT) actor traffic only. connect.mode is # a single global setting with no per-gateway override, and Tunnel mode's # CONNECT interception happens below the per-request pipeline (at raw @@ -80,23 +91,12 @@ data: "filter_state['dev.ate.authority']": request.host backends: - dynamic: - # The router reports the resolved worker atunnel address (host:443) - # as dynamic metadata rather than rewriting :authority -- see - # OriginalDstMetadataKey/OriginalDstAddressKey in xds.go. Reading it - # here means :authority/Host stays the actor's real DNS name the - # whole way through, so atunnel authorizes it directly with no - # restore-the-original-Host header needed. - target: extproc["envoy.filters.listener.original_dst"]["local"] + target: extproc["envoy.filters.listener.original_dst"]["connect_destination"] policies: - # atunnel serves HTTPS on each worker pod IP. Verify its certificate - # against the podidentity CA and present the router's podidentity - # credential. Worker SPIFFE IDs vary with their workload namespace, - # so skip DNS/IP hostname matching while retaining CA verification. - backendTLS: - cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem - key: /run/podidentity.podcert.ate.dev/credential-bundle.pem - root: /run/podidentity.podcert.ate.dev/trust-bundle.pem - insecureHost: true + backendTunnel: + proxy: + backend: /worker-connect-proxy + mode: connect # CONNECT ingress, on its own dedicated ports (matching Envoy's # connect/connect-tls listeners) because tunnelProtocol only applies to a @@ -160,14 +160,12 @@ data: "filter_state['dev.ate.authority']": source.connectHeaders["host"] backends: - dynamic: - # See substrate-actors' backend above. - target: extproc["envoy.filters.listener.original_dst"]["local"] + target: extproc["envoy.filters.listener.original_dst"]["connect_destination"] policies: - backendTLS: - cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem - key: /run/podidentity.podcert.ate.dev/credential-bundle.pem - root: /run/podidentity.podcert.ate.dev/trust-bundle.pem - insecureHost: true + backendTunnel: + proxy: + backend: /worker-connect-proxy + mode: connect --- apiVersion: v1 kind: ConfigMap