janhoy commented on code in PR #1548: URL: https://github.com/apache/solr/pull/1548#discussion_r2900065480
########## solr/modules/kubernetes/src/java/org/apache/solr/cloud/kube/KubernetesConfigSetService.java: ########## @@ -0,0 +1,490 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.cloud.kube; + +import io.kubernetes.client.informer.ResourceEventHandler; +import io.kubernetes.client.informer.SharedIndexInformer; +import io.kubernetes.client.informer.SharedInformerFactory; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1ConfigMapList; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.util.CallGeneratorParams; +import io.kubernetes.client.util.ClientBuilder; +import io.kubernetes.client.util.labels.SetMatcher; +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.cloud.ZkMaintenanceUtils; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.Utils; +import org.apache.solr.core.ConfigSetProperties; +import org.apache.solr.core.ConfigSetService; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.core.CoreDescriptor; +import org.apache.solr.core.SolrConfig; +import org.apache.solr.core.SolrResourceLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Kubernetes ConfigSetService impl. + * + * <p>Loads a ConfigSet defined by the core's configSet property, looking for a Kubernetes ConfigMap + * labeled for the given SolrCloud instance. If no configSet property is set, loads the ConfigSet + * instead from the core's instance directory. + */ +public class KubernetesConfigSetService extends ConfigSetService { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + private final ApiClient kubeClient; + private final CoreV1Api coreV1Api; + + private final String solrCloudNamespace; + private final String solrCloudName; + + private final Map<String, V1ConfigMap> existingConfigSetConfigMaps; + + /** ConfigMapLabel */ + public static final String CONFIG_SET_LABEL_KEY = "solr.apache.org/cloud/%s/resource"; + + public static final String CONFIG_SET_LABEL_VALUE = "configSet"; + public static final String CONFIG_SET_NAME_ANNOTATION_KEY = "solr.apache.org/configSet/name"; + + // These are always set on pods by the Solr Operator + public static final String POD_NAMESPACE_ENV_VAR = "POD_NAMESPACE"; + public static final String SOLR_CLOUD_NAME_ENV_VAR = "SOLR_CLOUD_NAME"; + + // Special ConfigMap data paths + public static final String CONFIG_SET_METADATA_KEY = "_metadata.json"; + public static final String CONFIG_SET_PROPERTIES_KEY = "_properties.json"; + + public KubernetesConfigSetService(CoreContainer cc) throws IOException { + super(cc.getResourceLoader(), cc.getConfig().hasSchemaCache()); + kubeClient = ClientBuilder.cluster().build(); + coreV1Api = new CoreV1Api(kubeClient); + + existingConfigSetConfigMaps = new ConcurrentHashMap<>(); + // TODO: Finalize these + solrCloudNamespace = System.getenv(POD_NAMESPACE_ENV_VAR); + solrCloudName = System.getenv(SOLR_CLOUD_NAME_ENV_VAR); + } + + public void init() { + SharedInformerFactory factory = new SharedInformerFactory(kubeClient); + + // ConfigMap informer + SharedIndexInformer<V1ConfigMap> nodeInformer = + factory.sharedIndexInformerFor( + // **NOTE**: + // The following "CallGenerator" lambda merely generates a stateless + // HTTPS request, the effective apiClient is the one specified when constructing + // the informer-factory. + (CallGeneratorParams params) -> + coreV1Api + .listNamespacedConfigMap(solrCloudNamespace) + .labelSelector( + SetMatcher.in( + String.format(Locale.ROOT, CONFIG_SET_LABEL_KEY, solrCloudName), + CONFIG_SET_LABEL_VALUE) + .toString()) + .resourceVersion(params.resourceVersion) + .timeoutSeconds(params.timeoutSeconds) + .watch(params.watch) + .buildCall(null), + V1ConfigMap.class, + V1ConfigMapList.class); + + nodeInformer.addEventHandler( + new ResourceEventHandler<>() { + @Override + public void onAdd(V1ConfigMap configMap) { + if (log.isInfoEnabled()) { + log.info("{} configMap added!", configMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.put(extractConfigSetName(configMap), configMap); + } + + @Override + public void onUpdate(V1ConfigMap oldConfigMap, V1ConfigMap newConfigMap) { + if (log.isInfoEnabled()) { + log.info( + "{} => {} configMap updated!", + oldConfigMap.getMetadata().getName(), + newConfigMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.put(extractConfigSetName(newConfigMap), newConfigMap); + } + + @Override + public void onDelete(V1ConfigMap configMap, boolean deletedFinalStateUnknown) { + if (log.isInfoEnabled()) { + log.info("{} configMap deleted!", configMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.remove(extractConfigSetName(configMap)); + } + }); + + factory.startAllRegisteredInformers(); + } + + private String extractConfigSetName(V1ConfigMap configMap) { + return Optional.ofNullable(configMap.getMetadata()) + .map(V1ObjectMeta::getAnnotations) + .map(ann -> ann.get(CONFIG_SET_NAME_ANNOTATION_KEY)) + .orElse(configMap.getMetadata().getName()); + } + + private V1ConfigMap getCachedConfigMap(String configName) throws SolrException { + V1ConfigMap configMap = existingConfigSetConfigMaps.get(configName); + if (configMap == null) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, + String.format(Locale.ROOT, "No ConfigMap exists for ConfigSet %s ", configName)); + } + if (configMap.getMetadata() == null) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + String.format( + Locale.ROOT, + "ConfigMap exists for ConfigSet %s, but it contains no metadata", + configName)); + } + return configMap; + } + + @Override + protected SolrResourceLoader createCoreResourceLoader(CoreDescriptor cd) { + // TODO: In SolrCloud mode, the configSet should be determined from the collection state. + // For now, use the configSet from the CoreDescriptor. + String configSetName = cd.getConfigSet(); + + return new KubernetesSolrResourceLoader( + cd.getInstanceDir(), configSetName, parentLoader.getClassLoader(), coreV1Api); + } + + @Override + protected NamedList<Object> loadConfigSetFlags(SolrResourceLoader loader) { + try { + return ConfigSetProperties.readFromResourceLoader(loader, CONFIG_SET_METADATA_KEY); + } catch (Exception ex) { + log.debug("No configSet flags", ex); + return null; + } + } + + @Override + protected Long getCurrentSchemaModificationVersion( + String configSet, SolrConfig solrConfig, String schemaFile) { + // Individual values/files in ConfigMaps do not have versions, + // we can only use the ConfigMap version as a whole. + // + // Return null if this configMap does not exist. + return Optional.ofNullable(existingConfigSetConfigMaps.get(configSet)) + .map(V1ConfigMap::getMetadata) + .map(V1ObjectMeta::getGeneration) + .orElse(null); + } + + @Override + public String configSetName(CoreDescriptor cd) { + return "configmap " + cd.getConfigSet(); + } + + @Override + public boolean checkConfigExists(String configName) { + return existingConfigSetConfigMaps.containsKey(configName); + } + + @Override + public void deleteConfig(String configName) throws IOException { + String configMapName = ""; + try { + if (existingConfigSetConfigMaps.containsKey(configName)) { + V1ConfigMap configMap = existingConfigSetConfigMaps.get(configName); + configMapName = configMap.getMetadata().getName(); + coreV1Api + .deleteNamespacedConfigMap(configMapName, configMap.getMetadata().getNamespace()) + .execute(); + } + } catch (ApiException e) { + throw new IOException( + String.format( + Locale.ROOT, + "Error deleting configMap %s, representing the configSet %s", + configMapName, + configName), + e); + } + } + + @Override + public void deleteFilesFromConfig(String configName, List<String> filesToDelete) + throws IOException { + if (filesToDelete == null) { + return; + } + V1ConfigMap configMap = getCachedConfigMap(configName); + var existingData = configMap.getData(); + Set<String> chosenFilesToDelete = new HashSet<>(filesToDelete); + if (existingData != null) { + // If there is existing data cached, then only delete the files we know to exist + // If there is no existing data cached, then try to do a patch delete and fail if it doesn't + // work. + chosenFilesToDelete.retainAll(existingData.keySet()); + } + // TODO: Patch the data to delete the files + throw new UnsupportedOperationException( + "deleteFilesFromConfig not yet implemented for Kubernetes"); + } + + private V1ConfigMap newConfigMap(String configName) { + // Follow Kubernetes name rules: + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names + configName = configName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-.]", "-"); + if (configName.endsWith("-") || configName.endsWith(".")) { + configName = configName.substring(0, configName.length() - 1); + } + return new V1ConfigMap() + .metadata( + new V1ObjectMeta() + .name( + String.format( + Locale.ROOT, "solrcloud-%s-configset-%s", solrCloudName, configName)) + .namespace(solrCloudNamespace) + .putLabelsItem( + String.format(Locale.ROOT, CONFIG_SET_LABEL_KEY, solrCloudName), + CONFIG_SET_LABEL_VALUE) + .putAnnotationsItem(CONFIG_SET_NAME_ANNOTATION_KEY, configName)); + } + + @Override + public void copyConfig(String fromConfig, String toConfig) throws IOException { + try { + V1ConfigMap fromConfigMap = getCachedConfigMap(fromConfig); + var existingData = fromConfigMap.getData(); + if (existingData == null) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + String.format( + Locale.ROOT, + "Cannot copy configSet %s, it has no data in its configMap %s", + fromConfig, + fromConfigMap.getMetadata().getName())); + } + if (existingConfigSetConfigMaps.containsKey(toConfig)) { + // Patch an existing configSet + // TODO: Should this be an option or an error? + } else { Review Comment: ```suggestion if (existingConfigSetConfigMaps.containsKey(toConfig)) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "ConfigSet " + toConfig + " already exists; overwrite not yet supported for Kubernetes"); } else { ``` ########## solr/modules/kubernetes/src/java/org/apache/solr/cloud/kube/KubernetesConfigSetService.java: ########## @@ -0,0 +1,490 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.cloud.kube; + +import io.kubernetes.client.informer.ResourceEventHandler; +import io.kubernetes.client.informer.SharedIndexInformer; +import io.kubernetes.client.informer.SharedInformerFactory; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1ConfigMapList; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.util.CallGeneratorParams; +import io.kubernetes.client.util.ClientBuilder; +import io.kubernetes.client.util.labels.SetMatcher; +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.cloud.ZkMaintenanceUtils; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.Utils; +import org.apache.solr.core.ConfigSetProperties; +import org.apache.solr.core.ConfigSetService; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.core.CoreDescriptor; +import org.apache.solr.core.SolrConfig; +import org.apache.solr.core.SolrResourceLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Kubernetes ConfigSetService impl. + * + * <p>Loads a ConfigSet defined by the core's configSet property, looking for a Kubernetes ConfigMap + * labeled for the given SolrCloud instance. If no configSet property is set, loads the ConfigSet + * instead from the core's instance directory. + */ +public class KubernetesConfigSetService extends ConfigSetService { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + private final ApiClient kubeClient; + private final CoreV1Api coreV1Api; + + private final String solrCloudNamespace; + private final String solrCloudName; + + private final Map<String, V1ConfigMap> existingConfigSetConfigMaps; + + /** ConfigMapLabel */ + public static final String CONFIG_SET_LABEL_KEY = "solr.apache.org/cloud/%s/resource"; + + public static final String CONFIG_SET_LABEL_VALUE = "configSet"; + public static final String CONFIG_SET_NAME_ANNOTATION_KEY = "solr.apache.org/configSet/name"; + + // These are always set on pods by the Solr Operator + public static final String POD_NAMESPACE_ENV_VAR = "POD_NAMESPACE"; + public static final String SOLR_CLOUD_NAME_ENV_VAR = "SOLR_CLOUD_NAME"; + + // Special ConfigMap data paths + public static final String CONFIG_SET_METADATA_KEY = "_metadata.json"; + public static final String CONFIG_SET_PROPERTIES_KEY = "_properties.json"; + + public KubernetesConfigSetService(CoreContainer cc) throws IOException { + super(cc.getResourceLoader(), cc.getConfig().hasSchemaCache()); + kubeClient = ClientBuilder.cluster().build(); + coreV1Api = new CoreV1Api(kubeClient); + + existingConfigSetConfigMaps = new ConcurrentHashMap<>(); + // TODO: Finalize these + solrCloudNamespace = System.getenv(POD_NAMESPACE_ENV_VAR); + solrCloudName = System.getenv(SOLR_CLOUD_NAME_ENV_VAR); + } + + public void init() { + SharedInformerFactory factory = new SharedInformerFactory(kubeClient); + + // ConfigMap informer + SharedIndexInformer<V1ConfigMap> nodeInformer = + factory.sharedIndexInformerFor( + // **NOTE**: + // The following "CallGenerator" lambda merely generates a stateless + // HTTPS request, the effective apiClient is the one specified when constructing + // the informer-factory. + (CallGeneratorParams params) -> + coreV1Api + .listNamespacedConfigMap(solrCloudNamespace) + .labelSelector( + SetMatcher.in( + String.format(Locale.ROOT, CONFIG_SET_LABEL_KEY, solrCloudName), + CONFIG_SET_LABEL_VALUE) + .toString()) + .resourceVersion(params.resourceVersion) + .timeoutSeconds(params.timeoutSeconds) + .watch(params.watch) + .buildCall(null), + V1ConfigMap.class, + V1ConfigMapList.class); + + nodeInformer.addEventHandler( + new ResourceEventHandler<>() { + @Override + public void onAdd(V1ConfigMap configMap) { + if (log.isInfoEnabled()) { + log.info("{} configMap added!", configMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.put(extractConfigSetName(configMap), configMap); + } + + @Override + public void onUpdate(V1ConfigMap oldConfigMap, V1ConfigMap newConfigMap) { + if (log.isInfoEnabled()) { + log.info( + "{} => {} configMap updated!", + oldConfigMap.getMetadata().getName(), + newConfigMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.put(extractConfigSetName(newConfigMap), newConfigMap); + } + + @Override + public void onDelete(V1ConfigMap configMap, boolean deletedFinalStateUnknown) { + if (log.isInfoEnabled()) { + log.info("{} configMap deleted!", configMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.remove(extractConfigSetName(configMap)); + } + }); + + factory.startAllRegisteredInformers(); + } + + private String extractConfigSetName(V1ConfigMap configMap) { + return Optional.ofNullable(configMap.getMetadata()) + .map(V1ObjectMeta::getAnnotations) + .map(ann -> ann.get(CONFIG_SET_NAME_ANNOTATION_KEY)) + .orElse(configMap.getMetadata().getName()); + } + + private V1ConfigMap getCachedConfigMap(String configName) throws SolrException { + V1ConfigMap configMap = existingConfigSetConfigMaps.get(configName); + if (configMap == null) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, + String.format(Locale.ROOT, "No ConfigMap exists for ConfigSet %s ", configName)); + } + if (configMap.getMetadata() == null) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + String.format( + Locale.ROOT, + "ConfigMap exists for ConfigSet %s, but it contains no metadata", + configName)); + } + return configMap; + } + + @Override + protected SolrResourceLoader createCoreResourceLoader(CoreDescriptor cd) { + // TODO: In SolrCloud mode, the configSet should be determined from the collection state. + // For now, use the configSet from the CoreDescriptor. + String configSetName = cd.getConfigSet(); + + return new KubernetesSolrResourceLoader( + cd.getInstanceDir(), configSetName, parentLoader.getClassLoader(), coreV1Api); + } + + @Override + protected NamedList<Object> loadConfigSetFlags(SolrResourceLoader loader) { + try { + return ConfigSetProperties.readFromResourceLoader(loader, CONFIG_SET_METADATA_KEY); + } catch (Exception ex) { + log.debug("No configSet flags", ex); + return null; + } + } + + @Override + protected Long getCurrentSchemaModificationVersion( + String configSet, SolrConfig solrConfig, String schemaFile) { + // Individual values/files in ConfigMaps do not have versions, + // we can only use the ConfigMap version as a whole. + // + // Return null if this configMap does not exist. + return Optional.ofNullable(existingConfigSetConfigMaps.get(configSet)) + .map(V1ConfigMap::getMetadata) + .map(V1ObjectMeta::getGeneration) + .orElse(null); + } + + @Override + public String configSetName(CoreDescriptor cd) { + return "configmap " + cd.getConfigSet(); + } + + @Override + public boolean checkConfigExists(String configName) { + return existingConfigSetConfigMaps.containsKey(configName); + } + + @Override + public void deleteConfig(String configName) throws IOException { + String configMapName = ""; + try { + if (existingConfigSetConfigMaps.containsKey(configName)) { + V1ConfigMap configMap = existingConfigSetConfigMaps.get(configName); + configMapName = configMap.getMetadata().getName(); Review Comment: Uses direct map lookup instead of `getCachedConfigMap()`, so no null-metadata check Replace with `getCachedConfigMap(configName)` to get the validated reference. ########## solr/modules/kubernetes/src/java/org/apache/solr/cloud/kube/KubernetesConfigSetService.java: ########## @@ -0,0 +1,490 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.cloud.kube; + +import io.kubernetes.client.informer.ResourceEventHandler; +import io.kubernetes.client.informer.SharedIndexInformer; +import io.kubernetes.client.informer.SharedInformerFactory; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1ConfigMapList; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.util.CallGeneratorParams; +import io.kubernetes.client.util.ClientBuilder; +import io.kubernetes.client.util.labels.SetMatcher; +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.cloud.ZkMaintenanceUtils; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.Utils; +import org.apache.solr.core.ConfigSetProperties; +import org.apache.solr.core.ConfigSetService; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.core.CoreDescriptor; +import org.apache.solr.core.SolrConfig; +import org.apache.solr.core.SolrResourceLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Kubernetes ConfigSetService impl. + * + * <p>Loads a ConfigSet defined by the core's configSet property, looking for a Kubernetes ConfigMap + * labeled for the given SolrCloud instance. If no configSet property is set, loads the ConfigSet + * instead from the core's instance directory. + */ +public class KubernetesConfigSetService extends ConfigSetService { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + private final ApiClient kubeClient; + private final CoreV1Api coreV1Api; + + private final String solrCloudNamespace; + private final String solrCloudName; + + private final Map<String, V1ConfigMap> existingConfigSetConfigMaps; + + /** ConfigMapLabel */ + public static final String CONFIG_SET_LABEL_KEY = "solr.apache.org/cloud/%s/resource"; + + public static final String CONFIG_SET_LABEL_VALUE = "configSet"; + public static final String CONFIG_SET_NAME_ANNOTATION_KEY = "solr.apache.org/configSet/name"; + + // These are always set on pods by the Solr Operator + public static final String POD_NAMESPACE_ENV_VAR = "POD_NAMESPACE"; + public static final String SOLR_CLOUD_NAME_ENV_VAR = "SOLR_CLOUD_NAME"; + + // Special ConfigMap data paths + public static final String CONFIG_SET_METADATA_KEY = "_metadata.json"; + public static final String CONFIG_SET_PROPERTIES_KEY = "_properties.json"; + + public KubernetesConfigSetService(CoreContainer cc) throws IOException { + super(cc.getResourceLoader(), cc.getConfig().hasSchemaCache()); + kubeClient = ClientBuilder.cluster().build(); + coreV1Api = new CoreV1Api(kubeClient); + + existingConfigSetConfigMaps = new ConcurrentHashMap<>(); + // TODO: Finalize these + solrCloudNamespace = System.getenv(POD_NAMESPACE_ENV_VAR); + solrCloudName = System.getenv(SOLR_CLOUD_NAME_ENV_VAR); + } + + public void init() { + SharedInformerFactory factory = new SharedInformerFactory(kubeClient); + + // ConfigMap informer + SharedIndexInformer<V1ConfigMap> nodeInformer = + factory.sharedIndexInformerFor( + // **NOTE**: + // The following "CallGenerator" lambda merely generates a stateless + // HTTPS request, the effective apiClient is the one specified when constructing + // the informer-factory. + (CallGeneratorParams params) -> + coreV1Api + .listNamespacedConfigMap(solrCloudNamespace) + .labelSelector( + SetMatcher.in( + String.format(Locale.ROOT, CONFIG_SET_LABEL_KEY, solrCloudName), + CONFIG_SET_LABEL_VALUE) + .toString()) + .resourceVersion(params.resourceVersion) + .timeoutSeconds(params.timeoutSeconds) + .watch(params.watch) + .buildCall(null), + V1ConfigMap.class, + V1ConfigMapList.class); + + nodeInformer.addEventHandler( + new ResourceEventHandler<>() { + @Override + public void onAdd(V1ConfigMap configMap) { + if (log.isInfoEnabled()) { + log.info("{} configMap added!", configMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.put(extractConfigSetName(configMap), configMap); + } + + @Override + public void onUpdate(V1ConfigMap oldConfigMap, V1ConfigMap newConfigMap) { + if (log.isInfoEnabled()) { + log.info( + "{} => {} configMap updated!", + oldConfigMap.getMetadata().getName(), + newConfigMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.put(extractConfigSetName(newConfigMap), newConfigMap); + } + + @Override + public void onDelete(V1ConfigMap configMap, boolean deletedFinalStateUnknown) { + if (log.isInfoEnabled()) { + log.info("{} configMap deleted!", configMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.remove(extractConfigSetName(configMap)); + } + }); + + factory.startAllRegisteredInformers(); + } + + private String extractConfigSetName(V1ConfigMap configMap) { + return Optional.ofNullable(configMap.getMetadata()) + .map(V1ObjectMeta::getAnnotations) + .map(ann -> ann.get(CONFIG_SET_NAME_ANNOTATION_KEY)) + .orElse(configMap.getMetadata().getName()); Review Comment: Critical NPE `.orElse(configMap.getMetadata().getName());` // NPE when metadata IS null ########## solr/modules/kubernetes/src/java/org/apache/solr/cloud/kube/KubernetesConfigSetService.java: ########## @@ -0,0 +1,490 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.cloud.kube; + +import io.kubernetes.client.informer.ResourceEventHandler; +import io.kubernetes.client.informer.SharedIndexInformer; +import io.kubernetes.client.informer.SharedInformerFactory; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1ConfigMapList; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.util.CallGeneratorParams; +import io.kubernetes.client.util.ClientBuilder; +import io.kubernetes.client.util.labels.SetMatcher; +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.cloud.ZkMaintenanceUtils; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.Utils; +import org.apache.solr.core.ConfigSetProperties; +import org.apache.solr.core.ConfigSetService; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.core.CoreDescriptor; +import org.apache.solr.core.SolrConfig; +import org.apache.solr.core.SolrResourceLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Kubernetes ConfigSetService impl. + * + * <p>Loads a ConfigSet defined by the core's configSet property, looking for a Kubernetes ConfigMap + * labeled for the given SolrCloud instance. If no configSet property is set, loads the ConfigSet + * instead from the core's instance directory. + */ +public class KubernetesConfigSetService extends ConfigSetService { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + private final ApiClient kubeClient; + private final CoreV1Api coreV1Api; + + private final String solrCloudNamespace; + private final String solrCloudName; + + private final Map<String, V1ConfigMap> existingConfigSetConfigMaps; + + /** ConfigMapLabel */ + public static final String CONFIG_SET_LABEL_KEY = "solr.apache.org/cloud/%s/resource"; + + public static final String CONFIG_SET_LABEL_VALUE = "configSet"; + public static final String CONFIG_SET_NAME_ANNOTATION_KEY = "solr.apache.org/configSet/name"; + + // These are always set on pods by the Solr Operator + public static final String POD_NAMESPACE_ENV_VAR = "POD_NAMESPACE"; + public static final String SOLR_CLOUD_NAME_ENV_VAR = "SOLR_CLOUD_NAME"; + + // Special ConfigMap data paths + public static final String CONFIG_SET_METADATA_KEY = "_metadata.json"; + public static final String CONFIG_SET_PROPERTIES_KEY = "_properties.json"; + + public KubernetesConfigSetService(CoreContainer cc) throws IOException { + super(cc.getResourceLoader(), cc.getConfig().hasSchemaCache()); + kubeClient = ClientBuilder.cluster().build(); + coreV1Api = new CoreV1Api(kubeClient); + + existingConfigSetConfigMaps = new ConcurrentHashMap<>(); + // TODO: Finalize these Review Comment: ```suggestion ``` ########## solr/modules/kubernetes/src/java/org/apache/solr/cloud/kube/KubernetesConfigSetService.java: ########## @@ -0,0 +1,490 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.cloud.kube; + +import io.kubernetes.client.informer.ResourceEventHandler; +import io.kubernetes.client.informer.SharedIndexInformer; +import io.kubernetes.client.informer.SharedInformerFactory; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1ConfigMapList; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.util.CallGeneratorParams; +import io.kubernetes.client.util.ClientBuilder; +import io.kubernetes.client.util.labels.SetMatcher; +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.cloud.ZkMaintenanceUtils; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.Utils; +import org.apache.solr.core.ConfigSetProperties; +import org.apache.solr.core.ConfigSetService; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.core.CoreDescriptor; +import org.apache.solr.core.SolrConfig; +import org.apache.solr.core.SolrResourceLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Kubernetes ConfigSetService impl. + * + * <p>Loads a ConfigSet defined by the core's configSet property, looking for a Kubernetes ConfigMap + * labeled for the given SolrCloud instance. If no configSet property is set, loads the ConfigSet + * instead from the core's instance directory. + */ +public class KubernetesConfigSetService extends ConfigSetService { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + private final ApiClient kubeClient; + private final CoreV1Api coreV1Api; + + private final String solrCloudNamespace; + private final String solrCloudName; + + private final Map<String, V1ConfigMap> existingConfigSetConfigMaps; + + /** ConfigMapLabel */ + public static final String CONFIG_SET_LABEL_KEY = "solr.apache.org/cloud/%s/resource"; + + public static final String CONFIG_SET_LABEL_VALUE = "configSet"; + public static final String CONFIG_SET_NAME_ANNOTATION_KEY = "solr.apache.org/configSet/name"; + + // These are always set on pods by the Solr Operator + public static final String POD_NAMESPACE_ENV_VAR = "POD_NAMESPACE"; + public static final String SOLR_CLOUD_NAME_ENV_VAR = "SOLR_CLOUD_NAME"; + + // Special ConfigMap data paths + public static final String CONFIG_SET_METADATA_KEY = "_metadata.json"; + public static final String CONFIG_SET_PROPERTIES_KEY = "_properties.json"; + + public KubernetesConfigSetService(CoreContainer cc) throws IOException { + super(cc.getResourceLoader(), cc.getConfig().hasSchemaCache()); + kubeClient = ClientBuilder.cluster().build(); + coreV1Api = new CoreV1Api(kubeClient); + + existingConfigSetConfigMaps = new ConcurrentHashMap<>(); + // TODO: Finalize these + solrCloudNamespace = System.getenv(POD_NAMESPACE_ENV_VAR); + solrCloudName = System.getenv(SOLR_CLOUD_NAME_ENV_VAR); + } + + public void init() { + SharedInformerFactory factory = new SharedInformerFactory(kubeClient); + + // ConfigMap informer + SharedIndexInformer<V1ConfigMap> nodeInformer = + factory.sharedIndexInformerFor( + // **NOTE**: + // The following "CallGenerator" lambda merely generates a stateless + // HTTPS request, the effective apiClient is the one specified when constructing + // the informer-factory. + (CallGeneratorParams params) -> + coreV1Api + .listNamespacedConfigMap(solrCloudNamespace) + .labelSelector( + SetMatcher.in( + String.format(Locale.ROOT, CONFIG_SET_LABEL_KEY, solrCloudName), + CONFIG_SET_LABEL_VALUE) + .toString()) + .resourceVersion(params.resourceVersion) + .timeoutSeconds(params.timeoutSeconds) + .watch(params.watch) + .buildCall(null), + V1ConfigMap.class, + V1ConfigMapList.class); + + nodeInformer.addEventHandler( + new ResourceEventHandler<>() { + @Override + public void onAdd(V1ConfigMap configMap) { + if (log.isInfoEnabled()) { + log.info("{} configMap added!", configMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.put(extractConfigSetName(configMap), configMap); + } + + @Override + public void onUpdate(V1ConfigMap oldConfigMap, V1ConfigMap newConfigMap) { + if (log.isInfoEnabled()) { + log.info( + "{} => {} configMap updated!", + oldConfigMap.getMetadata().getName(), + newConfigMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.put(extractConfigSetName(newConfigMap), newConfigMap); + } + + @Override + public void onDelete(V1ConfigMap configMap, boolean deletedFinalStateUnknown) { + if (log.isInfoEnabled()) { + log.info("{} configMap deleted!", configMap.getMetadata().getName()); + } + existingConfigSetConfigMaps.remove(extractConfigSetName(configMap)); + } + }); + + factory.startAllRegisteredInformers(); + } + + private String extractConfigSetName(V1ConfigMap configMap) { + return Optional.ofNullable(configMap.getMetadata()) + .map(V1ObjectMeta::getAnnotations) + .map(ann -> ann.get(CONFIG_SET_NAME_ANNOTATION_KEY)) + .orElse(configMap.getMetadata().getName()); + } + + private V1ConfigMap getCachedConfigMap(String configName) throws SolrException { + V1ConfigMap configMap = existingConfigSetConfigMaps.get(configName); + if (configMap == null) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, + String.format(Locale.ROOT, "No ConfigMap exists for ConfigSet %s ", configName)); + } + if (configMap.getMetadata() == null) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + String.format( + Locale.ROOT, + "ConfigMap exists for ConfigSet %s, but it contains no metadata", + configName)); + } + return configMap; + } + + @Override + protected SolrResourceLoader createCoreResourceLoader(CoreDescriptor cd) { + // TODO: In SolrCloud mode, the configSet should be determined from the collection state. + // For now, use the configSet from the CoreDescriptor. + String configSetName = cd.getConfigSet(); + + return new KubernetesSolrResourceLoader( + cd.getInstanceDir(), configSetName, parentLoader.getClassLoader(), coreV1Api); + } + + @Override + protected NamedList<Object> loadConfigSetFlags(SolrResourceLoader loader) { + try { + return ConfigSetProperties.readFromResourceLoader(loader, CONFIG_SET_METADATA_KEY); + } catch (Exception ex) { + log.debug("No configSet flags", ex); + return null; + } + } + + @Override + protected Long getCurrentSchemaModificationVersion( + String configSet, SolrConfig solrConfig, String schemaFile) { + // Individual values/files in ConfigMaps do not have versions, + // we can only use the ConfigMap version as a whole. + // + // Return null if this configMap does not exist. + return Optional.ofNullable(existingConfigSetConfigMaps.get(configSet)) + .map(V1ConfigMap::getMetadata) + .map(V1ObjectMeta::getGeneration) + .orElse(null); + } + + @Override + public String configSetName(CoreDescriptor cd) { + return "configmap " + cd.getConfigSet(); + } + + @Override + public boolean checkConfigExists(String configName) { + return existingConfigSetConfigMaps.containsKey(configName); + } + + @Override + public void deleteConfig(String configName) throws IOException { + String configMapName = ""; + try { + if (existingConfigSetConfigMaps.containsKey(configName)) { + V1ConfigMap configMap = existingConfigSetConfigMaps.get(configName); + configMapName = configMap.getMetadata().getName(); + coreV1Api + .deleteNamespacedConfigMap(configMapName, configMap.getMetadata().getNamespace()) + .execute(); + } + } catch (ApiException e) { + throw new IOException( + String.format( + Locale.ROOT, + "Error deleting configMap %s, representing the configSet %s", + configMapName, + configName), + e); + } + } + + @Override + public void deleteFilesFromConfig(String configName, List<String> filesToDelete) + throws IOException { + if (filesToDelete == null) { + return; + } + V1ConfigMap configMap = getCachedConfigMap(configName); + var existingData = configMap.getData(); + Set<String> chosenFilesToDelete = new HashSet<>(filesToDelete); + if (existingData != null) { + // If there is existing data cached, then only delete the files we know to exist + // If there is no existing data cached, then try to do a patch delete and fail if it doesn't + // work. + chosenFilesToDelete.retainAll(existingData.keySet()); + } + // TODO: Patch the data to delete the files + throw new UnsupportedOperationException( + "deleteFilesFromConfig not yet implemented for Kubernetes"); + } + + private V1ConfigMap newConfigMap(String configName) { + // Follow Kubernetes name rules: + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names + configName = configName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-.]", "-"); + if (configName.endsWith("-") || configName.endsWith(".")) { + configName = configName.substring(0, configName.length() - 1); + } + return new V1ConfigMap() + .metadata( + new V1ObjectMeta() + .name( + String.format( + Locale.ROOT, "solrcloud-%s-configset-%s", solrCloudName, configName)) + .namespace(solrCloudNamespace) + .putLabelsItem( + String.format(Locale.ROOT, CONFIG_SET_LABEL_KEY, solrCloudName), + CONFIG_SET_LABEL_VALUE) + .putAnnotationsItem(CONFIG_SET_NAME_ANNOTATION_KEY, configName)); + } + + @Override + public void copyConfig(String fromConfig, String toConfig) throws IOException { + try { + V1ConfigMap fromConfigMap = getCachedConfigMap(fromConfig); + var existingData = fromConfigMap.getData(); + if (existingData == null) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + String.format( + Locale.ROOT, + "Cannot copy configSet %s, it has no data in its configMap %s", + fromConfig, + fromConfigMap.getMetadata().getName())); + } + if (existingConfigSetConfigMaps.containsKey(toConfig)) { + // Patch an existing configSet + // TODO: Should this be an option or an error? + } else { + V1ConfigMap toConfigMap = newConfigMap(toConfig).data(existingData); + coreV1Api.createNamespacedConfigMap(solrCloudNamespace, toConfigMap).execute(); + } + } catch (ApiException e) { + throw new IOException("Could not create new configMap for configSet " + toConfig, e); + } + } + + @Override + public void uploadConfig(String configName, Path dir) throws IOException { + Map<String, String> dataToPut = new HashMap<>(); + String path = dir.toString(); + if (path.endsWith("*")) { + path = path.substring(0, path.length() - 1); + } + + final Path rootPath = Path.of(path); + + if (!Files.exists(rootPath)) throw new IOException("Path " + rootPath + " does not exist"); + + Files.walkFileTree( + rootPath, + new SimpleFileVisitor<Path>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + String filename = file.getFileName().toString(); + if ((ConfigSetService.UPLOAD_FILENAME_EXCLUDE_PATTERN.matcher(filename).matches())) { + log.info( + "uploadConfig skipping '{}' due to filenameExclusions '{}'", + filename, + ConfigSetService.UPLOAD_FILENAME_EXCLUDE_PATTERN); + return FileVisitResult.CONTINUE; + } + if (ZkMaintenanceUtils.isFileForbiddenInConfigSets(filename)) { + log.info("skipping '{}' in configMap due to forbidden file type", filename); + return FileVisitResult.CONTINUE; + } + String configMapKey = ZkMaintenanceUtils.createZkNodeName("", rootPath, file); + if (configMapKey.isEmpty()) { + configMapKey = CONFIG_SET_METADATA_KEY; + } + dataToPut.put(configMapKey, Files.readString(file, StandardCharsets.UTF_8)); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + if (dir.getFileName().toString().startsWith(".")) return FileVisitResult.SKIP_SUBTREE; + + return FileVisitResult.CONTINUE; + } + }); + + // TODO: Patch with new Data using JSON Merge Patch or Strategic Merge Patch + throw new UnsupportedOperationException( + "uploadConfig not yet fully implemented for Kubernetes"); + } + + @Override + public void uploadFileToConfig( + String configName, String fileName, byte[] data, boolean overwriteOnExists) + throws IOException { + if (ZkMaintenanceUtils.isFileForbiddenInConfigSets(fileName)) { + log.warn("Not including uploading file to config, as it is a forbidden type: {}", fileName); + } else { + V1ConfigMap configMap = getCachedConfigMap(configName); + var existingData = configMap.getData(); + if (existingData == null || !existingData.containsKey(fileName) || overwriteOnExists) { + // TODO: Patch the data + throw new UnsupportedOperationException( + "uploadFileToConfig not yet implemented for Kubernetes"); + } + } + } + + @Override + public void setConfigMetadata(String configName, Map<String, Object> data) throws IOException { + V1ConfigMap configMap = getCachedConfigMap(configName); + var existingData = configMap.getData(); + String newMetadata = Utils.toJSONString(data); + + String patchType; + if (existingData == null || !existingData.containsKey(CONFIG_SET_METADATA_KEY)) { + patchType = "insert"; + } else if (!data.get(CONFIG_SET_METADATA_KEY).equals(newMetadata)) { Review Comment: data here is the incoming parameter `Map<String, Object>`, not the existing `ConfigMap` data. `newMetadata` is `Utils.toJSONString(data)` so this always compares `data.get(key)` against the JSON serialization of the whole data map. Should compare `existingData.get(CONFIG_SET_METADATA_KEY)` against `newMetadata`: ```suggestion } else if (!newMetadata.equals(existingData.get(CONFIG_SET_METADATA_KEY))) { ``` ########## solr/modules/kubernetes/src/java/org/apache/solr/cloud/kube/KubernetesSolrResourceLoader.java: ########## @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.cloud.kube; + +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandles; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Locale; +import org.apache.solr.core.SolrResourceLoader; +import org.apache.solr.core.SolrResourceNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * ResourceLoader that works with Kubernetes ConfigMaps. + * + * <p>This loader attempts to load resources from a Kubernetes ConfigMap corresponding to the + * configured configSet. If a resource is not found in the ConfigMap, it falls back to the classpath + * loader. + */ +public class KubernetesSolrResourceLoader extends SolrResourceLoader { + + private final String configSetName; + private final String namespace; + private final CoreV1Api coreV1Api; + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + /** + * Creates a new KubernetesSolrResourceLoader. + * + * <p>This loader will first attempt to load resources from a Kubernetes ConfigMap for the given + * configSet. If not found, it will delegate to the context classloader. + * + * @param instanceDir the instance directory for the core + * @param configSetName the name of the configSet (used to look up the ConfigMap) + * @param parent the parent classloader + * @param coreV1Api the Kubernetes CoreV1Api client + */ + public KubernetesSolrResourceLoader( + Path instanceDir, String configSetName, ClassLoader parent, CoreV1Api coreV1Api) { + super(instanceDir, parent); + this.configSetName = configSetName; + this.coreV1Api = coreV1Api; + // TODO: namespace should come from the environment (POD_NAMESPACE env var) + this.namespace = System.getenv(KubernetesConfigSetService.POD_NAMESPACE_ENV_VAR); + } + + /** + * Opens any resource by its name. First attempts to load the resource from the Kubernetes + * ConfigMap for the configSet. If not found, delegates to the parent classloader. + * + * @return the stream for the named resource + */ + @Override + public InputStream openResource(String resource) throws IOException { + // Try to get the resource from the Kubernetes ConfigMap + try { + // TODO: Cache the ConfigMap locally rather than fetching from the API each time. + // Consider passing in the existingConfigSetConfigMaps cache from KubernetesConfigSetService. + String solrCloudName = System.getenv(KubernetesConfigSetService.SOLR_CLOUD_NAME_ENV_VAR); + String labelSelector = + String.format( + Locale.ROOT, + "%s=%s", + String.format( + Locale.ROOT, KubernetesConfigSetService.CONFIG_SET_LABEL_KEY, solrCloudName), + KubernetesConfigSetService.CONFIG_SET_LABEL_VALUE); + + V1ConfigMap configMap = + coreV1Api + .listNamespacedConfigMap(namespace) + .labelSelector(labelSelector) + .execute() + .getItems() + .stream() + .filter( + cm -> { + if (cm.getMetadata() == null || cm.getMetadata().getAnnotations() == null) { + return false; + } + return configSetName.equals( + cm.getMetadata() + .getAnnotations() + .get(KubernetesConfigSetService.CONFIG_SET_NAME_ANNOTATION_KEY)); + }) + .findFirst() + .orElse(null); + + if (configMap != null && configMap.getData() != null) { + String data = configMap.getData().get(resource); + if (data != null) { + return new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); + } + } + } catch (ApiException e) { + log.debug( Review Comment: Log level for API failure is too low ```suggestion log.warn( ``` ########## solr/modules/kubernetes/src/java/org/apache/solr/cloud/kube/KubernetesSolrResourceLoader.java: ########## @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.cloud.kube; + +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandles; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Locale; +import org.apache.solr.core.SolrResourceLoader; +import org.apache.solr.core.SolrResourceNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * ResourceLoader that works with Kubernetes ConfigMaps. + * + * <p>This loader attempts to load resources from a Kubernetes ConfigMap corresponding to the + * configured configSet. If a resource is not found in the ConfigMap, it falls back to the classpath + * loader. + */ +public class KubernetesSolrResourceLoader extends SolrResourceLoader { + + private final String configSetName; + private final String namespace; + private final CoreV1Api coreV1Api; + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + /** + * Creates a new KubernetesSolrResourceLoader. + * + * <p>This loader will first attempt to load resources from a Kubernetes ConfigMap for the given + * configSet. If not found, it will delegate to the context classloader. + * + * @param instanceDir the instance directory for the core + * @param configSetName the name of the configSet (used to look up the ConfigMap) + * @param parent the parent classloader + * @param coreV1Api the Kubernetes CoreV1Api client + */ + public KubernetesSolrResourceLoader( + Path instanceDir, String configSetName, ClassLoader parent, CoreV1Api coreV1Api) { + super(instanceDir, parent); + this.configSetName = configSetName; + this.coreV1Api = coreV1Api; + // TODO: namespace should come from the environment (POD_NAMESPACE env var) + this.namespace = System.getenv(KubernetesConfigSetService.POD_NAMESPACE_ENV_VAR); + } + + /** + * Opens any resource by its name. First attempts to load the resource from the Kubernetes + * ConfigMap for the configSet. If not found, delegates to the parent classloader. + * + * @return the stream for the named resource + */ + @Override + public InputStream openResource(String resource) throws IOException { + // Try to get the resource from the Kubernetes ConfigMap + try { + // TODO: Cache the ConfigMap locally rather than fetching from the API each time. + // Consider passing in the existingConfigSetConfigMaps cache from KubernetesConfigSetService. + String solrCloudName = System.getenv(KubernetesConfigSetService.SOLR_CLOUD_NAME_ENV_VAR); + String labelSelector = + String.format( + Locale.ROOT, + "%s=%s", + String.format( + Locale.ROOT, KubernetesConfigSetService.CONFIG_SET_LABEL_KEY, solrCloudName), + KubernetesConfigSetService.CONFIG_SET_LABEL_VALUE); + + V1ConfigMap configMap = + coreV1Api + .listNamespacedConfigMap(namespace) + .labelSelector(labelSelector) + .execute() + .getItems() + .stream() Review Comment: NPE if getItems() returns null -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
