From c039c22ec84c7b65724f9484635053d0fec3c389 Mon Sep 17 00:00:00 2001 From: Mohammed Naser Date: Fri, 13 Nov 2026 14:48:28 +0200 Subject: [PATCH] [#26] Enforce formatting and Reformatting the project Add make format, format-check and checkstyle targets (Spotless with google-java-format, Prettier, Checkstyle) and run them in CI. Format the existing code. Add my name to the acknowledgement file. --- .gitattributes | 1 + .github/workflows/ci.yml | 4 + .github/workflows/lint.yml | 2 + AUTHORS | 1 + CONTRIBUTING.md | 2 +- Makefile | 16 +- checkstyle.xml | 17 + docs/BUILDING.md | 5 +- docs/manual/en/97-acknowledgement.md | 1 + pom.xml | 2 +- .../java/ai/mnemosyne/cisys/AllowedImage.java | 14 +- .../ai/mnemosyne/cisys/BranchResource.java | 63 +- .../java/ai/mnemosyne/cisys/Branding.java | 26 +- .../ai/mnemosyne/cisys/BrandingResource.java | 168 ++- .../main/java/ai/mnemosyne/cisys/Build.java | 72 +- .../java/ai/mnemosyne/cisys/BuildLogs.java | 426 ++++--- .../ai/mnemosyne/cisys/BuildResource.java | 232 ++-- .../ai/mnemosyne/cisys/BuildRetention.java | 81 +- .../java/ai/mnemosyne/cisys/BuildRunner.java | 1135 +++++++++++------ .../mnemosyne/cisys/GitRepositoryService.java | 130 +- .../java/ai/mnemosyne/cisys/HostResolver.java | 2 +- .../ai/mnemosyne/cisys/ImageReference.java | 164 ++- .../ai/mnemosyne/cisys/ImageResource.java | 173 ++- .../java/ai/mnemosyne/cisys/LogResponses.java | 27 +- .../main/java/ai/mnemosyne/cisys/Project.java | 17 +- .../ai/mnemosyne/cisys/ProjectAccess.java | 37 +- .../cisys/ProjectBranchResource.java | 65 +- .../mnemosyne/cisys/ProjectPageResource.java | 179 ++- .../ai/mnemosyne/cisys/ProjectResource.java | 258 ++-- .../cisys/PublicRateLimitFilter.java | 51 +- .../ai/mnemosyne/cisys/PublicRateLimiter.java | 101 +- .../cisys/RepositoryUrlValidator.java | 32 +- .../ai/mnemosyne/cisys/RepositoryUrls.java | 271 ++-- .../mnemosyne/cisys/SessionInvalidator.java | 132 +- .../java/ai/mnemosyne/cisys/Settings.java | 39 +- .../ai/mnemosyne/cisys/SettingsResource.java | 102 +- .../ai/mnemosyne/cisys/StatsResource.java | 83 +- .../mnemosyne/cisys/SystemHostResolver.java | 8 +- .../java/ai/mnemosyne/cisys/Workflow.java | 861 +++++++------ .../mnemosyne/cisys/BranchResourceTest.java | 247 ++-- .../ai/mnemosyne/cisys/BranchTestProfile.java | 9 +- .../mnemosyne/cisys/BrandingResourceTest.java | 137 +- .../java/ai/mnemosyne/cisys/BuildLogTest.java | 503 +++++--- .../ai/mnemosyne/cisys/BuildQueueTest.java | 662 ++++++---- .../ai/mnemosyne/cisys/BuildResourceTest.java | 628 +++++++-- .../cisys/GitRepositoryServiceTest.java | 160 ++- .../mnemosyne/cisys/ImageReferenceTest.java | 174 ++- .../ai/mnemosyne/cisys/ImageResourceTest.java | 120 +- .../cisys/ProjectBranchResourceTest.java | 278 ++-- .../cisys/ProjectPageResourceTest.java | 652 ++++++---- .../mnemosyne/cisys/ProjectResourceTest.java | 575 ++++++--- .../mnemosyne/cisys/PublicRateLimitTest.java | 138 +- .../cisys/RepositoryUrlValidatorTest.java | 273 ++-- .../ai/mnemosyne/cisys/RetentionTest.java | 305 +++-- .../ai/mnemosyne/cisys/RunnerSmokeTest.java | 61 +- .../cisys/SessionInvalidatorTest.java | 158 ++- .../mnemosyne/cisys/SettingsResourceTest.java | 171 ++- .../ai/mnemosyne/cisys/StatsResourceTest.java | 282 ++-- .../cisys/TestGitRepositoryService.java | 26 +- .../ai/mnemosyne/cisys/TestHostResolver.java | 32 +- .../java/ai/mnemosyne/cisys/WorkflowTest.java | 830 ++++++------ src/frontend/.prettierignore | 3 + src/frontend/.prettierrc.json | 1 + src/frontend/eslint.config.js | 9 +- src/frontend/index.html | 12 +- src/frontend/package.json | 2 + src/frontend/src/BrandingPanel.jsx | 220 +++- src/frontend/src/BuildLogView.jsx | 308 +++-- src/frontend/src/BuildLogView.test.jsx | 150 ++- src/frontend/src/BuildStatus.jsx | 53 +- src/frontend/src/ImagesPanel.jsx | 348 ++++- src/frontend/src/ProjectBuild.jsx | 171 ++- src/frontend/src/ProjectBuilds.jsx | 271 +++- src/frontend/src/PublicHome.jsx | 73 +- src/frontend/src/PublicStats.jsx | 111 +- src/frontend/src/SettingsPanel.jsx | 200 ++- src/frontend/src/build-format.js | 24 +- src/frontend/src/cisys-theme.css | 1 - .../src/components/editor/image-component.tsx | 3 +- .../tool-ui/data-table/data-table.tsx | 9 +- .../tool-ui/data-table/formatters.tsx | 6 +- src/frontend/src/lib/utils.ts | 1 - src/frontend/src/main.jsx | 664 ++++++++-- src/frontend/src/style.css | 803 ++++++++++-- src/frontend/vite.config.js | 13 +- src/frontend/vitest.config.js | 10 +- 86 files changed, 9831 insertions(+), 5056 deletions(-) create mode 100644 checkstyle.xml create mode 100644 src/frontend/.prettierignore create mode 100644 src/frontend/.prettierrc.json diff --git a/.gitattributes b/.gitattributes index c5f04f9..c5eba89 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ +* text=auto eol=lf *.sh text eol=lf Makefile text eol=lf .husky/* text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 231fe31..bda7cc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,5 +24,9 @@ jobs: distribution: temurin java-version: "25" cache: maven + - name: Checkstyle + run: mvn -B checkstyle:check + - name: Check formatting + run: mvn -B spotless:check - name: Run tests run: mvn -B test diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 41aea4a..056c94e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,6 +28,8 @@ jobs: - name: Install frontend deps run: npm ci + - name: Check formatting + run: npm run format:check - name: Run frontend checks run: npm run check diff --git a/AUTHORS b/AUTHORS index 2bb01d0..b99e20c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -4,3 +4,4 @@ Shashank Singh Jesper Pedersen Adnan Khan Mohamed Hamed +Mohammed Naser diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8278d72..2da0c29 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,4 +142,4 @@ In your first pull request you need to add yourself to the `AUTHORS` file. ## Code Style -Please, follow the coding style of the project. The frontend is checked with ESLint (`npm run lint` in `src/frontend`), which also runs as a Husky `pre-commit` hook on staged files. Run the full lint and the tests with `make test`. +Please, follow the coding style of the project. Format with `make format` before committing; CI checks formatting of all files. Java is formatted with [google-java-format](https://github.com/google/google-java-format) (2-space indent, Spotless) and is additionally checked with Checkstyle (`make checkstyle`, rules in `checkstyle.xml`). The frontend is formatted with Prettier (the whole `src/frontend` folder) and checked with ESLint (`npm run lint` in `src/frontend`), which also runs as a Husky `pre-commit` hook on staged files. Run the frontend lint, the formatting and Checkstyle checks and the backend test cases with `make test`. diff --git a/Makefile b/Makefile index fff84bd..006d9d6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean run test lint check build docs platform-start platform-stop reset frontend setup full +.PHONY: all clean run test lint check build docs platform-start platform-stop reset frontend format format-check checkstyle setup full all: clean lint run @@ -18,6 +18,18 @@ lint: frontend check: frontend @npm --prefix src/frontend run check +# Reformats Java (google-java-format) and the frontend (Prettier). +format: frontend + @mvn -B spotless:apply && npm --prefix src/frontend run format + +# Checks formatting of Java and the frontend; makes no changes, as CI does. +format-check: frontend + @mvn -B spotless:check && npm --prefix src/frontend run format:check + +# Runs the minimal Checkstyle rules (see checkstyle.xml). +checkstyle: + @mvn -B checkstyle:check + build: frontend @npm --prefix src/frontend run build @@ -36,7 +48,7 @@ run: frontend trap 'kill $$frontend_pid $$backend_pid 2>/dev/null; wait' EXIT INT TERM; \ wait $$backend_pid -test: lint +test: lint format-check checkstyle @mvn test docs: diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..5c2de4d --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 6c98835..c26c1cd 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -185,9 +185,12 @@ The targets are * `clean` - Clean * `lint` - Run the frontend ESLint check * `check` - Run the frontend checks (lint and build), as CI does +* `format` - Format the backend and the whole `src/frontend` folder (Prettier) +* `format-check` - Check formatting of all files without changing them, as CI does +* `checkstyle` - Run the minimal Checkstyle rules (see `checkstyle.xml`) * `build` - Build the frontend * `run` - Run the backend and the frontend in development mode -* `test` - Run the frontend lint and the backend test cases +* `test` - Run the frontend lint, the formatting and Checkstyle checks and the backend test cases * `docs` - Generate the manual * `platform-start` - Start compose services (PostgreSQL + Keycloak) * `platform-stop` - Stop the compose services diff --git a/docs/manual/en/97-acknowledgement.md b/docs/manual/en/97-acknowledgement.md index 1eb945f..e9682db 100644 --- a/docs/manual/en/97-acknowledgement.md +++ b/docs/manual/en/97-acknowledgement.md @@ -11,6 +11,7 @@ Shashank Singh Jesper Pedersen Adnan Khan Mohamed Hamed +Mohammed Naser ``` ## Contributing diff --git a/pom.xml b/pom.xml index 703f3e0..ceb4c16 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ io.rest-assuredrest-assuredtest io.quarkusquarkus-test-security-jwttest - src/backend/main/javasrc/backend/test/javasrc/backend/main/resourcessrc/backend/test/resourcesio.quarkusquarkus-maven-plugin${quarkus.platform.version}truebuildgenerate-codegenerate-code-testsmaven-compiler-plugin3.14.0maven-surefire-plugin3.5.5org.jboss.logmanager.LogManager + src/backend/main/javasrc/backend/test/javasrc/backend/main/resourcessrc/backend/test/resourcesio.quarkusquarkus-maven-plugin${quarkus.platform.version}truebuildgenerate-codegenerate-code-testsmaven-compiler-plugin3.14.0maven-surefire-plugin3.5.5org.jboss.logmanager.LogManagercom.diffplug.spotlessspotless-maven-plugin3.11.0src/backend/main/java/**/*.javasrc/backend/test/java/**/*.java2.0.0org.apache.maven.pluginsmaven-checkstyle-plugin3.6.0com.puppycrawl.toolscheckstyle14.4.0${project.basedir}/checkstyle.xmltruetrueUTF-8UTF-8 manual-docs diff --git a/src/backend/main/java/ai/mnemosyne/cisys/AllowedImage.java b/src/backend/main/java/ai/mnemosyne/cisys/AllowedImage.java index df977ce..f26ac70 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/AllowedImage.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/AllowedImage.java @@ -9,18 +9,20 @@ package ai.mnemosyne.cisys; import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; -import jakarta.persistence.Column; /** A container image that admins have allowed for builds. */ @Entity public class AllowedImage extends PanacheEntityBase { - @Id @Column(length = 201) public String name; + @Id + @Column(length = 201) + public String name; - public static final String PATTERN = "[A-Za-z0-9][A-Za-z0-9_./:@-]{0,200}"; + public static final String PATTERN = "[A-Za-z0-9][A-Za-z0-9_./:@-]{0,200}"; - public static boolean isAllowed(String name) { - return name != null && findById(name) != null; - } + public static boolean isAllowed(String name) { + return name != null && findById(name) != null; + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BranchResource.java b/src/backend/main/java/ai/mnemosyne/cisys/BranchResource.java index 57c923c..e1df60a 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BranchResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BranchResource.java @@ -20,44 +20,51 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.eclipse.microprofile.jwt.JsonWebToken; - import java.util.List; import java.util.Map; +import org.eclipse.microprofile.jwt.JsonWebToken; @Path("/api/projects/{projectId}/branches") @Produces(MediaType.APPLICATION_JSON) @Authenticated public class BranchResource { - @Inject SecurityIdentity identity; - @Inject GitRepositoryService repositories; + @Inject SecurityIdentity identity; + @Inject GitRepositoryService repositories; - public record Branches(List branches, String reason) {} + public record Branches(List branches, String reason) {} - private Project visible(Long id) { - if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); - if (!(identity.getPrincipal() instanceof JsonWebToken jwt) || jwt.getSubject() == null) throw new NotAuthorizedException("Bearer"); - Project p = Project.findById(id); - if (p == null || (p.privateRepository && !identity.hasRole("admin") && !jwt.getSubject().equals(p.ownerSubject))) throw new NotFoundException(); - return p; - } + private Project visible(Long id) { + if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) + throw new ForbiddenException(); + if (!(identity.getPrincipal() instanceof JsonWebToken jwt) || jwt.getSubject() == null) + throw new NotAuthorizedException("Bearer"); + Project p = Project.findById(id); + if (p == null + || (p.privateRepository + && !identity.hasRole("admin") + && !jwt.getSubject().equals(p.ownerSubject))) throw new NotFoundException(); + return p; + } - @GET - public Response list(@PathParam("projectId") Long projectId) { - Project project = visible(projectId); - if (project.privateRepository) { - return Response.ok(new Branches(List.of(), "private_repository")).build(); - } - try { - return Response.ok(new Branches(repositories.listBranches(project), null)).build(); - } catch (IllegalArgumentException e) { - return error(400, e.getMessage()); - } catch (GitRepositoryService.BranchLookupException e) { - return error(502, e.getMessage()); - } + @GET + public Response list(@PathParam("projectId") Long projectId) { + Project project = visible(projectId); + if (project.privateRepository) { + return Response.ok(new Branches(List.of(), "private_repository")).build(); } - - private static Response error(int status, String message) { - return Response.status(status).type(MediaType.APPLICATION_JSON).entity(Map.of("error", message)).build(); + try { + return Response.ok(new Branches(repositories.listBranches(project), null)).build(); + } catch (IllegalArgumentException e) { + return error(400, e.getMessage()); + } catch (GitRepositoryService.BranchLookupException e) { + return error(502, e.getMessage()); } + } + + private static Response error(int status, String message) { + return Response.status(status) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of("error", message)) + .build(); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/Branding.java b/src/backend/main/java/ai/mnemosyne/cisys/Branding.java index 89f2082..d5f02c5 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/Branding.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/Branding.java @@ -14,11 +14,23 @@ @Entity public class Branding { - @Id public Long id = 1L; - @Column(nullable = false, length = 80) public String name = "cisys"; - @Column(nullable = false, length = 7) public String headerColor = "#176a4a"; - @Column(nullable = false, length = 7) public String headingColor = "#176a4a"; - @Column(nullable = false, length = 7) public String buttonColor = "#176a4a"; - @Column(length = 350000) public String logo; - @Column(length = 350000) public String background; + @Id public Long id = 1L; + + @Column(nullable = false, length = 80) + public String name = "cisys"; + + @Column(nullable = false, length = 7) + public String headerColor = "#176a4a"; + + @Column(nullable = false, length = 7) + public String headingColor = "#176a4a"; + + @Column(nullable = false, length = 7) + public String buttonColor = "#176a4a"; + + @Column(length = 350000) + public String logo; + + @Column(length = 350000) + public String background; } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BrandingResource.java b/src/backend/main/java/ai/mnemosyne/cisys/BrandingResource.java index cdc5c68..22f6ac9 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BrandingResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BrandingResource.java @@ -9,19 +9,19 @@ package ai.mnemosyne.cisys; import io.quarkus.security.Authenticated; -import jakarta.annotation.security.PermitAll; import io.quarkus.security.identity.SecurityIdentity; +import jakarta.annotation.security.PermitAll; import jakarta.inject.Inject; import jakarta.persistence.EntityManager; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.util.Base64; @@ -31,56 +31,118 @@ @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public class BrandingResource { - @Inject SecurityIdentity identity; - @Inject EntityManager em; - /** The name and colors only, readable without signing in so the Keycloak login pages can use them. */ - public record PublicBranding(String name, String headerColor, String headingColor, String buttonColor) {} - public record Input(String name, String headerColor, String headingColor, String buttonColor, String logo, String background) {} - @GET public Branding get() { - requireRole(); - Branding b = em.find(Branding.class, 1L); - return b == null ? new Branding() : b; - } - @GET @Path("/public") @PermitAll public PublicBranding publicBranding() { - Branding b = em.find(Branding.class, 1L); - if (b == null) b = new Branding(); - return new PublicBranding(b.name, b.headerColor, b.headingColor, b.buttonColor); - } - /** The logo as an image, readable without signing in so the Keycloak login pages can show it. */ - @GET @Path("/logo") @PermitAll @Produces({"image/png", "image/jpeg"}) public Response logo() { - Branding b = em.find(Branding.class, 1L); - if (b == null || b.logo == null) throw new NotFoundException(); - String type = b.logo.substring("data:".length(), b.logo.indexOf(';')); - byte[] bytes = Base64.getDecoder().decode(b.logo.substring(b.logo.indexOf(',') + 1)); - return Response.ok(bytes, type).header("Cache-Control", "no-cache").build(); - } - @PUT @Transactional public Branding update(Input input) { - if (!identity.hasRole("admin")) throw new ForbiddenException(); - if (input == null || input.name() == null || input.name().isBlank() || input.name().trim().length() > 80) throw new BadRequestException("A name of up to 80 characters is required"); - validateColor(input.headerColor()); validateColor(input.headingColor()); validateColor(input.buttonColor()); - validateImage(input.logo()); validateImage(input.background()); - Branding b = em.find(Branding.class, 1L); - if (b == null) { b = new Branding(); em.persist(b); } - b.name = input.name().trim(); b.headerColor = input.headerColor(); b.headingColor = input.headingColor(); b.buttonColor = input.buttonColor(); b.logo = emptyToNull(input.logo()); b.background = emptyToNull(input.background()); - em.flush(); - return b; - } - private void requireRole() { - if (!(identity.hasRole("admin") || identity.hasRole("lead") || identity.hasRole("member"))) throw new ForbiddenException(); - } - private static void validateColor(String color) { - if (color == null || !color.matches("#[0-9a-fA-F]{6}")) throw new BadRequestException("Colors must use #RRGGBB format"); + @Inject SecurityIdentity identity; + @Inject EntityManager em; + + /** + * The name and colors only, readable without signing in so the Keycloak login pages can use them. + */ + public record PublicBranding( + String name, String headerColor, String headingColor, String buttonColor) {} + + public record Input( + String name, + String headerColor, + String headingColor, + String buttonColor, + String logo, + String background) {} + + @GET + public Branding get() { + requireRole(); + Branding b = em.find(Branding.class, 1L); + return b == null ? new Branding() : b; + } + + @GET + @Path("/public") + @PermitAll + public PublicBranding publicBranding() { + Branding b = em.find(Branding.class, 1L); + if (b == null) b = new Branding(); + return new PublicBranding(b.name, b.headerColor, b.headingColor, b.buttonColor); + } + + /** The logo as an image, readable without signing in so the Keycloak login pages can show it. */ + @GET + @Path("/logo") + @PermitAll + @Produces({"image/png", "image/jpeg"}) + public Response logo() { + Branding b = em.find(Branding.class, 1L); + if (b == null || b.logo == null) throw new NotFoundException(); + String type = b.logo.substring("data:".length(), b.logo.indexOf(';')); + byte[] bytes = Base64.getDecoder().decode(b.logo.substring(b.logo.indexOf(',') + 1)); + return Response.ok(bytes, type).header("Cache-Control", "no-cache").build(); + } + + @PUT + @Transactional + public Branding update(Input input) { + if (!identity.hasRole("admin")) throw new ForbiddenException(); + if (input == null + || input.name() == null + || input.name().isBlank() + || input.name().trim().length() > 80) + throw new BadRequestException("A name of up to 80 characters is required"); + validateColor(input.headerColor()); + validateColor(input.headingColor()); + validateColor(input.buttonColor()); + validateImage(input.logo()); + validateImage(input.background()); + Branding b = em.find(Branding.class, 1L); + if (b == null) { + b = new Branding(); + em.persist(b); } - private static String emptyToNull(String value) { return value == null || value.isBlank() ? null : value; } - private static void validateImage(String value) { - if (value == null || value.isBlank()) return; - if (value.length() > 340000 || !(value.startsWith("data:image/png;base64,") || value.startsWith("data:image/jpeg;base64,"))) throw new BadRequestException("Images must be PNG or JPEG and at most 250 KB"); - try { - byte[] bytes = Base64.getDecoder().decode(value.substring(value.indexOf(',') + 1)); - if (bytes.length > 250000 || bytes.length < 8) throw new IllegalArgumentException(); - boolean png = value.startsWith("data:image/png") && bytes[0] == (byte) 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47; - boolean jpeg = value.startsWith("data:image/jpeg") && bytes[0] == (byte) 0xff && bytes[1] == (byte) 0xd8 && bytes[2] == (byte) 0xff; - if (!png && !jpeg) throw new IllegalArgumentException(); - } catch (IllegalArgumentException e) { throw new BadRequestException("Invalid image"); } + b.name = input.name().trim(); + b.headerColor = input.headerColor(); + b.headingColor = input.headingColor(); + b.buttonColor = input.buttonColor(); + b.logo = emptyToNull(input.logo()); + b.background = emptyToNull(input.background()); + em.flush(); + return b; + } + + private void requireRole() { + if (!(identity.hasRole("admin") || identity.hasRole("lead") || identity.hasRole("member"))) + throw new ForbiddenException(); + } + + private static void validateColor(String color) { + if (color == null || !color.matches("#[0-9a-fA-F]{6}")) + throw new BadRequestException("Colors must use #RRGGBB format"); + } + + private static String emptyToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + private static void validateImage(String value) { + if (value == null || value.isBlank()) return; + if (value.length() > 340000 + || !(value.startsWith("data:image/png;base64,") + || value.startsWith("data:image/jpeg;base64,"))) + throw new BadRequestException("Images must be PNG or JPEG and at most 250 KB"); + try { + byte[] bytes = Base64.getDecoder().decode(value.substring(value.indexOf(',') + 1)); + if (bytes.length > 250000 || bytes.length < 8) throw new IllegalArgumentException(); + boolean png = + value.startsWith("data:image/png") + && bytes[0] == (byte) 0x89 + && bytes[1] == 0x50 + && bytes[2] == 0x4e + && bytes[3] == 0x47; + boolean jpeg = + value.startsWith("data:image/jpeg") + && bytes[0] == (byte) 0xff + && bytes[1] == (byte) 0xd8 + && bytes[2] == (byte) 0xff; + if (!png && !jpeg) throw new IllegalArgumentException(); + } catch (IllegalArgumentException e) { + throw new BadRequestException("Invalid image"); } + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/Build.java b/src/backend/main/java/ai/mnemosyne/cisys/Build.java index a254e19..6dafc2a 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/Build.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/Build.java @@ -7,29 +7,59 @@ */ package ai.mnemosyne.cisys; + import io.quarkus.hibernate.orm.panache.PanacheEntity; import jakarta.persistence.*; import java.time.Instant; + /** A build. Its log is a large object in the unmapped logoid column, see {@link BuildLogs}. */ -@Entity public class Build extends PanacheEntity { - public Long projectId; - public String requestedBy; - public enum Status { QUEUED, RUNNING, PASSED, FAILED } - @Enumerated(EnumType.STRING) public Status status; - public Instant requestedAt; - @Column(length=2048) public String repositoryUrl; - public String revision; - public String branch; - /** The full name of the local image the workflow's container resolved to, once the build has read it. */ - public String image; - /** The text of the branch's {@value Workflow#PATH}, as the build read it. */ - @Column(length=Workflow.MAX_BYTES) public String workflow; - public String credentialRef; - @Column(length=300) public String imageDigest; - public Integer exitCode; - @Column(nullable=false) public boolean logTruncated; - /** Pinned builds are kept by retention cleanup. */ - @Column(nullable=false) public boolean pinned; - public Instant startedAt; - public Instant finishedAt; +@Entity +public class Build extends PanacheEntity { + public Long projectId; + public String requestedBy; + + public enum Status { + QUEUED, + RUNNING, + PASSED, + FAILED + } + + @Enumerated(EnumType.STRING) + public Status status; + + public Instant requestedAt; + + @Column(length = 2048) + public String repositoryUrl; + + public String revision; + public String branch; + + /** + * The full name of the local image the workflow's container resolved to, once the build has read + * it. + */ + public String image; + + /** The text of the branch's {@value Workflow#PATH}, as the build read it. */ + @Column(length = Workflow.MAX_BYTES) + public String workflow; + + public String credentialRef; + + @Column(length = 300) + public String imageDigest; + + public Integer exitCode; + + @Column(nullable = false) + public boolean logTruncated; + + /** Pinned builds are kept by retention cleanup. */ + @Column(nullable = false) + public boolean pinned; + + public Instant startedAt; + public Instant finishedAt; } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BuildLogs.java b/src/backend/main/java/ai/mnemosyne/cisys/BuildLogs.java index a54c457..86848a4 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BuildLogs.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BuildLogs.java @@ -33,249 +33,279 @@ /** Stores each build log as one PostgreSQL large object, through JDBC rather than Hibernate. */ @ApplicationScoped public class BuildLogs { - static final int FLUSH_BYTES = 1 << 20; - static final int READ_BYTES = 1 << 20; + static final int FLUSH_BYTES = 1 << 20; + static final int READ_BYTES = 1 << 20; - @Inject DataSource dataSource; + @Inject DataSource dataSource; - private volatile long flushIntervalMillis = 5000; - private volatile boolean failWrites; - private ScheduledExecutorService flusher; + private volatile long flushIntervalMillis = 5000; + private volatile boolean failWrites; + private ScheduledExecutorService flusher; - @PostConstruct - void start() { - flusher = Executors.newSingleThreadScheduledExecutor(r -> { - Thread t = new Thread(r, "cisys-log-flusher"); - t.setDaemon(true); - return t; + @PostConstruct + void start() { + flusher = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "cisys-log-flusher"); + t.setDaemon(true); + return t; }); - } + } - @PreDestroy - void stop() { flusher.shutdownNow(); } + @PreDestroy + void stop() { + flusher.shutdownNow(); + } - /** For tests. */ - void flushInterval(long millis) { flushIntervalMillis = millis; } + /** For tests. */ + void flushInterval(long millis) { + flushIntervalMillis = millis; + } - /** For tests. */ - void failWrites(boolean fail) { failWrites = fail; } + /** For tests. */ + void failWrites(boolean fail) { + failWrites = fail; + } - public Writer open(long buildId) { - long oid = QuarkusTransaction.requiringNew().call(() -> { - try (Connection c = dataSource.getConnection()) { + public Writer open(long buildId) { + long oid = + QuarkusTransaction.requiringNew() + .call(() -> { + try (Connection c = dataSource.getConnection()) { Long existing = logOid(c, buildId, true); if (existing != null) return existing; long created = largeObjects(c).createLO(LargeObjectManager.READWRITE); - try (PreparedStatement s = c.prepareStatement("update build set logoid = cast(? as oid) where id = ?")) { - s.setLong(1, created); - s.setLong(2, buildId); - s.executeUpdate(); + try (PreparedStatement s = + c.prepareStatement("update build set logoid = cast(? as oid) where id = ?")) { + s.setLong(1, created); + s.setLong(2, buildId); + s.executeUpdate(); } return created; - } - }); - return new Writer(oid); - } + } + }); + return new Writer(oid); + } - /** Returns the storage failure, or null. */ - public Throwable appendLine(long buildId, String text) { - Writer w; - try { w = open(buildId); } catch (RuntimeException e) { return e; } - w.line(text); - w.close(); - return w.failure(); + /** Returns the storage failure, or null. */ + public Throwable appendLine(long buildId, String text) { + Writer w; + try { + w = open(buildId); + } catch (RuntimeException e) { + return e; } + w.line(text); + w.close(); + return w.failure(); + } - public void streamTo(long buildId, OutputStream out) throws IOException { - streamTo(buildId, out, 0, -1); - } + public void streamTo(long buildId, OutputStream out) throws IOException { + streamTo(buildId, out, 0, -1); + } - /** - * Streams the stored bytes from {@code offset}, at most {@code limit} of them. A negative - * limit means no limit. Used by the log endpoints to support partial and tail reads. - */ - public void streamTo(long buildId, OutputStream out, long offset, long limit) throws IOException { - if (offset < 0) throw new IllegalArgumentException("offset must not be negative"); - if (limit == 0) return; - Long oid = logOid(buildId); - if (oid == null) return; - long written = 0; - for (long at = offset; limit < 0 || written < limit; ) { - int want = (int) Math.min(READ_BYTES, limit < 0 ? READ_BYTES : limit - written); - byte[] part = read(oid, at, want); - if (part.length == 0) return; - out.write(part); - out.flush(); - at += part.length; - written += part.length; - } + /** + * Streams the stored bytes from {@code offset}, at most {@code limit} of them. A negative limit + * means no limit. Used by the log endpoints to support partial and tail reads. + */ + public void streamTo(long buildId, OutputStream out, long offset, long limit) throws IOException { + if (offset < 0) throw new IllegalArgumentException("offset must not be negative"); + if (limit == 0) return; + Long oid = logOid(buildId); + if (oid == null) return; + long written = 0; + for (long at = offset; limit < 0 || written < limit; ) { + int want = (int) Math.min(READ_BYTES, limit < 0 ? READ_BYTES : limit - written); + byte[] part = read(oid, at, want); + if (part.length == 0) return; + out.write(part); + out.flush(); + at += part.length; + written += part.length; } + } - /** Total number of bytes stored for a build, or zero when it has no log yet. */ - public long size(long buildId) { - Long oid = logOid(buildId); - if (oid == null) return 0; - return QuarkusTransaction.requiringNew().call(() -> { - try (Connection c = dataSource.getConnection()) { - LargeObject lo = largeObjects(c).open(oid, LargeObjectManager.READ); - try { - return lo.size64(); - } finally { - lo.close(); - } + /** Total number of bytes stored for a build, or zero when it has no log yet. */ + public long size(long buildId) { + Long oid = logOid(buildId); + if (oid == null) return 0; + return QuarkusTransaction.requiringNew() + .call(() -> { + try (Connection c = dataSource.getConnection()) { + LargeObject lo = largeObjects(c).open(oid, LargeObjectManager.READ); + try { + return lo.size64(); + } finally { + lo.close(); } + } }); - } + } - /** A resolved byte range of one log. */ - public record Range(long offset, long length) {} + /** A resolved byte range of one log. */ + public record Range(long offset, long length) {} - /** - * Resolves the optional {@code offset}, {@code limit} and {@code tail} query values against - * the stored size. {@code tail} wins over {@code offset}; {@code maxBytes} clamps the result - * when it is not negative. A null limit means "everything that remains". A {@code tail} larger - * than {@code maxBytes} is reduced to the maximum, so the caller still gets the end of the log. - */ - public static Range range(long size, Long offset, Long limit, Long tail, long maxBytes) { - if (offset != null && offset < 0) throw new IllegalArgumentException("offset must not be negative"); - if (limit != null && limit < 0) throw new IllegalArgumentException("limit must not be negative"); - if (tail != null && tail < 0) throw new IllegalArgumentException("tail must not be negative"); - long start; - if (tail != null) { - long wanted = maxBytes >= 0 ? Math.min(tail, maxBytes) : tail; - start = Math.max(0, size - wanted); - } else { - start = offset == null ? 0 : offset; - } - long available = Math.max(0, size - start); - long length = limit == null ? available : Math.min(limit, available); - if (maxBytes >= 0) length = Math.min(length, maxBytes); - return new Range(start, length); + /** + * Resolves the optional {@code offset}, {@code limit} and {@code tail} query values against the + * stored size. {@code tail} wins over {@code offset}; {@code maxBytes} clamps the result when it + * is not negative. A null limit means "everything that remains". A {@code tail} larger than + * {@code maxBytes} is reduced to the maximum, so the caller still gets the end of the log. + */ + public static Range range(long size, Long offset, Long limit, Long tail, long maxBytes) { + if (offset != null && offset < 0) + throw new IllegalArgumentException("offset must not be negative"); + if (limit != null && limit < 0) + throw new IllegalArgumentException("limit must not be negative"); + if (tail != null && tail < 0) throw new IllegalArgumentException("tail must not be negative"); + long start; + if (tail != null) { + long wanted = maxBytes >= 0 ? Math.min(tail, maxBytes) : tail; + start = Math.max(0, size - wanted); + } else { + start = offset == null ? 0 : offset; } + long available = Math.max(0, size - start); + long length = limit == null ? available : Math.min(limit, available); + if (maxBytes >= 0) length = Math.min(length, maxBytes); + return new Range(start, length); + } - /** For tests. */ - String readAll(long buildId) { - try (ByteArrayOutputStream all = new ByteArrayOutputStream()) { - streamTo(buildId, all); - return all.toString(StandardCharsets.UTF_8); - } catch (IOException e) { - throw new IllegalStateException(e); - } + /** For tests. */ + String readAll(long buildId) { + try (ByteArrayOutputStream all = new ByteArrayOutputStream()) { + streamTo(buildId, all); + return all.toString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException(e); } + } - Long logOid(long buildId) { - return QuarkusTransaction.requiringNew().call(() -> { - try (Connection c = dataSource.getConnection()) { - return logOid(c, buildId, false); - } + Long logOid(long buildId) { + return QuarkusTransaction.requiringNew() + .call(() -> { + try (Connection c = dataSource.getConnection()) { + return logOid(c, buildId, false); + } }); - } + } - private static Long logOid(Connection c, long buildId, boolean lock) throws SQLException { - try (PreparedStatement s = c.prepareStatement("select logoid from build where id = ?" + (lock ? " for update" : ""))) { - s.setLong(1, buildId); - try (ResultSet r = s.executeQuery()) { - if (!r.next()) throw new IllegalArgumentException("No build " + buildId); - long oid = r.getLong(1); - return r.wasNull() ? null : oid; - } - } + private static Long logOid(Connection c, long buildId, boolean lock) throws SQLException { + try (PreparedStatement s = + c.prepareStatement("select logoid from build where id = ?" + (lock ? " for update" : ""))) { + s.setLong(1, buildId); + try (ResultSet r = s.executeQuery()) { + if (!r.next()) throw new IllegalArgumentException("No build " + buildId); + long oid = r.getLong(1); + return r.wasNull() ? null : oid; + } } + } - private static LargeObjectManager largeObjects(Connection c) throws SQLException { - return c.unwrap(PGConnection.class).getLargeObjectAPI(); - } + private static LargeObjectManager largeObjects(Connection c) throws SQLException { + return c.unwrap(PGConnection.class).getLargeObjectAPI(); + } - private byte[] read(long oid, long offset, int length) { - return QuarkusTransaction.requiringNew().call(() -> { - try (Connection c = dataSource.getConnection()) { - LargeObject lo = largeObjects(c).open(oid, LargeObjectManager.READ); - try { - lo.seek64(offset, LargeObject.SEEK_SET); - return lo.read(length); - } finally { - lo.close(); - } + private byte[] read(long oid, long offset, int length) { + return QuarkusTransaction.requiringNew() + .call(() -> { + try (Connection c = dataSource.getConnection()) { + LargeObject lo = largeObjects(c).open(oid, LargeObjectManager.READ); + try { + lo.seek64(offset, LargeObject.SEEK_SET); + return lo.read(length); + } finally { + lo.close(); } + } }); - } + } - private void writeAtEnd(long oid, byte[] data, int length) { - if (failWrites) throw new IllegalStateException("Log writes disabled for testing"); - QuarkusTransaction.requiringNew().call(() -> { - try (Connection c = dataSource.getConnection()) { - LargeObject lo = largeObjects(c).open(oid, LargeObjectManager.READWRITE); - try { - lo.seek64(0, LargeObject.SEEK_END); - lo.write(data, 0, length); - } finally { - lo.close(); - } + private void writeAtEnd(long oid, byte[] data, int length) { + if (failWrites) throw new IllegalStateException("Log writes disabled for testing"); + QuarkusTransaction.requiringNew() + .call(() -> { + try (Connection c = dataSource.getConnection()) { + LargeObject lo = largeObjects(c).open(oid, LargeObjectManager.READWRITE); + try { + lo.seek64(0, LargeObject.SEEK_END); + lo.write(data, 0, length); + } finally { + lo.close(); } - return null; + } + return null; }); - } + } - /** Never throws: a storage failure is recorded and later output is dropped, so the build is never blocked. */ - public final class Writer implements BuildRunner.LogSink, AutoCloseable { - private final long oid; - private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - private boolean closed; - private boolean failed; - private Throwable failure; - private final ScheduledFuture task; + /** + * Never throws: a storage failure is recorded and later output is dropped, so the build is never + * blocked. + */ + public final class Writer implements BuildRunner.LogSink, AutoCloseable { + private final long oid; + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private boolean closed; + private boolean failed; + private Throwable failure; + private final ScheduledFuture task; - private Writer(long oid) { - this.oid = oid; - long interval = flushIntervalMillis; - this.task = flusher.scheduleWithFixedDelay(this::periodicFlush, interval, interval, TimeUnit.MILLISECONDS); - } + private Writer(long oid) { + this.oid = oid; + long interval = flushIntervalMillis; + this.task = + flusher.scheduleWithFixedDelay( + this::periodicFlush, interval, interval, TimeUnit.MILLISECONDS); + } - @Override - public synchronized void append(byte[] bytes, int off, int len) { - while (len > 0 && !closed && !failed) { - int n = Math.min(len, FLUSH_BYTES - buffer.size()); - buffer.write(bytes, off, n); - off += n; - len -= n; - if (buffer.size() >= FLUSH_BYTES) flush(); - } - } + @Override + public synchronized void append(byte[] bytes, int off, int len) { + while (len > 0 && !closed && !failed) { + int n = Math.min(len, FLUSH_BYTES - buffer.size()); + buffer.write(bytes, off, n); + off += n; + len -= n; + if (buffer.size() >= FLUSH_BYTES) flush(); + } + } - public synchronized void line(String text) { - byte[] b = (text + "\n").getBytes(StandardCharsets.UTF_8); - append(b, 0, b.length); - } + public synchronized void line(String text) { + byte[] b = (text + "\n").getBytes(StandardCharsets.UTF_8); + append(b, 0, b.length); + } - public synchronized Throwable failure() { return failure; } + public synchronized Throwable failure() { + return failure; + } - /** For tests. */ - synchronized int buffered() { return buffer.size(); } + /** For tests. */ + synchronized int buffered() { + return buffer.size(); + } - private synchronized void periodicFlush() { - if (!closed) flush(); - } + private synchronized void periodicFlush() { + if (!closed) flush(); + } - private void flush() { - if (failed || buffer.size() == 0) return; - try { - writeAtEnd(oid, buffer.toByteArray(), buffer.size()); - buffer.reset(); - } catch (Throwable e) { - failure = e; - failed = true; - buffer.reset(); - } - } + private void flush() { + if (failed || buffer.size() == 0) return; + try { + writeAtEnd(oid, buffer.toByteArray(), buffer.size()); + buffer.reset(); + } catch (Throwable e) { + failure = e; + failed = true; + buffer.reset(); + } + } - @Override - public void close() { - task.cancel(false); - synchronized (this) { - if (closed) return; - flush(); - closed = true; - } - } + @Override + public void close() { + task.cancel(false); + synchronized (this) { + if (closed) return; + flush(); + closed = true; + } } + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java b/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java index 5f6ef20..ca394d9 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java @@ -7,6 +7,7 @@ */ package ai.mnemosyne.cisys; + import com.fasterxml.jackson.annotation.JsonInclude; import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; @@ -15,81 +16,168 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.eclipse.microprofile.jwt.JsonWebToken; import java.time.Instant; import java.util.List; +import org.eclipse.microprofile.jwt.JsonWebToken; -@Path("/api/projects/{projectId}/builds") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @Authenticated +@Path("/api/projects/{projectId}/builds") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Authenticated public class BuildResource { - @Inject SecurityIdentity identity; - @Inject BuildRunner runner; - @Inject BuildLogs logs; - @Inject RepositoryUrlValidator repositoryUrls; - /** The image and the commands come from the branch's {@value Workflow#PATH}. */ - public record RunRequest(String branch, String credentialRef) {} - /** Who requested the build and the credential reference are returned to admins only; the workflow file only for a single build. */ - public record BuildResponse(Long id, Long projectId, @JsonInclude(JsonInclude.Include.NON_NULL) String requestedBy, Build.Status status, - String revision, Instant startedAt, Instant finishedAt, String branch, - String image, @JsonInclude(JsonInclude.Include.NON_NULL) String credentialRef, - String imageDigest, Integer exitCode, boolean logTruncated, boolean pinned, - @JsonInclude(JsonInclude.Include.NON_NULL) String workflow) {} + @Inject SecurityIdentity identity; + @Inject BuildRunner runner; + @Inject BuildLogs logs; + @Inject RepositoryUrlValidator repositoryUrls; + + /** The image and the commands come from the branch's {@value Workflow#PATH}. */ + public record RunRequest(String branch, String credentialRef) {} + + /** + * Who requested the build and the credential reference are returned to admins only; the workflow + * file only for a single build. + */ + public record BuildResponse( + Long id, + Long projectId, + @JsonInclude(JsonInclude.Include.NON_NULL) String requestedBy, + Build.Status status, + String revision, + Instant startedAt, + Instant finishedAt, + String branch, + String image, + @JsonInclude(JsonInclude.Include.NON_NULL) String credentialRef, + String imageDigest, + Integer exitCode, + boolean logTruncated, + boolean pinned, + @JsonInclude(JsonInclude.Include.NON_NULL) String workflow) {} + + private BuildResponse response(Build build) { + return response(build, false); + } + + private BuildResponse response(Build build, boolean withWorkflow) { + boolean admin = identity.hasRole("admin"); + return new BuildResponse( + build.id, + build.projectId, + admin ? build.requestedBy : null, + build.status, + build.revision, + build.startedAt, + build.finishedAt, + build.branch, + build.image, + admin ? build.credentialRef : null, + build.imageDigest, + build.exitCode, + build.logTruncated, + build.pinned, + withWorkflow ? build.workflow : null); + } + + private Project visible(Long id) { + if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) + throw new ForbiddenException(); + if (!(identity.getPrincipal() instanceof JsonWebToken jwt) || jwt.getSubject() == null) + throw new NotAuthorizedException("Bearer"); + Project p = Project.findById(id); + if (p == null + || (p.privateRepository + && !identity.hasRole("admin") + && !jwt.getSubject().equals(p.ownerSubject))) throw new NotFoundException(); + return p; + } + + @GET + public List list(@PathParam("projectId") Long id) { + visible(id); + return Build.find("projectId = ?1 order by id desc", id).page(0, 50).list().stream() + .map(this::response) + .toList(); + } + + @GET + @Path("/{buildId}") + public BuildResponse detail(@PathParam("projectId") Long id, @PathParam("buildId") Long buildId) { + visible(id); + Build b = Build.findById(buildId); + if (b == null || !id.equals(b.projectId)) throw new NotFoundException(); + return response(b, true); + } + + /** + * A running build returns what is stored so far. Authenticated callers get the whole log by + * default; {@code offset}, {@code limit} and {@code tail} allow partial reads without a cap. + */ + @GET + @Path("/{buildId}/log") + @Produces("text/plain; charset=UTF-8") + public Response log( + @PathParam("projectId") Long id, + @PathParam("buildId") Long buildId, + @QueryParam("offset") Long offset, + @QueryParam("limit") Long limit, + @QueryParam("tail") Long tail) { + detail(id, buildId); + long size = logs.size(buildId); + BuildLogs.Range range; + try { + range = BuildLogs.range(size, offset, limit, tail, -1); + } catch (IllegalArgumentException e) { + throw new BadRequestException(e.getMessage()); + } + return LogResponses.stream(logs, buildId, size, range); + } + + @PUT + @Path("/{buildId}/pin") + @Transactional + public BuildResponse pin(@PathParam("projectId") Long id, @PathParam("buildId") Long buildId) { + return setPinned(id, buildId, true); + } + + @DELETE + @Path("/{buildId}/pin") + @Transactional + public BuildResponse unpin(@PathParam("projectId") Long id, @PathParam("buildId") Long buildId) { + return setPinned(id, buildId, false); + } + + /** Only the project's owner and admins may pin. */ + private BuildResponse setPinned(Long id, Long buildId, boolean pinned) { + Project p = visible(id); + if (!identity.hasRole("admin") + && !((JsonWebToken) identity.getPrincipal()).getSubject().equals(p.ownerSubject)) + throw new ForbiddenException(); + Build b = Build.findById(buildId); + if (b == null || !id.equals(b.projectId)) throw new NotFoundException(); + b.pinned = pinned; + return response(b, true); + } - private BuildResponse response(Build build) {return response(build,false);} - private BuildResponse response(Build build,boolean withWorkflow) { - boolean admin = identity.hasRole("admin"); - return new BuildResponse(build.id, build.projectId, admin ? build.requestedBy : null, build.status, - build.revision, build.startedAt, build.finishedAt, build.branch, - build.image, admin ? build.credentialRef : null, - build.imageDigest, build.exitCode, build.logTruncated, build.pinned, withWorkflow ? build.workflow : null); - } - private Project visible(Long id) { - if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); - if (!(identity.getPrincipal() instanceof JsonWebToken jwt) || jwt.getSubject()==null) throw new NotAuthorizedException("Bearer"); - Project p=Project.findById(id); - if(p==null || (p.privateRepository && !identity.hasRole("admin") && !jwt.getSubject().equals(p.ownerSubject))) throw new NotFoundException(); - return p; - } - @GET public List list(@PathParam("projectId") Long id) { - visible(id); - return Build.find("projectId = ?1 order by id desc",id).page(0,50).list() - .stream().map(this::response).toList(); - } - @GET @Path("/{buildId}") public BuildResponse detail(@PathParam("projectId") Long id,@PathParam("buildId") Long buildId) { - visible(id); Build b=Build.findById(buildId);if(b==null || !id.equals(b.projectId))throw new NotFoundException();return response(b,true); - } - /** - * A running build returns what is stored so far. Authenticated callers get the whole log by - * default; {@code offset}, {@code limit} and {@code tail} allow partial reads without a cap. - */ - @GET @Path("/{buildId}/log") @Produces("text/plain; charset=UTF-8") public Response log(@PathParam("projectId") Long id,@PathParam("buildId") Long buildId, - @QueryParam("offset") Long offset,@QueryParam("limit") Long limit,@QueryParam("tail") Long tail) { - detail(id,buildId); - long size=logs.size(buildId); - BuildLogs.Range range; - try{range=BuildLogs.range(size,offset,limit,tail,-1);}catch(IllegalArgumentException e){throw new BadRequestException(e.getMessage());} - return LogResponses.stream(logs,buildId,size,range); - } - @PUT @Path("/{buildId}/pin") @Transactional public BuildResponse pin(@PathParam("projectId") Long id,@PathParam("buildId") Long buildId) { - return setPinned(id,buildId,true); - } - @DELETE @Path("/{buildId}/pin") @Transactional public BuildResponse unpin(@PathParam("projectId") Long id,@PathParam("buildId") Long buildId) { - return setPinned(id,buildId,false); - } - /** Only the project's owner and admins may pin. */ - private BuildResponse setPinned(Long id,Long buildId,boolean pinned) { - Project p=visible(id); - if(!identity.hasRole("admin") && !((JsonWebToken)identity.getPrincipal()).getSubject().equals(p.ownerSubject))throw new ForbiddenException(); - Build b=Build.findById(buildId);if(b==null || !id.equals(b.projectId))throw new NotFoundException(); - b.pinned=pinned; - return response(b,true); - } - @POST public BuildResponse start(@PathParam("projectId") Long id,RunRequest input) { - Project p=visible(id); - if (!(identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); - if(input==null || input.branch()==null || !org.eclipse.jgit.lib.Repository.isValidRefName("refs/heads/"+input.branch()) || input.branch().startsWith("-") || input.branch().length()>150)throw new BadRequestException("Invalid branch"); - if(input.credentialRef()!=null && !identity.hasRole("admin"))throw new ForbiddenException(); - if(input.credentialRef()!=null && !input.credentialRef().matches("CISYS_GIT_[A-Z0-9_]{1,80}"))throw new BadRequestException("Invalid credential reference"); - try{repositoryUrls.validate(p.repositoryUrl);}catch(IllegalArgumentException e){throw new BadRequestException(e.getMessage());} - return response(runner.submit(id,((JsonWebToken)identity.getPrincipal()).getSubject(),p.repositoryUrl,input)); - } + @POST + public BuildResponse start(@PathParam("projectId") Long id, RunRequest input) { + Project p = visible(id); + if (!(identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); + if (input == null + || input.branch() == null + || !org.eclipse.jgit.lib.Repository.isValidRefName("refs/heads/" + input.branch()) + || input.branch().startsWith("-") + || input.branch().length() > 150) throw new BadRequestException("Invalid branch"); + if (input.credentialRef() != null && !identity.hasRole("admin")) throw new ForbiddenException(); + if (input.credentialRef() != null + && !input.credentialRef().matches("CISYS_GIT_[A-Z0-9_]{1,80}")) + throw new BadRequestException("Invalid credential reference"); + try { + repositoryUrls.validate(p.repositoryUrl); + } catch (IllegalArgumentException e) { + throw new BadRequestException(e.getMessage()); + } + return response( + runner.submit( + id, ((JsonWebToken) identity.getPrincipal()).getSubject(), p.repositoryUrl, input)); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BuildRetention.java b/src/backend/main/java/ai/mnemosyne/cisys/BuildRetention.java index b38c7ca..e5d58e4 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BuildRetention.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BuildRetention.java @@ -16,46 +16,57 @@ import java.time.Instant; /** - * Deletes, per project, the finished unpinned builds that are older than the maximum age or beyond the newest - * maximum count. Queued, running and pinned builds are never deleted. Logs go with their build (lo_manage trigger). + * Deletes, per project, the finished unpinned builds that are older than the maximum age or beyond + * the newest maximum count. Queued, running and pinned builds are never deleted. Logs go with their + * build (lo_manage trigger). */ @ApplicationScoped public class BuildRetention { - static final int BATCH = 500; + static final int BATCH = 500; - private static final String DELETE = """ - delete from build where status in ('PASSED', 'FAILED') and not pinned and id in ( - select id from ( - select id, coalesce(finishedat, startedat) as at, row_number() over ( - partition by projectid order by coalesce(finishedat, startedat) desc nulls last, id desc) as n - from build where status in ('PASSED', 'FAILED') and not pinned) eligible - where n > :maxBuilds or at < :cutoff - limit :batch) - """; + private static final String DELETE = + """ + delete from build where status in ('PASSED', 'FAILED') and not pinned and id in ( + select id from ( + select id, coalesce(finishedat, startedat) as at, row_number() over ( + partition by projectid order by coalesce(finishedat, startedat) desc nulls last, id desc) as n + from build where status in ('PASSED', 'FAILED') and not pinned) eligible + where n > :maxBuilds or at < :cutoff + limit :batch) + """; - /** The limits are set by admins under Settings, see {@link Settings}. */ - @Scheduled(every = "{cisys.retention.every}", delayed = "1m", concurrentExecution = Scheduled.ConcurrentExecution.SKIP) - void scheduled() { - try { - Settings s = QuarkusTransaction.requiringNew().call(Settings::current); - int deleted = purge(Instant.now(), s.retentionDays, s.retentionMaxBuilds); - if (deleted > 0) Log.infof("Retention deleted %d builds", deleted); - } catch (RuntimeException e) { - Log.error("Build retention failed", e); - } + /** The limits are set by admins under Settings, see {@link Settings}. */ + @Scheduled( + every = "{cisys.retention.every}", + delayed = "1m", + concurrentExecution = Scheduled.ConcurrentExecution.SKIP) + void scheduled() { + try { + Settings s = QuarkusTransaction.requiringNew().call(Settings::current); + int deleted = purge(Instant.now(), s.retentionDays, s.retentionMaxBuilds); + if (deleted > 0) Log.infof("Retention deleted %d builds", deleted); + } catch (RuntimeException e) { + Log.error("Build retention failed", e); } + } - /** A limit of 0 or less is turned off. Returns the number of builds deleted. */ - int purge(Instant now, int maxAgeDays, int maxBuilds) { - Instant cutoff = maxAgeDays > 0 ? now.minus(Duration.ofDays(maxAgeDays)) : Instant.EPOCH; - int max = maxBuilds > 0 ? maxBuilds : Integer.MAX_VALUE; - int total = 0, deleted; - do { - deleted = QuarkusTransaction.requiringNew().call(() -> Build.getEntityManager().createNativeQuery(DELETE) - .setParameter("maxBuilds", max).setParameter("cutoff", cutoff).setParameter("batch", BATCH) - .executeUpdate()); - total += deleted; - } while (deleted == BATCH); - return total; - } + /** A limit of 0 or less is turned off. Returns the number of builds deleted. */ + int purge(Instant now, int maxAgeDays, int maxBuilds) { + Instant cutoff = maxAgeDays > 0 ? now.minus(Duration.ofDays(maxAgeDays)) : Instant.EPOCH; + int max = maxBuilds > 0 ? maxBuilds : Integer.MAX_VALUE; + int total = 0, deleted; + do { + deleted = + QuarkusTransaction.requiringNew() + .call(() -> + Build.getEntityManager() + .createNativeQuery(DELETE) + .setParameter("maxBuilds", max) + .setParameter("cutoff", cutoff) + .setParameter("batch", BATCH) + .executeUpdate()); + total += deleted; + } while (deleted == BATCH); + return total; + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BuildRunner.java b/src/backend/main/java/ai/mnemosyne/cisys/BuildRunner.java index 0e05877..595c647 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BuildRunner.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BuildRunner.java @@ -7,19 +7,18 @@ */ package ai.mnemosyne.cisys; -import io.quarkus.narayana.jta.QuarkusTransaction; + import io.quarkus.arc.Arc; +import io.quarkus.logging.Log; +import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.runtime.ShutdownEvent; import io.quarkus.runtime.StartupEvent; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; -import jakarta.annotation.PreDestroy; -import jakarta.annotation.PostConstruct; -import io.quarkus.logging.Log; +import jakarta.inject.Inject; import jakarta.ws.rs.WebApplicationException; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.transport.RefSpec; -import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; @@ -29,379 +28,763 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; -import jakarta.inject.Inject; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.transport.RefSpec; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; import org.eclipse.microprofile.config.inject.ConfigProperty; -@ApplicationScoped public class BuildRunner { - @Inject BuildLogs logs; - @Inject RepositoryUrlValidator repositoryUrls; - @ConfigProperty(name="cisys.build.log-max-bytes",defaultValue="1073741824") long logMaxBytes; - String runtime; - /** Runs a container runtime command. Replaced in tests so cleanup never reaches the host. */ - interface RuntimeCommand{Result run(List args,int timeout,int limit)throws Exception;} - RuntimeCommand commands=BuildRunner::command; - /** Uses podman when it is installed, otherwise docker. */ - @PostConstruct void resolveRuntime(){ - runtime=onPath("podman")?"podman":onPath("docker")?"docker":"podman"; - Log.infof("Build runner uses the %s container runtime",runtime); - } - String runtime(){return runtime;} - private static boolean onPath(String program){ - for(String dir:System.getenv().getOrDefault("PATH","").split(File.pathSeparator)){if(!dir.isEmpty()&&Files.isExecutable(Path.of(dir,program)))return true;} - return false; - } - @ConfigProperty(name="cisys.build.concurrency",defaultValue="1") int concurrency; - @ConfigProperty(name="cisys.build.queue-limit",defaultValue="100") int queueLimit; - /** An admin's setting replaces the configured limit. */ - int concurrency(){Integer v=Settings.current().maxConcurrentBuilds;return v!=null?v:concurrency;} - int queueLimit(){Integer v=Settings.current().maxQueuedBuilds;return v!=null?v:queueLimit;} - private final ExecutorService executor=Executors.newCachedThreadPool(); - private final Map completions=new ConcurrentHashMap<>(); - record Completion(Long id,Build.Status status,String revision,Integer exitCode,String imageDigest, - boolean logTruncated,Instant finishedAt){} - /** Keep the result until its transaction commits; retry persistence, never execution. */ - void complete(Completion result){ - completions.put(result.id(),result); - retryCompletions(); - } - private synchronized void retryCompletions(){ - // After shutdown there is no transaction manager; the next start marks the build failed. - if(!running()){if(!completions.isEmpty())Log.warnf("Not saving the result of builds %s: the server has stopped",completions.keySet());return;} - for(Completion result:completions.values()){ - try{ - QuarkusTransaction.requiringNew().run(()->persistCompletion(result)); - completions.remove(result.id(),result); - }catch(RuntimeException e){Log.errorf(e,"Could not save result for build %d; will retry",result.id());} - } - } - /** Called inside a fresh transaction, also when retrying a failed commit. */ - void persistCompletion(Completion r){ - Build.update("status = ?1, revision = ?2, exitCode = ?3, imageDigest = ?4, logTruncated = ?5, finishedAt = ?6 where id = ?7 and status = ?8", - r.status(),r.revision(),r.exitCode(),r.imageDigest(),r.logTruncated(),r.finishedAt(),r.id(),Build.Status.RUNNING); - } - private volatile boolean ready; - private volatile boolean stopping; - /** Serialize admission and claims in the database, including the capacity check. */ - static void lockQueue(){ - Build.getEntityManager().createNativeQuery("select 1 from pg_advisory_xact_lock(1128878931)").getSingleResult(); - } - void recover(@Observes StartupEvent event){ - if(concurrency<1 || queueLimit<1)throw new IllegalArgumentException("Build concurrency and queue limit must be positive"); - cleanupOrphans(); - List running=QuarkusTransaction.requiringNew().call(()->Build.list("status",Build.Status.RUNNING).stream().map(b->b.id).toList()); - for(Long id:running){ - Throwable failure=logs.appendLine(id,"Server restarted during build"); - if(failure!=null)Log.errorf(failure,"Could not append to the log of build %d",id); - QuarkusTransaction.requiringNew().run(()->{ - Build b=Build.findById(id);b.status=Build.Status.FAILED;b.finishedAt=Instant.now(); - if(failure!=null)b.logTruncated=true; - }); - } - ready=true; - } - /** Containers a previous runner process left behind when it did not get to remove them. */ - List staleContainers(){ - if(runtime==null)return List.of(); - try{ - Result r=commands.run(List.of(runtime,"ps","-a","--format","{{.Names}}"),15,1_000_000); - if(r.timeout() || r.code()!=0)throw new IllegalStateException("Container listing failed"); - return r.output().lines().map(String::strip).filter(n->n.startsWith("cisys-build-")).toList(); - }catch(Exception e){ - throw new IllegalStateException("Could not list containers",e); - } - } - /** Removes one stale build container. */ - void removeContainer(String name){ - try{ - Result r=commands.run(List.of(runtime,"rm","-f",name),15,4096); - if(r.timeout() || r.code()!=0)throw new IllegalStateException("Container command failed"); - }catch(Exception e){ - throw new IllegalStateException("Could not remove container "+name,e); - } - } - /** Images of builds a previous runner process did not get to remove. */ - List staleImages(){ - if(runtime==null)return List.of(); - try{ - Result r=commands.run(List.of(runtime,"images","--format","{{.Repository}}"),15,1_000_000); - if(r.timeout() || r.code()!=0)throw new IllegalStateException("Image listing failed"); - return r.output().lines().map(String::strip).filter(n->n.matches("(localhost/)?cisys-build-[0-9]+")).distinct().toList(); - }catch(Exception e){ - throw new IllegalStateException("Could not list images",e); - } - } - void removeImage(String name){ - try{ - Result r=commands.run(List.of(runtime,"rmi","-f",name),60,4096); - if(r.timeout() || r.code()!=0)throw new IllegalStateException("Image command failed"); - }catch(Exception e){ - throw new IllegalStateException("Could not remove image "+name,e); - } - } - /** Root of the checkout directories created by {@link #run}. */ - Path checkoutRoot(){ - return Path.of(System.getProperty("java.io.tmpdir")); - } - /** Best-effort cleanup of a previous runner process. A failure is logged and does not stop startup. */ - private void cleanupOrphans(){ - try{ - cleanupOrphansUnchecked(); - }catch(RuntimeException e){ - Log.warn("Could not clean up orphaned runner state",e); - } - } - private void cleanupOrphansUnchecked(){ - try{ - for(String name:staleContainers()){ - try{removeContainer(name);Log.infof("Removed orphaned build container %s",name);} - catch(Exception e){Log.warnf(e,"Could not remove orphaned build container %s",name);} - } - }catch(RuntimeException e){ - Log.warn("Could not list orphaned build containers",e); - } - try{ - for(String image:staleImages()){ - try{removeImage(image);Log.infof("Removed orphaned build image %s",image);} - catch(Exception e){Log.warnf(e,"Could not remove orphaned build image %s",image);} - } - }catch(RuntimeException e){ - Log.warn("Could not list orphaned build images",e); - } - Path root=checkoutRoot(); - if(!Files.isDirectory(root))return; - try(var entries=Files.list(root)){ - for(Path path:entries.filter(p->p.getFileName().toString().startsWith("cisys-")).toList()){ - try{ - if(Files.isSymbolicLink(path))Files.deleteIfExists(path);else delete(path); - }catch(Exception e){ - Log.warnf(e,"Could not remove stale checkout %s",path); +@ApplicationScoped +public class BuildRunner { + @Inject BuildLogs logs; + @Inject RepositoryUrlValidator repositoryUrls; + + @ConfigProperty(name = "cisys.build.log-max-bytes", defaultValue = "1073741824") + long logMaxBytes; + + String runtime; + + /** Runs a container runtime command. Replaced in tests so cleanup never reaches the host. */ + interface RuntimeCommand { + Result run(List args, int timeout, int limit) throws Exception; + } + + RuntimeCommand commands = BuildRunner::command; + + /** Uses podman when it is installed, otherwise docker. */ + @PostConstruct + void resolveRuntime() { + runtime = onPath("podman") ? "podman" : onPath("docker") ? "docker" : "podman"; + Log.infof("Build runner uses the %s container runtime", runtime); + } + + String runtime() { + return runtime; + } + + private static boolean onPath(String program) { + for (String dir : System.getenv().getOrDefault("PATH", "").split(File.pathSeparator)) { + if (!dir.isEmpty() && Files.isExecutable(Path.of(dir, program))) return true; + } + return false; + } + + @ConfigProperty(name = "cisys.build.concurrency", defaultValue = "1") + int concurrency; + + @ConfigProperty(name = "cisys.build.queue-limit", defaultValue = "100") + int queueLimit; + + /** An admin's setting replaces the configured limit. */ + int concurrency() { + Integer v = Settings.current().maxConcurrentBuilds; + return v != null ? v : concurrency; + } + + int queueLimit() { + Integer v = Settings.current().maxQueuedBuilds; + return v != null ? v : queueLimit; + } + + private final ExecutorService executor = Executors.newCachedThreadPool(); + private final Map completions = new ConcurrentHashMap<>(); + + record Completion( + Long id, + Build.Status status, + String revision, + Integer exitCode, + String imageDigest, + boolean logTruncated, + Instant finishedAt) {} + + /** Keep the result until its transaction commits; retry persistence, never execution. */ + void complete(Completion result) { + completions.put(result.id(), result); + retryCompletions(); + } + + private synchronized void retryCompletions() { + // After shutdown there is no transaction manager; the next start marks the build failed. + if (!running()) { + if (!completions.isEmpty()) + Log.warnf( + "Not saving the result of builds %s: the server has stopped", completions.keySet()); + return; + } + for (Completion result : completions.values()) { + try { + QuarkusTransaction.requiringNew().run(() -> persistCompletion(result)); + completions.remove(result.id(), result); + } catch (RuntimeException e) { + Log.errorf(e, "Could not save result for build %d; will retry", result.id()); + } + } + } + + /** Called inside a fresh transaction, also when retrying a failed commit. */ + void persistCompletion(Completion r) { + Build.update( + "status = ?1, revision = ?2, exitCode = ?3, imageDigest = ?4, logTruncated = ?5, finishedAt = ?6 where id = ?7 and status = ?8", + r.status(), + r.revision(), + r.exitCode(), + r.imageDigest(), + r.logTruncated(), + r.finishedAt(), + r.id(), + Build.Status.RUNNING); + } + + private volatile boolean ready; + private volatile boolean stopping; + + /** Serialize admission and claims in the database, including the capacity check. */ + static void lockQueue() { + Build.getEntityManager() + .createNativeQuery("select 1 from pg_advisory_xact_lock(1128878931)") + .getSingleResult(); + } + + void recover(@Observes StartupEvent event) { + if (concurrency < 1 || queueLimit < 1) + throw new IllegalArgumentException("Build concurrency and queue limit must be positive"); + cleanupOrphans(); + List running = + QuarkusTransaction.requiringNew() + .call(() -> + Build.list("status", Build.Status.RUNNING).stream().map(b -> b.id).toList()); + for (Long id : running) { + Throwable failure = logs.appendLine(id, "Server restarted during build"); + if (failure != null) Log.errorf(failure, "Could not append to the log of build %d", id); + QuarkusTransaction.requiringNew() + .run(() -> { + Build b = Build.findById(id); + b.status = Build.Status.FAILED; + b.finishedAt = Instant.now(); + if (failure != null) b.logTruncated = true; + }); + } + ready = true; + } + + /** Containers a previous runner process left behind when it did not get to remove them. */ + List staleContainers() { + if (runtime == null) return List.of(); + try { + Result r = + commands.run(List.of(runtime, "ps", "-a", "--format", "{{.Names}}"), 15, 1_000_000); + if (r.timeout() || r.code() != 0) throw new IllegalStateException("Container listing failed"); + return r.output() + .lines() + .map(String::strip) + .filter(n -> n.startsWith("cisys-build-")) + .toList(); + } catch (Exception e) { + throw new IllegalStateException("Could not list containers", e); + } + } + + /** Removes one stale build container. */ + void removeContainer(String name) { + try { + Result r = commands.run(List.of(runtime, "rm", "-f", name), 15, 4096); + if (r.timeout() || r.code() != 0) throw new IllegalStateException("Container command failed"); + } catch (Exception e) { + throw new IllegalStateException("Could not remove container " + name, e); + } + } + + /** Images of builds a previous runner process did not get to remove. */ + List staleImages() { + if (runtime == null) return List.of(); + try { + Result r = + commands.run(List.of(runtime, "images", "--format", "{{.Repository}}"), 15, 1_000_000); + if (r.timeout() || r.code() != 0) throw new IllegalStateException("Image listing failed"); + return r.output() + .lines() + .map(String::strip) + .filter(n -> n.matches("(localhost/)?cisys-build-[0-9]+")) + .distinct() + .toList(); + } catch (Exception e) { + throw new IllegalStateException("Could not list images", e); + } + } + + void removeImage(String name) { + try { + Result r = commands.run(List.of(runtime, "rmi", "-f", name), 60, 4096); + if (r.timeout() || r.code() != 0) throw new IllegalStateException("Image command failed"); + } catch (Exception e) { + throw new IllegalStateException("Could not remove image " + name, e); + } + } + + /** Root of the checkout directories created by {@link #run}. */ + Path checkoutRoot() { + return Path.of(System.getProperty("java.io.tmpdir")); + } + + /** + * Best-effort cleanup of a previous runner process. A failure is logged and does not stop + * startup. + */ + private void cleanupOrphans() { + try { + cleanupOrphansUnchecked(); + } catch (RuntimeException e) { + Log.warn("Could not clean up orphaned runner state", e); + } + } + + private void cleanupOrphansUnchecked() { + try { + for (String name : staleContainers()) { + try { + removeContainer(name); + Log.infof("Removed orphaned build container %s", name); + } catch (Exception e) { + Log.warnf(e, "Could not remove orphaned build container %s", name); + } + } + } catch (RuntimeException e) { + Log.warn("Could not list orphaned build containers", e); + } + try { + for (String image : staleImages()) { + try { + removeImage(image); + Log.infof("Removed orphaned build image %s", image); + } catch (Exception e) { + Log.warnf(e, "Could not remove orphaned build image %s", image); + } + } + } catch (RuntimeException e) { + Log.warn("Could not list orphaned build images", e); + } + Path root = checkoutRoot(); + if (!Files.isDirectory(root)) return; + try (var entries = Files.list(root)) { + for (Path path : + entries.filter(p -> p.getFileName().toString().startsWith("cisys-")).toList()) { + try { + if (Files.isSymbolicLink(path)) Files.deleteIfExists(path); + else delete(path); + } catch (Exception e) { + Log.warnf(e, "Could not remove stale checkout %s", path); + } + } + } catch (Exception e) { + Log.warnf(e, "Could not list stale checkouts in %s", root); + } + } + + Build submit(Long projectId, String actor, String url, BuildResource.RunRequest request) { + return QuarkusTransaction.requiringNew() + .call(() -> { + lockQueue(); + if (Project.findById(projectId) == null) throw new jakarta.ws.rs.NotFoundException(); + if (Build.count("status", Build.Status.QUEUED) >= queueLimit()) + throw new WebApplicationException( + jakarta.ws.rs.core.Response.status(429) + .type(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) + .entity(Map.of("error", "Build queue is full. Try again later.")) + .build()); + Build b = new Build(); + b.projectId = projectId; + b.requestedBy = actor; + b.status = Build.Status.QUEUED; + b.requestedAt = Instant.now(); + b.repositoryUrl = url; + b.branch = request.branch(); + b.credentialRef = request.credentialRef(); + b.persist(); + return b; + }); + } + + /** Committed RUNNING rows reserve slots before any task is handed to the executor. */ + Build claim() { + return QuarkusTransaction.requiringNew() + .call(() -> { + lockQueue(); + if (Build.count("status", Build.Status.RUNNING) >= concurrency()) return null; + Build b = + Build.find("status = ?1 order by requestedAt, id", Build.Status.QUEUED) + .firstResult(); + if (b == null) return null; + int changed = + Build.update( + "status = ?1, startedAt = ?2 where id = ?3 and status = ?4", + Build.Status.RUNNING, + Instant.now(), + b.id, + Build.Status.QUEUED); + if (changed != 1) return null; + Build.getEntityManager().refresh(b); + return b; + }); + } + + @io.quarkus.scheduler.Scheduled( + every = "1s", + concurrentExecution = io.quarkus.scheduler.Scheduled.ConcurrentExecution.SKIP) + synchronized void dispatch() { + if (!ready || stopping) return; + try { + retryCompletions(); + Build b; + while ((b = claim()) != null) { + Build task = b; + try { + schedule(() -> execute(task)); + } catch (RejectedExecutionException e) { + QuarkusTransaction.requiringNew() + .run(() -> + Build.update( + "status = ?1, startedAt = null where id = ?2 and status = ?3", + Build.Status.QUEUED, + task.id, + Build.Status.RUNNING)); + throw e; + } + } + } catch (RuntimeException e) { + Log.error("Build queue dispatch failed; will retry", e); } - } - }catch(Exception e){ - Log.warnf(e,"Could not list stale checkouts in %s",root); - } - } - Build submit(Long projectId,String actor,String url,BuildResource.RunRequest request){ - return QuarkusTransaction.requiringNew().call(()->{ - lockQueue(); - if(Project.findById(projectId)==null)throw new jakarta.ws.rs.NotFoundException(); - if(Build.count("status",Build.Status.QUEUED)>=queueLimit())throw new WebApplicationException(jakarta.ws.rs.core.Response.status(429).type(jakarta.ws.rs.core.MediaType.APPLICATION_JSON).entity(Map.of("error","Build queue is full. Try again later.")).build()); - Build b=new Build(); - b.projectId=projectId;b.requestedBy=actor;b.status=Build.Status.QUEUED; - b.requestedAt=Instant.now();b.repositoryUrl=url; - b.branch=request.branch();b.credentialRef=request.credentialRef(); - b.persist();return b; - }); - } - /** Committed RUNNING rows reserve slots before any task is handed to the executor. */ - Build claim(){ - return QuarkusTransaction.requiringNew().call(()->{ - lockQueue(); - if(Build.count("status",Build.Status.RUNNING)>=concurrency())return null; - Build b=Build.find("status = ?1 order by requestedAt, id",Build.Status.QUEUED).firstResult(); - if(b==null)return null; - int changed=Build.update("status = ?1, startedAt = ?2 where id = ?3 and status = ?4", - Build.Status.RUNNING,Instant.now(),b.id,Build.Status.QUEUED); - if(changed!=1)return null; - Build.getEntityManager().refresh(b); - return b; - }); - } - @io.quarkus.scheduler.Scheduled(every="1s",concurrentExecution=io.quarkus.scheduler.Scheduled.ConcurrentExecution.SKIP) - synchronized void dispatch(){ - if(!ready || stopping)return; - try{ - retryCompletions(); - Build b; - while((b=claim())!=null){ - Build task=b; - try{schedule(()->execute(task));} - catch(RejectedExecutionException e){ - QuarkusTransaction.requiringNew().run(()->Build.update("status = ?1, startedAt = null where id = ?2 and status = ?3",Build.Status.QUEUED,task.id,Build.Status.RUNNING)); - throw e; + } + + /** Hands a claimed build to the executor. Overridden in tests to simulate a rejected schedule. */ + void schedule(Runnable task) { + executor.submit(task); + } + + void execute(Build b) { + run(b.id, b.repositoryUrl, new BuildResource.RunRequest(b.branch, b.credentialRef)); + } + + /** For tests waiting for the queue to drain. */ + boolean hasPendingBuilds() { + return QuarkusTransaction.requiringNew() + .call(() -> + Build.count("status in ?1", List.of(Build.Status.QUEUED, Build.Status.RUNNING)) > 0); + } + + private void run(Long id, String url, BuildResource.RunRequest request) { + Path dir = null; + String name = "cisys-build-" + id, revision = null, status = "FAILED", digest = null; + Integer exit = null; + boolean truncated = false; + Throwable captureError = null; + BuildLogs.Writer log = null; + try { + log = logs.open(id); + // Checked again here because DNS can change after the project was saved. + repositoryUrls.validate(url); + dir = Files.createTempDirectory(checkoutRoot(), "cisys-"); + try (Git git = Git.init().setDirectory(dir.toFile()).call()) { + var cfg = git.getRepository().getConfig(); + cfg.setString("http", null, "followRedirects", "false"); + cfg.save(); + var fetch = + git.fetch() + .setRemote(url) + .setRefSpecs( + new RefSpec("+refs/heads/" + request.branch() + ":refs/remotes/origin/build")) + .setDepth(1) + .setTimeout(60); + if (request.credentialRef() != null) { + String token = System.getenv(request.credentialRef()); + if (token == null || token.isBlank()) + throw new IllegalStateException("Missing Git credential"); + fetch.setCredentialsProvider(new UsernamePasswordCredentialsProvider("oauth2", token)); + } + fetch.call(); + git.checkout() + .setName("build") + .setCreateBranch(true) + .setStartPoint("refs/remotes/origin/build") + .call(); + revision = git.getRepository().resolve("HEAD").name(); + } + // The workflow on the fetched branch describes the image. This checkout is only read for the + // workflow file; + // the image clones the branch itself. + String text = Workflow.read(dir); + delete(dir); + dir = null; + if (text == null) throw new IllegalArgumentException("The branch has no " + Workflow.PATH); + // Stored before parsing, so a build keeps the file that it ran or that failed validation. + QuarkusTransaction.requiringNew() + .run(() -> Build.update("workflow = ?1 where id = ?2", text, id)); + Workflow workflow = Workflow.parse(text); + String base = workflow.container(); + ImageReference.Resolved from = ImageReference.resolve(base, localImages()); + QuarkusTransaction.requiringNew() + .run(() -> Build.update("image = ?1 where id = ?2", from.name(), id)); + // Allowed when the admin allowed any name of the image, whichever way the workflow names it. + if (!QuarkusTransaction.requiringNew() + .call(() -> from.names().stream().anyMatch(AllowedImage::isAllowed))) + throw new IllegalArgumentException("Image " + base + " is not allowed"); + String containerfile = + workflow.containerfile( + from.id(), url, request.branch(), revision, request.credentialRef() != null); + log.line( + "Workflow " + + workflow.name() + + " on " + + from.name() + + " (" + + from.id().substring(0, 12) + + ")"); + dir = Files.createTempDirectory(checkoutRoot(), "cisys-"); + Path context = Files.createDirectory(dir.resolve("context")); + Path file = Files.writeString(dir.resolve("Containerfile"), containerfile); + List build = + new ArrayList<>( + List.of( + runtime, + "build", + "--no-cache", + "--pull=never", + "-f", + file.toString(), + "-t", + name)); + if (runtime.equals("podman")) + build.addAll( + List.of( + "--layers=false", + "--security-opt=no-new-privileges", + "--memory=1g", + "--cpu-period=100000", + "--cpu-quota=100000")); + else build.add("--progress=plain"); + // The runtime reads the token from its environment, so it never appears in an argument or in + // the image. + if (request.credentialRef() != null) + build.add("--secret=id=" + Workflow.GIT_SECRET + ",env=" + request.credentialRef()); + build.add(context.toString()); + Streamed result = stream(build, 600, log, logMaxBytes); + captureError = result.readerError(); + if (result.truncated()) { + truncated = true; + log.line( + "[output truncated after " + + logMaxBytes + + " bytes; " + + (result.totalBytes() - logMaxBytes) + + " further bytes discarded]"); + } + digest = digest(from.id()); + if (result.timeout()) log.line("Build timed out"); + else { + // The image build stops at the first failing RUN and exits with its exit code. + exit = result.code(); + status = exit == 0 ? "PASSED" : "FAILED"; + } + } catch (InterruptedException e) { + if (log != null) log.line("Build stopped: the server is shutting down"); + else captureError = e; + } catch (IllegalArgumentException e) { + if (log != null) log.line("Build failed: " + e.getMessage()); + else captureError = e; + } catch (Exception e) { + if (log != null) + log.line( + stopping + ? "Build stopped: the server is shutting down" + : "Build failed during checkout or container execution (" + + e.getClass().getSimpleName() + + "). Check the runner configuration."); + else captureError = e; + } finally { + // A shutdown interrupts the build; the cleanup and the result must still run, so clear the + // flag until done. + boolean interrupted = Thread.interrupted(); + // The image only exists to run the workflow. + try { + command(List.of(runtime, "rmi", "-f", name), 60, 4096); + } catch (Exception ignored) { + } + if (dir != null) + try { + delete(dir); + } catch (Exception ignored) { + } + // A log failure must not stop the status update. + try { + if (log != null && running()) { + if (captureError == null) captureError = log.failure(); + if (captureError != null) + log.line("[log capture failed: " + captureError.getClass().getSimpleName() + "]"); + log.close(); + if (captureError == null) captureError = log.failure(); + } + } catch (Throwable t) { + if (captureError == null) captureError = t; + } + if (captureError != null) { + truncated = true; + Log.errorf(captureError, "Log capture failed for build %d", id); + } + complete( + new Completion( + id, Build.Status.valueOf(status), revision, exit, digest, truncated, Instant.now())); + if (interrupted) Thread.currentThread().interrupt(); } - } - }catch(RuntimeException e){Log.error("Build queue dispatch failed; will retry",e);} - } - /** Hands a claimed build to the executor. Overridden in tests to simulate a rejected schedule. */ - void schedule(Runnable task){executor.submit(task);} - void execute(Build b){run(b.id,b.repositoryUrl,new BuildResource.RunRequest(b.branch,b.credentialRef));} - /** For tests waiting for the queue to drain. */ - boolean hasPendingBuilds(){return QuarkusTransaction.requiringNew().call(()->Build.count("status in ?1",List.of(Build.Status.QUEUED,Build.Status.RUNNING))>0);} - private void run(Long id,String url,BuildResource.RunRequest request){ - Path dir=null;String name="cisys-build-"+id,revision=null,status="FAILED",digest=null;Integer exit=null;boolean truncated=false;Throwable captureError=null;BuildLogs.Writer log=null; - try { - log=logs.open(id); - // Checked again here because DNS can change after the project was saved. - repositoryUrls.validate(url); - dir=Files.createTempDirectory(checkoutRoot(),"cisys-"); - try(Git git=Git.init().setDirectory(dir.toFile()).call()){ - var cfg=git.getRepository().getConfig();cfg.setString("http",null,"followRedirects","false");cfg.save(); - var fetch=git.fetch().setRemote(url).setRefSpecs(new RefSpec("+refs/heads/"+request.branch()+":refs/remotes/origin/build")).setDepth(1).setTimeout(60); - if(request.credentialRef()!=null){String token=System.getenv(request.credentialRef());if(token==null || token.isBlank())throw new IllegalStateException("Missing Git credential");fetch.setCredentialsProvider(new UsernamePasswordCredentialsProvider("oauth2",token));} - fetch.call();git.checkout().setName("build").setCreateBranch(true).setStartPoint("refs/remotes/origin/build").call();revision=git.getRepository().resolve("HEAD").name(); - } - // The workflow on the fetched branch describes the image. This checkout is only read for the workflow file; - // the image clones the branch itself. - String text=Workflow.read(dir); - delete(dir);dir=null; - if(text==null)throw new IllegalArgumentException("The branch has no "+Workflow.PATH); - // Stored before parsing, so a build keeps the file that it ran or that failed validation. - QuarkusTransaction.requiringNew().run(()->Build.update("workflow = ?1 where id = ?2",text,id)); - Workflow workflow=Workflow.parse(text); - String base=workflow.container(); - ImageReference.Resolved from=ImageReference.resolve(base,localImages()); - QuarkusTransaction.requiringNew().run(()->Build.update("image = ?1 where id = ?2",from.name(),id)); - // Allowed when the admin allowed any name of the image, whichever way the workflow names it. - if(!QuarkusTransaction.requiringNew().call(()->from.names().stream().anyMatch(AllowedImage::isAllowed)))throw new IllegalArgumentException("Image "+base+" is not allowed"); - String containerfile=workflow.containerfile(from.id(),url,request.branch(),revision,request.credentialRef()!=null); - log.line("Workflow "+workflow.name()+" on "+from.name()+" ("+from.id().substring(0,12)+")"); - dir=Files.createTempDirectory(checkoutRoot(),"cisys-"); - Path context=Files.createDirectory(dir.resolve("context")); - Path file=Files.writeString(dir.resolve("Containerfile"),containerfile); - List build=new ArrayList<>(List.of(runtime,"build","--no-cache","--pull=never","-f",file.toString(),"-t",name)); - if(runtime.equals("podman"))build.addAll(List.of("--layers=false","--security-opt=no-new-privileges","--memory=1g","--cpu-period=100000","--cpu-quota=100000")); - else build.add("--progress=plain"); - // The runtime reads the token from its environment, so it never appears in an argument or in the image. - if(request.credentialRef()!=null)build.add("--secret=id="+Workflow.GIT_SECRET+",env="+request.credentialRef()); - build.add(context.toString()); - Streamed result=stream(build,600,log,logMaxBytes); - captureError=result.readerError(); - if(result.truncated()){truncated=true;log.line("[output truncated after "+logMaxBytes+" bytes; "+(result.totalBytes()-logMaxBytes)+" further bytes discarded]");} - digest=digest(from.id()); - if(result.timeout())log.line("Build timed out"); - else { - // The image build stops at the first failing RUN and exits with its exit code. - exit=result.code(); - status=exit==0?"PASSED":"FAILED"; - } - } catch(InterruptedException e){ - if(log!=null)log.line("Build stopped: the server is shutting down");else captureError=e; - } catch(IllegalArgumentException e){ - if(log!=null)log.line("Build failed: "+e.getMessage());else captureError=e; - } catch(Exception e){ - if(log!=null)log.line(stopping?"Build stopped: the server is shutting down" - :"Build failed during checkout or container execution ("+e.getClass().getSimpleName()+"). Check the runner configuration."); - else captureError=e; - } - finally { - // A shutdown interrupts the build; the cleanup and the result must still run, so clear the flag until done. - boolean interrupted=Thread.interrupted(); - // The image only exists to run the workflow. - try{command(List.of(runtime,"rmi","-f",name),60,4096);}catch(Exception ignored){} - if(dir!=null)try{delete(dir);}catch(Exception ignored){} - // A log failure must not stop the status update. - try{ - if(log!=null && running()){ - if(captureError==null)captureError=log.failure(); - if(captureError!=null)log.line("[log capture failed: "+captureError.getClass().getSimpleName()+"]"); - log.close(); - if(captureError==null)captureError=log.failure(); + } + + /** Every local image with its full ID, for resolving a workflow's base image. */ + List localImages() throws Exception { + Result r = + command( + List.of( + runtime, "images", "--no-trunc", "--format", "{{.Repository}}\t{{.Tag}}\t{{.ID}}"), + 30, + 1_000_000); + if (r.timeout() || r.code() != 0) throw new IllegalStateException("Container command failed"); + List images = new ArrayList<>(); + for (String line : r.output().split("\n")) { + String[] f = line.strip().split("\t"); + if (f.length != 3) continue; + String id = f[2].replace("sha256:", ""); + if (id.matches("[0-9a-f]{64}")) images.add(new ImageReference.Local(f[0], f[1], id)); } - }catch(Throwable t){if(captureError==null)captureError=t;} - if(captureError!=null){truncated=true;Log.errorf(captureError,"Log capture failed for build %d",id);} - complete(new Completion(id,Build.Status.valueOf(status),revision,exit,digest,truncated,Instant.now())); - if(interrupted)Thread.currentThread().interrupt(); - } - } - /** Every local image with its full ID, for resolving a workflow's base image. */ - List localImages()throws Exception{ - Result r=command(List.of(runtime,"images","--no-trunc","--format","{{.Repository}}\t{{.Tag}}\t{{.ID}}"),30,1_000_000); - if(r.timeout()||r.code()!=0)throw new IllegalStateException("Container command failed"); - List images=new ArrayList<>(); - for(String line:r.output().split("\n")){ - String[] f=line.strip().split("\t"); - if(f.length!=3)continue; - String id=f[2].replace("sha256:",""); - if(id.matches("[0-9a-f]{64}"))images.add(new ImageReference.Local(f[0],f[1],id)); - } - return images; - } - record LocalImage(String name,String id,String size,String created){} - /** Lists the tagged images in the local container runtime. */ - List images()throws Exception{ - Result r=command(List.of(runtime,"images","--format","{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}\t{{.CreatedSince}}"),30,1000000); - if(r.timeout()||r.code()!=0)throw new IllegalStateException("Container command failed"); - Map images=new TreeMap<>(); - for(String line:r.output().split("\n")){ - String[] f=line.split("\t"); - if(f.length<5||f[0].equals("")||f[1].equals(""))continue; - String name=f[0]+":"+f[1]; - if(name.matches(AllowedImage.PATTERN))images.putIfAbsent(name,new LocalImage(name,f[2],f[3],f[4])); - } - return List.copyOf(images.values()); - } - /** Pulls an image into the local container runtime. Returns the runtime's output when the pull fails. */ - Optional pull(String name)throws Exception{Result r=command(List.of(runtime,"pull",name),600,8192);return r.code()==0&&!r.timeout()?Optional.empty():Optional.of(r.timeout()?"Pull timed out":tail(r.output()));} - /** Removes an image from the local container runtime. Returns the runtime's output when the removal fails. */ - Optional remove(String name)throws Exception{Result r=command(List.of(runtime,"rmi",name),60,8192);return r.code()==0&&!r.timeout()?Optional.empty():Optional.of(r.timeout()?"Removal timed out":tail(r.output()));} - private static String tail(String s){s=s.strip();return s.length()>500?"…"+s.substring(s.length()-500):s;} - record Result(int code,boolean timeout,String output){} - static Result command(List args,int timeout,int limit)throws Exception{ - Process p=new ProcessBuilder(args).redirectErrorStream(true).start();p.getOutputStream().close();ByteArrayOutputStream buffer=new ByteArrayOutputStream();boolean[] truncated={false}; - Thread reader=Thread.ofPlatform().daemon(true).start(()->{try(var in=p.getInputStream()){byte[] bytes=new byte[8192];int n;while((n=in.read(bytes))!=-1){synchronized(buffer){int count=Math.min(n,limit-buffer.size());buffer.write(bytes,0,count);if(count args,int timeout)throws Exception{Result r=command(args,timeout,4096);if(r.timeout()||r.code()!=0)throw new IllegalStateException("Container command failed");return r.output();} - String digest(String image){ - try{String d=checked(List.of(runtime,"image","inspect","--format","{{index .RepoDigests 0}}",image),15).trim();return d.length()<=300&&d.matches("\\S+@sha256:[0-9a-f]{64}")?d:null;} - catch(Exception e){return null;} - } - - interface LogSink{void append(byte[] bytes,int off,int len);} - record Streamed(int code,boolean timeout,boolean truncated,long totalBytes,Throwable readerError){} - - static Streamed stream(List args,int timeout,LogSink sink,long maxBytes)throws Exception{return stream(args,timeout,sink,maxBytes,30_000,5_000);} - /** Output beyond maxBytes, or after the sink fails, is still read and discarded so the process never blocks. */ - static Streamed stream(List args,int timeout,LogSink sink,long maxBytes,long drainMillis,long forceMillis)throws Exception{ - Process p=new ProcessBuilder(args).redirectErrorStream(true).start();p.getOutputStream().close(); - InputStream raw=p.getInputStream(); - AtomicLong total=new AtomicLong(); - AtomicReference error=new AtomicReference<>(); - AtomicBoolean forced=new AtomicBoolean(); - Thread reader=Thread.ofPlatform().daemon(true).name("cisys-log-reader").start(()->{ - byte[] buf=new byte[8192];boolean sinkOk=true;int n; - try{ - while((n=raw.read(buf))!=-1){ - long before=total.getAndAdd(n); - int keep=(int)Math.max(0,Math.min(n,maxBytes-before)); - if(keep>0&&sinkOk){try{sink.append(buf,0,keep);}catch(Throwable t){error.compareAndSet(null,t);sinkOk=false;}} + return images; + } + + record LocalImage(String name, String id, String size, String created) {} + + /** Lists the tagged images in the local container runtime. */ + List images() throws Exception { + Result r = + command( + List.of( + runtime, + "images", + "--format", + "{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}\t{{.CreatedSince}}"), + 30, + 1000000); + if (r.timeout() || r.code() != 0) throw new IllegalStateException("Container command failed"); + Map images = new TreeMap<>(); + for (String line : r.output().split("\n")) { + String[] f = line.split("\t"); + if (f.length < 5 || f[0].equals("") || f[1].equals("")) continue; + String name = f[0] + ":" + f[1]; + if (name.matches(AllowedImage.PATTERN)) + images.putIfAbsent(name, new LocalImage(name, f[2], f[3], f[4])); } - }catch(Throwable t){if(!forced.get())error.compareAndSet(null,t);} - }); - try{ - boolean done=p.waitFor(timeout,TimeUnit.SECONDS); - if(!done){p.destroyForcibly();p.waitFor(5,TimeUnit.SECONDS);} - reader.join(drainMillis); - if(reader.isAlive()){ - // Something still holds the pipe open, such as a background child. - forced.set(true); - p.descendants().forEach(ProcessHandle::destroyForcibly); - try{raw.close();}catch(IOException ignored){} - reader.join(forceMillis); - } - Throwable err=error.get(); - if(err==null&&reader.isAlive())err=new IllegalStateException("Log reader did not stop"); - return new Streamed(done?p.exitValue():-1,!done,total.get()>maxBytes,total.get(),err); - }finally{if(p.isAlive())p.destroyForcibly();} - } - - static void delete(Path dir)throws IOException{try(var files=Files.walk(dir)){for(Path f:files.sorted(Comparator.reverseOrder()).toList())Files.deleteIfExists(f);}} - /** Whether the CDI container, and with it the database and transactions, is still available. */ - static boolean running(){var c=Arc.container();return c!=null && c.isRunning();} - /** Seconds a shutdown waits for running builds to save their log and result. */ - static final int STOP_SECONDS=30; - /** Runs while every bean still works, so interrupted builds can save their log and result. */ - void onShutdown(@Observes ShutdownEvent event){stop();} - /** - * Interrupts the running builds and waits for them to finish their cleanup. Not synchronized: a build's cleanup - * takes this object's lock to save its result. - */ - @PreDestroy void stop(){ - stopping=true; - executor.shutdownNow(); - try{ - if(!executor.awaitTermination(STOP_SECONDS,TimeUnit.SECONDS))Log.warnf("Builds still running after %d seconds of shutdown",STOP_SECONDS); - }catch(InterruptedException e){Thread.currentThread().interrupt();} - } + return List.copyOf(images.values()); + } + + /** + * Pulls an image into the local container runtime. Returns the runtime's output when the pull + * fails. + */ + Optional pull(String name) throws Exception { + Result r = command(List.of(runtime, "pull", name), 600, 8192); + return r.code() == 0 && !r.timeout() + ? Optional.empty() + : Optional.of(r.timeout() ? "Pull timed out" : tail(r.output())); + } + + /** + * Removes an image from the local container runtime. Returns the runtime's output when the + * removal fails. + */ + Optional remove(String name) throws Exception { + Result r = command(List.of(runtime, "rmi", name), 60, 8192); + return r.code() == 0 && !r.timeout() + ? Optional.empty() + : Optional.of(r.timeout() ? "Removal timed out" : tail(r.output())); + } + + private static String tail(String s) { + s = s.strip(); + return s.length() > 500 ? "…" + s.substring(s.length() - 500) : s; + } + + record Result(int code, boolean timeout, String output) {} + + static Result command(List args, int timeout, int limit) throws Exception { + Process p = new ProcessBuilder(args).redirectErrorStream(true).start(); + p.getOutputStream().close(); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + boolean[] truncated = {false}; + Thread reader = + Thread.ofPlatform() + .daemon(true) + .start(() -> { + try (var in = p.getInputStream()) { + byte[] bytes = new byte[8192]; + int n; + while ((n = in.read(bytes)) != -1) { + synchronized (buffer) { + int count = Math.min(n, limit - buffer.size()); + buffer.write(bytes, 0, count); + if (count < n) truncated[0] = true; + } + } + } catch (IOException ignored) { + } + }); + try { + boolean done = p.waitFor(timeout, TimeUnit.SECONDS); + if (!done) { + p.destroyForcibly(); + p.waitFor(5, TimeUnit.SECONDS); + } + reader.join(2000); + synchronized (buffer) { + return new Result( + done ? p.exitValue() : -1, + !done, + buffer.toString(StandardCharsets.UTF_8) + + (truncated[0] ? "\n[output truncated]\n" : "")); + } + } finally { + if (p.isAlive()) p.destroyForcibly(); + } + } + + static String checked(List args, int timeout) throws Exception { + Result r = command(args, timeout, 4096); + if (r.timeout() || r.code() != 0) throw new IllegalStateException("Container command failed"); + return r.output(); + } + + String digest(String image) { + try { + String d = + checked( + List.of( + runtime, "image", "inspect", "--format", "{{index .RepoDigests 0}}", image), + 15) + .trim(); + return d.length() <= 300 && d.matches("\\S+@sha256:[0-9a-f]{64}") ? d : null; + } catch (Exception e) { + return null; + } + } + + interface LogSink { + void append(byte[] bytes, int off, int len); + } + + record Streamed( + int code, boolean timeout, boolean truncated, long totalBytes, Throwable readerError) {} + + static Streamed stream(List args, int timeout, LogSink sink, long maxBytes) + throws Exception { + return stream(args, timeout, sink, maxBytes, 30_000, 5_000); + } + + /** + * Output beyond maxBytes, or after the sink fails, is still read and discarded so the process + * never blocks. + */ + static Streamed stream( + List args, + int timeout, + LogSink sink, + long maxBytes, + long drainMillis, + long forceMillis) + throws Exception { + Process p = new ProcessBuilder(args).redirectErrorStream(true).start(); + p.getOutputStream().close(); + InputStream raw = p.getInputStream(); + AtomicLong total = new AtomicLong(); + AtomicReference error = new AtomicReference<>(); + AtomicBoolean forced = new AtomicBoolean(); + Thread reader = + Thread.ofPlatform() + .daemon(true) + .name("cisys-log-reader") + .start(() -> { + byte[] buf = new byte[8192]; + boolean sinkOk = true; + int n; + try { + while ((n = raw.read(buf)) != -1) { + long before = total.getAndAdd(n); + int keep = (int) Math.max(0, Math.min(n, maxBytes - before)); + if (keep > 0 && sinkOk) { + try { + sink.append(buf, 0, keep); + } catch (Throwable t) { + error.compareAndSet(null, t); + sinkOk = false; + } + } + } + } catch (Throwable t) { + if (!forced.get()) error.compareAndSet(null, t); + } + }); + try { + boolean done = p.waitFor(timeout, TimeUnit.SECONDS); + if (!done) { + p.destroyForcibly(); + p.waitFor(5, TimeUnit.SECONDS); + } + reader.join(drainMillis); + if (reader.isAlive()) { + // Something still holds the pipe open, such as a background child. + forced.set(true); + p.descendants().forEach(ProcessHandle::destroyForcibly); + try { + raw.close(); + } catch (IOException ignored) { + } + reader.join(forceMillis); + } + Throwable err = error.get(); + if (err == null && reader.isAlive()) + err = new IllegalStateException("Log reader did not stop"); + return new Streamed( + done ? p.exitValue() : -1, !done, total.get() > maxBytes, total.get(), err); + } finally { + if (p.isAlive()) p.destroyForcibly(); + } + } + + static void delete(Path dir) throws IOException { + try (var files = Files.walk(dir)) { + for (Path f : files.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(f); + } + } + + /** Whether the CDI container, and with it the database and transactions, is still available. */ + static boolean running() { + var c = Arc.container(); + return c != null && c.isRunning(); + } + + /** Seconds a shutdown waits for running builds to save their log and result. */ + static final int STOP_SECONDS = 30; + + /** Runs while every bean still works, so interrupted builds can save their log and result. */ + void onShutdown(@Observes ShutdownEvent event) { + stop(); + } + + /** + * Interrupts the running builds and waits for them to finish their cleanup. Not synchronized: a + * build's cleanup takes this object's lock to save its result. + */ + @PreDestroy + void stop() { + stopping = true; + executor.shutdownNow(); + try { + if (!executor.awaitTermination(STOP_SECONDS, TimeUnit.SECONDS)) + Log.warnf("Builds still running after %d seconds of shutdown", STOP_SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/GitRepositoryService.java b/src/backend/main/java/ai/mnemosyne/cisys/GitRepositoryService.java index 4761750..e6d9808 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/GitRepositoryService.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/GitRepositoryService.java @@ -10,90 +10,94 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.Ref; -import org.eclipse.jgit.util.FileUtils; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collection; import java.util.List; import java.util.TreeSet; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.util.FileUtils; /** - * Read-only JGit operations shared by the REST API and, later, by the workflow support. - * Keeping the Git access here means callers never have to talk to JGit directly. + * Read-only JGit operations shared by the REST API and, later, by the workflow support. Keeping the + * Git access here means callers never have to talk to JGit directly. */ @ApplicationScoped public class GitRepositoryService { - static final int TIMEOUT_SECONDS = 10; + static final int TIMEOUT_SECONDS = 10; - @Inject RepositoryUrlValidator repositoryUrls; + @Inject RepositoryUrlValidator repositoryUrls; - /** Branches of the project's repository. Private projects need credentials, so they are not read. */ - public List listBranches(Project project) { - if (project.privateRepository) return List.of(); - return listBranches(project.repositoryUrl); - } + /** + * Branches of the project's repository. Private projects need credentials, so they are not read. + */ + public List listBranches(Project project) { + if (project.privateRepository) return List.of(); + return listBranches(project.repositoryUrl); + } - /** Branches of an HTTPS repository on an allowed host. */ - public List listBranches(String repositoryUrl) { - repositoryUrls.validate(repositoryUrl); - try { - return branchNames(remoteBranchRefs(repositoryUrl)); - } catch (GitAPIException | RuntimeException e) { - throw new BranchLookupException("Unable to read branches from the repository", e); - } + /** Branches of an HTTPS repository on an allowed host. */ + public List listBranches(String repositoryUrl) { + repositoryUrls.validate(repositoryUrl); + try { + return branchNames(remoteBranchRefs(repositoryUrl)); + } catch (GitAPIException | RuntimeException e) { + throw new BranchLookupException("Unable to read branches from the repository", e); } + } - /** The remote refs used for branch discovery. Overridden in tests so they never reach the network. */ - protected Collection remoteBranchRefs(String repositoryUrl) throws GitAPIException { - Path dir = null; + /** + * The remote refs used for branch discovery. Overridden in tests so they never reach the network. + */ + protected Collection remoteBranchRefs(String repositoryUrl) throws GitAPIException { + Path dir = null; + try { + dir = Files.createTempDirectory("cisys-branches-"); + try (Git git = Git.init().setDirectory(dir.toFile()).call()) { + var cfg = git.getRepository().getConfig(); + cfg.setString("http", null, "followRedirects", "false"); + cfg.save(); + return git + .lsRemote() + .setRemote(repositoryUrl) + .setHeads(true) + .setTimeout(TIMEOUT_SECONDS) + .call() + .stream() + .map(Ref::getName) + .toList(); + } + } catch (IOException e) { + throw new GitAPIException("Unable to prepare the repository", e) {}; + } finally { + if (dir != null) { try { - dir = Files.createTempDirectory("cisys-branches-"); - try (Git git = Git.init().setDirectory(dir.toFile()).call()) { - var cfg = git.getRepository().getConfig(); - cfg.setString("http", null, "followRedirects", "false"); - cfg.save(); - return git.lsRemote() - .setRemote(repositoryUrl) - .setHeads(true) - .setTimeout(TIMEOUT_SECONDS) - .call() - .stream() - .map(Ref::getName) - .toList(); - } - } catch (IOException e) { - throw new GitAPIException("Unable to prepare the repository", e) {}; - } finally { - if (dir != null) { - try { - FileUtils.delete(dir.toFile(), FileUtils.RECURSIVE | FileUtils.RETRY); - } catch (IOException ignored) { - // The temporary directory is not part of the result. - } - } + FileUtils.delete(dir.toFile(), FileUtils.RECURSIVE | FileUtils.RETRY); + } catch (IOException ignored) { + // The temporary directory is not part of the result. } + } } + } - /** Keeps only local branch refs, without duplicates, sorted. */ - static List branchNames(Collection refs) { - TreeSet names = new TreeSet<>(); - for (String ref : refs) { - if (ref == null || !ref.startsWith("refs/heads/")) continue; - String name = ref.substring("refs/heads/".length()); - if (!name.isBlank()) names.add(name); - } - return List.copyOf(names); + /** Keeps only local branch refs, without duplicates, sorted. */ + static List branchNames(Collection refs) { + TreeSet names = new TreeSet<>(); + for (String ref : refs) { + if (ref == null || !ref.startsWith("refs/heads/")) continue; + String name = ref.substring("refs/heads/".length()); + if (!name.isBlank()) names.add(name); } + return List.copyOf(names); + } - /** The remote repository could not be read. */ - public static class BranchLookupException extends RuntimeException { - public BranchLookupException(String message, Throwable cause) { - super(message, cause); - } + /** The remote repository could not be read. */ + public static class BranchLookupException extends RuntimeException { + public BranchLookupException(String message, Throwable cause) { + super(message, cause); } + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/HostResolver.java b/src/backend/main/java/ai/mnemosyne/cisys/HostResolver.java index d1494f3..26180d3 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/HostResolver.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/HostResolver.java @@ -13,5 +13,5 @@ /** Resolves a host name to every address it has. Replaced in tests so they never use DNS. */ public interface HostResolver { - InetAddress[] resolve(String host) throws UnknownHostException; + InetAddress[] resolve(String host) throws UnknownHostException; } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ImageReference.java b/src/backend/main/java/ai/mnemosyne/cisys/ImageReference.java index 78267f5..1703705 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ImageReference.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ImageReference.java @@ -14,83 +14,115 @@ /** * Finds the local image a workflow's {@code container} means. These all name the same image when - * {@code docker.io/library/alpine:3.23} with ID {@code 164b5c6a230f…} is the only local Alpine image: - * {@code alpine}, {@code alpine:3.23}, {@code docker.io/library/alpine}, {@code docker.io/library/alpine:3.23} and - * {@code 164b5c6a230f}. + * {@code docker.io/library/alpine:3.23} with ID {@code 164b5c6a230f…} is the only local Alpine + * image: {@code alpine}, {@code alpine:3.23}, {@code docker.io/library/alpine}, {@code + * docker.io/library/alpine:3.23} and {@code 164b5c6a230f}. */ public final class ImageReference { - private ImageReference() {} + private ImageReference() {} - /** One row of the runtime's image list. {@code id} is the full hexadecimal ID without {@code sha256:}. */ - public record Local(String repository, String tag, String id) { - String name() { - return repository + ":" + tag; - } + /** + * One row of the runtime's image list. {@code id} is the full hexadecimal ID without {@code + * sha256:}. + */ + public record Local(String repository, String tag, String id) { + String name() { + return repository + ":" + tag; + } - boolean named() { - return !repository.equals("") && !tag.equals(""); - } + boolean named() { + return !repository.equals("") && !tag.equals(""); } + } - /** The image to build from: its full ID and every name the runtime knows it by, sorted. */ - public record Resolved(String id, List names) { - /** The name to show for the build. */ - public String name() { - return names.isEmpty() ? id.substring(0, 12) : names.get(0); - } + /** The image to build from: its full ID and every name the runtime knows it by, sorted. */ + public record Resolved(String id, List names) { + /** The name to show for the build. */ + public String name() { + return names.isEmpty() ? id.substring(0, 12) : names.get(0); } + } - private static final String ID = "(sha256:)?[0-9a-f]{12,64}"; + private static final String ID = "(sha256:)?[0-9a-f]{12,64}"; - /** Resolves {@code base} against the local images, or throws an error that is safe to show in the build log. */ - public static Resolved resolve(String base, List images) { - String reference = base.strip(); - if (reference.matches(ID)) { - String prefix = reference.toLowerCase(Locale.ROOT).replace("sha256:", ""); - List ids = images.stream().map(Local::id).filter(id -> id.startsWith(prefix)).distinct().toList(); - if (ids.size() > 1) throw new Workflow.InvalidWorkflowException("Image ID " + base + " matches several local images"); - if (ids.size() == 1) return resolved(ids.get(0), images); - // Not an ID after all; a name may also look like one. - } - if (reference.contains("@")) { - throw new Workflow.InvalidWorkflowException("Image " + base + ": digest references are not supported, use the image ID"); - } - int slash = reference.lastIndexOf('/'); - int colon = reference.lastIndexOf(':'); - String repository = colon > slash ? reference.substring(0, colon) : reference; - String tag = colon > slash ? reference.substring(colon + 1) : null; - String qualified = official(repository); - List candidates = images.stream().filter(Local::named).filter(i -> i.repository().equals(qualified)).toList(); - if (tag != null) { - return candidates.stream().filter(i -> i.tag().equals(tag)).findFirst() - .map(i -> resolved(i.id(), images)) - .orElseThrow(() -> notAvailable(base)); - } - List ids = candidates.stream().map(Local::id).distinct().toList(); - if (ids.isEmpty()) throw notAvailable(base); - if (ids.size() == 1) return resolved(ids.get(0), images); - // Several versions are present: an untagged name means latest, as for the runtime itself. - return candidates.stream().filter(i -> i.tag().equals("latest")).findFirst() - .map(i -> resolved(i.id(), images)) - .orElseThrow(() -> new Workflow.InvalidWorkflowException("Image " + base + " matches several local images (" - + String.join(", ", candidates.stream().map(Local::name).sorted().toList()) + "); add the tag")); + /** + * Resolves {@code base} against the local images, or throws an error that is safe to show in the + * build log. + */ + public static Resolved resolve(String base, List images) { + String reference = base.strip(); + if (reference.matches(ID)) { + String prefix = reference.toLowerCase(Locale.ROOT).replace("sha256:", ""); + List ids = + images.stream().map(Local::id).filter(id -> id.startsWith(prefix)).distinct().toList(); + if (ids.size() > 1) + throw new Workflow.InvalidWorkflowException( + "Image ID " + base + " matches several local images"); + if (ids.size() == 1) return resolved(ids.get(0), images); + // Not an ID after all; a name may also look like one. } - - /** A repository without a registry is the official image on Docker Hub, for example {@code alpine}. */ - static String official(String repository) { - int slash = repository.indexOf('/'); - String first = slash < 0 ? "" : repository.substring(0, slash); - if (first.contains(".") || first.contains(":") || first.equals("localhost")) return repository; - return "docker.io/" + (slash < 0 ? "library/" : "") + repository; + if (reference.contains("@")) { + throw new Workflow.InvalidWorkflowException( + "Image " + base + ": digest references are not supported, use the image ID"); } - - private static Resolved resolved(String id, List images) { - List names = images.stream().filter(i -> i.id().equals(id)).filter(Local::named).map(Local::name) - .distinct().sorted(Comparator.naturalOrder()).toList(); - return new Resolved(id, names); + int slash = reference.lastIndexOf('/'); + int colon = reference.lastIndexOf(':'); + String repository = colon > slash ? reference.substring(0, colon) : reference; + String tag = colon > slash ? reference.substring(colon + 1) : null; + String qualified = official(repository); + List candidates = + images.stream().filter(Local::named).filter(i -> i.repository().equals(qualified)).toList(); + if (tag != null) { + return candidates.stream() + .filter(i -> i.tag().equals(tag)) + .findFirst() + .map(i -> resolved(i.id(), images)) + .orElseThrow(() -> notAvailable(base)); } + List ids = candidates.stream().map(Local::id).distinct().toList(); + if (ids.isEmpty()) throw notAvailable(base); + if (ids.size() == 1) return resolved(ids.get(0), images); + // Several versions are present: an untagged name means latest, as for the runtime itself. + return candidates.stream() + .filter(i -> i.tag().equals("latest")) + .findFirst() + .map(i -> resolved(i.id(), images)) + .orElseThrow(() -> + new Workflow.InvalidWorkflowException( + "Image " + + base + + " matches several local images (" + + String.join(", ", candidates.stream().map(Local::name).sorted().toList()) + + "); add the tag")); + } - private static Workflow.InvalidWorkflowException notAvailable(String base) { - return new Workflow.InvalidWorkflowException("Image " + base + " is not available on the build host; an admin can pull it on the Images page"); - } + /** + * A repository without a registry is the official image on Docker Hub, for example {@code + * alpine}. + */ + static String official(String repository) { + int slash = repository.indexOf('/'); + String first = slash < 0 ? "" : repository.substring(0, slash); + if (first.contains(".") || first.contains(":") || first.equals("localhost")) return repository; + return "docker.io/" + (slash < 0 ? "library/" : "") + repository; + } + + private static Resolved resolved(String id, List images) { + List names = + images.stream() + .filter(i -> i.id().equals(id)) + .filter(Local::named) + .map(Local::name) + .distinct() + .sorted(Comparator.naturalOrder()) + .toList(); + return new Resolved(id, names); + } + + private static Workflow.InvalidWorkflowException notAvailable(String base) { + return new Workflow.InvalidWorkflowException( + "Image " + + base + + " is not available on the build host; an admin can pull it on the Images page"); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ImageResource.java b/src/backend/main/java/ai/mnemosyne/cisys/ImageResource.java index 845e43d..e3bbab3 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ImageResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ImageResource.java @@ -17,78 +17,129 @@ import jakarta.ws.rs.core.Response; import java.util.*; -@Path("/api/images") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) +@Path("/api/images") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) @Authenticated public class ImageResource { - @Inject SecurityIdentity identity; - @Inject BuildRunner runner; + @Inject SecurityIdentity identity; + @Inject BuildRunner runner; - public record Selection(List images) {} - public record Pull(String name) {} - public record LocalImage(String name, String id, String size, String created, boolean allowed) {} + public record Selection(List images) {} - /** The images allowed for builds, for the build form. */ - @GET public List allowed() { - if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); - return names(); - } + public record Pull(String name) {} - /** The images present in the local container runtime, marked with whether each is allowed. */ - @GET @Path("/local") public List local() { - requireAdmin(); - Set allowed = new HashSet<>(names()); - List local; - try { local = runner.images(); } - catch (Exception e) { throw failure(unreachable()); } - return local.stream().map(i -> new LocalImage(i.name(), i.id(), i.size(), i.created(), allowed.contains(i.name()))).toList(); - } + public record LocalImage(String name, String id, String size, String created, boolean allowed) {} - /** Pulls an image into the local container runtime. */ - @POST @Path("/local") public List pull(Pull input) { - requireAdmin(); - String name = validName(input == null ? null : input.name()); - Optional failed; - try { failed = runner.pull(name); } catch (Exception e) { throw failure(unreachable()); } - if (failed.isPresent()) throw failure("Unable to pull " + name + ": " + failed.get()); - return local(); - } + /** The images allowed for builds, for the build form. */ + @GET + public List allowed() { + if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) + throw new ForbiddenException(); + return names(); + } - /** Removes an image from the local container runtime, and from the allowed images. */ - @DELETE @Path("/local") public List remove(@QueryParam("name") String name) { - requireAdmin(); - validName(name); - Optional failed; - try { failed = runner.remove(name); } catch (Exception e) { throw failure(unreachable()); } - if (failed.isPresent()) throw failure("Unable to remove " + name + ": " + failed.get()); - io.quarkus.narayana.jta.QuarkusTransaction.requiringNew().run(() -> AllowedImage.deleteById(name)); - return local(); + /** The images present in the local container runtime, marked with whether each is allowed. */ + @GET + @Path("/local") + public List local() { + requireAdmin(); + Set allowed = new HashSet<>(names()); + List local; + try { + local = runner.images(); + } catch (Exception e) { + throw failure(unreachable()); } + return local.stream() + .map(i -> + new LocalImage(i.name(), i.id(), i.size(), i.created(), allowed.contains(i.name()))) + .toList(); + } - /** Replaces the set of allowed images. */ - @PUT @Transactional public List update(Selection input) { - requireAdmin(); - if (input == null || input.images() == null || input.images().size() > 500) throw new BadRequestException("A list of images is required"); - Set wanted = new TreeSet<>(); - for (String name : input.images()) wanted.add(validName(name)); - AllowedImage.delete("name not in ?1", wanted.isEmpty() ? Set.of("") : wanted); - for (String name : wanted) { - if (AllowedImage.findById(name) == null) { AllowedImage image = new AllowedImage(); image.name = name; image.persist(); } - } - return names(); + /** Pulls an image into the local container runtime. */ + @POST + @Path("/local") + public List pull(Pull input) { + requireAdmin(); + String name = validName(input == null ? null : input.name()); + Optional failed; + try { + failed = runner.pull(name); + } catch (Exception e) { + throw failure(unreachable()); } + if (failed.isPresent()) throw failure("Unable to pull " + name + ": " + failed.get()); + return local(); + } - private String unreachable() { - return "Unable to run '" + runner.runtime() + "' on the backend host. Install podman or docker."; + /** Removes an image from the local container runtime, and from the allowed images. */ + @DELETE + @Path("/local") + public List remove(@QueryParam("name") String name) { + requireAdmin(); + validName(name); + Optional failed; + try { + failed = runner.remove(name); + } catch (Exception e) { + throw failure(unreachable()); } - private void requireAdmin() { if (!identity.hasRole("admin")) throw new ForbiddenException(); } - private static String validName(String name) { - if (name == null || !name.matches(AllowedImage.PATTERN)) throw new WebApplicationException(Response.status(400).type(MediaType.APPLICATION_JSON).entity(Map.of("error", "Invalid image name")).build()); - return name; - } - private static WebApplicationException failure(String message) { - return new WebApplicationException(Response.status(502).type(MediaType.APPLICATION_JSON).entity(Map.of("error", message)).build()); - } - private static List names() { - return AllowedImage.listAll().stream().map(i -> i.name).sorted().toList(); + if (failed.isPresent()) throw failure("Unable to remove " + name + ": " + failed.get()); + io.quarkus.narayana.jta.QuarkusTransaction.requiringNew() + .run(() -> AllowedImage.deleteById(name)); + return local(); + } + + /** Replaces the set of allowed images. */ + @PUT + @Transactional + public List update(Selection input) { + requireAdmin(); + if (input == null || input.images() == null || input.images().size() > 500) + throw new BadRequestException("A list of images is required"); + Set wanted = new TreeSet<>(); + for (String name : input.images()) wanted.add(validName(name)); + AllowedImage.delete("name not in ?1", wanted.isEmpty() ? Set.of("") : wanted); + for (String name : wanted) { + if (AllowedImage.findById(name) == null) { + AllowedImage image = new AllowedImage(); + image.name = name; + image.persist(); + } } + return names(); + } + + private String unreachable() { + return "Unable to run '" + + runner.runtime() + + "' on the backend host. Install podman or docker."; + } + + private void requireAdmin() { + if (!identity.hasRole("admin")) throw new ForbiddenException(); + } + + private static String validName(String name) { + if (name == null || !name.matches(AllowedImage.PATTERN)) + throw new WebApplicationException( + Response.status(400) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of("error", "Invalid image name")) + .build()); + return name; + } + + private static WebApplicationException failure(String message) { + return new WebApplicationException( + Response.status(502) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of("error", message)) + .build()); + } + + private static List names() { + return AllowedImage.listAll().stream().map(i -> i.name).sorted().toList(); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/LogResponses.java b/src/backend/main/java/ai/mnemosyne/cisys/LogResponses.java index 1d0283d..dfbddc9 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/LogResponses.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/LogResponses.java @@ -13,18 +13,19 @@ /** The response shape shared by the authenticated and public build-log endpoints. */ final class LogResponses { - private LogResponses() {} + private LogResponses() {} - /** - * Streams the resolved range as plain text. The headers let a client continue from the end of - * this response without downloading the whole log again. - */ - static Response stream(BuildLogs logs, long buildId, long size, BuildLogs.Range range) { - return Response.ok((StreamingOutput) out -> logs.streamTo(buildId, out, range.offset(), range.length())) - .type("text/plain; charset=UTF-8") - .header("X-Log-Size", size) - .header("X-Log-Offset", range.offset()) - .header("X-Log-Length", range.length()) - .build(); - } + /** + * Streams the resolved range as plain text. The headers let a client continue from the end of + * this response without downloading the whole log again. + */ + static Response stream(BuildLogs logs, long buildId, long size, BuildLogs.Range range) { + return Response.ok( + (StreamingOutput) out -> logs.streamTo(buildId, out, range.offset(), range.length())) + .type("text/plain; charset=UTF-8") + .header("X-Log-Size", size) + .header("X-Log-Offset", range.offset()) + .header("X-Log-Length", range.length()) + .build(); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/Project.java b/src/backend/main/java/ai/mnemosyne/cisys/Project.java index 0e15df8..c29cfb9 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/Project.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/Project.java @@ -9,13 +9,20 @@ package ai.mnemosyne.cisys; import io.quarkus.hibernate.orm.panache.PanacheEntity; -import jakarta.persistence.Entity; import jakarta.persistence.Column; +import jakarta.persistence.Entity; @Entity public class Project extends PanacheEntity { - @Column(nullable = false, length = 100, unique = true) public String name; - @Column(nullable = false, length = 2048, unique = true) public String repositoryUrl; - @Column(nullable = false) public boolean privateRepository; - @Column(nullable = false, length = 255) public String ownerSubject; + @Column(nullable = false, length = 100, unique = true) + public String name; + + @Column(nullable = false, length = 2048, unique = true) + public String repositoryUrl; + + @Column(nullable = false) + public boolean privateRepository; + + @Column(nullable = false, length = 255) + public String ownerSubject; } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectAccess.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectAccess.java index 4f2ac09..36df40f 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ProjectAccess.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectAccess.java @@ -16,24 +16,31 @@ import org.eclipse.microprofile.jwt.JsonWebToken; /** - * Visibility rules shared by project pages, their builds, logs and branches. - * Private projects are only readable by their owner and admins; everyone else gets a 404, - * so the existence of a private project is not revealed. + * Visibility rules shared by project pages, their builds, logs and branches. Private projects are + * only readable by their owner and admins; everyone else gets a 404, so the existence of a private + * project is not revealed. */ @ApplicationScoped public class ProjectAccess { - @Inject SecurityIdentity identity; + @Inject SecurityIdentity identity; - public Project visible(String name) { - Project p = Project.find("lower(name) = ?1", name.trim().toLowerCase(Locale.ROOT)).firstResult(); - if (p == null || (p.privateRepository && !canSeePrivate(p))) throw new NotFoundException(); - return p; - } + public Project visible(String name) { + Project p = + Project.find("lower(name) = ?1", name.trim().toLowerCase(Locale.ROOT)) + .firstResult(); + if (p == null || (p.privateRepository && !canSeePrivate(p))) throw new NotFoundException(); + return p; + } - /** Same rule as the authenticated project and build endpoints: a Cisys role, and admin or owner. */ - boolean canSeePrivate(Project p) { - if (identity.isAnonymous() || !(identity.getPrincipal() instanceof JsonWebToken jwt) || jwt.getSubject() == null) return false; - if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) return false; - return identity.hasRole("admin") || jwt.getSubject().equals(p.ownerSubject); - } + /** + * Same rule as the authenticated project and build endpoints: a Cisys role, and admin or owner. + */ + boolean canSeePrivate(Project p) { + if (identity.isAnonymous() + || !(identity.getPrincipal() instanceof JsonWebToken jwt) + || jwt.getSubject() == null) return false; + if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) + return false; + return identity.hasRole("admin") || jwt.getSubject().equals(p.ownerSubject); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectBranchResource.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectBranchResource.java index bc06a99..319dcc5 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ProjectBranchResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectBranchResource.java @@ -17,49 +17,54 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; - import java.util.List; import java.util.Map; /** - * Branches of a project page. Public projects read the repository. Private projects use the branches - * that already have builds, because reading a private repository needs credentials; anonymous callers - * and users without access get a 404, just like the rest of the private page. + * Branches of a project page. Public projects read the repository. Private projects use the + * branches that already have builds, because reading a private repository needs credentials; + * anonymous callers and users without access get a 404, just like the rest of the private page. */ @Path("/api/project-pages/{name}/branches") @Produces(MediaType.APPLICATION_JSON) @PermitAll @PublicRateLimited public class ProjectBranchResource { - @Inject ProjectAccess access; - @Inject GitRepositoryService repositories; - @Inject EntityManager entityManager; + @Inject ProjectAccess access; + @Inject GitRepositoryService repositories; + @Inject EntityManager entityManager; - public record Branches(List branches, String reason) {} + public record Branches(List branches, String reason) {} - @GET - public Response list(@PathParam("name") String name) { - Project project = access.visible(name); - if (project.privateRepository) { - return Response.ok(new Branches(buildBranches(project), null)).build(); - } - try { - return Response.ok(new Branches(repositories.listBranches(project), null)).build(); - } catch (IllegalArgumentException e) { - return error(400, e.getMessage()); - } catch (GitRepositoryService.BranchLookupException e) { - return error(502, e.getMessage()); - } + @GET + public Response list(@PathParam("name") String name) { + Project project = access.visible(name); + if (project.privateRepository) { + return Response.ok(new Branches(buildBranches(project), null)).build(); } - - private List buildBranches(Project project) { - return entityManager.createQuery("select distinct b.branch from Build b where b.projectId = :projectId" - + " and b.branch is not null and b.branch <> '' order by b.branch", String.class) - .setParameter("projectId", project.id) - .getResultList(); + try { + return Response.ok(new Branches(repositories.listBranches(project), null)).build(); + } catch (IllegalArgumentException e) { + return error(400, e.getMessage()); + } catch (GitRepositoryService.BranchLookupException e) { + return error(502, e.getMessage()); } + } - private static Response error(int status, String message) { - return Response.status(status).type(MediaType.APPLICATION_JSON).entity(Map.of("error", message)).build(); - } + private List buildBranches(Project project) { + return entityManager + .createQuery( + "select distinct b.branch from Build b where b.projectId = :projectId" + + " and b.branch is not null and b.branch <> '' order by b.branch", + String.class) + .setParameter("projectId", project.id) + .getResultList(); + } + + private static Response error(int status, String message) { + return Response.status(status) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of("error", message)) + .build(); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java index 849fb22..2aa27c8 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java @@ -25,84 +25,127 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; /** - * The page of a single project, addressed by its name (for example /cisys/ in the UI). - * Public projects are readable by anyone, including visitors who are not signed in. - * Private projects are only readable by their owner and admins; everyone else gets a 404, - * so the existence of a private project is not revealed. + * The page of a single project, addressed by its name (for example /cisys/ in the UI). Public + * projects are readable by anyone, including visitors who are not signed in. Private projects are + * only readable by their owner and admins; everyone else gets a 404, so the existence of a private + * project is not revealed. */ @Path("/api/project-pages/{name}") @Produces(MediaType.APPLICATION_JSON) @PermitAll @PublicRateLimited public class ProjectPageResource { - @Inject ProjectAccess access; - @Inject BuildLogs logs; - @Inject SecurityIdentity identity; - /** Anonymous callers may read at most this many bytes of one log in one response. */ - @ConfigProperty(name = "cisys.public.log-max-bytes", defaultValue = "2097152") long publicLogMaxBytes; + @Inject ProjectAccess access; + @Inject BuildLogs logs; + @Inject SecurityIdentity identity; - public record ProjectInfo(Long id, String name, String repositoryUrl, boolean privateRepository) {} - /** Leaves out who requested the build and any credential reference, as the page may be anonymous. */ - public record PageBuild(Long id, Long projectId, Build.Status status, String revision, String branch, String image, - Instant startedAt, Instant finishedAt, String imageDigest, Integer exitCode, - boolean logTruncated, boolean pinned, boolean canPin, - @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) String workflow) {} - public record ProjectPage(ProjectInfo project, List builds) {} + /** Anonymous callers may read at most this many bytes of one log in one response. */ + @ConfigProperty(name = "cisys.public.log-max-bytes", defaultValue = "2097152") + long publicLogMaxBytes; - @GET - public ProjectPage page(@PathParam("name") String name, @QueryParam("branch") String branch) { - Project p = access.visible(name); - String branchFilter = branch == null ? "" : branch.trim(); - List rows = branchFilter.isEmpty() - ? Build.find("projectId = ?1 order by id desc", p.id).page(0, 50).list() - : Build.find("projectId = ?1 and branch = ?2 order by id desc", p.id, branchFilter).page(0, 50).list(); - // Whoever may see a private project may also pin its builds. - boolean canPin = access.canSeePrivate(p); - List builds = rows.stream().map(b -> pageBuild(b, canPin, false)).toList(); - return new ProjectPage(new ProjectInfo(p.id, p.name, p.repositoryUrl, p.privateRepository), builds); - } + public record ProjectInfo( + Long id, String name, String repositoryUrl, boolean privateRepository) {} - /** One build of a project, for the dedicated build page. Visibility matches the project page and the log. */ - @GET - @Path("/builds/{buildId}") - public PageBuild build(@PathParam("name") String name, @PathParam("buildId") Long buildId) { - Project p = access.visible(name); - Build b = Build.findById(buildId); - if (b == null || !p.id.equals(b.projectId)) throw new NotFoundException(); - return pageBuild(b, access.canSeePrivate(p), true); - } + /** + * Leaves out who requested the build and any credential reference, as the page may be anonymous. + */ + public record PageBuild( + Long id, + Long projectId, + Build.Status status, + String revision, + String branch, + String image, + Instant startedAt, + Instant finishedAt, + String imageDigest, + Integer exitCode, + boolean logTruncated, + boolean pinned, + boolean canPin, + @com.fasterxml.jackson.annotation.JsonInclude( + com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + String workflow) {} - /** The workflow file is sent for a single build only, as it can be up to 64 KiB. */ - private static PageBuild pageBuild(Build b, boolean canPin, boolean withWorkflow) { - return new PageBuild(b.id, b.projectId, b.status, b.revision, b.branch, b.image, b.startedAt, - b.finishedAt, b.imageDigest, b.exitCode, b.logTruncated, b.pinned, canPin, - withWorkflow ? b.workflow : null); - } + public record ProjectPage(ProjectInfo project, List builds) {} + + @GET + public ProjectPage page(@PathParam("name") String name, @QueryParam("branch") String branch) { + Project p = access.visible(name); + String branchFilter = branch == null ? "" : branch.trim(); + List rows = + branchFilter.isEmpty() + ? Build.find("projectId = ?1 order by id desc", p.id).page(0, 50).list() + : Build.find( + "projectId = ?1 and branch = ?2 order by id desc", p.id, branchFilter) + .page(0, 50) + .list(); + // Whoever may see a private project may also pin its builds. + boolean canPin = access.canSeePrivate(p); + List builds = rows.stream().map(b -> pageBuild(b, canPin, false)).toList(); + return new ProjectPage( + new ProjectInfo(p.id, p.name, p.repositoryUrl, p.privateRepository), builds); + } + + /** + * One build of a project, for the dedicated build page. Visibility matches the project page and + * the log. + */ + @GET + @Path("/builds/{buildId}") + public PageBuild build(@PathParam("name") String name, @PathParam("buildId") Long buildId) { + Project p = access.visible(name); + Build b = Build.findById(buildId); + if (b == null || !p.id.equals(b.projectId)) throw new NotFoundException(); + return pageBuild(b, access.canSeePrivate(p), true); + } + + /** The workflow file is sent for a single build only, as it can be up to 64 KiB. */ + private static PageBuild pageBuild(Build b, boolean canPin, boolean withWorkflow) { + return new PageBuild( + b.id, + b.projectId, + b.status, + b.revision, + b.branch, + b.image, + b.startedAt, + b.finishedAt, + b.imageDigest, + b.exitCode, + b.logTruncated, + b.pinned, + canPin, + withWorkflow ? b.workflow : null); + } - /** - * Streams a public build log. Anonymous callers are capped at {@code cisys.public.log-max-bytes} - * per response; use {@code offset}, {@code limit} or {@code tail} to page through a larger log. - * The headers expose the total size and the returned range so a client can continue. - */ - @GET - @Path("/builds/{buildId}/log") - @Produces("text/plain; charset=UTF-8") - public Response log(@PathParam("name") String name, @PathParam("buildId") Long buildId, - @QueryParam("offset") Long offset, @QueryParam("limit") Long limit, - @QueryParam("tail") Long tail) { - Project p = access.visible(name); - Build b = Build.findById(buildId); - if (b == null || !p.id.equals(b.projectId)) throw new NotFoundException(); - long size = logs.size(buildId); - // The size and range are a snapshot; a running build may append while this response streams. - // Signed-in visitors, including the owner on a private project, still get the whole log. - long maxBytes = identity.isAnonymous() ? publicLogMaxBytes : -1; - BuildLogs.Range range; - try { - range = BuildLogs.range(size, offset, limit, tail, maxBytes); - } catch (IllegalArgumentException e) { - throw new BadRequestException(e.getMessage()); - } - return LogResponses.stream(logs, buildId, size, range); + /** + * Streams a public build log. Anonymous callers are capped at {@code cisys.public.log-max-bytes} + * per response; use {@code offset}, {@code limit} or {@code tail} to page through a larger log. + * The headers expose the total size and the returned range so a client can continue. + */ + @GET + @Path("/builds/{buildId}/log") + @Produces("text/plain; charset=UTF-8") + public Response log( + @PathParam("name") String name, + @PathParam("buildId") Long buildId, + @QueryParam("offset") Long offset, + @QueryParam("limit") Long limit, + @QueryParam("tail") Long tail) { + Project p = access.visible(name); + Build b = Build.findById(buildId); + if (b == null || !p.id.equals(b.projectId)) throw new NotFoundException(); + long size = logs.size(buildId); + // The size and range are a snapshot; a running build may append while this response streams. + // Signed-in visitors, including the owner on a private project, still get the whole log. + long maxBytes = identity.isAnonymous() ? publicLogMaxBytes : -1; + BuildLogs.Range range; + try { + range = BuildLogs.range(size, offset, limit, tail, maxBytes); + } catch (IllegalArgumentException e) { + throw new BadRequestException(e.getMessage()); } + return LogResponses.stream(logs, buildId, size, range); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectResource.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectResource.java index 0ad8004..e1496cc 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ProjectResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectResource.java @@ -10,104 +10,194 @@ import com.fasterxml.jackson.annotation.JsonInclude; import io.quarkus.security.Authenticated; -import org.eclipse.microprofile.jwt.JsonWebToken; import io.quarkus.security.identity.SecurityIdentity; import jakarta.inject.Inject; +import jakarta.persistence.PersistenceException; import jakarta.transaction.Transactional; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.net.URI; import java.util.List; -import java.util.Map; import java.util.Locale; +import java.util.Map; import java.util.regex.Pattern; -import jakarta.persistence.PersistenceException; +import org.eclipse.microprofile.jwt.JsonWebToken; -@Path("/api") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) +@Path("/api") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) @Authenticated public class ProjectResource { - @Inject SecurityIdentity identity; - @Inject RepositoryUrlValidator repositoryUrls; - private JsonWebToken token() { - if (identity.getPrincipal() instanceof JsonWebToken jwt) return jwt; - throw new NotAuthorizedException("Bearer"); - } - private String subject() { - String sub = token().getSubject(); - if (sub == null || sub.isBlank()) throw new NotAuthorizedException("Bearer"); - return sub; - } - private void requireCisysRole() { - if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); - } - private static final Pattern NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,99}"); - static final String NAME_RULE = "Project names use letters, digits, '.', '_' and '-', and start with a letter or digit"; - public record CreateProject(String name, String repositoryUrl, boolean privateRepository) {} - /** The owner's subject is for admins only; everyone else gets {@code owned}. */ - public record ProjectResponse(Long id, String name, String repositoryUrl, boolean privateRepository, boolean owned, - @JsonInclude(JsonInclude.Include.NON_NULL) String ownerSubject) {} - private ProjectResponse response(Project p) { - return new ProjectResponse(p.id, p.name, p.repositoryUrl, p.privateRepository, subject().equals(p.ownerSubject), - identity.hasRole("admin") ? p.ownerSubject : null); - } - @GET @Path("/session") public Map session() { - requireCisysRole(); - return Map.of("username", token().getClaim("preferred_username") == null ? subject() : token().getClaim("preferred_username"), "subject", subject(), "roles", identity.getRoles()); - } - @GET @Path("/projects") public List list() { - requireCisysRole(); - List projects = identity.hasRole("admin") ? Project.listAll() - : Project.list("privateRepository = false or ownerSubject = ?1", subject()); - return projects.stream().map(this::response).toList(); - } - @POST @Path("/projects") @Transactional public Response create(CreateProject input) { - if (!(identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); - if (input == null || input.name() == null || input.name().isBlank() || input.name().length() > 100 || input.repositoryUrl() == null || input.repositoryUrl().length() > 2048) throw new BadRequestException("Name and repository URL are required"); - String name = input.name().trim(); - // The name is the project's URL path, for example /cisys/ in the UI. - if (!NAME.matcher(name).matches()) throw badRequest(NAME_RULE); - String url; - try { url = gitUrl(input.repositoryUrl().trim()); } - catch (WebApplicationException e) { throw e; } - catch (IllegalArgumentException e) { throw new BadRequestException(RepositoryUrls.SHAPE); } - try { repositoryUrls.validate(url); } catch (IllegalArgumentException e) { throw badRequest(e.getMessage()); } - // Checked across all projects, including private projects the caller cannot see. - if (Project.count("lower(name) = ?1", name.toLowerCase(Locale.ROOT)) > 0) throw conflict("A project with this name already exists"); - if (Project.count("repositoryUrl", url) > 0) throw conflict("A project with this repository URL already exists"); - Project p = new Project(); p.name = name; p.repositoryUrl = url; p.privateRepository = input.privateRepository(); p.ownerSubject = subject(); - try { p.persistAndFlush(); } - catch (PersistenceException e) { throw conflict("A project with this name or repository URL already exists"); } - return Response.status(201).entity(response(p)).build(); - } - /** Deletes a project and its builds; the database unlinks their logs. Admins, or the lead who owns it. */ - @DELETE @Path("/projects/{id}") @Transactional public Response delete(@PathParam("id") Long id) { - if (!(identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); - BuildRunner.lockQueue(); - Project p = Project.findById(id); - boolean admin = identity.hasRole("admin"); - boolean owner = p != null && subject().equals(p.ownerSubject); - // Private projects the caller cannot see answer 404, as everywhere else. - if (p == null || (p.privateRepository && !admin && !owner)) throw new NotFoundException(); - if (!admin && !owner) throw new ForbiddenException(); - if (Build.count("projectId = ?1 and status in ('RUNNING', 'QUEUED')", id) > 0) throw conflict("A build is queued or running. Delete the project when its builds finish."); - Build.delete("projectId", id); - p.delete(); - return Response.noContent().build(); - } - /** Repository URLs name the Git repository itself: https://github.com/org/repo becomes https://github.com/org/repo.git. */ - static String gitUrl(String url) { - String trimmed = url.replaceAll("/+$", ""); - URI parsed = URI.create(trimmed); - String path = parsed.getPath(); - if (parsed.getQuery() != null || parsed.getFragment() != null || path == null || path.isEmpty() || path.endsWith("/.git")) - throw badRequest("Use the repository's URL, for example https://github.com/org/repo.git"); - return path.endsWith(".git") ? trimmed : trimmed + ".git"; + @Inject SecurityIdentity identity; + @Inject RepositoryUrlValidator repositoryUrls; + + private JsonWebToken token() { + if (identity.getPrincipal() instanceof JsonWebToken jwt) return jwt; + throw new NotAuthorizedException("Bearer"); + } + + private String subject() { + String sub = token().getSubject(); + if (sub == null || sub.isBlank()) throw new NotAuthorizedException("Bearer"); + return sub; + } + + private void requireCisysRole() { + if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) + throw new ForbiddenException(); + } + + private static final Pattern NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,99}"); + static final String NAME_RULE = + "Project names use letters, digits, '.', '_' and '-', and start with a letter or digit"; + + public record CreateProject(String name, String repositoryUrl, boolean privateRepository) {} + + /** The owner's subject is for admins only; everyone else gets {@code owned}. */ + public record ProjectResponse( + Long id, + String name, + String repositoryUrl, + boolean privateRepository, + boolean owned, + @JsonInclude(JsonInclude.Include.NON_NULL) String ownerSubject) {} + + private ProjectResponse response(Project p) { + return new ProjectResponse( + p.id, + p.name, + p.repositoryUrl, + p.privateRepository, + subject().equals(p.ownerSubject), + identity.hasRole("admin") ? p.ownerSubject : null); + } + + @GET + @Path("/session") + public Map session() { + requireCisysRole(); + return Map.of( + "username", + token().getClaim("preferred_username") == null + ? subject() + : token().getClaim("preferred_username"), + "subject", + subject(), + "roles", + identity.getRoles()); + } + + @GET + @Path("/projects") + public List list() { + requireCisysRole(); + List projects = + identity.hasRole("admin") + ? Project.listAll() + : Project.list("privateRepository = false or ownerSubject = ?1", subject()); + return projects.stream().map(this::response).toList(); + } + + @POST + @Path("/projects") + @Transactional + public Response create(CreateProject input) { + if (!(identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); + if (input == null + || input.name() == null + || input.name().isBlank() + || input.name().length() > 100 + || input.repositoryUrl() == null + || input.repositoryUrl().length() > 2048) + throw new BadRequestException("Name and repository URL are required"); + String name = input.name().trim(); + // The name is the project's URL path, for example /cisys/ in the UI. + if (!NAME.matcher(name).matches()) throw badRequest(NAME_RULE); + String url; + try { + url = gitUrl(input.repositoryUrl().trim()); + } catch (WebApplicationException e) { + throw e; + } catch (IllegalArgumentException e) { + throw new BadRequestException(RepositoryUrls.SHAPE); } - private static WebApplicationException badRequest(String message) { - return new WebApplicationException(Response.status(400).type(MediaType.APPLICATION_JSON).entity(Map.of("error", message)).build()); + try { + repositoryUrls.validate(url); + } catch (IllegalArgumentException e) { + throw badRequest(e.getMessage()); } - private static WebApplicationException conflict(String message) { - return new WebApplicationException(Response.status(409).type(MediaType.APPLICATION_JSON).entity(Map.of("error", message)).build()); + // Checked across all projects, including private projects the caller cannot see. + if (Project.count("lower(name) = ?1", name.toLowerCase(Locale.ROOT)) > 0) + throw conflict("A project with this name already exists"); + if (Project.count("repositoryUrl", url) > 0) + throw conflict("A project with this repository URL already exists"); + Project p = new Project(); + p.name = name; + p.repositoryUrl = url; + p.privateRepository = input.privateRepository(); + p.ownerSubject = subject(); + try { + p.persistAndFlush(); + } catch (PersistenceException e) { + throw conflict("A project with this name or repository URL already exists"); } + return Response.status(201).entity(response(p)).build(); + } + + /** + * Deletes a project and its builds; the database unlinks their logs. Admins, or the lead who owns + * it. + */ + @DELETE + @Path("/projects/{id}") + @Transactional + public Response delete(@PathParam("id") Long id) { + if (!(identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); + BuildRunner.lockQueue(); + Project p = Project.findById(id); + boolean admin = identity.hasRole("admin"); + boolean owner = p != null && subject().equals(p.ownerSubject); + // Private projects the caller cannot see answer 404, as everywhere else. + if (p == null || (p.privateRepository && !admin && !owner)) throw new NotFoundException(); + if (!admin && !owner) throw new ForbiddenException(); + if (Build.count("projectId = ?1 and status in ('RUNNING', 'QUEUED')", id) > 0) + throw conflict("A build is queued or running. Delete the project when its builds finish."); + Build.delete("projectId", id); + p.delete(); + return Response.noContent().build(); + } + + /** + * Repository URLs name the Git repository itself: https://github.com/org/repo becomes + * https://github.com/org/repo.git. + */ + static String gitUrl(String url) { + String trimmed = url.replaceAll("/+$", ""); + URI parsed = URI.create(trimmed); + String path = parsed.getPath(); + if (parsed.getQuery() != null + || parsed.getFragment() != null + || path == null + || path.isEmpty() + || path.endsWith("/.git")) + throw badRequest("Use the repository's URL, for example https://github.com/org/repo.git"); + return path.endsWith(".git") ? trimmed : trimmed + ".git"; + } + + private static WebApplicationException badRequest(String message) { + return new WebApplicationException( + Response.status(400) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of("error", message)) + .build()); + } + + private static WebApplicationException conflict(String message) { + return new WebApplicationException( + Response.status(409) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of("error", message)) + .build()); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimitFilter.java b/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimitFilter.java index 7b20a17..8678edf 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimitFilter.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimitFilter.java @@ -23,37 +23,38 @@ /** * Applies {@link PublicRateLimiter} to the endpoints marked with {@link PublicRateLimited}. - * Signed-in callers are never counted, so the authenticated build pages keep working during a - * burst of anonymous traffic. The client is identified by the socket address; behind a reverse - * proxy configure Quarkus to use the forwarded address, otherwise every visitor shares one bucket. + * Signed-in callers are never counted, so the authenticated build pages keep working during a burst + * of anonymous traffic. The client is identified by the socket address; behind a reverse proxy + * configure Quarkus to use the forwarded address, otherwise every visitor shares one bucket. */ @Provider @PublicRateLimited @Priority(Priorities.AUTHORIZATION) public class PublicRateLimitFilter implements ContainerRequestFilter { - @Inject SecurityIdentity identity; - @Inject PublicRateLimiter limiter; - @Context HttpServerRequest request; + @Inject SecurityIdentity identity; + @Inject PublicRateLimiter limiter; + @Context HttpServerRequest request; - @Override - public void filter(ContainerRequestContext context) { - // CORS preflight requests are cheap and must not consume a visitor's budget. - if (context.getMethod().equals("OPTIONS")) return; - if (!identity.isAnonymous()) return; - long retryAfter = limiter.acquire(clientAddress(), System.currentTimeMillis()); - if (retryAfter == 0) return; - context.abortWith(Response.status(429) - .type(MediaType.APPLICATION_JSON) - .header("Retry-After", retryAfter) - .entity(Map.of("error", "Too many requests. Try again later.")) - .build()); - } + @Override + public void filter(ContainerRequestContext context) { + // CORS preflight requests are cheap and must not consume a visitor's budget. + if (context.getMethod().equals("OPTIONS")) return; + if (!identity.isAnonymous()) return; + long retryAfter = limiter.acquire(clientAddress(), System.currentTimeMillis()); + if (retryAfter == 0) return; + context.abortWith( + Response.status(429) + .type(MediaType.APPLICATION_JSON) + .header("Retry-After", retryAfter) + .entity(Map.of("error", "Too many requests. Try again later.")) + .build()); + } - private String clientAddress() { - try { - return request.remoteAddress() == null ? "unknown" : request.remoteAddress().hostAddress(); - } catch (RuntimeException e) { - return "unknown"; - } + private String clientAddress() { + try { + return request.remoteAddress() == null ? "unknown" : request.remoteAddress().hostAddress(); + } catch (RuntimeException e) { + return "unknown"; } + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimiter.java b/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimiter.java index 652f285..114fde8 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimiter.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/PublicRateLimiter.java @@ -15,62 +15,71 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; /** - * A small per-client fixed-window counter for the anonymous endpoints. It lives in this process, - * so it resets on restart and is not shared between instances; the runner is documented as a + * A small per-client fixed-window counter for the anonymous endpoints. It lives in this process, so + * it resets on restart and is not shared between instances; the runner is documented as a * single-instance deployment. Expired windows are purged when the map reaches {@code max-clients}; * if it is still full, one live window is evicted so a new client is never locked out and the * memory a flood of addresses can use stays bounded. Expired-window purging is throttled and the - * eviction itself is constant time, so a full map cannot turn ordinary traffic into a scan of - * every entry on each request. + * eviction itself is constant time, so a full map cannot turn ordinary traffic into a scan of every + * entry on each request. */ @ApplicationScoped public class PublicRateLimiter { - @ConfigProperty(name = "cisys.public.rate-limit.requests", defaultValue = "120") int requests; - @ConfigProperty(name = "cisys.public.rate-limit.window-seconds", defaultValue = "60") int windowSeconds; - @ConfigProperty(name = "cisys.public.rate-limit.max-clients", defaultValue = "10000") int maxClients; + @ConfigProperty(name = "cisys.public.rate-limit.requests", defaultValue = "120") + int requests; - private static final long PURGE_INTERVAL_MILLIS = 1_000L; + @ConfigProperty(name = "cisys.public.rate-limit.window-seconds", defaultValue = "60") + int windowSeconds; - private record Window(long start, AtomicInteger count) {} - private final ConcurrentHashMap windows = new ConcurrentHashMap<>(); - private volatile long lastPurge; + @ConfigProperty(name = "cisys.public.rate-limit.max-clients", defaultValue = "10000") + int maxClients; - /** - * Counts one request. Returns zero when it is allowed, otherwise the number of seconds the - * client should wait before retrying. A non-positive request limit disables the limiter. - */ - long acquire(String client, long nowMillis) { - if (requests <= 0) return 0; - long windowMillis = Math.max(1, windowSeconds) * 1000L; - purge(nowMillis, windowMillis); - if (maxClients > 0 && windows.size() >= maxClients && !windows.containsKey(client)) { - // The map is full of live windows. Evict one and admit the new client: a client that - // rotates addresses can bypass the limiter either way, so rejecting new clients would - // only let a flood lock legitimate visitors out. Evicting the first entry the iterator - // returns keeps this O(1); "oldest" is not important for a fail-open limiter. - Iterator candidates = windows.keySet().iterator(); - if (candidates.hasNext()) windows.remove(candidates.next()); - } - Window window = windows.compute(client, (key, current) -> - current == null || nowMillis - current.start() >= windowMillis - ? new Window(nowMillis, new AtomicInteger()) - : current); - if (window.count().incrementAndGet() <= requests) return 0; - long remaining = window.start() + windowMillis - nowMillis; - return Math.max(1, (remaining + 999) / 1000); - } + private static final long PURGE_INTERVAL_MILLIS = 1_000L; - /** For tests. */ - void reset() { - windows.clear(); - lastPurge = 0; - } + private record Window(long start, AtomicInteger count) {} + + private final ConcurrentHashMap windows = new ConcurrentHashMap<>(); + private volatile long lastPurge; - private void purge(long now, long windowMillis) { - if (maxClients <= 0 || windows.size() < maxClients) return; - // A full map is checked on every request, so do the O(n) sweep at most once a second. - if (now - lastPurge < PURGE_INTERVAL_MILLIS) return; - lastPurge = now; - windows.entrySet().removeIf(entry -> now - entry.getValue().start() >= windowMillis); + /** + * Counts one request. Returns zero when it is allowed, otherwise the number of seconds the client + * should wait before retrying. A non-positive request limit disables the limiter. + */ + long acquire(String client, long nowMillis) { + if (requests <= 0) return 0; + long windowMillis = Math.max(1, windowSeconds) * 1000L; + purge(nowMillis, windowMillis); + if (maxClients > 0 && windows.size() >= maxClients && !windows.containsKey(client)) { + // The map is full of live windows. Evict one and admit the new client: a client that + // rotates addresses can bypass the limiter either way, so rejecting new clients would + // only let a flood lock legitimate visitors out. Evicting the first entry the iterator + // returns keeps this O(1); "oldest" is not important for a fail-open limiter. + Iterator candidates = windows.keySet().iterator(); + if (candidates.hasNext()) windows.remove(candidates.next()); } + Window window = + windows.compute( + client, + (key, current) -> + current == null || nowMillis - current.start() >= windowMillis + ? new Window(nowMillis, new AtomicInteger()) + : current); + if (window.count().incrementAndGet() <= requests) return 0; + long remaining = window.start() + windowMillis - nowMillis; + return Math.max(1, (remaining + 999) / 1000); + } + + /** For tests. */ + void reset() { + windows.clear(); + lastPurge = 0; + } + + private void purge(long now, long windowMillis) { + if (maxClients <= 0 || windows.size() < maxClients) return; + // A full map is checked on every request, so do the O(n) sweep at most once a second. + if (now - lastPurge < PURGE_INTERVAL_MILLIS) return; + lastPurge = now; + windows.entrySet().removeIf(entry -> now - entry.getValue().start() >= windowMillis); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrlValidator.java b/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrlValidator.java index aaa5110..d59852c 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrlValidator.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrlValidator.java @@ -22,21 +22,23 @@ */ @ApplicationScoped public class RepositoryUrlValidator { - private final HostResolver resolver; - private final Set allowedHosts; - private final List allowedNetworks; + private final HostResolver resolver; + private final Set allowedHosts; + private final List allowedNetworks; - @Inject - public RepositoryUrlValidator(HostResolver resolver, - @ConfigProperty(name = "cisys.git.allowed-hosts", defaultValue = "github.com,gitlab.com") String allowedHosts, - @ConfigProperty(name = "cisys.git.allowed-networks") Optional allowedNetworks) { - this.resolver = resolver; - this.allowedHosts = RepositoryUrls.parseHosts(allowedHosts); - this.allowedNetworks = RepositoryUrls.parseNetworks(allowedNetworks.orElse("")); - } + @Inject + public RepositoryUrlValidator( + HostResolver resolver, + @ConfigProperty(name = "cisys.git.allowed-hosts", defaultValue = "github.com,gitlab.com") + String allowedHosts, + @ConfigProperty(name = "cisys.git.allowed-networks") Optional allowedNetworks) { + this.resolver = resolver; + this.allowedHosts = RepositoryUrls.parseHosts(allowedHosts); + this.allowedNetworks = RepositoryUrls.parseNetworks(allowedNetworks.orElse("")); + } - /** Throws {@link IllegalArgumentException} with a message safe to show to the caller. */ - public void validate(String repositoryUrl) { - RepositoryUrls.validate(repositoryUrl, allowedHosts, allowedNetworks, resolver); - } + /** Throws {@link IllegalArgumentException} with a message safe to show to the caller. */ + public void validate(String repositoryUrl) { + RepositoryUrls.validate(repositoryUrl, allowedHosts, allowedNetworks, resolver); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrls.java b/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrls.java index 771590e..3dd6168 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrls.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/RepositoryUrls.java @@ -18,143 +18,168 @@ import java.util.Set; /** - * Repository URL rules shared by project creation, branch listing and builds. - * A URL is accepted only when it is an allowed HTTPS host and every address that host resolves - * to is public, unless an administrator allowed the network explicitly. + * Repository URL rules shared by project creation, branch listing and builds. A URL is accepted + * only when it is an allowed HTTPS host and every address that host resolves to is public, unless + * an administrator allowed the network explicitly. */ final class RepositoryUrls { - static final String REQUIRED = "Repository URL is required"; - static final String SHAPE = "Use an HTTPS repository URL without credentials"; - static final String BLOCKED = "The repository host resolves to a blocked address"; - static final String UNKNOWN = "The repository host could not be resolved"; + static final String REQUIRED = "Repository URL is required"; + static final String SHAPE = "Use an HTTPS repository URL without credentials"; + static final String BLOCKED = "The repository host resolves to a blocked address"; + static final String UNKNOWN = "The repository host could not be resolved"; - /** Never fetched, even when the host is on the allowlist, unless the network is allowed too. */ - private static final List BLOCKED_NETWORKS = parseNetworks(String.join(",", - "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", - "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.168.0.0/16", "198.18.0.0/15", - "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", - "::/128", "::1/128", "fe80::/10", "fc00::/7", "ff00::/8", "2001:db8::/32")); + /** Never fetched, even when the host is on the allowlist, unless the network is allowed too. */ + private static final List BLOCKED_NETWORKS = + parseNetworks( + String.join( + ",", + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "fe80::/10", + "fc00::/7", + "ff00::/8", + "2001:db8::/32")); - private RepositoryUrls() {} + private RepositoryUrls() {} - static void validate(String repositoryUrl, Set allowedHosts, List allowedNetworks, - HostResolver resolver) { - if (repositoryUrl == null || repositoryUrl.isBlank() || repositoryUrl.length() > 2048) { - throw new IllegalArgumentException(REQUIRED); - } - URI uri; - try { - uri = URI.create(repositoryUrl.trim()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException(SHAPE); - } - if (!"https".equalsIgnoreCase(uri.getScheme()) - || uri.getUserInfo() != null - || uri.getQuery() != null - || uri.getFragment() != null - || (uri.getPort() != -1 && uri.getPort() != 443) - || uri.getPath() == null - || uri.getPath().isEmpty() - || uri.getPath().equals("/")) { - throw new IllegalArgumentException(SHAPE); - } - String host = uri.getHost(); - if (host == null) throw new IllegalArgumentException(SHAPE); - host = host.toLowerCase(Locale.ROOT); - if (host.startsWith("[") && host.endsWith("]")) host = host.substring(1, host.length() - 1); - if (!allowedHosts.contains(host)) throw new IllegalArgumentException(hostMessage(allowedHosts)); - InetAddress[] addresses; - try { - addresses = resolver.resolve(host); - } catch (UnknownHostException e) { - throw new IllegalArgumentException(UNKNOWN); - } - if (addresses == null || addresses.length == 0) throw new IllegalArgumentException(UNKNOWN); - for (InetAddress address : addresses) { - if (address == null) throw new IllegalArgumentException(UNKNOWN); - byte[] raw = address.getAddress(); - byte[] bytes = unwrap(raw); - boolean allowed = inside(bytes, allowedNetworks) || inside(raw, allowedNetworks); - if (!allowed && inside(bytes, BLOCKED_NETWORKS)) throw new IllegalArgumentException(BLOCKED); - } + static void validate( + String repositoryUrl, + Set allowedHosts, + List allowedNetworks, + HostResolver resolver) { + if (repositoryUrl == null || repositoryUrl.isBlank() || repositoryUrl.length() > 2048) { + throw new IllegalArgumentException(REQUIRED); } - - static String hostMessage(Set allowedHosts) { - return allowedHosts.isEmpty() ? "Use a repository on an allowed host" - : "Use a repository on an allowed host (" + String.join(", ", allowedHosts) + ")"; + URI uri; + try { + uri = URI.create(repositoryUrl.trim()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(SHAPE); } - - static Set parseHosts(String value) { - Set hosts = new LinkedHashSet<>(); - if (value != null) { - for (String host : value.split(",")) { - String trimmed = host.trim().toLowerCase(Locale.ROOT); - if (!trimmed.isEmpty()) hosts.add(trimmed); - } - } - return java.util.Collections.unmodifiableSet(hosts); + if (!"https".equalsIgnoreCase(uri.getScheme()) + || uri.getUserInfo() != null + || uri.getQuery() != null + || uri.getFragment() != null + || (uri.getPort() != -1 && uri.getPort() != 443) + || uri.getPath() == null + || uri.getPath().isEmpty() + || uri.getPath().equals("/")) { + throw new IllegalArgumentException(SHAPE); } - - static List parseNetworks(String value) { - List networks = new ArrayList<>(); - if (value != null) { - for (String network : value.split(",")) { - String trimmed = network.trim(); - if (!trimmed.isEmpty()) networks.add(Cidr.parse(trimmed)); - } - } - return List.copyOf(networks); + String host = uri.getHost(); + if (host == null) throw new IllegalArgumentException(SHAPE); + host = host.toLowerCase(Locale.ROOT); + if (host.startsWith("[") && host.endsWith("]")) host = host.substring(1, host.length() - 1); + if (!allowedHosts.contains(host)) throw new IllegalArgumentException(hostMessage(allowedHosts)); + InetAddress[] addresses; + try { + addresses = resolver.resolve(host); + } catch (UnknownHostException e) { + throw new IllegalArgumentException(UNKNOWN); + } + if (addresses == null || addresses.length == 0) throw new IllegalArgumentException(UNKNOWN); + for (InetAddress address : addresses) { + if (address == null) throw new IllegalArgumentException(UNKNOWN); + byte[] raw = address.getAddress(); + byte[] bytes = unwrap(raw); + boolean allowed = inside(bytes, allowedNetworks) || inside(raw, allowedNetworks); + if (!allowed && inside(bytes, BLOCKED_NETWORKS)) throw new IllegalArgumentException(BLOCKED); } + } - /** Turns an IPv4-mapped IPv6 address, such as ::ffff:127.0.0.1, into its IPv4 form. */ - static byte[] unwrap(byte[] address) { - if (address.length != 16) return address; - for (int i = 0; i < 10; i++) if (address[i] != 0) return address; - if ((address[10] & 0xFF) != 0xFF || (address[11] & 0xFF) != 0xFF) return address; - return new byte[] {address[12], address[13], address[14], address[15]}; + static String hostMessage(Set allowedHosts) { + return allowedHosts.isEmpty() + ? "Use a repository on an allowed host" + : "Use a repository on an allowed host (" + String.join(", ", allowedHosts) + ")"; + } + + static Set parseHosts(String value) { + Set hosts = new LinkedHashSet<>(); + if (value != null) { + for (String host : value.split(",")) { + String trimmed = host.trim().toLowerCase(Locale.ROOT); + if (!trimmed.isEmpty()) hosts.add(trimmed); + } } + return java.util.Collections.unmodifiableSet(hosts); + } - private static boolean inside(byte[] address, List networks) { - for (Cidr network : networks) if (network.contains(address)) return true; - return false; + static List parseNetworks(String value) { + List networks = new ArrayList<>(); + if (value != null) { + for (String network : value.split(",")) { + String trimmed = network.trim(); + if (!trimmed.isEmpty()) networks.add(Cidr.parse(trimmed)); + } } + return List.copyOf(networks); + } + + /** Turns an IPv4-mapped IPv6 address, such as ::ffff:127.0.0.1, into its IPv4 form. */ + static byte[] unwrap(byte[] address) { + if (address.length != 16) return address; + for (int i = 0; i < 10; i++) if (address[i] != 0) return address; + if ((address[10] & 0xFF) != 0xFF || (address[11] & 0xFF) != 0xFF) return address; + return new byte[] {address[12], address[13], address[14], address[15]}; + } - /** An IPv4 or IPv6 network in CIDR notation, for the allowlists. */ - record Cidr(byte[] network, int prefix) { - static Cidr parse(String value) { - int slash = value.indexOf('/'); - if (slash <= 0 || slash == value.length() - 1) throw new IllegalArgumentException("Invalid network: " + value); - byte[] address; - try { - address = InetAddress.getByName(value.substring(0, slash)).getAddress(); - } catch (UnknownHostException e) { - throw new IllegalArgumentException("Invalid network: " + value); - } - int prefix; - try { - prefix = Integer.parseInt(value.substring(slash + 1)); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid network: " + value); - } - if (prefix < 0 || prefix > address.length * 8) throw new IllegalArgumentException("Invalid network: " + value); - byte[] network = address.clone(); - for (int i = 0; i < network.length; i++) { - int bits = prefix - i * 8; - if (bits >= 8) continue; - network[i] &= (byte) (bits <= 0 ? 0 : (0xFF << (8 - bits)) & 0xFF); - } - return new Cidr(network, prefix); - } + private static boolean inside(byte[] address, List networks) { + for (Cidr network : networks) if (network.contains(address)) return true; + return false; + } + + /** An IPv4 or IPv6 network in CIDR notation, for the allowlists. */ + record Cidr(byte[] network, int prefix) { + static Cidr parse(String value) { + int slash = value.indexOf('/'); + if (slash <= 0 || slash == value.length() - 1) + throw new IllegalArgumentException("Invalid network: " + value); + byte[] address; + try { + address = InetAddress.getByName(value.substring(0, slash)).getAddress(); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("Invalid network: " + value); + } + int prefix; + try { + prefix = Integer.parseInt(value.substring(slash + 1)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid network: " + value); + } + if (prefix < 0 || prefix > address.length * 8) + throw new IllegalArgumentException("Invalid network: " + value); + byte[] network = address.clone(); + for (int i = 0; i < network.length; i++) { + int bits = prefix - i * 8; + if (bits >= 8) continue; + network[i] &= (byte) (bits <= 0 ? 0 : (0xFF << (8 - bits)) & 0xFF); + } + return new Cidr(network, prefix); + } - boolean contains(byte[] address) { - if (address.length != network.length) return false; - for (int i = 0; i < address.length; i++) { - int bits = prefix - i * 8; - if (bits <= 0) break; - int mask = bits >= 8 ? 0xFF : (0xFF << (8 - bits)) & 0xFF; - if ((address[i] & mask) != (network[i] & mask)) return false; - } - return true; - } + boolean contains(byte[] address) { + if (address.length != network.length) return false; + for (int i = 0; i < address.length; i++) { + int bits = prefix - i * 8; + if (bits <= 0) break; + int mask = bits >= 8 ? 0xFF : (0xFF << (8 - bits)) & 0xFF; + if ((address[i] & mask) != (network[i] & mask)) return false; + } + return true; } + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/SessionInvalidator.java b/src/backend/main/java/ai/mnemosyne/cisys/SessionInvalidator.java index 894c663..3f65902 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/SessionInvalidator.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/SessionInvalidator.java @@ -12,8 +12,6 @@ import io.quarkus.runtime.ShutdownEvent; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; -import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.jboss.logging.Logger; import java.io.IOException; import java.net.URI; import java.net.URLEncoder; @@ -24,67 +22,97 @@ import java.time.Duration; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; /** * Signs every user out of the cisys realm when the application shuts down. The sessions live in * Keycloak, which keeps running after cisys stops, so they would otherwise survive a restart. */ -@ApplicationScoped public class SessionInvalidator { - private static final Logger LOG = Logger.getLogger(SessionInvalidator.class); - private static final Duration TIMEOUT = Duration.ofSeconds(5); - private static final Pattern ACCESS_TOKEN = Pattern.compile("\"access_token\"\\s*:\\s*\"([^\"]+)\""); +@ApplicationScoped +public class SessionInvalidator { + private static final Logger LOG = Logger.getLogger(SessionInvalidator.class); + private static final Duration TIMEOUT = Duration.ofSeconds(5); + private static final Pattern ACCESS_TOKEN = + Pattern.compile("\"access_token\"\\s*:\\s*\"([^\"]+)\""); - @ConfigProperty(name="cisys.session.invalidate-on-shutdown",defaultValue="true") boolean enabled; - @ConfigProperty(name="cisys.keycloak.url") String keycloakUrl; - @ConfigProperty(name="cisys.keycloak.realm",defaultValue="cisys") String realm; - @ConfigProperty(name="cisys.keycloak.admin-client-id") String clientId; - @ConfigProperty(name="cisys.keycloak.admin-client-secret") String clientSecret; + @ConfigProperty(name = "cisys.session.invalidate-on-shutdown", defaultValue = "true") + boolean enabled; - void onShutdown(@Observes ShutdownEvent event) { - if (enabled && !isLiveReload(LaunchMode.current(), event.isStandardShutdown())) invalidate(); - } + @ConfigProperty(name = "cisys.keycloak.url") + String keycloakUrl; - /** In development mode a live reload also fires ShutdownEvent, as a standard shutdown; really stopping is not standard. */ - static boolean isLiveReload(LaunchMode mode, boolean standardShutdown) { - return mode == LaunchMode.DEVELOPMENT && standardShutdown; - } + @ConfigProperty(name = "cisys.keycloak.realm", defaultValue = "cisys") + String realm; - /** Never throws: a Keycloak problem must not get in the way of shutting down. */ - boolean invalidate() { - try { - HttpClient client = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); - String form = "grant_type=client_credentials&client_id=" + encode(clientId) + "&client_secret=" + encode(clientSecret); - HttpResponse token = client.send(request("/realms/" + realm + "/protocol/openid-connect/token") - .header("Content-Type", "application/x-www-form-urlencoded") - .POST(HttpRequest.BodyPublishers.ofString(form)).build(), HttpResponse.BodyHandlers.ofString()); - Matcher accessToken = ACCESS_TOKEN.matcher(token.body()); - if (token.statusCode() != 200 || !accessToken.find()) { - LOG.warnf("Sessions were not removed: Keycloak refused client '%s' (HTTP %d). It is defined in contrib/keycloak/realm.json, which is only imported into a new realm.", clientId, token.statusCode()); - return false; - } - HttpResponse logout = client.send(request("/admin/realms/" + realm + "/logout-all") - .header("Authorization", "Bearer " + accessToken.group(1)) - .POST(HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.discarding()); - if (logout.statusCode() / 100 != 2) { - LOG.warnf("Sessions were not removed: Keycloak answered HTTP %d.", logout.statusCode()); + @ConfigProperty(name = "cisys.keycloak.admin-client-id") + String clientId; + + @ConfigProperty(name = "cisys.keycloak.admin-client-secret") + String clientSecret; + + void onShutdown(@Observes ShutdownEvent event) { + if (enabled && !isLiveReload(LaunchMode.current(), event.isStandardShutdown())) invalidate(); + } + + /** + * In development mode a live reload also fires ShutdownEvent, as a standard shutdown; really + * stopping is not standard. + */ + static boolean isLiveReload(LaunchMode mode, boolean standardShutdown) { + return mode == LaunchMode.DEVELOPMENT && standardShutdown; + } + + /** Never throws: a Keycloak problem must not get in the way of shutting down. */ + boolean invalidate() { + try { + HttpClient client = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); + String form = + "grant_type=client_credentials&client_id=" + + encode(clientId) + + "&client_secret=" + + encode(clientSecret); + HttpResponse token = + client.send( + request("/realms/" + realm + "/protocol/openid-connect/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(), + HttpResponse.BodyHandlers.ofString()); + Matcher accessToken = ACCESS_TOKEN.matcher(token.body()); + if (token.statusCode() != 200 || !accessToken.find()) { + LOG.warnf( + "Sessions were not removed: Keycloak refused client '%s' (HTTP %d). It is defined in contrib/keycloak/realm.json, which is only imported into a new realm.", + clientId, token.statusCode()); + return false; + } + HttpResponse logout = + client.send( + request("/admin/realms/" + realm + "/logout-all") + .header("Authorization", "Bearer " + accessToken.group(1)) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(), + HttpResponse.BodyHandlers.discarding()); + if (logout.statusCode() / 100 != 2) { + LOG.warnf("Sessions were not removed: Keycloak answered HTTP %d.", logout.statusCode()); + return false; + } + LOG.info("Removed all user sessions"); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Sessions were not removed: interrupted."); + } catch (IOException | IllegalArgumentException e) { + LOG.warnf("Sessions were not removed: %s", e.toString()); + } return false; - } - LOG.info("Removed all user sessions"); - return true; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOG.warn("Sessions were not removed: interrupted."); - } catch (IOException | IllegalArgumentException e) { - LOG.warnf("Sessions were not removed: %s", e.toString()); } - return false; - } - private HttpRequest.Builder request(String path) { - return HttpRequest.newBuilder(URI.create(keycloakUrl + path)).timeout(TIMEOUT); - } + private HttpRequest.Builder request(String path) { + return HttpRequest.newBuilder(URI.create(keycloakUrl + path)).timeout(TIMEOUT); + } - private static String encode(String value) { - return URLEncoder.encode(value, StandardCharsets.UTF_8); - } + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/Settings.java b/src/backend/main/java/ai/mnemosyne/cisys/Settings.java index 62e61d1..d7a0e9f 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/Settings.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/Settings.java @@ -13,21 +13,30 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; -/** Settings changed by admins. A singleton row with id 1; the defaults apply until it is first saved. */ +/** + * Settings changed by admins. A singleton row with id 1; the defaults apply until it is first + * saved. + */ @Entity public class Settings extends PanacheEntityBase { - @Id public Long id = 1L; - /** 0 turns the age limit off. */ - @Column(nullable = false) public int retentionDays = 30; - /** 0 turns the count limit off. */ - @Column(nullable = false) public int retentionMaxBuilds = 50; - /** Null uses cisys.build.concurrency. */ - public Integer maxConcurrentBuilds; - /** Null uses cisys.build.queue-limit. */ - public Integer maxQueuedBuilds; - - public static Settings current() { - Settings s = findById(1L); - return s == null ? new Settings() : s; - } + @Id public Long id = 1L; + + /** 0 turns the age limit off. */ + @Column(nullable = false) + public int retentionDays = 30; + + /** 0 turns the count limit off. */ + @Column(nullable = false) + public int retentionMaxBuilds = 50; + + /** Null uses cisys.build.concurrency. */ + public Integer maxConcurrentBuilds; + + /** Null uses cisys.build.queue-limit. */ + public Integer maxQueuedBuilds; + + public static Settings current() { + Settings s = findById(1L); + return s == null ? new Settings() : s; + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/SettingsResource.java b/src/backend/main/java/ai/mnemosyne/cisys/SettingsResource.java index d082eac..713e4e3 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/SettingsResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/SettingsResource.java @@ -27,54 +27,76 @@ @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public class SettingsResource { - static final int MAX_DAYS = 3650, MAX_BUILDS = 10000, MAX_CONCURRENT = 16, MAX_QUEUED = 1000; + static final int MAX_DAYS = 3650, MAX_BUILDS = 10000, MAX_CONCURRENT = 16, MAX_QUEUED = 1000; - @Inject SecurityIdentity identity; - @Inject BuildRunner runner; + @Inject SecurityIdentity identity; + @Inject BuildRunner runner; - /** 0 turns a limit off. */ - public record Retention(Integer maxAgeDays, Integer maxBuilds) {} + /** 0 turns a limit off. */ + public record Retention(Integer maxAgeDays, Integer maxBuilds) {} - public record Queue(Integer maxConcurrent, Integer maxQueued) {} + public record Queue(Integer maxConcurrent, Integer maxQueued) {} - @GET @Path("/retention") public Retention retention() { - requireAdmin(); - Settings s = Settings.current(); - return new Retention(s.retentionDays, s.retentionMaxBuilds); - } + @GET + @Path("/retention") + public Retention retention() { + requireAdmin(); + Settings s = Settings.current(); + return new Retention(s.retentionDays, s.retentionMaxBuilds); + } - @PUT @Path("/retention") @Transactional public Retention updateRetention(Retention input) { - requireAdmin(); - if (input == null || input.maxAgeDays() == null || input.maxAgeDays() < 0 || input.maxAgeDays() > MAX_DAYS) - throw new BadRequestException("Days must be between 0 and " + MAX_DAYS); - if (input.maxBuilds() == null || input.maxBuilds() < 0 || input.maxBuilds() > MAX_BUILDS) - throw new BadRequestException("Builds must be between 0 and " + MAX_BUILDS); - Settings s = Settings.findById(1L); - if (s == null) { s = new Settings(); s.persist(); } - s.retentionDays = input.maxAgeDays(); - s.retentionMaxBuilds = input.maxBuilds(); - return new Retention(s.retentionDays, s.retentionMaxBuilds); + @PUT + @Path("/retention") + @Transactional + public Retention updateRetention(Retention input) { + requireAdmin(); + if (input == null + || input.maxAgeDays() == null + || input.maxAgeDays() < 0 + || input.maxAgeDays() > MAX_DAYS) + throw new BadRequestException("Days must be between 0 and " + MAX_DAYS); + if (input.maxBuilds() == null || input.maxBuilds() < 0 || input.maxBuilds() > MAX_BUILDS) + throw new BadRequestException("Builds must be between 0 and " + MAX_BUILDS); + Settings s = Settings.findById(1L); + if (s == null) { + s = new Settings(); + s.persist(); } + s.retentionDays = input.maxAgeDays(); + s.retentionMaxBuilds = input.maxBuilds(); + return new Retention(s.retentionDays, s.retentionMaxBuilds); + } - @GET @Path("/queue") public Queue queue() { - requireAdmin(); - return new Queue(runner.concurrency(), runner.queueLimit()); - } + @GET + @Path("/queue") + public Queue queue() { + requireAdmin(); + return new Queue(runner.concurrency(), runner.queueLimit()); + } - @PUT @Path("/queue") @Transactional public Queue updateQueue(Queue input) { - requireAdmin(); - if (input == null || input.maxConcurrent() == null || input.maxConcurrent() < 1 || input.maxConcurrent() > MAX_CONCURRENT) - throw new BadRequestException("Concurrent builds must be between 1 and " + MAX_CONCURRENT); - if (input.maxQueued() == null || input.maxQueued() < 1 || input.maxQueued() > MAX_QUEUED) - throw new BadRequestException("Queued builds must be between 1 and " + MAX_QUEUED); - Settings s = Settings.findById(1L); - if (s == null) { s = new Settings(); s.persist(); } - s.maxConcurrentBuilds = input.maxConcurrent(); - s.maxQueuedBuilds = input.maxQueued(); - return new Queue(s.maxConcurrentBuilds, s.maxQueuedBuilds); + @PUT + @Path("/queue") + @Transactional + public Queue updateQueue(Queue input) { + requireAdmin(); + if (input == null + || input.maxConcurrent() == null + || input.maxConcurrent() < 1 + || input.maxConcurrent() > MAX_CONCURRENT) + throw new BadRequestException("Concurrent builds must be between 1 and " + MAX_CONCURRENT); + if (input.maxQueued() == null || input.maxQueued() < 1 || input.maxQueued() > MAX_QUEUED) + throw new BadRequestException("Queued builds must be between 1 and " + MAX_QUEUED); + Settings s = Settings.findById(1L); + if (s == null) { + s = new Settings(); + s.persist(); } + s.maxConcurrentBuilds = input.maxConcurrent(); + s.maxQueuedBuilds = input.maxQueued(); + return new Queue(s.maxConcurrentBuilds, s.maxQueuedBuilds); + } - private void requireAdmin() { - if (!identity.hasRole("admin")) throw new ForbiddenException(); - } + private void requireAdmin() { + if (!identity.hasRole("admin")) throw new ForbiddenException(); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/StatsResource.java b/src/backend/main/java/ai/mnemosyne/cisys/StatsResource.java index 10cb96f..79cb9df 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/StatsResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/StatsResource.java @@ -22,36 +22,53 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; /** - * Statistics for the public front page, computed from public projects only. - * Private projects and their builds are left out entirely, including from the totals. + * Statistics for the public front page, computed from public projects only. Private projects and + * their builds are left out entirely, including from the totals. */ @Path("/api/stats") @Produces(MediaType.APPLICATION_JSON) @ApplicationScoped public class StatsResource { - @Inject EntityManager em; - @ConfigProperty(name = "cisys.stats.cache-seconds", defaultValue = "30") long cacheSeconds; + @Inject EntityManager em; - public record PublicStats(long projects, long builds, long passed, long failed, long running, - long buildsLast7Days, Double successRate, Instant lastBuildAt) {} + @ConfigProperty(name = "cisys.stats.cache-seconds", defaultValue = "30") + long cacheSeconds; - private record Cached(PublicStats stats, Instant expires) {} - private volatile Cached cached; + public record PublicStats( + long projects, + long builds, + long passed, + long failed, + long running, + long buildsLast7Days, + Double successRate, + Instant lastBuildAt) {} - @GET @Path("/public") @PermitAll @PublicRateLimited - public Response publicStats() { - Cached current = cached; - if (current == null || !Instant.now().isBefore(current.expires())) { - current = new Cached(compute(), Instant.now().plusSeconds(cacheSeconds)); - if (cacheSeconds > 0) cached = current; - } - return Response.ok(current.stats()).header("Cache-Control", "public, max-age=" + cacheSeconds).build(); + private record Cached(PublicStats stats, Instant expires) {} + + private volatile Cached cached; + + @GET + @Path("/public") + @PermitAll + @PublicRateLimited + public Response publicStats() { + Cached current = cached; + if (current == null || !Instant.now().isBefore(current.expires())) { + current = new Cached(compute(), Instant.now().plusSeconds(cacheSeconds)); + if (cacheSeconds > 0) cached = current; } + return Response.ok(current.stats()) + .header("Cache-Control", "public, max-age=" + cacheSeconds) + .build(); + } - private PublicStats compute() { - long projects = Project.count("privateRepository", false); - // Build.projectId is a plain Long, so restrict to public projects with a subquery. - Object[] row = em.createQuery(""" + private PublicStats compute() { + long projects = Project.count("privateRepository", false); + // Build.projectId is a plain Long, so restrict to public projects with a subquery. + Object[] row = + em.createQuery( + """ select count(b), sum(case when b.status = 'PASSED' then 1 else 0 end), sum(case when b.status = 'FAILED' then 1 else 0 end), @@ -60,14 +77,22 @@ select count(b), max(b.startedAt) from Build b where b.projectId in (select p.id from Project p where p.privateRepository = false) - """, Object[].class) - .setParameter("since", Instant.now().minus(7, ChronoUnit.DAYS)) - .getSingleResult(); - long builds = number(row[0]), passed = number(row[1]), failed = number(row[2]), running = number(row[3]), last7 = number(row[4]); - Double successRate = passed + failed == 0 ? null : (double) passed / (passed + failed); - return new PublicStats(projects, builds, passed, failed, running, last7, successRate, (Instant) row[5]); - } + """, + Object[].class) + .setParameter("since", Instant.now().minus(7, ChronoUnit.DAYS)) + .getSingleResult(); + long builds = number(row[0]), + passed = number(row[1]), + failed = number(row[2]), + running = number(row[3]), + last7 = number(row[4]); + Double successRate = passed + failed == 0 ? null : (double) passed / (passed + failed); + return new PublicStats( + projects, builds, passed, failed, running, last7, successRate, (Instant) row[5]); + } - /** sum() is null when no rows match. */ - private static long number(Object value) { return value == null ? 0 : ((Number) value).longValue(); } + /** sum() is null when no rows match. */ + private static long number(Object value) { + return value == null ? 0 : ((Number) value).longValue(); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/SystemHostResolver.java b/src/backend/main/java/ai/mnemosyne/cisys/SystemHostResolver.java index 6fc8d5f..1d1fe68 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/SystemHostResolver.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/SystemHostResolver.java @@ -15,8 +15,8 @@ /** The default resolver: the JVM's own name lookup. */ @ApplicationScoped public class SystemHostResolver implements HostResolver { - @Override - public InetAddress[] resolve(String host) throws UnknownHostException { - return InetAddress.getAllByName(host); - } + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + return InetAddress.getAllByName(host); + } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/Workflow.java b/src/backend/main/java/ai/mnemosyne/cisys/Workflow.java index f887656..6263e4c 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/Workflow.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/Workflow.java @@ -16,7 +16,6 @@ import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.LinkOption; @@ -50,391 +49,511 @@ * wait: 8180 * * - * A step either runs a shell command ({@code run}) or waits until a TCP port accepts connections ({@code wait}). - * A build turns the workflow into a Containerfile, see {@link #containerfile(String, String, String, String, boolean)}. + * A step either runs a shell command ({@code run}) or waits until a TCP port accepts connections + * ({@code wait}). A build turns the workflow into a Containerfile, see {@link + * #containerfile(String, String, String, String, boolean)}. */ -public record Workflow(String name, String container, List environment, List dependencies, List jobs) { - /** - * Where the steps read {@link #environment()} from. Root writes it before the clone and makes it read-only, so a - * step cannot add variables for the steps after it. - */ - static final String ENVIRONMENT_FILE = "/etc/cisys-environment"; - static final int MAX_ENVIRONMENT = 100_000; - /** Where the workflow lives, relative to the repository root. */ - public static final String PATH = ".cisys/workflows/ci.yml"; - static final int MAX_BYTES = 64 * 1024; - static final int MAX_NAME = 200; - static final int MAX_COMMAND = 4000; - - /** One entry of an {@code environment} list. */ - public record Variable(String name, String value) {} - - /** - * One entry of the workflow's {@code dependencies} list: {@code value} is the package to install, {@code name} - * labels it in the log. Only the workflow has dependencies, not jobs or steps. - */ - public record Dependency(String name, String value) {} - - /** {@code environment} applies to this job's steps, over the workflow's. */ - public record Job(String job, List environment, List steps) {} - - /** - * Exactly one of {@code run} and {@code wait} is set; {@code wait} is held in {@link #port()}, as records cannot - * have a {@code wait} component. {@code environment} applies to this {@code run} step only, over the job's. - */ - public record Step(String name, List environment, String run, @JsonProperty("wait") Integer port) { - public boolean isRun() { - return run != null; - } +public record Workflow( + String name, + String container, + List environment, + List dependencies, + List jobs) { + /** + * Where the steps read {@link #environment()} from. Root writes it before the clone and makes it + * read-only, so a step cannot add variables for the steps after it. + */ + static final String ENVIRONMENT_FILE = "/etc/cisys-environment"; + + static final int MAX_ENVIRONMENT = 100_000; + + /** Where the workflow lives, relative to the repository root. */ + public static final String PATH = ".cisys/workflows/ci.yml"; + + static final int MAX_BYTES = 64 * 1024; + static final int MAX_NAME = 200; + static final int MAX_COMMAND = 4000; + + /** One entry of an {@code environment} list. */ + public record Variable(String name, String value) {} + + /** + * One entry of the workflow's {@code dependencies} list: {@code value} is the package to install, + * {@code name} labels it in the log. Only the workflow has dependencies, not jobs or steps. + */ + public record Dependency(String name, String value) {} + + /** {@code environment} applies to this job's steps, over the workflow's. */ + public record Job(String job, List environment, List steps) {} + + /** + * Exactly one of {@code run} and {@code wait} is set; {@code wait} is held in {@link #port()}, as + * records cannot have a {@code wait} component. {@code environment} applies to this {@code run} + * step only, over the job's. + */ + public record Step( + String name, List environment, String run, @JsonProperty("wait") Integer port) { + public boolean isRun() { + return run != null; } - - private static final ObjectMapper MAPPER = JsonMapper.builder(new YAMLFactory()) - .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) - .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) - .disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT) - .build(); - - /** Reads the workflow from a checkout, or returns null when the repository has none. */ - public static Workflow load(Path checkout) throws IOException { - String text = read(checkout); - return text == null ? null : parse(text); + } + + private static final ObjectMapper MAPPER = + JsonMapper.builder(new YAMLFactory()) + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT) + .build(); + + /** Reads the workflow from a checkout, or returns null when the repository has none. */ + public static Workflow load(Path checkout) throws IOException { + String text = read(checkout); + return text == null ? null : parse(text); + } + + /** + * The workflow file's text, unparsed, or null when the repository has none. Builds store it as + * they read it. + */ + public static String read(Path checkout) throws IOException { + Path file = checkout.resolve(PATH); + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return null; + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) + throw new InvalidWorkflowException(PATH + " must be a regular file"); + if (Files.size(file) > MAX_BYTES) + throw new InvalidWorkflowException(PATH + " is larger than " + MAX_BYTES + " bytes"); + String text; + try { + text = Files.readString(file); + } catch (java.nio.charset.CharacterCodingException e) { + throw new InvalidWorkflowException(PATH + " is not valid UTF-8"); } - - /** The workflow file's text, unparsed, or null when the repository has none. Builds store it as they read it. */ - public static String read(Path checkout) throws IOException { - Path file = checkout.resolve(PATH); - if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return null; - if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new InvalidWorkflowException(PATH + " must be a regular file"); - if (Files.size(file) > MAX_BYTES) throw new InvalidWorkflowException(PATH + " is larger than " + MAX_BYTES + " bytes"); - String text; - try { - text = Files.readString(file); - } catch (java.nio.charset.CharacterCodingException e) { - throw new InvalidWorkflowException(PATH + " is not valid UTF-8"); - } - // PostgreSQL text cannot hold NUL characters. - if (text.indexOf('\0') >= 0) throw new InvalidWorkflowException(PATH + " contains a NUL character"); - return text; + // PostgreSQL text cannot hold NUL characters. + if (text.indexOf('\0') >= 0) + throw new InvalidWorkflowException(PATH + " contains a NUL character"); + return text; + } + + /** Parses and validates a workflow document. */ + public static Workflow parse(String yaml) { + if (yaml == null || yaml.isBlank()) throw new InvalidWorkflowException("The workflow is empty"); + if (yaml.length() > MAX_BYTES) + throw new InvalidWorkflowException("The workflow is larger than " + MAX_BYTES + " bytes"); + Workflow workflow; + try { + workflow = MAPPER.readValue(yaml, Workflow.class); + } catch (UnrecognizedPropertyException e) { + throw new InvalidWorkflowException("Unknown key '" + e.getPropertyName() + "'" + location(e)); + } catch (JsonProcessingException e) { + throw new InvalidWorkflowException(e.getOriginalMessage() + location(e)); } - - /** Parses and validates a workflow document. */ - public static Workflow parse(String yaml) { - if (yaml == null || yaml.isBlank()) throw new InvalidWorkflowException("The workflow is empty"); - if (yaml.length() > MAX_BYTES) throw new InvalidWorkflowException("The workflow is larger than " + MAX_BYTES + " bytes"); - Workflow workflow; - try { - workflow = MAPPER.readValue(yaml, Workflow.class); - } catch (UnrecognizedPropertyException e) { - throw new InvalidWorkflowException("Unknown key '" + e.getPropertyName() + "'" + location(e)); - } catch (JsonProcessingException e) { - throw new InvalidWorkflowException(e.getOriginalMessage() + location(e)); + workflow.validate(); + return workflow; + } + + private static String location(JsonProcessingException e) { + return e.getLocation() == null ? "" : " at line " + e.getLocation().getLineNr(); + } + + private void validate() { + requireName(name, "name"); + validateEnvironment(environment, "environment"); + validateDependencies(dependencies, "dependencies"); + if (container == null || container.isBlank()) + throw new InvalidWorkflowException("container is required"); + if (!container.matches(AllowedImage.PATTERN)) + throw new InvalidWorkflowException("container is not a valid image reference"); + if (jobs == null || jobs.isEmpty()) + throw new InvalidWorkflowException("At least one job is required"); + for (int j = 0; j < jobs.size(); j++) { + Job job = jobs.get(j); + String where = "jobs[" + j + "]"; + if (job == null) throw new InvalidWorkflowException(where + " is empty"); + requireName(job.job(), where + ".job"); + validateEnvironment(job.environment(), where + ".environment"); + if (job.steps() == null || job.steps().isEmpty()) + throw new InvalidWorkflowException(where + " needs at least one step"); + for (int s = 0; s < job.steps().size(); s++) { + Step step = job.steps().get(s); + String at = where + ".steps[" + s + "]"; + if (step == null) throw new InvalidWorkflowException(at + " is empty"); + requireName(step.name(), at + ".name"); + validateEnvironment(step.environment(), at + ".environment"); + if ((step.run() == null) == (step.port() == null)) + throw new InvalidWorkflowException(at + " needs exactly one of run or wait"); + if (step.run() != null && (step.run().isBlank() || step.run().length() > MAX_COMMAND)) { + throw new InvalidWorkflowException( + at + ".run must be a command of at most " + MAX_COMMAND + " characters"); } - workflow.validate(); - return workflow; - } - - private static String location(JsonProcessingException e) { - return e.getLocation() == null ? "" : " at line " + e.getLocation().getLineNr(); - } - - private void validate() { - requireName(name, "name"); - validateEnvironment(environment, "environment"); - validateDependencies(dependencies, "dependencies"); - if (container == null || container.isBlank()) throw new InvalidWorkflowException("container is required"); - if (!container.matches(AllowedImage.PATTERN)) throw new InvalidWorkflowException("container is not a valid image reference"); - if (jobs == null || jobs.isEmpty()) throw new InvalidWorkflowException("At least one job is required"); - for (int j = 0; j < jobs.size(); j++) { - Job job = jobs.get(j); - String where = "jobs[" + j + "]"; - if (job == null) throw new InvalidWorkflowException(where + " is empty"); - requireName(job.job(), where + ".job"); - validateEnvironment(job.environment(), where + ".environment"); - if (job.steps() == null || job.steps().isEmpty()) throw new InvalidWorkflowException(where + " needs at least one step"); - for (int s = 0; s < job.steps().size(); s++) { - Step step = job.steps().get(s); - String at = where + ".steps[" + s + "]"; - if (step == null) throw new InvalidWorkflowException(at + " is empty"); - requireName(step.name(), at + ".name"); - validateEnvironment(step.environment(), at + ".environment"); - if ((step.run() == null) == (step.port() == null)) throw new InvalidWorkflowException(at + " needs exactly one of run or wait"); - if (step.run() != null && (step.run().isBlank() || step.run().length() > MAX_COMMAND)) { - throw new InvalidWorkflowException(at + ".run must be a command of at most " + MAX_COMMAND + " characters"); - } - if (step.port() != null && (step.port() < 1 || step.port() > 65535)) { - throw new InvalidWorkflowException(at + ".wait must be a port between 1 and 65535"); - } - if (step.port() != null && step.environment() != null) { - throw new InvalidWorkflowException(at + ".environment applies to run steps only"); - } - } + if (step.port() != null && (step.port() < 1 || step.port() > 65535)) { + throw new InvalidWorkflowException(at + ".wait must be a port between 1 and 65535"); } - } - - private static void validateEnvironment(List environment, String where) { - if (environment == null) return; - java.util.Set seen = new java.util.HashSet<>(); - for (int i = 0; i < environment.size(); i++) { - Variable v = environment.get(i); - String at = where + "[" + i + "]"; - if (v == null) throw new InvalidWorkflowException(at + " is empty"); - if (v.name() == null) throw new InvalidWorkflowException(at + ".name is required"); - if (!v.name().matches("[A-Za-z_][A-Za-z0-9_]{0,199}")) { - throw new InvalidWorkflowException(at + ".name '" + v.name() + "' must be a variable name: letters, digits and _, not starting with a digit"); - } - if (!seen.add(v.name())) throw new InvalidWorkflowException(at + ".name " + v.name() + " appears more than once"); - if (v.value() == null) throw new InvalidWorkflowException(at + ".value is required; use \"\" for an empty one"); - if (v.value().length() > MAX_COMMAND) throw new InvalidWorkflowException(at + ".value is longer than " + MAX_COMMAND + " characters"); + if (step.port() != null && step.environment() != null) { + throw new InvalidWorkflowException(at + ".environment applies to run steps only"); } - if (exports(environment).length() > MAX_ENVIRONMENT) throw new InvalidWorkflowException(where + " is too large"); + } } - - /** A package name, optionally with a version as the package manager writes it, such as {@code openjdk-25-jdk} or {@code git=2.47.3-0+deb13u1}. */ - static final String PACKAGE = "[A-Za-z0-9][A-Za-z0-9+._:=~@-]{0,199}"; - - private static void validateDependencies(List dependencies, String where) { - if (dependencies == null) return; - java.util.Set seen = new java.util.HashSet<>(); - for (int i = 0; i < dependencies.size(); i++) { - Dependency d = dependencies.get(i); - String at = where + "[" + i + "]"; - if (d == null) throw new InvalidWorkflowException(at + " is empty"); - requireName(d.name(), at + ".name"); - if (!seen.add(d.name())) throw new InvalidWorkflowException(at + ".name " + d.name() + " appears more than once"); - if (d.value() == null) throw new InvalidWorkflowException(at + ".value is required"); - if (!d.value().matches(PACKAGE)) throw new InvalidWorkflowException(at + ".value '" + d.value() + "' is not a package name"); - } + } + + private static void validateEnvironment(List environment, String where) { + if (environment == null) return; + java.util.Set seen = new java.util.HashSet<>(); + for (int i = 0; i < environment.size(); i++) { + Variable v = environment.get(i); + String at = where + "[" + i + "]"; + if (v == null) throw new InvalidWorkflowException(at + " is empty"); + if (v.name() == null) throw new InvalidWorkflowException(at + ".name is required"); + if (!v.name().matches("[A-Za-z_][A-Za-z0-9_]{0,199}")) { + throw new InvalidWorkflowException( + at + + ".name '" + + v.name() + + "' must be a variable name: letters, digits and _, not starting with a digit"); + } + if (!seen.add(v.name())) + throw new InvalidWorkflowException(at + ".name " + v.name() + " appears more than once"); + if (v.value() == null) + throw new InvalidWorkflowException(at + ".value is required; use \"\" for an empty one"); + if (v.value().length() > MAX_COMMAND) + throw new InvalidWorkflowException( + at + ".value is longer than " + MAX_COMMAND + " characters"); } - - /** How long a {@code wait} step waits for its port, in seconds. */ - static final int WAIT_SECONDS = 300; - static final int MAX_CONTAINERFILE = 200_000; - /** The user the clone and the steps run as. */ - static final String BUILD_USER = "65534:65534"; - /** The id of the build secret that holds the Git token, see {@link #containerfile}. */ - static final String GIT_SECRET = "git"; - - /** - * The Containerfile that builds this workflow's image from the local image {@code baseId}, which - * {@link ImageReference#resolve} found for {@code container}; see - * {@link #containerfile(String, String, String, String, boolean, int)}. - */ - public String containerfile(String baseId, String repositoryUrl, String branch, String revision, boolean credential) { - return containerfile(baseId, repositoryUrl, branch, revision, credential, WAIT_SECONDS); + if (exports(environment).length() > MAX_ENVIRONMENT) + throw new InvalidWorkflowException(where + " is too large"); + } + + /** + * A package name, optionally with a version as the package manager writes it, such as {@code + * openjdk-25-jdk} or {@code git=2.47.3-0+deb13u1}. + */ + static final String PACKAGE = "[A-Za-z0-9][A-Za-z0-9+._:=~@-]{0,199}"; + + private static void validateDependencies(List dependencies, String where) { + if (dependencies == null) return; + java.util.Set seen = new java.util.HashSet<>(); + for (int i = 0; i < dependencies.size(); i++) { + Dependency d = dependencies.get(i); + String at = where + "[" + i + "]"; + if (d == null) throw new InvalidWorkflowException(at + " is empty"); + requireName(d.name(), at + ".name"); + if (!seen.add(d.name())) + throw new InvalidWorkflowException(at + ".name " + d.name() + " appears more than once"); + if (d.value() == null) throw new InvalidWorkflowException(at + ".value is required"); + if (!d.value().matches(PACKAGE)) + throw new InvalidWorkflowException(at + ".value '" + d.value() + "' is not a package name"); } - - /** - * Builds an image from the base image, named by its ID so the build uses exactly the image that was resolved: - *
    - *
  1. installs {@link #TOOLS} (Git, Make, Podman and Podman Compose) as root when the image does not have them,
  2. - *
  3. clones the branch into {@code /workspace} as an unprivileged user and checks that it is at {@code revision}; - * the working tree stays on the branch, with its {@code .git},
  4. - *
  5. runs the steps as that user, without network, with the workflow's, the job's and the step's - * {@code environment} exported, the innermost winning. A variable is unset again when its scope ends: a step's - * variables when the step ends, a job's when the job ends. A step cannot change the variables of the steps after - * it, as each step starts a new shell from the read-only {@link #ENVIRONMENT_FILE}.
  6. - *
- * {@code dependencies} are installed as root, with network, with the package manager the image has, before the - * clone. - *

- * Each {@code run} step is its own {@code RUN}. A job with a {@code wait} step runs as a single {@code RUN}, - * because processes started in the background end with the {@code RUN} that started them. The first failing - * step stops the build with its exit code. The Git token, when there is one, is a build secret - * ({@value #GIT_SECRET}) that is mounted for the clone only and is not stored in the image. - */ - String containerfile(String baseId, String repositoryUrl, String branch, String revision, boolean credential, int waitSeconds) { - if (!baseId.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Not an image ID: " + baseId); - StringBuilder f = new StringBuilder(); - f.append("FROM ").append(baseId).append('\n'); - f.append("USER 0\n"); - f.append("RUN ").append(exec("sh", "-c", INSTALL_TOOLS)).append('\n'); - install(f, dependencies); - // The variables travel as an argument, so neither the Containerfile parser nor a shell interprets them here. - f.append("RUN --network=none ").append(exec("sh", "-c", "printf '%s' \"$1\" > " + ENVIRONMENT_FILE - + " && chown 0:0 " + ENVIRONMENT_FILE + " && chmod 444 " + ENVIRONMENT_FILE, "environment", exports())).append('\n'); - f.append("RUN ").append(exec("sh", "-c", "mkdir -p /workspace && chown " + BUILD_USER + " /workspace")).append('\n'); - f.append("USER ").append(BUILD_USER).append('\n'); - f.append("WORKDIR /workspace\n"); - f.append("ENV HOME=/tmp GIT_TERMINAL_PROMPT=0\n"); - f.append("RUN "); - if (credential) f.append("--mount=type=secret,id=").append(GIT_SECRET).append(' '); - f.append(exec("sh", "-c", clone(credential), "clone", repositoryUrl, branch, revision)).append('\n'); - for (String run : runs(waitSeconds)) { - f.append("RUN --network=none ").append(exec("sh", "-c", ". " + ENVIRONMENT_FILE + " || exit 1\n" + run)).append('\n'); - } - if (f.length() > MAX_CONTAINERFILE) throw new InvalidWorkflowException("The workflow is too large to run"); - return f.toString(); + } + + /** How long a {@code wait} step waits for its port, in seconds. */ + static final int WAIT_SECONDS = 300; + + static final int MAX_CONTAINERFILE = 200_000; + + /** The user the clone and the steps run as. */ + static final String BUILD_USER = "65534:65534"; + + /** The id of the build secret that holds the Git token, see {@link #containerfile}. */ + static final String GIT_SECRET = "git"; + + /** + * The Containerfile that builds this workflow's image from the local image {@code baseId}, which + * {@link ImageReference#resolve} found for {@code container}; see {@link #containerfile(String, + * String, String, String, boolean, int)}. + */ + public String containerfile( + String baseId, String repositoryUrl, String branch, String revision, boolean credential) { + return containerfile(baseId, repositoryUrl, branch, revision, credential, WAIT_SECONDS); + } + + /** + * Builds an image from the base image, named by its ID so the build uses exactly the image that + * was resolved: + * + *

    + *
  1. installs {@link #TOOLS} (Git, Make, Podman and Podman Compose) as root when the image + * does not have them, + *
  2. clones the branch into {@code /workspace} as an unprivileged user and checks that it is + * at {@code revision}; the working tree stays on the branch, with its {@code .git}, + *
  3. runs the steps as that user, without network, with the workflow's, the job's and the + * step's {@code environment} exported, the innermost winning. A variable is unset again + * when its scope ends: a step's variables when the step ends, a job's when the job ends. A + * step cannot change the variables of the steps after it, as each step starts a new shell + * from the read-only {@link #ENVIRONMENT_FILE}. + *
+ * + * {@code dependencies} are installed as root, with network, with the package manager the image + * has, before the clone. + * + *

Each {@code run} step is its own {@code RUN}. A job with a {@code wait} step runs as a + * single {@code RUN}, because processes started in the background end with the {@code RUN} that + * started them. The first failing step stops the build with its exit code. The Git token, when + * there is one, is a build secret ({@value #GIT_SECRET}) that is mounted for the clone only and + * is not stored in the image. + */ + String containerfile( + String baseId, + String repositoryUrl, + String branch, + String revision, + boolean credential, + int waitSeconds) { + if (!baseId.matches("[0-9a-f]{64}")) + throw new IllegalArgumentException("Not an image ID: " + baseId); + StringBuilder f = new StringBuilder(); + f.append("FROM ").append(baseId).append('\n'); + f.append("USER 0\n"); + f.append("RUN ").append(exec("sh", "-c", INSTALL_TOOLS)).append('\n'); + install(f, dependencies); + // The variables travel as an argument, so neither the Containerfile parser nor a shell + // interprets them here. + f.append("RUN --network=none ") + .append( + exec( + "sh", + "-c", + "printf '%s' \"$1\" > " + + ENVIRONMENT_FILE + + " && chown 0:0 " + + ENVIRONMENT_FILE + + " && chmod 444 " + + ENVIRONMENT_FILE, + "environment", + exports())) + .append('\n'); + f.append("RUN ") + .append(exec("sh", "-c", "mkdir -p /workspace && chown " + BUILD_USER + " /workspace")) + .append('\n'); + f.append("USER ").append(BUILD_USER).append('\n'); + f.append("WORKDIR /workspace\n"); + f.append("ENV HOME=/tmp GIT_TERMINAL_PROMPT=0\n"); + f.append("RUN "); + if (credential) f.append("--mount=type=secret,id=").append(GIT_SECRET).append(' '); + f.append(exec("sh", "-c", clone(credential), "clone", repositoryUrl, branch, revision)) + .append('\n'); + for (String run : runs(waitSeconds)) { + f.append("RUN --network=none ") + .append(exec("sh", "-c", ". " + ENVIRONMENT_FILE + " || exit 1\n" + run)) + .append('\n'); } - - /** The commands every build image contains; each is installed from the package of the same name when missing. */ - static final List TOOLS = List.of("git", "make", "podman", "podman-compose"); - - /** - * Shell code that finds the image's package manager, sets {@code pm} to it (empty when there is none) and defines - * {@code install PACKAGE...} and {@code installed PACKAGE...}. - *

- * {@code install} always reads fresh package metadata, never the image's cached copy, and installs the newest - * version; a package the image already has is upgraded to it. {@code installed} logs the version each package - * ended up at. - */ - private static final String PACKAGE_MANAGER = """ - . /etc/os-release 2>/dev/null - for pm in apk apt-get dnf microdnf yum zypper ''; do [ -z "$pm" ] || ! command -v "$pm" >/dev/null 2>&1 || break; done - install() { - case "$pm" in - apk) apk add --no-cache --upgrade "$@" ;; - apt-get) apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ca-certificates "$@" && rm -rf /var/lib/apt/lists/* ;; - dnf) dnf install -y -q --refresh "$@" && dnf upgrade -y -q "$@" && dnf clean all ;; - microdnf) microdnf install -y --refresh "$@" && microdnf upgrade -y "$@" && microdnf clean all ;; - yum) yum clean expire-cache -q && yum install -y -q "$@" && yum update -y -q "$@" && yum clean all ;; - zypper) zypper --non-interactive refresh && zypper --non-interactive install "$@" && zypper --non-interactive update "$@" && zypper clean ;; - *) return 1 ;; - esac - } - installed() { - for p in "$@"; do - n=${p%%[=<>]*} - case "$pm" in - apk) v=$(apk list --installed "$n" 2>/dev/null | cut -d' ' -f1 | head -n 1) ;; - apt-get) v=$(dpkg-query -W -f='${Package} ${Version}' "${n%%:*}" 2>/dev/null) ;; - *) v=$(rpm -q "$n" 2>/dev/null || rpm -q --whatprovides "$n" 2>/dev/null) ;; - esac - echo "==> Installed: ${v:-$p}" - done - } - """; - - private static final String INSTALL_TOOLS = PACKAGE_MANAGER + """ - echo "==> Container: ${PRETTY_NAME:-unknown}, package manager: ${pm:-none}" - missing= - for tool in %1$s; do command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"; done - [ -z "$missing" ] && exit 0 - [ -n "$pm" ] || { echo '==> Cannot install%2$s: the container has no supported package manager'; exit 1; } - echo "==> Installing$missing with $pm" - install $missing || { rc=$?; echo "==> Could not install$missing"; exit "$rc"; } - for tool in %1$s; do command -v "$tool" >/dev/null 2>&1 || { echo "==> $tool is missing after the installation"; exit 1; }; done - """.formatted(String.join(" ", TOOLS), String.join(", ", TOOLS).replaceFirst("^", " ")); - - /** Arguments: name and value of each dependency, in turn. The values are package names, see {@link #PACKAGE}. */ - private static final String INSTALL_DEPENDENCIES = PACKAGE_MANAGER + """ - [ -n "$pm" ] || { echo '==> Cannot install dependencies: the container has no supported package manager'; exit 1; } - packages= - while [ "$#" -gt 1 ]; do echo "==> Dependency: $1 ($2) with $pm"; packages="$packages $2"; shift 2; done - install $packages || { rc=$?; echo "==> Could not install$packages"; exit "$rc"; } - installed $packages - """; - - private static void install(StringBuilder f, List dependencies) { - if (dependencies == null || dependencies.isEmpty()) return; - List args = new java.util.ArrayList<>(List.of("sh", "-c", INSTALL_DEPENDENCIES, "dependencies")); - for (Dependency d : dependencies) { - args.add(d.name()); - args.add(d.value()); - } - f.append("RUN ").append(exec(args.toArray(String[]::new))).append('\n'); + if (f.length() > MAX_CONTAINERFILE) + throw new InvalidWorkflowException("The workflow is too large to run"); + return f.toString(); + } + + /** + * The commands every build image contains; each is installed from the package of the same name + * when missing. + */ + static final List TOOLS = List.of("git", "make", "podman", "podman-compose"); + + /** + * Shell code that finds the image's package manager, sets {@code pm} to it (empty when there is + * none) and defines {@code install PACKAGE...} and {@code installed PACKAGE...}. + * + *

{@code install} always reads fresh package metadata, never the image's cached copy, and + * installs the newest version; a package the image already has is upgraded to it. {@code + * installed} logs the version each package ended up at. + */ + private static final String PACKAGE_MANAGER = + """ + . /etc/os-release 2>/dev/null + for pm in apk apt-get dnf microdnf yum zypper ''; do [ -z "$pm" ] || ! command -v "$pm" >/dev/null 2>&1 || break; done + install() { + case "$pm" in + apk) apk add --no-cache --upgrade "$@" ;; + apt-get) apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ca-certificates "$@" && rm -rf /var/lib/apt/lists/* ;; + dnf) dnf install -y -q --refresh "$@" && dnf upgrade -y -q "$@" && dnf clean all ;; + microdnf) microdnf install -y --refresh "$@" && microdnf upgrade -y "$@" && microdnf clean all ;; + yum) yum clean expire-cache -q && yum install -y -q "$@" && yum update -y -q "$@" && yum clean all ;; + zypper) zypper --non-interactive refresh && zypper --non-interactive install "$@" && zypper --non-interactive update "$@" && zypper clean ;; + *) return 1 ;; + esac + } + installed() { + for p in "$@"; do + n=${p%%[=<>]*} + case "$pm" in + apk) v=$(apk list --installed "$n" 2>/dev/null | cut -d' ' -f1 | head -n 1) ;; + apt-get) v=$(dpkg-query -W -f='${Package} ${Version}' "${n%%:*}" 2>/dev/null) ;; + *) v=$(rpm -q "$n" 2>/dev/null || rpm -q --whatprovides "$n" 2>/dev/null) ;; + esac + echo "==> Installed: ${v:-$p}" + done + } + """; + + private static final String INSTALL_TOOLS = + PACKAGE_MANAGER + + """ + echo "==> Container: ${PRETTY_NAME:-unknown}, package manager: ${pm:-none}" + missing= + for tool in %1$s; do command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"; done + [ -z "$missing" ] && exit 0 + [ -n "$pm" ] || { echo '==> Cannot install%2$s: the container has no supported package manager'; exit 1; } + echo "==> Installing$missing with $pm" + install $missing || { rc=$?; echo "==> Could not install$missing"; exit "$rc"; } + for tool in %1$s; do command -v "$tool" >/dev/null 2>&1 || { echo "==> $tool is missing after the installation"; exit 1; }; done + """ + .formatted(String.join(" ", TOOLS), String.join(", ", TOOLS).replaceFirst("^", " ")); + + /** + * Arguments: name and value of each dependency, in turn. The values are package names, see {@link + * #PACKAGE}. + */ + private static final String INSTALL_DEPENDENCIES = + PACKAGE_MANAGER + + """ + [ -n "$pm" ] || { echo '==> Cannot install dependencies: the container has no supported package manager'; exit 1; } + packages= + while [ "$#" -gt 1 ]; do echo "==> Dependency: $1 ($2) with $pm"; packages="$packages $2"; shift 2; done + install $packages || { rc=$?; echo "==> Could not install$packages"; exit "$rc"; } + installed $packages + """; + + private static void install(StringBuilder f, List dependencies) { + if (dependencies == null || dependencies.isEmpty()) return; + List args = + new java.util.ArrayList<>(List.of("sh", "-c", INSTALL_DEPENDENCIES, "dependencies")); + for (Dependency d : dependencies) { + args.add(d.name()); + args.add(d.value()); } - - /** Arguments: $1 repository URL, $2 branch, $3 expected revision. */ - private static String clone(boolean credential) { - String helper = credential - ? "-c 'credential.helper=!f() { echo username=oauth2; printf \"password=%s\\n\" \"$(cat /run/secrets/" + GIT_SECRET + ")\"; }; f' " - : ""; - return "echo \"==> Checkout: $2\"\n" - + "git -c credential.helper= " + helper + "clone --quiet --depth 1 --single-branch --branch \"$2\" -- \"$1\" . || exit $?\n" - + "rev=$(git rev-parse HEAD) || exit $?\n" - + "if [ \"$rev\" != \"$3\" ]; then echo \"==> Checkout failed: $2 is at $rev, expected $3\"; exit 1; fi\n"; + f.append("RUN ").append(exec(args.toArray(String[]::new))).append('\n'); + } + + /** Arguments: $1 repository URL, $2 branch, $3 expected revision. */ + private static String clone(boolean credential) { + String helper = + credential + ? "-c 'credential.helper=!f() { echo username=oauth2; printf \"password=%s\\n\" \"$(cat /run/secrets/" + + GIT_SECRET + + ")\"; }; f' " + : ""; + return "echo \"==> Checkout: $2\"\n" + + "git -c credential.helper= " + + helper + + "clone --quiet --depth 1 --single-branch --branch \"$2\" -- \"$1\" . || exit $?\n" + + "rev=$(git rev-parse HEAD) || exit $?\n" + + "if [ \"$rev\" != \"$3\" ]; then echo \"==> Checkout failed: $2 is at $rev, expected $3\"; exit 1; fi\n"; + } + + private static final JsonMapper JSON = new JsonMapper(); + + /** + * The exec (JSON) form, so nothing in the arguments is interpreted by the Containerfile parser or + * a shell. + */ + private static String exec(String... args) { + try { + return JSON.writeValueAsString(args); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); } - - private static final JsonMapper JSON = new JsonMapper(); - - /** The exec (JSON) form, so nothing in the arguments is interpreted by the Containerfile parser or a shell. */ - private static String exec(String... args) { - try { - return JSON.writeValueAsString(args); - } catch (JsonProcessingException e) { - throw new IllegalStateException(e); - } - } - - /** The environment and all steps as one shell script, as the Containerfile's {@code RUN}s run them in order. */ - String script(int waitSeconds) { - // Each RUN is a new shell, so each runs in a subshell here. - StringBuilder s = new StringBuilder(exports()); - for (String run : runs(waitSeconds)) s.append("(\n").append(run).append(") || exit $?\n"); - return s.toString(); - } - - /** The scripts of the Containerfile's step {@code RUN}s. */ - List runs(int waitSeconds) { - List runs = new java.util.ArrayList<>(); - for (Job job : jobs) { - String header = "echo " + quote("==> Job: " + job.job()) + "\n"; - // Each RUN of the job sets the job's variables again, as a RUN starts a new shell. - String jobEnvironment = exports(job.environment()); - if (job.steps().stream().anyMatch(step -> !step.isRun())) { - StringBuilder s = new StringBuilder(jobEnvironment + header); - for (Step step : job.steps()) s.append(step(step, waitSeconds)); - runs.add(s.toString()); - } else { - for (int i = 0; i < job.steps().size(); i++) { - Step step = job.steps().get(i); - runs.add(jobEnvironment + (i == 0 ? header : "") + step(step, waitSeconds)); - } - } + } + + /** + * The environment and all steps as one shell script, as the Containerfile's {@code RUN}s run them + * in order. + */ + String script(int waitSeconds) { + // Each RUN is a new shell, so each runs in a subshell here. + StringBuilder s = new StringBuilder(exports()); + for (String run : runs(waitSeconds)) s.append("(\n").append(run).append(") || exit $?\n"); + return s.toString(); + } + + /** The scripts of the Containerfile's step {@code RUN}s. */ + List runs(int waitSeconds) { + List runs = new java.util.ArrayList<>(); + for (Job job : jobs) { + String header = "echo " + quote("==> Job: " + job.job()) + "\n"; + // Each RUN of the job sets the job's variables again, as a RUN starts a new shell. + String jobEnvironment = exports(job.environment()); + if (job.steps().stream().anyMatch(step -> !step.isRun())) { + StringBuilder s = new StringBuilder(jobEnvironment + header); + for (Step step : job.steps()) s.append(step(step, waitSeconds)); + runs.add(s.toString()); + } else { + for (int i = 0; i < job.steps().size(); i++) { + Step step = job.steps().get(i); + runs.add(jobEnvironment + (i == 0 ? header : "") + step(step, waitSeconds)); } - return runs; + } } - - /** - * A {@code run} step runs in its own shell from {@code /workspace}. A {@code wait} step polls the kernel's TCP - * tables for a listening socket on the port, so the image needs only {@code sh} and {@code grep}. - */ - private static String step(Step step, int waitSeconds) { - StringBuilder s = new StringBuilder(); - s.append("echo ").append(quote("==> Step: " + step.name())).append('\n'); - if (step.isRun()) { - // The step's variables are set in a subshell, so they do not reach the job's later steps. - String own = exports(step.environment()); - s.append(own.isEmpty() ? "" : "(\n" + own + "exec ").append("sh -c ").append(quote(step.run())).append(own.isEmpty() ? "" : "\n)") - .append(" || { rc=$?; echo ").append(quote("==> Step failed: " + step.name() + ", exit code")) - .append(" \"$rc\"; exit \"$rc\"; }\n"); - } else { - String port = String.format("%04X", step.port()); - s.append("i=0; until grep -Eqs '^ *[0-9]+: [0-9A-F]+:").append(port) - .append(" [0-9A-F]+:[0-9A-F]+ 0A ' /proc/net/tcp /proc/net/tcp6; do") - .append(" i=$((i+1)); if [ \"$i\" -ge ").append(waitSeconds).append(" ]; then echo ") - .append(quote("==> Step failed: " + step.name() + ", port " + step.port() - + " not listening after " + waitSeconds + " seconds")) - .append("; exit 1; fi; sleep 1; done\n"); - } - return s.toString(); + return runs; + } + + /** + * A {@code run} step runs in its own shell from {@code /workspace}. A {@code wait} step polls the + * kernel's TCP tables for a listening socket on the port, so the image needs only {@code sh} and + * {@code grep}. + */ + private static String step(Step step, int waitSeconds) { + StringBuilder s = new StringBuilder(); + s.append("echo ").append(quote("==> Step: " + step.name())).append('\n'); + if (step.isRun()) { + // The step's variables are set in a subshell, so they do not reach the job's later steps. + String own = exports(step.environment()); + s.append(own.isEmpty() ? "" : "(\n" + own + "exec ") + .append("sh -c ") + .append(quote(step.run())) + .append(own.isEmpty() ? "" : "\n)") + .append(" || { rc=$?; echo ") + .append(quote("==> Step failed: " + step.name() + ", exit code")) + .append(" \"$rc\"; exit \"$rc\"; }\n"); + } else { + String port = String.format("%04X", step.port()); + s.append("i=0; until grep -Eqs '^ *[0-9]+: [0-9A-F]+:") + .append(port) + .append(" [0-9A-F]+:[0-9A-F]+ 0A ' /proc/net/tcp /proc/net/tcp6; do") + .append(" i=$((i+1)); if [ \"$i\" -ge ") + .append(waitSeconds) + .append(" ]; then echo ") + .append( + quote( + "==> Step failed: " + + step.name() + + ", port " + + step.port() + + " not listening after " + + waitSeconds + + " seconds")) + .append("; exit 1; fi; sleep 1; done\n"); } - - /** The workflow's {@link #environment()} as shell {@code export} lines, in the order of the file. */ - String exports() { - return exports(environment); - } - - private static String exports(List environment) { - StringBuilder s = new StringBuilder(); - if (environment != null) environment.forEach(v -> s.append("export ").append(v.name()).append('=').append(quote(v.value())).append('\n')); - return s.toString(); - } - - private static String quote(String value) { - return "'" + value.replace("'", "'\\''") + "'"; - } - - private static void requireName(String value, String field) { - if (value == null || value.isBlank()) throw new InvalidWorkflowException(field + " is required"); - if (value.length() > MAX_NAME) throw new InvalidWorkflowException(field + " is longer than " + MAX_NAME + " characters"); - } - - /** The workflow file is not valid; the message is safe to show to the user. */ - public static class InvalidWorkflowException extends IllegalArgumentException { - public InvalidWorkflowException(String message) { - super(message); - } + return s.toString(); + } + + /** + * The workflow's {@link #environment()} as shell {@code export} lines, in the order of the file. + */ + String exports() { + return exports(environment); + } + + private static String exports(List environment) { + StringBuilder s = new StringBuilder(); + if (environment != null) + environment.forEach(v -> + s.append("export ").append(v.name()).append('=').append(quote(v.value())).append('\n')); + return s.toString(); + } + + private static String quote(String value) { + return "'" + value.replace("'", "'\\''") + "'"; + } + + private static void requireName(String value, String field) { + if (value == null || value.isBlank()) + throw new InvalidWorkflowException(field + " is required"); + if (value.length() > MAX_NAME) + throw new InvalidWorkflowException(field + " is longer than " + MAX_NAME + " characters"); + } + + /** The workflow file is not valid; the message is safe to show to the user. */ + public static class InvalidWorkflowException extends IllegalArgumentException { + public InvalidWorkflowException(String message) { + super(message); } + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BranchResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/BranchResourceTest.java index 0edad9f..97ba853 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/BranchResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/BranchResourceTest.java @@ -8,6 +8,13 @@ package ai.mnemosyne.cisys; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; + import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; @@ -16,126 +23,130 @@ import io.quarkus.test.security.jwt.JwtSecurity; import org.junit.jupiter.api.Test; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.jupiter.api.Assertions.assertEquals; - @QuarkusTest @TestProfile(BranchTestProfile.class) class BranchResourceTest { - private static final String GITHUB = "https://github.com/octocat/Hello-World"; - - private static String github() { - return GITHUB + "-" + java.util.UUID.randomUUID() + ".git"; - } - - private static String unreachable() { - return "https://github.com/octocat/unreachable-" + java.util.UUID.randomUUID() + ".git"; - } - - private Long project(boolean priv, String repositoryUrl) { - return QuarkusTransaction.requiringNew().call(() -> { - Project p = new Project(); - p.name = "example-" + java.util.UUID.randomUUID(); - p.repositoryUrl = repositoryUrl; - p.ownerSubject = "owner-id"; - p.privateRepository = priv; - p.persist(); - return p.id; + private static final String GITHUB = "https://github.com/octocat/Hello-World"; + + private static String github() { + return GITHUB + "-" + java.util.UUID.randomUUID() + ".git"; + } + + private static String unreachable() { + return "https://github.com/octocat/unreachable-" + java.util.UUID.randomUUID() + ".git"; + } + + private Long project(boolean priv, String repositoryUrl) { + return QuarkusTransaction.requiringNew() + .call(() -> { + Project p = new Project(); + p.name = "example-" + java.util.UUID.randomUUID(); + p.repositoryUrl = repositoryUrl; + p.ownerSubject = "owner-id"; + p.privateRepository = priv; + p.persist(); + return p.id; }); - } - - @Test - void anonymousIsRejected() { - Long id = project(false, github()); - given().get("/api/projects/" + id + "/branches").then().statusCode(401); - } - - @Test - @TestSecurity(user = "outsider", roles = "outsider") - @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) - void unrelatedRoleIsForbidden() { - Long id = project(false, github()); - given().get("/api/projects/" + id + "/branches").then().statusCode(403); - } - - @Test - @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) - void memberCanListBranchesOfPublicProject() { - Long id = project(false, github()); - TestGitRepositoryService.resetCalls(); - given().get("/api/projects/" + id + "/branches").then() - .statusCode(200) - .body("branches", hasSize(2)) - .body("branches", contains("dev", "main")) - .body("reason", nullValue()); - assertEquals(1, TestGitRepositoryService.calls()); - } - - @Test - @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) - void privateProjectIsHidden() { - Long id = project(true, github()); - TestGitRepositoryService.resetCalls(); - given().get("/api/projects/" + id + "/branches").then().statusCode(404); - assertEquals(0, TestGitRepositoryService.calls()); - } - - @Test - @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) - void ownerGetsPrivateRepositoryReasonWithoutRemoteCall() { - Long id = project(true, github()); - TestGitRepositoryService.resetCalls(); - given().get("/api/projects/" + id + "/branches").then() - .statusCode(200) - .body("branches", hasSize(0)) - .body("reason", equalTo("private_repository")); - assertEquals(0, TestGitRepositoryService.calls()); - } - - @Test - @TestSecurity(user = "admin", roles = "admin") - @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) - void adminGetsPrivateRepositoryReasonWithoutRemoteCall() { - Long id = project(true, github()); - TestGitRepositoryService.resetCalls(); - given().get("/api/projects/" + id + "/branches").then() - .statusCode(200) - .body("branches", hasSize(0)) - .body("reason", equalTo("private_repository")); - assertEquals(0, TestGitRepositoryService.calls()); - } - - @Test - @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) - void unsupportedRepositoryUrlIsRejected() { - Long id = project(false, "http://example.com/octocat/Hello-World.git"); - given().get("/api/projects/" + id + "/branches").then() - .statusCode(400) - .body("error", equalTo("Use an HTTPS repository URL without credentials")); - } - - @Test - @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) - void repositoryFailureIsReportedAsBadGateway() { - Long id = project(false, unreachable()); - given().get("/api/projects/" + id + "/branches").then() - .statusCode(502) - .body("error", equalTo("Unable to read branches from the repository")); - } - - @Test - @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) - void unknownProjectIsNotFound() { - given().get("/api/projects/999999/branches").then().statusCode(404); - } + } + + @Test + void anonymousIsRejected() { + Long id = project(false, github()); + given().get("/api/projects/" + id + "/branches").then().statusCode(401); + } + + @Test + @TestSecurity(user = "outsider", roles = "outsider") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void unrelatedRoleIsForbidden() { + Long id = project(false, github()); + given().get("/api/projects/" + id + "/branches").then().statusCode(403); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void memberCanListBranchesOfPublicProject() { + Long id = project(false, github()); + TestGitRepositoryService.resetCalls(); + given() + .get("/api/projects/" + id + "/branches") + .then() + .statusCode(200) + .body("branches", hasSize(2)) + .body("branches", contains("dev", "main")) + .body("reason", nullValue()); + assertEquals(1, TestGitRepositoryService.calls()); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void privateProjectIsHidden() { + Long id = project(true, github()); + TestGitRepositoryService.resetCalls(); + given().get("/api/projects/" + id + "/branches").then().statusCode(404); + assertEquals(0, TestGitRepositoryService.calls()); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerGetsPrivateRepositoryReasonWithoutRemoteCall() { + Long id = project(true, github()); + TestGitRepositoryService.resetCalls(); + given() + .get("/api/projects/" + id + "/branches") + .then() + .statusCode(200) + .body("branches", hasSize(0)) + .body("reason", equalTo("private_repository")); + assertEquals(0, TestGitRepositoryService.calls()); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) + void adminGetsPrivateRepositoryReasonWithoutRemoteCall() { + Long id = project(true, github()); + TestGitRepositoryService.resetCalls(); + given() + .get("/api/projects/" + id + "/branches") + .then() + .statusCode(200) + .body("branches", hasSize(0)) + .body("reason", equalTo("private_repository")); + assertEquals(0, TestGitRepositoryService.calls()); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void unsupportedRepositoryUrlIsRejected() { + Long id = project(false, "http://example.com/octocat/Hello-World.git"); + given() + .get("/api/projects/" + id + "/branches") + .then() + .statusCode(400) + .body("error", equalTo("Use an HTTPS repository URL without credentials")); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void repositoryFailureIsReportedAsBadGateway() { + Long id = project(false, unreachable()); + given() + .get("/api/projects/" + id + "/branches") + .then() + .statusCode(502) + .body("error", equalTo("Unable to read branches from the repository")); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void unknownProjectIsNotFound() { + given().get("/api/projects/999999/branches").then().statusCode(404); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BranchTestProfile.java b/src/backend/test/java/ai/mnemosyne/cisys/BranchTestProfile.java index b4d6206..747ee3e 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/BranchTestProfile.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/BranchTestProfile.java @@ -9,12 +9,11 @@ package ai.mnemosyne.cisys; import io.quarkus.test.junit.QuarkusTestProfile; - import java.util.Set; public class BranchTestProfile implements QuarkusTestProfile { - @Override - public Set> getEnabledAlternatives() { - return Set.of(TestGitRepositoryService.class); - } + @Override + public Set> getEnabledAlternatives() { + return Set.of(TestGitRepositoryService.class); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BrandingResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/BrandingResourceTest.java index 4f24e79..845082b 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/BrandingResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/BrandingResourceTest.java @@ -8,49 +8,112 @@ package ai.mnemosyne.cisys; -import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.security.TestSecurity; -import org.junit.jupiter.api.Test; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.nullValue; -import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import java.util.Base64; +import org.junit.jupiter.api.Test; @QuarkusTest class BrandingResourceTest { - private static final String BRAND = "{\"name\":\"My CI\",\"headerColor\":\"#123456\",\"headingColor\":\"#345678\",\"buttonColor\":\"#567890\"}"; - @Test void anonymousCannotReadBranding() { given().get("/api/branding").then().statusCode(401); } - @Test @TestSecurity(user = "member", roles = "member") - void memberCannotUpdate() { given().contentType("application/json").body(BRAND).put("/api/branding").then().statusCode(403); } - @Test @TestSecurity(user = "lead", roles = "lead") - void leadCannotUpdate() { given().contentType("application/json").body(BRAND).put("/api/branding").then().statusCode(403); } - @Test @TestSecurity(user = "admin", roles = "admin") - void adminCanSaveBranding() { - given().contentType("application/json").body(BRAND).put("/api/branding").then().statusCode(200).body("name", equalTo("My CI")); - given().get("/api/branding").then().statusCode(200).body("headerColor", equalTo("#123456")); - given().get("/api/branding/public").then().statusCode(200).body("name", equalTo("My CI")).body("buttonColor", equalTo("#567890")); - } - @Test @TestSecurity(user = "admin", roles = "admin") - void servesTheLogoAsAnImage() { - given().contentType("application/json").body(BRAND).put("/api/branding").then().statusCode(200); - given().get("/api/branding/logo").then().statusCode(404); - byte[] png = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}; - String withLogo = BRAND.replace("}", ",\"logo\":\"data:image/png;base64," + Base64.getEncoder().encodeToString(png) + "\"}"); - given().contentType("application/json").body(withLogo).put("/api/branding").then().statusCode(200); - byte[] served = given().get("/api/branding/logo").then().statusCode(200).contentType("image/png").extract().asByteArray(); - assertArrayEquals(png, served); - given().contentType("application/json").body(BRAND).put("/api/branding").then().statusCode(200); - } - @Test void anonymousCanRequestTheLogo() { - given().get("/api/branding/logo").then().statusCode(anyOf(is(200), is(404))); - } - @Test void anonymousCanReadNameAndColorsOnly() { - given().get("/api/branding/public").then().statusCode(200).body("name", notNullValue()).body("logo", nullValue()).body("background", nullValue()); - } - @Test @TestSecurity(user = "admin", roles = "admin") - void rejectsInvalidColors() { given().contentType("application/json").body(BRAND.replace("#123456", "red")).put("/api/branding").then().statusCode(400); } + private static final String BRAND = + "{\"name\":\"My CI\",\"headerColor\":\"#123456\",\"headingColor\":\"#345678\",\"buttonColor\":\"#567890\"}"; + + @Test + void anonymousCannotReadBranding() { + given().get("/api/branding").then().statusCode(401); + } + + @Test + @TestSecurity(user = "member", roles = "member") + void memberCannotUpdate() { + given().contentType("application/json").body(BRAND).put("/api/branding").then().statusCode(403); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + void leadCannotUpdate() { + given().contentType("application/json").body(BRAND).put("/api/branding").then().statusCode(403); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + void adminCanSaveBranding() { + given() + .contentType("application/json") + .body(BRAND) + .put("/api/branding") + .then() + .statusCode(200) + .body("name", equalTo("My CI")); + given().get("/api/branding").then().statusCode(200).body("headerColor", equalTo("#123456")); + given() + .get("/api/branding/public") + .then() + .statusCode(200) + .body("name", equalTo("My CI")) + .body("buttonColor", equalTo("#567890")); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + void servesTheLogoAsAnImage() { + given().contentType("application/json").body(BRAND).put("/api/branding").then().statusCode(200); + given().get("/api/branding/logo").then().statusCode(404); + byte[] png = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}; + String withLogo = + BRAND.replace( + "}", + ",\"logo\":\"data:image/png;base64," + Base64.getEncoder().encodeToString(png) + "\"}"); + given() + .contentType("application/json") + .body(withLogo) + .put("/api/branding") + .then() + .statusCode(200); + byte[] served = + given() + .get("/api/branding/logo") + .then() + .statusCode(200) + .contentType("image/png") + .extract() + .asByteArray(); + assertArrayEquals(png, served); + given().contentType("application/json").body(BRAND).put("/api/branding").then().statusCode(200); + } + + @Test + void anonymousCanRequestTheLogo() { + given().get("/api/branding/logo").then().statusCode(anyOf(is(200), is(404))); + } + + @Test + void anonymousCanReadNameAndColorsOnly() { + given() + .get("/api/branding/public") + .then() + .statusCode(200) + .body("name", notNullValue()) + .body("logo", nullValue()) + .body("background", nullValue()); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + void rejectsInvalidColors() { + given() + .contentType("application/json") + .body(BRAND.replace("#123456", "red")) + .put("/api/branding") + .then() + .statusCode(400); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BuildLogTest.java b/src/backend/test/java/ai/mnemosyne/cisys/BuildLogTest.java index e456374..f8e681d 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/BuildLogTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/BuildLogTest.java @@ -7,171 +7,354 @@ */ package ai.mnemosyne.cisys; -import io.quarkus.test.junit.QuarkusTest; + +import static org.junit.jupiter.api.Assertions.*; + import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class BuildLogTest { + @Inject BuildLogs logs; + @Inject BuildRunner runner; + + static final class Sink implements BuildRunner.LogSink { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + @Override + public synchronized void append(byte[] b, int off, int len) { + bytes.write(b, off, len); + } + + synchronized byte[] bytes() { + return bytes.toByteArray(); + } + + String text() { + return new String(bytes(), StandardCharsets.UTF_8); + } + } + + private static BuildRunner.Streamed sh(String script, BuildRunner.LogSink sink, long maxBytes) + throws Exception { + return BuildRunner.stream(List.of("sh", "-c", script), 60, sink, maxBytes); + } + + static Long project() { + return QuarkusTransaction.requiringNew() + .call(() -> { + Project p = new Project(); + String n = java.util.UUID.randomUUID().toString(); + p.name = "logs-" + n; + p.repositoryUrl = "https://github.com/octocat/logs-" + n + ".git"; + p.ownerSubject = "owner-id"; + p.persist(); + return p.id; + }); + } + + static long newBuild() { + Long project = project(); + return QuarkusTransaction.requiringNew() + .call(() -> { + Build b = new Build(); + b.projectId = project; + b.requestedBy = "owner-id"; + b.status = Build.Status.PASSED; + b.startedAt = java.time.Instant.now(); + b.persist(); + return b.id; + }); + } + + private static long largeObjectCount(long oid) { + return QuarkusTransaction.requiringNew() + .call(() -> + ((Number) + Build.getEntityManager() + .createNativeQuery( + "select count(*) from pg_largeobject_metadata where oid = cast(?1 as oid)") + .setParameter(1, oid) + .getSingleResult()) + .longValue()); + } + + @Test + void streamKeepsLargeOutputComplete() throws Exception { + Sink sink = new Sink(); + var r = sh("yes cisys | head -c 3000000", sink, Long.MAX_VALUE); + assertEquals(0, r.code()); + assertFalse(r.truncated()); + assertEquals(3_000_000, r.totalBytes()); + assertNull(r.readerError()); + assertEquals(3_000_000, sink.bytes().length); + assertTrue(sink.text().startsWith("cisys\ncisys\n")); + } + + @Test + void streamPassesBytesThroughUnchanged() throws Exception { + Sink sink = new Sink(); + sh("printf 'a\\000b\\303\\251c\\377'", sink, Long.MAX_VALUE); + assertArrayEquals( + new byte[] {'a', 0, 'b', (byte) 0xC3, (byte) 0xA9, 'c', (byte) 0xFF}, sink.bytes()); + } + + @Test + void streamAtExactLimitIsNotTruncated() throws Exception { + Sink exact = new Sink(); + var r = sh("head -c 1000 /dev/zero", exact, 1000); + assertFalse(r.truncated()); + assertEquals(1000, r.totalBytes()); + assertEquals(1000, exact.bytes().length); + Sink over = new Sink(); + r = sh("head -c 1001 /dev/zero", over, 1000); + assertTrue(r.truncated()); + assertEquals(1001, r.totalBytes()); + assertEquals(1000, over.bytes().length); + } + + @Test + void streamDrainsPastLimit() throws Exception { + Sink sink = new Sink(); + long start = System.nanoTime(); + var r = sh("head -c 50000000 /dev/zero", sink, 1000); + assertEquals(0, r.code()); + assertFalse(r.timeout()); + assertTrue(r.truncated()); + assertEquals(50_000_000, r.totalBytes()); + assertEquals(1000, sink.bytes().length); + assertTrue(System.nanoTime() - start < TimeUnit.SECONDS.toNanos(50)); + } + + @Test + void streamIncludesStderr() throws Exception { + Sink sink = new Sink(); + sh("echo out; echo err 1>&2", sink, Long.MAX_VALUE); + assertTrue(sink.text().contains("out\n")); + assertTrue(sink.text().contains("err\n")); + } + + @Test + void streamDrainsLargeStderr() throws Exception { + Sink sink = new Sink(); + var r = sh("head -c 5000000 /dev/zero 1>&2", sink, Long.MAX_VALUE); + assertEquals(0, r.code()); + assertFalse(r.timeout()); + assertEquals(5_000_000, r.totalBytes()); + } + + @Test + void streamStopsWhenChildHoldsPipe() throws Exception { + // The background sleep keeps the pipe open after the shell exits. + Sink sink = new Sink(); + long start = System.nanoTime(); + var r = + BuildRunner.stream( + List.of("sh", "-c", "echo hi; sleep 30 &"), 20, sink, Long.MAX_VALUE, 300, 300); + assertTrue( + System.nanoTime() - start < TimeUnit.SECONDS.toNanos(10), + "stream() must not wait for the background child"); + assertEquals(0, r.code()); + assertTrue(sink.text().contains("hi")); + } + + @Test + void sinkFailureKeepsDraining() throws Exception { + BuildRunner.LogSink failing = + (b, off, len) -> { + throw new IllegalStateException("storage down"); + }; + var r = sh("head -c 5000000 /dev/zero", failing, Long.MAX_VALUE); + assertEquals(0, r.code()); + assertFalse(r.timeout()); + assertEquals(5_000_000, r.totalBytes()); + assertInstanceOf(IllegalStateException.class, r.readerError()); + } + + @Test + void writerStoresLargeLogInOneLargeObject() { + long id = newBuild(); + byte[] data = "0123456789".repeat(300_000).getBytes(StandardCharsets.UTF_8); + try (var w = logs.open(id)) { + w.append(data, 0, data.length); + } + Long oid = logs.logOid(id); + assertNotNull(oid); + assertEquals(1, largeObjectCount(oid)); + assertEquals("0123456789".repeat(300_000), logs.readAll(id)); + } + + @Test + void writerKeepsBytesUnchanged() throws Exception { + long id = newBuild(); + byte[] data = {'a', 0, 'b', (byte) 0xFF}; + try (var w = logs.open(id)) { + w.append(data, 0, data.length); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + logs.streamTo(id, out); + assertArrayEquals(data, out.toByteArray()); + } + + @Test + void buildWithoutLogStreamsNothing() throws Exception { + long id = newBuild(); + assertNull(logs.logOid(id)); + assertEquals(0, logs.size(id)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + logs.streamTo(id, out); + assertEquals(0, out.size()); + } + + @Test + void sizeAndPartialReadsUseStoredBytes() throws Exception { + long id = newBuild(); + try (var w = logs.open(id)) { + w.line("0123456789"); + } + assertEquals(11, logs.size(id)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + logs.streamTo(id, out, 2, 4); + assertEquals("2345", out.toString(StandardCharsets.UTF_8)); + } + + @Test + void offsetPastTheEndStreamsNothing() throws Exception { + long id = newBuild(); + try (var w = logs.open(id)) { + w.line("abc"); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + logs.streamTo(id, out, 100, 10); + assertEquals(0, out.size()); + } + + @Test + void rangeResolvesOffsetLimitTailAndCap() { + assertEquals(new BuildLogs.Range(10, 20), BuildLogs.range(100, 10L, 20L, null, 50)); + assertEquals(new BuildLogs.Range(70, 30), BuildLogs.range(100, null, null, 30L, 50)); + assertEquals(new BuildLogs.Range(0, 50), BuildLogs.range(100, null, null, null, 50)); + assertEquals(new BuildLogs.Range(90, 10), BuildLogs.range(100, 90L, null, null, 50)); + assertEquals(new BuildLogs.Range(0, 50), BuildLogs.range(100, null, 200L, null, 50)); + assertEquals(new BuildLogs.Range(0, 0), BuildLogs.range(0, null, null, null, 50)); + assertEquals(new BuildLogs.Range(0, 100), BuildLogs.range(100, null, null, null, -1)); + assertEquals(new BuildLogs.Range(50, 50), BuildLogs.range(100, null, null, 80L, 50)); + } + + @Test + void rangeRejectsNegativeValues() { + assertThrows(IllegalArgumentException.class, () -> BuildLogs.range(100, -1L, null, null, -1)); + assertThrows(IllegalArgumentException.class, () -> BuildLogs.range(100, null, -1L, null, -1)); + assertThrows(IllegalArgumentException.class, () -> BuildLogs.range(100, null, null, -1L, -1)); + } + + @Test + void periodicFlushStoresQuietOutput() throws Exception { + long id = newBuild(); + logs.flushInterval(200); + try (var w = logs.open(id)) { + w.line("x"); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (logs.readAll(id).isEmpty() && System.nanoTime() < deadline) Thread.sleep(50); + assertEquals( + "x\n", logs.readAll(id), "buffered output is stored without a further append or close"); + } finally { + logs.flushInterval(5000); + } + } + + @Test + void openAppendsToExistingLog() { + long id = newBuild(); + try (var w = logs.open(id)) { + w.line("first"); + } + Long oid = logs.logOid(id); + try (var w = logs.open(id)) { + w.line("second"); + } + assertEquals(oid, logs.logOid(id), "the same large object is reused"); + assertEquals("first\nsecond\n", logs.readAll(id)); + } + + @Test + void appendAfterCloseIsDropped() { + long id = newBuild(); + var w = logs.open(id); + w.line("kept"); + w.close(); + w.line("dropped"); + w.close(); + assertEquals("kept\n", logs.readAll(id)); + } + + @Test + void deletingBuildRemovesItsLargeObject() { + long id = newBuild(); + try (var w = logs.open(id)) { + w.line("gone soon"); + } + long oid = logs.logOid(id); + assertEquals(1, largeObjectCount(oid)); + QuarkusTransaction.requiringNew().run(() -> Build.deleteById(id)); + assertEquals(0, largeObjectCount(oid), "lo_manage unlinks the log with its build"); + } + + @Test + void writerFailureDiscardsBuffer() { + long id = newBuild(); + var w = logs.open(id); + logs.failWrites(true); + try { + byte[] big = new byte[BuildLogs.FLUSH_BYTES + 10]; + w.append(big, 0, big.length); + assertNotNull(w.failure()); + assertEquals(0, w.buffered()); + w.append(big, 0, big.length); + assertEquals(0, w.buffered(), "a failed writer must not grow its buffer"); + assertDoesNotThrow(w::close); + } finally { + logs.failWrites(false); + } + assertEquals("", logs.readAll(id)); + } -@QuarkusTest class BuildLogTest { - @Inject BuildLogs logs; - @Inject BuildRunner runner; - - static final class Sink implements BuildRunner.LogSink { - final ByteArrayOutputStream bytes=new ByteArrayOutputStream(); - @Override public synchronized void append(byte[] b,int off,int len){bytes.write(b,off,len);} - synchronized byte[] bytes(){return bytes.toByteArray();} - String text(){return new String(bytes(),StandardCharsets.UTF_8);} - } - private static BuildRunner.Streamed sh(String script,BuildRunner.LogSink sink,long maxBytes)throws Exception{return BuildRunner.stream(List.of("sh","-c",script),60,sink,maxBytes);} - static Long project(){return QuarkusTransaction.requiringNew().call(()->{Project p=new Project();String n=java.util.UUID.randomUUID().toString();p.name="logs-"+n;p.repositoryUrl="https://github.com/octocat/logs-"+n+".git";p.ownerSubject="owner-id";p.persist();return p.id;});} - static long newBuild(){Long project=project();return QuarkusTransaction.requiringNew().call(()->{Build b=new Build();b.projectId=project;b.requestedBy="owner-id";b.status=Build.Status.PASSED;b.startedAt=java.time.Instant.now();b.persist();return b.id;});} - private static long largeObjectCount(long oid){return QuarkusTransaction.requiringNew().call(()->((Number)Build.getEntityManager().createNativeQuery("select count(*) from pg_largeobject_metadata where oid = cast(?1 as oid)").setParameter(1,oid).getSingleResult()).longValue());} - - @Test void streamKeepsLargeOutputComplete()throws Exception{ - Sink sink=new Sink();var r=sh("yes cisys | head -c 3000000",sink,Long.MAX_VALUE); - assertEquals(0,r.code());assertFalse(r.truncated());assertEquals(3_000_000,r.totalBytes());assertNull(r.readerError()); - assertEquals(3_000_000,sink.bytes().length);assertTrue(sink.text().startsWith("cisys\ncisys\n")); - } - @Test void streamPassesBytesThroughUnchanged()throws Exception{ - Sink sink=new Sink();sh("printf 'a\\000b\\303\\251c\\377'",sink,Long.MAX_VALUE); - assertArrayEquals(new byte[]{'a',0,'b',(byte)0xC3,(byte)0xA9,'c',(byte)0xFF},sink.bytes()); - } - @Test void streamAtExactLimitIsNotTruncated()throws Exception{ - Sink exact=new Sink();var r=sh("head -c 1000 /dev/zero",exact,1000); - assertFalse(r.truncated());assertEquals(1000,r.totalBytes());assertEquals(1000,exact.bytes().length); - Sink over=new Sink();r=sh("head -c 1001 /dev/zero",over,1000); - assertTrue(r.truncated());assertEquals(1001,r.totalBytes());assertEquals(1000,over.bytes().length); - } - @Test void streamDrainsPastLimit()throws Exception{ - Sink sink=new Sink();long start=System.nanoTime(); - var r=sh("head -c 50000000 /dev/zero",sink,1000); - assertEquals(0,r.code());assertFalse(r.timeout());assertTrue(r.truncated());assertEquals(50_000_000,r.totalBytes());assertEquals(1000,sink.bytes().length); - assertTrue(System.nanoTime()-start&2",sink,Long.MAX_VALUE); - assertTrue(sink.text().contains("out\n"));assertTrue(sink.text().contains("err\n")); - } - @Test void streamDrainsLargeStderr()throws Exception{ - Sink sink=new Sink();var r=sh("head -c 5000000 /dev/zero 1>&2",sink,Long.MAX_VALUE); - assertEquals(0,r.code());assertFalse(r.timeout());assertEquals(5_000_000,r.totalBytes()); - } - @Test void streamStopsWhenChildHoldsPipe()throws Exception{ - // The background sleep keeps the pipe open after the shell exits. - Sink sink=new Sink();long start=System.nanoTime(); - var r=BuildRunner.stream(List.of("sh","-c","echo hi; sleep 30 &"),20,sink,Long.MAX_VALUE,300,300); - assertTrue(System.nanoTime()-start{throw new IllegalStateException("storage down");}; - var r=sh("head -c 5000000 /dev/zero",failing,Long.MAX_VALUE); - assertEquals(0,r.code());assertFalse(r.timeout());assertEquals(5_000_000,r.totalBytes()); - assertInstanceOf(IllegalStateException.class,r.readerError()); - } - - @Test void writerStoresLargeLogInOneLargeObject(){ - long id=newBuild();byte[] data="0123456789".repeat(300_000).getBytes(StandardCharsets.UTF_8); - try(var w=logs.open(id)){w.append(data,0,data.length);} - Long oid=logs.logOid(id);assertNotNull(oid);assertEquals(1,largeObjectCount(oid)); - assertEquals("0123456789".repeat(300_000),logs.readAll(id)); - } - @Test void writerKeepsBytesUnchanged()throws Exception{ - long id=newBuild();byte[] data={'a',0,'b',(byte)0xFF}; - try(var w=logs.open(id)){w.append(data,0,data.length);} - ByteArrayOutputStream out=new ByteArrayOutputStream();logs.streamTo(id,out); - assertArrayEquals(data,out.toByteArray()); - } - @Test void buildWithoutLogStreamsNothing()throws Exception{ - long id=newBuild();assertNull(logs.logOid(id)); - assertEquals(0,logs.size(id)); - ByteArrayOutputStream out=new ByteArrayOutputStream();logs.streamTo(id,out);assertEquals(0,out.size()); - } - @Test void sizeAndPartialReadsUseStoredBytes()throws Exception{ - long id=newBuild();try(var w=logs.open(id)){w.line("0123456789");} - assertEquals(11,logs.size(id)); - ByteArrayOutputStream out=new ByteArrayOutputStream(); - logs.streamTo(id,out,2,4); - assertEquals("2345",out.toString(StandardCharsets.UTF_8)); - } - @Test void offsetPastTheEndStreamsNothing()throws Exception{ - long id=newBuild();try(var w=logs.open(id)){w.line("abc");} - ByteArrayOutputStream out=new ByteArrayOutputStream(); - logs.streamTo(id,out,100,10); - assertEquals(0,out.size()); - } - @Test void rangeResolvesOffsetLimitTailAndCap(){ - assertEquals(new BuildLogs.Range(10,20),BuildLogs.range(100,10L,20L,null,50)); - assertEquals(new BuildLogs.Range(70,30),BuildLogs.range(100,null,null,30L,50)); - assertEquals(new BuildLogs.Range(0,50),BuildLogs.range(100,null,null,null,50)); - assertEquals(new BuildLogs.Range(90,10),BuildLogs.range(100,90L,null,null,50)); - assertEquals(new BuildLogs.Range(0,50),BuildLogs.range(100,null,200L,null,50)); - assertEquals(new BuildLogs.Range(0,0),BuildLogs.range(0,null,null,null,50)); - assertEquals(new BuildLogs.Range(0,100),BuildLogs.range(100,null,null,null,-1)); - assertEquals(new BuildLogs.Range(50,50),BuildLogs.range(100,null,null,80L,50)); - } - @Test void rangeRejectsNegativeValues(){ - assertThrows(IllegalArgumentException.class,()->BuildLogs.range(100,-1L,null,null,-1)); - assertThrows(IllegalArgumentException.class,()->BuildLogs.range(100,null,-1L,null,-1)); - assertThrows(IllegalArgumentException.class,()->BuildLogs.range(100,null,null,-1L,-1)); - } - @Test void periodicFlushStoresQuietOutput()throws Exception{ - long id=newBuild();logs.flushInterval(200); - try(var w=logs.open(id)){ - w.line("x"); - long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5); - while(logs.readAll(id).isEmpty()&&System.nanoTime()Build.deleteById(id)); - assertEquals(0,largeObjectCount(oid),"lo_manage unlinks the log with its build"); - } - @Test void writerFailureDiscardsBuffer(){ - long id=newBuild();var w=logs.open(id); - logs.failWrites(true); - try{ - byte[] big=new byte[BuildLogs.FLUSH_BYTES+10]; - w.append(big,0,big.length); - assertNotNull(w.failure());assertEquals(0,w.buffered()); - w.append(big,0,big.length);assertEquals(0,w.buffered(),"a failed writer must not grow its buffer"); - assertDoesNotThrow(w::close); - }finally{logs.failWrites(false);} - assertEquals("",logs.readAll(id)); - } - @Test void runFinishesWhenFinalFlushFails()throws Exception{ - // A local URL that cannot be fetched fails the checkout without network or a container runtime. - Long project=project(); - logs.failWrites(true); - try{ - Build created=runner.submit(project,"owner-id","file:///nonexistent/cisys-"+project,new BuildResource.RunRequest("main",null)); - runner.dispatch(); - long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(60);Build b; - do{Thread.sleep(100);b=QuarkusTransaction.requiringNew().call(()->Build.findById(created.id));}while((b.status==Build.Status.RUNNING || b.status==Build.Status.QUEUED)&&System.nanoTime() Build.findById(created.id)); + } while ((b.status == Build.Status.RUNNING || b.status == Build.Status.QUEUED) + && System.nanoTime() < deadline); + assertEquals(Build.Status.FAILED, b.status); + assertNotNull(b.finishedAt); + assertTrue(b.logTruncated); + assertNull(b.exitCode); + assertEquals("main", b.branch); + assertNull(b.image); + while (runner.hasPendingBuilds() && System.nanoTime() < deadline) Thread.sleep(50); + assertFalse(runner.hasPendingBuilds(), "the runner is released"); + } finally { + logs.failWrites(false); + } + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BuildQueueTest.java b/src/backend/test/java/ai/mnemosyne/cisys/BuildQueueTest.java index a5d4dda..0106bac 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/BuildQueueTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/BuildQueueTest.java @@ -8,243 +8,453 @@ package ai.mnemosyne.cisys; +import static org.junit.jupiter.api.Assertions.*; + import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; import jakarta.ws.rs.WebApplicationException; -import org.junit.jupiter.api.*; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.*; @QuarkusTest class BuildQueueTest { - @Inject BuildRunner runner; - @Inject BuildLogs logs; - @Inject BuildRetention retention; - Long project; - final BuildResource.RunRequest request=new BuildResource.RunRequest("main","CISYS_GIT_TEST"); - @BeforeEach void setup(){ - runner=io.quarkus.arc.ClientProxy.unwrap(runner); - QuarkusTransaction.requiringNew().run(()->{Build.deleteAll();Project.deleteAll();}); - runner.concurrency=1;runner.queueLimit=100; - project=QuarkusTransaction.requiringNew().call(()->{ - Project p=new Project();p.name="queue-test";p.ownerSubject="owner";p.repositoryUrl="https://github.com/example/test.git";p.persist();return p.id; - }); - } - @AfterEach void cleanup(){runner.concurrency=1;runner.queueLimit=100;SettingsResourceTest.clearQueue();QuarkusTransaction.requiringNew().run(()->{Build.deleteAll();Project.deleteAll();});} - Build submit(){return runner.submit(project,"owner","https://github.com/example/original.git",request);} - Build read(Long id){return QuarkusTransaction.requiringNew().call(()->Build.findById(id));} - void finish(Long id){QuarkusTransaction.requiringNew().run(()->{Build b=Build.findById(id);b.status=Build.Status.PASSED;b.finishedAt=java.time.Instant.now();});} - @Test void fifoAndSlots(){ - Build first=submit(),second=submit(),third=submit(); - assertEquals(Build.Status.QUEUED,first.status);assertNull(first.startedAt); - assertEquals(first.id,runner.claim().id);assertNull(runner.claim()); - finish(first.id);assertEquals(second.id,runner.claim().id); - finish(second.id);assertEquals(third.id,runner.claim().id);assertNull(runner.claim()); - Build stored=read(third.id);assertNotNull(stored.startedAt); - assertEquals("https://github.com/example/original.git",stored.repositoryUrl); - assertEquals(request.branch(),stored.branch);assertEquals(request.credentialRef(),stored.credentialRef); - assertNull(stored.image);assertNull(stored.workflow); - } - @Test void concurrentClaimsNeverDuplicateOrExceedCapacity()throws Exception{ - runner.concurrency=3;for(int i=0;i<10;i++)submit(); - Set claimed=new HashSet<>(); - try(var pool=Executors.newFixedThreadPool(8)){ - List> calls=new ArrayList<>();for(int i=0;i<16;i++)calls.add(runner::claim); - for(Future f:pool.invokeAll(calls)){Build b=f.get();if(b!=null)assertTrue(claimed.add(b.id));} - } - assertEquals(3,claimed.size()); - assertEquals(3L,QuarkusTransaction.requiringNew().call(()->Build.count("status",Build.Status.RUNNING))); - } - @Test void concurrentAdmissionsRespectQueueLimit()throws Exception{ - runner.queueLimit=3;int accepted=0,rejected=0; - try(var pool=Executors.newFixedThreadPool(8)){ - List> calls=new ArrayList<>();for(int i=0;i<12;i++)calls.add(()->{try{submit();return true;}catch(WebApplicationException e){assertEquals(429,e.getResponse().getStatus());return false;}}); - for(Future f:pool.invokeAll(calls)){if(f.get())accepted++;else rejected++;} - } - assertEquals(3,accepted);assertEquals(9,rejected); - } - @Test void recoveryKeepsQueuedRequestsAndFailsInterruptedBuilds(){ - Build interrupted=submit(),waiting=submit();runner.claim(); - BuildRunner restarted=new BuildRunner();restarted.logs=logs;restarted.concurrency=1;restarted.queueLimit=100; - try{ - restarted.recover(null); - assertEquals(Build.Status.FAILED,read(interrupted.id).status);assertNotNull(read(interrupted.id).finishedAt); - assertEquals(Build.Status.QUEUED,read(waiting.id).status);assertNull(read(waiting.id).startedAt); - assertEquals(waiting.id,restarted.claim().id);assertNull(restarted.claim()); - }finally{restarted.stop();} - } - @Test void dispatcherRunsWaitingBuildAfterSlotBecomesFree()throws Exception{ - Build first=submit(),second=submit(); - CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1),done=new CountDownLatch(1); - List order=new CopyOnWriteArrayList<>(); - BuildRunner worker=new BuildRunner(){ - @Override void execute(Build b){ - order.add(b.id); - try{if(b.id.equals(first.id)){entered.countDown();assertTrue(release.await(10,TimeUnit.SECONDS));}finish(b.id);} - catch(InterruptedException e){Thread.currentThread().interrupt();} - finally{done.countDown();} - } - }; - worker.logs=logs;worker.concurrency=1;worker.queueLimit=100;worker.recover(null); - try{ - worker.dispatch();assertTrue(entered.await(10,TimeUnit.SECONDS)); - worker.dispatch();assertEquals(Build.Status.QUEUED,read(second.id).status); - release.countDown();assertTrue(done.await(10,TimeUnit.SECONDS)); - worker.dispatch(); - long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(10); - while(read(second.id).status!=Build.Status.PASSED && System.nanoTime() { + Build.deleteAll(); + Project.deleteAll(); + }); + runner.concurrency = 1; + runner.queueLimit = 100; + project = + QuarkusTransaction.requiringNew() + .call(() -> { + Project p = new Project(); + p.name = "queue-test"; + p.ownerSubject = "owner"; + p.repositoryUrl = "https://github.com/example/test.git"; + p.persist(); + return p.id; + }); + } + + @AfterEach + void cleanup() { + runner.concurrency = 1; + runner.queueLimit = 100; + SettingsResourceTest.clearQueue(); + QuarkusTransaction.requiringNew() + .run(() -> { + Build.deleteAll(); + Project.deleteAll(); + }); + } + + Build submit() { + return runner.submit(project, "owner", "https://github.com/example/original.git", request); + } + + Build read(Long id) { + return QuarkusTransaction.requiringNew().call(() -> Build.findById(id)); + } + + void finish(Long id) { + QuarkusTransaction.requiringNew() + .run(() -> { + Build b = Build.findById(id); + b.status = Build.Status.PASSED; + b.finishedAt = java.time.Instant.now(); + }); + } + + @Test + void fifoAndSlots() { + Build first = submit(), second = submit(), third = submit(); + assertEquals(Build.Status.QUEUED, first.status); + assertNull(first.startedAt); + assertEquals(first.id, runner.claim().id); + assertNull(runner.claim()); + finish(first.id); + assertEquals(second.id, runner.claim().id); + finish(second.id); + assertEquals(third.id, runner.claim().id); + assertNull(runner.claim()); + Build stored = read(third.id); + assertNotNull(stored.startedAt); + assertEquals("https://github.com/example/original.git", stored.repositoryUrl); + assertEquals(request.branch(), stored.branch); + assertEquals(request.credentialRef(), stored.credentialRef); + assertNull(stored.image); + assertNull(stored.workflow); + } + + @Test + void concurrentClaimsNeverDuplicateOrExceedCapacity() throws Exception { + runner.concurrency = 3; + for (int i = 0; i < 10; i++) submit(); + Set claimed = new HashSet<>(); + try (var pool = Executors.newFixedThreadPool(8)) { + List> calls = new ArrayList<>(); + for (int i = 0; i < 16; i++) calls.add(runner::claim); + for (Future f : pool.invokeAll(calls)) { + Build b = f.get(); + if (b != null) assertTrue(claimed.add(b.id)); + } + } + assertEquals(3, claimed.size()); + assertEquals( + 3L, + QuarkusTransaction.requiringNew().call(() -> Build.count("status", Build.Status.RUNNING))); + } + + @Test + void concurrentAdmissionsRespectQueueLimit() throws Exception { + runner.queueLimit = 3; + int accepted = 0, rejected = 0; + try (var pool = Executors.newFixedThreadPool(8)) { + List> calls = new ArrayList<>(); + for (int i = 0; i < 12; i++) + calls.add(() -> { + try { + submit(); + return true; + } catch (WebApplicationException e) { + assertEquals(429, e.getResponse().getStatus()); + return false; + } + }); + for (Future f : pool.invokeAll(calls)) { + if (f.get()) accepted++; + else rejected++; + } + } + assertEquals(3, accepted); + assertEquals(9, rejected); + } + + @Test + void recoveryKeepsQueuedRequestsAndFailsInterruptedBuilds() { + Build interrupted = submit(), waiting = submit(); + runner.claim(); + BuildRunner restarted = new BuildRunner(); + restarted.logs = logs; + restarted.concurrency = 1; + restarted.queueLimit = 100; + try { + restarted.recover(null); + assertEquals(Build.Status.FAILED, read(interrupted.id).status); + assertNotNull(read(interrupted.id).finishedAt); + assertEquals(Build.Status.QUEUED, read(waiting.id).status); + assertNull(read(waiting.id).startedAt); + assertEquals(waiting.id, restarted.claim().id); + assertNull(restarted.claim()); + } finally { + restarted.stop(); } - } - }; - worker.logs=logs;worker.concurrency=1;worker.queueLimit=100;worker.recover(null); - worker.dispatch();assertTrue(entered.await(10,TimeUnit.SECONDS)); - worker.stop(); - // stop() returns only after the build saved its result, while the database is still there. - Build saved=read(running.id); - assertEquals(Build.Status.FAILED,saved.status);assertNotNull(saved.finishedAt); - } - @Test void failedCompletionIsRetriedWithoutRerunningAndQueueResumes()throws Exception{ - Build first=submit(),second=submit(); - CountDownLatch attempted=new CountDownLatch(1),secondDone=new CountDownLatch(1); - var failures=new java.util.concurrent.atomic.AtomicInteger(2); - List executions=new CopyOnWriteArrayList<>(); - var finished=java.time.Instant.parse("2026-11-12T10:00:00Z"); - BuildRunner worker=new BuildRunner(){ - @Override void execute(Build b){ - executions.add(b.id); - complete(new Completion(b.id,Build.Status.PASSED,"revision",0,"image-digest",true,finished)); - if(b.id.equals(first.id))attempted.countDown();else secondDone.countDown(); - } - @Override void persistCompletion(Completion r){ - super.persistCompletion(r); - // Fail after the SQL update so the test also verifies transaction rollback. - if(r.id().equals(first.id) && failures.getAndDecrement()>0)throw new IllegalStateException("database unavailable"); - } - }; - worker.logs=logs;worker.concurrency=1;worker.queueLimit=100;worker.recover(null); - try{ - worker.dispatch();assertTrue(attempted.await(10,TimeUnit.SECONDS)); - assertEquals(Build.Status.RUNNING,read(first.id).status); - assertNull(read(first.id).finishedAt); - worker.dispatch(); // The second persistence attempt still fails. - assertEquals(Build.Status.RUNNING,read(first.id).status); - assertEquals(Build.Status.QUEUED,read(second.id).status); - worker.dispatch(); // Database recovery frees the slot and starts the next build. - assertTrue(secondDone.await(10,TimeUnit.SECONDS)); - Build saved=read(first.id); - assertEquals(Build.Status.PASSED,saved.status);assertEquals(finished,saved.finishedAt); - assertEquals("revision",saved.revision);assertEquals(0,saved.exitCode); - assertEquals("image-digest",saved.imageDigest);assertTrue(saved.logTruncated); - assertEquals(Build.Status.PASSED,read(second.id).status); - worker.dispatch();assertEquals(List.of(first.id,second.id),executions); - }finally{worker.stop();} - } - @Test void adminSettingsReplaceConfiguredLimits(){ - QuarkusTransaction.requiringNew().run(()->{Settings s=Settings.findById(1L);if(s==null){s=new Settings();s.persist();}s.maxConcurrentBuilds=2;s.maxQueuedBuilds=3;}); - Build first=submit(),second=submit();submit(); - WebApplicationException full=assertThrows(WebApplicationException.class,this::submit);assertEquals(429,full.getResponse().getStatus()); - assertEquals(first.id,runner.claim().id);assertEquals(second.id,runner.claim().id);assertNull(runner.claim()); - } - @Test void recoveryRemovesOrphanedContainersAndCheckoutDirectories()throws Exception{ - Path root=Files.createTempDirectory("cisys-recovery-"); - Path stale=Files.createDirectory(root.resolve("cisys-old")); - Files.writeString(stale.resolve("file"),"checkout"); - Path keep=Files.createDirectory(root.resolve("keep-me")); - Build interrupted=submit(); - runner.claim(); - List> calls=new CopyOnWriteArrayList<>(); - BuildRunner restarted=new BuildRunner(){ - @Override Path checkoutRoot(){return root;} - }; - restarted.runtime="podman"; - restarted.commands=(args,timeout,limit)->{ - calls.add(List.copyOf(args)); - if(args.get(1).equals("ps"))return new BuildRunner.Result(0,false,"cisys-build-1\ncisys-build-2\nunrelated\n"); - if(args.get(1).equals("images"))return new BuildRunner.Result(0,false,"localhost/cisys-build-3\ndocker.io/library/alpine\nlocalhost/cisys-build-x\n"); - return new BuildRunner.Result(0,false,""); - }; - restarted.logs=logs;restarted.concurrency=1;restarted.queueLimit=100; - try{ - restarted.recover(null); - assertEquals(List.of( - List.of("podman","ps","-a","--format","{{.Names}}"), - List.of("podman","rm","-f","cisys-build-1"), - List.of("podman","rm","-f","cisys-build-2"), - List.of("podman","images","--format","{{.Repository}}"), - List.of("podman","rmi","-f","localhost/cisys-build-3")),calls); - assertFalse(Files.exists(stale),"the stale checkout is removed"); - assertTrue(Files.exists(keep),"unrelated directories are kept"); - assertEquals(Build.Status.FAILED,read(interrupted.id).status); - }finally{ - restarted.stop(); - BuildRunner.delete(root); - } - } - @Test void cleanupFailureDoesNotStopRecovery(){ - Build interrupted=submit(); - runner.claim(); - BuildRunner restarted=new BuildRunner(){ - @Override Path checkoutRoot(){return Path.of("/definitely/not/a/checkout/root");} - }; - restarted.runtime="podman"; - restarted.commands=(args,timeout,limit)->{ - if(args.get(1).equals("ps"))return new BuildRunner.Result(0,false,"cisys-build-1\n"); - return new BuildRunner.Result(1,false,"container runtime unavailable"); - }; - restarted.logs=logs;restarted.concurrency=1;restarted.queueLimit=100; - try{ - assertDoesNotThrow(()->restarted.recover(null)); - assertEquals(Build.Status.FAILED,read(interrupted.id).status); - }finally{restarted.stop();} - } - @Test void rejectedScheduleReturnsTheBuildToTheQueue()throws Exception{ - Build queued=submit(); - AtomicBoolean reject=new AtomicBoolean(true); - List executed=new CopyOnWriteArrayList<>(); - BuildRunner worker=new BuildRunner(){ - @Override void schedule(Runnable task){ - if(reject.get())throw new RejectedExecutionException("worker stopped"); - super.schedule(task); - } - @Override void execute(Build b){executed.add(b.id);finish(b.id);} - }; - worker.logs=logs;worker.concurrency=1;worker.queueLimit=100;worker.recover(null); - try{ - worker.dispatch(); - Build after=read(queued.id); - assertEquals(Build.Status.QUEUED,after.status); - assertNull(after.startedAt); - reject.set(false); - worker.dispatch(); - long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(10); - while(executed.isEmpty() && System.nanoTime(){Build b=Build.findById(waiting.id);b.startedAt=java.time.Instant.EPOCH;}); - retention.purge(java.time.Instant.now(),1,1); - assertEquals(Build.Status.QUEUED,read(waiting.id).status); - } + } + + @Test + void dispatcherRunsWaitingBuildAfterSlotBecomesFree() throws Exception { + Build first = submit(), second = submit(); + CountDownLatch entered = new CountDownLatch(1), + release = new CountDownLatch(1), + done = new CountDownLatch(1); + List order = new CopyOnWriteArrayList<>(); + BuildRunner worker = + new BuildRunner() { + @Override + void execute(Build b) { + order.add(b.id); + try { + if (b.id.equals(first.id)) { + entered.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + } + finish(b.id); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + } + }; + worker.logs = logs; + worker.concurrency = 1; + worker.queueLimit = 100; + worker.recover(null); + try { + worker.dispatch(); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + worker.dispatch(); + assertEquals(Build.Status.QUEUED, read(second.id).status); + release.countDown(); + assertTrue(done.await(10, TimeUnit.SECONDS)); + worker.dispatch(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (read(second.id).status != Build.Status.PASSED && System.nanoTime() < deadline) + Thread.sleep(10); + assertEquals(Build.Status.PASSED, read(second.id).status); + assertEquals(List.of(first.id, second.id), order); + } finally { + release.countDown(); + worker.stop(); + } + } + + @Test + void stopWaitsForRunningBuildsToSaveTheirResult() throws Exception { + Build running = submit(); + CountDownLatch entered = new CountDownLatch(1); + BuildRunner worker = + new BuildRunner() { + @Override + void execute(Build b) { + entered.countDown(); + try { + Thread.sleep(60_000); + } catch (InterruptedException e) { + // The interrupted build's cleanup is slow and needs the lock that saving a result + // takes. + try { + Thread.sleep(500); + } catch (InterruptedException again) { + Thread.currentThread().interrupt(); + } + complete( + new Completion( + b.id, Build.Status.FAILED, null, null, null, false, java.time.Instant.now())); + } + } + }; + worker.logs = logs; + worker.concurrency = 1; + worker.queueLimit = 100; + worker.recover(null); + worker.dispatch(); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + worker.stop(); + // stop() returns only after the build saved its result, while the database is still there. + Build saved = read(running.id); + assertEquals(Build.Status.FAILED, saved.status); + assertNotNull(saved.finishedAt); + } + + @Test + void failedCompletionIsRetriedWithoutRerunningAndQueueResumes() throws Exception { + Build first = submit(), second = submit(); + CountDownLatch attempted = new CountDownLatch(1), secondDone = new CountDownLatch(1); + var failures = new java.util.concurrent.atomic.AtomicInteger(2); + List executions = new CopyOnWriteArrayList<>(); + var finished = java.time.Instant.parse("2026-11-12T10:00:00Z"); + BuildRunner worker = + new BuildRunner() { + @Override + void execute(Build b) { + executions.add(b.id); + complete( + new Completion( + b.id, Build.Status.PASSED, "revision", 0, "image-digest", true, finished)); + if (b.id.equals(first.id)) attempted.countDown(); + else secondDone.countDown(); + } + + @Override + void persistCompletion(Completion r) { + super.persistCompletion(r); + // Fail after the SQL update so the test also verifies transaction rollback. + if (r.id().equals(first.id) && failures.getAndDecrement() > 0) + throw new IllegalStateException("database unavailable"); + } + }; + worker.logs = logs; + worker.concurrency = 1; + worker.queueLimit = 100; + worker.recover(null); + try { + worker.dispatch(); + assertTrue(attempted.await(10, TimeUnit.SECONDS)); + assertEquals(Build.Status.RUNNING, read(first.id).status); + assertNull(read(first.id).finishedAt); + worker.dispatch(); // The second persistence attempt still fails. + assertEquals(Build.Status.RUNNING, read(first.id).status); + assertEquals(Build.Status.QUEUED, read(second.id).status); + worker.dispatch(); // Database recovery frees the slot and starts the next build. + assertTrue(secondDone.await(10, TimeUnit.SECONDS)); + Build saved = read(first.id); + assertEquals(Build.Status.PASSED, saved.status); + assertEquals(finished, saved.finishedAt); + assertEquals("revision", saved.revision); + assertEquals(0, saved.exitCode); + assertEquals("image-digest", saved.imageDigest); + assertTrue(saved.logTruncated); + assertEquals(Build.Status.PASSED, read(second.id).status); + worker.dispatch(); + assertEquals(List.of(first.id, second.id), executions); + } finally { + worker.stop(); + } + } + + @Test + void adminSettingsReplaceConfiguredLimits() { + QuarkusTransaction.requiringNew() + .run(() -> { + Settings s = Settings.findById(1L); + if (s == null) { + s = new Settings(); + s.persist(); + } + s.maxConcurrentBuilds = 2; + s.maxQueuedBuilds = 3; + }); + Build first = submit(), second = submit(); + submit(); + WebApplicationException full = assertThrows(WebApplicationException.class, this::submit); + assertEquals(429, full.getResponse().getStatus()); + assertEquals(first.id, runner.claim().id); + assertEquals(second.id, runner.claim().id); + assertNull(runner.claim()); + } + + @Test + void recoveryRemovesOrphanedContainersAndCheckoutDirectories() throws Exception { + Path root = Files.createTempDirectory("cisys-recovery-"); + Path stale = Files.createDirectory(root.resolve("cisys-old")); + Files.writeString(stale.resolve("file"), "checkout"); + Path keep = Files.createDirectory(root.resolve("keep-me")); + Build interrupted = submit(); + runner.claim(); + List> calls = new CopyOnWriteArrayList<>(); + BuildRunner restarted = + new BuildRunner() { + @Override + Path checkoutRoot() { + return root; + } + }; + restarted.runtime = "podman"; + restarted.commands = + (args, timeout, limit) -> { + calls.add(List.copyOf(args)); + if (args.get(1).equals("ps")) + return new BuildRunner.Result(0, false, "cisys-build-1\ncisys-build-2\nunrelated\n"); + if (args.get(1).equals("images")) + return new BuildRunner.Result( + 0, + false, + "localhost/cisys-build-3\ndocker.io/library/alpine\nlocalhost/cisys-build-x\n"); + return new BuildRunner.Result(0, false, ""); + }; + restarted.logs = logs; + restarted.concurrency = 1; + restarted.queueLimit = 100; + try { + restarted.recover(null); + assertEquals( + List.of( + List.of("podman", "ps", "-a", "--format", "{{.Names}}"), + List.of("podman", "rm", "-f", "cisys-build-1"), + List.of("podman", "rm", "-f", "cisys-build-2"), + List.of("podman", "images", "--format", "{{.Repository}}"), + List.of("podman", "rmi", "-f", "localhost/cisys-build-3")), + calls); + assertFalse(Files.exists(stale), "the stale checkout is removed"); + assertTrue(Files.exists(keep), "unrelated directories are kept"); + assertEquals(Build.Status.FAILED, read(interrupted.id).status); + } finally { + restarted.stop(); + BuildRunner.delete(root); + } + } + + @Test + void cleanupFailureDoesNotStopRecovery() { + Build interrupted = submit(); + runner.claim(); + BuildRunner restarted = + new BuildRunner() { + @Override + Path checkoutRoot() { + return Path.of("/definitely/not/a/checkout/root"); + } + }; + restarted.runtime = "podman"; + restarted.commands = + (args, timeout, limit) -> { + if (args.get(1).equals("ps")) return new BuildRunner.Result(0, false, "cisys-build-1\n"); + return new BuildRunner.Result(1, false, "container runtime unavailable"); + }; + restarted.logs = logs; + restarted.concurrency = 1; + restarted.queueLimit = 100; + try { + assertDoesNotThrow(() -> restarted.recover(null)); + assertEquals(Build.Status.FAILED, read(interrupted.id).status); + } finally { + restarted.stop(); + } + } + + @Test + void rejectedScheduleReturnsTheBuildToTheQueue() throws Exception { + Build queued = submit(); + AtomicBoolean reject = new AtomicBoolean(true); + List executed = new CopyOnWriteArrayList<>(); + BuildRunner worker = + new BuildRunner() { + @Override + void schedule(Runnable task) { + if (reject.get()) throw new RejectedExecutionException("worker stopped"); + super.schedule(task); + } + + @Override + void execute(Build b) { + executed.add(b.id); + finish(b.id); + } + }; + worker.logs = logs; + worker.concurrency = 1; + worker.queueLimit = 100; + worker.recover(null); + try { + worker.dispatch(); + Build after = read(queued.id); + assertEquals(Build.Status.QUEUED, after.status); + assertNull(after.startedAt); + reject.set(false); + worker.dispatch(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (executed.isEmpty() && System.nanoTime() < deadline) Thread.sleep(10); + assertEquals(List.of(queued.id), executed); + } finally { + worker.stop(); + } + } + + @Test + void retentionProtectsQueuedBuilds() { + Build waiting = submit(); + QuarkusTransaction.requiringNew() + .run(() -> { + Build b = Build.findById(waiting.id); + b.startedAt = java.time.Instant.EPOCH; + }); + retention.purge(java.time.Instant.now(), 1, 1); + assertEquals(Build.Status.QUEUED, read(waiting.id).status); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java index 0299ae6..d73c36d 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java @@ -7,129 +7,519 @@ */ package ai.mnemosyne.cisys; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; + +import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import io.quarkus.test.security.jwt.Claim; import io.quarkus.test.security.jwt.JwtSecurity; -import io.quarkus.narayana.jta.QuarkusTransaction; import jakarta.inject.Inject; -import org.junit.jupiter.api.Test; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; import java.util.List; -@QuarkusTest class BuildResourceTest { - @org.junit.jupiter.api.AfterEach void releaseSyntheticRunningBuilds(){ - QuarkusTransaction.requiringNew().run(()->Build.update("status = ?1 where status = ?2 and repositoryUrl is null",Build.Status.FAILED,Build.Status.RUNNING)); - } - - @Inject BuildRunner runner; - @Inject BuildLogs logs; - private Long project(boolean priv){return QuarkusTransaction.requiringNew().call(()->{Project p=new Project();String n=java.util.UUID.randomUUID().toString();p.name="example-"+n;p.repositoryUrl="https://github.com/octocat/Hello-World-"+n+".git";p.ownerSubject="owner-id";p.privateRepository=priv;p.persist();return p.id;});} - private static void allow(String image){QuarkusTransaction.requiringNew().run(()->{if(AllowedImage.findById(image)==null){AllowedImage i=new AllowedImage();i.name=image;i.persist();}});} - private Long build(Long project,String status,String... logParts){Long id=QuarkusTransaction.requiringNew().call(()->{Build b=new Build();b.projectId=project;b.requestedBy="owner-id";b.status=Build.Status.valueOf(status);b.branch="main";b.image="alpine:3.23";b.exitCode="PASSED".equals(status)?0:null;b.startedAt=java.time.Instant.now();b.persist();return b.id;});if(logParts.length>0)try(var w=logs.open(id)){for(String part:logParts){byte[] bytes=part.getBytes(java.nio.charset.StandardCharsets.UTF_8);w.append(bytes,0,bytes.length);}}return id;} - static final String REQUEST="{\"branch\":\"main\"}"; - @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void privateLogsAreHidden(){Long id=project(true);given().get("/api/projects/"+id+"/builds").then().statusCode(404);} - @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void memberCanViewPublicButCannotTrigger(){Long id=project(false);given().get("/api/projects/"+id+"/builds").then().statusCode(200);given().contentType("application/json").body(REQUEST).post("/api/projects/"+id+"/builds").then().statusCode(403);} - @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="owner-id")) - void ownerCanQueueWhileRunningAndGetsClearCapacityError(){ - Long id=project(true);allow("alpine:3.23");build(id,"RUNNING"); - BuildRunner target=io.quarkus.arc.ClientProxy.unwrap(runner);int limit=target.queueLimit;target.queueLimit=1; - String path="/api/projects/"+id+"/builds"; - try{ - given().contentType("application/json").body(REQUEST).post(path).then().statusCode(200) - .body("status",equalTo("QUEUED"),"startedAt",nullValue()); - given().contentType("application/json").body(REQUEST).post(path).then().statusCode(429).body(containsString("queue is full")); - }finally{ - target.queueLimit=limit; - QuarkusTransaction.requiringNew().run(()->Build.delete("projectId = ?1 and status = ?2",id,Build.Status.QUEUED)); - } - } - - @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="owner-id")) void rejectInvalidBranch(){Long id=project(true);given().contentType("application/json").body(REQUEST.replace("main","branch.lock")).post("/api/projects/"+id+"/builds").then().statusCode(400);} - @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="owner-id")) void leadCannotUseGlobalCredentials(){Long id=project(true);allow("alpine:3.23");given().contentType("application/json").body(REQUEST.replace("}", ",\"credentialRef\":\"CISYS_GIT_SECRET\"}")).post("/api/projects/"+id+"/builds").then().statusCode(403);} - @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="owner-id")) void rejectHostResolvingToABlockedAddress(){Long id=project(false);allow("alpine:3.23");TestHostResolver.set("github.com","127.0.0.1");try{given().contentType("application/json").body(REQUEST).post("/api/projects/"+id+"/builds").then().statusCode(400);}finally{TestHostResolver.reset();}} - - @Test - @TestSecurity(user="admin",roles="admin") - @JwtSecurity(claims=@Claim(key="sub",value="admin-id")) - void savesBuildRequestDetailsAndReturnsCredentialReferenceToAdmin() throws InterruptedException { - Long projectId=project(true); - allow("alpine:3.23"); - String path="/api/projects/"+projectId+"/builds"; - // Use an unset reference so the checkout fails before contacting the remote. - String credentialRef="CISYS_GIT_TEST_"+java.util.UUID.randomUUID().toString().replace("-", "") - .toUpperCase(java.util.Locale.ROOT); - String request=REQUEST - .replace("}",",\"credentialRef\":\""+credentialRef+"\"}"); - try { - int id=given().contentType("application/json").body(request).post(path).then() - .statusCode(200).body("branch",equalTo("main"),"image",nullValue(),"$",not(hasKey("command")), - "credentialRef",equalTo(credentialRef)) - .extract().path("id"); - runner.dispatch(); - given().get(path).then().statusCode(200).body("[0].credentialRef",equalTo(credentialRef)); - given().get(path+"/"+id).then().statusCode(200) - .body("branch",equalTo("main"),"credentialRef",equalTo(credentialRef)); - } finally { - long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(20); - while(runner.hasPendingBuilds() && System.nanoTime(){ - Build legacy=new Build(); - legacy.projectId=projectId; - legacy.status=Build.Status.PASSED; - legacy.persist(); - Build build=new Build(); - build.projectId=projectId; - build.status=Build.Status.PASSED; - build.branch="main"; - build.image="alpine:3.23"; - build.credentialRef="CISYS_GIT_SECRET"; - build.workflow="name: main\n"; - build.persist(); - return build.id; - }); - String path="/api/projects/"+projectId+"/builds"; - given().get(path).then().statusCode(200) - .body("[0].branch",equalTo("main"),"[0].image",equalTo("alpine:3.23"), - "[0]",not(hasKey("command")),"[0]",not(hasKey("credentialRef")),"[0]",not(hasKey("workflow")), - "[1].status",equalTo("PASSED"),"[1].branch",nullValue(), - "[1].image",nullValue()); - given().get(path+"/"+buildId).then().statusCode(200) - .body("branch",equalTo("main"),"$",not(hasKey("credentialRef")),"workflow",equalTo("name: main\n")); - } - - @Test void processOutputIsBounded()throws Exception{var r=BuildRunner.command(List.of("sh","-c","head -c 500000 /dev/zero"),10,1024);assertEquals(0,r.code());assertTrue(r.output().endsWith("[output truncated]\n"));assertTrue(r.output().length()<1100);} - @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void listOmitsOutput(){Long id=project(false);build(id,"PASSED","secret-log-text");given().get("/api/projects/"+id+"/builds").then().statusCode(200).body(not(containsString("secret-log-text"))).body(not(containsString("\"output\""))).body("[0].branch",equalTo("main")).body("[0].image",equalTo("alpine:3.23")).body("[0].exitCode",equalTo(0)).body("[0].logTruncated",equalTo(false));} - @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void logStreamsAppendedParts(){Long id=project(false);Long b=build(id,"PASSED","one\n","two\n","é\n");given().get("/api/projects/"+id+"/builds/"+b+"/log").then().statusCode(200).contentType(startsWith("text/plain")).body(equalTo("one\ntwo\né\n"));} - @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void logSupportsOffsetLimitAndTail(){Long id=project(false);Long b=build(id,"PASSED","0123456789");String path="/api/projects/"+id+"/builds/"+b+"/log"; - given().queryParam("offset",2).queryParam("limit",3).get(path).then().statusCode(200).header("X-Log-Size","10").header("X-Log-Offset","2").header("X-Log-Length","3").body(equalTo("234")); - given().queryParam("tail",4).get(path).then().statusCode(200).header("X-Log-Offset","6").body(equalTo("6789")); - given().queryParam("offset",-1).get(path).then().statusCode(400); - } - @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void privateLogIsHidden(){Long id=project(true);Long b=build(id,"PASSED","hidden");given().get("/api/projects/"+id+"/builds/"+b+"/log").then().statusCode(404);} - @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void logOfBuildInOtherProjectIs404(){Long id=project(false),other=project(false);Long b=build(other,"PASSED","elsewhere");given().get("/api/projects/"+id+"/builds/"+b+"/log").then().statusCode(404);} - @Test void recoverKeepsPartialLog(){Long id=project(false);Long b=build(id,"RUNNING","partial\n");runner.recover(null);Build after=QuarkusTransaction.requiringNew().call(()->Build.findById(b));assertEquals(Build.Status.FAILED,after.status);assertNotNull(after.finishedAt);assertEquals("partial\nServer restarted during build\n",logs.readAll(b));} - private static boolean pinned(Long build){return QuarkusTransaction.requiringNew().call(()->Build.findById(build).pinned);} - @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="owner-id")) void ownerPinsAndUnpins(){Long id=project(false);Long b=build(id,"PASSED");String pin="/api/projects/"+id+"/builds/"+b+"/pin";given().contentType("application/json").put(pin).then().statusCode(200).body("pinned",equalTo(true));assertTrue(pinned(b));given().get("/api/projects/"+id+"/builds").then().body("[0].pinned",equalTo(true));given().contentType("application/json").put(pin).then().statusCode(200).body("pinned",equalTo(true));given().contentType("application/json").delete(pin).then().statusCode(200).body("pinned",equalTo(false));assertFalse(pinned(b));} - @Test @TestSecurity(user="admin",roles="admin") @JwtSecurity(claims=@Claim(key="sub",value="admin-id")) void adminPinsPrivateProjectOfOthers(){Long id=project(true);Long b=build(id,"FAILED");given().contentType("application/json").put("/api/projects/"+id+"/builds/"+b+"/pin").then().statusCode(200).body("pinned",equalTo(true));assertTrue(pinned(b));} - @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void otherLeadCannotPin(){Long id=project(false);Long b=build(id,"PASSED");given().contentType("application/json").put("/api/projects/"+id+"/builds/"+b+"/pin").then().statusCode(403);assertFalse(pinned(b));} - @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void memberCannotPinOrSeePrivate(){Long pub=project(false),priv=project(true);Long b=build(pub,"PASSED"),hidden=build(priv,"PASSED");given().contentType("application/json").put("/api/projects/"+pub+"/builds/"+b+"/pin").then().statusCode(403);given().contentType("application/json").put("/api/projects/"+priv+"/builds/"+hidden+"/pin").then().statusCode(404);assertFalse(pinned(b));assertFalse(pinned(hidden));} - @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="owner-id")) void pinOfBuildInOtherProjectIs404(){Long id=project(false),other=project(false);Long b=build(other,"PASSED");given().contentType("application/json").put("/api/projects/"+id+"/builds/"+b+"/pin").then().statusCode(404);assertFalse(pinned(b));} - - private void requesterVisible(boolean visible){Long id=project(false);Long b=build(id,"PASSED");String path="/api/projects/"+id+"/builds"; - if(visible){given().get(path).then().statusCode(200).body("[0].requestedBy",equalTo("owner-id"));given().get(path+"/"+b).then().statusCode(200).body("requestedBy",equalTo("owner-id"));} - else{given().get(path).then().statusCode(200).body("[0]",not(hasKey("requestedBy")));given().get(path+"/"+b).then().statusCode(200).body("$",not(hasKey("requestedBy")));}} - @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void memberDoesNotSeeRequester(){requesterVisible(false);} - @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="owner-id")) void leadDoesNotSeeRequester(){requesterVisible(false);} - @Test @TestSecurity(user="admin",roles="admin") @JwtSecurity(claims=@Claim(key="sub",value="admin-id")) void adminSeesRequester(){requesterVisible(true);} +import org.junit.jupiter.api.Test; + +@QuarkusTest +class BuildResourceTest { + @org.junit.jupiter.api.AfterEach + void releaseSyntheticRunningBuilds() { + QuarkusTransaction.requiringNew() + .run(() -> + Build.update( + "status = ?1 where status = ?2 and repositoryUrl is null", + Build.Status.FAILED, + Build.Status.RUNNING)); + } + + @Inject BuildRunner runner; + @Inject BuildLogs logs; + + private Long project(boolean priv) { + return QuarkusTransaction.requiringNew() + .call(() -> { + Project p = new Project(); + String n = java.util.UUID.randomUUID().toString(); + p.name = "example-" + n; + p.repositoryUrl = "https://github.com/octocat/Hello-World-" + n + ".git"; + p.ownerSubject = "owner-id"; + p.privateRepository = priv; + p.persist(); + return p.id; + }); + } + + private static void allow(String image) { + QuarkusTransaction.requiringNew() + .run(() -> { + if (AllowedImage.findById(image) == null) { + AllowedImage i = new AllowedImage(); + i.name = image; + i.persist(); + } + }); + } + + private Long build(Long project, String status, String... logParts) { + Long id = + QuarkusTransaction.requiringNew() + .call(() -> { + Build b = new Build(); + b.projectId = project; + b.requestedBy = "owner-id"; + b.status = Build.Status.valueOf(status); + b.branch = "main"; + b.image = "alpine:3.23"; + b.exitCode = "PASSED".equals(status) ? 0 : null; + b.startedAt = java.time.Instant.now(); + b.persist(); + return b.id; + }); + if (logParts.length > 0) + try (var w = logs.open(id)) { + for (String part : logParts) { + byte[] bytes = part.getBytes(java.nio.charset.StandardCharsets.UTF_8); + w.append(bytes, 0, bytes.length); + } + } + return id; + } + + static final String REQUEST = "{\"branch\":\"main\"}"; + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void privateLogsAreHidden() { + Long id = project(true); + given().get("/api/projects/" + id + "/builds").then().statusCode(404); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void memberCanViewPublicButCannotTrigger() { + Long id = project(false); + given().get("/api/projects/" + id + "/builds").then().statusCode(200); + given() + .contentType("application/json") + .body(REQUEST) + .post("/api/projects/" + id + "/builds") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerCanQueueWhileRunningAndGetsClearCapacityError() { + Long id = project(true); + allow("alpine:3.23"); + build(id, "RUNNING"); + BuildRunner target = io.quarkus.arc.ClientProxy.unwrap(runner); + int limit = target.queueLimit; + target.queueLimit = 1; + String path = "/api/projects/" + id + "/builds"; + try { + given() + .contentType("application/json") + .body(REQUEST) + .post(path) + .then() + .statusCode(200) + .body("status", equalTo("QUEUED"), "startedAt", nullValue()); + given() + .contentType("application/json") + .body(REQUEST) + .post(path) + .then() + .statusCode(429) + .body(containsString("queue is full")); + } finally { + target.queueLimit = limit; + QuarkusTransaction.requiringNew() + .run(() -> Build.delete("projectId = ?1 and status = ?2", id, Build.Status.QUEUED)); + } + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void rejectInvalidBranch() { + Long id = project(true); + given() + .contentType("application/json") + .body(REQUEST.replace("main", "branch.lock")) + .post("/api/projects/" + id + "/builds") + .then() + .statusCode(400); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void leadCannotUseGlobalCredentials() { + Long id = project(true); + allow("alpine:3.23"); + given() + .contentType("application/json") + .body(REQUEST.replace("}", ",\"credentialRef\":\"CISYS_GIT_SECRET\"}")) + .post("/api/projects/" + id + "/builds") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void rejectHostResolvingToABlockedAddress() { + Long id = project(false); + allow("alpine:3.23"); + TestHostResolver.set("github.com", "127.0.0.1"); + try { + given() + .contentType("application/json") + .body(REQUEST) + .post("/api/projects/" + id + "/builds") + .then() + .statusCode(400); + } finally { + TestHostResolver.reset(); + } + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) + void savesBuildRequestDetailsAndReturnsCredentialReferenceToAdmin() throws InterruptedException { + Long projectId = project(true); + allow("alpine:3.23"); + String path = "/api/projects/" + projectId + "/builds"; + // Use an unset reference so the checkout fails before contacting the remote. + String credentialRef = + "CISYS_GIT_TEST_" + + java.util + .UUID + .randomUUID() + .toString() + .replace("-", "") + .toUpperCase(java.util.Locale.ROOT); + String request = REQUEST.replace("}", ",\"credentialRef\":\"" + credentialRef + "\"}"); + try { + int id = + given() + .contentType("application/json") + .body(request) + .post(path) + .then() + .statusCode(200) + .body( + "branch", + equalTo("main"), + "image", + nullValue(), + "$", + not(hasKey("command")), + "credentialRef", + equalTo(credentialRef)) + .extract() + .path("id"); + runner.dispatch(); + given().get(path).then().statusCode(200).body("[0].credentialRef", equalTo(credentialRef)); + given() + .get(path + "/" + id) + .then() + .statusCode(200) + .body("branch", equalTo("main"), "credentialRef", equalTo(credentialRef)); + } finally { + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(20); + while (runner.hasPendingBuilds() && System.nanoTime() < deadline) Thread.sleep(50); + assertFalse(runner.hasPendingBuilds(), "Build did not finish"); + } + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void buildHistoryHidesCredentialReferenceAndSupportsLegacyBuilds() { + Long projectId = project(false); + Long buildId = + QuarkusTransaction.requiringNew() + .call(() -> { + Build legacy = new Build(); + legacy.projectId = projectId; + legacy.status = Build.Status.PASSED; + legacy.persist(); + Build build = new Build(); + build.projectId = projectId; + build.status = Build.Status.PASSED; + build.branch = "main"; + build.image = "alpine:3.23"; + build.credentialRef = "CISYS_GIT_SECRET"; + build.workflow = "name: main\n"; + build.persist(); + return build.id; + }); + String path = "/api/projects/" + projectId + "/builds"; + given() + .get(path) + .then() + .statusCode(200) + .body( + "[0].branch", + equalTo("main"), + "[0].image", + equalTo("alpine:3.23"), + "[0]", + not(hasKey("command")), + "[0]", + not(hasKey("credentialRef")), + "[0]", + not(hasKey("workflow")), + "[1].status", + equalTo("PASSED"), + "[1].branch", + nullValue(), + "[1].image", + nullValue()); + given() + .get(path + "/" + buildId) + .then() + .statusCode(200) + .body( + "branch", + equalTo("main"), + "$", + not(hasKey("credentialRef")), + "workflow", + equalTo("name: main\n")); + } + + @Test + void processOutputIsBounded() throws Exception { + var r = BuildRunner.command(List.of("sh", "-c", "head -c 500000 /dev/zero"), 10, 1024); + assertEquals(0, r.code()); + assertTrue(r.output().endsWith("[output truncated]\n")); + assertTrue(r.output().length() < 1100); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void listOmitsOutput() { + Long id = project(false); + build(id, "PASSED", "secret-log-text"); + given() + .get("/api/projects/" + id + "/builds") + .then() + .statusCode(200) + .body(not(containsString("secret-log-text"))) + .body(not(containsString("\"output\""))) + .body("[0].branch", equalTo("main")) + .body("[0].image", equalTo("alpine:3.23")) + .body("[0].exitCode", equalTo(0)) + .body("[0].logTruncated", equalTo(false)); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void logStreamsAppendedParts() { + Long id = project(false); + Long b = build(id, "PASSED", "one\n", "two\n", "é\n"); + given() + .get("/api/projects/" + id + "/builds/" + b + "/log") + .then() + .statusCode(200) + .contentType(startsWith("text/plain")) + .body(equalTo("one\ntwo\né\n")); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void logSupportsOffsetLimitAndTail() { + Long id = project(false); + Long b = build(id, "PASSED", "0123456789"); + String path = "/api/projects/" + id + "/builds/" + b + "/log"; + given() + .queryParam("offset", 2) + .queryParam("limit", 3) + .get(path) + .then() + .statusCode(200) + .header("X-Log-Size", "10") + .header("X-Log-Offset", "2") + .header("X-Log-Length", "3") + .body(equalTo("234")); + given() + .queryParam("tail", 4) + .get(path) + .then() + .statusCode(200) + .header("X-Log-Offset", "6") + .body(equalTo("6789")); + given().queryParam("offset", -1).get(path).then().statusCode(400); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void privateLogIsHidden() { + Long id = project(true); + Long b = build(id, "PASSED", "hidden"); + given().get("/api/projects/" + id + "/builds/" + b + "/log").then().statusCode(404); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void logOfBuildInOtherProjectIs404() { + Long id = project(false), other = project(false); + Long b = build(other, "PASSED", "elsewhere"); + given().get("/api/projects/" + id + "/builds/" + b + "/log").then().statusCode(404); + } + + @Test + void recoverKeepsPartialLog() { + Long id = project(false); + Long b = build(id, "RUNNING", "partial\n"); + runner.recover(null); + Build after = QuarkusTransaction.requiringNew().call(() -> Build.findById(b)); + assertEquals(Build.Status.FAILED, after.status); + assertNotNull(after.finishedAt); + assertEquals("partial\nServer restarted during build\n", logs.readAll(b)); + } + + private static boolean pinned(Long build) { + return QuarkusTransaction.requiringNew().call(() -> Build.findById(build).pinned); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerPinsAndUnpins() { + Long id = project(false); + Long b = build(id, "PASSED"); + String pin = "/api/projects/" + id + "/builds/" + b + "/pin"; + given() + .contentType("application/json") + .put(pin) + .then() + .statusCode(200) + .body("pinned", equalTo(true)); + assertTrue(pinned(b)); + given().get("/api/projects/" + id + "/builds").then().body("[0].pinned", equalTo(true)); + given() + .contentType("application/json") + .put(pin) + .then() + .statusCode(200) + .body("pinned", equalTo(true)); + given() + .contentType("application/json") + .delete(pin) + .then() + .statusCode(200) + .body("pinned", equalTo(false)); + assertFalse(pinned(b)); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) + void adminPinsPrivateProjectOfOthers() { + Long id = project(true); + Long b = build(id, "FAILED"); + given() + .contentType("application/json") + .put("/api/projects/" + id + "/builds/" + b + "/pin") + .then() + .statusCode(200) + .body("pinned", equalTo(true)); + assertTrue(pinned(b)); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void otherLeadCannotPin() { + Long id = project(false); + Long b = build(id, "PASSED"); + given() + .contentType("application/json") + .put("/api/projects/" + id + "/builds/" + b + "/pin") + .then() + .statusCode(403); + assertFalse(pinned(b)); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void memberCannotPinOrSeePrivate() { + Long pub = project(false), priv = project(true); + Long b = build(pub, "PASSED"), hidden = build(priv, "PASSED"); + given() + .contentType("application/json") + .put("/api/projects/" + pub + "/builds/" + b + "/pin") + .then() + .statusCode(403); + given() + .contentType("application/json") + .put("/api/projects/" + priv + "/builds/" + hidden + "/pin") + .then() + .statusCode(404); + assertFalse(pinned(b)); + assertFalse(pinned(hidden)); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void pinOfBuildInOtherProjectIs404() { + Long id = project(false), other = project(false); + Long b = build(other, "PASSED"); + given() + .contentType("application/json") + .put("/api/projects/" + id + "/builds/" + b + "/pin") + .then() + .statusCode(404); + assertFalse(pinned(b)); + } + + private void requesterVisible(boolean visible) { + Long id = project(false); + Long b = build(id, "PASSED"); + String path = "/api/projects/" + id + "/builds"; + if (visible) { + given().get(path).then().statusCode(200).body("[0].requestedBy", equalTo("owner-id")); + given().get(path + "/" + b).then().statusCode(200).body("requestedBy", equalTo("owner-id")); + } else { + given().get(path).then().statusCode(200).body("[0]", not(hasKey("requestedBy"))); + given().get(path + "/" + b).then().statusCode(200).body("$", not(hasKey("requestedBy"))); + } + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void memberDoesNotSeeRequester() { + requesterVisible(false); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void leadDoesNotSeeRequester() { + requesterVisible(false); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) + void adminSeesRequester() { + requesterVisible(true); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/GitRepositoryServiceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/GitRepositoryServiceTest.java index 607c392..c45edd2 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/GitRepositoryServiceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/GitRepositoryServiceTest.java @@ -8,87 +8,115 @@ package ai.mnemosyne.cisys; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.errors.GitAPIException; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.sun.net.httpserver.HttpServer; - import java.net.InetSocketAddress; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class GitRepositoryServiceTest { - @TempDir Path temp; + @TempDir Path temp; - @Test - void keepsOnlySortedUniqueBranchNames() { - assertEquals(List.of("dev", "main", "release/1.0"), GitRepositoryService.branchNames(List.of( - "refs/heads/main", "refs/heads/dev", "refs/tags/v1", "refs/heads/dev", "refs/heads/release/1.0"))); - } + @Test + void keepsOnlySortedUniqueBranchNames() { + assertEquals( + List.of("dev", "main", "release/1.0"), + GitRepositoryService.branchNames( + List.of( + "refs/heads/main", + "refs/heads/dev", + "refs/tags/v1", + "refs/heads/dev", + "refs/heads/release/1.0"))); + } - @Test - void ignoresMalformedRefs() { - assertEquals(List.of("main"), GitRepositoryService.branchNames(java.util.Arrays.asList(null, "", "refs/heads/", "refs/heads/main"))); - } + @Test + void ignoresMalformedRefs() { + assertEquals( + List.of("main"), + GitRepositoryService.branchNames( + java.util.Arrays.asList(null, "", "refs/heads/", "refs/heads/main"))); + } - @Test - void listsMoreThanFiftyBranchesFromLocalBareRepository() throws Exception { - Path work = Files.createDirectory(temp.resolve("work")); - Path bare = temp.resolve("bare"); - try (Git git = Git.init().setDirectory(work.toFile()).setInitialBranch("master").call()) { - Files.writeString(work.resolve("README.md"), "test"); - git.add().addFilepattern(".").call(); - git.commit().setMessage("initial").setAuthor("Test", "test@example.com").setCommitter("Test", "test@example.com").call(); - for (int i = 0; i < 60; i++) { - git.branchCreate().setName("branch-" + String.format("%02d", i)).setStartPoint("HEAD").call(); - } - } - try (Git ignored = Git.cloneRepository().setURI(work.toUri().toString()).setDirectory(bare.toFile()).setBare(true).call()) { - // The clone creates the bare remote. - } - GitRepositoryService service = new GitRepositoryService(); - List names = GitRepositoryService.branchNames(service.remoteBranchRefs(bare.toUri().toString())); - assertEquals(61, names.size()); - assertEquals("branch-00", names.get(0)); - assertTrue(names.contains("branch-59")); - assertEquals("master", names.get(60)); + @Test + void listsMoreThanFiftyBranchesFromLocalBareRepository() throws Exception { + Path work = Files.createDirectory(temp.resolve("work")); + Path bare = temp.resolve("bare"); + try (Git git = Git.init().setDirectory(work.toFile()).setInitialBranch("master").call()) { + Files.writeString(work.resolve("README.md"), "test"); + git.add().addFilepattern(".").call(); + git.commit() + .setMessage("initial") + .setAuthor("Test", "test@example.com") + .setCommitter("Test", "test@example.com") + .call(); + for (int i = 0; i < 60; i++) { + git.branchCreate() + .setName("branch-" + String.format("%02d", i)) + .setStartPoint("HEAD") + .call(); + } + } + try (Git ignored = + Git.cloneRepository() + .setURI(work.toUri().toString()) + .setDirectory(bare.toFile()) + .setBare(true) + .call()) { + // The clone creates the bare remote. } + GitRepositoryService service = new GitRepositoryService(); + List names = + GitRepositoryService.branchNames(service.remoteBranchRefs(bare.toUri().toString())); + assertEquals(61, names.size()); + assertEquals("branch-00", names.get(0)); + assertTrue(names.contains("branch-59")); + assertEquals("master", names.get(60)); + } - @Test - void doesNotFollowHttpRedirects() throws Exception { - AtomicInteger redirected = new AtomicInteger(); - HttpServer target = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - target.createContext("/", exchange -> { - redirected.incrementAndGet(); - exchange.sendResponseHeaders(200, -1); - exchange.close(); + @Test + void doesNotFollowHttpRedirects() throws Exception { + AtomicInteger redirected = new AtomicInteger(); + HttpServer target = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + target.createContext( + "/", + exchange -> { + redirected.incrementAndGet(); + exchange.sendResponseHeaders(200, -1); + exchange.close(); }); - target.start(); - HttpServer redirect = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - redirect.createContext("/", exchange -> { - exchange.getResponseHeaders().add("Location", - "http://127.0.0.1:" + target.getAddress().getPort() + "/repo.git/info/refs"); - exchange.sendResponseHeaders(302, -1); - exchange.close(); + target.start(); + HttpServer redirect = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + redirect.createContext( + "/", + exchange -> { + exchange + .getResponseHeaders() + .add( + "Location", + "http://127.0.0.1:" + target.getAddress().getPort() + "/repo.git/info/refs"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); }); - redirect.start(); - try { - GitRepositoryService service = new GitRepositoryService(); - String url = "http://127.0.0.1:" + redirect.getAddress().getPort() + "/repo.git"; - // validate() only accepts github.com/gitlab.com, so the protected method is called directly. - assertThrows(GitAPIException.class, () -> service.remoteBranchRefs(url)); - assertEquals(0, redirected.get()); - } finally { - redirect.stop(0); - target.stop(0); - } + redirect.start(); + try { + GitRepositoryService service = new GitRepositoryService(); + String url = "http://127.0.0.1:" + redirect.getAddress().getPort() + "/repo.git"; + // validate() only accepts github.com/gitlab.com, so the protected method is called directly. + assertThrows(GitAPIException.class, () -> service.remoteBranchRefs(url)); + assertEquals(0, redirected.get()); + } finally { + redirect.stop(0); + target.stop(0); } + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/ImageReferenceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/ImageReferenceTest.java index 7d6b6f4..a9782b6 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/ImageReferenceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/ImageReferenceTest.java @@ -8,88 +8,124 @@ package ai.mnemosyne.cisys; -import org.junit.jupiter.api.Test; - -import java.util.List; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; +import org.junit.jupiter.api.Test; + class ImageReferenceTest { - static final String ALPINE = "164b5c6a230f645862b6123e07cf0a67f2f03371696aa82a12ccf3589e87c016"; - static final String DEBIAN = "596c6d80c879d7e2b4d1a4a1c4e5b7f1a1f5f6a3f9e0d8c7b6a5948372615049"; - static final String VALKEY = "9033f1e0a509a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3"; - static final String DANGLING = "d2b120a24cdd6effd15c0f4ba1a9da247a96f8da5fdf2edbbe205dad7e08de4a"; + static final String ALPINE = "164b5c6a230f645862b6123e07cf0a67f2f03371696aa82a12ccf3589e87c016"; + static final String DEBIAN = "596c6d80c879d7e2b4d1a4a1c4e5b7f1a1f5f6a3f9e0d8c7b6a5948372615049"; + static final String VALKEY = "9033f1e0a509a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3"; + static final String DANGLING = "d2b120a24cdd6effd15c0f4ba1a9da247a96f8da5fdf2edbbe205dad7e08de4a"; - /** As {@code podman images} lists them on a build host. */ - static final List IMAGES = List.of( - new ImageReference.Local("", "", DANGLING), - new ImageReference.Local("docker.io/library/alpine", "3.23", ALPINE), - new ImageReference.Local("docker.io/library/debian", "13-slim", DEBIAN), - new ImageReference.Local("docker.io/valkey/valkey", "8-alpine", VALKEY)); + /** As {@code podman images} lists them on a build host. */ + static final List IMAGES = + List.of( + new ImageReference.Local("", "", DANGLING), + new ImageReference.Local("docker.io/library/alpine", "3.23", ALPINE), + new ImageReference.Local("docker.io/library/debian", "13-slim", DEBIAN), + new ImageReference.Local("docker.io/valkey/valkey", "8-alpine", VALKEY)); - @Test - void allFormsResolveToTheSameImage() { - for (String base : List.of("alpine", "alpine:3.23", "docker.io/library/alpine", "docker.io/library/alpine:3.23", - "164b5c6a230f", "sha256:164b5c6a230f", ALPINE)) { - var resolved = ImageReference.resolve(base, IMAGES); - assertEquals(ALPINE, resolved.id(), base); - assertEquals(List.of("docker.io/library/alpine:3.23"), resolved.names(), base); - assertEquals("docker.io/library/alpine:3.23", resolved.name(), base); - } + @Test + void allFormsResolveToTheSameImage() { + for (String base : + List.of( + "alpine", + "alpine:3.23", + "docker.io/library/alpine", + "docker.io/library/alpine:3.23", + "164b5c6a230f", + "sha256:164b5c6a230f", + ALPINE)) { + var resolved = ImageReference.resolve(base, IMAGES); + assertEquals(ALPINE, resolved.id(), base); + assertEquals(List.of("docker.io/library/alpine:3.23"), resolved.names(), base); + assertEquals("docker.io/library/alpine:3.23", resolved.name(), base); } + } - @Test - void resolvesNonLibraryImages() { - assertEquals(VALKEY, ImageReference.resolve("valkey/valkey", IMAGES).id()); - assertEquals(VALKEY, ImageReference.resolve("valkey/valkey:8-alpine", IMAGES).id()); - } + @Test + void resolvesNonLibraryImages() { + assertEquals(VALKEY, ImageReference.resolve("valkey/valkey", IMAGES).id()); + assertEquals(VALKEY, ImageReference.resolve("valkey/valkey:8-alpine", IMAGES).id()); + } - @Test - void otherRegistriesAreNotDockerHub() { - var images = List.of(new ImageReference.Local("quay.io/keycloak/keycloak", "26.8.2", ALPINE)); - assertEquals(ALPINE, ImageReference.resolve("quay.io/keycloak/keycloak", images).id()); - assertThrows(Workflow.InvalidWorkflowException.class, () -> ImageReference.resolve("keycloak/keycloak", images)); - } + @Test + void otherRegistriesAreNotDockerHub() { + var images = List.of(new ImageReference.Local("quay.io/keycloak/keycloak", "26.8.2", ALPINE)); + assertEquals(ALPINE, ImageReference.resolve("quay.io/keycloak/keycloak", images).id()); + assertThrows( + Workflow.InvalidWorkflowException.class, + () -> ImageReference.resolve("keycloak/keycloak", images)); + } - @Test - void untaggedNameWithSeveralVersionsUsesLatestOrAsksForTheTag() { - var two = List.of( - new ImageReference.Local("docker.io/library/alpine", "3.23", ALPINE), - new ImageReference.Local("docker.io/library/alpine", "3.22", DEBIAN)); - var e = assertThrows(Workflow.InvalidWorkflowException.class, () -> ImageReference.resolve("alpine", two)); - assertTrue(e.getMessage().contains("docker.io/library/alpine:3.22, docker.io/library/alpine:3.23"), e.getMessage()); - var withLatest = List.of(two.get(0), two.get(1), new ImageReference.Local("docker.io/library/alpine", "latest", DEBIAN)); - assertEquals(DEBIAN, ImageReference.resolve("alpine", withLatest).id()); - // Two tags of one image are not ambiguous. - var sameImage = List.of(two.get(0), new ImageReference.Local("docker.io/library/alpine", "3", ALPINE)); - var resolved = ImageReference.resolve("alpine", sameImage); - assertEquals(List.of("docker.io/library/alpine:3", "docker.io/library/alpine:3.23"), resolved.names()); - } + @Test + void untaggedNameWithSeveralVersionsUsesLatestOrAsksForTheTag() { + var two = + List.of( + new ImageReference.Local("docker.io/library/alpine", "3.23", ALPINE), + new ImageReference.Local("docker.io/library/alpine", "3.22", DEBIAN)); + var e = + assertThrows( + Workflow.InvalidWorkflowException.class, () -> ImageReference.resolve("alpine", two)); + assertTrue( + e.getMessage().contains("docker.io/library/alpine:3.22, docker.io/library/alpine:3.23"), + e.getMessage()); + var withLatest = + List.of( + two.get(0), + two.get(1), + new ImageReference.Local("docker.io/library/alpine", "latest", DEBIAN)); + assertEquals(DEBIAN, ImageReference.resolve("alpine", withLatest).id()); + // Two tags of one image are not ambiguous. + var sameImage = + List.of(two.get(0), new ImageReference.Local("docker.io/library/alpine", "3", ALPINE)); + var resolved = ImageReference.resolve("alpine", sameImage); + assertEquals( + List.of("docker.io/library/alpine:3", "docker.io/library/alpine:3.23"), resolved.names()); + } - @Test - void missingImagesAreReported() { - for (String base : List.of("alpine:3.22", "ubuntu", "docker.io/library/ubuntu:24.04", "abcdefabcdef", "alpine@sha256:" + ALPINE)) { - assertThrows(Workflow.InvalidWorkflowException.class, () -> ImageReference.resolve(base, IMAGES), base); - } - var e = assertThrows(Workflow.InvalidWorkflowException.class, () -> ImageReference.resolve("ubuntu", IMAGES)); - assertTrue(e.getMessage().contains("Images page"), e.getMessage()); + @Test + void missingImagesAreReported() { + for (String base : + List.of( + "alpine:3.22", + "ubuntu", + "docker.io/library/ubuntu:24.04", + "abcdefabcdef", + "alpine@sha256:" + ALPINE)) { + assertThrows( + Workflow.InvalidWorkflowException.class, + () -> ImageReference.resolve(base, IMAGES), + base); } + var e = + assertThrows( + Workflow.InvalidWorkflowException.class, + () -> ImageReference.resolve("ubuntu", IMAGES)); + assertTrue(e.getMessage().contains("Images page"), e.getMessage()); + } - @Test - void danglingImagesHaveNoNames() { - var resolved = ImageReference.resolve("d2b120a24cdd", IMAGES); - assertEquals(DANGLING, resolved.id()); - assertEquals(List.of(), resolved.names()); - assertEquals("d2b120a24cdd", resolved.name()); - } + @Test + void danglingImagesHaveNoNames() { + var resolved = ImageReference.resolve("d2b120a24cdd", IMAGES); + assertEquals(DANGLING, resolved.id()); + assertEquals(List.of(), resolved.names()); + assertEquals("d2b120a24cdd", resolved.name()); + } - @Test - void ambiguousIdPrefix() { - var images = List.of(new ImageReference.Local("a/b", "1", ALPINE), - new ImageReference.Local("a/c", "1", "164b5c6a230f" + "0".repeat(52))); - assertThrows(Workflow.InvalidWorkflowException.class, () -> ImageReference.resolve("164b5c6a230f", images)); - assertEquals(ALPINE, ImageReference.resolve(ALPINE.substring(0, 13), images).id()); - } + @Test + void ambiguousIdPrefix() { + var images = + List.of( + new ImageReference.Local("a/b", "1", ALPINE), + new ImageReference.Local("a/c", "1", "164b5c6a230f" + "0".repeat(52))); + assertThrows( + Workflow.InvalidWorkflowException.class, + () -> ImageReference.resolve("164b5c6a230f", images)); + assertEquals(ALPINE, ImageReference.resolve(ALPINE.substring(0, 13), images).id()); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/ImageResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/ImageResourceTest.java index 57046f6..8e11c2b 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/ImageResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/ImageResourceTest.java @@ -8,46 +8,96 @@ package ai.mnemosyne.cisys; -import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.security.TestSecurity; -import org.junit.jupiter.api.Test; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.is; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import org.junit.jupiter.api.Test; + @QuarkusTest class ImageResourceTest { - private static final String SELECTION = "{\"images\":[\"alpine:3.23\",\"docker.io/library/fedora:44\"]}"; - @Test void anonymousCannotReadImages() { given().get("/api/images").then().statusCode(401); } - @Test @TestSecurity(user = "member", roles = "member") - void memberCanReadAllowedButNotAvailable() { - given().get("/api/images").then().statusCode(200); - given().get("/api/images/local").then().statusCode(403); - } - @Test @TestSecurity(user = "lead", roles = "lead") - void leadCannotManageImages() { - given().contentType("application/json").body(SELECTION).put("/api/images").then().statusCode(403); - given().contentType("application/json").body("{\"name\":\"alpine:3.23\"}").post("/api/images/local").then().statusCode(403); - given().queryParam("name", "alpine:3.23").delete("/api/images/local").then().statusCode(403); - } - @Test @TestSecurity(user = "admin", roles = "admin") - void rejectsInvalidPullAndRemoveNames() { - given().contentType("application/json").body("{\"name\":\"--all\"}").post("/api/images/local").then().statusCode(400); - given().queryParam("name", "-a").delete("/api/images/local").then().statusCode(400); - } - @Test @TestSecurity(user = "admin", roles = "admin") - void adminCanReplaceAllowedImages() { - given().contentType("application/json").body(SELECTION).put("/api/images").then().statusCode(200).body("$", contains("alpine:3.23", "docker.io/library/fedora:44")); - given().contentType("application/json").body("{\"images\":[\"alpine:3.23\"]}").put("/api/images").then().statusCode(200).body("$", contains("alpine:3.23")); - given().get("/api/images").then().statusCode(200).body("$", contains("alpine:3.23")); - } - @Test @TestSecurity(user = "admin", roles = "admin") - void rejectsInvalidImageNames() { - given().contentType("application/json").body("{\"images\":[\"--privileged\"]}").put("/api/images").then().statusCode(400); - } - @Test @TestSecurity(user = "admin", roles = "admin") - void listsLocalImagesWhenARuntimeIsPresent() { - given().get("/api/images/local").then().statusCode(anyOf(is(200), is(502))); - } + private static final String SELECTION = + "{\"images\":[\"alpine:3.23\",\"docker.io/library/fedora:44\"]}"; + + @Test + void anonymousCannotReadImages() { + given().get("/api/images").then().statusCode(401); + } + + @Test + @TestSecurity(user = "member", roles = "member") + void memberCanReadAllowedButNotAvailable() { + given().get("/api/images").then().statusCode(200); + given().get("/api/images/local").then().statusCode(403); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + void leadCannotManageImages() { + given() + .contentType("application/json") + .body(SELECTION) + .put("/api/images") + .then() + .statusCode(403); + given() + .contentType("application/json") + .body("{\"name\":\"alpine:3.23\"}") + .post("/api/images/local") + .then() + .statusCode(403); + given().queryParam("name", "alpine:3.23").delete("/api/images/local").then().statusCode(403); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + void rejectsInvalidPullAndRemoveNames() { + given() + .contentType("application/json") + .body("{\"name\":\"--all\"}") + .post("/api/images/local") + .then() + .statusCode(400); + given().queryParam("name", "-a").delete("/api/images/local").then().statusCode(400); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + void adminCanReplaceAllowedImages() { + given() + .contentType("application/json") + .body(SELECTION) + .put("/api/images") + .then() + .statusCode(200) + .body("$", contains("alpine:3.23", "docker.io/library/fedora:44")); + given() + .contentType("application/json") + .body("{\"images\":[\"alpine:3.23\"]}") + .put("/api/images") + .then() + .statusCode(200) + .body("$", contains("alpine:3.23")); + given().get("/api/images").then().statusCode(200).body("$", contains("alpine:3.23")); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + void rejectsInvalidImageNames() { + given() + .contentType("application/json") + .body("{\"images\":[\"--privileged\"]}") + .put("/api/images") + .then() + .statusCode(400); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + void listsLocalImagesWhenARuntimeIsPresent() { + given().get("/api/images/local").then().statusCode(anyOf(is(200), is(502))); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/ProjectBranchResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/ProjectBranchResourceTest.java index ba90f7f..ae5514e 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/ProjectBranchResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/ProjectBranchResourceTest.java @@ -8,148 +8,164 @@ package ai.mnemosyne.cisys; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; + import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; import io.quarkus.test.security.TestSecurity; import io.quarkus.test.security.jwt.Claim; import io.quarkus.test.security.jwt.JwtSecurity; -import org.junit.jupiter.api.Test; - import java.util.UUID; - -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; @QuarkusTest @TestProfile(BranchTestProfile.class) class ProjectBranchResourceTest { - private static final String GITHUB = "https://github.com/octocat/Hello-World"; - - private static String github() { - return GITHUB + "-" + UUID.randomUUID() + ".git"; - } - - private static String unreachable() { - return "https://github.com/octocat/unreachable-" + UUID.randomUUID() + ".git"; - } - - private String project(boolean priv, String repositoryUrl) { - return QuarkusTransaction.requiringNew().call(() -> { - String name = "Public-Page-" + UUID.randomUUID(); - Project p = new Project(); - p.name = name; - p.repositoryUrl = repositoryUrl; - p.ownerSubject = "owner-id"; - p.privateRepository = priv; - p.persist(); - return name; + private static final String GITHUB = "https://github.com/octocat/Hello-World"; + + private static String github() { + return GITHUB + "-" + UUID.randomUUID() + ".git"; + } + + private static String unreachable() { + return "https://github.com/octocat/unreachable-" + UUID.randomUUID() + ".git"; + } + + private String project(boolean priv, String repositoryUrl) { + return QuarkusTransaction.requiringNew() + .call(() -> { + String name = "Public-Page-" + UUID.randomUUID(); + Project p = new Project(); + p.name = name; + p.repositoryUrl = repositoryUrl; + p.ownerSubject = "owner-id"; + p.privateRepository = priv; + p.persist(); + return name; }); - } - - private void build(String projectName, String branch) { - QuarkusTransaction.requiringNew().run(() -> { - Project p = Project.find("name = ?1", projectName).firstResult(); - Build b = new Build(); - b.projectId = p.id; - b.status=Build.Status.PASSED; - b.branch = branch; - b.persist(); + } + + private void build(String projectName, String branch) { + QuarkusTransaction.requiringNew() + .run(() -> { + Project p = Project.find("name = ?1", projectName).firstResult(); + Build b = new Build(); + b.projectId = p.id; + b.status = Build.Status.PASSED; + b.branch = branch; + b.persist(); }); - } - - @Test - void anonymousSeesBranchesOfPublicProject() { - String name = project(false, github()); - TestGitRepositoryService.resetCalls(); - given().get("/api/project-pages/" + name.toLowerCase() + "/branches").then() - .statusCode(200) - .body("branches", hasSize(2)) - .body("branches", contains("dev", "main")) - .body("reason", nullValue()); - assertEquals(1, TestGitRepositoryService.calls()); - } - - @Test - void anonymousCannotSeeBranchesOfPrivateProject() { - String name = project(true, github()); - TestGitRepositoryService.resetCalls(); - given().get("/api/project-pages/" + name + "/branches").then().statusCode(404); - assertEquals(0, TestGitRepositoryService.calls()); - } - - @Test - @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) - void otherMemberCannotSeeBranchesOfPrivateProject() { - String name = project(true, github()); - TestGitRepositoryService.resetCalls(); - given().get("/api/project-pages/" + name + "/branches").then().statusCode(404); - assertEquals(0, TestGitRepositoryService.calls()); - } - - @Test - @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) - void ownerGetsTheBranchesThatHaveBuilds() { - String name = project(true, github()); - build(name, "dev"); - build(name, "main"); - build(name, "dev"); - TestGitRepositoryService.resetCalls(); - given().get("/api/project-pages/" + name + "/branches").then() - .statusCode(200) - .body("branches", hasSize(2)) - .body("branches", contains("dev", "main")) - .body("reason", nullValue()); - assertEquals(0, TestGitRepositoryService.calls()); - } - - @Test - @TestSecurity(user = "admin", roles = "admin") - @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) - void adminGetsTheBranchesThatHaveBuilds() { - String name = project(true, github()); - build(name, "release"); - given().get("/api/project-pages/" + name + "/branches").then() - .statusCode(200) - .body("branches", hasSize(1)) - .body("branches", contains("release")); - } - - @Test - @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) - void ownerOfPrivateProjectWithoutBuildsGetsAnEmptyList() { - String name = project(true, github()); - given().get("/api/project-pages/" + name + "/branches").then() - .statusCode(200) - .body("branches", hasSize(0)) - .body("reason", nullValue()); - } - - @Test - void unknownProjectIsNotFound() { - given().get("/api/project-pages/does-not-exist-" + UUID.randomUUID() + "/branches").then().statusCode(404); - } - - @Test - void unreachableRepositoryIsReportedAsBadGateway() { - String name = project(false, unreachable()); - given().get("/api/project-pages/" + name + "/branches").then() - .statusCode(502) - .body("error", equalTo("Unable to read branches from the repository")); - } - - @Test - void invalidRepositoryUrlIsRejected() { - String name = project(false, "https://example.com/octocat/Hello-World.git"); - given().get("/api/project-pages/" + name + "/branches").then() - .statusCode(400) - .body("error", equalTo("Use a repository on an allowed host (github.com, gitlab.com)")); - } + } + + @Test + void anonymousSeesBranchesOfPublicProject() { + String name = project(false, github()); + TestGitRepositoryService.resetCalls(); + given() + .get("/api/project-pages/" + name.toLowerCase() + "/branches") + .then() + .statusCode(200) + .body("branches", hasSize(2)) + .body("branches", contains("dev", "main")) + .body("reason", nullValue()); + assertEquals(1, TestGitRepositoryService.calls()); + } + + @Test + void anonymousCannotSeeBranchesOfPrivateProject() { + String name = project(true, github()); + TestGitRepositoryService.resetCalls(); + given().get("/api/project-pages/" + name + "/branches").then().statusCode(404); + assertEquals(0, TestGitRepositoryService.calls()); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void otherMemberCannotSeeBranchesOfPrivateProject() { + String name = project(true, github()); + TestGitRepositoryService.resetCalls(); + given().get("/api/project-pages/" + name + "/branches").then().statusCode(404); + assertEquals(0, TestGitRepositoryService.calls()); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerGetsTheBranchesThatHaveBuilds() { + String name = project(true, github()); + build(name, "dev"); + build(name, "main"); + build(name, "dev"); + TestGitRepositoryService.resetCalls(); + given() + .get("/api/project-pages/" + name + "/branches") + .then() + .statusCode(200) + .body("branches", hasSize(2)) + .body("branches", contains("dev", "main")) + .body("reason", nullValue()); + assertEquals(0, TestGitRepositoryService.calls()); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) + void adminGetsTheBranchesThatHaveBuilds() { + String name = project(true, github()); + build(name, "release"); + given() + .get("/api/project-pages/" + name + "/branches") + .then() + .statusCode(200) + .body("branches", hasSize(1)) + .body("branches", contains("release")); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerOfPrivateProjectWithoutBuildsGetsAnEmptyList() { + String name = project(true, github()); + given() + .get("/api/project-pages/" + name + "/branches") + .then() + .statusCode(200) + .body("branches", hasSize(0)) + .body("reason", nullValue()); + } + + @Test + void unknownProjectIsNotFound() { + given() + .get("/api/project-pages/does-not-exist-" + UUID.randomUUID() + "/branches") + .then() + .statusCode(404); + } + + @Test + void unreachableRepositoryIsReportedAsBadGateway() { + String name = project(false, unreachable()); + given() + .get("/api/project-pages/" + name + "/branches") + .then() + .statusCode(502) + .body("error", equalTo("Unable to read branches from the repository")); + } + + @Test + void invalidRepositoryUrlIsRejected() { + String name = project(false, "https://example.com/octocat/Hello-World.git"); + given() + .get("/api/project-pages/" + name + "/branches") + .then() + .statusCode(400) + .body("error", equalTo("Use a repository on an allowed host (github.com, gitlab.com)")); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java index 2944dd5..8f133bb 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java @@ -8,6 +8,9 @@ package ai.mnemosyne.cisys; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; @@ -17,271 +20,400 @@ import java.nio.charset.StandardCharsets; import java.util.UUID; import org.junit.jupiter.api.Test; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; @QuarkusTest class ProjectPageResourceTest { - @Inject BuildLogs logs; - @Inject ProjectPageResource pages; - - record Seeded(String name, long buildId) {} - - /** Creates a project with one finished build and returns its name. */ - private String project(boolean priv) { - return seed(priv).name(); - } - - private Seeded seed(boolean priv) { - return seed(priv, "hello"); - } - - private Seeded seed(boolean priv, String logText) { - Seeded seeded = QuarkusTransaction.requiringNew().call(() -> { - String n = UUID.randomUUID().toString(); - Project p = new Project(); - p.name = "Page-" + n; - p.repositoryUrl = "https://github.com/octocat/page-" + n + ".git"; - p.ownerSubject = "owner-id"; - p.privateRepository = priv; - p.persist(); - Build b = new Build(); - b.projectId = p.id; - b.status=Build.Status.PASSED; - b.branch = "main"; - b.requestedBy = "owner-id"; - b.credentialRef = "CISYS_GIT_SECRET"; - b.persist(); - return new Seeded(p.name, b.id); - }); - byte[] log = logText.getBytes(StandardCharsets.UTF_8); - try (var w = logs.open(seeded.buildId())) { w.append(log, 0, log.length); } - return seeded; + @Inject BuildLogs logs; + @Inject ProjectPageResource pages; + + record Seeded(String name, long buildId) {} + + /** Creates a project with one finished build and returns its name. */ + private String project(boolean priv) { + return seed(priv).name(); + } + + private Seeded seed(boolean priv) { + return seed(priv, "hello"); + } + + private Seeded seed(boolean priv, String logText) { + Seeded seeded = + QuarkusTransaction.requiringNew() + .call(() -> { + String n = UUID.randomUUID().toString(); + Project p = new Project(); + p.name = "Page-" + n; + p.repositoryUrl = "https://github.com/octocat/page-" + n + ".git"; + p.ownerSubject = "owner-id"; + p.privateRepository = priv; + p.persist(); + Build b = new Build(); + b.projectId = p.id; + b.status = Build.Status.PASSED; + b.branch = "main"; + b.requestedBy = "owner-id"; + b.credentialRef = "CISYS_GIT_SECRET"; + b.persist(); + return new Seeded(p.name, b.id); + }); + byte[] log = logText.getBytes(StandardCharsets.UTF_8); + try (var w = logs.open(seeded.buildId())) { + w.append(log, 0, log.length); } - - private void addBuild(String projectName, String branch) { - QuarkusTransaction.requiringNew().run(() -> { - Project p = Project.find("name = ?1", projectName).firstResult(); - Build b = new Build(); - b.projectId = p.id; - b.status=Build.Status.PASSED; - b.branch = branch; - b.persist(); + return seeded; + } + + private void addBuild(String projectName, String branch) { + QuarkusTransaction.requiringNew() + .run(() -> { + Project p = Project.find("name = ?1", projectName).firstResult(); + Build b = new Build(); + b.projectId = p.id; + b.status = Build.Status.PASSED; + b.branch = branch; + b.persist(); }); + } + + @Test + void anonymousSeesPublicProjectBuilds() { + String name = project(false); + given() + .get("/api/project-pages/" + name.toLowerCase()) + .then() + .statusCode(200) + .body( + "project.name", + equalTo(name), + "project.privateRepository", + equalTo(false), + "builds", + hasSize(1), + "builds[0].status", + equalTo("PASSED"), + "builds[0].logTruncated", + equalTo(false), + "builds[0].pinned", + equalTo(false), + "builds[0].canPin", + equalTo(false), + "builds[0]", + not(hasKey("output")), + "builds[0]", + not(hasKey("requestedBy")), + "builds[0]", + not(hasKey("credentialRef"))); + } + + @Test + void anonymousCanFilterPublicProjectBuildsByBranch() { + String name = project(false); + addBuild(name, "dev"); + given() + .queryParam("branch", "dev") + .get("/api/project-pages/" + name.toLowerCase()) + .then() + .statusCode(200) + .body("builds", hasSize(1), "builds[0].branch", equalTo("dev")); + given() + .queryParam("branch", "main") + .get("/api/project-pages/" + name.toLowerCase()) + .then() + .statusCode(200) + .body("builds", hasSize(1), "builds[0].branch", equalTo("main")); + } + + @Test + void filteringByUnknownBranchReturnsNoBuilds() { + String name = project(false); + given() + .queryParam("branch", "missing") + .get("/api/project-pages/" + name.toLowerCase()) + .then() + .statusCode(200) + .body("builds", hasSize(0)); + } + + @Test + void privateProjectFilterIsStillNotFound() { + String name = project(true); + given() + .queryParam("branch", "main") + .get("/api/project-pages/" + name.toLowerCase()) + .then() + .statusCode(404); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerCanFilterPrivateProjectByBranch() { + String name = project(true); + addBuild(name, "dev"); + given() + .queryParam("branch", "dev") + .get("/api/project-pages/" + name.toLowerCase()) + .then() + .statusCode(200) + .body("builds", hasSize(1), "builds[0].branch", equalTo("dev")); + given() + .queryParam("branch", "main") + .get("/api/project-pages/" + name.toLowerCase()) + .then() + .statusCode(200) + .body("builds", hasSize(1), "builds[0].branch", equalTo("main")); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void otherMemberCannotFilterPrivateProjectByBranch() { + String name = project(true); + given() + .queryParam("branch", "main") + .get("/api/project-pages/" + name.toLowerCase()) + .then() + .statusCode(404); + } + + @Test + void anonymousReadsPublicBuildPage() { + Seeded s = seed(false); + given() + .get("/api/project-pages/" + s.name().toLowerCase() + "/builds/" + s.buildId()) + .then() + .statusCode(200) + .body( + "status", + equalTo("PASSED"), + "branch", + equalTo("main"), + "logTruncated", + equalTo(false), + "$", + not(hasKey("requestedBy")), + "$", + not(hasKey("credentialRef"))); + } + + @Test + void anonymousCannotReadPrivateBuildPage() { + Seeded s = seed(true); + given() + .get("/api/project-pages/" + s.name().toLowerCase() + "/builds/" + s.buildId()) + .then() + .statusCode(404); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerReadsPrivateBuildPage() { + Seeded s = seed(true); + given() + .get("/api/project-pages/" + s.name().toLowerCase() + "/builds/" + s.buildId()) + .then() + .statusCode(200) + .body("status", equalTo("PASSED"), "canPin", equalTo(true), "projectId", notNullValue()); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void otherLeadCannotPinPublicBuild() { + Seeded s = seed(false); + given() + .get("/api/project-pages/" + s.name() + "/builds/" + s.buildId()) + .then() + .statusCode(200) + .body("canPin", equalTo(false)); + } + + @Test + void buildPageOfAnotherProjectIsNotFound() { + Seeded page = seed(false), other = seed(false); + given() + .get("/api/project-pages/" + page.name().toLowerCase() + "/builds/" + other.buildId()) + .then() + .statusCode(404); + } + + @Test + void unknownBuildPageIsNotFound() { + given() + .get("/api/project-pages/" + project(false).toLowerCase() + "/builds/999999") + .then() + .statusCode(404); + } + + @Test + void anonymousReadsPublicBuildLog() { + Seeded s = seed(false); + given() + .get("/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log") + .then() + .statusCode(200) + .contentType(startsWith("text/plain")) + .body(equalTo("hello")); + } + + @Test + void anonymousLogReadIsCappedAndCanContinue() { + Seeded s = seed(false, "0123456789"); + ProjectPageResource resource = io.quarkus.arc.ClientProxy.unwrap(pages); + long original = resource.publicLogMaxBytes; + resource.publicLogMaxBytes = 5; + try { + String url = "/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log"; + given() + .get(url) + .then() + .statusCode(200) + .header("X-Log-Size", "10") + .header("X-Log-Offset", "0") + .header("X-Log-Length", "5") + .body(equalTo("01234")); + given() + .queryParam("offset", 5) + .get(url) + .then() + .statusCode(200) + .header("X-Log-Offset", "5") + .header("X-Log-Length", "5") + .body(equalTo("56789")); + // A tail larger than the cap still returns the end of the log, not a chunk from the middle. + given() + .queryParam("tail", 50) + .get(url) + .then() + .statusCode(200) + .header("X-Log-Offset", "5") + .header("X-Log-Length", "5") + .body(equalTo("56789")); + } finally { + resource.publicLogMaxBytes = original; } - - @Test - void anonymousSeesPublicProjectBuilds() { - String name = project(false); - given().get("/api/project-pages/" + name.toLowerCase()).then().statusCode(200) - .body("project.name", equalTo(name), "project.privateRepository", equalTo(false), - "builds", hasSize(1), "builds[0].status", equalTo("PASSED"), "builds[0].logTruncated", equalTo(false), - "builds[0].pinned", equalTo(false), "builds[0].canPin", equalTo(false), - "builds[0]", not(hasKey("output")), - "builds[0]", not(hasKey("requestedBy")), "builds[0]", not(hasKey("credentialRef"))); - } - - @Test - void anonymousCanFilterPublicProjectBuildsByBranch() { - String name = project(false); - addBuild(name, "dev"); - given().queryParam("branch", "dev").get("/api/project-pages/" + name.toLowerCase()).then() - .statusCode(200).body("builds", hasSize(1), "builds[0].branch", equalTo("dev")); - given().queryParam("branch", "main").get("/api/project-pages/" + name.toLowerCase()).then() - .statusCode(200).body("builds", hasSize(1), "builds[0].branch", equalTo("main")); - } - - @Test - void filteringByUnknownBranchReturnsNoBuilds() { - String name = project(false); - given().queryParam("branch", "missing").get("/api/project-pages/" + name.toLowerCase()).then() - .statusCode(200).body("builds", hasSize(0)); - } - - @Test - void privateProjectFilterIsStillNotFound() { - String name = project(true); - given().queryParam("branch", "main").get("/api/project-pages/" + name.toLowerCase()).then().statusCode(404); - } - - @Test - @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) - void ownerCanFilterPrivateProjectByBranch() { - String name = project(true); - addBuild(name, "dev"); - given().queryParam("branch", "dev").get("/api/project-pages/" + name.toLowerCase()).then() - .statusCode(200).body("builds", hasSize(1), "builds[0].branch", equalTo("dev")); - given().queryParam("branch", "main").get("/api/project-pages/" + name.toLowerCase()).then() - .statusCode(200).body("builds", hasSize(1), "builds[0].branch", equalTo("main")); - } - - @Test - @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) - void otherMemberCannotFilterPrivateProjectByBranch() { - String name = project(true); - given().queryParam("branch", "main").get("/api/project-pages/" + name.toLowerCase()).then().statusCode(404); - } - - @Test - void anonymousReadsPublicBuildPage() { - Seeded s = seed(false); - given().get("/api/project-pages/" + s.name().toLowerCase() + "/builds/" + s.buildId()).then().statusCode(200) - .body("status", equalTo("PASSED"), "branch", equalTo("main"), "logTruncated", equalTo(false), - "$", not(hasKey("requestedBy")), "$", not(hasKey("credentialRef"))); - } - - @Test - void anonymousCannotReadPrivateBuildPage() { - Seeded s = seed(true); - given().get("/api/project-pages/" + s.name().toLowerCase() + "/builds/" + s.buildId()).then().statusCode(404); - } - - @Test - @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) - void ownerReadsPrivateBuildPage() { - Seeded s = seed(true); - given().get("/api/project-pages/" + s.name().toLowerCase() + "/builds/" + s.buildId()).then().statusCode(200) - .body("status", equalTo("PASSED"), "canPin", equalTo(true), "projectId", notNullValue()); - } - - @Test - @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) - void otherLeadCannotPinPublicBuild() { - Seeded s = seed(false); - given().get("/api/project-pages/" + s.name() + "/builds/" + s.buildId()).then().statusCode(200) - .body("canPin", equalTo(false)); - } - - @Test - void buildPageOfAnotherProjectIsNotFound() { - Seeded page = seed(false), other = seed(false); - given().get("/api/project-pages/" + page.name().toLowerCase() + "/builds/" + other.buildId()).then().statusCode(404); - } - - @Test - void unknownBuildPageIsNotFound() { - given().get("/api/project-pages/" + project(false).toLowerCase() + "/builds/999999").then().statusCode(404); - } - - @Test - void anonymousReadsPublicBuildLog() { - Seeded s = seed(false); - given().get("/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log").then().statusCode(200) - .contentType(startsWith("text/plain")).body(equalTo("hello")); - } - - @Test - void anonymousLogReadIsCappedAndCanContinue() { - Seeded s = seed(false, "0123456789"); - ProjectPageResource resource = io.quarkus.arc.ClientProxy.unwrap(pages); - long original = resource.publicLogMaxBytes; - resource.publicLogMaxBytes = 5; - try { - String url = "/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log"; - given().get(url).then().statusCode(200) - .header("X-Log-Size", "10") - .header("X-Log-Offset", "0").header("X-Log-Length", "5") - .body(equalTo("01234")); - given().queryParam("offset", 5).get(url).then().statusCode(200) - .header("X-Log-Offset", "5").header("X-Log-Length", "5") - .body(equalTo("56789")); - // A tail larger than the cap still returns the end of the log, not a chunk from the middle. - given().queryParam("tail", 50).get(url).then().statusCode(200) - .header("X-Log-Offset", "5").header("X-Log-Length", "5") - .body(equalTo("56789")); - } finally { - resource.publicLogMaxBytes = original; - } - } - - @Test - void anonymousLogSupportsOffsetLimitAndTail() { - Seeded s = seed(false, "0123456789"); - String url = "/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log"; - given().queryParam("offset", 2).queryParam("limit", 3).get(url).then().statusCode(200) - .header("X-Log-Offset", "2").header("X-Log-Length", "3").body(equalTo("234")); - given().queryParam("tail", 4).get(url).then().statusCode(200) - .header("X-Log-Offset", "6").header("X-Log-Length", "4").body(equalTo("6789")); - } - - @Test - void negativeLogRangeIsRejected() { - Seeded s = seed(false); - String url = "/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log"; - given().queryParam("offset", -1).get(url).then().statusCode(400); - given().queryParam("limit", -1).get(url).then().statusCode(400); - given().queryParam("tail", -1).get(url).then().statusCode(400); - } - - @Test - @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) - void signedInLogReadIsNotCapped() { - Seeded s = seed(true, "0123456789"); - ProjectPageResource resource = io.quarkus.arc.ClientProxy.unwrap(pages); - long original = resource.publicLogMaxBytes; - resource.publicLogMaxBytes = 5; - try { - given().get("/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log").then().statusCode(200) - .header("X-Log-Size", "10").body(equalTo("0123456789")); - } finally { - resource.publicLogMaxBytes = original; - } - } - - @Test - void anonymousCannotReadPrivateBuildLog() { - Seeded s = seed(true); - given().get("/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log").then().statusCode(404); - } - - @Test - @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) - void ownerReadsPrivateBuildLog() { - Seeded s = seed(true); - given().get("/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log").then().statusCode(200) - .body(equalTo("hello")); - } - - @Test - void logOfAnotherProjectsBuildIsNotFound() { - Seeded page = seed(false), other = seed(false); - given().get("/api/project-pages/" + page.name() + "/builds/" + other.buildId() + "/log").then().statusCode(404); - } - - @Test - void anonymousCannotSeePrivateProject() { - given().get("/api/project-pages/" + project(true)).then().statusCode(404); - } - - @Test - void unknownProjectIsNotFound() { - given().get("/api/project-pages/does-not-exist-" + UUID.randomUUID()).then().statusCode(404); - } - - @Test - @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) - void otherMemberCannotSeePrivateProject() { - given().get("/api/project-pages/" + project(true)).then().statusCode(404); - } - - @Test - @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) - void ownerSeesPrivateProject() { - given().get("/api/project-pages/" + project(true)).then().statusCode(200).body("builds", hasSize(1)); - } - - @Test - @TestSecurity(user = "admin", roles = "admin") - @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) - void adminSeesPrivateProject() { - given().get("/api/project-pages/" + project(true)).then().statusCode(200).body("builds[0].canPin", equalTo(true)); + } + + @Test + void anonymousLogSupportsOffsetLimitAndTail() { + Seeded s = seed(false, "0123456789"); + String url = "/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log"; + given() + .queryParam("offset", 2) + .queryParam("limit", 3) + .get(url) + .then() + .statusCode(200) + .header("X-Log-Offset", "2") + .header("X-Log-Length", "3") + .body(equalTo("234")); + given() + .queryParam("tail", 4) + .get(url) + .then() + .statusCode(200) + .header("X-Log-Offset", "6") + .header("X-Log-Length", "4") + .body(equalTo("6789")); + } + + @Test + void negativeLogRangeIsRejected() { + Seeded s = seed(false); + String url = "/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log"; + given().queryParam("offset", -1).get(url).then().statusCode(400); + given().queryParam("limit", -1).get(url).then().statusCode(400); + given().queryParam("tail", -1).get(url).then().statusCode(400); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void signedInLogReadIsNotCapped() { + Seeded s = seed(true, "0123456789"); + ProjectPageResource resource = io.quarkus.arc.ClientProxy.unwrap(pages); + long original = resource.publicLogMaxBytes; + resource.publicLogMaxBytes = 5; + try { + given() + .get("/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log") + .then() + .statusCode(200) + .header("X-Log-Size", "10") + .body(equalTo("0123456789")); + } finally { + resource.publicLogMaxBytes = original; } + } + + @Test + void anonymousCannotReadPrivateBuildLog() { + Seeded s = seed(true); + given() + .get("/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log") + .then() + .statusCode(404); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerReadsPrivateBuildLog() { + Seeded s = seed(true); + given() + .get("/api/project-pages/" + s.name() + "/builds/" + s.buildId() + "/log") + .then() + .statusCode(200) + .body(equalTo("hello")); + } + + @Test + void logOfAnotherProjectsBuildIsNotFound() { + Seeded page = seed(false), other = seed(false); + given() + .get("/api/project-pages/" + page.name() + "/builds/" + other.buildId() + "/log") + .then() + .statusCode(404); + } + + @Test + void anonymousCannotSeePrivateProject() { + given().get("/api/project-pages/" + project(true)).then().statusCode(404); + } + + @Test + void unknownProjectIsNotFound() { + given().get("/api/project-pages/does-not-exist-" + UUID.randomUUID()).then().statusCode(404); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void otherMemberCannotSeePrivateProject() { + given().get("/api/project-pages/" + project(true)).then().statusCode(404); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerSeesPrivateProject() { + given() + .get("/api/project-pages/" + project(true)) + .then() + .statusCode(200) + .body("builds", hasSize(1)); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) + void adminSeesPrivateProject() { + given() + .get("/api/project-pages/" + project(true)) + .then() + .statusCode(200) + .body("builds[0].canPin", equalTo(true)); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/ProjectResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/ProjectResourceTest.java index 6d7a547..f8f6b70 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/ProjectResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/ProjectResourceTest.java @@ -8,192 +8,423 @@ package ai.mnemosyne.cisys; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; + import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import io.quarkus.test.security.jwt.Claim; import io.quarkus.test.security.jwt.JwtSecurity; import org.junit.jupiter.api.Test; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; @QuarkusTest class ProjectResourceTest { - @Test void anonymousCannotListProjects() { - given().when().get("/api/projects").then().statusCode(401); - } - @Test @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = {@Claim(key = "sub", value = "member-id")}) - void memberCannotCreateProject() { - given().contentType("application/json").body("{\"name\":\"demo\",\"repositoryUrl\":\"https://github.com/repo.git\"}") - .when().post("/api/projects").then().statusCode(403); - } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void leadCreatesPrivateProjectWithSubjectOwner() { - given().contentType("application/json").body("{\"name\":\"demo\",\"repositoryUrl\":\"https://github.com/repo.git\",\"privateRepository\":true}") - .when().post("/api/projects").then().statusCode(201) - .body("owned", equalTo(true), "privateRepository", equalTo(true), "$", not(hasKey("ownerSubject"))); - assertEquals("lead-id", QuarkusTransaction.requiringNew().call(() -> Project.find("name", "demo").firstResult().ownerSubject)); - } - @Test @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = {@Claim(key = "sub", value = "member-id")}) - void memberListHidesOwnerSubject() { - Long id = project("lead-id", false); - given().when().get("/api/projects").then().statusCode(200) - .body(inList(id) + ".owned", equalTo(false), inList(id), not(hasKey("ownerSubject")), "ownerSubject", everyItem(nullValue())); - } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void leadListMarksOwnProjects() { - Long own = project("lead-id", true), other = project("other-id", false); - given().when().get("/api/projects").then().statusCode(200) - .body(inList(own) + ".owned", equalTo(true), inList(other) + ".owned", equalTo(false), - "ownerSubject", everyItem(nullValue())); - } - @Test @TestSecurity(user = "admin", roles = "admin") - @JwtSecurity(claims = {@Claim(key = "sub", value = "admin-id")}) - void adminSeesOwnerSubject() { - Long id = project("lead-id", true); - given().when().get("/api/projects").then().statusCode(200) - .body(inList(id) + ".ownerSubject", equalTo("lead-id"), inList(id) + ".owned", equalTo(false)); - given().contentType("application/json").body("{\"name\":\"admin-owned\",\"repositoryUrl\":\"https://github.com/admin-owned.git\"}") - .when().post("/api/projects").then().statusCode(201).body("ownerSubject", equalTo("admin-id"), "owned", equalTo(true)); - } - private static String inList(Long id) { - return "find { it.id == " + id + " }"; - } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void rejectsDuplicateNameOrRepositoryUrl() { - given().contentType("application/json").body("{\"name\":\"unique-one\",\"repositoryUrl\":\"https://github.com/unique-one.git\"}") - .when().post("/api/projects").then().statusCode(201); - given().contentType("application/json").body("{\"name\":\" Unique-One \",\"repositoryUrl\":\"https://github.com/other.git\"}") - .when().post("/api/projects").then().statusCode(409).body("error", containsString("name")); - given().contentType("application/json").body("{\"name\":\"unique-two\",\"repositoryUrl\":\"https://github.com/unique-one.git\"}") - .when().post("/api/projects").then().statusCode(409).body("error", containsString("repository URL")); - } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void rejectsInvalidNames() { - for (String name : new String[] {"org/repo", ".", "..", ".hidden", "-x", "_x", "a b", "a?b", "a#b", "a%20b", "a\\nb", "a\\u200Bb", "c\\u0456sys", "\\u00fcn"}) { - given().contentType("application/json").body("{\"name\":\"" + name + "\",\"repositoryUrl\":\"https://github.com/invalid-name.git\"}") - .when().post("/api/projects").then().statusCode(400).body("error", equalTo(ProjectResource.NAME_RULE)); - } - } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void acceptsSlugNames() { - for (String name : new String[] {"slug", "My.Project_2-x", "9lives", "a".repeat(100)}) { - given().contentType("application/json").body("{\"name\":\"" + name + "\",\"repositoryUrl\":\"https://github.com/slug-" + name + ".git\"}") - .when().post("/api/projects").then().statusCode(201); - } - given().urlEncodingEnabled(false).when().get("/api/project-pages/My.Project_2-x").then().statusCode(200).body("project.name", equalTo("My.Project_2-x")); - given().contentType("application/json").body("{\"name\":\"" + "a".repeat(101) + "\",\"repositoryUrl\":\"https://github.com/too-long.git\"}") - .when().post("/api/projects").then().statusCode(400); - } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void rejectsHostThatIsNotAllowed() { - given().contentType("application/json").body("{\"name\":\"not-allowed\",\"repositoryUrl\":\"https://example.com/repo.git\"}") - .when().post("/api/projects").then().statusCode(400).body(containsString("allowed host")); - } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void rejectsHostThatResolvesToABlockedAddress() { - TestHostResolver.set("gitlab.com", "10.0.0.1"); - try { - given().contentType("application/json").body("{\"name\":\"blocked-address\",\"repositoryUrl\":\"https://gitlab.com/group/repo.git\"}") - .when().post("/api/projects").then().statusCode(400).body(containsString("blocked address")); - } finally { - TestHostResolver.reset(); - } + @Test + void anonymousCannotListProjects() { + given().when().get("/api/projects").then().statusCode(401); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = {@Claim(key = "sub", value = "member-id")}) + void memberCannotCreateProject() { + given() + .contentType("application/json") + .body("{\"name\":\"demo\",\"repositoryUrl\":\"https://github.com/repo.git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void leadCreatesPrivateProjectWithSubjectOwner() { + given() + .contentType("application/json") + .body( + "{\"name\":\"demo\",\"repositoryUrl\":\"https://github.com/repo.git\",\"privateRepository\":true}") + .when() + .post("/api/projects") + .then() + .statusCode(201) + .body( + "owned", + equalTo(true), + "privateRepository", + equalTo(true), + "$", + not(hasKey("ownerSubject"))); + assertEquals( + "lead-id", + QuarkusTransaction.requiringNew() + .call(() -> Project.find("name", "demo").firstResult().ownerSubject)); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = {@Claim(key = "sub", value = "member-id")}) + void memberListHidesOwnerSubject() { + Long id = project("lead-id", false); + given() + .when() + .get("/api/projects") + .then() + .statusCode(200) + .body( + inList(id) + ".owned", + equalTo(false), + inList(id), + not(hasKey("ownerSubject")), + "ownerSubject", + everyItem(nullValue())); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void leadListMarksOwnProjects() { + Long own = project("lead-id", true), other = project("other-id", false); + given() + .when() + .get("/api/projects") + .then() + .statusCode(200) + .body( + inList(own) + ".owned", + equalTo(true), + inList(other) + ".owned", + equalTo(false), + "ownerSubject", + everyItem(nullValue())); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = {@Claim(key = "sub", value = "admin-id")}) + void adminSeesOwnerSubject() { + Long id = project("lead-id", true); + given() + .when() + .get("/api/projects") + .then() + .statusCode(200) + .body( + inList(id) + ".ownerSubject", + equalTo("lead-id"), + inList(id) + ".owned", + equalTo(false)); + given() + .contentType("application/json") + .body("{\"name\":\"admin-owned\",\"repositoryUrl\":\"https://github.com/admin-owned.git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(201) + .body("ownerSubject", equalTo("admin-id"), "owned", equalTo(true)); + } + + private static String inList(Long id) { + return "find { it.id == " + id + " }"; + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void rejectsDuplicateNameOrRepositoryUrl() { + given() + .contentType("application/json") + .body("{\"name\":\"unique-one\",\"repositoryUrl\":\"https://github.com/unique-one.git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(201); + given() + .contentType("application/json") + .body("{\"name\":\" Unique-One \",\"repositoryUrl\":\"https://github.com/other.git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(409) + .body("error", containsString("name")); + given() + .contentType("application/json") + .body("{\"name\":\"unique-two\",\"repositoryUrl\":\"https://github.com/unique-one.git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(409) + .body("error", containsString("repository URL")); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void rejectsInvalidNames() { + for (String name : + new String[] { + "org/repo", + ".", + "..", + ".hidden", + "-x", + "_x", + "a b", + "a?b", + "a#b", + "a%20b", + "a\\nb", + "a\\u200Bb", + "c\\u0456sys", + "\\u00fcn" + }) { + given() + .contentType("application/json") + .body( + "{\"name\":\"" + + name + + "\",\"repositoryUrl\":\"https://github.com/invalid-name.git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(400) + .body("error", equalTo(ProjectResource.NAME_RULE)); } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void addsGitSuffix() { - given().contentType("application/json").body("{\"name\":\"suffix-added\",\"repositoryUrl\":\"https://github.com/org/suffix-added/\"}") - .when().post("/api/projects").then().statusCode(201).body("repositoryUrl", equalTo("https://github.com/org/suffix-added.git")); - given().contentType("application/json").body("{\"name\":\"suffix-kept\",\"repositoryUrl\":\"https://github.com/org/suffix-kept.git\"}") - .when().post("/api/projects").then().statusCode(201).body("repositoryUrl", equalTo("https://github.com/org/suffix-kept.git")); - // The same repository with and without .git is a duplicate. - given().contentType("application/json").body("{\"name\":\"suffix-again\",\"repositoryUrl\":\"https://github.com/org/suffix-kept\"}") - .when().post("/api/projects").then().statusCode(409); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void acceptsSlugNames() { + for (String name : new String[] {"slug", "My.Project_2-x", "9lives", "a".repeat(100)}) { + given() + .contentType("application/json") + .body( + "{\"name\":\"" + + name + + "\",\"repositoryUrl\":\"https://github.com/slug-" + + name + + ".git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(201); } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void rejectsUrlsWithoutRepository() { - for (String url : new String[] {"https://example.com/", "https://example.com", "https://example.com/.git", "https://github.com/repo.git?x=1", "https://example.com/repo#top"}) { - given().contentType("application/json").body("{\"name\":\"suffix\",\"repositoryUrl\":\"" + url + "\"}") - .when().post("/api/projects").then().statusCode(400).body("error", containsString("repository's URL")); - } + given() + .urlEncodingEnabled(false) + .when() + .get("/api/project-pages/My.Project_2-x") + .then() + .statusCode(200) + .body("project.name", equalTo("My.Project_2-x")); + given() + .contentType("application/json") + .body( + "{\"name\":\"" + + "a".repeat(101) + + "\",\"repositoryUrl\":\"https://github.com/too-long.git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(400); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void rejectsHostThatIsNotAllowed() { + given() + .contentType("application/json") + .body("{\"name\":\"not-allowed\",\"repositoryUrl\":\"https://example.com/repo.git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(400) + .body(containsString("allowed host")); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void rejectsHostThatResolvesToABlockedAddress() { + TestHostResolver.set("gitlab.com", "10.0.0.1"); + try { + given() + .contentType("application/json") + .body( + "{\"name\":\"blocked-address\",\"repositoryUrl\":\"https://gitlab.com/group/repo.git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(400) + .body(containsString("blocked address")); + } finally { + TestHostResolver.reset(); } - @Test @TestSecurity(user = "outsider", roles = "unrelated") - @JwtSecurity(claims = {@Claim(key = "sub", value = "outsider-id")}) - void unrelatedRoleCannotReadProjects() { - given().when().get("/api/projects").then().statusCode(403); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void addsGitSuffix() { + given() + .contentType("application/json") + .body( + "{\"name\":\"suffix-added\",\"repositoryUrl\":\"https://github.com/org/suffix-added/\"}") + .when() + .post("/api/projects") + .then() + .statusCode(201) + .body("repositoryUrl", equalTo("https://github.com/org/suffix-added.git")); + given() + .contentType("application/json") + .body( + "{\"name\":\"suffix-kept\",\"repositoryUrl\":\"https://github.com/org/suffix-kept.git\"}") + .when() + .post("/api/projects") + .then() + .statusCode(201) + .body("repositoryUrl", equalTo("https://github.com/org/suffix-kept.git")); + // The same repository with and without .git is a duplicate. + given() + .contentType("application/json") + .body( + "{\"name\":\"suffix-again\",\"repositoryUrl\":\"https://github.com/org/suffix-kept\"}") + .when() + .post("/api/projects") + .then() + .statusCode(409); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void rejectsUrlsWithoutRepository() { + for (String url : + new String[] { + "https://example.com/", + "https://example.com", + "https://example.com/.git", + "https://github.com/repo.git?x=1", + "https://example.com/repo#top" + }) { + given() + .contentType("application/json") + .body("{\"name\":\"suffix\",\"repositoryUrl\":\"" + url + "\"}") + .when() + .post("/api/projects") + .then() + .statusCode(400) + .body("error", containsString("repository's URL")); } - private static Long project(String owner, boolean priv) { - return QuarkusTransaction.requiringNew().call(() -> { - Project p = new Project(); String n = java.util.UUID.randomUUID().toString(); - p.name = "delete-" + n; p.repositoryUrl = "https://example.com/delete-" + n; p.ownerSubject = owner; p.privateRepository = priv; - p.persist(); return p.id; + } + + @Test + @TestSecurity(user = "outsider", roles = "unrelated") + @JwtSecurity(claims = {@Claim(key = "sub", value = "outsider-id")}) + void unrelatedRoleCannotReadProjects() { + given().when().get("/api/projects").then().statusCode(403); + } + + private static Long project(String owner, boolean priv) { + return QuarkusTransaction.requiringNew() + .call(() -> { + Project p = new Project(); + String n = java.util.UUID.randomUUID().toString(); + p.name = "delete-" + n; + p.repositoryUrl = "https://example.com/delete-" + n; + p.ownerSubject = owner; + p.privateRepository = priv; + p.persist(); + return p.id; }); - } - private static Long build(Long project, String status) { - return QuarkusTransaction.requiringNew().call(() -> { - Build b = new Build(); b.projectId = project; b.requestedBy = "lead-id"; b.status=Build.Status.valueOf(status); b.branch = "main"; - b.persist(); return b.id; + } + + private static Long build(Long project, String status) { + return QuarkusTransaction.requiringNew() + .call(() -> { + Build b = new Build(); + b.projectId = project; + b.requestedBy = "lead-id"; + b.status = Build.Status.valueOf(status); + b.branch = "main"; + b.persist(); + return b.id; }); + } + + private static boolean exists(Long project) { + return QuarkusTransaction.requiringNew().call(() -> Project.findById(project) != null); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void ownerDeletesProjectAndItsBuilds() { + Long id = project("lead-id", false); + Long b = build(id, "PASSED"); + given().when().delete("/api/projects/" + id).then().statusCode(204); + assertFalse(exists(id)); + assertNull(QuarkusTransaction.requiringNew().call(() -> Build.findById(b))); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) + void leadCannotDeleteOthersProjects() { + Long open = project("other-id", false), hidden = project("other-id", true); + given().when().delete("/api/projects/" + open).then().statusCode(403); + given().when().delete("/api/projects/" + hidden).then().statusCode(404); + assertTrue(exists(open)); + assertTrue(exists(hidden)); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = {@Claim(key = "sub", value = "member-id")}) + void memberCannotDeleteProject() { + Long id = project("member-id", false); + given().when().delete("/api/projects/" + id).then().statusCode(403); + assertTrue(exists(id)); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = {@Claim(key = "sub", value = "admin-id")}) + void queuedBuildPreventsProjectDeletion() { + Long id = project("other-id", true), queued = build(id, "QUEUED"); + try { + given() + .delete("/api/projects/" + id) + .then() + .statusCode(409) + .body("error", containsString("queued")); + assertTrue(exists(id)); + } finally { + QuarkusTransaction.requiringNew().run(() -> Build.deleteById(queued)); } - private static boolean exists(Long project) { - return QuarkusTransaction.requiringNew().call(() -> Project.findById(project) != null); - } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void ownerDeletesProjectAndItsBuilds() { - Long id = project("lead-id", false); - Long b = build(id, "PASSED"); - given().when().delete("/api/projects/" + id).then().statusCode(204); - assertFalse(exists(id)); - assertNull(QuarkusTransaction.requiringNew().call(() -> Build.findById(b))); - } - @Test @TestSecurity(user = "lead", roles = "lead") - @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) - void leadCannotDeleteOthersProjects() { - Long open = project("other-id", false), hidden = project("other-id", true); - given().when().delete("/api/projects/" + open).then().statusCode(403); - given().when().delete("/api/projects/" + hidden).then().statusCode(404); - assertTrue(exists(open)); - assertTrue(exists(hidden)); - } - @Test @TestSecurity(user = "member", roles = "member") - @JwtSecurity(claims = {@Claim(key = "sub", value = "member-id")}) - void memberCannotDeleteProject() { - Long id = project("member-id", false); - given().when().delete("/api/projects/" + id).then().statusCode(403); - assertTrue(exists(id)); - } - @Test @TestSecurity(user = "admin", roles = "admin") - @JwtSecurity(claims = {@Claim(key = "sub", value = "admin-id")}) - void queuedBuildPreventsProjectDeletion() { - Long id=project("other-id",true),queued=build(id,"QUEUED"); - try { - given().delete("/api/projects/"+id).then().statusCode(409).body("error",containsString("queued")); - assertTrue(exists(id)); - } finally {QuarkusTransaction.requiringNew().run(()->Build.deleteById(queued));} - } - @Test @TestSecurity(user = "admin", roles = "admin") - @JwtSecurity(claims = {@Claim(key = "sub", value = "admin-id")}) - void adminDeletesAnyProjectButNotWhileBuilding() { - Long id = project("other-id", true); - Long running = build(id, "RUNNING"); - given().when().delete("/api/projects/" + id).then().statusCode(409).body("error", containsString("running")); - assertTrue(exists(id)); - QuarkusTransaction.requiringNew().run(() -> Build.findById(running).status=Build.Status.PASSED); - given().when().delete("/api/projects/" + id).then().statusCode(204); - assertFalse(exists(id)); - given().when().delete("/api/projects/" + id).then().statusCode(404); - } + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = {@Claim(key = "sub", value = "admin-id")}) + void adminDeletesAnyProjectButNotWhileBuilding() { + Long id = project("other-id", true); + Long running = build(id, "RUNNING"); + given() + .when() + .delete("/api/projects/" + id) + .then() + .statusCode(409) + .body("error", containsString("running")); + assertTrue(exists(id)); + QuarkusTransaction.requiringNew() + .run(() -> Build.findById(running).status = Build.Status.PASSED); + given().when().delete("/api/projects/" + id).then().statusCode(204); + assertFalse(exists(id)); + given().when().delete("/api/projects/" + id).then().statusCode(404); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/PublicRateLimitTest.java b/src/backend/test/java/ai/mnemosyne/cisys/PublicRateLimitTest.java index 1c85b40..9d44a35 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/PublicRateLimitTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/PublicRateLimitTest.java @@ -8,87 +8,91 @@ package ai.mnemosyne.cisys; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import jakarta.inject.Inject; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.notNullValue; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; @QuarkusTest class PublicRateLimitTest { - @Inject PublicRateLimiter limiter; - int requests, windowSeconds, maxClients; + @Inject PublicRateLimiter limiter; + int requests, windowSeconds, maxClients; - @BeforeEach - void save() { - limiter = io.quarkus.arc.ClientProxy.unwrap(limiter); - requests = limiter.requests; - windowSeconds = limiter.windowSeconds; - maxClients = limiter.maxClients; - limiter.reset(); - } + @BeforeEach + void save() { + limiter = io.quarkus.arc.ClientProxy.unwrap(limiter); + requests = limiter.requests; + windowSeconds = limiter.windowSeconds; + maxClients = limiter.maxClients; + limiter.reset(); + } - @AfterEach - void restore() { - limiter.requests = requests; - limiter.windowSeconds = windowSeconds; - limiter.maxClients = maxClients; - limiter.reset(); - } + @AfterEach + void restore() { + limiter.requests = requests; + limiter.windowSeconds = windowSeconds; + limiter.maxClients = maxClients; + limiter.reset(); + } - @Test - void anonymousRequestsAreLimited() { - limiter.requests = 2; - limiter.windowSeconds = 60; - given().get("/api/stats/public").then().statusCode(200); - given().get("/api/stats/public").then().statusCode(200); - given().get("/api/stats/public").then().statusCode(429) - .header("Retry-After", notNullValue()) - .body("error", equalTo("Too many requests. Try again later.")); - } + @Test + void anonymousRequestsAreLimited() { + limiter.requests = 2; + limiter.windowSeconds = 60; + given().get("/api/stats/public").then().statusCode(200); + given().get("/api/stats/public").then().statusCode(200); + given() + .get("/api/stats/public") + .then() + .statusCode(429) + .header("Retry-After", notNullValue()) + .body("error", equalTo("Too many requests. Try again later.")); + } - @Test - void projectPagesShareTheLimit() { - limiter.requests = 1; - limiter.windowSeconds = 60; - given().get("/api/project-pages/does-not-exist").then().statusCode(404); - given().get("/api/project-pages/does-not-exist").then().statusCode(429); - } + @Test + void projectPagesShareTheLimit() { + limiter.requests = 1; + limiter.windowSeconds = 60; + given().get("/api/project-pages/does-not-exist").then().statusCode(404); + given().get("/api/project-pages/does-not-exist").then().statusCode(429); + } - @Test - void branchListingSharesTheLimit() { - limiter.requests = 1; - limiter.windowSeconds = 60; - given().get("/api/project-pages/does-not-exist/branches").then().statusCode(404); - given().get("/api/project-pages/does-not-exist/branches").then().statusCode(429); - } + @Test + void branchListingSharesTheLimit() { + limiter.requests = 1; + limiter.windowSeconds = 60; + given().get("/api/project-pages/does-not-exist/branches").then().statusCode(404); + given().get("/api/project-pages/does-not-exist/branches").then().statusCode(429); + } - @Test - @TestSecurity(user = "member", roles = "member") - void signedInRequestsAreNotLimited() { - limiter.requests = 1; - limiter.windowSeconds = 60; - for (int i = 0; i < 5; i++) given().get("/api/stats/public").then().statusCode(200); - } + @Test + @TestSecurity(user = "member", roles = "member") + void signedInRequestsAreNotLimited() { + limiter.requests = 1; + limiter.windowSeconds = 60; + for (int i = 0; i < 5; i++) given().get("/api/stats/public").then().statusCode(200); + } - @Test - void aFullClientMapEvictsAWindowInsteadOfRejectingANewClient() { - limiter.requests = 1; - limiter.windowSeconds = 60; - limiter.maxClients = 2; - long now = 1_000L; - assertEquals(0, limiter.acquire("first", now)); - assertEquals(0, limiter.acquire("second", now + 1)); - // A known client is still limited while its window is live. - assertTrue(limiter.acquire("second", now + 2) > 0); - // The map is full, so a live window gives way to a new visitor instead of locking it out. - assertEquals(0, limiter.acquire("third", now + 3)); - assertTrue(limiter.acquire("third", now + 4) > 0); - } + @Test + void aFullClientMapEvictsAWindowInsteadOfRejectingANewClient() { + limiter.requests = 1; + limiter.windowSeconds = 60; + limiter.maxClients = 2; + long now = 1_000L; + assertEquals(0, limiter.acquire("first", now)); + assertEquals(0, limiter.acquire("second", now + 1)); + // A known client is still limited while its window is live. + assertTrue(limiter.acquire("second", now + 2) > 0); + // The map is full, so a live window gives way to a new visitor instead of locking it out. + assertEquals(0, limiter.acquire("third", now + 3)); + assertTrue(limiter.acquire("third", now + 4) > 0); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/RepositoryUrlValidatorTest.java b/src/backend/test/java/ai/mnemosyne/cisys/RepositoryUrlValidatorTest.java index 8f75c52..843be3f 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/RepositoryUrlValidatorTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/RepositoryUrlValidatorTest.java @@ -8,123 +8,180 @@ package ai.mnemosyne.cisys; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + import java.net.InetAddress; import java.net.UnknownHostException; import java.util.List; import java.util.Set; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; class RepositoryUrlValidatorTest { - private static final String REPOSITORY = "https://github.com/octocat/Hello-World.git"; - private static final Set HOSTS = RepositoryUrls.parseHosts("github.com,gitlab.com"); - - private static HostResolver resolved(String... addresses) { - return host -> { - InetAddress[] result = new InetAddress[addresses.length]; - for (int i = 0; i < addresses.length; i++) result[i] = InetAddress.getByName(addresses[i]); - return result; - }; - } - - private static HostResolver unknown() { - return host -> { throw new UnknownHostException(host); }; - } - - private static IllegalArgumentException rejected(String url, List networks, - HostResolver resolver) { - return assertThrows(IllegalArgumentException.class, - () -> RepositoryUrls.validate(url, HOSTS, networks, resolver)); - } - - @Test - void acceptsPublicRepositories() { - RepositoryUrls.validate(REPOSITORY, HOSTS, List.of(), resolved("140.82.112.3")); - RepositoryUrls.validate("https://gitlab.com/group/project.git", HOSTS, List.of(), resolved("172.65.251.78")); - RepositoryUrls.validate("https://github.com:443/octocat/Hello-World.git", HOSTS, List.of(), resolved("140.82.112.3")); + private static final String REPOSITORY = "https://github.com/octocat/Hello-World.git"; + private static final Set HOSTS = RepositoryUrls.parseHosts("github.com,gitlab.com"); + + private static HostResolver resolved(String... addresses) { + return host -> { + InetAddress[] result = new InetAddress[addresses.length]; + for (int i = 0; i < addresses.length; i++) result[i] = InetAddress.getByName(addresses[i]); + return result; + }; + } + + private static HostResolver unknown() { + return host -> { + throw new UnknownHostException(host); + }; + } + + private static IllegalArgumentException rejected( + String url, List networks, HostResolver resolver) { + return assertThrows( + IllegalArgumentException.class, + () -> RepositoryUrls.validate(url, HOSTS, networks, resolver)); + } + + @Test + void acceptsPublicRepositories() { + RepositoryUrls.validate(REPOSITORY, HOSTS, List.of(), resolved("140.82.112.3")); + RepositoryUrls.validate( + "https://gitlab.com/group/project.git", HOSTS, List.of(), resolved("172.65.251.78")); + RepositoryUrls.validate( + "https://github.com:443/octocat/Hello-World.git", + HOSTS, + List.of(), + resolved("140.82.112.3")); + } + + @Test + void rejectsBlockedAddresses() { + for (String address : + List.of( + "127.0.0.1", + "10.0.0.5", + "10.255.255.255", + "172.16.0.1", + "172.31.255.255", + "192.168.1.10", + "169.254.169.254", + "100.64.0.1", + "0.0.0.0", + "224.0.0.1", + "240.0.0.1", + "192.0.2.10", + "198.51.100.10", + "203.0.113.10", + "::1", + "fe80::1", + "fc00::1", + "ff02::1", + "2001:db8::1", + "::ffff:127.0.0.1", + "::ffff:10.0.0.1")) { + assertEquals( + RepositoryUrls.BLOCKED, + rejected(REPOSITORY, List.of(), resolved(address)).getMessage(), + address); } - - @Test - void rejectsBlockedAddresses() { - for (String address : List.of( - "127.0.0.1", "10.0.0.5", "10.255.255.255", "172.16.0.1", "172.31.255.255", - "192.168.1.10", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", - "240.0.0.1", "192.0.2.10", "198.51.100.10", "203.0.113.10", - "::1", "fe80::1", "fc00::1", "ff02::1", "2001:db8::1", - "::ffff:127.0.0.1", "::ffff:10.0.0.1")) { - assertEquals(RepositoryUrls.BLOCKED, rejected(REPOSITORY, List.of(), resolved(address)).getMessage(), address); - } - } - - @Test - void rejectsWhenAnyAddressIsBlocked() { - assertEquals(RepositoryUrls.BLOCKED, - rejected(REPOSITORY, List.of(), resolved("140.82.112.3", "127.0.0.1")).getMessage()); - } - - @Test - void rejectsUnknownHosts() { - assertEquals(RepositoryUrls.UNKNOWN, rejected(REPOSITORY, List.of(), unknown()).getMessage()); - assertEquals(RepositoryUrls.UNKNOWN, rejected(REPOSITORY, List.of(), host -> new InetAddress[0]).getMessage()); - } - - @Test - void rejectsHostsThatAreNotAllowed() { - HostResolver mustNotRun = host -> { throw new AssertionError("resolved " + host); }; - IllegalArgumentException e = rejected("https://example.com/octocat/Hello-World.git", List.of(), mustNotRun); - assertEquals(RepositoryUrls.hostMessage(HOSTS), e.getMessage()); - assertTrue(e.getMessage().contains("github.com")); - assertTrue(e.getMessage().contains("gitlab.com")); - } - - @Test - void rejectsMalformedUrls() { - for (String url : List.of( - "http://github.com/octocat/Hello-World.git", - "https://user:token@github.com/octocat/Hello-World.git", - "https://github.com/octocat/Hello-World.git?ref=main", - "https://github.com/octocat/Hello-World.git#main", - "https://github.com:8443/octocat/Hello-World.git", - "https://github.com", "https://github.com/", "not a url")) { - assertEquals(RepositoryUrls.SHAPE, rejected(url, List.of(), resolved("140.82.112.3")).getMessage(), url); - } - } - - @Test - void rejectsMissingAndOverlongUrls() { - for (String url : new String[] {null, "", " ", "https://github.com/" + "a".repeat(2048)}) { - assertEquals(RepositoryUrls.REQUIRED, rejected(url, List.of(), resolved("140.82.112.3")).getMessage()); - } - } - - @Test - void allowedNetworksOverrideTheBlock() { - List networks = RepositoryUrls.parseNetworks("10.0.0.0/8,2001:db8::/32"); - RepositoryUrls.validate(REPOSITORY, HOSTS, networks, resolved("10.1.2.3")); - RepositoryUrls.validate(REPOSITORY, HOSTS, networks, resolved("2001:db8::5")); - assertEquals(RepositoryUrls.BLOCKED, - rejected(REPOSITORY, RepositoryUrls.parseNetworks("10.0.0.0/16"), resolved("10.1.2.3")).getMessage()); - assertEquals(RepositoryUrls.BLOCKED, - rejected(REPOSITORY, RepositoryUrls.parseNetworks("2001:db8::/64"), resolved("2001:db8:1::5")).getMessage()); + } + + @Test + void rejectsWhenAnyAddressIsBlocked() { + assertEquals( + RepositoryUrls.BLOCKED, + rejected(REPOSITORY, List.of(), resolved("140.82.112.3", "127.0.0.1")).getMessage()); + } + + @Test + void rejectsUnknownHosts() { + assertEquals(RepositoryUrls.UNKNOWN, rejected(REPOSITORY, List.of(), unknown()).getMessage()); + assertEquals( + RepositoryUrls.UNKNOWN, + rejected(REPOSITORY, List.of(), host -> new InetAddress[0]).getMessage()); + } + + @Test + void rejectsHostsThatAreNotAllowed() { + HostResolver mustNotRun = + host -> { + throw new AssertionError("resolved " + host); + }; + IllegalArgumentException e = + rejected("https://example.com/octocat/Hello-World.git", List.of(), mustNotRun); + assertEquals(RepositoryUrls.hostMessage(HOSTS), e.getMessage()); + assertTrue(e.getMessage().contains("github.com")); + assertTrue(e.getMessage().contains("gitlab.com")); + } + + @Test + void rejectsMalformedUrls() { + for (String url : + List.of( + "http://github.com/octocat/Hello-World.git", + "https://user:token@github.com/octocat/Hello-World.git", + "https://github.com/octocat/Hello-World.git?ref=main", + "https://github.com/octocat/Hello-World.git#main", + "https://github.com:8443/octocat/Hello-World.git", + "https://github.com", + "https://github.com/", + "not a url")) { + assertEquals( + RepositoryUrls.SHAPE, + rejected(url, List.of(), resolved("140.82.112.3")).getMessage(), + url); } + } - @Test - void parsesHostListsAndNetworks() { - assertEquals(List.of("github.com", "gitlab.com"), List.copyOf(RepositoryUrls.parseHosts(" GitHub.com , gitlab.com "))); - assertTrue(RepositoryUrls.parseHosts("").isEmpty()); - assertEquals("Use a repository on an allowed host (github.com)", - RepositoryUrls.hostMessage(RepositoryUrls.parseHosts("github.com"))); - for (String value : new String[] {"10.0.0.0", "10.0.0.0/33", "x/8", "10.0.0.0/"}) { - assertThrows(IllegalArgumentException.class, () -> RepositoryUrls.Cidr.parse(value), value); - } + @Test + void rejectsMissingAndOverlongUrls() { + for (String url : new String[] {null, "", " ", "https://github.com/" + "a".repeat(2048)}) { + assertEquals( + RepositoryUrls.REQUIRED, rejected(url, List.of(), resolved("140.82.112.3")).getMessage()); } - - @Test - void unwrapsIpv4MappedAddresses() { - assertEquals(4, RepositoryUrls.unwrap(new byte[] {0,0,0,0,0,0,0,0,0,0,(byte)0xFF,(byte)0xFF,10,0,0,1}).length); - byte[] ipv6 = new byte[] {0x20,0x01,0x0d,(byte)0xb8,0,0,0,0,0,0,0,0,0,0,0,1}; - assertEquals(16, RepositoryUrls.unwrap(ipv6).length); + } + + @Test + void allowedNetworksOverrideTheBlock() { + List networks = RepositoryUrls.parseNetworks("10.0.0.0/8,2001:db8::/32"); + RepositoryUrls.validate(REPOSITORY, HOSTS, networks, resolved("10.1.2.3")); + RepositoryUrls.validate(REPOSITORY, HOSTS, networks, resolved("2001:db8::5")); + assertEquals( + RepositoryUrls.BLOCKED, + rejected(REPOSITORY, RepositoryUrls.parseNetworks("10.0.0.0/16"), resolved("10.1.2.3")) + .getMessage()); + assertEquals( + RepositoryUrls.BLOCKED, + rejected( + REPOSITORY, + RepositoryUrls.parseNetworks("2001:db8::/64"), + resolved("2001:db8:1::5")) + .getMessage()); + } + + @Test + void parsesHostListsAndNetworks() { + assertEquals( + List.of("github.com", "gitlab.com"), + List.copyOf(RepositoryUrls.parseHosts(" GitHub.com , gitlab.com "))); + assertTrue(RepositoryUrls.parseHosts("").isEmpty()); + assertEquals( + "Use a repository on an allowed host (github.com)", + RepositoryUrls.hostMessage(RepositoryUrls.parseHosts("github.com"))); + for (String value : new String[] {"10.0.0.0", "10.0.0.0/33", "x/8", "10.0.0.0/"}) { + assertThrows(IllegalArgumentException.class, () -> RepositoryUrls.Cidr.parse(value), value); } + } + + @Test + void unwrapsIpv4MappedAddresses() { + assertEquals( + 4, + RepositoryUrls.unwrap( + new byte[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (byte) 0xFF, (byte) 0xFF, 10, 0, 0, 1}) + .length); + byte[] ipv6 = new byte[] {0x20, 0x01, 0x0d, (byte) 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; + assertEquals(16, RepositoryUrls.unwrap(ipv6).length); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/RetentionTest.java b/src/backend/test/java/ai/mnemosyne/cisys/RetentionTest.java index 66353d3..99ac946 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/RetentionTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/RetentionTest.java @@ -8,6 +8,8 @@ package ai.mnemosyne.cisys; +import static org.junit.jupiter.api.Assertions.*; + import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; @@ -17,152 +19,175 @@ import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; @QuarkusTest class RetentionTest { - @org.junit.jupiter.api.AfterEach void releaseSyntheticRunningBuilds(){ - QuarkusTransaction.requiringNew().run(()->Build.update("status = ?1 where status = ?2 and repositoryUrl is null",Build.Status.FAILED,Build.Status.RUNNING)); - } - - @Inject BuildRetention retention; - @Inject BuildLogs logs; - - private final Instant now = Instant.now(); - - private static Long project() { - return QuarkusTransaction.requiringNew().call(() -> { - String n = UUID.randomUUID().toString(); - Project p = new Project(); - p.name = "retention-" + n; - p.repositoryUrl = "https://github.com/octocat/retention-" + n + ".git"; - p.ownerSubject = "owner-id"; - p.persist(); - return p.id; + @org.junit.jupiter.api.AfterEach + void releaseSyntheticRunningBuilds() { + QuarkusTransaction.requiringNew() + .run(() -> + Build.update( + "status = ?1 where status = ?2 and repositoryUrl is null", + Build.Status.FAILED, + Build.Status.RUNNING)); + } + + @Inject BuildRetention retention; + @Inject BuildLogs logs; + + private final Instant now = Instant.now(); + + private static Long project() { + return QuarkusTransaction.requiringNew() + .call(() -> { + String n = UUID.randomUUID().toString(); + Project p = new Project(); + p.name = "retention-" + n; + p.repositoryUrl = "https://github.com/octocat/retention-" + n + ".git"; + p.ownerSubject = "owner-id"; + p.persist(); + return p.id; }); - } - - private static Long build(Long project, String status, Instant finishedAt, boolean pinned) { - return QuarkusTransaction.requiringNew().call(() -> { - Build b = new Build(); - b.projectId = project; - b.status=Build.Status.valueOf(status); - b.startedAt = (finishedAt == null ? Instant.now() : finishedAt).minusSeconds(60); - b.finishedAt = finishedAt; - b.pinned = pinned; - b.persist(); - return b.id; + } + + private static Long build(Long project, String status, Instant finishedAt, boolean pinned) { + return QuarkusTransaction.requiringNew() + .call(() -> { + Build b = new Build(); + b.projectId = project; + b.status = Build.Status.valueOf(status); + b.startedAt = (finishedAt == null ? Instant.now() : finishedAt).minusSeconds(60); + b.finishedAt = finishedAt; + b.pinned = pinned; + b.persist(); + return b.id; }); + } + + private static boolean exists(Long build) { + return QuarkusTransaction.requiringNew().call(() -> Build.findById(build) != null); + } + + private static long count(Long project) { + return QuarkusTransaction.requiringNew().call(() -> Build.count("projectId", project)); + } + + private Instant daysAgo(int days) { + return now.minus(Duration.ofDays(days)); + } + + @Test + void deletesFinishedBuildsOlderThanMaxAge() { + Long p = project(); + Long old = build(p, "FAILED", daysAgo(31), false), + recent = build(p, "PASSED", daysAgo(29), false); + retention.purge(now, 30, 50); + assertFalse(exists(old)); + assertTrue(exists(recent)); + } + + @Test + void keepsTheNewestBuildsOfEachProject() { + Long p = project(), other = project(); + List builds = new ArrayList<>(); + for (int i = 0; i < 53; i++) + builds.add(build(p, "PASSED", now.minusSeconds(60L * (53 - i)), false)); + for (int i = 0; i < 3; i++) build(other, "PASSED", daysAgo(1), false); + retention.purge(now, 30, 50); + assertEquals(50, count(p)); + for (int i = 0; i < 3; i++) assertFalse(exists(builds.get(i)), "oldest builds go first"); + assertTrue(exists(builds.get(3))); + assertEquals(3, count(other), "each project has its own limit"); + } + + @Test + void keepsPinnedAndRunningBuilds() { + Long p = project(); + Long pinned = build(p, "FAILED", daysAgo(90), true), running = build(p, "RUNNING", null, false); + QuarkusTransaction.requiringNew() + .run(() -> Build.update("startedAt = ?1 where id = ?2", daysAgo(90), running)); + retention.purge(now, 30, 50); + assertTrue(exists(pinned)); + assertTrue(exists(running)); + } + + @Test + void pinnedBuildsDoNotCountTowardsTheLimit() { + Long p = project(); + Long pinned = build(p, "PASSED", daysAgo(2), true); + for (int i = 0; i < 50; i++) build(p, "PASSED", daysAgo(1), false); + retention.purge(now, 30, 50); + assertEquals(51, count(p)); + assertTrue(exists(pinned)); + } + + @Test + void zeroTurnsALimitOff() { + Long p = project(); + Long old = build(p, "PASSED", daysAgo(400), false); + for (int i = 0; i < 3; i++) build(p, "PASSED", daysAgo(1), false); + retention.purge(now, 0, 0); + assertTrue(exists(old)); + retention.purge(now, 0, 2); + assertEquals(2, count(p)); + assertFalse(exists(old)); + } + + @Test + void deletesInBatches() { + Long p = project(); + for (int i = 0; i < BuildRetention.BATCH + 5; i++) build(p, "PASSED", daysAgo(40), false); + assertTrue(retention.purge(now, 30, 50) >= BuildRetention.BATCH + 5); + assertEquals(0, count(p)); + } + + @Test + void deletesTheLogWithItsBuild() { + Long p = project(); + Long old = build(p, "PASSED", daysAgo(31), false); + try (var w = logs.open(old)) { + w.line("expired"); } - - private static boolean exists(Long build) { - return QuarkusTransaction.requiringNew().call(() -> Build.findById(build) != null); - } - - private static long count(Long project) { - return QuarkusTransaction.requiringNew().call(() -> Build.count("projectId", project)); - } - - private Instant daysAgo(int days) { - return now.minus(Duration.ofDays(days)); - } - - @Test - void deletesFinishedBuildsOlderThanMaxAge() { - Long p = project(); - Long old = build(p, "FAILED", daysAgo(31), false), recent = build(p, "PASSED", daysAgo(29), false); - retention.purge(now, 30, 50); - assertFalse(exists(old)); - assertTrue(exists(recent)); - } - - @Test - void keepsTheNewestBuildsOfEachProject() { - Long p = project(), other = project(); - List builds = new ArrayList<>(); - for (int i = 0; i < 53; i++) builds.add(build(p, "PASSED", now.minusSeconds(60L * (53 - i)), false)); - for (int i = 0; i < 3; i++) build(other, "PASSED", daysAgo(1), false); - retention.purge(now, 30, 50); - assertEquals(50, count(p)); - for (int i = 0; i < 3; i++) assertFalse(exists(builds.get(i)), "oldest builds go first"); - assertTrue(exists(builds.get(3))); - assertEquals(3, count(other), "each project has its own limit"); - } - - @Test - void keepsPinnedAndRunningBuilds() { - Long p = project(); - Long pinned = build(p, "FAILED", daysAgo(90), true), running = build(p, "RUNNING", null, false); - QuarkusTransaction.requiringNew().run(() -> Build.update("startedAt = ?1 where id = ?2", daysAgo(90), running)); - retention.purge(now, 30, 50); - assertTrue(exists(pinned)); - assertTrue(exists(running)); - } - - @Test - void pinnedBuildsDoNotCountTowardsTheLimit() { - Long p = project(); - Long pinned = build(p, "PASSED", daysAgo(2), true); - for (int i = 0; i < 50; i++) build(p, "PASSED", daysAgo(1), false); - retention.purge(now, 30, 50); - assertEquals(51, count(p)); - assertTrue(exists(pinned)); - } - - @Test - void zeroTurnsALimitOff() { - Long p = project(); - Long old = build(p, "PASSED", daysAgo(400), false); - for (int i = 0; i < 3; i++) build(p, "PASSED", daysAgo(1), false); - retention.purge(now, 0, 0); - assertTrue(exists(old)); - retention.purge(now, 0, 2); - assertEquals(2, count(p)); - assertFalse(exists(old)); - } - - @Test - void deletesInBatches() { - Long p = project(); - for (int i = 0; i < BuildRetention.BATCH + 5; i++) build(p, "PASSED", daysAgo(40), false); - assertTrue(retention.purge(now, 30, 50) >= BuildRetention.BATCH + 5); - assertEquals(0, count(p)); - } - - @Test - void deletesTheLogWithItsBuild() { - Long p = project(); - Long old = build(p, "PASSED", daysAgo(31), false); - try (var w = logs.open(old)) { w.line("expired"); } - long oid = logs.logOid(old); - retention.purge(now, 30, 50); - long left = QuarkusTransaction.requiringNew().call(() -> ((Number) Build.getEntityManager() - .createNativeQuery("select count(*) from pg_largeobject_metadata where oid = cast(?1 as oid)") - .setParameter(1, oid).getSingleResult()).longValue()); - assertEquals(0, left); - } - - private static void saveSettings(int days, int maxBuilds) { - QuarkusTransaction.requiringNew().run(() -> { - Settings s = Settings.findById(1L); - if (s == null) { s = new Settings(); s.persist(); } - s.retentionDays = days; - s.retentionMaxBuilds = maxBuilds; + long oid = logs.logOid(old); + retention.purge(now, 30, 50); + long left = + QuarkusTransaction.requiringNew() + .call(() -> + ((Number) + Build.getEntityManager() + .createNativeQuery( + "select count(*) from pg_largeobject_metadata where oid = cast(?1 as oid)") + .setParameter(1, oid) + .getSingleResult()) + .longValue()); + assertEquals(0, left); + } + + private static void saveSettings(int days, int maxBuilds) { + QuarkusTransaction.requiringNew() + .run(() -> { + Settings s = Settings.findById(1L); + if (s == null) { + s = new Settings(); + s.persist(); + } + s.retentionDays = days; + s.retentionMaxBuilds = maxBuilds; }); + } + + @Test + void scheduledRunUsesTheAdminSettings() { + Long p = project(); + Long build = build(p, "PASSED", daysAgo(20), false); + retention.scheduled(); + assertTrue(exists(build), "kept under the default 30 days"); + try { + saveSettings(10, 50); + retention.scheduled(); + assertFalse(exists(build)); + } finally { + saveSettings(30, 50); } - - @Test - void scheduledRunUsesTheAdminSettings() { - Long p = project(); - Long build = build(p, "PASSED", daysAgo(20), false); - retention.scheduled(); - assertTrue(exists(build), "kept under the default 30 days"); - try { - saveSettings(10, 50); - retention.scheduled(); - assertFalse(exists(build)); - } finally { - saveSettings(30, 50); - } - } + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/RunnerSmokeTest.java b/src/backend/test/java/ai/mnemosyne/cisys/RunnerSmokeTest.java index 4b7343a..627596b 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/RunnerSmokeTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/RunnerSmokeTest.java @@ -7,24 +7,57 @@ */ package ai.mnemosyne.cisys; -import io.quarkus.test.junit.QuarkusTest; + +import static org.junit.jupiter.api.Assertions.*; + import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import static org.junit.jupiter.api.Assertions.*; + @QuarkusTest -@EnabledIfEnvironmentVariable(named="CISYS_RUNNER_SMOKE", matches="true") +@EnabledIfEnvironmentVariable(named = "CISYS_RUNNER_SMOKE", matches = "true") class RunnerSmokeTest { - @Inject BuildRunner runner; - @Inject BuildLogs logs; - @Test void publicRepositoryBuildCompletes() throws Exception { - Long project=QuarkusTransaction.requiringNew().call(()->{Project p=new Project();p.name="smoke";p.repositoryUrl="https://github.com/octocat/Hello-World.git";p.ownerSubject="test";p.persist();return p.id;}); - Build created=runner.submit(project,"test","https://github.com/octocat/Hello-World.git",new BuildResource.RunRequest("master",null)); - runner.dispatch(); - long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.MINUTES.toNanos(2); - while(System.nanoTime()Build.findById(created.id));if(!(b.status==Build.Status.RUNNING || b.status==Build.Status.QUEUED)){String log=logs.readAll(b.id);// The repository has no workflow file, so the build fails after a successful checkout. -assertEquals(Build.Status.FAILED,b.status,log);assertTrue(log.contains("has no "+Workflow.PATH),log);assertNotNull(b.revision);assertNull(b.exitCode);assertEquals("master",b.branch);return;}Thread.sleep(500);} - fail("Build did not finish within two minutes"); - } + @Inject BuildRunner runner; + @Inject BuildLogs logs; + + @Test + void publicRepositoryBuildCompletes() throws Exception { + Long project = + QuarkusTransaction.requiringNew() + .call(() -> { + Project p = new Project(); + p.name = "smoke"; + p.repositoryUrl = "https://github.com/octocat/Hello-World.git"; + p.ownerSubject = "test"; + p.persist(); + return p.id; + }); + Build created = + runner.submit( + project, + "test", + "https://github.com/octocat/Hello-World.git", + new BuildResource.RunRequest("master", null)); + runner.dispatch(); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.MINUTES.toNanos(2); + while (System.nanoTime() < deadline) { + Build b = QuarkusTransaction.requiringNew().call(() -> Build.findById(created.id)); + if (!(b.status == Build.Status.RUNNING || b.status == Build.Status.QUEUED)) { + String log = + logs.readAll( + b.id); // The repository has no workflow file, so the build fails after a successful + // checkout. + assertEquals(Build.Status.FAILED, b.status, log); + assertTrue(log.contains("has no " + Workflow.PATH), log); + assertNotNull(b.revision); + assertNull(b.exitCode); + assertEquals("master", b.branch); + return; + } + Thread.sleep(500); + } + fail("Build did not finish within two minutes"); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/SessionInvalidatorTest.java b/src/backend/test/java/ai/mnemosyne/cisys/SessionInvalidatorTest.java index 5066360..0fa9612 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/SessionInvalidatorTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/SessionInvalidatorTest.java @@ -25,83 +25,103 @@ import org.junit.jupiter.api.Test; class SessionInvalidatorTest { - private HttpServer keycloak; - private final List calls = new CopyOnWriteArrayList<>(); - private int tokenStatus = 200; - private int logoutStatus = 204; + private HttpServer keycloak; + private final List calls = new CopyOnWriteArrayList<>(); + private int tokenStatus = 200; + private int logoutStatus = 204; - @BeforeEach void start() throws IOException { - keycloak = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - keycloak.createContext("/realms/cisys/protocol/openid-connect/token", exchange -> { - String form = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); - calls.add("token " + form); - byte[] body = "{\"access_token\":\"abc\",\"expires_in\":60}".getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(tokenStatus, body.length); - exchange.getResponseBody().write(body); - exchange.close(); - }); - keycloak.createContext("/admin/realms/cisys/logout-all", exchange -> { - calls.add(exchange.getRequestMethod() + " logout-all " + exchange.getRequestHeaders().getFirst("Authorization")); - exchange.sendResponseHeaders(logoutStatus, -1); - exchange.close(); - }); - keycloak.start(); - } + @BeforeEach + void start() throws IOException { + keycloak = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + keycloak.createContext( + "/realms/cisys/protocol/openid-connect/token", + exchange -> { + String form = + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + calls.add("token " + form); + byte[] body = + "{\"access_token\":\"abc\",\"expires_in\":60}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(tokenStatus, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + keycloak.createContext( + "/admin/realms/cisys/logout-all", + exchange -> { + calls.add( + exchange.getRequestMethod() + + " logout-all " + + exchange.getRequestHeaders().getFirst("Authorization")); + exchange.sendResponseHeaders(logoutStatus, -1); + exchange.close(); + }); + keycloak.start(); + } - @AfterEach void stop() { - keycloak.stop(0); - } + @AfterEach + void stop() { + keycloak.stop(0); + } - private SessionInvalidator invalidator() { - SessionInvalidator invalidator = new SessionInvalidator(); - invalidator.enabled = true; - invalidator.keycloakUrl = "http://127.0.0.1:" + keycloak.getAddress().getPort(); - invalidator.realm = "cisys"; - invalidator.clientId = "cisys-backend"; - invalidator.clientSecret = "s&cret"; - return invalidator; - } + private SessionInvalidator invalidator() { + SessionInvalidator invalidator = new SessionInvalidator(); + invalidator.enabled = true; + invalidator.keycloakUrl = "http://127.0.0.1:" + keycloak.getAddress().getPort(); + invalidator.realm = "cisys"; + invalidator.clientId = "cisys-backend"; + invalidator.clientSecret = "s&cret"; + return invalidator; + } - @Test void logsOutAllSessionsWithTheServiceAccount() { - assertTrue(invalidator().invalidate()); - assertEquals(List.of( - "token grant_type=client_credentials&client_id=cisys-backend&client_secret=s%26cret", - "POST logout-all Bearer abc"), calls); - } + @Test + void logsOutAllSessionsWithTheServiceAccount() { + assertTrue(invalidator().invalidate()); + assertEquals( + List.of( + "token grant_type=client_credentials&client_id=cisys-backend&client_secret=s%26cret", + "POST logout-all Bearer abc"), + calls); + } - @Test void shutdownEventInvalidates() { - invalidator().onShutdown(new ShutdownEvent()); - assertEquals(2, calls.size()); - } + @Test + void shutdownEventInvalidates() { + invalidator().onShutdown(new ShutdownEvent()); + assertEquals(2, calls.size()); + } - @Test void doesNothingWhenDisabled() { - SessionInvalidator invalidator = invalidator(); - invalidator.enabled = false; - invalidator.onShutdown(new ShutdownEvent()); - assertTrue(calls.isEmpty()); - } + @Test + void doesNothingWhenDisabled() { + SessionInvalidator invalidator = invalidator(); + invalidator.enabled = false; + invalidator.onShutdown(new ShutdownEvent()); + assertTrue(calls.isEmpty()); + } - @Test void onlyDevelopmentStandardShutdownIsLiveReload() { - assertTrue(SessionInvalidator.isLiveReload(LaunchMode.DEVELOPMENT, true)); - assertFalse(SessionInvalidator.isLiveReload(LaunchMode.DEVELOPMENT, false)); - assertFalse(SessionInvalidator.isLiveReload(LaunchMode.NORMAL, true)); - assertFalse(SessionInvalidator.isLiveReload(LaunchMode.NORMAL, false)); - } + @Test + void onlyDevelopmentStandardShutdownIsLiveReload() { + assertTrue(SessionInvalidator.isLiveReload(LaunchMode.DEVELOPMENT, true)); + assertFalse(SessionInvalidator.isLiveReload(LaunchMode.DEVELOPMENT, false)); + assertFalse(SessionInvalidator.isLiveReload(LaunchMode.NORMAL, true)); + assertFalse(SessionInvalidator.isLiveReload(LaunchMode.NORMAL, false)); + } - @Test void refusedClientDoesNotAttemptLogout() { - tokenStatus = 401; - assertFalse(invalidator().invalidate()); - assertEquals(1, calls.size()); - } + @Test + void refusedClientDoesNotAttemptLogout() { + tokenStatus = 401; + assertFalse(invalidator().invalidate()); + assertEquals(1, calls.size()); + } - @Test void failedLogoutIsReported() { - logoutStatus = 403; - assertFalse(invalidator().invalidate()); - } + @Test + void failedLogoutIsReported() { + logoutStatus = 403; + assertFalse(invalidator().invalidate()); + } - @Test void unreachableKeycloakDoesNotThrow() { - SessionInvalidator invalidator = invalidator(); - keycloak.stop(0); - assertFalse(invalidator.invalidate()); - } + @Test + void unreachableKeycloakDoesNotThrow() { + SessionInvalidator invalidator = invalidator(); + keycloak.stop(0); + assertFalse(invalidator.invalidate()); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/SettingsResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/SettingsResourceTest.java index a6bf0ab..7a9624e 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/SettingsResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/SettingsResourceTest.java @@ -8,80 +8,143 @@ package ai.mnemosyne.cisys; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; + import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import org.junit.jupiter.api.Test; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.equalTo; @QuarkusTest class SettingsResourceTest { - private static final String PATH = "/api/settings/retention"; - static final String DEFAULTS = "{\"maxAgeDays\":30,\"maxBuilds\":50}"; + private static final String PATH = "/api/settings/retention"; + static final String DEFAULTS = "{\"maxAgeDays\":30,\"maxBuilds\":50}"; - @Test void anonymousCannotRead() { given().get(PATH).then().statusCode(401); } + @Test + void anonymousCannotRead() { + given().get(PATH).then().statusCode(401); + } - @Test @TestSecurity(user = "lead", roles = "lead") - void leadCannotReadOrUpdate() { - given().get(PATH).then().statusCode(403); - given().contentType("application/json").body(DEFAULTS).put(PATH).then().statusCode(403); - } + @Test + @TestSecurity(user = "lead", roles = "lead") + void leadCannotReadOrUpdate() { + given().get(PATH).then().statusCode(403); + given().contentType("application/json").body(DEFAULTS).put(PATH).then().statusCode(403); + } - @Test @TestSecurity(user = "member", roles = "member") - void memberCannotUpdate() { given().contentType("application/json").body(DEFAULTS).put(PATH).then().statusCode(403); } + @Test + @TestSecurity(user = "member", roles = "member") + void memberCannotUpdate() { + given().contentType("application/json").body(DEFAULTS).put(PATH).then().statusCode(403); + } - @Test @TestSecurity(user = "admin", roles = "admin") - void adminSavesRetention() { - try { - given().contentType("application/json").body("{\"maxAgeDays\":7,\"maxBuilds\":0}").put(PATH).then().statusCode(200) - .body("maxAgeDays", equalTo(7), "maxBuilds", equalTo(0)); - given().get(PATH).then().statusCode(200).body("maxAgeDays", equalTo(7), "maxBuilds", equalTo(0)); - } finally { - given().contentType("application/json").body(DEFAULTS).put(PATH).then().statusCode(200); - } - given().get(PATH).then().statusCode(200).body("maxAgeDays", equalTo(30), "maxBuilds", equalTo(50)); + @Test + @TestSecurity(user = "admin", roles = "admin") + void adminSavesRetention() { + try { + given() + .contentType("application/json") + .body("{\"maxAgeDays\":7,\"maxBuilds\":0}") + .put(PATH) + .then() + .statusCode(200) + .body("maxAgeDays", equalTo(7), "maxBuilds", equalTo(0)); + given() + .get(PATH) + .then() + .statusCode(200) + .body("maxAgeDays", equalTo(7), "maxBuilds", equalTo(0)); + } finally { + given().contentType("application/json").body(DEFAULTS).put(PATH).then().statusCode(200); } + given() + .get(PATH) + .then() + .statusCode(200) + .body("maxAgeDays", equalTo(30), "maxBuilds", equalTo(50)); + } - @Test @TestSecurity(user = "admin", roles = "admin") - void rejectsInvalidLimits() { - for (String body : new String[] {"{\"maxAgeDays\":-1,\"maxBuilds\":50}", "{\"maxAgeDays\":30,\"maxBuilds\":-1}", - "{\"maxAgeDays\":3651,\"maxBuilds\":50}", "{\"maxAgeDays\":30,\"maxBuilds\":10001}", "{\"maxAgeDays\":30}", "{}"}) { - given().contentType("application/json").body(body).put(PATH).then().statusCode(400); - } + @Test + @TestSecurity(user = "admin", roles = "admin") + void rejectsInvalidLimits() { + for (String body : + new String[] { + "{\"maxAgeDays\":-1,\"maxBuilds\":50}", + "{\"maxAgeDays\":30,\"maxBuilds\":-1}", + "{\"maxAgeDays\":3651,\"maxBuilds\":50}", + "{\"maxAgeDays\":30,\"maxBuilds\":10001}", + "{\"maxAgeDays\":30}", + "{}" + }) { + given().contentType("application/json").body(body).put(PATH).then().statusCode(400); } + } - private static final String QUEUE = "/api/settings/queue"; + private static final String QUEUE = "/api/settings/queue"; - static void clearQueue() { - QuarkusTransaction.requiringNew().run(() -> Settings.update("maxConcurrentBuilds = null, maxQueuedBuilds = null where id = 1")); - } + static void clearQueue() { + QuarkusTransaction.requiringNew() + .run(() -> + Settings.update("maxConcurrentBuilds = null, maxQueuedBuilds = null where id = 1")); + } - @Test void anonymousCannotReadQueue() { given().get(QUEUE).then().statusCode(401); } + @Test + void anonymousCannotReadQueue() { + given().get(QUEUE).then().statusCode(401); + } - @Test @TestSecurity(user = "lead", roles = "lead") - void leadCannotReadOrUpdateQueue() { - given().get(QUEUE).then().statusCode(403); - given().contentType("application/json").body("{\"maxConcurrent\":2,\"maxQueued\":10}").put(QUEUE).then().statusCode(403); - } + @Test + @TestSecurity(user = "lead", roles = "lead") + void leadCannotReadOrUpdateQueue() { + given().get(QUEUE).then().statusCode(403); + given() + .contentType("application/json") + .body("{\"maxConcurrent\":2,\"maxQueued\":10}") + .put(QUEUE) + .then() + .statusCode(403); + } - @Test @TestSecurity(user = "admin", roles = "admin") - void adminSavesQueueOverConfiguredDefaults() { - given().get(QUEUE).then().statusCode(200).body("maxConcurrent", equalTo(1), "maxQueued", equalTo(100)); - try { - given().contentType("application/json").body("{\"maxConcurrent\":4,\"maxQueued\":10}").put(QUEUE).then().statusCode(200) - .body("maxConcurrent", equalTo(4), "maxQueued", equalTo(10)); - given().get(QUEUE).then().statusCode(200).body("maxConcurrent", equalTo(4), "maxQueued", equalTo(10)); - } finally { - clearQueue(); - } + @Test + @TestSecurity(user = "admin", roles = "admin") + void adminSavesQueueOverConfiguredDefaults() { + given() + .get(QUEUE) + .then() + .statusCode(200) + .body("maxConcurrent", equalTo(1), "maxQueued", equalTo(100)); + try { + given() + .contentType("application/json") + .body("{\"maxConcurrent\":4,\"maxQueued\":10}") + .put(QUEUE) + .then() + .statusCode(200) + .body("maxConcurrent", equalTo(4), "maxQueued", equalTo(10)); + given() + .get(QUEUE) + .then() + .statusCode(200) + .body("maxConcurrent", equalTo(4), "maxQueued", equalTo(10)); + } finally { + clearQueue(); } + } - @Test @TestSecurity(user = "admin", roles = "admin") - void rejectsInvalidQueue() { - for (String body : new String[] {"{\"maxConcurrent\":0,\"maxQueued\":10}", "{\"maxConcurrent\":17,\"maxQueued\":10}", - "{\"maxConcurrent\":1,\"maxQueued\":0}", "{\"maxConcurrent\":1,\"maxQueued\":1001}", "{\"maxConcurrent\":1}", "{}"}) { - given().contentType("application/json").body(body).put(QUEUE).then().statusCode(400); - } + @Test + @TestSecurity(user = "admin", roles = "admin") + void rejectsInvalidQueue() { + for (String body : + new String[] { + "{\"maxConcurrent\":0,\"maxQueued\":10}", + "{\"maxConcurrent\":17,\"maxQueued\":10}", + "{\"maxConcurrent\":1,\"maxQueued\":0}", + "{\"maxConcurrent\":1,\"maxQueued\":1001}", + "{\"maxConcurrent\":1}", + "{}" + }) { + given().contentType("application/json").body(body).put(QUEUE).then().statusCode(400); } + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/StatsResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/StatsResourceTest.java index 54be1f1..5afe08a 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/StatsResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/StatsResourceTest.java @@ -8,6 +8,12 @@ package ai.mnemosyne.cisys; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; @@ -15,129 +21,175 @@ import io.quarkus.test.security.jwt.JwtSecurity; import io.restassured.path.json.JsonPath; import io.restassured.path.json.config.JsonPathConfig; -import org.junit.jupiter.api.Test; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.UUID; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.startsWith; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; +import org.junit.jupiter.api.Test; -/** Tests share one database and run in any order, so counts are compared before and after each change. */ +/** + * Tests share one database and run in any order, so counts are compared before and after each + * change. + */ @QuarkusTest class StatsResourceTest { - @org.junit.jupiter.api.AfterEach void releaseSyntheticRunningBuilds(){ - QuarkusTransaction.requiringNew().run(()->Build.update("status = ?1 where status = ?2 and repositoryUrl is null",Build.Status.FAILED,Build.Status.RUNNING)); - } - - private static final List FIELDS = List.of("projects", "builds", "passed", "failed", "running", "buildsLast7Days"); - - private static String raw() { - return given().get("/api/stats/public").then().statusCode(200).extract().asString(); - } - - // Read decimals as double: the default float would round successRate (0.666... becomes 0.6666667). - private static final JsonPathConfig DOUBLES = new JsonPathConfig(JsonPathConfig.NumberReturnType.DOUBLE); - - private static JsonPath stats() { return JsonPath.from(raw()).using(DOUBLES); } - - private static Long project(boolean privateRepository) { - String id = UUID.randomUUID().toString(); - return QuarkusTransaction.requiringNew().call(() -> { - Project p = new Project(); - p.name = "stats-" + id; p.repositoryUrl = "https://github.com/test/" + id + ".git"; - p.privateRepository = privateRepository; p.ownerSubject = "stats-owner"; - p.persist(); - return p.id; + @org.junit.jupiter.api.AfterEach + void releaseSyntheticRunningBuilds() { + QuarkusTransaction.requiringNew() + .run(() -> + Build.update( + "status = ?1 where status = ?2 and repositoryUrl is null", + Build.Status.FAILED, + Build.Status.RUNNING)); + } + + private static final List FIELDS = + List.of("projects", "builds", "passed", "failed", "running", "buildsLast7Days"); + + private static String raw() { + return given().get("/api/stats/public").then().statusCode(200).extract().asString(); + } + + // Read decimals as double: the default float would round successRate (0.666... becomes + // 0.6666667). + private static final JsonPathConfig DOUBLES = + new JsonPathConfig(JsonPathConfig.NumberReturnType.DOUBLE); + + private static JsonPath stats() { + return JsonPath.from(raw()).using(DOUBLES); + } + + private static Long project(boolean privateRepository) { + String id = UUID.randomUUID().toString(); + return QuarkusTransaction.requiringNew() + .call(() -> { + Project p = new Project(); + p.name = "stats-" + id; + p.repositoryUrl = "https://github.com/test/" + id + ".git"; + p.privateRepository = privateRepository; + p.ownerSubject = "stats-owner"; + p.persist(); + return p.id; }); - } - - private static void build(Long projectId, String status, Instant startedAt) { - QuarkusTransaction.requiringNew().run(() -> { - Build b = new Build(); - b.projectId = projectId; b.requestedBy = "stats-owner"; b.status=Build.Status.valueOf(status); b.startedAt = startedAt; - if (!"RUNNING".equals(status)) b.finishedAt = startedAt.plusSeconds(10); - b.persist(); + } + + private static void build(Long projectId, String status, Instant startedAt) { + QuarkusTransaction.requiringNew() + .run(() -> { + Build b = new Build(); + b.projectId = projectId; + b.requestedBy = "stats-owner"; + b.status = Build.Status.valueOf(status); + b.startedAt = startedAt; + if (!"RUNNING".equals(status)) b.finishedAt = startedAt.plusSeconds(10); + b.persist(); }); - } - - private static long delta(JsonPath before, JsonPath after, String field) { - return after.getLong(field) - before.getLong(field); - } - - @Test void anonymousCanReadStatsWithCacheHeader() { - given().get("/api/stats/public").then().statusCode(200).header("Cache-Control", startsWith("public, max-age=")); - } - - @Test void doesNotExposePrivateProjectCounts() { - String body = raw(); - assertFalse(body.contains("privateProjects")); - assertFalse(body.contains("publicProjects")); - } - - @Test void countsOnlyPublicBuildsWithTheirStatusesAndDates() { - JsonPath before = stats(); - Long publicProject = project(false), privateProject = project(true); - Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); - Instant latestPublic = now.plus(1, ChronoUnit.HOURS); - - build(publicProject, "PASSED", now.minus(1, ChronoUnit.DAYS)); - build(publicProject, "PASSED", now.minus(10, ChronoUnit.DAYS)); // outside the last 7 days - build(publicProject, "FAILED", now.minus(2, ChronoUnit.DAYS)); - build(publicProject, "RUNNING", latestPublic); - // Private builds, including a newer one, must not show up anywhere. - build(privateProject, "PASSED", now); - build(privateProject, "FAILED", now.plus(2, ChronoUnit.HOURS)); - - JsonPath after = stats(); - assertEquals(1, delta(before, after, "projects")); - assertEquals(4, delta(before, after, "builds")); - assertEquals(2, delta(before, after, "passed")); - assertEquals(1, delta(before, after, "failed")); - assertEquals(1, delta(before, after, "running")); - assertEquals(3, delta(before, after, "buildsLast7Days")); - assertEquals(latestPublic, Instant.parse(after.getString("lastBuildAt"))); - double expectedRate = (double) after.getLong("passed") / (after.getLong("passed") + after.getLong("failed")); - assertEquals(expectedRate, after.getDouble("successRate"), 1e-9); - } - - @Test void privateProjectAndItsBuildsChangeNothing() { - JsonPath before = stats(); - Long privateProject = project(true); - build(privateProject, "PASSED", Instant.now()); - build(privateProject, "FAILED", Instant.now()); - JsonPath after = stats(); - for (String field : FIELDS) assertEquals(0, delta(before, after, field), field); - assertEquals(before.getString("lastBuildAt"), after.getString("lastBuildAt")); - assertEquals(before.getString("successRate"), after.getString("successRate")); - } - - @Test void successRateIsNullWithoutFinishedPublicBuilds() { - JsonPath current = stats(); - if (current.getLong("passed") + current.getLong("failed") == 0) assertNull(current.get("successRate")); - } - - @Test @TestSecurity(user = "admin", roles = "admin") - @JwtSecurity(claims = {@Claim(key = "sub", value = "stats-admin-id")}) - void countsIncreaseWithoutRevealingProjects() { - JsonPath before = stats(); - String id = UUID.randomUUID().toString(); - String publicName = "stats-pub-" + id, privateName = "stats-priv-" + id; - String publicUrl = "https://github.com/test/pub-" + id + ".git", privateUrl = "https://github.com/test/priv-" + id + ".git"; - given().contentType("application/json").body(Map.of("name", publicName, "repositoryUrl", publicUrl, "privateRepository", false)).post("/api/projects").then().statusCode(201); - Long privateId = given().contentType("application/json").body(Map.of("name", privateName, "repositoryUrl", privateUrl, "privateRepository", true)) - .post("/api/projects").then().statusCode(201).extract().jsonPath().getLong("id"); - build(privateId, "PASSED", Instant.now()); - - String raw = raw(); - JsonPath after = JsonPath.from(raw).using(DOUBLES); - assertEquals(1, delta(before, after, "projects")); - for (String field : FIELDS.subList(1, FIELDS.size())) assertEquals(0, delta(before, after, field), field); - for (String secret : new String[] {id, publicName, privateName, publicUrl, privateUrl}) assertFalse(raw.contains(secret), secret); - for (String key : new String[] {"\"name\"", "\"repositoryUrl\"", "\"ownerSubject\""}) assertFalse(raw.contains(key), key); - } + } + + private static long delta(JsonPath before, JsonPath after, String field) { + return after.getLong(field) - before.getLong(field); + } + + @Test + void anonymousCanReadStatsWithCacheHeader() { + given() + .get("/api/stats/public") + .then() + .statusCode(200) + .header("Cache-Control", startsWith("public, max-age=")); + } + + @Test + void doesNotExposePrivateProjectCounts() { + String body = raw(); + assertFalse(body.contains("privateProjects")); + assertFalse(body.contains("publicProjects")); + } + + @Test + void countsOnlyPublicBuildsWithTheirStatusesAndDates() { + JsonPath before = stats(); + Long publicProject = project(false), privateProject = project(true); + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant latestPublic = now.plus(1, ChronoUnit.HOURS); + + build(publicProject, "PASSED", now.minus(1, ChronoUnit.DAYS)); + build(publicProject, "PASSED", now.minus(10, ChronoUnit.DAYS)); // outside the last 7 days + build(publicProject, "FAILED", now.minus(2, ChronoUnit.DAYS)); + build(publicProject, "RUNNING", latestPublic); + // Private builds, including a newer one, must not show up anywhere. + build(privateProject, "PASSED", now); + build(privateProject, "FAILED", now.plus(2, ChronoUnit.HOURS)); + + JsonPath after = stats(); + assertEquals(1, delta(before, after, "projects")); + assertEquals(4, delta(before, after, "builds")); + assertEquals(2, delta(before, after, "passed")); + assertEquals(1, delta(before, after, "failed")); + assertEquals(1, delta(before, after, "running")); + assertEquals(3, delta(before, after, "buildsLast7Days")); + assertEquals(latestPublic, Instant.parse(after.getString("lastBuildAt"))); + double expectedRate = + (double) after.getLong("passed") / (after.getLong("passed") + after.getLong("failed")); + assertEquals(expectedRate, after.getDouble("successRate"), 1e-9); + } + + @Test + void privateProjectAndItsBuildsChangeNothing() { + JsonPath before = stats(); + Long privateProject = project(true); + build(privateProject, "PASSED", Instant.now()); + build(privateProject, "FAILED", Instant.now()); + JsonPath after = stats(); + for (String field : FIELDS) assertEquals(0, delta(before, after, field), field); + assertEquals(before.getString("lastBuildAt"), after.getString("lastBuildAt")); + assertEquals(before.getString("successRate"), after.getString("successRate")); + } + + @Test + void successRateIsNullWithoutFinishedPublicBuilds() { + JsonPath current = stats(); + if (current.getLong("passed") + current.getLong("failed") == 0) + assertNull(current.get("successRate")); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = {@Claim(key = "sub", value = "stats-admin-id")}) + void countsIncreaseWithoutRevealingProjects() { + JsonPath before = stats(); + String id = UUID.randomUUID().toString(); + String publicName = "stats-pub-" + id, privateName = "stats-priv-" + id; + String publicUrl = "https://github.com/test/pub-" + id + ".git", + privateUrl = "https://github.com/test/priv-" + id + ".git"; + given() + .contentType("application/json") + .body(Map.of("name", publicName, "repositoryUrl", publicUrl, "privateRepository", false)) + .post("/api/projects") + .then() + .statusCode(201); + Long privateId = + given() + .contentType("application/json") + .body( + Map.of("name", privateName, "repositoryUrl", privateUrl, "privateRepository", true)) + .post("/api/projects") + .then() + .statusCode(201) + .extract() + .jsonPath() + .getLong("id"); + build(privateId, "PASSED", Instant.now()); + + String raw = raw(); + JsonPath after = JsonPath.from(raw).using(DOUBLES); + assertEquals(1, delta(before, after, "projects")); + for (String field : FIELDS.subList(1, FIELDS.size())) + assertEquals(0, delta(before, after, field), field); + for (String secret : new String[] {id, publicName, privateName, publicUrl, privateUrl}) + assertFalse(raw.contains(secret), secret); + for (String key : new String[] {"\"name\"", "\"repositoryUrl\"", "\"ownerSubject\""}) + assertFalse(raw.contains(key), key); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/TestGitRepositoryService.java b/src/backend/test/java/ai/mnemosyne/cisys/TestGitRepositoryService.java index 108d76a..92cc7d8 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/TestGitRepositoryService.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/TestGitRepositoryService.java @@ -10,25 +10,29 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Alternative; -import org.eclipse.jgit.api.errors.GitAPIException; - import java.util.Collection; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import org.eclipse.jgit.api.errors.GitAPIException; /** Replaces the real network lookup in {@link BranchResourceTest}. */ @Alternative @ApplicationScoped public class TestGitRepositoryService extends GitRepositoryService { - private static final AtomicInteger CALLS = new AtomicInteger(); + private static final AtomicInteger CALLS = new AtomicInteger(); + + static void resetCalls() { + CALLS.set(0); + } - static void resetCalls() { CALLS.set(0); } - static int calls() { return CALLS.get(); } + static int calls() { + return CALLS.get(); + } - @Override - protected Collection remoteBranchRefs(String repositoryUrl) throws GitAPIException { - CALLS.incrementAndGet(); - if (repositoryUrl.contains("unreachable")) throw new GitAPIException("unreachable") {}; - return List.of("refs/heads/main", "refs/heads/dev", "refs/tags/v1", "refs/heads/dev"); - } + @Override + protected Collection remoteBranchRefs(String repositoryUrl) throws GitAPIException { + CALLS.incrementAndGet(); + if (repositoryUrl.contains("unreachable")) throw new GitAPIException("unreachable") {}; + return List.of("refs/heads/main", "refs/heads/dev", "refs/tags/v1", "refs/heads/dev"); + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/TestHostResolver.java b/src/backend/test/java/ai/mnemosyne/cisys/TestHostResolver.java index 6111705..eba8426 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/TestHostResolver.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/TestHostResolver.java @@ -24,23 +24,23 @@ @Priority(1) @ApplicationScoped public class TestHostResolver implements HostResolver { - private static final Map ADDRESSES = new ConcurrentHashMap<>(); + private static final Map ADDRESSES = new ConcurrentHashMap<>(); - /** Forces a host to the given literal addresses until it is reset. */ - public static void set(String host, String... addresses) { - if (addresses.length == 0) ADDRESSES.remove(host); - else ADDRESSES.put(host, addresses.clone()); - } + /** Forces a host to the given literal addresses until it is reset. */ + public static void set(String host, String... addresses) { + if (addresses.length == 0) ADDRESSES.remove(host); + else ADDRESSES.put(host, addresses.clone()); + } - public static void reset() { - ADDRESSES.clear(); - } + public static void reset() { + ADDRESSES.clear(); + } - @Override - public InetAddress[] resolve(String host) throws UnknownHostException { - String[] values = ADDRESSES.getOrDefault(host, new String[] {"140.82.112.3"}); - InetAddress[] result = new InetAddress[values.length]; - for (int i = 0; i < values.length; i++) result[i] = InetAddress.getByName(values[i]); - return result; - } + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + String[] values = ADDRESSES.getOrDefault(host, new String[] {"140.82.112.3"}); + InetAddress[] result = new InetAddress[values.length]; + for (int i = 0; i < values.length; i++) result[i] = InetAddress.getByName(values[i]); + return result; + } } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/WorkflowTest.java b/src/backend/test/java/ai/mnemosyne/cisys/WorkflowTest.java index e43bef6..88bd848 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/WorkflowTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/WorkflowTest.java @@ -8,13 +8,6 @@ package ai.mnemosyne.cisys; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -22,390 +15,473 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -class WorkflowTest { - @TempDir Path temp; - - static final String VALID = """ - name: main branch +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +class WorkflowTest { + @TempDir Path temp; + + static final String VALID = + """ + name: main branch + + container: alpine:3.23 + + environment: + - name: MODE + value: test + - name: PORT + value: 8180 + + jobs: + - job: Build and test + steps: + - name: Start + run: make platform-start + - name: Wait for 8180 + wait: 8180 + - name: Run + run: make run + - name: Stop + run: make platform-stop + """; + + @Test + void parsesWorkflow() { + Workflow w = Workflow.parse(VALID); + assertEquals("main branch", w.name()); + assertEquals("alpine:3.23", w.container()); + assertEquals( + List.of(new Workflow.Variable("MODE", "test"), new Workflow.Variable("PORT", "8180")), + w.environment()); + assertEquals(1, w.jobs().size()); + Workflow.Job job = w.jobs().get(0); + assertEquals("Build and test", job.job()); + assertEquals( + List.of("Start", "Wait for 8180", "Run", "Stop"), + job.steps().stream().map(Workflow.Step::name).toList()); + assertTrue(job.steps().get(0).isRun()); + assertEquals("make platform-start", job.steps().get(0).run()); + assertFalse(job.steps().get(1).isRun()); + assertEquals(8180, job.steps().get(1).port()); + } + + @Test + void parsesRepositoryWorkflow() throws Exception { + assertNotNull(Workflow.load(Path.of("."))); + } + + @Test + void loadsFromCheckout() throws Exception { + Path file = temp.resolve(Workflow.PATH); + Files.createDirectories(file.getParent()); + Files.writeString(file, VALID); + assertEquals("main branch", Workflow.load(temp).name()); + } + + @Test + void missingFileIsNull() throws Exception { + assertNull(Workflow.load(temp)); + } + + @Test + void rejectsSymbolicLink() throws Exception { + Path target = Files.writeString(temp.resolve("elsewhere.yml"), VALID); + Path file = temp.resolve(Workflow.PATH); + Files.createDirectories(file.getParent()); + Files.createSymbolicLink(file, target); + assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.load(temp)); + } + + @Test + void rejectsLargeFile() throws Exception { + Path file = temp.resolve(Workflow.PATH); + Files.createDirectories(file.getParent()); + Files.writeString(file, VALID + "#".repeat(Workflow.MAX_BYTES)); + assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.load(temp)); + } + + @Test + void rejectsInvalidDocuments() { + for (String yaml : + List.of( + "", + "name: [", + VALID.replace("name: main branch", "name: \"\""), + VALID.replace("container: alpine:3.23", "container: \"-rm\""), + VALID.replace("container: alpine:3.23\n", ""), + VALID.replace("container: alpine:3.23", "container:\n base: alpine:3.23"), + VALID.replace("container: alpine:3.23", "environment:\n base: alpine:3.23"), + VALID.replace(" steps:", " stepz:"), + VALID.replace(" wait: 8180", " wait: 70000"), + VALID.replace(" wait: 8180", " wait: 81.5"), + VALID.replace(" wait: 8180", " wait: 8180\n run: true"), + VALID.replace(" wait: 8180", " timeout: 5"), + VALID.replace(" run: make run", " run: \" \""), + VALID + "name: duplicate\n", + VALID.replace("name: PORT", "name: MODE"), + VALID.replace("name: MODE", "name: 1MODE"), + VALID.replace("name: MODE", "name: MY-MODE"), + VALID.replace(" - name: MODE\n value: test\n", " - value: test\n"), + VALID.replace(" value: test", " value:"), + VALID.replace(" value: test", " value: [a, b]"), + VALID.replace(" value: test", " value:\n nested: x"), + VALID.replace(" value: test", " value: test\n other: x"), + VALID.replace(" value: test", " value: " + "x".repeat(Workflow.MAX_COMMAND + 1)), + VALID.replace(" - name: MODE\n value: test\n", " MODE: test\n"), + VALID.replace( + " wait: 8180", + " wait: 8180\n environment:\n - name: X\n value: y"), + "name: x\ncontainer: alpine:3.23\njobs: []\n", + "name: x\ncontainer: alpine:3.23\njobs:\n - job: j\n steps: []\n")) { + assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.parse(yaml), yaml); + } + } + + private static Workflow workflow(String steps) { + return Workflow.parse( + "name: w\ncontainer: alpine:3.23\njobs:\n - job: j\n steps:\n" + steps); + } + + private static BuildRunner.Result runScript(String script) throws Exception { + return BuildRunner.command(List.of("sh", "-c", script), 30, 100_000); + } + + @Test + void scriptRunsStepsInOrderAndStopsAtFirstFailure() throws Exception { + var r = + runScript( + workflow( + """ + - name: First + run: echo "it's one" + - name: Fails + run: exit 3 + - name: Never + run: echo never + """) + .script(Workflow.WAIT_SECONDS)); + assertEquals(3, r.code(), r.output()); + assertTrue( + r.output().contains("==> Job: j\n==> Step: First\nit's one\n==> Step: Fails\n"), + r.output()); + assertTrue(r.output().contains("==> Step failed: Fails, exit code 3"), r.output()); + assertFalse(r.output().contains("never"), r.output()); + } + + @Test + void stepsSeeTheEnvironment() throws Exception { + Workflow w = + Workflow.parse( + """ + name: w container: alpine:3.23 - environment: - - name: MODE - value: test - - name: PORT - value: 8180 - + - name: GREETING + value: "it's $HOME `x` \\\\ \\"q\\"" + - name: EMPTY + value: "" + - name: LEVEL + value: workflow jobs: - - job: Build and test - steps: - - name: Start - run: make platform-start - - name: Wait for 8180 - wait: 8180 - - name: Run - run: make run - - name: Stop - run: make platform-stop - """; - - @Test - void parsesWorkflow() { - Workflow w = Workflow.parse(VALID); - assertEquals("main branch", w.name()); - assertEquals("alpine:3.23", w.container()); - assertEquals(List.of(new Workflow.Variable("MODE", "test"), new Workflow.Variable("PORT", "8180")), w.environment()); - assertEquals(1, w.jobs().size()); - Workflow.Job job = w.jobs().get(0); - assertEquals("Build and test", job.job()); - assertEquals(List.of("Start", "Wait for 8180", "Run", "Stop"), job.steps().stream().map(Workflow.Step::name).toList()); - assertTrue(job.steps().get(0).isRun()); - assertEquals("make platform-start", job.steps().get(0).run()); - assertFalse(job.steps().get(1).isRun()); - assertEquals(8180, job.steps().get(1).port()); - } - - @Test - void parsesRepositoryWorkflow() throws Exception { - assertNotNull(Workflow.load(Path.of("."))); - } - - @Test - void loadsFromCheckout() throws Exception { - Path file = temp.resolve(Workflow.PATH); - Files.createDirectories(file.getParent()); - Files.writeString(file, VALID); - assertEquals("main branch", Workflow.load(temp).name()); - } - - @Test - void missingFileIsNull() throws Exception { - assertNull(Workflow.load(temp)); - } - - @Test - void rejectsSymbolicLink() throws Exception { - Path target = Files.writeString(temp.resolve("elsewhere.yml"), VALID); - Path file = temp.resolve(Workflow.PATH); - Files.createDirectories(file.getParent()); - Files.createSymbolicLink(file, target); - assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.load(temp)); - } - - @Test - void rejectsLargeFile() throws Exception { - Path file = temp.resolve(Workflow.PATH); - Files.createDirectories(file.getParent()); - Files.writeString(file, VALID + "#".repeat(Workflow.MAX_BYTES)); - assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.load(temp)); - } - - @Test - void rejectsInvalidDocuments() { - for (String yaml : List.of( - "", - "name: [", - VALID.replace("name: main branch", "name: \"\""), - VALID.replace("container: alpine:3.23", "container: \"-rm\""), - VALID.replace("container: alpine:3.23\n", ""), - VALID.replace("container: alpine:3.23", "container:\n base: alpine:3.23"), - VALID.replace("container: alpine:3.23", "environment:\n base: alpine:3.23"), - VALID.replace(" steps:", " stepz:"), - VALID.replace(" wait: 8180", " wait: 70000"), - VALID.replace(" wait: 8180", " wait: 81.5"), - VALID.replace(" wait: 8180", " wait: 8180\n run: true"), - VALID.replace(" wait: 8180", " timeout: 5"), - VALID.replace(" run: make run", " run: \" \""), - VALID + "name: duplicate\n", - VALID.replace("name: PORT", "name: MODE"), - VALID.replace("name: MODE", "name: 1MODE"), - VALID.replace("name: MODE", "name: MY-MODE"), - VALID.replace(" - name: MODE\n value: test\n", " - value: test\n"), - VALID.replace(" value: test", " value:"), - VALID.replace(" value: test", " value: [a, b]"), - VALID.replace(" value: test", " value:\n nested: x"), - VALID.replace(" value: test", " value: test\n other: x"), - VALID.replace(" value: test", " value: " + "x".repeat(Workflow.MAX_COMMAND + 1)), - VALID.replace(" - name: MODE\n value: test\n", " MODE: test\n"), - VALID.replace(" wait: 8180", " wait: 8180\n environment:\n - name: X\n value: y"), - "name: x\ncontainer: alpine:3.23\njobs: []\n", - "name: x\ncontainer: alpine:3.23\njobs:\n - job: j\n steps: []\n")) { - assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.parse(yaml), yaml); - } - } - - private static Workflow workflow(String steps) { - return Workflow.parse("name: w\ncontainer: alpine:3.23\njobs:\n - job: j\n steps:\n" + steps); - } - - private static BuildRunner.Result runScript(String script) throws Exception { - return BuildRunner.command(List.of("sh", "-c", script), 30, 100_000); - } - - @Test - void scriptRunsStepsInOrderAndStopsAtFirstFailure() throws Exception { - var r = runScript(workflow(""" - - name: First - run: echo "it's one" - - name: Fails - run: exit 3 - - name: Never - run: echo never - """).script(Workflow.WAIT_SECONDS)); - assertEquals(3, r.code(), r.output()); - assertTrue(r.output().contains("==> Job: j\n==> Step: First\nit's one\n==> Step: Fails\n"), r.output()); - assertTrue(r.output().contains("==> Step failed: Fails, exit code 3"), r.output()); - assertFalse(r.output().contains("never"), r.output()); - } - - @Test - void stepsSeeTheEnvironment() throws Exception { - Workflow w = Workflow.parse(""" - name: w - container: alpine:3.23 + - job: j environment: - - name: GREETING - value: "it's $HOME `x` \\\\ \\"q\\"" - - name: EMPTY - value: "" - name: LEVEL - value: workflow - jobs: - - job: j - environment: - - name: LEVEL - value: job - steps: - - name: Show - run: printf '[%s][%s][%s]\\n' "$GREETING" "${EMPTY-unset}" "$LEVEL" - - name: Own - environment: - - name: LEVEL - value: step - - name: ONLY_HERE - value: "yes" - run: echo "own=$LEVEL $ONLY_HERE" - - name: After - run: echo "after=$LEVEL ${ONLY_HERE-unset}" - - job: k - steps: - - name: Other job - run: echo "other=$LEVEL" - """); - var r = runScript(w.script(Workflow.WAIT_SECONDS)); - assertEquals(0, r.code(), r.output()); - assertTrue(r.output().contains("[it's $HOME `x` \\ \"q\"][][job]"), r.output()); - assertTrue(r.output().contains("own=step yes"), r.output()); - assertTrue(r.output().contains("after=job unset"), r.output()); - assertTrue(r.output().contains("other=workflow"), r.output()); - } - - @Test - void environmentIsOptional() { - Workflow w = workflow(" - name: s\n run: true\n"); - assertNull(w.environment()); - assertEquals("", w.exports()); - } - - @Test - void waitStepPassesOnceThePortListens() throws Exception { - try (var server = new java.net.ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress())) { - var r = runScript(workflow(" - name: Wait\n wait: " + server.getLocalPort() - + "\n - name: After\n run: echo ready\n").script(5)); - assertEquals(0, r.code(), r.output()); - assertTrue(r.output().contains("ready"), r.output()); - } - } - - @Test - void variablesAreUnsetWhenTheirScopeEnds() throws Exception { - try (var server = new java.net.ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress())) { - Workflow w = Workflow.parse(""" - name: w - container: alpine:3.23 + value: job + steps: + - name: Show + run: printf '[%s][%s][%s]\\n' "$GREETING" "${EMPTY-unset}" "$LEVEL" + - name: Own environment: - name: LEVEL - value: workflow - jobs: - - job: Steps - environment: - - name: LEVEL - value: job - - name: JOB_ONLY - value: one - steps: - - name: Own - environment: - - name: STEP_ONLY - value: s1 - run: echo "a=$LEVEL $JOB_ONLY $STEP_ONLY" - - name: Next - run: echo "b=$LEVEL $JOB_ONLY ${STEP_ONLY-unset}" - - job: One RUN - environment: - - name: WAIT_JOB - value: two - steps: - - name: Own - environment: - - name: STEP_ONLY - value: s2 - - name: LEVEL - value: step - run: echo "c=$LEVEL $WAIT_JOB $STEP_ONLY ${JOB_ONLY-unset}" - - name: Wait - wait: %d - - name: Next - run: echo "d=$LEVEL $WAIT_JOB ${STEP_ONLY-unset}" - - job: Last - steps: - - name: Last - run: echo "e=$LEVEL ${JOB_ONLY-unset} ${WAIT_JOB-unset} ${STEP_ONLY-unset}" - """.formatted(server.getLocalPort())); - var r = runScript(w.script(5)); - assertEquals(0, r.code(), r.output()); - for (String line : List.of("a=job one s1", "b=job one unset", "c=step two s2 unset", "d=workflow two unset", - "e=workflow unset unset unset")) { - assertTrue(r.output().contains(line + "\n"), line + " in\n" + r.output()); - } - } - } - - @Test - void waitStepFailsWhenThePortNeverListens() throws Exception { - int port; - try (var probe = new java.net.ServerSocket(0)) { - port = probe.getLocalPort(); - } - var r = runScript(workflow(" - name: Wait\n wait: " + port + "\n").script(1)); - assertEquals(1, r.code(), r.output()); - assertTrue(r.output().contains("port " + port + " not listening after 1 seconds"), r.output()); - } - - @Test - void rejectsContainerfilesTooLargeToRun() { - // Each quote grows to five characters in the Containerfile ('\'' in JSON), so 13 steps exceed the limit. - String step = " - name: s\n run: x" + "'".repeat(3999) + "\n"; - Workflow w = workflow(step.repeat(13)); - assertThrows(Workflow.InvalidWorkflowException.class, () -> w.containerfile(ID, URL, "main", REV, false)); - } - - static final String URL = "https://github.com/example/app"; - static final String REV = "0123456789abcdef0123456789abcdef01234567"; - static final String ID = "164b5c6a230f645862b6123e07cf0a67f2f03371696aa82a12ccf3589e87c016"; - - @Test - void containerfileClonesTheBranchThenRunsEachStepWithoutNetwork() { - List lines = Workflow.parse(VALID.replace(" wait: 8180\n", " run: make wait\n")) - .containerfile(ID, URL, "feature/x", REV, false).lines().toList(); - assertEquals("FROM " + ID, lines.get(0)); - assertEquals("USER 0", lines.get(1)); - assertTrue(lines.get(2).startsWith("RUN [\"sh\",\"-c\",\". /etc/os-release"), lines.get(2)); - assertTrue(lines.get(2).contains("for tool in git make podman podman-compose; do"), lines.get(2)); - // Root writes the variables read-only, before switching to the build user. - assertEquals("RUN --network=none [\"sh\",\"-c\",\"printf '%s' \\\"$1\\\" > /etc/cisys-environment && chown 0:0 /etc/cisys-environment && chmod 444 /etc/cisys-environment\",\"environment\",\"export MODE='test'\\nexport PORT='8180'\\n\"]", lines.get(3)); - assertEquals("USER 65534:65534", lines.get(5)); - assertEquals("WORKDIR /workspace", lines.get(6)); - String clone = lines.get(8); - assertTrue(clone.startsWith("RUN [\"sh\",\"-c\","), clone); - assertTrue(clone.endsWith(",\"clone\",\"" + URL + "\",\"feature/x\",\"" + REV + "\"]"), clone); - assertFalse(clone.contains("secret"), clone); - List steps = lines.subList(9, lines.size()); - assertEquals(4, steps.size(), String.join("\n", lines)); - assertTrue(steps.stream().allMatch(l -> l.startsWith("RUN --network=none [\"sh\",\"-c\",\". /etc/cisys-environment || exit 1\\n")), String.join("\n", steps)); - assertTrue(steps.get(0).contains("==> Job: Build and test") && steps.get(0).contains("make platform-start"), steps.get(0)); - } - - @Test - void jobWithAWaitStepIsOneRun() { - var runs = Workflow.parse(VALID).runs(Workflow.WAIT_SECONDS); - assertEquals(1, runs.size()); - String script = runs.get(0); - assertTrue(script.contains("make platform-start") && script.contains("make platform-stop"), script); - } - - static final String DEPENDENCIES = """ - name: w - container: alpine:3.23 - dependencies: - - name: JSON processor - value: jq - - name: Compiler - value: gcc - jobs: - - job: Build + value: step + - name: ONLY_HERE + value: "yes" + run: echo "own=$LEVEL $ONLY_HERE" + - name: After + run: echo "after=$LEVEL ${ONLY_HERE-unset}" + - job: k steps: - - name: Compile - run: make - """; - - private static String installs(String line) { - return line.substring(line.indexOf(",\"dependencies\",") + 16); - } - - @Test - void dependenciesAreInstalledAsRootBeforeTheClone() { - List lines = Workflow.parse(DEPENDENCIES).containerfile(ID, URL, "main", REV, false).lines().toList(); - assertEquals("USER 0", lines.get(1)); - assertTrue(lines.get(3).startsWith("RUN [\"sh\",\"-c\",\". /etc/os-release"), lines.get(3)); - assertEquals("\"JSON processor\",\"jq\",\"Compiler\",\"gcc\"]", installs(lines.get(3))); - // Fresh package metadata, already installed packages upgraded, and the resulting versions logged. - for (String part : List.of("apk add --no-cache --upgrade", "apt-get update", "dnf install -y -q --refresh", "dnf upgrade -y -q", - "zypper --non-interactive refresh", "installed $packages")) { - assertTrue(lines.get(3).contains(part), part); - } - assertTrue(lines.get(4).contains("/etc/cisys-environment"), lines.get(4)); - assertEquals("USER 65534:65534", lines.get(6)); - assertTrue(lines.get(9).contains("\"clone\""), lines.get(9)); - assertEquals(1, lines.stream().filter(l -> l.equals("USER 0")).count(), String.join("\n", lines)); + - name: Other job + run: echo "other=$LEVEL" + """); + var r = runScript(w.script(Workflow.WAIT_SECONDS)); + assertEquals(0, r.code(), r.output()); + assertTrue(r.output().contains("[it's $HOME `x` \\ \"q\"][][job]"), r.output()); + assertTrue(r.output().contains("own=step yes"), r.output()); + assertTrue(r.output().contains("after=job unset"), r.output()); + assertTrue(r.output().contains("other=workflow"), r.output()); + } + + @Test + void environmentIsOptional() { + Workflow w = workflow(" - name: s\n run: true\n"); + assertNull(w.environment()); + assertEquals("", w.exports()); + } + + @Test + void waitStepPassesOnceThePortListens() throws Exception { + try (var server = new java.net.ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress())) { + var r = + runScript( + workflow( + " - name: Wait\n wait: " + + server.getLocalPort() + + "\n - name: After\n run: echo ready\n") + .script(5)); + assertEquals(0, r.code(), r.output()); + assertTrue(r.output().contains("ready"), r.output()); } - - @Test - void dependenciesAreTopLevelOnly() { - for (String yaml : List.of( - DEPENDENCIES.replace(" steps:\n", " dependencies:\n - name: Archiver\n value: zip\n steps:\n"), - DEPENDENCIES.replace(" run: make\n", " run: make\n dependencies:\n - name: Archiver\n value: zip\n"))) { - assertFalse(yaml.equals(DEPENDENCIES), "the test document must differ"); - assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.parse(yaml), yaml); - } + } + + @Test + void variablesAreUnsetWhenTheirScopeEnds() throws Exception { + try (var server = new java.net.ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress())) { + Workflow w = + Workflow.parse( + """ + name: w + container: alpine:3.23 + environment: + - name: LEVEL + value: workflow + jobs: + - job: Steps + environment: + - name: LEVEL + value: job + - name: JOB_ONLY + value: one + steps: + - name: Own + environment: + - name: STEP_ONLY + value: s1 + run: echo "a=$LEVEL $JOB_ONLY $STEP_ONLY" + - name: Next + run: echo "b=$LEVEL $JOB_ONLY ${STEP_ONLY-unset}" + - job: One RUN + environment: + - name: WAIT_JOB + value: two + steps: + - name: Own + environment: + - name: STEP_ONLY + value: s2 + - name: LEVEL + value: step + run: echo "c=$LEVEL $WAIT_JOB $STEP_ONLY ${JOB_ONLY-unset}" + - name: Wait + wait: %d + - name: Next + run: echo "d=$LEVEL $WAIT_JOB ${STEP_ONLY-unset}" + - job: Last + steps: + - name: Last + run: echo "e=$LEVEL ${JOB_ONLY-unset} ${WAIT_JOB-unset} ${STEP_ONLY-unset}" + """ + .formatted(server.getLocalPort())); + var r = runScript(w.script(5)); + assertEquals(0, r.code(), r.output()); + for (String line : + List.of( + "a=job one s1", + "b=job one unset", + "c=step two s2 unset", + "d=workflow two unset", + "e=workflow unset unset unset")) { + assertTrue(r.output().contains(line + "\n"), line + " in\n" + r.output()); + } } + } - @Test - void rejectsInvalidDependencies() { - for (String yaml : List.of( - DEPENDENCIES.replace("value: jq", "value: \"jq; rm -rf /\""), - DEPENDENCIES.replace("value: jq", "value: \"-y\""), - DEPENDENCIES.replace("value: jq", "value: \"\""), - DEPENDENCIES.replace("value: jq", "value: [jq, gcc]"), - DEPENDENCIES.replace(" value: jq\n", ""), - DEPENDENCIES.replace(" - name: JSON processor\n", " - value: jq\n").replace(" value: jq\n value: jq", " value: jq"), - DEPENDENCIES.replace(" - name: Compiler\n", " - name: JSON processor\n"), - DEPENDENCIES.replace("dependencies:\n - name: JSON processor\n value: jq", "dependencies:\n jq: JSON processor"))) { - assertFalse(yaml.equals(DEPENDENCIES), "the test document must differ"); - assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.parse(yaml), yaml); - } - assertEquals("git=2.47.3-0+deb13u1", Workflow.parse(DEPENDENCIES.replace("value: jq", "value: git=2.47.3-0+deb13u1")) - .dependencies().get(0).value()); + @Test + void waitStepFailsWhenThePortNeverListens() throws Exception { + int port; + try (var probe = new java.net.ServerSocket(0)) { + port = probe.getLocalPort(); } - - @Test - void credentialIsMountedForTheCloneOnly() { - String file = Workflow.parse(VALID).containerfile(ID, URL, "main", REV, true); - assertEquals(1, file.split("--mount=type=secret,id=" + Workflow.GIT_SECRET + " ", -1).length - 1, file); - assertTrue(file.contains("/run/secrets/" + Workflow.GIT_SECRET), file); + var r = runScript(workflow(" - name: Wait\n wait: " + port + "\n").script(1)); + assertEquals(1, r.code(), r.output()); + assertTrue(r.output().contains("port " + port + " not listening after 1 seconds"), r.output()); + } + + @Test + void rejectsContainerfilesTooLargeToRun() { + // Each quote grows to five characters in the Containerfile ('\'' in JSON), so 13 steps exceed + // the limit. + String step = " - name: s\n run: x" + "'".repeat(3999) + "\n"; + Workflow w = workflow(step.repeat(13)); + assertThrows( + Workflow.InvalidWorkflowException.class, + () -> w.containerfile(ID, URL, "main", REV, false)); + } + + static final String URL = "https://github.com/example/app"; + static final String REV = "0123456789abcdef0123456789abcdef01234567"; + static final String ID = "164b5c6a230f645862b6123e07cf0a67f2f03371696aa82a12ccf3589e87c016"; + + @Test + void containerfileClonesTheBranchThenRunsEachStepWithoutNetwork() { + List lines = + Workflow.parse(VALID.replace(" wait: 8180\n", " run: make wait\n")) + .containerfile(ID, URL, "feature/x", REV, false) + .lines() + .toList(); + assertEquals("FROM " + ID, lines.get(0)); + assertEquals("USER 0", lines.get(1)); + assertTrue(lines.get(2).startsWith("RUN [\"sh\",\"-c\",\". /etc/os-release"), lines.get(2)); + assertTrue( + lines.get(2).contains("for tool in git make podman podman-compose; do"), lines.get(2)); + // Root writes the variables read-only, before switching to the build user. + assertEquals( + "RUN --network=none [\"sh\",\"-c\",\"printf '%s' \\\"$1\\\" > /etc/cisys-environment && chown 0:0 /etc/cisys-environment && chmod 444 /etc/cisys-environment\",\"environment\",\"export MODE='test'\\nexport PORT='8180'\\n\"]", + lines.get(3)); + assertEquals("USER 65534:65534", lines.get(5)); + assertEquals("WORKDIR /workspace", lines.get(6)); + String clone = lines.get(8); + assertTrue(clone.startsWith("RUN [\"sh\",\"-c\","), clone); + assertTrue(clone.endsWith(",\"clone\",\"" + URL + "\",\"feature/x\",\"" + REV + "\"]"), clone); + assertFalse(clone.contains("secret"), clone); + List steps = lines.subList(9, lines.size()); + assertEquals(4, steps.size(), String.join("\n", lines)); + assertTrue( + steps.stream() + .allMatch(l -> + l.startsWith( + "RUN --network=none [\"sh\",\"-c\",\". /etc/cisys-environment || exit 1\\n")), + String.join("\n", steps)); + assertTrue( + steps.get(0).contains("==> Job: Build and test") + && steps.get(0).contains("make platform-start"), + steps.get(0)); + } + + @Test + void jobWithAWaitStepIsOneRun() { + var runs = Workflow.parse(VALID).runs(Workflow.WAIT_SECONDS); + assertEquals(1, runs.size()); + String script = runs.get(0); + assertTrue( + script.contains("make platform-start") && script.contains("make platform-stop"), script); + } + + static final String DEPENDENCIES = + """ + name: w + container: alpine:3.23 + dependencies: + - name: JSON processor + value: jq + - name: Compiler + value: gcc + jobs: + - job: Build + steps: + - name: Compile + run: make + """; + + private static String installs(String line) { + return line.substring(line.indexOf(",\"dependencies\",") + 16); + } + + @Test + void dependenciesAreInstalledAsRootBeforeTheClone() { + List lines = + Workflow.parse(DEPENDENCIES).containerfile(ID, URL, "main", REV, false).lines().toList(); + assertEquals("USER 0", lines.get(1)); + assertTrue(lines.get(3).startsWith("RUN [\"sh\",\"-c\",\". /etc/os-release"), lines.get(3)); + assertEquals("\"JSON processor\",\"jq\",\"Compiler\",\"gcc\"]", installs(lines.get(3))); + // Fresh package metadata, already installed packages upgraded, and the resulting versions + // logged. + for (String part : + List.of( + "apk add --no-cache --upgrade", + "apt-get update", + "dnf install -y -q --refresh", + "dnf upgrade -y -q", + "zypper --non-interactive refresh", + "installed $packages")) { + assertTrue(lines.get(3).contains(part), part); } - - @Test - void argumentsCannotEscapeTheExecForm() { - String file = workflow(" - name: \"a\\\"]\\nRUN evil\"\n run: echo \"\\\\\\n\"\n") - .containerfile(ID, URL, "main", REV, false); - assertEquals(10, file.lines().count(), file); - assertTrue(file.lines().noneMatch(l -> l.startsWith("RUN evil")), file); + assertTrue(lines.get(4).contains("/etc/cisys-environment"), lines.get(4)); + assertEquals("USER 65534:65534", lines.get(6)); + assertTrue(lines.get(9).contains("\"clone\""), lines.get(9)); + assertEquals( + 1, lines.stream().filter(l -> l.equals("USER 0")).count(), String.join("\n", lines)); + } + + @Test + void dependenciesAreTopLevelOnly() { + for (String yaml : + List.of( + DEPENDENCIES.replace( + " steps:\n", + " dependencies:\n - name: Archiver\n value: zip\n steps:\n"), + DEPENDENCIES.replace( + " run: make\n", + " run: make\n dependencies:\n - name: Archiver\n value: zip\n"))) { + assertFalse(yaml.equals(DEPENDENCIES), "the test document must differ"); + assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.parse(yaml), yaml); } - - @Test - void reportsUnknownKey() { - var e = assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.parse(VALID.replace(" steps:", " stepz:"))); - assertTrue(e.getMessage().contains("stepz"), e.getMessage()); + } + + @Test + void rejectsInvalidDependencies() { + for (String yaml : + List.of( + DEPENDENCIES.replace("value: jq", "value: \"jq; rm -rf /\""), + DEPENDENCIES.replace("value: jq", "value: \"-y\""), + DEPENDENCIES.replace("value: jq", "value: \"\""), + DEPENDENCIES.replace("value: jq", "value: [jq, gcc]"), + DEPENDENCIES.replace(" value: jq\n", ""), + DEPENDENCIES + .replace(" - name: JSON processor\n", " - value: jq\n") + .replace(" value: jq\n value: jq", " value: jq"), + DEPENDENCIES.replace(" - name: Compiler\n", " - name: JSON processor\n"), + DEPENDENCIES.replace( + "dependencies:\n - name: JSON processor\n value: jq", + "dependencies:\n jq: JSON processor"))) { + assertFalse(yaml.equals(DEPENDENCIES), "the test document must differ"); + assertThrows(Workflow.InvalidWorkflowException.class, () -> Workflow.parse(yaml), yaml); } + assertEquals( + "git=2.47.3-0+deb13u1", + Workflow.parse(DEPENDENCIES.replace("value: jq", "value: git=2.47.3-0+deb13u1")) + .dependencies() + .get(0) + .value()); + } + + @Test + void credentialIsMountedForTheCloneOnly() { + String file = Workflow.parse(VALID).containerfile(ID, URL, "main", REV, true); + assertEquals( + 1, file.split("--mount=type=secret,id=" + Workflow.GIT_SECRET + " ", -1).length - 1, file); + assertTrue(file.contains("/run/secrets/" + Workflow.GIT_SECRET), file); + } + + @Test + void argumentsCannotEscapeTheExecForm() { + String file = + workflow(" - name: \"a\\\"]\\nRUN evil\"\n run: echo \"\\\\\\n\"\n") + .containerfile(ID, URL, "main", REV, false); + assertEquals(10, file.lines().count(), file); + assertTrue(file.lines().noneMatch(l -> l.startsWith("RUN evil")), file); + } + + @Test + void reportsUnknownKey() { + var e = + assertThrows( + Workflow.InvalidWorkflowException.class, + () -> Workflow.parse(VALID.replace(" steps:", " stepz:"))); + assertTrue(e.getMessage().contains("stepz"), e.getMessage()); + } } diff --git a/src/frontend/.prettierignore b/src/frontend/.prettierignore new file mode 100644 index 0000000..91a3983 --- /dev/null +++ b/src/frontend/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +package-lock.json diff --git a/src/frontend/.prettierrc.json b/src/frontend/.prettierrc.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/frontend/.prettierrc.json @@ -0,0 +1 @@ +{} diff --git a/src/frontend/eslint.config.js b/src/frontend/eslint.config.js index d30530e..91cf6a7 100644 --- a/src/frontend/eslint.config.js +++ b/src/frontend/eslint.config.js @@ -19,7 +19,13 @@ export default defineConfig( ignores: ["dist/**", "node_modules/**"], }, js.configs.recommended, - { files: ["src/**/*.{js,jsx,ts,tsx}"], languageOptions: { globals: globals.browser, parserOptions: { ecmaFeatures: { jsx: true } } } }, + { + files: ["src/**/*.{js,jsx,ts,tsx}"], + languageOptions: { + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, ...tseslint.configs.recommended, { files: ["**/*.{ts,tsx,jsx}"], @@ -39,4 +45,3 @@ export default defineConfig( }, prettierConfig, ); - diff --git a/src/frontend/index.html b/src/frontend/index.html index 49c40e5..b0e5c32 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -6,4 +6,14 @@ PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. --> -Cisys

+ + + + + Cisys + + +
+ + + diff --git a/src/frontend/package.json b/src/frontend/package.json index 10538ac..b3de580 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -12,6 +12,8 @@ "lint": "eslint src", "test": "vitest run", "check": "npm run lint && npm run test && npm run build", + "format": "prettier --write .", + "format:check": "prettier --check .", "prepare": "cd ../.. && husky" }, "dependencies": { diff --git a/src/frontend/src/BrandingPanel.jsx b/src/frontend/src/BrandingPanel.jsx index a20e675..e52c795 100644 --- a/src/frontend/src/BrandingPanel.jsx +++ b/src/frontend/src/BrandingPanel.jsx @@ -6,17 +6,20 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import React, { useRef, useState } from 'react'; -import { Save, Upload } from 'lucide-react'; -import { Button } from './components/ui/button'; -import { Input } from './components/ui/input'; -import { Label } from './components/ui/label'; -import { Card, CardContent } from './components/ui/card'; +import React, { useRef, useState } from "react"; +import { Save, Upload } from "lucide-react"; +import { Button } from "./components/ui/button"; +import { Input } from "./components/ui/input"; +import { Label } from "./components/ui/label"; +import { Card, CardContent } from "./components/ui/card"; // Matches the backend limit on stored images. const MAX_IMAGE_BYTES = 250000; // The logo keeps transparency as PNG; the background is a photo, so JPEG compresses it far better. -const IMAGE_TARGETS = { logo: { type: 'image/png', maxSide: 512 }, background: { type: 'image/jpeg', maxSide: 2560 } }; +const IMAGE_TARGETS = { + logo: { type: "image/png", maxSide: 512 }, + background: { type: "image/jpeg", maxSide: 2560 }, +}; function readDataUrl(blob) { return new Promise((resolve, reject) => { @@ -32,58 +35,193 @@ async function fitImage(file, { type, maxSide }) { const bitmap = await createImageBitmap(file); try { const { width, height } = bitmap; - if (['image/png', 'image/jpeg'].includes(file.type) && file.size <= MAX_IMAGE_BYTES && Math.max(width, height) <= maxSide) return await readDataUrl(file); - const canvas = document.createElement('canvas'); + if ( + ["image/png", "image/jpeg"].includes(file.type) && + file.size <= MAX_IMAGE_BYTES && + Math.max(width, height) <= maxSide + ) + return await readDataUrl(file); + const canvas = document.createElement("canvas"); let scale = Math.min(1, maxSide / Math.max(width, height)); for (let attempt = 0; attempt < 12; attempt++, scale *= 0.75) { canvas.width = Math.max(1, Math.round(width * scale)); canvas.height = Math.max(1, Math.round(height * scale)); - const context = canvas.getContext('2d'); - if (type === 'image/jpeg') { context.fillStyle = '#fff'; context.fillRect(0, 0, canvas.width, canvas.height); } + const context = canvas.getContext("2d"); + if (type === "image/jpeg") { + context.fillStyle = "#fff"; + context.fillRect(0, 0, canvas.width, canvas.height); + } context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); - for (const quality of type === 'image/jpeg' ? [0.9, 0.8, 0.7] : [undefined]) { - const blob = await new Promise((resolve) => canvas.toBlob(resolve, type, quality)); - if (blob && blob.size <= MAX_IMAGE_BYTES) return await readDataUrl(blob); + for (const quality of type === "image/jpeg" + ? [0.9, 0.8, 0.7] + : [undefined]) { + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, type, quality), + ); + if (blob && blob.size <= MAX_IMAGE_BYTES) + return await readDataUrl(blob); } } - throw new Error('Image too large'); - } finally { bitmap.close(); } + throw new Error("Image too large"); + } finally { + bitmap.close(); + } } export default function BrandingPanel({ branding, save }) { const [draft, setDraft] = useState(branding); - const [error, setError] = useState(''); - const [notice, setNotice] = useState(''); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); const [busy, setBusy] = useState(false); const [fileNames, setFileNames] = useState({}); const inputs = { logo: useRef(null), background: useRef(null) }; - function change(key, value) { setDraft((d) => ({ ...d, [key]: value })); setNotice(''); } + function change(key, value) { + setDraft((d) => ({ ...d, [key]: value })); + setNotice(""); + } async function image(key, file) { - setError(''); setNotice(''); + setError(""); + setNotice(""); if (!file) return; setBusy(true); - try { change(key, await fitImage(file, IMAGE_TARGETS[key])); setFileNames((n) => ({ ...n, [key]: file.name })); } - catch { setError('Unable to read the image.'); } - finally { setBusy(false); } + try { + change(key, await fitImage(file, IMAGE_TARGETS[key])); + setFileNames((n) => ({ ...n, [key]: file.name })); + } catch { + setError("Unable to read the image."); + } finally { + setBusy(false); + } } async function submit(e) { - e.preventDefault(); setBusy(true); setError(''); setNotice(''); - try { const result = await save(draft); setDraft(result); setNotice('Branding saved.'); } - catch (err) { setError(err.message); } finally { setBusy(false); } + e.preventDefault(); + setBusy(true); + setError(""); + setNotice(""); + try { + const result = await save(draft); + setDraft(result); + setNotice("Branding saved."); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } } - return - - {error &&
{error}
} - {notice &&
{notice}
} -

Changes apply to everyone after they refresh or sign in.

-
-
-
change('name', e.target.value)}/>
-
{[['headerColor','Header and footer'],['headingColor','Heading'],['buttonColor','Buttons']].map(([key, label]) =>
change(key, e.target.value)}/>{draft[key]}
)}
- { [['logo','Logo'],['background','Background image']].map(([key, label]) =>
{ void image(key, e.target.files?.[0]); e.target.value = ''; }}/>{draft[key] ? fileNames[key] || 'Current image' : 'None'}
{draft[key] &&
{`${label}
}
)} -
-
-
-
-
; + return ( + + + {error && ( +
+ {error} +
+ )} + {notice && ( +
+ {notice} +
+ )} +

+ Changes apply to everyone after they refresh or sign in. +

+
+
+
+ + change("name", e.target.value)} + /> +
+
+ {[ + ["headerColor", "Header and footer"], + ["headingColor", "Heading"], + ["buttonColor", "Buttons"], + ].map(([key, label]) => ( +
+ +
+ change(key, e.target.value)} + /> + {draft[key]} +
+
+ ))} +
+ {[ + ["logo", "Logo"], + ["background", "Background image"], + ].map(([key, label]) => ( +
+ +
+ { + void image(key, e.target.files?.[0]); + e.target.value = ""; + }} + /> + + {draft[key] ? fileNames[key] || "Current image" : "None"} + + +
+ {draft[key] && ( +
+ {`${label} + +
+ )} +
+ ))} +
+
+ +
+
+
+
+ ); } diff --git a/src/frontend/src/BuildLogView.jsx b/src/frontend/src/BuildLogView.jsx index 531e54a..c76fd05 100644 --- a/src/frontend/src/BuildLogView.jsx +++ b/src/frontend/src/BuildLogView.jsx @@ -6,11 +6,11 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Download, RefreshCw } from 'lucide-react'; -import { Button } from './components/ui/button'; -import { CardTitle } from './components/ui/card'; -import { duration, startedAt } from './build-format'; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Download, RefreshCw } from "lucide-react"; +import { Button } from "./components/ui/button"; +import { CardTitle } from "./components/ui/card"; +import { duration, startedAt } from "./build-format"; const INLINE_LIMIT = 2 * 1024 * 1024; /** A rate-limited page is retried after Retry-After; enough retries for a 1 GiB log over 2 MiB pages. */ @@ -37,7 +37,11 @@ export async function copyLog(api, logPath, sink, wait = sleep) { if (e.status !== 429 || retries >= MAX_RATE_LIMIT_RETRIES) throw e; retries += 1; const seconds = Number(e.retryAfter); - await wait((Number.isFinite(seconds) && seconds > 0 ? seconds : RETRY_FALLBACK_SECONDS) * 1000); + await wait( + (Number.isFinite(seconds) && seconds > 0 + ? seconds + : RETRY_FALLBACK_SECONDS) * 1000, + ); continue; } const reader = response.body.getReader(); @@ -60,13 +64,21 @@ export async function copyLog(api, logPath, sink, wait = sleep) { * and, without the duplicated metadata and inside a scroll box, on the dedicated build page. */ /** With `title`, the title and the log actions share one row. */ -export function BuildLogPanel({ api, logPath, build, onRefresh, showMeta = true, scrollable = false, title }) { +export function BuildLogPanel({ + api, + logPath, + build, + onRefresh, + showMeta = true, + scrollable = false, + title, +}) { const [loaded, setLoaded] = useState(false); const [loading, setLoading] = useState(false); - const [text, setText] = useState(''); + const [text, setText] = useState(""); const [more, setMore] = useState(false); const [tail, setTail] = useState(false); - const [error, setError] = useState(''); + const [error, setError] = useState(""); const scrollRef = useRef(null); // Follow the end of a running log, unless the visitor has scrolled up to read earlier output. const stickToBottom = useRef(true); @@ -77,54 +89,85 @@ export function BuildLogPanel({ api, logPath, build, onRefresh, showMeta = true, // the accepted response instead of skipping output. const requestRef = useRef(0); // Kept across appends so a character split at a page boundary is completed by the next page. - const decoderRef = useRef(new TextDecoder('utf-8')); + const decoderRef = useRef(new TextDecoder("utf-8")); // Mirrors `tail` for the status-change effect, which must not restart from the beginning. const tailRef = useRef(false); - const load = useCallback(async ({ reset = true, running = false } = {}) => { - const request = ++requestRef.current; - const current = () => request === requestRef.current; - setLoading(true); setError(''); - if (reset) { offsetRef.current = 0; decoderRef.current = new TextDecoder('utf-8'); tailRef.current = false; setTail(false); } - const startOffset = offsetRef.current; - // Once the inline view has filled up, a running build switches to the tail so new output stays visible. - const showTail = !reset && running && startOffset >= INLINE_LIMIT; - const remaining = INLINE_LIMIT - startOffset; - if (!showTail && remaining <= 0) { setMore(true); setLoaded(true); setLoading(false); return; } - try { - const params = showTail - ? new URLSearchParams({ tail: String(INLINE_LIMIT) }) - : new URLSearchParams({ offset: String(startOffset), limit: String(remaining) }); - const response = await api(`${logPath}?${params.toString()}`, { raw: true }); - if (!current()) { await response.body?.cancel().catch(() => {}); return; } - const size = Number(response.headers.get('X-Log-Size') || 0); - const reader = response.body.getReader(); - const decoder = showTail ? new TextDecoder('utf-8') : decoderRef.current; - let received = 0, out = ''; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (!current()) { await reader.cancel().catch(() => {}); return; } - received += value.length; - out += decoder.decode(value, { stream: true }); + const load = useCallback( + async ({ reset = true, running = false } = {}) => { + const request = ++requestRef.current; + const current = () => request === requestRef.current; + setLoading(true); + setError(""); + if (reset) { + offsetRef.current = 0; + decoderRef.current = new TextDecoder("utf-8"); + tailRef.current = false; + setTail(false); } - if (!current()) return; - if (showTail) { - offsetRef.current = Math.max(startOffset, size); - setText(out); - tailRef.current = true; - setTail(true); - setMore(false); - } else { - // Only the accepted response moves the offset; a superseded one must not skip output. - offsetRef.current = startOffset + received; - setText((previous) => (reset ? out : previous + out)); - setMore(size > offsetRef.current); + const startOffset = offsetRef.current; + // Once the inline view has filled up, a running build switches to the tail so new output stays visible. + const showTail = !reset && running && startOffset >= INLINE_LIMIT; + const remaining = INLINE_LIMIT - startOffset; + if (!showTail && remaining <= 0) { + setMore(true); + setLoaded(true); + setLoading(false); + return; } - setLoaded(true); - } catch (e) { if (current()) setError(e.message); } - finally { if (current()) setLoading(false); } - }, [api, logPath]); + try { + const params = showTail + ? new URLSearchParams({ tail: String(INLINE_LIMIT) }) + : new URLSearchParams({ + offset: String(startOffset), + limit: String(remaining), + }); + const response = await api(`${logPath}?${params.toString()}`, { + raw: true, + }); + if (!current()) { + await response.body?.cancel().catch(() => {}); + return; + } + const size = Number(response.headers.get("X-Log-Size") || 0); + const reader = response.body.getReader(); + const decoder = showTail + ? new TextDecoder("utf-8") + : decoderRef.current; + let received = 0, + out = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!current()) { + await reader.cancel().catch(() => {}); + return; + } + received += value.length; + out += decoder.decode(value, { stream: true }); + } + if (!current()) return; + if (showTail) { + offsetRef.current = Math.max(startOffset, size); + setText(out); + tailRef.current = true; + setTail(true); + setMore(false); + } else { + // Only the accepted response moves the offset; a superseded one must not skip output. + offsetRef.current = startOffset + received; + setText((previous) => (reset ? out : previous + out)); + setMore(size > offsetRef.current); + } + setLoaded(true); + } catch (e) { + if (current()) setError(e.message); + } finally { + if (current()) setLoading(false); + } + }, + [api, logPath], + ); useEffect(() => { // When a build finishes while the view follows the tail, keep showing the end of the log. @@ -133,25 +176,32 @@ export function BuildLogPanel({ api, logPath, build, onRefresh, showMeta = true, }, [load, build.status]); useEffect(() => { - if (!scrollable || build.status !== 'RUNNING' || !stickToBottom.current) return; + if (!scrollable || build.status !== "RUNNING" || !stickToBottom.current) + return; const element = scrollRef.current; if (element) element.scrollTop = element.scrollHeight; }, [scrollable, build.status, text]); function onLogScroll() { const element = scrollRef.current; - if (element) stickToBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48; + if (element) + stickToBottom.current = + element.scrollHeight - element.scrollTop - element.clientHeight < 48; } async function download() { - setError(''); + setError(""); const name = `build-${build.id}.log`; try { if (window.showSaveFilePicker) { // Ask for the file while the click still counts as a user gesture. let handle; - try { handle = await window.showSaveFilePicker({ suggestedName: name }); } - catch (e) { if (e.name === 'AbortError') return; throw e; } + try { + handle = await window.showSaveFilePicker({ suggestedName: name }); + } catch (e) { + if (e.name === "AbortError") return; + throw e; + } const writable = await handle.createWritable(); try { await copyLog(api, logPath, writable); @@ -164,44 +214,116 @@ export function BuildLogPanel({ api, logPath, build, onRefresh, showMeta = true, return; } const chunks = []; - await copyLog(api, logPath, { write: async (chunk) => { chunks.push(chunk); } }); - const url = URL.createObjectURL(new Blob(chunks, { type: 'text/plain' })); - const a = document.createElement('a'); - a.href = url; a.download = name; a.click(); + await copyLog(api, logPath, { + write: async (chunk) => { + chunks.push(chunk); + }, + }); + const url = URL.createObjectURL(new Blob(chunks, { type: "text/plain" })); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); setTimeout(() => URL.revokeObjectURL(url), 10000); - } catch (e) { setError(e.message); } + } catch (e) { + setError(e.message); + } } async function refresh() { - await load({ reset: false, running: build.status === 'RUNNING' }); + await load({ reset: false, running: build.status === "RUNNING" }); if (onRefresh) await onRefresh(); } return (
- {showMeta &&
-
Branch
-
{build.branch ?? 'Unknown'}
-
Commit
-
{build.revision ?? 'Unknown'}
-
Image
-
{build.image ?? 'Unknown'}{build.imageDigest ? ` (${build.imageDigest})` : ''}
-
} - {build.logTruncated &&

The stored output for this build is incomplete or was cut at the size limit.

} -
+ {showMeta && ( +
+
Branch
+
{build.branch ?? "Unknown"}
+
Commit
+
{build.revision ?? "Unknown"}
+
Image
+
+ {build.image ?? "Unknown"} + {build.imageDigest ? ` (${build.imageDigest})` : ""} +
+
+ )} + {build.logTruncated && ( +

+ The stored output for this build is incomplete or was cut at the size + limit. +

+ )} +
{title && {title}} - {['QUEUED', 'RUNNING'].includes(build.status) && } - + {["QUEUED", "RUNNING"].includes(build.status) && ( + + )} +
- {error &&

{error}

} + {error && ( +

+ {error} +

+ )} {scrollable ? ( -
- {loading && !loaded ?

Loading log…

:
{text}
} +
+ {loading && !loaded ? ( +

Loading log…

+ ) : ( +
{text}
+ )}
- ) : loading && !loaded ?

Loading log…

- :
{text}
} - {tail ?

Showing the latest 2 MiB; download the full log.

- : more &&

Showing the first 2 MiB; download the full log.

} + ) : loading && !loaded ? ( +

Loading log…

+ ) : ( +
+          {text}
+        
+ )} + {tail ? ( +

+ Showing the latest 2 MiB; download the full log. +

+ ) : ( + more && ( +

+ Showing the first 2 MiB; download the full log. +

+ ) + )}
); } @@ -210,12 +332,26 @@ export function BuildLogPanel({ api, logPath, build, onRefresh, showMeta = true, export default function BuildLogView({ api, logPath, build, onRefresh }) { const [open, setOpen] = useState(false); return ( -
setOpen(e.currentTarget.open)}> +
setOpen(e.currentTarget.open)} + > - #{build.id} · {build.status}{build.branch ? ` · ${build.branch}` : ''} · {startedAt(build)} · {duration(build)} - {build.revision ? ` · ${build.revision.slice(0, 12)}` : ''}{build.exitCode != null ? ` · exit ${build.exitCode}` : ''}{build.pinned ? ' · pinned' : ''} + #{build.id} · {build.status} + {build.branch ? ` · ${build.branch}` : ""} · {startedAt(build)} ·{" "} + {duration(build)} + {build.revision ? ` · ${build.revision.slice(0, 12)}` : ""} + {build.exitCode != null ? ` · exit ${build.exitCode}` : ""} + {build.pinned ? " · pinned" : ""} - {open && } + {open && ( + + )}
); } diff --git a/src/frontend/src/BuildLogView.test.jsx b/src/frontend/src/BuildLogView.test.jsx index d76b8af..1e79065 100644 --- a/src/frontend/src/BuildLogView.test.jsx +++ b/src/frontend/src/BuildLogView.test.jsx @@ -1,11 +1,11 @@ -import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import { BuildLogPanel, copyLog } from './BuildLogView'; +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { BuildLogPanel, copyLog } from "./BuildLogView"; function response(text, size) { return { - headers: { get: (name) => (name === 'X-Log-Size' ? String(size) : null) }, + headers: { get: (name) => (name === "X-Log-Size" ? String(size) : null) }, body: new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(text)); @@ -17,107 +17,165 @@ function response(text, size) { function deferred() { let resolve; - const promise = new Promise((done) => { resolve = done; }); + const promise = new Promise((done) => { + resolve = done; + }); return { promise, resolve }; } -describe('BuildLogPanel', () => { - it('ignores a superseded load and resumes from the accepted offset', async () => { +describe("BuildLogPanel", () => { + it("ignores a superseded load and resumes from the accepted offset", async () => { const requests = []; const api = vi.fn((url) => { const request = deferred(); requests.push({ url, ...request }); return request.promise; }); - const logPath = '/api/projects/1/builds/7/log'; + const logPath = "/api/projects/1/builds/7/log"; const { container, rerender } = render( - , + , ); await waitFor(() => expect(requests).toHaveLength(1)); // A build status change starts a second load while the first is still pending. - rerender(); + rerender( + , + ); await waitFor(() => expect(requests).toHaveLength(2)); - expect(requests[0].url).toContain('offset=0'); - expect(requests[1].url).toContain('offset=0'); + expect(requests[0].url).toContain("offset=0"); + expect(requests[1].url).toContain("offset=0"); // The later request wins, so its bytes are shown and the offset moves to 3. - requests[1].resolve(response('abc', 3)); - await waitFor(() => expect(container.querySelector('pre').textContent).toBe('abc')); + requests[1].resolve(response("abc", 3)); + await waitFor(() => + expect(container.querySelector("pre").textContent).toBe("abc"), + ); // The earlier response arrives late and must not append its bytes or move the offset again. - requests[0].resolve(response('abc', 3)); - expect(container.querySelector('pre').textContent).toBe('abc'); + requests[0].resolve(response("abc", 3)); + expect(container.querySelector("pre").textContent).toBe("abc"); // The next refresh asks for the accepted offset, not 6. - const refresh = await screen.findByRole('button', { name: /refresh/i }); + const refresh = await screen.findByRole("button", { name: /refresh/i }); await waitFor(() => expect(refresh.disabled).toBe(false)); fireEvent.click(refresh); await waitFor(() => expect(requests).toHaveLength(3)); - expect(requests[2].url).toContain('offset=3'); + expect(requests[2].url).toContain("offset=3"); - requests[2].resolve(response('', 3)); - await waitFor(() => expect(container.querySelector('pre').textContent).toBe('abc')); + requests[2].resolve(response("", 3)); + await waitFor(() => + expect(container.querySelector("pre").textContent).toBe("abc"), + ); }); - it('retries a rate-limited log page from the same offset', async () => { + it("retries a rate-limited log page from the same offset", async () => { const urls = []; const replies = [ - Object.assign(new Error('Too many requests. Try again later.'), { status: 429, retryAfter: 2 }), - response('abc', 3), - response('', 3), + Object.assign(new Error("Too many requests. Try again later."), { + status: 429, + retryAfter: 2, + }), + response("abc", 3), + response("", 3), ]; const api = vi.fn((url) => { urls.push(url); const next = replies.shift(); - return next instanceof Error ? Promise.reject(next) : Promise.resolve(next); + return next instanceof Error + ? Promise.reject(next) + : Promise.resolve(next); }); const chunks = []; const waits = []; - await copyLog(api, '/log', { write: async (chunk) => { chunks.push(chunk); } }, async (millis) => { waits.push(millis); }); + await copyLog( + api, + "/log", + { + write: async (chunk) => { + chunks.push(chunk); + }, + }, + async (millis) => { + waits.push(millis); + }, + ); - expect(urls).toEqual(['/log?offset=0', '/log?offset=0', '/log?offset=3']); - expect(new TextDecoder().decode(new Uint8Array(chunks.flatMap((chunk) => [...chunk])))).toBe('abc'); + expect(urls).toEqual(["/log?offset=0", "/log?offset=0", "/log?offset=3"]); + expect( + new TextDecoder().decode( + new Uint8Array(chunks.flatMap((chunk) => [...chunk])), + ), + ).toBe("abc"); expect(waits).toEqual([2000]); }); - it('resets the retry budget after a successful page', async () => { - const rateLimited = () => Object.assign( - new Error('Too many requests. Try again later.'), - { status: 429, retryAfter: 0 }, - ); + it("resets the retry budget after a successful page", async () => { + const rateLimited = () => + Object.assign(new Error("Too many requests. Try again later."), { + status: 429, + retryAfter: 0, + }); const urls = []; // Six 429s are the per-page budget; a successful page must restore it, so a second run of six // still completes instead of aborting with the cumulative count. const replies = [ ...Array.from({ length: 6 }, rateLimited), - response('abc', 3), + response("abc", 3), ...Array.from({ length: 6 }, rateLimited), - response('def', 6), - response('', 6), + response("def", 6), + response("", 6), ]; const api = vi.fn((url) => { urls.push(url); const next = replies.shift(); - return next instanceof Error ? Promise.reject(next) : Promise.resolve(next); + return next instanceof Error + ? Promise.reject(next) + : Promise.resolve(next); }); const chunks = []; - await copyLog(api, '/log', { write: async (chunk) => { chunks.push(chunk); } }, async () => {}); + await copyLog( + api, + "/log", + { + write: async (chunk) => { + chunks.push(chunk); + }, + }, + async () => {}, + ); expect(urls).toEqual([ - ...Array(7).fill('/log?offset=0'), - ...Array(7).fill('/log?offset=3'), - '/log?offset=6', + ...Array(7).fill("/log?offset=0"), + ...Array(7).fill("/log?offset=3"), + "/log?offset=6", ]); - expect(new TextDecoder().decode(new Uint8Array(chunks.flatMap((chunk) => [...chunk])))).toBe('abcdef'); + expect( + new TextDecoder().decode( + new Uint8Array(chunks.flatMap((chunk) => [...chunk])), + ), + ).toBe("abcdef"); }); - it('does not retry a download that failed for another reason', async () => { - const api = vi.fn(() => Promise.reject(Object.assign(new Error('Server error'), { status: 500 }))); - await expect(copyLog(api, '/log', { write: async () => {} }, async () => {})).rejects.toThrow('Server error'); + it("does not retry a download that failed for another reason", async () => { + const api = vi.fn(() => + Promise.reject(Object.assign(new Error("Server error"), { status: 500 })), + ); + await expect( + copyLog(api, "/log", { write: async () => {} }, async () => {}), + ).rejects.toThrow("Server error"); expect(api).toHaveBeenCalledTimes(1); }); }); diff --git a/src/frontend/src/BuildStatus.jsx b/src/frontend/src/BuildStatus.jsx index 9a19d34..fbf9acf 100644 --- a/src/frontend/src/BuildStatus.jsx +++ b/src/frontend/src/BuildStatus.jsx @@ -6,26 +6,51 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import React from 'react'; -import { Circle, Clock, RefreshCw, ThumbsDown, ThumbsUp } from 'lucide-react'; +import React from "react"; +import { Circle, Clock, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"; -const LABELS = { PASSED: 'Passed', FAILED: 'Failed', RUNNING: 'Running', QUEUED: 'Queued' }; +const LABELS = { + PASSED: "Passed", + FAILED: "Failed", + RUNNING: "Running", + QUEUED: "Queued", +}; /** Thumbs up for a passing build, thumbs down for a failing one, a spinner while it runs. */ export default function BuildStatus({ status }) { - const label = LABELS[status] || status || 'Unknown'; - const common = { role: 'img', 'aria-label': `Build ${label}`, title: label }; - if (status === 'PASSED') { - return ; + const label = LABELS[status] || status || "Unknown"; + const common = { role: "img", "aria-label": `Build ${label}`, title: label }; + if (status === "PASSED") { + return ( + + + ); } - if (status === 'FAILED') { - return ; + if (status === "FAILED") { + return ( + + + ); } - if (status === 'QUEUED') { - return ; + if (status === "QUEUED") { + return ( + + + ); } - if (status === 'RUNNING') { - return ; + if (status === "RUNNING") { + return ( + + + ); } - return ; + return ( + + + ); } diff --git a/src/frontend/src/ImagesPanel.jsx b/src/frontend/src/ImagesPanel.jsx index 46731cf..423e178 100644 --- a/src/frontend/src/ImagesPanel.jsx +++ b/src/frontend/src/ImagesPanel.jsx @@ -6,107 +6,321 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import React, { useEffect, useState } from 'react'; -import { Container, Download, RefreshCw, Save, Trash2 } from 'lucide-react'; -import { Button } from './components/ui/button'; -import { Spinner } from './components/ui/spinner'; -import { Badge } from './components/ui/badge'; -import { Input } from './components/ui/input'; -import { Card, CardHeader, CardTitle, CardContent } from './components/ui/card'; -import PageHeader from './components/layout/PageHeader'; +import React, { useEffect, useState } from "react"; +import { Container, Download, RefreshCw, Save, Trash2 } from "lucide-react"; +import { Button } from "./components/ui/button"; +import { Spinner } from "./components/ui/spinner"; +import { Badge } from "./components/ui/badge"; +import { Input } from "./components/ui/input"; +import { Card, CardHeader, CardTitle, CardContent } from "./components/ui/card"; +import PageHeader from "./components/layout/PageHeader"; export default function ImagesPanel({ api, onSaved }) { const [local, setLocal] = useState([]); const [selected, setSelected] = useState(new Set()); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); - const [error, setError] = useState(''); - const [notice, setNotice] = useState(''); - const [pullName, setPullName] = useState(''); - const [working, setWorking] = useState(''); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [pullName, setPullName] = useState(""); + const [working, setWorking] = useState(""); async function load() { - setLoading(true); setError(''); - const allowed = await api('/images').catch(() => []); + setLoading(true); + setError(""); + const allowed = await api("/images").catch(() => []); setSelected(new Set(allowed)); - try { setLocal(await api('/images/local')); } - catch (e) { setLocal([]); setError(e.message); } - finally { setLoading(false); } + try { + setLocal(await api("/images/local")); + } catch (e) { + setLocal([]); + setError(e.message); + } finally { + setLoading(false); + } } // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { void load(); }, []); + useEffect(() => { + void load(); + }, []); function toggle(name) { - setNotice(''); - setSelected((s) => { const next = new Set(s); if (next.has(name)) next.delete(name); else next.add(name); return next; }); + setNotice(""); + setSelected((s) => { + const next = new Set(s); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); } async function save() { - setBusy(true); setError(''); setNotice(''); + setBusy(true); + setError(""); + setNotice(""); try { - const saved = await api('/images', { method: 'PUT', body: JSON.stringify({ images: [...selected] }) }); - setSelected(new Set(saved)); onSaved?.(saved); setNotice('Allowed images saved.'); - } catch (e) { setError(e.message); } finally { setBusy(false); } + const saved = await api("/images", { + method: "PUT", + body: JSON.stringify({ images: [...selected] }), + }); + setSelected(new Set(saved)); + onSaved?.(saved); + setNotice("Allowed images saved."); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } } async function pull(event, image) { event?.preventDefault(); const name = image || pullName.trim(); - setWorking(image ? 'pull:' + image : 'pull'); setError(''); setNotice(''); - try { setLocal(await api('/images/local', { method: 'POST', body: JSON.stringify({ name }) })); if (!image) setPullName(''); setNotice(selected.has(name) ? `Pulled ${name}.` : `Pulled ${name}. Allow it below and save to use it for builds.`); } - catch (e) { setError(e.message); } finally { setWorking(''); } + setWorking(image ? "pull:" + image : "pull"); + setError(""); + setNotice(""); + try { + setLocal( + await api("/images/local", { + method: "POST", + body: JSON.stringify({ name }), + }), + ); + if (!image) setPullName(""); + setNotice( + selected.has(name) + ? `Pulled ${name}.` + : `Pulled ${name}. Allow it below and save to use it for builds.`, + ); + } catch (e) { + setError(e.message); + } finally { + setWorking(""); + } } async function remove(name) { - if (!window.confirm(`Remove ${name} from the backend host? It will also be removed from the allowed images.`)) return; - setWorking(name); setError(''); setNotice(''); + if ( + !window.confirm( + `Remove ${name} from the backend host? It will also be removed from the allowed images.`, + ) + ) + return; + setWorking(name); + setError(""); + setNotice(""); try { - setLocal(await api('/images/local?name=' + encodeURIComponent(name), { method: 'DELETE' })); - setSelected((s) => { const next = new Set(s); next.delete(name); return next; }); - onSaved?.(await api('/images')); + setLocal( + await api("/images/local?name=" + encodeURIComponent(name), { + method: "DELETE", + }), + ); + setSelected((s) => { + const next = new Set(s); + next.delete(name); + return next; + }); + onSaved?.(await api("/images")); setNotice(`Removed ${name}.`); - } catch (e) { setError(e.message); } finally { setWorking(''); } + } catch (e) { + setError(e.message); + } finally { + setWorking(""); + } } const localNames = new Set(local.map((i) => i.name)); const missing = [...selected].filter((name) => !localNames.has(name)).sort(); const row = (name, details) => (
- toggle(name)}/> - + toggle(name)} + /> + {details}
); - return
- {selected.size} allowed}/> - {error &&
{error}
} - {notice &&
{notice}
} - - Pull an image - -
-
setPullName(e.target.value)} placeholder="docker.io/library/alpine:3.23"/>
- -
-
-
- - Local images - - {loading ?

Loading images…

: local.length === 0 ? ( -
- ) :
- - {local.map((i) => row(i.name, <>{i.id.slice(0, 12)}{i.size}{i.created}))} -
} - {missing.length > 0 &&
-

Allowed but not present locally. Builds with these images will fail until they are pulled. Clear a box to remove it.

- {missing.map((name) => row(name, <>))} -
} -
- - + return ( +
+ {selected.size} allowed} + /> + {error && ( +
+ {error}
- - -
; + )} + {notice && ( +
+ {notice} +
+ )} + + + Pull an image + + +
+
+ setPullName(e.target.value)} + placeholder="docker.io/library/alpine:3.23" + /> +
+ +
+
+
+ + + Local images + + + {loading ? ( +

Loading images…

+ ) : local.length === 0 ? ( +
+
+ ) : ( +
+ + {local.map((i) => + row( + i.name, + <> + {i.id.slice(0, 12)} + {i.size} + {i.created} + + , + ), + )} +
+ )} + {missing.length > 0 && ( +
+

+ Allowed but not present locally. Builds with + these images will fail until they are pulled. Clear a box to + remove it. +

+ {missing.map((name) => + row( + name, + <> + + + + + , + ), + )} +
+ )} +
+ + +
+
+
+
+ ); } diff --git a/src/frontend/src/ProjectBuild.jsx b/src/frontend/src/ProjectBuild.jsx index 78dcd54..f0d72ff 100644 --- a/src/frontend/src/ProjectBuild.jsx +++ b/src/frontend/src/ProjectBuild.jsx @@ -6,80 +6,153 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import React, { useCallback, useEffect, useState } from 'react'; -import { ArrowLeft, Pin, PinOff } from 'lucide-react'; -import { Button } from './components/ui/button'; -import { Card, CardHeader, CardTitle, CardDescription, CardContent } from './components/ui/card'; -import PageHeader from './components/layout/PageHeader'; -import BuildStatus from './BuildStatus'; -import { BuildLogPanel } from './BuildLogView'; -import { duration, startedAt } from './build-format'; +import React, { useCallback, useEffect, useState } from "react"; +import { ArrowLeft, Pin, PinOff } from "lucide-react"; +import { Button } from "./components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "./components/ui/card"; +import PageHeader from "./components/layout/PageHeader"; +import BuildStatus from "./BuildStatus"; +import { BuildLogPanel } from "./BuildLogView"; +import { duration, startedAt } from "./build-format"; /** Dedicated page for one build, including the expanded log. */ /** `onNotFound()` is called when the build does not exist or the visitor may not see it. */ -export default function ProjectBuild({ name, branch, buildId, load, onBack, onNotFound }) { +export default function ProjectBuild({ + name, + branch, + buildId, + load, + onBack, + onNotFound, +}) { // undefined while loading. const [build, setBuild] = useState(undefined); - const [error, setError] = useState(''); + const [error, setError] = useState(""); const [pinning, setPinning] = useState(false); const loadBuild = useCallback(async () => { try { - setBuild(await load(`/project-pages/${encodeURIComponent(name)}/builds/${buildId}`)); - setError(''); + setBuild( + await load( + `/project-pages/${encodeURIComponent(name)}/builds/${buildId}`, + ), + ); + setError(""); } catch (e) { if (e.status === 404) onNotFound(); - else setError(e.message || 'Unable to load the build.'); + else setError(e.message || "Unable to load the build."); } }, [load, name, buildId, onNotFound]); async function togglePin() { - setPinning(true); setError(''); + setPinning(true); + setError(""); try { - await load(`/projects/${build.projectId}/builds/${build.id}/pin`, { method: build.pinned ? 'DELETE' : 'PUT' }); + await load(`/projects/${build.projectId}/builds/${build.id}/pin`, { + method: build.pinned ? "DELETE" : "PUT", + }); await loadBuild(); - } catch (e) { setError(e.message || 'Unable to change the pin.'); } - finally { setPinning(false); } + } catch (e) { + setError(e.message || "Unable to change the pin."); + } finally { + setPinning(false); + } } useEffect(() => { const initial = setTimeout(loadBuild, 0); const timer = setInterval(loadBuild, 5000); - return () => { clearTimeout(initial); clearInterval(timer); }; + return () => { + clearTimeout(initial); + clearInterval(timer); + }; }, [loadBuild]); if (build === undefined) { - return
{error - ?
{error}
- :

Loading build…

}
; + return ( +
+ {error ? ( +
+ {error} +
+ ) : ( +

+ Loading build… +

+ )} +
+ ); } - const finished = build.finishedAt ? new Date(build.finishedAt).toLocaleString() : 'Not finished'; + const finished = build.finishedAt + ? new Date(build.finishedAt).toLocaleString() + : "Not finished"; return (
- - {build.canPin && } - - }/> - {error &&
{error}
} + + {build.canPin && ( + + )} + + + } + /> + {error && ( +
+ {error} +
+ )} - + {build.status} - {branch ? `${name} · ${branch}` : name} + + {branch ? `${name} · ${branch}` : name} +
Branch
-
{build.branch || 'Unknown'}
+
{build.branch || "Unknown"}
Commit
-
{build.revision ?? 'Unknown'}
+
+ {build.revision ?? "Unknown"} +
Started
{startedAt(build)}
Finished
@@ -87,20 +160,38 @@ export default function ProjectBuild({ name, branch, buildId, load, onBack, onNo
Duration
{duration(build)}
Exit code
-
{build.exitCode ?? '—'}
+
{build.exitCode ?? "—"}
Pinned
-
{build.pinned ? 'Yes' : 'No'}
+
{build.pinned ? "Yes" : "No"}
Image
-
{build.image ?? 'Unknown'}{build.imageDigest ? ` (${build.imageDigest})` : ''}
+
+ {build.image ?? "Unknown"} + {build.imageDigest ? ` (${build.imageDigest})` : ""} +
Workflow
-
{build.workflow ?
{build.workflow}
: build.status === 'QUEUED' ? 'Not read yet' : 'None'}
+
+ {build.workflow ? ( +
{build.workflow}
+ ) : build.status === "QUEUED" ? ( + "Not read yet" + ) : ( + "None" + )} +
- +
diff --git a/src/frontend/src/ProjectBuilds.jsx b/src/frontend/src/ProjectBuilds.jsx index b317be1..5b41177 100644 --- a/src/frontend/src/ProjectBuilds.jsx +++ b/src/frontend/src/ProjectBuilds.jsx @@ -6,19 +6,35 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import React, { useEffect, useRef, useState } from 'react'; -import { GitBranch, Hammer, LockKeyhole, LockKeyholeOpen, Pin } from 'lucide-react'; -import { Badge } from './components/ui/badge'; -import { Card, CardContent } from './components/ui/card'; -import PageHeader from './components/layout/PageHeader'; -import BuildStatus from './BuildStatus'; -import { branchFromSegment, buildPath, duration, shortRevision, startedAt } from './build-format'; +import React, { useEffect, useRef, useState } from "react"; +import { + GitBranch, + Hammer, + LockKeyhole, + LockKeyholeOpen, + Pin, +} from "lucide-react"; +import { Badge } from "./components/ui/badge"; +import { Card, CardContent } from "./components/ui/card"; +import PageHeader from "./components/layout/PageHeader"; +import BuildStatus from "./BuildStatus"; +import { + branchFromSegment, + buildPath, + duration, + shortRevision, + startedAt, +} from "./build-format"; /** Returns the project name for paths like /cisys/ (or /cisys), otherwise null. */ export function projectNameFromPath(pathname) { const match = /^\/([^/]+)\/?$/.exec(pathname); if (!match) return null; - try { return decodeURIComponent(match[1]); } catch { return null; } + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } } /** Returns { name, branch, buildId } for paths like /cisys/master/build/51, otherwise null. */ @@ -31,7 +47,9 @@ export function projectBuildFromPath(pathname) { branch: branchFromSegment(decodeURIComponent(match[2])), buildId: Number(match[3]), }; - } catch { return null; } + } catch { + return null; + } } export function projectPath(name) { @@ -44,14 +62,21 @@ export function projectPath(name) { * either way `onNotFound()` is called. Render it with `key={name}` so state resets per project. * `onBuild(projectId, branch)` starts a build; without it no Build buttons are shown. */ -export default function ProjectBuilds({ name, load, onOpenBuild, onBuild, onNotFound, initialBranch = '' }) { +export default function ProjectBuilds({ + name, + load, + onOpenBuild, + onBuild, + onNotFound, + initialBranch = "", +}) { // undefined while loading. const [page, setPage] = useState(undefined); - const [error, setError] = useState(''); + const [error, setError] = useState(""); const [branches, setBranches] = useState(null); const [branchesError, setBranchesError] = useState(false); const [selectedBranch, setSelectedBranch] = useState(initialBranch); - const [buildsBranch, setBuildsBranch] = useState(''); + const [buildsBranch, setBuildsBranch] = useState(""); const [startingBranch, setStartingBranch] = useState(null); const [reload, setReload] = useState(0); const projectId = page?.project?.id; @@ -59,23 +84,38 @@ export default function ProjectBuilds({ name, load, onOpenBuild, onBuild, onNotF const pageRef = useRef(undefined); // main is the default branch; older repositories still use master. There is no "all branches" view. const branchNames = branches || []; - const defaultBranch = branchNames.includes('main') ? 'main' - : branchNames.includes('master') ? 'master' : (branchNames[0] || ''); + const defaultBranch = branchNames.includes("main") + ? "main" + : branchNames.includes("master") + ? "master" + : branchNames[0] || ""; const activeBranch = selectedBranch || defaultBranch; useEffect(() => { let active = true; let lastFetch = 0; - const branchQuery = activeBranch ? `?branch=${encodeURIComponent(activeBranch)}` : ''; + const branchQuery = activeBranch + ? `?branch=${encodeURIComponent(activeBranch)}` + : ""; const fetchPage = async () => { lastFetch = Date.now(); try { - const data = await load(`/project-pages/${encodeURIComponent(name)}${branchQuery}`); - if (active) { pageRef.current = data; setPage(data); setBuildsBranch(activeBranch); setError(''); } + const data = await load( + `/project-pages/${encodeURIComponent(name)}${branchQuery}`, + ); + if (active) { + pageRef.current = data; + setPage(data); + setBuildsBranch(activeBranch); + setError(""); + } } catch (e) { if (!active) return; - if (e.status === 404) { pageRef.current = null; setPage(null); onNotFound(); } - else setError(e.message || 'Unable to load the project.'); + if (e.status === 404) { + pageRef.current = null; + setPage(null); + onNotFound(); + } else setError(e.message || "Unable to load the project."); } }; void fetchPage(); @@ -83,15 +123,19 @@ export default function ProjectBuilds({ name, load, onOpenBuild, onBuild, onNotF const timer = setInterval(() => { if (document.hidden) return; const builds = pageRef.current?.builds ?? []; - const activeBuild = builds.some((b) => b.status === 'RUNNING' || b.status === 'QUEUED'); + const activeBuild = builds.some( + (b) => b.status === "RUNNING" || b.status === "QUEUED", + ); if (activeBuild || Date.now() - lastFetch >= 30000) void fetchPage(); }, 5000); - const onVisibility = () => { if (!document.hidden) void fetchPage(); }; - document.addEventListener('visibilitychange', onVisibility); + const onVisibility = () => { + if (!document.hidden) void fetchPage(); + }; + document.addEventListener("visibilitychange", onVisibility); return () => { active = false; clearInterval(timer); - document.removeEventListener('visibilitychange', onVisibility); + document.removeEventListener("visibilitychange", onVisibility); }; }, [name, load, activeBranch, reload, onNotFound]); @@ -102,18 +146,36 @@ export default function ProjectBuilds({ name, load, onOpenBuild, onBuild, onNotF let active = true; load(`/project-pages/${encodeURIComponent(name)}/branches`) .then((data) => { - if (active) { setBranches(Array.isArray(data.branches) ? data.branches : []); setBranchesError(false); } + if (active) { + setBranches(Array.isArray(data.branches) ? data.branches : []); + setBranchesError(false); + } }) .catch(() => { - if (active) { setBranches([]); setBranchesError(true); } + if (active) { + setBranches([]); + setBranchesError(true); + } }); - return () => { active = false; }; + return () => { + active = false; + }; }, [name, load, projectId]); if (page === undefined) { - return
{error - ?
{error}
- :

Loading project…

}
; + return ( +
+ {error ? ( +
+ {error} +
+ ) : ( +

+ Loading project… +

+ )} +
+ ); } const { project, builds } = page; @@ -124,52 +186,95 @@ export default function ProjectBuilds({ name, load, onOpenBuild, onBuild, onNotF setStartingBranch(branch); try { await onBuild(project.id, branch); - setError(''); + setError(""); setSelectedBranch(branch); setReload((n) => n + 1); } catch (e) { - setError(e.message || 'Unable to start the build.'); + setError(e.message || "Unable to start the build."); } finally { setStartingBranch(null); } }; - const openBuild = (build) => onOpenBuild?.(project.name, build.branch || activeBranch, build.id); + const openBuild = (build) => + onOpenBuild?.(project.name, build.branch || activeBranch, build.id); return (
- {project.privateRepository - ?