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")
+}