diff --git a/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/services/v1/VersionsService.java b/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/services/v1/VersionsService.java index c6813e370c..89146e1ae3 100644 --- a/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/services/v1/VersionsService.java +++ b/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/services/v1/VersionsService.java @@ -20,6 +20,7 @@ import com.netflix.spinnaker.halyard.config.config.v1.RelaxedObjectMapper; import com.netflix.spinnaker.halyard.config.problem.v1.ConfigProblemBuilder; import com.netflix.spinnaker.halyard.core.error.v1.HalException; +import com.netflix.spinnaker.halyard.core.memoize.v1.ExpiringConcurrentMap; import com.netflix.spinnaker.halyard.core.registry.v1.BillOfMaterials; import com.netflix.spinnaker.halyard.core.registry.v1.ProfileRegistry; import com.netflix.spinnaker.halyard.core.registry.v1.Versions; @@ -29,6 +30,7 @@ import retrofit.RetrofitError; import java.io.IOException; +import java.util.Optional; import static com.netflix.spinnaker.halyard.core.problem.v1.Problem.Severity.FATAL; @@ -43,6 +45,12 @@ public class VersionsService { @Autowired RelaxedObjectMapper relaxedObjectMapper; + static private ExpiringConcurrentMap concurrentMap = ExpiringConcurrentMap.fromMinutes(10); + + static private String latestHalyardKey = "__latest-halyard__"; + + static private String latestSpinnakerKey = "__latest-spinnaker__"; + public Versions getVersions() { try { return relaxedObjectMapper.convertValue( @@ -82,7 +90,29 @@ public BillOfMaterials getBillOfMaterials(String version) { } } - public String getLatest() { - return getVersions().getLatestSpinnaker(); + public String getLatestHalyardVersion() { + String result = concurrentMap.get(latestHalyardKey); + if (result == null) { + result = getVersions().getLatestHalyard(); + concurrentMap.put(latestHalyardKey, result); + } + + return result; + } + + public String getRunningHalyardVersion() { + return Optional.ofNullable(VersionsService.class + .getPackage() + .getImplementationVersion()).orElse("Unknown"); + } + + public String getLatestSpinnakerVersion() { + String result = concurrentMap.get(latestSpinnakerKey); + if (result == null) { + result = getVersions().getLatestSpinnaker(); + concurrentMap.put(latestSpinnakerKey, result); + } + + return result; } } diff --git a/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/validate/v1/DeploymentConfigurationValidator.java b/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/validate/v1/DeploymentConfigurationValidator.java index 542ccf2a84..b8f2fd7099 100644 --- a/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/validate/v1/DeploymentConfigurationValidator.java +++ b/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/validate/v1/DeploymentConfigurationValidator.java @@ -68,10 +68,10 @@ public void validate(ConfigProblemSetBuilder p, DeploymentConfiguration n) { if (!isReleased) { // Checks if version is of the form X.Y.Z if (version.matches("\\d+\\.\\d+\\.\\d+")) { - String majorMinor = toMajorMinor(version); + String majorMinor = Versions.toMajorMinor(version); Optional patchVersion = versions.getVersions() .stream() - .map(v -> new ImmutablePair<>(v, toMajorMinor(v.getVersion()))) + .map(v -> new ImmutablePair<>(v, Versions.toMajorMinor(v.getVersion()))) .filter(v -> v.getRight() != null) .filter(v -> v.getRight().equals(majorMinor)) .map(ImmutablePair::getLeft) @@ -89,13 +89,4 @@ public void validate(ConfigProblemSetBuilder p, DeploymentConfiguration n) { } } } - - public static String toMajorMinor(String fullVersion) { - int lastDot = fullVersion.lastIndexOf("."); - if (lastDot < 0) { - return null; - } - - return fullVersion.substring(0, lastDot); - } } diff --git a/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/validate/v1/HalconfigValidator.java b/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/validate/v1/HalconfigValidator.java new file mode 100644 index 0000000000..f310d76d49 --- /dev/null +++ b/halyard-config/src/main/java/com/netflix/spinnaker/halyard/config/validate/v1/HalconfigValidator.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017 Google, Inc. + * + * 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.netflix.spinnaker.halyard.config.validate.v1; + +import com.netflix.spinnaker.halyard.config.model.v1.node.Halconfig; +import com.netflix.spinnaker.halyard.config.model.v1.node.Validator; +import com.netflix.spinnaker.halyard.config.problem.v1.ConfigProblemSetBuilder; +import com.netflix.spinnaker.halyard.config.services.v1.VersionsService; +import com.netflix.spinnaker.halyard.core.problem.v1.Problem; +import com.netflix.spinnaker.halyard.core.registry.v1.Versions; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class HalconfigValidator extends Validator { + @Autowired + VersionsService versionsService; + + @Override + public void validate(ConfigProblemSetBuilder p, Halconfig n) { + try { + String runningVersion = versionsService.getRunningHalyardVersion(); + String latestVersion = versionsService.getLatestHalyardVersion(); + + if (StringUtils.isEmpty(latestVersion)) { + log.warn("No latest version of halyard published."); + return; + } + + if (runningVersion.contains("SNAPSHOT")) { + return; + } + + + if (Versions.lessThan(runningVersion, latestVersion)) { + p.addProblem(Problem.Severity.WARNING, "There is a newer version of Halyard available (" + latestVersion + "), please update when possible") + .setRemediation("sudo apt-get update && sudo apt-get upgrade spinnaker-halyard"); + } + } catch (Exception e) { + log.warn("Unexpected error comparing versions: " + e); + } + } +} diff --git a/halyard-config/src/test/groovy/com/netflix/spinnaker/halyard/config/validate/v1/HalconfigValidatorSpec.groovy b/halyard-config/src/test/groovy/com/netflix/spinnaker/halyard/config/validate/v1/HalconfigValidatorSpec.groovy new file mode 100644 index 0000000000..29659fb566 --- /dev/null +++ b/halyard-config/src/test/groovy/com/netflix/spinnaker/halyard/config/validate/v1/HalconfigValidatorSpec.groovy @@ -0,0 +1,62 @@ +/* + * Copyright 2017 Google, Inc. + * + * 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.netflix.spinnaker.halyard.config.validate.v1 + +import com.netflix.spinnaker.halyard.config.problem.v1.ConfigProblemSetBuilder +import com.netflix.spinnaker.halyard.config.services.v1.VersionsService +import spock.lang.Specification + +class HalconfigValidatorSpec extends Specification { + void "complains that you're out of date"() { + given: + VersionsService versionsServiceMock = Mock(VersionsService) + versionsServiceMock.getLatestHalyardVersion() >> latest + versionsServiceMock.getRunningHalyardVersion() >> current + HalconfigValidator validator = new HalconfigValidator() + validator.versionsService = versionsServiceMock + + ConfigProblemSetBuilder problemBuilder = new ConfigProblemSetBuilder(null); + + when: + validator.validate(problemBuilder, null) + + then: + def problems = problemBuilder.build().problems + problems.size() == 1 + problems.get(0).message.contains("please update") + + where: + current | latest + "0.0.0" | "1.0.0" + "0.0.0" | "0.1.0" + "0.0.0" | "0.0.1" + "1.0.0" | "1.0.1" + "1.1.0" | "1.1.1" + "1.2.0" | "2.0.0" + "1.1.0" | "10.0.0" + "3.1.0" | "10.0.0" + "30.1.0-1" | "100.0.0" + "0.0.0-1" | "1.0.0-0" + "0.0.0-2" | "0.1.0-2" + "0.0.0-3" | "0.0.1-1" + "0.0.1-1" | "1.0.0-0" + "0.0.1-2" | "0.1.0-2" + "0.0.1-3" | "0.1.1-1" + } +} diff --git a/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/memoize/v1/ExpiringConcurrentMap.java b/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/memoize/v1/ExpiringConcurrentMap.java new file mode 100644 index 0000000000..b559d906ee --- /dev/null +++ b/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/memoize/v1/ExpiringConcurrentMap.java @@ -0,0 +1,168 @@ +/* + * Copyright 2017 Google, Inc. + * + * 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.netflix.spinnaker.halyard.core.memoize.v1; + +import lombok.extern.slf4j.Slf4j; + +import java.util.AbstractMap; +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Slf4j +public class ExpiringConcurrentMap implements Map { + private final long timeout; + + private final ConcurrentHashMap delegate; + + public static ExpiringConcurrentMap fromMillis(long timeout) { + return new ExpiringConcurrentMap(timeout); + } + + public static ExpiringConcurrentMap fromMinutes(long timeout) { + return new ExpiringConcurrentMap(TimeUnit.MINUTES.toMillis(timeout)); + } + + private ExpiringConcurrentMap(long timeout) { + this.timeout = timeout; + this.delegate = new ConcurrentHashMap<>(); + } + + @Override + public int size() { + return delegate.size(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public boolean containsKey(Object key) { + return get(key) != null; + } + + @Override + public boolean containsValue(Object value) { + return delegate.values().stream().anyMatch(v -> v.value.equals(value) && !v.expired()); + } + + @Override + public V get(Object key) { + Entry e = delegate.get(key); + if (e == null) { + return null; + } else if (e.expired()) { + log.info("Removing expired key: " + key); + delegate.remove(key); + return null; + } else { + return e.value; + } + } + + @Override + public V put(K key, V value) { + delegate.put(key, new Entry(value)); + return value; + } + + @Override + public V remove(Object key) { + return (V) delegate.remove(key); + } + + @Override + public void putAll(Map m) { + m.entrySet().forEach(es -> put(es.getKey(), es.getValue())); + } + + @Override + public void clear() { + delegate.clear(); + } + + @Override + public Set keySet() { + return entrySet().stream() + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + @Override + public Collection values() { + return entrySet().stream() + .map(Map.Entry::getValue) + .collect(Collectors.toList()); + } + + @Override + public Set> entrySet() { + Set result = delegate.entrySet() + .stream() + .filter(e -> !e.getValue().expired()) + .collect(Collectors.toSet()); + + return result.stream() + .map(e -> new AbstractMap.SimpleEntry<>((K) e.getKey(), ((Entry) e.getValue()).value)) + .collect(Collectors.toSet()); + } + + private class Entry { + long lastUpdate; + V value; + + public Entry() { } + + public Entry(V value) { + this.value = value; + this.lastUpdate = System.currentTimeMillis(); + } + + @Override + public String toString() { + return "lastUpdate: " + lastUpdate + ", value: " + value; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } else if (!Entry.class.isAssignableFrom(obj.getClass())) { + return false; + } + + Entry other = (Entry) obj; + + if (other.value == null) { + return value == null; + } else { + return other.value.equals(value); + } + } + + boolean expired() { + return System.currentTimeMillis() - lastUpdate > timeout; + } + } +} diff --git a/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/registry/v1/Versions.java b/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/registry/v1/Versions.java index 0adbe7e68b..31f2650635 100644 --- a/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/registry/v1/Versions.java +++ b/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/registry/v1/Versions.java @@ -20,8 +20,10 @@ import lombok.Data; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.List; +import java.util.stream.Collectors; @Data public class Versions { @@ -65,4 +67,47 @@ public String toString() { return result.toString(); } + + public static String toMajorMinor(String fullVersion) { + int lastDot = fullVersion.lastIndexOf("."); + if (lastDot < 0) { + return null; + } + + return fullVersion.substring(0, lastDot); + } + + public static String toMajorMinorPatch(String fullVersion) { + int lastDash = fullVersion.indexOf("-"); + if (lastDash < 0) { + return fullVersion; + } + + return fullVersion.substring(0, lastDash); + } + + public static boolean lessThan(String v1, String v2) { + v1 = toMajorMinorPatch(v1); + v2 = toMajorMinorPatch(v2); + + List split1 = Arrays.stream(v1.split("\\.")).map(Integer::valueOf).collect(Collectors.toList()); + List split2 = Arrays.stream(v2.split("\\.")).map(Integer::valueOf).collect(Collectors.toList()); + + if (split1.size() != split2.size() || split1.size() != 3) { + throw new IllegalArgumentException("Both versions must satisfy the X.Y.Z naming convention"); + } + + for (int i = 0; i < split1.size(); i++) { + if (split1.get(i) == split2.get(i)) { + continue; + } else if (split1.get(i) < split2.get(i)) { + return true; + } else if (split1.get(i) > split2.get(i)) { + return false; + } + } + + // all 3 points are equal + return false; + } } diff --git a/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/tasks/v1/DaemonTask.java b/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/tasks/v1/DaemonTask.java index 1aa22056d9..e4e535b56b 100644 --- a/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/tasks/v1/DaemonTask.java +++ b/halyard-core/src/main/java/com/netflix/spinnaker/halyard/core/tasks/v1/DaemonTask.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; import java.util.function.Consumer; import java.util.function.Supplier; @@ -31,6 +32,7 @@ public class DaemonTask { final String uuid; boolean timedOut; final long timeout; + final String version; State state = State.NOT_STARTED; DaemonResponse response; Exception fatalError; @@ -45,6 +47,9 @@ public DaemonTask(@JsonProperty("name") String name, @JsonProperty("timeout") lo this.name = name; this.uuid = UUID.randomUUID().toString(); this.timeout = timeout; + this.version = Optional.ofNullable(DaemonTask.class + .getPackage() + .getImplementationVersion()).orElse("Unknown"); } void newStage(String name) { diff --git a/halyard-web/src/main/java/com/netflix/spinnaker/halyard/controllers/v1/VersionsController.java b/halyard-web/src/main/java/com/netflix/spinnaker/halyard/controllers/v1/VersionsController.java index 27db09f119..389b7fad84 100644 --- a/halyard-web/src/main/java/com/netflix/spinnaker/halyard/controllers/v1/VersionsController.java +++ b/halyard-web/src/main/java/com/netflix/spinnaker/halyard/controllers/v1/VersionsController.java @@ -42,7 +42,7 @@ DaemonTask config() { @RequestMapping(value = "/latest/", method = RequestMethod.GET) DaemonTask latest() { DaemonResponse.StaticRequestBuilder builder = new DaemonResponse.StaticRequestBuilder<>(); - builder.setBuildResponse(() -> versionsService.getLatest()); + builder.setBuildResponse(() -> versionsService.getLatestSpinnakerVersion()); return DaemonTaskHandler.submitTask(builder::build, "Get latest released version"); }