diff --git a/app/build.gradle b/app/build.gradle index 8a0e673..65ed631 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,6 +37,7 @@ 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/src/main/java/io/seqera/wavelit/App.java b/app/src/main/java/io/seqera/wavelit/App.java index e71e708..efe2230 100644 --- a/app/src/main/java/io/seqera/wavelit/App.java +++ b/app/src/main/java/io/seqera/wavelit/App.java @@ -16,8 +16,10 @@ import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; +import java.net.URL; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; @@ -47,12 +49,11 @@ import picocli.CommandLine; import static io.seqera.wave.util.DockerHelper.addPackagesToSpackFile; import static io.seqera.wave.util.DockerHelper.spackPackagesToSpackFile; +import static io.seqera.wavelit.util.Checkers.isEnvVar; import static org.apache.commons.lang3.StringUtils.isEmpty; import static picocli.CommandLine.Command; import static picocli.CommandLine.Option; -import static io.seqera.wavelit.util.Checkers.isEnvVar; - /** * Wavelit main class */ @@ -111,6 +112,9 @@ public class App implements Runnable { @Option(names = {"--config-entrypoint"}, paramLabel = "''", description = "Overwrite the default ENTRYPOINT of the image.") private String entrypoint; + @Option(names = {"--config-file"}, paramLabel = "''", description = "Configuration file in JSON format to overwrite the default configuration of the image.") + private String configFile; + @Option(names = {"--config-working-dir"}, paramLabel = "''", description = "Overwrite the default WORKDIR of the image e.g. /some/work/dir.") private String workingDir; @@ -340,7 +344,9 @@ private String encodePathBase64(String value) { return null; // read the text from a URI resource and encode to base64 if( value.startsWith("file:/") || value.startsWith("http://") || value.startsWith("https://")) { - return Base64.getEncoder().encodeToString(Files.readAllBytes(Path.of(new URI(value)))); + try(InputStream stream=new URI(value).toURL().openStream()) { + return Base64.getEncoder().encodeToString(stream.readAllBytes()); + } } // read the text from a local file and encode to base64 return Base64.getEncoder().encodeToString(Files.readAllBytes(Path.of(value))); @@ -380,7 +386,13 @@ protected BuildContext prepareContext() { } protected ContainerConfig prepareConfig() { - final ContainerConfig result = new ContainerConfig(); + ContainerConfig result = new ContainerConfig(); + + // add configuration from config file if specified + if( configFile != null ){ + if( "".equals(configFile.trim()) ) throw new IllegalCliArgumentException("The specified config file is an empty string"); + result = readConfig(configFile); + } // add the entrypoint if specified if( entrypoint!=null ) @@ -490,4 +502,25 @@ protected String dumpOutput(SubmitContainerTokenResponse resp) { ? resp.containerImage : resp.targetImage; } + + protected ContainerConfig readConfig(String path) { + try { + if( path.startsWith("http://") || path.startsWith("https://") || path.startsWith("file:/")) { + try (InputStream stream=new URL(path).openStream()) { + return JsonHelper.fromJson(new String(stream.readAllBytes()), ContainerConfig.class); + } + } + else { + return JsonHelper.fromJson(Files.readString(Path.of(path)), ContainerConfig.class); + } + } + catch (FileNotFoundException | NoSuchFileException e) { + throw new IllegalCliArgumentException("Invalid container config file - File not found: " + path); + } + catch (IOException e) { + String msg = String.format("Unable to read container config file: %s - Cause: %s", path, e.getMessage()); + throw new IllegalCliArgumentException(msg, e); + } + } + } diff --git a/app/src/test/groovy/io/seqera/wavelit/AppCondaOptsTest.groovy b/app/src/test/groovy/io/seqera/wavelit/AppCondaOptsTest.groovy index 67d873e..ecce35d 100644 --- a/app/src/test/groovy/io/seqera/wavelit/AppCondaOptsTest.groovy +++ b/app/src/test/groovy/io/seqera/wavelit/AppCondaOptsTest.groovy @@ -17,6 +17,7 @@ import io.seqera.wavelit.exception.IllegalCliArgumentException import picocli.CommandLine import spock.lang.Specification /** + * Test App Conda prefixed options * * @author Paolo Di Tommaso */ diff --git a/app/src/test/groovy/io/seqera/wavelit/AppConfigOptsTest.groovy b/app/src/test/groovy/io/seqera/wavelit/AppConfigOptsTest.groovy new file mode 100644 index 0000000..181e338 --- /dev/null +++ b/app/src/test/groovy/io/seqera/wavelit/AppConfigOptsTest.groovy @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2023, Seqera Labs. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * This Source Code Form is "Incompatible With Secondary Licenses", as + * defined by the Mozilla Public License, v. 2.0. + */ + +package io.seqera.wavelit + +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 io.seqera.wave.api.ContainerConfig +import io.seqera.wavelit.exception.IllegalCliArgumentException +import picocli.CommandLine +import spock.lang.Specification + +/** + * Test App config prefixed options + * + * @author Paolo Di Tommaso + */ +class AppConfigOptsTest extends Specification { + + def CONFIG_JSON = '''\ + { + "entrypoint": [ "/some", "--entrypoint" ], + "layers": [ + { + "location": "https://location", + "gzipDigest": "sha256:gzipDigest", + "gzipSize": 100, + "tarDigest": "sha256:tarDigest", + "skipHashing": true + } + ] + } + ''' + + 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: + def app = new App() + String[] args = ["--config-entrypoint", "entryPoint"] + def cli = new CommandLine(app) + + when: + cli.parseArgs(args) + then: + app.@entrypoint == "entryPoint" + + when: + def config = app.prepareConfig() + then: + config == new ContainerConfig(entrypoint: ['entryPoint']) + } + + def "test invalid entrypoint"() { + given: + def app = new App() + String[] args = ["--config-entrypoint"] + + when: + new CommandLine(app).parseArgs(args) + + then: + thrown(CommandLine.MissingParameterException) + } + + def "test valid command"() { + given: + def app = new App() + String[] args = ["--config-cmd", "/some/command"] + + when: + new CommandLine(app).parseArgs(args) + then: + app.@command == "/some/command" + + when: + def config = app.prepareConfig() + then: + config == new ContainerConfig(cmd: ['/some/command']) + } + + def "test invalid command"() { + given: + def app = new App() + String[] args = ["--config-cmd", ""] + + when: + new CommandLine(app).parseArgs(args) + app.prepareConfig() + then: + thrown(IllegalCliArgumentException) + + } + + def "test valid environment"() { + given: + def app = new App() + String[] args = ["--config-env", "var1=value1","--config-env", "var2=value2"] + + when: + new CommandLine(app).parseArgs(args) + then: + app.@environment[0] == "var1=value1" + app.@environment[1] == "var2=value2" + + when: + def config = app.prepareConfig() + then: + config == new ContainerConfig(env: ['var1=value1', 'var2=value2']) + } + + def "test invalid environment"() { + given: + def app = new App() + String[] args = ["--config-env", "VAR"] + + when: + new CommandLine(app).parseArgs(args) + app.prepareConfig() + then: + def e = thrown(IllegalCliArgumentException) + e.message == 'Invalid environment variable syntax - offending value: VAR' + + } + + def "test valid working directory"() { + given: + def app = new App() + String[] args = ["--config-working-dir", "/work/dir"] + + when: + new CommandLine(app).parseArgs(args) + then: + app.@workingDir == "/work/dir" + + when: + def config = app.prepareConfig() + then: + config == new ContainerConfig(workingDir: '/work/dir') + } + + def "test invalid working directory"() { + given: + def app = new App() + String[] args = ["--config-working-dir", " "] + + when: + new CommandLine(app).parseArgs(args) + app.prepareConfig() + then: + thrown(IllegalCliArgumentException) + + } + + def "test valid config file from a path"() { + given: + def folder = Files.createTempDirectory('test') + def configFile = folder.resolve('config.json') + configFile.text = CONFIG_JSON + and: + def app = new App() + String[] args = ["--config-file", configFile.toString()] + + when: + new CommandLine(app).parseArgs(args) + then: + app.@configFile == configFile.toString() + + when: + def config = app.prepareConfig() + then: + config.entrypoint == [ "/some", "--entrypoint" ] + def layer = config.layers[0] + layer.location == "https://location" + layer.gzipDigest == "sha256:gzipDigest" + layer.tarDigest == "sha256:tarDigest" + layer.gzipSize == 100 + + cleanup: + folder?.deleteDir() + } + + def "test valid config file from a URL"() { + given: + def app = new App() + String[] args = ["--config-file", "http://localhost:8080/api/data"] + + when: + new CommandLine(app).parseArgs(args) + then: + app.@configFile == "http://localhost:8080/api/data" + + when: + def config = app.prepareConfig() + then: + config.entrypoint == [ "/some", "--entrypoint" ] + def layer = config.layers[0] + layer.location == "https://location" + layer.gzipDigest == "sha256:gzipDigest" + layer.tarDigest == "sha256:tarDigest" + layer.gzipSize == 100 + } +} diff --git a/app/src/test/groovy/io/seqera/wavelit/AppSpackOptsTest.groovy b/app/src/test/groovy/io/seqera/wavelit/AppSpackOptsTest.groovy index 59995fc..0deb576 100644 --- a/app/src/test/groovy/io/seqera/wavelit/AppSpackOptsTest.groovy +++ b/app/src/test/groovy/io/seqera/wavelit/AppSpackOptsTest.groovy @@ -17,6 +17,7 @@ import io.seqera.wavelit.exception.IllegalCliArgumentException import picocli.CommandLine import spock.lang.Specification /** + * Test App Spack prefixed options * * @author Paolo Di Tommaso */ diff --git a/app/src/test/groovy/io/seqera/wavelit/AppTest.groovy b/app/src/test/groovy/io/seqera/wavelit/AppTest.groovy index e57cecd..460dd4d 100644 --- a/app/src/test/groovy/io/seqera/wavelit/AppTest.groovy +++ b/app/src/test/groovy/io/seqera/wavelit/AppTest.groovy @@ -3,14 +3,13 @@ package io.seqera.wavelit import java.time.Instant -import io.seqera.wave.api.ContainerConfig import io.seqera.wave.api.SubmitContainerTokenResponse -import io.seqera.wavelit.exception.IllegalCliArgumentException import picocli.CommandLine import spock.lang.Specification class AppTest extends Specification { + def "test valid no entrypoint"() { given: def app = new App() @@ -28,125 +27,6 @@ class AppTest extends Specification { config == null } - - def "test valid entrypoint"() { - given: - def app = new App() - String[] args = ["--config-entrypoint", "entryPoint"] - def cli = new CommandLine(app) - - when: - cli.parseArgs(args) - then: - app.@entrypoint == "entryPoint" - - when: - def config = app.prepareConfig() - then: - config == new ContainerConfig(entrypoint: ['entryPoint']) - } - - def "test invalid entrypoint"() { - given: - def app = new App() - String[] args = ["--config-entrypoint"] - - when: - new CommandLine(app).parseArgs(args) - - then: - thrown(CommandLine.MissingParameterException) - } - - def "test valid command"() { - given: - def app = new App() - String[] args = ["--config-cmd", "/some/command"] - - when: - new CommandLine(app).parseArgs(args) - then: - app.@command == "/some/command" - - when: - def config = app.prepareConfig() - then: - config == new ContainerConfig(cmd: ['/some/command']) - } - - def "test invalid command"() { - given: - def app = new App() - String[] args = ["--config-cmd", ""] - - when: - new CommandLine(app).parseArgs(args) - app.prepareConfig() - then: - thrown(IllegalCliArgumentException) - - } - - def "test valid environment"() { - given: - def app = new App() - String[] args = ["--config-env", "var1=value1","--config-env", "var2=value2"] - - when: - new CommandLine(app).parseArgs(args) - then: - app.@environment[0] == "var1=value1" - app.@environment[1] == "var2=value2" - - when: - def config = app.prepareConfig() - then: - config == new ContainerConfig(env: ['var1=value1', 'var2=value2']) - } - - def "test invalid environment"() { - given: - def app = new App() - String[] args = ["--config-env", "VAR"] - - when: - new CommandLine(app).parseArgs(args) - app.prepareConfig() - then: - def e = thrown(IllegalCliArgumentException) - e.message == 'Invalid environment variable syntax - offending value: VAR' - - } - - def "test valid working directory"() { - given: - def app = new App() - String[] args = ["--config-working-dir", "/work/dir"] - - when: - new CommandLine(app).parseArgs(args) - then: - app.@workingDir == "/work/dir" - - when: - def config = app.prepareConfig() - then: - config == new ContainerConfig(workingDir: '/work/dir') - } - - def "test invalid working directory"() { - given: - def app = new App() - String[] args = ["--config-working-dir", " "] - - when: - new CommandLine(app).parseArgs(args) - app.prepareConfig() - then: - thrown(IllegalCliArgumentException) - - } - def 'should dump response to yaml' () { given: def app = new App() @@ -192,4 +72,5 @@ class AppTest extends Specification { then: result == '{"buildId":"98765","containerImage":"docker.io/some/container","containerToken":"12345","expiration":"1970-01-20T13:57:19.913Z","targetImage":"docker.io/some/repo"}' } + }