diff --git a/admission/src/main/java/io/reshapr/kubernetes/admission/AdmissionControllerConfig.java b/admission/src/main/java/io/reshapr/kubernetes/admission/AdmissionControllerConfig.java index 17161b9..757ea1d 100644 --- a/admission/src/main/java/io/reshapr/kubernetes/admission/AdmissionControllerConfig.java +++ b/admission/src/main/java/io/reshapr/kubernetes/admission/AdmissionControllerConfig.java @@ -16,7 +16,9 @@ package io.reshapr.kubernetes.admission; import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.webhook.admission.AdmissionController; +import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; @@ -27,9 +29,12 @@ public class AdmissionControllerConfig { public static final String MUTATING_CONTROLLER = "mutatingController"; + @Inject + KubernetesClient client; + @Singleton @Named(MUTATING_CONTROLLER) public AdmissionController mutatingController() { - return AdmissionControllers.mutatingController(); + return AdmissionControllers.mutatingController(client); } } diff --git a/admission/src/main/java/io/reshapr/kubernetes/admission/AdmissionControllers.java b/admission/src/main/java/io/reshapr/kubernetes/admission/AdmissionControllers.java index 7619bf1..806e3de 100644 --- a/admission/src/main/java/io/reshapr/kubernetes/admission/AdmissionControllers.java +++ b/admission/src/main/java/io/reshapr/kubernetes/admission/AdmissionControllers.java @@ -15,39 +15,159 @@ */ package io.reshapr.kubernetes.admission; +import io.fabric8.kubernetes.api.model.Container; +import io.fabric8.kubernetes.api.model.ContainerBuilder; import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.webhook.admission.AdmissionController; import io.javaoperatorsdk.webhook.admission.NotAllowedException; import io.javaoperatorsdk.webhook.admission.Operation; import io.javaoperatorsdk.webhook.admission.mutation.Mutator; import java.util.HashMap; +import java.util.Map; +import java.util.ArrayList; /** * @author laurent */ public class AdmissionControllers { + public static final String INJECT_ANNOTATION = "io.reshapr/inject"; + public static final String CONTROL_PLANE_URL_ANNOTATION = "io.reshapr/control-plane-url"; + public static final String TOKEN_SECRET_NAME_ANNOTATION = "io.reshapr/token-secret-name"; + + public static final String PROXY_INJECTED_LABEL = "reshapr.io/proxy-injected"; + + public static final String PROXY_CONTAINER_NAME = "reshapr-proxy"; + public static final String DEFAULT_PROXY_IMAGE = "quay.io/reshapr/reshapr-proxy:latest"; + private AdmissionControllers() { // Private constructor to prevent instantiation. } - public static AdmissionController mutatingController() { - return new AdmissionController<>(new PodMutator()); + public static AdmissionController mutatingController(KubernetesClient client) { + return new AdmissionController<>(new PodMutator(client)); } /** - * + * Mutates Pods to inject the Reshapr Proxy container if annotated. */ public static class PodMutator implements Mutator { + private final KubernetesClient client; + + public PodMutator(KubernetesClient client) { + this.client = client; + } + @Override public Pod mutate(Pod resource, Operation operation) throws NotAllowedException { - // Example mutation: add a label to the Pod - if (resource.getMetadata().getLabels() == null) { - resource.getMetadata().setLabels(new HashMap<>()); + Map annotations = resource.getMetadata().getAnnotations(); + if (annotations != null && "true".equalsIgnoreCase(annotations.get(INJECT_ANNOTATION))) { + + // Check if already injected + if (resource.getSpec().getContainers() != null && resource.getSpec().getContainers().stream().anyMatch(c -> PROXY_CONTAINER_NAME.equals(c.getName()))) { + return resource; + } + + // Fetch defaults from ConfigMap + String namespace = resource.getMetadata().getNamespace(); + if (namespace == null) { + // If namespace is not set on the resource, it defaults to "reshapr-system" in Kubernetes + namespace = "reshapr-system"; + } + + Map configMapData = new HashMap<>(); + try { + var configMap = client.configMaps().inNamespace(namespace).withName("reshapr-injection-config").get(); + if (configMap != null && configMap.getData() != null) { + configMapData = configMap.getData(); + } + } catch (Exception e) { + // Ignore if ConfigMap doesn't exist or client fails + } + + // 0. Resolve proxy port + int proxyPort = 7777; + String portStr = annotations.containsKey("io.reshapr/proxy-port") + ? annotations.get("io.reshapr/proxy-port") + : configMapData.get("proxy-port"); + if (portStr != null && !portStr.isBlank()) { + try { + proxyPort = Integer.parseInt(portStr); + } catch (NumberFormatException ignored) {} + } + + ContainerBuilder proxyBuilder = new ContainerBuilder() + .withName(PROXY_CONTAINER_NAME) + .withImage(DEFAULT_PROXY_IMAGE) + .addNewPort() + .withContainerPort(proxyPort) + .withName("proxy") + .endPort(); + + // 1. Inject control plane URL + String controlPlaneUrl = annotations.containsKey(CONTROL_PLANE_URL_ANNOTATION) + ? annotations.get(CONTROL_PLANE_URL_ANNOTATION) + : configMapData.get("control-plane-url"); + + if (controlPlaneUrl != null && !controlPlaneUrl.isBlank()) { + proxyBuilder.addNewEnv() + .withName("RESHAPR_CONTROL_PLANE_URL") + .withValue(controlPlaneUrl) + .endEnv(); + } + + // 2. Inject secret as envFrom + String secretName = annotations.containsKey(TOKEN_SECRET_NAME_ANNOTATION) + ? annotations.get(TOKEN_SECRET_NAME_ANNOTATION) + : configMapData.get("token-secret-name"); + + if (secretName != null && !secretName.isBlank()) { + proxyBuilder.addNewEnvFrom() + .withNewSecretRef() + .withName(secretName) + .endSecretRef() + .endEnvFrom(); + } + + // 3. Inject Gateway Labels + String gatewayLabels = annotations.containsKey("io.reshapr/gateway-labels") + ? annotations.get("io.reshapr/gateway-labels") + : configMapData.get("gateway-labels"); + + if (gatewayLabels != null && !gatewayLabels.isBlank()) { + proxyBuilder.addNewEnv() + .withName("RESHAPR_GATEWAY_LABELS") + .withValue(gatewayLabels) + .endEnv(); + } + + // 4. Inject Gateway Instance (Control Plane Binding) + String gatewayInstance = annotations.containsKey("io.reshapr/instance") + ? annotations.get("io.reshapr/instance") + : configMapData.get("instance"); + + if (gatewayInstance != null && !gatewayInstance.isBlank()) { + proxyBuilder.addNewEnv() + .withName("RESHAPR_GATEWAY_INSTANCE") + .withValue(gatewayInstance) + .endEnv(); + } + + // Add the container to the Pod + if (resource.getSpec().getContainers() == null) { + resource.getSpec().setContainers(new ArrayList<>()); + } + resource.getSpec().getContainers().add(proxyBuilder.build()); + + // Add the routing label + if (resource.getMetadata().getLabels() == null) { + resource.getMetadata().setLabels(new HashMap<>()); + } + resource.getMetadata().getLabels().put(PROXY_INJECTED_LABEL, "true"); } - resource.getMetadata().getLabels().put("mutated", "true"); return resource; } } diff --git a/operator/src/main/java/io/reshapr/kubernetes/operator/DeploymentProxyReconciler.java b/operator/src/main/java/io/reshapr/kubernetes/operator/DeploymentProxyReconciler.java new file mode 100644 index 0000000..392c2cb --- /dev/null +++ b/operator/src/main/java/io/reshapr/kubernetes/operator/DeploymentProxyReconciler.java @@ -0,0 +1,124 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.reshapr.kubernetes.operator; + +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.ServiceBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import org.jboss.logging.Logger; + +import java.util.HashMap; +import java.util.Map; + +@ControllerConfiguration +public class DeploymentProxyReconciler implements Reconciler { + + private static final Logger logger = Logger.getLogger(DeploymentProxyReconciler.class); + + public static final String INJECT_ANNOTATION = "io.reshapr/inject"; + public static final String PROXY_INJECTED_LABEL = "reshapr.io/proxy-injected"; + public static final int DEFAULT_PROXY_PORT = 7777; + + private final KubernetesClient client; + + public DeploymentProxyReconciler(KubernetesClient client) { + this.client = client; + } + + @Override + public UpdateControl reconcile(Deployment deployment, Context context) { + Map annotations = deployment.getMetadata().getAnnotations(); + String namespace = deployment.getMetadata().getNamespace(); + if (namespace == null) { + namespace = "reshapr-system"; + } + + String deploymentName = deployment.getMetadata().getName(); + String serviceName = "reshapr-proxy-" + deploymentName; + + if (annotations == null || !"true".equalsIgnoreCase(annotations.get(INJECT_ANNOTATION))) { + Service existingService = client.services().inNamespace(namespace).withName(serviceName).get(); + if (existingService != null) { + logger.infof("Injection annotation absent/removed. Deleting dedicated Service %s in namespace %s", serviceName, namespace); + client.services().inNamespace(namespace).withName(serviceName).delete(); + } + return UpdateControl.noUpdate(); + } + + logger.infof("Reconciling Deployment %s/%s for Reshapr Proxy Service", namespace, deploymentName); + + // Check if service already exists + Service existingService = client.services().inNamespace(namespace).withName(serviceName).get(); + if (existingService == null) { + logger.infof("Creating dedicated Service %s in namespace %s", serviceName, namespace); + + // Resolve proxy port + int proxyPort = DEFAULT_PROXY_PORT; + String portStr = annotations.get("io.reshapr/proxy-port"); + if (portStr == null) { + try { + var configMap = client.configMaps().inNamespace(namespace).withName("reshapr-injection-config").get(); + if (configMap != null && configMap.getData() != null && configMap.getData().containsKey("proxy-port")) { + portStr = configMap.getData().get("proxy-port"); + } + } catch (Exception ignored) {} + } + if (portStr != null && !portStr.isBlank()) { + try { + proxyPort = Integer.parseInt(portStr); + } catch (NumberFormatException ignored) {} + } + + Map serviceSelector = new HashMap<>(); + serviceSelector.put(PROXY_INJECTED_LABEL, "true"); + if (deployment.getSpec() != null && deployment.getSpec().getSelector() != null && deployment.getSpec().getSelector().getMatchLabels() != null) { + serviceSelector.putAll(deployment.getSpec().getSelector().getMatchLabels()); + } + + Service newService = new ServiceBuilder() + .withNewMetadata() + .withName(serviceName) + .withNamespace(namespace) + // Add owner reference so it's deleted when deployment is deleted + .addNewOwnerReference() + .withApiVersion("apps/v1") + .withKind("Deployment") + .withName(deploymentName) + .withUid(deployment.getMetadata().getUid()) + .endOwnerReference() + .endMetadata() + .withNewSpec() + .withSessionAffinity("ClientIP") + .withSelector(serviceSelector) + .addNewPort() + .withName("proxy") + .withPort(proxyPort) + .withNewTargetPort(proxyPort) + .endPort() + .endSpec() + .build(); + + client.services().inNamespace(namespace).resource(newService).create(); + } + + return UpdateControl.noUpdate(); + } +}