diff --git a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy index d182fed6a..8108d0ff7 100644 --- a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy @@ -31,6 +31,7 @@ import io.micronaut.http.annotation.Get import io.micronaut.http.annotation.QueryValue import io.micronaut.scheduling.TaskExecutors import io.micronaut.scheduling.annotation.ExecuteOn +import io.micronaut.views.ModelAndView import io.micronaut.views.View import io.seqera.wave.exception.HttpResponseException import io.seqera.wave.exception.NotFoundException @@ -114,55 +115,81 @@ class ViewController { return binding } - @View("build-view") @Get('/builds/{buildId}') HttpResponse viewBuild(String buildId) { // check redirection for invalid suffix in the form `-nn` - final r1 = shouldRedirect1(buildId) + final r1 = isBuildInvalidSuffix(buildId) if( r1 ) { log.debug "Redirect to build page [1]: $r1" return HttpResponse.redirect(URI.create(r1)) } - // check redirection when missing the suffix `_nn` - final r2 = shouldRedirect2(buildId) - if( r2 ) { - log.debug "Redirect to build page [2]: $r2" - return HttpResponse.redirect(URI.create(r2)) + + // check all builds matching the pattern + if( isBuildMissingSuffix(buildId) ) { + final builds = buildService.getAllBuilds(buildId) + if( !builds ) { + log.debug "Found not build with id: $buildId" + throw new NotFoundException("Unknown container build id '$buildId'") + } + if( builds.size()==1 ) { + log.debug "Redirect to build page [2]: ${builds.first().buildId}" + return HttpResponse.temporaryRedirect(URI.create("/view/builds/${builds.first().buildId}")) + } + else + return HttpResponse.ok(new ModelAndView("build-list", renderBuildsView(builds))) } + // go ahead with proper handling final record = buildService.getBuildRecord(buildId) if( !record ) throw new NotFoundException("Unknown container build id '$buildId'") - return HttpResponse.ok(renderBuildView(record)) + return HttpResponse.ok(new ModelAndView("build-view", renderBuildView(record))) } - static final private Pattern DASH_SUFFIX = ~/([0-9a-zA-Z\-]+)-(\d+)$/ + static final private Pattern DASH_SUFFIX_REGEX = ~/([0-9a-zA-Z\-]+)-(\d+)$/ - static final private Pattern MISSING_SUFFIX = ~/([0-9a-zA-Z\-]+)(? renderBuildsView(List results) { + // create template binding + final binding = new ArrayList>() + for (def result : results){ + final bind = new HashMap(20) + bind.build_id = result.buildId + bind.build_image = result.targetImage + bind.build_status = getStatus(result) + bind.build_time = formatTimestamp(result.startTime, result.offsetId) ?: '-' + binding.add(bind) + } + // result the main object + return Map.of("build_records", binding, 'server_url', serverUrl) + } + + protected static String getStatus(WaveBuildRecord result){ + if( result.done() && result.succeeded() ) + return "SUCCEEDED" + else if ( result.done() && !result.succeeded() ) + return "FAILED" + else if (!result.done()) + return "IN PROGRESS" + else + return "UNKNOWN" } Map renderBuildView(WaveBuildRecord result) { diff --git a/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildService.groovy b/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildService.groovy index 2e540ff0f..4996aac64 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildService.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildService.groovy @@ -93,4 +93,12 @@ interface ContainerBuildService { */ WaveBuildRecord getLatestBuild(String containerId) + /** + * Retrieve the all build record available for the specified container id. + * + * @param containerId The ID of the container for which the build record needs to be retrieve + * @return The {@link WaveBuildRecord} associated with the corresponding Id, or {@code null} if it cannot be found + */ + List getAllBuilds(String containerId) + } diff --git a/src/main/groovy/io/seqera/wave/service/builder/impl/ContainerBuildServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/builder/impl/ContainerBuildServiceImpl.groovy index 03c426d49..0d36875d5 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/impl/ContainerBuildServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/impl/ContainerBuildServiceImpl.groovy @@ -381,4 +381,12 @@ class ContainerBuildServiceImpl implements ContainerBuildService, JobHandler getAllBuilds(String containerId) { + return persistenceService.allBuilds(containerId) + } + } diff --git a/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy index 213402bec..acc0f55f3 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy @@ -67,6 +67,14 @@ interface PersistenceService { */ WaveBuildRecord latestBuild(String containerId) + /** + * Retrieve all {@link WaveBuildRecord} object for the given container id + * + * @param containerId The container id for which the latest build record should be retrieved + * @return The corresponding {@link WaveBuildRecord} object or {@code null} if no record is found + */ + List allBuilds(String containerId) + /** * Store a {@link WaveContainerRecord} object in the Surreal wave_request table. * diff --git a/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy index 3f9df0c5b..b8249e82b 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy @@ -60,6 +60,15 @@ class LocalPersistenceService implements PersistenceService { .reverse() [0] } + @Override + List allBuilds(String containerId) { + buildStore + .values() + .findAll( it-> it.buildId.contains(containerId) ) + .sort { it.startTime } + .reverse() + } + @Override WaveBuildRecord loadBuildSucceed(String targetImage, String digest) { buildStore.values().find( (build) -> build.targetImage==targetImage && build.digest==digest && build.succeeded() ) diff --git a/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy index fc8d8903b..99e1a234a 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy @@ -182,6 +182,21 @@ class SurrealPersistenceService implements PersistenceService { return result } + @Override + List allBuilds(String containerId) { + final query = """ + select * + from wave_build + where buildId ~ '${containerId}' + order by startTime desc + """.stripIndent() + final json = surrealDb.sqlAsString(getAuthorization(), query) + final type = new TypeReference>>() {} + final data= json ? JacksonHelper.fromJson(json, type) : null + final result = data && data[0].result ? data[0].result : null + return result ? Arrays.asList(result) : null + } + @Override void saveContainerRequest(WaveContainerRecord data) { // note: use surreal sql in order to by-pass issue with large payload diff --git a/src/main/resources/io/seqera/wave/build-list.hbs b/src/main/resources/io/seqera/wave/build-list.hbs new file mode 100644 index 000000000..e07e32650 --- /dev/null +++ b/src/main/resources/io/seqera/wave/build-list.hbs @@ -0,0 +1,100 @@ + + + + + + + Wave container builds + + + +
+
+ +

Wave container build records

+
+{{#if build_records}} +
+ + + + + + + + {{#each build_records}} + + + + + + + {{/each}} +
Build IDContainer imageBuild timeStatus
{{build_id}}{{build_image}}{{build_time}}{{build_status}}
+
+{{else}} +
 No Build Record Found
+{{/if}} + + + +
+ + diff --git a/src/main/resources/io/seqera/wave/build-view.hbs b/src/main/resources/io/seqera/wave/build-view.hbs index 2eaa82548..41bb84847 100644 --- a/src/main/resources/io/seqera/wave/build-view.hbs +++ b/src/main/resources/io/seqera/wave/build-view.hbs @@ -123,7 +123,11 @@ Container image + {{#if build_in_progress}} + {{build_image}} + {{else}} {{build_image}} + {{/if}} diff --git a/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy index 24bb1d500..8327f06b4 100644 --- a/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy @@ -78,6 +78,9 @@ class ViewControllerTest extends Specification { @Inject ContainerInspectService inspectService + @Inject + ContainerBuildService buildService + @Value('${wave.server.url}') String serverUrl @@ -129,7 +132,7 @@ class ViewControllerTest extends Specification { def 'should render build page' () { given: def record1 = new WaveBuildRecord( - buildId: '112233', + buildId: '112233_1', dockerFile: 'FROM docker.io/test:foo', targetImage: 'test', userName: 'test', @@ -159,7 +162,7 @@ class ViewControllerTest extends Specification { def 'should render build page with conda file' () { given: def record1 = new WaveBuildRecord( - buildId: 'test', + buildId: '112233_1', condaFile: 'conda::foo', targetImage: 'test', userName: 'test', @@ -435,6 +438,131 @@ class ViewControllerTest extends Specification { response.body().contains(serverUrl) } + def 'should render builds page' () { + given: + def record1 = new WaveBuildRecord( + buildId: 'bd-0727765dc72cee24_1', + dockerFile: 'FROM docker.io/test:foo', + targetImage: 'test', + userName: 'test', + userEmail: 'test', + userId: 1, + requestIp: '127.0.0.1', + startTime: Instant.now(), + duration: Duration.ofSeconds(1), + exitStatus: 0 ) + def record2 = new WaveBuildRecord( + buildId: 'bd-0727765dc72cee24_2', + dockerFile: 'FROM docker.io/test:foo', + targetImage: 'test', + userName: 'test', + userEmail: 'test', + userId: 1, + requestIp: '127.0.0.1', + startTime: Instant.now(), + duration: Duration.ofSeconds(1), + exitStatus: 0 ) + + and: + persistenceService.saveBuild(record1) + persistenceService.saveBuild(record2) + + when: + def request = HttpRequest.GET("/view/builds/0727765dc72cee24") + def response = client.toBlocking().exchange(request, String) + + then: + response.body().contains(record1.buildId) + response.body().contains(record2.buildId) + and: + response.body().contains('test') + and: + response.body().contains(serverUrl) + } + + def 'should render build page after fixing buildId' () { + given: + def record1 = new WaveBuildRecord( + buildId: '112233_1', + dockerFile: 'FROM docker.io/test:foo', + targetImage: 'test', + userName: 'test', + userEmail: 'test', + userId: 1, + requestIp: '127.0.0.1', + startTime: Instant.now(), + duration: Duration.ofSeconds(1), + exitStatus: 0 ) + + when: + persistenceService.saveBuild(record1) + and: + def request = HttpRequest.GET("/view/builds/112233-1") + def response = client.toBlocking().exchange(request, String) + then: + response.body().contains(record1.buildId) + and: + response.body().contains('Container file') + response.body().contains('FROM docker.io/test:foo') + and: + !response.body().contains('Conda file') + and: + response.body().contains(serverUrl) + } + + def 'should return correct status for success build record'() { + given: + def result = new WaveBuildRecord( + buildId: '112233_1', + dockerFile: 'FROM docker.io/test:foo', + targetImage: 'test', + userName: 'test', + userEmail: 'test', + userId: 1, + requestIp: '127.0.0.1', + startTime: Instant.now(), + duration: Duration.ofSeconds(1), + exitStatus: 0 ) + + expect: + ViewController.getStatus(result) == "SUCCEEDED" + } + + def 'should return correct status for failed build record'() { + given: + def result = new WaveBuildRecord( + buildId: '112233_1', + dockerFile: 'FROM docker.io/test:foo', + targetImage: 'test', + userName: 'test', + userEmail: 'test', + userId: 1, + requestIp: '127.0.0.1', + startTime: Instant.now(), + duration: Duration.ofSeconds(1), + exitStatus: 1 ) + + expect: + ViewController.getStatus(result) == "FAILED" + } + + def 'should return correct status for in progress build record'() { + given: + def result = new WaveBuildRecord( + buildId: '112233_1', + dockerFile: 'FROM docker.io/test:foo', + targetImage: 'test', + userName: 'test', + userEmail: 'test', + userId: 1, + requestIp: '127.0.0.1', + startTime: Instant.now(), + duration: null) + + expect: + ViewController.getStatus(result) == "IN PROGRESS" + } + @Unroll def 'should validate redirection check' () { given: @@ -442,7 +570,7 @@ class ViewControllerTest extends Specification { def controller = new ViewController(buildService: service) when: - def result = controller.shouldRedirect1(BUILD) + def result = controller.isBuildInvalidSuffix(BUILD) then: result == EXPECTED @@ -457,29 +585,24 @@ class ViewControllerTest extends Specification { 'bd-887766-1' | '/view/builds/bd-887766_1' } - def 'should validate redirect 2' () { + def 'should validate build id patten' () { given: def service = Mock(ContainerBuildService) def controller = new ViewController(buildService: service) when: - def result = controller.shouldRedirect2(BUILD) + def result = controller.isBuildMissingSuffix(BUILD) then: result == EXPECTED - TIMES * service.getLatestBuild(BUILD) >> Mock(WaveBuildRecord) { buildId>>LATEST } where: - BUILD | TIMES | LATEST | EXPECTED - '12345_1' | 0 | null | null - '12345' | 1 | '12345_99' | '/view/builds/12345_99' - '12345' | 1 | 'xyz_99' | null - 'foo-887766' | 1 | 'foo-887766_99' | '/view/builds/foo-887766_99' - 'foo-887766' | 1 | 'foo-887766' | null - 'bd-887766' | 1 | 'bd-887766_2' | '/view/builds/bd-887766_2' - '887766' | 1 | 'bd-887766_2' | '/view/builds/bd-887766_2' - - + BUILD | EXPECTED + null | false + 'bd-beac24afd572398d_1' | false // fully qualified + and: + 'bd-beac24afd572398d' | true // prerix + container id + 'beac24afd572398d' | true // just the container id + and: + 'beac24afd572398' | false // too short } - - } diff --git a/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy b/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy index 41d29f246..e77aacf72 100644 --- a/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy @@ -510,4 +510,38 @@ class SurrealPersistenceServiceTest extends Specification implements SurrealDBTe persistence.loadBuild(request.buildId) == build1 } + def 'should find all builds' () { + given: + def surreal = applicationContext.getBean(SurrealClient) + def persistence = applicationContext.getBean(SurrealPersistenceService) + def auth = persistence.getAuthorization() + def request1 = new BuildRequest( containerId: 'abc', buildId: 'bd-abc_1' , workspace: Path.of('.'), startTime: Instant.now().minusSeconds(30), identity: PlatformId.NULL) + def request2 = new BuildRequest( containerId: 'abc', buildId: 'bd-abc_2' , workspace: Path.of('.'), startTime: Instant.now().minusSeconds(20), identity: PlatformId.NULL) + def request3 = new BuildRequest( containerId: 'abc', buildId: 'bd-abc_3' , workspace: Path.of('.'), startTime: Instant.now().minusSeconds(10), identity: PlatformId.NULL) + def request4 = new BuildRequest( containerId: 'abc', buildId: 'bd-xyz_3' , workspace: Path.of('.'), startTime: Instant.now().minusSeconds(0), identity: PlatformId.NULL) + + def result1 = new BuildResult(request1.buildId, -1, "ok", request1.startTime, Duration.ofSeconds(2), null) + def record1 =WaveBuildRecord.fromEvent(new BuildEvent(request1, result1)) + surreal.insertBuild(auth, record1) + and: + def result2 = new BuildResult(request2.buildId, -1, "ok", request2.startTime, Duration.ofSeconds(2), null) + def record2 = WaveBuildRecord.fromEvent(new BuildEvent(request2, result2)) + surreal.insertBuild(auth, record2) + and: + def result3 = new BuildResult(request3.buildId, -1, "ok", request3.startTime, Duration.ofSeconds(2), null) + def record3 = WaveBuildRecord.fromEvent(new BuildEvent(request3, result3)) + surreal.insertBuild(auth, record3) + and: + def result4 = new BuildResult(request4.buildId, -1, "ok", request4.startTime, Duration.ofSeconds(2), null) + def record4 = WaveBuildRecord.fromEvent(new BuildEvent(request4, result4)) + surreal.insertBuild(auth, record4) + + expect: + persistence.allBuilds('abc') == [record3, record2, record1] + and: + persistence.allBuilds('bd-abc') == [record3, record2, record1] + and: + persistence.allBuilds('bd-abc_2') == [record2] + } + }