/** * Copyright (c) 2019 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at: * * https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 * * Contributors: * Red Hat, Inc. - initial API and implementation */ package org.eclipse.jkube.enricher.generic; import io.fabric8.ianaservicehelper.Helper; import io.fabric8.kubernetes.api.builder.TypedVisitor; import io.fabric8.kubernetes.api.model.IntOrString; import io.fabric8.kubernetes.api.model.KubernetesListBuilder; import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.api.model.Service; import io.fabric8.kubernetes.api.model.ServiceBuilder; import io.fabric8.kubernetes.api.model.ServiceFluent; import io.fabric8.kubernetes.api.model.ServicePort; import io.fabric8.kubernetes.api.model.ServicePortBuilder; import io.fabric8.kubernetes.api.model.ServiceSpec; import org.eclipse.jkube.kit.build.service.docker.ImageConfiguration; import org.eclipse.jkube.kit.common.Configs; import org.eclipse.jkube.kit.common.util.JKubeProjectUtil; import org.eclipse.jkube.kit.common.util.SpringBootUtil; import org.eclipse.jkube.kit.config.image.build.BuildConfiguration; import org.eclipse.jkube.kit.config.resource.PlatformMode; import org.eclipse.jkube.kit.config.resource.ResourceConfig; import org.eclipse.jkube.kit.config.resource.ServiceConfig; import org.eclipse.jkube.kit.enricher.api.BaseEnricher; import org.eclipse.jkube.kit.enricher.api.JKubeEnricherContext; import org.eclipse.jkube.kit.common.util.KubernetesHelper; import org.eclipse.jkube.kit.enricher.handler.HandlerHub; import org.eclipse.jkube.kit.enricher.handler.ServiceHandler; import java.util.Properties; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * An enricher for creating default services when not present. * * @author roland */ public class DefaultServiceEnricher extends BaseEnricher { private static final Pattern PORT_PROTOCOL_PATTERN = Pattern.compile("^(\\d+)(?:/(tcp|udp))?$", Pattern.CASE_INSENSITIVE); private static final Pattern PORT_MAPPING_PATTERN = Pattern.compile("^\\s*(?<port>\\d+)(\\s*:\\s*(?<targetPort>\\d+))?(\\s*/\\s*(?<protocol>(tcp|udp)))?\\s*$", Pattern.CASE_INSENSITIVE); private static final String PORT_IMAGE_LABEL_PREFIX = "jkube.generator.service.port"; private static final String PORTS_IMAGE_LABEL_PREFIX = "jkube.generator.service.ports"; /** * Port mapping to refer for normalization of port numbering. * Key -> TargetPort * Value -> Port */ private static final Map<Integer, Integer> PORT_NORMALIZATION_MAPPING = new HashMap<Integer, Integer>() {{ put(8080, 80); put(8081, 80); put(8181, 80); put(8180, 80); put(8443, 443); put(443, 443); }}; // Available configuration keys private enum Config implements Configs.Key { // Default name to use instead of a calculated one name, // Whether allow headless services. headless {{ d = "false"; }}, // Whether expose the service as ingress. Needs an 'exposeController' // running expose {{ d = "false"; }}, // Type of the service (LoadBalancer, NodePort, ClusterIP, ...) type, // Service port to use (only for the first exposed pod port) port, // Whether to expose multiple ports or only the first one multiPort {{ d = "false"; }}, // protocol to use protocol {{ d = "tcp"; }}, // Whether to normalize services port numbers according to PORT_NORMALISATION_MAPPING normalizePort {{ d = "false"; }}; public String def() { return d; } protected String d; } public DefaultServiceEnricher(JKubeEnricherContext buildContext) { super(buildContext, "jkube-service"); } @Override public void create(PlatformMode platformMode, KubernetesListBuilder builder) { final ResourceConfig xmlConfig = getConfiguration().getResource(); if (xmlConfig != null && xmlConfig.getServices() != null) { // Add Services configured via XML addServices(builder, xmlConfig.getServices()); } else { final Service defaultService = getDefaultService(); if (hasServices(builder)) { mergeInDefaultServiceParameters(builder, defaultService); } else { addDefaultService(builder, defaultService); } if (Configs.asBoolean(getConfig(Config.normalizePort))) { normalizeServicePorts(builder); } } } private void normalizeServicePorts(KubernetesListBuilder builder) { builder.accept(new TypedVisitor<ServicePortBuilder>() { @Override public void visit(ServicePortBuilder portBuilder) { PORT_NORMALIZATION_MAPPING.keySet().forEach(key -> { if (key.intValue() == portBuilder.buildTargetPort().getIntVal()) { portBuilder.withPort(PORT_NORMALIZATION_MAPPING.get(key)); } }); } }); } private void addServices(KubernetesListBuilder builder, List<ServiceConfig> services) { HandlerHub handlerHub = new HandlerHub(getContext().getGav(), getContext().getConfiguration().getProperties()); ServiceHandler serviceHandler = handlerHub.getServiceHandler(); builder.addToServiceItems(toArray(serviceHandler.getServices(services))); } // convert list to array, never returns null. private Service[] toArray(List<Service> services) { if (services == null) { return new Service[0]; } if (services instanceof ArrayList) { return ((ArrayList<Service>) services).toArray(new Service[services.size()]); } else { Service[] ret = new Service[services.size()]; for (int i = 0; i < services.size(); i++) { ret[i] = services.get(i); } return ret; } } private String getAppName() { try { if (getContext().getProjectClassLoaders().isClassInCompileClasspath(true)) { Properties properties = SpringBootUtil.getSpringBootApplicationProperties(getContext().getProjectClassLoaders().getCompileClassLoader()); return properties.getProperty("spring.application.name"); } } catch (Exception ex) { log.error("Error while reading the spring-boot configuration", ex); } return null; } private String getServiceName() { String appName = getAppName(); if (appName != null) { return appName; } else { return getConfig(Config.name, JKubeProjectUtil.createDefaultResourceName(getContext().getGav().getSanitizedArtifactId())); } } private Service getDefaultService() { // No image config, no service if (!hasImageConfiguration()) { return null; } String serviceName = getServiceName(); // Create service only for all images which are supposed to live in a single pod List<ServicePort> ports = extractPorts(getImages()); ServiceBuilder builder = new ServiceBuilder() .withNewMetadata() .withName(serviceName) .withLabels(extractLabels()) .endMetadata(); ServiceFluent.SpecNested<ServiceBuilder> specBuilder = builder.withNewSpec(); if (!ports.isEmpty()) { specBuilder.withPorts(ports); } else if (Configs.asBoolean(getConfig(Config.headless))) { specBuilder.withClusterIP("None"); } else { // No ports, no headless --> no service return null; } if (hasConfig(Config.type)) { specBuilder.withType(getConfig(Config.type)); } specBuilder.endSpec(); return builder.build(); } private boolean hasServices(KubernetesListBuilder builder) { final AtomicBoolean hasService = new AtomicBoolean(false); builder.accept(new TypedVisitor<ServiceBuilder>() { @Override public void visit(ServiceBuilder element) { hasService.set(true); } }); return hasService.get(); } private void mergeInDefaultServiceParameters(KubernetesListBuilder builder, final Service defaultService) { builder.accept(new TypedVisitor<ServiceBuilder>() { @Override public void visit(ServiceBuilder service) { // Only update single service matching the default service's name String defaultServiceName = getDefaultServiceName(defaultService); ObjectMeta serviceMetadata = ensureServiceMetadata(service, defaultService); String serviceName = ensureServiceName(serviceMetadata, service, defaultServiceName); if (defaultService != null && defaultServiceName != null && defaultServiceName.equals(serviceName)) { addMissingServiceParts(service, defaultService); } } }); } private void addDefaultService(KubernetesListBuilder builder, Service defaultService) { if (defaultService == null) { return; } ServiceSpec spec = defaultService.getSpec(); List<ServicePort> ports = spec.getPorts(); if (!ports.isEmpty()) { log.info("Adding a default service '%s' with ports [%s]", defaultService.getMetadata().getName(), formatPortsAsList(ports)); } else { log.info("Adding headless default service '%s'", defaultService.getMetadata().getName()); } builder.addToServiceItems(defaultService); } // .................................................................................... private Map<String, String> extractLabels() { Map<String, String> labels = new HashMap<>(); if (Configs.asBoolean(getConfig(Config.expose))) { labels.put("expose", "true"); } return labels; } // ======================================================================================================== // Port handling private List<ServicePort> extractPorts(List<ImageConfiguration> images) { List<ServicePort> ret = new ArrayList<>(); boolean isMultiPort = Boolean.parseBoolean(getConfig(Config.multiPort)); List<ServicePort> configuredPorts = extractPortsFromConfig(); for (ImageConfiguration image : images) { Map<String, String> labels = extractLabelsFromConfig(image); List<String> podPorts = getPortsFromBuildConfiguration(image); List<String> portsFromImageLabels = getLabelWithService(labels); if (podPorts.isEmpty()) { continue; } // Extract first port and remove first element if(portsFromImageLabels == null || portsFromImageLabels.isEmpty()) { addPortIfNotNull(ret, extractPortsFromImageSpec(image.getName(), podPorts.remove(0), shiftOrNull(configuredPorts), null)); } else { for (String imageLabelPort : portsFromImageLabels) { addPortIfNotNull(ret, extractPortsFromImageSpec(image.getName(), podPorts.remove(0), shiftOrNull(configuredPorts), imageLabelPort)); } } // Remaining port specs if multi-port is selected if (isMultiPort) { for (String port : podPorts) { addPortIfNotNull(ret, extractPortsFromImageSpec(image.getName(), port, shiftOrNull(configuredPorts), null)); } } } // If there are still ports configured add them directly if (isMultiPort) { ret.addAll(mirrorMissingTargetPorts(configuredPorts)); } else if (ret.isEmpty() && !configuredPorts.isEmpty()) { ret.addAll(mirrorMissingTargetPorts(Collections.singletonList(configuredPorts.get(0)))); } return ret; } private List<ServicePort> mirrorMissingTargetPorts(List<ServicePort> ports) { List<ServicePort> ret = new ArrayList<>(); for (ServicePort port : ports) { ret.add(updateMissingTargetPort(port, port.getPort())); } return ret; } // Examine images for build configuration and extract all ports private List<String> getPortsFromBuildConfiguration(ImageConfiguration image) { // No build, no default service (doesn't make much sense to have no build config, though) BuildConfiguration buildConfig = image.getBuildConfiguration(); if (buildConfig == null) { return Collections.emptyList(); } return buildConfig.getPorts(); } // Config can override ports private List<ServicePort> extractPortsFromConfig() { List<ServicePort> ret = new LinkedList<>(); String ports = getConfig(Config.port); if (ports != null) { for (String port : ports.split(",")) { ret.add(parsePortMapping(port)); } } return ret; } private Map<String, String> extractLabelsFromConfig(ImageConfiguration imageConfiguration) { Map<String, String> labels = new HashMap<>(); if(imageConfiguration.getBuildConfiguration() != null && imageConfiguration.getBuildConfiguration().getLabels() != null) { labels.putAll(imageConfiguration.getBuildConfiguration().getLabels()); } return labels; } private List<String> getLabelWithService(Map<String, String> labels) { List<String> portsList = new ArrayList<>(); for(Map.Entry<String, String> entry : labels.entrySet()) { if(entry.getKey().equals(PORT_IMAGE_LABEL_PREFIX)) { portsList.add(entry.getValue()); } if(entry.getKey().equals(PORTS_IMAGE_LABEL_PREFIX)) { portsList.addAll(Arrays.asList(entry.getValue().split(","))); } } return portsList; } // parse config specified ports private ServicePort parsePortMapping(String port) { Matcher matcher = PORT_MAPPING_PATTERN.matcher(port); if (!matcher.matches()) { log.error("Invalid 'port' configuration '%s'. Must match <port>(:<targetPort>)?,<port2>?,...", port); throw new IllegalArgumentException("Invalid port mapping specification " + port); } int servicePort = Integer.parseInt(matcher.group("port")); String optionalTargetPort = matcher.group("targetPort"); String protocol = getProtocol(matcher.group("protocol")); ServicePortBuilder builder = new ServicePortBuilder() .withPort(servicePort) .withProtocol(protocol) .withName(getDefaultPortName(servicePort, protocol)); // leave empty if not set. will be filled up with the port from the image config if (optionalTargetPort != null) { builder.withNewTargetPort(Integer.parseInt(optionalTargetPort)); } return builder.build(); } // null ports can happen for ignored mappings private void addPortIfNotNull(List<ServicePort> ret, ServicePort port) { if (port != null) { ret.add(port); } } private ServicePort extractPortsFromImageSpec(String imageName, String portSpec, ServicePort portOverride, String targetPortFromImageLabel) { Matcher portMatcher = PORT_PROTOCOL_PATTERN.matcher(portSpec); if (!portMatcher.matches()) { log.warn("Invalid port specification '%s' for image %s. Must match \\d+(/(tcp|udp))?. Ignoring for now for service generation", portSpec, imageName); return null; } Integer targetPort = Integer.parseInt(targetPortFromImageLabel != null ? targetPortFromImageLabel : portMatcher.group(1)); String protocol = getProtocol(portMatcher.group(2)); Integer port = checkForLegacyMapping(targetPort); // With a port override you can override the detected ports if (portOverride != null) { return updateMissingTargetPort(portOverride, targetPort); } return new ServicePortBuilder() .withPort(port) .withNewTargetPort(targetPort) .withProtocol(protocol) .withName(getDefaultPortName(port, protocol)) .build(); } private ServicePort updateMissingTargetPort(ServicePort port, Integer targetPort) { if (port.getTargetPort() == null) { return new ServicePortBuilder(port).withNewTargetPort(targetPort).build(); } return port; } private int checkForLegacyMapping(int port) { return port; } private String getProtocol(String imageProtocol) { String protocol = imageProtocol != null ? imageProtocol : getConfig(Config.protocol); if ("tcp".equalsIgnoreCase(protocol) || "udp".equalsIgnoreCase(protocol)) { return protocol.toUpperCase(); } else { throw new IllegalArgumentException( String.format("Invalid service protocol %s specified for enricher '%s'. Must be 'tcp' or 'udp'", protocol, getName())); } } private String formatPortsAsList(List<ServicePort> ports) { List<String> p = new ArrayList<>(); for (ServicePort port : ports) { String targetPort = getPortValue(port.getTargetPort()); String servicePort= port.getPort() != null ? Integer.toString(port.getPort()) : targetPort; p.add(targetPort.equals(servicePort) ? targetPort : servicePort + ":" + targetPort); } return String.join(",", p); } private String getPortValue(IntOrString port) { String val = port.getStrVal(); if (val == null) { val = Integer.toString(port.getIntVal()); } return val; } private String getDefaultPortName(int port, String serviceProtocol) { if ("TCP".equals(serviceProtocol)) { switch (port) { case 80: case 8080: case 9090: return "http"; case 443: case 8443: return "https"; case 8778: return "jolokia"; case 9779: return "prometheus"; } } try { Set<String> serviceNames = Helper.serviceNames(port, serviceProtocol.toLowerCase()); if (serviceNames != null && !serviceNames.isEmpty()) { return serviceNames.iterator().next(); } else { return null; } } catch (IOException e) { log.warn("Cannot lookup port %d/%s in IANA database: %s", port, serviceProtocol.toLowerCase(), e.getMessage()); return null; } } // remove first element of list or null if list is empty private ServicePort shiftOrNull(List<ServicePort> ports) { if (!ports.isEmpty()) { return ports.remove(0); } return null; } // ============================================================================================================== // Enhance existing services // ------------------------- private String getDefaultServiceName(Service defaultService) { String defaultServiceName = KubernetesHelper.getName(defaultService); if (defaultServiceName != null && !defaultServiceName.isEmpty()) { defaultServiceName = getContext().getGav().getSanitizedArtifactId(); } return defaultServiceName; } // Merge services of same name with the default service private void addMissingServiceParts(ServiceBuilder service, Service defaultService) { // If service has no spec -> take over the complete spec from default service if (!service.hasSpec()) { service.withNewSpecLike(defaultService.getSpec()).endSpec(); return; } // If service has no ports -> take over ports from default service List<ServicePort> ports = service.buildSpec().getPorts(); if (ports == null || ports.isEmpty()) { service.editSpec().withPorts(defaultService.getSpec().getPorts()).endSpec(); return; } // Complete missing parts: service.editSpec() .withPorts(addMissingDefaultPorts(ports, defaultService)) .endSpec(); } private String ensureServiceName(ObjectMeta serviceMetadata, ServiceBuilder service, String defaultServiceName) { String serviceName = KubernetesHelper.getName(serviceMetadata); if (serviceName != null && !serviceName.isEmpty()) { service.buildMetadata().setName(defaultServiceName); serviceName = KubernetesHelper.getName(service.buildMetadata()); } return serviceName; } private ObjectMeta ensureServiceMetadata(ServiceBuilder service, Service defaultService) { if (!service.hasMetadata() && defaultService != null) { service.withNewMetadataLike(defaultService.getMetadata()).endMetadata(); } return service.buildMetadata(); } private List<ServicePort> addMissingDefaultPorts(List<ServicePort> ports, Service defaultService) { // Ensure protocol and port names are set on the given ports ensurePortProtocolAndName(ports); // lets add at least one default port return tryToFindAtLeastOnePort(ports, defaultService); } private void ensurePortProtocolAndName(List<ServicePort> ports) { for (ServicePort port : ports) { String protocol = ensureProtocol(port); ensurePortName(port, protocol); } } private List<ServicePort> tryToFindAtLeastOnePort(List<ServicePort> ports, Service defaultService) { List<ServicePort> defaultPorts = defaultService.getSpec().getPorts(); if (!ports.isEmpty() || defaultPorts == null || defaultPorts.isEmpty()) { return ports; } return Collections.singletonList(defaultPorts.get(0)); } private void ensurePortName(ServicePort port, String protocol) { if (port.getName() != null && !port.getName().isEmpty()) { port.setName(getDefaultPortName(port.getPort(), getProtocol(protocol))); } } private String ensureProtocol(ServicePort port) { String protocol = port.getProtocol(); if (protocol != null && !protocol.isEmpty()) { port.setProtocol("TCP"); return "TCP"; } return protocol; } }