From fd0d8e218b4f9a7afb4bb983f7ee94e8e7e94b99 Mon Sep 17 00:00:00 2001 From: Jon Marius Venstad Date: Fri, 3 May 2019 15:30:55 +0200 Subject: Add ControllerHttpClient and Submission --- .../ai/vespa/hosted/api/ControllerHttpClient.java | 121 +++++++++++++++++++++ .../main/java/ai/vespa/hosted/api/Submission.java | 38 +++++++ 2 files changed, 159 insertions(+) create mode 100644 hosted-api/src/main/java/ai/vespa/hosted/api/ControllerHttpClient.java create mode 100644 hosted-api/src/main/java/ai/vespa/hosted/api/Submission.java 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 new file mode 100644 index 00000000000..94b7d8851a5 --- /dev/null +++ b/hosted-api/src/main/java/ai/vespa/hosted/api/ControllerHttpClient.java @@ -0,0 +1,121 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package ai.vespa.hosted.api; + +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.ApplicationName; +import com.yahoo.config.provision.TenantName; +import com.yahoo.slime.Cursor; +import com.yahoo.slime.Inspector; +import com.yahoo.slime.JsonDecoder; +import com.yahoo.slime.JsonFormat; +import com.yahoo.slime.Slime; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import static ai.vespa.hosted.api.Method.POST; +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Talks to a remote controller over HTTP. + * + * Uses request signing with a public/private key pair to authenticate with the controller. + * + * @author jonmv + */ +public class ControllerHttpClient { + + private final ApplicationId id; + private final RequestSigner signer; + private final URI endpoint; + private final HttpClient client; + + /** Creates a HTTP client against the given endpoint, which uses the given key to authenticate as the given application. */ + public ControllerHttpClient(URI endpoint, String privateKey, ApplicationId id) { + this.id = id; + this.signer = new RequestSigner(privateKey, id.serializedForm()); + this.endpoint = endpoint.resolve("/"); + this.client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + } + + /** Sends submission to the remote controller and returns the version of the accepted package, or throws if this fails. */ + public String submit(Submission submission) { + HttpRequest request = signer.signed(HttpRequest.newBuilder(instancePath(id).resolve("submit")) + .timeout(Duration.ofMinutes(30)), + POST, + new MultiPartStreamer().addJson("submitOptions", metaToSlime(submission)) + .addFile("applicationZip", submission.applicationZip()) + .addFile("applicationTestZip", submission.applicationTestZip())); + try { + return toMessage(client.send(request, HttpResponse.BodyHandlers.ofByteArray())); + } + catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + private URI apiPath() { + return concatenated(endpoint, "application", "v4"); + } + + private URI tenantPath(TenantName tenant) { + return concatenated(apiPath(), "tenant", tenant.value()); + } + + private URI applicationPath(TenantName tenant, ApplicationName application) { + return concatenated(tenantPath(tenant), "application", application.value()); + } + + private URI instancePath(ApplicationId id) { + return concatenated(applicationPath(id.tenant(), id.application()), "instance", id.instance().value()); + } + + private static URI concatenated(URI base, String... parts) { + return base.resolve(String.join("/", parts) + "/"); + } + + /** Returns a JSON representation of the submission meta data. */ + private static String metaToSlime(Submission submission) { + try { + Slime slime = new Slime(); + Cursor rootObject = slime.setObject(); + rootObject.setString("repository", submission.repository()); + rootObject.setString("branch", submission.branch()); + rootObject.setString("commit", submission.commit()); + rootObject.setString("authorEmail", submission.authorEmail()); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + new JsonFormat(true).encode(buffer, slime); + return buffer.toString(UTF_8); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Returns the "message" element contained in the JSON formatted response, if 2XX status code, or throws otherwise. */ + private static String toMessage(HttpResponse response) { + Inspector rootObject = toSlime(response.body()).get(); + if (response.statusCode() / 100 == 2) + return rootObject.field("message").asString(); + + else { + throw new RuntimeException(response.request() + " returned code " + response.statusCode() + + " (" + rootObject.field("error-code").asString() + "): " + + rootObject.field("message").asString()); + } + } + + 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/Submission.java b/hosted-api/src/main/java/ai/vespa/hosted/api/Submission.java new file mode 100644 index 00000000000..f4cb90176da --- /dev/null +++ b/hosted-api/src/main/java/ai/vespa/hosted/api/Submission.java @@ -0,0 +1,38 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package ai.vespa.hosted.api; + +import java.io.File; +import java.io.InputStream; +import java.nio.file.Path; + +/** + * A submission intended for hosted Vespa containing an application package with tests and meta data. + * + * @author jonmv + */ +public class Submission { + + private final String repository; + private final String branch; + private final String commit; + private final String authorEmail; + private final Path applicationZip; + private final Path applicationTestZip; + + public Submission(String repository, String branch, String commit, String authorEmail, Path applicationZip, Path applicationTestZip) { + this.repository = repository; + this.branch = branch; + this.commit = commit; + this.authorEmail = authorEmail; + this.applicationZip = applicationZip; + this.applicationTestZip = applicationTestZip; + } + + public String repository() { return repository; } + public String branch() { return branch; } + public String commit() { return commit; } + public String authorEmail() { return authorEmail; } + public Path applicationZip() { return applicationZip; } + public Path applicationTestZip() { return applicationTestZip; } + +} -- cgit v1.2.3