diff --git a/xds-api/src/main/java/com/linecorp/armeria/xds/api/IgnoreUnsupportedFieldHandler.java b/xds-api/src/main/java/com/linecorp/armeria/xds/api/IgnoreUnsupportedFieldHandler.java new file mode 100644 index 00000000000..d45f9c45ff5 --- /dev/null +++ b/xds-api/src/main/java/com/linecorp/armeria/xds/api/IgnoreUnsupportedFieldHandler.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.api; + +enum IgnoreUnsupportedFieldHandler implements UnsupportedFieldHandler { + INSTANCE; + + @Override + public void handle(String descriptorName, String fieldPath, Object value) { + // no-op + } +} diff --git a/xds-api/src/main/java/com/linecorp/armeria/xds/api/SupportedFieldValidator.java b/xds-api/src/main/java/com/linecorp/armeria/xds/api/SupportedFieldValidator.java new file mode 100644 index 00000000000..3e31b0a3614 --- /dev/null +++ b/xds-api/src/main/java/com/linecorp/armeria/xds/api/SupportedFieldValidator.java @@ -0,0 +1,183 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.api; + +import static java.util.Objects.requireNonNull; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.protobuf.Descriptors; +import com.google.protobuf.Descriptors.EnumValueDescriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Message; + +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * Validates protobuf messages against the {@code (armeria.xds.supported)} field annotation. + * Any set field that lacks the annotation is reported as an unsupported field violation. + * The validator walks recursively into supported message-typed fields. + * + *

Currently, supported fields are annotated inline on each field declaration in the proto files, e.g.: + *

{@code
+ * string exact = 1 [(armeria.xds.supported) = true];
+ * }
+ */ +@UnstableApi +public final class SupportedFieldValidator { + + static final Logger unsupportedLogger = LoggerFactory.getLogger("com.linecorp.armeria.xds.unsupported"); + private static final SupportedFieldValidator DEFAULT = new SupportedFieldValidator( + UnsupportedFieldHandler.warn()); + private static final SupportedFieldValidator NOOP = new SupportedFieldValidator( + UnsupportedFieldHandler.ignore()); + + private final ConcurrentMap> supportedFieldsCache = + new ConcurrentHashMap<>(); + + private final UnsupportedFieldHandler handler; + + private SupportedFieldValidator(UnsupportedFieldHandler handler) { + this.handler = requireNonNull(handler, "handler"); + } + + /** + * Returns a {@link SupportedFieldValidator} with the default {@link UnsupportedFieldHandler#warn()} + * handler. + */ + public static SupportedFieldValidator of() { + return DEFAULT; + } + + /** + * Returns a {@link SupportedFieldValidator} with the specified {@link UnsupportedFieldHandler}. + */ + public static SupportedFieldValidator of(UnsupportedFieldHandler handler) { + requireNonNull(handler, "handler"); + if (handler == IgnoreUnsupportedFieldHandler.INSTANCE) { + return NOOP; + } + return new SupportedFieldValidator(handler); + } + + /** + * Returns a no-op validator that does not perform any validation. + */ + public static SupportedFieldValidator noop() { + return NOOP; + } + + /** + * Validates the message, calling the handler directly for each unsupported field found. + * If the handler is the {@link UnsupportedFieldHandler#ignore()} sentinel, returns immediately + * to skip recursion cost. + */ + public void validate(Message message) { + requireNonNull(message, "message"); + if (handler == IgnoreUnsupportedFieldHandler.INSTANCE) { + return; + } + final String descriptorName = message.getDescriptorForType().getFullName(); + doValidate(message, descriptorName, "$"); + } + + @SuppressWarnings("unchecked") + private void doValidate(Message message, String descriptorName, String path) { + if (unsupportedPackage(message.getDescriptorForType().getFile().getPackage())) { + return; + } + final Descriptors.Descriptor descriptor = message.getDescriptorForType(); + final Set supported = supportedFields(descriptor); + + for (Map.Entry entry : message.getAllFields().entrySet()) { + final FieldDescriptor fd = entry.getKey(); + final Object value = entry.getValue(); + final String fieldPath = path + '.' + fd.getJsonName(); + + if (!supported.contains(fd)) { + handler.handle(descriptorName, fieldPath, value); + continue; + } + + // Field is supported — check enum values and recurse into nested messages. + if (fd.isMapField()) { + final FieldDescriptor valueField = fd.getMessageType().findFieldByNumber(2); + if (valueField != null) { + final List mapEntries = (List) value; + for (int i = 0; i < mapEntries.size(); i++) { + validateFieldValue(valueField, mapEntries.get(i).getField(valueField), + descriptorName, fieldPath + '[' + i + "].value"); + } + } + } else if (fd.isRepeated()) { + final List elements = (List) value; + for (int i = 0; i < elements.size(); i++) { + validateFieldValue(fd, elements.get(i), descriptorName, + fieldPath + '[' + i + ']'); + } + } else { + validateFieldValue(fd, value, descriptorName, fieldPath); + } + } + } + + private void validateFieldValue(FieldDescriptor fd, Object value, + String descriptorName, String fieldPath) { + if (fd.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { + if (value instanceof Message) { + doValidate((Message) value, descriptorName, fieldPath); + } + } else if (fd.getJavaType() == FieldDescriptor.JavaType.ENUM) { + if (!unsupportedPackage(fd.getEnumType().getFile().getPackage()) && + value instanceof EnumValueDescriptor) { + final EnumValueDescriptor ev = (EnumValueDescriptor) value; + if (unsupportedEnumValue(ev)) { + handler.handle(descriptorName, fieldPath, ev); + } + } + } + } + + private static boolean unsupportedEnumValue(EnumValueDescriptor ev) { + return !ev.getOptions().getExtension(SupportedFieldProto.supportedValue); + } + + private static boolean unsupportedPackage(String pkg) { + return !(pkg.startsWith("envoy.") || pkg.startsWith("xds.") || pkg.startsWith("armeria.")); + } + + private Set supportedFields(Descriptors.Descriptor descriptor) { + return supportedFieldsCache.computeIfAbsent(descriptor, d -> { + final Set result = new HashSet<>(); + for (FieldDescriptor fd : d.getFields()) { + if (fd.getOptions().getExtension(SupportedFieldProto.supported)) { + result.add(fd); + } + } + return Collections.unmodifiableSet(result); + }); + } +} diff --git a/xds-api/src/main/java/com/linecorp/armeria/xds/api/UnsupportedFieldHandler.java b/xds-api/src/main/java/com/linecorp/armeria/xds/api/UnsupportedFieldHandler.java new file mode 100644 index 00000000000..93b02eff63b --- /dev/null +++ b/xds-api/src/main/java/com/linecorp/armeria/xds/api/UnsupportedFieldHandler.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.api; + +import static com.linecorp.armeria.xds.api.SupportedFieldValidator.unsupportedLogger; +import static java.util.Objects.requireNonNull; + +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A handler that is invoked when unsupported xDS fields are detected in a protobuf message. + * Unsupported fields are those not annotated with {@code (armeria.xds.supported) = true}. + */ +@UnstableApi +@FunctionalInterface +public interface UnsupportedFieldHandler { + + /** + * Called when an unsupported field is detected. + * + * @param descriptorName the full name of the root message being validated + * (e.g., {@code "envoy.config.cluster.v3.Cluster"}) + * @param fieldPath the JSON path of the unsupported field (e.g., {@code "$.edsConfig.serviceName"}) + * @param value the raw value of the unsupported field + */ + void handle(String descriptorName, String fieldPath, Object value); + + /** + * Returns a composed handler that first invokes this handler, then the {@code after} handler. + */ + default UnsupportedFieldHandler andThen(UnsupportedFieldHandler after) { + requireNonNull(after, "after"); + return (descriptorName, fieldPath, value) -> { + handle(descriptorName, fieldPath, value); + after.handle(descriptorName, fieldPath, value); + }; + } + + /** + * Returns a handler that logs a warning for each unsupported field path. + */ + static UnsupportedFieldHandler warn() { + return (descriptorName, fieldPath, value) -> + unsupportedLogger.warn("Unsupported xDS field detected in {}: {}", descriptorName, fieldPath); + } + + /** + * Returns a handler that throws an {@link IllegalArgumentException} on the first unsupported field. + */ + static UnsupportedFieldHandler reject() { + return (descriptorName, fieldPath, value) -> { + throw new IllegalArgumentException( + "Unsupported xDS field detected in " + descriptorName + ": " + fieldPath); + }; + } + + /** + * Returns a handler that silently ignores unsupported fields. + */ + static UnsupportedFieldHandler ignore() { + return IgnoreUnsupportedFieldHandler.INSTANCE; + } +} diff --git a/xds-api/src/main/proto/armeria/xds/supported.proto b/xds-api/src/main/proto/armeria/xds/supported.proto new file mode 100644 index 00000000000..ba594135ca6 --- /dev/null +++ b/xds-api/src/main/proto/armeria/xds/supported.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +package armeria.xds; + +option java_package = "com.linecorp.armeria.xds.api"; +option java_outer_classname = "SupportedFieldProto"; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional bool supported = 50000; +} + +extend google.protobuf.EnumValueOptions { + // Distinct name from the FieldOptions extension to avoid Java codegen name collision. + optional bool supported_value = 50000; +} diff --git a/xds-api/src/test/java/com/linecorp/armeria/xds/api/SupportedFieldValidatorTest.java b/xds-api/src/test/java/com/linecorp/armeria/xds/api/SupportedFieldValidatorTest.java new file mode 100644 index 00000000000..1f5c7bc1d05 --- /dev/null +++ b/xds-api/src/test/java/com/linecorp/armeria/xds/api/SupportedFieldValidatorTest.java @@ -0,0 +1,297 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.google.protobuf.Any; +import com.google.protobuf.Descriptors.EnumValueDescriptor; +import com.google.protobuf.Duration; +import com.google.protobuf.UInt32Value; + +import com.linecorp.armeria.xds.api.testing.TestCluster; +import com.linecorp.armeria.xds.api.testing.TestDiscoveryType; +import com.linecorp.armeria.xds.api.testing.TestEdsConfig; +import com.linecorp.armeria.xds.api.testing.TestOutlierDetection; + +class SupportedFieldValidatorTest { + + @Test + void allSupportedFields() { + final List violations = new ArrayList<>(); + final SupportedFieldValidator validator = + SupportedFieldValidator.of((descriptor, path, value) -> violations.add(path)); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setType(TestDiscoveryType.STATIC) + .build(); + validator.validate(cluster); + assertThat(violations).isEmpty(); + } + + @Test + void unsupportedScalarField() { + final List violations = new ArrayList<>(); + final List values = new ArrayList<>(); + final SupportedFieldValidator validator = + SupportedFieldValidator.of((descriptor, path, value) -> { + violations.add(path); + values.add(value); + }); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setUnsupportedField("bad") + .build(); + validator.validate(cluster); + assertThat(violations).containsExactly("$.unsupportedField"); + assertThat(values).containsExactly("bad"); + } + + @Test + void unsupportedMessageField() { + final List violations = new ArrayList<>(); + final List values = new ArrayList<>(); + final SupportedFieldValidator validator = + SupportedFieldValidator.of((descriptor, path, value) -> { + violations.add(path); + values.add(value); + }); + + final TestOutlierDetection outlier = TestOutlierDetection.newBuilder() + .setConsecutiveErrors(5) + .build(); + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setOutlierDetection(outlier) + .build(); + validator.validate(cluster); + assertThat(violations).containsExactly("$.outlierDetection"); + assertThat(values).containsExactly(outlier); + } + + @Test + void recursiveNestedUnsupported() { + final List violations = new ArrayList<>(); + final List values = new ArrayList<>(); + final SupportedFieldValidator validator = + SupportedFieldValidator.of((descriptor, path, value) -> { + violations.add(path); + values.add(value); + }); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setEdsConfig( + TestEdsConfig.newBuilder() + .setServiceName("svc") + .setUnsupportedNested("bad") + .build()) + .build(); + validator.validate(cluster); + assertThat(violations).containsExactly("$.edsConfig.unsupportedNested"); + assertThat(values).containsExactly("bad"); + } + + @Test + void unsupportedEnumValue() { + final List violations = new ArrayList<>(); + final List values = new ArrayList<>(); + final SupportedFieldValidator validator = + SupportedFieldValidator.of((descriptor, path, value) -> { + violations.add(path); + values.add(value); + }); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setType(TestDiscoveryType.LOGICAL_DNS) + .build(); + validator.validate(cluster); + assertThat(violations).containsExactly("$.type"); + assertThat(values).hasSize(1); + assertThat(((EnumValueDescriptor) values.get(0)).getName()).isEqualTo("LOGICAL_DNS"); + } + + @Test + void rejectHandler() { + final SupportedFieldValidator validator = SupportedFieldValidator.of( + UnsupportedFieldHandler.reject()); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setUnsupportedField("bad") + .build(); + assertThatThrownBy(() -> validator.validate(cluster)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("$.unsupportedField"); + } + + @Test + void ignoreHandler() { + final SupportedFieldValidator validator = SupportedFieldValidator.of( + UnsupportedFieldHandler.ignore()); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setUnsupportedField("bad") + .build(); + // Should not throw + validator.validate(cluster); + } + + @Test + void fullyUnannotatedMessage() { + final List violations = new ArrayList<>(); + final SupportedFieldValidator validator = + SupportedFieldValidator.of((descriptor, path, value) -> violations.add(path)); + + final TestOutlierDetection outlier = TestOutlierDetection.newBuilder() + .setConsecutiveErrors(5) + .build(); + validator.validate(outlier); + assertThat(violations).containsExactly("$.consecutiveErrors"); + } + + @Test + void noopValidatorDoesNothing() { + final SupportedFieldValidator validator = SupportedFieldValidator.noop(); + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setUnsupportedField("bad") + .build(); + // Should not throw - noop ignores everything + validator.validate(cluster); + } + + @Test + void andThenComposition() { + final List first = new ArrayList<>(); + final List second = new ArrayList<>(); + final UnsupportedFieldHandler composed = + ((UnsupportedFieldHandler) (descriptor, path, value) -> first.add(path)) + .andThen((descriptor, path, value) -> second.add(path)); + final SupportedFieldValidator validator = SupportedFieldValidator.of(composed); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setUnsupportedField("bad") + .build(); + validator.validate(cluster); + assertThat(first).containsExactly("$.unsupportedField"); + assertThat(second).containsExactly("$.unsupportedField"); + } + + @Test + void rejectFailsFast() { + final UnsupportedFieldHandler failFast = UnsupportedFieldHandler.reject(); + final SupportedFieldValidator validator = SupportedFieldValidator.of(failFast); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setUnsupportedField("bad") + .setOutlierDetection( + TestOutlierDetection.newBuilder() + .setConsecutiveErrors(5) + .build()) + .build(); + assertThatThrownBy(() -> validator.validate(cluster)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void ignoreEarlyExit() { + // Validate that ignore() handler causes early exit (no recursion) + // This is a behavior test — the ignore handler should skip validation entirely + final SupportedFieldValidator validator = SupportedFieldValidator.of( + UnsupportedFieldHandler.ignore()); + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setUnsupportedField("bad") + .setOutlierDetection( + TestOutlierDetection.newBuilder() + .setConsecutiveErrors(5) + .build()) + .build(); + // Should not throw — ignore skips all validation + validator.validate(cluster); + } + + @Test + void unsupportedEnumValueInMapField() { + final List violations = new ArrayList<>(); + final List values = new ArrayList<>(); + final SupportedFieldValidator validator = + SupportedFieldValidator.of((descriptor, path, value) -> { + violations.add(path); + values.add(value); + }); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .putTypeMap("a", TestDiscoveryType.STATIC) + .putTypeMap("b", TestDiscoveryType.LOGICAL_DNS) + .build(); + validator.validate(cluster); + assertThat(violations).hasSize(1); + assertThat(violations.get(0)).endsWith(".value"); + assertThat(((EnumValueDescriptor) values.get(0)).getName()).isEqualTo("LOGICAL_DNS"); + } + + @Test + void supportedEnumValueInMapField() { + final List violations = new ArrayList<>(); + final SupportedFieldValidator validator = + SupportedFieldValidator.of((descriptor, path, value) -> violations.add(path)); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .putTypeMap("a", TestDiscoveryType.STATIC) + .putTypeMap("b", TestDiscoveryType.EDS) + .build(); + validator.validate(cluster); + assertThat(violations).isEmpty(); + } + + @Test + void externalTypesDoNotProduceFalsePositives() { + final List violations = new ArrayList<>(); + final SupportedFieldValidator validator = + SupportedFieldValidator.of((descriptor, path, value) -> violations.add(path)); + + final TestCluster cluster = TestCluster.newBuilder() + .setName("test") + .setTypedConfig(Any.pack( + UInt32Value.of(42))) + .setMaxRequests(UInt32Value.of(100)) + .setTimeout(Duration.newBuilder() + .setSeconds(30) + .build()) + .build(); + validator.validate(cluster); + // External types (Any, UInt32Value, Duration) should not be recursed into, + // so their internal fields (type_url, value, seconds, nanos) must not appear. + assertThat(violations).isEmpty(); + } +} diff --git a/xds-api/src/test/proto/armeria/xds/testing/test_supported.proto b/xds-api/src/test/proto/armeria/xds/testing/test_supported.proto new file mode 100644 index 00000000000..318d622633f --- /dev/null +++ b/xds-api/src/test/proto/armeria/xds/testing/test_supported.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; +package armeria.xds.testing; + +option java_package = "com.linecorp.armeria.xds.api.testing"; +option java_multiple_files = true; + +import "armeria/xds/supported.proto"; +import "google/protobuf/any.proto"; +import "google/protobuf/wrappers.proto"; +import "google/protobuf/duration.proto"; + +message TestCluster { + string name = 1 [(armeria.xds.supported) = true]; + TestDiscoveryType type = 2 [(armeria.xds.supported) = true]; + TestEdsConfig eds_config = 3 [(armeria.xds.supported) = true]; + string unsupported_field = 4; + TestOutlierDetection outlier_detection = 5; + google.protobuf.Any typed_config = 6 [(armeria.xds.supported) = true]; + google.protobuf.UInt32Value max_requests = 7 [(armeria.xds.supported) = true]; + google.protobuf.Duration timeout = 8 [(armeria.xds.supported) = true]; + map type_map = 9 [(armeria.xds.supported) = true]; +} + +enum TestDiscoveryType { + STATIC = 0 [(armeria.xds.supported_value) = true]; + EDS = 1 [(armeria.xds.supported_value) = true]; + LOGICAL_DNS = 2; +} + +message TestEdsConfig { + string service_name = 1 [(armeria.xds.supported) = true]; + string unsupported_nested = 2; +} + +message TestOutlierDetection { + int32 consecutive_errors = 1; +} diff --git a/xds-validator/src/main/java/com/linecorp/armeria/xds/validator/XdsValidatorIndex.java b/xds-validator/src/main/java/com/linecorp/armeria/xds/validator/XdsValidatorIndex.java index dd5b3fbe3e1..957f9d615b4 100644 --- a/xds-validator/src/main/java/com/linecorp/armeria/xds/validator/XdsValidatorIndex.java +++ b/xds-validator/src/main/java/com/linecorp/armeria/xds/validator/XdsValidatorIndex.java @@ -1,5 +1,5 @@ /* - * Copyright 2025 LY Corporation + * Copyright 2026 LY Corporation * * LY Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -21,6 +21,7 @@ /** * Validates an xDS resource. Validators are loaded using Java SPI (Service Provider Interface). */ +@FunctionalInterface @UnstableApi public interface XdsValidatorIndex {