Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions app/src/main/java/io/seqera/wave/cli/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ public class App implements Runnable {

private static final List<String> 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<String> AMD64_ALIASES = List.of("amd64", "x86_64", "linux/amd64", "linux/x86_64");

private static final List<String> 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.")
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down
263 changes: 255 additions & 8 deletions app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading