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 c7976f9..ed6cf62 100644 --- a/app/src/main/java/io/seqera/wave/cli/App.java +++ b/app/src/main/java/io/seqera/wave/cli/App.java @@ -97,6 +97,18 @@ public class App implements Runnable { private static final List VALID_PLATFORMS = List.of("amd64", "x86_64", "linux/amd64", "linux/x86_64", "arm64", "linux/arm64"); + private static final String CANONICAL_AMD64 = "linux/amd64"; + + private static final String CANONICAL_ARM64 = "linux/arm64"; + + private static final List AMD64_ALIASES = List.of("amd64", "x86_64", "linux/amd64", "linux/x86_64"); + + private static final List ARM64_ALIASES = List.of("arm64", "linux/arm64"); + + private static final String MULTI_ARCH_PLATFORM = CANONICAL_AMD64 + "," + CANONICAL_ARM64; + + private static final String PLATFORM_ALL = "all"; + private static final long _1MB = 1024 * 1024; @Option(names = {"-i", "--image"}, paramLabel = "''", description = "Container image name to be provisioned e.g alpine:latest.") @@ -126,7 +138,7 @@ public class App implements Runnable { @Option(names = {"--freeze", "-F"}, paramLabel = "false", description = "Request a container freeze.") private boolean freeze; - @Option(names = {"--platform"}, paramLabel = "''", description = "Platform to be used for the container build. One of: linux/amd64, linux/arm64.") + @Option(names = {"--platform"}, paramLabel = "''", description = "Platform to be used for the container build. One of: linux/amd64, linux/arm64. Use 'all' for multi-arch builds.") private String platform; @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.") @@ -420,9 +432,19 @@ protected void validateArgs() { if( dryRun && await != null ) throw new IllegalCliArgumentException("Options --dry-run and --await conflicts each other"); - if( !isEmpty(platform) && !VALID_PLATFORMS.contains(platform) ) - throw new IllegalCliArgumentException(String.format("Unsupported container platform: '%s'", platform)); + if( !isEmpty(platform) ) { + platform = normalizePlatform(platform); + if( platform.contains(",") ) + validateMultiArch(); + } + + } + private void validateMultiArch() { + if( isEmpty(containerFile) && isEmpty(condaFile) && condaPackages==null && cranPackages==null ) + throw new IllegalCliArgumentException("Multi-arch build requires a container file (--containerfile) or package specification"); + if( singularity ) + throw new IllegalCliArgumentException("Multi-arch build is not compatible with Singularity format"); } protected Client client() { @@ -456,6 +478,29 @@ protected SubmitContainerTokenRequest createRequest() { ; } + protected String normalizePlatform(String platform) { + if( isEmpty(platform) ) + return null; + if( PLATFORM_ALL.equals(platform) ) + return MULTI_ARCH_PLATFORM; + if( platform.contains(",") ) { + return Arrays.stream(platform.split(",")) + .map(App::canonicalPlatform) + .distinct() + .collect(Collectors.joining(",")); + } + return canonicalPlatform(platform); + } + + private static String canonicalPlatform(String value) { + final String v = value.trim(); + if( AMD64_ALIASES.contains(v) ) + return CANONICAL_AMD64; + if( ARM64_ALIASES.contains(v) ) + return CANONICAL_ARM64; + throw new IllegalCliArgumentException(String.format("Unsupported container platform: '%s'", v)); + } + BuildCompression compression(BuildCompression.Mode mode) { if( mode==null ) return null; 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 30f3e93..e2c514c 100644 --- a/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy +++ b/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy @@ -388,16 +388,16 @@ class AppTest extends Specification { and: app.validateArgs() then: - app.@platform == PLATFORM + app.@platform == EXPECTED where: - PLATFORM || _ - 'amd64' || _ - 'x86_64' || _ - 'arm64' || _ - 'linux/amd64' || _ - 'linux/x86_64' || _ - 'linux/arm64' || _ + PLATFORM || EXPECTED + 'amd64' || 'linux/amd64' + 'x86_64' || 'linux/amd64' + 'arm64' || 'linux/arm64' + 'linux/amd64' || 'linux/amd64' + 'linux/x86_64' || 'linux/amd64' + 'linux/arm64' || 'linux/arm64' } @Unroll @@ -631,6 +631,253 @@ class AppTest extends Specification { e.getMessage() == "Option --mirror and requires the use of a build repository" } + def 'should allow multi-arch platform linux/amd64,linux/arm64 with containerfile' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "linux/amd64,linux/arm64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64,linux/arm64' + + cleanup: + folder?.deleteDir() + } + + def 'should normalize shorthand multi-arch platform to canonical form' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "amd64,arm64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64,linux/arm64' + + cleanup: + folder?.deleteDir() + } + + def 'should normalize single platform to canonical form' () { + given: + def app = new App() + String[] args = ["-i", "ubuntu:latest", "--platform", "x86_64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64' + } + + def 'should allow platform all and map to multi-arch' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "all"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64,linux/arm64' + + cleanup: + folder?.deleteDir() + } + + def 'should fail multi-arch with image only and no containerfile' () { + given: + def app = new App() + String[] args = ["-i", "ubuntu:latest", "--platform", "linux/amd64,linux/arm64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + def e = thrown(IllegalCliArgumentException) + e.message == 'Multi-arch build requires a container file (--containerfile) or package specification' + } + + def 'should fail multi-arch all with image only' () { + given: + def app = new App() + String[] args = ["-i", "ubuntu:latest", "--platform", "all"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + def e = thrown(IllegalCliArgumentException) + e.message == 'Multi-arch build requires a container file (--containerfile) or package specification' + } + + def 'should fail multi-arch with singularity' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "linux/amd64,linux/arm64", "--singularity", "--freeze", "--build-repo", "docker.io/foo", "--tower-token", "xyz"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + def e = thrown(IllegalCliArgumentException) + e.message == 'Multi-arch build is not compatible with Singularity format' + + cleanup: + folder?.deleteDir() + } + + def 'should allow multi-arch with conda packages' () { + given: + def app = new App() + String[] args = ["--conda-package", "samtools=1.17", "--platform", "linux/amd64,linux/arm64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + } + + def 'should normalize platform with whitespace in comma-separated values' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "linux/amd64, linux/arm64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64,linux/arm64' + + cleanup: + folder?.deleteDir() + } + + def 'should fail platform all with singularity' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "all", "--singularity", "--freeze", "--build-repo", "docker.io/foo", "--tower-token", "xyz"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + def e = thrown(IllegalCliArgumentException) + e.message == 'Multi-arch build is not compatible with Singularity format' + + cleanup: + folder?.deleteDir() + } + + def 'should deduplicate repeated platforms' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "amd64,amd64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64' + + cleanup: + folder?.deleteDir() + } + + def 'should fail when comma-separated platform contains invalid value' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "linux/amd64,bogus"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + def e = thrown(IllegalCliArgumentException) + e.message == "Unsupported container platform: 'bogus'" + + cleanup: + folder?.deleteDir() + } + @Unroll def 'should check service version'() { given: