BundleUtilities.java
package org.entando.kubernetes.service.digitalexchange;
import io.fabric8.zjsonpatch.internal.guava.Strings;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.entando.kubernetes.config.AppConfiguration;
import org.entando.kubernetes.exception.EntandoComponentManagerException;
import org.entando.kubernetes.exception.digitalexchange.InvalidBundleException;
import org.entando.kubernetes.model.DbmsVendor;
import org.entando.kubernetes.model.bundle.BundleType;
import org.entando.kubernetes.model.bundle.EntandoBundleVersion;
import org.entando.kubernetes.model.bundle.descriptor.DockerImage;
import org.entando.kubernetes.model.bundle.descriptor.plugin.PluginDescriptor;
import org.entando.kubernetes.model.bundle.descriptor.plugin.PluginDescriptorV1Role;
import org.entando.kubernetes.model.bundle.reader.BundleReader;
import org.entando.kubernetes.model.debundle.EntandoDeBundle;
import org.entando.kubernetes.model.plugin.EntandoPlugin;
import org.entando.kubernetes.model.plugin.EntandoPluginBuilder;
import org.entando.kubernetes.model.plugin.ExpectedRole;
import org.entando.kubernetes.model.plugin.Permission;
import org.entando.kubernetes.model.plugin.PluginSecurityLevel;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
@UtilityClass
@Slf4j
public class BundleUtilities {
public static final String OFFICIAL_SEMANTIC_VERSION_REGEX = "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-("
+ "(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\"
+ ".[0-9a-zA-Z-]+)*))?$";
public static final int MAX_K8S_POD_NAME_LENGTH = 63;
public static final int RESERVED_K8S_POD_NAME_LENGTH = 31;
public static final int MAX_ENTANDO_K8S_POD_NAME_LENGTH = MAX_K8S_POD_NAME_LENGTH - RESERVED_K8S_POD_NAME_LENGTH;
public static final String DEPLOYMENT_BASE_NAME_MAX_LENGHT_EXCEEDED_ERROR = "The prefix \"%s\" of the pod that is "
+ "about to be created is longer than %d. The prefix has been created using %s";
public static final String DEPLOYMENT_BASE_NAME_MAX_LENGHT_ERROR_DEPLOYMENT_END = "Please specify a shorter value "
+ "in the \"deploymentBaseName\" plugin descriptor property";
public static final String DEPLOYMENT_BASE_NAME_MAX_LENGHT_ERROR_DOCKER_IMAGE_SUFFIX = "the format "
+ "[docker-organization]-[docker-image-name]-[docker-image-version]. "
+ DEPLOYMENT_BASE_NAME_MAX_LENGHT_ERROR_DEPLOYMENT_END;
public static final String DEPLOYMENT_BASE_NAME_MAX_LENGHT_ERROR_DEPLOYMENT_SUFFIX = "the descriptor "
+ "\"deploymentBaseName\" property. " + DEPLOYMENT_BASE_NAME_MAX_LENGHT_ERROR_DEPLOYMENT_END;
public static final String BUNDLE_TYPE_LABEL_NAME = "bundle-type";
public static final String LATEST_VERSION = "latest";
private static final String DESCRIPTOR_VERSION_STARTING_CHAR = "v";
public static final String PLUGIN_DESCRIPTOR_VERSION_REGEXP = "^(v)(\\d+)";
public static final Pattern PLUGIN_DESCRIPTOR_VERSION_PATTERN = Pattern
.compile(BundleUtilities.PLUGIN_DESCRIPTOR_VERSION_REGEXP);
public static String getBundleVersionOrFail(EntandoDeBundle bundle, String versionReference) {
if (Strings.isNullOrEmpty(versionReference)) {
throw new EntandoComponentManagerException("Null or empty version property received");
}
String version = versionReference;
if (version.equals(LATEST_VERSION)) {
version = composeLatestVersion(bundle)
.map(EntandoBundleVersion::getVersion)
.orElse(null);
} else if (!isSemanticVersion(versionReference)) {
version = (String) bundle.getSpec().getDetails().getDistTags().get(versionReference);
}
if (Strings.isNullOrEmpty(version)) {
throw new EntandoComponentManagerException(
"Invalid version '" + versionReference + "' for bundle '" + bundle.getSpec().getDetails().getName()
+ "'");
}
return version;
}
public static boolean isSemanticVersion(String versionToFind) {
String possibleSemVer = versionToFind.startsWith("v") ? versionToFind.substring(1) : versionToFind;
return possibleSemVer.matches(getOfficialSemanticVersionRegex());
}
/**
* Check semantic version definition: https://semver.org/#is-v123-a-semantic-version
*
* @return The semantic version PCRE compatible regular expression
*/
public static String getOfficialSemanticVersionRegex() {
return OFFICIAL_SEMANTIC_VERSION_REGEX;
}
/**
* define and return the latest version respect to the sem version rules applied to the available versions list.
*
* @param entandoDeBundle the EntandoDeBundle of which return the latest version
* @return the latest version respect to the sem version rules
*/
public static Optional<EntandoBundleVersion> composeLatestVersion(EntandoDeBundle entandoDeBundle) {
if (entandoDeBundle == null || entandoDeBundle.getSpec() == null
|| entandoDeBundle.getSpec().getDetails() == null) {
return Optional.empty();
}
Optional<EntandoBundleVersion> latestVersionOpt;
// get the latest from the spec.details.dist-tags.latest property if available
if (entandoDeBundle.getSpec().getDetails().getDistTags() != null
&& entandoDeBundle.getSpec().getDetails().getDistTags().containsKey(LATEST_VERSION)) {
latestVersionOpt = Optional.of(new EntandoBundleVersion()
.setVersion(entandoDeBundle.getSpec().getDetails().getDistTags().get(LATEST_VERSION).toString()));
} else if (! CollectionUtils.isEmpty(entandoDeBundle.getSpec().getDetails().getVersions())) {
// calculate the latest from the versions list
latestVersionOpt = entandoDeBundle.getSpec().getDetails().getVersions().stream()
.map(version -> new EntandoBundleVersion().setVersion(version))
.max(Comparator.comparing(EntandoBundleVersion::getSemVersion));
} else {
latestVersionOpt = Optional.empty();
}
return latestVersionOpt;
}
/**
* compose the plugin descriptor version by concatenating the received version number to the leading char v.
*
* @param version the integer version
* @return the composed plugin descriptor version
*/
public static String composePluginDescriptorVersion(int version) {
return DESCRIPTOR_VERSION_STARTING_CHAR + version;
}
public static List<ExpectedRole> extractRolesFromDescriptor(PluginDescriptor descriptor) {
return descriptor.getRoles().stream()
.distinct()
.map(role -> new ExpectedRole(role, role))
.collect(Collectors.toList());
}
public static List<Permission> extractPermissionsFromDescriptor(PluginDescriptor descriptor) {
return Optional.ofNullable(descriptor.getPermissions())
.orElse(Collections.emptyList())
.stream()
.distinct()
.map(permission -> new Permission(permission.getClientId(), permission.getRole()))
.collect(Collectors.toList());
}
public static String extractNameFromDescriptor(PluginDescriptor descriptor) {
return composeDeploymentBaseNameAndTruncateIfNeeded(descriptor);
}
public static String extractIngressPathFromDescriptor(PluginDescriptor descriptor) {
return Optional.ofNullable(composeIngressPathFromIngressPathProperty(descriptor))
.orElse(composeIngressPathFromDockerImage(descriptor));
}
public static Map<String, String> extractLabelsFromDescriptor(PluginDescriptor descriptor) {
var dockerImage = descriptor.getDockerImage();
return getLabelsFromImage(dockerImage);
}
public static String composeDeploymentBaseName(PluginDescriptor descriptor) {
if (StringUtils.hasLength(descriptor.getDeploymentBaseName())) {
return makeKubernetesCompatible(descriptor.getDeploymentBaseName());
} else {
return composeNameFromDockerImage(descriptor.getDockerImage());
}
}
private static String composeDeploymentBaseNameAndTruncateIfNeeded(PluginDescriptor descriptor) {
String deploymentBaseName;
String errorSuffix;
if (StringUtils.hasLength(descriptor.getDeploymentBaseName())) {
deploymentBaseName = makeKubernetesCompatible(descriptor.getDeploymentBaseName());
errorSuffix = DEPLOYMENT_BASE_NAME_MAX_LENGHT_ERROR_DEPLOYMENT_SUFFIX;
} else {
deploymentBaseName = composeNameFromDockerImage(descriptor.getDockerImage());
errorSuffix = DEPLOYMENT_BASE_NAME_MAX_LENGHT_ERROR_DOCKER_IMAGE_SUFFIX;
}
if (AppConfiguration.isTruncatePluginBaseNameIfLonger()) {
deploymentBaseName = truncatePodPrefixName(deploymentBaseName);
}
return validateAndReturnDeploymentBaseName(deploymentBaseName, errorSuffix,
AppConfiguration.isTruncatePluginBaseNameIfLonger());
}
/**
* validate the deploymentBaseName. if the validation fails an EntandoComponentManagerException is thrown
*
* @param deploymentBaseName the base name to use for the deployments that have to be generated in
* kubernetes
* @param errorSuffix the suffix to append to the error that specifies which properties was used to
* generate the deployment base name
* @param isTruncatePluginBaseEnabled if true the plugin base name should be truncated if it's longer than the
* admitted, so no validation is applied here
* @return the validated string
*/
private static String validateAndReturnDeploymentBaseName(
String deploymentBaseName,
String errorSuffix,
boolean isTruncatePluginBaseEnabled) {
// deploymentBaseName has to not be longer than 63 chars
if (!isTruncatePluginBaseEnabled && deploymentBaseName.length() > MAX_ENTANDO_K8S_POD_NAME_LENGTH) {
throw new EntandoComponentManagerException(
String.format(
DEPLOYMENT_BASE_NAME_MAX_LENGHT_EXCEEDED_ERROR,
deploymentBaseName,
MAX_ENTANDO_K8S_POD_NAME_LENGTH,
errorSuffix));
}
return deploymentBaseName;
}
public static String composeNameFromDockerImage(DockerImage image) {
return String.join("-",
makeKubernetesCompatible(image.getOrganization()),
makeKubernetesCompatible(image.getName()));
}
public static String truncatePodPrefixName(String podPrefixName) {
if (podPrefixName.length() > MAX_ENTANDO_K8S_POD_NAME_LENGTH) {
podPrefixName = podPrefixName
.substring(0, Math.min(MAX_ENTANDO_K8S_POD_NAME_LENGTH, podPrefixName.length()))
.replaceAll("-$", ""); // remove a possible ending hyphen
}
return podPrefixName;
}
/**
* read the ingress path property from the plugin descriptor and return its value if present, null otherwise.
*
* @param descriptor the PluginDescriptor from which get the ingress path
* @return the ingress path read from the plugin descriptor property or null if it is not present
*/
private static String composeIngressPathFromIngressPathProperty(PluginDescriptor descriptor) {
String ingressPath = null;
if (StringUtils.hasLength(descriptor.getIngressPath())) {
ingressPath = descriptor.getIngressPath();
if (ingressPath.charAt(0) != '/') {
ingressPath = "/" + ingressPath;
}
}
return ingressPath;
}
private static String composeIngressPathFromDockerImage(PluginDescriptor descriptor) {
DockerImage image = descriptor.getDockerImage();
List<String> ingressSegmentList = new ArrayList<>(Arrays.asList(image.getOrganization(), image.getName()));
if (BundleUtilities.getPluginDescriptorIntegerVersion(descriptor) < 3) {
ingressSegmentList.add(image.getVersion());
}
List<String> kubeCompatiblesSegmentList = ingressSegmentList.stream()
.map(BundleUtilities::makeKubernetesCompatible).collect(Collectors.toList());
return "/" + String.join("/", kubeCompatiblesSegmentList);
}
/**
* read a plugin descriptor and return the corresponding plugin descriptor version as integer (without the leading v
* char).
*
* @param pluginDescriptor the plugin descriptor of which return the version number
* @return the integer version of the received plugin descriptor
*/
public static Integer getPluginDescriptorIntegerVersion(PluginDescriptor pluginDescriptor) {
if (!StringUtils.hasLength(pluginDescriptor.getDescriptorVersion())) {
return pluginDescriptor.isVersion1() ? 1 : 2;
} else {
Matcher matcher = PLUGIN_DESCRIPTOR_VERSION_PATTERN.matcher(pluginDescriptor.getDescriptorVersion());
if (!matcher.matches()) {
String err = "The plugin descriptor version does not match the expected format";
log.debug(err);
throw new InvalidBundleException(err);
}
return Integer.parseInt(matcher.group(2));
}
}
public static Map<String, String> getLabelsFromImage(DockerImage dockerImage) {
Map<String, String> labels = new HashMap<>();
labels.put("organization", dockerImage.getOrganization());
labels.put("name", dockerImage.getName());
labels.put("version", dockerImage.getVersion());
return labels;
}
/**
* generate the EntandoPlugin CR starting by the received plugin descriptor.
*
* @param descriptor the plugin descriptor from which get the CR data
* @return the EntandoPlugin CR generated starting by the descriptor data
*/
public static EntandoPlugin generatePluginFromDescriptor(PluginDescriptor descriptor) {
return descriptor.isVersion1()
? generatePluginFromDescriptorV1(descriptor) :
generatePluginFromDescriptorV2Plus(descriptor);
}
/**
* generate the EntandoPlugin CR starting by the received plugin descriptor version equal or major than 2.
*
* @param descriptor the plugin descriptor from which get the CR data
* @return the EntandoPlugin CR generated starting by the descriptor data
*/
public static EntandoPlugin generatePluginFromDescriptorV2Plus(PluginDescriptor descriptor) {
return new EntandoPluginBuilder()
.withNewMetadata()
.withName(extractNameFromDescriptor(descriptor))
.withLabels(extractLabelsFromDescriptor(descriptor))
.endMetadata()
.withNewSpec()
.withDbms(DbmsVendor.valueOf(descriptor.getDbms().toUpperCase()))
.withImage(descriptor.getImage())
.withIngressPath(extractIngressPathFromDescriptor(descriptor))
.withRoles(extractRolesFromDescriptor(descriptor))
.withHealthCheckPath(descriptor.getHealthCheckPath())
.withPermissions(extractPermissionsFromDescriptor(descriptor))
.withSecurityLevel(PluginSecurityLevel.forName(descriptor.getSecurityLevel()))
.endSpec()
.build();
}
/**
* generate the EntandoPlugin CR starting by the received plugin descriptor version equal to 1.
*
* @param descriptor the plugin descriptor from which get the CR data
* @return the EntandoPlugin CR generated starting by the descriptor data
*/
public static EntandoPlugin generatePluginFromDescriptorV1(PluginDescriptor descriptor) {
return new EntandoPluginBuilder()
.withNewMetadata()
.withName(extractNameFromDescriptor(descriptor))
.withLabels(getLabelsFromImage(descriptor.getDockerImage()))
.endMetadata()
.withNewSpec()
.withDbms(DbmsVendor.valueOf(descriptor.getSpec().getDbms().toUpperCase()))
.withImage(descriptor.getDockerImage().toString())
.withIngressPath(composeIngressPathFromDockerImage(descriptor))
.withRoles(extractRolesFromRoleList(descriptor.getSpec().getRoles()))
.withHealthCheckPath(descriptor.getSpec().getHealthCheckPath())
.withSecurityLevel(PluginSecurityLevel.forName(descriptor.getSpec().getSecurityLevel()))
.endSpec()
.build();
}
public static List<ExpectedRole> extractRolesFromRoleList(List<PluginDescriptorV1Role> roleList) {
return roleList.stream()
.distinct()
.map(role -> new ExpectedRole(role.getCode(), role.getName()))
.collect(Collectors.toList());
}
private static String makeKubernetesCompatible(String value) {
return value.toLowerCase()
.replaceAll("[\\/\\.\\:_]", "-");
}
/**
* extract the bundle type from the received EntandoDeBundle.
*
* @param entandoDeBundle the EntandoDeBundle from which extract the bundle type
* @return the BundleType reflecting the value found in the received EntandoDeBundle, BundleType.STANDARD_BUNDLE if
* no type is found
*/
public static BundleType extractBundleTypeFromBundle(EntandoDeBundle entandoDeBundle) {
if (null == entandoDeBundle) {
throw new EntandoComponentManagerException("The received EntandoDeBundle is null");
}
if (null == entandoDeBundle.getMetadata() || null == entandoDeBundle.getMetadata().getLabels()) {
return BundleType.STANDARD_BUNDLE;
}
return entandoDeBundle.getMetadata().getLabels()
.entrySet().stream()
.filter(entry -> entry.getKey().equals(BUNDLE_TYPE_LABEL_NAME))
.findFirst()
.map(bundleTypeEntry -> BundleType.fromType(bundleTypeEntry.getValue()))
.orElse(BundleType.STANDARD_BUNDLE);
}
/**
* determine and return the resource root folder for the current bundle. - if the current bundle is a standard
* bundle, root folder = current_bundle_code + '/resources' - otherwise '/resources'
*
* @param bundleReader the reader of the current bundle
* @return the resource root folder for the current bundle
* @throws IOException if a read error occurs during the bundle reading
*/
public static String determineBundleResourceRootFolder(BundleReader bundleReader) throws IOException {
var bundleType = bundleReader.readBundleDescriptor().getBundleType();
return "/" + (null == bundleType || bundleType == BundleType.STANDARD_BUNDLE
? bundleReader.getBundleCode()
: "");
}
}