diff options
author | Jon Marius Venstad <jvenstad@yahoo-inc.com> | 2019-06-13 13:52:18 +0200 |
---|---|---|
committer | Jon Marius Venstad <jvenstad@yahoo-inc.com> | 2019-06-13 13:52:18 +0200 |
commit | 2bb71582b979a83a39260f3f11466737b94ee47f (patch) | |
tree | ac4d17548ae440348a7ecd62bbd87cfa998c6496 | |
parent | 7d2366a939f64f964a208e01c4455dd530f833c6 (diff) |
Wrap TestConfig in TestRuntime, which includes and Authenticator, and obtain the former from controller
10 files changed, 296 insertions, 141 deletions
diff --git a/hosted-api/src/main/java/ai/vespa/hosted/api/Authenticator.java b/hosted-api/src/main/java/ai/vespa/hosted/api/Authenticator.java new file mode 100644 index 00000000000..acd8a215e7f --- /dev/null +++ b/hosted-api/src/main/java/ai/vespa/hosted/api/Authenticator.java @@ -0,0 +1,23 @@ +package ai.vespa.hosted.api; + +import javax.net.ssl.SSLContext; +import java.net.http.HttpRequest; +import java.util.Optional; + +/** + * Adds environment dependent authentication to HTTP request against hosted Vespa API and deployments. + * + * @author jonmv + */ +public interface Authenticator { + + /** Returns an SSLContext which provides authentication against a Vespa endpoint. */ + SSLContext sslContext(); + + /** Adds necessary authentication to the given HTTP request builder, to pass the data plane of a Vespa endpoint. */ + HttpRequest.Builder authenticated(HttpRequest.Builder request); + + /** Returns a client authenticated to talk to the hosted Vespa API. */ + ControllerHttpClient controller(); + +} 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 d59eb166e2b..153a03e56d2 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 @@ -104,14 +104,14 @@ public abstract class ControllerHttpClient { /** Deactivates the deployment of the given application in the given zone. */ public String deactivate(ApplicationId id, ZoneId zone) { - return asText(send(request(HttpRequest.newBuilder(deploymentPath(id, zone)) - .timeout(Duration.ofSeconds(10)), - DELETE))); + return asString(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()) + /** Returns the default {@link ZoneId} for the given environment, if any. */ + public ZoneId defaultZone(Environment environment) { + Inspector rootObject = toInspector(send(request(HttpRequest.newBuilder(defaultRegionPath(environment)) .timeout(Duration.ofSeconds(10)), GET))); return ZoneId.from("dev", rootObject.field("name").asString()); @@ -125,6 +125,11 @@ public abstract class ControllerHttpClient { .field("compileVersion").asString(); } + /** Returns the test config for functional and verification tests of the indicated Vespa deployment. */ + public TestConfig testConfig(ApplicationId id, ZoneId zone) { + return TestConfig.fromJson(asBytes(send(request(HttpRequest.newBuilder(testConfigPath(id, zone)), GET)))); + } + /** Returns the sorted list of log entries after the given after from the deployment job of the given ids. */ public DeploymentLog deploymentLog(ApplicationId id, ZoneId zone, long run, long after) { return toDeploymentLog(send(request(HttpRequest.newBuilder(runPath(id, zone, run, after)) @@ -137,6 +142,7 @@ public abstract class ControllerHttpClient { return deploymentLog(id, zone, run, -1); } + /** Returns an authenticated request from the given input. Override this for, e.g., request signing. */ protected HttpRequest request(HttpRequest.Builder request, Method method, Supplier<InputStream> data) { return request.method(method.name(), ofInputStream(data)).build(); } @@ -181,15 +187,22 @@ public abstract class ControllerHttpClient { "deploy", jobNameOf(zone)); } + private URI jobPath(ApplicationId id, ZoneId zone) { + return concatenated(instancePath(id), "job", jobNameOf(zone)); + } + + private URI testConfigPath(ApplicationId id, ZoneId zone) { + return concatenated(instancePath(id), "test-config"); + } + private URI runPath(ApplicationId id, ZoneId zone, long run, long after) { - return withQuery(concatenated(instancePath(id), - "job", jobNameOf(zone), + return withQuery(concatenated(jobPath(id, zone), "run", Long.toString(run)), "after", Long.toString(after)); } - private URI defaultRegionPath() { - return concatenated(endpoint, "zone", "v1", "environment", Environment.dev.value(), "default"); + private URI defaultRegionPath(Environment environment) { + return concatenated(endpoint, "zone", "v1", "environment", environment.value(), "default"); } private static URI concatenated(URI base, String... parts) { @@ -247,9 +260,15 @@ public abstract class ControllerHttpClient { return streamer; } - private static String asText(HttpResponse<byte[]> response) { + /** Returns the response body as a String, or throws if the status code is non-2XX. */ + private static String asString(HttpResponse<byte[]> response) { + return new String(asBytes(response), UTF_8); + } + + /** Returns the response body as a byte array, or throws if the status code is non-2XX. */ + private static byte[] asBytes(HttpResponse<byte[]> response) { toInspector(response); - return new String(response.body(), UTF_8); + return response.body(); } /** Returns an {@link Inspector} for the assumed JSON formatted response, or throws if the status code is non-2XX. */ diff --git a/hosted-api/src/main/java/ai/vespa/hosted/api/Properties.java b/hosted-api/src/main/java/ai/vespa/hosted/api/Properties.java new file mode 100644 index 00000000000..61893a30e7e --- /dev/null +++ b/hosted-api/src/main/java/ai/vespa/hosted/api/Properties.java @@ -0,0 +1,55 @@ +package ai.vespa.hosted.api; + +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.RegionName; + +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; + +/** + * Utilities and common definitions of system properties defining a Vespa application project. + * + * @author jonmv + */ +public class Properties { + + public static ApplicationId application() { + return ApplicationId.from(requireNonBlankProperty("tenant"), + requireNonBlankProperty("application"), + getNonBlankProperty("instance").orElse("default")); + } + + public static Optional<Environment> environment() { + return getNonBlankProperty("environment").map(Environment::from); + } + + public static Optional<RegionName> region() { + return getNonBlankProperty("region").map(RegionName::from); + } + + public static URI endpoint() { + return URI.create(requireNonBlankProperty("endpoint")); + } + + public static Path privateKeyFile() { + return Paths.get(requireNonBlankProperty("privateKeyFile")); + } + + public static Path certificateFile() { + return Paths.get(requireNonBlankProperty("certificateFile")); + } + + /** Returns the system property with the given name if it is set, or empty. */ + public static Optional<String> getNonBlankProperty(String name) { + return Optional.ofNullable(System.getProperty(name)).filter(value -> ! value.isBlank()); + } + + /** Returns the system property with the given name if it is set, or throws. */ + public static String requireNonBlankProperty(String name) { + return getNonBlankProperty(name).orElseThrow(() -> new IllegalStateException("Missing required property '" + name + "'")); + } + +} diff --git a/hosted-api/src/main/java/ai/vespa/hosted/api/TestConfig.java b/hosted-api/src/main/java/ai/vespa/hosted/api/TestConfig.java new file mode 100644 index 00000000000..a0c679f312e --- /dev/null +++ b/hosted-api/src/main/java/ai/vespa/hosted/api/TestConfig.java @@ -0,0 +1,70 @@ +package ai.vespa.hosted.api; + +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.zone.ZoneId; +import com.yahoo.slime.Inspector; +import com.yahoo.slime.JsonDecoder; +import com.yahoo.slime.ObjectTraverser; +import com.yahoo.slime.Slime; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import static java.util.Objects.requireNonNull; + +/** + * Config required to run a functional or verification test of a Vespa deployment. + * + * @author jvenstad + */ +public class TestConfig { + + private final ApplicationId application; + private final ZoneId zone; + private final SystemName system; + private final Map<ZoneId, Map<String, URI>> deployments; + + public TestConfig(ApplicationId application, ZoneId zone, SystemName system, Map<ZoneId, Map<String, URI>> deployments) { + if ( ! deployments.containsKey(zone)) + throw new IllegalArgumentException("Config must contain a deployment for its zone, but only does for " + deployments.keySet()); + this.application = requireNonNull(application); + this.zone = requireNonNull(zone); + this.system = requireNonNull(system); + this.deployments = deployments.entrySet().stream() + .collect(Collectors.toUnmodifiableMap(entry -> entry.getKey(), + entry -> Map.copyOf(entry.getValue()))); + } + + public static TestConfig fromJson(byte[] jsonBytes) { + Inspector config = new JsonDecoder().decode(new Slime(), jsonBytes).get(); + ApplicationId application = ApplicationId.fromSerializedForm(config.field("application").asString()); + ZoneId zone = ZoneId.from(config.field("zone").asString()); + SystemName system = SystemName.from(config.field("system").asString()); + Map<ZoneId, Map<String, URI>> deployments = new HashMap<>(); + config.field("clusterEndpoints").traverse((ObjectTraverser) (zoneId, endpointsObject) -> { + Map<String, URI> endpoints = new HashMap<>(); + endpointsObject.traverse((ObjectTraverser) (cluster, uri) -> endpoints.put(cluster, URI.create(uri.asString()))); + deployments.put(ZoneId.from(zoneId), endpoints); + }); + return new TestConfig(application, zone, system, deployments); + } + + /** Returns the full id of the application to test. */ + public ApplicationId application() { return application; } + + /** Returns the zone of the deployment to test. */ + public ZoneId zone() { return zone; } + + /** Returns an immutable view of deployments, per zone, of the application to test. */ + public Map<ZoneId, Map<String, URI>> allDeployments() { return deployments; } + + /** Returns the deployment to test in this test runtime. */ + public Map<String, URI> deploymentToTest() { return deployments.get(zone); } + + /** Returns the hosted Vespa system this is run against. */ + public SystemName system() { return system; } + +} diff --git a/tenant-auth/src/main/java/ai/vespa/hosted/auth/Authenticator.java b/tenant-auth/src/main/java/ai/vespa/hosted/auth/Authenticator.java index 33c1d09c828..f2de1f1e210 100644 --- a/tenant-auth/src/main/java/ai/vespa/hosted/auth/Authenticator.java +++ b/tenant-auth/src/main/java/ai/vespa/hosted/auth/Authenticator.java @@ -1,6 +1,7 @@ package ai.vespa.hosted.auth; import ai.vespa.hosted.api.ControllerHttpClient; +import ai.vespa.hosted.api.Properties; import com.yahoo.config.provision.ApplicationId; import com.yahoo.security.KeyUtils; import com.yahoo.security.SslContextBuilder; @@ -20,17 +21,21 @@ import java.security.cert.X509Certificate; import java.time.Instant; import java.util.Optional; +import static ai.vespa.hosted.api.Properties.getNonBlankProperty; +import static ai.vespa.hosted.api.Properties.requireNonBlankProperty; + /** - * Authenticates {@link HttpRequest}s against a hosted Vespa application based on mutual TLS. + * Authenticates against the hosted Vespa API using private key signatures, and against Vespa applications using mutual TLS. * * @author jonmv */ -public class Authenticator { +public class Authenticator implements ai.vespa.hosted.api.Authenticator { - /** Returns an SSLContext which provides authentication against a Vespa endpoint. - * + /** * If {@code System.getProperty("vespa.test.credentials.root")} is set, key and certificate files - * "key" and "cert" in that directory are used; otherwise, the system default SSLContext is returned. */ + * "key" and "cert" in that directory are used; otherwise, the system default SSLContext is returned. + */ + @Override public SSLContext sslContext() { try { Optional<String> credentialsRootProperty = getNonBlankProperty("vespa.test.credentials.root"); @@ -57,28 +62,17 @@ public class Authenticator { } } - /** Adds necessary authentication to the given HTTP request builder, to be verified by a Vespa endpoint. */ + @Override public HttpRequest.Builder authenticated(HttpRequest.Builder request) { return request; } - /** Returns an authenticated controller client. */ + /** Returns an authenticating controller client, using the (overridable) project properties of this Vespa application. */ + @Override public ControllerHttpClient controller() { - ApplicationId id = ApplicationId.from(requireNonBlankProperty("tenant"), - requireNonBlankProperty("application"), - getNonBlankProperty("instance").orElse("default")); - URI endpoint = URI.create(requireNonBlankProperty("endpoint")); - Path privateKeyFile = Paths.get(requireNonBlankProperty("privateKeyFile")); - - return ControllerHttpClient.withSignatureKey(endpoint, privateKeyFile, id); - } - - static Optional<String> getNonBlankProperty(String name) { - return Optional.ofNullable(System.getProperty(name)).filter(value -> ! value.isBlank()); - } - - static String requireNonBlankProperty(String name) { - return getNonBlankProperty(name).orElseThrow(() -> new IllegalStateException("Missing required property '" + name + "'")); + return ControllerHttpClient.withSignatureKey(Properties.endpoint(), + Properties.privateKeyFile(), + Properties.application()); } } diff --git a/tenant-cd/src/main/java/ai/vespa/hosted/cd/TestConfig.java b/tenant-cd/src/main/java/ai/vespa/hosted/cd/TestConfig.java deleted file mode 100644 index e441254cff7..00000000000 --- a/tenant-cd/src/main/java/ai/vespa/hosted/cd/TestConfig.java +++ /dev/null @@ -1,101 +0,0 @@ -package ai.vespa.hosted.cd; - -import ai.vespa.hosted.api.ControllerHttpClient; -import ai.vespa.hosted.auth.Authenticator; -import com.yahoo.config.provision.ApplicationId; -import com.yahoo.config.provision.SystemName; -import com.yahoo.slime.ArrayTraverser; -import com.yahoo.slime.Inspector; -import com.yahoo.slime.JsonDecoder; -import com.yahoo.slime.ObjectTraverser; -import com.yahoo.config.provision.zone.ZoneId; -import com.yahoo.slime.Slime; - -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * The place to obtain environment-dependent configuration for the current test run. - * - * If the system property 'vespa.test.config' is set, this class attempts to parse config - * from a JSON file at that location -- otherwise, attempts to access the config will return null. - * - * @author jvenstad - */ -public class TestConfig { - - private static TestConfig theConfig; - - private final ApplicationId application; - private final ZoneId zone; - private final SystemName system; - private final Map<ZoneId, Deployment> deployments; - - private TestConfig(ApplicationId application, ZoneId zone, SystemName system, Map<ZoneId, Deployment> deployments) { - this.application = application; - this.zone = zone; - this.system = system; - this.deployments = Map.copyOf(deployments); - } - - /** Returns the config for this test, or null if it has not been provided. */ - public static synchronized TestConfig get() { - if (theConfig == null) { - String configPath = System.getProperty("vespa.test.config"); - theConfig = configPath != null ? fromFile(configPath) : fromController(); - } - return theConfig; - } - - /** Returns the full id of the application to be tested. */ - public ApplicationId application() { return application; } - - /** Returns the zone of the deployment to test. */ - public ZoneId zone() { return zone; } - - /** Returns an immutable view of all configured endpoints for each zone of the application to test. */ - public Map<ZoneId, Deployment> allDeployments() { return deployments; } - - /** Returns the deployment to test in this test runtime. */ - public Deployment deploymentToTest() { return deployments.get(zone); } - - /** Returns the system this is run against. */ - public SystemName system() { return system; } - - static TestConfig fromFile(String path) { - if (path == null) - return null; - - try { - return fromJson(Files.readAllBytes(Paths.get(path))); - } - catch (Exception e) { - throw new IllegalArgumentException("Failed reading config from '" + path + "'!", e); - } - } - - static TestConfig fromController() { - ControllerHttpClient controller = new Authenticator().controller(); - return null; - } - - static TestConfig fromJson(byte[] jsonBytes) { - Inspector config = new JsonDecoder().decode(new Slime(), jsonBytes).get(); - ApplicationId application = ApplicationId.fromSerializedForm(config.field("application").asString()); - ZoneId zone = ZoneId.from(config.field("zone").asString()); - SystemName system = SystemName.from(config.field("system").asString()); - Map<ZoneId, Deployment> endpoints = new HashMap<>(); - config.field("endpoints").traverse((ObjectTraverser) (zoneId, endpointArray) -> { - List<URI> uris = new ArrayList<>(); - endpointArray.traverse((ArrayTraverser) (__, uri) -> uris.add(URI.create(uri.asString()))); - endpoints.put(ZoneId.from(zoneId), null); // TODO jvenstad - }); - return new TestConfig(application, zone, system, endpoints); - } - -} diff --git a/tenant-cd/src/main/java/ai/vespa/hosted/cd/TestRuntime.java b/tenant-cd/src/main/java/ai/vespa/hosted/cd/TestRuntime.java new file mode 100644 index 00000000000..fa09d7037c9 --- /dev/null +++ b/tenant-cd/src/main/java/ai/vespa/hosted/cd/TestRuntime.java @@ -0,0 +1,77 @@ +package ai.vespa.hosted.cd; + +import ai.vespa.hosted.api.Authenticator; +import ai.vespa.hosted.api.ControllerHttpClient; +import ai.vespa.hosted.api.Properties; +import ai.vespa.hosted.api.TestConfig; +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.RegionName; +import com.yahoo.config.provision.zone.ZoneId; + +import java.nio.file.Files; +import java.nio.file.Paths; + +import static ai.vespa.hosted.api.TestConfig.fromJson; + +/** + * The place to obtain environment-dependent configuration for test of a Vespa deployment. + * + * @author jvenstad + */ +public class TestRuntime { + + private static TestRuntime theRuntime; + + private final TestConfig config; + private final Authenticator authenticator; + + private TestRuntime(TestConfig config, Authenticator authenticator) { + this.config = config; + this.authenticator = authenticator; + } + + /** + * Returns the config for this test, or null if it has not been provided. + * + * If the system property {@code "vespa.test.config"} is set (to a file path), a file at that location + * is attempted read, and config parsed from it. + * Otherwise, config is fetched over HTTP from the hosted Vespa API, assuming the deployment indicated + * by the optional {@code "environment"} and {@code "region"} system properties exists. + * When environment is not specified, it defaults to {@link Environment#dev}, + * while region must be set unless the environment is {@link Environment#dev} or {@link Environment#perf}. + */ + public static synchronized TestRuntime get() { + if (theRuntime == null) { + String configPath = System.getProperty("vespa.test.config"); + Authenticator authenticator = new ai.vespa.hosted.auth.Authenticator(); + theRuntime = new TestRuntime(configPath != null ? fromFile(configPath) : fromController(authenticator), + authenticator); + } + return theRuntime; + } + + /** Returns a copy of this runtime, with the given authenticator. */ + public TestRuntime with(Authenticator authenticator) { + return new TestRuntime(config, authenticator); + } + + private static TestConfig fromFile(String path) { + try { + return TestConfig.fromJson(Files.readAllBytes(Paths.get(path))); + } + catch (Exception e) { + throw new IllegalArgumentException("Failed reading config from '" + path + "'!", e); + } + } + + private static TestConfig fromController(Authenticator authenticator) { + ControllerHttpClient controller = authenticator.controller(); + ApplicationId id = Properties.application(); + Environment environment = Properties.environment().orElse(Environment.dev); + ZoneId zone = Properties.region().map(region -> ZoneId.from(environment, region)) + .orElseGet(() -> controller.defaultZone(environment)); + return controller.testConfig(id, zone); + } + +} diff --git a/tenant-cd/src/main/java/ai/vespa/hosted/cd/http/HttpDeployment.java b/tenant-cd/src/main/java/ai/vespa/hosted/cd/http/HttpDeployment.java index 8eebe04ebef..6234b54c0a1 100644 --- a/tenant-cd/src/main/java/ai/vespa/hosted/cd/http/HttpDeployment.java +++ b/tenant-cd/src/main/java/ai/vespa/hosted/cd/http/HttpDeployment.java @@ -1,9 +1,13 @@ package ai.vespa.hosted.cd.http; +import ai.vespa.hosted.api.Authenticator; import ai.vespa.hosted.cd.Deployment; import ai.vespa.hosted.cd.Endpoint; import ai.vespa.hosted.cd.TestDeployment; -import ai.vespa.hosted.cd.TestEndpoint; + +import java.net.URI; +import java.util.Map; +import java.util.stream.Collectors; /** * A remote deployment of a Vespa application, reachable over HTTP. Contains {@link HttpEndpoint}s. @@ -12,6 +16,15 @@ import ai.vespa.hosted.cd.TestEndpoint; */ public class HttpDeployment implements Deployment { + private final Map<String, HttpEndpoint> endpoints; + + /** Creates a representation of the given deployment endpoints, using the authenticator for data plane access. */ + public HttpDeployment(Map<String, URI> endpoints, Authenticator authenticator) { + this.endpoints = endpoints.entrySet().stream() + .collect(Collectors.toUnmodifiableMap(entry -> entry.getKey(), + entry -> new HttpEndpoint(entry.getValue(), authenticator))); + } + @Override public Endpoint endpoint() { return null; diff --git a/tenant-cd/src/main/java/ai/vespa/hosted/cd/http/HttpEndpoint.java b/tenant-cd/src/main/java/ai/vespa/hosted/cd/http/HttpEndpoint.java index 4fafa65773d..798eb1e692b 100644 --- a/tenant-cd/src/main/java/ai/vespa/hosted/cd/http/HttpEndpoint.java +++ b/tenant-cd/src/main/java/ai/vespa/hosted/cd/http/HttpEndpoint.java @@ -1,6 +1,6 @@ package ai.vespa.hosted.cd.http; -import ai.vespa.hosted.auth.Authenticator; +import ai.vespa.hosted.api.Authenticator; import com.yahoo.slime.Inspector; import com.yahoo.slime.JsonDecoder; import com.yahoo.slime.Slime; @@ -34,11 +34,11 @@ public class HttpEndpoint implements TestEndpoint { private final URI endpoint; private final HttpClient client; - private final Authenticator authenticator; + private final ai.vespa.hosted.api.Authenticator authenticator; - public HttpEndpoint(URI endpoint) { + public HttpEndpoint(URI endpoint, Authenticator authenticator) { this.endpoint = requireNonNull(endpoint); - this.authenticator = new Authenticator(); + this.authenticator = requireNonNull(authenticator); this.client = HttpClient.newBuilder() .sslContext(authenticator.sslContext()) .connectTimeout(Duration.ofSeconds(5)) diff --git a/vespa-maven-plugin/src/main/java/ai/vespa/hosted/plugin/DeployMojo.java b/vespa-maven-plugin/src/main/java/ai/vespa/hosted/plugin/DeployMojo.java index 32ff03ae202..d62ccb1bba4 100644 --- a/vespa-maven-plugin/src/main/java/ai/vespa/hosted/plugin/DeployMojo.java +++ b/vespa-maven-plugin/src/main/java/ai/vespa/hosted/plugin/DeployMojo.java @@ -4,6 +4,7 @@ import ai.vespa.hosted.api.Deployment; import ai.vespa.hosted.api.DeploymentLog; import ai.vespa.hosted.api.DeploymentResult; import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.Environment; import com.yahoo.config.provision.zone.ZoneId; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; @@ -41,7 +42,11 @@ public class DeployMojo extends AbstractVespaMojo { projectPathOf("target", "application.zip")))); if (vespaVersion != null) deployment = deployment.atVersion(vespaVersion); - ZoneId zone = environment == null || region == null ? controller.devZone() : ZoneId.from(environment, region); + ZoneId zone = region == null + ? controller.defaultZone(environment == null + ? Environment.dev + : Environment.from(environment)) + : ZoneId.from(environment, region); DeploymentResult result = controller.deploy(deployment, id, zone); getLog().info(result.message()); |