From 323d6fc61e820aa6dda2907e7ac9075746b139d1 Mon Sep 17 00:00:00 2001 From: Andreas Svanberg <andreass@dsv.su.se> Date: Thu, 19 Dec 2024 10:44:48 +0100 Subject: [PATCH] Automate deployment of pull requests (#15) Click link and see that system is working. Log in using the principal `admin@example.com`. Change something in the deployed system. Re-run the action. See that the database has reset. **Major change** Added OAuth 2 login so no longer need modified web.xml with filter. Run `docker compose up` to start the local OAuth 2 authorization server to log in. Use the custom ticket form and enter the username you want to log in as in the "Principal" field. Squashed all migrations since there are faulty ones that can't be applied to an empty database. Reviewed-on: https://gitea.dsv.su.se/DMC/scipro/pulls/15 Reviewed-by: Tom Zhao <tom.zhao@dsv.su.se> Co-authored-by: Andreas Svanberg <andreass@dsv.su.se> Co-committed-by: Andreas Svanberg <andreass@dsv.su.se> --- .gitea/workflows/deploy-branch-cleanup.yaml | 14 ++++ .gitea/workflows/deploy-branch.yaml | 26 +++++++ Dockerfile | 39 ++++++++++ README.md | 10 +++ compose-branch-deploy.yaml | 72 +++++++++++++++++++ war/pom.xml | 72 +++++++++++++++++++ .../main/java/se/su/dsv/scipro/war/Main.java | 19 +++++ .../resources/application-branch.properties | 19 +++++ .../resources/application-tomcat.properties | 1 + war/src/main/resources/application.properties | 18 ++--- 10 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 .gitea/workflows/deploy-branch-cleanup.yaml create mode 100644 .gitea/workflows/deploy-branch.yaml create mode 100644 Dockerfile create mode 100644 compose-branch-deploy.yaml create mode 100644 war/src/main/resources/application-branch.properties create mode 100644 war/src/main/resources/application-tomcat.properties diff --git a/.gitea/workflows/deploy-branch-cleanup.yaml b/.gitea/workflows/deploy-branch-cleanup.yaml new file mode 100644 index 0000000000..764e04ccf2 --- /dev/null +++ b/.gitea/workflows/deploy-branch-cleanup.yaml @@ -0,0 +1,14 @@ +name: Remove branch deployment from branch.dsv.su.se +on: + pull_request: + types: + - closed +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - uses: https://gitea.dsv.su.se/ansv7779/action-branch-deploy@v2 + with: + cleanup-ssh-key: ${{ secrets.BRANCH_CLEANUP_KEY }} + compose-file: compose-branch-deploy.yaml + mode: 'cleanup' diff --git a/.gitea/workflows/deploy-branch.yaml b/.gitea/workflows/deploy-branch.yaml new file mode 100644 index 0000000000..73ab386992 --- /dev/null +++ b/.gitea/workflows/deploy-branch.yaml @@ -0,0 +1,26 @@ +name: Deploy to branch.dsv.su.se +on: + - pull_request +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - id: deploy + uses: https://gitea.dsv.su.se/ansv7779/action-branch-deploy@v2 + with: + ssh-key: ${{ secrets.BRANCH_DEPLOY_KEY }} + compose-file: compose-branch-deploy.yaml + - name: Post URL to deployment as comment + uses: actions/github-script@v7 + if: github.event.action == 'opened' + env: + BRANCH_URL: ${{ steps.deploy.outputs.url }} + with: + script: | + const url = process.env.BRANCH_URL; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `Deployed to ${url}` + }) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..9212410a58 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM debian:bookworm AS build + +RUN apt-get update && apt-get install -y openjdk-17-jdk-headless + +WORKDIR /app + +COPY pom.xml . +COPY .mvn/ .mvn/ +COPY mvnw . +COPY api/pom.xml api/pom.xml +COPY core/pom.xml core/pom.xml +COPY view/pom.xml view/pom.xml +COPY war/pom.xml war/pom.xml +COPY daisy-integration/pom.xml daisy-integration/pom.xml + +# Download dependencies in a separate layer to allow caching for future builds +RUN ./mvnw dependency:go-offline \ + --batch-mode \ + --define includeScope=compile \ + --activate-profiles docker-dependencies + +COPY api/src/ api/src/ +COPY core/src/ core/src/ +COPY view/src/ view/src/ +COPY war/src/ war/src/ +COPY daisy-integration/src/ daisy-integration/src/ + +RUN ./mvnw package \ + --offline \ + --define skipTests \ + --activate-profiles branch,DEV \ + --define skip.npm \ + --define skip.installnodenpm + +FROM tomcat:10 AS run + +COPY --from=build /app/war/target/*.war /usr/local/tomcat/webapps/ROOT.war + +EXPOSE 8080 diff --git a/README.md b/README.md index 22533e79fc..de348d2c3b 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,13 @@ can be performed. Go to `Settings -> Language & Frameworks -> JavaScript -> Prettier` and then check `Automatic Prettier Configuration`, set `Run for files` to `**/*.{java}`, and finally check `Run on save`. + +## Test servers +All pull requests are automatically deployed to a test server. +The URL to the test server will be posted as a comment in the pull request once deployed. + +Prepare test data in the `DataInitializer` class to help others test your changes. +Document (in the pull request) which users to log in as and what to do to see the changes. + +If you want to reset the data to its original state you can re-run the "deploy-branch.yaml" +workflow at https://gitea.dsv.su.se/DMC/scipro/actions for the branch you want to reset. diff --git a/compose-branch-deploy.yaml b/compose-branch-deploy.yaml new file mode 100644 index 0000000000..aba04bbb51 --- /dev/null +++ b/compose-branch-deploy.yaml @@ -0,0 +1,72 @@ +services: + scipro: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + db: + condition: service_healthy + oauth2: + condition: service_started + environment: + - JDBC_DATABASE_URL=jdbc:mariadb://db:3306/scipro + - JDBC_DATABASE_USERNAME=scipro + - JDBC_DATABASE_PASSWORD=scipro + - OAUTH2_AUTHORIZATION_URI=https://oauth2-${VHOST}/authorize + - OAUTH2_TOKEN_URI=https://oauth2-${VHOST}/exchange + - OAUTH2_USER_INFO_URI=https://oauth2-${VHOST}/verify + - OAUTH2_CLIENT_ID=scipro_client + - OAUTH2_CLIENT_SECRET=scipro_secret + - OAUTH2_RESOURCE_SERVER_ID=scipro_api_client + - OAUTH2_RESOURCE_SERVER_SECRET=scipro_api_secret + - OAUTH2_RESOURCE_SERVER_INTROSPECTION_URI=https://oauth2-${VHOST}/introspect + networks: + - traefik + - internal + labels: + - "traefik.enable=true" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${VHOST}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.tls.certresolver=letsencrypt" + + db: + image: mariadb:10.11 + restart: unless-stopped + networks: + - internal + environment: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: scipro + MARIADB_USER: scipro + MARIADB_PASSWORD: scipro + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect"] + start_period: 10s + interval: 10s + timeout: 5s + retries: 6 + + oauth2: + build: + context: https://github.com/dsv-su/toker.git + dockerfile: embedded.Dockerfile + restart: unless-stopped + environment: + - CLIENT_ID=scipro_client + - CLIENT_SECRET=scipro_secret + - CLIENT_REDIRECT_URI=https://${VHOST}/login/oauth2/code/scipro + - RESOURCE_SERVER_ID=scipro_api_client + - RESOURCE_SERVER_SECRET=scipro_api_secret + networks: + - traefik + labels: + - "traefik.enable=true" + - "traefik.http.routers.oauth2-${COMPOSE_PROJECT_NAME}.rule=Host(`oauth2-${VHOST}`)" + - "traefik.http.routers.oauth2-${COMPOSE_PROJECT_NAME}.tls.certresolver=letsencrypt" + +networks: + traefik: + name: traefik + external: true + internal: + name: ${COMPOSE_PROJECT_NAME}_internal diff --git a/war/pom.xml b/war/pom.xml index 456eeae393..19e7317b23 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -12,6 +12,10 @@ <artifactId>war</artifactId> <packaging>war</packaging> + <properties> + <spring.profile.active>tomcat</spring.profile.active> + </properties> + <dependencies> <dependency> <groupId>org.springframework.boot</groupId> @@ -88,7 +92,26 @@ </dependencies> <build> + <resources> + <resource> + <directory>src/main/resources</directory> + <filtering>true</filtering> + </resource> + </resources> <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-resources-plugin</artifactId> + <version>3.3.1</version> + <configuration> + <propertiesEncoding>${project.build.sourceEncoding}</propertiesEncoding> + <delimiters> + <!-- delimiter for resource filtering is changed since Spring hijacks ${...} --> + <delimiter>@</delimiter> + </delimiters> + <useDefaultDelimiters>false</useDefaultDelimiters> + </configuration> + </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> @@ -110,4 +133,53 @@ </plugins> </build> + <profiles> + <profile> + <id>branch</id> + <properties> + <spring.profile.active>branch</spring.profile.active> + </properties> + </profile> + <profile> + <id>docker-dependencies</id> + <!-- + Some dependencies are not discovered by default when running dependency:go-offline. + They are added here manually to allow Docker build layers to be cached properly. + --> + <dependencies> + <dependency> + <groupId>org.jboss.logging</groupId> + <artifactId>jboss-logging</artifactId> + </dependency> + <dependency> + <groupId>com.fasterxml</groupId> + <artifactId>classmate</artifactId> + </dependency> + <dependency> + <groupId>net.bytebuddy</groupId> + <artifactId>byte-buddy-agent</artifactId> + </dependency> + <dependency> + <groupId>com.querydsl</groupId> + <artifactId>querydsl-apt</artifactId> + <version>${querydsl.version}</version> + <classifier>jakarta</classifier> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>net.minidev</groupId> + <artifactId>json-smart</artifactId> + </dependency> + <dependency> + <groupId>org.hamcrest</groupId> + <artifactId>hamcrest-core</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + </profile> + </profiles> </project> diff --git a/war/src/main/java/se/su/dsv/scipro/war/Main.java b/war/src/main/java/se/su/dsv/scipro/war/Main.java index 2a14e64915..e47665cc3a 100644 --- a/war/src/main/java/se/su/dsv/scipro/war/Main.java +++ b/war/src/main/java/se/su/dsv/scipro/war/Main.java @@ -16,12 +16,15 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.boot.autoconfigure.orm.jpa.EntityManagerFactoryBuilderCustomizer; import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.orm.jpa.SharedEntityManagerCreator; import org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter; +import org.springframework.web.filter.ForwardedHeaderFilter; import se.su.dsv.scipro.CoreConfig; import se.su.dsv.scipro.FileSystemStore; import se.su.dsv.scipro.RepositoryConfiguration; @@ -84,6 +87,22 @@ public class Main extends SpringBootServletInitializer implements ServletContain return currentProfile; } + /** + * Spring runs on HTTP and is protected by a HTTPS proxy. + * This filter takes the `X-Forwarded-*` headers and updates the request to reflect the original HTTP request. + * <p> + * Note: This is not needed when we're running behind Apache as a proxy since it uses an AJP connector that has a + * built-in mechanism for handling this, + * see <a href="https://tomcat.apache.org/connectors-doc/common_howto/proxy.html#AJP_as_a_Solution">AJP proxy documentation</a>. + * So this is only for the temporary test servers that are running behind Traefik and use regular HTTP. + */ + @Bean + public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter() { + var filterRegistrationBean = new FilterRegistrationBean<>(new ForwardedHeaderFilter()); + filterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return filterRegistrationBean; + } + @Bean public FileStore fileStore() { return new FileSystemStore(); diff --git a/war/src/main/resources/application-branch.properties b/war/src/main/resources/application-branch.properties new file mode 100644 index 0000000000..0d6cb9b213 --- /dev/null +++ b/war/src/main/resources/application-branch.properties @@ -0,0 +1,19 @@ +spring.datasource.url=${JDBC_DATABASE_URL} +spring.datasource.username=${JDBC_DATABASE_USERNAME} +spring.datasource.password=${JDBC_DATABASE_PASSWORD} + +profile=DEV + +# No secrets available for branch deployment to branch.dsv.su.se +# Will have to set up some mock API for this later +service.grading.url= +oauth.uri= +oauth.clientId= +oauth.clientSecret= +oauth.redirectUri= + +# No secrets available for branch deployment to branch.dsv.su.se +# Will have to set up some mock API for this later +daisy.api.url= +daisy.api.username= +daisy.api.password= diff --git a/war/src/main/resources/application-tomcat.properties b/war/src/main/resources/application-tomcat.properties new file mode 100644 index 0000000000..4e2b4fedce --- /dev/null +++ b/war/src/main/resources/application-tomcat.properties @@ -0,0 +1 @@ +spring.datasource.jndi-name=java:/comp/env/jdbc/sciproDS diff --git a/war/src/main/resources/application.properties b/war/src/main/resources/application.properties index f405136272..bb89287006 100644 --- a/war/src/main/resources/application.properties +++ b/war/src/main/resources/application.properties @@ -1,4 +1,4 @@ -spring.datasource.jndi-name=java:/comp/env/jdbc/sciproDS +spring.profiles.active=@spring.profile.active@ spring.flyway.baseline-version=2 spring.flyway.baseline-on-migrate=true @@ -16,17 +16,17 @@ springdoc.swagger-ui.path=/swagger springdoc.swagger-ui.persist-authorization=true # These will be overwritten by configuration in the environment of servers it is deployed to -spring.security.oauth2.resourceserver.opaquetoken.client-id=scipro-api-client -spring.security.oauth2.resourceserver.opaquetoken.client-secret=scipro-api-secret -spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=http://localhost:59733/introspect +spring.security.oauth2.resourceserver.opaquetoken.client-id=${OAUTH2_RESOURCE_SERVER_ID:scipro-api-client} +spring.security.oauth2.resourceserver.opaquetoken.client-secret=${OAUTH2_RESOURCE_SERVER_SECRET:scipro-api-secret} +spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=${OAUTH2_RESOURCE_SERVER_INTROSPECTION_URI:http://localhost:59733/introspect} # Log in via local OAuth 2 authorization server -spring.security.oauth2.client.provider.docker.user-info-uri=http://localhost:59734/verify +spring.security.oauth2.client.provider.docker.user-info-uri=${OAUTH2_USER_INFO_URI:http://localhost:59734/verify} spring.security.oauth2.client.provider.docker.user-name-attribute=sub -spring.security.oauth2.client.provider.docker.token-uri=http://localhost:59734/exchange -spring.security.oauth2.client.provider.docker.authorization-uri=http://localhost:59734/authorize +spring.security.oauth2.client.provider.docker.token-uri=${OAUTH2_TOKEN_URI:http://localhost:59734/exchange} +spring.security.oauth2.client.provider.docker.authorization-uri=${OAUTH2_AUTHORIZATION_URI:http://localhost:59734/authorize} spring.security.oauth2.client.registration.scipro.redirect-uri={baseUrl}/login/oauth2/code/{registrationId} spring.security.oauth2.client.registration.scipro.provider=docker -spring.security.oauth2.client.registration.scipro.client-id=scipro -spring.security.oauth2.client.registration.scipro.client-secret=s3cr3t +spring.security.oauth2.client.registration.scipro.client-id=${OAUTH2_CLIENT_ID:scipro} +spring.security.oauth2.client.registration.scipro.client-secret=${OAUTH2_CLIENT_SECRET:s3cr3t} spring.security.oauth2.client.registration.scipro.authorization-grant-type=authorization_code