From 8f794d7e1c1e99a4f6afa3b87a9d1410853c1709 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 1 Feb 2024 13:40:54 +0000 Subject: [PATCH] Create GitHub Action for deploying to Artifactory This commit contains a GitHub Action for deploying to Artifactory. It is heavily based on the source of the existing Concourse Artifactory Resource (https://github.com/spring-io/artifactory-resource). It provides for GitHub Actions functionality that is equivalent to the Concourse resource's out support. --- .github/workflows/ci.yaml | 84 +++++ .gitignore | 29 ++ CODE_OF_CONDUCT.adoc | 44 +++ CONTRIBUTING.adoc | 75 ++++ Dockerfile | 9 + LICENSE | 201 ++++++++++ README.adoc | 81 ++++ action.yaml | 62 ++++ build.gradle | 83 +++++ config/checkstyle/checkstyle-suppressions.xml | 7 + config/checkstyle/checkstyle.xml | 10 + gradle.properties | 2 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 249 +++++++++++++ gradlew.bat | 92 +++++ settings.gradle | 1 + src/checkstyle/checkstyle-suppressions.xml | 6 + src/checkstyle/checkstyle.xml | 26 ++ .../ArtifactoryDeployIntegrationTests.java | 169 +++++++++ .../artifactorydeploy/ArtifactoryDeploy.java | 39 ++ .../ArtifactoryDeployProperties.java | 93 +++++ .../DeployableArtifactsSigner.java | 145 ++++++++ .../actions/artifactorydeploy/Deployer.java | 256 +++++++++++++ .../artifactory/Artifactory.java | 66 ++++ .../artifactory/ArtifactoryConfiguration.java | 46 +++ .../artifactory/HttpArtifactory.java | 199 ++++++++++ .../StringToArtifactPropertiesConverter.java | 77 ++++ .../artifactory/package-info.java | 20 + .../artifactory/payload/BuildAgent.java | 36 ++ .../artifactory/payload/BuildArtifact.java | 46 +++ .../artifactory/payload/BuildInfo.java | 65 ++++ .../artifactory/payload/BuildModule.java | 43 +++ .../artifactory/payload/Checksums.java | 63 ++++ .../artifactory/payload/CiAgent.java | 46 +++ .../payload/DeployableArtifact.java | 62 ++++ .../payload/DeployableFileArtifact.java | 96 +++++ .../artifactory/payload/package-info.java | 20 + .../artifactorydeploy/io/Checksum.java | 178 +++++++++ .../io/DirectoryScanner.java | 53 +++ .../actions/artifactorydeploy/io/FileSet.java | 221 +++++++++++ .../artifactorydeploy/io/PathFilter.java | 68 ++++ .../artifactorydeploy/io/package-info.java | 20 + .../maven/MavenBuildModulesGenerator.java | 124 +++++++ .../maven/MavenCoordinates.java | 160 ++++++++ .../maven/MavenVersionType.java | 61 +++ .../artifactorydeploy/maven/package-info.java | 20 + .../openpgp/ArmoredAsciiSigner.java | 344 +++++++++++++++++ .../openpgp/package-info.java | 20 + .../artifactorydeploy/package-info.java | 20 + .../system/ConsoleLogger.java | 44 +++ .../artifactorydeploy/system/DebugLogger.java | 46 +++ .../system/package-info.java | 20 + src/main/resources/application.properties | 3 + .../DeployableArtifactsSignerTests.java | 99 +++++ .../artifactorydeploy/DeployerTests.java | 339 +++++++++++++++++ .../artifactory/ApplicationTests.java | 47 +++ .../artifactory/HttpArtifactoryTests.java | 323 ++++++++++++++++ ...ingToArtifactPropertiesConverterTests.java | 96 +++++ .../payload/BuildArtifactTests.java | 81 ++++ .../artifactory/payload/BuildInfoTests.java | 95 +++++ .../artifactory/payload/BuildModuleTests.java | 68 ++++ .../artifactory/payload/ChecksumsTests.java | 72 ++++ .../payload/DeployableFileArtifactTests.java | 99 +++++ .../artifactorydeploy/io/ChecksumTests.java | 129 +++++++ .../io/DirectoryScannerTests.java | 76 ++++ .../artifactorydeploy/io/FileSetTests.java | 235 ++++++++++++ .../MavenBuildModulesGeneratorTests.java | 103 +++++ .../maven/MavenCoordinatesTests.java | 74 ++++ .../maven/MavenVersionTypeTests.java | 51 +++ .../openpgp/ArmoredAsciiSignerTests.java | 351 ++++++++++++++++++ .../resources/application-test.properties | 4 + .../artifactory/payload/build-artifact.json | 6 + .../artifactory/payload/build-info.json | 21 ++ .../artifactory/payload/build-module.json | 9 + .../actions/artifactorydeploy/io/typical.txt | 51 +++ .../artifactorydeploy/openpgp/expected.asc | 14 + .../artifactorydeploy/openpgp/source.txt | 2 + .../openpgp/test-bad-private.txt | 83 +++++ .../openpgp/test-private.txt | 84 +++++ .../artifactorydeploy/openpgp/test-public.txt | 41 ++ 81 files changed, 6710 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.adoc create mode 100644 CONTRIBUTING.adoc create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.adoc create mode 100644 action.yaml create mode 100644 build.gradle create mode 100644 config/checkstyle/checkstyle-suppressions.xml create mode 100644 config/checkstyle/checkstyle.xml create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/checkstyle/checkstyle-suppressions.xml create mode 100644 src/checkstyle/checkstyle.xml create mode 100644 src/integrationTest/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeployIntegrationTests.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeploy.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeployProperties.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/DeployableArtifactsSigner.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/Deployer.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/Artifactory.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/ArtifactoryConfiguration.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/HttpArtifactory.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/StringToArtifactPropertiesConverter.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/package-info.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildAgent.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildArtifact.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildInfo.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildModule.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/Checksums.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/CiAgent.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableArtifact.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableFileArtifact.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/package-info.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/io/Checksum.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/io/DirectoryScanner.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/io/FileSet.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/io/PathFilter.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/io/package-info.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenBuildModulesGenerator.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenCoordinates.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenVersionType.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/maven/package-info.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/openpgp/ArmoredAsciiSigner.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/openpgp/package-info.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/package-info.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/system/ConsoleLogger.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/system/DebugLogger.java create mode 100644 src/main/java/io/spring/github/actions/artifactorydeploy/system/package-info.java create mode 100644 src/main/resources/application.properties create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/DeployableArtifactsSignerTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/DeployerTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/ApplicationTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/HttpArtifactoryTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/StringToArtifactPropertiesConverterTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildArtifactTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildInfoTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildModuleTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/ChecksumsTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableFileArtifactTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/io/ChecksumTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/io/DirectoryScannerTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/io/FileSetTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenBuildModulesGeneratorTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenCoordinatesTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenVersionTypeTests.java create mode 100644 src/test/java/io/spring/github/actions/artifactorydeploy/openpgp/ArmoredAsciiSignerTests.java create mode 100644 src/test/resources/application-test.properties create mode 100644 src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-artifact.json create mode 100644 src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-info.json create mode 100644 src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-module.json create mode 100644 src/test/resources/io/spring/github/actions/artifactorydeploy/io/typical.txt create mode 100644 src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/expected.asc create mode 100644 src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/source.txt create mode 100644 src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-bad-private.txt create mode 100644 src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-private.txt create mode 100644 src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-public.txt diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c4b2a3a --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,84 @@ +name: CI +on: + - push +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} +jobs: + build: + name: 'Build' + runs-on: 'ubuntu-latest' + steps: + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: 'liberica' + java-version: 17 + - name: Check out code + uses: actions/checkout@v4 + - name: Set up Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 + with: + cache-read-only: false + - name: Build + id: build + run: ./gradlew build + integration-test: + runs-on: ubuntu-latest + name: 'Integration test' + services: + artifactory: + image: docker.bintray.io/jfrog/artifactory-oss:7.12.10 + ports: + - 8081:8081 + steps: + - name: Check out action + uses: actions/checkout@v4 + - name: Create artifacts to deploy + run: | + mkdir -p deployment-repository/com/example/module-a/1.0.0-SNAPSHOT + touch deployment-repository/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT.jar + touch deployment-repository/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT.pom + touch deployment-repository/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT-sources.jar + touch deployment-repository/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT-javadoc.jar + mkdir -p deployment-repository/com/example/module-b/1.0.0-SNAPSHOT + touch deployment-repository/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT.jar + touch deployment-repository/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT.pom + touch deployment-repository/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT-sources.jar + touch deployment-repository/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT-javadoc.jar + - name: Run action + uses: ./ + id: run + with: + uri: 'http://artifactory:8081/artifactory' + username: 'admin' + password: 'password' + build-name: ${{github.action}} + repository: 'example-repo-local' + folder: 'deployment-repository' + signing-key: ${{ secrets.INTEGRATION_TEST_SIGNING_KEY }} + signing-passphrase: ${{ secrets.INTEGRATION_TEST_SIGNING_PASSPHRASE }} + artifact-properties: | + :/**/*.jar:not-jar=true + /**/module-a-*::a=alpha + /**/module-b-*::b=bravo,c=charlie + env: + ACTIONS_STEP_DEBUG: ${{ secrets.ACTIONS_STEP_DEBUG }} + - name: Download artifacts + run: | + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT.jar + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT.jar.asc + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT.pom + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT.pom.asc + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT-sources.jar + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT-sources.jar.asc + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT-javadoc.jar + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-a/1.0.0-SNAPSHOT/module-a-1.0.0-SNAPSHOT-javadoc.jar.asc + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT.jar + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT.jar.asc + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT.pom + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT.pom.asc + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT-sources.jar + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT-sources.jar.asc + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT-javadoc.jar + wget http://admin:password@localhost:8081/artifactory/example-repo-local/com/example/module-b/1.0.0-SNAPSHOT/module-b-1.0.0-SNAPSHOT-javadoc.jar.asc + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd3dae8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +.gradle +build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/CODE_OF_CONDUCT.adoc b/CODE_OF_CONDUCT.adoc new file mode 100644 index 0000000..17783c7 --- /dev/null +++ b/CODE_OF_CONDUCT.adoc @@ -0,0 +1,44 @@ += Contributor Code of Conduct + +As contributors and maintainers of this project, and in the interest of fostering an open +and welcoming community, we pledge to respect all people who contribute through reporting +issues, posting feature requests, updating documentation, submitting pull requests or +patches, and other activities. + +We are committed to making participation in this project a harassment-free experience for +everyone, regardless of level of experience, gender, gender identity and expression, +sexual orientation, disability, personal appearance, body size, race, ethnicity, age, +religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, such as physical or electronic addresses, + without explicit permission +* Other unethical or unprofessional conduct + +Project maintainers have the right and responsibility to remove, edit, or reject comments, +commits, code, wiki edits, issues, and other contributions that are not aligned to this +Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors +that they deem inappropriate, threatening, offensive, or harmful. + +By adopting this Code of Conduct, project maintainers commit themselves to fairly and +consistently applying these principles to every aspect of managing this project. Project +maintainers who do not follow or enforce the Code of Conduct may be permanently removed +from the project team. + +This Code of Conduct applies both within project spaces and in public spaces when an +individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by +contacting a project maintainer at spring-code-of-conduct@pivotal.io . All complaints will +be reviewed and investigated and will result in a response that is deemed necessary and +appropriate to the circumstances. Maintainers are obligated to maintain confidentiality +with regard to the reporter of an incident. + +This Code of Conduct is adapted from the +https://contributor-covenant.org[Contributor Covenant], version 1.3.0, available at +https://contributor-covenant.org/version/1/3/0/[contributor-covenant.org/version/1/3/0/] diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc new file mode 100644 index 0000000..255391b --- /dev/null +++ b/CONTRIBUTING.adoc @@ -0,0 +1,75 @@ += Contributing + +This project is released under the Apache 2.0 license. +If you would like to contribute something, or simply want to hack on the code this document should help you get started. + + + +== Code of Conduct +This project adheres to the Contributor Covenant link:CODE_OF_CONDUCT.adoc[code of conduct]. +By participating, you are expected to uphold this code. +Please report unacceptable behavior to spring-code-of-conduct@pivotal.io. + + + +== Using GitHub issues +We use GitHub issues to track bugs and enhancements. +If you are reporting a bug, please help to speed up problem diagnosis by providing as much information as possible. +Ideally, that would include a small sample project that reproduces the problem. + + + +== Sign the Contributor License Agreement +Before we accept a non-trivial patch or pull request we will need you to https://cla.pivotal.io/sign/spring[sign the Contributor License Agreement]. +Signing the contributor's agreement does not grant anyone commit rights to the main repository, but it does mean that we can accept your contributions, and you will get an author credit if we do. +Active contributors might be asked to join the core team, and given the ability to merge pull requests. + + + +== Code Conventions and Housekeeping +None of these is essential for a pull request, but they will all help. +They can also be added after the original pull request but before a merge. + +* Make sure all new `.java` files to have a simple Javadoc class comment with at least an `@author` tag identifying you, and preferably at least a paragraph on what the class is for. +* Add the ASF license header comment to all new `.java` files (copy from existing files in the project) +* Add yourself as an `@author` to the `.java` files that you modify substantially (more than cosmetic changes). +* Add some Javadocs. +* A few unit tests would help a lot as well -- someone has to do it. +* If no-one else is using your branch, please rebase it against the current main (or other target branch in the main project). +* When writing a commit message please follow https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html[these conventions], if you are fixing an existing issue please add `Fixes gh-XXXX` at the end of the commit message (where `XXXX` is the issue number). + + + +== Working with the code +If you don't have an IDE preference we would recommend that you use https://spring.io/tools/sts[Spring Tools Suite] or https://eclipse.org[Eclipse] when working with the code. +We use Buildship for Gradle support. +Other IDEs and tools with Gradle support should also work without issue. + + + +=== Building from source +To build the source you will need to install JDK 17. +The project is built with Gradle. Using the wrapper that is included in the project's source is strongly recommended. + +The project can be built from the root directory using the standard Gradle command: + +[indent=0] +---- + $ ./gradlew build +---- + + + +==== Integration tests +Docker is required for the integration tests that run as part of the default build. +If you don't have Docker installed, those tests will be automatically skipped. + + + +=== Importing into Eclipse +You can import the resource's code into any Eclipse based distribution with Buildship, the Eclipse Gradle tooling, installed. + + + +=== Importing into other IDEs +Gradle is well supported by most Java IDEs. Refer to your vendor documentation. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6bd49bd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM gradle:8.5.0-jdk21-alpine as build +COPY src /app/src/ +COPY config /app/config/ +COPY build.gradle settings.gradle gradle.properties /app/ +RUN cd /app && gradle -Dorg.gradle.welcome=never --no-daemon bootJar + +FROM ghcr.io/bell-sw/liberica-openjdk-debian:21.0.2-14 +COPY --from=build /app/build/libs/artifactory-deploy-action.jar /opt/action/artifactory-deploy.jar +ENTRYPOINT ["java", "-jar", "/opt/action/artifactory-deploy.jar"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9b259bd --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..1b7fae3 --- /dev/null +++ b/README.adoc @@ -0,0 +1,81 @@ += Artifactory Deploy Action + +A https://docs.github.com/en/actions[GitHub action] for deploying to a https://www.jfrog.com/artifactory/[JFrog Artifactory] server. + + + +== Overview + +This action can be used to deploy artifacts to a JFrog artifactory server. +It makes use of the "builds" and "artifact properties" features of Artifactory to link deployed artifacts to their builds. + + + +== Configuration + + + +=== Required Inputs + +- `uri`: URI of the Artifactory server +- `username`: Username for authentication with Artifactory +- `password`: Password for authentication with Artifactory +- `build-name`: Name of the build +- `repository`: Artifactory repository to which the artifacts should be deployed +- `folder`: Folder containing the artifacts to deploy + + + +=== Optional Inputs + +- `artifact-properties`: Properties to apply to the deployed artifacts. + Each line should be of the form `::`. + `includes` and `excludes` are comma-separated Ant patterns. + `properties` is a comma-separated list of `key=value` pairs +- `build-uri`: URI of the build that produced that artifacts that are to be deployed. + Defaults to the URI of the current workflow run +- `project`: Artifactory project in which the build info should be stored +- `threads`: Number of threads to use when deploying artifacts. + Defaults to 1 +- `signing-key`: A PGP/GPG signing key that will be used to sign artifacts before they are deployed +- `signing-passphrase`: Passphrase of the signing key + + + +=== Minimal Example + +[source,yaml,indent=0] +---- +steps: + - name: Deploy + uses: spring-io/artifactory-deploy-action@main + with: + uri: 'https://repo.example.com' + username: ${{ secrets.ARTIFACTORY_USERNAME }} + password: ${{ secrets.ARTIFACTORY_PASSWORD }} + build-name: 'example-build' + repository: 'test-libs-snapshot-local' + folder: 'deployment-repository' +---- + + + +=== Debugging + +The action uses the `ACTION_STEPS_DEBUG` environment variable to enable additional debug logging. +This can be configured by passing through the value of the `ACTION_STEPS_DEBUG` secret that GitHub Actions sets when re-running with debug logging enabled: + +[source,yaml,indent=0] +---- +steps: + - name: Deploy + uses: spring-io/artifactory-deploy-action@main + env: + ACTION_STEPS_DEBUG: ${{ secrets.ACTION_STEPS_DEBUG }} +---- + + + +== License + +Artifactory Deploy Action is Open Source software released under the https://www.apache.org/licenses/LICENSE-2.0.html[Apache 2.0 license]. diff --git a/action.yaml b/action.yaml new file mode 100644 index 0000000..38ff8aa --- /dev/null +++ b/action.yaml @@ -0,0 +1,62 @@ +name: 'Artifactory Deploy' +description: 'Deploy artifacts to Artifactory' +inputs: + uri: + description: 'URI of the Artifactory server' + required: true + username: + description: 'Artifactory username' + required: true + password: + description: 'Artifactory password' + required: true + build-name: + description: 'Name of the build' + required: true + build-number: + description: 'Number of the build' + required: true + default: ${{ github.run_number }} + build-uri: + description: 'URI of the build that produced the artifacts that are to be deployed' + required: false + default: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} + folder: + description: 'Folder containing the artifacts to deploy' + required: true + project: + description: 'Project in which build info should be stored' + required: false + repository: + description: 'Artifactory repository to which the artifacts should be deployed' + threads: + description: 'Number of threads to use when deploying artifacts' + required: false + default: 1 + signing-key: + description: 'A PGP/GPG signing key that will be used to sign artifacts before they are deployed' + required: false + signing-passphrase: + description: 'Passphrase of the signing key' + required: false + artifact-properties: + description: 'Properties to apply to the deployed artifacts. Each line should be of the form + `::. includes and excludes are comma-separated Ant patterns. + properties is a comma-separated list of key=value pairs' +runs: + using: 'docker' + image: 'Dockerfile' + args: + - --artifactory.server.uri=${{ inputs.uri }} + - --artifactory.server.username=${{ inputs.username }} + - --artifactory.server.password=${{ inputs.password }} + - --artifactory.deploy.artifact-properties=${{ inputs.artifact-properties }} + - --artifactory.deploy.build.name=${{ inputs.build-name }} + - --artifactory.deploy.build.number=${{ inputs.build-number }} + - --artifactory.deploy.build.uri=${{ inputs.build-uri }} + - --artifactory.deploy.folder=${{ inputs.folder }} + - --artifactory.deploy.project=${{ inputs.project }} + - --artifactory.deploy.repository=${{ inputs.repository }} + - --artifactory.deploy.threads=${{ inputs.threads }} + - --artifactory.signing.key=${{ inputs.signing-key }} + - --artifactory.signing.passphrase=${{ inputs.signing-passphrase }} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..4187c22 --- /dev/null +++ b/build.gradle @@ -0,0 +1,83 @@ +plugins { + id "checkstyle" + id "io.spring.javaformat" version "$javaFormatVersion" + id "java" + id "org.springframework.boot" version "3.2.2" +} + +repositories { + mavenCentral() +} + +java { + sourceCompatibility = '17' +} + +checkstyle { + toolVersion = "10.13.0" +} + +def integrationTest = sourceSets.create("integrationTest") { + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output +} + +configurations { + checkstyle { + resolutionStrategy.capabilitiesResolution.withCapability("com.google.collections:google-collections") { + select("com.google.guava:guava:0") + } + } + integrationTestImplementation { + extendsFrom(testImplementation) + } + integrationTestRuntimeOnly { + extendsFrom(testRuntimeOnly) + } +} + +dependencies { + checkstyle("com.puppycrawl.tools:checkstyle:${checkstyle.toolVersion}") + checkstyle("io.spring.javaformat:spring-javaformat-checkstyle:${javaFormatVersion}") + + implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)) + + implementation("org.bouncycastle:bcpg-jdk18on:1.77") + implementation("org.springframework:spring-web") + implementation("org.springframework.boot:spring-boot-starter-json") + + integrationTestImplementation("org.testcontainers:junit-jupiter") + integrationTestImplementation("org.testcontainers:testcontainers") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.withType(Test) { + useJUnitPlatform() +} + +tasks.named("jar") { + enabled = false; +} + +tasks.named("bootJar") { + archiveVersion = "" +} + +tasks.register('integrationTest', Test) { + description = "Runs integration tests" + group = "verification" + testClassesDirs = integrationTest.output.classesDirs + classpath = integrationTest.runtimeClasspath + shouldRunAfter(tasks.named('test')) +} + +tasks.named("check") { + dependsOn(tasks.named("integrationTest")) +} + +tasks.withType(JavaCompile) { + options.compilerArgs = ["-parameters", "-Xlint:all"] +} diff --git a/config/checkstyle/checkstyle-suppressions.xml b/config/checkstyle/checkstyle-suppressions.xml new file mode 100644 index 0000000..be8d110 --- /dev/null +++ b/config/checkstyle/checkstyle-suppressions.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..6196048 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3836337 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +javaFormatVersion=0.0.41 +version=0.0.1-SNAPSHOT diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..8dba0c8 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'artifactory-deploy-action' diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml new file mode 100644 index 0000000..e54158a --- /dev/null +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src/checkstyle/checkstyle.xml b/src/checkstyle/checkstyle.xml new file mode 100644 index 0000000..2d6de3b --- /dev/null +++ b/src/checkstyle/checkstyle.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/integrationTest/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeployIntegrationTests.java b/src/integrationTest/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeployIntegrationTests.java new file mode 100644 index 0000000..8c43958 --- /dev/null +++ b/src/integrationTest/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeployIntegrationTests.java @@ -0,0 +1,169 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.boot.test.json.BasicJsonTester; +import org.springframework.boot.test.json.JsonContent; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link ArtifactoryDeploy} that invoke its main method. + * + * @author Andy Wilkinson + */ +@Testcontainers(disabledWithoutDocker = true) +class ArtifactoryDeployIntegrationTests { + + @Container + static GenericContainer container = new GenericContainer<>("docker.bintray.io/jfrog/artifactory-oss:7.12.10") + .waitingFor(Wait.forHttp("/artifactory/api/system/ping").withStartupTimeout(Duration.ofMinutes(15))) + .withExposedPorts(8081); + + @Test + void deploy(@TempDir File temp) throws IOException { + File example = new File(temp, "com/example/module/1.0.0"); + example.mkdirs(); + Files.writeString(new File(example, "module-1.0.0.jar").toPath(), "jar-file-content"); + Files.writeString(new File(example, "module-1.0.0.pom").toPath(), "pom-file-content"); + ArtifactoryDeploy.main(new String[] { + String.format("--artifactory.server.uri=http://%s:%s/artifactory", container.getHost(), + container.getFirstMappedPort()), + "--artifactory.server.username=admin", "--artifactory.server.password=password", + "--artifactory.deploy.repository=example-repo-local", "--artifactory.deploy.build.number=12", + "--artifactory.deploy.build.name=integration-test", "--artifactory.deploy.folder=" + temp, + "--artifactory.deploy.threads=2" }); + RestTemplate rest = new RestTemplateBuilder().basicAuthentication("admin", "password") + .rootUri("http://%s:%s/artifactory/".formatted(container.getHost(), container.getFirstMappedPort())) + .build(); + assertThat(rest.getForObject("/example-repo-local/com/example/module/1.0.0/module-1.0.0.jar", String.class)) + .isEqualTo("jar-file-content"); + assertThat(rest.getForObject("/example-repo-local/com/example/module/1.0.0/module-1.0.0.pom", String.class)) + .isEqualTo("pom-file-content"); + JsonContent buildInfoJson = new BasicJsonTester(getClass()) + .from(rest.getForObject("/api/build/integration-test/12", String.class)); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.name").isEqualTo("integration-test"); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.number").isEqualTo("12"); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.buildAgent.name").isEqualTo("Artifactory Action"); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.agent.name").isEqualTo("GitHub Actions"); + assertThat(buildInfoJson).extractingJsonPathArrayValue("buildInfo.modules").hasSize(1); + assertThat(buildInfoJson).extractingJsonPathArrayValue("buildInfo.modules.[0].artifacts").hasSize(2); + } + + @Test + void deployWithArtifactProeprties(@TempDir File temp) throws IOException { + File example = new File(temp, "com/example/example-docs/1.0.0"); + example.mkdirs(); + Files.writeString(new File(example, "example-docs-1.0.0.zip").toPath(), "jar-file-content"); + Files.writeString(new File(example, "example-docs-1.0.0.pom").toPath(), "pom-file-content"); + ArtifactoryDeploy.main(new String[] { + String.format("--artifactory.server.uri=http://%s:%s/artifactory", container.getHost(), + container.getFirstMappedPort()), + "--artifactory.server.username=admin", "--artifactory.server.password=password", + "--artifactory.deploy.repository=example-repo-local", "--artifactory.deploy.build.number=13", + "--artifactory.deploy.build.name=integration-test", "--artifactory.deploy.folder=" + temp, + "--artifactory.deploy.threads=2", + "--artifactory.deploy.artifact-properties=/**/example-docs-*.zip::zip.type=docs,zip.deployed=false" }); + RestTemplate rest = new RestTemplateBuilder().basicAuthentication("admin", "password") + .rootUri("http://%s:%s/artifactory/".formatted(container.getHost(), container.getFirstMappedPort())) + .build(); + assertThat(rest.getForObject("/example-repo-local/com/example/example-docs/1.0.0/example-docs-1.0.0.zip", + String.class)) + .isEqualTo("jar-file-content"); + assertThat(rest.getForObject("/example-repo-local/com/example/example-docs/1.0.0/example-docs-1.0.0.pom", + String.class)) + .isEqualTo("pom-file-content"); + String buildInfo = rest.getForObject("/api/build/integration-test/13", String.class); + BasicJsonTester jsonTester = new BasicJsonTester(getClass()); + JsonContent buildInfoJson = jsonTester.from(buildInfo); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.name").isEqualTo("integration-test"); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.number").isEqualTo("13"); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.buildAgent.name").isEqualTo("Artifactory Action"); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.agent.name").isEqualTo("GitHub Actions"); + assertThat(buildInfoJson).extractingJsonPathArrayValue("buildInfo.modules").hasSize(1); + assertThat(buildInfoJson).extractingJsonPathArrayValue("buildInfo.modules.[0].artifacts").hasSize(2); + String zipProperties = rest.getForObject( + "/api/storage/example-repo-local/com/example/example-docs/1.0.0/example-docs-1.0.0.zip?properties", + String.class); + JsonContent zipPropertiesJson = jsonTester.from(zipProperties); + assertThat(zipPropertiesJson).extractingJsonPathMapValue("properties") + .containsOnlyKeys("build.name", "build.number", "build.timestamp", "zip.deployed", "zip.type"); + assertThat(zipPropertiesJson).extractingJsonPathArrayValue("properties['zip.deployed']") + .containsExactly("false"); + assertThat(zipPropertiesJson).extractingJsonPathArrayValue("properties['zip.type']").containsExactly("docs"); + String pomProperties = rest.getForObject( + "/api/storage/example-repo-local/com/example/example-docs/1.0.0/example-docs-1.0.0.pom?properties", + String.class); + JsonContent pomPropertiesJson = jsonTester.from(pomProperties); + assertThat(pomPropertiesJson).extractingJsonPathMapValue("properties") + .containsOnlyKeys("build.name", "build.number", "build.timestamp"); + } + + @Test + void deployWithSignatures(@TempDir File temp) throws IOException { + File example = new File(temp, "com/example/module/1.0.0"); + example.mkdirs(); + Files.writeString(new File(example, "module-1.0.0.jar").toPath(), "jar-file-content"); + Files.writeString(new File(example, "module-1.0.0.pom").toPath(), "pom-file-content"); + ArtifactoryDeploy.main(new String[] { + String.format("--artifactory.server.uri=http://%s:%s/artifactory", container.getHost(), + container.getFirstMappedPort()), + "--artifactory.server.username=admin", "--artifactory.server.password=password", + "--artifactory.deploy.repository=example-repo-local", "--artifactory.deploy.build.number=14", + "--artifactory.deploy.build.name=integration-test", "--artifactory.deploy.folder=" + temp, + "--artifactory.deploy.threads=2", + "--artifactory.signing.key=" + Files.readString(Path.of("src", "test", "resources", "io", "spring", + "github", "actions", "artifactorydeploy", "openpgp", "test-private.txt")), + "--artifactory.signing.passphrase=password" }); + RestTemplate rest = new RestTemplateBuilder().basicAuthentication("admin", "password") + .rootUri("http://%s:%s/artifactory/".formatted(container.getHost(), container.getFirstMappedPort())) + .build(); + assertThat(rest.getForObject("/example-repo-local/com/example/module/1.0.0/module-1.0.0.jar", String.class)) + .isEqualTo("jar-file-content"); + assertThat(rest.getForObject("/example-repo-local/com/example/module/1.0.0/module-1.0.0.pom", String.class)) + .isEqualTo("pom-file-content"); + assertThat(rest.getForObject("/example-repo-local/com/example/module/1.0.0/module-1.0.0.jar.asc", byte[].class)) + .isNotEmpty(); + assertThat(rest.getForObject("/example-repo-local/com/example/module/1.0.0/module-1.0.0.pom.asc", byte[].class)) + .isNotEmpty(); + String buildInfo = rest.getForObject("/api/build/integration-test/14", String.class); + BasicJsonTester jsonTester = new BasicJsonTester(getClass()); + JsonContent buildInfoJson = jsonTester.from(buildInfo); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.name").isEqualTo("integration-test"); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.number").isEqualTo("14"); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.buildAgent.name").isEqualTo("Artifactory Action"); + assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.agent.name").isEqualTo("GitHub Actions"); + assertThat(buildInfoJson).extractingJsonPathArrayValue("buildInfo.modules").hasSize(1); + assertThat(buildInfoJson).extractingJsonPathArrayValue("buildInfo.modules.[0].artifacts").hasSize(4); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeploy.java b/src/main/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeploy.java new file mode 100644 index 0000000..0ce74a6 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeploy.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * Main Application entry point. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +@SpringBootApplication +@EnableConfigurationProperties(ArtifactoryDeployProperties.class) +public class ArtifactoryDeploy { + + public static void main(String[] args) { + ConfigurableApplicationContext app = SpringApplication.run(ArtifactoryDeploy.class, args); + app.getBean(Deployer.class).deploy(); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeployProperties.java b/src/main/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeployProperties.java new file mode 100644 index 0000000..9ea61eb --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/ArtifactoryDeployProperties.java @@ -0,0 +1,93 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy; + +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.util.Assert; + +/** + * Configuration properties for deploying to Artifactory. + * + * @param server server properties + * @param signing signing properties + * @param deploy deploy properties + * @author Andy Wilkinson + */ +@ConfigurationProperties(prefix = "artifactory") +public record ArtifactoryDeployProperties(@DefaultValue ArtifactoryDeployProperties.Server server, + @DefaultValue ArtifactoryDeployProperties.Signing signing, + @DefaultValue ArtifactoryDeployProperties.Deploy deploy) { + + public record Server(URI uri, String username, String password) { + + public Server(URI uri, String username, String password) { + Assert.notNull(uri, "artifactory.server.uri is required"); + this.uri = uri; + this.username = username; + this.password = password; + } + + } + + public record Signing(String key, String passphrase) { + } + + public record Deploy(String project, String folder, String repository, int threads, Deploy.Build build, + List artifactProperties) { + + public Deploy(String project, String folder, String repository, @DefaultValue("1") int threads, + @DefaultValue Deploy.Build build, List artifactProperties) { + Assert.hasText(folder, "artifactory.deploy.folder is required"); + Assert.hasText(repository, "artifactory.deploy.repository is required"); + this.project = project; + this.folder = folder; + this.repository = repository; + this.threads = threads; + this.build = build; + this.artifactProperties = (artifactProperties != null) ? artifactProperties : Collections.emptyList(); + } + + public record Build(String name, int number, URI uri) { + + public Build(String name, int number, URI uri) { + Assert.hasText(name, "artifactory.deploy.build.name is required"); + this.name = name; + this.number = number; + this.uri = uri; + } + + } + + public record ArtifactProperties(List include, List exclude, Map properties) { + + public ArtifactProperties(List include, List exclude, Map properties) { + this.include = (include != null) ? include : Collections.emptyList(); + this.exclude = (exclude != null) ? exclude : Collections.emptyList(); + this.properties = (properties != null) ? properties : Collections.emptyMap(); + } + + } + + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/DeployableArtifactsSigner.java b/src/main/java/io/spring/github/actions/artifactorydeploy/DeployableArtifactsSigner.java new file mode 100644 index 0000000..4892059 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/DeployableArtifactsSigner.java @@ -0,0 +1,145 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import io.spring.github.actions.artifactorydeploy.artifactory.payload.Checksums; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableArtifact; +import io.spring.github.actions.artifactorydeploy.io.FileSet.Category; +import io.spring.github.actions.artifactorydeploy.openpgp.ArmoredAsciiSigner; +import io.spring.github.actions.artifactorydeploy.system.ConsoleLogger; + +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Utility to sign a set of batched {@link DeployableArtifact DeployableArtifacts}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class DeployableArtifactsSigner { + + private static final String FILE_EXTENSION = ".asc"; + + private static final ConsoleLogger console = new ConsoleLogger(); + + private static final File temp; + + private final Map buildProperties; + + static { + try { + temp = Files.createTempDirectory("artifactory-resource-asc").toFile(); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private final ArmoredAsciiSigner signer; + + DeployableArtifactsSigner(ArmoredAsciiSigner signer, Map buildProperties) { + this.signer = signer; + this.buildProperties = buildProperties; + } + + MultiValueMap addSignatures( + MultiValueMap batchedArtifacts) { + Assert.state(CollectionUtils.isEmpty(batchedArtifacts.get(Category.SIGNATURE)), + "Files must not already be signed"); + List signed = batchedArtifacts.values() + .stream() + .flatMap(List::stream) + .map(ArtifactSignature::new) + .collect(Collectors.toList()); + LinkedMultiValueMap batchedAndSigned = new LinkedMultiValueMap<>( + batchedArtifacts); + batchedAndSigned.put(Category.SIGNATURE, signed); + return batchedAndSigned; + } + + static boolean isSignatureFile(String name) { + return name.toLowerCase().endsWith(FILE_EXTENSION); + } + + private class ArtifactSignature implements DeployableArtifact { + + private final String path; + + private final FileSystemResource signatureResource; + + private final long size; + + private Checksums checksums; + + ArtifactSignature(DeployableArtifact artifact) { + try { + this.path = artifact.getPath() + FILE_EXTENSION; + File signatureFile = new File(temp, artifact.getPath()); + this.signatureResource = new FileSystemResource(signatureFile); + signatureFile.getParentFile().mkdirs(); + signatureFile.deleteOnExit(); + console.debug("Signing {}", artifact.getPath()); + DeployableArtifactsSigner.this.signer.sign(artifact.getContent().getInputStream(), + this.signatureResource.getOutputStream()); + this.size = this.signatureResource.contentLength(); + this.checksums = Checksums.calculate(this.signatureResource); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + @Override + public String getPath() { + return this.path; + } + + @Override + public Resource getContent() { + return this.signatureResource; + } + + @Override + public long getSize() { + return this.size; + } + + @Override + public Map getProperties() { + return DeployableArtifactsSigner.this.buildProperties; + } + + @Override + public Checksums getChecksums() { + return this.checksums; + } + + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/Deployer.java b/src/main/java/io/spring/github/actions/artifactorydeploy/Deployer.java new file mode 100644 index 0000000..743f7a7 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/Deployer.java @@ -0,0 +1,256 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy; + +import java.io.File; +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import io.spring.github.actions.artifactorydeploy.ArtifactoryDeployProperties.Deploy.ArtifactProperties; +import io.spring.github.actions.artifactorydeploy.ArtifactoryDeployProperties.Signing; +import io.spring.github.actions.artifactorydeploy.artifactory.Artifactory; +import io.spring.github.actions.artifactorydeploy.artifactory.Artifactory.BuildRun; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.BuildModule; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableArtifact; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableFileArtifact; +import io.spring.github.actions.artifactorydeploy.io.DirectoryScanner; +import io.spring.github.actions.artifactorydeploy.io.FileSet; +import io.spring.github.actions.artifactorydeploy.io.FileSet.Category; +import io.spring.github.actions.artifactorydeploy.io.PathFilter; +import io.spring.github.actions.artifactorydeploy.maven.MavenBuildModulesGenerator; +import io.spring.github.actions.artifactorydeploy.maven.MavenCoordinates; +import io.spring.github.actions.artifactorydeploy.maven.MavenVersionType; +import io.spring.github.actions.artifactorydeploy.openpgp.ArmoredAsciiSigner; +import io.spring.github.actions.artifactorydeploy.system.ConsoleLogger; + +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Deployer for deploying to Artifactory. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Gabriel Petrovay + * @author Andy Wilkinson + */ +@Component +public class Deployer { + + private static final Set METADATA_FILES = Collections + .unmodifiableSet(new HashSet<>(Arrays.asList("maven-metadata.xml", "maven-metadata-local.xml"))); + + private static final Set CHECKSUM_FILE_EXTENSIONS = Collections + .unmodifiableSet(new HashSet<>(Arrays.asList(".md5", ".sha1", ".sha256", ".sha512"))); + + private static final ConsoleLogger console = new ConsoleLogger(); + + private final ArtifactoryDeployProperties artifactoryProperties; + + private final Artifactory artifactory; + + private final DirectoryScanner directoryScanner; + + public Deployer(ArtifactoryDeployProperties properties, Artifactory artifactory, + DirectoryScanner directoryScanner) { + this.artifactoryProperties = properties; + this.artifactory = artifactory; + this.directoryScanner = directoryScanner; + } + + public void deploy() { + Instant started = Instant.now(); + Map buildProperties = getBuildProperties(this.artifactoryProperties.deploy().build().number(), + started); + MultiValueMap batchedArtifacts = getBatchedArtifacts(buildProperties); + batchedArtifacts = signArtifactsIfNecessary(batchedArtifacts, buildProperties); + int size = batchedArtifacts.values().stream().mapToInt(List::size).sum(); + Assert.state(size > 0, "No artifacts found to deploy"); + console.log("Deploying {} artifacts to {} in {} as build {} of {} using {} thread(s)", size, + this.artifactoryProperties.deploy().repository(), this.artifactoryProperties.server().uri(), + this.artifactoryProperties.deploy().build().number(), + this.artifactoryProperties.deploy().build().name(), this.artifactoryProperties.deploy().threads()); + deployArtifacts(batchedArtifacts); + addBuildRun(this.artifactoryProperties.deploy().build().number(), started, batchedArtifacts); + console.debug("Done"); + } + + private MultiValueMap getBatchedArtifacts(Map buildProperties) { + File root = new File(this.artifactoryProperties.deploy().folder()); + Assert.state(!ObjectUtils.isEmpty(root.listFiles()), + () -> "No artifacts found in empty directory '%s'".formatted(root.getAbsolutePath())); + console.debug("Getting deployable artifacts from {}", root); + FileSet fileSet = this.directoryScanner.scan(root).filter(getChecksumFilter()).filter(getMetadataFilter()); + MultiValueMap batchedArtifacts = new LinkedMultiValueMap<>(); + Set paths = new HashSet<>(); + fileSet.batchedByCategory().forEach((category, files) -> { + files.forEach((file) -> { + String path = DeployableFileArtifact.calculatePath(root, file); + console.debug("Including file {} with path {}", file, path); + Map properties = new LinkedHashMap<>(buildProperties); + properties.putAll(getArtifactProperties(path)); + path = stripSnapshotTimestamp(path); + if (paths.add(path)) { + batchedArtifacts.add(category, new DeployableFileArtifact(path, file, properties, null)); + } + }); + }); + return batchedArtifacts; + } + + private String stripSnapshotTimestamp(String path) { + MavenCoordinates coordinates = MavenCoordinates.fromPath(path); + if (coordinates.getVersionType() != MavenVersionType.TIMESTAMP_SNAPSHOT) { + return path; + } + String stripped = path.replace(coordinates.getSnapshotVersion(), coordinates.getVersion()); + console.debug("Stripped timestamp version {} to {}", path, stripped); + return stripped; + } + + private Map getArtifactProperties(String path) { + Map properties = new LinkedHashMap<>(); + for (ArtifactProperties artifactProperties : this.artifactoryProperties.deploy().artifactProperties()) { + if (getFilter(artifactProperties).isMatch(path)) { + console.debug("Artifact properties matched, adding properties {}", artifactProperties.properties()); + properties.putAll(artifactProperties.properties()); + } + } + return properties; + } + + private PathFilter getFilter(ArtifactProperties artifactProperties) { + console.debug("Creating artifact properties filter including {} and excluding {}", artifactProperties.include(), + artifactProperties.exclude()); + return new PathFilter(artifactProperties.include(), artifactProperties.exclude()); + } + + private Map getBuildProperties(int buildNumber, Instant started) { + return Map.of("build.name", this.artifactoryProperties.deploy().build().name(), "build.number", + Integer.toString(buildNumber), "build.timestamp", Long.toString(started.toEpochMilli())); + } + + private MultiValueMap signArtifactsIfNecessary( + MultiValueMap batchedArtifacts, Map buildProperties) { + Signing signing = this.artifactoryProperties.signing(); + if (signing == null || !StringUtils.hasText(signing.key())) { + return batchedArtifacts; + } + return signArtifacts(batchedArtifacts, signing.key(), signing.passphrase(), buildProperties); + } + + private MultiValueMap signArtifacts( + MultiValueMap batchedArtifacts, String signingKey, String signingPassphrase, + Map buildProperties) { + try { + console.log("Signing artifacts"); + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(signingKey, signingPassphrase); + return new DeployableArtifactsSigner(signer, buildProperties).addSignatures(batchedArtifacts); + } + catch (IOException ex) { + throw new IllegalStateException("Unable to sign artifacts", ex); + } + } + + private void deployArtifacts(MultiValueMap batchedArtifacts) { + ExecutorService executor = Executors.newFixedThreadPool(this.artifactoryProperties.deploy().threads()); + Function> deployer = (deployableArtifact) -> getArtifactDeployer( + deployableArtifact); + try { + batchedArtifacts.forEach((category, artifacts) -> deploy(category, artifacts, deployer)); + } + finally { + executor.shutdown(); + } + } + + private void deploy(Category category, List artifacts, + Function> deployer) { + console.debug("Deploying {} artifacts", category); + deploy(artifacts.stream().map(deployer).toArray(CompletableFuture[]::new)); + } + + private void deploy(CompletableFuture[] batch) { + try { + CompletableFuture.allOf(batch).get(); + } + catch (ExecutionException ex) { + throw new RuntimeException(ex); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + + private CompletableFuture getArtifactDeployer(DeployableArtifact deployableArtifact) { + return CompletableFuture.runAsync(() -> deployArtifact(deployableArtifact)); + } + + private void deployArtifact(DeployableArtifact deployableArtifact) { + console.log("Deploying {} {} ({}/{})", deployableArtifact.getPath(), deployableArtifact.getProperties(), + deployableArtifact.getChecksums().getSha1(), deployableArtifact.getChecksums().getMd5()); + this.artifactory.deploy(this.artifactoryProperties.deploy().repository(), deployableArtifact); + } + + private Predicate getMetadataFilter() { + return (file) -> !METADATA_FILES.contains(file.getName().toLowerCase()); + } + + private Predicate getChecksumFilter() { + return (file) -> { + String name = file.getName().toLowerCase(); + for (String extension : CHECKSUM_FILE_EXTENSIONS) { + if (name.endsWith(extension)) { + return false; + } + } + return true; + }; + } + + private void addBuildRun(int buildNumber, Instant started, + MultiValueMap batchedArtifacts) { + List artifacts = batchedArtifacts.values() + .stream() + .flatMap(List::stream) + .collect(Collectors.toList()); + console.debug("Adding build run {}", buildNumber); + List modules = new MavenBuildModulesGenerator().getBuildModules(artifacts); + this.artifactory.addBuildRun(this.artifactoryProperties.deploy().project(), + this.artifactoryProperties.deploy().build().name(), + new BuildRun(buildNumber, started, this.artifactoryProperties.deploy().build().uri(), modules)); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/Artifactory.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/Artifactory.java new file mode 100644 index 0000000..09a8048 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/Artifactory.java @@ -0,0 +1,66 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory; + +import java.net.URI; +import java.time.Instant; +import java.util.List; + +import io.spring.github.actions.artifactorydeploy.artifactory.payload.BuildModule; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableArtifact; + +/** + * Provides access to Artifactory. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Gabriel Petrovay + * @author Andy Wilkinson + * @see HttpArtifactory + */ +public interface Artifactory { + + /** + * Deploy the specified artifact to the repository. + * @param repository the name of the repository + * @param artifact the artifact to deploy + */ + void deploy(String repository, DeployableArtifact artifact); + + /** + * Adds a build run. + * @param project the name of the project, if any, that should store the build run's + * info + * @param buildName the name of the build + * @param buildRun the build run to add + */ + void addBuildRun(String project, String buildName, BuildRun buildRun); + + /** + * A build run. + * + * @param number the number of the build + * @param started the instant at which the build started + * @param uri the URI of the build, typically on a CI server + * @param modules the modules produced by the build + * + */ + record BuildRun(int number, Instant started, URI uri, List modules) { + + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/ArtifactoryConfiguration.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/ArtifactoryConfiguration.java new file mode 100644 index 0000000..639bbcc --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/ArtifactoryConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory; + +import io.spring.github.actions.artifactorydeploy.ArtifactoryDeployProperties; + +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * {@link Configuration} for Artifactory-related classes. + * + * @author Andy Wilkinson + */ +@Configuration(proxyBeanMethods = false) +class ArtifactoryConfiguration { + + @Bean + Artifactory artifactory(ArtifactoryDeployProperties properties, RestTemplateBuilder restTemplateBuilder) { + return new HttpArtifactory(restTemplateBuilder, properties.server().uri(), properties.server().username(), + properties.server().password()); + } + + @Bean + @ConfigurationPropertiesBinding + StringToArtifactPropertiesConverter artifactPropertiesConverter() { + return new StringToArtifactPropertiesConverter(); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/HttpArtifactory.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/HttpArtifactory.java new file mode 100644 index 0000000..1c26bb0 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/HttpArtifactory.java @@ -0,0 +1,199 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory; + +import java.net.SocketException; +import java.net.URI; +import java.time.Duration; +import java.util.Map; + +import io.spring.github.actions.artifactorydeploy.artifactory.payload.BuildInfo; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.Checksums; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableArtifact; +import io.spring.github.actions.artifactorydeploy.system.ConsoleLogger; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.RequestEntity.BodyBuilder; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Default {@link Artifactory} implementation communicating over HTTP. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Gabriel Petrovay + */ +class HttpArtifactory implements Artifactory { + + private static final long CHECKSUM_THRESHOLD = 10 * 1024; + + private static final ConsoleLogger console = new ConsoleLogger(); + + private final RestTemplate restTemplate; + + private final String uri; + + private final Duration retryDelay; + + HttpArtifactory(RestTemplateBuilder restTemplateBuilder, URI uri, String username, String password) { + this(restTemplateBuilder, uri, username, password, Duration.ofSeconds(5)); + } + + HttpArtifactory(RestTemplateBuilder restTemplateBuilder, URI uri, String username, String password, + Duration retryDelay) { + RestTemplateBuilder builder = restTemplateBuilder.setConnectTimeout(Duration.ofMinutes(1)) + .setReadTimeout(Duration.ofMinutes(5)); + if (StringUtils.hasText(username)) { + builder = builder.basicAuthentication(username, password); + } + this.restTemplate = builder.build(); + String uriString = uri.toString(); + this.uri = uriString.endsWith("/") ? uriString : uriString + "/"; + this.retryDelay = retryDelay; + } + + @Override + public void deploy(String repository, DeployableArtifact artifact) { + try { + Assert.notNull(artifact, "Artifact must not be null"); + if (artifact.getSize() <= CHECKSUM_THRESHOLD) { + deployUsingContent(repository, artifact); + return; + } + try { + deployUsingChecksum(repository, artifact); + } + catch (Exception ex) { + if (!(ex instanceof HttpClientErrorException || isCausedBySocketException(ex))) { + throw ex; + } + deployUsingContent(repository, artifact); + } + } + catch (Exception ex) { + throw new RuntimeException( + "Error deploying artifact " + artifact.getPath() + " with checksums " + artifact.getChecksums(), + ex); + } + } + + private void deployUsingChecksum(String repository, DeployableArtifact artifact) { + RequestEntity request = deployRequest(repository, artifact).header("X-Checksum-Deploy", "true").build(); + this.restTemplate.exchange(request, Void.class); + } + + private void deployUsingContent(String repository, DeployableArtifact artifact) { + int attempt = 0; + while (true) { + try { + attempt++; + RequestEntity request = deployRequest(repository, artifact).contentLength(artifact.getSize()) + .body(artifact.getContent()); + this.restTemplate.exchange(request, Void.class); + return; + } + catch (RestClientResponseException | ResourceAccessException ex) { + HttpStatusCode statusCode = (ex instanceof RestClientResponseException restClientException) + ? restClientException.getStatusCode() : null; + boolean flaky = (statusCode == HttpStatus.BAD_REQUEST || statusCode == HttpStatus.NOT_FOUND) + || isCausedBySocketException(ex); + if (!flaky || attempt >= 3) { + throw ex; + } + console.log("Deploy failed with {} response. Retrying in {}ms.", statusCode, + this.retryDelay.toMillis()); + trySleep(this.retryDelay); + } + } + } + + private boolean isCausedBySocketException(Throwable ex) { + while (ex != null) { + if (ex instanceof SocketException) { + return true; + } + ex = ex.getCause(); + } + return false; + } + + private void trySleep(Duration time) { + try { + Thread.sleep(time.toMillis()); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + + private BodyBuilder deployRequest(String repository, DeployableArtifact artifact) { + UriComponents uriComponents = UriComponentsBuilder.fromUriString(this.uri) + .path(repository) + .path(artifact.getPath()) + .path(buildMatrixParams(artifact.getProperties())) + .build(); + URI uri = uriComponents.encode().toUri(); + Checksums checksums = artifact.getChecksums(); + return RequestEntity.put(uri) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("X-Checksum-Sha1", checksums.getSha1()) + .header("X-Checksum-Md5", checksums.getMd5()); + } + + private String buildMatrixParams(Map matrixParams) { + StringBuilder matrix = new StringBuilder(); + if (matrixParams != null && !matrixParams.isEmpty()) { + for (Map.Entry entry : matrixParams.entrySet()) { + matrix.append(";" + entry.getKey() + "=" + entry.getValue()); + } + } + return matrix.toString(); + } + + @Override + public void addBuildRun(String project, String buildName, BuildRun buildRun) { + console.debug("Adding {} build {}", buildName, buildRun.number()); + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(this.uri).path("api/build"); + if (StringUtils.hasText(project)) { + console.debug("Publishing to project {}", project); + builder = builder.queryParam("project", project); + } + UriComponents uriComponents = builder.build(); + URI uri = uriComponents.encode().toUri(); + console.debug("Publishing build info to {}", uri); + RequestEntity request = RequestEntity.put(uri) + .contentType(MediaType.APPLICATION_JSON) + .body(new BuildInfo(buildName, Integer.toString(buildRun.number()), buildRun.started(), + (buildRun.uri() != null) ? buildRun.uri().toString() : null, buildRun.modules())); + ResponseEntity exchange = this.restTemplate.exchange(request, Void.class); + exchange.getBody(); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/StringToArtifactPropertiesConverter.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/StringToArtifactPropertiesConverter.java new file mode 100644 index 0000000..7d34531 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/StringToArtifactPropertiesConverter.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory; + +import java.io.BufferedReader; +import java.io.StringReader; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import io.spring.github.actions.artifactorydeploy.ArtifactoryDeployProperties.Deploy.ArtifactProperties; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Converter to create a list of {@link ArtifactProperties artifact properties} from a + * {@link String}. + * + * @author Andy Wilkinson + */ +class StringToArtifactPropertiesConverter implements Converter> { + + @Override + public List convert(String source) { + BufferedReader reader = new BufferedReader(new StringReader(source)); + return reader.lines().map(this::toArtifactProperties).filter(Objects::nonNull).toList(); + } + + private ArtifactProperties toArtifactProperties(String line) { + if (!StringUtils.hasLength(line)) { + return null; + } + String[] components = line.split(":"); + Assert.state(components != null && components.length == 3, + "Artifact properties must be configured in the form ::"); + return new ArtifactProperties(commaSeparatedList(components[0]), commaSeparatedList(components[1]), + commaSeparatedKeyValues(components[2])); + } + + private List commaSeparatedList(String input) { + if (!StringUtils.hasText(input)) { + return Collections.emptyList(); + } + return Arrays.asList(input.split(",")); + } + + private Map commaSeparatedKeyValues(String input) { + Map properties = new LinkedHashMap<>(); + for (String pair : input.split(",")) { + int equalsIndex = pair.indexOf('='); + String key = pair.substring(0, equalsIndex); + String value = pair.substring(equalsIndex + 1, pair.length()); + properties.put(key, value); + } + return properties; + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/package-info.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/package-info.java new file mode 100644 index 0000000..05e2a29 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Artifactory API and implementation. + */ +package io.spring.github.actions.artifactorydeploy.artifactory; diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildAgent.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildAgent.java new file mode 100644 index 0000000..bbaf670 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildAgent.java @@ -0,0 +1,36 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +/** + * The build agent information included in {@link BuildInfo}. + * + * @param name the name of the build agent + * @param version the version of the build agent + * @author Andy Wilkinson + */ +@JsonInclude(Include.NON_NULL) +public record BuildAgent(String name, String version) { + + public BuildAgent() { + this("Artifactory Action", BuildAgent.class.getPackage().getImplementationVersion()); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildArtifact.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildArtifact.java new file mode 100644 index 0000000..15779ee --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildArtifact.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import org.springframework.util.Assert; + +/** + * A single build artifact included in {@link BuildInfo}. + * + * @param type type of the artifact + * @param sha1 SHA1 checksum of the artifact + * @param md5 MD5 checksum of the artifact + * @param name name of the artifact + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + * @see BuildInfo + */ +public record BuildArtifact(String type, String sha1, String md5, String name) { + + public BuildArtifact(String type, String sha1, String md5, String name) { + Assert.hasText(type, "Type must not be empty"); + Assert.hasText(sha1, "SHA1 must not be empty"); + Assert.hasText(md5, "MD5 must not be empty"); + Assert.hasText(name, "Name must not be empty"); + this.type = type; + this.sha1 = sha1; + this.md5 = md5; + this.name = name; + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildInfo.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildInfo.java new file mode 100644 index 0000000..aa516af --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildInfo.java @@ -0,0 +1,65 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import org.springframework.util.Assert; + +/** + * Build information for Artifactory. + * + * @param name name of the build + * @param number number of the build + * @param agent CI server that performed the build + * @param buildAgent Agent that deployed the build + * @param started Instant at which the build start + * @param url URL of the build on the CI server + * @param modules modules produced by the build + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +public record BuildInfo( + String name, String number, CiAgent agent, BuildAgent buildAgent, @JsonFormat(shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSX", timezone = "UTC") Instant started, + String url, List modules) { + + public BuildInfo(String name, String number, Instant started, String url, List modules) { + this(name, number, new CiAgent(), new BuildAgent(), started, url, modules); + } + + public BuildInfo(String name, String number, CiAgent agent, BuildAgent buildAgent, Instant started, String url, + List modules) { + Assert.hasText(name, "Name must not be empty"); + Assert.hasText(number, "Number must not be empty"); + this.name = name; + this.number = number; + this.agent = agent; + this.buildAgent = buildAgent; + this.started = (started != null) ? started : Instant.now(); + this.url = url; + this.modules = (modules != null) ? Collections.unmodifiableList(new ArrayList<>(modules)) + : Collections.emptyList(); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildModule.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildModule.java new file mode 100644 index 0000000..63f37e3 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildModule.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * A single module included in {@link BuildInfo}. + * + * @param id the id of the module ({@code groupId:artifactId:version}) + * @param artifacts the artifacts of the module + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +public record BuildModule(String id, List artifacts) { + + public BuildModule(String id, List artifacts) { + Assert.hasText(id, "ID must not be empty"); + this.id = id; + this.artifacts = (artifacts != null) ? Collections.unmodifiableList(new ArrayList<>(artifacts)) + : Collections.emptyList(); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/Checksums.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/Checksums.java new file mode 100644 index 0000000..f97a03d --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/Checksums.java @@ -0,0 +1,63 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import java.util.Map; + +import io.spring.github.actions.artifactorydeploy.io.Checksum; + +import org.springframework.core.io.Resource; +import org.springframework.core.style.ToStringCreator; + +/** + * SHA1 and MD5 Checksums supported by artifactory. + * + * @author Phillip Webb + * @author Madhura Bhave + */ +public class Checksums { + + private final String sha1; + + private final String md5; + + public Checksums(String sha1, String md5) { + Checksum.SHA1.validate(sha1); + Checksum.MD5.validate(md5); + this.sha1 = sha1; + this.md5 = md5; + } + + public String getSha1() { + return this.sha1; + } + + public String getMd5() { + return this.md5; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("sha1", this.sha1).append("md5", this.md5).toString(); + } + + public static Checksums calculate(Resource content) { + Map all = Checksum.calculateAll(content); + return new Checksums(all.get(Checksum.SHA1), all.get(Checksum.MD5)); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/CiAgent.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/CiAgent.java new file mode 100644 index 0000000..60144b5 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/CiAgent.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import org.springframework.util.Assert; + +/** + * The CI agent information included in {@link BuildInfo}. + * + * @param name the name of the CI agent + * @param version the version of the CI agent + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +@JsonInclude(Include.NON_NULL) +public record CiAgent(String name, String version) { + + public CiAgent() { + this("GitHub Actions", null); + } + + public CiAgent(String name, String version) { + Assert.hasText(name, "Name must not be empty"); + this.name = name; + this.version = version; + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableArtifact.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableArtifact.java new file mode 100644 index 0000000..231e9d0 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableArtifact.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import java.util.Map; + +import org.springframework.core.io.Resource; + +/** + * A single artifact that can be deployed. + * + * @author Phillip Webb + * @author Madhura Bhave + */ +public interface DeployableArtifact { + + /** + * Return the path of the artifact starting with {@code /}, for example + * {@code /com/example/foo/1.0.0-SNAPSHOT/bar.jar}. + * @return the path of the artifact + */ + String getPath(); + + /** + * Return the contents of the underlying artifact file. + * @return the contents of the artifact + */ + Resource getContent(); + + /** + * Return the size of the contents in bytes. + * @return the size + */ + long getSize(); + + /** + * Return any property meta-data that is attached to the artifact. + * @return the property meta-data + */ + Map getProperties(); + + /** + * Return the checksums (SHA1, MD5) for the artifact. + * @return the checksums + */ + Checksums getChecksums(); + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableFileArtifact.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableFileArtifact.java new file mode 100644 index 0000000..35c17df --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableFileArtifact.java @@ -0,0 +1,96 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import java.io.File; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link DeployableArtifact} backed by a {@link File}. + * + * @author Phillip Webb + * @author Madhura Bhave + */ +public class DeployableFileArtifact implements DeployableArtifact { + + private final String path; + + private final Map properties; + + private Checksums checksums; + + private final File file; + + public DeployableFileArtifact(String path, File file, Map properties, Checksums checksums) { + Assert.isTrue(file.exists(), "File '" + file + "' does not exist"); + Assert.isTrue(file.isFile(), "File '" + file + "' does not refer to a file"); + this.path = path; + this.properties = (properties != null) ? Collections.unmodifiableMap(new LinkedHashMap<>(properties)) + : Collections.emptyMap(); + this.checksums = checksums; + this.file = file; + } + + @Override + public String getPath() { + return this.path; + } + + @Override + public Map getProperties() { + return this.properties; + } + + @Override + public Checksums getChecksums() { + if (this.checksums == null) { + this.checksums = Checksums.calculate(getContent()); + } + return this.checksums; + } + + @Override + public Resource getContent() { + return new FileSystemResource(this.file); + } + + @Override + public long getSize() { + return this.file.length(); + } + + public static String calculatePath(File root, File file) { + String rootPath = root.getAbsolutePath(); + String filePath = file.getAbsolutePath(); + Assert.isTrue(filePath.startsWith(rootPath), "File '" + root + "' is not a parent of '" + file + "'"); + return cleanPath(filePath.substring(rootPath.length() + 1)); + } + + private static String cleanPath(String path) { + path = StringUtils.cleanPath(path); + path = (path.startsWith("/") ? path : "/" + path); + return path; + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/package-info.java b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/package-info.java new file mode 100644 index 0000000..12e4a06 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Artifactory payloads. + */ +package io.spring.github.actions.artifactorydeploy.artifactory.payload; diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/io/Checksum.java b/src/main/java/io/spring/github/actions/artifactorydeploy/io/Checksum.java new file mode 100644 index 0000000..a2e8caf --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/io/Checksum.java @@ -0,0 +1,178 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.io; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Stream; + +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StreamUtils; + +/** + * Support for checksums used by the artifactory resource. + * + * @author Phillip Webb + */ +public enum Checksum { + + /** + * MD5 Checksum. + */ + MD5("MD5", 32), + + /** + * SHA-1 Checksum. + */ + SHA1("SHA-1", 40); + + private final String algorithm; + + private final int length; + + Checksum(String algorithm, int length) { + this.algorithm = algorithm; + this.length = length; + } + + /** + * Return the file extension used for the checksum. + * @return the file extension. + */ + public String getFileExtension() { + return "." + name().toLowerCase(); + } + + /** + * Validate the given checksum value. + * @param checksum the checksum value to test + * @throws IllegalArgumentException on an invalid checksum + */ + public void validate(String checksum) { + Assert.hasText(checksum, name() + " must not be empty"); + Assert.isTrue(checksum.length() == this.length, name() + " must be " + this.length + " characters long"); + } + + private DigestInputStream getDigestStream(InputStream content) throws NoSuchAlgorithmException { + return new DigestInputStream(content, MessageDigest.getInstance(this.algorithm)); + } + + /** + * Generate checksum files for the given source file. + * @param source the source file + * @throws IOException on file read error + */ + public static void generateChecksumFiles(File source) throws IOException { + if (!isChecksumFile(source.getName())) { + Map checksums = calculateAll(new FileSystemResource(source)); + for (Map.Entry entry : checksums.entrySet()) { + File file = new File(source.getParentFile(), source.getName() + entry.getKey().getFileExtension()); + FileCopyUtils.copy(entry.getValue(), new FileWriter(file)); + } + } + } + + /** + * Returns true if the specified path is for a checksum file. + * @param path the path to check + * @return if the path is a checksum file + */ + public static boolean isChecksumFile(String path) { + return getFileExtensions().anyMatch(path.toLowerCase()::endsWith); + } + + /** + * Return all checksum file extensions. + * @return the checksum file extensions + */ + public static Stream getFileExtensions() { + return Arrays.stream(values()).map(Checksum::getFileExtension); + } + + /** + * Calculate all checksums for the specified content. + * @param content the source content + * @return a map of all checksums + */ + public static Map calculateAll(Resource content) { + try { + Assert.notNull(content, "Content must not be null"); + return calculateAll(content.getInputStream()); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + /** + * Calculate all checksums for the specified content. + * @param content the source content + * @return a map of all checksums + */ + public static Map calculateAll(String content) { + Assert.notNull(content, "Content must not be null"); + return calculateAll(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); + } + + private static Map calculateAll(InputStream content) { + Assert.notNull(content, "Content must not be null"); + try { + try { + Map streams = new LinkedHashMap<>(); + for (Checksum checksum : values()) { + DigestInputStream digestStream = checksum.getDigestStream(content); + streams.put(checksum, digestStream); + content = digestStream; + } + StreamUtils.drain(content); + return getDigests(streams); + } + finally { + content.close(); + } + } + catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + private static Map getDigests(Map streams) { + Map checksums = new LinkedHashMap<>(streams.size()); + streams.forEach((checksum, stream) -> checksums.put(checksum, getDigestHex(stream))); + return checksums; + } + + private static String getDigestHex(DigestInputStream inputStream) { + byte[] digest = inputStream.getMessageDigest().digest(); + return HexFormat.of().formatHex(digest); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/io/DirectoryScanner.java b/src/main/java/io/spring/github/actions/artifactorydeploy/io/DirectoryScanner.java new file mode 100644 index 0000000..2f4d092 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/io/DirectoryScanner.java @@ -0,0 +1,53 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.io; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.springframework.stereotype.Component; + +/** + * Utility to scan a directory for contents. + * + * @author Phillip Webb + * @author Madhura Bhave + */ +@Component +public class DirectoryScanner { + + /** + * Scan the given directory for files, accounting for the include and exclude + * patterns. + * @param directory the source directory + * @return the scanned set of files + */ + public FileSet scan(File directory) { + try { + return FileSet.of(Files + .find(directory.toPath(), Integer.MAX_VALUE, (path, fileAttributes) -> Files.isRegularFile(path)) + .map(Path::toFile) + .toArray(File[]::new)); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/io/FileSet.java b/src/main/java/io/spring/github/actions/artifactorydeploy/io/FileSet.java new file mode 100644 index 0000000..11dd3b3 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/io/FileSet.java @@ -0,0 +1,221 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.io; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * An ordered set of files as returned from the {@link DirectoryScanner}. In order to + * support correct timestamp generation when uploaded to Artifactory. Files in this set + * are ordered as follows within each parent folder: + *

    + *
  1. Primary artifacts (usually the JAR)
  2. + *
  3. The POM
  4. + *
  5. Maven metadata files
  6. + *
  7. Additional artifacts (E.g. javadoc JARs)
  8. + *
+ *

+ * In addition, files may be returned in uploaded batches if multi-threaded uploads are + * being used. + * + * @author Philllip Webb + */ +public final class FileSet implements Iterable { + + private final Map roots; + + private final List files; + + private FileSet(Map roots, List files) { + this.roots = roots; + this.files = Collections.unmodifiableList(files); + } + + /** + * Return a new {@link FileSet} consisting of files from this set that match the given + * predicate. + * @param predicate the filter predicate + * @return a new filtered {@link FileSet} instance + */ + public FileSet filter(Predicate predicate) { + return new FileSet(this.roots, this.files.stream().filter(predicate).collect(Collectors.toList())); + } + + /** + * Return the files from this set batched by {@link Category}. The contents of each + * batch can be safely uploaded in parallel. + * @return the batched files + */ + public MultiValueMap batchedByCategory() { + MultiValueMap batched = new LinkedMultiValueMap<>(); + Arrays.stream(Category.values()).forEach((category) -> batched.put(category, new ArrayList<>())); + this.files.forEach((file) -> batched.add(getCategory(this.roots, file), file)); + batched.entrySet().removeIf((entry) -> entry.getValue().isEmpty()); + return batched; + } + + @Override + public Iterator iterator() { + return this.files.iterator(); + } + + public static FileSet of(File... files) { + Assert.notNull(files, "Files must not be null"); + return of(Arrays.asList(files)); + } + + public static FileSet of(List files) { + Assert.notNull(files, "Files must not be null"); + MultiValueMap filesByParent = getFilesByParent(files); + Map roots = getRoots(filesByParent); + Comparator comparator = Comparator.comparing(File::getParent); + comparator = comparator.thenComparing((file) -> getCategory(roots, file)); + comparator = comparator.thenComparing(FileSet::getFileExtension); + comparator = comparator.thenComparing(FileSet::getNameWithoutExtension); + List sorted = new ArrayList<>(files); + sorted.sort(comparator); + return new FileSet(roots, sorted); + } + + private static MultiValueMap getFilesByParent(List files) { + MultiValueMap filesByParent = new LinkedMultiValueMap<>(); + files.forEach((file) -> filesByParent.add(file.getParentFile(), file)); + return filesByParent; + } + + private static Map getRoots(MultiValueMap filesByParent) { + Map roots = new LinkedHashMap<>(); + filesByParent.forEach((parent, files) -> findRoot(files).ifPresent((root) -> roots.put(parent, root))); + return roots; + } + + private static Optional findRoot(List files) { + return files.stream() + .filter(FileSet::isRootCandidate) + .map(FileSet::getNameWithoutExtension) + .reduce(FileSet::getShortest); + } + + private static boolean isRootCandidate(File file) { + if (isMavenMetaData(file) || file.isHidden() || file.getName().startsWith(".") || file.isDirectory() + || isChecksumFile(file)) { + return false; + } + return true; + } + + private static boolean isChecksumFile(File file) { + String name = file.getName().toLowerCase(); + return name.endsWith(".md5") || name.endsWith("sha1"); + } + + private static String getShortest(String name1, String name2) { + int len1 = (StringUtils.hasLength(name1)) ? name1.length() : Integer.MAX_VALUE; + int len2 = (StringUtils.hasLength(name2)) ? name2.length() : Integer.MAX_VALUE; + return (len1 < len2) ? name1 : name2; + } + + private static Category getCategory(Map roots, File file) { + if (file.getName().endsWith(".pom")) { + return Category.POM; + } + if (file.getName().endsWith(".asc")) { + return Category.SIGNATURE; + } + if (isMavenMetaData(file)) { + return Category.MAVEN_METADATA; + } + String root = roots.get(file.getParentFile()); + return getNameWithoutExtension(file).equals(root) ? Category.PRIMARY : Category.ADDITIONAL; + } + + private static boolean isMavenMetaData(File file) { + return file.getName().toLowerCase().startsWith("maven-metadata.xml") + || file.getName().toLowerCase().startsWith("maven-metadata-local.xml"); + } + + private static String getFileExtension(File file) { + String extension = StringUtils.getFilenameExtension(file.getName()); + return (extension != null) ? extension : ""; + } + + private static String getNameWithoutExtension(File file) { + String name = file.getName(); + String extension = StringUtils.getFilenameExtension(name); + return (extension != null) ? name.substring(0, name.length() - extension.length() - 1) : name; + } + + /** + * Categorization used for ordering and batching. + */ + public enum Category { + + /** + * The primary artifact (usually the JAR). + */ + PRIMARY("pimary"), + + /** + * The POM file. + */ + POM("pom file"), + + /** + * An ASC signature file. + */ + SIGNATURE("signature"), + + /** + * Maven metadata. + */ + MAVEN_METADATA("maven metadata"), + + /** + * Any artifacts that include a classifier (for example Source JARs). + */ + ADDITIONAL("additional"); + + private final String description; + + Category(String description) { + this.description = description; + } + + @Override + public String toString() { + return this.description; + } + + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/io/PathFilter.java b/src/main/java/io/spring/github/actions/artifactorydeploy/io/PathFilter.java new file mode 100644 index 0000000..903e633 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/io/PathFilter.java @@ -0,0 +1,68 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.io; + +import java.util.List; + +import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; +import org.springframework.util.PathMatcher; + +/** + * Filter that matches paths based on {@code include}/{@code exclude} patterns. + * + * @author Phillip Webb + * @author Madhura Bhave + */ +public class PathFilter { + + private static final PathMatcher pathMatcher = new AntPathMatcher(); + + private final List include; + + private final List exclude; + + public PathFilter(List include, List exclude) { + Assert.notNull(include, "Include must not be null"); + Assert.notNull(exclude, "Exclude must not be null"); + this.include = include; + this.exclude = exclude; + } + + public boolean isMatch(String path) { + return ((this.include.isEmpty() || hasMatch(pathMatcher, path, this.include)) + && !hasMatch(pathMatcher, path, this.exclude)); + } + + private boolean hasMatch(PathMatcher pathMatcher, String path, List patterns) { + for (String pattern : patterns) { + pattern = cleanPattern(path, pattern); + if (pathMatcher.match(pattern, path)) { + return true; + } + } + return false; + } + + private String cleanPattern(String path, String pattern) { + if (path.startsWith("/")) { + return !pattern.startsWith("/") ? "/" + pattern : pattern; + } + return pattern.startsWith("/") ? pattern.substring(1) : pattern; + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/io/package-info.java b/src/main/java/io/spring/github/actions/artifactorydeploy/io/package-info.java new file mode 100644 index 0000000..5617fec --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/io/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * IO utilities. + */ +package io.spring.github.actions.artifactorydeploy.io; diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenBuildModulesGenerator.java b/src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenBuildModulesGenerator.java new file mode 100644 index 0000000..7b8b401 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenBuildModulesGenerator.java @@ -0,0 +1,124 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.maven; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.spring.github.actions.artifactorydeploy.artifactory.payload.BuildArtifact; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.BuildModule; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.Checksums; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableArtifact; + +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Generates {@link BuildModule build modules} using standard Maven layout rules. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +public class MavenBuildModulesGenerator { + + private static final Map SUFFIX_TYPES; + + static { + Map suffixTypes = new LinkedHashMap<>(); + suffixTypes.put("-sources.jar", "java-source-jar"); + SUFFIX_TYPES = Collections.unmodifiableMap(suffixTypes); + } + + private static final Set IGNORED = Collections + .unmodifiableSet(new LinkedHashSet<>(Arrays.asList("md5", "sha"))); + + private static Pattern PATH_PATTERN = Pattern.compile("\\/(.*)\\/(.*)\\/(.*)\\/(.*)"); + + public List getBuildModules(List deployableArtifacts) { + List buildModules = new ArrayList<>(); + getBuildArtifactsById(deployableArtifacts) + .forEach((id, artifacts) -> buildModules.add(new BuildModule(id, artifacts))); + return buildModules; + } + + private MultiValueMap getBuildArtifactsById(List deployableArtifacts) { + MultiValueMap buildArtifacts = new LinkedMultiValueMap<>(); + deployableArtifacts.forEach((deployableArtifact) -> { + try { + String id = getArtifactId(deployableArtifact); + getBuildArtifact(deployableArtifact) + .ifPresent((buildArtifact) -> buildArtifacts.add(id, buildArtifact)); + } + catch (Exception ex) { + // Ignore and don't add as a module + } + }); + return buildArtifacts; + } + + private String getArtifactId(DeployableArtifact deployableArtifact) { + Matcher pathMatcher = getPathMatcher(deployableArtifact); + String groupId = pathMatcher.group(1).replace('/', '.'); + String artifactId = pathMatcher.group(2); + String version = pathMatcher.group(3); + return groupId + ":" + artifactId + ":" + version; + } + + private Optional getBuildArtifact(DeployableArtifact deployableArtifact) { + Matcher pathMatcher = getPathMatcher(deployableArtifact); + String filename = pathMatcher.group(4); + String type = getType(filename); + if (type == null) { + return Optional.empty(); + } + Checksums checksums = deployableArtifact.getChecksums(); + return Optional.of(new BuildArtifact(type, checksums.getSha1(), checksums.getMd5(), filename)); + } + + private String getType(String name) { + for (Map.Entry entry : SUFFIX_TYPES.entrySet()) { + if (name.toLowerCase().endsWith(entry.getKey())) { + return entry.getValue(); + } + } + String extension = StringUtils.getFilenameExtension(name).toLowerCase(); + if (extension == null || IGNORED.contains(extension)) { + return null; + } + return extension; + } + + private Matcher getPathMatcher(DeployableArtifact deployableArtifact) { + String path = deployableArtifact.getPath(); + Matcher matcher = PATH_PATTERN.matcher(path); + Assert.state(matcher.matches(), "Invalid path " + path); + return matcher; + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenCoordinates.java b/src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenCoordinates.java new file mode 100644 index 0000000..957b22c --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenCoordinates.java @@ -0,0 +1,160 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.maven; + +import java.util.Comparator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Maven coordinates (group/artifact/version etc). + * + * @author Phillip Webb + */ +public final class MavenCoordinates implements Comparable { + + private static final String SNAPSHOT = "SNAPSHOT"; + + private static final String SNAPSHOT_SUFFIX = "-" + SNAPSHOT; + + private static final Pattern FOLDER_PATTERN = Pattern.compile("(.*)\\/(.*)\\/(.*)\\/(.*)"); + + private static final Pattern VERSION_FILE_PATTERN = Pattern.compile("^([0-9]{8}.[0-9]{6})-([0-9]+)(.*)$"); + + private final String groupId; + + private final String artifactId; + + private final String version; + + private final String classifier; + + private final String extension; + + private final String snapshotVersion; + + private MavenCoordinates(String groupId, String artifactId, String version, String classifier, String extension, + String snapshotVersion) { + this.groupId = groupId; + this.artifactId = artifactId; + this.version = version; + this.classifier = classifier; + this.extension = extension; + this.snapshotVersion = snapshotVersion; + } + + public String getGroupId() { + return this.groupId; + } + + public String getArtifactId() { + return this.artifactId; + } + + public String getVersion() { + return this.version; + } + + public String getClassifier() { + return this.classifier; + } + + public String getExtension() { + return this.extension; + } + + public String getSnapshotVersion() { + return this.snapshotVersion; + } + + public boolean isSnapshotVersion() { + return getVersionType() != MavenVersionType.FIXED; + } + + public MavenVersionType getVersionType() { + return MavenVersionType.fromVersion(this.snapshotVersion); + } + + @Override + public String toString() { + return this.groupId + ":" + this.artifactId + ":" + this.version + ":" + this.classifier + ":" + this.version + + ":" + this.snapshotVersion; + } + + @Override + public int compareTo(MavenCoordinates o) { + return Comparator.comparing(MavenCoordinates::getGroupId) + .thenComparing(MavenCoordinates::getArtifactId) + .thenComparing(MavenCoordinates::getVersion) + .thenComparing(MavenCoordinates::getExtension) + .thenComparing(MavenCoordinates::getClassifier) + .compare(this, o); + } + + public static MavenCoordinates fromPath(String path) { + try { + if (path.startsWith("/")) { + path = path.substring(1); + } + Matcher folderMatcher = FOLDER_PATTERN.matcher(path); + Assert.state(folderMatcher.matches(), "Path does not match folder pattern"); + String groupId = folderMatcher.group(1).replace('/', '.'); + String artifactId = folderMatcher.group(2); + String version = folderMatcher.group(3); + String rootVersion = (version.endsWith(SNAPSHOT_SUFFIX) + ? version.substring(0, version.length() - SNAPSHOT_SUFFIX.length()) : version); + String name = folderMatcher.group(4); + Assert.state(name.startsWith(artifactId), + "Name '" + name + "' does not start with artifact ID '" + artifactId + "'"); + String snapshotVersionAndClassifier = name.substring(artifactId.length() + 1); + String extension = StringUtils.getFilenameExtension(snapshotVersionAndClassifier); + snapshotVersionAndClassifier = snapshotVersionAndClassifier.substring(0, + snapshotVersionAndClassifier.length() - extension.length() - 1); + String classifier = snapshotVersionAndClassifier; + if (classifier.startsWith(rootVersion)) { + classifier = classifier.substring(rootVersion.length()); + classifier = stripDash(classifier); + } + Matcher versionMatcher = VERSION_FILE_PATTERN.matcher(classifier); + if (versionMatcher.matches()) { + classifier = versionMatcher.group(3); + classifier = stripDash(classifier); + } + if (classifier.startsWith(SNAPSHOT)) { + classifier = classifier.substring(SNAPSHOT.length()); + classifier = stripDash(classifier); + } + String snapshotVersion = (classifier.isEmpty() ? snapshotVersionAndClassifier : snapshotVersionAndClassifier + .substring(0, snapshotVersionAndClassifier.length() - classifier.length() - 1)); + return new MavenCoordinates(groupId, artifactId, version, classifier, extension, snapshotVersion); + } + catch (Exception ex) { + throw new IllegalStateException("Unable to parse maven coordinates from path '" + path + "'", ex); + } + } + + private static String stripDash(String classifier) { + if (classifier.startsWith("-")) { + return classifier.substring(1); + } + return classifier; + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenVersionType.java b/src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenVersionType.java new file mode 100644 index 0000000..34ac024 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/maven/MavenVersionType.java @@ -0,0 +1,61 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.maven; + +import java.util.regex.Pattern; + +import org.springframework.util.Assert; + +/** + * Different styles of maven version numbers. + * + * @author Phillip Webb + */ +public enum MavenVersionType { + + /** + * A timestamp based snapshot, for example {@code 1.0.0-20171005.194031-1}. + */ + TIMESTAMP_SNAPSHOT, + + /** + * A regular snapshot, for example {@code 1.0.0-SNAPSHOT}. + */ + SNAPSHOT, + + /** + * A fixed version, for example {@code 1.0.0}. + */ + FIXED; + + private static final String SNAPSHOT_VERSION = "SNAPSHOT"; + + private static final Pattern VERSION_FILE_PATTERN = Pattern.compile("^(.*)-([0-9]{8}.[0-9]{6})-([0-9]+)$"); + + public static MavenVersionType fromVersion(String version) { + Assert.hasLength(version, "Version must not be empty"); + if (version.regionMatches(true, version.length() - SNAPSHOT_VERSION.length(), SNAPSHOT_VERSION, 0, + SNAPSHOT_VERSION.length())) { + return SNAPSHOT; + } + if (VERSION_FILE_PATTERN.matcher(version).matches()) { + return TIMESTAMP_SNAPSHOT; + } + return FIXED; + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/maven/package-info.java b/src/main/java/io/spring/github/actions/artifactorydeploy/maven/package-info.java new file mode 100644 index 0000000..9caea37 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/maven/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Maven support classes. + */ +package io.spring.github.actions.artifactorydeploy.maven; diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/openpgp/ArmoredAsciiSigner.java b/src/main/java/io/spring/github/actions/artifactorydeploy/openpgp/ArmoredAsciiSigner.java new file mode 100644 index 0000000..acb6d9a --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/openpgp/ArmoredAsciiSigner.java @@ -0,0 +1,344 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.openpgp; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.Security; +import java.time.Clock; +import java.util.Date; + +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.bcpg.HashAlgorithmTags; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPrivateKey; +import org.bouncycastle.openpgp.PGPSecretKey; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.PGPSecretKeyRingCollection; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureGenerator; +import org.bouncycastle.openpgp.PGPSignatureSubpacketGenerator; +import org.bouncycastle.openpgp.PGPUtil; +import org.bouncycastle.openpgp.operator.PBESecretKeyDecryptor; +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; + +import org.springframework.core.io.InputStreamSource; +import org.springframework.util.Assert; + +/** + * Utility to sign artifacts by generating armored ASCII. + * + * @author Phillip Webb + */ +public final class ArmoredAsciiSigner { + + private static final String PRIVATE_KEY_BLOCK_HEADER = "-----BEGIN PGP PRIVATE KEY BLOCK-----"; + + private static final JcaKeyFingerprintCalculator FINGERPRINT_CALCULATOR = new JcaKeyFingerprintCalculator(); + + private static final int BUFFER_SIZE = 4096; + + static { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + private final PGPSecretKey signingKey; + + private final PGPPrivateKey privateKey; + + private final JcaPGPContentSignerBuilder contentSigner; + + private final Clock clock; + + private ArmoredAsciiSigner(Clock clock, InputStream signingKeyInputStream, String passphrase) { + PGPSecretKey signingKey = getSigningKey(signingKeyInputStream); + this.clock = clock; + this.signingKey = signingKey; + this.privateKey = extractPrivateKey(passphrase, signingKey); + this.contentSigner = getContentSigner(signingKey.getPublicKey().getAlgorithm()); + } + + private PGPSecretKey getSigningKey(InputStream inputStream) { + try { + try (InputStream decoderStream = PGPUtil.getDecoderStream(inputStream)) { + PGPSecretKeyRingCollection keyrings = new PGPSecretKeyRingCollection(decoderStream, + FINGERPRINT_CALCULATOR); + return getSigningKey(keyrings); + } + } + catch (Exception ex) { + throw new IllegalStateException("Unable to read signing key", ex); + } + } + + private PGPSecretKey getSigningKey(PGPSecretKeyRingCollection keyrings) { + for (PGPSecretKeyRing keyring : keyrings) { + Iterable secretKeys = keyring::getSecretKeys; + for (PGPSecretKey candidate : secretKeys) { + if (candidate.isSigningKey()) { + return candidate; + } + } + } + throw new IllegalArgumentException("Keyring does not contain a suitable signing key"); + } + + private PGPPrivateKey extractPrivateKey(String passphrase, PGPSecretKey signingKey) { + try { + return signingKey.extractPrivateKey(getDecryptorFactory(passphrase)); + } + catch (Exception ex) { + throw new IllegalStateException("Unable to extract private key", ex); + } + } + + private PBESecretKeyDecryptor getDecryptorFactory(String passphrase) throws PGPException { + return new JcePBESecretKeyDecryptorBuilder().setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(passphrase.toCharArray()); + } + + private JcaPGPContentSignerBuilder getContentSigner(int signingAlgorithm) { + return new JcaPGPContentSignerBuilder(signingAlgorithm, HashAlgorithmTags.SHA256) + .setProvider(BouncyCastleProvider.PROVIDER_NAME); + } + + /** + * Sign the given source. + * @param source the source to sign + * @return the signature + * @throws IOException on IO error + */ + public String sign(String source) throws IOException { + Assert.notNull(source, "Source must not be null"); + return sign(new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8))); + } + + /** + * Sign the given source. + * @param source the source to sign + * @return the signature + * @throws IOException on IO error + */ + public String sign(InputStreamSource source) throws IOException { + Assert.notNull(source, "Source must not be null"); + return sign(source.getInputStream()); + } + + /** + * Sign the given source. + * @param source the source to sign (will be closed after use) + * @return the signature + * @throws IOException on IO error + */ + public String sign(InputStream source) throws IOException { + Assert.notNull(source, "Source must not be null"); + ByteArrayOutputStream destination = new ByteArrayOutputStream(); + sign(source, destination); + return new String(destination.toByteArray(), StandardCharsets.UTF_8); + } + + /** + * Sign the given source. + * @param source the source to sign (will be closed after use) + * @param destination the signature destination + * @throws IOException on IO error + */ + public void sign(InputStream source, OutputStream destination) throws IOException { + Assert.notNull(source, "Source must not be null"); + Assert.notNull(destination, "Destination must not be null"); + try (ArmoredOutputStream armoredOutputStream = ArmoredOutputStream.builder().build(destination)) { + sign(source, armoredOutputStream); + } + catch (PGPException ex) { + throw new IllegalStateException(ex); + } + finally { + destination.close(); + } + } + + private void sign(InputStream source, ArmoredOutputStream destination) throws PGPException, IOException { + PGPSignatureGenerator signatureGenerator = getSignatureGenerator(); + updateSignatureGenerator(source, signatureGenerator); + signatureGenerator.generate().encode(destination); + } + + private PGPSignatureGenerator getSignatureGenerator() throws PGPException { + PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator(this.contentSigner); + signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, this.privateKey); + PGPSignatureSubpacketGenerator subpacketGenerator = getSignatureSubpacketGenerator(); + signatureGenerator.setHashedSubpackets(subpacketGenerator.generate()); + return signatureGenerator; + } + + private PGPSignatureSubpacketGenerator getSignatureSubpacketGenerator() { + PGPSignatureSubpacketGenerator subpacketGenerator = new PGPSignatureSubpacketGenerator(); + subpacketGenerator.setIssuerFingerprint(false, this.signingKey.getPublicKey()); + subpacketGenerator.setSignatureCreationTime(false, Date.from(this.clock.instant())); + return subpacketGenerator; + } + + private void updateSignatureGenerator(InputStream source, PGPSignatureGenerator signatureGenerator) + throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = source.read(buffer)) != -1) { + signatureGenerator.update(buffer, 0, bytesRead); + } + } + + /** + * Get an {@link ArmoredAsciiSigner} for the given {@code signingKey} and + * {@code passphrase}. The signing key may either contain a PHP private key block or + * reference a file. + * @param signingKey the signing key (either the key itself or a reference to a file) + * @param passphrase the passphrase to use + * @return an {@link ArmoredAsciiSigner} insance + * @throws IOException on IO error + */ + public static ArmoredAsciiSigner get(String signingKey, String passphrase) throws IOException { + return get(Clock.systemDefaultZone(), signingKey, passphrase); + } + + /** + * Get an {@link ArmoredAsciiSigner} for the given {@code signingKey} and + * {@code passphrase}. The signing key may either contain a PHP private key block or + * reference a file. + * @param clock the clock to use when generating signatures + * @param signingKey the signing key (either the key itself or a reference to a file) + * @param passphrase the passphrase to use + * @return an {@link ArmoredAsciiSigner} insance + * @throws IOException on IO error + */ + public static ArmoredAsciiSigner get(Clock clock, String signingKey, String passphrase) throws IOException { + Assert.notNull(clock, "Clock must not be null"); + Assert.notNull(signingKey, "SigningKey must not be null"); + Assert.hasText(signingKey, "SigningKey must not be empty"); + if (isArmoredAscii(signingKey)) { + byte[] bytes = signingKey.getBytes(StandardCharsets.UTF_8); + return get(clock, new ByteArrayInputStream(bytes), passphrase); + } + Assert.isTrue(!signingKey.contains("\n"), + "Signing key is not does not contain a PGP private key block and does not reference a file"); + return get(clock, new File(signingKey), passphrase); + } + + /** + * Get an {@link ArmoredAsciiSigner} for the given {@code signingKey} file and + * {@code passphrase}. + * @param signingKey the signing key file + * @param passphrase the passphrase to use + * @return an {@link ArmoredAsciiSigner} insance + * @throws IOException on IO error + */ + public static ArmoredAsciiSigner get(File signingKey, String passphrase) throws IOException { + return get(Clock.systemDefaultZone(), signingKey, passphrase); + } + + /** + * Get an {@link ArmoredAsciiSigner} for the given {@code signingKey} and + * {@code passphrase}. + * @param clock the clock to use when generating signatures + * @param signingKey the signing key file + * @param passphrase the passphrase to use + * @return an {@link ArmoredAsciiSigner} insance + * @throws IOException on IO error + */ + public static ArmoredAsciiSigner get(Clock clock, File signingKey, String passphrase) throws IOException { + Assert.notNull(clock, "Clock must not be null"); + Assert.notNull(signingKey, "SigningKey must not be null"); + Assert.notNull(passphrase, "Passphrase must not be null"); + Assert.isTrue(signingKey.exists(), "Signing key file does not exist"); + Assert.isTrue(signingKey.isFile(), "Signing key file does not reference a file"); + return get(clock, new FileInputStream(signingKey), passphrase); + } + + /** + * Get an {@link ArmoredAsciiSigner} for the given {@code signingKey} and + * {@code passphrase}. + * @param signingKey an {@link InputStreamSource} providing the signing key + * @param passphrase the passphrase to use + * @return an {@link ArmoredAsciiSigner} insance + * @throws IOException on IO error + */ + public static ArmoredAsciiSigner get(InputStreamSource signingKey, String passphrase) throws IOException { + return get(Clock.systemDefaultZone(), signingKey, passphrase); + } + + /** + * Get an {@link ArmoredAsciiSigner} for the given {@code signingKey} and + * {@code passphrase}. + * @param clock the clock to use when generating signatures + * @param signingKey an {@link InputStreamSource} providing the signing key + * @param passphrase the passphrase to use + * @return an {@link ArmoredAsciiSigner} insance + * @throws IOException on IO error + */ + public static ArmoredAsciiSigner get(Clock clock, InputStreamSource signingKey, String passphrase) + throws IOException { + Assert.notNull(clock, "Clock must not be null"); + Assert.notNull(signingKey, "SigningKey must not be null"); + Assert.notNull(passphrase, "Passphrase must not be null"); + return get(clock, signingKey.getInputStream(), passphrase); + } + + /** + * Get an {@link ArmoredAsciiSigner} for the given {@code signingKey} and + * {@code passphrase}. + * @param signingKey an {@link InputStream} providing the signing key (will be closed + * after use) + * @param passphrase the passphrase to use + * @return an {@link ArmoredAsciiSigner} insance + * @throws IOException on IO error + */ + public static ArmoredAsciiSigner get(InputStream signingKey, String passphrase) throws IOException { + return get(Clock.systemDefaultZone(), signingKey, passphrase); + } + + /** + * Get an {@link ArmoredAsciiSigner} for the given {@code signingKey} and + * {@code passphrase}. + * @param clock the clock to use when generating signatures + * @param signingKey an {@link InputStream} providing the signing key (will be closed + * after use) + * @param passphrase the passphrase to use + * @return an {@link ArmoredAsciiSigner} insance + * @throws IOException on IO error + */ + public static ArmoredAsciiSigner get(Clock clock, InputStream signingKey, String passphrase) throws IOException { + Assert.notNull(clock, "Clock must not be null"); + Assert.notNull(signingKey, "SigningKey must not be null"); + Assert.notNull(passphrase, "Passphrase must not be null"); + return new ArmoredAsciiSigner(clock, signingKey, passphrase); + } + + private static boolean isArmoredAscii(String signingKey) { + return signingKey.contains(PRIVATE_KEY_BLOCK_HEADER); + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/openpgp/package-info.java b/src/main/java/io/spring/github/actions/artifactorydeploy/openpgp/package-info.java new file mode 100644 index 0000000..f3d24a9 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/openpgp/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * OpenPGP utilities. + */ +package io.spring.github.actions.artifactorydeploy.openpgp; diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/package-info.java b/src/main/java/io/spring/github/actions/artifactorydeploy/package-info.java new file mode 100644 index 0000000..0c07d3b --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Main application entrypoint. + */ +package io.spring.github.actions.artifactorydeploy; diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/system/ConsoleLogger.java b/src/main/java/io/spring/github/actions/artifactorydeploy/system/ConsoleLogger.java new file mode 100644 index 0000000..408e993 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/system/ConsoleLogger.java @@ -0,0 +1,44 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.system; + +import org.slf4j.helpers.MessageFormatter; + +/** + * Simple console logger used to output progress messages. + * + * @author Andy Wilkinson + */ +public class ConsoleLogger { + + private final boolean debugEnabled; + + public ConsoleLogger() { + this.debugEnabled = Boolean.valueOf(System.getenv("ACTIONS_STEP_DEBUG")); + } + + public void log(String message, Object... args) { + System.out.println(MessageFormatter.arrayFormat(message, args).getMessage()); + } + + public void debug(String message, Object... args) { + if (this.debugEnabled) { + log("##[debug]" + message, args); + } + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/system/DebugLogger.java b/src/main/java/io/spring/github/actions/artifactorydeploy/system/DebugLogger.java new file mode 100644 index 0000000..80f5b6e --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/system/DebugLogger.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.system; + +/** + * Simple debug logger that outputs messages to the console when enabled. + * + * @author Andy Wilkinson + */ +public class DebugLogger { + + private static final ConsoleLogger console = new ConsoleLogger(); + + private final boolean enabled; + + public DebugLogger() { + this.enabled = Boolean.valueOf(System.getenv("ACTION_STEP_DEBUG")); + } + + public void log(String message, Object... args) { + if (this.enabled) { + console.log(message, args); + } + } + + public void debug(String message, Object... args) { + if (this.enabled) { + console.log(message, args); + } + } + +} diff --git a/src/main/java/io/spring/github/actions/artifactorydeploy/system/package-info.java b/src/main/java/io/spring/github/actions/artifactorydeploy/system/package-info.java new file mode 100644 index 0000000..68fa362 --- /dev/null +++ b/src/main/java/io/spring/github/actions/artifactorydeploy/system/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Pluggable system abstractions. + */ +package io.spring.github.actions.artifactorydeploy.system; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..af0fdc8 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,3 @@ +spring.main.banner-mode=off +spring.jackson.default-property-inclusion=non_null +logging.level.io.spring.github.actions.artifactoryaction=warn \ No newline at end of file diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/DeployableArtifactsSignerTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/DeployableArtifactsSignerTests.java new file mode 100644 index 0000000..aab00c9 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/DeployableArtifactsSignerTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; + +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableArtifact; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableFileArtifact; +import io.spring.github.actions.artifactorydeploy.io.FileSet.Category; +import io.spring.github.actions.artifactorydeploy.openpgp.ArmoredAsciiSigner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.util.FileCopyUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link DeployableArtifactsSigner}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class DeployableArtifactsSignerTests { + + private final Map properties = Map.of("test", "param"); + + @TempDir + File tempDir; + + private DeployableArtifactsSigner signer; + + @BeforeEach + void setup() throws IOException { + ArmoredAsciiSigner signer = ArmoredAsciiSigner + .get(ArmoredAsciiSigner.class.getResourceAsStream("test-private.txt"), "password"); + this.signer = new DeployableArtifactsSigner(signer, this.properties); + } + + @Test + void signWhenAlreadyContainsSignedFilesThrowsException() { + MultiValueMap batchedArtifacts = new LinkedMultiValueMap<>(); + batchedArtifacts.add(Category.SIGNATURE, artifact("/file.asc", new byte[0])); + assertThatIllegalStateException().isThrownBy(() -> this.signer.addSignatures(batchedArtifacts)) + .withMessage("Files must not already be signed"); + } + + @Test + void signAddsSignedFiles() throws Exception { + DeployableArtifact artifact = artifact("/com/example/myapp.jar", "test".getBytes(StandardCharsets.UTF_8)); + MultiValueMap batchedArtifacts = new LinkedMultiValueMap<>(); + batchedArtifacts.add(Category.PRIMARY, artifact); + MultiValueMap signed = this.signer.addSignatures(batchedArtifacts); + assertThat(signed.getFirst(Category.PRIMARY)).isEqualTo(artifact); + DeployableArtifact signatureResource = signed.getFirst(Category.SIGNATURE); + assertThat(signatureResource.getPath()).isEqualTo("/com/example/myapp.jar.asc"); + assertThat(FileCopyUtils.copyToByteArray(signatureResource.getContent().getInputStream())) + .asString(StandardCharsets.UTF_8) + .contains("PGP SIGNATURE"); + assertThat(signatureResource.getSize()).isGreaterThan(10); + assertThat(signatureResource.getProperties()).isEqualTo(this.properties); + assertThat(signatureResource.getChecksums()).isNotNull(); + } + + private DeployableArtifact artifact(String path, byte[] bytes) { + File artifact = new File(this.tempDir, path); + artifact.getParentFile().mkdirs(); + try { + Files.write(artifact.toPath(), bytes); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + return new DeployableFileArtifact(path, artifact, null, null); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/DeployerTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/DeployerTests.java new file mode 100644 index 0000000..da3dd80 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/DeployerTests.java @@ -0,0 +1,339 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import io.spring.github.actions.artifactorydeploy.ArtifactoryDeployProperties.Deploy; +import io.spring.github.actions.artifactorydeploy.ArtifactoryDeployProperties.Deploy.ArtifactProperties; +import io.spring.github.actions.artifactorydeploy.ArtifactoryDeployProperties.Deploy.Build; +import io.spring.github.actions.artifactorydeploy.ArtifactoryDeployProperties.Server; +import io.spring.github.actions.artifactorydeploy.artifactory.Artifactory; +import io.spring.github.actions.artifactorydeploy.artifactory.Artifactory.BuildRun; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.BuildModule; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableArtifact; +import io.spring.github.actions.artifactorydeploy.io.DirectoryScanner; +import io.spring.github.actions.artifactorydeploy.io.FileSet; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link Deployer}. + * + * @author Madhura Bhave + * @author Phillip Webb + * @author Andy Wilkinson + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DeployerTests { + + private static final byte[] NO_BYTES = {}; + + @TempDir + File tempDir; + + @Mock + private Artifactory artifactory; + + @Mock + private DirectoryScanner directoryScanner; + + @Captor + ArgumentCaptor buildRunCaptor; + + @Captor + private ArgumentCaptor artifactCaptor; + + @Test + void deployWhenFolderIsEmptyThrowsException() { + given(this.directoryScanner.scan(any(File.class))).willReturn(FileSet.of()); + assertThatIllegalStateException().isThrownBy(() -> deployer(1).deploy()) + .withMessage("No artifacts found in empty directory '%s'".formatted(this.tempDir)); + } + + @Test + void deployWhenScanningFindsNoFilesThrowsException() throws IOException { + given(this.directoryScanner.scan(any(File.class))).willReturn(FileSet.of()); + Files.createFile(new File(this.tempDir, "file").toPath()); + assertThatIllegalStateException().isThrownBy(() -> deployer(1).deploy()) + .withMessage("No artifacts found to deploy"); + } + + @Test + void deployAddsBuildRun() throws Exception { + File artifact = new File(this.tempDir, "com/example/foo/0.0.1/foo-0.0.1.jar"); + artifact.getParentFile().mkdirs(); + Files.createFile(artifact.toPath()); + given(this.directoryScanner.scan(any(File.class))).willReturn(FileSet.of(artifact)); + deployer(1234).deploy(); + verify(this.artifactory).addBuildRun(eq(null), eq("my-build"), this.buildRunCaptor.capture()); + BuildRun buildRun = this.buildRunCaptor.getValue(); + assertThat(buildRun.number()).isEqualTo(1234); + assertThat(buildRun.modules()).hasSize(1); + assertThat(buildRun.modules()).first().satisfies((module) -> { + assertThat(module.id()).isEqualTo("com.example:foo:0.0.1"); + assertThat(module.artifacts()).hasSize(1).first().satisfies((moduleArtifact) -> { + assertThat(moduleArtifact.name()).isEqualTo("foo-0.0.1.jar"); + assertThat(moduleArtifact.type()).isEqualTo("jar"); + }); + }); + } + + @Test + void deployWithProjectAddsBuildRunToProject() throws Exception { + File artifact = new File(this.tempDir, "com/example/foo/0.0.1/foo-0.0.1.jar"); + artifact.getParentFile().mkdirs(); + Files.createFile(artifact.toPath()); + given(this.directoryScanner.scan(any(File.class))).willReturn(FileSet.of(artifact)); + deployer(1234, "my-project").deploy(); + verify(this.artifactory).addBuildRun(eq("my-project"), eq("my-build"), this.buildRunCaptor.capture()); + BuildRun buildRun = this.buildRunCaptor.getValue(); + assertThat(buildRun.number()).isEqualTo(1234); + assertThat(buildRun.modules()).hasSize(1).first().satisfies((module) -> { + assertThat(module.id()).isEqualTo("com.example:foo:0.0.1"); + assertThat(module.artifacts()).hasSize(1).first().satisfies((moduleArtifact) -> { + assertThat(moduleArtifact.name()).isEqualTo("foo-0.0.1.jar"); + assertThat(moduleArtifact.type()).isEqualTo("jar"); + }); + }); + } + + @Test + void deployDeploysArtifacts() throws Exception { + File artifact = new File(this.tempDir, "com/example/foo/0.0.1/foo-0.0.1.jar"); + artifact.getParentFile().mkdirs(); + Files.createFile(artifact.toPath()); + given(this.directoryScanner.scan(any(File.class))).willReturn(FileSet.of(artifact)); + deployer(1234).deploy(); + verify(this.artifactory).deploy(eq("libs-example-local"), this.artifactCaptor.capture()); + DeployableArtifact deployed = this.artifactCaptor.getValue(); + assertThat(deployed.getPath()).isEqualTo("/com/example/foo/0.0.1/foo-0.0.1.jar"); + assertThat(deployed.getProperties()).containsEntry("build.name", "my-build") + .containsEntry("build.number", "1234") + .containsKey("build.timestamp"); + } + + @Test + void deployDeploysMultipleArtifactsInBatches() throws Exception { + List files = new ArrayList<>(); + File foos = createStructure(this.tempDir, "com", "example", "foo", "0.0.1"); + File bars = createStructure(this.tempDir, "com", "example", "bar", "0.0.1"); + File bazs = createStructure(this.tempDir, "com", "example", "baz", "0.0.1"); + files.add(new File(foos, "foo-0.0.1.jar")); + files.add(new File(bars, "bar-0.0.1.jar")); + files.add(new File(bazs, "baz-0.0.1.jar")); + files.add(new File(foos, "foo-0.0.1.pom")); + files.add(new File(bars, "bar-0.0.1.pom")); + files.add(new File(bazs, "baz-0.0.1.pom")); + files.add(new File(foos, "foo-0.0.1-javadoc.jar")); + files.add(new File(bars, "bar-0.0.1-javadoc.jar")); + files.add(new File(bazs, "baz-0.0.1-javadoc.jar")); + files.add(new File(foos, "foo-0.0.1-sources.jar")); + files.add(new File(bars, "bar-0.0.1-sources.jar")); + files.add(new File(bazs, "baz-0.0.1-sources.jar")); + createEmptyFiles(files); + given(this.directoryScanner.scan(any())).willReturn(FileSet.of(files)); + deployer(1234).deploy(); + verify(this.artifactory, times(12)).deploy(eq("libs-example-local"), this.artifactCaptor.capture()); + List values = this.artifactCaptor.getAllValues(); + for (int i = 0; i < 3; i++) { + assertThat(values.get(i).getPath()).doesNotContain("javadoc", "sources").endsWith(".jar"); + } + for (int i = 3; i < 6; i++) { + assertThat(values.get(i).getPath()).endsWith(".pom"); + } + for (int i = 6; i < 12; i++) { + assertThat(values.get(i).getPath()) + .matches((path) -> path.endsWith("-javadoc.jar") || path.endsWith("-sources.jar")); + } + } + + @Test + void deployWhenHasArtifactPropertiesDeploysWithAdditionalProperties() throws Exception { + File artifact = new File(this.tempDir, "com/example/foo/0.0.1/foo-0.0.1.jar"); + artifact.getParentFile().mkdirs(); + Files.createFile(artifact.toPath()); + given(this.directoryScanner.scan(any(File.class))).willReturn(FileSet.of(artifact)); + deployer(1234, + new ArtifactProperties(List.of("/**/foo-0.0.1.jar"), Collections.emptyList(), Map.of("foo", "bar"))) + .deploy(); + verify(this.artifactory).deploy(eq("libs-example-local"), this.artifactCaptor.capture()); + DeployableArtifact deployed = this.artifactCaptor.getValue(); + assertThat(deployed.getPath()).isEqualTo("/com/example/foo/0.0.1/foo-0.0.1.jar"); + assertThat(deployed.getProperties()).containsEntry("build.name", "my-build") + .containsEntry("build.number", "1234") + .containsKey("build.timestamp") + .containsEntry("foo", "bar"); + } + + @Test + void deployFiltersChecksumFiles() throws IOException { + File fooModule = new File(this.tempDir, "com/example/foo/0.0.1"); + createStructure(fooModule); + List files = new ArrayList<>(); + files.add(new File(fooModule, "foo-0.0.1.jar")); + files.add(new File(fooModule, "foo-0.0.1.md5")); + files.add(new File(fooModule, "foo-0.0.1.sha1")); + files.add(new File(fooModule, "foo-0.0.1.sha256")); + files.add(new File(fooModule, "foo-0.0.1.sha512")); + createEmptyFiles(files); + given(this.directoryScanner.scan(this.tempDir)).willReturn(FileSet.of(files)); + deployer(1234).deploy(); + verify(this.artifactory).addBuildRun(eq(null), eq("my-build"), this.buildRunCaptor.capture()); + List buildModules = this.buildRunCaptor.getValue().modules(); + assertThat(buildModules).hasSize(1).first().satisfies((module) -> assertThat(module.artifacts()).hasSize(1)); + } + + @Test + void deployFiltersOutMavenMetadataFiles() throws IOException { + File fooModule = new File(this.tempDir, "com/example/foo/0.0.1"); + createStructure(fooModule); + List files = new ArrayList<>(); + files.add(new File(fooModule.getParentFile(), "maven-metadata.xml")); + files.add(new File(fooModule, "foo-0.0.1.jar")); + createEmptyFiles(files); + given(this.directoryScanner.scan(this.tempDir)).willReturn(FileSet.of(files)); + deployer(1234).deploy(); + verify(this.artifactory).addBuildRun(eq(null), eq("my-build"), this.buildRunCaptor.capture()); + List buildModules = this.buildRunCaptor.getValue().modules(); + assertThat(buildModules).hasSize(1).first().satisfies((module) -> assertThat(module.artifacts()).hasSize(1)); + } + + @Test + void deployFiltersOutMavenMetadataLocalFiles() throws IOException { + File fooModule = new File(this.tempDir, "com/example/foo/0.0.1"); + createStructure(fooModule); + List files = new ArrayList<>(); + files.add(new File(fooModule.getParentFile(), "maven-metadata-local.xml")); + files.add(new File(fooModule, "foo-0.0.1.jar")); + createEmptyFiles(files); + given(this.directoryScanner.scan(this.tempDir)).willReturn(FileSet.of(files)); + deployer(1234).deploy(); + verify(this.artifactory).addBuildRun(eq(null), eq("my-build"), this.buildRunCaptor.capture()); + List buildModules = this.buildRunCaptor.getValue().modules(); + assertThat(buildModules).hasSize(1).first().satisfies((module) -> assertThat(module.artifacts()).hasSize(1)); + } + + @Test + void deployWithSnapshotTimestampArtifactChangesArtifactPath() throws IOException { + File fooModule = new File(this.tempDir, "com/example/foo/0.0.1-SNAPSHOT"); + createStructure(fooModule); + List files = new ArrayList<>(); + files.add(new File(fooModule, "foo-0.0.1-20240305.110926-1.jar")); + createEmptyFiles(files); + given(this.directoryScanner.scan(this.tempDir)).willReturn(FileSet.of(files)); + deployer(1234).deploy(); + verify(this.artifactory).deploy(eq("libs-example-local"), this.artifactCaptor.capture()); + DeployableArtifact artifact = this.artifactCaptor.getValue(); + assertThat(artifact.getPath()).isEqualTo("/com/example/foo/0.0.1-SNAPSHOT/foo-0.0.1-SNAPSHOT.jar"); + verify(this.artifactory).addBuildRun(eq(null), eq("my-build"), this.buildRunCaptor.capture()); + List buildModules = this.buildRunCaptor.getValue().modules(); + assertThat(buildModules).hasSize(1) + .first() + .satisfies((module) -> assertThat(module.artifacts()).hasSize(1).first().satisfies((moduleArtifact) -> { + assertThat(moduleArtifact.type()).isEqualTo("jar"); + assertThat(moduleArtifact.name()).isEqualTo("foo-0.0.1-SNAPSHOT.jar"); + })); + } + + @Test + void deployWithSnapshotTimestampArtifactRemovesDuplicates() throws IOException { + File fooModule = new File(this.tempDir, "com/example/foo/0.0.1-SNAPSHOT"); + createStructure(fooModule); + List files = new ArrayList<>(); + files.add(new File(fooModule, "foo-0.0.1-20240305.110926-1.jar")); + files.add(new File(fooModule, "foo-0.0.1-20240305.110926-2.jar")); + createEmptyFiles(files); + given(this.directoryScanner.scan(this.tempDir)).willReturn(FileSet.of(files)); + deployer(1234).deploy(); + verify(this.artifactory).deploy(eq("libs-example-local"), this.artifactCaptor.capture()); + DeployableArtifact artifact = this.artifactCaptor.getValue(); + assertThat(artifact.getPath()).isEqualTo("/com/example/foo/0.0.1-SNAPSHOT/foo-0.0.1-SNAPSHOT.jar"); + verify(this.artifactory).addBuildRun(eq(null), eq("my-build"), this.buildRunCaptor.capture()); + List buildModules = this.buildRunCaptor.getValue().modules(); + assertThat(buildModules).hasSize(1) + .first() + .satisfies((module) -> assertThat(module.artifacts()).hasSize(1).first().satisfies((moduleArtifact) -> { + assertThat(moduleArtifact.type()).isEqualTo("jar"); + assertThat(moduleArtifact.name()).isEqualTo("foo-0.0.1-SNAPSHOT.jar"); + })); + } + + private File createStructure(File directory, String... paths) { + File dir = new File(directory, String.join("/", paths)); + dir.mkdirs(); + return dir; + } + + private void createEmptyFiles(List files) throws IOException { + for (File file : files) { + FileCopyUtils.copy(NO_BYTES, file); + } + } + + private Deployer deployer(int buildNumber) { + return deployer(buildNumber, null, null); + } + + private Deployer deployer(int buildNumber, ArtifactProperties artifactProperties) { + return deployer(buildNumber, null, artifactProperties); + } + + private Deployer deployer(int buildNumber, String project) { + return deployer(buildNumber, project, null); + } + + private Deployer deployer(int buildNumber, String project, ArtifactProperties artifactProperties) { + return new Deployer(createProperties(buildNumber, project, artifactProperties), this.artifactory, + this.directoryScanner); + } + + private ArtifactoryDeployProperties createProperties(int buildNumber, String project, + ArtifactProperties artifactProperties) { + return new ArtifactoryDeployProperties(new Server(URI.create("https://repo.example.com"), "alice", "secret"), + null, + new Deploy(project, this.tempDir.getAbsolutePath(), "libs-example-local", 1, + new Build("my-build", buildNumber, URI.create("https://ci.example.com/builds/" + buildNumber)), + (artifactProperties != null) ? List.of(artifactProperties) : Collections.emptyList())); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/ApplicationTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/ApplicationTests.java new file mode 100644 index 0000000..478898e --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/ApplicationTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory; + +import io.spring.github.actions.artifactorydeploy.ArtifactoryDeploy; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ArtifactoryDeploy}. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +@SpringBootTest +@ActiveProfiles("test") +class ApplicationTests { + + @Autowired + private ArtifactoryDeploy application; + + @Test + void applicationLoads() { + assertThat(this.application).isNotNull(); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/HttpArtifactoryTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/HttpArtifactoryTests.java new file mode 100644 index 0000000..ccb8e93 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/HttpArtifactoryTests.java @@ -0,0 +1,323 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory; + +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.SocketException; +import java.net.URI; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import io.spring.github.actions.artifactorydeploy.artifactory.Artifactory.BuildRun; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.BuildArtifact; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.BuildModule; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableArtifact; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableFileArtifact; +import org.json.JSONException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.skyscreamer.jsonassert.JSONAssert; + +import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.RequestMatcher; +import org.springframework.test.web.client.ResponseCreator; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withException; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for {@link HttpArtifactory}. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +class HttpArtifactoryTests { + + private final MockServerRestTemplateCustomizer customizer = new MockServerRestTemplateCustomizer(); + + private MockRestServiceServer server; + + private static final byte[] BYTES; + + private Artifactory artifactory; + + static { + BYTES = new byte[1024 * 11]; + new Random().nextBytes(BYTES); + } + + @TempDir + File tempDir; + + @BeforeEach + void setup() { + RestTemplateBuilder builder = new RestTemplateBuilder().additionalCustomizers(this.customizer); + this.artifactory = new HttpArtifactory(builder, URI.create("https://repo.example.com"), "alice", "secret", + Duration.ofMillis(10)); + this.server = this.customizer.getServer(); + } + + @AfterEach + void tearDown() { + this.customizer.getExpectationManagers().clear(); + } + + @Test + void deployUploadsTheDeployableArtifact() { + DeployableArtifact artifact = artifact("/foo/bar.jar"); + String url = "https://repo.example.com/libs-snapshot-local/foo/bar.jar"; + this.server.expect(requestTo(url)) + .andExpect(method(HttpMethod.PUT)) + .andExpect(header("X-Checksum-Deploy", "true")) + .andExpect(header("X-Checksum-Sha1", artifact.getChecksums().getSha1())) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + this.server.expect(requestTo(url)) + .andExpect(header("Content-Length", Long.toString(artifact.getSize()))) + .andRespond(withSuccess()); + this.artifactory.deploy("libs-snapshot-local", artifact); + this.server.verify(); + } + + @Test + void deployUploadsTheDeployableArtifactWithMatrixParameters() { + Map properties = new HashMap<>(); + properties.put("buildNumber", "1"); + properties.put("revision", "123"); + DeployableArtifact artifact = artifact("/foo/bar.jar", properties); + String url = "https://repo.example.com/libs-snapshot-local/foo/bar.jar;buildNumber=1;revision=123"; + this.server.expect(requestTo(url)).andRespond(withSuccess()); + this.artifactory.deploy("libs-snapshot-local", artifact); + this.server.verify(); + } + + @Test + void deployWhenChecksumMatchesDoesNotUpload() { + DeployableArtifact artifact = artifact("/foo/bar.jar"); + String url = "https://repo.example.com/libs-snapshot-local/foo/bar.jar"; + this.server.expect(requestTo(url)) + .andExpect(method(HttpMethod.PUT)) + .andExpect(header("X-Checksum-Deploy", "true")) + .andExpect(header("X-Checksum-Sha1", artifact.getChecksums().getSha1())) + .andRespond(withSuccess()); + this.artifactory.deploy("libs-snapshot-local", artifact); + this.server.verify(); + } + + @Test + void deployWhenChecksumUploadFailsWithHttpClientErrorExceptionUploads() { + DeployableArtifact artifact = artifact("/foo/bar.jar"); + String url = "https://repo.example.com/libs-snapshot-local/foo/bar.jar"; + this.server.expect(requestTo(url)) + .andExpect(method(HttpMethod.PUT)) + .andExpect(header("X-Checksum-Deploy", "true")) + .andExpect(header("X-Checksum-Sha1", artifact.getChecksums().getSha1())) + .andRespond(withStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)); + this.server.expect(requestTo(url)) + .andExpect(method(HttpMethod.PUT)) + .andExpect(header("X-Checksum-Sha1", artifact.getChecksums().getSha1())) + .andRespond(withSuccess()); + this.artifactory.deploy("libs-snapshot-local", artifact); + this.server.verify(); + } + + @Test + void deployWhenSmallFileDoesNotUseChecksum() { + DeployableArtifact artifact = artifact("/foo/bar.jar", "small".getBytes()); + String url = "https://repo.example.com/libs-snapshot-local/foo/bar.jar"; + this.server.expect(requestTo(url)) + .andExpect(method(HttpMethod.PUT)) + .andExpect(noChecksumHeader()) + .andRespond(withSuccess()); + this.artifactory.deploy("libs-snapshot-local", artifact); + this.server.verify(); + } + + @Test + void deployWhenFlaky400AndLaterAttemptWorksDeploys() { + deployWhenFlaky(false, HttpStatus.BAD_REQUEST); + } + + @Test + void deployWhenFlaky400AndLaterAttemptsFailThrowsException() { + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> deployWhenFlaky(true, HttpStatus.BAD_REQUEST)) + .withMessageStartingWith("Error deploying artifact"); + } + + @Test + void deployWhenFlaky404AndLaterAttemptWorksDeploys() { + deployWhenFlaky(false, HttpStatus.NOT_FOUND); + } + + @Test + void deployWhenFlaky404AndLaterAttemptsFailThrowsException() { + assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> deployWhenFlaky(true, HttpStatus.NOT_FOUND)) + .withMessageStartingWith("Error deploying artifact"); + } + + @Test + void deployWhenFlakySocketExceptionAndLaterAttemptWorksDeploys() { + deployWhenFlaky(false, withException(new SocketException())); + } + + @Test + void deployWhenFlakySocketExceptionAndLaterAttemptsFailThrowsException() { + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> deployWhenFlaky(true, withException(new SocketException()))) + .withMessageStartingWith("Error deploying artifact"); + } + + private void deployWhenFlaky(boolean fail, HttpStatus flakyStatus) { + deployWhenFlaky(fail, withStatus(flakyStatus)); + } + + private void deployWhenFlaky(boolean fail, ResponseCreator failResponse) { + DeployableArtifact artifact = artifact("/foo/bar.jar"); + String url = "https://repo.example.com/libs-snapshot-local/foo/bar.jar"; + try { + this.server.expect(requestTo(url)) + .andExpect(method(HttpMethod.PUT)) + .andExpect(header("X-Checksum-Deploy", "true")) + .andExpect(header("X-Checksum-Sha1", artifact.getChecksums().getSha1())) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + this.server.expect(requestTo(url)).andRespond(failResponse); + this.server.expect(requestTo(url)).andRespond(failResponse); + this.server.expect(requestTo(url)).andRespond(fail ? failResponse : withStatus(HttpStatus.OK)); + this.artifactory.deploy("libs-snapshot-local", artifact); + } + finally { + this.server.verify(); + } + } + + private RequestMatcher noChecksumHeader() { + return (request) -> assertThat(request.getHeaders().keySet()).doesNotContain("X-Checksum-Deploy"); + } + + @Test + void addAddsBuildInfo() { + this.server.expect(requestTo("https://repo.example.com/api/build")) + .andExpect(method(HttpMethod.PUT)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonContent(getResource("payload/build-info.json"))) + .andRespond(withSuccess()); + BuildArtifact artifact = new BuildArtifact("jar", "a9993e364706816aba3e25717850c26c9cd0d89d", + "900150983cd24fb0d6963f7d28e17f72", "foo.jar"); + List artifacts = Collections.singletonList(artifact); + List modules = Collections + .singletonList(new BuildModule("com.example.module:my-module:1.0.0-SNAPSHOT", artifacts)); + Instant started = ZonedDateTime.parse("2014-09-30T12:00:19.893Z", DateTimeFormatter.ISO_DATE_TIME).toInstant(); + this.artifactory.addBuildRun(null, "my-build", + new BuildRun(5678, started, URI.create("https://ci.example.com"), modules)); + this.server.verify(); + } + + @Test + void addWithProjectAddsBuildInfo() { + this.server.expect(requestTo("https://repo.example.com/api/build?project=my-project")) + .andExpect(method(HttpMethod.PUT)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonContent(getResource("payload/build-info.json"))) + .andRespond(withSuccess()); + BuildArtifact artifact = new BuildArtifact("jar", "a9993e364706816aba3e25717850c26c9cd0d89d", + "900150983cd24fb0d6963f7d28e17f72", "foo.jar"); + List artifacts = Collections.singletonList(artifact); + List modules = Collections + .singletonList(new BuildModule("com.example.module:my-module:1.0.0-SNAPSHOT", artifacts)); + Instant started = ZonedDateTime.parse("2014-09-30T12:00:19.893Z", DateTimeFormatter.ISO_DATE_TIME).toInstant(); + this.artifactory.addBuildRun("my-project", "my-build", + new BuildRun(5678, started, URI.create("https://ci.example.com"), modules)); + this.server.verify(); + } + + private RequestMatcher jsonContent(Resource expected) { + return (request) -> { + String actualJson = ((MockClientHttpRequest) request).getBodyAsString(); + String expectedJson = FileCopyUtils + .copyToString(new InputStreamReader(expected.getInputStream(), Charset.forName("UTF-8"))); + assertJson(actualJson, expectedJson); + }; + } + + private void assertJson(String actualJson, String expectedJson) throws AssertionError { + try { + JSONAssert.assertEquals(expectedJson, actualJson, true); + } + catch (JSONException ex) { + throw new AssertionError(ex.getMessage(), ex); + } + } + + private Resource getResource(String path) { + return new ClassPathResource(path, getClass()); + } + + private DeployableArtifact artifact(String path) { + return artifact(path, BYTES, null); + } + + private DeployableArtifact artifact(String path, byte[] bytes) { + return artifact(path, bytes, null); + } + + private DeployableArtifact artifact(String path, Map properties) { + return artifact(path, BYTES, properties); + } + + private DeployableArtifact artifact(String path, byte[] bytes, Map properties) { + File artifact = new File(this.tempDir, path); + artifact.getParentFile().mkdirs(); + try { + Files.write(artifact.toPath(), bytes); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + return new DeployableFileArtifact(path, artifact, properties, null); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/StringToArtifactPropertiesConverterTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/StringToArtifactPropertiesConverterTests.java new file mode 100644 index 0000000..042e12c --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/StringToArtifactPropertiesConverterTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory; + +import java.util.List; + +import io.spring.github.actions.artifactorydeploy.ArtifactoryDeployProperties.Deploy.ArtifactProperties; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link StringToArtifactPropertiesConverter}. + * + * @author Andy Wilkinson + */ +class StringToArtifactPropertiesConverterTests { + + private final StringToArtifactPropertiesConverter converter = new StringToArtifactPropertiesConverter(); + + @Test + void convertWithSingleLineProducesOneArtifactProperties() { + List artifactProperties = this.converter.convert("one,two:three:a=alpha,b=bravo"); + assertThat(artifactProperties).hasSize(1).first().satisfies((properties) -> { + assertThat(properties.include()).containsExactly("one", "two"); + assertThat(properties.exclude()).containsExactly("three"); + assertThat(properties.properties()).hasSize(2).containsEntry("a", "alpha").containsEntry("b", "bravo"); + }); + } + + @Test + void convertWithEmptyIncludeHasEmptyListInArtifactProperties() { + List artifactProperties = this.converter.convert(":one,two:a=alpha"); + assertThat(artifactProperties).hasSize(1).first().satisfies((properties) -> { + assertThat(properties.include()).isEmpty(); + assertThat(properties.exclude()).containsExactly("one", "two"); + assertThat(properties.properties()).hasSize(1).containsEntry("a", "alpha"); + }); + } + + @Test + void convertWithEmptyExcludeHasEmptyListInArtifactProperties() { + List artifactProperties = this.converter.convert("one,two::a=alpha"); + assertThat(artifactProperties).hasSize(1).first().satisfies((properties) -> { + assertThat(properties.include()).containsExactly("one", "two"); + assertThat(properties.exclude()).isEmpty(); + assertThat(properties.properties()).hasSize(1).containsEntry("a", "alpha"); + }); + } + + @Test + void convertWithPropertyWithEqualsInValueHasProperty() { + List artifactProperties = this.converter.convert("one:two:a=alpha=bravo"); + assertThat(artifactProperties).hasSize(1).first().satisfies((properties) -> { + assertThat(properties.include()).containsExactly("one"); + assertThat(properties.exclude()).containsExactly("two"); + assertThat(properties.properties()).hasSize(1).containsEntry("a", "alpha=bravo"); + }); + } + + @Test + void convertWithMultipleLinesProducesMultipleArtifactProperties() { + List artifactProperties = this.converter + .convert("one,two:three:a=alpha,b=bravo%nfour:five:c=charlie%nsix:seven:d=delta".formatted()); + assertThat(artifactProperties).hasSize(3).first().satisfies((properties) -> { + assertThat(properties.include()).containsExactly("one", "two"); + assertThat(properties.exclude()).containsExactly("three"); + assertThat(properties.properties()).hasSize(2).containsEntry("a", "alpha").containsEntry("b", "bravo"); + }); + assertThat(artifactProperties).element(1).satisfies((properties) -> { + assertThat(properties.include()).containsExactly("four"); + assertThat(properties.exclude()).containsExactly("five"); + assertThat(properties.properties()).hasSize(1).containsEntry("c", "charlie"); + }); + assertThat(artifactProperties).element(2).satisfies((properties) -> { + assertThat(properties.include()).containsExactly("six"); + assertThat(properties.exclude()).containsExactly("seven"); + assertThat(properties.properties()).hasSize(1).containsEntry("d", "delta"); + }); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildArtifactTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildArtifactTests.java new file mode 100644 index 0000000..9c46de5 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildArtifactTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; +import org.springframework.boot.test.json.JacksonTester; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link BuildArtifact}. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +@JsonTest +@ActiveProfiles("test") +class BuildArtifactTests { + + private static final String TYPE = "jar"; + + private static final String SHA1 = "a9993e364706816aba3e25717850c26c9cd0d89d"; + + private static final String MD5 = "900150983cd24fb0d6963f7d28e17f72"; + + private static final String NAME = "foo.jar"; + + @Autowired + JacksonTester json; + + @Test + void createWhenTypeIsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new BuildArtifact("", SHA1, MD5, NAME)) + .withMessage("Type must not be empty"); + } + + @Test + void createWhenSha1IsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new BuildArtifact(TYPE, "", MD5, NAME)) + .withMessage("SHA1 must not be empty"); + } + + @Test + void createWhenMd5IsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new BuildArtifact(TYPE, SHA1, "", NAME)) + .withMessage("MD5 must not be empty"); + } + + @Test + void createWhenNameIsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new BuildArtifact(TYPE, SHA1, MD5, "")) + .withMessage("Name must not be empty"); + } + + @Test + void writeSerializesJson() throws Exception { + BuildArtifact artifact = new BuildArtifact(TYPE, SHA1, MD5, NAME); + assertThat(this.json.write(artifact)).isEqualToJson("build-artifact.json"); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildInfoTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildInfoTests.java new file mode 100644 index 0000000..862d275 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildInfoTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; +import org.springframework.boot.test.json.JacksonTester; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link BuildInfo}. + * + * @author Phillip Webb + * @author Madhura Bhave + */ +@JsonTest +@ActiveProfiles("test") +class BuildInfoTests { + + private static final String BUILD_NAME = "my-build"; + + private static final String BUILD_NUMBER = "5678"; + + private static final CiAgent CI_AGENT = new CiAgent("GitHub Actions", "3.0.0"); + + private static final BuildAgent BUILD_AGENT = new BuildAgent("Artifactory Action", "0.1.2"); + + private static final Instant STARTED = ZonedDateTime + .parse("2014-09-30T12:00:19.893123Z", DateTimeFormatter.ISO_DATE_TIME) + .toInstant(); + + private static final String BUILD_URI = "https://ci.example.com"; + + private static final BuildArtifact ARTIFACT = new BuildArtifact("jar", "a9993e364706816aba3e25717850c26c9cd0d89d", + "900150983cd24fb0d6963f7d28e17f72", "foo.jar"); + + private static final List MODULES = Collections.singletonList( + new BuildModule("com.example.module:my-module:1.0.0-SNAPSHOT", Collections.singletonList(ARTIFACT))); + + @Autowired + private JacksonTester json; + + @Test + void createWhenBuildNameIsEmptyThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new BuildInfo("", BUILD_NUMBER, CI_AGENT, BUILD_AGENT, STARTED, BUILD_URI, MODULES)) + .withMessage("Name must not be empty"); + } + + @Test + void createWhenBuildNumberIsEmptyThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new BuildInfo(BUILD_NAME, "", CI_AGENT, BUILD_AGENT, STARTED, BUILD_URI, MODULES)) + .withMessage("Number must not be empty"); + } + + @Test + void createWhenModulesIsNullUsesEmptyList() { + BuildInfo buildInfo = new BuildInfo(BUILD_NAME, BUILD_NUMBER, CI_AGENT, BUILD_AGENT, STARTED, BUILD_URI, null); + assertThat(buildInfo.modules()).isNotNull().isEmpty(); + } + + @Test + void writeSerializesJson() throws Exception { + BuildInfo buildInfo = new BuildInfo(BUILD_NAME, BUILD_NUMBER, CI_AGENT, BUILD_AGENT, STARTED, BUILD_URI, + MODULES); + assertThat(this.json.write(buildInfo)).isEqualToJson("build-info.json"); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildModuleTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildModuleTests.java new file mode 100644 index 0000000..58a867d --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/BuildModuleTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; +import org.springframework.boot.test.json.JacksonTester; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link BuildModule}. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +@JsonTest +@ActiveProfiles("test") +class BuildModuleTests { + + private static final String ID = "com.example.module:my-module:1.0.0-SNAPSHOT"; + + private static final BuildArtifact BUILD_ARTIFACT = new BuildArtifact("jar", + "a9993e364706816aba3e25717850c26c9cd0d89d", "900150983cd24fb0d6963f7d28e17f72", "foo.jar"); + + @Autowired + private JacksonTester json; + + @Test + void createWhenIdIsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new BuildModule("", null)) + .withMessage("ID must not be empty"); + } + + @Test + void createWhenArtifactsIsEmptyUsesEmptyList() { + BuildModule module = new BuildModule(ID, null); + assertThat(module.artifacts()).isNotNull().isEmpty(); + } + + @Test + void writeSerializesJson() throws Exception { + BuildModule module = new BuildModule(ID, Collections.singletonList(BUILD_ARTIFACT)); + assertThat(this.json.write(module)).isEqualToJson("build-module.json"); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/ChecksumsTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/ChecksumsTests.java new file mode 100644 index 0000000..ee402d5 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/ChecksumsTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link Checksums}. + * + * @author Phillip Webb + * @author Madhura Bhave + */ +class ChecksumsTests { + + private static final String SHA1 = "a9993e364706816aba3e25717850c26c9cd0d89d"; + + private static final String MD5 = "900150983cd24fb0d6963f7d28e17f72"; + + @Test + void createWhenSha1IsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new Checksums("", MD5)) + .withMessage("SHA1 must not be empty"); + } + + @Test + void createWhenMd5IsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new Checksums(SHA1, "")) + .withMessage("MD5 must not be empty"); + } + + @Test + void createWhenSha1IsIncorrectLengthThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new Checksums("0", MD5)) + .withMessage("SHA1 must be 40 characters long"); + } + + @Test + void createWhenMd5IsIncorrectLengthThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new Checksums(SHA1, "0")) + .withMessage("MD5 must be 32 characters long"); + } + + @Test + void getSha1GetsSha1() { + Checksums checksums = new Checksums(SHA1, MD5); + assertThat(checksums.getSha1()).isEqualTo(SHA1); + } + + @Test + void getMd5GetsMd5() { + Checksums checksums = new Checksums(SHA1, MD5); + assertThat(checksums.getMd5()).isEqualTo(MD5); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableFileArtifactTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableFileArtifactTests.java new file mode 100644 index 0000000..32cbf83 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/artifactory/payload/DeployableFileArtifactTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.artifactory.payload; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DeployableFileArtifact}. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +class DeployableFileArtifactTests { + + private static final byte[] CONTENT = "abc".getBytes(); + + @TempDir + File tempDir; + + @Test + void createWhenPropertiesIsNullUsesEmptyProperties() { + DeployableArtifact artifact = create("/foo", CONTENT, null, null); + assertThat(artifact.getProperties()).isNotNull().isEmpty(); + } + + @Test + void createWhenChecksumIsNullCalculatesChecksums() { + DeployableArtifact artifact = create("/foo", CONTENT, null, null); + assertThat(artifact.getChecksums().getSha1()).isEqualTo("a9993e364706816aba3e25717850c26c9cd0d89d"); + assertThat(artifact.getChecksums().getMd5()).isEqualTo("900150983cd24fb0d6963f7d28e17f72"); + } + + @Test + void getPropertiesReturnsProperties() { + Map properties = Collections.singletonMap("foo", "bar"); + DeployableArtifact artifact = create("/foo", CONTENT, properties, null); + assertThat(artifact.getProperties()).isEqualTo(properties); + } + + @Test + void getChecksumReturnsChecksum() { + Checksums checksums = new Checksums("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + DeployableArtifact artifact = create("/foo", CONTENT, null, checksums); + assertThat(artifact.getChecksums()).isEqualTo(checksums); + } + + @Test + void getPathReturnsPath() { + DeployableArtifact artifact = create("/foo/bar", CONTENT, null, null); + assertThat(artifact.getPath()).isEqualTo("/foo/bar"); + } + + @Test + void getContentReturnsContent() throws Exception { + DeployableArtifact artifact = create("/foo", CONTENT, null, null); + assertThat(FileCopyUtils.copyToByteArray(artifact.getContent().getInputStream())).isEqualTo(CONTENT); + } + + private DeployableArtifact create(String path, byte[] content, Map properties, + Checksums checksums) { + File artifact = new File(this.tempDir, path); + artifact.getParentFile().mkdirs(); + try { + Files.write(artifact.toPath(), content); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + return new DeployableFileArtifact(path, artifact, properties, checksums); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/io/ChecksumTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/io/ChecksumTests.java new file mode 100644 index 0000000..ad07a67 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/io/ChecksumTests.java @@ -0,0 +1,129 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.io; + +import java.io.File; +import java.io.FileWriter; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.entry; + +/** + * Tests for {@link Checksum}. + * + * @author Phillip Webb + */ +class ChecksumTests { + + private static final String SOURCE = "foo"; + + private static final String SHA1 = "0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33"; + + private static final String MD5 = "acbd18db4cc2f85cedef654fccc4a4d8"; + + @TempDir + File tempDir; + + @Test + void getFileExtensionReturnsExtension() { + assertThat(Checksum.MD5.getFileExtension()).isEqualTo(".md5"); + assertThat(Checksum.SHA1.getFileExtension()).isEqualTo(".sha1"); + } + + @Test + void validateWhenNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> Checksum.SHA1.validate(null)) + .withMessage("SHA1 must not be empty"); + } + + @Test + void validateWhenEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> Checksum.MD5.validate("")) + .withMessage("MD5 must not be empty"); + } + + @Test + void validateWhenTooLongThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> Checksum.SHA1.validate("a9993e364706816aba3e25717850c26c9cd0d89d1")) + .withMessage("SHA1 must be 40 characters long"); + } + + @Test + void validateWhenTooShortThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> Checksum.SHA1.validate("a9993e364706816aba3e25717850c26c9cd0d89")) + .withMessage("SHA1 must be 40 characters long"); + } + + @Test + void generateChecksumFilesGeneratesChecksumFiles() throws Exception { + File file = new File(this.tempDir, "test"); + FileCopyUtils.copy(SOURCE, new FileWriter(file)); + Checksum.generateChecksumFiles(file); + assertThat(new File(file.getParentFile(), file.getName() + ".sha1")).hasContent(SHA1); + assertThat(new File(file.getParentFile(), file.getName() + ".md5")).hasContent(MD5); + } + + @Test + void generateChecksumFileWhenChecksumFileDoesNotGenerate() throws Exception { + File file = new File(this.tempDir, "test.md5"); + FileCopyUtils.copy(SOURCE, new FileWriter(file)); + Checksum.generateChecksumFiles(file); + assertThat(new File(file.getParentFile(), file.getName() + ".sha1")).doesNotExist(); + assertThat(new File(file.getParentFile(), file.getName() + ".md5")).doesNotExist(); + } + + @Test + void isChecksumFileWhenChecksumFileReturnsTrue() { + assertThat(Checksum.isChecksumFile("foo.md5")).isTrue(); + assertThat(Checksum.isChecksumFile("foo/bar.MD5")).isTrue(); + assertThat(Checksum.isChecksumFile("foo.sha1")).isTrue(); + } + + @Test + void isChecksumFileWhenNotChecksumFileReturnsFalse() { + assertThat(Checksum.isChecksumFile("foo.xml")).isFalse(); + } + + @Test + void getFileExtensionsReturnsExtensions() { + assertThat(Checksum.getFileExtensions()).containsOnly(".md5", ".sha1"); + } + + @Test + void calculateAllFromResourceReturnsChecksums() { + ByteArrayResource resource = new ByteArrayResource(SOURCE.getBytes()); + Map checksums = Checksum.calculateAll(resource); + assertThat(checksums).containsOnly(entry(Checksum.MD5, MD5), entry(Checksum.SHA1, SHA1)); + } + + @Test + void calculateAllFromStringReturnsChecksums() { + Map checksums = Checksum.calculateAll(SOURCE); + assertThat(checksums).containsOnly(entry(Checksum.MD5, MD5), entry(Checksum.SHA1, SHA1)); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/io/DirectoryScannerTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/io/DirectoryScannerTests.java new file mode 100644 index 0000000..cccf62e --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/io/DirectoryScannerTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.io; + +import java.io.File; +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DirectoryScanner}. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +class DirectoryScannerTests { + + private static final byte[] NO_BYTES = {}; + + @TempDir + File tempDir; + + private DirectoryScanner scanner = new DirectoryScanner(); + + @Test + void scanFindsAllFiles() throws Exception { + File root = createFiles(); + FileSet files = this.scanner.scan(root); + assertThat(files).extracting((f) -> relativePath(root, f)) + .containsExactly("/bar/bar.jar", "/bar/bar.pom", "/baz/baz.jar", "/baz/baz.pom"); + } + + private String relativePath(File rootFile, File file) { + String root = StringUtils.cleanPath(rootFile.getPath()); + String path = StringUtils.cleanPath(file.getPath()); + return path.substring(root.length()); + } + + private File createFiles() throws IOException { + File root = this.tempDir; + File barDir = new File(root, "bar"); + touch(new File(barDir, "bar.jar")); + touch(new File(barDir, "bar.pom")); + File bazDir = new File(root, "baz"); + touch(new File(bazDir, "baz.jar")); + touch(new File(bazDir, "baz.pom")); + return root; + } + + private void touch(File file) throws IOException { + file.getParentFile().mkdirs(); + FileCopyUtils.copy(NO_BYTES, file); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/io/FileSetTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/io/FileSetTests.java new file mode 100644 index 0000000..0589ff6 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/io/FileSetTests.java @@ -0,0 +1,235 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.io; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.function.Consumer; + +import io.spring.github.actions.artifactorydeploy.io.FileSet.Category; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link FileSet}. + * + * @author Phillip Webb + */ +class FileSetTests { + + @TempDir + File tempDir; + + @Test + void ofWhenArrayIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> FileSet.of((File[]) null)) + .withMessage("Files must not be null"); + } + + @Test + void ofWhenListIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> FileSet.of((List) null)) + .withMessage("Files must not be null"); + } + + @Test + void ofOrdersOnParentPath() { + assertThatFileSetIsOrdered("bar/bar.jar", "foo/bar.jar"); + } + + @Test + void ofWhenIdenticalPathOrdersOnExtension() { + assertThatFileSetIsOrdered("foo/bar.jar", "foo/bar.war"); + } + + @Test + void ofWhenPomToNonPomOrdersPomLast() { + assertThatFileSetIsOrdered("foo.jar", "foo.pom"); + } + + @Test + void ofWhenIdenticalPathAndExceptionOrdersOnName() { + assertThat(fileSetOf("foo/bar.jar", "foo/bar-a.jar", "foo/bar-b.jar")) + .satisfies(filesNamed("bar.jar", "bar-a.jar", "bar-b.jar")); + } + + @Test + void ofWhenNoFileExtensionOrdersOnName() { + assertThatFileSetIsOrdered("foo/bar", "foo/bar.jar"); + } + + @Test + void ofOrdersFilesCorrectly() { + List names = new ArrayList<>(); + names.add("com/example/project/foo/2.0.0/foo-2.0.0-sources.jar"); + names.add("com/example/project/foo/2.0.0/foo-2.0.0.jar"); + names.add("com/example/project/bar/2.0.0/bar-2.0.0-sources.jar"); + names.add("com/example/project/foo/2.0.0/foo-2.0.0.pom"); + names.add("com/example/project/foo/2.0.0/maven-metadata.xml"); + names.add("com/example/project/bar/2.0.0/bar-2.0.0.pom"); + names.add("com/example/project/foo/2.0.0/foo-2.0.0-javadoc.jar"); + names.add("com/example/project/bar/2.0.0/bar-2.0.0.jar"); + names.add("com/example/project/bar/2.0.0/bar-2.0.0-javadoc.jar"); + names.add("com/example/project/bar/2.0.0/maven-metadata-local.xml"); + FileSet fileSet = fileSetOf(names); + assertThat(fileSet).satisfies(filesNamed("bar-2.0.0.jar", "bar-2.0.0.pom", "maven-metadata-local.xml", + "bar-2.0.0-javadoc.jar", "bar-2.0.0-sources.jar", "foo-2.0.0.jar", "foo-2.0.0.pom", + "maven-metadata.xml", "foo-2.0.0-javadoc.jar", "foo-2.0.0-sources.jar")); + } + + @Test + void ofWhenHasShorterHiddenFileOrdersFilesCorrectly() { + List names = new ArrayList<>(); + String folder = "com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/"; + names.add(folder + ".foo.bar"); + names.add(folder + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-SNAPSHOT.jar"); + names.add(folder + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-SNAPSHOT-javadoc.jar"); + names.add(folder + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-SNAPSHOT-sources.jar"); + names.add(folder + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-SNAPSHOT.pom"); + FileSet fileSet = fileSetOf(names); + assertThat(fileSet).satisfies(filesNamed("spring-boot-actuator-autoconfigure-2.0.0.BUILD-SNAPSHOT.jar", + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-SNAPSHOT.pom", ".foo.bar", + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-SNAPSHOT-javadoc.jar", + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-SNAPSHOT-sources.jar")); + } + + @Test // spring-io/artifactory-resource#4 + void ofWhenUsingTypicalOutputWorksInSort() throws Exception { + List names = readNames(getClass().getResourceAsStream("typical.txt")); + FileSet fileSet = fileSetOf(names).filter(this::filter); + assertThat(fileSet).satisfies(filesNamed("spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1.jar", + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1.pom", + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1-javadoc.jar", + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1-sources.jar", + "spring-boot-actuator-2.0.0.BUILD-20171030.171543-1.jar", + "spring-boot-actuator-2.0.0.BUILD-20171030.171543-1.pom", + "spring-boot-actuator-2.0.0.BUILD-20171030.171543-1-javadoc.jar", + "spring-boot-actuator-2.0.0.BUILD-20171030.171543-1-sources.jar", + "spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1.jar", + "spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1.pom", + "spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1-sources.jar")); + } + + @Test + void filterFiltersFiles() { + FileSet fileSet = fileSetOf("test.jar", "test.md5").filter(this::filter); + assertThat(fileSet).satisfies(filesNamed("test.jar")); + } + + @Test + void batchedByCategoryReturnsBatchedFiles() throws Exception { + List names = readNames(getClass().getResourceAsStream("typical.txt")); + FileSet fileSet = fileSetOf(names).filter(this::filter); + MultiValueMap batched = fileSet.batchedByCategory(); + assertThat((Iterable) batched.get(Category.PRIMARY)) + .satisfies(filesNamed("spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1.jar", + "spring-boot-actuator-2.0.0.BUILD-20171030.171543-1.jar", + "spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1.jar")); + assertThat((Iterable) batched.get(Category.POM)) + .satisfies(filesNamed("spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1.pom", + "spring-boot-actuator-2.0.0.BUILD-20171030.171543-1.pom", + "spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1.pom")); + assertThat(batched.get(Category.MAVEN_METADATA)).isNull(); + assertThat((Iterable) batched.get(Category.ADDITIONAL)) + .satisfies(filesNamed("spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1-javadoc.jar", + "spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1-sources.jar", + "spring-boot-actuator-2.0.0.BUILD-20171030.171543-1-javadoc.jar", + "spring-boot-actuator-2.0.0.BUILD-20171030.171543-1-sources.jar", + "spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1-sources.jar")); + } + + @Test + void batchedByCategoryWithAscFilesReturnsBatchedFiles() throws Exception { + List names = readNames(getClass().getResourceAsStream("typical.txt")); + List signedNames = new ArrayList<>(); + for (String name : names) { + signedNames.add(name); + signedNames.add(name + ".asc"); + } + FileSet fileSet = fileSetOf(signedNames).filter(this::filter); + MultiValueMap batched = fileSet.batchedByCategory(); + List signatureFiles = batched.get(Category.SIGNATURE); + assertThat(signatureFiles).hasSize(names.size()); + assertThat(signatureFiles).allSatisfy((file) -> file.getName().endsWith(".asc")); + } + + private boolean filter(File file) { + String name = file.getName().toLowerCase(); + return (!name.endsWith(".md5") && !name.endsWith("sha1") && !name.equalsIgnoreCase("maven-metadata.xml")); + } + + private void assertThatFileSetIsOrdered(String... names) { + File[] expected = Arrays.stream(names).map(this::makeFile).toArray(File[]::new); + FileSet fileSet = fileSetOf(names); + assertThat(fileSet).containsExactly(expected); + } + + private FileSet fileSetOf(List names) { + return fileSetOf(StringUtils.toStringArray(names)); + } + + private FileSet fileSetOf(String... names) { + File[] files = Arrays.stream(names).map(this::makeFile).toArray(File[]::new); + FileSet fileSet = FileSet.of(files); + File[] reversedFiles = files.clone(); + Collections.reverse(Arrays.asList(reversedFiles)); + FileSet reversedFileSet = FileSet.of(reversedFiles); + assertThat(fileSet).containsExactlyElementsOf(reversedFileSet); + return fileSet; + } + + private List readNames(InputStream inputStream) throws IOException { + List names = new ArrayList<>(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + String name = reader.readLine(); + while (name != null) { + names.add(name); + name = reader.readLine(); + } + reader.close(); + return names; + } + + private File makeFile(String path) { + String tempPath = this.tempDir.getAbsolutePath(); + return new File(StringUtils.cleanPath(tempPath) + "/" + StringUtils.cleanPath(path)); + } + + private Consumer> filesNamed(String... names) { + return (iterable) -> { + Iterator iterator = iterable.iterator(); + for (String name : names) { + assertThat(iterator.next()).hasName(name); + } + }; + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenBuildModulesGeneratorTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenBuildModulesGeneratorTests.java new file mode 100644 index 0000000..2771128 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenBuildModulesGeneratorTests.java @@ -0,0 +1,103 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.maven; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +import io.spring.github.actions.artifactorydeploy.artifactory.payload.BuildArtifact; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.BuildModule; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableArtifact; +import io.spring.github.actions.artifactorydeploy.artifactory.payload.DeployableFileArtifact; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MavenBuildModulesGenerator}. + * + * @author Phillip Webb + * @author Madhura Bhave + * @author Andy Wilkinson + */ +class MavenBuildModulesGeneratorTests { + + private MavenBuildModulesGenerator generator = new MavenBuildModulesGenerator(); + + @TempDir + File tempDir; + + @Test + void getBuildModulesReturnsBuildModules() { + List deployableArtifacts = new ArrayList<>(); + deployableArtifacts.add(artifact("/com/example/foo/1.0.0/foo-1.0.0.pom")); + deployableArtifacts.add(artifact("/com/example/foo/1.0.0/foo-1.0.0.jar")); + deployableArtifacts.add(artifact("/com/example/foo/1.0.0/foo-1.0.0-sources.jar")); + deployableArtifacts.add(artifact("/com/example/bar/1.0.0/bar-1.0.0.pom")); + deployableArtifacts.add(artifact("/com/example/bar/1.0.0/bar-1.0.0.jar")); + deployableArtifacts.add(artifact("/com/example/bar/1.0.0/bar-1.0.0-sources.jar")); + List buildModules = this.generator.getBuildModules(deployableArtifacts); + assertThat(buildModules).hasSize(2); + assertThat(buildModules.get(0).id()).isEqualTo("com.example:foo:1.0.0"); + assertThat(buildModules.get(0).artifacts()).extracting(BuildArtifact::name) + .containsExactly("foo-1.0.0.pom", "foo-1.0.0.jar", "foo-1.0.0-sources.jar"); + assertThat(buildModules.get(0).artifacts()).extracting(BuildArtifact::type) + .containsExactly("pom", "jar", "java-source-jar"); + assertThat(buildModules.get(1).id()).isEqualTo("com.example:bar:1.0.0"); + assertThat(buildModules.get(1).artifacts()).extracting(BuildArtifact::name) + .containsExactly("bar-1.0.0.pom", "bar-1.0.0.jar", "bar-1.0.0-sources.jar"); + assertThat(buildModules.get(1).artifacts()).extracting(BuildArtifact::type) + .containsExactly("pom", "jar", "java-source-jar"); + } + + @Test + void getBuildModulesWhenContainingSpecificExtensionsFiltersArtifacts() { + List deployableArtifacts = new ArrayList<>(); + deployableArtifacts.add(artifact("/com/example/foo/1.0.0/foo-1.0.0.pom")); + deployableArtifacts.add(artifact("/com/example/foo/1.0.0/foo-1.0.0.asc")); + deployableArtifacts.add(artifact("/com/example/foo/1.0.0/foo-1.0.0.md5")); + deployableArtifacts.add(artifact("/com/example/foo/1.0.0/foo-1.0.0.sha")); + List buildModules = this.generator.getBuildModules(deployableArtifacts); + assertThat(buildModules.get(0).artifacts()).extracting(BuildArtifact::name) + .containsExactly("foo-1.0.0.pom", "foo-1.0.0.asc"); + } + + @Test + void getBuildModulesWhenContainingUnexpectedLayoutReturnsEmptyList() { + List deployableArtifacts = new ArrayList<>(); + deployableArtifacts.add(artifact("/foo-1.0.0.zip")); + List buildModules = this.generator.getBuildModules(deployableArtifacts); + assertThat(buildModules).isEmpty(); + } + + private DeployableArtifact artifact(String path) { + File artifact = new File(this.tempDir, path); + artifact.getParentFile().mkdirs(); + try { + Files.createFile(artifact.toPath()); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + return new DeployableFileArtifact(path, artifact, null, null); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenCoordinatesTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenCoordinatesTests.java new file mode 100644 index 0000000..a45ea2d --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenCoordinatesTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.maven; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link MavenCoordinates}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class MavenCoordinatesTests { + + @Test + void fromPathReturnsCoordinates() { + MavenCoordinates coordinates = MavenCoordinates.fromPath( + "/com/example/project/" + "my-project/" + "1.0.0-SNAPSHOT/" + "my-project-1.0.0-20171005.194031-1.jar"); + assertThat(coordinates.getGroupId()).isEqualTo("com.example.project"); + assertThat(coordinates.getArtifactId()).isEqualTo("my-project"); + assertThat(coordinates.getVersion()).isEqualTo("1.0.0-SNAPSHOT"); + assertThat(coordinates.getClassifier()).isEqualTo(""); + assertThat(coordinates.getSnapshotVersion()).isEqualTo("1.0.0-20171005.194031-1"); + } + + @Test + void fromPathWhenHasClassifierReturnsCoordinates() { + MavenCoordinates coordinates = MavenCoordinates.fromPath("/com/example/project/" + "my-project/" + + "1.0.0-SNAPSHOT/" + "my-project-1.0.0-20171005.194031-1-sources.jar"); + assertThat(coordinates.getGroupId()).isEqualTo("com.example.project"); + assertThat(coordinates.getArtifactId()).isEqualTo("my-project"); + assertThat(coordinates.getVersion()).isEqualTo("1.0.0-SNAPSHOT"); + assertThat(coordinates.getClassifier()).isEqualTo("sources"); + assertThat(coordinates.getSnapshotVersion()).isEqualTo("1.0.0-20171005.194031-1"); + } + + @Test + void fromPathWhenReleaseReturnsCoordinates() { + MavenCoordinates coordinates = MavenCoordinates + .fromPath("/com/example/project/" + "my-project/" + "1.0.0/" + "my-project-1.0.0-sources.jar"); + assertThat(coordinates.getGroupId()).isEqualTo("com.example.project"); + assertThat(coordinates.getArtifactId()).isEqualTo("my-project"); + assertThat(coordinates.getVersion()).isEqualTo("1.0.0"); + assertThat(coordinates.getClassifier()).isEqualTo("sources"); + assertThat(coordinates.getSnapshotVersion()).isEqualTo("1.0.0"); + } + + @Test // spring-io/artifactory-resource#5 + void fromPathWhenIsBadThrowsNiceException() { + assertThatIllegalStateException() + .isThrownBy(() -> MavenCoordinates.fromPath("org/springframework/cloud/skipper/acceptance/app/" + + "skipper-server-with-drivers/maven-metadata-local.xml")) + .withMessageContaining("Unable to parse maven coordinates from path") + .withStackTraceContaining("Name 'maven-metadata-local.xml' does not start with artifact ID 'app'"); + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenVersionTypeTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenVersionTypeTests.java new file mode 100644 index 0000000..7a879da --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/maven/MavenVersionTypeTests.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.maven; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MavenVersionType}. + * + * @author Phillip Webb + */ +class MavenVersionTypeTests { + + @Test + void fromVersionWhenTimestampReturnsTimestampSnapshot() { + assertThat(MavenVersionType.fromVersion("0.0.1.BUILD-20171005.194031-1")) + .isEqualTo(MavenVersionType.TIMESTAMP_SNAPSHOT); + assertThat(MavenVersionType.fromVersion("0.0.1-20171005.194031-1")) + .isEqualTo(MavenVersionType.TIMESTAMP_SNAPSHOT); + } + + @Test + void fromVersionWhenSnapshotReturnsSnapshot() { + assertThat(MavenVersionType.fromVersion("0.0.1.BUILD-SNAPSHOT")).isEqualTo(MavenVersionType.SNAPSHOT); + assertThat(MavenVersionType.fromVersion("0.0.1-SNAPSHOT")).isEqualTo(MavenVersionType.SNAPSHOT); + } + + @Test + void fromVersionWhenFixedReturnsFixed() { + assertThat(MavenVersionType.fromVersion("0.0.1.RELEASE")).isEqualTo(MavenVersionType.FIXED); + assertThat(MavenVersionType.fromVersion("0.0.1")).isEqualTo(MavenVersionType.FIXED); + + } + +} diff --git a/src/test/java/io/spring/github/actions/artifactorydeploy/openpgp/ArmoredAsciiSignerTests.java b/src/test/java/io/spring/github/actions/artifactorydeploy/openpgp/ArmoredAsciiSignerTests.java new file mode 100644 index 0000000..c66acf2 --- /dev/null +++ b/src/test/java/io/spring/github/actions/artifactorydeploy/openpgp/ArmoredAsciiSignerTests.java @@ -0,0 +1,351 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.github.actions.artifactorydeploy.openpgp; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamSource; +import org.springframework.core.io.Resource; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ArmoredAsciiSigner}. + * + * @author Phillip Webb + */ +class ArmoredAsciiSignerTests { + + private static final Clock FIXED = Clock.fixed(Instant.EPOCH, ZoneId.of("UTC")); + + private File signingKeyFile; + + private Resource signingKeyResource; + + private String signingKeyContent; + + private String passphrase = "password"; + + private File sourceFile; + + private String sourceContent; + + private String expectedSignature; + + private File temp; + + @BeforeEach + void setup(@TempDir File temp) throws Exception { + this.temp = temp; + this.signingKeyFile = copyClasspathFile("test-private.txt"); + this.signingKeyResource = new FileSystemResource(this.signingKeyFile); + this.signingKeyContent = copyToString(this.signingKeyFile); + this.sourceFile = copyClasspathFile("source.txt"); + this.sourceContent = copyToString(this.sourceFile); + this.expectedSignature = copyToString(ArmoredAsciiSignerTests.class.getResourceAsStream("expected.asc")); + } + + @Test + void getWithStringSigningKeyWhenSigningKeyIsKeyReturnsSigner() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, this.passphrase); + assertThat(signer.sign(this.sourceContent)).isEqualTo(this.expectedSignature); + } + + @Test + void getWithStringSigningKeyWhenSigningKeyIsFileReturnsSigner() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyFile.getAbsolutePath(), + this.passphrase); + assertThat(signer.sign(this.sourceContent)).isEqualTo(this.expectedSignature); + } + + @Test + void getWithStringSigningKeyWhenClockIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(null, this.signingKeyContent, this.passphrase)) + .withMessage("Clock must not be null"); + } + + @Test + void getWithStringSigningKeyWhenSigningKeyIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, (String) null, this.passphrase)) + .withMessage("SigningKey must not be null"); + } + + @Test + void getWithStringSigningKeyWhenSigningKeyIsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, "", this.passphrase)) + .withMessage("SigningKey must not be empty"); + } + + @Test + void getWithStringSigningKeyWhenSigningKeyIsMultiLineWithoutHeaderThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, "ab\ncd", this.passphrase)) + .withMessage( + "Signing key is not does not contain a PGP private key block " + "and does not reference a file"); + } + + @Test + void getWithStringSigningKeyWhenSigningKeyIsMalformedThrowsException() throws Exception { + String signingKey = copyToString(getClass().getResourceAsStream("test-bad-private.txt")); + assertThatIllegalStateException().isThrownBy(() -> ArmoredAsciiSigner.get(signingKey, this.passphrase)) + .withMessage("Unable to read signing key"); + } + + @Test + void getWithStringSigningKeyWhenPassphraseIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, null)) + .withMessage("Passphrase must not be null"); + } + + @Test + void getWithStringSigningKeyWhenPassphraseIsWrongThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, this.signingKeyFile, "bad")) + .withMessage("Unable to extract private key"); + } + + @Test + void getWithFileSigningKeyKeyReturnsSigner() throws IOException { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyFile, this.passphrase); + assertThat(signer.sign(this.sourceContent)).isEqualTo(this.expectedSignature); + } + + @Test + void getWithFileSigningKeyWhenClockIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(null, this.signingKeyFile, this.passphrase)) + .withMessage("Clock must not be null"); + } + + @Test + void getWithFileSigningKeyWhenSigningKeyIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, (File) null, this.passphrase)) + .withMessage("SigningKey must not be null"); + } + + @Test + void getWithFileSigningKeyWhenSigningKeyIsMalformedThrowsException() throws Exception { + File signingKey = copyClasspathFile("test-bad-private.txt"); + assertThatIllegalStateException().isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, signingKey, this.passphrase)) + .withMessage("Unable to read signing key"); + } + + @Test + void getWithFileSigningKeyWhenPassphraseIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, this.signingKeyFile, null)) + .withMessage("Passphrase must not be null"); + } + + @Test + void getWithFileSigningKeyWhenPassphraseIsWrongThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, this.signingKeyFile, "bad")) + .withMessage("Unable to extract private key"); + } + + @Test + void getWithInputStreamSourceSigningKeyKeyReturnsSigner() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyResource, this.passphrase); + assertThat(signer.sign(this.sourceContent)).isEqualTo(this.expectedSignature); + } + + @Test + void getWithInputStreamSourceSigningKeyWhenClockIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(null, this.signingKeyResource, this.passphrase)) + .withMessage("Clock must not be null"); + } + + @Test + void getWithInputStreamSourceSigningKeyWhenSigningKeyIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, (InputStreamSource) null, this.passphrase)) + .withMessage("SigningKey must not be null"); + } + + @Test + void getWithInputStreamSourceSigningKeyWhenSigningKeyIsMalformedThrowsException() throws Exception { + File signingKeyFile = copyClasspathFile("test-bad-private.txt"); + Resource signingKeyResource = new FileSystemResource(signingKeyFile); + assertThatIllegalStateException().isThrownBy(() -> ArmoredAsciiSigner.get(signingKeyResource, this.passphrase)) + .withMessage("Unable to read signing key"); + } + + @Test + void getWithInputStreamSourceSigningKeyWhenPassphraseIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, this.signingKeyResource, null)) + .withMessage("Passphrase must not be null"); + } + + @Test + void getWithInputStreamSourceSigningKeyWhenPassphraseIsWrongThrowsException() { + assertThatIllegalStateException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, this.signingKeyResource, "bad")) + .withMessage("Unable to extract private key"); + } + + @Test + void getWithInputStreamSigningKeyKeyReturnsSigner() { + assertThatIllegalStateException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, new FileInputStream(this.signingKeyFile), "bad")) + .withMessage("Unable to extract private key"); + } + + @Test + void getWithInputStreamSigningKeyWhenClockIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(null, new FileInputStream(this.signingKeyFile), this.passphrase)) + .withMessage("Clock must not be null"); + } + + @Test + void getWithInputStreamSigningKeyWhenSigningKeyIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, (InputStream) null, this.passphrase)) + .withMessage("SigningKey must not be null"); + } + + @Test + void getWithInputStreamSigningKeyWhenSigningKeyIsMalformedThrowsException() throws Exception { + File signingKey = copyClasspathFile("test-bad-private.txt"); + assertThatIllegalStateException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, new FileInputStream(signingKey), this.passphrase)) + .withMessage("Unable to read signing key"); + } + + @Test + void getWithInputStreamSigningKeyWhenPassphraseIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, new FileInputStream(this.signingKeyFile), null)) + .withMessage("Passphrase must not be null"); + } + + @Test + void getWithInputStreamSigningKeyWhenPassphraseIsWrongThrowsException() { + assertThatIllegalStateException() + .isThrownBy(() -> ArmoredAsciiSigner.get(FIXED, new FileInputStream(this.signingKeyFile), "bad")) + .withMessage("Unable to extract private key"); + } + + @Test + void signWithStringReturnsSignature() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, this.passphrase); + assertThat(signer.sign(this.sourceContent)).isEqualTo(this.expectedSignature); + } + + @Test + void signWithStringWhenSourceIsNullThrowsException() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, this.passphrase); + assertThatIllegalArgumentException().isThrownBy(() -> signer.sign((String) null)) + .withMessage("Source must not be null"); + } + + @Test + void signWithInputStreamSourceReturnsSignature() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, this.passphrase); + assertThat(signer.sign(new FileSystemResource(this.sourceFile))).isEqualTo(this.expectedSignature); + } + + @Test + void signWithInputStreamSourceWhenSourceIsNullThrowsException() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, this.passphrase); + assertThatIllegalArgumentException().isThrownBy(() -> signer.sign((InputStreamSource) null)) + .withMessage("Source must not be null"); + } + + @Test + void signWithInputStreamReturnsSignature() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, this.passphrase); + assertThat(signer.sign(new FileInputStream(this.sourceFile))).isEqualTo(this.expectedSignature); + } + + @Test + void signWithInputStreamWhenSourceIsNullThrowsException() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, this.passphrase); + assertThatIllegalArgumentException().isThrownBy(() -> signer.sign((InputStream) null)) + .withMessage("Source must not be null"); + } + + @Test + void signWithInputStreamAndOutputStreamWritesSignature() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, this.passphrase); + ByteArrayOutputStream destination = new ByteArrayOutputStream(); + signer.sign(new FileInputStream(this.sourceFile), destination); + assertThat(destination.toByteArray()).asString(StandardCharsets.UTF_8).isEqualTo(this.expectedSignature); + } + + @Test + void signWithInputStreamAndOutputStreamWritesWhenSourceIsNullThrowsException() throws IOException { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, this.passphrase); + assertThatIllegalArgumentException().isThrownBy(() -> signer.sign(null, new ByteArrayOutputStream())) + .withMessage("Source must not be null"); + } + + @Test + void signWithInputStreamAndOutputStreamWritesWhenDestinationIsNullThrowsException() throws Exception { + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(FIXED, this.signingKeyContent, this.passphrase); + assertThatIllegalArgumentException().isThrownBy(() -> signer.sign(new FileInputStream(this.sourceFile), null)) + .withMessage("Destination must not be null"); + } + + @Test + void signWithClockTickReturnsDifferentContent() throws Exception { + Clock clock = mock(Clock.class); + given(clock.instant()).willReturn(FIXED.instant(), Clock.offset(FIXED, Duration.ofSeconds(2)).instant()); + ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(clock, this.signingKeyContent, this.passphrase); + String signatureOne = signer.sign(this.sourceContent); + String signatureTwo = signer.sign(this.sourceContent); + assertThat(signatureOne).isNotEqualTo(signatureTwo); + } + + private File copyClasspathFile(String name) throws IOException { + File file = new File(this.temp, name); + FileCopyUtils.copy(ArmoredAsciiSignerTests.class.getResourceAsStream(name), new FileOutputStream(file)); + return file; + } + + private String copyToString(File file) throws IOException { + return copyToString(new FileInputStream(file)); + } + + private String copyToString(InputStream inputStream) throws IOException { + return new String(FileCopyUtils.copyToByteArray(inputStream), StandardCharsets.UTF_8); + } + +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..1a58c27 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,4 @@ +artifactory.server.uri=https://repo.example.com +artifactory.deploy.build.name=test +artifactory.deploy.folder=artifacts +artifactory.deploy.repository=test-artifacts-local \ No newline at end of file diff --git a/src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-artifact.json b/src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-artifact.json new file mode 100644 index 0000000..bb406de --- /dev/null +++ b/src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-artifact.json @@ -0,0 +1,6 @@ +{ + "type": "jar", + "sha1": "a9993e364706816aba3e25717850c26c9cd0d89d", + "md5": "900150983cd24fb0d6963f7d28e17f72", + "name": "foo.jar" +} \ No newline at end of file diff --git a/src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-info.json b/src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-info.json new file mode 100644 index 0000000..d6a3aba --- /dev/null +++ b/src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-info.json @@ -0,0 +1,21 @@ +{ + "name": "my-build", + "number": "5678", + "agent": { + "name": "GitHub Actions" + }, + "buildAgent": { + "name": "Artifactory Action" + }, + "started": "2014-09-30T12:00:19.893Z", + "url": "https://ci.example.com", + "modules": [{ + "id": "com.example.module:my-module:1.0.0-SNAPSHOT", + "artifacts": [{ + "type": "jar", + "sha1": "a9993e364706816aba3e25717850c26c9cd0d89d", + "md5": "900150983cd24fb0d6963f7d28e17f72", + "name": "foo.jar" + }] + }] +} diff --git a/src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-module.json b/src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-module.json new file mode 100644 index 0000000..c349132 --- /dev/null +++ b/src/test/resources/io/spring/github/actions/artifactorydeploy/artifactory/payload/build-module.json @@ -0,0 +1,9 @@ +{ + "id": "com.example.module:my-module:1.0.0-SNAPSHOT", + "artifacts": [{ + "type": "jar", + "sha1": "a9993e364706816aba3e25717850c26c9cd0d89d", + "md5": "900150983cd24fb0d6963f7d28e17f72", + "name": "foo.jar" + }] +} \ No newline at end of file diff --git a/src/test/resources/io/spring/github/actions/artifactorydeploy/io/typical.txt b/src/test/resources/io/spring/github/actions/artifactorydeploy/io/typical.txt new file mode 100644 index 0000000..c8b0b83 --- /dev/null +++ b/src/test/resources/io/spring/github/actions/artifactorydeploy/io/typical.txt @@ -0,0 +1,51 @@ +com/example/project/spring-boot-starter-actuator/maven-metadata.xml +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1.jar.md5 +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/maven-metadata.xml +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1.pom.sha1 +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1.jar +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1.pom +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1.pom.md5 +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1.jar.sha1 +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1-sources.jar.sha1 +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1-sources.jar +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-starter-actuator-2.0.0.BUILD-20171030.172553-1-sources.jar.md5 +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/maven-metadata.xml.md5 +com/example/project/spring-boot-starter-actuator/2.0.0.BUILD-SNAPSHOT/maven-metadata.xml.sha1 +com/example/project/spring-boot-starter-actuator/maven-metadata.xml.md5 +com/example/project/spring-boot-starter-actuator/maven-metadata.xml.sha1 +com/example/project/spring-boot-actuator-autoconfigure/maven-metadata.xml +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1.jar.md5 +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1-sources.jar.md5 +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1.pom.md5 +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1.jar +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1-javadoc.jar +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1-sources.jar +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/maven-metadata.xml +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1-javadoc.jar.md5 +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1-sources.jar.sha1 +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1.pom +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1-javadoc.jar.sha1 +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1.pom.sha1 +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/maven-metadata.xml.md5 +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/maven-metadata.xml.sha1 +com/example/project/spring-boot-actuator-autoconfigure/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-autoconfigure-2.0.0.BUILD-20171030.171822-1.jar.sha1 +com/example/project/spring-boot-actuator-autoconfigure/maven-metadata.xml.md5 +com/example/project/spring-boot-actuator-autoconfigure/maven-metadata.xml.sha1 +com/example/project/spring-boot-actuator/maven-metadata.xml +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1.jar +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1.pom.md5 +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1-sources.jar +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1-javadoc.jar +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1.pom.sha1 +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1-javadoc.jar.sha1 +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/maven-metadata.xml +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1.pom +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1-sources.jar.md5 +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1.jar.md5 +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1-sources.jar.sha1 +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/maven-metadata.xml.md5 +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1-javadoc.jar.md5 +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/maven-metadata.xml.sha1 +com/example/project/spring-boot-actuator/2.0.0.BUILD-SNAPSHOT/spring-boot-actuator-2.0.0.BUILD-20171030.171543-1.jar.sha1 +com/example/project/spring-boot-actuator/maven-metadata.xml.md5 +com/example/project/spring-boot-actuator/maven-metadata.xml.sha1 \ No newline at end of file diff --git a/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/expected.asc b/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/expected.asc new file mode 100644 index 0000000..7e496e4 --- /dev/null +++ b/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/expected.asc @@ -0,0 +1,14 @@ +-----BEGIN PGP SIGNATURE----- + +iQGzBAABCAAdFiEEa20YkXMm681UfxMwutQ4eEFOc9EFAgAAAAAACgkQutQ4eEFO +c9FdPgwAklvSBHu3DKOYKACsLRxY4Ka9NToV/x7lmutz8N/a6dGJSyCsJ7GAOBjT +tyEaiwcVELCQy2fjPCXMO5KG0Tk8K03od6Ir0IifOL6Ubz3f2VeqOLh8YdXltT3g +MCxhlcac6jflaYzoRI39lwgaCzoXPrfJtrrX2QXltYkrdIQ9RGm49sLMWtuOIYfW +G8q0w62X350hSTJZjU0D7nXuFhd2AiWOaonruqgGMF56F1cH91cHQdqfG6ic+ctI +GnHAnipkJb1ateJIPuh+x7J/XbyOxInr8+mD5N6XEq1NqEUYz1wPMphInthyz5Kk +24SgtfImV5gNOnRSB9PuhDmdd4GxdnUyAVJxwLYdmigBklfpdVlV/yUqOMR26Qo/ +Gt3Drp6ffTEBuav7Jx7+ueJxYKLw9sa0wQDOCwgG5w3Ej/or3bPrz++PoXMblQjm +AAL6amJuNTs+vs40hx9eGkJN93U+0/6R8IomoZB8ozNDFULDYBYc+VP0DgYJYe+x +f/IUanet +=Xowy +-----END PGP SIGNATURE----- diff --git a/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/source.txt b/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/source.txt new file mode 100644 index 0000000..76e579a --- /dev/null +++ b/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/source.txt @@ -0,0 +1,2 @@ +test + diff --git a/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-bad-private.txt b/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-bad-private.txt new file mode 100644 index 0000000..c219005 --- /dev/null +++ b/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-bad-private.txt @@ -0,0 +1,83 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +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 +KQ== +=DJH7 +-----END PGP PRIVATE KEY BLOCK----- diff --git a/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-private.txt b/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-private.txt new file mode 100644 index 0000000..9c09542 --- /dev/null +++ b/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-private.txt @@ -0,0 +1,84 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +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 +KQ== +=DJH7 +-----END PGP PRIVATE KEY BLOCK----- diff --git a/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-public.txt b/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-public.txt new file mode 100644 index 0000000..314dae2 --- /dev/null +++ b/src/test/resources/io/spring/github/actions/artifactorydeploy/openpgp/test-public.txt @@ -0,0 +1,41 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=O+u1 +-----END PGP PUBLIC KEY BLOCK-----