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
141 changes: 141 additions & 0 deletions .github/workflows/agentgateway-image.yaml
Original file line number Diff line number Diff line change
@@ -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"
21 changes: 15 additions & 6 deletions cmd/atenet/internal/router/ingress/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions cmd/atenet/internal/router/ingress/ingress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
})
}
}
Expand Down Expand Up @@ -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
Expand Down
44 changes: 21 additions & 23 deletions manifests/ate-install/components/agentgateway/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading