diff --git a/app/build.gradle b/app/build.gradle index 7d087d0..f6aa595 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ repositories { } dependencies { - implementation 'io.seqera:wave-api:0.7.1' - implementation 'io.seqera:wave-utils:0.9.0' + implementation 'io.seqera:wave-api:0.9.1' + implementation 'io.seqera:wave-utils:0.12.0' implementation 'info.picocli:picocli:4.6.1' implementation 'com.squareup.moshi:moshi:1.15.0' implementation 'com.squareup.moshi:moshi-adapters:1.14.0' @@ -38,7 +38,6 @@ dependencies { testImplementation ("org.objenesis:objenesis:3.2") testImplementation ("org.spockframework:spock-core:2.3-groovy-3.0") { exclude group: 'org.codehaus.groovy'; exclude group: 'net.bytebuddy' } testImplementation ('org.spockframework:spock-junit4:2.3-groovy-3.0') { exclude group: 'org.codehaus.groovy'; exclude group: 'net.bytebuddy' } - testImplementation 'com.github.tomakehurst:wiremock:2.27.2' } test { diff --git a/app/conf/reflect-config.json b/app/conf/reflect-config.json index 9056b0d..29f267c 100644 --- a/app/conf/reflect-config.json +++ b/app/conf/reflect-config.json @@ -148,6 +148,16 @@ "allDeclaredFields":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.wave.api.BuildStatusResponse", + "allDeclaredFields":true, + "queryAllPublicMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.wave.api.BuildStatusResponse$Status", + "fields":[{"name":"COMPLETED"}, {"name":"PENDING"}] +}, { "name":"io.seqera.wave.api.ContainerConfig", "allDeclaredFields":true, @@ -175,6 +185,15 @@ "allDeclaredFields":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.wave.api.PackagesSpec", + "allDeclaredFields":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.wave.api.PackagesSpec$Type", + "fields":[{"name":"CONDA"}, {"name":"SPACK"}] +}, { "name":"io.seqera.wave.api.ServiceInfo", "allDeclaredFields":true, @@ -196,6 +215,12 @@ "queryAllPublicMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.wave.api.SubmitContainerTokenResponseBeanInfo" +}, +{ + "name":"io.seqera.wave.api.SubmitContainerTokenResponseCustomizer" +}, { "name":"io.seqera.wave.cli.App", "allDeclaredFields":true, @@ -208,18 +233,40 @@ { "name":"io.seqera.wave.cli.json.DateTimeAdapter", "queryAllDeclaredMethods":true, - "methods":[{"name":"deserializeInstant","parameterTypes":["java.lang.String"] }, {"name":"serializeInstant","parameterTypes":["java.time.Instant"] }] + "methods":[{"name":"deserializeDuration","parameterTypes":["java.lang.String"] }, {"name":"deserializeInstant","parameterTypes":["java.lang.String"] }, {"name":"serializeInstant","parameterTypes":["java.time.Instant"] }] }, { "name":"io.seqera.wave.cli.json.PathAdapter", "queryAllDeclaredMethods":true }, +{ + "name":"io.seqera.wave.cli.model.ContainerInspectResponseEx", + "allDeclaredFields":true +}, +{ + "name":"io.seqera.wave.cli.model.ContainerSpecEx", + "allDeclaredFields":true +}, +{ + "name":"io.seqera.wave.cli.model.LayerRef", + "allDeclaredFields":true +}, { "name":"io.seqera.wave.cli.util.CliVersionProvider", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.wave.config.CondaOpts", + "allDeclaredFields":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.wave.config.SpackOpts", + "allDeclaredFields":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.wave.core.spec.ConfigSpec", "allDeclaredFields":true, diff --git a/app/src/main/java/io/seqera/wave/cli/App.java b/app/src/main/java/io/seqera/wave/cli/App.java index e4db90b..bfd3b12 100644 --- a/app/src/main/java/io/seqera/wave/cli/App.java +++ b/app/src/main/java/io/seqera/wave/cli/App.java @@ -30,11 +30,11 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.time.Duration; import java.time.OffsetDateTime; import java.util.Arrays; import java.util.Base64; import java.util.List; -import java.util.Optional; import java.util.stream.Collectors; import ch.qos.logback.classic.Level; @@ -44,6 +44,7 @@ import io.seqera.wave.api.ContainerInspectRequest; import io.seqera.wave.api.ContainerInspectResponse; import io.seqera.wave.api.ContainerLayer; +import io.seqera.wave.api.PackagesSpec; import io.seqera.wave.api.ServiceInfo; import io.seqera.wave.api.SubmitContainerTokenRequest; import io.seqera.wave.api.SubmitContainerTokenResponse; @@ -51,8 +52,11 @@ import io.seqera.wave.cli.exception.ClientConnectionException; import io.seqera.wave.cli.exception.IllegalCliArgumentException; import io.seqera.wave.cli.json.JsonHelper; +import io.seqera.wave.cli.model.ContainerInspectResponseEx; +import io.seqera.wave.cli.model.ContainerSpecEx; import io.seqera.wave.cli.util.BuildInfo; import io.seqera.wave.cli.util.CliVersionProvider; +import io.seqera.wave.cli.util.DurationConverter; import io.seqera.wave.cli.util.YamlHelper; import io.seqera.wave.config.CondaOpts; import io.seqera.wave.config.SpackOpts; @@ -62,16 +66,6 @@ import picocli.CommandLine; import static io.seqera.wave.cli.util.Checkers.isEmpty; import static io.seqera.wave.cli.util.Checkers.isEnvVar; -import static io.seqera.wave.util.DockerHelper.addPackagesToSpackFile; -import static io.seqera.wave.util.DockerHelper.condaFileFromPackages; -import static io.seqera.wave.util.DockerHelper.condaFileFromPath; -import static io.seqera.wave.util.DockerHelper.condaFileToDockerFile; -import static io.seqera.wave.util.DockerHelper.condaFileToSingularityFile; -import static io.seqera.wave.util.DockerHelper.condaPackagesToDockerFile; -import static io.seqera.wave.util.DockerHelper.condaPackagesToSingularityFile; -import static io.seqera.wave.util.DockerHelper.spackFileToDockerFile; -import static io.seqera.wave.util.DockerHelper.spackFileToSingularityFile; -import static io.seqera.wave.util.DockerHelper.spackPackagesToSpackFile; import static picocli.CommandLine.Command; import static picocli.CommandLine.Option; @@ -124,8 +118,8 @@ public class App implements Runnable { @Option(names = {"--platform"}, paramLabel = "''", description = "Platform to be used for the container build. One of: linux/amd64, linux/arm64.") private String platform; - @Option(names = {"--await"}, paramLabel = "false", description = "Await the container build to be available.") - private boolean await; + @Option(names = {"--await"}, paramLabel = "false", arity = "0..1", description = "Await the container build to be available. you can provide a timeout like --await 10m or 2s, by default its 15 minutes.") + private Duration await; @Option(names = {"--context"}, paramLabel = "''", description = "Directory path where the build context is stored e.g. /some/context/path.") private String contextDir; @@ -205,6 +199,9 @@ public static void main(String[] args) { final App app = new App(); final CommandLine cli = new CommandLine(app); + //register duration converter + cli.registerConverter(Duration.class, new DurationConverter()); + // add examples in help cli .getCommandSpec() @@ -376,7 +373,7 @@ protected void validateArgs() { throw new IllegalCliArgumentException("Context path is not a directory - offending value: " + contextDir); } - if( dryRun && await) + if( dryRun && await != null ) throw new IllegalCliArgumentException("Options --dry-run and --await conflicts each other"); if( !isEmpty(platform) && !VALID_PLATFORMS.contains(platform) ) @@ -392,8 +389,7 @@ protected SubmitContainerTokenRequest createRequest() { return new SubmitContainerTokenRequest() .withContainerImage(image) .withContainerFile(containerFileBase64()) - .withCondaFile(condaFileBase64()) - .withSpackFile(spackFileBase64()) + .withPackages(packagesSpec()) .withContainerPlatform(platform) .withTimestamp(OffsetDateTime.now()) .withBuildRepository(buildRepository) @@ -420,7 +416,8 @@ public void inspect() { ; final ContainerInspectResponse resp = client.inspect(req); - System.out.println(dumpOutput(resp)); + final ContainerSpecEx spec = new ContainerSpecEx(resp.getContainer()); + System.out.println(dumpOutput(new ContainerInspectResponseEx(spec))); } @Override @@ -437,8 +434,8 @@ public void run() { // submit it SubmitContainerTokenResponse resp = client.submit(request); // await build to be completed - if( await ) - client.awaitImage(resp.targetImage); + if( await != null && resp.buildId!=null && !resp.cached ) + client.awaitCompletion(resp.buildId, await); // print the wave container name System.out.println(dumpOutput(resp)); } @@ -576,69 +573,55 @@ private ContainerInspectRequest inspectRequest(String image) { private CondaOpts condaOpts() { return new CondaOpts() .withMambaImage(condaBaseImage) - .withCommands(condaRunCommands); + .withCommands(condaRunCommands) + ; } - protected String containerFileBase64() { - if( !isEmpty(containerFile) ) { - return encodePathBase64(containerFile); - } - - if (!isEmpty(condaFile) || !isEmpty(condaPackages)) { - String result; - final String lock = condaLock(); - if (!isEmpty(lock)) { - result = singularity - ? condaPackagesToSingularityFile(lock, condaChannels(), condaOpts()) - : condaPackagesToDockerFile(lock, condaChannels(), condaOpts()); - } else { - result = singularity - ? condaFileToSingularityFile(condaOpts()) - : condaFileToDockerFile(condaOpts()); - } - return encodeStringBase64(result); - } - - if( !isEmpty(spackFile) || spackPackages!=null ) { - final SpackOpts opts = new SpackOpts() .withCommands(spackRunCommands); - final String result = singularity - ? spackFileToSingularityFile(opts) - : spackFileToDockerFile(opts); - return encodeStringBase64(result); - } + private SpackOpts spackOpts() { + return new SpackOpts() + .withCommands(spackRunCommands); + } - return null; + protected String containerFileBase64() { + return !isEmpty(containerFile) + ? encodePathBase64(containerFile) + : null; } - protected String condaFileBase64() { - if (!isEmpty(condaFile)) { - // parse the attribute as a conda file path *and* append the base packages if any - // note 'channel' is null, because they are expected to be provided in the conda file - final Path path = condaFileFromPath(condaFile, null); - return path != null ? encodePathBase64(path.toString()) : null; + protected PackagesSpec packagesSpec() { + if( !isEmpty(condaFile) ) { + return new PackagesSpec() + .withType(PackagesSpec.Type.CONDA) + .withCondaOpts(condaOpts()) + .withEnvironment(encodePathBase64(condaFile)) + .withChannels(condaChannels()) + ; } - else if (!isEmpty(condaPackages) && isEmpty(condaLock())) { - // create a minimal conda file with package spec from user input - final String packages = condaPackages.stream().collect(Collectors.joining(" ")); - final Path path = condaFileFromPackages(packages, condaChannels()); - return path != null ? encodePathBase64(path.toString()) : null; + + if( !isEmpty(condaPackages) ) { + return new PackagesSpec() + .withType(PackagesSpec.Type.CONDA) + .withCondaOpts(condaOpts()) + .withEntries(condaPackages) + .withChannels(condaChannels()) + ; } - else - return null; - } - protected String spackFileBase64() { if( !isEmpty(spackFile) ) { - // parse the attribute as a spack file path *and* append the base packages if any - return encodePathBase64(addPackagesToSpackFile(spackFile, new SpackOpts()).toString()); + return new PackagesSpec() + .withType(PackagesSpec.Type.SPACK) + .withSpackOpts(spackOpts()) + .withEnvironment(encodePathBase64(spackFile)); } - else if( spackPackages!=null && spackPackages.size()>0 ) { - // create a minimal spack file with package spec from user input - final String packages = spackPackages.stream().collect(Collectors.joining(" ")); - return encodePathBase64(spackPackagesToSpackFile(packages, new SpackOpts()).toString()); + + if( !isEmpty(spackPackages) ) { + return new PackagesSpec() + .withType(PackagesSpec.Type.SPACK) + .withSpackOpts(spackOpts()) + .withEntries(spackPackages); } - else - return null; + + return null; } protected String dumpOutput(SubmitContainerTokenResponse resp) { @@ -651,12 +634,10 @@ protected String dumpOutput(SubmitContainerTokenResponse resp) { if( outputFormat!=null ) throw new IllegalArgumentException("Unexpected output format: "+outputFormat); - return freeze - ? resp.containerImage - : resp.targetImage; + return resp.targetImage; } - protected String dumpOutput(ContainerInspectResponse resp) { + protected String dumpOutput(ContainerInspectResponseEx resp) { if( "json".equals(outputFormat) || outputFormat==null ) { return JsonHelper.toJson(resp); } @@ -696,21 +677,6 @@ protected List condaChannels() { .collect(Collectors.toList()); } - protected String condaLock() { - if( isEmpty(condaPackages) ) - return null; - Optional result = condaPackages - .stream() - .filter(it->it.startsWith("http://") || it.startsWith("https://")) - .findFirst(); - if( !result.isPresent() ) - return null; - if( condaPackages.size()!=1 ) { - throw new IllegalCliArgumentException("No more than one Conda lock remote file can be specified at the same time"); - } - return result.get(); - } - void printInfo() { System.out.println(String.format("Client:")); System.out.println(String.format(" Version : %s", BuildInfo.getVersion())); diff --git a/app/src/main/java/io/seqera/wave/cli/Client.java b/app/src/main/java/io/seqera/wave/cli/Client.java index dbd58f7..eb89bcc 100644 --- a/app/src/main/java/io/seqera/wave/cli/Client.java +++ b/app/src/main/java/io/seqera/wave/cli/Client.java @@ -25,8 +25,10 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.function.Predicate; import dev.failsafe.Failsafe; @@ -35,6 +37,7 @@ import dev.failsafe.event.EventListener; import dev.failsafe.event.ExecutionAttemptedEvent; import dev.failsafe.function.CheckedSupplier; +import io.seqera.wave.api.BuildStatusResponse; import io.seqera.wave.api.ContainerInspectRequest; import io.seqera.wave.api.ContainerInspectResponse; import io.seqera.wave.api.ServiceInfo; @@ -112,7 +115,7 @@ ContainerInspectResponse inspect(ContainerInspectRequest request) { SubmitContainerTokenResponse submit(SubmitContainerTokenRequest request) { final String body = JsonHelper.toJson(request); - final URI uri = URI.create(endpoint + "/container-token"); + final URI uri = URI.create(endpoint + "/v1alpha2/container"); log.debug("Wave request: {} - payload: {}", uri, request); final HttpRequest req = HttpRequest.newBuilder() .uri(uri) @@ -194,24 +197,41 @@ protected URI imageToManifestUri(String image) { return URI.create(result); } - protected void awaitImage(String image) { - final URI manifest = imageToManifestUri(image); + void awaitCompletion(String buildId, Duration await) { + log.debug("Waiting for build completion: {} - timeout: {} Seconds", buildId, await.toSeconds()); + final long startTime = Instant.now().toEpochMilli(); + while (!isComplete(buildId)) { + if (System.currentTimeMillis() - startTime > await.toMillis()) { + break; + } + } + } + + protected boolean isComplete(String buildId) { + final String statusEndpoint = endpoint + "/v1alpha1/builds/"+buildId+"/status"; final HttpRequest req = HttpRequest.newBuilder() - .uri(manifest) - .headers(REQUEST_HEADERS) - .timeout(Duration.ofMinutes(5)) + .uri(URI.create(statusEndpoint)) + .headers("Content-Type","application/json") .GET() .build(); - final long begin = System.currentTimeMillis(); - final HttpResponse resp = httpSend(req); - final int code = resp.statusCode(); - if( code>=200 && code<400 ) { - final long delta = System.currentTimeMillis()-begin; - log.debug("Wave container available in {} [{}] {}", delta, code, resp.body()); + + try { + //interval of 10 seconds + TimeUnit.SECONDS.sleep(10); + + final HttpResponse resp = httpSend(req); + log.debug("Wave response: statusCode={}; body={}", resp.statusCode(), resp.body()); + if( resp.statusCode()==200 ) { + BuildStatusResponse result = JsonHelper.fromJson(resp.body(), BuildStatusResponse.class); + return result.status == BuildStatusResponse.Status.COMPLETED; + } + else { + String msg = String.format("Wave invalid response: [%s] %s", resp.statusCode(), resp.body()); + throw new BadClientResponseException(msg); + } } - else { - String message = String.format("Unexpected response for '%s': [%d] %s", manifest, resp.statusCode(), resp.body()); - throw new IllegalStateException(message); + catch (IOException | FailsafeException | InterruptedException e) { + throw new ClientConnectionException("Unable to connect Wave service: " + endpoint, e); } } diff --git a/app/src/main/java/io/seqera/wave/cli/json/JsonHelper.java b/app/src/main/java/io/seqera/wave/cli/json/JsonHelper.java index 2cba8fb..60b9183 100644 --- a/app/src/main/java/io/seqera/wave/cli/json/JsonHelper.java +++ b/app/src/main/java/io/seqera/wave/cli/json/JsonHelper.java @@ -22,9 +22,9 @@ import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; import io.seqera.wave.api.ContainerInspectRequest; -import io.seqera.wave.api.ContainerInspectResponse; import io.seqera.wave.api.SubmitContainerTokenRequest; import io.seqera.wave.api.SubmitContainerTokenResponse; +import io.seqera.wave.cli.model.ContainerInspectResponseEx; /** * Helper class to encode and decode JSON payloads @@ -54,8 +54,8 @@ public static String toJson(ContainerInspectRequest request) { return adapter.toJson(request); } - public static String toJson(ContainerInspectResponse response) { - JsonAdapter adapter = moshi.adapter(ContainerInspectResponse.class); + public static String toJson(ContainerInspectResponseEx response) { + JsonAdapter adapter = moshi.adapter(ContainerInspectResponseEx.class); return adapter.toJson(response); } diff --git a/app/src/main/java/io/seqera/wave/cli/model/ContainerInspectResponseEx.java b/app/src/main/java/io/seqera/wave/cli/model/ContainerInspectResponseEx.java new file mode 100644 index 0000000..446c210 --- /dev/null +++ b/app/src/main/java/io/seqera/wave/cli/model/ContainerInspectResponseEx.java @@ -0,0 +1,35 @@ +/* + * Copyright 2023, Seqera Labs + * + * 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 io.seqera.wave.cli.model; + +import io.seqera.wave.api.ContainerInspectResponse; +import io.seqera.wave.core.spec.ContainerSpec; + +/** + * @author Paolo Di Tommaso + */ +public class ContainerInspectResponseEx extends ContainerInspectResponse { + + public ContainerInspectResponseEx(ContainerInspectResponse response) { + super(new ContainerSpecEx(response.getContainer())); + } + + public ContainerInspectResponseEx(ContainerSpec spec) { + super(new ContainerSpecEx(spec)); + } +} diff --git a/app/src/main/java/io/seqera/wave/cli/model/ContainerSpecEx.java b/app/src/main/java/io/seqera/wave/cli/model/ContainerSpecEx.java new file mode 100644 index 0000000..27bf60e --- /dev/null +++ b/app/src/main/java/io/seqera/wave/cli/model/ContainerSpecEx.java @@ -0,0 +1,44 @@ +/* + * Copyright 2023, Seqera Labs + * + * 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 io.seqera.wave.cli.model; + +import java.util.List; + +import io.seqera.wave.core.spec.ContainerSpec; +import io.seqera.wave.core.spec.ObjectRef; + +/** + * Wrapper for {@link ContainerSpec} that replaces + * {@link ObjectRef} with {@link LayerRef} objects + * + * @author Paolo Di Tommaso + */ +public class ContainerSpecEx extends ContainerSpec { + public ContainerSpecEx(ContainerSpec spec) { + super(spec); + // update the layers uri + if( spec.getManifest()!=null && spec.getManifest().getLayers()!=null ) { + List layers = spec.getManifest().getLayers(); + for( int i=0; i + */ +public class LayerRef extends ObjectRef { + + final public String uri; + + public LayerRef(ObjectRef obj, String uri) { + super(obj); + this.uri = uri; + } + +} diff --git a/app/src/main/java/io/seqera/wave/cli/util/DurationConverter.java b/app/src/main/java/io/seqera/wave/cli/util/DurationConverter.java new file mode 100644 index 0000000..79ab74b --- /dev/null +++ b/app/src/main/java/io/seqera/wave/cli/util/DurationConverter.java @@ -0,0 +1,38 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +package io.seqera.wave.cli.util; + +import picocli.CommandLine; + +import java.time.Duration; +/** + * Converter to convert cli argument to duration + * + * @author Munish Chouhan + */ +public class DurationConverter implements CommandLine.ITypeConverter { + @Override + public Duration convert(String value) { + if (value == null || value.trim().isEmpty()) { + return Duration.ofMinutes(15); + } + return Duration.parse("PT" + value.toUpperCase()); + } +} \ No newline at end of file diff --git a/app/src/main/java/io/seqera/wave/cli/util/YamlHelper.java b/app/src/main/java/io/seqera/wave/cli/util/YamlHelper.java index d7c7eeb..9a5b8ef 100644 --- a/app/src/main/java/io/seqera/wave/cli/util/YamlHelper.java +++ b/app/src/main/java/io/seqera/wave/cli/util/YamlHelper.java @@ -21,6 +21,9 @@ import io.seqera.wave.api.ContainerInspectResponse; import io.seqera.wave.api.SubmitContainerTokenResponse; +import io.seqera.wave.cli.model.ContainerInspectResponseEx; +import io.seqera.wave.cli.model.ContainerSpecEx; +import io.seqera.wave.cli.model.LayerRef; import io.seqera.wave.core.spec.ConfigSpec; import io.seqera.wave.core.spec.ContainerSpec; import io.seqera.wave.core.spec.ManifestSpec; @@ -51,7 +54,7 @@ public static String toYaml(SubmitContainerTokenResponse resp) { return yaml.dump(resp); } - public static String toYaml(ContainerInspectResponse resp) { + public static String toYaml(ContainerInspectResponseEx resp) { final DumperOptions opts = new DumperOptions(); opts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); opts.setAllowReadOnlyProperties(true); @@ -59,9 +62,12 @@ public static String toYaml(ContainerInspectResponse resp) { final Representer representer = new Representer(opts) { { addClassTag(ContainerSpec.class, Tag.MAP); + addClassTag(ContainerSpecEx.class, Tag.MAP); addClassTag(ConfigSpec.class, Tag.MAP); addClassTag(ManifestSpec.class, Tag.MAP); addClassTag(ContainerInspectResponse.class, Tag.MAP); + addClassTag(ContainerInspectResponseEx.class, Tag.MAP); + addClassTag(LayerRef.class, Tag.MAP); representers.put(Instant.class, data -> representScalar(Tag.STR, data.toString())); } }; diff --git a/app/src/test/groovy/io/seqera/wave/cli/AppCondaOptsTest.groovy b/app/src/test/groovy/io/seqera/wave/cli/AppCondaOptsTest.groovy index 1c54c8a..51b5e78 100644 --- a/app/src/test/groovy/io/seqera/wave/cli/AppCondaOptsTest.groovy +++ b/app/src/test/groovy/io/seqera/wave/cli/AppCondaOptsTest.groovy @@ -19,7 +19,9 @@ package io.seqera.wave.cli import java.nio.file.Files +import io.seqera.wave.api.PackagesSpec import io.seqera.wave.cli.exception.IllegalCliArgumentException +import io.seqera.wave.config.CondaOpts import picocli.CommandLine import spock.lang.Specification /** @@ -129,17 +131,21 @@ class AppCondaOptsTest extends Specification { and: def req = app.createRequest() then: - new String(req.containerFile.decodeBase64()) == '''\ - FROM mambaorg/micromamba:1.5.5 - COPY --chown=$MAMBA_USER:$MAMBA_USER conda.yml /tmp/conda.yml - RUN micromamba install -y -n base -f /tmp/conda.yml \\ - && micromamba install -y -n base conda-forge::procps-ng \\ - && micromamba clean -a -y - USER root - ENV PATH="$MAMBA_ROOT_PREFIX/bin:$PATH" - '''.stripIndent() + req.packages.type == PackagesSpec.Type.CONDA and: - new String(req.condaFile.decodeBase64()) == CONDA_RECIPE + new String(req.packages.environment.decodeBase64()) == ''' + name: my-recipe + dependencies: + - one=1.0 + - two:2.0 + '''.stripIndent(true) + and: + req.packages.condaOpts == new CondaOpts(mambaImage: CondaOpts.DEFAULT_MAMBA_IMAGE, basePackages: CondaOpts.DEFAULT_PACKAGES) + req.packages.channels == ['seqera', 'conda-forge', 'bioconda', 'defaults'] + and: + !req.packages.entries + and: + !req.condaFile cleanup: folder?.deleteDir() @@ -156,165 +162,42 @@ class AppCondaOptsTest extends Specification { and: def req = app.createRequest() then: - new String(req.containerFile.decodeBase64()) == '''\ - FROM mambaorg/micromamba:1.5.5 - COPY --chown=$MAMBA_USER:$MAMBA_USER conda.yml /tmp/conda.yml - RUN micromamba install -y -n base -f /tmp/conda.yml \\ - && micromamba install -y -n base conda-forge::procps-ng \\ - && micromamba clean -a -y - USER root - ENV PATH="$MAMBA_ROOT_PREFIX/bin:$PATH" - '''.stripIndent() - and: - new String(req.condaFile.decodeBase64()) == '''\ - channels: - - seqera - - conda-forge - - bioconda - - defaults - dependencies: - - foo - '''.stripIndent(true) - } - - def 'should create docker env from conda lock file' () { - given: - def app = new App() - String[] args = ["--conda-package", "https://host.com/file-lock.yml"] - - when: - new CommandLine(app).parseArgs(args) - and: - def req = app.createRequest() - then: - new String(req.containerFile.decodeBase64()) == '''\ - FROM mambaorg/micromamba:1.5.5 - RUN \\ - micromamba install -y -n base -c seqera -c conda-forge -c bioconda -c defaults -f https://host.com/file-lock.yml \\ - && micromamba install -y -n base conda-forge::procps-ng \\ - && micromamba clean -a -y - USER root - ENV PATH="$MAMBA_ROOT_PREFIX/bin:$PATH" - '''.stripIndent() + req.packages.type == PackagesSpec.Type.CONDA + req.packages.entries == ['foo'] and: - req.condaFile == null - } - - def 'should create docker file from conda package and custom options' () { - given: - def app = new App() - String[] args = [ - "--conda-package", "foo", - "--conda-package", "bar", - "--conda-base-image", "my/mamba:latest", - "--conda-channels", "alpha,beta", - "--conda-run-command", "RUN one", - "--conda-run-command", "RUN two", - - ] - - when: - new CommandLine(app).parseArgs(args) + req.packages.condaOpts == new CondaOpts(mambaImage: CondaOpts.DEFAULT_MAMBA_IMAGE, basePackages: CondaOpts.DEFAULT_PACKAGES) + req.packages.channels == ['seqera', 'conda-forge', 'bioconda', 'defaults'] and: - def req = app.createRequest() - then: - new String(req.containerFile.decodeBase64()) == '''\ - FROM my/mamba:latest - COPY --chown=$MAMBA_USER:$MAMBA_USER conda.yml /tmp/conda.yml - RUN micromamba install -y -n base -f /tmp/conda.yml \\ - && micromamba install -y -n base conda-forge::procps-ng \\ - && micromamba clean -a -y - USER root - ENV PATH="$MAMBA_ROOT_PREFIX/bin:$PATH" - RUN one - RUN two - '''.stripIndent() - + !req.packages.environment and: - new String(req.condaFile.decodeBase64()) == '''\ - channels: - - alpha - - beta - dependencies: - - foo - - bar - '''.stripIndent(true) + !req.condaFile } - - def 'should create singularity file from conda file' () { + def 'should create docker env from conda lock file' () { given: - def folder = Files.createTempDirectory('test') - def condaFile = folder.resolve('conda.yml'); - condaFile.text = 'MY CONDA FILE' - and: def app = new App() - String[] args = ['--singularity', "--conda-file", condaFile.toString()] + String[] args = ["--conda-package", "https://host.com/file-lock.yml"] when: new CommandLine(app).parseArgs(args) and: def req = app.createRequest() then: - new String(req.containerFile.decodeBase64()) == '''\ - BootStrap: docker - From: mambaorg/micromamba:1.5.5 - %files - {{wave_context_dir}}/conda.yml /scratch/conda.yml - %post - micromamba install -y -n base -f /scratch/conda.yml - micromamba install -y -n base conda-forge::procps-ng - micromamba clean -a -y - %environment - export PATH="$MAMBA_ROOT_PREFIX/bin:$PATH" - '''.stripIndent() + req.packages.type == PackagesSpec.Type.CONDA + req.packages.entries == ['https://host.com/file-lock.yml'] and: - new String(req.condaFile.decodeBase64()) == 'MY CONDA FILE' - - cleanup: - folder?.deleteDir() - } - - - def 'should create singularity file from conda package' () { - given: - def app = new App() - String[] args = ['--singularity', "--conda-package", "foo"] - - when: - new CommandLine(app).parseArgs(args) + req.packages.condaOpts == new CondaOpts(mambaImage: CondaOpts.DEFAULT_MAMBA_IMAGE, basePackages: CondaOpts.DEFAULT_PACKAGES) + req.packages.channels == ['seqera', 'conda-forge', 'bioconda', 'defaults'] and: - def req = app.createRequest() - then: - new String(req.containerFile.decodeBase64()) == '''\ - BootStrap: docker - From: mambaorg/micromamba:1.5.5 - %files - {{wave_context_dir}}/conda.yml /scratch/conda.yml - %post - micromamba install -y -n base -f /scratch/conda.yml - micromamba install -y -n base conda-forge::procps-ng - micromamba clean -a -y - %environment - export PATH="$MAMBA_ROOT_PREFIX/bin:$PATH" - '''.stripIndent() + !req.packages.environment and: - new String(req.condaFile.decodeBase64()) == '''\ - channels: - - seqera - - conda-forge - - bioconda - - defaults - dependencies: - - foo - '''.stripIndent(true) + !req.condaFile } - def 'should create singularity file from conda package and custom options' () { + def 'should create docker file from conda package and custom options' () { given: def app = new App() String[] args = [ - '--singularity', "--conda-package", "foo", "--conda-package", "bar", "--conda-base-image", "my/mamba:latest", @@ -328,75 +211,15 @@ class AppCondaOptsTest extends Specification { and: def req = app.createRequest() then: - new String(req.containerFile.decodeBase64()) == '''\ - BootStrap: docker - From: my/mamba:latest - %files - {{wave_context_dir}}/conda.yml /scratch/conda.yml - %post - micromamba install -y -n base -f /scratch/conda.yml - micromamba install -y -n base conda-forge::procps-ng - micromamba clean -a -y - %environment - export PATH="$MAMBA_ROOT_PREFIX/bin:$PATH" - %post - RUN one - RUN two - '''.stripIndent(true) - - and: - new String(req.condaFile.decodeBase64()) == '''\ - channels: - - alpha - - beta - dependencies: - - foo - - bar - '''.stripIndent(true) - } - - def 'should create singularity file from conda lock file' () { - given: - def app = new App() - String[] args = ["--conda-package", "https://host.com/file-lock.yml", '--singularity'] - - when: - new CommandLine(app).parseArgs(args) - and: - def req = app.createRequest() - then: - new String(req.containerFile.decodeBase64()) == '''\ - BootStrap: docker - From: mambaorg/micromamba:1.5.5 - %post - micromamba install -y -n base -c seqera -c conda-forge -c bioconda -c defaults -f https://host.com/file-lock.yml - micromamba install -y -n base conda-forge::procps-ng - micromamba clean -a -y - %environment - export PATH="$MAMBA_ROOT_PREFIX/bin:$PATH" - '''.stripIndent() + req.packages.type == PackagesSpec.Type.CONDA + req.packages.entries == ['foo','bar'] + req.packages.channels == ['alpha','beta'] and: - req.condaFile == null - } - - - def 'should get conda lock file' () { - expect: - new App(condaPackages: ['https://foo.com/lock.yml']) - .condaLock() == 'https://foo.com/lock.yml' - + req.packages.condaOpts == new CondaOpts(mambaImage: 'my/mamba:latest', basePackages: CondaOpts.DEFAULT_PACKAGES, commands: ['RUN one','RUN two']) and: - new App(condaPackages: ['foo', 'bar']) - .condaLock() == null - + !req.packages.environment and: - new App(condaPackages: null) - .condaLock() == null - - when: - new App(condaPackages: ['foo', 'http://foo.com']) .condaLock() - then: - thrown(IllegalCliArgumentException) + !req.condaFile } diff --git a/app/src/test/groovy/io/seqera/wave/cli/AppConfigOptsTest.groovy b/app/src/test/groovy/io/seqera/wave/cli/AppConfigOptsTest.groovy index 0d208c0..c7889a1 100644 --- a/app/src/test/groovy/io/seqera/wave/cli/AppConfigOptsTest.groovy +++ b/app/src/test/groovy/io/seqera/wave/cli/AppConfigOptsTest.groovy @@ -19,14 +19,13 @@ package io.seqera.wave.cli import java.nio.file.Files -import com.github.tomakehurst.wiremock.WireMockServer -import com.github.tomakehurst.wiremock.client.WireMock -import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler +import com.sun.net.httpserver.HttpServer import io.seqera.wave.api.ContainerConfig import io.seqera.wave.cli.exception.IllegalCliArgumentException import picocli.CommandLine import spock.lang.Specification - /** * Test App config prefixed options * @@ -49,26 +48,6 @@ class AppConfigOptsTest extends Specification { } ''' - WireMockServer wireMockServer - def setup() { - wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().port(8080)) - wireMockServer.start() - - WireMock.stubFor( - WireMock.get(WireMock.urlEqualTo("/api/data")) - .willReturn( - WireMock.aResponse() - .withStatus(200) - .withHeader("Content-Type", "application/json") - .withBody(CONFIG_JSON) - ) - ) - } - - def cleanup() { - wireMockServer.stop() - } - def "test valid entrypoint"() { given: @@ -218,13 +197,27 @@ class AppConfigOptsTest extends Specification { def "test valid config file from a URL"() { given: + HttpHandler handler = { HttpExchange exchange -> + String body = CONFIG_JSON + exchange.getResponseHeaders().add("Content-Type", "text/json") + exchange.sendResponseHeaders(200, body.size()) + exchange.getResponseBody() << body + exchange.getResponseBody().close() + + } + + HttpServer server = HttpServer.create(new InetSocketAddress(9901), 0); + server.createContext("/", handler); + server.start() + + def app = new App() - String[] args = ["--config-file", "http://localhost:8080/api/data"] + String[] args = ["--config-file", "http://localhost:9901/api/data"] when: new CommandLine(app).parseArgs(args) then: - app.@configFile == "http://localhost:8080/api/data" + app.@configFile == "http://localhost:9901/api/data" when: def config = app.prepareConfig() @@ -235,5 +228,8 @@ class AppConfigOptsTest extends Specification { layer.gzipDigest == "sha256:gzipDigest" layer.tarDigest == "sha256:tarDigest" layer.gzipSize == 100 + + cleanup: + server?.stop(0) } } diff --git a/app/src/test/groovy/io/seqera/wave/cli/AppSpackOptsTest.groovy b/app/src/test/groovy/io/seqera/wave/cli/AppSpackOptsTest.groovy index d2a6d60..d51ed54 100644 --- a/app/src/test/groovy/io/seqera/wave/cli/AppSpackOptsTest.groovy +++ b/app/src/test/groovy/io/seqera/wave/cli/AppSpackOptsTest.groovy @@ -19,7 +19,9 @@ package io.seqera.wave.cli import java.nio.file.Files +import io.seqera.wave.api.PackagesSpec import io.seqera.wave.cli.exception.IllegalCliArgumentException +import io.seqera.wave.config.SpackOpts import picocli.CommandLine import spock.lang.Specification /** @@ -128,10 +130,19 @@ class AppSpackOptsTest extends Specification { and: def req = app.createRequest() then: - new String(req.containerFile.decodeBase64()).startsWith("# Runner image") - + req.packages.type == PackagesSpec.Type.SPACK + and: + new String(req.packages.environment.decodeBase64()) == SPACK_FILE + and: + req.packages.spackOpts == new SpackOpts() + and: + !req.packages.condaOpts + !req.packages.channels + !req.packages.entries + !req.packages.channels and: - new String(req.spackFile.decodeBase64()) == SPACK_FILE + !req.spackFile + !req.containerFile cleanup: folder?.deleteDir() @@ -148,17 +159,18 @@ class AppSpackOptsTest extends Specification { and: def req = app.createRequest() then: - def spec = new String(req.containerFile.decodeBase64()).tokenize('\n') - spec[0] == '# Runner image' - spec[1] == 'FROM {{spack_runner_image}}' - spec[2] == 'COPY --from=builder /opt/spack-env /opt/spack-env' - + req.packages.type == PackagesSpec.Type.SPACK + req.packages.entries == ['foo'] and: - new String(req.spackFile.decodeBase64()) == '''\ - spack: - specs: [foo] - concretizer: {unify: true, reuse: false} - '''.stripIndent(true) + !req.packages.environment + and: + req.packages.spackOpts == new SpackOpts() + and: + !req.packages.condaOpts + !req.packages.channels + and: + !req.spackFile + !req.containerFile } def 'should create container file from spack package and custom options' () { @@ -176,39 +188,19 @@ class AppSpackOptsTest extends Specification { and: def req = app.createRequest() then: - new String(req.containerFile.decodeBase64()).startsWith("# Runner image") - new String(req.containerFile.decodeBase64()).contains("RUN one\n") - new String(req.containerFile.decodeBase64()).contains("RUN two\n") - + req.packages.type == PackagesSpec.Type.SPACK + req.packages.entries == ['foo','bar'] and: - new String(req.spackFile.decodeBase64()) == '''\ - spack: - specs: [foo, bar] - concretizer: {unify: true, reuse: false} - '''.stripIndent(true) - } - - def 'should create container file from spack package with singularity' () { - given: - def app = new App() - String[] args = ["--spack-package", "foo", "--singularity"] - - when: - new CommandLine(app).parseArgs(args) + !req.packages.environment and: - def req = app.createRequest() - then: - def spec = new String(req.containerFile.decodeBase64()).tokenize('\n') - spec[0] == 'Bootstrap: docker' - spec[1] == 'From: {{spack_runner_image}}' - spec[2] == 'stage: final' - + req.packages.spackOpts == new SpackOpts(commands: ['RUN one','RUN two']) and: - new String(req.spackFile.decodeBase64()) == '''\ - spack: - specs: [foo] - concretizer: {unify: true, reuse: false} - '''.stripIndent(true) + !req.packages.condaOpts + !req.packages.channels + !req.packages.channels + and: + !req.spackFile + !req.containerFile } } diff --git a/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy b/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy index 8cd647d..eeb5c9f 100644 --- a/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy +++ b/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy @@ -17,14 +17,17 @@ package io.seqera.wave.cli +import io.seqera.wave.cli.util.DurationConverter + import java.nio.file.Files +import java.time.Duration import java.time.Instant -import io.seqera.wave.api.ContainerInspectResponse import io.seqera.wave.api.SubmitContainerTokenResponse +import io.seqera.wave.cli.exception.IllegalCliArgumentException +import io.seqera.wave.cli.model.ContainerInspectResponseEx import io.seqera.wave.core.spec.ContainerSpec import io.seqera.wave.util.TarUtils -import io.seqera.wave.cli.exception.IllegalCliArgumentException import picocli.CommandLine import spock.lang.Specification import spock.lang.Unroll @@ -59,7 +62,8 @@ class AppTest extends Specification { targetImage: 'docker.io/some/repo', containerImage: 'docker.io/some/container', expiration: Instant.ofEpochMilli(1691839913), - buildId: '98765' + buildId: '98765', + cached: true ) when: @@ -68,9 +72,11 @@ class AppTest extends Specification { then: result == '''\ buildId: '98765' + cached: true containerImage: docker.io/some/container containerToken: '12345' expiration: '1970-01-20T13:57:19.913Z' + freeze: null targetImage: docker.io/some/repo '''.stripIndent(true) } @@ -100,13 +106,13 @@ class AppTest extends Specification { def app = new App() String[] args = ["--output", "json"] and: - def resp = new ContainerInspectResponse( new ContainerSpec('docker.io', 'busybox', 'latest', 'sha:12345', null, null, null) ) + def resp = new ContainerInspectResponseEx( new ContainerSpec('docker.io', 'https://docker.io', 'busybox', 'latest', 'sha:12345', null, null) ) when: new CommandLine(app).parseArgs(args) def result = app.dumpOutput(resp) then: - result == '{"container":{"digest":"sha:12345","imageName":"busybox","reference":"latest","registry":"docker.io"}}' + result == '{"container":{"digest":"sha:12345","hostName":"https://docker.io","imageName":"busybox","reference":"latest","registry":"docker.io"}}' } def 'should dump inspect to yaml' () { @@ -114,7 +120,7 @@ class AppTest extends Specification { def app = new App() String[] args = ["--output", "yaml"] and: - def resp = new ContainerInspectResponse( new ContainerSpec('docker.io', 'busybox', 'latest', 'sha:12345', null, null, null) ) + def resp = new ContainerInspectResponseEx( new ContainerSpec('docker.io', 'https://docker.io', 'busybox', 'latest', 'sha:12345', null, null) ) when: new CommandLine(app).parseArgs(args) @@ -124,6 +130,7 @@ class AppTest extends Specification { container: config: null digest: sha:12345 + hostName: https://docker.io imageName: busybox manifest: null reference: latest @@ -188,7 +195,9 @@ class AppTest extends Specification { String[] args = ["-i", "ubuntu:latest","--dry-run", '--await'] when: - new CommandLine(app).parseArgs(args) + def cli = new CommandLine(app) + cli.registerConverter(Duration.class, new DurationConverter()) + cli.parseArgs(args) and: app.validateArgs() then: @@ -269,4 +278,54 @@ class AppTest extends Specification { app.@towerToken == 'xyz' } + def 'should get the correct await duration in minutes'(){ + given: + def app = new App() + String[] args = ["-i", "ubuntu:latest", '--await', '10m'] + + when: + def cli = new CommandLine(app) + cli.registerConverter(Duration.class, new DurationConverter()) + cli.parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + and: + app.@await == Duration.ofMinutes(10) + } + + def 'should get the correct await duration in seconds'(){ + given: + def app = new App() + String[] args = ["-i", "ubuntu:latest", '--await', '10s'] + + when: + def cli = new CommandLine(app) + cli.registerConverter(Duration.class, new DurationConverter()) + cli.parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + and: + app.@await == Duration.ofSeconds(10) + } + + def 'should get the default await duration'(){ + given: + def app = new App() + String[] args = ["-i", "ubuntu:latest", '--await'] + + when: + def cli = new CommandLine(app) + cli.registerConverter(Duration.class, new DurationConverter()) + cli.parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + and: + app.@await == Duration.ofMinutes(15) + } } diff --git a/app/src/test/groovy/io/seqera/wave/cli/json/JsonHelperTest.groovy b/app/src/test/groovy/io/seqera/wave/cli/json/JsonHelperTest.groovy index 5fed98e..4785e74 100644 --- a/app/src/test/groovy/io/seqera/wave/cli/json/JsonHelperTest.groovy +++ b/app/src/test/groovy/io/seqera/wave/cli/json/JsonHelperTest.groovy @@ -18,8 +18,9 @@ package io.seqera.wave.cli.json import io.seqera.wave.api.SubmitContainerTokenRequest -import spock.lang.Specification; - +import io.seqera.wave.cli.model.ContainerInspectResponseEx +import io.seqera.wave.core.spec.ContainerSpec +import spock.lang.Specification /** * @author Paolo Di Tommaso */ @@ -43,5 +44,15 @@ class JsonHelperTest extends Specification { result.containerImage == 'quay.io/nextflow/bash:latest' } + def 'should convert response to json' () { + given: + def spec = new ContainerSpec('docker.io', 'https://docker.io', 'ubuntu', '22.04', 'sha:12345', null, null) + def resp = new ContainerInspectResponseEx(spec) + + when: + def result = JsonHelper.toJson(resp) + then: + result == '{"container":{"digest":"sha:12345","hostName":"https://docker.io","imageName":"ubuntu","reference":"22.04","registry":"docker.io"}}' + } } diff --git a/app/src/test/groovy/io/seqera/wave/cli/util/YamlHelperTest.groovy b/app/src/test/groovy/io/seqera/wave/cli/util/YamlHelperTest.groovy index 5aa8879..94ea941 100644 --- a/app/src/test/groovy/io/seqera/wave/cli/util/YamlHelperTest.groovy +++ b/app/src/test/groovy/io/seqera/wave/cli/util/YamlHelperTest.groovy @@ -17,12 +17,13 @@ package io.seqera.wave.cli.util - import java.time.Instant -import io.seqera.wave.api.ContainerInspectResponse import io.seqera.wave.api.SubmitContainerTokenResponse +import io.seqera.wave.cli.model.ContainerInspectResponseEx import io.seqera.wave.core.spec.ContainerSpec +import io.seqera.wave.core.spec.ManifestSpec +import io.seqera.wave.core.spec.ObjectRef import spock.lang.Specification /** * @@ -37,7 +38,9 @@ class YamlHelperTest extends Specification { targetImage: 'docker.io/some/repo', containerImage: 'docker.io/some/container', expiration: Instant.ofEpochMilli(1691839913), - buildId: '98765' + buildId: '98765', + cached: false, + freeze: false ) when: @@ -45,17 +48,21 @@ class YamlHelperTest extends Specification { then: result == '''\ buildId: '98765' + cached: false containerImage: docker.io/some/container containerToken: '12345' expiration: '1970-01-20T13:57:19.913Z' + freeze: false targetImage: docker.io/some/repo '''.stripIndent(true) } def 'should convert response to yaml' () { given: - def spec = new ContainerSpec('docker.io','ubuntu','22.04','sha:12345', null, null, null) - def resp = new ContainerInspectResponse(spec) + def layers = [ new ObjectRef('text', 'sha256:12345', 100, null), new ObjectRef('text', 'sha256:67890', 200, null) ] + def manifest = new ManifestSpec(2, 'some/media', null, layers, [one: '1', two:'2']) + def spec = new ContainerSpec('docker.io', 'https://docker.io', 'ubuntu','22.04','sha:12345', null, manifest) + def resp = new ContainerInspectResponseEx(spec) when: def result = YamlHelper.toYaml(resp) @@ -64,8 +71,26 @@ class YamlHelperTest extends Specification { container: config: null digest: sha:12345 + hostName: https://docker.io imageName: ubuntu - manifest: null + manifest: + annotations: + one: '1' + two: '2' + config: null + layers: + - annotations: null + digest: sha256:12345 + mediaType: text + size: 100 + uri: https://docker.io/v2/ubuntu/blobs/sha256:12345 + - annotations: null + digest: sha256:67890 + mediaType: text + size: 200 + uri: https://docker.io/v2/ubuntu/blobs/sha256:67890 + mediaType: some/media + schemaVersion: 2 reference: '22.04' registry: docker.io '''.stripIndent(true)