com.spotify.helios.common.JobValidator Maven / Gradle / Ivy
/*-
* -\-\-
* Helios Client
* --
* Copyright (C) 2016 Spotify AB
* --
* 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 com.spotify.helios.common;
import static com.google.common.base.Strings.isNullOrEmpty;
import static java.lang.String.format;
import static java.util.Collections.emptySet;
import static java.util.regex.Pattern.compile;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Sets;
import com.spotify.helios.common.descriptors.ExecHealthCheck;
import com.spotify.helios.common.descriptors.HealthCheck;
import com.spotify.helios.common.descriptors.HttpHealthCheck;
import com.spotify.helios.common.descriptors.Job;
import com.spotify.helios.common.descriptors.JobId;
import com.spotify.helios.common.descriptors.PortMapping;
import com.spotify.helios.common.descriptors.ServiceEndpoint;
import com.spotify.helios.common.descriptors.ServicePorts;
import com.spotify.helios.common.descriptors.TcpHealthCheck;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
public class JobValidator {
private static final Pattern NAME_VERSION_PATTERN = Pattern.compile("[0-9a-zA-Z-_.]+");
private static final Pattern HOSTNAME_PATTERN =
Pattern.compile("^([a-z0-9][a-z0-9-]{0,62}$)");
private static final Pattern DOMAIN_PATTERN =
Pattern.compile("^(?:(?:[a-zA-Z0-9]|(?:[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9]))"
+ "(\\.(?:[a-zA-Z0-9]|(?:[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])))*)\\.?$");
private static final Pattern IPV4_PATTERN =
Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$");
private static final Pattern NAME_COMPONENT_PATTERN = Pattern.compile("^([a-z0-9._-]+)$");
private static final int REPO_NAME_MAX_LENGTH = 255;
// taken from https://github.com/docker/distribution/blob/3150937b9f2b1b5b096b2634d0e7c44d4a0f89fb/reference/regexp.go#L36-L37
private static final Pattern TAG_PATTERN = Pattern.compile("[\\w][\\w.-]{0,127}");
private static final Pattern DIGIT_PERIOD = Pattern.compile("^[0-9.]+$");
private static final Pattern PORT_MAPPING_PROTO_PATTERN = compile("(tcp|udp)");
private static final Pattern PORT_MAPPING_NAME_PATTERN = compile("\\S+");
private static final Pattern REGISTRATION_NAME_PATTERN = compile("[_\\-\\w]+");
private static final List VALID_NETWORK_MODES = ImmutableList.of("bridge", "host");
private final boolean shouldValidateJobHash;
private final boolean shouldValidateAddCapabilities;
private final Set whitelistedCapabilities;
public JobValidator() {
this(true);
}
public JobValidator(final boolean shouldValidateJobHash) {
this(shouldValidateJobHash, false);
}
public JobValidator(final boolean shouldValidateJobHash,
final boolean shouldValidateAddCapabilities) {
this(shouldValidateJobHash, shouldValidateAddCapabilities, Collections.emptySet());
}
public JobValidator(final boolean shouldValidateJobHash,
final boolean shouldValidateAddCapabilities,
final Set whitelistedCapabilities) {
this.shouldValidateJobHash = shouldValidateJobHash;
this.shouldValidateAddCapabilities = shouldValidateAddCapabilities;
this.whitelistedCapabilities = whitelistedCapabilities;
}
public Set validate(final Job job) {
final Set errors = Sets.newHashSet();
errors.addAll(validateJobId(job));
errors.addAll(validateJobImage(job.getImage()));
errors.addAll(validateJobHostName(job.getHostname()));
// Check that there's not external port collision
final Set externalPorts = Sets.newHashSet();
for (final PortMapping mapping : job.getPorts().values()) {
final Integer externalMappedPort = mapping.getExternalPort();
if (externalPorts.contains(externalMappedPort) && externalMappedPort != null) {
errors.add(format("Duplicate external port mapping: %s", externalMappedPort));
}
externalPorts.add(externalMappedPort);
}
// Verify port mappings
for (final Map.Entry entry : job.getPorts().entrySet()) {
final String name = entry.getKey();
final PortMapping mapping = entry.getValue();
if (!PORT_MAPPING_PROTO_PATTERN.matcher(mapping.getProtocol()).matches()) {
errors.add(format("Invalid port mapping protocol: %s", mapping.getProtocol()));
}
if (!legalPort(mapping.getInternalPort())) {
errors.add(format("Invalid internal port: %d", mapping.getInternalPort()));
}
if (mapping.getExternalPort() != null && !legalPort(mapping.getExternalPort())) {
errors.add(format("Invalid external port: %d", mapping.getExternalPort()));
}
if (!PORT_MAPPING_NAME_PATTERN.matcher(name).matches()) {
errors.add(format("Invalid port mapping endpoint name: %s", name));
}
}
// Verify service registrations
for (final ServiceEndpoint registration : job.getRegistration().keySet()) {
final ServicePorts servicePorts = job.getRegistration().get(registration);
if (servicePorts == null || servicePorts.getPorts() == null) {
errors.add(format("registration for '%s' is malformed: does not have a port mapping",
registration.getName()));
continue;
}
for (final String portName : servicePorts.getPorts().keySet()) {
if (!job.getPorts().containsKey(portName)) {
errors.add(format("Service registration refers to missing port mapping: %s=%s",
registration, portName));
}
if (!REGISTRATION_NAME_PATTERN.matcher(registration.getName()).matches()) {
errors.add(format("Invalid service registration name: %s", registration.getName()));
}
}
}
// Validate volumes
for (final Map.Entry entry : job.getVolumes().entrySet()) {
final String path = entry.getKey();
final String source = entry.getValue();
if (!path.startsWith("/")) {
errors.add("Volume path is not absolute: " + path);
continue;
}
if (!isNullOrEmpty(source) && !source.startsWith("/")) {
errors.add("Volume source is not absolute: " + source);
continue;
}
final String[] parts = path.split(":", 3);
if (path.isEmpty()
|| path.equals("/") | parts.length > 2
|| (parts.length > 1 && parts[1].isEmpty())) {
errors.add(format("Invalid volume path: %s", path));
}
}
// Validate Expiry
final Date expiry = job.getExpires();
if (expiry != null && expiry.before(new Date())) {
errors.add("Job expires in the past");
}
errors.addAll(validateJobHealthCheck(job));
errors.addAll(validateJobNetworkMode(job));
if (shouldValidateAddCapabilities) {
errors.addAll(validateAddCapabilities(job));
}
return errors;
}
/**
* Validate the Job's image by checking it's not null or empty and has the right format.
* @param image The image String
* @return A set of error Strings
*/
private Set validateJobImage(final String image) {
final Set errors = Sets.newHashSet();
if (image == null) {
errors.add("Image was not specified.");
} else {
// Validate image name
validateImageReference(image, errors);
}
return errors;
}
/**
* Validate the Job's JobId by checking name, version, and hash are
* not null or empty, don't contain invalid characters.
* @param job The Job to check.
* @return A set of error Strings
*/
private Set validateJobId(final Job job) {
final Set errors = Sets.newHashSet();
final JobId jobId = job.getId();
if (jobId == null) {
errors.add("Job id was not specified.");
return errors;
}
final String jobIdVersion = jobId.getVersion();
final String jobIdHash = jobId.getHash();
final JobId recomputedId = job.toBuilder().build().getId();
errors.addAll(validateJobName(jobId, recomputedId));
errors.addAll(validateJobVersion(jobIdVersion, recomputedId));
if (this.shouldValidateJobHash) {
errors.addAll(validateJobHash(jobIdHash, recomputedId));
}
return errors;
}
private Set validateJobName(final JobId jobId, final JobId recomputedId) {
final Set errors = Sets.newHashSet();
final String jobIdName = jobId.getName();
if (isNullOrEmpty(jobIdName)) {
errors.add("Job name was not specified.");
return errors;
}
// Check that the job name contains only allowed characters
if (!NAME_VERSION_PATTERN.matcher(jobIdName).matches()) {
errors.add(format("Job name may only contain [0-9a-zA-Z-_.] in job name [%s].",
recomputedId.getName()));
}
// Check that the job id is correct
if (!recomputedId.getName().equals(jobIdName)) {
errors.add(format("Id name mismatch: %s != %s", jobIdName, recomputedId.getName()));
}
return errors;
}
private Set validateJobHostName(final String hostname) {
final Set errors = Sets.newHashSet();
// we're fine if no hostname is set
if (isNullOrEmpty(hostname)) {
return errors;
}
// Check that the job name contains only allowed characters
if (!HOSTNAME_PATTERN.matcher(hostname).matches()) {
errors.add(
format("Invalid hostname (%s), only [a-z0-9][a-z0-9-] are allowed, size between 1 and 63",
hostname));
}
return errors;
}
private Set validateJobVersion(final String jobIdVersion, final JobId recomputedId) {
final Set errors = Sets.newHashSet();
if (isNullOrEmpty(jobIdVersion)) {
errors.add(format("Job version was not specified in job id [%s].", recomputedId));
return errors;
}
if (!NAME_VERSION_PATTERN.matcher(jobIdVersion).matches()) {
// Check that the job version contains only allowed characters
errors.add(format("Job version may only contain [0-9a-zA-Z-_.] in job version [%s].",
recomputedId.getVersion()));
}
// Check that the job version is correct
if (!recomputedId.getVersion().equals(jobIdVersion)) {
errors.add(format("Id version mismatch: %s != %s", jobIdVersion, recomputedId.getVersion()));
}
return errors;
}
private Set validateJobHash(final String jobIdHash, final JobId recomputedId) {
final Set errors = Sets.newHashSet();
if (isNullOrEmpty(jobIdHash)) {
errors.add(format("Job hash was not specified in job id [%s].", recomputedId));
return errors;
}
if (jobIdHash.indexOf(':') != -1) {
// TODO (dxia) Are hashes allowed to have chars not in NAME_VERSION_PATTERN?
errors.add(format("Job hash contains colon in job id [%s].", recomputedId));
}
// Check that the job hash is correct
if (!recomputedId.getHash().equals(jobIdHash)) {
errors.add(format("Id hash mismatch: %s != %s", jobIdHash, recomputedId.getHash()));
}
return errors;
}
@SuppressWarnings("ConstantConditions")
private boolean validateImageReference(final String imageRef, final Collection errors) {
boolean valid = true;
final String repo;
final String tag;
final String digest;
final int lastAtSign = imageRef.lastIndexOf('@');
final int lastColon = imageRef.lastIndexOf(':');
if (lastAtSign != -1) {
repo = imageRef.substring(0, lastAtSign);
digest = imageRef.substring(lastAtSign + 1);
valid &= validateDigest(digest, errors);
} else if (lastColon != -1 && !(tag = imageRef.substring(lastColon + 1)).contains("/")) {
repo = imageRef.substring(0, lastColon);
valid &= validateTag(tag, errors);
} else {
repo = imageRef;
}
final String invalidRepoName = "Invalid repository name (ex: \"registry.domain.tld/myrepos\")";
if (repo.contains("://")) {
// It cannot contain a scheme!
errors.add(invalidRepoName);
return false;
}
final String[] nameParts = repo.split("/", 2);
if (!nameParts[0].contains(".")
&& !nameParts[0].contains(":")
&& !nameParts[0].equals("localhost")) {
// This is a Docker Index repos (ex: samalba/hipache or ubuntu)
return validateRepositoryName(imageRef, repo, errors);
}
if (nameParts.length < 2) {
// There is a dot in repos name (and no registry address)
// Is it a Registry address without repos name?
errors.add(invalidRepoName);
return false;
}
final String endpoint = nameParts[0];
final String reposName = nameParts[1];
valid &= validateEndpoint(endpoint, errors);
valid &= validateRepositoryName(imageRef, reposName, errors);
return valid;
}
private boolean validateTag(final String tag, final Collection errors) {
if (tag.isEmpty()) {
errors.add("Tag cannot be empty");
return false;
}
if (!TAG_PATTERN.matcher(tag).matches()) {
errors.add(format("Illegal tag: \"%s\", must match %s", tag, TAG_PATTERN));
return false;
}
return true;
}
private boolean validateDigest(final String digest, final Collection errors) {
if (digest.isEmpty()) {
errors.add("Digest cannot be empty");
return false;
}
final int firstColon = digest.indexOf(':');
final int lastColon = digest.lastIndexOf(':');
if ((firstColon <= 0) || (firstColon != lastColon) || (firstColon == digest.length() - 1)) {
errors.add(format("Illegal digest: \"%s\"", digest));
return false;
}
return true;
}
private boolean validateEndpoint(final String endpoint, final Collection errors) {
final String[] parts = endpoint.split(":", 2);
if (!validateAddress(parts[0], errors)) {
return false;
}
if (parts.length > 1) {
final int port;
try {
port = Integer.valueOf(parts[1]);
} catch (NumberFormatException e) {
errors.add(format("Invalid port in endpoint: \"%s\"", endpoint));
return false;
}
if (port < 0 || port > 65535) {
errors.add(format("Invalid port in endpoint: \"%s\"", endpoint));
return false;
}
}
return true;
}
private boolean validateAddress(final String address, final Collection errors) {
if (IPV4_PATTERN.matcher(address).matches()) {
return true;
} else if (!DOMAIN_PATTERN.matcher(address).matches() || DIGIT_PERIOD.matcher(address).find()) {
errors.add(format("Invalid domain name: \"%s\"", address));
return false;
}
return true;
}
private boolean validateRepositoryName(
final String imageName,
final String repositoryName,
final Collection errors) {
/*
From https://github.com/docker/docker/commit/ea98cf74aad3c2633268d5a0b8a2f80b331ddc0b:
The image name which is made up of slash-separated name components, ....
Name components may contain lowercase characters, digits and separators. A separator is defined
as a period, one or two underscores, or one or more dashes. A name component may not start or
end with a separator.
*/
final String[] nameParts = repositoryName.split("/");
for (String name : nameParts) {
if (!NAME_COMPONENT_PATTERN.matcher(name).matches()) {
errors.add(
format("Invalid image name (%s), only %s is allowed for each slash-separated "
+ "name component (failed on \"%s\")",
imageName, NAME_COMPONENT_PATTERN, name)
);
return false;
}
}
if (repositoryName.length() > REPO_NAME_MAX_LENGTH) {
errors.add(
format("Invalid image name (%s), repository name cannot be larger than %d characters",
imageName, REPO_NAME_MAX_LENGTH)
);
return false;
}
return true;
}
/**
* Validate the Job's health check.
* @param job The Job to check.
* @return A set of error Strings
*/
private Set validateJobHealthCheck(final Job job) {
final HealthCheck healthCheck = job.getHealthCheck();
if (healthCheck == null) {
return emptySet();
}
final Set errors = Sets.newHashSet();
if (healthCheck instanceof ExecHealthCheck) {
final List command = ((ExecHealthCheck) healthCheck).getCommand();
if (command == null || command.isEmpty()) {
errors.add("A command must be defined for `docker exec`-based health checks.");
}
} else if (healthCheck instanceof HttpHealthCheck || healthCheck instanceof TcpHealthCheck) {
final String port;
if (healthCheck instanceof HttpHealthCheck) {
port = ((HttpHealthCheck) healthCheck).getPort();
} else {
port = ((TcpHealthCheck) healthCheck).getPort();
}
final Map ports = job.getPorts();
if (isNullOrEmpty(port)) {
errors.add("A port must be defined for HTTP and TCP health checks.");
} else if (!ports.containsKey(port)) {
errors.add(format("Health check port '%s' not defined in the job. Known ports are '%s'",
port, Joiner.on(", ").join(ports.keySet())));
}
}
return errors;
}
/**
* Validate the Job's network mode.
* @param job The Job to check.
* @return A set of error Strings
*/
private Set validateJobNetworkMode(final Job job) {
final String networkMode = job.getNetworkMode();
if (networkMode == null) {
return emptySet();
}
final Set errors = Sets.newHashSet();
if (!VALID_NETWORK_MODES.contains(networkMode) && !networkMode.startsWith("container:")) {
errors.add(String.format(
"A Docker container's network mode must be %s, or container:.",
Joiner.on(", ").join(VALID_NETWORK_MODES)));
}
return errors;
}
/**
* Validate the Job's added Linux capabilities.
* @param job The Job to check.
* @return A set of error Strings
*/
private Set validateAddCapabilities(final Job job) {
final Set caps = job.getAddCapabilities();
if (caps == null) {
return emptySet();
}
final Set errors = Sets.newHashSet();
final Set disallowedCaps = Sets.difference(caps, whitelistedCapabilities);
if (!disallowedCaps.isEmpty()) {
errors.add(String.format(
"The following Linux capabilities aren't allowed by the Helios master: '%s'. "
+ "The allowed capabilities are: '%s'.",
Joiner.on(", ").join(disallowedCaps), Joiner.on(", ").join(whitelistedCapabilities)));
}
return errors;
}
private boolean legalPort(final int port) {
return port >= 0 && port <= 65535;
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy