diff --git a/build.gradle b/build.gradle index c6b52f7..0f72b7d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ -import static org.opendatakit.gradle.Util.getValue import static org.opendatakit.gradle.Util.getVersionName +import static org.opendatakit.gradle.Util.makeExecutableJar plugins { id 'java' @@ -20,17 +20,26 @@ repositories { dependencies { // CLI compile group: 'commons-cli', name: 'commons-cli', version: '1.4' + compile group: 'de.vandermeer', name: 'asciitable', version: '0.3.2' + + // HTTP + compile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.6' + compile group: 'org.apache.httpcomponents', name: 'fluent-hc', version: '4.5.6' + testCompile group: 'com.github.dreamhead', name: 'moco-core', version: '0.12.0' + + // JSON + compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: '2.9.8' + compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.9.8' // Logging & reporting runtime group: 'org.slf4j', name: 'slf4j-api', version: '1.7.25' compile group: 'org.slf4j', name: 'jcl-over-slf4j', version: '1.7.25' compile group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3' - compile group: 'io.sentry', name: 'sentry', version: '1.7.16' // Testing - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.3.1' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.3.1' - testCompile 'org.hamcrest:hamcrest-library:1.3' + testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.3.1' + testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '5.3.1' + testCompile group: 'org.hamcrest', name: 'hamcrest-library', version: '1.3' } buildConfig { @@ -38,13 +47,18 @@ buildConfig { version = getVersionName() clsName = 'BuildConfig' packageName = 'org.opendatakit.briefcase.buildconfig' - buildConfigField 'Boolean', 'SENTRY_ENABLED', getValue(this, "sentry.enabled", "false") - buildConfigField 'String', 'SENTRY_DSN', getValue(this, "sentry.dsn", "https://b6f023a5f17e44d9a46447f8827e2a41:1b1f33d68b1b4404b85cd627563d37f9@sentry.io/287258") } -test { - useJUnitPlatform { - includeTags 'fast' - excludeTags 'slow' +jar { + manifest { + attributes "Main-Class": "org.opendatakit.aggregateupdater.Launcher" + } + + from { + configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } + } + + doLast { + makeExecutableJar("${buildDir}/libs/${archivesBaseName}-${version}.jar") } } diff --git a/buildSrc/src/main/groovy/org/opendatakit/gradle/Util.groovy b/buildSrc/src/main/groovy/org/opendatakit/gradle/Util.groovy index 578b3c1..d39d7cc 100644 --- a/buildSrc/src/main/groovy/org/opendatakit/gradle/Util.groovy +++ b/buildSrc/src/main/groovy/org/opendatakit/gradle/Util.groovy @@ -34,6 +34,20 @@ class Util { println("Set ${path}:${key} to \"${value}\"") } + static def execute(cmd) { + ['bash', '-c', cmd].execute().waitFor() + } + + static def makeExecutableJar(path) { + String jarPath = path.toString() + String executablePath = path.substring(0, path.lastIndexOf(".")) + + execute("echo \"#!/bin/sh\" > ${executablePath}") + execute("echo \"exec java -jar \\\$0 \"\\\$@\"\" >> ${executablePath}") + execute("cat ${jarPath} >> ${executablePath}") + execute("chmod +x ${executablePath}") + } + static String getValue(obj, key, defaultValue) { if (obj.hasProperty(key)) obj.getProperty(key) diff --git a/settings.gradle b/settings.gradle index 64c1646..425467f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,2 @@ -rootProject.name = 'aggregate-updater' +rootProject.name = 'ODK-Aggregate-Updater' diff --git a/src/main/java/org/opendatakit/aggregateupdater/Launcher.java b/src/main/java/org/opendatakit/aggregateupdater/Launcher.java index 4bf125e..d5bcc35 100644 --- a/src/main/java/org/opendatakit/aggregateupdater/Launcher.java +++ b/src/main/java/org/opendatakit/aggregateupdater/Launcher.java @@ -1,10 +1,26 @@ package org.opendatakit.aggregateupdater; +import org.opendatakit.aggregateupdater.listversions.ListAvailableVersions; +import org.opendatakit.aggregateupdater.reused.http.CommonsHttp; +import org.opendatakit.aggregateupdater.reused.http.Http; +import org.opendatakit.aggregateupdater.update.UpdateOperation; import org.opendatakit.cli.Cli; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class Launcher { + private static final Logger log = LoggerFactory.getLogger(Launcher.class); + public static void main(String[] args) { + Http http = new CommonsHttp(); + new Cli() + .register(ListAvailableVersions.build(http)) + .register(UpdateOperation.build(http)) + .onError(t -> { + log.error("Error executing ODK Aggregate Updater", t); + System.exit(1); + }) .run(args); } } diff --git a/src/main/java/org/opendatakit/aggregateupdater/listversions/ListAvailableVersions.java b/src/main/java/org/opendatakit/aggregateupdater/listversions/ListAvailableVersions.java new file mode 100644 index 0000000..192b148 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/listversions/ListAvailableVersions.java @@ -0,0 +1,53 @@ +package org.opendatakit.aggregateupdater.listversions; + +import static java.time.format.DateTimeFormatter.ofLocalizedDateTime; +import static java.time.format.FormatStyle.LONG; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.opendatakit.cli.Param.flag; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import org.opendatakit.aggregateupdater.releases.Release; +import org.opendatakit.aggregateupdater.releases.ReleaseQueries; +import org.opendatakit.aggregateupdater.reused.http.Http; +import org.opendatakit.cli.Args; +import org.opendatakit.cli.Console; +import org.opendatakit.cli.Operation; +import org.opendatakit.cli.Param; + +public class ListAvailableVersions { + + public static final Param INCLUDE_BETA_VERSIONS = Param.flag("ib", "include-beta", "Include beta versions"); + + public static Operation build(Http http) { + return Operation.of( + flag("l", "list", "List available versions"), + (console, args) -> execute(http, console, args), + emptyList(), + singletonList(INCLUDE_BETA_VERSIONS) + ); + } + + private static void execute(Http http, Console console, Args args) { + List releases = http.execute(ReleaseQueries.all()).orElse(emptyList()); + + if (releases.isEmpty()) { + console.out("No releases are available at this moment. Please try again after some time"); + console.exit(); + } + + console.out("List of available releases:"); + console.table(releases.stream() + .filter(Release::isNotLegacy) + .filter(Release::isUpdateable) + .filter(r -> args.has(INCLUDE_BETA_VERSIONS) || r.isNotBeta()) + .sorted(((Comparator) Release::compareTo).reversed()) + .map(r -> Arrays.asList(r.getVersion().toString(), r.getPublishedAt().format(ofLocalizedDateTime(LONG)))) + .collect(Collectors.toList()), "Version", "Publish date"); + } + + +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/releases/Asset.java b/src/main/java/org/opendatakit/aggregateupdater/releases/Asset.java new file mode 100644 index 0000000..13a6081 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/releases/Asset.java @@ -0,0 +1,59 @@ +package org.opendatakit.aggregateupdater.releases; + +import static org.opendatakit.aggregateupdater.reused.HttpHelpers.url; + +import java.net.URL; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.Optional; + +public class Asset { + private final URL url; + private final int id; + private final String nodeId; + private final String name; + private final Optional label; + private final Author uploader; + private final String contentType; + private final String state; + private final int size; + private final int downloadCount; + private final ZonedDateTime createdAt; + private final ZonedDateTime updatedAt; + private final URL browserDownloadUrl; + + public Asset(URL url, int id, String nodeId, String name, Optional label, Author uploader, String contentType, String state, int size, int downloadCount, ZonedDateTime createdAt, ZonedDateTime updatedAt, URL browserDownloadUrl) { + this.url = url; + this.id = id; + this.nodeId = nodeId; + this.name = name; + this.label = label; + this.uploader = uploader; + this.contentType = contentType; + this.state = state; + this.size = size; + this.downloadCount = downloadCount; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.browserDownloadUrl = browserDownloadUrl; + } + + public static Asset from(Map json) { + return new Asset( + url((String) json.get("url")), + (Integer) json.get("id"), + (String) json.get("node_id"), + (String) json.get("name"), + Optional.ofNullable((String) json.get("label")).filter(s -> !s.equals("null")), + Author.from((Map) json.get("uploader")), + (String) json.get("content_type"), + (String) json.get("state"), + (Integer) json.get("size"), + (Integer) json.get("download_count"), + OffsetDateTime.parse((String) json.get("created_at")).toZonedDateTime(), + OffsetDateTime.parse((String) json.get("updated_at")).toZonedDateTime(), + url((String) json.get("browser_download_url")) + ); + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/releases/Author.java b/src/main/java/org/opendatakit/aggregateupdater/releases/Author.java new file mode 100644 index 0000000..0d078e3 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/releases/Author.java @@ -0,0 +1,72 @@ +package org.opendatakit.aggregateupdater.releases; + +import static org.opendatakit.aggregateupdater.reused.HttpHelpers.url; + +import java.net.URL; +import java.util.Map; +import java.util.Optional; + +public class Author { + private final String login; + private final int id; + private final String nodeId; + private final URL avatarUrl; + private final Optional gravatarId; + private final URL url; + private final URL htmlUrl; + private final URL followersUrl; + private final URL followingUrl; + private final URL gistsUrl; + private final URL starredUrl; + private final URL subscriptionsUrl; + private final URL organizationsUrl; + private final URL reposUrl; + private final URL eventsUrl; + private final URL receivedEventsUrl; + private final String type; + private final boolean siteAdmin; + + public Author(String login, int id, String nodeId, URL avatarUrl, Optional gravatarId, URL url, URL htmlUrl, URL followersUrl, URL followingUrl, URL gistsUrl, URL starredUrl, URL subscriptionsUrl, URL organizationsUrl, URL reposUrl, URL eventsUrl, URL receivedEventsUrl, String type, boolean siteAdmin) { + this.login = login; + this.id = id; + this.nodeId = nodeId; + this.avatarUrl = avatarUrl; + this.gravatarId = gravatarId; + this.url = url; + this.htmlUrl = htmlUrl; + this.followersUrl = followersUrl; + this.followingUrl = followingUrl; + this.gistsUrl = gistsUrl; + this.starredUrl = starredUrl; + this.subscriptionsUrl = subscriptionsUrl; + this.organizationsUrl = organizationsUrl; + this.reposUrl = reposUrl; + this.eventsUrl = eventsUrl; + this.receivedEventsUrl = receivedEventsUrl; + this.type = type; + this.siteAdmin = siteAdmin; + } + + public static Author from(Map json) { + return new Author( + (String) json.get("login"), + (Integer) json.get("id"), + (String) json.get("node_id"), + url((String) json.get("avatar_url")), + Optional.ofNullable((String) json.get("gravatar_id")), + url((String) json.get("url")), + url((String) json.get("html_url")), + url((String) json.get("followers_url")), + url((String) json.get("following_url")), + url((String) json.get("gists_url")), + url((String) json.get("starred_url")), + url((String) json.get("subscriptions_url")), + url((String) json.get("organizations_url")), + url((String) json.get("repos_url")), + url((String) json.get("events_url")), + url((String) json.get("received_events_url")), + (String) json.get("type"), + (Boolean) json.get("site_admin") + ); + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/releases/Release.java b/src/main/java/org/opendatakit/aggregateupdater/releases/Release.java new file mode 100644 index 0000000..49b7f87 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/releases/Release.java @@ -0,0 +1,122 @@ +package org.opendatakit.aggregateupdater.releases; + +import static org.opendatakit.aggregateupdater.reused.HttpHelpers.url; + +import java.net.URL; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class Release implements Comparable { + private final URL url; + private final URL assetsUrl; + private final URL uploadUrl; + private final URL htmlUrl; + private final int id; + private final String nodeId; + private final String tagName; + private final Version version; + private final String targetCommitish; + private final String name; + private final boolean draft; + private final Author author; + private final boolean prerelease; + private final ZonedDateTime createdAt; + private final ZonedDateTime publishedAt; + private final List assets; + private final URL tarballUrl; + private final URL zipballUrl; + private final String body; + + public Release(URL url, URL assetsUrl, URL uploadUrl, URL htmlUrl, int id, String nodeId, String tagName, Version version, String targetCommitish, String name, boolean draft, Author author, boolean prerelease, ZonedDateTime createdAt, ZonedDateTime publishedAt, List assets, URL tarballUrl, URL zipballUrl, String body) { + this.url = url; + this.assetsUrl = assetsUrl; + this.uploadUrl = uploadUrl; + this.htmlUrl = htmlUrl; + this.id = id; + this.nodeId = nodeId; + this.tagName = tagName; + this.version = version; + this.targetCommitish = targetCommitish; + this.name = name; + this.draft = draft; + this.author = author; + this.prerelease = prerelease; + this.createdAt = createdAt; + this.publishedAt = publishedAt; + this.assets = assets; + this.tarballUrl = tarballUrl; + this.zipballUrl = zipballUrl; + this.body = body; + } + + @SuppressWarnings("unchecked") + public static Release from(Map json) { + Version version; + String tagName = (String) json.get("tag_name"); + try { + version = Version.from(tagName); + } catch (IllegalArgumentException e) { + version = new Version.NullVersion(tagName); + } + return new Release( + url((String) json.get("url")), + url((String) json.get("assets_url")), + url((String) json.get("upload_url")), + url((String) json.get("html_url")), + (Integer) json.get("id"), + (String) json.get("node_id"), + tagName, + version, + (String) json.get("target_commitish"), + (String) json.get("name"), + (Boolean) json.get("draft"), + Author.from((Map) json.get("author")), + (Boolean) json.get("prerelease"), + OffsetDateTime.parse((String) json.get("created_at")).toZonedDateTime(), + OffsetDateTime.parse((String) json.get("published_at")).toZonedDateTime(), + ((List>) json.get("assets")).stream().map(Asset::from).collect(Collectors.toList()), + url((String) json.get("tarball_url")), + url((String) json.get("zipball_url")), + (String) json.get("body") + ); + } + + @Override + public String toString() { + return String.format("Release %s", tagName); + } + + @Override + public int compareTo(Release o) { + return version.compareTo(o.version); + } + + public boolean isNotLegacy() { + return !version.isLegacy(); + } + + public boolean isNotBeta() { + return !version.isBeta(); + } + + public boolean isUpdateable() { + // Hard lower boundary for ODK Aggregate v2 or greater versions + // Older versions could not be updateable by this program + return !version.isLegacy() && version.getMajor() >= 2; + } + + public Version getVersion() { + return version; + } + + public ZonedDateTime getPublishedAt() { + return publishedAt; + } + + public boolean isVersion(Version version) { + return this.version.equals(version); + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/releases/ReleaseQueries.java b/src/main/java/org/opendatakit/aggregateupdater/releases/ReleaseQueries.java new file mode 100644 index 0000000..37ffa08 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/releases/ReleaseQueries.java @@ -0,0 +1,25 @@ +package org.opendatakit.aggregateupdater.releases; + +import static java.util.Comparator.reverseOrder; +import static java.util.stream.Collectors.toList; +import static org.opendatakit.aggregateupdater.reused.HttpHelpers.url; + +import java.util.List; +import org.opendatakit.aggregateupdater.reused.http.Request; + +public class ReleaseQueries { + public static Request> all() { + return Request.getJsonList(url("https://api.github.com/repos/opendatakit/aggregate/releases")) + .header("Accept", "application/vnd.github.v3+json") + .header("User-Agent", "Aggregate Updater") + .withMapper(jsonObjects -> jsonObjects.stream() + .map(Release::from) + .filter(Release::isUpdateable) + .sorted(reverseOrder()) + .collect(toList())); + } + + public static Request latest() { + return all().withMapper(releases -> releases.get(0)); + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/releases/Version.java b/src/main/java/org/opendatakit/aggregateupdater/releases/Version.java new file mode 100644 index 0000000..a5cec5f --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/releases/Version.java @@ -0,0 +1,142 @@ +package org.opendatakit.aggregateupdater.releases; + +import static org.opendatakit.aggregateupdater.releases.Version.Type.BETA; +import static org.opendatakit.aggregateupdater.releases.Version.Type.LEGACY; +import static org.opendatakit.aggregateupdater.releases.Version.Type.NORMAL; + +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Version implements Comparable { + private final String semver; + private final int major; + private final int minor; + private final int patch; + private final Type type; + private final Optional iteration; + + public Version(String semver, int major, int minor, int patch, Type type, Optional iteration) { + this.semver = semver; + this.major = major; + this.minor = minor; + this.patch = patch; + this.type = type; + this.iteration = iteration; + } + + public static Version from(String semver) { + Pattern p = Pattern.compile("^v?(\\d+?)\\.(\\d+?)\\.(\\d+).*"); + Matcher matcher = p.matcher(semver); + if (!matcher.matches()) + throw new IllegalArgumentException("Given semver string can't be parsed: " + semver); + int major = Integer.parseInt(matcher.group(1)); + int minor = Integer.parseInt(matcher.group(2)); + int patch = Integer.parseInt(matcher.group(3)); + Type type = semver.contains("-") + ? Type.parse(semver.substring(semver.indexOf("-") + 1, semver.lastIndexOf("."))) + : NORMAL; + Optional iteration = semver.contains("-") + ? Optional.of(Integer.parseInt(semver.substring(semver.lastIndexOf(".") + 1).split("-")[0])) + : Optional.empty(); + return new Version(semver, major, minor, patch, type, iteration); + } + + public int getMajor() { + return major; + } + + public int getMinor() { + return minor; + } + + public int getPatch() { + return patch; + } + + public boolean isBeta() { + return type == BETA; + } + + public int getIteration() { + return iteration.orElseThrow(RuntimeException::new); + } + + @Override + public String toString() { + return semver; + } + + @Override + public int compareTo(Version other) { + // Legacy versions are always less than any other version type + if (type == LEGACY) + return -1; + if (other.type == LEGACY) + return 1; + + // Next case: semver comparison + if (major > other.major) + return 1; + if (major == other.major && minor > other.minor) + return 1; + if (major == other.major && minor == other.minor && patch > other.patch) + return 1; + + // Next case: Same semver, but this one is a beta + // Beta is less than no beta within the same semver + if (major == other.major && minor == other.minor && patch == other.patch && isBeta() && !other.isBeta()) + return -1; + + // Next case: both have the same semver and both are beta versions. + // We need to compare the beta iteration + if (major == other.major && minor == other.minor && patch == other.patch && isBeta() && other.isBeta()) + return Integer.compare(getIteration(), other.getIteration()); + + // Next case: both have the same semver + if (major == other.major && minor == other.minor && patch == other.patch) + return 0; + + // Default case: this one is less than the other + return -1; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Version version = (Version) o; + return major == version.major && + minor == version.minor && + patch == version.patch && + type == version.type && + Objects.equals(iteration, version.iteration); + } + + @Override + public int hashCode() { + return Objects.hash(major, minor, patch, type, iteration); + } + + public boolean isLegacy() { + return type == LEGACY; + } + + public static class NullVersion extends Version { + public NullVersion(String semver) { + super(semver, 0, 0, 0, Type.LEGACY, Optional.empty()); + } + } + + enum Type { + NORMAL, BETA, LEGACY; + + public static Type parse(String type) { + if (type.equalsIgnoreCase("beta")) + return BETA; + throw new IllegalArgumentException("Version type " + type + " not supported"); + } + + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/reused/HttpHelpers.java b/src/main/java/org/opendatakit/aggregateupdater/reused/HttpHelpers.java new file mode 100644 index 0000000..ec38596 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/reused/HttpHelpers.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2018 Nafundi + * + * 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 org.opendatakit.aggregateupdater.reused; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; + +public class HttpHelpers { + public static URL url(String url) { + try { + return new URL(url); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + public static URI uri(URL url) { + try { + return url.toURI(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/reused/Optionals.java b/src/main/java/org/opendatakit/aggregateupdater/reused/Optionals.java new file mode 100644 index 0000000..eca5aa9 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/reused/Optionals.java @@ -0,0 +1,33 @@ +package org.opendatakit.aggregateupdater.reused; + +import java.util.Optional; +import java.util.function.Supplier; + +public class Optionals { + /** + * Returns the first (the winner of the "race") {@link Optional} in the array that is present. + */ + @SafeVarargs + public static Optional race(Optional... optionals) { + for (Optional maybeT : optionals) + if (maybeT.isPresent()) + return maybeT; + return Optional.empty(); + } + + /** + * Returns the first (the winner of the "race") {@link Optional} in the array that is present. + *

+ * Values are evaluated in order. Not all suppliers might be evaluated. + */ + + @SafeVarargs + public static Optional race(Supplier>... optionalSuppliers) { + for (Supplier> supplier : optionalSuppliers) { + Optional maybeT = supplier.get(); + if (maybeT.isPresent()) + return maybeT; + } + return Optional.empty(); + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/reused/Pair.java b/src/main/java/org/opendatakit/aggregateupdater/reused/Pair.java new file mode 100644 index 0000000..c602cb2 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/reused/Pair.java @@ -0,0 +1,55 @@ +package org.opendatakit.aggregateupdater.reused; + +import java.util.Objects; +import java.util.function.Function; + +public class Pair { + private final T left; + private final U right; + + public Pair(T left, U right) { + this.left = left; + this.right = right; + } + + public static Pair of(TT left, UU right) { + return new Pair<>(left, right); + } + + public T getLeft() { + return left; + } + + public U getRight() { + return right; + } + + public Pair map(Function leftMapper, Function rightMapper) { + return new Pair<>(leftMapper.apply(left), rightMapper.apply(right)); + } + + @Override + public String toString() { + return "Pair{" + + "left=" + left + + ", right=" + right + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + Pair pair = (Pair) o; + return Objects.equals(left, pair.left) && + Objects.equals(right, pair.right); + } + + @Override + public int hashCode() { + return Objects.hash(left, right); + } + +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/reused/http/CommonsHttp.java b/src/main/java/org/opendatakit/aggregateupdater/reused/http/CommonsHttp.java new file mode 100644 index 0000000..572d4a0 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/reused/http/CommonsHttp.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2018 Nafundi + * + * 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 org.opendatakit.aggregateupdater.reused.http; + +import static org.apache.http.client.config.CookieSpecs.STANDARD; +import static org.apache.http.client.config.RequestConfig.custom; +import static org.opendatakit.aggregateupdater.reused.HttpHelpers.uri; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import org.apache.http.HttpEntity; +import org.apache.http.HttpHost; +import org.apache.http.HttpResponse; +import org.apache.http.client.fluent.Executor; +import org.apache.http.conn.ConnectTimeoutException; +import org.apache.http.conn.HttpHostConnectException; +import org.apache.http.impl.client.HttpClientBuilder; + +public class CommonsHttp implements Http { + @Override + public Response execute(Request request) { + // Always instantiate a new Executor to avoid side-effects between executions + Executor executor = Executor.newInstance(HttpClientBuilder + .create() + .setDefaultRequestConfig(custom().setCookieSpec(STANDARD).build()) + .build()); + // Apply auth settings if credentials are received + request.ifCredentials((URL url, Credentials credentials) -> executor.auth( + HttpHost.create(url.getHost()), + credentials.getUsername(), + credentials.getPassword() + )); + // get the response body and let the Request map it + return uncheckedExecute(request, executor); + } + + private Response uncheckedExecute(Request request, Executor executor) { + org.apache.http.client.fluent.Request commonsRequest = getCommonsRequest(request); + commonsRequest.connectTimeout(10_000); + commonsRequest.socketTimeout(10_000); + commonsRequest.addHeader("X-OpenRosa-Version", "1.0"); + request.headers.forEach(pair -> commonsRequest.addHeader(pair.getLeft(), pair.getRight())); + try { + return executor + .execute(commonsRequest) + .handleResponse(res -> { + int statusCode = res.getStatusLine().getStatusCode(); + String statusPhrase = res.getStatusLine().getReasonPhrase(); + if (statusCode >= 500) + return new Response.ServerError<>(statusCode, statusPhrase); + if (statusCode >= 400) + return new Response.ClientError<>(statusCode, statusPhrase); + if (statusCode >= 300) + return new Response.Redirection<>(statusCode, statusPhrase); + return Response.Success.of(request, res); + }); + } catch (HttpHostConnectException e) { + throw new HttpException("Connection refused"); + } catch (SocketTimeoutException | ConnectTimeoutException e) { + throw new HttpException("The connection has timed out"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private InputStream extractOutput(HttpResponse res) { + return Optional.ofNullable(res.getEntity()) + .map(this::uncheckedGetContent) + .orElse(new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8))); + } + + private static org.apache.http.client.fluent.Request getCommonsRequest(Request request) { + switch (request.getMethod()) { + case GET: + return org.apache.http.client.fluent.Request.Get(uri(request.getUrl())); + case HEAD: + return org.apache.http.client.fluent.Request.Head(uri(request.getUrl())); + default: + throw new HttpException("Method " + request.getMethod() + " is not supported"); + } + } + + private InputStream uncheckedGetContent(HttpEntity entity) { + try { + return entity.getContent(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/reused/http/Credentials.java b/src/main/java/org/opendatakit/aggregateupdater/reused/http/Credentials.java new file mode 100644 index 0000000..27ee088 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/reused/http/Credentials.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2018 Nafundi + * + * 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 org.opendatakit.aggregateupdater.reused.http; + +import java.util.Objects; + +/** + * This Value Object class holds a username/password pair to be used as credentials. + */ +public class Credentials { + private final String username; + private final String password; + + public Credentials(String username, String password) { + this.username = username; + this.password = password; + } + + public static Credentials from(String username, String password) { + if (Objects.requireNonNull(username).isEmpty() || Objects.requireNonNull(password).isEmpty()) + throw new IllegalArgumentException("You need to provide non-empty username and password."); + + return new Credentials(username, password); + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Credentials that = (Credentials) o; + return Objects.equals(username, that.username) && + Objects.equals(password, that.password); + } + + @Override + public int hashCode() { + return Objects.hash(username, password); + } + + @Override + public String toString() { + return "Credentials(" + username + ", ***)"; + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/reused/http/Http.java b/src/main/java/org/opendatakit/aggregateupdater/reused/http/Http.java new file mode 100644 index 0000000..f38e803 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/reused/http/Http.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2018 Nafundi + * + * 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 org.opendatakit.aggregateupdater.reused.http; + +/** + * This interface has Briefcase's HTTP API to interact with external services + */ +// TODO Create a persistent connection (reused) variant based on CommonsHttp +public interface Http { + /** + * Runs a {@link Request} and returns some output value. + * + * @param request the {@link Request} to be executed + * @param type of the output {@link Response} + * @return an output value of type T + */ + Response execute(Request request); +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/reused/http/HttpException.java b/src/main/java/org/opendatakit/aggregateupdater/reused/http/HttpException.java new file mode 100644 index 0000000..ed0ffbe --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/reused/http/HttpException.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2018 Nafundi + * + * 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 org.opendatakit.aggregateupdater.reused.http; + +import java.util.Optional; + +public class HttpException extends RuntimeException { + private final Optional> response; + + public HttpException(String message) { + super(message); + response = Optional.empty(); + } + + public HttpException(String message, Throwable cause) { + super(message, cause); + response = Optional.empty(); + } + + public HttpException(Response response) { + super("HTTP Response status code " + response.getStatusCode()); + this.response = Optional.of(response); + } + + public HttpException(Response response, Throwable cause) { + super("HTTP Response status code " + response.getStatusCode(), cause); + this.response = Optional.of(response); + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/reused/http/Request.java b/src/main/java/org/opendatakit/aggregateupdater/reused/http/Request.java new file mode 100644 index 0000000..fd2b8d8 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/reused/http/Request.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2018 Nafundi + * + * 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 org.opendatakit.aggregateupdater.reused.http; + +import static java.util.Collections.emptyList; +import static org.opendatakit.aggregateupdater.reused.HttpHelpers.url; +import static org.opendatakit.aggregateupdater.reused.http.Request.Method.GET; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Function; +import org.opendatakit.aggregateupdater.reused.Pair; + +/** + * This Value Object class represents an HTTP request to some {@link URL}, maybe using + * some {@link Credentials} for authentication. + *

+ * It also gives type hints about the result calling sites would be able to expect + * when executed. + */ +public class Request { + private final Method method; + private final URL url; + private final Optional credentials; + private final Function contentMapper; + final List> headers; + + private Request(Method method, URL url, Optional credentials, Function contentMapper, List> headers) { + this.method = method; + this.url = url; + this.credentials = credentials; + this.contentMapper = contentMapper; + this.headers = headers; + } + + public static Request>> getJsonList(URL url) { + return new Request<>(GET, url, Optional.empty(), Request::readJsonList, emptyList()); + } + + private static List> readJsonList(InputStream is) { + try { + TypeReference>> listOfObjects = new TypeReference>>() { + }; + return new ObjectMapper().readValue(is, listOfObjects); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public Request resolve(String path) { + // Normalize slashes to ensure that the resulting url + // has exactly one slash before the input path + String newUrl = url.toString() + + (!url.toString().endsWith("/") ? "/" : "") + + (path.startsWith("/") ? path.substring(1) : path); + return new Request<>(method, url(newUrl), credentials, contentMapper, headers); + } + + void ifCredentials(BiConsumer consumer) { + credentials.ifPresent(c -> consumer.accept(url, c)); + } + + public URL getUrl() { + return url; + } + + public Method getMethod() { + return method; + } + + public T map(InputStream contents) { + return contentMapper.apply(contents); + } + + public Request withMapper(Function newBodyMapper) { + return new Request<>(method, url, credentials, contentMapper.andThen(newBodyMapper), headers); + } + + public Request header(String key, String value) { + List> newHeaders = new ArrayList<>(); + newHeaders.addAll(headers); + newHeaders.add(Pair.of(key, value)); + return new Request<>(method, url, credentials, contentMapper, newHeaders); + } + + enum Method { + GET, HEAD + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Request request = (Request) o; + return Objects.equals(url, request.url) && + Objects.equals(credentials, request.credentials); + } + + @Override + public int hashCode() { + return Objects.hash(url, credentials); + } + + @Override + public String toString() { + return method + " " + url + " " + credentials.map(Credentials::toString).orElse("(no credentials)"); + } + +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/reused/http/Response.java b/src/main/java/org/opendatakit/aggregateupdater/reused/http/Response.java new file mode 100644 index 0000000..f876c96 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/reused/http/Response.java @@ -0,0 +1,361 @@ +/* + * Copyright (C) 2018 Nafundi + * + * 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 org.opendatakit.aggregateupdater.reused.http; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; + +public interface Response { + static Response ok(String body) { + return new Success<>(200, body); + } + + static Response noContent() { + return new Success<>(204, null); + } + + static Response found() { + return new Redirection<>(302, "Found"); + } + + static Response unauthorized() { + return new ClientError<>(401, "Unauthorized"); + } + + static Response notFound() { + return new ClientError<>(404, "Not Found"); + } + + T get(); + + int getStatusCode(); + + Response map(Function outputMapper); + + T orElse(T defaultValue); + + T orElseThrow(Supplier supplier); + + boolean isSuccess(); + + boolean isFailure(); + + boolean isUnauthorized(); + + boolean isNotFound(); + + boolean isRedirection(); + + class Success implements Response { + private final int statusCode; + private final T output; + + Success(int statusCode, T output) { + this.statusCode = statusCode; + this.output = output; + } + + static Success of(Request request, HttpResponse httpResponse) { + InputStream inputStream = Optional.ofNullable(httpResponse.getEntity()) + .map(Success::uncheckedGetContent) + .orElse(new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8))); + return new Success<>( + httpResponse.getStatusLine().getStatusCode(), + request.map(inputStream) + ); + } + + private static InputStream uncheckedGetContent(HttpEntity entity) { + try { + return entity.getContent(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public T get() { + return output; + } + + @Override + public int getStatusCode() { + return statusCode; + } + + @Override + public Response map(Function outputMapper) { + return new Success<>(statusCode, outputMapper.apply(output)); + } + + @Override + public T orElse(T defaultValue) { + return output; + } + + @Override + public T orElseThrow(Supplier supplier) { + return output; + } + + @Override + public boolean isSuccess() { + return true; + } + + @Override + public boolean isFailure() { + return false; + } + + @Override + public boolean isUnauthorized() { + return false; + } + + @Override + public boolean isNotFound() { + return false; + } + + @Override + public boolean isRedirection() { + return false; + } + } + + class Redirection implements Response { + private final int statusCode; + private final String name; + + Redirection(int statusCode, String name) { + this.statusCode = statusCode; + this.name = name; + } + + @Override + public T get() { + throw new HttpException("No output to get"); + } + + @Override + public int getStatusCode() { + return statusCode; + } + + @Override + public Response map(Function outputMapper) { + return new Redirection<>(statusCode, name); + } + + @Override + public T orElse(T defaultValue) { + return defaultValue; + } + + @Override + public T orElseThrow(Supplier supplier) { + throw supplier.get(); + } + + @Override + public boolean isSuccess() { + return false; + } + + @Override + public boolean isFailure() { + return false; + } + + @Override + public boolean isUnauthorized() { + return false; + } + + @Override + public boolean isNotFound() { + return false; + } + + @Override + public boolean isRedirection() { + return true; + } + } + + class ClientError implements Response { + private final int statusCode; + private final String name; + + ClientError(int statusCode, String name) { + this.statusCode = statusCode; + this.name = name; + } + + @Override + public T get() { + throw new HttpException("No output to get"); + } + + @Override + public int getStatusCode() { + return statusCode; + } + + @Override + public Response map(Function outputMapper) { + return new ClientError<>(statusCode, name); + } + + @Override + public T orElse(T defaultValue) { + return defaultValue; + } + + @Override + public T orElseThrow(Supplier supplier) { + throw supplier.get(); + } + + @Override + public boolean isSuccess() { + return false; + } + + @Override + public boolean isFailure() { + return true; + } + + @Override + public boolean isUnauthorized() { + return statusCode == 401; + } + + @Override + public boolean isNotFound() { + return statusCode == 404; + } + + @Override + public boolean isRedirection() { + return false; + } + } + + class ServerError implements Response { + private final int statusCode; + private final String name; + + ServerError(int statusCode, String name) { + this.statusCode = statusCode; + this.name = name; + } + + @Override + public T get() { + throw new HttpException("No output to get"); + } + + @Override + public int getStatusCode() { + return statusCode; + } + + @Override + public Response map(Function outputMapper) { + return new ServerError<>(statusCode, name); + } + + @Override + public T orElse(T defaultValue) { + return defaultValue; + } + + @Override + public T orElseThrow(Supplier supplier) { + throw supplier.get(); + } + + @Override + public boolean isSuccess() { + return false; + } + + @Override + public boolean isFailure() { + return true; + } + + @Override + public boolean isUnauthorized() { + return false; + } + + @Override + public boolean isNotFound() { + return false; + } + + @Override + public boolean isRedirection() { + return false; + } + } + + final class Lazy { + private final Supplier valueSupplier; + private T value; + private boolean resolved; + + private Lazy(T value) { + this.value = value; + this.resolved = true; + this.valueSupplier = () -> value; + } + + private Lazy(Supplier valueSupplier) { + this.resolved = false; + this.valueSupplier = valueSupplier; + } + + public static Lazy of(U value) { + return new Lazy<>(value); + } + + public static Lazy of(Supplier valueSupplier) { + return new Lazy<>(valueSupplier); + } + + public synchronized T get() { + if (resolved) + return value; + value = valueSupplier.get(); + resolved = true; + return value; + } + } +} diff --git a/src/main/java/org/opendatakit/aggregateupdater/update/UpdateOperation.java b/src/main/java/org/opendatakit/aggregateupdater/update/UpdateOperation.java new file mode 100644 index 0000000..2500a49 --- /dev/null +++ b/src/main/java/org/opendatakit/aggregateupdater/update/UpdateOperation.java @@ -0,0 +1,185 @@ +package org.opendatakit.aggregateupdater.update; + +import static java.lang.String.format; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static org.opendatakit.aggregateupdater.listversions.ListAvailableVersions.INCLUDE_BETA_VERSIONS; +import static org.opendatakit.aggregateupdater.reused.Optionals.race; +import static org.opendatakit.cli.Param.arg; +import static org.opendatakit.cli.Param.flag; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.opendatakit.aggregateupdater.releases.Release; +import org.opendatakit.aggregateupdater.releases.ReleaseQueries; +import org.opendatakit.aggregateupdater.releases.Version; +import org.opendatakit.aggregateupdater.reused.http.Http; +import org.opendatakit.cli.Args; +import org.opendatakit.cli.Console; +import org.opendatakit.cli.Operation; +import org.opendatakit.cli.Param; + +public class UpdateOperation { + + private static final Param REQUESTED_VERSION = arg("rv", "requested-version", "Requested version (latest by default)", Version::from); + private static final Param DRY_RUN = flag("dr", "dry-run", "Dry run (emulate update process)"); + private static final Param FORCE = flag("f", "force", "Force update"); + private static final Param ALWAYS_YES = flag("y", "yes", "Always answer 'yes' to confirm prompts"); + private static final Param VERBOSE = flag("vv", "verbose", "Verbose mode. Shows all commands"); + private static final Path USER_HOME = Paths.get(System.getProperty("user.home")).toAbsolutePath(); + private static final Path VERSION_FILE = USER_HOME.resolve("aggregate-version.txt"); + private static final Path BACKUP = USER_HOME.resolve("aggregate-backup"); + private static final Path BACKUP_CONF = BACKUP.resolve("conf"); + private static final Path BACKUP_WEBAPP = BACKUP.resolve("webapp"); + private static final Path TOMCAT_HOME = Paths.get("/var/lib/tomcat8"); + private static final Path DEPLOYED_WEBAPP = TOMCAT_HOME.resolve("webapps/ROOT"); + private static final Path DEPLOYED_CONF = DEPLOYED_WEBAPP.resolve("WEB-INF/classes"); + private static final Path JDBC_CONF = Paths.get("jdbc.properties"); + private static final Path JDBC_CONF_ORIGINAL = Paths.get("jdbc.properties.original"); + private static final Path SECURITY_CONF = Paths.get("security.properties"); + private static final Path SECURITY_CONF_ORIGINAL = Paths.get("security.properties.original"); + + public static Operation build(Http http) { + return Operation.of( + flag("u", "update", "Update ODK Aggregate"), + (console, args) -> execute(http, console, args), + emptyList(), + asList(DRY_RUN, REQUESTED_VERSION, INCLUDE_BETA_VERSIONS, FORCE, ALWAYS_YES, VERBOSE) + ); + } + + private static void execute(Http http, Console console, Args args) { + console.setVerboseMode(args.has(VERBOSE)); + console.setDryRunMode(args.has(DRY_RUN)); + console.setAlwaysYesMode(args.has(ALWAYS_YES)); + + console.out("Update ODK Aggregate"); + console.out(); + + Version installedVersion = Version.from(new String(readAllBytes(VERSION_FILE), UTF_8).trim()); + Optional requestedVersion = args.getOptional(REQUESTED_VERSION); + List availableReleases = http.execute(ReleaseQueries.all()).orElse(emptyList()); + Optional latestVersion = availableReleases.stream() + .filter(r -> args.has(INCLUDE_BETA_VERSIONS) || r.isNotBeta()) + .map(Release::getVersion) + .max(Version::compareTo); + + requestedVersion.ifPresent(version -> { + if (availableReleases.stream().noneMatch(release -> release.isVersion(version))) { + console.error("Requested version " + version + " doesn't exist."); + console.error("Please choose one between: "); + availableReleases.forEach(r -> console.error("\t- " + r.getVersion().toString())); + console.exit(1); + } + }); + + Version selectedVersion = race(requestedVersion, latestVersion).orElseThrow(RuntimeException::new); + + console.out("Installed version: " + installedVersion); + console.out("Requested version: " + requestedVersion.map(Version::toString).orElse("None (defaults to latest available)")); + console.out(" Latest version: " + latestVersion.orElseThrow(RuntimeException::new)); + console.out(" Selected version: " + selectedVersion.toString()); + console.out(); + + if (selectedVersion.equals(installedVersion) && !args.has(FORCE)) { + console.out("No action needed"); + console.exit(); + } + + if (selectedVersion.equals(installedVersion) && args.has(FORCE)) { + console.out("(forcing the update)"); + console.out(); + } + + console.out("- Stopping Tomcat"); + console.execute("service tomcat8 stop"); + console.out(" done"); + console.out(); + + console.out("- Backing up installed Aggregate"); + console.execute(format("mkdir -p %s", BACKUP_CONF)); + console.execute(format("cp %s %s", DEPLOYED_CONF.resolve(JDBC_CONF), BACKUP_CONF.resolve(JDBC_CONF))); + console.execute(format("cp %s %s", DEPLOYED_CONF.resolve(SECURITY_CONF), BACKUP_CONF.resolve(SECURITY_CONF))); + console.execute(format("mkdir -p %s", BACKUP_WEBAPP)); + console.execute(format( + "zip -q -r %s/ODK-Aggregate-backup-%s.zip %s", + BACKUP_WEBAPP, + LocalDate.now().format(ISO_LOCAL_DATE), + DEPLOYED_WEBAPP + )); + console.out(" done"); + console.out(); + + + console.out("- Deploying selected Aggregate version"); + console.execute(format("rm -r %s", DEPLOYED_WEBAPP)); + console.execute(format("mkdir -p %s", DEPLOYED_WEBAPP)); + console.execute(format( + "wget -O /tmp/aggregate.war https://github.com/opendatakit/aggregate/releases/download/%s/ODK-Aggregate-%s.war", + selectedVersion, + selectedVersion + )); + console.execute(format("unzip -qq /tmp/aggregate.war -d %s", DEPLOYED_WEBAPP)); + console.execute(format("cp %s %s", DEPLOYED_CONF.resolve(JDBC_CONF), DEPLOYED_CONF.resolve(JDBC_CONF_ORIGINAL))); + console.execute(format("cp %s %s", DEPLOYED_CONF.resolve(SECURITY_CONF), DEPLOYED_CONF.resolve(SECURITY_CONF_ORIGINAL))); + console.execute(format("sed -i s/^#.*$//g %s", DEPLOYED_CONF.resolve(SECURITY_CONF_ORIGINAL))); + console.execute(format("cp %s %s", BACKUP_CONF.resolve(JDBC_CONF), DEPLOYED_CONF.resolve(JDBC_CONF))); + console.execute(format("cp %s %s", BACKUP_CONF.resolve(SECURITY_CONF), DEPLOYED_CONF.resolve(SECURITY_CONF))); + console.execute(format("chown -R tomcat8:tomcat8 %s", DEPLOYED_WEBAPP)); + console.out(" done"); + console.out(); + + console.out("- Cleaning up update assets"); + console.execute("rm /tmp/aggregate.war", true); + console.execute(format("rm -rf %s", BACKUP_CONF), true); + console.out(" done"); + console.out(); + + // Diff conf files and ask for confirmation + console.out(); + console.out("Update completed"); + console.out("Please, check the differences between the deployed (+) and original (-) configurations"); + console.out(); + console.execute(format("diff -u --color -B -w %s %s", DEPLOYED_CONF.resolve(JDBC_CONF), DEPLOYED_CONF.resolve(JDBC_CONF_ORIGINAL)), true); + console.execute(format("diff -u --color -B -w %s %s", DEPLOYED_CONF.resolve(SECURITY_CONF), DEPLOYED_CONF.resolve(SECURITY_CONF_ORIGINAL)), true); + + // Write version in version file + write(VERSION_FILE, selectedVersion.toString().getBytes(UTF_8), StandardOpenOption.TRUNCATE_EXISTING); + + console.out(); + if (console.confirm("Start Tomcat?")) { + console.out("- Starting Tomcat"); + console.execute("service tomcat8 start"); + console.out(" done"); + console.out(); + } + + console.exit(); + } + + private static void write(Path path, byte[] bytes, OpenOption... options) { + try { + Files.write(path, bytes, options); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private static byte[] readAllBytes(Path path) { + try { + return Files.readAllBytes(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/main/java/org/opendatakit/cli/Cli.java b/src/main/java/org/opendatakit/cli/Cli.java index 7393f04..00f8bca 100644 --- a/src/main/java/org/opendatakit/cli/Cli.java +++ b/src/main/java/org/opendatakit/cli/Cli.java @@ -38,9 +38,10 @@ import org.slf4j.LoggerFactory; /** - *

Cli is a command line adapter. It helps define executable operations and their + * Cli is a command line adapter. It helps define executable operations and their * required and optional params. - *

It defines some default operations like "show help" and "show version" + *

+ * It defines some default operations like "show help" and "show version" */ public class Cli { private static final Logger log = LoggerFactory.getLogger(Cli.class); @@ -55,8 +56,8 @@ public class Cli { private final List> onErrorCallbacks = new ArrayList<>(); public Cli() { - register(Operation.of(SHOW_HELP, args -> printHelp())); - register(Operation.of(SHOW_VERSION, args -> printVersion())); + register(Operation.of(SHOW_HELP, (console, args) -> printHelp())); + register(Operation.of(SHOW_VERSION, (console, args) -> printVersion())); } /** @@ -71,14 +72,9 @@ public void printHelp() { *

* When Briefcase detects this param, it will show a message, output the help and * exit with a non-zero status - * - * @param oldParam the {@link Param} to mark as deprecated - * @param alternative the alternative {@link Operation} that Briefcase will suggest to be - * used instead of the deprecated Param - * @return self {@link Cli} instance to chain more method calls */ public Cli deprecate(Param oldParam, Operation alternative) { - operations.add(Operation.deprecated(oldParam, __ -> { + operations.add(Operation.deprecated(oldParam, (console, args) -> { log.warn("Trying to run deprecated param -{}", oldParam.shortCode); System.out.println("The param -" + oldParam.shortCode + " has been deprecated. Run Briefcase again with -" + alternative.param.shortCode + " instead"); printHelp(); @@ -87,12 +83,6 @@ public Cli deprecate(Param oldParam, Operation alternative) { return this; } - /** - * Register an {@link Operation} - * - * @param operation an {@link Operation} instance - * @return self {@link Cli} instance to chain more method calls - */ public Cli register(Operation operation) { operations.add(operation); return this; @@ -101,9 +91,6 @@ public Cli register(Operation operation) { /** * Register a {@link Runnable} block that will be executed if no {@link Operation} * is executed. For example, if the user passes no arguments when executing this program - * - * @param callback a {@link BiConsumer} that will receive the {@link Cli} and {@link CommandLine} instances - * @return self {@link Cli} instance to chain more method calls */ public Cli otherwise(BiConsumer callback) { otherwiseCallbacks.add(callback); @@ -112,23 +99,21 @@ public Cli otherwise(BiConsumer callback) { /** * Runs the command line program - * - * @param args command line arguments - * @see Java 8 consumer supplier explained in 5 minutes */ public void run(String[] args) { Set allParams = getAllParams(); CommandLine cli = getCli(args, allParams); + Console console = Console.std(); try { requiredOperations.forEach(operation -> { checkForMissingParams(cli, operation.requiredParams); - operation.argsConsumer.accept(Args.from(cli, operation.requiredParams)); + operation.accept(console, Args.from(cli, operation.requiredParams)); }); operations.forEach(operation -> { if (cli.hasOption(operation.param.shortCode)) { checkForMissingParams(cli, operation.requiredParams); - operation.argsConsumer.accept(Args.from(cli, operation.getAllParams())); + operation.accept(console, Args.from(cli, operation.getAllParams())); executedOperations.add(operation); } }); @@ -156,13 +141,6 @@ public Cli onError(Consumer callback) { return this; } - /** - * Flatmap all required params from all required operations, all params - * from all operations and flatmap them into a {@link Set}<{@link Param}>> - * - * @return a {@link Set} of {@link Param}> instances - * @see Java 8 flatmap example - */ private Set getAllParams() { return Stream.of( requiredOperations.stream().flatMap(operation -> operation.requiredParams.stream()), diff --git a/src/main/java/org/opendatakit/cli/Console.java b/src/main/java/org/opendatakit/cli/Console.java new file mode 100644 index 0000000..d508210 --- /dev/null +++ b/src/main/java/org/opendatakit/cli/Console.java @@ -0,0 +1,203 @@ +package org.opendatakit.cli; + +import static java.util.concurrent.Executors.newSingleThreadExecutor; + +import de.vandermeer.asciitable.AsciiTable; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.List; +import java.util.Scanner; +import java.util.function.Consumer; + +public class Console { + private final InputStream inStream; + private final PrintStream outStream; + private final PrintStream errorStream; + private boolean verboseMode = false; + private boolean dryRunMode = false; + private boolean alwaysYesMode = false; + + public Console(InputStream inStream, PrintStream outStream, PrintStream errorStream) { + this.inStream = inStream; + this.outStream = outStream; + this.errorStream = errorStream; + } + + public static Console std() { + return new Console(System.in, System.out, System.err); + } + + public void out() { + outStream.println(); + } + + public void out(String text) { + outStream.println(text); + } + + public void error(String text) { + errorStream.println(text); + } + + public void exit() { + System.exit(0); + } + + public void exit(int status) { + System.exit(status); + } + + public void table(List> rows, String... headers) { + AsciiTable table = new AsciiTable(); + table.addRule(); + table.addRow(Arrays.asList(headers)); + table.addRule(); + rows.forEach(release -> { + table.addRow(release); + table.addRule(); + }); + outStream.println(table.render()); + } + + public void execute(String command) { + execute(command, false); + } + + public void execute(String command, boolean ignoreErrors) { + if (verboseMode) { + outStream.println(command); + } + if (dryRunMode) + return; + + Process process = getProcess(command); + ProcessWatcher processWatcher = ProcessWatcher.attach(process) + .onOut(outStream::println) + .onError(errorStream::println) + .verbose(verboseMode) + .build(); + newSingleThreadExecutor().submit(processWatcher); + int exitCode = waitFor(process); + if (exitCode != 0 && !ignoreErrors) + throw new RuntimeException("Command '" + command + "' exited with exit code " + exitCode); + } + + private static int waitFor(Process process) { + try { + return process.waitFor(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private static Process getProcess(String command) { + try { + return Runtime.getRuntime().exec(command); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public void setVerboseMode(boolean enabled) { + if (enabled) { + outStream.println("[Console] Enabling verbose mode. Showing all commands"); + outStream.println(); + } + verboseMode = enabled; + } + + public void setDryRunMode(boolean enabled) { + if (enabled && verboseMode) { + outStream.println("[Console] Enabling dry run mode. No command will be actually executed"); + outStream.println(); + } + dryRunMode = enabled; + } + + public void setAlwaysYesMode(boolean enabled) { + if (enabled && verboseMode) { + outStream.println("[Console] Assuming 'yes' in all confirm prompts"); + outStream.println(); + } + alwaysYesMode = enabled; + } + + public boolean confirm(String message) { + outStream.print(String.format("%s (YES/no): ", message)); + if (alwaysYesMode) { + outStream.println("yes"); + return true; + } + String input = new Scanner(inStream).nextLine().trim(); + if (!input.isEmpty() && !input.equalsIgnoreCase("yes") && !input.equalsIgnoreCase("no")) { + errorStream.println("You need to answer 'yes' or 'no'."); + return confirm(message); + } else + return !input.equalsIgnoreCase("no"); + } + + private static class ProcessWatcher implements Runnable { + private final Process process; + private final Consumer outConsumer; + private final Consumer errorConsumer; + private final boolean verboseMode; + + public ProcessWatcher(Process process, Consumer outConsumer, Consumer errorConsumer, boolean verboseMode) { + this.process = process; + this.outConsumer = outConsumer; + this.errorConsumer = errorConsumer; + this.verboseMode = verboseMode; + } + + static ProcessWatcherBuilder attach(Process process) { + return new ProcessWatcherBuilder(process); + } + + @Override + public void run() { + new BufferedReader(new InputStreamReader(process.getInputStream())).lines().forEach(line -> { + if (verboseMode) + outConsumer.accept(line); + }); + new BufferedReader(new InputStreamReader(process.getErrorStream())).lines().forEach(line -> { + if (verboseMode) + errorConsumer.accept(line); + }); + } + } + + private static class ProcessWatcherBuilder { + private final Process process; + private Consumer outConsumer = s -> {}; + private Consumer errorConsumer = s -> {}; + private boolean verboseMode = false; + + public ProcessWatcherBuilder(Process process) { + this.process = process; + } + + public ProcessWatcherBuilder onOut(Consumer consumer) { + outConsumer = consumer; + return this; + } + + public ProcessWatcherBuilder onError(Consumer consumer) { + errorConsumer = consumer; + return this; + } + + public ProcessWatcherBuilder verbose(boolean enabled) { + this.verboseMode = enabled; + return this; + } + + public ProcessWatcher build() { + return new ProcessWatcher(process, outConsumer, errorConsumer, verboseMode); + } + } +} diff --git a/src/main/java/org/opendatakit/cli/CustomHelpFormatter.java b/src/main/java/org/opendatakit/cli/CustomHelpFormatter.java index 6bc3bd5..8bc7e4d 100644 --- a/src/main/java/org/opendatakit/cli/CustomHelpFormatter.java +++ b/src/main/java/org/opendatakit/cli/CustomHelpFormatter.java @@ -95,7 +95,7 @@ private static void printAvailableOperations(Map helpLinesPerSho private static void printUsage() { System.out.println(); - System.out.println("Launch an operation with: java -jar " + jarFile + " "); + System.out.println("Launch an operation with: aggregate-updater "); System.out.println(); } diff --git a/src/main/java/org/opendatakit/cli/Operation.java b/src/main/java/org/opendatakit/cli/Operation.java index 4ceda0e..7e7f7f3 100644 --- a/src/main/java/org/opendatakit/cli/Operation.java +++ b/src/main/java/org/opendatakit/cli/Operation.java @@ -6,72 +6,36 @@ import java.util.List; import java.util.Objects; import java.util.Set; -import java.util.function.Consumer; +import java.util.function.BiConsumer; -/** - * This class represents a Briefcase operation to be executed in a command-line environment - *

- * Uses a {@link Consumer}<{@link Args}> to pass command-line arguments to the logic of this {@link Operation} - */ public class Operation { final Param param; - final Consumer argsConsumer; + final BiConsumer payload; final Set requiredParams; final Set optionalParams; private final boolean deprecated; - private Operation(Param param, Consumer argsConsumer, Set requiredParams, Set optionalParams, boolean deprecated) { + private Operation(Param param, BiConsumer payload, Set requiredParams, Set optionalParams, boolean deprecated) { this.param = param; - this.argsConsumer = argsConsumer; + this.payload = payload; this.requiredParams = requiredParams; this.optionalParams = optionalParams; this.deprecated = deprecated; } - /** - * Creates a new {@link Operation} without params of any kind (required or optional) - * - * @param param main {@link Param} that will trigger the execution of this {@link Operation}, normally a {@link Param#flag(String, String, String)} - * @param argsConsumer {@link Consumer}<{@link Args}> with the logic of this {@link Operation} - * @return a new {@link Operation} instance - */ - public static Operation of(Param param, Consumer argsConsumer) { + public static Operation of(Param param, BiConsumer argsConsumer) { return new Operation(param, argsConsumer, emptySet(), emptySet(), false); } - /** - * Creates a new {@link Operation} with some required params - * - * @param param main {@link Param} that will trigger the execution of this {@link Operation}, normally a {@link Param#flag(String, String, String)} - * @param argsConsumer {@link Consumer}<{@link Args}> with the logic of this {@link Operation} - * @param requiredParams a {@link Set}<{@link Param}> with the required params for this operation - * @return a new {@link Operation} instance - */ - public static Operation of(Param param, Consumer argsConsumer, List requiredParams) { + public static Operation of(Param param, BiConsumer argsConsumer, List requiredParams) { return new Operation(param, argsConsumer, new HashSet<>(requiredParams), emptySet(), false); } - /** - * Creates a new {@link Operation} with some required and optional params - * - * @param param main {@link Param} that will trigger the execution of this {@link Operation}, normally a {@link Param#flag(String, String, String)} - * @param argsConsumer {@link Consumer}<{@link Args}> with the logic of this {@link Operation} - * @param requiredParams a {@link Set}<{@link Param}> with the required params for this operation - * @param optionalParams a {@link Set}<{@link Param}> with the optional params for this operation - * @return a new {@link Operation} instance - */ - public static Operation of(Param param, Consumer argsConsumer, List requiredParams, List optionalParams) { + public static Operation of(Param param, BiConsumer argsConsumer, List requiredParams, List optionalParams) { return new Operation(param, argsConsumer, new HashSet<>(requiredParams), new HashSet<>(optionalParams), false); } - /** - * Creates a new deprecated {@link Operation} without params of any kind (required or optional) - * - * @param param main {@link Param} that will trigger the execution of this {@link Operation}, normally a {@link Param#flag(String, String, String)} - * @param argsConsumer {@link Consumer}<{@link Args}> with the logic of this {@link Operation} - * @return a new {@link Operation} instance - */ - static Operation deprecated(Param param, Consumer argsConsumer) { + static Operation deprecated(Param param, BiConsumer argsConsumer) { return new Operation(param, argsConsumer, emptySet(), emptySet(), true); } @@ -105,24 +69,19 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Operation operation = (Operation) o; - return Objects.equals(param, operation.param) && - Objects.equals(argsConsumer, operation.argsConsumer) && + return deprecated == operation.deprecated && + Objects.equals(param, operation.param) && + Objects.equals(payload, operation.payload) && Objects.equals(requiredParams, operation.requiredParams) && Objects.equals(optionalParams, operation.optionalParams); } @Override public int hashCode() { - return Objects.hash(param, argsConsumer, requiredParams, optionalParams); + return Objects.hash(param, payload, requiredParams, optionalParams, deprecated); } - @Override - public String toString() { - return "Operation{" + - "param=" + param + - ", argsConsumer=" + argsConsumer + - ", requiredParams=" + requiredParams + - ", optionalParams=" + optionalParams + - '}'; + public void accept(Console console, Args args) { + payload.accept(console, args); } } diff --git a/src/test/java/org/opendatakit/aggregateupdater/listversions/VersionTest.java b/src/test/java/org/opendatakit/aggregateupdater/listversions/VersionTest.java new file mode 100644 index 0000000..140ae3f --- /dev/null +++ b/src/test/java/org/opendatakit/aggregateupdater/listversions/VersionTest.java @@ -0,0 +1,84 @@ +package org.opendatakit.aggregateupdater.listversions; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.Matchers.lessThanOrEqualTo; + +import org.junit.jupiter.api.Test; +import org.opendatakit.aggregateupdater.releases.Version; + +class VersionTest { + @Test + void name() { + Version v = Version.from("1.2.3"); + assertThat(v.getMajor(), is(1)); + assertThat(v.getMinor(), is(2)); + assertThat(v.getPatch(), is(3)); + } + + @Test + void name0() { + Version v = Version.from("v1.2.3"); + assertThat(v.getMajor(), is(1)); + assertThat(v.getMinor(), is(2)); + assertThat(v.getPatch(), is(3)); + } + + @Test + void name1() { + assertThat(Version.from("0.0.1"), is(lessThan(Version.from("0.0.2")))); + assertThat(Version.from("0.0.1"), is(lessThan(Version.from("0.1.0")))); + assertThat(Version.from("0.1.0"), is(lessThan(Version.from("0.1.1")))); + assertThat(Version.from("0.1.0"), is(lessThan(Version.from("0.2.0")))); + assertThat(Version.from("1.0.0"), is(lessThan(Version.from("1.0.1")))); + assertThat(Version.from("1.0.0"), is(lessThan(Version.from("1.1.0")))); + assertThat(Version.from("1.0.0"), is(lessThan(Version.from("2.0.0")))); + assertThat(Version.from("2.2.2"), is(greaterThan(Version.from("2.2.1")))); + assertThat(Version.from("2.2.2"), is(greaterThan(Version.from("2.1.2")))); + assertThat(Version.from("2.2.2"), is(greaterThan(Version.from("1.2.2")))); + } + + @Test + void name2() { + Version v = Version.from("v2.1.3-beta.4"); + assertThat(v.getMajor(), is(2)); + assertThat(v.getMinor(), is(1)); + assertThat(v.getPatch(), is(3)); + assertThat(v.isBeta(), is(true)); + assertThat(v.getIteration(), is(4)); + } + + @Test + void name3() { + assertThat(Version.from("0.0.1"), is(lessThan(Version.from("0.0.2")))); + assertThat(Version.from("0.0.1"), is(lessThanOrEqualTo(Version.from("0.0.1")))); + assertThat(Version.from("0.0.1"), is(equalTo(Version.from("0.0.1")))); + assertThat(Version.from("0.0.2"), is(greaterThanOrEqualTo(Version.from("0.0.2")))); + assertThat(Version.from("0.0.2"), is(greaterThan(Version.from("0.0.1")))); + } + + @Test + void name4() { + assertThat(Version.from("1.0.0-beta.0"), is(lessThan(Version.from("1.0.0")))); + assertThat(Version.from("1.0.0-beta.0"), is(lessThan(Version.from("1.0.0-beta.1")))); + assertThat(Version.from("1.0.1-beta.0"), is(greaterThan(Version.from("1.0.0")))); + } + + @Test + void name5() { + assertThat(Version.from("0.0.1-beta.0"), is(lessThan(Version.from("0.0.1-beta.1")))); + assertThat(Version.from("0.0.1-beta.0"), is(lessThanOrEqualTo(Version.from("0.0.1-beta.0")))); + assertThat(Version.from("0.0.1-beta.1"), is(equalTo(Version.from("0.0.1-beta.1")))); + assertThat(Version.from("0.0.1-beta.1"), is(greaterThanOrEqualTo(Version.from("0.0.1-beta.1")))); + assertThat(Version.from("0.0.1-beta.1"), is(greaterThan(Version.from("0.0.1-beta.0")))); + } + + @Test + void name6() { + assertThat(Version.from("v2.0.0-beta.0-9-gf72dfeed-dirty"), is(Version.from("v2.0.0-beta.0"))); + } +} diff --git a/src/test/java/org/opendatakit/cli/CliTest.java b/src/test/java/org/opendatakit/cli/CliTest.java index b878e5e..e006928 100644 --- a/src/test/java/org/opendatakit/cli/CliTest.java +++ b/src/test/java/org/opendatakit/cli/CliTest.java @@ -29,8 +29,7 @@ class CliTest { @Test void the_help_flag_prints_defined_ops() { Output output = captureOutputOf(() -> new Cli() - .register(Operation.of(Param.flag("o", "operation", "Run some operation"), args -> { - })) + .register(Operation.of(Param.flag("o", "operation", "Run some operation"), (console, args) -> {})) .run(new String[]{"-h"}) ); diff --git a/src/test/java/org/opendatakit/cli/OperationTest.java b/src/test/java/org/opendatakit/cli/OperationTest.java index 2f149d5..13fec3d 100644 --- a/src/test/java/org/opendatakit/cli/OperationTest.java +++ b/src/test/java/org/opendatakit/cli/OperationTest.java @@ -21,15 +21,13 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; -import java.util.function.Consumer; +import java.util.function.BiConsumer; import java.util.stream.IntStream; import org.junit.jupiter.api.Test; class OperationTest { - private static final Consumer NO_OP = args -> { - }; - + private static final BiConsumer NO_OP = (console, args) -> { }; @Test void knows_if_it_has_required_params() {