diff --git a/README.md b/README.md
index cbbe3e5f6..10d853924 100644
--- a/README.md
+++ b/README.md
@@ -14,10 +14,9 @@ images.
* Augment container images i.e. dynamically add one or more container layers to existing images;
* Build container images on-demand for a given container file (aka Dockerfile);
* Build container images on-demand based on one or more Conda packages;
-* Build container images on-demand based on one or more Spack packages, Spack support will be removed in future releases;
* Build container images for a specified target platform (currently linux/amd64 and linux/arm64);
* Push and cache built containers to a user-provided container repository;
-* Build Singularity native containers both using a Singularity spec file, Conda package(s) and Spack package(s);
+* Build Singularity native containers both using a Singularity spec file, Conda package(s);
* Push Singularity native container images to OCI-compliant registries;
diff --git a/VERSION b/VERSION
index 6b89d58f8..81f363239 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.12.2
+1.12.3
diff --git a/build.gradle b/build.gradle
index d1e3293bf..951f39628 100644
--- a/build.gradle
+++ b/build.gradle
@@ -34,7 +34,7 @@ dependencies {
compileOnly("io.micronaut:micronaut-http-validation")
implementation("jakarta.persistence:jakarta.persistence-api:3.0.0")
api 'io.seqera:lib-mail:1.0.0'
- api 'io.seqera:wave-api:0.10.0'
+ api 'io.seqera:wave-api:0.12.0'
api 'io.seqera:wave-utils:0.13.1'
implementation("io.micronaut:micronaut-http-client")
diff --git a/changelog.txt b/changelog.txt
index 5c865f7eb..5eaee4da4 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,4 +1,8 @@
# Wave changelog
+1.12.3 - 22 Sep 2024
+- Fix build status completion of submit exception [3c3af360]
+- Fix singularity build mounts [3b338b29]
+
1.12.2 - 18 Sep 2024
- Fix Remove entries permanently from stream once consumed [adfad9d6]
- Refactor container build service [1a858c12]
@@ -13,7 +17,7 @@
- Do not retry on build failure (#632) [e6568d1e]
- Fix Blob cache failure duration (#643) [ebf65adc]
- Fix K8s job status detection (#630) [d5b45d8d] [7a9046ed] [e26811dd]
- - Fix Retry policy delay multipler (#629) [80037565]
+ - Fix Retry policy delay multiplier (#629) [80037565]
- Improve blob cache info (#644) [8b96173a]
- Improve blob cache logging [e4c75671]
- Improve blob cache reliability (#596) [dfb64bad]
@@ -33,7 +37,7 @@
- Add /v1alpha2/container/{token} in typespec (#618) [5cbd67a8]
- Fix failing type checks [bd704bea]
- Fix too many requests error code (#610) [ec43fa0d]
-- Increase blob cache timeout to 10m and decrese status to 1h [cf4b7588]
+- Increase blob cache timeout to 10m and decrease status to 1h [cf4b7588]
- Improve container view page (#615) [d9b8cab8]
- Improve registry auth error handling (#628) [c9185730]
- Increase cache-tower-client to 1min (#641) [df32b305]
diff --git a/configuration.md b/configuration.md
index b7523b17c..d162a98b5 100644
--- a/configuration.md
+++ b/configuration.md
@@ -107,20 +107,6 @@ Below are the standard format for known registries, but you can change registry
- **`wave.build.force-compression`**: determines whether to force the compression for each cache layers produced by the build process. The default is `false`, enabling compression for more efficient storage. *Optional*.
-### Spack configuration for wave build process
-
-**Note**: Spack support will be removed in future releases.
-
-Spack configuration consists of the path of its secret file, the mount path for the secret file in the spack container, and the optional S3 bucket name for the spack binary cache.
-
-**Note**: these configuration are mandatory to support Spack in a wave installation.
-
-- **`wave.build.spack.secretKeyFile`**: the path to the file containing the PGP private key used to [sign Spack packages built by Wave](https://spack.readthedocs.io/en/latest/binary_caches.html#build-cache-signing). For example, `/efs/wave/spack/key`. *Mandatory*.
-
-- **`wave.build.spack.secretMountPath`**: sets the mount path inside the Spack Docker image for the PGP private key specified by `wave.build.spack.secretKeyFile`. For instance `/var/seqera/spack/key`. Indicating where the PGP private key should be mounted inside the Spack Docker image. *Mandatory*.
-
-- **`wave.build.spack.cacheBucket`**: specifies the S3 bucket for the Spack binary cache, for example, `s3://spack-binarycache`. *Optional*.
-
### Build process logs configuration
This configuration specifies attributes for the persistence of the logs fetched from containers or k8s pods used for building requested images, which can be accessed later and also attached to the build completion email.
diff --git a/docs/api.mdx b/docs/api.mdx
index 0d646c0b3..ca52843f3 100644
--- a/docs/api.mdx
+++ b/docs/api.mdx
@@ -52,7 +52,6 @@ This API endpoint is deprecated in current versions of Wave.
]
},
condaFile: string,
- spackFile: string,
containerPlatform: string,
buildRepository: string,
cacheRepository: string,
@@ -81,7 +80,6 @@ This API endpoint is deprecated in current versions of Wave.
| `containerConfig.layers.gzipSize` | The size in bytes of the the provided layer tar gzip file. |
| `containerFile` | Dockerfile used for building a new container encoded in base64 (optional). When provided, the attribute `containerImage` must be omitted. |
| `condaFile` | Conda environment file encoded as base64 string. |
-| `spackFile` | `Deprecated` Spack recipe file encoded as base64 string. Spack support will be removed in future releases. |
| `containerPlatform` | Target container architecture of the built container, e.g., `linux/amd64` (optional). Currently only supporting amd64 and arm64. |
| `buildRepository` | Container repository where container builds should be pushed, e.g., `docker.io/user/my-image` (optional). |
| `cacheRepository` | Container repository used to cache build layers `docker.io/user/my-cache` (optional). |
@@ -136,7 +134,6 @@ The endpoint returns the name of the container request made available by Wave.
]
},
condaFile: string,
- spackFile: string,
containerPlatform: string,
buildRepository: string,
cacheRepository: string,
@@ -157,10 +154,6 @@ The endpoint returns the name of the container request made available by Wave.
commands: string[],
basePackages: string
}
- spackOpts:{
- commands: string[],
- basePackages: string
- }
},
nameStrategy: string
@@ -182,7 +175,6 @@ The endpoint returns the name of the container request made available by Wave.
| `containerConfig.layers.gzipSize` | The size in bytes of the the provided layer tar gzip file. |
| `containerFile` | Dockerfile used for building a new container encoded in base64 (optional). When provided, the attribute `containerImage` must be omitted. |
| `condaFile` | Conda environment file encoded as base64 string. |
-| `spackFile` | `Deprecated` Spack recipe file encoded as base64 string. Spack support will be removed in future releases. |
| `containerPlatform` | Target container architecture of the built container, e.g., `linux/amd64` (optional). Currently only supporting amd64 and arm64. |
| `buildRepository` | Container repository where container builds should be pushed, e.g., `docker.io/user/my-image` (optional). |
| `cacheRepository` | Container repository used to cache build layers `docker.io/user/my-cache` (optional). |
diff --git a/src/main/groovy/io/seqera/wave/configuration/SpackConfig.groovy b/src/main/groovy/io/seqera/wave/configuration/SpackConfig.groovy
deleted file mode 100644
index 0b81d6299..000000000
--- a/src/main/groovy/io/seqera/wave/configuration/SpackConfig.groovy
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * 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.configuration
-
-import java.nio.file.Path
-
-import groovy.transform.CompileStatic
-import groovy.transform.EqualsAndHashCode
-import groovy.transform.ToString
-import io.micronaut.context.annotation.Value
-import io.micronaut.core.annotation.Nullable
-import jakarta.inject.Singleton
-/**
- * Model Spack configuration
- *
- * @author Paolo Di Tommaso
- */
-@ToString
-@EqualsAndHashCode
-@Singleton
-@CompileStatic
-@Deprecated
-class SpackConfig {
-
- /**
- * The s3 bucket where Spack cached binaries are stored
- */
- @Nullable
- @Value('${wave.build.spack.cacheBucket}')
- private String cacheBucket
-
- /**
- * The host path where the GPG key required by the Spack "buildcache" is located
- */
- @Nullable
- @Value('${wave.build.spack.secretKeyFile}')
- private String secretKeyFile
-
- /**
- * The container path where the GPG key required by the Spack "buildcache" is located
- */
- @Nullable
- @Value('${wave.build.spack.secretMountPath}')
- private String secretMountPath
-
- /**
- * The container image used for Spack builds
- */
- @Value('${wave.build.spack.builderImage:`spack/ubuntu-jammy:v0.20.0`}')
- private String builderImage
-
- /**
- * The container image used for Spack container
- */
- @Value('${wave.build.spack.runnerImage:`ubuntu:22.04`}')
- private String runnerImage
-
- String getCacheBucket() {
- if( !cacheBucket )
- throw new IllegalStateException("Missing Spack 'cacheBucket' configuration setting")
- return cacheBucket
- }
-
- Path getSecretKeyFile() {
- if( !secretKeyFile )
- throw new IllegalStateException("Missing Spack 'secretKeyFile' configuration setting")
- return Path.of(secretKeyFile).toAbsolutePath().normalize()
- }
-
- String getSecretMountPath() {
- if( !secretMountPath )
- throw new IllegalStateException("Missing Spack 'secretMountPath' configuration setting")
- return secretMountPath
- }
-
- String getBuilderImage() {
- return builderImage
- }
-
- String getRunnerImage() {
- return runnerImage
- }
-}
diff --git a/src/main/groovy/io/seqera/wave/controller/BuildController.groovy b/src/main/groovy/io/seqera/wave/controller/BuildController.groovy
index 0036232ce..f7e016d6d 100644
--- a/src/main/groovy/io/seqera/wave/controller/BuildController.groovy
+++ b/src/main/groovy/io/seqera/wave/controller/BuildController.groovy
@@ -31,9 +31,12 @@ import io.micronaut.http.server.types.files.StreamedFile
import io.micronaut.scheduling.TaskExecutors
import io.micronaut.scheduling.annotation.ExecuteOn
import io.seqera.wave.api.BuildStatusResponse
+import io.seqera.wave.exception.BadRequestException
import io.seqera.wave.service.builder.ContainerBuildService
import io.seqera.wave.service.conda.CondaLockService
import io.seqera.wave.service.logs.BuildLogService
+import io.seqera.wave.service.mirror.ContainerMirrorService
+import io.seqera.wave.service.mirror.MirrorRequest
import io.seqera.wave.service.persistence.WaveBuildRecord
import jakarta.inject.Inject
/**
@@ -50,6 +53,9 @@ class BuildController {
@Inject
private ContainerBuildService buildService
+ @Inject
+ private ContainerMirrorService mirrorService
+
@Inject
@Nullable
BuildLogService logService
@@ -58,7 +64,7 @@ class BuildController {
CondaLockService condaLockService
@Get("/v1alpha1/builds/{buildId}")
- HttpResponse getBuildRecord(String buildId){
+ HttpResponse getBuildRecord(String buildId) {
final record = buildService.getBuildRecord(buildId)
return record
? HttpResponse.ok(record)
@@ -77,10 +83,10 @@ class BuildController {
}
@Get("/v1alpha1/builds/{buildId}/status")
- HttpResponse getBuildStatus(String buildId){
- final build = buildService.getBuildRecord(buildId)
- build != null
- ? HttpResponse.ok(build.toStatusResponse())
+ HttpResponse getBuildStatus(String buildId) {
+ final resp = buildResponse0(buildId)
+ resp != null
+ ? HttpResponse.ok(resp)
: HttpResponse.notFound()
}
@@ -96,4 +102,20 @@ class BuildController {
: HttpResponse.notFound()
}
+ protected BuildStatusResponse buildResponse0(String buildId) {
+ if( !buildId )
+ throw new BadRequestException("Missing 'buildId' parameter")
+ // build IDs starting with the `mr-` prefix are interpreted as mirror requests
+ if( buildId.startsWith(MirrorRequest.ID_PREFIX) ) {
+ return mirrorService
+ .getMirrorState(buildId)
+ ?.toStatusResponse()
+ }
+ else {
+ return buildService
+ .getBuildRecord(buildId)
+ ?.toStatusResponse()
+ }
+ }
+
}
diff --git a/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy b/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy
index 2499bf925..5478d44dd 100644
--- a/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy
+++ b/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy
@@ -59,6 +59,8 @@ import io.seqera.wave.service.builder.ContainerBuildService
import io.seqera.wave.service.builder.FreezeService
import io.seqera.wave.service.inclusion.ContainerInclusionService
import io.seqera.wave.service.inspect.ContainerInspectService
+import io.seqera.wave.service.mirror.ContainerMirrorService
+import io.seqera.wave.service.mirror.MirrorRequest
import io.seqera.wave.service.pairing.PairingService
import io.seqera.wave.service.pairing.socket.PairingChannel
import io.seqera.wave.service.persistence.PersistenceService
@@ -86,8 +88,6 @@ import static io.seqera.wave.util.ContainerHelper.makeResponseV1
import static io.seqera.wave.util.ContainerHelper.makeResponseV2
import static io.seqera.wave.util.ContainerHelper.makeTargetImage
import static io.seqera.wave.util.ContainerHelper.patchPlatformEndpoint
-import static io.seqera.wave.util.ContainerHelper.spackFileFromRequest
-import static io.seqera.wave.util.SpackHelper.prependBuilderTemplate
import static java.util.concurrent.CompletableFuture.completedFuture
/**
* Implement a controller to receive container token requests
@@ -127,7 +127,7 @@ class ContainerController {
ContainerBuildService buildService
@Inject
- ContainerInspectService dockerAuthService
+ ContainerInspectService inspectService
@Inject
RegistryProxyService registryProxyService
@@ -154,6 +154,9 @@ class ContainerController {
@Nullable
RateLimiterService rateLimiterService
+ @Inject
+ private ContainerMirrorService mirrorService
+
@PostConstruct
private void init() {
log.info "Wave server url: $serverUrl; allowAnonymous: $allowAnonymous; tower-endpoint-url: $towerEndpointUrl; default-build-repo: $buildConfig.defaultBuildRepository; default-cache-repo: $buildConfig.defaultCacheRepository; default-public-repo: $buildConfig.defaultPublicRepository"
@@ -178,6 +181,7 @@ class ContainerController {
// validate request
validateContainerRequest(req)
+ validateMirrorRequest(req, v2)
// this is needed for backward compatibility with old clients
if( !req.towerEndpoint ) {
@@ -237,6 +241,10 @@ class ContainerController {
req = req.copyWith(containerFile: generated.bytes.encodeBase64().toString())
}
+ if( req.spackFile ) {
+ throw new BadRequestException("Spack packages are not supported any more")
+ }
+
final ip = addressResolver.resolve(httpRequest)
// check the rate limit before continuing
if( rateLimiterService )
@@ -317,18 +325,16 @@ class ContainerController {
final containerSpec = decodeBase64OrFail(req.containerFile, 'containerFile')
final condaContent = condaFileFromRequest(req)
- final spackContent = spackFileFromRequest(req)
final format = req.formatSingularity() ? SINGULARITY : DOCKER
final platform = ContainerPlatform.of(req.containerPlatform)
final buildRepository = targetRepo( req.buildRepository ?: (req.freeze && buildConfig.defaultPublicRepository
? buildConfig.defaultPublicRepository
: buildConfig.defaultBuildRepository), req.nameStrategy)
final cacheRepository = req.cacheRepository ?: buildConfig.defaultCacheRepository
- final configJson = dockerAuthService.credentialsConfigJson(containerSpec, buildRepository, cacheRepository, identity)
+ final configJson = inspectService.credentialsConfigJson(containerSpec, buildRepository, cacheRepository, identity)
final containerConfig = req.freeze ? req.containerConfig : null
final offset = DataTimeUtils.offsetId(req.timestamp)
final scanId = scanEnabled && format==DOCKER ? LongRndKey.rndHex() : null
- final containerFile = spackContent ? prependBuilderTemplate(containerSpec,format) : containerSpec
// use 'imageSuffix' strategy by default for public repo images
final nameStrategy = req.nameStrategy==null
&& buildRepository
@@ -338,14 +344,13 @@ class ContainerController {
checkContainerSpec(containerSpec)
// create a unique digest to identify the build request
- final containerId = makeContainerId(containerFile, condaContent, spackContent, platform, buildRepository, req.buildContext)
- final targetImage = makeTargetImage(format, buildRepository, containerId, condaContent, spackContent, nameStrategy)
+ final containerId = makeContainerId(containerSpec, condaContent, platform, buildRepository, req.buildContext)
+ final targetImage = makeTargetImage(format, buildRepository, containerId, condaContent, nameStrategy)
final maxDuration = buildConfig.buildMaxDuration(req)
return new BuildRequest(
containerId,
- containerFile,
+ containerSpec,
condaContent,
- spackContent,
Path.of(buildConfig.buildWorkspace),
targetImage,
identity,
@@ -415,6 +420,15 @@ class ContainerController {
buildId = track.id
buildNew = !track.cached
}
+ else if( req.mirrorRegistry ) {
+ final mirror = makeMirrorRequest(req, identity)
+ final track = checkMirror(mirror, identity, req.dryRun)
+ targetImage = track.targetImage
+ targetContent = null
+ condaContent = null
+ buildId = track.id
+ buildNew = !track.cached
+ }
else if( req.containerImage ) {
// normalize container image
final coords = ContainerCoordinates.parse(req.containerImage)
@@ -436,7 +450,51 @@ class ContainerController {
ContainerPlatform.of(req.containerPlatform),
buildId,
buildNew,
- req.freeze )
+ req.freeze,
+ req.mirrorRegistry!=null
+ )
+ }
+
+ protected MirrorRequest makeMirrorRequest(SubmitContainerTokenRequest request, PlatformId identity) {
+ final coords = ContainerCoordinates.parse(request.containerImage)
+ if( coords.registry == request.mirrorRegistry )
+ throw new BadRequestException("Source and target mirror registry as the same - offending value '${request.mirrorRegistry}'")
+ final targetImage = request.mirrorRegistry + '/' + coords.imageAndTag
+ final configJson = inspectService.credentialsConfigJson(null, request.containerImage, targetImage, identity)
+ final platform = request.containerPlatform
+ ? ContainerPlatform.of(request.containerPlatform)
+ : ContainerPlatform.DEFAULT
+ final digest = registryProxyService.getImageDigest(request.containerImage, identity)
+ if( !digest )
+ throw new BadRequestException("Container image '$request.containerImage' does not exist")
+ return MirrorRequest.create(
+ request.containerImage,
+ targetImage,
+ digest,
+ platform,
+ Path.of(buildConfig.buildWorkspace).toAbsolutePath(),
+ configJson )
+ }
+
+ protected BuildTrack checkMirror(MirrorRequest request, PlatformId identity, boolean dryRun) {
+ final targetDigest = registryProxyService.getImageDigest(request.targetImage, identity)
+ log.debug "== Mirror target digest: $targetDigest"
+ final cached = request.digest==targetDigest
+ // check for dry-run execution
+ if( dryRun ) {
+ log.debug "== Dry-run request request: $request"
+ final dryId = request.id + BuildRequest.SEP + '0'
+ return new BuildTrack(dryId, request.targetImage, cached)
+ }
+ // check for existing image
+ if( request.digest==targetDigest ) {
+ log.debug "== Found cached request for request: $request"
+ final cache = persistenceService.loadMirrorState(request.targetImage, targetDigest)
+ return new BuildTrack(cache?.mirrorId, request.targetImage, true)
+ }
+ else {
+ return mirrorService.mirrorImage(request)
+ }
}
protected String targetImage(String token, ContainerCoordinates container) {
@@ -462,7 +520,7 @@ class ContainerController {
return HttpResponse.ok()
}
- void validateContainerRequest(SubmitContainerTokenRequest req) throws BadRequestException{
+ void validateContainerRequest(SubmitContainerTokenRequest req) throws BadRequestException {
String msg
// check valid image name
msg = validationService.checkContainerName(req.containerImage)
@@ -475,6 +533,32 @@ class ContainerController {
if( msg ) throw new BadRequestException(msg)
}
+ void validateMirrorRequest(SubmitContainerTokenRequest req, boolean v2) throws BadRequestException {
+ if( !req.mirrorRegistry )
+ return
+ // container mirror validation
+ if( !v2 )
+ throw new BadRequestException("Container mirroring requires the use of v2 API")
+ if( !req.containerImage )
+ throw new BadRequestException("Attribute `containerImage` is required when specifying `mirrorRegistry`")
+ if( !req.towerAccessToken )
+ throw new BadRequestException("Container mirroring requires an authenticated request - specify the tower token attribute")
+ if( req.freeze )
+ throw new BadRequestException("Attribute `mirrorRegistry` and `freeze` conflict each other")
+ if( req.containerFile )
+ throw new BadRequestException("Attribute `mirrorRegistry` and `containerFile` conflict each other")
+ if( req.containerIncludes )
+ throw new BadRequestException("Attribute `mirrorRegistry` and `containerIncludes` conflict each other")
+ if( req.containerConfig )
+ throw new BadRequestException("Attribute `mirrorRegistry` and `containerConfig` conflict each other")
+ final coords = ContainerCoordinates.parse(req.containerImage)
+ if( coords.registry == req.mirrorRegistry )
+ throw new BadRequestException("Source and target mirror registry as the same - offending value '${req.mirrorRegistry}'")
+ def msg = validationService.checkMirrorRegistry(req.mirrorRegistry)
+ if( msg )
+ throw new BadRequestException(msg)
+ }
+
@Error(exception = AuthorizationException.class)
HttpResponse> handleAuthorizationException() {
return HttpResponse.unauthorized()
diff --git a/src/main/groovy/io/seqera/wave/controller/MirrorController.groovy b/src/main/groovy/io/seqera/wave/controller/MirrorController.groovy
new file mode 100644
index 000000000..bb1626505
--- /dev/null
+++ b/src/main/groovy/io/seqera/wave/controller/MirrorController.groovy
@@ -0,0 +1,53 @@
+/*
+ * 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.controller
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.annotation.Controller
+import io.micronaut.http.annotation.Get
+import io.micronaut.scheduling.TaskExecutors
+import io.micronaut.scheduling.annotation.ExecuteOn
+import io.seqera.wave.service.mirror.ContainerMirrorService
+import io.seqera.wave.service.mirror.MirrorState
+import jakarta.inject.Inject
+/**
+ * Implements a controller for container mirror apis
+ *
+ * @author Paolo Di Tommaso
+ */
+@Slf4j
+@CompileStatic
+@Controller("/")
+@ExecuteOn(TaskExecutors.IO)
+class MirrorController {
+
+ @Inject
+ private ContainerMirrorService mirrorService
+
+ @Get("/v1alpha1/mirrors/{mirrorId}")
+ HttpResponse getMirrorRecord(String mirrorId) {
+ final result = mirrorService.getMirrorState(mirrorId)
+ return result
+ ? HttpResponse.ok(result)
+ : HttpResponse.notFound()
+ }
+
+}
diff --git a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy
index 02735f372..4df9ba1be 100644
--- a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy
+++ b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy
@@ -18,11 +18,12 @@
package io.seqera.wave.controller
-import groovy.json.JsonOutput
-import io.micronaut.core.annotation.Nullable
+import java.util.regex.Pattern
import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
import io.micronaut.context.annotation.Value
+import io.micronaut.core.annotation.Nullable
import io.micronaut.http.HttpResponse
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
@@ -47,6 +48,7 @@ import static io.seqera.wave.util.DataTimeUtils.formatTimestamp
*
* @author Paolo Di Tommaso
*/
+@Slf4j
@CompileStatic
@Controller("/view")
@ExecuteOn(TaskExecutors.IO)
@@ -74,13 +76,55 @@ class ViewController {
@View("build-view")
@Get('/builds/{buildId}')
- HttpResponse