From 272f8185a355f003ebc9255ee456e3c41523893b Mon Sep 17 00:00:00 2001 From: Jon Marius Venstad Date: Tue, 7 May 2019 11:38:11 +0200 Subject: Support deployments through the ControllerHttpClient --- .../ai/vespa/hosted/api/ControllerHttpClient.java | 83 ++++++++++++++++++++++ .../main/java/ai/vespa/hosted/api/Deployment.java | 67 +++++++++++++++++ .../java/ai/vespa/hosted/api/DeploymentResult.java | 21 ++++++ 3 files changed, 171 insertions(+) create mode 100644 hosted-api/src/main/java/ai/vespa/hosted/api/Deployment.java create mode 100644 hosted-api/src/main/java/ai/vespa/hosted/api/DeploymentResult.java (limited to 'hosted-api/src') diff --git a/hosted-api/src/main/java/ai/vespa/hosted/api/ControllerHttpClient.java b/hosted-api/src/main/java/ai/vespa/hosted/api/ControllerHttpClient.java index b4904f7915b..fe8ad66dcb7 100644 --- a/hosted-api/src/main/java/ai/vespa/hosted/api/ControllerHttpClient.java +++ b/hosted-api/src/main/java/ai/vespa/hosted/api/ControllerHttpClient.java @@ -3,7 +3,9 @@ package ai.vespa.hosted.api; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.ApplicationName; +import com.yahoo.config.provision.Environment; import com.yahoo.config.provision.TenantName; +import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.security.SslContextBuilder; import com.yahoo.slime.Cursor; import com.yahoo.slime.Inspector; @@ -26,6 +28,8 @@ import java.time.Duration; import java.util.concurrent.Callable; import java.util.function.Supplier; +import static ai.vespa.hosted.api.Method.DELETE; +import static ai.vespa.hosted.api.Method.GET; import static ai.vespa.hosted.api.Method.POST; import static java.net.http.HttpRequest.BodyPublishers.ofByteArray; import static java.net.http.HttpRequest.BodyPublishers.ofInputStream; @@ -70,6 +74,28 @@ public abstract class ControllerHttpClient { .addFile("applicationTestZip", submission.applicationTestZip())))); } + /** Sends the given deployment to the given application in the given zone, or throws if this fails. */ + public DeploymentResult deploy(Deployment deployment, ApplicationId id, ZoneId zone) { + return toDeploymentResult(send(request(HttpRequest.newBuilder(deploymentPath(id, zone)) + .timeout(Duration.ofMinutes(60)), + POST, + toDataStream(deployment)))); + } + + public String deactivate(ApplicationId id, ZoneId zone) { + return asText(send(request(HttpRequest.newBuilder(deploymentPath(id, zone)) + .timeout(Duration.ofSeconds(10)), + DELETE))); + } + + /** Returns the default {@link Environment#dev} {@link ZoneId}, to use for development deployments. */ + public ZoneId devZone() { + Inspector rootObject = toInspector(send(request(HttpRequest.newBuilder(defaultRegionPath()) + .timeout(Duration.ofSeconds(10)), + GET))); + return ZoneId.from("dev", rootObject.field("name").asString()); + } + protected HttpRequest request(HttpRequest.Builder request, Method method, Supplier data) { return request.method(method.name(), ofInputStream(data)).build(); } @@ -102,6 +128,17 @@ public abstract class ControllerHttpClient { return concatenated(applicationPath(id.tenant(), id.application()), "instance", id.instance().value()); } + private URI deploymentPath(ApplicationId id, ZoneId zone) { + return concatenated(applicationPath(id.tenant(), id.application()), + "environment", zone.environment().value(), + "region", zone.region().value(), + "instance", id.instance().value()); + } + + private URI defaultRegionPath() { + return concatenated(endpoint, "zone", "v1", "environment", Environment.dev.value(), "default"); + } + private static URI concatenated(URI base, String... parts) { return base.resolve(String.join("/", parts) + "/"); } @@ -119,6 +156,27 @@ public abstract class ControllerHttpClient { } } + /** Returns a JSON representation of the deployment meta data. */ + private static String metaToJson(Deployment deployment) { + Slime slime = new Slime(); + Cursor rootObject = slime.setObject(); + + if (deployment.repository().isPresent()) { + Cursor revisionObject = rootObject.setObject("sourceRevision"); + deployment.repository().ifPresent(repository -> revisionObject.setString("repository", repository)); + deployment.branch().ifPresent(branch -> revisionObject.setString("branch", branch)); + deployment.commit().ifPresent(commit -> revisionObject.setString("commit", commit)); + deployment.build().ifPresent(build -> rootObject.setLong("buildNumber", build)); + } + + deployment.version().ifPresent(version -> rootObject.setString("vespaVersion", version)); + + if (deployment.ignoreValidationErrors()) rootObject.setBool("ignoreValidationErrors", true); + rootObject.setBool("deployDirectly", true); + + return toJson(slime); + } + /** Returns a JSON representation of the submission meta data. */ private static String metaToJson(Submission submission) { Slime slime = new Slime(); @@ -130,6 +188,19 @@ public abstract class ControllerHttpClient { return toJson(slime); } + /** Returns a multi part data stream with meta data and, if contained in the deployment, an application package. */ + private static MultiPartStreamer toDataStream(Deployment deployment) { + MultiPartStreamer streamer = new MultiPartStreamer(); + streamer.addJson("deployOptions", metaToJson(deployment)); + deployment.applicationZip().ifPresent(zip -> streamer.addFile("applicationZip", zip)); + return streamer; + } + + private static String asText(HttpResponse response) { + toInspector(response); + return new String(response.body(), UTF_8); + } + /** Returns an {@link Inspector} for the assumed JSON formatted response, or throws if the status code is non-2XX. */ private static Inspector toInspector(HttpResponse response) { Inspector rootObject = toSlime(response.body()).get(); @@ -146,6 +217,18 @@ public abstract class ControllerHttpClient { return toInspector(response).field("message").asString(); } + private static DeploymentResult toDeploymentResult(HttpResponse response) { + try { + Inspector responseObject = toInspector(response); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + new JsonFormat(false).encode(buffer, responseObject); // Pretty-print until done properly. + return new DeploymentResult(buffer.toString(UTF_8)); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + private static Slime toSlime(byte[] data) { return new JsonDecoder().decode(new Slime(), data); } diff --git a/hosted-api/src/main/java/ai/vespa/hosted/api/Deployment.java b/hosted-api/src/main/java/ai/vespa/hosted/api/Deployment.java new file mode 100644 index 00000000000..8f981ca5f05 --- /dev/null +++ b/hosted-api/src/main/java/ai/vespa/hosted/api/Deployment.java @@ -0,0 +1,67 @@ +package ai.vespa.hosted.api; + +import java.nio.file.Path; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * A deployment intended for hosted Vespa, containing an application package and some meta data. + */ +public class Deployment { + + // Deployment options + private final Optional version; + private final boolean ignoreValidationErrors; + + // Provide an application package ... + private final Optional applicationZip; + + // ... or reference a previously submitted one. + private final Optional repository; + private final Optional branch; + private final Optional commit; + private final OptionalLong build; + + private Deployment(Optional version, boolean ignoreValidationErrors, Optional applicationZip, + Optional repository, Optional branch, Optional commit, OptionalLong build) { + this.version = version; + this.ignoreValidationErrors = ignoreValidationErrors; + this.applicationZip = applicationZip; + this.repository = repository; + this.branch = branch; + this.commit = commit; + this.build = build; + } + + + /** Returns a deployment which will use the provided application package. */ + public static Deployment ofPackage(Path applicationZipFile) { + return new Deployment(Optional.empty(), false, Optional.of(applicationZipFile), + Optional.empty(), Optional.empty(), Optional.empty(), OptionalLong.empty()); + } + + /** Returns a deployment which will use the previously submitted package with the given reference. */ + public static Deployment ofReference(String repository, String branch, String commit, long build) { + return new Deployment(Optional.empty(), false, Optional.empty(), + Optional.of(repository), Optional.of(branch), Optional.of(commit), OptionalLong.of(build)); + } + + /** Returns a copy of this which will have the specified Vespa version on its nodes. */ + public Deployment atVersion(String vespaVersion) { + return new Deployment(Optional.of(vespaVersion), ignoreValidationErrors, applicationZip, repository, branch, commit, build); + } + + /** Returns a copy of this which will additionally ignore validation errors upon deployment. */ + public Deployment ignoringValidationErrors() { + return new Deployment(version, true, applicationZip, repository, branch, commit, build); + } + + public Optional version() { return version; } + public boolean ignoreValidationErrors() { return ignoreValidationErrors; } + public Optional applicationZip() { return applicationZip; } + public Optional repository() { return repository; } + public Optional branch() { return branch; } + public Optional commit() { return commit; } + public OptionalLong build() { return build; } + +} diff --git a/hosted-api/src/main/java/ai/vespa/hosted/api/DeploymentResult.java b/hosted-api/src/main/java/ai/vespa/hosted/api/DeploymentResult.java new file mode 100644 index 00000000000..63142ffbbf1 --- /dev/null +++ b/hosted-api/src/main/java/ai/vespa/hosted/api/DeploymentResult.java @@ -0,0 +1,21 @@ +package ai.vespa.hosted.api; + + +/** + * Contains information about the result of a {@link Deployment} against a {@link ControllerHttpClient}. + * + * @author jonmv + */ +public class DeploymentResult { + + private final String json; // TODO probably do this properly. + + public DeploymentResult(String json) { + this.json = json; + } + + public String json() { + return json; + } + +} -- cgit v1.2.3