1   /*
2    * Copyright 2026 LY Corporation
3    *
4    * LY Corporation licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  package com.linecorp.centraldogma.xds.k8s.v1;
17  
18  import java.util.HashSet;
19  import java.util.LinkedHashMap;
20  import java.util.List;
21  import java.util.Map;
22  import java.util.Set;
23  
24  import org.jspecify.annotations.Nullable;
25  
26  import com.google.protobuf.Struct;
27  import com.google.protobuf.Value;
28  
29  import com.linecorp.armeria.client.Endpoint;
30  import com.linecorp.armeria.client.kubernetes.endpoints.KubernetesResourceAccess;
31  
32  import io.envoyproxy.envoy.config.core.v3.Address;
33  import io.envoyproxy.envoy.config.core.v3.Metadata;
34  import io.envoyproxy.envoy.config.core.v3.SocketAddress;
35  import io.envoyproxy.envoy.config.endpoint.v3.LbEndpoint;
36  import io.envoyproxy.envoy.config.endpoint.v3.LocalityLbEndpoints;
37  import io.fabric8.kubernetes.api.model.Node;
38  import io.fabric8.kubernetes.api.model.ObjectMeta;
39  import io.fabric8.kubernetes.api.model.Pod;
40  
41  /**
42   * Converts Kubernetes {@link Endpoint}s into Envoy {@link LbEndpoint}s, applying the
43   * {@link ServiceEndpointWatcher#getDistinctEndpoint() distinct_endpoint} and
44   * {@link ServiceEndpointWatcher#getMetadataMappingList() metadata_mapping} options.
45   */
46  final class KubernetesEndpointConverter {
47  
48      private static final String DEFAULT_METADATA_NAMESPACE = "envoy.lb";
49  
50      /**
51       * Appends an {@link LbEndpoint} to the specified {@code builder} for each {@link Endpoint}, collapsing
52       * endpoints that share the same host and port when {@code distinct_endpoint} is enabled and copying the
53       * Pod/Node label/annotation values described by {@code metadata_mapping} into the endpoint metadata.
54       */
55      static void addLbEndpoints(LocalityLbEndpoints.Builder builder, Iterable<Endpoint> endpoints,
56                                 ServiceEndpointWatcher watcher) {
57          final boolean distinct = watcher.getDistinctEndpoint();
58          final List<MetadataMapping> mappings = watcher.getMetadataMappingList();
59          final Set<String> seen = distinct ? new HashSet<>() : null;
60          for (Endpoint endpoint : endpoints) {
61              if (!endpoint.hasPort()) {
62                  continue;
63              }
64              if (seen != null && !seen.add(endpoint.host() + ':' + endpoint.port())) {
65                  continue;
66              }
67              final SocketAddress socketAddress = SocketAddress.newBuilder()
68                                                               .setAddress(endpoint.host())
69                                                               .setPortValue(endpoint.port())
70                                                               .build();
71              final LbEndpoint.Builder lbEndpointBuilder =
72                      LbEndpoint.newBuilder()
73                                .setEndpoint(io.envoyproxy.envoy.config.endpoint.v3.Endpoint.newBuilder()
74                                                      .setAddress(Address.newBuilder()
75                                                                         .setSocketAddress(socketAddress)
76                                                                         .build())
77                                                      .build());
78              final Metadata metadata = buildMetadata(endpoint, mappings);
79              if (metadata != null) {
80                  lbEndpointBuilder.setMetadata(metadata);
81              }
82              builder.addLbEndpoints(lbEndpointBuilder.build());
83          }
84      }
85  
86      @Nullable
87      private static Metadata buildMetadata(Endpoint endpoint, List<MetadataMapping> mappings) {
88          if (mappings.isEmpty()) {
89              return null;
90          }
91          final Map<String, Struct.Builder> structsByNamespace = new LinkedHashMap<>();
92          for (MetadataMapping mapping : mappings) {
93              final ObjectMeta objectMeta = objectMeta(endpoint, mapping.getResourceType());
94              if (objectMeta == null) {
95                  continue;
96              }
97              final Map<String, String> source =
98                      mapping.getEntryType() == MetadataMapping.EntryType.ANNOTATION ? objectMeta.getAnnotations()
99                                                                                     : objectMeta.getLabels();
100             if (source == null || source.isEmpty()) {
101                 continue;
102             }
103             final String namespace = mapping.getMetadataNamespace().isEmpty() ? DEFAULT_METADATA_NAMESPACE
104                                                                               : mapping.getMetadataNamespace();
105             switch (mapping.getSourceCase()) {
106                 case SOURCE_KEY:
107                     // Exact match: the destination key is metadata_key or, if empty, the source key.
108                     final String value = source.get(mapping.getSourceKey());
109                     if (value != null) {
110                         final String key = mapping.getMetadataKey().isEmpty() ? mapping.getSourceKey()
111                                                                               : mapping.getMetadataKey();
112                         putField(structsByNamespace, namespace, key, value);
113                     }
114                     break;
115                 case SOURCE_KEY_PREFIX:
116                     // Prefix match: copy every matching entry, preserving the original key.
117                     final String prefix = mapping.getSourceKeyPrefix();
118                     for (Map.Entry<String, String> entry : source.entrySet()) {
119                         if (entry.getKey().startsWith(prefix)) {
120                             putField(structsByNamespace, namespace, entry.getKey(), entry.getValue());
121                         }
122                     }
123                     break;
124                 default:
125                     // SOURCE_NOT_SET is rejected by validation.
126                     break;
127             }
128         }
129         if (structsByNamespace.isEmpty()) {
130             return null;
131         }
132         final Metadata.Builder metadataBuilder = Metadata.newBuilder();
133         structsByNamespace.forEach((namespace, struct) ->
134                                            metadataBuilder.putFilterMetadata(namespace, struct.build()));
135         return metadataBuilder.build();
136     }
137 
138     private static void putField(Map<String, Struct.Builder> structsByNamespace, String namespace,
139                                  String key, String value) {
140         structsByNamespace.computeIfAbsent(namespace, unused -> Struct.newBuilder())
141                           .putFields(key, Value.newBuilder().setStringValue(value).build());
142     }
143 
144     @Nullable
145     private static ObjectMeta objectMeta(Endpoint endpoint, MetadataMapping.ResourceType resourceType) {
146         switch (resourceType) {
147             case POD:
148                 final Pod pod = KubernetesResourceAccess.pod(endpoint);
149                 return pod != null ? pod.getMetadata() : null;
150             case NODE:
151                 final Node node = KubernetesResourceAccess.node(endpoint);
152                 return node != null ? node.getMetadata() : null;
153             default:
154                 return null;
155         }
156     }
157 
158     private KubernetesEndpointConverter() {}
159 }