/*- * -\-\- * 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.Charsets.UTF_8; import static com.google.common.collect.Sets.newHashSet; import static com.spotify.helios.common.descriptors.Job.DEFAULT_NETWORK_MODE; import static com.spotify.helios.common.descriptors.Job.EMPTY_CAPS; import static com.spotify.helios.common.descriptors.Job.EMPTY_COMMAND; import static com.spotify.helios.common.descriptors.Job.EMPTY_CREATED; import static com.spotify.helios.common.descriptors.Job.EMPTY_CREATING_USER; import static com.spotify.helios.common.descriptors.Job.EMPTY_ENV; import static com.spotify.helios.common.descriptors.Job.EMPTY_EXPIRES; import static com.spotify.helios.common.descriptors.Job.EMPTY_GRACE_PERIOD; import static com.spotify.helios.common.descriptors.Job.EMPTY_HEALTH_CHECK; import static com.spotify.helios.common.descriptors.Job.EMPTY_HOSTNAME; import static com.spotify.helios.common.descriptors.Job.EMPTY_LABELS; import static com.spotify.helios.common.descriptors.Job.EMPTY_METADATA; import static com.spotify.helios.common.descriptors.Job.EMPTY_PORTS; import static com.spotify.helios.common.descriptors.Job.EMPTY_REGISTRATION; import static com.spotify.helios.common.descriptors.Job.EMPTY_REGISTRATION_DOMAIN; import static com.spotify.helios.common.descriptors.Job.EMPTY_RESOURCES; import static com.spotify.helios.common.descriptors.Job.EMPTY_SECONDS_TO_WAIT; import static com.spotify.helios.common.descriptors.Job.EMPTY_SECURITY_OPT; import static com.spotify.helios.common.descriptors.Job.EMPTY_TOKEN; import static com.spotify.helios.common.descriptors.Job.EMPTY_VOLUMES; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.core.Is.is; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThat; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.io.Resources; import com.spotify.helios.common.descriptors.HealthCheck; 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.ServicePortParameters; import com.spotify.helios.common.descriptors.ServicePorts; import java.net.URL; import java.nio.file.Files; import java.util.Map; import java.util.Set; import org.junit.Test; public class JobValidatorTest { private static final HealthCheck HEALTH_CHECK = HealthCheck.newHttpHealthCheck().setPath("/").setPort("1").build(); private static final Job VALID_JOB = Job.newBuilder() .setName("foo") .setVersion("1") .setImage("bar") .setHostname("baz") .setEnv(ImmutableMap.of("FOO", "BAR")) .setPorts(ImmutableMap.of("1", PortMapping.of(1, 1), "2", PortMapping.of(2, 2))) .setHealthCheck(HEALTH_CHECK) .build(); private final JobValidator validator = new JobValidator(true, true); @Test public void testValidJobPasses() { assertThat(validator.validate(VALID_JOB), is(empty())); } @Test public void testValidNamesPass() { final Job.Builder b = Job.newBuilder().setVersion("1").setImage("bar"); assertThat(validator.validate(b.setName("foo").build()), is(empty())); assertThat(validator.validate(b.setName("17").build()), is(empty())); assertThat(validator.validate(b.setName("foo17.bar-baz_quux").build()), is(empty())); } @Test public void testValidVersionsPass() { final Job.Builder b = Job.newBuilder().setName("foo").setImage("bar"); assertThat(validator.validate(b.setVersion("foo").build()), is(empty())); assertThat(validator.validate(b.setVersion("17").build()), is(empty())); assertThat(validator.validate(b.setVersion("foo17.bar-baz_quux").build()), is(empty())); } @Test public void testValidImagePasses() { final Job.Builder b = Job.newBuilder().setName("foo").setVersion("1"); assertThat(validator.validate(b.setImage("repo").build()), is(empty())); assertThat(validator.validate(b.setImage("namespace/repo").build()), is(empty())); assertThat(validator.validate(b.setImage("namespace/repo:tag").build()), is(empty())); assertThat(validator.validate(b.setImage("namespace/repo:1.2").build()), is(empty())); assertThat(validator.validate(b.setImage("reg.istry:4711/repo").build()), is(empty())); assertThat(validator.validate(b.setImage("reg.istry.:4711/repo").build()), is(empty())); assertThat(validator.validate(b.setImage("reg.istry:4711/namespace/repo").build()), is(empty())); assertThat(validator.validate(b.setImage("reg.istry.:4711/namespace/repo").build()), is(empty())); assertThat(validator.validate(b.setImage("1.2.3.4:4711/namespace/repo").build()), is(empty())); assertThat(validator.validate(b.setImage("registry.test.net:80/fooo/bar").build()), is(empty())); assertThat(validator.validate(b.setImage("registry.test.net.:80/fooo/bar").build()), is(empty())); assertThat( validator.validate(b.setImage( "namespace/foo@sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae") .build()), is(empty())); assertThat( validator.validate(b.setImage( "foo.net/bar@sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae") .build()), is(empty())); assertThat( validator.validate(b.setImage( "foo@tarsum.v1+sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b") .build()), is(empty())); } @Test public void testValidHostnamesPass() { final Job.Builder b = Job.newBuilder().setName("foo").setVersion("1").setImage("bar"); assertThat(validator.validate(b.setHostname("foo").build()), is(empty())); assertThat(validator.validate(b.setHostname("17").build()), is(empty())); // 63 chars assertThat(validator.validate(b.setHostname(Strings.repeat("hostname", 7) + "hostnam").build()), is(empty())); assertThat(validator.validate(b.setHostname("a").build()), is(empty())); assertThat(validator.validate(b.setHostname("foo17bar-baz-quux").build()), is(empty())); } @Test public void testValidVolumesPass() { final Job j = Job.newBuilder().setName("foo").setVersion("1").setImage("foobar").build(); assertThat(validator.validate(j.toBuilder().addVolume("/foo").build()), is(empty())); assertThat(validator.validate(j.toBuilder().addVolume("/foo", "/").build()), is(empty())); assertThat(validator.validate(j.toBuilder().addVolume("/foo:ro", "/").build()), is(empty())); assertThat(validator.validate(j.toBuilder().addVolume("/foo", "/bar").build()), is(empty())); assertThat(validator.validate(j.toBuilder().addVolume("/foo:ro", "/bar").build()), is(empty())); } @Test public void testValidPortTagsPass() { final Job j = Job.newBuilder().setName("foo").setVersion("1").setImage("foobar").build(); final Job.Builder builder = j.toBuilder(); final Map<String, PortMapping> ports = ImmutableMap.of("add_ports1", PortMapping.of(1234), "add_ports2", PortMapping.of(2345)); final ImmutableMap.Builder<String, ServicePortParameters> servicePortsBuilder = ImmutableMap.builder(); servicePortsBuilder.put("add_ports1", new ServicePortParameters( ImmutableList.of("tag1", "tag2"))); servicePortsBuilder.put("add_ports2", new ServicePortParameters( ImmutableList.of("tag3", "tag4"))); final ServicePorts servicePorts = new ServicePorts(servicePortsBuilder.build()); final Map<ServiceEndpoint, ServicePorts> addRegistration = ImmutableMap.of( ServiceEndpoint.of("add_service", "add_proto"), servicePorts); builder.setPorts(ports).setRegistration(addRegistration); assertThat(validator.validate(builder.build()), is(empty())); } @Test public void testPortMappingCollisionFails() throws Exception { final Job job = Job.newBuilder() .setName("foo") .setVersion("1") .setImage("bar") .setPorts(ImmutableMap.of("1", PortMapping.of(1, 1), "2", PortMapping.of(2, 1))) .build(); assertEquals(ImmutableSet.of("Duplicate external port mapping: 1"), validator.validate(job)); } @Test public void testIdMismatchFails() throws Exception { final Job job = new Job(JobId.fromString("foo:bar:badf00d"), "bar", EMPTY_HOSTNAME, EMPTY_CREATED, EMPTY_COMMAND, EMPTY_ENV, EMPTY_RESOURCES, EMPTY_PORTS, EMPTY_REGISTRATION, EMPTY_GRACE_PERIOD, EMPTY_VOLUMES, EMPTY_EXPIRES, EMPTY_REGISTRATION_DOMAIN, EMPTY_CREATING_USER, EMPTY_TOKEN, EMPTY_HEALTH_CHECK, EMPTY_SECURITY_OPT, DEFAULT_NETWORK_MODE, EMPTY_METADATA, EMPTY_CAPS, EMPTY_CAPS, EMPTY_LABELS, EMPTY_SECONDS_TO_WAIT); final JobId recomputedId = job.toBuilder().build().getId(); assertEquals(ImmutableSet.of("Id hash mismatch: " + job.getId().getHash() + " != " + recomputedId.getHash()), validator.validate(job)); } @Test public void testInvalidNamesFail() throws Exception { final Job.Builder b = Job.newBuilder().setVersion("1").setImage("foo"); assertEquals(newHashSet("Job name was not specified.", "Job hash was not specified in job id [null:1]."), validator.validate(b.build())); assertThat(validator.validate(b.setName("foo@bar").build()), contains( equalTo("Job name may only contain [0-9a-zA-Z-_.] in job name [foo@bar]."))); assertThat(validator.validate(b.setName("foo&bar").build()), contains( equalTo("Job name may only contain [0-9a-zA-Z-_.] in job name [foo&bar]."))); } @Test public void testInvalidVersionsFail() throws Exception { final Job.Builder b = Job.newBuilder().setName("foo").setImage("foo"); assertEquals(newHashSet("Job version was not specified in job id [foo:null].", "Job hash was not specified in job id [foo:null]."), validator.validate(b.build())); assertThat(validator.validate(b.setVersion("17@bar").build()), contains(equalTo("Job version may only contain [0-9a-zA-Z-_.] " + "in job version [17@bar]."))); assertThat(validator.validate(b.setVersion("17&bar").build()), contains(equalTo("Job version may only contain [0-9a-zA-Z-_.] " + "in job version [17&bar]."))); } @Test public void testInvalidImagesFail() throws Exception { final Job.Builder b = Job.newBuilder().setName("foo").setVersion("1"); assertEquals(newHashSet("Tag cannot be empty"), validator.validate(b.setImage("repo:").build())); assertEquals(newHashSet("Digest cannot be empty"), validator.validate(b.setImage("foo@").build())); assertEquals(newHashSet("Illegal digest: \":123\""), validator.validate(b.setImage("foo@:123").build())); assertEquals(newHashSet("Illegal digest: \"sha256:\""), validator.validate(b.setImage("foo@sha256:").build())); assertFalse(validator.validate(b.setImage("repo:/").build()).isEmpty()); assertEquals(newHashSet("Invalid domain name: \"1.2.3.4.\""), validator.validate(b.setImage("1.2.3.4.:4711/namespace/repo").build())); assertEquals(newHashSet("Invalid domain name: \" reg.istry\""), validator.validate(b.setImage(" reg.istry:4711/repo").build())); assertEquals(newHashSet("Invalid domain name: \"reg .istry\""), validator.validate(b.setImage("reg .istry:4711/repo").build())); assertEquals(newHashSet("Invalid domain name: \"reg.istry \""), validator.validate(b.setImage("reg.istry :4711/repo").build())); assertEquals(newHashSet("Invalid port in endpoint: \"reg.istry: 4711\""), validator.validate(b.setImage("reg.istry: 4711/repo").build())); assertEquals(newHashSet("Invalid port in endpoint: \"reg.istry:4711 \""), validator.validate(b.setImage("reg.istry:4711 /repo").build())); assertEquals(newHashSet("Invalid image name (reg.istry:4711/ repo), only ^([a-z0-9._-]+)$ is " + "allowed for each slash-separated name component " + "(failed on \" repo\")"), validator.validate(b.setImage("reg.istry:4711/ repo").build())); assertEquals(newHashSet("Invalid image name (reg.istry:4711/namespace /repo), only " + "^([a-z0-9._-]+)$ is allowed for each slash-separated name component " + "(failed on \"namespace \")"), validator.validate(b.setImage("reg.istry:4711/namespace /repo").build())); assertEquals(newHashSet("Invalid image name (reg.istry:4711/namespace/ repo), only " + "^([a-z0-9._-]+)$ is allowed for each slash-separated name component " + "(failed on \" repo\")"), validator.validate(b.setImage("reg.istry:4711/namespace/ repo").build())); assertEquals(newHashSet("Invalid image name (reg.istry:4711/namespace/repo ), only " + "^([a-z0-9._-]+)$ is allowed for each slash-separated name component " + "(failed on \"repo \")"), validator.validate(b.setImage("reg.istry:4711/namespace/repo ").build())); assertEquals(newHashSet("Invalid domain name: \"foo-.ba|z\""), validator.validate(b.setImage("foo-.ba|z/namespace/baz").build())); assertEquals(newHashSet("Invalid domain name: \"reg..istry\""), validator.validate(b.setImage("reg..istry/namespace/baz").build())); assertEquals(newHashSet("Invalid domain name: \"reg..istry\""), validator.validate(b.setImage("reg..istry/namespace/baz").build())); assertEquals(newHashSet("Invalid port in endpoint: \"foo:345345345\""), validator.validate(b.setImage("foo:345345345/namespace/baz").build())); assertEquals(newHashSet("Invalid port in endpoint: \"foo:-17\""), validator.validate(b.setImage("foo:-17/namespace/baz").build())); final String foos = Strings.repeat("foo", 100); final String image = foos + "/bar"; assertEquals(newHashSet("Invalid image name (" + image + "), repository name cannot be larger" + " than 255 characters"), validator.validate(b.setImage(image).build())); } @Test public void testInValidHostnamesFail() { final Job.Builder b = Job.newBuilder().setName("foo").setVersion("1").setImage("bar"); // 64 chars final String toolonghostname = Strings.repeat("hostname", 8); assertEquals(newHashSet("Invalid hostname (" + toolonghostname + "), " + "only [a-z0-9][a-z0-9-] are allowed, size between 1 and 63"), validator.validate(b.setHostname(toolonghostname).build())); assertEquals(newHashSet("Invalid hostname (%/ RJU&%(=N/U), " + "only [a-z0-9][a-z0-9-] are allowed, size between 1 and 63"), validator.validate(b.setHostname("%/ RJU&%(=N/U").build())); assertEquals(newHashSet("Invalid hostname (-), " + "only [a-z0-9][a-z0-9-] are allowed, size between 1 and 63"), validator.validate(b.setHostname("-").build())); assertEquals(newHashSet("Invalid hostname (foo17.bar-baz_quux), " + "only [a-z0-9][a-z0-9-] are allowed, size between 1 and 63"), validator.validate(b.setHostname("foo17.bar-baz_quux").build())); assertEquals(newHashSet("Invalid hostname (D34DB33F), " + "only [a-z0-9][a-z0-9-] are allowed, size between 1 and 63"), validator.validate(b.setHostname("D34DB33F").build())); } @Test public void testInvalidVolumesFail() { final Job j = Job.newBuilder().setName("foo").setVersion("1").setImage("foobar").build(); assertEquals(newHashSet("Invalid volume path: /"), validator.validate(j.toBuilder().addVolume("/").build())); assertEquals(newHashSet("Invalid volume path: /foo:"), validator.validate(j.toBuilder().addVolume("/foo:", "/bar").build())); assertEquals(newHashSet("Volume path is not absolute: foo"), validator.validate(j.toBuilder().addVolume("foo").build())); assertEquals(newHashSet("Volume path is not absolute: foo"), validator.validate(j.toBuilder().addVolume("foo", "/bar").build())); assertEquals(newHashSet("Volume source is not absolute: bar"), validator.validate(j.toBuilder().addVolume("/foo", "bar").build())); } @Test public void testInvalidHealthCheckFail() { final Job jobWithNoPorts = Job.newBuilder() .setName("foo") .setVersion("1") .setImage("foobar") .setHealthCheck(HEALTH_CHECK) .build(); assertEquals(1, validator.validate(jobWithNoPorts).size()); final Job jobWithWrongPort = jobWithNoPorts.toBuilder() .addPort("a", PortMapping.of(1, 1)) .build(); assertEquals(1, validator.validate(jobWithWrongPort).size()); } @Test public void testValidNetworkModesPass() { Job job = Job.newBuilder() .setName("foo") .setVersion("1") .setImage("foobar") .setNetworkMode("bridge") .build(); assertEquals(0, validator.validate(job).size()); job = Job.newBuilder() .setName("foo") .setVersion("1") .setImage("foobar") .setNetworkMode("host") .build(); assertEquals(0, validator.validate(job).size()); job = Job.newBuilder() .setName("foo") .setVersion("1") .setImage("foobar") .setNetworkMode("container:foo") .build(); assertEquals(0, validator.validate(job).size()); } @Test public void testInvalidNetworkModeFail() { final Job job = Job.newBuilder() .setName("foo") .setVersion("1") .setImage("foobar") .setNetworkMode("chocolate") .build(); assertEquals(1, validator.validate(job).size()); } @Test public void testExpiry() { // make a date that's 24 hours behind final java.util.Date d = new java.util.Date(System.currentTimeMillis() - (86400 * 1000)); final Job j = Job.newBuilder().setName("foo").setVersion("1").setImage("foobar") .setExpires(d).build(); assertEquals(newHashSet("Job expires in the past"), validator.validate(j)); } @Test public void testWhitelistedCapabilities_noWhitelist() throws Exception { final Job job = Job.newBuilder() .setName("foo") .setVersion("1") .setImage("foobar") .setAddCapabilities(ImmutableSet.of("cap1", "cap2")) .build(); assertEquals(1, validator.validate(job).size()); } @Test public void testWhitelistedCapabilities_withWhitelist() throws Exception { final JobValidator validator = new JobValidator(true, true, ImmutableSet.of("cap1")); final Job job = Job.newBuilder() .setName("foo") .setVersion("1") .setImage("foobar") .setAddCapabilities(ImmutableSet.of("cap1", "cap2")) .build(); final Set<String> errors = validator.validate(job); assertEquals(1, errors.size()); assertThat(errors.iterator().next(), equalTo( "The following Linux capabilities aren't allowed by the Helios master: 'cap2'. " + "The allowed capabilities are: 'cap1'.")); final JobValidator validator2 = new JobValidator(true, true, ImmutableSet.of("cap1", "cap2")); final Set<String> errors2 = validator2.validate(job); assertEquals(0, errors2.size()); } @Test public void testImageNamespaceWithHyphens() { final Job job = VALID_JOB.toBuilder() .setImage("b.gcr.io/cloudsql-docker/gce-proxy:1.05") .build(); assertThat(validator.validate(job), is(empty())); } @Test public void testImageNameWithManyNameComponents() { final Job job = VALID_JOB.toBuilder() .setImage("b.gcr.io/cloudsql-docker/and/more/components/gce-proxy:1.05") .build(); assertThat(validator.validate(job), is(empty())); } @Test public void testImageNameInvalidTag() { final Job job = VALID_JOB.toBuilder() .setImage("foo/bar:a b c") .build(); assertThat(validator.validate(job), contains( containsString("Illegal tag: \"a b c\"") )); } /** * Tests that Jobs deserialized from JSON representation that happen to have malformed * "registration" sections are properly handled. This mimics a real life test case where the * "volumes" entry was accidentally indented wrong and included within the "registration" * section. */ @Test public void testJobFromJsonWithInvalidRegistration() throws Exception { final URL resource = getClass().getResource("job-with-bad-registration.json"); final byte[] bytes = Resources.toByteArray(resource); final Job job = Json.read(bytes, Job.class); assertThat(validator.validate(job), contains("registration for 'volumes' is malformed: does not have a port mapping")); } }