From 452671968d07eb410217248b79f0408a49a98d99 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Tue, 4 Aug 2026 16:50:45 +0900 Subject: [PATCH 1/3] minimal impl --- examples/xds-example/build.gradle | 21 + examples/xds-example/docker-compose.yml | 33 ++ .../grafana/dashboards/xds-traffic.json | 303 +++++++++++++ .../provisioning/dashboards/provider.yml | 10 + .../provisioning/datasources/datasource.yml | 8 + examples/xds-example/prometheus.yml | 8 + .../example/armeria/xds/DashboardService.java | 196 ++++++++ .../example/armeria/xds/Layer2Server.java | 100 ++++ .../example/armeria/xds/Layer3Server.java | 69 +++ .../main/java/example/armeria/xds/Main.java | 49 ++ .../example/armeria/xds/ProfileState.java | 40 ++ .../armeria/xds/RouteMetricsClient.java | 43 ++ .../java/example/armeria/xds/ServiceMesh.java | 198 ++++++++ .../armeria/xds/XdsTemplateReader.java | 196 ++++++++ .../xds-example/src/main/resources/index.html | 429 ++++++++++++++++++ .../basic/layer1-client-cluster.template.yaml | 16 + .../layer1-client-listener.template.yaml | 55 +++ .../xds/basic/layer1-endpoints.template.yaml | 104 +++++ .../basic/layer2-client-cluster.template.yaml | 16 + .../layer2-client-listener.template.yaml | 55 +++ .../xds/basic/layer2-endpoints.template.yaml | 104 +++++ .../layer2-server-listener.template.yaml | 18 + .../layer3-server-listener.template.yaml | 18 + .../layer2-server-listener.template.yaml | 26 ++ .../layer3-server-listener.template.yaml | 26 ++ .../xds/layer1-bootstrap.template.yaml | 9 + .../xds/layer2-client-bootstrap.template.yaml | 22 + .../xds/layer2-server-bootstrap.template.yaml | 6 + .../xds/layer3-bootstrap.template.yaml | 6 + .../layer1-client-listener.template.yaml | 58 +++ .../layer2-client-listener.template.yaml | 58 +++ .../tls/layer1-client-cluster.template.yaml | 29 ++ .../tls/layer2-client-cluster.template.yaml | 29 ++ .../tls/layer2-server-listener.template.yaml | 32 ++ .../tls/layer3-server-listener.template.yaml | 32 ++ .../unhealthy/layer1-endpoints.template.yaml | 104 +++++ .../unhealthy/layer2-endpoints.template.yaml | 104 +++++ .../example/armeria/xds/ServiceMeshTest.java | 33 ++ .../linecorp/armeria/xds/it/RetryTest.java | 139 ++++++ settings.gradle | 1 + .../com/linecorp/armeria/xds/RouteStream.java | 15 +- 41 files changed, 2814 insertions(+), 4 deletions(-) create mode 100644 examples/xds-example/build.gradle create mode 100644 examples/xds-example/docker-compose.yml create mode 100644 examples/xds-example/grafana/dashboards/xds-traffic.json create mode 100644 examples/xds-example/grafana/provisioning/dashboards/provider.yml create mode 100644 examples/xds-example/grafana/provisioning/datasources/datasource.yml create mode 100644 examples/xds-example/prometheus.yml create mode 100644 examples/xds-example/src/main/java/example/armeria/xds/DashboardService.java create mode 100644 examples/xds-example/src/main/java/example/armeria/xds/Layer2Server.java create mode 100644 examples/xds-example/src/main/java/example/armeria/xds/Layer3Server.java create mode 100644 examples/xds-example/src/main/java/example/armeria/xds/Main.java create mode 100644 examples/xds-example/src/main/java/example/armeria/xds/ProfileState.java create mode 100644 examples/xds-example/src/main/java/example/armeria/xds/RouteMetricsClient.java create mode 100644 examples/xds-example/src/main/java/example/armeria/xds/ServiceMesh.java create mode 100644 examples/xds-example/src/main/java/example/armeria/xds/XdsTemplateReader.java create mode 100644 examples/xds-example/src/main/resources/index.html create mode 100644 examples/xds-example/src/main/resources/xds/basic/layer1-client-cluster.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/basic/layer1-client-listener.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/basic/layer1-endpoints.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/basic/layer2-client-cluster.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/basic/layer2-client-listener.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/basic/layer2-endpoints.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/basic/layer2-server-listener.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/basic/layer3-server-listener.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/fault/layer2-server-listener.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/fault/layer3-server-listener.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/layer1-bootstrap.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/layer2-client-bootstrap.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/layer2-server-bootstrap.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/layer3-bootstrap.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/retry/layer1-client-listener.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/retry/layer2-client-listener.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/tls/layer1-client-cluster.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/tls/layer2-client-cluster.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/tls/layer2-server-listener.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/tls/layer3-server-listener.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/unhealthy/layer1-endpoints.template.yaml create mode 100644 examples/xds-example/src/main/resources/xds/unhealthy/layer2-endpoints.template.yaml create mode 100644 examples/xds-example/src/test/java/example/armeria/xds/ServiceMeshTest.java diff --git a/examples/xds-example/build.gradle b/examples/xds-example/build.gradle new file mode 100644 index 00000000000..697a568d52a --- /dev/null +++ b/examples/xds-example/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'application' +} + +dependencies { + implementation project(':xds') + implementation project(':prometheus1') + implementation libs.jackson.databind + implementation libs.jackson.dataformat.yaml + implementation libs.micrometer.prometheus + implementation libs.protobuf.java.util + runtimeOnly libs.slf4j.simple + + testImplementation project(':junit5') + testImplementation libs.assertj + testImplementation libs.junit5.jupiter.api +} + +application { + mainClass.set('example.armeria.xds.Main') +} diff --git a/examples/xds-example/docker-compose.yml b/examples/xds-example/docker-compose.yml new file mode 100644 index 00000000000..9b98cd7008c --- /dev/null +++ b/examples/xds-example/docker-compose.yml @@ -0,0 +1,33 @@ +# Prometheus + Grafana for xDS metrics visualization. +# +# Usage: +# 1. Start the xDS example: ./gradlew :examples:xds-example:run +# 2. Start monitoring: docker compose up -d +# 3. Open Grafana at http://localhost:3000 (anonymous access enabled) +# 4. The "Armeria xDS Metrics" dashboard is auto-provisioned. + +services: + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + extra_hosts: + - "host.docker.internal:host-gateway" + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_DISABLE_LOGIN_FORM=true + - GF_DASHBOARDS_MIN_REFRESH_INTERVAL=1s + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/var/lib/grafana/dashboards + depends_on: + - prometheus diff --git a/examples/xds-example/grafana/dashboards/xds-traffic.json b/examples/xds-example/grafana/dashboards/xds-traffic.json new file mode 100644 index 00000000000..2b742790e0c --- /dev/null +++ b/examples/xds-example/grafana/dashboards/xds-traffic.json @@ -0,0 +1,303 @@ +{ + "title": "Armeria xDS Metrics", + "uid": "armeria-xds", + "editable": true, + "time": {"from": "now-5m", "to": "now"}, + "refresh": "1s", + "timepicker": {"refresh_intervals": ["1s", "2s", "5s", "10s", "30s"]}, + "templating": { + "list": [ + { + "name": "cluster", + "type": "query", + "query": "label_values(armeria_xds_lb_membership, cluster)", + "datasource": "Prometheus", + "refresh": 2, + "includeAll": true, + "multi": true, + "current": {"text": "All", "value": "$__all"} + } + ] + }, + "panels": [ + {"title": "Armeria", "type": "row", "gridPos": {"h": 1, "w": 24, "x": 0, "y": 0}, "collapsed": false}, + { + "title": "L1 Requests (by status)", + "description": "Per-attempt counts (includes retry attempts)", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 0, "y": 1}, + "interval": "1s", + "targets": [{"expr": "sum by (http_status)(rate(layer1_requests_total[3s]))", "legendFormat": "{{http_status}}"}], + "fieldConfig": {"defaults": {"unit": "reqps"}} + }, + { + "title": "L2 Requests (by status)", + "description": "Aggregate Layer 2 server requests from Layer 1", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 8, "y": 1}, + "interval": "1s", + "targets": [{"expr": "sum by (http_status)(rate(layer2_requests_total{side=\"server\"}[3s]))", "legendFormat": "{{http_status}}"}], + "fieldConfig": {"defaults": {"unit": "reqps"}} + }, + { + "title": "Retries", + "description": "Rate of retry attempts (actual requests minus logical requests)", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 16, "y": 1}, + "interval": "1s", + "targets": [ + {"expr": "sum(rate(layer1_actual_requests_attempts_sum[3s])) - sum(rate(layer1_actual_requests_attempts_count[3s]))", "legendFormat": "L1 retries"}, + { + "expr": "sum(rate(layer2_actual_requests_attempts_sum{side=\"client\"}[3s])) - sum(rate(layer2_actual_requests_attempts_count{side=\"client\"}[3s]))", + "legendFormat": "L2 retries" + } + ], + "fieldConfig": {"defaults": {"unit": "reqps"}} + }, + { + "title": "Route Success (L1 → L2 → L3)", + "description": "Successful request routing path (2xx)", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 0, "y": 7}, + "interval": "1s", + "targets": [ + {"expr": "sum by (layer2_node, layer3_node)(rate(layer1_route_total{http_status=\"200\"}[3s]))", "legendFormat": "{{layer2_node}} → {{layer3_node}}"} + ], + "fieldConfig": {"defaults": {"unit": "reqps"}} + }, + { + "title": "Route Failure (L1 → L2 → L3)", + "description": "Failed request routing path (non-2xx)", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 8, "y": 7}, + "interval": "1s", + "targets": [ + {"expr": "sum by (layer2_node, layer3_node, http_status)(rate(layer1_route_total{http_status!=\"200\"}[3s]))", "legendFormat": "{{layer2_node}} → {{layer3_node}} {{http_status}}"} + ], + "fieldConfig": {"defaults": {"unit": "reqps"}} + }, + { + "title": "Active Connections (by protocol)", + "description": "Client connection pool — h2 = TLS, h2c = cleartext. Cycles every 5s due to max connection age.", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 16, "y": 7}, + "interval": "1s", + "targets": [ + {"expr": "layer1_client_active_connections", "legendFormat": "L1 {{protocol}} → {{remote_ip}}"}, + {"expr": "layer2_client_active_connections", "legendFormat": "L2 {{protocol}} → {{remote_ip}}"} + ] + }, + { + "title": "Route Distribution (L2)", + "description": "Percentage of successful traffic routed to each L2 node", + "type": "piechart", + "gridPos": {"h": 6, "w": 8, "x": 0, "y": 13}, + "interval": "1s", + "targets": [{"expr": "sum by (layer2_node)(rate(layer1_route_total{http_status=\"200\"}[10s]))", "legendFormat": "{{layer2_node}}"}], + "options": { + "reduceOptions": {"calcs": ["lastNotNull"]}, + "legend": {"displayMode": "table", "placement": "right", "values": ["percent"]}, + "pieType": "donut" + } + }, + { + "title": "Route Distribution (L3)", + "description": "Percentage of successful traffic routed to each L3 node", + "type": "piechart", + "gridPos": {"h": 6, "w": 8, "x": 8, "y": 13}, + "interval": "1s", + "targets": [{"expr": "sum by (layer3_node)(rate(layer1_route_total{http_status=\"200\"}[10s]))", "legendFormat": "{{layer3_node}}"}], + "options": { + "reduceOptions": {"calcs": ["lastNotNull"]}, + "legend": {"displayMode": "table", "placement": "right", "values": ["percent"]}, + "pieType": "donut" + } + }, + { + "title": "Route Distribution (L2 → L3)", + "description": "Percentage of successful traffic per L2→L3 path", + "type": "piechart", + "gridPos": {"h": 6, "w": 8, "x": 16, "y": 13}, + "interval": "1s", + "targets": [ + {"expr": "sum by (layer2_node, layer3_node)(rate(layer1_route_total{http_status=\"200\"}[10s]))", "legendFormat": "{{layer2_node}} → {{layer3_node}}"} + ], + "options": { + "reduceOptions": {"calcs": ["lastNotNull"]}, + "legend": {"displayMode": "table", "placement": "right", "values": ["percent"]}, + "pieType": "donut" + } + }, + {"title": "Control Plane", "type": "row", "gridPos": {"h": 1, "w": 24, "x": 0, "y": 20}, "collapsed": false}, + { + "title": "File Loaded", + "type": "timeseries", + "gridPos": {"h": 6, "w": 6, "x": 0, "y": 21}, + "interval": "1s", + "targets": [{"expr": "sum by (node) (rate(armeria_xds_configsource_file_loaded_total[3s]))", "legendFormat": "{{node}}"}], + "fieldConfig": {"defaults": {"unit": "ops"}} + }, + { + "title": "File Errors", + "type": "timeseries", + "gridPos": {"h": 6, "w": 6, "x": 6, "y": 21}, + "interval": "1s", + "targets": [{"expr": "sum by (node) (rate(armeria_xds_configsource_file_error_total[3s]))", "legendFormat": "{{node}}"}], + "fieldConfig": {"defaults": {"unit": "ops"}} + }, + { + "title": "Resources Active", + "type": "timeseries", + "gridPos": {"h": 6, "w": 6, "x": 12, "y": 21}, + "interval": "1s", + "targets": [{"expr": "armeria_xds_resource_node_revision", "legendFormat": "{{type}}/{{name}}"}] + }, + { + "title": "Resource Errors", + "type": "timeseries", + "gridPos": {"h": 6, "w": 6, "x": 18, "y": 21}, + "interval": "1s", + "targets": [ + {"expr": "rate(armeria_xds_resource_node_error_total[3s])", "legendFormat": "error {{type}}/{{name}}"}, + {"expr": "rate(armeria_xds_resource_node_missing_total[3s])", "legendFormat": "missing {{type}}/{{name}}"} + ], + "fieldConfig": {"defaults": {"unit": "ops"}} + }, + {"title": "Load Balancer", "type": "row", "gridPos": {"h": 1, "w": 24, "x": 0, "y": 27}, "collapsed": false}, + { + "title": "Membership per Locality", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 0, "y": 28}, + "interval": "1s", + "targets": [{"expr": "armeria_xds_lb_membership{cluster=~\"$cluster\"}", "legendFormat": "{{cluster}} / {{region}}"}] + }, + { + "title": "Healthy / Degraded Membership", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 8, "y": 28}, + "interval": "1s", + "targets": [ + {"expr": "armeria_xds_lb_membership_healthy{cluster=~\"$cluster\"}", "legendFormat": "healthy {{cluster}}/{{region}}"}, + {"expr": "armeria_xds_lb_membership_degraded{cluster=~\"$cluster\"}", "legendFormat": "degraded {{cluster}}/{{region}}"} + ] + }, + { + "title": "Locality Weights", + "type": "timeseries", + "gridPos": {"h": 6, "w": 8, "x": 16, "y": 28}, + "interval": "1s", + "targets": [{"expr": "armeria_xds_lb_locality_weight{cluster=~\"$cluster\"}", "legendFormat": "{{cluster}} / {{region}}"}] + }, + { + "title": "Priority Load", + "description": "Traffic distribution across priorities. In panic mode, all endpoints are treated as healthy regardless of actual health status.", + "type": "timeseries", + "gridPos": {"h": 6, "w": 12, "x": 0, "y": 34}, + "interval": "1s", + "targets": [ + {"expr": "armeria_xds_lb_state_load_healthy{cluster=~\"$cluster\"}", "legendFormat": "healthy p{{priority}} {{cluster}}"}, + {"expr": "armeria_xds_lb_state_load_degraded{cluster=~\"$cluster\"}", "legendFormat": "degraded p{{priority}} {{cluster}}"} + ] + }, + { + "title": "Panic Mode", + "type": "timeseries", + "gridPos": {"h": 6, "w": 6, "x": 12, "y": 34}, + "interval": "1s", + "targets": [{"expr": "armeria_xds_lb_state_panic{cluster=~\"$cluster\"}", "legendFormat": "{{cluster}} p{{priority}}"}] + }, + { + "title": "Subsets", + "type": "timeseries", + "gridPos": {"h": 6, "w": 6, "x": 18, "y": 34}, + "interval": "1s", + "targets": [{"expr": "armeria_xds_lb_state_subsets{cluster=~\"$cluster\"}", "legendFormat": "{{cluster}}"}] + }, + {"title": "Zone-Aware Routing", "type": "row", "gridPos": {"h": 1, "w": 24, "x": 0, "y": 40}, "collapsed": false}, + { + "title": "ZAR Local Percentage", + "description": "Percentage of traffic routed to the local zone (computed by Armeria ZAR algorithm)", + "type": "bargauge", + "gridPos": {"h": 6, "w": 12, "x": 0, "y": 41}, + "interval": "1s", + "targets": [ + {"expr": "armeria_xds_lb_zar_local_percentage{cluster=~\"$cluster\",cluster!~\".*local.*\"}", "legendFormat": "{{node}} / {{cluster}}"} + ], + "options": { + "orientation": "horizontal", + "displayMode": "gradient", + "reduceOptions": {"calcs": ["lastNotNull"]} + }, + "fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1}} + }, + { + "title": "ZAR Residual Percentage per Locality", + "description": "Residual traffic percentage for each locality after local zone allocation", + "type": "table", + "gridPos": {"h": 6, "w": 12, "x": 12, "y": 41}, + "interval": "1s", + "targets": [ + { + "expr": "armeria_xds_lb_zar_residual_percentage{cluster=~\"$cluster\",cluster!~\".*local.*\"}", + "legendFormat": "{{node}} / {{cluster}} / {{region}}", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": {"excludeByName": {"Time": true, "__name__": true, "instance": true, "job": true, "priority": true}, "renameByName": {"Value": "residual %"}} + } + ], + "fieldConfig": {"defaults": {"unit": "percentunit"}, "overrides": []} + }, + {"title": "LB Selection", "type": "row", "gridPos": {"h": 1, "w": 24, "x": 0, "y": 47}, "collapsed": false}, + { + "title": "Selection Rate (hit vs miss)", + "type": "timeseries", + "gridPos": {"h": 6, "w": 12, "x": 0, "y": 48}, + "interval": "1s", + "targets": [ + {"expr": "rate(armeria_xds_lb_select_total{result=\"hit\",cluster=~\"$cluster\"}[3s])", "legendFormat": "hit {{cluster}}/{{region}}"}, + {"expr": "rate(armeria_xds_lb_select_total{result=\"miss\",cluster=~\"$cluster\"}[3s])", "legendFormat": "miss {{cluster}}/{{region}}"} + ], + "fieldConfig": {"defaults": {"unit": "ops"}} + }, + { + "title": "Errors (no_endpoints / no_host_source)", + "type": "timeseries", + "gridPos": {"h": 6, "w": 12, "x": 12, "y": 48}, + "interval": "1s", + "targets": [ + {"expr": "rate(armeria_xds_lb_select_total{result=\"no_endpoints\",cluster=~\"$cluster\"}[3s])", "legendFormat": "no_endpoints {{cluster}}"}, + {"expr": "rate(armeria_xds_lb_select_total{result=\"no_host_source\",cluster=~\"$cluster\"}[3s])", "legendFormat": "no_host_source {{cluster}}"} + ], + "fieldConfig": {"defaults": {"unit": "ops"}} + }, + {"title": "LB Update Events", "type": "row", "gridPos": {"h": 1, "w": 24, "x": 0, "y": 54}, "collapsed": false}, + { + "title": "Resource / Endpoint / State Updates", + "type": "timeseries", + "gridPos": {"h": 6, "w": 12, "x": 0, "y": 55}, + "interval": "1s", + "targets": [ + {"expr": "rate(armeria_xds_lb_resource_updated_count_total{cluster=~\"$cluster\"}[3s])", "legendFormat": "resource {{cluster}}"}, + {"expr": "rate(armeria_xds_lb_endpoints_updated_count_total{cluster=~\"$cluster\"}[3s])", "legendFormat": "endpoints {{cluster}}"}, + {"expr": "rate(armeria_xds_lb_state_updated_count_total{cluster=~\"$cluster\"}[3s])", "legendFormat": "state {{cluster}}"}, + {"expr": "rate(armeria_xds_lb_state_rejected_count_total{cluster=~\"$cluster\"}[3s])", "legendFormat": "rejected {{cluster}}"} + ], + "fieldConfig": {"defaults": {"unit": "ops"}} + }, + { + "title": "Update Revisions", + "type": "timeseries", + "gridPos": {"h": 6, "w": 12, "x": 12, "y": 55}, + "interval": "1s", + "targets": [ + {"expr": "armeria_xds_lb_resource_updated_revision{cluster=~\"$cluster\"}", "legendFormat": "resource {{cluster}}"}, + {"expr": "armeria_xds_lb_state_updated_revision{cluster=~\"$cluster\"}", "legendFormat": "state {{cluster}}"} + ] + } + ] +} diff --git a/examples/xds-example/grafana/provisioning/dashboards/provider.yml b/examples/xds-example/grafana/provisioning/dashboards/provider.yml new file mode 100644 index 00000000000..b3965de6fc3 --- /dev/null +++ b/examples/xds-example/grafana/provisioning/dashboards/provider.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/examples/xds-example/grafana/provisioning/datasources/datasource.yml b/examples/xds-example/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 00000000000..86fd3465e14 --- /dev/null +++ b/examples/xds-example/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true diff --git a/examples/xds-example/prometheus.yml b/examples/xds-example/prometheus.yml new file mode 100644 index 00000000000..d0309b8e881 --- /dev/null +++ b/examples/xds-example/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 1s + +scrape_configs: + - job_name: 'armeria-xds-example' + metrics_path: '/metrics' + static_configs: + - targets: ['host.docker.internal:8080'] diff --git a/examples/xds-example/src/main/java/example/armeria/xds/DashboardService.java b/examples/xds-example/src/main/java/example/armeria/xds/DashboardService.java new file mode 100644 index 00000000000..dc57c154b0b --- /dev/null +++ b/examples/xds-example/src/main/java/example/armeria/xds/DashboardService.java @@ -0,0 +1,196 @@ +package example.armeria.xds; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.google.protobuf.Any; +import com.google.protobuf.GeneratedMessageV3; +import com.google.protobuf.InvalidProtocolBufferException; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.RequestHeadersBuilder; +import com.linecorp.armeria.server.annotation.Blocking; +import com.linecorp.armeria.server.annotation.Get; +import com.linecorp.armeria.server.annotation.Post; +import com.linecorp.armeria.server.annotation.RequestObject; +import com.linecorp.armeria.xds.XdsResourceReader; +import com.linecorp.armeria.xds.XdsType; + +import io.envoyproxy.envoy.config.cluster.v3.Cluster; +import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment; +import io.envoyproxy.envoy.config.listener.v3.Listener; + +@Blocking +final class DashboardService { + + private static final List AVAILABLE_PROFILES = + XdsTemplateReader.VALID_PROFILES.values().stream() + .flatMap(List::stream).distinct().sorted().toList(); + + private final ServiceMesh mesh; + private final BlockingWebClient layer1Client; + private volatile ProfileState profileState; + + DashboardService(ServiceMesh mesh, String initialPreset) { + this.mesh = mesh; + layer1Client = mesh.client().blocking(); + profileState = new ProfileState(initialPreset); + } + + // -- Request/Response DTOs -- + record SendTrafficRequest(Map headers) {} + + record XdsResourceInfo(String file, String type, String name, String config) {} + + record ConfigResponse(String configDir, List resources) {} + + record ResourceEntry(String type, String config) {} + + record UpdateFileRequest(String file, List resources) {} + + record ProfilesResponse(String baseProfile, Map overrides, + List available, + Map> validProfiles) {} + + record PreviewRequest(String resource, String type, String profile) {} + + // -- Endpoints -- + @Post("/send-traffic") + public HttpResponse sendTraffic(@RequestObject SendTrafficRequest body) { + final Map headers = body.headers(); + final RequestHeadersBuilder reqBuilder = + RequestHeaders.builder(HttpMethod.GET, "/"); + if (headers != null) { + headers.forEach(reqBuilder::add); + } + layer1Client.execute(reqBuilder.build()); + return HttpResponse.of(HttpStatus.OK); + } + + @Get("/config") + public HttpResponse getConfig() throws IOException { + final List resources = new ArrayList<>(); + + try (DirectoryStream stream = Files.newDirectoryStream(mesh.configDir(), "*.yaml")) { + for (Path path : stream) { + resources.addAll(readResources(path)); + } + } + + return HttpResponse.ofJson(new ConfigResponse(mesh.configDir().toString(), resources)); + } + + private static List readResources(Path path) + throws InvalidProtocolBufferException { + final String fileName = path.getFileName().toString(); + final var response = XdsTemplateReader.readDiscoveryResponse(path); + final String typeUrl = response.getTypeUrl(); + final List results = new ArrayList<>(); + for (Any any : response.getResourcesList()) { + if (XdsType.LISTENER.typeUrl().equals(typeUrl)) { + final Listener listener = any.unpack(Listener.class); + results.add(new XdsResourceInfo( + fileName, "listener", listener.getName(), + XdsTemplateReader.printProtoYaml(listener))); + } else if (XdsType.CLUSTER.typeUrl().equals(typeUrl)) { + final Cluster cluster = any.unpack(Cluster.class); + results.add(new XdsResourceInfo( + fileName, "cluster", cluster.getName(), + XdsTemplateReader.printProtoYaml(cluster))); + } else if (XdsType.ENDPOINT.typeUrl().equals(typeUrl)) { + final ClusterLoadAssignment endpoints = + any.unpack(ClusterLoadAssignment.class); + results.add(new XdsResourceInfo( + fileName, "endpoint", endpoints.getClusterName(), + XdsTemplateReader.printProtoYaml(endpoints))); + } + } + return results; + } + + @Post("/update-file") + public HttpResponse updateFile(@RequestObject UpdateFileRequest body) { + final String file = body.file(); + if (file == null || file.isEmpty() || body.resources() == null || body.resources().isEmpty()) { + return HttpResponse.of(HttpStatus.BAD_REQUEST); + } + + final Path path = mesh.configDir().resolve(file).normalize(); + if (!path.startsWith(mesh.configDir().normalize())) { + return HttpResponse.of(HttpStatus.BAD_REQUEST); + } + final String firstType = body.resources().get(0).type(); + if (firstType == null) { + return HttpResponse.of(HttpStatus.BAD_REQUEST); + } + final String typeUrl; + final List resources = new ArrayList<>(); + switch (firstType) { + case "listener" -> { + typeUrl = XdsType.LISTENER.typeUrl(); + for (ResourceEntry entry : body.resources()) { + resources.add(XdsResourceReader.from(entry.config(), Listener.class)); + } + } + case "cluster" -> { + typeUrl = XdsType.CLUSTER.typeUrl(); + for (ResourceEntry entry : body.resources()) { + resources.add(XdsResourceReader.from(entry.config(), Cluster.class)); + } + } + case "endpoint" -> { + typeUrl = XdsType.ENDPOINT.typeUrl(); + for (ResourceEntry entry : body.resources()) { + resources.add(XdsResourceReader.from(entry.config(), ClusterLoadAssignment.class)); + } + } + default -> { + return HttpResponse.of(HttpStatus.BAD_REQUEST); + } + } + XdsTemplateReader.writeDiscoveryResponse(path, typeUrl, resources); + return HttpResponse.of(HttpStatus.OK); + } + + @Post("/preview") + public HttpResponse preview(@RequestObject PreviewRequest body) { + final String resource = body.resource(); + final String type = body.type(); + if (resource == null || type == null || body.profile() == null) { + return HttpResponse.of(HttpStatus.BAD_REQUEST); + } + final ProfileState state = new ProfileState(body.profile()); + final Map allVars = mesh.buildAllVars(); + final GeneratedMessageV3 rendered = switch (type) { + case "listener" -> XdsTemplateReader.readListener(state, resource, allVars); + case "cluster" -> XdsTemplateReader.read(state, resource, allVars, Cluster.class); + case "endpoint" -> XdsTemplateReader.read(state, resource, allVars, ClusterLoadAssignment.class); + default -> throw new IllegalArgumentException("Unknown type: " + type); + }; + return HttpResponse.ofJson(new XdsResourceInfo( + "", type, resource, XdsTemplateReader.printProtoYaml(rendered))); + } + + @Get("/profiles") + public HttpResponse getProfiles() { + return HttpResponse.ofJson(new ProfilesResponse( + profileState.baseProfile(), profileState.overrides(), + AVAILABLE_PROFILES, XdsTemplateReader.VALID_PROFILES)); + } + + @Post("/apply-profile") + public HttpResponse applyProfile(@RequestObject ProfileState body) { + profileState = profileState.mergeFrom(body); + mesh.applyProfile(profileState); + return HttpResponse.of(HttpStatus.OK); + } +} diff --git a/examples/xds-example/src/main/java/example/armeria/xds/Layer2Server.java b/examples/xds-example/src/main/java/example/armeria/xds/Layer2Server.java new file mode 100644 index 00000000000..5692fceea62 --- /dev/null +++ b/examples/xds-example/src/main/java/example/armeria/xds/Layer2Server.java @@ -0,0 +1,100 @@ +package example.armeria.xds; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.metric.MetricCollectingClient; +import com.linecorp.armeria.common.metric.MeterIdPrefix; +import com.linecorp.armeria.common.metric.MeterIdPrefixFunction; +import com.linecorp.armeria.common.util.AsyncCloseable; +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.metric.MetricCollectingService; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor; +import com.linecorp.armeria.xds.server.XdsServerPlugin; + +final class Layer2Server implements AsyncCloseable { + + private static final Logger logger = LoggerFactory.getLogger(Layer2Server.class); + + private final String nodeId; + private final Path configDir; + private final String zone; + private final ClientFactory clientFactory; + private Server server; + // Normally we would use a single bootstrap, but this is separated for simplicity + // to have a single file per resource + private XdsBootstrap clientBootstrap; + private XdsBootstrap serverBootstrap; + private XdsHttpPreprocessor preprocessor; + + Layer2Server(String zone, Path configDir, ClientFactory clientFactory) { + nodeId = "layer2-" + zone; + this.configDir = configDir; + this.zone = zone; + this.clientFactory = clientFactory; + } + + void start() { + final Map bootstrapVars = Map.of( + "config-dir", configDir.toString(), "zone", zone); + clientBootstrap = XdsBootstrap.builder( + XdsTemplateReader.readBootstrap("layer2-client-bootstrap", bootstrapVars)) + .meterIdPrefix(new MeterIdPrefix("armeria.xds", "node", nodeId)) + .build(); + serverBootstrap = XdsBootstrap.builder( + XdsTemplateReader.readBootstrap("layer2-server-bootstrap", bootstrapVars)) + .meterIdPrefix(new MeterIdPrefix("armeria.xds", "node", nodeId + "-server")) + .build(); + preprocessor = XdsHttpPreprocessor.ofListener("layer2-client-listener", clientBootstrap); + + final WebClient xdsClient = WebClient.builder(preprocessor) + .factory(clientFactory) + .decorator(MetricCollectingClient.newDecorator( + MeterIdPrefixFunction.ofDefault("layer2") + .withTags("node", nodeId, + "side", "client"))) + .build(); + + server = Server.builder() + .plugin(XdsServerPlugin.of(serverBootstrap, "layer2-server-listener")) + .service("/", (ctx, req) -> { + ctx.addAdditionalResponseHeader("layer2-node", nodeId); + return xdsClient.execute(req); + }) + .decorator(MetricCollectingService.newDecorator( + MeterIdPrefixFunction.ofDefault("layer2") + .withTags("node", nodeId, + "side", "server"))) + .build(); + server.start().join(); + logger.info("Layer 2 server ({}) started on port {}", zone, port()); + } + + String zone() { + return zone; + } + + int port() { + return server.activePort().localAddress().getPort(); + } + + @Override + public CompletableFuture closeAsync() { + preprocessor.close(); + clientBootstrap.close(); + serverBootstrap.close(); + return server.stop(); + } + + @Override + public void close() { + closeAsync().join(); + } +} diff --git a/examples/xds-example/src/main/java/example/armeria/xds/Layer3Server.java b/examples/xds-example/src/main/java/example/armeria/xds/Layer3Server.java new file mode 100644 index 00000000000..236f8923a94 --- /dev/null +++ b/examples/xds-example/src/main/java/example/armeria/xds/Layer3Server.java @@ -0,0 +1,69 @@ +package example.armeria.xds; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.metric.MeterIdPrefix; +import com.linecorp.armeria.common.metric.MeterIdPrefixFunction; +import com.linecorp.armeria.common.util.AsyncCloseable; +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.metric.MetricCollectingService; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.server.XdsServerPlugin; + +final class Layer3Server implements AsyncCloseable { + + private final String nodeId; + private final String zone; + private final Path configDir; + private Server server; + private XdsBootstrap xdsBootstrap; + + Layer3Server(String zone, Path configDir) { + nodeId = "layer3-" + zone; + this.zone = zone; + this.configDir = configDir; + } + + void start() { + xdsBootstrap = XdsBootstrap.builder( + XdsTemplateReader.readBootstrap("layer3-bootstrap", Map.of( + "config-dir", configDir.toString()))) + .meterIdPrefix(new MeterIdPrefix("armeria.xds", "node", nodeId)) + .build(); + + server = Server.builder() + .plugin(XdsServerPlugin.of(xdsBootstrap, "layer3-server-listener")) + .service("/", (ctx, req) -> + HttpResponse.of(HttpStatus.OK) + .mapHeaders(h -> h.toBuilder() + .add("layer3-node", nodeId) + .build())) + .decorator(MetricCollectingService.newDecorator( + MeterIdPrefixFunction.ofDefault(nodeId))) + .build(); + server.start().join(); + } + + String zone() { + return zone; + } + + int port() { + return server.activePort().localAddress().getPort(); + } + + @Override + public CompletableFuture closeAsync() { + xdsBootstrap.close(); + return server.stop(); + } + + @Override + public void close() { + closeAsync().join(); + } +} diff --git a/examples/xds-example/src/main/java/example/armeria/xds/Main.java b/examples/xds-example/src/main/java/example/armeria/xds/Main.java new file mode 100644 index 00000000000..9d1fd0b3e4a --- /dev/null +++ b/examples/xds-example/src/main/java/example/armeria/xds/Main.java @@ -0,0 +1,49 @@ +package example.armeria.xds; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.file.HttpFile; +import com.linecorp.armeria.server.prometheus.PrometheusExpositionService; + +/** + * Starts a {@link ServiceMesh} and a dashboard server on port 8080. + * The dashboard lets you send traffic through the mesh and switch + * between configuration profiles at runtime. + * + * @see ServiceMesh + */ +public final class Main { + + private static final Logger logger = LoggerFactory.getLogger(Main.class); + + private static final String INITIAL_PROFILE = "basic"; + + public static void main(String[] args) { + final ServiceMesh mesh = new ServiceMesh(INITIAL_PROFILE); + + final DashboardService dashboardService = + new DashboardService(mesh, INITIAL_PROFILE); + final Server dashboard = Server.builder() + .http(8080) + .service("/", + HttpFile.of(Main.class.getClassLoader(), "index.html") + .asService()) + .annotatedService("/api", dashboardService) + .service("/metrics", + PrometheusExpositionService.of( + mesh.meterRegistry().getPrometheusRegistry())) + .build(); + dashboard.closeOnJvmShutdown(); + Runtime.getRuntime().addShutdownHook(new Thread(mesh::close)); + dashboard.start().join(); + logger.info("Dashboard started at http://127.0.0.1:{}", dashboard.activeLocalPort()); + logger.info("Run Grafana with: docker compose up"); + logger.info(" (from the armeria repo: docker compose -f examples/xds-example/docker-compose.yml up)"); + logger.info("Grafana dashboard at http://localhost:3000"); + logger.info("Open your browser and visit the dashboard to interact with the xDS example."); + } + + private Main() {} +} diff --git a/examples/xds-example/src/main/java/example/armeria/xds/ProfileState.java b/examples/xds-example/src/main/java/example/armeria/xds/ProfileState.java new file mode 100644 index 00000000000..6ca72d3d618 --- /dev/null +++ b/examples/xds-example/src/main/java/example/armeria/xds/ProfileState.java @@ -0,0 +1,40 @@ +package example.armeria.xds; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +record ProfileState(String baseProfile, Map overrides) { + + ProfileState { + if (overrides == null) { + overrides = Map.of(); + } + } + + ProfileState(String baseProfile) { + this(baseProfile, Map.of()); + } + + ProfileState mergeFrom(ProfileState update) { + if (update.baseProfile() != null) { + return new ProfileState(update.baseProfile(), update.overrides()); + } + final Map merged = new HashMap<>(overrides); + merged.putAll(update.overrides()); + return new ProfileState(baseProfile, merged); + } + + boolean shouldUpdate(String resource) { + return !"override".equals(profileFor(resource)); + } + + String profileFor(String resource) { + final String override = overrides.get(resource); + if (override != null) { + return override; + } + final List valid = XdsTemplateReader.VALID_PROFILES.get(resource); + return valid != null && valid.contains(baseProfile) ? baseProfile : "basic"; + } +} diff --git a/examples/xds-example/src/main/java/example/armeria/xds/RouteMetricsClient.java b/examples/xds-example/src/main/java/example/armeria/xds/RouteMetricsClient.java new file mode 100644 index 00000000000..5117684cef0 --- /dev/null +++ b/examples/xds-example/src/main/java/example/armeria/xds/RouteMetricsClient.java @@ -0,0 +1,43 @@ +package example.armeria.xds; + +import java.util.function.Function; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.HttpClient; +import com.linecorp.armeria.client.SimpleDecoratingHttpClient; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +final class RouteMetricsClient extends SimpleDecoratingHttpClient { + + private final MeterRegistry registry; + + RouteMetricsClient(HttpClient delegate, MeterRegistry registry) { + super(delegate); + this.registry = registry; + } + + static Function newDecorator(MeterRegistry registry) { + return delegate -> new RouteMetricsClient(delegate, registry); + } + + @Override + public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception { + final HttpResponse res = unwrap().execute(ctx, req); + ctx.log().whenComplete().thenAccept(log -> { + final String layer2Node = log.responseHeaders().get("layer2-node", "unknown"); + final String layer3Node = log.responseHeaders().get("layer3-node", "unknown"); + final String status = log.responseHeaders().status().codeAsText(); + Counter.builder("layer1.route") + .tag("layer2.node", layer2Node) + .tag("layer3.node", layer3Node) + .tag("http.status", status) + .register(registry) + .increment(); + }); + return res; + } +} diff --git a/examples/xds-example/src/main/java/example/armeria/xds/ServiceMesh.java b/examples/xds-example/src/main/java/example/armeria/xds/ServiceMesh.java new file mode 100644 index 00000000000..2771b848df5 --- /dev/null +++ b/examples/xds-example/src/main/java/example/armeria/xds/ServiceMesh.java @@ -0,0 +1,198 @@ +package example.armeria.xds; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.function.ToIntFunction; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.ConnectionPoolListener; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.metric.MetricCollectingClient; +import com.linecorp.armeria.common.metric.MeterIdPrefix; +import com.linecorp.armeria.common.metric.MeterIdPrefixFunction; +import com.linecorp.armeria.common.util.AsyncCloseable; +import com.linecorp.armeria.common.util.Exceptions; +import com.linecorp.armeria.internal.common.util.SelfSignedCertificate; +import com.linecorp.armeria.internal.common.util.SignedCertificate; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor; + +import io.micrometer.core.instrument.Metrics; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; + +final class ServiceMesh implements AsyncCloseable { + + private static final Logger logger = LoggerFactory.getLogger(ServiceMesh.class); + + static final List ZONES = List.of("us-east", "us-west", "eu-west"); + + private final Path configDir; + private final Map certVars; + private final Map> layer2Ports; + private final Map> layer3Ports; + private final List layer3Servers = new ArrayList<>(); + private final List layer2Servers = new ArrayList<>(); + private final ClientFactory layer1ClientFactory; + private final ClientFactory layer2ClientFactory; + private final XdsBootstrap layer1Bootstrap; + private final XdsHttpPreprocessor layer1Preprocessor; + private final WebClient client; + private final PrometheusMeterRegistry meterRegistry; + + ServiceMesh(String initialProfile) { + this(initialProfile, new PrometheusMeterRegistry(PrometheusConfig.DEFAULT)); + } + + ServiceMesh(String initialProfile, PrometheusMeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + Metrics.globalRegistry.add(meterRegistry); + final ProfileState initialState = new ProfileState(initialProfile); + + try { + configDir = Files.createTempDirectory("armeria-xds-example"); + logger.info("xDS config directory: {}", configDir); + + // Generate a root CA, server cert, and client cert + final SelfSignedCertificate rootCa = new SelfSignedCertificate(); + final SignedCertificate serverCert = new SignedCertificate("127.0.0.1", rootCa, false); + final SignedCertificate clientCert = new SignedCertificate("127.0.0.1", rootCa, false); + certVars = Map.of( + "server-cert-path", serverCert.certificate().getAbsolutePath(), + "server-key-path", serverCert.privateKey().getAbsolutePath(), + "client-cert-path", clientCert.certificate().getAbsolutePath(), + "client-key-path", clientCert.privateKey().getAbsolutePath(), + "ca-cert-path", rootCa.certificate().getAbsolutePath()); + } catch (Exception e) { + Exceptions.throwUnsafely(e); + throw new AssertionError(); // Never reaches here. + } + + // 1. Write Layer 3 xDS config and start backend servers + XdsTemplateReader.applyProfileForLayer(configDir, initialState, buildAllVars(), 3); + + for (String zone : ZONES) { + for (int i = 0; i < 3; i++) { + final Layer3Server server = new Layer3Server(zone, configDir); + server.start(); + layer3Servers.add(server); + } + } + layer3Ports = collectPorts(layer3Servers, Layer3Server::zone, Layer3Server::port); + logger.info("Layer 3 backends started (9 servers) — {}", layer3Ports); + + // 2. Write Layer 2 xDS config and start middle-tier servers + XdsTemplateReader.applyProfileForLayer(configDir, initialState, buildAllVars(), 2); + + layer2ClientFactory = newClientFactory("layer2.client"); + + for (String zone : ZONES) { + for (int i = 0; i < 3; i++) { + final Layer2Server server = new Layer2Server(zone, configDir, layer2ClientFactory); + server.start(); + layer2Servers.add(server); + } + } + layer2Ports = collectPorts(layer2Servers, Layer2Server::zone, Layer2Server::port); + logger.info("Layer 2 servers started (9 servers) — {}", layer2Ports); + + // 3. Write Layer 1 xDS config + XdsTemplateReader.applyProfileForLayer(configDir, initialState, buildAllVars(), 1); + + // 4. Create Layer 1 xDS client + layer1ClientFactory = newClientFactory("layer1.client"); + + layer1Bootstrap = XdsBootstrap.builder( + XdsTemplateReader.readBootstrap("layer1-bootstrap", Map.of( + "config-dir", configDir.toString()))) + .meterIdPrefix(new MeterIdPrefix("armeria.xds", "node", "layer1-dashboard")) + .build(); + layer1Preprocessor = + XdsHttpPreprocessor.ofListener("layer1-client-listener", layer1Bootstrap); + + client = WebClient.builder(layer1Preprocessor) + .factory(layer1ClientFactory) + .decorator(MetricCollectingClient.newDecorator( + MeterIdPrefixFunction.ofDefault("layer1"))) + .decorator(RouteMetricsClient.newDecorator(meterRegistry)) + .build(); + } + + WebClient client() { + return client; + } + + Path configDir() { + return configDir; + } + + PrometheusMeterRegistry meterRegistry() { + return meterRegistry; + } + + Map buildAllVars() { + final Map vars = new HashMap<>(certVars); + vars.put("config-dir", configDir.toString()); + if (layer2Ports != null) { + vars.putAll(XdsTemplateReader.portVars("layer2-", layer2Ports)); + } + if (layer3Ports != null) { + vars.putAll(XdsTemplateReader.portVars("layer3-", layer3Ports)); + } + return vars; + } + + void applyProfile(ProfileState state) { + XdsTemplateReader.applyProfile(configDir, state, buildAllVars()); + } + + private static ClientFactory newClientFactory(String metricsPrefix) { + return ClientFactory.builder() + .maxConnectionAge(Duration.ofSeconds(5)) + .connectionPoolListener(ConnectionPoolListener.metricCollecting( + Metrics.globalRegistry, new MeterIdPrefix(metricsPrefix))) + .build(); + } + + private static Map> collectPorts( + List servers, Function zone, ToIntFunction port) { + final Map> ports = new LinkedHashMap<>(); + for (T server : servers) { + ports.computeIfAbsent(zone.apply(server), k -> new ArrayList<>()).add(port.applyAsInt(server)); + } + return ports; + } + + @Override + public CompletableFuture closeAsync() { + layer1Preprocessor.close(); + layer1Bootstrap.close(); + layer1ClientFactory.close(); + layer2ClientFactory.close(); + final List> futures = new ArrayList<>(); + for (Layer2Server server : layer2Servers) { + futures.add(server.closeAsync()); + } + for (Layer3Server server : layer3Servers) { + futures.add(server.closeAsync()); + } + return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)) + .thenRun(meterRegistry::close); + } + + @Override + public void close() { + closeAsync().join(); + } +} diff --git a/examples/xds-example/src/main/java/example/armeria/xds/XdsTemplateReader.java b/examples/xds-example/src/main/java/example/armeria/xds/XdsTemplateReader.java new file mode 100644 index 00000000000..8726ba97455 --- /dev/null +++ b/examples/xds-example/src/main/java/example/armeria/xds/XdsTemplateReader.java @@ -0,0 +1,196 @@ +package example.armeria.xds; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.protobuf.Any; +import com.google.protobuf.GeneratedMessageV3; +import com.google.protobuf.util.JsonFormat; + +import com.linecorp.armeria.xds.XdsResourceReader; +import com.linecorp.armeria.xds.XdsType; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; +import io.envoyproxy.envoy.config.cluster.v3.Cluster; +import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment; +import io.envoyproxy.envoy.config.listener.v3.Listener; +import io.envoyproxy.envoy.service.discovery.v3.DiscoveryResponse; + +final class XdsTemplateReader { + + private static final JsonFormat.Printer PROTO_PRINTER = + JsonFormat.printer() + .usingTypeRegistry(XdsResourceReader.typeRegistry()) + .preservingProtoFieldNames(); + + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + static final Map> VALID_PROFILES = scanValidProfiles(); + + private static Map> scanValidProfiles() { + try { + final Path base = Path.of(XdsTemplateReader.class.getResource("/xds/").toURI()); + final Map> map = new HashMap<>(); + try (DirectoryStream dirs = Files.newDirectoryStream(base, Files::isDirectory)) { + for (Path dir : dirs) { + final String profile = dir.getFileName().toString(); + try (DirectoryStream templates = + Files.newDirectoryStream(dir, "*.template.yaml")) { + for (Path f : templates) { + final String name = f.getFileName().toString() + .replace(".template.yaml", ""); + map.computeIfAbsent(name, k -> new ArrayList<>()).add(profile); + } + } + } + } + map.values().forEach(l -> l.sort(null)); + return Map.copyOf(map); + } catch (Exception e) { + throw new RuntimeException("Failed to scan xDS templates", e); + } + } + + static Listener readListener(ProfileState state, String name, Map vars) { + final Map allVars = new HashMap<>(vars); + allVars.put("listener-name", name); + return read(state, name, allVars, Listener.class); + } + + static T read( + ProfileState state, String name, Map vars, Class type) { + String yaml = readTemplate(state.profileFor(name) + '/' + name); + for (var entry : vars.entrySet()) { + yaml = yaml.replace("${" + entry.getKey() + '}', entry.getValue()); + } + return XdsResourceReader.from(yaml, type); + } + + static Bootstrap readBootstrap(String name, Map vars) { + String yaml = readTemplate(name); + for (var entry : vars.entrySet()) { + yaml = yaml.replace("${" + entry.getKey() + '}', entry.getValue()); + } + return XdsResourceReader.from(yaml, Bootstrap.class); + } + + static void writeDiscoveryResponse(Path path, String typeUrl, + List resources) { + final DiscoveryResponse.Builder builder = + DiscoveryResponse.newBuilder() + .setTypeUrl(typeUrl) + .setVersionInfo(String.valueOf(System.currentTimeMillis())); + for (GeneratedMessageV3 resource : resources) { + builder.addResources(Any.pack(resource, "")); + } + try { + final String json = PROTO_PRINTER.print(builder.build()); + final JsonNode tree = JSON_MAPPER.readTree(json); + final String yaml = YAML_MAPPER.writeValueAsString(tree); + // Write to a temp file first, then atomically rename to avoid + // the file watcher reading a partially-written file. + final Path tmp = Files.createTempFile(path.getParent(), ".xds-", ".tmp"); + Files.writeString(tmp, yaml, StandardCharsets.UTF_8); + Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write: " + path, e); + } + } + + static DiscoveryResponse readDiscoveryResponse(Path path) { + try { + final String content = Files.readString(path, StandardCharsets.UTF_8); + return XdsResourceReader.from(content, DiscoveryResponse.class); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read: " + path, e); + } + } + + static String printProtoYaml(GeneratedMessageV3 msg) { + try { + final String json = PROTO_PRINTER.print(msg); + final JsonNode tree = JSON_MAPPER.readTree(json); + return YAML_MAPPER.writeValueAsString(tree); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + static void applyProfile(Path configDir, ProfileState state, Map vars) { + for (String name : VALID_PROFILES.keySet()) { + applyResource(configDir, state, name, vars); + } + } + + static void applyProfileForLayer(Path configDir, ProfileState state, + Map vars, int layer) { + final String prefix = "layer" + layer + '-'; + for (String name : VALID_PROFILES.keySet()) { + if (name.startsWith(prefix)) { + applyResource(configDir, state, name, vars); + } + } + } + + private static void applyResource(Path configDir, ProfileState state, + String name, Map vars) { + if (!state.shouldUpdate(name)) { + return; + } + if (name.endsWith("-listener")) { + writeDiscoveryResponse(configDir.resolve(name + ".yaml"), + XdsType.LISTENER.typeUrl(), + List.of(readListener(state, name, vars))); + } else if (name.endsWith("-cluster")) { + writeDiscoveryResponse(configDir.resolve(name + ".yaml"), + XdsType.CLUSTER.typeUrl(), + List.of(read(state, name, vars, Cluster.class))); + } else if (name.endsWith("-endpoints")) { + writeDiscoveryResponse(configDir.resolve(name + ".yaml"), + XdsType.ENDPOINT.typeUrl(), + List.of(read(state, name, vars, + ClusterLoadAssignment.class))); + } + } + + static Map portVars(String prefix, Map> ports) { + final Map vars = new HashMap<>(); + for (var entry : ports.entrySet()) { + final String zone = entry.getKey(); + final List zonePorts = entry.getValue(); + for (int i = 0; i < zonePorts.size(); i++) { + vars.put(prefix + zone + "-port-" + (i + 1), String.valueOf(zonePorts.get(i))); + } + } + return vars; + } + + private static String readTemplate(String name) { + final String path = "/xds/" + name + ".template.yaml"; + try (InputStream is = XdsTemplateReader.class.getResourceAsStream(path)) { + if (is == null) { + throw new IllegalArgumentException("Template not found: " + path); + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private XdsTemplateReader() {} +} diff --git a/examples/xds-example/src/main/resources/index.html b/examples/xds-example/src/main/resources/index.html new file mode 100644 index 00000000000..6d250987e34 --- /dev/null +++ b/examples/xds-example/src/main/resources/index.html @@ -0,0 +1,429 @@ + + + + + +Armeria xDS Example Dashboard + + + +
+ +

Armeria xDS Example

+

Three-layer architecture with zone-aware routing and subset routing via file-based xDS

+ + +
+
+
+
+ Layer 1 +
+ Dashboard
xDS +
+
+
+
+ Layer 2 +
+ Layer 2 us-east
×3 +
+
+ Layer 2 us-west
×3 +
+
+ Layer 2 eu-west
×3 +
+
+
+
+ Layer 3 +
+ Layer 3 us-east
×3 +
+
+ Layer 3 us-west
×3 +
+
+ Layer 3 eu-west
×3 +
+
+
+
+
+ +
+ +
+
+
+
Global Profile
+
+ + + +
+
+
+
+
+ + +
+
+
+
Traffic
+
+ + + + req/s + +
+
+
+
+ + +
+
+
+
Request Headers
+
+ +
+
+
+ + +
+
+
+
xDS Resources
+
+
+ +
+
+
+
+
+ + +
+
+
+
Activity Log
+
+
+
+
+
+ +
+ + + + diff --git a/examples/xds-example/src/main/resources/xds/basic/layer1-client-cluster.template.yaml b/examples/xds-example/src/main/resources/xds/basic/layer1-client-cluster.template.yaml new file mode 100644 index 00000000000..12d7b894f51 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/basic/layer1-client-cluster.template.yaml @@ -0,0 +1,16 @@ +name: layer1-client-cluster +type: EDS +eds_cluster_config: + eds_config: + path_config_source: + path: ${config-dir}/layer1-endpoints.yaml + service_name: layer1-endpoints +lb_subset_config: + fallback_policy: ANY_ENDPOINT + subset_selectors: + - keys: ["region"] +common_lb_config: + zone_aware_lb_config: + routing_enabled: + value: 100 + min_cluster_size: 1 diff --git a/examples/xds-example/src/main/resources/xds/basic/layer1-client-listener.template.yaml b/examples/xds-example/src/main/resources/xds/basic/layer1-client-listener.template.yaml new file mode 100644 index 00000000000..f51ffd851ed --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/basic/layer1-client-listener.template.yaml @@ -0,0 +1,55 @@ +name: layer1-client-listener +api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: layer1_ingress + route_config: + name: layer1-route + virtual_hosts: + - name: default + domains: ["*"] + routes: + - match: + prefix: "/" + headers: + - name: x-layer1-zone + string_match: + exact: us-east + route: + cluster: layer1-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: us-east + - match: + prefix: "/" + headers: + - name: x-layer1-zone + string_match: + exact: us-west + route: + cluster: layer1-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: us-west + - match: + prefix: "/" + headers: + - name: x-layer1-zone + string_match: + exact: eu-west + route: + cluster: layer1-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: eu-west + - match: + prefix: "/" + route: + cluster: layer1-client-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router diff --git a/examples/xds-example/src/main/resources/xds/basic/layer1-endpoints.template.yaml b/examples/xds-example/src/main/resources/xds/basic/layer1-endpoints.template.yaml new file mode 100644 index 00000000000..c3ed526221a --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/basic/layer1-endpoints.template.yaml @@ -0,0 +1,104 @@ +cluster_name: layer1-endpoints +endpoints: + - locality: + region: us-east + load_balancing_weight: 100 + lb_endpoints: + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-east-port-1} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-east-port-2} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-east-port-3} + - locality: + region: us-west + load_balancing_weight: 100 + lb_endpoints: + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-west-port-1} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-west-port-2} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-west-port-3} + - locality: + region: eu-west + load_balancing_weight: 100 + lb_endpoints: + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-eu-west-port-1} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-eu-west-port-2} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-eu-west-port-3} diff --git a/examples/xds-example/src/main/resources/xds/basic/layer2-client-cluster.template.yaml b/examples/xds-example/src/main/resources/xds/basic/layer2-client-cluster.template.yaml new file mode 100644 index 00000000000..a9d820cfc91 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/basic/layer2-client-cluster.template.yaml @@ -0,0 +1,16 @@ +name: layer2-client-cluster +type: EDS +eds_cluster_config: + eds_config: + path_config_source: + path: ${config-dir}/layer2-endpoints.yaml + service_name: layer2-endpoints +lb_subset_config: + fallback_policy: ANY_ENDPOINT + subset_selectors: + - keys: ["region"] +common_lb_config: + zone_aware_lb_config: + routing_enabled: + value: 100 + min_cluster_size: 1 diff --git a/examples/xds-example/src/main/resources/xds/basic/layer2-client-listener.template.yaml b/examples/xds-example/src/main/resources/xds/basic/layer2-client-listener.template.yaml new file mode 100644 index 00000000000..74c34cd81f2 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/basic/layer2-client-listener.template.yaml @@ -0,0 +1,55 @@ +name: layer2-client-listener +api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: layer2_ingress + route_config: + name: layer2-route + virtual_hosts: + - name: default + domains: ["*"] + routes: + - match: + prefix: "/" + headers: + - name: x-layer2-zone + string_match: + exact: us-east + route: + cluster: layer2-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: us-east + - match: + prefix: "/" + headers: + - name: x-layer2-zone + string_match: + exact: us-west + route: + cluster: layer2-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: us-west + - match: + prefix: "/" + headers: + - name: x-layer2-zone + string_match: + exact: eu-west + route: + cluster: layer2-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: eu-west + - match: + prefix: "/" + route: + cluster: layer2-client-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router diff --git a/examples/xds-example/src/main/resources/xds/basic/layer2-endpoints.template.yaml b/examples/xds-example/src/main/resources/xds/basic/layer2-endpoints.template.yaml new file mode 100644 index 00000000000..1d2069974cf --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/basic/layer2-endpoints.template.yaml @@ -0,0 +1,104 @@ +cluster_name: layer2-endpoints +endpoints: + - locality: + region: us-east + load_balancing_weight: 100 + lb_endpoints: + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-east-port-1} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-east-port-2} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-east-port-3} + - locality: + region: us-west + load_balancing_weight: 100 + lb_endpoints: + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-west-port-1} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-west-port-2} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-west-port-3} + - locality: + region: eu-west + load_balancing_weight: 100 + lb_endpoints: + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-eu-west-port-1} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-eu-west-port-2} + - health_status: HEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-eu-west-port-3} diff --git a/examples/xds-example/src/main/resources/xds/basic/layer2-server-listener.template.yaml b/examples/xds-example/src/main/resources/xds/basic/layer2-server-listener.template.yaml new file mode 100644 index 00000000000..4affb8562b8 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/basic/layer2-server-listener.template.yaml @@ -0,0 +1,18 @@ +name: ${listener-name} +default_filter_chain: + filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ${listener-name} + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.router diff --git a/examples/xds-example/src/main/resources/xds/basic/layer3-server-listener.template.yaml b/examples/xds-example/src/main/resources/xds/basic/layer3-server-listener.template.yaml new file mode 100644 index 00000000000..4affb8562b8 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/basic/layer3-server-listener.template.yaml @@ -0,0 +1,18 @@ +name: ${listener-name} +default_filter_chain: + filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ${listener-name} + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.router diff --git a/examples/xds-example/src/main/resources/xds/fault/layer2-server-listener.template.yaml b/examples/xds-example/src/main/resources/xds/fault/layer2-server-listener.template.yaml new file mode 100644 index 00000000000..39795ea3a84 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/fault/layer2-server-listener.template.yaml @@ -0,0 +1,26 @@ +name: ${listener-name} +default_filter_chain: + filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ${listener-name} + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.fault + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault + abort: + http_status: 503 + percentage: + numerator: 25 + denominator: HUNDRED + - name: envoy.filters.http.router diff --git a/examples/xds-example/src/main/resources/xds/fault/layer3-server-listener.template.yaml b/examples/xds-example/src/main/resources/xds/fault/layer3-server-listener.template.yaml new file mode 100644 index 00000000000..39795ea3a84 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/fault/layer3-server-listener.template.yaml @@ -0,0 +1,26 @@ +name: ${listener-name} +default_filter_chain: + filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ${listener-name} + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.fault + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault + abort: + http_status: 503 + percentage: + numerator: 25 + denominator: HUNDRED + - name: envoy.filters.http.router diff --git a/examples/xds-example/src/main/resources/xds/layer1-bootstrap.template.yaml b/examples/xds-example/src/main/resources/xds/layer1-bootstrap.template.yaml new file mode 100644 index 00000000000..21c2ea19933 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/layer1-bootstrap.template.yaml @@ -0,0 +1,9 @@ +dynamic_resources: + lds_config: + path_config_source: + path: ${config-dir}/layer1-client-listener.yaml + cds_config: + path_config_source: + path: ${config-dir}/layer1-client-cluster.yaml +node: + id: layer1-dashboard diff --git a/examples/xds-example/src/main/resources/xds/layer2-client-bootstrap.template.yaml b/examples/xds-example/src/main/resources/xds/layer2-client-bootstrap.template.yaml new file mode 100644 index 00000000000..0a3a0730d6a --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/layer2-client-bootstrap.template.yaml @@ -0,0 +1,22 @@ +cluster_manager: + local_cluster_name: layer2-local-cluster +dynamic_resources: + lds_config: + path_config_source: + path: ${config-dir}/layer2-client-listener.yaml + cds_config: + path_config_source: + path: ${config-dir}/layer2-client-cluster.yaml +static_resources: + clusters: + - name: layer2-local-cluster + type: EDS + eds_cluster_config: + eds_config: + path_config_source: + path: ${config-dir}/layer1-endpoints.yaml + service_name: layer1-endpoints +node: + id: layer2-${zone} + locality: + region: ${zone} diff --git a/examples/xds-example/src/main/resources/xds/layer2-server-bootstrap.template.yaml b/examples/xds-example/src/main/resources/xds/layer2-server-bootstrap.template.yaml new file mode 100644 index 00000000000..5b734bf7ceb --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/layer2-server-bootstrap.template.yaml @@ -0,0 +1,6 @@ +dynamic_resources: + lds_config: + path_config_source: + path: ${config-dir}/layer2-server-listener.yaml +node: + id: layer2-${zone}-server diff --git a/examples/xds-example/src/main/resources/xds/layer3-bootstrap.template.yaml b/examples/xds-example/src/main/resources/xds/layer3-bootstrap.template.yaml new file mode 100644 index 00000000000..e50b049ccb3 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/layer3-bootstrap.template.yaml @@ -0,0 +1,6 @@ +dynamic_resources: + lds_config: + path_config_source: + path: ${config-dir}/layer3-server-listener.yaml +node: + id: layer3-server diff --git a/examples/xds-example/src/main/resources/xds/retry/layer1-client-listener.template.yaml b/examples/xds-example/src/main/resources/xds/retry/layer1-client-listener.template.yaml new file mode 100644 index 00000000000..23e64787f5d --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/retry/layer1-client-listener.template.yaml @@ -0,0 +1,58 @@ +name: layer1-client-listener +api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: layer1_ingress + route_config: + name: layer1-route + virtual_hosts: + - name: default + domains: ["*"] + retry_policy: + retry_on: "5xx" + num_retries: 3 + routes: + - match: + prefix: "/" + headers: + - name: x-layer1-zone + string_match: + exact: us-east + route: + cluster: layer1-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: us-east + - match: + prefix: "/" + headers: + - name: x-layer1-zone + string_match: + exact: us-west + route: + cluster: layer1-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: us-west + - match: + prefix: "/" + headers: + - name: x-layer1-zone + string_match: + exact: eu-west + route: + cluster: layer1-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: eu-west + - match: + prefix: "/" + route: + cluster: layer1-client-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router diff --git a/examples/xds-example/src/main/resources/xds/retry/layer2-client-listener.template.yaml b/examples/xds-example/src/main/resources/xds/retry/layer2-client-listener.template.yaml new file mode 100644 index 00000000000..7b6602ec110 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/retry/layer2-client-listener.template.yaml @@ -0,0 +1,58 @@ +name: layer2-client-listener +api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: layer2_ingress + route_config: + name: layer2-route + virtual_hosts: + - name: default + domains: ["*"] + retry_policy: + retry_on: "5xx" + num_retries: 3 + routes: + - match: + prefix: "/" + headers: + - name: x-layer2-zone + string_match: + exact: us-east + route: + cluster: layer2-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: us-east + - match: + prefix: "/" + headers: + - name: x-layer2-zone + string_match: + exact: us-west + route: + cluster: layer2-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: us-west + - match: + prefix: "/" + headers: + - name: x-layer2-zone + string_match: + exact: eu-west + route: + cluster: layer2-client-cluster + metadata_match: + filter_metadata: + envoy.lb: + region: eu-west + - match: + prefix: "/" + route: + cluster: layer2-client-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router diff --git a/examples/xds-example/src/main/resources/xds/tls/layer1-client-cluster.template.yaml b/examples/xds-example/src/main/resources/xds/tls/layer1-client-cluster.template.yaml new file mode 100644 index 00000000000..d282fccb1ad --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/tls/layer1-client-cluster.template.yaml @@ -0,0 +1,29 @@ +name: layer1-client-cluster +type: EDS +eds_cluster_config: + eds_config: + path_config_source: + path: ${config-dir}/layer1-endpoints.yaml + service_name: layer1-endpoints +transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificates: + - certificate_chain: + filename: "${client-cert-path}" + private_key: + filename: "${client-key-path}" + validation_context: + trusted_ca: + filename: "${ca-cert-path}" +lb_subset_config: + fallback_policy: ANY_ENDPOINT + subset_selectors: + - keys: ["region"] +common_lb_config: + zone_aware_lb_config: + routing_enabled: + value: 100 + min_cluster_size: 1 diff --git a/examples/xds-example/src/main/resources/xds/tls/layer2-client-cluster.template.yaml b/examples/xds-example/src/main/resources/xds/tls/layer2-client-cluster.template.yaml new file mode 100644 index 00000000000..ce7295e37dd --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/tls/layer2-client-cluster.template.yaml @@ -0,0 +1,29 @@ +name: layer2-client-cluster +type: EDS +eds_cluster_config: + eds_config: + path_config_source: + path: ${config-dir}/layer2-endpoints.yaml + service_name: layer2-endpoints +transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificates: + - certificate_chain: + filename: "${client-cert-path}" + private_key: + filename: "${client-key-path}" + validation_context: + trusted_ca: + filename: "${ca-cert-path}" +lb_subset_config: + fallback_policy: ANY_ENDPOINT + subset_selectors: + - keys: ["region"] +common_lb_config: + zone_aware_lb_config: + routing_enabled: + value: 100 + min_cluster_size: 1 diff --git a/examples/xds-example/src/main/resources/xds/tls/layer2-server-listener.template.yaml b/examples/xds-example/src/main/resources/xds/tls/layer2-server-listener.template.yaml new file mode 100644 index 00000000000..c14f0272a98 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/tls/layer2-server-listener.template.yaml @@ -0,0 +1,32 @@ +name: ${listener-name} +default_filter_chain: + filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ${listener-name} + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.router + transport_socket: + name: envoy.transport_sockets.downstream_tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext + require_client_certificate: true + common_tls_context: + tls_certificates: + - certificate_chain: + filename: "${server-cert-path}" + private_key: + filename: "${server-key-path}" + validation_context: + trusted_ca: + filename: "${ca-cert-path}" diff --git a/examples/xds-example/src/main/resources/xds/tls/layer3-server-listener.template.yaml b/examples/xds-example/src/main/resources/xds/tls/layer3-server-listener.template.yaml new file mode 100644 index 00000000000..c14f0272a98 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/tls/layer3-server-listener.template.yaml @@ -0,0 +1,32 @@ +name: ${listener-name} +default_filter_chain: + filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ${listener-name} + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.router + transport_socket: + name: envoy.transport_sockets.downstream_tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext + require_client_certificate: true + common_tls_context: + tls_certificates: + - certificate_chain: + filename: "${server-cert-path}" + private_key: + filename: "${server-key-path}" + validation_context: + trusted_ca: + filename: "${ca-cert-path}" diff --git a/examples/xds-example/src/main/resources/xds/unhealthy/layer1-endpoints.template.yaml b/examples/xds-example/src/main/resources/xds/unhealthy/layer1-endpoints.template.yaml new file mode 100644 index 00000000000..c733477bc4c --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/unhealthy/layer1-endpoints.template.yaml @@ -0,0 +1,104 @@ +cluster_name: layer1-endpoints +endpoints: + - locality: + region: us-east + load_balancing_weight: 100 + lb_endpoints: + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-east-port-1} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-east-port-2} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-east-port-3} + - locality: + region: us-west + load_balancing_weight: 100 + lb_endpoints: + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-west-port-1} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-west-port-2} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-us-west-port-3} + - locality: + region: eu-west + load_balancing_weight: 100 + lb_endpoints: + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-eu-west-port-1} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-eu-west-port-2} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer2-eu-west-port-3} diff --git a/examples/xds-example/src/main/resources/xds/unhealthy/layer2-endpoints.template.yaml b/examples/xds-example/src/main/resources/xds/unhealthy/layer2-endpoints.template.yaml new file mode 100644 index 00000000000..27709e8ea22 --- /dev/null +++ b/examples/xds-example/src/main/resources/xds/unhealthy/layer2-endpoints.template.yaml @@ -0,0 +1,104 @@ +cluster_name: layer2-endpoints +endpoints: + - locality: + region: us-east + load_balancing_weight: 100 + lb_endpoints: + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-east-port-1} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-east-port-2} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-east + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-east-port-3} + - locality: + region: us-west + load_balancing_weight: 100 + lb_endpoints: + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-west-port-1} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-west-port-2} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: us-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-us-west-port-3} + - locality: + region: eu-west + load_balancing_weight: 100 + lb_endpoints: + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-eu-west-port-1} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-eu-west-port-2} + - health_status: UNHEALTHY + metadata: + filter_metadata: + envoy.lb: + region: eu-west + endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: ${layer3-eu-west-port-3} diff --git a/examples/xds-example/src/test/java/example/armeria/xds/ServiceMeshTest.java b/examples/xds-example/src/test/java/example/armeria/xds/ServiceMeshTest.java new file mode 100644 index 00000000000..438bb9196f4 --- /dev/null +++ b/examples/xds-example/src/test/java/example/armeria/xds/ServiceMeshTest.java @@ -0,0 +1,33 @@ +package example.armeria.xds; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; + +class ServiceMeshTest { + + private static ServiceMesh mesh; + + @BeforeAll + static void setUp() { + mesh = new ServiceMesh("basic"); + } + + @AfterAll + static void tearDown() { + if (mesh != null) { + mesh.close(); + } + } + + @Test + void basicProfile() { + final AggregatedHttpResponse response = mesh.client().blocking().get("/"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + } +} diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/RetryTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/RetryTest.java index 4dc64a16e40..89f0218c9f9 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/RetryTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/RetryTest.java @@ -907,6 +907,145 @@ void retrySelectsDifferentEndpoints() { } } + //language=YAML + private static final String vhostRetryBootstrap = + """ + static_resources: + listeners: + - name: my-listener + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.\ + http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + route_config: + name: local_route + virtual_hosts: + - name: local_service1 + domains: [ "*" ] + retry_policy: + retry_on: "5xx" + num_retries: 2 + routes: + - match: + prefix: / + route: + cluster: my-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + upstream_http_filters: + - name: test.mock-response + clusters: + - name: my-cluster + type: STATIC + load_assignment: + cluster_name: my-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 8080 + """; + + @Test + void virtualHostLevelRetryPolicy() { + final HttpFilterFactory mockFactory = + mockResponseFactory(() -> HttpResponse.of(ResponseHeaders.of(503))); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(XdsResourceReader.fromYaml(vhostRetryBootstrap)) + .extensionFactories(mockFactory) + .build(); + XdsHttpPreprocessor preprocessor = XdsHttpPreprocessor.ofListener("my-listener", xdsBootstrap)) { + final ClientRequestContext ctx; + try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) { + final AggregatedHttpResponse res = + WebClient.builder(preprocessor) + .build() + .blocking() + .execute(HttpRequest.of(HttpMethod.GET, "/")); + assertThat(res.status().code()).isEqualTo(503); + ctx = captor.get(); + } + // 1 original + 2 retries = 3 total attempts + assertThat(ctx.log().children()).hasSize(3); + } + } + + @Test + void routeLevelRetryPolicyOverridesVirtualHost() { + // Virtual host says retry on 5xx with 2 retries, + // but route-level overrides with gateway-error and 1 retry. + // A 500 response should NOT trigger retries (gateway-error doesn't match 500). + //language=YAML + final String overrideBootstrap = + """ + static_resources: + listeners: + - name: my-listener + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.\ + http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + route_config: + name: local_route + virtual_hosts: + - name: local_service1 + domains: [ "*" ] + retry_policy: + retry_on: "5xx" + num_retries: 2 + routes: + - match: + prefix: / + route: + cluster: my-cluster + retry_policy: + retry_on: "gateway-error" + num_retries: 1 + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + upstream_http_filters: + - name: test.mock-response + clusters: + - name: my-cluster + type: STATIC + load_assignment: + cluster_name: my-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 8080 + """; + final HttpFilterFactory mockFactory = + mockResponseFactory(() -> HttpResponse.of(ResponseHeaders.of(500))); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(XdsResourceReader.fromYaml(overrideBootstrap)) + .extensionFactories(mockFactory) + .build(); + XdsHttpPreprocessor preprocessor = XdsHttpPreprocessor.ofListener("my-listener", xdsBootstrap)) { + final ClientRequestContext ctx; + try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) { + final AggregatedHttpResponse res = + WebClient.builder(preprocessor) + .build() + .blocking() + .execute(HttpRequest.of(HttpMethod.GET, "/")); + assertThat(res.status().code()).isEqualTo(500); + ctx = captor.get(); + } + // Route-level gateway-error doesn't match 500, so no retries + assertThat(ctx.log().children()).hasSize(1); + } + } + private static HttpFilterFactory mockResponseFactory(Supplier responseSupplier) { return mockResponseFactory((delegate, ctx, req) -> responseSupplier.get()); } diff --git a/settings.gradle b/settings.gradle index c7c2db93ca9..680560fe5be 100644 --- a/settings.gradle +++ b/settings.gradle @@ -338,3 +338,4 @@ includeWithFlags ':examples:tutorials:grpc-tutorial', 'java11' project(':examples:tutorials:grpc-tutorial').projectDir = file('examples/tutorials/grpc') includeWithFlags ':examples:tutorials:rest-api-annotated-service', 'java11' includeWithFlags ':examples:tutorials:thrift', 'java11' +includeWithFlags ':examples:xds-example', 'java17' diff --git a/xds/src/main/java/com/linecorp/armeria/xds/RouteStream.java b/xds/src/main/java/com/linecorp/armeria/xds/RouteStream.java index 38bcff0ea56..7c4e873921b 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/RouteStream.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/RouteStream.java @@ -175,10 +175,17 @@ protected Subscription onStart(SnapshotWatcher watcher) { final Map filterConfigs = FilterUtil.mergeFilterConfigs( routeConfigFilterConfigs, vhostFilterConfigs, routeFilterConfigs); - // Determine retry policy - final RetryPolicy retryPolicy = route.getRoute().getRetryPolicy(); - final RetryPolicy effectiveRetryPolicy = - retryPolicy == RetryPolicy.getDefaultInstance() ? null : retryPolicy; + // Determine retry policy: route level takes precedence over virtual host level + final RetryPolicy routeRetryPolicy = route.getRoute().getRetryPolicy(); + final RetryPolicy vhostRetryPolicy = vhostResource.resource().getRetryPolicy(); + final RetryPolicy effectiveRetryPolicy; + if (routeRetryPolicy != RetryPolicy.getDefaultInstance()) { + effectiveRetryPolicy = routeRetryPolicy; + } else if (vhostRetryPolicy != RetryPolicy.getDefaultInstance()) { + effectiveRetryPolicy = vhostRetryPolicy; + } else { + effectiveRetryPolicy = null; + } final ClientDecoration retryDecoration = FilterUtil.buildRetryDecoration(effectiveRetryPolicy); From 36c573326f1d3cbc8d0f95026825aca2efa313b0 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Fri, 7 Aug 2026 17:11:21 +0900 Subject: [PATCH 2/3] rename to xds-playground --- examples/{xds-example => xds-playground}/build.gradle | 0 examples/{xds-example => xds-playground}/docker-compose.yml | 2 +- .../grafana/dashboards/xds-traffic.json | 0 .../grafana/provisioning/dashboards/provider.yml | 0 .../grafana/provisioning/datasources/datasource.yml | 0 examples/{xds-example => xds-playground}/prometheus.yml | 2 +- .../src/main/java/example/armeria/xds/DashboardService.java | 0 .../src/main/java/example/armeria/xds/Layer2Server.java | 0 .../src/main/java/example/armeria/xds/Layer3Server.java | 0 .../src/main/java/example/armeria/xds/Main.java | 2 +- .../src/main/java/example/armeria/xds/ProfileState.java | 0 .../src/main/java/example/armeria/xds/RouteMetricsClient.java | 0 .../src/main/java/example/armeria/xds/ServiceMesh.java | 2 +- .../src/main/java/example/armeria/xds/XdsTemplateReader.java | 0 .../src/main/resources/index.html | 0 .../resources/xds/basic/layer1-client-cluster.template.yaml | 0 .../resources/xds/basic/layer1-client-listener.template.yaml | 0 .../src/main/resources/xds/basic/layer1-endpoints.template.yaml | 0 .../resources/xds/basic/layer2-client-cluster.template.yaml | 0 .../resources/xds/basic/layer2-client-listener.template.yaml | 0 .../src/main/resources/xds/basic/layer2-endpoints.template.yaml | 0 .../resources/xds/basic/layer2-server-listener.template.yaml | 0 .../resources/xds/basic/layer3-server-listener.template.yaml | 0 .../resources/xds/fault/layer2-server-listener.template.yaml | 0 .../resources/xds/fault/layer3-server-listener.template.yaml | 0 .../src/main/resources/xds/layer1-bootstrap.template.yaml | 0 .../main/resources/xds/layer2-client-bootstrap.template.yaml | 0 .../main/resources/xds/layer2-server-bootstrap.template.yaml | 0 .../src/main/resources/xds/layer3-bootstrap.template.yaml | 0 .../resources/xds/retry/layer1-client-listener.template.yaml | 0 .../resources/xds/retry/layer2-client-listener.template.yaml | 0 .../main/resources/xds/tls/layer1-client-cluster.template.yaml | 0 .../main/resources/xds/tls/layer2-client-cluster.template.yaml | 0 .../main/resources/xds/tls/layer2-server-listener.template.yaml | 0 .../main/resources/xds/tls/layer3-server-listener.template.yaml | 0 .../main/resources/xds/unhealthy/layer1-endpoints.template.yaml | 0 .../main/resources/xds/unhealthy/layer2-endpoints.template.yaml | 0 .../src/test/java/example/armeria/xds/ServiceMeshTest.java | 0 settings.gradle | 2 +- 39 files changed, 5 insertions(+), 5 deletions(-) rename examples/{xds-example => xds-playground}/build.gradle (100%) rename examples/{xds-example => xds-playground}/docker-compose.yml (93%) rename examples/{xds-example => xds-playground}/grafana/dashboards/xds-traffic.json (100%) rename examples/{xds-example => xds-playground}/grafana/provisioning/dashboards/provider.yml (100%) rename examples/{xds-example => xds-playground}/grafana/provisioning/datasources/datasource.yml (100%) rename examples/{xds-example => xds-playground}/prometheus.yml (78%) rename examples/{xds-example => xds-playground}/src/main/java/example/armeria/xds/DashboardService.java (100%) rename examples/{xds-example => xds-playground}/src/main/java/example/armeria/xds/Layer2Server.java (100%) rename examples/{xds-example => xds-playground}/src/main/java/example/armeria/xds/Layer3Server.java (100%) rename examples/{xds-example => xds-playground}/src/main/java/example/armeria/xds/Main.java (97%) rename examples/{xds-example => xds-playground}/src/main/java/example/armeria/xds/ProfileState.java (100%) rename examples/{xds-example => xds-playground}/src/main/java/example/armeria/xds/RouteMetricsClient.java (100%) rename examples/{xds-example => xds-playground}/src/main/java/example/armeria/xds/ServiceMesh.java (99%) rename examples/{xds-example => xds-playground}/src/main/java/example/armeria/xds/XdsTemplateReader.java (100%) rename examples/{xds-example => xds-playground}/src/main/resources/index.html (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/basic/layer1-client-cluster.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/basic/layer1-client-listener.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/basic/layer1-endpoints.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/basic/layer2-client-cluster.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/basic/layer2-client-listener.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/basic/layer2-endpoints.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/basic/layer2-server-listener.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/basic/layer3-server-listener.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/fault/layer2-server-listener.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/fault/layer3-server-listener.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/layer1-bootstrap.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/layer2-client-bootstrap.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/layer2-server-bootstrap.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/layer3-bootstrap.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/retry/layer1-client-listener.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/retry/layer2-client-listener.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/tls/layer1-client-cluster.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/tls/layer2-client-cluster.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/tls/layer2-server-listener.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/tls/layer3-server-listener.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/unhealthy/layer1-endpoints.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/main/resources/xds/unhealthy/layer2-endpoints.template.yaml (100%) rename examples/{xds-example => xds-playground}/src/test/java/example/armeria/xds/ServiceMeshTest.java (100%) diff --git a/examples/xds-example/build.gradle b/examples/xds-playground/build.gradle similarity index 100% rename from examples/xds-example/build.gradle rename to examples/xds-playground/build.gradle diff --git a/examples/xds-example/docker-compose.yml b/examples/xds-playground/docker-compose.yml similarity index 93% rename from examples/xds-example/docker-compose.yml rename to examples/xds-playground/docker-compose.yml index 9b98cd7008c..c1fb7785996 100644 --- a/examples/xds-example/docker-compose.yml +++ b/examples/xds-playground/docker-compose.yml @@ -1,7 +1,7 @@ # Prometheus + Grafana for xDS metrics visualization. # # Usage: -# 1. Start the xDS example: ./gradlew :examples:xds-example:run +# 1. Start the xDS example: ./gradlew :examples:xds-playground:run # 2. Start monitoring: docker compose up -d # 3. Open Grafana at http://localhost:3000 (anonymous access enabled) # 4. The "Armeria xDS Metrics" dashboard is auto-provisioned. diff --git a/examples/xds-example/grafana/dashboards/xds-traffic.json b/examples/xds-playground/grafana/dashboards/xds-traffic.json similarity index 100% rename from examples/xds-example/grafana/dashboards/xds-traffic.json rename to examples/xds-playground/grafana/dashboards/xds-traffic.json diff --git a/examples/xds-example/grafana/provisioning/dashboards/provider.yml b/examples/xds-playground/grafana/provisioning/dashboards/provider.yml similarity index 100% rename from examples/xds-example/grafana/provisioning/dashboards/provider.yml rename to examples/xds-playground/grafana/provisioning/dashboards/provider.yml diff --git a/examples/xds-example/grafana/provisioning/datasources/datasource.yml b/examples/xds-playground/grafana/provisioning/datasources/datasource.yml similarity index 100% rename from examples/xds-example/grafana/provisioning/datasources/datasource.yml rename to examples/xds-playground/grafana/provisioning/datasources/datasource.yml diff --git a/examples/xds-example/prometheus.yml b/examples/xds-playground/prometheus.yml similarity index 78% rename from examples/xds-example/prometheus.yml rename to examples/xds-playground/prometheus.yml index d0309b8e881..3449d37b679 100644 --- a/examples/xds-example/prometheus.yml +++ b/examples/xds-playground/prometheus.yml @@ -2,7 +2,7 @@ global: scrape_interval: 1s scrape_configs: - - job_name: 'armeria-xds-example' + - job_name: 'armeria-xds-playground' metrics_path: '/metrics' static_configs: - targets: ['host.docker.internal:8080'] diff --git a/examples/xds-example/src/main/java/example/armeria/xds/DashboardService.java b/examples/xds-playground/src/main/java/example/armeria/xds/DashboardService.java similarity index 100% rename from examples/xds-example/src/main/java/example/armeria/xds/DashboardService.java rename to examples/xds-playground/src/main/java/example/armeria/xds/DashboardService.java diff --git a/examples/xds-example/src/main/java/example/armeria/xds/Layer2Server.java b/examples/xds-playground/src/main/java/example/armeria/xds/Layer2Server.java similarity index 100% rename from examples/xds-example/src/main/java/example/armeria/xds/Layer2Server.java rename to examples/xds-playground/src/main/java/example/armeria/xds/Layer2Server.java diff --git a/examples/xds-example/src/main/java/example/armeria/xds/Layer3Server.java b/examples/xds-playground/src/main/java/example/armeria/xds/Layer3Server.java similarity index 100% rename from examples/xds-example/src/main/java/example/armeria/xds/Layer3Server.java rename to examples/xds-playground/src/main/java/example/armeria/xds/Layer3Server.java diff --git a/examples/xds-example/src/main/java/example/armeria/xds/Main.java b/examples/xds-playground/src/main/java/example/armeria/xds/Main.java similarity index 97% rename from examples/xds-example/src/main/java/example/armeria/xds/Main.java rename to examples/xds-playground/src/main/java/example/armeria/xds/Main.java index 9d1fd0b3e4a..62337fb137f 100644 --- a/examples/xds-example/src/main/java/example/armeria/xds/Main.java +++ b/examples/xds-playground/src/main/java/example/armeria/xds/Main.java @@ -40,7 +40,7 @@ public static void main(String[] args) { dashboard.start().join(); logger.info("Dashboard started at http://127.0.0.1:{}", dashboard.activeLocalPort()); logger.info("Run Grafana with: docker compose up"); - logger.info(" (from the armeria repo: docker compose -f examples/xds-example/docker-compose.yml up)"); + logger.info(" (from the armeria repo: docker compose -f examples/xds-playground/docker-compose.yml up)"); logger.info("Grafana dashboard at http://localhost:3000"); logger.info("Open your browser and visit the dashboard to interact with the xDS example."); } diff --git a/examples/xds-example/src/main/java/example/armeria/xds/ProfileState.java b/examples/xds-playground/src/main/java/example/armeria/xds/ProfileState.java similarity index 100% rename from examples/xds-example/src/main/java/example/armeria/xds/ProfileState.java rename to examples/xds-playground/src/main/java/example/armeria/xds/ProfileState.java diff --git a/examples/xds-example/src/main/java/example/armeria/xds/RouteMetricsClient.java b/examples/xds-playground/src/main/java/example/armeria/xds/RouteMetricsClient.java similarity index 100% rename from examples/xds-example/src/main/java/example/armeria/xds/RouteMetricsClient.java rename to examples/xds-playground/src/main/java/example/armeria/xds/RouteMetricsClient.java diff --git a/examples/xds-example/src/main/java/example/armeria/xds/ServiceMesh.java b/examples/xds-playground/src/main/java/example/armeria/xds/ServiceMesh.java similarity index 99% rename from examples/xds-example/src/main/java/example/armeria/xds/ServiceMesh.java rename to examples/xds-playground/src/main/java/example/armeria/xds/ServiceMesh.java index 2771b848df5..84e0c58ba08 100644 --- a/examples/xds-example/src/main/java/example/armeria/xds/ServiceMesh.java +++ b/examples/xds-playground/src/main/java/example/armeria/xds/ServiceMesh.java @@ -61,7 +61,7 @@ final class ServiceMesh implements AsyncCloseable { final ProfileState initialState = new ProfileState(initialProfile); try { - configDir = Files.createTempDirectory("armeria-xds-example"); + configDir = Files.createTempDirectory("armeria-xds-playground"); logger.info("xDS config directory: {}", configDir); // Generate a root CA, server cert, and client cert diff --git a/examples/xds-example/src/main/java/example/armeria/xds/XdsTemplateReader.java b/examples/xds-playground/src/main/java/example/armeria/xds/XdsTemplateReader.java similarity index 100% rename from examples/xds-example/src/main/java/example/armeria/xds/XdsTemplateReader.java rename to examples/xds-playground/src/main/java/example/armeria/xds/XdsTemplateReader.java diff --git a/examples/xds-example/src/main/resources/index.html b/examples/xds-playground/src/main/resources/index.html similarity index 100% rename from examples/xds-example/src/main/resources/index.html rename to examples/xds-playground/src/main/resources/index.html diff --git a/examples/xds-example/src/main/resources/xds/basic/layer1-client-cluster.template.yaml b/examples/xds-playground/src/main/resources/xds/basic/layer1-client-cluster.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/basic/layer1-client-cluster.template.yaml rename to examples/xds-playground/src/main/resources/xds/basic/layer1-client-cluster.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/basic/layer1-client-listener.template.yaml b/examples/xds-playground/src/main/resources/xds/basic/layer1-client-listener.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/basic/layer1-client-listener.template.yaml rename to examples/xds-playground/src/main/resources/xds/basic/layer1-client-listener.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/basic/layer1-endpoints.template.yaml b/examples/xds-playground/src/main/resources/xds/basic/layer1-endpoints.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/basic/layer1-endpoints.template.yaml rename to examples/xds-playground/src/main/resources/xds/basic/layer1-endpoints.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/basic/layer2-client-cluster.template.yaml b/examples/xds-playground/src/main/resources/xds/basic/layer2-client-cluster.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/basic/layer2-client-cluster.template.yaml rename to examples/xds-playground/src/main/resources/xds/basic/layer2-client-cluster.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/basic/layer2-client-listener.template.yaml b/examples/xds-playground/src/main/resources/xds/basic/layer2-client-listener.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/basic/layer2-client-listener.template.yaml rename to examples/xds-playground/src/main/resources/xds/basic/layer2-client-listener.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/basic/layer2-endpoints.template.yaml b/examples/xds-playground/src/main/resources/xds/basic/layer2-endpoints.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/basic/layer2-endpoints.template.yaml rename to examples/xds-playground/src/main/resources/xds/basic/layer2-endpoints.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/basic/layer2-server-listener.template.yaml b/examples/xds-playground/src/main/resources/xds/basic/layer2-server-listener.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/basic/layer2-server-listener.template.yaml rename to examples/xds-playground/src/main/resources/xds/basic/layer2-server-listener.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/basic/layer3-server-listener.template.yaml b/examples/xds-playground/src/main/resources/xds/basic/layer3-server-listener.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/basic/layer3-server-listener.template.yaml rename to examples/xds-playground/src/main/resources/xds/basic/layer3-server-listener.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/fault/layer2-server-listener.template.yaml b/examples/xds-playground/src/main/resources/xds/fault/layer2-server-listener.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/fault/layer2-server-listener.template.yaml rename to examples/xds-playground/src/main/resources/xds/fault/layer2-server-listener.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/fault/layer3-server-listener.template.yaml b/examples/xds-playground/src/main/resources/xds/fault/layer3-server-listener.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/fault/layer3-server-listener.template.yaml rename to examples/xds-playground/src/main/resources/xds/fault/layer3-server-listener.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/layer1-bootstrap.template.yaml b/examples/xds-playground/src/main/resources/xds/layer1-bootstrap.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/layer1-bootstrap.template.yaml rename to examples/xds-playground/src/main/resources/xds/layer1-bootstrap.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/layer2-client-bootstrap.template.yaml b/examples/xds-playground/src/main/resources/xds/layer2-client-bootstrap.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/layer2-client-bootstrap.template.yaml rename to examples/xds-playground/src/main/resources/xds/layer2-client-bootstrap.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/layer2-server-bootstrap.template.yaml b/examples/xds-playground/src/main/resources/xds/layer2-server-bootstrap.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/layer2-server-bootstrap.template.yaml rename to examples/xds-playground/src/main/resources/xds/layer2-server-bootstrap.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/layer3-bootstrap.template.yaml b/examples/xds-playground/src/main/resources/xds/layer3-bootstrap.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/layer3-bootstrap.template.yaml rename to examples/xds-playground/src/main/resources/xds/layer3-bootstrap.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/retry/layer1-client-listener.template.yaml b/examples/xds-playground/src/main/resources/xds/retry/layer1-client-listener.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/retry/layer1-client-listener.template.yaml rename to examples/xds-playground/src/main/resources/xds/retry/layer1-client-listener.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/retry/layer2-client-listener.template.yaml b/examples/xds-playground/src/main/resources/xds/retry/layer2-client-listener.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/retry/layer2-client-listener.template.yaml rename to examples/xds-playground/src/main/resources/xds/retry/layer2-client-listener.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/tls/layer1-client-cluster.template.yaml b/examples/xds-playground/src/main/resources/xds/tls/layer1-client-cluster.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/tls/layer1-client-cluster.template.yaml rename to examples/xds-playground/src/main/resources/xds/tls/layer1-client-cluster.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/tls/layer2-client-cluster.template.yaml b/examples/xds-playground/src/main/resources/xds/tls/layer2-client-cluster.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/tls/layer2-client-cluster.template.yaml rename to examples/xds-playground/src/main/resources/xds/tls/layer2-client-cluster.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/tls/layer2-server-listener.template.yaml b/examples/xds-playground/src/main/resources/xds/tls/layer2-server-listener.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/tls/layer2-server-listener.template.yaml rename to examples/xds-playground/src/main/resources/xds/tls/layer2-server-listener.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/tls/layer3-server-listener.template.yaml b/examples/xds-playground/src/main/resources/xds/tls/layer3-server-listener.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/tls/layer3-server-listener.template.yaml rename to examples/xds-playground/src/main/resources/xds/tls/layer3-server-listener.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/unhealthy/layer1-endpoints.template.yaml b/examples/xds-playground/src/main/resources/xds/unhealthy/layer1-endpoints.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/unhealthy/layer1-endpoints.template.yaml rename to examples/xds-playground/src/main/resources/xds/unhealthy/layer1-endpoints.template.yaml diff --git a/examples/xds-example/src/main/resources/xds/unhealthy/layer2-endpoints.template.yaml b/examples/xds-playground/src/main/resources/xds/unhealthy/layer2-endpoints.template.yaml similarity index 100% rename from examples/xds-example/src/main/resources/xds/unhealthy/layer2-endpoints.template.yaml rename to examples/xds-playground/src/main/resources/xds/unhealthy/layer2-endpoints.template.yaml diff --git a/examples/xds-example/src/test/java/example/armeria/xds/ServiceMeshTest.java b/examples/xds-playground/src/test/java/example/armeria/xds/ServiceMeshTest.java similarity index 100% rename from examples/xds-example/src/test/java/example/armeria/xds/ServiceMeshTest.java rename to examples/xds-playground/src/test/java/example/armeria/xds/ServiceMeshTest.java diff --git a/settings.gradle b/settings.gradle index 680560fe5be..0687d69ce8f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -338,4 +338,4 @@ includeWithFlags ':examples:tutorials:grpc-tutorial', 'java11' project(':examples:tutorials:grpc-tutorial').projectDir = file('examples/tutorials/grpc') includeWithFlags ':examples:tutorials:rest-api-annotated-service', 'java11' includeWithFlags ':examples:tutorials:thrift', 'java11' -includeWithFlags ':examples:xds-example', 'java17' +includeWithFlags ':examples:xds-playground', 'java17' From d4b7ccdd7cbce0c0b121435e8b43778f280e0154 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Fri, 7 Aug 2026 17:22:19 +0900 Subject: [PATCH 3/3] lint --- .../xds-playground/src/main/java/example/armeria/xds/Main.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/xds-playground/src/main/java/example/armeria/xds/Main.java b/examples/xds-playground/src/main/java/example/armeria/xds/Main.java index 62337fb137f..9984e1d5a06 100644 --- a/examples/xds-playground/src/main/java/example/armeria/xds/Main.java +++ b/examples/xds-playground/src/main/java/example/armeria/xds/Main.java @@ -40,7 +40,7 @@ public static void main(String[] args) { dashboard.start().join(); logger.info("Dashboard started at http://127.0.0.1:{}", dashboard.activeLocalPort()); logger.info("Run Grafana with: docker compose up"); - logger.info(" (from the armeria repo: docker compose -f examples/xds-playground/docker-compose.yml up)"); + logger.info(" (or docker compose -f examples/xds-playground/docker-compose.yml up)"); logger.info("Grafana dashboard at http://localhost:3000"); logger.info("Open your browser and visit the dashboard to interact with the xDS example."); }