diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml new file mode 100644 index 0000000..77f3de5 --- /dev/null +++ b/.buildkite/pipeline.yml @@ -0,0 +1,166 @@ +env: + APP_NAME: ${BUILDKITE_PIPELINE_SLUG} + IMAGE_REPO: ghcr.io/theopenlane/${APP_NAME} + SONAR_HOST: "https://sonarcloud.io" +steps: + - group: ":test_tube: Tests" + key: "tests" + steps: + - label: ":golangci-lint: lint :lint-roller:" + key: "lint" + cancel_on_build_failing: true + plugins: + - docker#v5.11.0: + image: "ghcr.io/theopenlane/build-image:latest" + always-pull: true + command: ["task", "go:lint"] + environment: + - "GOTOOLCHAIN=auto" + - label: ":golang: go test" + key: "go_test" + cancel_on_build_failing: true + plugins: + - docker#v5.11.0: + image: "ghcr.io/theopenlane/build-image:latest" + always-pull: true + command: ["task", "go:test:cover"] + environment: + - "GOTOOLCHAIN=auto" + artifact_paths: ["coverage.out"] + - group: ":closed_lock_with_key: Security Checks" + depends_on: "go_test" + key: "security" + steps: + - label: ":closed_lock_with_key: gosec" + key: "gosec" + plugins: + - docker#v5.11.0: + image: "securego/gosec:2.20.0" + command: ["-no-fail", "-exclude-generated", "-fmt sonarqube", "-out", "results.txt", "./..."] + environment: + - "GOTOOLCHAIN=auto" + artifact_paths: ["results.txt"] + - label: ":github: upload PR reports" + key: "scan-upload-pr" + if: build.pull_request.id != null + depends_on: ["gosec", "go_test"] + plugins: + - artifacts#v1.9.4: + download: "results.txt" + - artifacts#v1.9.4: + download: "coverage.out" + step: "go_test" + - docker#v5.11.0: + image: "sonarsource/sonar-scanner-cli:11.0" + environment: + - "SONAR_TOKEN" + - "SONAR_HOST_URL=$SONAR_HOST" + - "SONAR_SCANNER_OPTS=-Dsonar.pullrequest.branch=$BUILDKITE_BRANCH -Dsonar.pullrequest.base=$BUILDKITE_PULL_REQUEST_BASE_BRANCH -Dsonar.pullrequest.key=$BUILDKITE_PULL_REQUEST" + - label: ":github: upload reports" + key: "scan-upload" + if: build.branch == "main" + depends_on: ["gosec", "go_test"] + plugins: + - artifacts#v1.9.4: + download: results.txt + - artifacts#v1.9.4: + download: coverage.out + step: "go_test" + - docker#v5.11.0: + image: "sonarsource/sonar-scanner-cli:11.0" + environment: + - "SONAR_TOKEN" + - "SONAR_HOST_URL=$SONAR_HOST" + - group: ":golang: Builds" + key: "go-builds" + steps: + - label: ":golang: build" + key: "gobuild-server" + artifact_paths: "bin/${APP_NAME}" + plugins: + - docker#v5.11.0: + image: "ghcr.io/theopenlane/build-image:latest" + always_pull: true + environment: + - CGO_ENABLED=0 + - GOOS=linux + command: ["task", "go:build:ci"] + - group: ":docker: Image Build" + depends_on: "go-builds" + key: "image-build" + steps: + - label: ":docker: docker pr build" + key: "docker-pr-build" + cancel_on_build_failing: true + if: build.branch != "main" && build.tag == null + commands: | + #!/bin/bash + ls + plugins: + - theopenlane/docker-metadata#v1.0.0: + images: + - "${IMAGE_REPO}" + extra_tags: + - "${IMAGE_TAG}" + - theopenlane/container-build#v1.0.0: + dockerfile: docker/Dockerfile + push: false + build-args: + - NAME=${APP_NAME} + - equinixmetal-buildkite/trivy#v1.18.5: + severity: CRITICAL,HIGH + ignore-unfixed: true + security-checks: config,secret,vuln + skip-files: "cosign.key,Dockerfile.dev" + - label: ":docker: docker build and publish" + key: "docker-build" + cancel_on_build_failing: true + if: build.branch == "main" + commands: | + #!/bin/bash + ls + plugins: + - docker-login#v3.0.0: + username: openlane-bender + password-env: SECRET_GHCR_PUBLISH_TOKEN + server: ghcr.io + - theopenlane/docker-metadata#v1.0.0: + images: + - "${IMAGE_REPO}" + extra_tags: + - "${IMAGE_TAG}" + - theopenlane/container-build#v1.0.0: + dockerfile: docker/Dockerfile + push: true + build-args: + - NAME=${APP_NAME} + - equinixmetal-buildkite/trivy#v1.18.5: + severity: CRITICAL,HIGH + ignore-unfixed: true + security-checks: config,secret,vuln + skip-files: "cosign.key,Dockerfile.dev" + - label: ":docker: docker build and publish" + key: "docker-build-and-tag" + if: build.tag != null + commands: | + #!/bin/bash + plugins: + - docker-login#v3.0.0: + username: openlane-bender + password-env: SECRET_GHCR_PUBLISH_TOKEN + server: ghcr.io + - theopenlane/docker-metadata#v1.0.0: + images: + - "${IMAGE_REPO}" + extra_tags: + - "${BUILDKITE_TAG}" + - theopenlane/container-build#v1.0.0: + dockerfile: docker/Dockerfile + push: true + build-args: + - NAME=${APP_NAME} + - equinixmetal-buildkite/trivy#v1.18.5: + severity: CRITICAL,HIGH + ignore-unfixed: true + security-checks: config,secret,vuln + skip-files: "cosign.key,Dockerfile.dev" diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..57a9603 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing + +Please read the [contributing](.github/CONTRIBUTING.md) guide as well as the +[Developer Certificate of Origin](https://developercertificate.org/). You will +be required to sign all commits to the openlane project, so if you're unfamiliar +with how to set that up, see +[github's documentation](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification). + +Given external users will not have write to the branches in this repository, +you'll need to follow the forking process to open a PR - +[here](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) +is a guide from github on how to do so. + +## Licensing + +This repository contains open source software that comprises the openlane stack +which is open source software under [Apache 2.0](LICENSE). Openlane's SaaS / +Cloud Services are products produced from this open source software exclusively +by theopenlane, Inc. This product is produced under our published commercial +terms (which are subject to change). Any logos or trademarks in our repositories +in [theopenlane](https://github.com/theopenlane) organization are not covered +under the Apache License and are trademarks of theopenlane, Inc. + +Others are allowed to make their own distribution of this software or include +this software in other commercial offerings, but cannot use any of the Openlane +logos, trademarks, cloud services, etc. + +## Security + +We take the security of our software products and services seriously, including +our commercial services and all of the open source code repositories managed +through our Github Organizations, such as +[theopenlane](https://github.com/theopenlane). If you believe you have found a +security vulnerability in any of our repositories or in our SaaS offering(s), +please report it to us through coordinated disclosure. + +**Please do NOT report security vulnerabilities through public github issues, +discussions, or pull requests!** + +Instead, please send an email to `security@theopenlane.io` with as much +information as possible to best help us understand and resolve the issues. See +the security policy attached to this repository for more details. + +## Questions? + +You can email us at `info@theopenlane.io`, open a github issue in this +repository, or reach out to [matoszz](https://github.com/matoszz) directly. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..263ebe5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,16 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[Bug]" +labels: bug +assignees: '' + +--- + +**Describe the bug or issue you're encountering** + + +**What are the relevant steps to reproduce, including the version(s) of the relevant software?** + + +**What is the expected behavior?** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..897f8f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature Request]" +labels: enhancement +assignees: matoszz + +--- + +**Describe how the feature might make your life easier or solve a problem** + +**Describe the solution you'd like to see with any relevant context** + +**Describe any alternatives you've considered or if there are short-tern vs. long-term options** diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..b5f34c9 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,24 @@ +# Add 'bug' label to any PR where the head branch name starts with `bug` or has a `bug` section in the name +bug: + - head-branch: ["^bug", "bug"] +# Add 'enhancement' label to any PR where the head branch name starts with `enhancement` or has a `enhancement` section in the name +enhancement: + - head-branch: ["^enhancement", "enhancement", "^feature", "feature", "^enhance", "enhance", "^feat", "feat"] +# Add 'breaking-change' label to any PR where the head branch name starts with `breaking-change` or has a `breaking-change` section in the name +breaking-change: + - head-branch: ["^breaking-change", "breaking-change"] +# Add 'dependencies' label to any PR where the head branch name starts with `dependencies` or has a `dependencies` section in the name +dependencies: + - head-branch: ["^dependencies", "dependencies", "^deps", "deps"] +ci: + - changed-files: + - any-glob-to-any-file: .github/** + - any-glob-to-any-file: .buildkite/** +local-development: + - changed-files: + - any-glob-to-any-file: scripts/** + - any-glob-to-any-file: Taskfile.yaml + - any-glob-to-any-file: docker/** +jobs: + - changed-files: + - any-glob-to-any-file: pkg/jobs/** diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..37df9bc --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,24 @@ +changelog: + exclude: + labels: + - ignore-for-release + authors: [] + categories: + - title: Breaking Changes 🛠 + labels: + - Semver-Major + - breaking-change + - title: New Features 🎉 + labels: + - Semver-Minor + - enhancement + - feature + - title: Bug Fixes 🐛 + labels: + - bug + - title: 👒 Dependencies + labels: + - dependencies + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/labeler.yaml b/.github/workflows/labeler.yaml new file mode 100644 index 0000000..fc43cb1 --- /dev/null +++ b/.github/workflows/labeler.yaml @@ -0,0 +1,13 @@ +name: "Pull Request Labeler" +on: + - pull_request_target +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + sync-labels: true diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml new file mode 100644 index 0000000..9e29f43 --- /dev/null +++ b/.github/workflows/releaser.yml @@ -0,0 +1,127 @@ +name: Release +on: + workflow_dispatch: + release: + types: [created] +permissions: + contents: write +jobs: + ldflags_args: + runs-on: ubuntu-latest + outputs: + commit-date: ${{ steps.ldflags.outputs.commit-date }} + commit: ${{ steps.ldflags.outputs.commit }} + version: ${{ steps.ldflags.outputs.version }} + tree-state: ${{ steps.ldflags.outputs.tree-state }} + steps: + - id: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: ldflags + run: | + echo "commit=$GITHUB_SHA" >> $GITHUB_OUTPUT + echo "commit-date=$(git log --date=iso8601-strict -1 --pretty=%ct)" >> $GITHUB_OUTPUT + echo "version=$(git describe --tags --always --dirty | cut -c2-)" >> $GITHUB_OUTPUT + echo "tree-state=$(if git diff --quiet; then echo "clean"; else echo "dirty"; fi)" >> $GITHUB_OUTPUT + release: + name: Build and release + needs: + - ldflags_args + outputs: + hashes: ${{ steps.hash.outputs.hashes }} + permissions: + contents: write # To add assets to a release. + id-token: write # To do keyless signing with cosign + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + - name: Install Syft + uses: anchore/sbom-action/download-syft@61119d458adab75f756bc0b9e4bde25725f86a7a # v0.17.2 + - name: Install Cosign + uses: sigstore/cosign-installer@v3.6.0 + - name: Run GoReleaser + id: run-goreleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + VERSION: ${{ needs.ldflags_args.outputs.version }} + COMMIT: ${{ needs.ldflags_args.outputs.commit }} + COMMIT_DATE: ${{ needs.ldflags_args.outputs.commit-date }} + TREE_STATE: ${{ needs.ldflags_args.outputs.tree-state }} + - name: Generate subject + id: hash + env: + ARTIFACTS: "${{ steps.run-goreleaser.outputs.artifacts }}" + run: | + set -euo pipefail + hashes=$(echo $ARTIFACTS | jq --raw-output '.[] | {name, "digest": (.extra.Digest // .extra.Checksum)} | select(.digest) | {digest} + {name} | join(" ") | sub("^sha256:";"")' | base64 -w0) + if test "$hashes" = ""; then # goreleaser < v1.13.0 + checksum_file=$(echo "$ARTIFACTS" | jq -r '.[] | select (.type=="Checksum") | .path') + hashes=$(cat $checksum_file | base64 -w0) + fi + echo "hashes=$hashes" >> $GITHUB_OUTPUT + provenance: + name: Generate provenance (SLSA3) + needs: + - release + permissions: + actions: read # To read the workflow path. + id-token: write # To sign the provenance. + contents: write # To add assets to a release. + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 + with: + base64-subjects: "${{ needs.release.outputs.hashes }}" + upload-assets: true # upload to a new release + verification: + name: Verify provenance of assets (SLSA3) + needs: + - release + - provenance + runs-on: ubuntu-latest + permissions: read-all + steps: + - name: Install the SLSA verifier + uses: slsa-framework/slsa-verifier/actions/installer@v2.6.0 + - name: Download assets + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + CHECKSUMS: "${{ needs.release.outputs.hashes }}" + ATT_FILE_NAME: "${{ needs.provenance.outputs.provenance-name }}" + run: | + set -euo pipefail + checksums=$(echo "$CHECKSUMS" | base64 -d) + while read -r line; do + fn=$(echo $line | cut -d ' ' -f2) + echo "Downloading $fn" + gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "$fn" + done <<<"$checksums" + gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "$ATT_FILE_NAME" + - name: Verify assets + env: + CHECKSUMS: "${{ needs.release.outputs.hashes }}" + PROVENANCE: "${{ needs.provenance.outputs.provenance-name }}" + run: |- + set -euo pipefail + checksums=$(echo "$CHECKSUMS" | base64 -d) + while read -r line; do + fn=$(echo $line | cut -d ' ' -f2) + echo "Verifying SLSA provenance for $fn" + slsa-verifier verify-artifact --provenance-path "$PROVENANCE" \ + --source-uri "github.com/$GITHUB_REPOSITORY" \ + --source-tag "$GITHUB_REF_NAME" \ + "$fn" + done <<<"$checksums" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07c1587 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work + +# local dev created files +*.db +riverboat + +# emails tests +emails/* +fixtures/emails/* + +# Packages +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar + +# Logs +*.log + +# Editor files +.vscode + +# OS Generated Files +.DS_Store* +.AppleDouble +.LSOverride +ehthumbs.db +Icon? +Thumbs.db + +.scannerwork/** +results.txt + +*.mime +*.mim + +# Configs +*.env +*.env-dev +*.config.yaml diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..2a8cc69 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,44 @@ +run: + timeout: 10m + allow-serial-runners: true + concurrency: 0 +linters-settings: + goimports: + local-prefixes: github.com/theopenlane/riverboat + gofumpt: + extra-rules: true + gosec: + exclude-generated: true + revive: + ignore-generated-header: true +linters: + enable: + - bodyclose + - errcheck + - gocritic + - gocyclo + - err113 + - gofmt + - goimports + - mnd + - gosimple + - govet + - gosec + - ineffassign + - misspell + - noctx + - revive + - staticcheck + - stylecheck + - typecheck + - unused + - whitespace + - wsl +issues: + fix: true + exclude-use-default: true + exclude-dirs: + - .buildkite/* + - .github/* + - docker/* + exclude-files: [] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a8b4350 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +default_stages: [pre-commit] +fail_fast: true +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: detect-private-key + exclude: providers/gmail/gmail_test.go + - repo: https://github.com/google/yamlfmt + rev: v0.13.0 + hooks: + - id: yamlfmt + - repo: https://github.com/crate-ci/typos + rev: v1.24.6 + hooks: + - id: typos diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..e69de29 diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 0000000..2dcfb1d --- /dev/null +++ b/.typos.toml @@ -0,0 +1,20 @@ +[files] +extend-exclude = ["go.mod","go.sum","providers/smtp/smtp_test.go"] +ignore-hidden = true +ignore-files = true +ignore-dot = true +ignore-vcs = true +ignore-global = true +ignore-parent = true + +[default] +binary = false +check-filename = true +check-file = true +unicode = true +ignore-hex = true +identifier-leading-digits = false +locale = "en" +extend-ignore-identifiers-re = [] +extend-ignore-words-re = ["(?i)requestor","(?i)indentity","(?i)encrypter","(?i)seeked","(?i)generater"] +extend-ignore-re = ["#\\s*spellchecker:off\\s*\\n.*\\n\\s*#\\s*spellchecker:on"] \ No newline at end of file diff --git a/.yamlfmt b/.yamlfmt new file mode 100644 index 0000000..f6cfc8b --- /dev/null +++ b/.yamlfmt @@ -0,0 +1,4 @@ +exclude: + - config/ +formatter: + retain_line_breaks: true \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9..6cb8f3e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,187 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://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. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and + distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the + copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other + entities that control, are controlled by, or are under common control with + that entity. For the purposes of this definition, "control" means (i) the + power, direct or indirect, to cause the direction or management of such + entity, whether by contract or otherwise, or (ii) ownership of fifty percent + (50%) or more of the outstanding shares, or (iii) beneficial ownership of + such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising + permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation source, and + configuration files. + + "Object" form shall mean any form resulting from mechanical transformation or + translation of a Source form, including but not limited to compiled object + code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, + made available under the License, as indicated by a copyright notice that is + included in or attached to the work (an example is provided in the Appendix + below). + + "Derivative Works" shall mean any work, whether in Source or Object form, + that is based on (or derived from) the Work and for which the editorial + revisions, annotations, elaborations, or other modifications represent, as a + whole, an original work of authorship. For the purposes of this License, + Derivative Works shall not include works that remain separable from, or + merely link (or bind by name) to the interfaces of, the Work and Derivative + Works thereof. + + "Contribution" shall mean any work of authorship, including the original + version of the Work and any modifications or additions to that Work or + Derivative Works thereof, that is intentionally submitted to Licensor for + inclusion in the Work by the copyright owner or by an individual or Legal + Entity authorized to submit on behalf of the copyright owner. For the + purposes of this definition, "submitted" means any form of electronic, + verbal, or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed by, + or on behalf of, the Licensor for the purpose of discussing and improving the + Work, but excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on + behalf of whom a Contribution has been received by Licensor and subsequently + incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable copyright license to + reproduce, prepare Derivative Works of, publicly display, publicly perform, + sublicense, and distribute the Work and such Derivative Works in Source or + Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, + each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, + no-charge, royalty-free, irrevocable (except as stated in this section) + patent license to make, have made, use, offer to sell, sell, import, and + otherwise transfer the Work, where such license applies only to those patent + claims licensable by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) with the + Work to which such Contribution(s) was submitted. If You institute patent + litigation against any entity (including a cross-claim or counterclaim in a + lawsuit) alleging that the Work or a Contribution incorporated within the + Work constitutes direct or contributory patent infringement, then any patent + licenses granted to You under this License for that Work shall terminate as + of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or + Derivative Works thereof in any medium, with or without modifications, and in + Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy + of this License; and + + (b) You must cause any modified files to carry prominent notices stating that + You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices from + the Source form of the Work, excluding those notices that do not pertain to + any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, + then any Derivative Works that You distribute must include a readable copy of + the attribution notices contained within such NOTICE file, excluding those + notices that do not pertain to any part of the Derivative Works, in at least + one of the following places: within a NOTICE text file distributed as part of + the Derivative Works; within the Source form or documentation, if provided + along with the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices normally appear. + The contents of the NOTICE file are for informational purposes only and do + not modify the License. You may add Your own attribution notices within + Derivative Works that You distribute, alongside or as an addendum to the + NOTICE text from the Work, provided that such additional attribution notices + cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may + provide additional or different license terms and conditions for use, + reproduction, or distribution of Your modifications, or for any such + Derivative Works as a whole, provided Your use, reproduction, and + distribution of the Work otherwise complies with the conditions stated in + this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any + Contribution intentionally submitted for inclusion in the Work by You to the + Licensor shall be under the terms and conditions of this License, without any + additional terms or conditions. Notwithstanding the above, nothing herein + shall supersede or modify the terms of any separate license agreement you may + have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, + trademarks, service marks, or product names of the Licensor, except as + required for reasonable and customary use in describing the origin of the + Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in + writing, Licensor provides the Work (and each Contributor provides its + Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied, including, without limitation, any + warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or + FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining + the appropriateness of using or redistributing the Work and assume any risks + associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in + tort (including negligence), contract, or otherwise, unless required by + applicable law (such as deliberate and grossly negligent acts) or agreed to + in writing, shall any Contributor be liable to You for damages, including any + direct, indirect, special, incidental, or consequential damages of any + character arising as a result of this License or out of the use or inability + to use the Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all other + commercial damages or losses), even if such Contributor has been advised of + the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or + Derivative Works thereof, You may choose to offer, and charge a fee for, + acceptance of support, warranty, indemnity, or other liability obligations + and/or rights consistent with this License. However, in accepting such + obligations, You may act only on Your own behalf and on Your sole + responsibility, not on behalf of any other Contributor, and only if You agree + to indemnify, defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason of your + accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2024, theopenlane, Inc. + +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 + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..73976ed --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +[![Go Report Card](https://goreportcard.com/badge/github.com/theopenlane/riverboat)](https://goreportcard.com/report/github.com/theopenlane/riverboat) +[![Build status](https://badge.buildkite.com/34ad31fe4231b2953cd3f2d116364d21a39b2a4dbf1eea539a.svg)](https://buildkite.com/theopenlane/riverboat?branch=main) +[![Go Reference](https://pkg.go.dev/badge/github.com/theopenlane/riverboat.svg)](https://pkg.go.dev/github.com/theopenlane/riverboat) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache2.0-brightgreen.svg)](https://opensource.org/licenses/Apache-2.0) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=theopenlane_riverboat&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=theopenlane_riverboat) + +# Riverboat + +Riverboat is the job queue used in openlane based on the +[riverqueue](https://riverqueue.com/) project. + +## Usage + +Jobs can be inserted into the job queue either from this server directly, or +from any codebase with an +[Insert Only](https://riverqueue.com/docs#insert-only-clients) river client. All +jobs will be processed via the `riverboat` server. Since jobs are committed to +the database within a transaction, and stored in the database we do not have to +worry about dropped events. + +## Getting Started + +This repo includes several [Taskfiles](https://taskfile.dev/) to assist with +getting things running. + +### Dependencies + +- Go 1.23+ +- Docker (used for running Postgres and the river-ui) +- [task](https://taskfile.dev/) + +### Starting the Server + +The following will start up postgres, the river-ui, and the riverboat server: + +```bash +task run-dev +``` + +### Test Jobs + +Included in the `test/` directory are test jobs corresponding to the job types +in `pkg/jobs`. + +1. Start the `riverboat` server using `task run-dev` +1. Run the test main, for example the `email`: + + ```bash + go run test/email/main.go + ``` +1. This should insert the job successfully, it should be processed by `river` + and the email should be added to `fixtures/email` + +## Adding New Jobs + +1. New jobs should be added to the `pkg/jobs` directory in a new file, refer to + the [upstream docs](https://riverqueue.com/docs#job-args-and-workers) for + implementation details. The following is a stem job that could be copied to + get you started. + ```go + package jobs + + import ( + "context" + + "github.com/riverqueue/river" + "github.com/rs/zerolog/log" + ) + + // ExampleArgs for the example worker to process the job + type ExampleArgs struct { + // ExampleArg is an example argument + ExampleArg string `json:"example_arg"` + } + + // Kind satisfies the river.Job interface + func (ExampleArgs) Kind() string { return "example" } + + // ExampleWorker does all sorts of neat stuff + type ExampleWorker struct { + river.WorkerDefaults[ExampleArgs] + + ExampleConfig + } + + // ExampleConfig contains the configuration for the example worker + type ExampleConfig struct { + // DevMode is a flag to enable dev mode so we don't actually send millions of carrier pigeons + DevMode bool `koanf:"devMode" json:"devMode" jsonschema:"description=enable dev mode" default:"true"` + } + + // Work satisfies the river.Worker interface for the example worker + func (w *ExampleConfig) Work(ctx context.Context, job *river.Job[ExampleArgs]) error { + // do some work + + return nil + } + ``` + +1. Add a test for the new job, see `email_test.go` as an example. There are + additional helper functions that can be used, see + [river test helpers](https://riverqueue.com/docs/testing) for details. +1. If there are configuration settings, add the worker to `pkg/river/config.go` + `Workers` struct, this will allow the config variables to be set via the + `koanf` config setup. Once added you will need to regenerate the config: + ```bash + task config:generate + ``` +1. Register the worker by adding the `river.AddWorkerSafely` to the + `pkg/river/workers.go` `createWorkers` function. +1. Add a `test` job to `test/` directory by creating a new directory with a + `main.go` function that will insert the job into the queue. + +## Contributing + +See the [contributing](.github/CONTRIBUTING.md) guide for more information. diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..587d562 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,94 @@ +version: "3" + +includes: + config: + taskfile: ./configgen/Taskfile.yaml + docker: + taskfile: ./docker/Taskfile.yaml + dir: ./docker + aliases: [compose] + +env: + GOFLAGS: -buildvcs=false + +tasks: + default: + silent: true + cmds: + - task --list + + ## Go tasks + go:lint: + desc: runs golangci-lint, the most annoying opinionated linter ever + cmds: + - golangci-lint run --config=.golangci.yaml --verbose --fast --fix + + go:fmt: + desc: format all go code + cmds: + - go fmt ./... + + go:test: + desc: runs and outputs results of created go tests + aliases: [test] + cmds: + - go test -v ./... + + go:test:cover: + desc: runs and outputs results of created go tests with coverage + aliases: [cover] + cmds: + - go test -v ./... -coverprofile=coverage.out + + go:test:cover:out: + desc: runs and outputs results of created go tests with coverage + cmds: + - task: go:test:cover + - go tool cover -html=coverage.out + + go:tidy: + desc: runs go mod tidy on the backend + aliases: [tidy] + cmds: + - go mod tidy + + go:all: + aliases: [go] + desc: runs all go test and lint related tasks + cmds: + - task: go:tidy + - task: go:fmt + - task: go:lint + - task: go:test + + go:build: + desc: Runs go build for the riverboat server + cmds: + - go build -mod=mod -o riverboat + + go:build:ci: + desc: Runs go build for the riverboat server + cmds: + - go build -mod=mod -a -o bin/riverboat + + run: + dotenv: ["{{.ENV}}/.env-dev"] + desc: runs the riverboat server in dev mode, assumes all other dependencies (postgres) are running + cmds: + - go run main.go serve --debug --pretty + + run-dev: + dotenv: ["{{.ENV}}/.env-dev"] + desc: runs the riverboat server in dev mode with dependencies in docker + cmds: + - task: docker:postgres + - task: docker:ui:up + - 'open "http://localhost:8082"' + - task: run + + precommit-full: + desc: Lint the project against all files + cmds: + - pre-commit install && pre-commit install-hooks + - pre-commit autoupdate + - pre-commit run --show-diff-on-failure --color=always --all-files diff --git a/cmd/doc.go b/cmd/doc.go new file mode 100644 index 0000000..b7c3e01 --- /dev/null +++ b/cmd/doc.go @@ -0,0 +1,2 @@ +// Package cmd is our cobra cli implementation +package cmd diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..8dea8e8 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "runtime/debug" + "time" + + "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/koanf/v2" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +const ( + appName = "riverboat" + prettyFlag = "pretty" + debugFlag = "debug" +) + +var k *koanf.Koanf + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: appName, + Short: "A cli for interacting with the riverboat job queue server", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + err := initCmdFlags(cmd) + cobra.CheckErr(err) + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + cobra.CheckErr(rootCmd.Execute()) +} + +func init() { + k = koanf.New(".") // Create a new koanf instance. + + cobra.OnInitialize(initConfig) + + rootCmd.PersistentFlags().Bool(prettyFlag, false, "enable pretty (human readable) logging output") + rootCmd.PersistentFlags().Bool(debugFlag, false, "debug logging output") +} + +// initConfig reads in flags set for server startup +// all other configuration is done by the server with koanf +// refer to the README.md for more information +func initConfig() { + if err := initCmdFlags(rootCmd); err != nil { + log.Fatal().Err(err).Msg("error loading config") + } + + setupLogging() +} + +// initCmdFlags loads the flags from the command line into the koanf instance +func initCmdFlags(cmd *cobra.Command) error { + return k.Load(posflag.Provider(cmd.Flags(), k.Delim(), k), nil) +} + +func setupLogging() { + // setup logging with time and app name + log.Logger = zerolog.New(os.Stderr). + With().Timestamp(). + Logger(). + With().Str("app", appName). + Logger() + + // set the log level + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + // add additional information to the logger + buildInfo, _ := debug.ReadBuildInfo() + + log.Logger = log.Logger.With(). + Caller(). + Int("pid", os.Getpid()). + Str("go_version", buildInfo.GoVersion).Logger() + + // set the log level to debug if the debug flag is set and add additional information + if k.Bool(debugFlag) { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } + + // pretty logging for development + if k.Bool(prettyFlag) { + log.Logger = log.Output(zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: time.RFC3339, + FormatCaller: func(i interface{}) string { + return filepath.Base(fmt.Sprintf("%s", i)) + }, + }) + } +} diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..8590f64 --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/theopenlane/riverboat/internal/river" + "github.com/theopenlane/riverboat/internal/server/serveropts" +) + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "start the riverboat server", + Run: func(cmd *cobra.Command, args []string) { + err := serve(cmd.Context()) + cobra.CheckErr(err) + }, +} + +func init() { + rootCmd.AddCommand(serveCmd) + + serveCmd.PersistentFlags().String("config", "./config/.config.yaml", "config file location") +} + +func serve(ctx context.Context) error { + serverOpts := []serveropts.ServerOption{} + + so := serveropts.NewServerOptions(serverOpts, k.String("config")) + + // pass the logger options to the job queue + so.Config.Settings.JobQueue.Logger.Debug = k.Bool(debugFlag) + so.Config.Settings.JobQueue.Logger.Pretty = k.Bool(prettyFlag) + + return river.Start(ctx, so.Config.Settings.JobQueue) +} diff --git a/config/.env.example b/config/.env.example new file mode 100644 index 0000000..716200d --- /dev/null +++ b/config/.env.example @@ -0,0 +1,3 @@ +RIVERBOAT_REFRESHINTERVAL="10m" +RIVERBOAT_JOBQUEUE_DATABASEHOST="postgres://postgres:password@0.0.0.0:5432/jobs?sslmode=disable" +RIVERBOAT_JOBQUEUE_QUEUES="" diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..38e79f2 --- /dev/null +++ b/config/README.md @@ -0,0 +1,24 @@ +# Configuration + +You will need to perform a 1-time action of creating a `.config.yaml` file based +on the `.example` files. The Taskfiles will also source a `.dotenv` files which +match the naming conventions called for `{{.ENV}}` to ease the overriding of +environment variables. These files are intentionally added to the `.gitignore` +within this repository to prevent you from accidentally committing secrets or +other sensitive information which may live inside the server's environment +variables. + +All settings in the `yaml` configuration can also be overwritten with +environment variables prefixed with `RIVERBOAT_`. + +Configuration precedence is as follows, the latter overriding the former: + +1. `default` values set in the config struct within the code +1. `.config.yaml` values +1. Environment variables + +## Regenerating + +If you've made changes to the code in this code base (specifically interfaces +referenced in the `config.go`) and want to regenerate the configuration, run +`task config:generate` diff --git a/config/config.example.yaml b/config/config.example.yaml new file mode 100644 index 0000000..65cb312 --- /dev/null +++ b/config/config.example.yaml @@ -0,0 +1,10 @@ +jobQueue: + databaseHost: postgres://postgres:password@0.0.0.0:5432/jobs?sslmode=disable + queues: null + workers: + emailWorker: + devMode: true + fromEmail: no-reply@example.com + testDir: fixtures/email + token: "" +refreshInterval: 600000000000 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..cf900cc --- /dev/null +++ b/config/config.go @@ -0,0 +1,69 @@ +package config + +import ( + "fmt" + "strings" + "time" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" + "github.com/mcuadros/go-defaults" + + "github.com/theopenlane/riverboat/internal/river" +) + +var ( + DefaultConfigFilePath = "./config/.config.yaml" + envPrefix = "RIVERBOAT_" +) + +// Config contains the configuration for the server +type Config struct { + // RefreshInterval determines how often to reload the config + RefreshInterval time.Duration `json:"refreshInterval" koanf:"refreshInterval" default:"10m"` + // JobQueue is the configuration for the job queue + JobQueue river.Config `koanf:"jobQueue" json:"jobQueue"` +} + +// Load is responsible for loading the configuration from a YAML file and environment variables. +// If the `cfgFile` is empty or nil, it sets the default configuration file path. +// Config settings are taken from default values, then from the config file, and finally from environment +// the later overwriting the former. +func Load(cfgFile *string) (*Config, error) { + k := koanf.New(".") + + if cfgFile == nil || *cfgFile == "" { + *cfgFile = DefaultConfigFilePath + } + + // load defaults + conf := &Config{} + defaults.SetDefaults(conf) + + // parse yaml config + if err := k.Load(file.Provider(*cfgFile), yaml.Parser()); err != nil { + fmt.Println("failed to load config file", err) + } else { + // unmarshal the config + if err := k.Unmarshal("", &conf); err != nil { + panic(err) + } + } + + // load env vars + if err := k.Load(env.Provider(envPrefix, ".", func(s string) string { + return strings.ReplaceAll(strings.ToLower( + strings.TrimPrefix(s, envPrefix)), "_", ".") + }), nil); err != nil { + panic(err) + } + + // unmarshal the env vars + if err := k.Unmarshal("", &conf); err != nil { + panic(err) + } + + return conf, nil +} diff --git a/config/configmap.yaml b/config/configmap.yaml new file mode 100644 index 0000000..068d1b7 --- /dev/null +++ b/config/configmap.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "common.names.fullname" . -}}-config + {{ $labels := include "common.tplvalues.merge" (dict "values" ( list .Values.api.commonLabels (include "common.labels.standard" .) ) "context" . ) }} + labels: {{- include "common.tplvalues.render" ( dict "value" $labels "context" $) | nindent 4 }} + {{- if .Values.api.commonAnnotations }} + {{- $annotations := include "common.tplvalues.merge" ( dict "values" ( list .Values.api.commonAnnotations ) "context" . ) }} + annotations: {{- include "common.tplvalues.render" ( dict "value" $annotations "context" $) | nindent 4 }} + {{- end }} +data: + RIVERBOAT_REFRESHINTERVAL: {{ .Values.riverboat.refreshInterval | default "10m" }} + RIVERBOAT_JOBQUEUE_DATABASEHOST: {{ .Values.riverboat.jobQueue.databaseHost | default "postgres://postgres:password@0.0.0.0:5432/jobs?sslmode=disable" }} + RIVERBOAT_JOBQUEUE_QUEUES: {{ .Values.riverboat.jobQueue.queues }} diff --git a/config/doc.go b/config/doc.go new file mode 100644 index 0000000..6ccae48 --- /dev/null +++ b/config/doc.go @@ -0,0 +1,2 @@ +// Package config holds configuration stuff to configure the things +package config diff --git a/configgen/Taskfile.yaml b/configgen/Taskfile.yaml new file mode 100644 index 0000000..c82414f --- /dev/null +++ b/configgen/Taskfile.yaml @@ -0,0 +1,36 @@ +version: '3' + +tasks: + install: + desc: install dependencies + cmds: + - npm install jsonschema2mk --global + + generate: + desc: generate the jsonschema and documentation + cmds: + - task: schema + - task: docs + + schema: + desc: generate a new jsonschema and corresponding config/config.example.yaml + cmds: + - go run configgen/generator.go + + docs: + desc: generate documentation from the jsonschema + cmds: + - npx jsonschema2mk --schema configgen/riverboat.config.json > configgen/api-docs.md + + ci: + desc: a task that runs during CI to confirm there are no changes after running generate + cmds: + - task: generate + - "git config --global --add safe.directory /workdir" + - | + status=$(git status --porcelain) + if [ -n "$status" ]; then + echo "detected git diff after running generate; please re-run tasks" + echo "$status" + exit 1 + fi diff --git a/configgen/api-docs.md b/configgen/api-docs.md new file mode 100644 index 0000000..5234dfd --- /dev/null +++ b/configgen/api-docs.md @@ -0,0 +1,54 @@ +# object + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**refreshInterval**|`integer`||| +|[**jobQueue**](#jobqueue)|`object`|Config is the configuration for the river server
|| + +**Additional Properties:** not allowed + +## jobQueue: object + +Config is the configuration for the river server + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**databaseHost**|`string`|DatabaseHost for connecting to the postgres database
|| +|[**queues**](#jobqueuequeues)|`array`||| +|[**workers**](#jobqueueworkers)|`object`||| + +**Additional Properties:** not allowed + +### jobQueue\.queues: array + +**Items** + + +### jobQueue\.workers: object + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|[**emailWorker**](#jobqueueworkersemailworker)|`object`||| + +**Additional Properties:** not allowed + +#### jobQueue\.workers\.emailWorker: object + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**devMode**|`boolean`|enable dev mode
|| +|**testDir**|`string`|the directory to use for dev mode
|| +|**token**|`string`|the token to use for the email provider
|| +|**fromEmail**|`string`||| + +**Additional Properties:** not allowed + diff --git a/configgen/generator.go b/configgen/generator.go new file mode 100644 index 0000000..d63a60b --- /dev/null +++ b/configgen/generator.go @@ -0,0 +1,187 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "strings" + + "github.com/invopop/jsonschema" + "github.com/invopop/yaml" + "github.com/mcuadros/go-defaults" + "github.com/rs/zerolog/log" + + "github.com/theopenlane/utils/envparse" + + "github.com/theopenlane/riverboat/config" +) + +// appName is the name of the application +const appName = "riverboat" + +// const values used for the schema generator +const ( + koanfTagName = "koanf" + skipper = "-" + defaultTag = "default" + jsonSchemaPath = "./configgen/%s.config.json" + yamlConfigPath = "./config/config.example.yaml" + envConfigPath = "./config/.env.example" + configMapPath = "./config/configmap.yaml" + ownerReadWrite = 0600 + repoRoot = "github.com/theopenlane/%s/" +) + +// includedPackages is a list of packages to include in the schema generation +// that contain Go comments to be added to the schema +// any external packages must use the jsonschema description tags to add comments +var includedPackages = []string{ + "internal/river", +} + +// schemaConfig represents the configuration for the schema generator +type schemaConfig struct { + // jsonSchemaPath represents the file path of the JSON schema to be generated + jsonSchemaPath string + // yamlConfigPath is the file path to the YAML configuration to be generated + yamlConfigPath string + // envConfigPath is the file path to the environment variable configuration to be generated + envConfigPath string + // configMapPath is the file path to the kubernetes config map configuration to be generated + configMapPath string +} + +func main() { + c := schemaConfig{ + jsonSchemaPath: fmt.Sprintf(jsonSchemaPath, appName), + yamlConfigPath: yamlConfigPath, + envConfigPath: envConfigPath, + configMapPath: configMapPath, + } + + generateSchema(appName, c, &config.Config{}) +} + +// generateSchema generates a JSON schema and a YAML schema based on the provided schemaConfig and structure +func generateSchema(appName string, c schemaConfig, structure interface{}) { + // override the default name to using the prefixed pkg name + r := jsonschema.Reflector{ + Namer: namePkg, + ExpandedStruct: true, + RequiredFromJSONSchemaTags: true, + FieldNameTag: koanfTagName, + } + + // add go comments to the schema + for _, pkg := range includedPackages { + if err := r.AddGoComments(fmt.Sprintf(repoRoot, appName), pkg); err != nil { + log.Panic().Err(err).Msg("error adding go comments to schema") + } + } + + s := r.Reflect(structure) + + // generate the json schema + genJSONSchema(s, c) + + // generate yaml schema with default + genYAMLSchema(s, c) + + // generate environment variables + configMapSchema := genEnvVarSchema(s, c) + + // Get the configmap header + genConfigMapSchema(configMapSchema, c) +} + +func namePkg(r reflect.Type) string { + return r.String() +} + +func genJSONSchema(s interface{}, c schemaConfig) { + // generate the json schema + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + log.Panic().Err(err).Msg("error marshalling json schema") + } + + if err := os.WriteFile(c.jsonSchemaPath, data, ownerReadWrite); err != nil { + log.Panic().Err(err).Msg("error writing json schema") + } +} + +func genYAMLSchema(s interface{}, c schemaConfig) { + yamlConfig := &config.Config{} + defaults.SetDefaults(yamlConfig) + + // this uses the `json` tag to generate the yaml schema + yamlSchema, err := yaml.Marshal(yamlConfig) + if err != nil { + log.Panic().Err(err).Msg("error marshalling yaml schema") + } + + if err = os.WriteFile(c.yamlConfigPath, yamlSchema, ownerReadWrite); err != nil { + log.Panic().Err(err).Msg("error writing yaml schema") + } +} + +func genEnvVarSchema(s interface{}, c schemaConfig) string { + cp := envparse.Config{ + FieldTagName: koanfTagName, + Skipper: skipper, + } + + out, err := cp.GatherEnvInfo(strings.ToUpper(appName), &config.Config{}) + if err != nil { + log.Panic().Err(err).Msg("error gathering environment variables") + } + + // generate the environment variables from the config + envSchema := "" + configMapSchema := "\n" + + for _, k := range out { + defaultVal := k.Tags.Get(defaultTag) + + envSchema += fmt.Sprintf("%s=\"%s\"\n", k.Key, defaultVal) + + // if the default value is empty, use the value from the values.yaml + if defaultVal == "" { + configMapSchema += fmt.Sprintf(" %s: {{ .Values.%s }}\n", k.Key, k.FullPath) + } else { + switch k.Type.Kind() { + case reflect.String, reflect.Int64: + defaultVal = "\"" + defaultVal + "\"" // add quotes to the string + case reflect.Slice: + defaultVal = strings.Replace(defaultVal, "[", "", 1) + defaultVal = strings.Replace(defaultVal, "]", "", 1) + defaultVal = "\"" + defaultVal + "\"" // add quotes to the string + } + + configMapSchema += fmt.Sprintf(" %s: {{ .Values.%s | default %s }}\n", k.Key, k.FullPath, defaultVal) + } + } + + // write the environment variables to a file + if err = os.WriteFile(c.envConfigPath, []byte(envSchema), ownerReadWrite); err != nil { + log.Panic().Err(err).Msg("error writing environment variables to file") + } + + return configMapSchema +} + +func genConfigMapSchema(configMapSchema string, c schemaConfig) { + cm, err := os.ReadFile("./configgen/templates/configmap.tmpl") + if err != nil { + log.Panic().Err(err).Msg("error reading configmap template") + } + + // append the configmap schema to the header + cm = append(cm, []byte(configMapSchema)...) + + // write the configmap to a file + if err = os.WriteFile(c.configMapPath, cm, ownerReadWrite); err != nil { + log.Panic().Err(err).Msg("error writing configmap") + } +} diff --git a/configgen/riverboat.config.json b/configgen/riverboat.config.json new file mode 100644 index 0000000..e153f1f --- /dev/null +++ b/configgen/riverboat.config.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/theopenlane/riverboat/config/config.-config", + "$defs": { + "[]river.Queue": { + "items": { + "$ref": "#/$defs/river.Queue" + }, + "type": "array" + }, + "jobs.EmailWorker": { + "properties": { + "devMode": { + "type": "boolean", + "description": "enable dev mode" + }, + "testDir": { + "type": "string", + "description": "the directory to use for dev mode" + }, + "token": { + "type": "string", + "description": "the token to use for the email provider" + }, + "fromEmail": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "river.Config": { + "properties": { + "databaseHost": { + "type": "string", + "description": "DatabaseHost for connecting to the postgres database" + }, + "queues": { + "$ref": "#/$defs/[]river.Queue", + "description": "Queues to be enabled on the server, if not provided, a default queue is created" + }, + "workers": { + "$ref": "#/$defs/river.Workers", + "description": "Workers to be enabled on the server" + } + }, + "additionalProperties": false, + "type": "object", + "description": "Config is the configuration for the river server" + }, + "river.Queue": { + "properties": { + "name": { + "type": "string", + "description": "Name of the queue" + }, + "maxWorkers": { + "type": "integer", + "description": "MaxWorkers allotted for the queue" + } + }, + "additionalProperties": false, + "type": "object", + "description": "Queue is the configuration for a queue" + }, + "river.Workers": { + "properties": { + "emailWorker": { + "$ref": "#/$defs/jobs.EmailWorker" + } + }, + "additionalProperties": false, + "type": "object" + } + }, + "properties": { + "refreshInterval": { + "type": "integer" + }, + "jobQueue": { + "$ref": "#/$defs/river.Config" + } + }, + "additionalProperties": false, + "type": "object" +} \ No newline at end of file diff --git a/configgen/templates/configmap.tmpl b/configgen/templates/configmap.tmpl new file mode 100644 index 0000000..177a335 --- /dev/null +++ b/configgen/templates/configmap.tmpl @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "common.names.fullname" . -}}-config + {{ $labels := include "common.tplvalues.merge" (dict "values" ( list .Values.api.commonLabels (include "common.labels.standard" .) ) "context" . ) }} + labels: {{- include "common.tplvalues.render" ( dict "value" $labels "context" $) | nindent 4 }} + {{- if .Values.api.commonAnnotations }} + {{- $annotations := include "common.tplvalues.merge" ( dict "values" ( list .Values.api.commonAnnotations ) "context" . ) }} + annotations: {{- include "common.tplvalues.render" ( dict "value" $annotations "context" $) | nindent 4 }} + {{- end }} +data: \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..807fce9 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.23.1 AS builder + +WORKDIR /go/src/app +COPY . . + +RUN CGO_ENABLED=1 GOOS=linux go build -o /go/bin/riverboat -a -ldflags '-linkmode external -extldflags "-static"' . + +FROM gcr.io/distroless/static:nonroot + +# `nonroot` coming from distroless +USER 65532:65532 + +# Copy the binary +COPY --from=builder /go/bin/riverboat /bin/riverboat + +# Run the server on container startup +ENTRYPOINT [ "/bin/riverboat" ] +CMD ["serve"] diff --git a/docker/Taskfile.yaml b/docker/Taskfile.yaml new file mode 100644 index 0000000..4dd6daf --- /dev/null +++ b/docker/Taskfile.yaml @@ -0,0 +1,49 @@ +version: "3" + +tasks: + build: + dir: .. + desc: builds the riverboat docker image + cmds: + - "docker build -f docker/Dockerfile . -t riverboat:dev" + + riverboat:ui::up: + dir: .. + desc: runs the riverboat ui + aliases: ['ui:up'] + cmds: + - "docker compose -f ./docker/docker-compose-ui.yml -p riverboat-ui up -d" + + riverboat:ui:down: + dir: .. + aliases: ['ui:down'] + desc: brings the riverboat ui environment down + cmds: + - "docker compose -p riverboat-ui down" + + riverboat: + dir: .. + aliases: [up] + desc: brings up the compose environment for the riverboat server + deps: [build] + cmds: + - "docker compose -f ./docker/docker-compose-pg.yml -f ./docker/docker-compose.yml -p riverboat up -d" + + riverboat:down: + dir: .. + aliases: [down] + desc: brings the riverboat compose environment down + cmds: + - "docker compose -p riverboat down" + + postgres: + dir: .. + desc: brings up the compose environment for postgres development + cmds: + - "docker compose -f ./docker/docker-compose-pg.yml -p postgres up -d" + + postgres:down: + dir: .. + desc: brings the postgres compose environment down + cmds: + - docker compose -p postgres down diff --git a/docker/docker-compose-pg.yml b/docker/docker-compose-pg.yml new file mode 100644 index 0000000..fa124c8 --- /dev/null +++ b/docker/docker-compose-pg.yml @@ -0,0 +1,19 @@ +services: + postgres: + image: postgres:16 + container_name: postgres + command: postgres -c 'max_connections=150' + ports: + - "5432:5432" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + volumes: + - ./pg-init-scripts:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - default diff --git a/docker/docker-compose-ui.yml b/docker/docker-compose-ui.yml new file mode 100644 index 0000000..4199b77 --- /dev/null +++ b/docker/docker-compose-ui.yml @@ -0,0 +1,9 @@ +services: + ui: + image: ghcr.io/riverqueue/riverui:latest + ports: + - 8082:8080 + environment: + - DATABASE_URL=postgres://postgres:password@host.docker.internal:5432/jobs?sslmode=disable + networks: + - default diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..cbedfd0 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,14 @@ +services: + api: + image: riverboat:dev + depends_on: + - postgres + command: + - serve + - --debug + - --pretty + environment: + - RIVERBOAT_JOBQUEUE_DATABASEHOST=postgres://postgres:password@postgres:5432/jobs?sslmode=disable + restart: unless-stopped + networks: + - default diff --git a/docker/pg-init-scripts/init.sql b/docker/pg-init-scripts/init.sql new file mode 100644 index 0000000..1d46f1e --- /dev/null +++ b/docker/pg-init-scripts/init.sql @@ -0,0 +1,3 @@ +CREATE DATABASE jobs; + +GRANT ALL PRIVILEGES ON DATABASE jobs TO postgres; \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4bba6cf --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module github.com/theopenlane/riverboat + +go 1.23.1 + +require ( + github.com/invopop/jsonschema v0.12.0 + github.com/invopop/yaml v0.3.1 + github.com/jackc/pgx/v5 v5.7.1 + github.com/knadh/koanf/parsers/yaml v0.1.0 + github.com/knadh/koanf/providers/env v0.1.0 + github.com/knadh/koanf/providers/file v1.1.0 + github.com/knadh/koanf/providers/posflag v0.1.0 + github.com/knadh/koanf/v2 v2.1.1 + github.com/mcuadros/go-defaults v1.2.0 + github.com/riverqueue/river v0.11.4 + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.11.4 + github.com/rs/zerolog v1.33.0 + github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.9.0 + github.com/theopenlane/newman v0.1.1 + github.com/theopenlane/utils v0.2.1 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-viper/mapstructure/v2 v2.1.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/knadh/koanf/maps v0.1.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/resend/resend-go/v2 v2.11.0 // indirect + github.com/riverqueue/river/riverdriver v0.11.4 // indirect + github.com/riverqueue/river/rivershared v0.11.4 // indirect + github.com/riverqueue/river/rivertype v0.11.4 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + go.uber.org/goleak v1.3.0 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6258503 --- /dev/null +++ b/go.sum @@ -0,0 +1,143 @@ +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-viper/mapstructure/v2 v2.1.0 h1:gHnMa2Y/pIxElCH2GlZZ1lZSsn6XMtufpGyP1XxdC/w= +github.com/go-viper/mapstructure/v2 v2.1.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= +github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= +github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= +github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w= +github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY= +github.com/knadh/koanf/providers/env v0.1.0 h1:LqKteXqfOWyx5Ab9VfGHmjY9BvRXi+clwyZozgVRiKg= +github.com/knadh/koanf/providers/env v0.1.0/go.mod h1:RE8K9GbACJkeEnkl8L/Qcj8p4ZyPXZIQ191HJi44ZaQ= +github.com/knadh/koanf/providers/file v1.1.0 h1:MTjA+gRrVl1zqgetEAIaXHqYje0XSosxSiMD4/7kz0o= +github.com/knadh/koanf/providers/file v1.1.0/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI= +github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHYujwipe7Ie3qW6U= +github.com/knadh/koanf/providers/posflag v0.1.0/go.mod h1:SYg03v/t8ISBNrMBRMlojH8OsKowbkXV7giIbBVgbz0= +github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM= +github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mcuadros/go-defaults v1.2.0 h1:FODb8WSf0uGaY8elWJAkoLL0Ri6AlZ1bFlenk56oZtc= +github.com/mcuadros/go-defaults v1.2.0/go.mod h1:WEZtHEVIGYVDqkKSWBdWKUVdRyKlMfulPaGDWIVeCWY= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/resend/resend-go/v2 v2.11.0 h1:Ja5eXizUCbvyLgbiP8sFsJW/UN1b7d6IEUqi80IlgiU= +github.com/resend/resend-go/v2 v2.11.0/go.mod h1:ihnxc7wPpSgans8RV8d8dIF4hYWVsqMK5KxXAr9LIos= +github.com/riverqueue/river v0.11.4 h1:NMRsODhRgFztf080RMCjI377jldLXsx41E2r7+c0lPE= +github.com/riverqueue/river v0.11.4/go.mod h1:HvgBkqon7lYKm9Su4lVOnn1qx8Q4FnSMJjf5auVial4= +github.com/riverqueue/river/riverdriver v0.11.4 h1:kBg68vfTnRuSwsgcZ7UbKC4ocZ+KSCGnuZw/GwMMMP4= +github.com/riverqueue/river/riverdriver v0.11.4/go.mod h1:+NxTrldRYYsdTbZSxX7L2LuWU/B0IAtAActDJcNbcPs= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.11.4 h1:QBegZQrB59dafWaiNphJC85KTA0CmeGYcpCqu52qbnI= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.11.4/go.mod h1:CQC2a/+GRtN6b67IA7jFCvcCtOBWRz3lWqyNxDggKSM= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.11.4 h1:rRY8WabllXRsLp8U+gxUpYgTgI8dveF3UWnZJu965Lg= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.11.4/go.mod h1:GgWsTnC7V7lanQLyj8W1UuYuzyDoJZc4bhhDomtYr30= +github.com/riverqueue/river/rivershared v0.11.4 h1:XGfzJKG7hhwd0MwImF/4r+t6F9aq2Q7e6NNYifStnus= +github.com/riverqueue/river/rivershared v0.11.4/go.mod h1:vZc9tRvSZ9spLqcz9UUuKbZGuDRwBhS3LuzLY7d/jkw= +github.com/riverqueue/river/rivertype v0.11.4 h1:TAdi4CQEYukveYneAqm5LupRVZjvSfB8tL3xKR13wi4= +github.com/riverqueue/river/rivertype v0.11.4/go.mod h1:3WRQEDlLKZky/vGwFcZC3uKjC+/8izE6ucHwCsuir98= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/theopenlane/newman v0.1.1 h1:pxGPRcy8kXQplfv4Sp1N3XUkWmx/scZvp7oj+y2l8wI= +github.com/theopenlane/newman v0.1.1/go.mod h1:A37pInKEYsdvUmjQzTDv7x5T4KhMxoFW105DA3XvH4Y= +github.com/theopenlane/utils v0.2.1 h1:T6VfvOQDcAXBa1NFVL4QCsCbHvVQkp6Tl4hGJVd7TwQ= +github.com/theopenlane/utils v0.2.1/go.mod h1:ydEtwhmEvkVt3KKmNqiQiSY5b3rKH7U4umZ3QbFDsxU= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/river/config.go b/internal/river/config.go new file mode 100644 index 0000000..5cd5251 --- /dev/null +++ b/internal/river/config.go @@ -0,0 +1,38 @@ +package river + +import "github.com/theopenlane/riverboat/pkg/jobs" + +// Config is the configuration for the river server +type Config struct { + // Logger configuration, which is inherited from the core logger + Logger Logger `koanf:"-" json:"-"` + + // DatabaseHost for connecting to the postgres database + DatabaseHost string `koanf:"databaseHost" json:"databaseHost" default:"postgres://postgres:password@0.0.0.0:5432/jobs?sslmode=disable"` + // Queues to be enabled on the server, if not provided, a default queue is created + Queues []Queue `koanf:"queues" json:"queues" default:""` + // Workers to be enabled on the server + Workers Workers `koanf:"workers" json:"workers"` +} + +// Queue is the configuration for a queue +type Queue struct { + // Name of the queue + Name string `koanf:"name" json:"name" default:"default"` + // MaxWorkers allotted for the queue + MaxWorkers int `koanf:"maxWorkers" json:"maxWorkers" default:"100"` +} + +// Logger is the configuration for the logger used in the river server +type Logger struct { + // Debug enables debug logging + Debug bool `koanf:"-" json:"-"` + // Pretty enables pretty logging + Pretty bool `koanf:"-" json:"-"` +} + +type Workers struct { + EmailWorker jobs.EmailWorker `koanf:"emailWorker" json:"emailWorker"` + + // add more workers here +} diff --git a/internal/river/doc.go b/internal/river/doc.go new file mode 100644 index 0000000..c7113c2 --- /dev/null +++ b/internal/river/doc.go @@ -0,0 +1 @@ +package river diff --git a/internal/river/migrate.go b/internal/river/migrate.go new file mode 100644 index 0000000..c2dc7fa --- /dev/null +++ b/internal/river/migrate.go @@ -0,0 +1,23 @@ +package river + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivermigrate" +) + +// runMigrations runs the migrations for the river server +// see https://riverqueue.com/docs/migrations for more information +func runMigrations(ctx context.Context, dbPool *pgxpool.Pool) error { + // run migrations here + migrator := rivermigrate.New(riverpgxv5.New(dbPool), nil) + + if _, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil); err != nil { + return err + } + + return nil +} diff --git a/internal/river/river.go b/internal/river/river.go new file mode 100644 index 0000000..112d133 --- /dev/null +++ b/internal/river/river.go @@ -0,0 +1,178 @@ +package river + +import ( + "context" + "errors" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/rs/zerolog/log" +) + +const ( + defaultMaxWorkers = 100 +) + +// Start the river server with the given configuration +func Start(ctx context.Context, c Config) error { + // Create a new database connection pool + dbPool, err := pgxpool.New(ctx, c.DatabaseHost) + if err != nil { + log.Fatal().Err(err).Msg("failed to connect to database") + } + + // Run migrations on startup + if err := runMigrations(ctx, dbPool); err != nil { + log.Fatal().Err(err).Msg("failed to run migrations") + } + + // Create workers based on the configuration + worker, err := createWorkers(c.Workers) + if err != nil { + log.Fatal().Err(err).Msg("failed to create workers") + } + + log.Debug().Msg("workers created") + + // create queues + queues := createQueueConfig(c.Queues) + + log.Debug().Interface("queues", queues).Msg("queues created") + + // create a new river client + client, err := river.NewClient( + riverpgxv5.New(dbPool), + &river.Config{ + Workers: worker, + Queues: queues, + Logger: createLogger(c.Logger), + }, + ) + if err != nil { + log.Fatal().Err(err).Msg("failed to create river client") + } + + log.Info().Msg(startBlock) + + // run the client + if err := client.Start(ctx); err != nil { + log.Fatal().Err(err).Msg("failed to start river client") + } + + sigintOrTerm := make(chan os.Signal, 1) + signal.Notify(sigintOrTerm, syscall.SIGINT, syscall.SIGTERM) + + // this waits for SIGINT/SIGTERM and when received, tries to stop + // gracefully by allowing a chance for jobs to finish. But if that isn't + // working, a second SIGINT/SIGTERM will tell it to terminate with prejudice and + // it'll issue a hard stop that cancels the context of all active jobs. In + // case that doesn't work, a third SIGINT/SIGTERM ignores River's stop procedure + // completely and exits uncleanly. + go func() { + <-sigintOrTerm + log.Info().Msg("Received SIGINT/SIGTERM; initiating soft stop (try to wait for jobs to finish)") + + softStopCtx, softStopCtxCancel := context.WithTimeout(ctx, 10*time.Second) // nolint:mnd + defer softStopCtxCancel() + + go func() { + select { + case <-sigintOrTerm: + log.Info().Msg("Received SIGINT/SIGTERM again; initiating hard stop (cancel everything)") + softStopCtxCancel() + case <-softStopCtx.Done(): + log.Info().Msg("soft stop timeout; initiating hard stop (cancel everything)") + } + }() + + err := client.Stop(softStopCtx) + + if err != nil && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { + panic(err) + } + + if err == nil { + log.Info().Msg("soft stop succeeded") + + return + } + + hardStopCtx, hardStopCtxCancel := context.WithTimeout(ctx, 10*time.Second) // nolint:mnd + defer hardStopCtxCancel() + + // As long as all jobs respect context cancellation, StopAndCancel will + // always work. However, in the case of a bug where a job blocks despite + // being cancelled, it may be necessary to either ignore River's stop + // result (what's shown here) or have a supervisor kill the process. + err = client.StopAndCancel(hardStopCtx) + if err != nil && errors.Is(err, context.DeadlineExceeded) { + log.Info().Msg("hard stop timeout; ignoring stop procedure and exiting unsafely") + } else if err != nil { + log.Panic().Err(err).Msg("hard stop failed") + } + }() + <-client.Stopped() + + return nil +} + +// createLogger creates a new logger based on the configuration +func createLogger(c Logger) *slog.Logger { + level := slog.LevelInfo + + if c.Debug { + level = slog.LevelDebug + } + + // create a new pretty logger + opts := slog.HandlerOptions{ + Level: level, + } + + if c.Pretty { + return slog.New(slog.NewTextHandler(os.Stderr, &opts)) + } + + // create a new logger + return slog.New(slog.NewJSONHandler(os.Stderr, &opts)) +} + +// createQueueConfig creates a map of queue configurations +func createQueueConfig(queues []Queue) map[string]river.QueueConfig { + qc := map[string]river.QueueConfig{ + river.QueueDefault: {MaxWorkers: defaultMaxWorkers}, + } + + // if no queues are defined, just use the default queue + if len(queues) == 0 { + log.Debug().Msg("using default queues") + + return qc + } + + for _, q := range queues { + qc[q.Name] = river.QueueConfig{ + MaxWorkers: q.MaxWorkers, + } + } + + return qc +} + +var startBlock = ` + $$\ $$\ $$\ + \__| $$ | $$ | + $$$$$$\ $$\ $$\ $$\ $$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ +$$ __$$\ $$ |\$$\ $$ |$$ __$$\ $$ __$$\ $$ __$$\ $$ __$$\ \____$$\\_$$ _| +$$ | \__|$$ | \$$\$$ / $$$$$$$$ |$$ | \__|$$ | $$ |$$ / $$ | $$$$$$$ | $$ | +$$ | $$ | \$$$ / $$ ____|$$ | $$ | $$ |$$ | $$ |$$ __$$ | $$ |$$\ +$$ | $$ | \$ / \$$$$$$$\ $$ | $$$$$$$ |\$$$$$$ |\$$$$$$$ | \$$$$ | +\__| \__| \_/ \_______|\__| \_______/ \______/ \_______| \____/ + +` diff --git a/internal/river/river_test.go b/internal/river/river_test.go new file mode 100644 index 0000000..78bf49a --- /dev/null +++ b/internal/river/river_test.go @@ -0,0 +1,103 @@ +package river + +import ( + "context" + "testing" + + "log/slog" + + "github.com/riverqueue/river" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateLogger(t *testing.T) { + tests := []struct { + name string + config Logger + }{ + { + name: "Default Logger", + config: Logger{ + Debug: false, + Pretty: false, + }, + }, + { + name: "Debug Logger", + config: Logger{ + Debug: true, + Pretty: false, + }, + }, + { + name: "Pretty Logger", + config: Logger{ + Debug: false, + Pretty: true, + }, + }, + { + name: "Debug Pretty Logger", + config: Logger{ + Debug: true, + Pretty: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + logger := createLogger(tt.config) + + require.NotNil(t, logger, "Logger should not be nil") + + handler := logger.Handler() + require.NotNil(t, handler, "Logger handler should not be nil") + + assert.Equal(t, handler.Enabled(ctx, slog.LevelDebug), tt.config.Debug) + + if tt.config.Pretty { + assert.IsType(t, &slog.TextHandler{}, handler, "Logger handler should be TextHandler") + } else { + assert.IsType(t, &slog.JSONHandler{}, handler, "Logger handler should be JSONHandler") + } + }) + } +} + +func TestCreateQueueConfig(t *testing.T) { + tests := []struct { + name string + queues []Queue + expect map[string]river.QueueConfig + }{ + { + name: "Default Queue", + queues: []Queue{}, + expect: map[string]river.QueueConfig{ + river.QueueDefault: {MaxWorkers: defaultMaxWorkers}, + }, + }, + { + name: "Custom Queues", + queues: []Queue{ + {Name: "queue1", MaxWorkers: 10}, + {Name: "queue2", MaxWorkers: 20}, + }, + expect: map[string]river.QueueConfig{ + river.QueueDefault: {MaxWorkers: defaultMaxWorkers}, + "queue1": {MaxWorkers: 10}, + "queue2": {MaxWorkers: 20}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + qc := createQueueConfig(tt.queues) + assert.Equal(t, tt.expect, qc) + }) + } +} diff --git a/internal/river/workers.go b/internal/river/workers.go new file mode 100644 index 0000000..974a185 --- /dev/null +++ b/internal/river/workers.go @@ -0,0 +1,23 @@ +package river + +import ( + "github.com/riverqueue/river" + "github.com/theopenlane/riverboat/pkg/jobs" +) + +// createWorkers creates a new workers instance +func createWorkers(c Workers) (*river.Workers, error) { + // create workers + workers := river.NewWorkers() + + if err := river.AddWorkerSafely(workers, &jobs.EmailWorker{ + EmailConfig: c.EmailWorker.EmailConfig, + }, + ); err != nil { + return nil, err + } + + // add more workers here + + return workers, nil +} diff --git a/internal/server/config/config.go b/internal/server/config/config.go new file mode 100644 index 0000000..124360d --- /dev/null +++ b/internal/server/config/config.go @@ -0,0 +1,19 @@ +package config + +import ( + "github.com/theopenlane/riverboat/config" +) + +// Config is the configuration for the http server +type Config struct { + // add all the configuration settings for the server + Settings config.Config +} + +// Ensure that *Config implements ConfigProvider interface. +var _ ConfigProvider = &Config{} + +// GetConfig implements ConfigProvider. +func (c *Config) GetConfig() (*Config, error) { + return c, nil +} diff --git a/internal/server/config/configprovider.go b/internal/server/config/configprovider.go new file mode 100644 index 0000000..3d63f69 --- /dev/null +++ b/internal/server/config/configprovider.go @@ -0,0 +1,7 @@ +package config + +// ConfigProvider serves as a common interface to read echo server configuration +type ConfigProvider interface { + // GetConfig returns the server configuration + GetConfig() (*Config, error) +} diff --git a/internal/server/config/configproviderrefresh.go b/internal/server/config/configproviderrefresh.go new file mode 100644 index 0000000..a66e2c7 --- /dev/null +++ b/internal/server/config/configproviderrefresh.go @@ -0,0 +1,94 @@ +package config + +import ( + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +// ConfigProviderWithRefresh shows a config provider with automatic refresh; it contains fields and methods to manage the configuration, +// and refresh it periodically based on a specified interval +type ConfigProviderWithRefresh struct { + sync.RWMutex + + config *Config + + configProvider ConfigProvider + + refreshInterval time.Duration + + ticker *time.Ticker + stop chan bool +} + +// NewConfigProviderWithRefresh function is a constructor function that creates a new instance of ConfigProviderWithRefresh +func NewConfigProviderWithRefresh(cfgProvider ConfigProvider) (*ConfigProviderWithRefresh, error) { + cfg, err := cfgProvider.GetConfig() + if err != nil { + return nil, err + } + + cfgRefresh := &ConfigProviderWithRefresh{ + config: cfg, + configProvider: cfgProvider, + refreshInterval: cfg.Settings.RefreshInterval, + } + cfgRefresh.initialize() + + return cfgRefresh, nil +} + +// GetConfig retrieves the current echo server configuration; it acquires a read lock to ensure thread safety and returns the `config` field +func (s *ConfigProviderWithRefresh) GetConfig() (*Config, error) { + s.RLock() + defer s.RUnlock() + + return s.config, nil +} + +// initialize the config provider with refresh +func (s *ConfigProviderWithRefresh) initialize() { + if s.refreshInterval != 0 { + s.stop = make(chan bool) + s.ticker = time.NewTicker(s.refreshInterval) + + go s.refreshConfig() + } +} + +func (s *ConfigProviderWithRefresh) refreshConfig() { + for { + select { + case <-s.stop: + break + case <-s.ticker.C: + } + + newConfig, err := s.configProvider.GetConfig() + if err != nil { + log.Error().Msg("failed to load new server configuration") + continue + } + + log.Info().Msg("loaded new server configuration") + + s.Lock() + s.config = newConfig + s.Unlock() + } +} + +// Close function is used to stop the automatic refresh of the configuration. +// It stops the ticker that triggers the refresh and closes the stop channel, +// which signals the goroutine to stop refreshing the configuration +func (s *ConfigProviderWithRefresh) Close() { + if s.ticker != nil { + s.ticker.Stop() + } + + if s.stop != nil { + s.stop <- true + close(s.stop) + } +} diff --git a/internal/server/config/doc.go b/internal/server/config/doc.go new file mode 100644 index 0000000..8fbd4cf --- /dev/null +++ b/internal/server/config/doc.go @@ -0,0 +1,2 @@ +// Package config holds the server configuration utilities +package config diff --git a/internal/server/serveropts/doc.go b/internal/server/serveropts/doc.go new file mode 100644 index 0000000..116a06a --- /dev/null +++ b/internal/server/serveropts/doc.go @@ -0,0 +1,2 @@ +// Package serveropts contains an echo server options wrapper +package serveropts diff --git a/internal/server/serveropts/option.go b/internal/server/serveropts/option.go new file mode 100644 index 0000000..486ede3 --- /dev/null +++ b/internal/server/serveropts/option.go @@ -0,0 +1,30 @@ +package serveropts + +import ( + "github.com/theopenlane/riverboat/internal/server/config" +) + +type ServerOption interface { + apply(*ServerOptions) +} + +type applyFunc struct { + applyInternal func(*ServerOptions) +} + +func (fso *applyFunc) apply(s *ServerOptions) { + fso.applyInternal(s) +} + +func newApplyFunc(apply func(option *ServerOptions)) *applyFunc { + return &applyFunc{ + applyInternal: apply, + } +} + +// WithConfigProvider supplies the config for the server +func WithConfigProvider(cfgProvider config.ConfigProvider) ServerOption { + return newApplyFunc(func(s *ServerOptions) { + s.ConfigProvider = cfgProvider + }) +} diff --git a/internal/server/serveropts/server.go b/internal/server/serveropts/server.go new file mode 100644 index 0000000..c2f9bce --- /dev/null +++ b/internal/server/serveropts/server.go @@ -0,0 +1,37 @@ +package serveropts + +import ( + "github.com/theopenlane/riverboat/config" + serverconfig "github.com/theopenlane/riverboat/internal/server/config" +) + +type ServerOptions struct { + ConfigProvider serverconfig.ConfigProvider + Config serverconfig.Config +} + +func NewServerOptions(opts []ServerOption, cfgLoc string) *ServerOptions { + // load koanf config + c, err := config.Load(&cfgLoc) + if err != nil { + panic(err) + } + + so := &ServerOptions{ + Config: serverconfig.Config{ + Settings: *c, + }, + } + + for _, opt := range opts { + opt.apply(so) + } + + return so +} + +// AddServerOptions applies a server option after the initial setup +// this should be used when information is not available on NewServerOptions +func (so *ServerOptions) AddServerOptions(opt ServerOption) { + opt.apply(so) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3e85391 --- /dev/null +++ b/main.go @@ -0,0 +1,10 @@ +/* +Copyright © 2024 theopenlane, Inc. +*/ +package main + +import "github.com/theopenlane/riverboat/cmd" + +func main() { + cmd.Execute() +} diff --git a/pkg/jobs/doc.go b/pkg/jobs/doc.go new file mode 100644 index 0000000..e34b308 --- /dev/null +++ b/pkg/jobs/doc.go @@ -0,0 +1,2 @@ +// package jobs contains the jobs that can be used in the river server +package jobs diff --git a/pkg/jobs/email.go b/pkg/jobs/email.go new file mode 100644 index 0000000..22d85b1 --- /dev/null +++ b/pkg/jobs/email.go @@ -0,0 +1,88 @@ +package jobs + +import ( + "context" + + "github.com/riverqueue/river" + "github.com/rs/zerolog/log" + "github.com/theopenlane/newman" + "github.com/theopenlane/newman/providers/resend" +) + +// EmailArgs for the email worker to process the job +type EmailArgs struct { + // Message is the email message to send + Message newman.EmailMessage `json:"message"` +} + +// Kind satisfies the river.Job interface +func (EmailArgs) Kind() string { return "email" } + +// EmailWorker is a worker to send emails using the resend email provider +// the config defaults to dev mode, which will write the email to a file using the mock provider +// a token is required to send emails using the actual resend provider +type EmailWorker struct { + river.WorkerDefaults[EmailArgs] + + EmailConfig +} + +// EmailConfig contains the configuration for the email worker +type EmailConfig struct { + // DevMode is a flag to enable dev mode + DevMode bool `koanf:"devMode" json:"devMode" jsonschema:"description=enable dev mode" default:"true"` + // TestDir is the directory to use for dev mode + TestDir string `koanf:"testDir" json:"testDir" jsonschema:"description=the directory to use for dev mode" default:"fixtures/email"` + // Token is the token to use for the email provider + Token string `koanf:"token" json:"token" jsonschema:"description=the token to use for the email provider"` + // FromEmail is the email address to use as the sender + FromEmail string `koanf:"fromEmail" json:"fromEmail" jsonschema:"required description=the email address to use as the sender" default:"no-reply@example.com"` +} + +// validateEmailConfig validates the email configuration settings +func (w *EmailWorker) validateEmailConfig() error { + if w.DevMode && w.TestDir == "" { + return ErrMissingTestDir + } + + if !w.DevMode && w.Token == "" { + return ErrMissingToken + } + + return nil +} + +// Work satisfies the river.Worker interface for the email worker +// it sends an email using the resend email provider with the provided email message +func (w *EmailWorker) Work(ctx context.Context, job *river.Job[EmailArgs]) error { + // validate the email configuration + if err := w.validateEmailConfig(); err != nil { + return err + } + + log.Info().Strs("to", job.Args.Message.To). + Str("subject", job.Args.Message.Subject). + Msg("sending email") + + // set the options for the resend client + opts := []resend.Option{} + + if w.DevMode { + log.Debug().Str("directory", w.TestDir).Msg("running in dev mode") + + opts = append(opts, resend.WithDevMode(w.TestDir)) + } + + // if the from email is not set on the message, use the default from the worker config + if job.Args.Message.From == "" { + job.Args.Message.From = w.FromEmail + } + + // create the resend client + client, err := resend.New(w.Token, opts...) + if err != nil { + return err + } + + return client.SendEmailWithContext(ctx, &job.Args.Message) +} diff --git a/pkg/jobs/email_test.go b/pkg/jobs/email_test.go new file mode 100644 index 0000000..6c1cf1e --- /dev/null +++ b/pkg/jobs/email_test.go @@ -0,0 +1,106 @@ +package jobs_test + +import ( + "context" + "testing" + + "github.com/riverqueue/river" + "github.com/stretchr/testify/require" + "github.com/theopenlane/newman" + + "github.com/theopenlane/riverboat/pkg/jobs" +) + +func (suite *TestSuite) TestEmailWorker() { + t := suite.T() + + emailWithFrom := newman.NewEmailMessageWithOptions( + newman.WithTo([]string{"ted@mosby.com"}), + newman.WithFrom("robin@scherbatsky.com"), + ) + + emailWithoutFrom := newman.NewEmailMessageWithOptions( + newman.WithTo([]string{"ted@mosby.com"}), + ) + + testCases := []struct { + name string + worker *jobs.EmailWorker + msg *newman.EmailMessage + expectedError string + }{ + { + name: "happy path, dev mode", + worker: &jobs.EmailWorker{ + EmailConfig: jobs.EmailConfig{ + DevMode: true, + TestDir: "test", + FromEmail: "robin@scherbatsky.net", + }, + }, + msg: emailWithoutFrom, + }, + { + name: "missing test directory", + worker: &jobs.EmailWorker{ + EmailConfig: jobs.EmailConfig{ + DevMode: true, + FromEmail: "robin@scherbatsky.net", + }, + }, + msg: emailWithoutFrom, + expectedError: jobs.ErrMissingTestDir.Error(), + }, + { + name: "missing from email", + worker: &jobs.EmailWorker{ + EmailConfig: jobs.EmailConfig{ + DevMode: true, + TestDir: "test", + }, + }, + msg: emailWithoutFrom, + expectedError: "from is required", + }, + { + name: "happy path, missing from email but in message", + worker: &jobs.EmailWorker{ + EmailConfig: jobs.EmailConfig{ + DevMode: true, + TestDir: "test", + }, + }, + msg: emailWithFrom, + }, + { + name: "missing token", + worker: &jobs.EmailWorker{ + EmailConfig: jobs.EmailConfig{ + DevMode: false, + FromEmail: "robin@scherbatsky.net", + }, + }, + msg: emailWithoutFrom, + expectedError: jobs.ErrMissingToken.Error(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + err := (tc.worker).Work(ctx, &river.Job[jobs.EmailArgs]{Args: jobs.EmailArgs{ + Message: *tc.msg, + }}) + + if tc.expectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedError) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/pkg/jobs/errors.go b/pkg/jobs/errors.go new file mode 100644 index 0000000..fd5b2f0 --- /dev/null +++ b/pkg/jobs/errors.go @@ -0,0 +1,10 @@ +package jobs + +import "errors" + +var ( + // ErrMissingTestDir is the error for missing test directory + ErrMissingTestDir = errors.New("missing test directory in email config") + // ErrMissingToken is the error for missing token + ErrMissingToken = errors.New("missing resend api token, set token or use dev mode") +) diff --git a/pkg/jobs/tools_test.go b/pkg/jobs/tools_test.go new file mode 100644 index 0000000..60c72f6 --- /dev/null +++ b/pkg/jobs/tools_test.go @@ -0,0 +1,31 @@ +package jobs_test + +import ( + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/suite" +) + +// TestGraphTestSuite runs all the tests in the GraphTestSuite +func TestTestSuite(t *testing.T) { + suite.Run(t, new(TestSuite)) +} + +// TestSuite handles the setup and teardown between tests +type TestSuite struct { + suite.Suite +} + +func (suite *TestSuite) SetupSuite() { + zerolog.SetGlobalLevel(zerolog.Disabled) +} + +func (suite *TestSuite) TearDownSuite() { +} + +func (suite *TestSuite) SetupTest() { +} + +func (suite *TestSuite) TearDownTest() { +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..ec944db --- /dev/null +++ b/renovate.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "config:base" + ], + "postUpdateOptions": [ + "gomodTidy" + ], + "labels": [ + "dependencies" + ] +} \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..6b6e3c3 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,17 @@ +sonar.projectKey=theopenlane_riverboat +sonar.organization=theopenlane + +sonar.projectName=riverboat +sonar.projectVersion=1.0 + +sonar.sources=. + +sonar.exclusions=**/*_test.go,test/**,docker/** +sonar.exclusions=**/*_test.go,**/vendor/**,docker/**,test/** +sonar.tests=. +sonar.test.inclusions=**/*_test.go +sonar.test.exclusions=**/vendor/** + +sonar.sourceEncoding=UTF-8 +sonar.go.coverage.reportPaths=coverage.out +sonar.externalIssuesReportPaths=results.txt \ No newline at end of file diff --git a/test/common/river.go b/test/common/river.go new file mode 100644 index 0000000..6c51448 --- /dev/null +++ b/test/common/river.go @@ -0,0 +1,29 @@ +package common + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/rs/zerolog/log" +) + +const ( + devDatabaseHost = "postgres://postgres:password@0.0.0.0:5432/jobs?sslmode=disable" +) + +func NewInsertOnlyRiverClient() *river.Client[pgx.Tx] { + dbPool, err := pgxpool.New(context.Background(), devDatabaseHost) + if err != nil { + log.Fatal().Err(err).Msg("error creating job queue database connection") + } + + client, err := river.NewClient(riverpgxv5.New(dbPool), &river.Config{}) + if err != nil { + log.Fatal().Err(err).Msg("error creating river client") + } + + return client +} diff --git a/test/email/main.go b/test/email/main.go new file mode 100644 index 0000000..69654a4 --- /dev/null +++ b/test/email/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + + "github.com/rs/zerolog/log" + "github.com/theopenlane/newman" + + "github.com/theopenlane/riverboat/test/common" + + "github.com/theopenlane/riverboat/pkg/jobs" +) + +// the main function here will insert an email job into the river +// this will be picked up by the river server and processed +// assuming the server is run in the default setup, the email will be written to a file (fixtures/email) +func main() { + client := common.NewInsertOnlyRiverClient() + + msg := newman.NewEmailMessageWithOptions( + newman.WithSubject("test subject"), + newman.WithText("body"), + newman.WithTo([]string{"meowfunk@example.com"}), + ) + + _, err := client.Insert(context.Background(), jobs.EmailArgs{ + Message: *msg, + }, nil) + if err != nil { + log.Fatal().Err(err).Msg("error inserting email job") + } + + log.Info().Msg("email job successfully inserted") +}