diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 587a79b..0f51b55 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,5 +1,5 @@ # https://docs.github.com/en/github/administering-a-repository/displaying-a-sponsor-button-in-your-repository -# I need to apply here: https://github.com/sponsors +# Maybe apply here: https://github.com/sponsors # github: [alexandru] patreon: alexelcu diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ccf49a2..7884dd6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,31 +1,29 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# GitHub recommends pinning actions to a commit SHA. +# To get a newer version, you will need to update the SHA. +# You can also reference a tag or branch, but the action may change without warning. + name: build on: [push, pull_request] jobs: build: - runs-on: ubuntu-20.04 - + runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - uses: haskell/actions/setup@v1 - with: - ghc-version: '8.10.7' # lts-18.21? - enable-stack: true - stack-version: 'latest' + - uses: actions/checkout@v3 - - uses: actions/cache@v2 - name: Cache ~/.stack + - name: Set up JDK + uses: actions/setup-java@v3 with: - key: ${{ runner.os }}-stack-${{ hashFiles('**/stack.yaml.lock') }}-${{ hashFiles('**/github-webhook-listener.cabal') }} - restore-keys: | - ${{ runner.os }}-stack- - path: | - ~/.stack + java-version: '17' + distribution: 'adopt' - - name: Resolve/Update Dependencies - run: | - stack --no-terminal setup + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 - - name: Run tests - run: | - stack --no-terminal test + - name: Run Tests + run: ./gradlew check diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5661ab3..b8ff98a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,34 +4,75 @@ on: types: [released] jobs: - deploy_docker: - runs-on: ubuntu-20.04 + deploy_docker_jvm: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + + - name: Install Docker + run: | + sudo apt-get remove docker docker-engine docker.io containerd runc + sudo apt-get install apt-transport-https ca-certificates curl software-properties-common + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - + sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" + sudo apt-get update + sudo apt-get install docker-ce docker-ce-cli containerd.io + + - name: Login to Docker + run: docker login ghcr.io --username=alexandru --password="$GH_TOKEN" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build JVM Docker image + run: | + make build-jvm + env: + GIT_TAG: ${{ github.ref }} + + - name: Push JVM Docker images + run: | + make push-jvm + env: + GIT_TAG: ${{ github.ref }} + + deploy_docker_native: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + + - name: Install Docker + run: | + sudo apt-get remove docker docker-engine docker.io containerd runc + sudo apt-get install apt-transport-https ca-certificates curl software-properties-common + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - + sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" + sudo apt-get update + sudo apt-get install docker-ce docker-ce-cli containerd.io + + - name: Login to Docker + run: docker login ghcr.io --username=alexandru --password="$GH_TOKEN" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Native Docker image + run: | + make build-native + env: + GIT_TAG: ${{ github.ref }} + + - name: Push Native Docker images + run: | + make push-native + env: + GIT_TAG: ${{ github.ref }} + all: + name: Pushed All + if: always() + needs: [ deploy_docker_native, deploy_docker_jvm ] + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@master - - - name: Install Docker - run: | - sudo apt-get remove docker docker-engine docker.io containerd runc - sudo apt-get install apt-transport-https ca-certificates curl software-properties-common - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" - sudo apt-get update - sudo apt-get install docker-ce docker-ce-cli containerd.io - - - name: Login to Docker - run: docker login --username=$DOCKER_USER --password=$DOCKER_PASS - env: - DOCKER_USER: ${{ secrets.DOCKER_USER }} - DOCKER_PASS: ${{ secrets.DOCKER_PASS }} - - - name: Build Docker image - run: | - make build - env: - GIT_TAG: ${{ github.ref }} - - - name: Push Docker image - run: | - make push - \ No newline at end of file + - name: Validate required tests + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/refreshVersions.yml b/.github/workflows/refreshVersions.yml new file mode 100644 index 0000000..12a945b --- /dev/null +++ b/.github/workflows/refreshVersions.yml @@ -0,0 +1,55 @@ +# Worfklow for https://jmfayard.github.io/refreshVersions/ + +name: RefreshVersions + +on: + workflow_dispatch: + schedule: + - cron: '0 7 * * 1' + +jobs: + "Refresh-Versions": + runs-on: "ubuntu-latest" + steps: + - id: step-0 + name: check-out + uses: actions/checkout@v3 + with: + ref: main + - id: step-1 + name: setup-java + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: adopt + - id: step-2 + name: create-branch + uses: peterjgrainger/action-create-branch@v2.2.0 + with: + branch: dependency-update + env: + GITHUB_TOKEN: {{ secrets.GITHUB_TOKEN }} + - id: step-3 + name: gradle refreshVersions + uses: gradle/gradle-build-action@v2 + with: + arguments: refreshVersions + - id: step-4 + name: Commit + uses: EndBug/add-and-commit@v9 + with: + author_name: GitHub Actions + author_email: noreply@github.com + message: Refresh versions.properties + new_branch: dependency-update + push: --force --set-upstream origin dependency-update + - id: step-5 + name: Pull Request + uses: repo-sync/pull-request@v2 + with: + source_branch: dependency-update + destination_branch: main + pr_title: Upgrade gradle dependencies + pr_body: '[refreshVersions](https://github.com/jmfayard/refreshVersions) has found those library updates!' + pr_draft: true + github_token: {{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e720099..c426c32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,36 @@ -dist -dist-* -cabal-dev -*.o -*.hi -*.chi -*.chs.h -*.dyn_o -*.dyn_hi -.hpc -.hsenv -.cabal-sandbox/ -cabal.sandbox.config -*.prof -*.aux -*.hp -*.eventlog -.stack-work/ -cabal.project.local -cabal.project.local~ -.HTF/ -.ghc.environment.* -.vscode/ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 0000000..239718e --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1,3 @@ +# Enable auto-env through the sdkman_auto_env config +# Add key=value pairs of SDKs to use below +java=22.2.r17-grl diff --git a/ChangeLog.md b/ChangeLog.md deleted file mode 100644 index 6b1b76a..0000000 --- a/ChangeLog.md +++ /dev/null @@ -1,3 +0,0 @@ -# Changelog for github-webhook-listener - -## Unreleased changes diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 003931f..0000000 --- a/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -FROM fpco/stack-build:lts-18.21 as build - -COPY . /opt/build/ - -WORKDIR /opt/build - -RUN stack build --system-ghc - -RUN mv "$(stack path --local-install-root --system-ghc)/bin" /opt/build/bin - -# ------------------------------------------------------------------------------------------- -# Base image for stack build so compiled artifact from previous -# stage should run -FROM ubuntu:18.04 as app -RUN mkdir -p /opt/app -WORKDIR /opt/app - -RUN apt-get update -RUN apt-get install git curl jq -y -RUN apt-get upgrade -y -RUN apt-get autoremove -y - -COPY --from=build /opt/build/bin . - -RUN mkdir -p /opt/app/config -COPY ./resources/config-sample.yaml /opt/app/config/config.yaml - -CMD [ "/opt/app/github-webhook-listener-exe", "-c", "/opt/app/config/config.yaml" ] diff --git a/Dockerfile.fromCurrent b/Dockerfile.fromCurrent deleted file mode 100644 index 38a2000..0000000 --- a/Dockerfile.fromCurrent +++ /dev/null @@ -1,6 +0,0 @@ -FROM alexelcu/github-webhook-listener:latest as app - -RUN mkdir -p /opt/app/config -COPY ./resources/config-sample.yaml /opt/app/config/config.yaml - -CMD [ "/opt/app/github-webhook-listener-exe", "-c", "/opt/app/config/config.yaml" ] diff --git a/LICENSE b/LICENSE index e2c71ac..0ad25db 100644 --- a/LICENSE +++ b/LICENSE @@ -1,30 +1,661 @@ -Copyright Alexandru Nedelcu (c) 2018 - -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - - * Neither the name of Alexandru Nedelcu nor the names of other - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile index 1fe9935..94a6ac3 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,26 @@ -NAME := alexelcu/github-webhook-listener -TAG := $$(./scripts/new-version) -IMG := ${NAME}:${TAG} -LATEST := ${NAME}:latest +NAME := ghcr.io/alexandru/github-webhook-listener +TAG := $$(./scripts/new-version.sh) +IMG_JVM := ${NAME}:jvm-${TAG} +IMG_NATIVE := ${NAME}:native-${TAG} +LATEST_JVM := ${NAME}:jvm-latest +LATEST_NATIVE := ${NAME}:native-latest +LATEST := ${NAME}:latest -build: - docker build -t "${IMG}" . - docker tag "${IMG}" "${LATEST}" +build-jvm: + docker build -f ./src/main/docker/Dockerfile.jvm -t "${IMG_JVM}" . + docker tag "${IMG_JVM}" "${LATEST_JVM}" + +push-jvm: + docker push ${IMG_JVM} + docker push ${LATEST_JVM} + +build-native: + docker build -f ./src/main/docker/Dockerfile.native -t "${IMG_NATIVE}" . + docker tag "${IMG_NATIVE}" "${LATEST_NATIVE}" + docker tag "${IMG_NATIVE}" "${LATEST}" + +push-native: + docker push ${IMG_NATIVE} + docker push ${LATEST_NATIVE} + docker push ${LATEST} -push: - docker push ${NAME} diff --git a/README.md b/README.md index b3ecaad..9c8c2ac 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,54 @@ -# github-webhook-listener +# GitHub Webhook Listener (ver. 2) -[![Build](https://github.com/alexandru/github-webhook-listener/workflows/build/badge.svg?branch=master)](https://github.com/alexandru/github-webhook-listener/actions?query=branch%3Amaster+workflow%3Abuild) [![Deploy](https://github.com/alexandru/github-webhook-listener/workflows/deploy/badge.svg)](https://github.com/alexandru/github-webhook-listener/actions?query=workflow%3Adeploy) +[![Build](https://github.com/alexandru/github-webhook-listener/workflows/build/badge.svg?branch=main)](https://github.com/alexandru/github-webhook-listener/actions?query=branch%3Amain+workflow%3Abuild) [![Deploy](https://github.com/alexandru/github-webhook-listener/workflows/deploy/badge.svg)](https://github.com/alexandru/github-webhook-listener/actions?query=workflow%3Adeploy) A simple web app that can be registered as a [GitHub Webhook](https://developer.github.com/webhooks/) and trigger shell commands in response to events. Main use-case is to trigger refreshes of websites hosted on your own -server via [Travis-CI](https://travis-ci.org/) jobs, but in a secure -way, without exposing server credentials or SSH keys. +server via CI jobs (e.g., GitHub Actions), but in a secure way, without +exposing server credentials or SSH keys. The server process is also light in resource usage, not using more -than 20 MB of RAM on a 64-bit Ubuntu machine, so it can be installed -on under-powered servers. +than 20 MB of RAM, so it can be installed on under-powered servers. -## Setup +## Development -Images are being pushed on [Docker Hub](https://hub.docker.com/repository/docker/alexelcu/github-webhook-listener) and you can quickly run the process like this: +To run the project in development mode: ```sh -docker run \ - -p 8080:8080 \ - -ti alexelcu/github-webhook-listener +./gradlew run -Pdevelopment --args="./config/application-dummy.conf" ``` -### Server Configuration +After adding new dependencies: -On its own this just starts the server, but doesn't know how to do anything. We'll need to specify a configuration file: Create your `./config.yaml`: - -```yaml -http: - path: "/" - port: 8080 - -runtime: - workers: 2 - output: stdout - -projects: - myproject: - ref: "refs/heads/gh-pages" - directory: "/var/www/myproject" - command: "git pull" - secret: "xxxxxxxxxxxxxxxxxxxxxxxxxx" -``` - -Notes: - -1. `myproject` in `project.myproject` is just a name of a project, it could be anything -2. `ref` says to only react on pushes to the `gh-pages` branch -3. `directory` is where the `command` should be executed -4. `command` is to be executed — note that `git` is already installed in the Docker image, doing `git pull` on a directory being the primary use case - -It would be better if the `git pull` command would update files using a specified host user and group. And we'll also need an SSH key to install our "deployment key". So on your Linux box: - -``` -sudo adduser synchronize - -sudo adduser synchronize www-data - -sudo chown -R synchornize:www-data /var/www/myproject +```sh +./gradlew refreshVersionsMigrate --mode=VersionCatalogOnly ``` -And afterwards: +To update project dependencies: ```sh -docker run \ - -p 8080:8080 \ - -v "$(pwd)/config.yaml:/opt/app/config/config.yaml" \ - -u "$(id -u synchronize):$(id -g synchronize)" \ - -ti alexelcu/github-webhook-listener +./gradlew refreshVersions ``` -You could also use [docker-compose](https://docs.docker.com/compose/), here's what I have on my own server: - -```yaml -version: '3.3' - -services: - github-webhook-listener: - container_name: github-webhook-listener - image: 'alexelcu/github-webhook-listener:latest' - restart: unless-stopped - ports: - - "8080:8080" - tty: true - networks: - - main - volumes: - - /var/www:/var/www - - /etc/github-webhook-listener/config.yaml:/opt/app/config/config.yaml - user: "${SYNC_UID}:${SYNC_GID}" - -networks: - main: - external: - name: main -``` +## Docker -Then to expose this server via [Nginx](https://www.nginx.com/), it's just a matter of configuring a `proxy_pass`: +To build the JVM image from scratch: -```conf -location / { - proxy_pass http://127.0.0.1:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-for $remote_addr; - proxy_connect_timeout 300; -} +```sh +docker build -f ./src/main/docker/Dockerfile.jvm -t github-webhook-listener-jvm . ``` -### Configuring Your GitHub Project - -Go to the settings page of your project, the "Webhooks" section, link -should be like: `https://github.com///settings/hooks` - -Setup screen for adding a new Webhook should look like this: - -![Webhook setup screen](https://github.com/alexandru/github-webhook-listener/wiki/setup.png) +To run the JVM image: -NOTEs on those fields: - -1. the Payload URL contains a `some-id`, in the described path, that should be configured in your `config.yaml` file to identify your project -2. the Secret is the passphrase you also configured in `config.yaml` — this is optional, but if the `config.yaml` mentions a passphrase which you're not mentioning in this setup, then requests will fail - -### Manual Setup (without Docker) - -[Wiki instructions for Setup](https://github.com/alexandru/github-webhook-listener/wiki/Setup) +```sh +docker run -p 8080:8080 github-webhook-listener-jvm +``` ## License Copyright © 2018-2022 Alexandru Nedelcu, some rights reserved. -Licensed under the 3-Clause BSD License. See [LICENSE](./LICENSE). +Licensed under the AGPL-3.0 license. See [LICENSE](./LICENSE). diff --git a/Setup.hs b/Setup.hs deleted file mode 100644 index 9a994af..0000000 --- a/Setup.hs +++ /dev/null @@ -1,2 +0,0 @@ -import Distribution.Simple -main = defaultMain diff --git a/app/Main.hs b/app/Main.hs deleted file mode 100644 index 30b82d0..0000000 --- a/app/Main.hs +++ /dev/null @@ -1,21 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -module Main where - -import CmdLine -import Server (run) -import System.IO (stdout, stderr, withFile, IOMode(AppendMode)) -import qualified AppConfig as Cfg - -main :: IO () -main = do - args <- getCmdLineArgs - appConfig <- Cfg.readAppConfig (configPath args) - let output = Cfg.output . Cfg.runtime $ appConfig - case output of - "stdout" -> - run appConfig stdout - "stderr" -> - run appConfig stderr - path -> - withFile path AppendMode (run appConfig) diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..d76966f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,83 @@ +plugins { + application + kotlin("jvm") + id("io.ktor.plugin") + id("org.jetbrains.kotlin.plugin.serialization") + // See https://github.com/JLLeitschuh/ktlint-gradle + id("org.jlleitschuh.gradle.ktlint") + // https://graalvm.github.io/native-build-tools/0.9.14/gradle-plugin.html + id("org.graalvm.buildtools.native") +} + +group = "org.alexn.hook" +version = "0.0.1" + +application { + mainClass.set("org.alexn.hook.MainKt") + + val isDevelopment: Boolean = project.ext.has("development") + applicationDefaultJvmArgs = listOf( + "-Dio.ktor.development=$isDevelopment", + // https://www.graalvm.org/22.0/reference-manual/native-image/Agent/ + // "-agentlib:native-image-agent=config-output-dir=./src/main/resources/META-INF/native-image" + ) +} + +// https://ktor.io/docs/graalvm.html#execute-the-native-image-tool +// https://github.com/ktorio/ktor-samples/blob/main/graalvm/build.gradle.kts +graalvmNative { + binaries { + named("main") { + fallback.set(false) + verbose.set(true) + + buildArgs.add("--initialize-at-build-time=org.slf4j.LoggerFactory,ch.qos.logback") + buildArgs.add("--initialize-at-build-time=io.ktor,kotlinx,kotlin") + + buildArgs.add("-H:+InstallExitHandlers") + buildArgs.add("-H:+ReportUnsupportedElementsAtRuntime") + buildArgs.add("-H:+ReportExceptionStackTraces") + buildArgs.add("--no-fallback") + + imageName.set("github-webhook-listener") + } + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + implementation(libs.logback.classic) + implementation(libs.kaml) + implementation(libs.commons.codec) + implementation(libs.arrow.core) + implementation(libs.arrow.fx.coroutines) + implementation(libs.arrow.fx.stm) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.server.core) + // implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.tests.jvm) + implementation(libs.commons.text) + implementation(libs.kotlin.test.junit) + implementation(libs.kotlinx.cli) + implementation(libs.kotlinx.serialization.hocon) + implementation(libs.kotlinx.serialization.json) +} + +tasks { + withType { + kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + kotlinOptions.javaParameters = true + } +} + +ktor { + fatJar { + archiveFileName.set("github-webhook-listener-fat.jar") + } +} diff --git a/config/application-dummy.yaml b/config/application-dummy.yaml new file mode 100644 index 0000000..e63d99f --- /dev/null +++ b/config/application-dummy.yaml @@ -0,0 +1,13 @@ +http: + host: "0.0.0.0" + port: 8080 + path: "/" + +projects: + myproject: + action: "push" + ref: "refs/heads/gh-pages" + directory: "/tmp" + command: "touch ./i-was-here.txt" + timeout: "PT5S" + secret: "xxxxxxxxxxxxxxxxxxxxxxxxxx" diff --git a/github-webhook-listener.cabal b/github-webhook-listener.cabal deleted file mode 100644 index 928d459..0000000 --- a/github-webhook-listener.cabal +++ /dev/null @@ -1,93 +0,0 @@ -cabal-version: 1.12 - --- This file has been generated from package.yaml by hpack version 0.31.2. --- --- see: https://github.com/sol/hpack --- --- hash: e1f1443ff11518d2be450b6954b3705d939bd3d4e06133ab1be12dd24564265c - -name: github-webhook-listener -version: 0.1.0.3 -description: Please see the README on GitHub at -homepage: https://github.com/alexandru/github-webhook-listener#readme -bug-reports: https://github.com/alexandru/github-webhook-listener/issues -author: Alexandru Nedelcu -maintainer: noreply@alexn.org -copyright: 2018-2022 Alexandru Nedelcu -license: BSD3 -license-file: LICENSE -build-type: Simple -extra-source-files: - README.md - ChangeLog.md -data-files: - resources/*.yaml - -source-repository head - type: git - location: https://github.com/alexandru/github-webhook-listener - -library - other-modules: - Paths_github_webhook_listener - hs-source-dirs: - src - default-language: Haskell2010 - ghc-options: -Wall -Werror - build-depends: - base >=4.7 && <5 - , SHA - , aeson - , blaze-builder - , bytestring - , containers - , directory - , filelock - , filepath - , http-types - , optparse-applicative - , regex-compat - , scotty - , shelly - , text - , time - , yaml - - exposed-modules: - AppConfig - , CmdLine - , Command - , Controller - , Logger - , Payload - , Server - -executable github-webhook-listener-exe - main-is: Main.hs - other-modules: - Paths_github_webhook_listener - hs-source-dirs: - app - default-language: Haskell2010 - ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall -Werror - build-depends: - base >=4.12 && <5 - , github-webhook-listener - -test-suite github-webhook-listener-test - type: exitcode-stdio-1.0 - main-is: Main.hs - other-modules: - Paths_github_webhook_listener - , AppConfigSpec - hs-source-dirs: - test - default-language: Haskell2010 - ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall -Werror - build-depends: - base >=4.12 && <5 - , containers - , filepath - , github-webhook-listener - , hspec - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..97c2bb6 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,166 @@ +## Generated by $ ./gradlew refreshVersionsCatalog + +[plugins] + +org-jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "1.7.20" } + +io-ktor-plugin = { id = "io.ktor.plugin", version = "2.1.2" } + +org-jetbrains-kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "1.7.20" } + +org-jlleitschuh-gradle-ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "11.0.0" } + +org-graalvm-buildtools-native = { id = "org.graalvm.buildtools.native", version = "0.9.14" } + +[versions] + +kotlin = "1.7.20" + +arrow = "1.1.2" +## ⬆ = "1.1.3-alpha.1" +## ⬆ = "1.1.3-alpha.2" +## ⬆ = "1.1.3-alpha.3.0+2022-05-16T16-21-58-758705Z" +## ⬆ = "1.1.3-alpha.4.0+2022-05-17T09-11-10-723810Z" +## ⬆ = "1.1.3-alpha.5.0+2022-05-17T11-44-11-714740Z" +## ⬆ = "1.1.3-alpha.6" +## ⬆ = "1.1.3-alpha.7" +## ⬆ = "1.1.3-alpha.8" +## ⬆ = "1.1.3-alpha.9" +## ⬆ = "1.1.3-alpha.10" +## ⬆ = "1.1.3-alpha.11" +## ⬆ = "1.1.3-alpha.12" +## ⬆ = "1.1.3-alpha.13" +## ⬆ = "1.1.3-alpha.14" +## ⬆ = "1.1.3-alpha.15" +## ⬆ = "1.1.3-alpha.16" +## ⬆ = "1.1.3-alpha.17" +## ⬆ = "1.1.3-alpha.18" +## ⬆ = "1.1.3-alpha.19" +## ⬆ = "1.1.3-alpha.20" +## ⬆ = "1.1.3-alpha.21" +## ⬆ = "1.1.3-alpha.22" +## ⬆ = "1.1.3-alpha.23" +## ⬆ = "1.1.3-alpha.24" +## ⬆ = "1.1.3-alpha.25" +## ⬆ = "1.1.3-alpha.26" +## ⬆ = "1.1.3-alpha.27" +## ⬆ = "1.1.3-alpha.28" +## ⬆ = "1.1.3-alpha.29" +## ⬆ = "1.1.3-alpha.30" +## ⬆ = "1.1.3-alpha.31" +## ⬆ = "1.1.3-alpha.32" +## ⬆ = "1.1.3-alpha.33" +## ⬆ = "1.1.3-alpha.34" +## ⬆ = "1.1.3-alpha.35" +## ⬆ = "1.1.3-alpha.36" +## ⬆ = "1.1.3-alpha.37" +## ⬆ = "1.1.3-alpha.38" +## ⬆ = "1.1.3-alpha.39" +## ⬆ = "1.1.3-alpha.40" +## ⬆ = "1.1.3-alpha.41" +## ⬆ = "1.1.3-alpha.42" +## ⬆ = "1.1.3-alpha.43" +## ⬆ = "1.1.3-alpha.44" +## ⬆ = "1.1.3-alpha.45" +## ⬆ = "1.1.3-alpha.46" +## ⬆ = "1.1.3-alpha.47" +## ⬆ = "1.1.3-alpha.48" +## ⬆ = "1.1.3-alpha.49" +## ⬆ = "1.1.3-alpha.50" +## ⬆ = "1.1.3-alpha.51" +## ⬆ = "1.1.3-alpha.52" +## ⬆ = "1.1.3-rc.1" +## ⬆ = "1.1.3" +## ⬆ = "1.1.4-alpha.1" +## ⬆ = "1.1.4-alpha.2" +## ⬆ = "1.1.4-alpha.3" +## ⬆ = "1.1.4-alpha.4" +## ⬆ = "1.1.4-alpha.5" +## ⬆ = "1.1.4-alpha.6" +## ⬆ = "1.1.4-alpha.7" + +ktor = "2.1.2" + +kotlinx-cli = "0.3.5" + +kotlinx-serialization = "1.4.0" + +[libraries] + +logback-classic = "ch.qos.logback:logback-classic:1.2.11" +## ⬆ :1.3.0-alpha0" +## ⬆ :1.3.0-alpha1" +## ⬆ :1.3.0-alpha2" +## ⬆ :1.3.0-alpha3" +## ⬆ :1.3.0-alpha4" +## ⬆ :1.3.0-alpha5" +## ⬆ :1.3.0-alpha6" +## ⬆ :1.3.0-alpha7" +## ⬆ :1.3.0-alpha8" +## ⬆ :1.3.0-alpha9" +## ⬆ :1.3.0-alpha10" +## ⬆ :1.3.0-alpha11" +## ⬆ :1.3.0-alpha12" +## ⬆ :1.3.0-alpha13" +## ⬆ :1.3.0-alpha14" +## ⬆ :1.3.0-alpha15" +## ⬆ :1.3.0-alpha16" +## ⬆ :1.3.0-beta0" +## ⬆ :1.3.0" +## ⬆ :1.3.1" +## ⬆ :1.3.2" +## ⬆ :1.3.3" +## ⬆ :1.3.4" +## ⬆ :1.4.0" +## ⬆ :1.4.1" +## ⬆ :1.4.2" +## ⬆ :1.4.3" +## ⬆ :1.4.4" + +kaml = "com.charleskorn.kaml:kaml:0.49.0" + +commons-codec = "commons-codec:commons-codec:1.15" + +arrow-core = { group = "io.arrow-kt", name = "arrow-core", version.ref = "arrow" } + +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } + +ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } + +ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } + +ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" } + +ktor-server-tests-jvm = { group = "io.ktor", name = "ktor-server-tests-jvm", version.ref = "ktor" } + +commons-text = "org.apache.commons:commons-text:1.10.0" + +kotlin-test-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" } + +kotlinx-cli = { group = "org.jetbrains.kotlinx", name = "kotlinx-cli", version.ref = "kotlinx-cli" } + +kotlinx-serialization-hocon = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-hocon", version.ref = "kotlinx-serialization" } + +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +kotlin-scripting-compiler-embeddable = { group = "org.jetbrains.kotlin", name = "kotlin-scripting-compiler-embeddable", version.ref = "kotlin" } + +kotlin-serialization = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "kotlin" } + +ktlint = "com.pinterest:ktlint:0.43.2" +## ⬆ :0.44.0" +## ⬆ :0.45.0" +## ⬆ :0.45.1" +## ⬆ :0.45.2" +## ⬆ :0.46.0" +## ⬆ :0.46.1" +## ⬆ :0.47.0" +## ⬆ :0.47.1" + +arrow-fx-coroutines = { group = "io.arrow-kt", name = "arrow-fx-coroutines", version.ref = "arrow" } + +arrow-fx-stm = { group = "io.arrow-kt", name = "arrow-fx-stm", version.ref = "arrow" } + +junit-platform-native = "org.graalvm.buildtools:junit-platform-native:0.9.14" + +ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7454180 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ae04661 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/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/master/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 + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# 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"' + +# 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 + which java >/dev/null 2>&1 || 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 + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + 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 + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@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=. +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%" == "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%"=="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! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/resources/config-sample.yaml b/resources/config-sample.yaml deleted file mode 100644 index 108c4fe..0000000 --- a/resources/config-sample.yaml +++ /dev/null @@ -1,14 +0,0 @@ -http: - path: "/" - port: 8080 - -runtime: - workers: 2 - output: stdout - -projects: {} - # myproject: - # ref: "refs/heads/gh-pages" - # directory: "/var/www/domain.com" - # secret: "xxxxxxxxxxxxxxxxxxxx" - # command: "git pull" diff --git a/resources/config.yaml b/resources/config.yaml deleted file mode 100644 index 90bd01a..0000000 --- a/resources/config.yaml +++ /dev/null @@ -1,14 +0,0 @@ -http: - path: "/" - port: 8080 - -runtime: - workers: 2 - output: stdout - -projects: - myproject: - ref: "refs/heads/gh-pages" - directory: "/var/www/domain.com" - secret: "xxxxxxxxxxxxxxxxxxxx" - command: "git pull" diff --git a/resources/github-webhook-listener.service b/resources/github-webhook-listener.service deleted file mode 100644 index 741692d..0000000 --- a/resources/github-webhook-listener.service +++ /dev/null @@ -1,25 +0,0 @@ -[Unit] -Description=GitHub Webhook Listener -Requires=network.target - -[Service] -Type=simple -WorkingDirectory=/opt/github-webhook-listener/ -EnvironmentFile= -ExecStart=/opt/github-webhook-listener/bin/github-webhook-listener -c /etc/github-webhook-listener/config.yaml -ExecReload=/bin/kill -HUP $MAINPID -StandardOutput=syslog -StandardError=syslog -Restart=always -RestartSec=60 -SuccessExitStatus= -TimeoutStopSec=5 -User=synchronize -ExecStartPre=/bin/mkdir -p /run/github-webhook-listener -ExecStartPre=/bin/chown synchronize:synchronize /run/github-webhook-listener -ExecStartPre=/bin/chmod 755 /run/github-webhook-listener -PermissionsStartOnly=true -LimitNOFILE=1024 - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/scripts/TestRequest.kt b/scripts/TestRequest.kt new file mode 100755 index 0000000..722e255 --- /dev/null +++ b/scripts/TestRequest.kt @@ -0,0 +1,41 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 17+ +//KOTLIN 1.7.20 +//DEPS org.apache.commons:commons-text:1.9 +//DEPS commons-codec:commons-codec:1.15 +//DEPS org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4 +//DEPS io.ktor:ktor-client-core-jvm:2.1.2 +//DEPS io.ktor:ktor-client-cio-jvm:2.1.2 + +import kotlinx.coroutines.* +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import org.apache.commons.codec.digest.HmacAlgorithms +import org.apache.commons.codec.digest.HmacUtils + +fun main() = runBlocking { + val signKey = "xxxxxxxxxxxxxxxxxxxxxxxxxx" + val bodyText = """ + { + "action": "push", + "ref": "refs/heads/gh-pages" + } + """.trimIndent() + + val client = HttpClient(CIO) + val response = + client.post("http://localhost:8080/myproject") { + headers { + append(HttpHeaders.ContentType, "application/json") + append( + "X-Hub-Signature-256", + "sha256=" + HmacUtils(HmacAlgorithms.HMAC_SHA_256, signKey).hmacHex(bodyText) + ) + } + setBody(bodyText) + } + println("HTTP ${response.status}: ${response.bodyAsText()}") +} diff --git a/scripts/java-exec b/scripts/java-exec new file mode 100755 index 0000000..3607755 --- /dev/null +++ b/scripts/java-exec @@ -0,0 +1,8 @@ +#!/bin/sh + +exec java \ + -XX:+UseShenandoahGC \ + -XX:+UnlockExperimentalVMOptions \ + -XX:ShenandoahUncommitDelay=1000 \ + -XX:ShenandoahGuaranteedGCInterval=10000 \ + "$@" diff --git a/scripts/new-version b/scripts/new-version.sh similarity index 100% rename from scripts/new-version rename to scripts/new-version.sh diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..73c2a75 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,13 @@ +rootProject.name = "github-webhook-listener" + +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + // See https://jmfayard.github.io/refreshVersions + id("de.fayard.refreshVersions") version "0.50.2" +} diff --git a/src/AppConfig.hs b/src/AppConfig.hs deleted file mode 100644 index 4f13f72..0000000 --- a/src/AppConfig.hs +++ /dev/null @@ -1,72 +0,0 @@ -{-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE StrictData #-} - -module AppConfig ( - All (..), - Http (..), - Project(..), - Runtime(..), - readAppConfig) where - -import Data.Map (Map) -import Data.Text -import Data.Yaml (FromJSON, decodeFileThrow) -import GHC.Generics -import System.Directory (makeAbsolute) - --- | The complete format of the configuration file -data All = All - { - http :: Http, - runtime :: Runtime, - projects :: Map Text Project - } deriving (Generic, Show, Eq) - -instance FromJSON All - -{-| - Configuration for where the web server will listen for requests - (e.g. HTTP path and port) --} -data Http = Http - { - path :: Text -- ^ HTTP path prefix, to attach to all routes (e.g. "/" or "/api/") - , port :: Integer -- ^ HTTP port to listen on (e.g. 8080) - } deriving (Generic, Show, Eq) - -instance FromJSON Http - --- | Project configuration. -data Project = Project - { - action :: Maybe Text -- ^ Identifies the GitHub event, none for "push" - , ref :: Maybe Text -- ^ Git ref that we are expecting - , directory :: Text -- ^ working local directory (e.g. `/var/www/website.com`) - , command :: Text -- ^ shell command to execute - , secret :: Maybe Text -- ^ key used to sign the request - } deriving (Generic, Show, Eq) - -instance FromJSON Project - -{-| - Configures misc runtime properties --} -data Runtime = Runtime - { - workers :: Int -- ^ the number of workers to process requests in parallel - , output :: FilePath -- ^ where to send the logs to; accepts: stdout, stderr - } deriving (Generic, Show, Eq) - -instance FromJSON Runtime - -{-| - Given a file path, parses its contents into an 'All' - configuration object. - - Can throw if the file isn't valid. --} -readAppConfig :: FilePath -> IO All -readAppConfig filePath = do - absolute <- makeAbsolute filePath - decodeFileThrow absolute diff --git a/src/CmdLine.hs b/src/CmdLine.hs deleted file mode 100644 index 3218227..0000000 --- a/src/CmdLine.hs +++ /dev/null @@ -1,54 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE StrictData #-} - -module CmdLine ( - AppArgs (..), - getCmdLineArgs) where - -import Control.Exception (Exception) -import Control.Exception.Base (throwIO) -import Options.Applicative -import System.Directory (doesFileExist) - --- |Models command line arguments -newtype AppArgs = AppArgs - { - configPath :: FilePath - } deriving (Show) - --- |Thrown in case the given `configFile` does not exist -newtype ConfigFileDoesNotExistsException = - ConfigFileDoesNotExistsException String - deriving Show - -instance Exception ConfigFileDoesNotExistsException - -appArgsParser :: Parser AppArgs -appArgsParser = AppArgs - <$> strOption - ( long "config-path" - <> short 'c' - <> metavar "CONFIG_PATH" - <> help "Path to the configuration file" - ) - -opts :: ParserInfo AppArgs -opts = info (appArgsParser <**> helper) - ( fullDesc - <> progDesc "Starts the web server as configured via the indicated config file" - <> header "github-webhook-listener - a web server that responds to GitHub's Webhooks" ) - -{-| - Parse command line arguments. - - Throws `ConfigFileDoesNotExistsException` in case the given - `configFile` does not exist. --} -getCmdLineArgs :: IO AppArgs -getCmdLineArgs = do - config <- execParser opts - hasFile <- doesFileExist (configPath config) - if hasFile then - return config - else - throwIO (ConfigFileDoesNotExistsException (configPath config)) diff --git a/src/Command.hs b/src/Command.hs deleted file mode 100644 index 7cae7a9..0000000 --- a/src/Command.hs +++ /dev/null @@ -1,95 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE RecordWildCards #-} -{-# LANGUAGE StrictData #-} - -module Command (startWorkers) where - -import AppConfig (Project(..)) -import Control.Concurrent (ThreadId, forkIO) -import Control.Concurrent.Chan (Chan) -import Control.Exception (catch) -import Data.String (fromString) -import Shelly (shelly, liftIO, bash_, chdir) -import System.FilePath (joinPath) -import Text.Regex (splitRegex, mkRegex) -import Logger (LogHandle) - -import qualified AppConfig as AC -import qualified Control.Concurrent.Chan as Chan -import qualified Data.Text as T -import qualified Logger -import qualified System.FileLock as FL - -{-| Starts background workers that consume from the message channel and -execute commands. --} --- startWorkers --- :: Int -- ^ number of workers to start --- -> Chan Project -- ^ the channel used for messaging --- -> LogHandle -- ^ used for logging --- -> IO () -startWorkers - :: Int - -> Chan Project - -> LogHandle - -> IO [ThreadId] -startWorkers n chan h = - sequence workers - where - consumer i = do - Logger.logInfo h "Command" $ "Starting worker " <> (T.pack . show $ i) - consumeFromChan chan h - workers = - fmap (forkIO . consumer) (enumFromTo 1 n) - -consumeFromChan - :: Chan Project - -> LogHandle - -> IO () -consumeFromChan chan h = - do - command <- Chan.readChan chan - executeShellCommand command h `catch` Logger.logCaughtError h "Command" - consumeFromChan chan h -- continue - -{-| - Executes the command for the given 'Project'. - - These steps are involved: - - * acquires a file lock (".webhook" in the project's directory) - * switches to the project's directory - * executes the command via "bash" - - If the program terminate in error, then this function will throw - an exception. --} -executeShellCommand - :: Project - -> LogHandle - -> IO () -executeShellCommand project h = - withLock $ - unsafeExecuteShellCommand project h - where - lockFile = joinPath [T.unpack (AC.directory project), ".webhook"] - withLock f = FL.withFileLock lockFile FL.Exclusive (const f) - -{-| - Internal API: executes the shell command without a file lock. --} -unsafeExecuteShellCommand - :: Project - -> LogHandle - -> IO () -unsafeExecuteShellCommand Project{..} h = shelly $ do - liftIO $ Logger.logInfo h "Command" $ "Executing: chdir " <> directory - chdir (fromString . T.unpack $ directory) $ - case commandAndArgs of - x : xs -> do - liftIO $ Logger.logInfo h "Command" $ "Executing: " <> command - bash_ (fromString x) (fmap T.pack xs) - [] -> - liftIO $ Logger.logInfo h "Command" "No shell command to execute" - where - commandAndArgs = splitRegex (mkRegex "[ \t]+") (T.unpack command) diff --git a/src/Controller.hs b/src/Controller.hs deleted file mode 100644 index b9c4737..0000000 --- a/src/Controller.hs +++ /dev/null @@ -1,76 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE StrictData #-} - -module Controller (ping, processKey) where - -import Control.Concurrent.Chan (Chan, writeChan) -import Control.Monad.IO.Class (liftIO) -import Data.Map.Strict (Map) -import Data.String (fromString) -import Data.Text (Text) -import Data.Text.Lazy.Encoding (encodeUtf8, decodeUtf8) -import Logger (LogHandle) -import Network.HTTP.Types (status200, status404, status204, status403) -import Web.Scotty - -import qualified AppConfig as Cfg -import qualified Data.ByteString as BS -import qualified Data.ByteString.Lazy as BSL -import qualified Data.Map.Strict as Map -import qualified Data.Text as DT -import qualified Data.Text.Encoding as DTE -import qualified Data.Text.Lazy as DTL -import qualified Logger -import qualified Payload as P - -ping :: DT.Text -> ScottyM () -ping path = - get (fromString $ DT.unpack path) $ - html "Pong!" - -processKey :: DT.Text -> Map Text Cfg.Project -> Chan Cfg.Project -> LogHandle -> ScottyM () -processKey path projects chan h = - post (fromString route) $ do - key <- param "key" - liftIO $ Logger.logInfo h "Controller" $ "POST " <> path <> key - case Map.lookup key projects of - Nothing -> send404 key - Just project -> process key project - where - route = DT.unpack (path <> ":key") - - process key project = do - b <- body - hubSig <- getHeader "X-Hub-Signature" - let bodyText = DTL.toStrict $ decodeUtf8 b - let payload = P.parsePayload bodyText - let action = payload >>= P.action - let ref = payload >>= P.ref - - case P.validatePayload project payload hubSig bodyText of - P.Execute -> do - liftIO $ Logger.logInfo h "Controller" $ "Executing shell command for key: " <> key - liftIO $ writeChan chan project - status status200 - text "Ok" - P.SkipExecution -> do - liftIO $ Logger.logInfo h "Controller" $ - "Nothing to do for key: " <> key <> ", action: " - <> P.actionLog action <> " and ref: " - <> P.refLog ref - status status204 - P.FailedAuthentication -> do - liftIO $ Logger.logWarn h "Controller" $ "Validation failed for signature: " <> (DT.pack . show $ hubSig) - status status403 - text "403 Forbidden" - - send404 key = do - liftIO $ Logger.logWarn h "Controller" $ "404 Not Found: " <> key - status status404 - text "404 Not Found" - - getHeader name = - fmap (DTE.decodeUtf8 . lazyToStrict . encodeUtf8) <$> - header name - where - lazyToStrict = BS.concat . BSL.toChunks diff --git a/src/Logger.hs b/src/Logger.hs deleted file mode 100644 index b765516..0000000 --- a/src/Logger.hs +++ /dev/null @@ -1,53 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE StrictData #-} - -module Logger ( - LogHandle - , Severity(..) - , logToHandle - , logDebug - , logInfo - , logWarn - , logError - , logCaughtError) where - -import Data.Text as T -import Data.Time.Clock -import Data.Time.Format -import Text.Printf (printf) -import System.IO (Handle, hPutStrLn, stdout, hFlush) - --- | Just an alias for documentation purposes -type LogHandle = Handle - -data Severity = - DEBUG | INFO | WARN | ERROR - deriving Show - -now :: IO Text -now = do - ts <- getCurrentTime - let str = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S%z" ts - return (pack str) - -logToHandle :: LogHandle -> Severity -> Text -> Text -> IO () -logToHandle handle severity context message = do - nowStr <- now - hPutStrLn handle (printf "[%s] [%s] [%s] %s" nowStr (show severity) context message) - hFlush handle - -logDebug :: Text -> Text -> IO () -logDebug = logToHandle stdout DEBUG - -logInfo :: LogHandle -> Text -> Text -> IO () -logInfo handle = logToHandle handle INFO - -logWarn :: LogHandle -> Text -> Text -> IO () -logWarn handle = logToHandle handle WARN - -logError :: LogHandle -> Text -> Text -> IO () -logError handle = logToHandle handle ERROR - -logCaughtError :: LogHandle -> Text -> IOError -> IO () -logCaughtError handle ctx err = - logToHandle handle ERROR ctx (T.pack (show err)) diff --git a/src/Payload.hs b/src/Payload.hs deleted file mode 100644 index a9cc4f1..0000000 --- a/src/Payload.hs +++ /dev/null @@ -1,116 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE StrictData #-} - -module Payload ( - Payload(..), - PAction(..), - PRef(..), - ValidateResult(..), - parsePayload, - validatePayload, - actionLog, - refLog) where - -import AppConfig (Project) -import Control.Monad (mfilter) -import Data.Aeson (FromJSON, decode) -import Data.Digest.Pure.SHA (showDigest, hmacSha1) -import Data.Text (Text) -import Data.Text.Lazy (fromStrict) -import GHC.Generics - -import qualified AppConfig as C -import qualified Data.Text as T -import qualified Data.Text.Lazy.Encoding as DTLE - -{-| - Request received via GitHub's Webhooks. - - See: --} -data Payload = Payload - { - action :: Maybe PAction, - ref :: Maybe PRef - } deriving (Show, Generic) - -instance FromJSON Payload - -{-| - GitHub repository action being performed. - - E.g. "opened". N.B. the "push" event doesn't have an "action" - signaled in the payload. --} -newtype PAction = PAction Text - deriving (Eq, Show, Generic) - -instance FromJSON PAction - --- | Unpacks a "Maybe" action for logging purposes. -actionLog :: Maybe PAction -> Text -actionLog Nothing = "" -actionLog (Just (PAction a)) = a - -{-| - Git ref, used to identify the branch or tag being acted upon. - E.g. "refs/heads/gh-pages" --} -newtype PRef = PRef Text - deriving (Eq, Show, Generic) - -instance FromJSON PRef - --- | Unpacks a "Maybe" ref for logging purposes. -refLog :: Maybe PRef -> Text -refLog Nothing = "" -refLog (Just (PRef r)) = r - -{-| - Validation result indicating what to do next. - - Used by the controller. --} -data ValidateResult = - Execute -- ^ Command can be executed - | SkipExecution -- ^ Failed conditions (e.g. ref / action) - | FailedAuthentication -- ^ Failed authentication (e.g. signature) - deriving (Eq, Show) - --- | For parsing a text into a 'Payload' -parsePayload :: Text -> Maybe Payload -parsePayload text = - decode (DTLE.encodeUtf8 (fromStrict text)) - -validatePayload :: Project -> Maybe Payload -> Maybe Text -> Text -> ValidateResult -validatePayload _ Nothing _ _ = SkipExecution -validatePayload project (Just p) sigHead body - | not (isAuthenticated (C.secret project) sigHead body) = - FailedAuthentication - | action p /= expectedAction || ref p /= expectedRef = - SkipExecution - | otherwise = - Execute - where - expectedAction = - PAction <$> mfilter (/= "push") (C.action project) - expectedRef = - PRef <$> C.ref project - -isAuthenticated :: Maybe Text -> Maybe Text -> Text -> Bool -isAuthenticated key sigHead body = - case key of - Nothing -> True - Just k -> - case sigHead of - Just sig -> - let (header, rest) = T.splitAt 5 sig in - header == "sha1=" && rest == T.pack digest - _ -> - False - where - digest = - let digestKey = DTLE.encodeUtf8 . fromStrict $ k in - let digestBody = DTLE.encodeUtf8 . fromStrict $ body in - showDigest (hmacSha1 digestKey digestBody) diff --git a/src/Server.hs b/src/Server.hs deleted file mode 100644 index 5088bfb..0000000 --- a/src/Server.hs +++ /dev/null @@ -1,57 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE StrictData #-} - -module Server (run) where - -import Control.Concurrent (killThread) -import Control.Exception (bracket, uninterruptibleMask_) -import Control.Concurrent.Chan (Chan) -import Controller (ping, processKey) -import Logger (LogHandle) -import Web.Scotty (scotty) - -import qualified AppConfig as Cfg -import qualified Control.Concurrent.Chan as Chan -import qualified Command -import qualified Logger -import qualified Data.Text as T - - -startServer :: Cfg.All -> Chan Cfg.Project -> LogHandle -> IO () -startServer config chan h = - scotty port $ - ping path >> - processKey path (Cfg.projects config) chan h - where - port = fromInteger (toInteger (Cfg.port . Cfg.http $ config)) - path = Cfg.path . Cfg.http $ config - - -run :: Cfg.All -> LogHandle -> IO () -run appConfig h = do - Logger.logInfo h "Main" "Starting server ..." - -- Message queue used for delaying requests - chan <- Chan.newChan - -- Ensures that resources terminate safely - safeBracket - -- Workers processing requests asynchronously - (startWorkers chan) - -- Terminates workers on server shutdown - (mapM_ killThreadAndLog) - -- Starts server for receiving HTTP requests - (const (startServer appConfig chan h)) - where - startWorkers chan = - Command.startWorkers (Cfg.workers . Cfg.runtime $ appConfig) chan h - killThreadAndLog tid = do - Logger.logInfo h "Command" $ "Killing worker (" <> (T.pack . show $ tid) <> ")" - killThread tid - --- | Version of bracket that makes the finalizer uninterruptible -safeBracket - :: IO a -- ^ acquisition - -> (a -> IO b) -- ^ release - -> (a -> IO c) -- ^ use - -> IO c -safeBracket ini fin = - bracket (uninterruptibleMask_ ini) (uninterruptibleMask_ . fin) diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..46929d2 --- /dev/null +++ b/src/main/docker/Dockerfile.jvm @@ -0,0 +1,34 @@ +# To build: +# +# docker build -f ./src/main/docker/Dockerfile.jvm -t github-webhook-listener-jvm . +# +# To run: +# +# docker run -p 8080:8080 github-webhook-listener-jvm +# +FROM --platform=linux/amd64 gradle:7-jdk17 AS build +COPY --chown=gradle:gradle . /home/gradle/src +RUN ls -alh /home/gradle/src +WORKDIR /home/gradle/src +RUN gradle buildFatJar --no-daemon + +################################################## + +FROM --platform=linux/amd64 alpine:latest +RUN mkdir -p /opt/app +RUN mkdir -p /opt/app/config +RUN adduser -u 1001 -h /opt/app -s /bin/sh -D appuser +WORKDIR /opt/app +RUN chown -R appuser /opt/app && chmod -R "g+rwX" /opt/app && chown -R appuser:root /opt/app + +RUN apk add --no-cache openjdk17-jre-headless +# RUN apk add --no-cache git curl jq + +COPY --from=build --chown=appuser:root /home/gradle/src/build/libs/github-webhook-listener-fat.jar /opt/app/github-webhook-listener-fat.jar +COPY --from=build --chown=appuser:root /home/gradle/src/config/application-dummy.yaml /opt/app/config/config.yaml +COPY --from=build --chown=appuser:root /home/gradle/src/scripts/java-exec /opt/app/java-exec + +EXPOSE 8080 +USER appuser + +CMD ["/opt/app/java-exec","-jar","/opt/app/github-webhook-listener-fat.jar","/opt/app/config/config.yaml"] diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..3066dab --- /dev/null +++ b/src/main/docker/Dockerfile.native @@ -0,0 +1,29 @@ +# To build: +# +# docker build -f ./src/main/docker/Dockerfile.native -t github-webhook-listener-native . +# +# To run: +# +# docker run -p 8080:8080 github-webhook-listener-native +# +FROM --platform=linux/amd64 ghcr.io/graalvm/native-image:22.2.0 AS build +COPY --chown=root:root . /app/source +WORKDIR /app/source +RUN ./gradlew nativeCompile --no-daemon + +FROM --platform=linux/amd64 alpine:latest +RUN mkdir -p /opt/app/config +RUN adduser -u 1001 -h /opt/app -s /bin/sh -D appuser +WORKDIR /opt/app +RUN chown -R appuser /opt/app && chmod -R "g+rwX" /opt/app && chown -R appuser:root /opt/app + +RUN apk add --no-cache gcompat +# RUN apk add --no-cache git curl jq + +COPY --from=build --chown=appuser:root /app/source/build/native/nativeCompile/github-webhook-listener /opt/app/github-webhook-listener +COPY --from=build --chown=appuser:root /app/source/config/application-dummy.yaml /opt/app/config/config.yaml + +EXPOSE 8080 +USER appuser + +CMD ["/opt/app/github-webhook-listener", "/opt/app/config/config.yaml"] diff --git a/src/main/docker/Dockerfile.native.old b/src/main/docker/Dockerfile.native.old new file mode 100644 index 0000000..5d6f9c6 --- /dev/null +++ b/src/main/docker/Dockerfile.native.old @@ -0,0 +1,27 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.type=native +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native -t quarkus/rest-kotlin-quickstart . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/rest-kotlin-quickstart +# +### +FROM registry.access.redhat.com/ubi8/ubi-minimal:8.5 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/src/main/kotlin/org/alexn/hook/AppConfig.kt b/src/main/kotlin/org/alexn/hook/AppConfig.kt new file mode 100644 index 0000000..d9143a1 --- /dev/null +++ b/src/main/kotlin/org/alexn/hook/AppConfig.kt @@ -0,0 +1,69 @@ +package org.alexn.hook + +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.YamlConfiguration +import com.typesafe.config.ConfigFactory +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.hocon.Hocon +import java.io.File +import kotlin.time.Duration + +@Serializable +data class AppConfig( + val http: Http, + val projects: Map, +) { + @Serializable + data class Http( + val port: Int, + val host: String? = null, + val path: String? = null, + ) { + val basePath: String + get() { + var bp = path ?: return "" + if (bp.endsWith("/")) bp = bp.dropLast(1) + return bp + } + } + + @Serializable + data class Project( + val ref: String, + val directory: String, + val command: String, + val secret: String, + val action: String? = null, + val timeout: Duration? = null, + ) + + companion object { + @OptIn(ExperimentalSerializationApi::class) + fun parseHocon(string: String): AppConfig = + Hocon.decodeFromConfig( + serializer(), + ConfigFactory.parseString(string).resolve() + ) + + fun parseYaml(string: String): AppConfig = + yamlParser.decodeFromString( + serializer(), + string + ) + + fun loadFromFile(file: File): AppConfig { + val txt = file.readText() + return if (file.extension.matches("(?i)yaml|yml".toRegex())) + parseYaml(txt) + else + parseHocon(txt) + } + + private val yamlParser = Yaml( + configuration = YamlConfiguration( + strictMode = false + ) + ) + } +} diff --git a/src/main/kotlin/org/alexn/hook/CommandTrigger.kt b/src/main/kotlin/org/alexn/hook/CommandTrigger.kt new file mode 100644 index 0000000..2c7447b --- /dev/null +++ b/src/main/kotlin/org/alexn/hook/CommandTrigger.kt @@ -0,0 +1,71 @@ +package org.alexn.hook + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import arrow.fx.coroutines.Atomic +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withTimeout +import java.io.File +import kotlin.time.Duration.Companion.seconds + +/** + * Handles the actual shell command execution, per project. + */ +class CommandTrigger private constructor( + private val projects: Map, + private val locks: Atomic> +) { + private suspend fun lockFor(key: String): Mutex { + val lockRef = locks.get()[key] + if (lockRef != null) return lockRef + + return locks.modify { currentMap -> + val r = currentMap[key] + if (r != null) return@modify currentMap to r + + val newRef = Mutex() + val newMap = currentMap + mapOf(key to newRef) + newMap to newRef + } + } + + suspend fun triggerCommand(key: String): Either { + val project = projects[key] + ?: return RequestError.NotFound("Project `$key` does not exist").left() + + val timeoutDuration = project.timeout ?: 30.seconds + val mutex = lockFor(key) + mutex.lock() + return try { + val result = withTimeout(timeoutDuration) { + executeRawShellCommand( + command = project.command, + dir = File(project.directory) + ) + } + if (result.isSuccessful) + Unit.right() + else + RequestError.Internal("Command execution failed", null).left() + } catch (e: TimeoutCancellationException) { + RequestError.TimedOut( + "Command execution timed-out after $timeoutDuration" + ).left() + } finally { + mutex.unlock() + } + } + + companion object { + /** + * Builder with side effects. + */ + suspend operator fun invoke(projects: Map): CommandTrigger = + CommandTrigger( + projects, + Atomic(mapOf()) + ) + } +} diff --git a/src/main/kotlin/org/alexn/hook/EventPayload.kt b/src/main/kotlin/org/alexn/hook/EventPayload.kt new file mode 100644 index 0000000..369f4d0 --- /dev/null +++ b/src/main/kotlin/org/alexn/hook/EventPayload.kt @@ -0,0 +1,131 @@ +package org.alexn.hook + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import io.ktor.http.ContentType +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.apache.commons.codec.digest.HmacAlgorithms +import org.apache.commons.codec.digest.HmacUtils +import java.net.URLDecoder +import java.nio.charset.StandardCharsets.UTF_8 + +/** + * + */ +@Serializable +data class EventPayload( + val action: String?, + val ref: String?, +) { + fun shouldProcess(prj: AppConfig.Project): Boolean = + action == (prj.action ?: "push") && ref == prj.ref + + companion object { + private val jsonParser = Json { + isLenient = true + ignoreUnknownKeys = true + } + + fun authenticateRequest( + body: String, + signatureKey: String, + signatureHeader: String?, + ): Either { + if (signatureHeader == null) + return RequestError.Forbidden("No signature header was provided").left() + + val sha1Prefix = "sha1=" + val sha256Prefix = "sha256=" + + if (signatureHeader.startsWith(sha256Prefix)) { + val hmacHex = HmacUtils(HmacAlgorithms.HMAC_SHA_256, signatureKey).hmacHex(body) + if (!signatureHeader.substring(sha256Prefix.length).equals(hmacHex, ignoreCase = true)) + return RequestError.Forbidden("Invalid checksum (sha256)").left() + return Unit.right() + } + if (signatureHeader.startsWith(sha1Prefix)) { + val hmacHex = HmacUtils(HmacAlgorithms.HMAC_SHA_1, signatureKey).hmacHex(body) + if (!signatureHeader.substring(sha1Prefix.length).equals(hmacHex, ignoreCase = true)) + return RequestError.Forbidden("Invalid checksum (sha1)").left() + return Unit.right() + } + return RequestError.Forbidden("Unsupported algorithm").left() + } + + fun parse(contentType: ContentType, body: String): Either = + if (contentType.match(ContentType("application", "json"))) + parseJson(body) + else if (contentType.match(ContentType("application", "x-www-form-urlencoded"))) + parseFormData(body) + else + RequestError.UnsupportedMediaType("Cannot process `$contentType` media type").left() + + fun parseJson(json: String): Either { + try { + val payload = jsonParser.decodeFromString(json) + return payload.right() + } catch (e: SerializationException) { + return RequestError.BadInput("Invalid JSON", e).left() + } catch (e: IllegalArgumentException) { + return RequestError.BadInput("Invalid JSON", e).left() + } + } + + fun parseFormData(body: String): Either = + try { + val map = mutableMapOf() + for (part in body.split("&")) { + val values = part.split("=").map { URLDecoder.decode(it, UTF_8) } + assert(values.size in 1..2) + map[values[0]] = values[1] ?: "" + } + EventPayload( + action = map.get("action"), + ref = map.get("ref"), + ).right() + } catch (e: AssertionError) { + RequestError.BadInput("Invalid form-urlencoded data", null).left() + } + } +} + +sealed class RequestError(val httpCode: Int) { + abstract val message: String + + fun toException(): Exception = + when (this) { + is BadInput -> + RequestException("$httpCode Bad Input — $message", exception) + is Forbidden -> + RequestException("$httpCode Forbidden — $message", null) + is Internal -> { + val metaStr = (meta ?: mapOf()).map { "\n ${it.key}:${it.value}" }.joinToString("") + RequestException("$httpCode Internal Server Error — $message$metaStr", exception) + } + is NotFound -> + RequestException("$httpCode Not Found — $message", null) + is Skipped -> + RequestException("$httpCode Skipped — $message", null) + is TimedOut -> + RequestException("$httpCode Timed out — $message", null) + is UnsupportedMediaType -> + RequestException("$httpCode Unsupported Media Type — $message", null) + } + + data class BadInput(override val message: String, val exception: Exception? = null) : RequestError(400) + data class Forbidden(override val message: String) : RequestError(403) + data class Internal(override val message: String, val exception: Exception? = null, val meta: Map? = null) : RequestError(500) + data class NotFound(override val message: String) : RequestError(404) + data class Skipped(override val message: String) : RequestError(200) + data class TimedOut(override val message: String) : RequestError(408) + data class UnsupportedMediaType(override val message: String) : RequestError(415) +} + +class RequestException( + message: String, + cause: Throwable? +) : java.lang.Exception(message, cause) diff --git a/src/main/kotlin/org/alexn/hook/Main.kt b/src/main/kotlin/org/alexn/hook/Main.kt new file mode 100644 index 0000000..22d72f7 --- /dev/null +++ b/src/main/kotlin/org/alexn/hook/Main.kt @@ -0,0 +1,37 @@ +package org.alexn.hook + +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import kotlinx.cli.ArgParser +import kotlinx.cli.ArgType +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import java.io.File + +fun main(args: Array): Unit = runBlocking { + val appConfig = loadConfig(args) + val commandTrigger = CommandTrigger(appConfig.projects) + val server = embeddedServer( + CIO, + port = appConfig.http.port, + host = appConfig.http.host ?: "0.0.0.0", + ) { + configureRouting(appConfig, commandTrigger) + } + runInterruptible { + server.start(wait = true) + } +} + +fun loadConfig(args: Array): AppConfig { + val parser = ArgParser(programName = "github-webhook-listener") + val config by parser + .argument( + ArgType.String, + fullName = "config-path", + description = "Path to the application configuration" + ) + + parser.parse(args) + return AppConfig.loadFromFile(File(config)) +} diff --git a/src/main/kotlin/org/alexn/hook/OperatingSystem.kt b/src/main/kotlin/org/alexn/hook/OperatingSystem.kt new file mode 100644 index 0000000..dd226ee --- /dev/null +++ b/src/main/kotlin/org/alexn/hook/OperatingSystem.kt @@ -0,0 +1,108 @@ +package org.alexn.hook + +import arrow.core.Option +import arrow.core.orElse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import org.apache.commons.text.StringEscapeUtils +import java.io.File +import java.nio.charset.StandardCharsets.UTF_8 +import java.nio.file.Path + +data class CommandResult( + val exitCode: Int, + val stdout: String, + val stderr: String, +) { + val isSuccessful get() = exitCode == 0 +} + +/** + * Executes a program. This needs to be a valid path on the + * file system. + * + * See [executeEscapedShellCommand] for the version that executes + * `/bin/sh` commands. + */ +suspend fun executeCommand( + executable: Path, + args: List? = null, + dir: File? = null +): CommandResult = + // Blocking I/O should use threads designated for I/O + withContext(Dispatchers.IO) { + val cmdArgs = listOf(executable.toAbsolutePath().toString()) + (args ?: listOf()) + val proc = Runtime.getRuntime().exec( + cmdArgs.toTypedArray(), + arrayOf(), + dir + ) + try { + // Concurrent execution ensures the stream's buffer doesn't + // block processing when overflowing + val stdout = async { + runInterruptible(Dispatchers.IO) { + // That `InputStream.read` doesn't listen to thread interruption + // signals; but for future development it doesn't hurt + String(proc.inputStream.readAllBytes(), UTF_8) + } + } + val stderr = async { + runInterruptible(Dispatchers.IO) { + String(proc.errorStream.readAllBytes(), UTF_8) + } + } + CommandResult( + exitCode = runInterruptible(Dispatchers.IO) { proc.waitFor() }, + stdout = stdout.await(), + stderr = stderr.await() + ) + } finally { + proc.destroy() + } + } + +/** + * Executes shell commands. + * + * This version does shell escaping of command arguments. + * WARN: command arguments need be given explicitly because + * they need to be properly escaped. + + * @see [executeRawShellCommand] + */ +suspend fun executeEscapedShellCommand( + command: String, + args: List? = null, + dir: File? = null +): CommandResult = + executeRawShellCommand( + command = (listOf(command) + (args ?: listOf())) + .map(StringEscapeUtils::escapeXSI) + .joinToString(" "), + dir = dir + ) + +/** + * Executes shell commands. + */ +suspend fun executeRawShellCommand( + command: String, + dir: File? = null +): CommandResult = + executeCommand( + executable = Path.of("/bin/sh"), + args = listOf("-c", command), + dir = dir + ) + +val USER_HOME: File? by lazy { + Option.fromNullable(System.getProperty("user.home")) + .filter { it.isNotEmpty() } + .orElse { Option.fromNullable(System.getenv("HOME")) } + .filter { it.isNotEmpty() } + .map { File(it) } + .orNull() +} diff --git a/src/main/kotlin/org/alexn/hook/Routing.kt b/src/main/kotlin/org/alexn/hook/Routing.kt new file mode 100644 index 0000000..3e2bc23 --- /dev/null +++ b/src/main/kotlin/org/alexn/hook/Routing.kt @@ -0,0 +1,107 @@ +package org.alexn.hook + +import arrow.core.Either +import arrow.core.continuations.either +import arrow.core.left +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.request.contentType +import io.ktor.server.request.header +import io.ktor.server.request.receiveText +import io.ktor.server.response.respond +import io.ktor.server.response.respondRedirect +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import kotlinx.serialization.json.Json +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +fun Application.configureRouting( + config: AppConfig, + commandTriggerService: CommandTrigger +) { + val logger: Logger by lazy { + LoggerFactory.getLogger("org.alexn.hook.Routing") + } + val basePath = config.http.basePath + + routing { + install(ContentNegotiation) { + json( + Json { + prettyPrint = true + isLenient = true + } + ) + } + + if (config.http.basePath.isNotEmpty()) + get(config.http.basePath) { + call.respondRedirect("$basePath/") + } + + get("$basePath/") { + call.respond( + mapOf( + "configured" to config.projects.map { it.key } + ) + ) + } + + post("$basePath/{project}") { + val projectKey = call.parameters["project"] + if (projectKey == null) { + call.respondText("Project key not specified", status = HttpStatusCode.BadRequest) + return@post + } + + val response = either { + val project = Either + .fromNullable(config.projects[projectKey]) + .mapLeft { RequestError.NotFound("Project `$projectKey` does not exist") } + .bind() + + val signature = call.request.header("X-Hub-Signature-256") ?: call.request.header("X-Hub-Signature") + val body = call.receiveText() + EventPayload + .authenticateRequest(body, project.secret, signature) + .bind() + + val parsed = + EventPayload.parse(call.request.contentType(), body).bind() + + val result = if (parsed.shouldProcess(project)) + commandTriggerService.triggerCommand(projectKey) + else + RequestError.Skipped("Nothing to do for project `$projectKey`").left() + + result.bind() + } + + when (response) { + is Either.Right -> { + call.respondText("OK", status = HttpStatusCode.OK) + logger.info("POST /$projectKey — OK") + } + is Either.Left -> { + val err = response.value + call.respondText(err.message, status = HttpStatusCode.fromValue(err.httpCode)) + when (err) { + is RequestError.Skipped -> + logger.info("POST /$projectKey — Skipped") + else -> { + val ex = err.toException() + logger.warn("POST /$projectKey — ${ex.message}", ex.cause) + } + } + } + } + } + } +} diff --git a/src/main/resources/META-INF/native-image/jni-config.json b/src/main/resources/META-INF/native-image/jni-config.json new file mode 100644 index 0000000..f8b7f83 --- /dev/null +++ b/src/main/resources/META-INF/native-image/jni-config.json @@ -0,0 +1,37 @@ +[ +{ + "name":"java.lang.Boolean", + "methods":[{"name":"getBoolean","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"java.lang.String", + "methods":[ + {"name":"lastIndexOf","parameterTypes":["int"] }, + {"name":"substring","parameterTypes":["int"] } + ] +}, +{ + "name":"java.lang.System", + "methods":[ + {"name":"getProperty","parameterTypes":["java.lang.String"] }, + {"name":"setProperty","parameterTypes":["java.lang.String","java.lang.String"] } + ] +}, +{ + "name":"org.alexn.hook.MainKt", + "methods":[{"name":"main","parameterTypes":["java.lang.String[]"] }] +}, +{ + "name":"sun.management.VMManagementImpl", + "fields":[ + {"name":"compTimeMonitoringSupport"}, + {"name":"currentThreadCpuTimeSupport"}, + {"name":"objectMonitorUsageSupport"}, + {"name":"otherThreadCpuTimeSupport"}, + {"name":"remoteDiagnosticCommandsSupport"}, + {"name":"synchronizerUsageSupport"}, + {"name":"threadAllocatedMemorySupport"}, + {"name":"threadContentionMonitoringSupport"} + ] +} +] diff --git a/src/main/resources/META-INF/native-image/predefined-classes-config.json b/src/main/resources/META-INF/native-image/predefined-classes-config.json new file mode 100644 index 0000000..0e79b2c --- /dev/null +++ b/src/main/resources/META-INF/native-image/predefined-classes-config.json @@ -0,0 +1,8 @@ +[ + { + "type":"agent-extracted", + "classes":[ + ] + } +] + diff --git a/src/main/resources/META-INF/native-image/proxy-config.json b/src/main/resources/META-INF/native-image/proxy-config.json new file mode 100644 index 0000000..0d4f101 --- /dev/null +++ b/src/main/resources/META-INF/native-image/proxy-config.json @@ -0,0 +1,2 @@ +[ +] diff --git a/src/main/resources/META-INF/native-image/reflect-config.json b/src/main/resources/META-INF/native-image/reflect-config.json new file mode 100644 index 0000000..47bdb15 --- /dev/null +++ b/src/main/resources/META-INF/native-image/reflect-config.json @@ -0,0 +1,143 @@ +[ +{ + "name":"ch.qos.logback.classic.encoder.PatternLayoutEncoder", + "queryAllPublicMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.DateConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.LevelConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.LineSeparatorConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.LoggerConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.MessageConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.classic.pattern.ThreadConverter", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.core.ConsoleAppender", + "queryAllPublicMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"ch.qos.logback.core.OutputStreamAppender", + "methods":[{"name":"setEncoder","parameterTypes":["ch.qos.logback.core.encoder.Encoder"] }] +}, +{ + "name":"ch.qos.logback.core.encoder.LayoutWrappingEncoder", + "methods":[{"name":"setParent","parameterTypes":["ch.qos.logback.core.spi.ContextAware"] }] +}, +{ + "name":"ch.qos.logback.core.pattern.PatternLayoutEncoderBase", + "methods":[{"name":"setPattern","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"com.sun.crypto.provider.HmacCore$HmacSHA256", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.ktor.utils.io.ByteReadChannel" +}, +{ + "name":"java.io.FilePermission" +}, +{ + "name":"java.lang.RuntimePermission" +}, +{ + "name":"java.lang.String", + "allDeclaredFields":true, + "allDeclaredClasses":true +}, +{ + "name":"java.net.NetPermission" +}, +{ + "name":"java.net.SocketPermission" +}, +{ + "name":"java.net.StandardSocketOptions" +}, +{ + "name":"java.net.URLPermission", + "methods":[{"name":"","parameterTypes":["java.lang.String","java.lang.String"] }] +}, +{ + "name":"java.security.AllPermission" +}, +{ + "name":"java.security.SecurityPermission" +}, +{ + "name":"java.util.List" +}, +{ + "name":"java.util.Map" +}, +{ + "name":"java.util.PropertyPermission" +}, +{ + "name":"javax.management.ObjectName" +}, +{ + "name":"kotlin.Metadata", + "queryAllDeclaredMethods":true, + "methods":[ + {"name":"bv","parameterTypes":[] }, + {"name":"d1","parameterTypes":[] }, + {"name":"d2","parameterTypes":[] }, + {"name":"k","parameterTypes":[] }, + {"name":"mv","parameterTypes":[] }, + {"name":"pn","parameterTypes":[] }, + {"name":"xi","parameterTypes":[] }, + {"name":"xs","parameterTypes":[] } + ] +}, +{ + "name":"kotlin.internal.jdk8.JDK8PlatformImplementations", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"kotlin.jvm.internal.DefaultConstructorMarker" +}, +{ + "name":"kotlin.reflect.jvm.internal.ReflectionFactoryImpl", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"org.alexn.hook.EventPayload", + "fields":[{"name":"Companion"}] +}, +{ + "name":"org.alexn.hook.EventPayload$Companion", + "methods":[{"name":"serializer","parameterTypes":[] }] +}, +{ + "name":"org.alexn.hook.MainKt$main$1$server$1", + "queryAllPublicMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"sun.security.provider.SHA2$SHA256", + "methods":[{"name":"","parameterTypes":[] }] +} +] diff --git a/src/main/resources/META-INF/native-image/resource-config.json b/src/main/resources/META-INF/native-image/resource-config.json new file mode 100644 index 0000000..a5e7182 --- /dev/null +++ b/src/main/resources/META-INF/native-image/resource-config.json @@ -0,0 +1,39 @@ +{ + "resources":{ + "includes":[ + { + "pattern":"\\QMETA-INF/services/kotlin.reflect.jvm.internal.impl.builtins.BuiltInsLoader\\E" + }, + { + "pattern":"\\QMETA-INF/services/kotlin.reflect.jvm.internal.impl.resolve.ExternalOverridabilityCondition\\E" + }, + { + "pattern":"\\Qkotlin/annotation/annotation.kotlin_builtins\\E" + }, + { + "pattern":"\\Qkotlin/collections/collections.kotlin_builtins\\E" + }, + { + "pattern":"\\Qkotlin/coroutines/coroutines.kotlin_builtins\\E" + }, + { + "pattern":"\\Qkotlin/internal/internal.kotlin_builtins\\E" + }, + { + "pattern":"\\Qkotlin/kotlin.kotlin_builtins\\E" + }, + { + "pattern":"\\Qkotlin/ranges/ranges.kotlin_builtins\\E" + }, + { + "pattern":"\\Qkotlin/reflect/reflect.kotlin_builtins\\E" + }, + { + "pattern":"\\Qlogback.xml\\E" + }, + { + "pattern":"\\Qorg/slf4j/impl/StaticLoggerBinder.class\\E" + } + ]}, + "bundles":[] +} diff --git a/src/main/resources/META-INF/native-image/serialization-config.json b/src/main/resources/META-INF/native-image/serialization-config.json new file mode 100644 index 0000000..bf554e0 --- /dev/null +++ b/src/main/resources/META-INF/native-image/serialization-config.json @@ -0,0 +1,6 @@ +{ + "types":[ + ], + "lambdaCapturingTypes":[ + ] +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..bdbb64e --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/src/test/kotlin/org/alexn/hook/AppConfigTest.kt b/src/test/kotlin/org/alexn/hook/AppConfigTest.kt new file mode 100644 index 0000000..1ab9d9a --- /dev/null +++ b/src/test/kotlin/org/alexn/hook/AppConfigTest.kt @@ -0,0 +1,113 @@ +@file:OptIn(ExperimentalSerializationApi::class, ExperimentalSerializationApi::class) + +package org.alexn.hook + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.hocon.Hocon +import kotlinx.serialization.hocon.decodeFromConfig +import kotlinx.serialization.hocon.encodeToConfig +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +class AppConfigTest { + val expected = AppConfig( + http = AppConfig.Http( + host = "0.0.0.0", + port = 8080 + ), + projects = mapOf( + "monix" to AppConfig.Project( + action = null, + ref = "refs/heads/gh-pages", + directory = "/var/www/myproject", + command = "git pull", + timeout = 3.seconds, + secret = "xxxxx" + ) + ) + ) + + @Test + fun jsonCodecWorks() { + val encoded = Json.encodeToString(expected) + val received = Json.decodeFromString(encoded) + assertEquals(expected, received) + } + + @Test + fun hoconCodecWorks() { + val encoded = Hocon.encodeToConfig(expected) + val received = Hocon.decodeFromConfig(encoded) + assertEquals(expected, received) + } + + @Test + fun parseFromHoconString() { + val config = """ + http { + host = "0.0.0.0" + port = 8080 + } + + projects { + monix { + ref: "refs/heads/gh-pages" + directory: "/var/www/myproject" + command: "git pull" + timeout: "PT3S" + secret: "xxxxx" + } + } + """.trimIndent() + + assertEquals( + expected, + AppConfig.parseHocon(config) + ) + } + + @Test + fun parseLegacyYamlConfig() { + val config = """ + http: + path: "/" + port: 8080 + + runtime: + workers: 2 + output: stdout + + projects: + myproject: + ref: "refs/heads/gh-pages" + directory: "/var/www/myproject" + command: "git pull" + secret: "xxxxxxxxxxxxxxxxxxxxxxxxxx" + """.trimIndent() + + assertEquals( + AppConfig( + http = AppConfig.Http( + host = null, + port = 8080, + path = "/" + ), + projects = mapOf( + "myproject" to AppConfig.Project( + action = null, + ref = "refs/heads/gh-pages", + directory = "/var/www/myproject", + command = "git pull", + timeout = null, + secret = "xxxxxxxxxxxxxxxxxxxxxxxxxx" + ) + ) + ), + AppConfig.parseYaml(config) + ) + } +} diff --git a/src/test/kotlin/org/alexn/hook/ApplicationTest.kt b/src/test/kotlin/org/alexn/hook/ApplicationTest.kt new file mode 100644 index 0000000..bc4c7bf --- /dev/null +++ b/src/test/kotlin/org/alexn/hook/ApplicationTest.kt @@ -0,0 +1,22 @@ +// package org.alexn.hook +// +// import io.ktor.client.request.get +// import io.ktor.client.statement.bodyAsText +// import io.ktor.http.HttpStatusCode +// import io.ktor.server.testing.testApplication +// import org.alexn.hook.plugins.configureRouting +// import kotlin.test.Test +// import kotlin.test.assertEquals +// +// class ApplicationTest { +// @Test +// fun testRoot() = testApplication { +// application { +// configureRouting() +// } +// client.get("/").apply { +// assertEquals(HttpStatusCode.OK, status) +// assertEquals("Hello World!", bodyAsText()) +// } +// } +// } diff --git a/src/test/kotlin/org/alexn/hook/EventPayloadTest.kt b/src/test/kotlin/org/alexn/hook/EventPayloadTest.kt new file mode 100644 index 0000000..be47ddf --- /dev/null +++ b/src/test/kotlin/org/alexn/hook/EventPayloadTest.kt @@ -0,0 +1,120 @@ +package org.alexn.hook + +import arrow.core.getOrHandle +import org.apache.commons.codec.digest.HmacAlgorithms +import org.apache.commons.codec.digest.HmacUtils +import java.net.URLEncoder +import java.nio.charset.StandardCharsets.UTF_8 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EventPayloadTest { + @Test + fun `authenticate message with hmac-sha1`() { + val key = "some-key" + val json = """ + { + "action": "some-action", + "ref": "some-ref", + "additional": { "field": true } + } + """.trimIndent() + + EventPayload + .authenticateRequest( + json, + key, + "sha1=" + HmacUtils(HmacAlgorithms.HMAC_SHA_1, key).hmacHex(json), + ) + .getOrHandle { throw it.toException() } + } + + @Test + fun `authenticate message with hmac-sha256`() { + val key = "some-key" + val json = """ + { + "action": "some-action", + "ref": "some-ref", + "additional": { "field": true } + } + """.trimIndent() + + EventPayload + .authenticateRequest( + json, + key, + "sha256=" + HmacUtils(HmacAlgorithms.HMAC_SHA_256, key).hmacHex(json), + ) + .getOrHandle { throw it.toException() } + } + + @Test + fun `authenticate fails on unknown algorithm`() { + val key = "some-key" + val json = """ + { + "action": "some-action", + "ref": "some-ref", + "additional": { "field": true } + } + """.trimIndent() + + val r = EventPayload + .authenticateRequest( + json, + key, + "sha512=" + HmacUtils(HmacAlgorithms.HMAC_SHA_512, key).hmacHex(json), + ) + assertTrue(r.isLeft(), "Unexpected result: $r") + } + + @Test + fun `parse JSON`() { + val json = """ + { + "action": "some-action", + "ref": "some-ref", + "additional": { "field": true } + } + """.trimIndent() + + val received = EventPayload.parseJson(json).getOrHandle { throw it.toException() } + assertEquals( + EventPayload( + action = "some-action", + ref = "some-ref" + ), + received + ) + } + + @Test + fun `parse multipart-form data`() { + val formData = + "action=${URLEncoder.encode("some action", UTF_8)}&" + + "ref=${URLEncoder.encode("some ref", UTF_8)}" + val received = EventPayload.parseFormData(formData).getOrHandle { throw it.toException() } + assertEquals( + EventPayload( + action = "some action", + ref = "some ref" + ), + received + ) + } + + @Test + fun `parse fails on invalid JSON`() { + val yaml = """ + action: some-action + ref: some-ref + additional: + field: true + """.trimIndent() + + val r = EventPayload.parseJson(yaml) + assertTrue(r.isLeft(), "Unexpected result: $r") + } +} diff --git a/src/test/kotlin/org/alexn/hook/OperatingSystemKtTest.kt b/src/test/kotlin/org/alexn/hook/OperatingSystemKtTest.kt new file mode 100644 index 0000000..27d933f --- /dev/null +++ b/src/test/kotlin/org/alexn/hook/OperatingSystemKtTest.kt @@ -0,0 +1,34 @@ +package org.alexn.hook + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertTrue + +class OperatingSystemKtTest { + @Test + fun `execute shell command with escaped arguments`() { + runBlocking { + val r = executeEscapedShellCommand( + command = "ls", + args = listOf("-alh"), + dir = USER_HOME + ) + assertTrue(r.isSuccessful, "isSuccessful") + assertTrue(r.stdout.isNotEmpty(), "stdout.isNotEmpty") + assertTrue(r.stderr.isEmpty(), "stderr.isEmpty") + } + } + + @Test + fun `execute raw shell command`() { + runBlocking { + val r = executeRawShellCommand( + command = "ls -alh", + dir = USER_HOME + ) + assertTrue(r.isSuccessful, "isSuccessful") + assertTrue(r.stdout.isNotEmpty(), "stdout.isNotEmpty") + assertTrue(r.stderr.isEmpty(), "stderr.isEmpty") + } + } +} diff --git a/stack.yaml b/stack.yaml deleted file mode 100644 index 874a52a..0000000 --- a/stack.yaml +++ /dev/null @@ -1,66 +0,0 @@ -# This file was automatically generated by 'stack init' -# -# Some commonly used options have been documented as comments in this file. -# For advanced use and comprehensive documentation of the format, please see: -# https://docs.haskellstack.org/en/stable/yaml_configuration/ - -# Resolver to choose a 'specific' stackage snapshot or a compiler version. -# A snapshot resolver dictates the compiler version and the set of packages -# to be used for project dependencies. For example: -# -# resolver: lts-3.5 -# resolver: nightly-2015-09-21 -# resolver: ghc-7.10.2 -# -# The location of a snapshot can be provided as a file or url. Stack assumes -# a snapshot provided as a file might change, whereas a url resource does not. -# -# resolver: ./custom-snapshot.yaml -# resolver: https://example.com/snapshots/2018-01-01.yaml -resolver: lts-18.21 - -# User packages to be built. -# Various formats can be used as shown in the example below. -# -# packages: -# - some-directory -# - https://example.com/foo/bar/baz-0.0.2.tar.gz -# subdirs: -# - auto-update -# - wai -packages: -- . -# Dependency packages to be pulled from upstream that are not in the resolver. -# These entries can reference officially published versions as well as -# forks / in-progress versions pinned to a git hash. For example: -# -# extra-deps: -# - acme-missiles-0.3 -# - git: https://github.com/commercialhaskell/stack.git -# commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a -# -# extra-deps: [] - -# Override default flag values for local packages and extra-deps -# flags: {} - -# Extra package databases containing global packages -# extra-package-dbs: [] - -# Control whether we use the GHC we find on the path -# system-ghc: true -# -# Require a specific version of stack, using version ranges -# require-stack-version: -any # Default -# require-stack-version: ">=2.1" -# -# Override the architecture used by stack, especially useful on Windows -# arch: i386 -# arch: x86_64 -# -# Extra directories used by stack for building -# extra-include-dirs: [/path/to/dir] -# extra-lib-dirs: [/path/to/dir] -# -# Allow a newer minor version of GHC than the snapshot specifies -# compiler-check: newer-minor diff --git a/stack.yaml.lock b/stack.yaml.lock deleted file mode 100644 index 9d48cbc..0000000 --- a/stack.yaml.lock +++ /dev/null @@ -1,12 +0,0 @@ -# This file was autogenerated by Stack. -# You should not edit this file by hand. -# For more information, please see the documentation at: -# https://docs.haskellstack.org/en/stable/lock_files - -packages: [] -snapshots: -- completed: - size: 586110 - url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/18/21.yaml - sha256: ce4fb8d44f3c6c6032060a02e0ebb1bd29937c9a70101c1517b92a87d9515160 - original: lts-18.21 diff --git a/test/AppConfigSpec.hs b/test/AppConfigSpec.hs deleted file mode 100644 index 83981a5..0000000 --- a/test/AppConfigSpec.hs +++ /dev/null @@ -1,39 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -module AppConfigSpec ( - appConfigSpec) where - -import Test.Hspec -import Paths_github_webhook_listener -import Data.String (fromString) - -import AppConfig -import qualified Data.Map as Map - -appConfigSpec :: SpecWith () -appConfigSpec = - describe "AppConfig" $ - it "can read and parse config" $ do - file <- getDataFileName "resources/config.yaml" - cfg <- readAppConfig file - cfg `shouldBe` allConfig - where - allConfig = All { - http = Http { - path = fromString "/", - port = 8080 - }, - runtime = Runtime { - workers = 2, - output = "stdout" - }, - projects = Map.fromList [ - (fromString "myproject", Project { - action = Nothing, - ref = Just "refs/heads/gh-pages", - directory = "/var/www/domain.com", - command = "git pull", - secret = Just "xxxxxxxxxxxxxxxxxxxx" - }) - ] - } diff --git a/test/Main.hs b/test/Main.hs deleted file mode 100644 index a4b8af3..0000000 --- a/test/Main.hs +++ /dev/null @@ -1,7 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -import Test.Hspec -import AppConfigSpec - -main :: IO () -main = hspec appConfigSpec diff --git a/versions.properties b/versions.properties new file mode 100644 index 0000000..a305867 --- /dev/null +++ b/versions.properties @@ -0,0 +1,46 @@ +#### Dependencies and Plugin versions with their available updates. +#### Generated by `./gradlew refreshVersions` version 0.50.2 +#### +#### Don't manually edit or split the comments that start with four hashtags (####), +#### they will be overwritten by refreshVersions. +#### +#### suppress inspection "SpellCheckingInspection" for whole file +#### suppress inspection "UnusedProperty" for whole file + +plugin.org.graalvm.buildtools.native=0.9.14 + +## unused +version.org.apache.commons..commons-text=1.10.0 + +## unused + version.ktor=2.1.2 + +## unused +version.kotlinx.serialization=1.4.0 + +## unused +version.kotlinx.cli=0.3.5 + +version.kotlin=1.7.20 + +## unused +version.commons-codec..commons-codec=1.15 + +## unused +version.com.pinterest..ktlint=0.43.2 + +## unused +version.com.charleskorn.kaml..kaml=0.49.0 + +## unused +version.ch.qos.logback..logback-classic=1.2.11 + +## unused +version.arrow=1.1.2 + +plugin.org.jlleitschuh.gradle.ktlint=11.0.0 + +## unused +version.org.graalvm.buildtools..junit-platform-native=0.9.14 + +plugin.io.ktor.plugin=2.1.2