diff --git a/.env.example b/.env.example index ba9bc13..9993540 100644 --- a/.env.example +++ b/.env.example @@ -1,29 +1,53 @@ ########################################################################## -# Plumber configuration file # +# Plumber Platform v2 configuration file # # Documentation: https://getplumber.io/docs/installation/docker-compose/ # ########################################################################## +# +# Copy to .env (or let ./install.sh write it). PLATFORM_VERSION is NOT set +# here: it lives in versions.env and is synced into .env by install.sh and +# scripts/update.sh, so one variable pins both images (backend + frontend). # Required configuration DOMAIN_NAME="" -JOBS_GITLAB_URL="" -ORGANIZATION="" -GITLAB_OAUTH2_CLIENT_ID="" -GITLAB_OAUTH2_CLIENT_SECRET="" +GITLAB_URL="" # Auto-generated secrets (populated by install script) -SECRET_KEY="" -JOBS_DB_PASSWORD="" -JOBS_REDIS_PASSWORD="" +# PLUMBER_TOKEN_ENCRYPTION_KEY must be exactly 64 hex chars (openssl rand -hex 32). +# It seals every GitLab OAuth secret and token at rest; back it up, it cannot +# be rotated in place. +PLUMBER_TOKEN_ENCRYPTION_KEY="" +PLUMBER_DB_PASSWORD="" +PLUMBER_REDIS_PASSWORD="" # Deployment profile (set by install script) # Options: letsencrypt or custom-certs, combined with internal-db or omit for external COMPOSE_PROFILES="letsencrypt,internal-db" CERT_RESOLVER="le" +# Optional: GitLab instances allowed to push analysis results (comma-separated +# base URLs). Defaults to GITLAB_URL. +# PLUMBER_OIDC_ALLOWED_ISSUERS="https://gitlab.example.com" + # Optional: Override database defaults (only needed when NOT using internal-db profile) -# JOBS_DB_HOST="your-db-host" -# JOBS_DB_PORT="5432" -# JOBS_DB_USER="jobs" -# JOBS_DB_NAME="jobs" -# JOBS_DB_SSLMODE="disable" -# JOBS_DB_TIMEZONE="Europe/Paris" +# PLUMBER_DB_HOST="your-db-host" +# PLUMBER_DB_PORT="5432" +# PLUMBER_DB_USER="plumber" +# PLUMBER_DB_NAME="plumber" +# PLUMBER_DB_SSLMODE="disable" # disable|allow|prefer|require|verify-ca|verify-full +# PLUMBER_DB_SSLROOTCERT="" # PEM path inside the container (mount it yourself) +# PLUMBER_DB_TIMEZONE="UTC" + +# Optional: private CA for your GitLab instance. Drop .pem/.crt files into +# .docker/ca-certificates/ and run scripts/ca-bundle.sh (install.sh and +# scripts/update.sh do it for you); it sets this line when needed. +# PLUMBER_PROVIDER_CA_BUNDLE="/usr/local/share/ca-certificates/plumber-ca.bundle" + +# Optional: air-gapped install, disable the daily update check entirely +# PLUMBER_UPDATE_CHECK_URL="off" + +# Optional tuning +# PLUMBER_LOG_LEVEL="info" # debug|info|warn|error +# PLUMBER_LOG_FORMAT="json" # json|text +# PLUMBER_SESSION_TTL="168h" +# PLUMBER_RETENTION_DAYS="30" # floor 2 +# PLUMBER_TRUSTED_PROXY_CIDRS="" # only if a proxy hop is outside private ranges diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de94a67..7c735b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,67 +10,91 @@ on: workflow_dispatch: permissions: - contents: write + contents: read + checks: write concurrency: group: ${{ github.ref }}-${{ github.workflow }} cancel-in-progress: true jobs: - code-analysis: - name: Code analysis + charts-lint: + name: Charts lint (v1 + v2) runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3.1.0 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install Helm - uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5 + uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 with: - version: 3.10.1 + version: v3.21.4 - name: Install Python - uses: actions/setup-python@v4.3.0 + uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: "3.12" - name: Install chart-testing - uses: helm/chart-testing-action@afea100a513515fbd68b0e72a7bb0ae34cb62aec # v2.3.1 + uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0 - name: Add dependency chart repositories - run: ./scripts/add_helm_repo.sh - - name: List changed charts - id: list-changed + run: ./legacy-v1/scripts/add_helm_repo.sh + # The chart version is owned by the release PR and cross-checked by publish-release.yml, so ct must not demand a bump on every chart change. + - name: Lint changed charts env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - changed=$(ct list-changed --target-branch "$DEFAULT_BRANCH") - charts=$(echo "$changed" | tr '\n' ' ' | xargs) - if [[ -n "$changed" ]]; then - echo "changed=true" >> $GITHUB_OUTPUT - echo "changed_charts=$charts" >> $GITHUB_OUTPUT - fi - - name: Lint charts - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: ct lint --target-branch "$DEFAULT_BRANCH" - unit-tests: - name: Unit tests + run: ct lint --target-branch "$DEFAULT_BRANCH" --chart-dirs legacy-v1/charts,charts --validate-maintainers=false --check-version-increment=false + + charts-unit-tests: + name: Charts unit tests (v1 + v2) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: d3adb5/helm-unittest-action@850bc76597579183998069830d5fa8c3ef0ea34a # 2.5.0 with: - charts: charts/plumber + charts: legacy-v1/charts/plumber charts/plumber-platform flags: --color -o test-results.xml --output-type JUnit - helm-version: v3.10.1 - # helm-unittest v1.1.0 (2026-05-08) switched plugin.yaml to the - # platformHooks/platformCommand schema, which Helm 3.10.1 cannot - # parse. Pin to the last v1.x release using the legacy schema. + helm-version: v3.21.4 + # helm-unittest v1.1.0+ switched plugin.yaml to a schema older Helm + # versions cannot parse; v1.0.3 is the last release on the legacy schema. unittest-version: v1.0.3 - name: Test Report uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 # v2.1.1 - if: ${{ !cancelled() }} # run this step even if previous step failed + if: ${{ !cancelled() }} with: - name: Helm Unit Tests # Name of the check run which will be created - path: test-results.xml # Path to test results - reporter: java-junit # Format of test results + name: Helm Unit Tests + path: test-results.xml + reporter: java-junit + chart-v2-schema: + name: Chart v2 lint + kubeconform + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Helm + uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + with: + version: v3.21.4 + - name: Install helm-unittest and kubeconform + run: | + set -euo pipefail + helm plugin install https://github.com/helm-unittest/helm-unittest --version v1.0.3 + KC=v0.7.0 + curl -sSL -o kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KC/kubeconform-linux-amd64.tar.gz" + tar xzf kubeconform.tgz kubeconform + sudo mv kubeconform /usr/local/bin/ + - run: scripts/ci/chart-check.sh + + compose: + name: Compose config + scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: scripts/ci/compose-check.sh + - run: scripts/ci/scripts-check.sh + + actionlint: + name: Workflow lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7 -color diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..8b2d909 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,120 @@ +# The platform-repo half of a release. The monorepo's release workflow opens a PR +# here (branch release-vX.Y.Z) that bumps, in ONE commit: versions.env +# (PLATFORM_VERSION), charts/plumber-platform/Chart.yaml (version + appVersion), +# charts/plumber-platform/values.yaml (both image tags), latest.json and +# releases/vX.Y.Z.md. Thomas merges it: that merge is the release approval. +# This workflow reacts to that merge and publishes what customers consume: +# the git tag, the GitHub Release (notes + packaged chart) and the Helm repo +# index on gh-pages (charts.getplumber.io). It never opens or approves anything. +# +# Narrow trigger (paths: latest.json) so an unrelated push to main never fires it. +name: publish release + +on: + push: + branches: [main] + paths: [latest.json] + +# Shared with the v1 chart-releaser workflow: both push gh-pages. +concurrency: {group: charts-gh-pages, cancel-in-progress: false} + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: {fetch-depth: 0} + + - uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + with: {version: v3.21.4} + + - name: resolve and cross-check the version + id: ver + run: | + set -euo pipefail + V=$(jq -r '.latest' latest.json) + if [ -z "$V" ] || [ "$V" = "null" ]; then + echo "::error::latest.json has no .latest field"; exit 1 + fi + if ! [[ "$V" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::latest.json's .latest ('$V') is not a vX.Y.Z version"; exit 1 + fi + X="${V#v}" + NOTES="releases/$V.md" + [ -f "$NOTES" ] || { echo "::error::$NOTES not found - the release PR must add it"; exit 1; } + PIN=$(sed -n 's/^PLATFORM_VERSION=//p' versions.env) + [ "$PIN" = "$V" ] || { echo "::error::versions.env pins '$PIN', latest.json says '$V'"; exit 1; } + CV=$(yq -r '.version' charts/plumber-platform/Chart.yaml) + CA=$(yq -r '.appVersion' charts/plumber-platform/Chart.yaml) + if [ "$CV" != "$X" ] || [ "$CA" != "$X" ]; then + echo "::error::Chart.yaml is version '$CV' / appVersion '$CA', want '$X'"; exit 1 + fi + FT=$(yq -r '.front.tag' charts/plumber-platform/values.yaml) + BT=$(yq -r '.backend.tag' charts/plumber-platform/values.yaml) + if [ "$FT" != "$V" ] || [ "$BT" != "$V" ]; then + echo "::error::values.yaml tags are front '$FT' / backend '$BT', want '$V'"; exit 1 + fi + { + echo "version=$V" + echo "chart_version=$X" + echo "notes=$NOTES" + } >> "$GITHUB_OUTPUT" + + - name: tag + github release (idempotent) + env: + GH_TOKEN: ${{ github.token }} + V: ${{ steps.ver.outputs.version }} + NOTES: ${{ steps.ver.outputs.notes }} + run: | + set -euo pipefail + git config user.name "plumber-release-bot" + git config user.email "release@getplumber.io" + if git rev-parse -q --verify "refs/tags/$V" >/dev/null; then + echo "tag $V already exists - keeping it" + else + git tag "$V" + git push origin "$V" + fi + if gh release view "$V" >/dev/null 2>&1; then + echo "release $V already exists - keeping it" + else + gh release create "$V" --title "$V" --notes-file "$NOTES" + fi + + - name: package the chart and attach it to the release + env: + GH_TOKEN: ${{ github.token }} + V: ${{ steps.ver.outputs.version }} + X: ${{ steps.ver.outputs.chart_version }} + run: | + set -euo pipefail + mkdir -p .cr-release-packages + helm package charts/plumber-platform -d .cr-release-packages + test -f ".cr-release-packages/plumber-platform-$X.tgz" + gh release upload "$V" ".cr-release-packages/plumber-platform-$X.tgz" --clobber + + - name: update the helm repo index on gh-pages + env: + V: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + git worktree add /tmp/gh-pages origin/gh-pages + # helm repo index merges the new package entry (download URL = the release asset) + # into the existing index, which also lists the v1 `plumber` chart. + helm repo index .cr-release-packages \ + --url "https://github.com/${GITHUB_REPOSITORY}/releases/download/$V" \ + --merge /tmp/gh-pages/index.yaml + cp .cr-release-packages/index.yaml /tmp/gh-pages/index.yaml + cd /tmp/gh-pages + [ -f CNAME ] || echo "charts.getplumber.io" > CNAME + git add index.yaml CNAME + if git diff --cached --quiet; then + echo "index.yaml unchanged - nothing to push" + else + git commit -m "Update index.yaml for plumber-platform $V" + git push origin HEAD:gh-pages + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c858ad3..8e4d457 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,16 @@ -name: Release +# v1 chart only (legacy-v1/charts/plumber, versions 1.x). The v2 chart plumber-platform is published by publish-release.yml. +name: release v1 chart on: push: branches: - main + paths: + - "legacy-v1/charts/**" workflow_dispatch: +concurrency: {group: charts-gh-pages, cancel-in-progress: false} + jobs: release: runs-on: ubuntu-latest @@ -14,7 +19,7 @@ jobs: pages: write steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Configure Git @@ -26,9 +31,13 @@ jobs: with: version: 3.10.1 - name: Add dependency chart repositories - run: ./scripts/add_helm_repo.sh + # v1's own scripts moved to legacy-v1/ 2026-08-25 (root takeover by v2) - path updated, + # nothing else about this script changed. + run: ./legacy-v1/scripts/add_helm_repo.sh - name: Run chart-releaser uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 + with: + charts_dir: legacy-v1/charts env: CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" CR_RELEASE_NAME_TEMPLATE: "v{{ .Version }}" diff --git a/.gitignore b/.gitignore index 30d2fb3..4f6e280 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ values_mine.yaml compose.custom.yml configmap.yml podman.yml + +# generated by scripts/ca-bundle.sh +.docker/ca-certificates/plumber-ca.bundle diff --git a/README.md b/README.md index 4ff45a2..1c76c60 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,113 @@ -# Self-Managed Plumber +# Plumber Platform (self-managed) -[![Plumber Score](https://score.getplumber.io/github.com/getplumber/platform.svg)](https://score.getplumber.io/github.com/getplumber/platform) [![CI](https://github.com/getplumber/platform/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/getplumber/platform/actions/workflows/ci.yml) -[![Release](https://github.com/getplumber/platform/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/getplumber/platform/actions/workflows/release.yml) -This project contains resources to setup a self-managed instance of [Plumber](https://getplumber.io/). +Everything needed to self-host the [Plumber](https://getplumber.io/) Platform, the CI/CD security +and compliance control plane. Analysis stays in the open-source +[CLI](https://github.com/getplumber/plumber), which pushes results here over native CI OIDC. -## Installation +This is the **v2** line: images `docker.io/getplumber/platform-backend` and +`docker.io/getplumber/platform-frontend`, one version for both (`versions.env`), Helm chart +`plumber-platform`. The previous line (v1: chart `plumber`, its own images and installer) lives +unchanged in [`legacy-v1/`](legacy-v1/); the two are not compatible and do not share a database. -- ๐Ÿณ **Docker Compose** โ€” [Documentation](https://getplumber.io/docs/installation/docker-compose/) +## Docker Compose - ```bash - curl -fsSL https://raw.githubusercontent.com/getplumber/platform/main/install.sh | bash - ``` +Requirements: a Linux host with Docker (Compose plugin 2.20+), git, openssl, ports 80 and 443 +free, a DNS record for your domain, and a GitLab instance (17.7+ recommended). -- โ˜ธ๏ธ **Kubernetes with Helm** โ€” [Documentation](https://getplumber.io/docs/installation/kubernetes/) +### Quick start -## Contributions +```bash +curl -fsSL https://raw.githubusercontent.com/getplumber/platform/main/install.sh | bash +``` + +Choose **Production**. The installer checks prerequisites, asks for the domain, the GitLab URL, +the connection scope (whole instance or one root group), the GitLab OAuth application (it prints +the exact redirect URI, `https:///api/v1/auth/callback`, and the link to create it), an +optional access token, the TLS method (Let's Encrypt or your own certificates), an optional +private CA and the database (bundled or external). It generates the secrets, writes `.env`, starts +the stack and configures the GitLab connection. Then open `https://` and sign in with +GitLab: the first account becomes the organisation Admin. + +Choose **Local** for a laptop install on `http://localhost:3000` (no TLS). + +### Manual install + +```bash +git clone https://github.com/getplumber/platform.git plumber-platform +cd plumber-platform +cp .env.example .env +cat versions.env >> .env +``` + +Fill `.env`: `DOMAIN_NAME`, `GITLAB_URL`, `PLUMBER_TOKEN_ENCRYPTION_KEY` (`openssl rand -hex 32`, +back it up: it seals every stored secret and cannot be rotated in place), `PLUMBER_DB_PASSWORD` +and `PLUMBER_REDIS_PASSWORD` (`openssl rand -hex 16`), then pick a profile: + +| `COMPOSE_PROFILES` | `CERT_RESOLVER` | Meaning | +|---|---|---| +| `letsencrypt,internal-db` | `le` | Let's Encrypt + bundled Postgres | +| `custom-certs,internal-db` | (empty) | Your certificates in `.docker/traefik/certs/plumber_fullchain.pem` and `plumber_privkey.pem` + bundled Postgres | +| `letsencrypt` or `custom-certs` | as above | External Postgres: set `PLUMBER_DB_HOST` and friends in `.env` | + +Private CA for GitLab: drop the `.pem`/`.crt` files in `.docker/ca-certificates/` and run +`./scripts/ca-bundle.sh` (both containers trust them). + +```bash +./scripts/preflight.sh +docker compose up -d +``` + +Then configure the GitLab connection once (create an OAuth application first: redirect URI +`https:///api/v1/auth/callback`, confidential, scope `api`): + +```bash +read -rs -p "OAuth application secret: " PLUMBER_BOOTSTRAP_CLIENT_SECRET; echo; export PLUMBER_BOOTSTRAP_CLIENT_SECRET +docker compose exec -T -e PLUMBER_BOOTSTRAP_CLIENT_SECRET \ + backend plumber-bootstrap -base-url https://gitlab.example.com -client-id -scope instance +``` + +Use `-scope group -root-group ` to scope to one root group, and export +`PLUMBER_BOOTSTRAP_TOKEN` the same way (add `-e PLUMBER_BOOTSTRAP_TOKEN`) to store an access token +(`api` scope) in the same run. +The command refuses an already-configured instance, so it is safe to retry on a fresh install. -You are welcome to help us improve this repository! +### Update, backup, restore + +```bash +./scripts/update.sh # pulls the repo, syncs PLATFORM_VERSION from versions.env, restarts +./scripts/backup.sh 18 # database dump + .env (+ CA files) into backups/, optional S3 upload +./scripts/restore.sh 18 # the reverse +``` + +Every release pins both images to one version. Upgrades are sequential and additive (the backend +migrates the database on boot); rolling back is reverting `PLATFORM_VERSION` in `.env` and +`docker compose up -d`. Never downgrade the database by hand. + +## Kubernetes (Helm) + +```bash +helm repo add plumber https://charts.getplumber.io/ +helm repo update +helm upgrade --install plumber plumber/plumber-platform -n plumber --create-namespace -f values.yaml +``` + +See [`charts/plumber-platform/README.md`](charts/plumber-platform/README.md) for the minimal +values (one ingress host serves both apps), the secrets, the bundled or external Postgres and +Redis, the private CA options and the first-run bootstrap (manual command or the post-install Job). + +## Releases + +Each release is a git tag `vX.Y.Z` with a GitHub Release carrying the changelog and the packaged +chart; `latest.json` is what installs poll for the update banner. `releases/` holds the notes. + +## Legacy v1 + +The previous install line (Helm chart `plumber`, Compose with bundled Traefik, podman, +`install.sh`) lives unchanged in [`legacy-v1/`](legacy-v1/). See `legacy-v1/README.md`. + +## Contributions -๐ŸŽฎ Open an Issue or create Pull Requests from your fork \ No newline at end of file +You are welcome to help us improve this repository! Open an Issue or create a Pull Request from +your fork. diff --git a/charts/plumber-platform/.helmignore b/charts/plumber-platform/.helmignore new file mode 100644 index 0000000..faeb926 --- /dev/null +++ b/charts/plumber-platform/.helmignore @@ -0,0 +1,24 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ +tests/ diff --git a/charts/plumber-platform/Chart.yaml b/charts/plumber-platform/Chart.yaml new file mode 100644 index 0000000..68079db --- /dev/null +++ b/charts/plumber-platform/Chart.yaml @@ -0,0 +1,25 @@ +apiVersion: v2 +name: plumber-platform +description: Helm chart for the Plumber Platform (v2 line). The v1 chart is `plumber`. +type: application +version: "2.0.0" +appVersion: "2.0.0" +home: https://github.com/getplumber/platform/ +sources: + - https://github.com/getplumber/platform +keywords: + - plumber + - ci-cd + - security + - compliance +maintainers: + - name: devpro + email: bertrand@devpro.fr + - name: gemini-jtff + email: slongo@klanik.com + - name: thomasboni + email: thomas@getplumber.io + - name: Totara-thib + email: thibaud@getplumber.io + - name: Joseph94m + email: joseph.moukarzel@jmclarity.com diff --git a/charts/plumber-platform/README.md b/charts/plumber-platform/README.md new file mode 100644 index 0000000..de37db7 --- /dev/null +++ b/charts/plumber-platform/README.md @@ -0,0 +1,78 @@ +# Helm chart: plumber-platform + +Installs the [Plumber Platform](https://getplumber.io/) v2 (backend + frontend) on Kubernetes. +The previous product line keeps its own chart, `plumber` (see `legacy-v1/charts/plumber`); the two +are not compatible and do not share a database. + +```bash +helm repo add plumber https://charts.getplumber.io/ +helm repo update +helm upgrade --install plumber plumber/plumber-platform -n plumber --create-namespace -f values.yaml +``` + +## Minimal values + +```yaml +platform: + baseUrl: https://plumber.example.com # public origin (also the OIDC audience) + gitlabUrl: https://gitlab.example.com + existingSecret: plumber-secrets # key tokenEncryptionKey = openssl rand -hex 32 +ingress: + enabled: true + className: nginx + host: plumber.example.com # one host: /api -> backend, / -> frontend + tls: + enabled: true +postgresql: + deploy: true # or point custom.host at your own instance + global: + postgresql: + auth: + existingSecret: plumber-pg # key password +redis: + deploy: true + auth: + existingSecret: plumber-redis # key password +``` + +Inline `platform.tokenEncryptionKey`, `postgresql.global.postgresql.auth.password` and +`redis.auth.password` are accepted for evaluation setups; the chart then writes them into the +Secret `-plumber-platform`. + +## First run + +Create a GitLab OAuth application (redirect URI `/api/v1/auth/callback`, +confidential, scope `api`), then either run the command printed by `helm install` (NOTES), or let +the chart do it once with a post-install Job: + +```yaml +bootstrap: + enabled: true + gitlabUrl: https://gitlab.example.com + clientId: + scope: instance # or group + rootGroup + existingSecret: plumber-bootstrap # keys clientSecret, optional token +``` + +The Job waits for the backend, whose first boot can take a few minutes (image pull, migrations). Pass +`--timeout 15m` to `helm install` when `bootstrap.enabled` is true so Helm does not mark the release +failed while the Job is still waiting. + +The Job waits for the backend `/readyz` (migrations run on the backend's first boot) and refuses an +already-configured instance, so it is safe to leave enabled. + +## Operations + +- Upgrade: bump `front.tag` and `backend.tag` together (every release pins both to one version), + `helm upgrade`. The backend Deployment uses `Recreate` and one replica by design: the process owns + the job queues, and two versions must never run at once. Rollback = roll the image back; never run + a database downgrade. +- Private CA for GitLab: `customCertificateAuthority.{existingSecret|configMapName|certificates}`; + the chart mounts it in both apps and builds the single PEM bundle the backend reads. +- Air-gapped: `platform.updateCheckUrl: "off"`. +- Probes: liveness `/healthz`, readiness `/readyz` (database-backed). Metrics: `/metrics` on the + backend Service (not exposed by the ingress). + +## Values + +See `values.yaml`; every key is commented. diff --git a/charts/plumber-platform/templates/NOTES.txt b/charts/plumber-platform/templates/NOTES.txt new file mode 100644 index 0000000..f1b9520 --- /dev/null +++ b/charts/plumber-platform/templates/NOTES.txt @@ -0,0 +1,42 @@ +==================================================================================================== +Plumber Platform {{ .Chart.AppVersion }} deployed (release {{ .Release.Name }}, namespace {{ .Release.Namespace }}) +==================================================================================================== +{{- if .Values.ingress.enabled }} + +Access: {{ if .Values.ingress.tls.enabled }}https{{ else }}http{{ end }}://{{ .Values.ingress.host }} +{{- else }} + +No ingress enabled. Set ingress.enabled=true and ingress.host: the browser calls the API +same-origin under /api, so ONE host must serve both apps (this chart's ingress does that). +{{- end }} +{{- if gt (int .Values.backend.replicaCount) 1 }} + +WARNING: backend.replicaCount is {{ .Values.backend.replicaCount }}. The backend runs its job +queues in-process; two versions running at once discard work. Keep 1 replica. +{{- end }} +{{- if not .Values.bootstrap.enabled }} + +First run: configure the GitLab connection once (bootstrap.enabled=false, manual path). +Create a GitLab OAuth application (redirect URI {{ .Values.platform.baseUrl }}/api/v1/auth/callback, +confidential, scope api), then: + + kubectl exec -n {{ .Release.Namespace }} deploy/{{ .Values.backend.name }} -- \ + /usr/local/bin/plumber-bootstrap -base-url https://gitlab.example.com \ + -client-id -client-secret -scope instance + + Add -token to store an access token in the same run, or use + -scope group -root-group to scope to one root group. The secret is visible in the pod's + process list only for the duration of the command; the hook Job (bootstrap.enabled=true with a + Secret) avoids that. +{{- else }} + +First run: the post-install Job {{ include "plumber-platform.fullname" . }}-bootstrap configures the +GitLab connection once the backend is ready. Check it with: + kubectl logs -n {{ .Release.Namespace }} job/{{ include "plumber-platform.fullname" . }}-bootstrap +{{- end }} + +The first GitLab account that signs in becomes the organisation Admin. + +Documentation: https://getplumber.io/docs +Issues: https://github.com/getplumber/platform/issues +==================================================================================================== diff --git a/charts/plumber-platform/templates/_helpers.tpl b/charts/plumber-platform/templates/_helpers.tpl new file mode 100644 index 0000000..df0537f --- /dev/null +++ b/charts/plumber-platform/templates/_helpers.tpl @@ -0,0 +1,170 @@ +{{/* Release-scoped name for chart-owned resources (Secret, Ingress). */}} +{{- define "plumber-platform.fullname" -}} +{{- printf "%s-plumber-platform" .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end }} + +{{- define "plumber-platform.labels" -}} +app.kubernetes.io/part-of: plumber-platform +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | quote }} +{{- range $key, $val := .Values.additionalLabels }} +{{ $key }}: {{ $val | quote }} +{{- end }} +{{- end }} + +{{- define "plumber-platform.annotations" -}} +{{- with .Values.additionalAnnotations }} +annotations: + {{- range $key, $val := . }} + {{ $key }}: {{ $val | quote }} + {{- end }} +{{- end }} +{{- end }} + +{{/* App image ref: dict "root" $ "app" .Values.backend */}} +{{- define "plumber-platform.appImage" -}} +{{- if .root.Values.imageRegistry -}} +{{ .root.Values.imageRegistry }}/{{ .app.imageName }}:{{ .app.tag }} +{{- else -}} +{{ .app.image }}:{{ .app.tag }} +{{- end -}} +{{- end }} + +{{/* Generic image ref for postgres/redis: [registry/]repository:tag[@digest] */}} +{{- define "plumber-platform.depImage" -}} +{{- $base := ternary (printf "%s/%s" .registry .repository) .repository (ne .registry "") -}} +{{- ternary (printf "%s:%s@%s" $base .tag .digest) (printf "%s:%s" $base .tag) (ne .digest "") -}} +{{- end }} + +{{- define "plumber-platform.secretName" -}} +{{- .Values.platform.existingSecret | default (include "plumber-platform.fullname" .) -}} +{{- end }} + +{{- define "plumber-platform.postgresHost" -}} +{{- .Values.postgresql.custom.host | default (printf "%s-postgresql" .Release.Name) -}} +{{- end }} + +{{- define "plumber-platform.redisHost" -}} +{{- .Values.redis.custom.host | default (printf "%s-redis-master" .Release.Name) -}} +{{- end }} + +{{- define "plumber-platform.dbSecretName" -}} +{{- .Values.postgresql.global.postgresql.auth.existingSecret | default (include "plumber-platform.fullname" .) -}} +{{- end }} + +{{- define "plumber-platform.dbSecretKey" -}} +{{- if .Values.postgresql.global.postgresql.auth.existingSecret -}} +{{ .Values.postgresql.global.postgresql.auth.secretKeys.userPasswordKey }} +{{- else -}} +dbPassword +{{- end -}} +{{- end }} + +{{- define "plumber-platform.redisAuthEnabled" -}} +{{- if or .Values.redis.auth.existingSecret .Values.redis.auth.password }}true{{ end -}} +{{- end }} + +{{- define "plumber-platform.redisSecretName" -}} +{{- .Values.redis.auth.existingSecret | default (include "plumber-platform.fullname" .) -}} +{{- end }} + +{{- define "plumber-platform.redisSecretKey" -}} +{{- if .Values.redis.auth.existingSecret -}} +{{ .Values.redis.auth.existingSecretPasswordKey }} +{{- else -}} +redisPassword +{{- end -}} +{{- end }} + +{{- define "plumber-platform.caEnabled" -}} +{{- with .Values.customCertificateAuthority -}} +{{- if or .existingSecret .configMapName .certificates }}true{{ end -}} +{{- end -}} +{{- end }} + +{{/* The ca-certificates volume source (v1 semantics: secret > configmap > generated configmap). */}} +{{- define "plumber-platform.caVolume" -}} +- name: ca-certificates + {{- with .Values.customCertificateAuthority }} + {{- if .existingSecret }} + secret: + secretName: {{ .existingSecret }} + {{- else }} + configMap: + name: {{ .configMapName | default "plumber-ca-certificates" }} + {{- end }} + {{- end }} +{{- end }} + +{{/* Backend runtime env, shared by the Deployment and the bootstrap Job. */}} +{{- define "plumber-platform.backendEnv" -}} +- name: PLUMBER_LISTEN_ADDR + value: "0.0.0.0" +- name: PLUMBER_LISTEN_PORT + value: {{ .Values.backend.port | quote }} +- name: PLUMBER_BASE_URL + value: {{ required "platform.baseUrl is required (the public origin, e.g. https://plumber.example.com)" .Values.platform.baseUrl | quote }} +- name: PLUMBER_OIDC_AUDIENCE + value: {{ .Values.platform.oidc.audience | default .Values.platform.baseUrl | quote }} +- name: PLUMBER_OIDC_ALLOWED_ISSUERS + value: {{ (join "," .Values.platform.oidc.allowedIssuers) | default .Values.platform.gitlabUrl | quote }} +- name: PLUMBER_COOKIE_SECURE + value: {{ .Values.platform.config.cookieSecure | quote }} +- name: PLUMBER_TRUST_FORWARDED_FOR + value: {{ .Values.platform.config.trustForwardedFor | quote }} +{{- with .Values.platform.config.trustedProxyCidrs }} +- name: PLUMBER_TRUSTED_PROXY_CIDRS + value: {{ join "," . | quote }} +{{- end }} +- name: PLUMBER_SESSION_TTL + value: {{ .Values.platform.config.sessionTTL | quote }} +- name: PLUMBER_LOG_LEVEL + value: {{ .Values.platform.config.logLevel | quote }} +- name: PLUMBER_LOG_FORMAT + value: {{ .Values.platform.config.logFormat | quote }} +- name: PLUMBER_RETENTION_DAYS + value: {{ .Values.platform.config.retentionDays | quote }} +{{- with .Values.platform.updateCheckUrl }} +- name: PLUMBER_UPDATE_CHECK_URL + value: {{ . | quote }} +{{- end }} +- name: PLUMBER_DB_HOST + value: {{ include "plumber-platform.postgresHost" . | quote }} +- name: PLUMBER_DB_PORT + value: {{ .Values.postgresql.custom.port | quote }} +- name: PLUMBER_DB_USER + value: {{ .Values.postgresql.global.postgresql.auth.username | quote }} +- name: PLUMBER_DB_NAME + value: {{ .Values.postgresql.custom.dbName | quote }} +- name: PLUMBER_DB_SSLMODE + value: {{ .Values.postgresql.custom.sslmode | quote }} +- name: PLUMBER_DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "plumber-platform.dbSecretName" . | quote }} + key: {{ include "plumber-platform.dbSecretKey" . | quote }} +{{- if include "plumber-platform.redisAuthEnabled" . }} +- name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "plumber-platform.redisSecretName" . | quote }} + key: {{ include "plumber-platform.redisSecretKey" . | quote }} +- name: PLUMBER_REDIS_URL + value: {{ printf "redis://default:$(REDIS_PASSWORD)@%s:%v/%v" (include "plumber-platform.redisHost" .) .Values.redis.custom.port .Values.redis.custom.databaseIndex | quote }} +{{- else }} +- name: PLUMBER_REDIS_URL + value: {{ printf "redis://%s:%v/%v" (include "plumber-platform.redisHost" .) .Values.redis.custom.port .Values.redis.custom.databaseIndex | quote }} +{{- end }} +- name: PLUMBER_TOKEN_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: {{ include "plumber-platform.secretName" . | quote }} + key: {{ .Values.platform.existingSecretKey | quote }} +{{- if include "plumber-platform.caEnabled" . }} +- name: PLUMBER_PROVIDER_CA_BUNDLE + value: /etc/plumber/ca/ca-bundle.pem +{{- end }} +{{- with .Values.backend.extraEnv }} +{{ toYaml . }} +{{- end }} +{{- end }} diff --git a/charts/plumber-platform/templates/configmap-ca.yaml b/charts/plumber-platform/templates/configmap-ca.yaml new file mode 100644 index 0000000..24b85d0 --- /dev/null +++ b/charts/plumber-platform/templates/configmap-ca.yaml @@ -0,0 +1,13 @@ +{{- if .Values.customCertificateAuthority.certificates }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.customCertificateAuthority.configMapName | default "plumber-ca-certificates" }} + labels: + {{- include "plumber-platform.labels" . | nindent 4 }} + {{- include "plumber-platform.annotations" . | nindent 2 }} +data: + {{- range .Values.customCertificateAuthority.certificates }} + {{ .name }}: {{ .value | quote }} + {{- end }} +{{- end }} diff --git a/charts/plumber-platform/templates/deployment-backend.yaml b/charts/plumber-platform/templates/deployment-backend.yaml new file mode 100644 index 0000000..6edb948 --- /dev/null +++ b/charts/plumber-platform/templates/deployment-backend.yaml @@ -0,0 +1,115 @@ +{{- if .Values.backend.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.backend.name }} + labels: + app: {{ .Values.backend.name }} + app.kubernetes.io/name: {{ .Values.backend.name }} + app.kubernetes.io/component: backend + {{- include "plumber-platform.labels" . | nindent 4 }} + {{- include "plumber-platform.annotations" . | nindent 2 }} +spec: + replicas: {{ .Values.backend.replicaCount }} + revisionHistoryLimit: {{ .Values.backend.revisionHistoryLimit }} + # One version at a time: the single process owns the job queues; two versions + # running together would let an old replica claim and fail new job kinds. + strategy: + type: Recreate + selector: + matchLabels: + app: {{ .Values.backend.name }} + app.kubernetes.io/name: {{ .Values.backend.name }} + template: + metadata: + labels: + app: {{ .Values.backend.name }} + app.kubernetes.io/name: {{ .Values.backend.name }} + app.kubernetes.io/component: backend + {{- include "plumber-platform.labels" . | nindent 8 }} + spec: + automountServiceAccountToken: {{ .Values.backend.automountServiceAccountToken }} + terminationGracePeriodSeconds: {{ .Values.backend.terminationGracePeriodSeconds }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.serviceAccountName }} + serviceAccountName: {{ . | quote }} + {{- end }} + {{- with .Values.backend.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if include "plumber-platform.caEnabled" . }} + initContainers: + # The backend reads ONE PEM file; concatenate whatever the operator mounted. + - name: ca-bundle + image: {{ .Values.customCertificateAuthority.bundlerImage }} + command: ["sh", "-c", "cat /ca-in/*.crt /ca-in/*.pem 2>/dev/null > /ca-out/ca-bundle.pem; test -s /ca-out/ca-bundle.pem"] + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: ca-certificates + mountPath: /ca-in + readOnly: true + - name: ca-bundle + mountPath: /ca-out + {{- end }} + containers: + - name: {{ .Values.backend.name }} + image: {{ include "plumber-platform.appImage" (dict "root" $ "app" .Values.backend) }} + imagePullPolicy: {{ .Values.backend.imagePullPolicy | default "IfNotPresent" }} + ports: + - name: http + containerPort: {{ .Values.backend.port }} + protocol: TCP + # Liveness checks nothing external; readiness is database-backed. + startupProbe: + httpGet: + path: /readyz + port: http + periodSeconds: {{ .Values.backend.startupProbe.periodSeconds }} + failureThreshold: {{ .Values.backend.startupProbe.failureThreshold }} + livenessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: http + periodSeconds: 10 + env: + {{- include "plumber-platform.backendEnv" . | nindent 12 }} + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if include "plumber-platform.caEnabled" . }} + volumeMounts: + - name: ca-bundle + mountPath: /etc/plumber/ca + readOnly: true + {{- end }} + {{- if include "plumber-platform.caEnabled" . }} + volumes: + {{- include "plumber-platform.caVolume" . | nindent 8 }} + - name: ca-bundle + emptyDir: + sizeLimit: 1Mi + {{- end }} +{{- end }} diff --git a/charts/plumber-platform/templates/deployment-frontend.yaml b/charts/plumber-platform/templates/deployment-frontend.yaml new file mode 100644 index 0000000..48da283 --- /dev/null +++ b/charts/plumber-platform/templates/deployment-frontend.yaml @@ -0,0 +1,96 @@ +{{- if .Values.front.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.front.name }} + labels: + app: {{ .Values.front.name }} + app.kubernetes.io/name: {{ .Values.front.name }} + app.kubernetes.io/component: frontend + {{- include "plumber-platform.labels" . | nindent 4 }} + {{- include "plumber-platform.annotations" . | nindent 2 }} +spec: + replicas: {{ .Values.front.replicaCount }} + revisionHistoryLimit: {{ .Values.front.revisionHistoryLimit }} + selector: + matchLabels: + app: {{ .Values.front.name }} + app.kubernetes.io/name: {{ .Values.front.name }} + template: + metadata: + labels: + app: {{ .Values.front.name }} + app.kubernetes.io/name: {{ .Values.front.name }} + app.kubernetes.io/component: frontend + {{- include "plumber-platform.labels" . | nindent 8 }} + spec: + automountServiceAccountToken: {{ .Values.front.automountServiceAccountToken }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.front.serviceAccountName }} + serviceAccountName: {{ . | quote }} + {{- end }} + {{- with .Values.front.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.front.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.front.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Values.front.name }} + image: {{ include "plumber-platform.appImage" (dict "root" $ "app" .Values.front) }} + imagePullPolicy: {{ .Values.front.imagePullPolicy | default "IfNotPresent" }} + ports: + - name: http + containerPort: {{ .Values.front.port }} + protocol: TCP + # No dedicated health route: GET / answers 200 or a 307 to /login, both healthy. + livenessProbe: + httpGet: + path: / + port: http + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: http + periodSeconds: 10 + env: + # Server-side rendering reaches the backend inside the cluster. + - name: API_INTERNAL_URL + value: {{ printf "http://%s:%v/api" .Values.backend.name .Values.backend.port | quote }} + - name: GITLAB_URL + value: {{ .Values.platform.gitlabUrl | quote }} + {{- with .Values.front.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.front.resources | nindent 12 }} + {{- with .Values.front.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + # The image entrypoint writes the CA bundle it builds under /tmp. + - name: tmp + mountPath: /tmp + {{- if include "plumber-platform.caEnabled" . }} + - name: ca-certificates + mountPath: /usr/local/share/ca-certificates + readOnly: true + {{- end }} + volumes: + - name: tmp + emptyDir: {} + {{- if include "plumber-platform.caEnabled" . }} + {{- include "plumber-platform.caVolume" . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/plumber-platform/templates/deployment-redis.yaml b/charts/plumber-platform/templates/deployment-redis.yaml new file mode 100644 index 0000000..23e8a7f --- /dev/null +++ b/charts/plumber-platform/templates/deployment-redis.yaml @@ -0,0 +1,65 @@ +{{- if .Values.redis.deploy }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ printf "%s-redis-master" .Release.Name }} + labels: + app: {{ printf "%s-redis-master" .Release.Name }} + app.kubernetes.io/name: {{ printf "%s-redis-master" .Release.Name }} + {{- include "plumber-platform.labels" . | nindent 4 }} + {{- include "plumber-platform.annotations" . | nindent 2 }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ printf "%s-redis-master" .Release.Name }} + app.kubernetes.io/name: {{ printf "%s-redis-master" .Release.Name }} + template: + metadata: + labels: + app: {{ printf "%s-redis-master" .Release.Name }} + app.kubernetes.io/name: {{ printf "%s-redis-master" .Release.Name }} + {{- include "plumber-platform.labels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.redis.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: redis + image: {{ include "plumber-platform.depImage" .Values.redis.image }} + imagePullPolicy: {{ .Values.redis.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: redis + containerPort: 6379 + protocol: TCP + {{- if include "plumber-platform.redisAuthEnabled" . }} + command: ["redis-server", "--requirepass", "$(REDIS_PASSWORD)"] + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "plumber-platform.redisSecretName" . | quote }} + key: {{ include "plumber-platform.redisSecretKey" . | quote }} + {{- end }} + livenessProbe: + tcpSocket: + port: redis + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + tcpSocket: + port: redis + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.redis.resources | nindent 12 }} + {{- with .Values.redis.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} +{{- end }} diff --git a/charts/plumber-platform/templates/ingress.yaml b/charts/plumber-platform/templates/ingress.yaml new file mode 100644 index 0000000..0913187 --- /dev/null +++ b/charts/plumber-platform/templates/ingress.yaml @@ -0,0 +1,50 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "plumber-platform.fullname" . }} + labels: + {{- include "plumber-platform.labels" . | nindent 4 }} + {{- if or .Values.ingress.annotations .Values.additionalAnnotations }} + annotations: + {{- range $key, $val := .Values.ingress.annotations }} + {{ $key }}: {{ $val | quote }} + {{- end }} + {{- range $key, $val := .Values.additionalAnnotations }} + {{ $key }}: {{ $val | quote }} + {{- end }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ required "ingress.host is required when ingress.enabled is true" .Values.ingress.host | quote }} + secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" (include "plumber-platform.fullname" .)) }} + {{- end }} + rules: + # One origin: the browser calls /api same-origin, so both apps share the host. + - host: {{ required "ingress.host is required when ingress.enabled is true" .Values.ingress.host | quote }} + http: + paths: + {{- if .Values.backend.enabled }} + - path: /api + pathType: Prefix + backend: + service: + name: {{ .Values.backend.name }} + port: + number: {{ .Values.backend.port }} + {{- end }} + {{- if .Values.front.enabled }} + - path: / + pathType: Prefix + backend: + service: + name: {{ .Values.front.name }} + port: + number: {{ .Values.front.port }} + {{- end }} +{{- end }} diff --git a/charts/plumber-platform/templates/job-bootstrap.yaml b/charts/plumber-platform/templates/job-bootstrap.yaml new file mode 100644 index 0000000..159d6e1 --- /dev/null +++ b/charts/plumber-platform/templates/job-bootstrap.yaml @@ -0,0 +1,121 @@ +{{- if .Values.bootstrap.enabled }} +{{- $b := .Values.bootstrap }} +{{- $gitlab := required "bootstrap.gitlabUrl is required when bootstrap.enabled is true" $b.gitlabUrl }} +{{- $clientId := required "bootstrap.clientId is required when bootstrap.enabled is true" $b.clientId }} +{{- $secret := required "bootstrap.existingSecret is required when bootstrap.enabled is true (keys: clientSecret, optional token)" $b.existingSecret }} +{{- if and (eq $b.scope "group") (not $b.rootGroup) }} +{{- fail "bootstrap.rootGroup is required when bootstrap.scope is group" }} +{{- end }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "plumber-platform.fullname" . }}-bootstrap + labels: + app.kubernetes.io/component: bootstrap + {{- include "plumber-platform.labels" . | nindent 4 }} + annotations: + # First install only: plumber-bootstrap refuses an already-configured instance, + # so this never runs on upgrades. Re-created (not re-run) if the hook is re-triggered. + "helm.sh/hook": post-install + "helm.sh/hook-weight": "10" + "helm.sh/hook-delete-policy": before-hook-creation + {{- range $key, $val := .Values.additionalAnnotations }} + {{ $key }}: {{ $val | quote }} + {{- end }} +spec: + backoffLimit: 0 + template: + metadata: + labels: + app.kubernetes.io/component: bootstrap + {{- include "plumber-platform.labels" . | nindent 8 }} + spec: + restartPolicy: Never + automountServiceAccountToken: false + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + initContainers: + {{- if include "plumber-platform.caEnabled" . }} + # Same bundle the backend needs: an access token exchange against a + # privately-signed GitLab must trust it too. + - name: ca-bundle + image: {{ .Values.customCertificateAuthority.bundlerImage }} + command: ["sh", "-c", "cat /ca-in/*.crt /ca-in/*.pem 2>/dev/null > /ca-out/ca-bundle.pem; test -s /ca-out/ca-bundle.pem"] + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: ca-certificates + mountPath: /ca-in + readOnly: true + - name: ca-bundle + mountPath: /ca-out + {{- end }} + # Migrations run inside the backend on its first boot; wait for /readyz. + - name: wait-for-backend + image: {{ $b.waitImage }} + command: ["sh", "-c"] + args: + - | + until curl -sf http://{{ .Values.backend.name }}:{{ .Values.backend.port }}/readyz > /dev/null; do + echo "waiting for the backend to be ready..." + sleep 5 + done + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + containers: + - name: bootstrap + image: {{ include "plumber-platform.appImage" (dict "root" $ "app" .Values.backend) }} + imagePullPolicy: {{ .Values.backend.imagePullPolicy | default "IfNotPresent" }} + command: + - /usr/local/bin/plumber-bootstrap + - -base-url + - {{ $gitlab | quote }} + - -client-id + - {{ $clientId | quote }} + - -scope + - {{ $b.scope | quote }} + {{- if eq $b.scope "group" }} + - -root-group + - {{ $b.rootGroup | quote }} + {{- end }} + env: + {{- include "plumber-platform.backendEnv" . | nindent 12 }} + - name: PLUMBER_BOOTSTRAP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ $secret | quote }} + key: {{ $b.existingSecretClientSecretKey | quote }} + - name: PLUMBER_BOOTSTRAP_TOKEN + valueFrom: + secretKeyRef: + name: {{ $secret | quote }} + key: {{ $b.existingSecretTokenKey | quote }} + optional: true + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if include "plumber-platform.caEnabled" . }} + volumeMounts: + - name: ca-bundle + mountPath: /etc/plumber/ca + readOnly: true + {{- end }} + {{- if include "plumber-platform.caEnabled" . }} + volumes: + {{- include "plumber-platform.caVolume" . | nindent 8 }} + - name: ca-bundle + emptyDir: + sizeLimit: 1Mi + {{- end }} +{{- end }} diff --git a/charts/plumber-platform/templates/secret.yaml b/charts/plumber-platform/templates/secret.yaml new file mode 100644 index 0000000..9c5a74f --- /dev/null +++ b/charts/plumber-platform/templates/secret.yaml @@ -0,0 +1,29 @@ +{{- $needKey := not .Values.platform.existingSecret -}} +{{- $needDb := not .Values.postgresql.global.postgresql.auth.existingSecret -}} +{{- $needRedis := and (not .Values.redis.auth.existingSecret) (ne .Values.redis.auth.password "") -}} +{{- if $needKey }} +{{- $key := required "platform.tokenEncryptionKey is required when platform.existingSecret is empty (openssl rand -hex 32)" .Values.platform.tokenEncryptionKey -}} +{{- if not (regexMatch "^[0-9a-fA-F]{64}$" $key) }} +{{- fail "platform.tokenEncryptionKey must be exactly 64 hex characters (32 bytes, openssl rand -hex 32)" }} +{{- end }} +{{- end }} +{{- if or $needKey $needDb $needRedis }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "plumber-platform.fullname" . }} + labels: + {{- include "plumber-platform.labels" . | nindent 4 }} + {{- include "plumber-platform.annotations" . | nindent 2 }} +type: Opaque +stringData: + {{- if $needKey }} + tokenEncryptionKey: {{ .Values.platform.tokenEncryptionKey | quote }} + {{- end }} + {{- if $needDb }} + dbPassword: {{ required "postgresql.global.postgresql.auth.password is required when postgresql.global.postgresql.auth.existingSecret is empty" .Values.postgresql.global.postgresql.auth.password | quote }} + {{- end }} + {{- if $needRedis }} + redisPassword: {{ .Values.redis.auth.password | quote }} + {{- end }} +{{- end }} diff --git a/charts/plumber-platform/templates/service-redis.yaml b/charts/plumber-platform/templates/service-redis.yaml new file mode 100644 index 0000000..52557df --- /dev/null +++ b/charts/plumber-platform/templates/service-redis.yaml @@ -0,0 +1,21 @@ +{{- if .Values.redis.deploy }} +apiVersion: v1 +kind: Service +metadata: + name: {{ printf "%s-redis-master" .Release.Name }} + labels: + app: {{ printf "%s-redis-master" .Release.Name }} + app.kubernetes.io/name: {{ printf "%s-redis-master" .Release.Name }} + {{- include "plumber-platform.labels" . | nindent 4 }} + {{- include "plumber-platform.annotations" . | nindent 2 }} +spec: + type: ClusterIP + selector: + app: {{ printf "%s-redis-master" .Release.Name }} + app.kubernetes.io/name: {{ printf "%s-redis-master" .Release.Name }} + ports: + - name: redis + port: 6379 + targetPort: redis + protocol: TCP +{{- end }} diff --git a/charts/plumber-platform/templates/service.yaml b/charts/plumber-platform/templates/service.yaml new file mode 100644 index 0000000..3aab8e9 --- /dev/null +++ b/charts/plumber-platform/templates/service.yaml @@ -0,0 +1,25 @@ +{{- $applications := list .Values.front .Values.backend -}} +{{- range $applications }} +{{- if .enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .name }} + labels: + app: {{ .name }} + app.kubernetes.io/name: {{ .name }} + {{- include "plumber-platform.labels" $ | nindent 4 }} + {{- include "plumber-platform.annotations" $ | nindent 2 }} +spec: + type: ClusterIP + ports: + - port: {{ .port }} + targetPort: http + protocol: TCP + name: http + selector: + app: {{ .name }} + app.kubernetes.io/name: {{ .name }} +{{- end }} +{{- end }} diff --git a/charts/plumber-platform/templates/statefulset-postgresql.yaml b/charts/plumber-platform/templates/statefulset-postgresql.yaml new file mode 100644 index 0000000..65e0295 --- /dev/null +++ b/charts/plumber-platform/templates/statefulset-postgresql.yaml @@ -0,0 +1,105 @@ +{{- if .Values.postgresql.deploy }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ printf "%s-postgresql" .Release.Name }} + labels: + app: {{ printf "%s-postgresql" .Release.Name }} + app.kubernetes.io/name: {{ printf "%s-postgresql" .Release.Name }} + {{- include "plumber-platform.labels" . | nindent 4 }} + {{- include "plumber-platform.annotations" . | nindent 2 }} +spec: + clusterIP: None + selector: + app: {{ printf "%s-postgresql" .Release.Name }} + app.kubernetes.io/name: {{ printf "%s-postgresql" .Release.Name }} + ports: + - name: postgresql + port: 5432 + targetPort: postgresql + protocol: TCP +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ printf "%s-postgresql" .Release.Name }} + labels: + app: {{ printf "%s-postgresql" .Release.Name }} + app.kubernetes.io/name: {{ printf "%s-postgresql" .Release.Name }} + {{- include "plumber-platform.labels" . | nindent 4 }} + {{- include "plumber-platform.annotations" . | nindent 2 }} +spec: + replicas: 1 + serviceName: {{ printf "%s-postgresql" .Release.Name }} + selector: + matchLabels: + app: {{ printf "%s-postgresql" .Release.Name }} + app.kubernetes.io/name: {{ printf "%s-postgresql" .Release.Name }} + template: + metadata: + labels: + app: {{ printf "%s-postgresql" .Release.Name }} + app.kubernetes.io/name: {{ printf "%s-postgresql" .Release.Name }} + {{- include "plumber-platform.labels" . | nindent 8 }} + spec: + automountServiceAccountToken: {{ .Values.postgresql.automountServiceAccountToken }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.postgresql.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: postgresql + image: {{ include "plumber-platform.depImage" .Values.postgresql.image }} + imagePullPolicy: {{ .Values.postgresql.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: postgresql + containerPort: 5432 + protocol: TCP + env: + - name: POSTGRES_DB + value: {{ .Values.postgresql.custom.dbName | quote }} + - name: POSTGRES_USER + value: {{ .Values.postgresql.global.postgresql.auth.username | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "plumber-platform.dbSecretName" . | quote }} + key: {{ include "plumber-platform.dbSecretKey" . | quote }} + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + livenessProbe: + exec: + command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"] + initialDelaySeconds: 30 + periodSeconds: 30 + readinessProbe: + exec: + command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"] + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + {{- toYaml .Values.postgresql.resources | nindent 12 }} + {{- with .Values.postgresql.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: /var/lib/postgresql + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [{{ .Values.postgresql.persistence.accessMode | quote }}] + {{- with .Values.postgresql.persistence.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgresql.persistence.size | quote }} +{{- end }} diff --git a/charts/plumber-platform/tests/backend_test.yaml b/charts/plumber-platform/tests/backend_test.yaml new file mode 100644 index 0000000..d3f45de --- /dev/null +++ b/charts/plumber-platform/tests/backend_test.yaml @@ -0,0 +1,196 @@ +suite: backend deployment (single process, Recreate, probes, env) +templates: + - deployment-backend.yaml +values: + - ./values/base.yaml +release: + name: plumber +tests: + - it: renders one Recreate deployment named plumber-backend with one replica + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Deployment + - equal: + path: metadata.name + value: plumber-backend + - equal: + path: spec.replicas + value: 1 + - equal: + path: spec.strategy.type + value: Recreate + - equal: + path: spec.template.spec.terminationGracePeriodSeconds + value: 1200 + - it: pins the image from tag and wires liveness to /healthz and readiness to /readyz + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: docker.io/getplumber/platform-backend:v2.0.0 + - equal: + path: spec.template.spec.containers[0].livenessProbe.httpGet.path + value: /healthz + - equal: + path: spec.template.spec.containers[0].readinessProbe.httpGet.path + value: /readyz + - equal: + path: spec.template.spec.containers[0].startupProbe.httpGet.path + value: /readyz + - equal: + path: spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem + value: true + - it: honours imageRegistry + set: + imageRegistry: registry.example.com + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: registry.example.com/getplumber/platform-backend:v2.0.0 + - it: sets the public origin, audience and issuers from platform values + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_BASE_URL + value: https://plumber.example.com + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_OIDC_AUDIENCE + value: https://plumber.example.com + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_OIDC_ALLOWED_ISSUERS + value: https://gitlab.example.com + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_COOKIE_SECURE + value: "true" + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_TRUST_FORWARDED_FOR + value: "true" + - it: joins explicit issuers and audience + set: + platform.oidc.audience: https://aud.example.com + platform.oidc.allowedIssuers: [https://a.example.com, https://b.example.com] + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_OIDC_AUDIENCE + value: https://aud.example.com + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_OIDC_ALLOWED_ISSUERS + value: https://a.example.com,https://b.example.com + - it: reads the encryption key and passwords from the chart secret by default + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_TOKEN_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: plumber-plumber-platform + key: tokenEncryptionKey + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_DB_PASSWORD + valueFrom: + secretKeyRef: + name: plumber-plumber-platform + key: dbPassword + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_DB_HOST + value: plumber-postgresql + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_REDIS_URL + value: redis://default:$(REDIS_PASSWORD)@plumber-redis-master:6379/0 + - notContains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_REDIS_ADDR + any: true + - it: reads from existing secrets when configured + set: + platform.existingSecret: my-platform + platform.existingSecretKey: key + postgresql.global.postgresql.auth.existingSecret: my-pg + postgresql.global.postgresql.auth.secretKeys.userPasswordKey: pw + redis.auth.existingSecret: my-redis + redis.auth.existingSecretPasswordKey: rpw + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_TOKEN_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: my-platform + key: key + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_DB_PASSWORD + valueFrom: + secretKeyRef: + name: my-pg + key: pw + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: my-redis + key: rpw + - it: builds a passwordless redis url when no redis auth is configured + set: + redis.auth.password: "" + redis.custom.host: redis.example.com + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_REDIS_URL + value: redis://redis.example.com:6379/0 + - it: sets the update check url only when given + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_UPDATE_CHECK_URL + any: true + - it: air-gapped installs turn the update check off + set: + platform.updateCheckUrl: "off" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_UPDATE_CHECK_URL + value: "off" + - it: fails without a base url + set: + platform.baseUrl: "" + asserts: + - failedTemplate: + errorPattern: platform.baseUrl is required + - it: renders nothing when disabled + set: + backend.enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/charts/plumber-platform/tests/bootstrap_test.yaml b/charts/plumber-platform/tests/bootstrap_test.yaml new file mode 100644 index 0000000..f5aa17e --- /dev/null +++ b/charts/plumber-platform/tests/bootstrap_test.yaml @@ -0,0 +1,91 @@ +suite: bootstrap post-install job +templates: + - job-bootstrap.yaml +values: + - ./values/base.yaml +release: + name: plumber +set: + bootstrap.enabled: true + bootstrap.gitlabUrl: https://gitlab.example.com + bootstrap.clientId: app-id + bootstrap.existingSecret: bootstrap-secret +tests: + - it: is absent when disabled + set: + bootstrap.enabled: false + asserts: + - hasDocuments: + count: 0 + - it: renders a post-install hook job that waits for readyz then bootstraps + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Job + - equal: + path: metadata.annotations["helm.sh/hook"] + value: post-install + - equal: + path: metadata.annotations["helm.sh/hook-delete-policy"] + value: before-hook-creation + - equal: + path: spec.backoffLimit + value: 0 + - equal: + path: spec.template.spec.initContainers[0].image + value: curlimages/curl:8.11.1 + - matchRegex: + path: spec.template.spec.initContainers[0].args[0] + pattern: http://plumber-backend:8080/readyz + - equal: + path: spec.template.spec.containers[0].image + value: docker.io/getplumber/platform-backend:v2.0.0 + - equal: + path: spec.template.spec.containers[0].command + value: ["/usr/local/bin/plumber-bootstrap", "-base-url", "https://gitlab.example.com", "-client-id", "app-id", "-scope", "instance"] + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_BOOTSTRAP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: bootstrap-secret + key: clientSecret + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_BOOTSTRAP_TOKEN + valueFrom: + secretKeyRef: + name: bootstrap-secret + key: token + optional: true + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_TOKEN_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: plumber-plumber-platform + key: tokenEncryptionKey + - it: passes the root group for a group scope + set: + bootstrap.scope: group + bootstrap.rootGroup: my-company + asserts: + - equal: + path: spec.template.spec.containers[0].command + value: ["/usr/local/bin/plumber-bootstrap", "-base-url", "https://gitlab.example.com", "-client-id", "app-id", "-scope", "group", "-root-group", "my-company"] + - it: fails on a group scope without a root group + set: + bootstrap.scope: group + asserts: + - failedTemplate: + errorPattern: bootstrap.rootGroup is required + - it: fails without the existing secret + set: + bootstrap.existingSecret: "" + asserts: + - failedTemplate: + errorPattern: bootstrap.existingSecret is required diff --git a/charts/plumber-platform/tests/ca_test.yaml b/charts/plumber-platform/tests/ca_test.yaml new file mode 100644 index 0000000..aac546e --- /dev/null +++ b/charts/plumber-platform/tests/ca_test.yaml @@ -0,0 +1,106 @@ +suite: custom certificate authority +templates: + - configmap-ca.yaml + - deployment-backend.yaml + - deployment-frontend.yaml +values: + - ./values/base.yaml +release: + name: plumber +tests: + - it: renders no configmap and no ca volume when unconfigured + template: configmap-ca.yaml + asserts: + - hasDocuments: + count: 0 + - it: renders the configmap from inline certificates with the default name + template: configmap-ca.yaml + set: + customCertificateAuthority.certificates: + - name: root.crt + value: | + MY SUPER CERTIFICATE + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + - equal: + path: metadata.name + value: plumber-ca-certificates + - equal: + path: data["root.crt"] + value: | + MY SUPER CERTIFICATE + - it: names the configmap from configMapName + template: configmap-ca.yaml + set: + customCertificateAuthority.configMapName: custom-name + customCertificateAuthority.certificates: + - name: root.crt + value: X + asserts: + - equal: + path: metadata.name + value: custom-name + - it: backend binds an existing secret, builds the bundle and points the backend at it + template: deployment-backend.yaml + set: + customCertificateAuthority.existingSecret: my-ca-secret + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: ca-certificates + secret: + secretName: my-ca-secret + - equal: + path: spec.template.spec.initContainers[0].name + value: ca-bundle + - contains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_PROVIDER_CA_BUNDLE + value: /etc/plumber/ca/ca-bundle.pem + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: ca-bundle + mountPath: /etc/plumber/ca + readOnly: true + - it: backend binds the generated configmap when certificates are inline + template: deployment-backend.yaml + set: + customCertificateAuthority.certificates: + - name: root.crt + value: X + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: ca-certificates + configMap: + name: plumber-ca-certificates + - it: backend has no init container, no bundle env and no volumes without a CA + template: deployment-backend.yaml + asserts: + - notExists: + path: spec.template.spec.initContainers + - notExists: + path: spec.template.spec.volumes + - notContains: + path: spec.template.spec.containers[0].env + content: + name: PLUMBER_PROVIDER_CA_BUNDLE + any: true + - it: frontend binds the same source + template: deployment-frontend.yaml + set: + customCertificateAuthority.existingSecret: my-ca-secret + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: ca-certificates + secret: + secretName: my-ca-secret diff --git a/charts/plumber-platform/tests/frontend_test.yaml b/charts/plumber-platform/tests/frontend_test.yaml new file mode 100644 index 0000000..99f1196 --- /dev/null +++ b/charts/plumber-platform/tests/frontend_test.yaml @@ -0,0 +1,77 @@ +suite: frontend deployment +templates: + - deployment-frontend.yaml +values: + - ./values/base.yaml +release: + name: plumber +tests: + - it: renders plumber-front with the pinned image and a writable /tmp + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: plumber-front + - equal: + path: spec.template.spec.containers[0].image + value: docker.io/getplumber/platform-frontend:v2.0.0 + - contains: + path: spec.template.spec.volumes + content: + name: tmp + emptyDir: {} + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: tmp + mountPath: /tmp + - it: talks to the backend service server-side and knows the gitlab url + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: API_INTERNAL_URL + value: http://plumber-backend:8080/api + - contains: + path: spec.template.spec.containers[0].env + content: + name: GITLAB_URL + value: https://gitlab.example.com + - it: probes GET / on the http port + asserts: + - equal: + path: spec.template.spec.containers[0].livenessProbe.httpGet.path + value: / + - equal: + path: spec.template.spec.containers[0].readinessProbe.httpGet.port + value: http + - it: mounts the ca-certificates directory when a custom CA is configured + set: + customCertificateAuthority.configMapName: my-ca + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: ca-certificates + mountPath: /usr/local/share/ca-certificates + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: ca-certificates + configMap: + name: my-ca + - it: has no ca mount without a custom CA + asserts: + - notContains: + path: spec.template.spec.volumes + content: + name: ca-certificates + any: true + - it: renders nothing when disabled + set: + front.enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/charts/plumber-platform/tests/ingress_test.yaml b/charts/plumber-platform/tests/ingress_test.yaml new file mode 100644 index 0000000..27769e1 --- /dev/null +++ b/charts/plumber-platform/tests/ingress_test.yaml @@ -0,0 +1,78 @@ +suite: single-host ingress +templates: + - ingress.yaml +values: + - ./values/base.yaml +release: + name: plumber +tests: + - it: is absent by default + asserts: + - hasDocuments: + count: 0 + - it: routes /api to the backend and / to the frontend on one host + set: + ingress.enabled: true + ingress.host: plumber.example.com + ingress.className: nginx + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Ingress + - equal: + path: spec.ingressClassName + value: nginx + - equal: + path: spec.rules[0].host + value: plumber.example.com + - equal: + path: spec.rules[0].http.paths[0].path + value: /api + - equal: + path: spec.rules[0].http.paths[0].backend.service.name + value: plumber-backend + - equal: + path: spec.rules[0].http.paths[0].backend.service.port.number + value: 8080 + - equal: + path: spec.rules[0].http.paths[1].path + value: / + - equal: + path: spec.rules[0].http.paths[1].backend.service.name + value: plumber-front + - notExists: + path: spec.tls + - it: adds tls with a default secret name + set: + ingress.enabled: true + ingress.host: plumber.example.com + ingress.tls.enabled: true + asserts: + - equal: + path: spec.tls[0].secretName + value: plumber-plumber-platform-tls + - equal: + path: spec.tls[0].hosts[0] + value: plumber.example.com + - it: merges ingress and additional annotations + set: + ingress.enabled: true + ingress.host: plumber.example.com + ingress.annotations: + cert-manager.io/cluster-issuer: letsencrypt + additionalAnnotations: + team: platform + asserts: + - equal: + path: metadata.annotations["cert-manager.io/cluster-issuer"] + value: letsencrypt + - equal: + path: metadata.annotations.team + value: platform + - it: fails without a host + set: + ingress.enabled: true + asserts: + - failedTemplate: + errorPattern: ingress.host is required diff --git a/charts/plumber-platform/tests/postgresql_test.yaml b/charts/plumber-platform/tests/postgresql_test.yaml new file mode 100644 index 0000000..a9c35a7 --- /dev/null +++ b/charts/plumber-platform/tests/postgresql_test.yaml @@ -0,0 +1,86 @@ +suite: bundled postgresql +templates: + - statefulset-postgresql.yaml +values: + - ./values/base.yaml +release: + name: plumber +tests: + - it: is absent by default + asserts: + - hasDocuments: + count: 0 + - it: renders a headless service and a statefulset mounting /var/lib/postgresql + set: + postgresql.deploy: true + asserts: + - hasDocuments: + count: 2 + - isKind: + of: Service + documentIndex: 0 + - equal: + path: metadata.name + value: plumber-postgresql + documentIndex: 0 + - equal: + path: spec.clusterIP + value: None + documentIndex: 0 + - isKind: + of: StatefulSet + documentIndex: 1 + - equal: + path: spec.template.spec.containers[0].image + value: postgres:18 + documentIndex: 1 + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: data + mountPath: /var/lib/postgresql + documentIndex: 1 + - contains: + path: spec.template.spec.containers[0].env + content: + name: PGDATA + value: /var/lib/postgresql/data/pgdata + documentIndex: 1 + - contains: + path: spec.template.spec.containers[0].env + content: + name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: plumber-plumber-platform + key: dbPassword + documentIndex: 1 + - equal: + path: spec.volumeClaimTemplates[0].spec.resources.requests.storage + value: 10Gi + documentIndex: 1 + - it: builds the image ref with registry and digest + set: + postgresql.deploy: true + postgresql.image.registry: mirror.example.com + postgresql.image.digest: sha256:abc + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: mirror.example.com/postgres:18@sha256:abc + documentIndex: 1 + - it: reads the password from an existing secret + set: + postgresql.deploy: true + postgresql.global.postgresql.auth.existingSecret: pg + postgresql.global.postgresql.auth.secretKeys.userPasswordKey: pw + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: pg + key: pw + documentIndex: 1 diff --git a/charts/plumber-platform/tests/redis_test.yaml b/charts/plumber-platform/tests/redis_test.yaml new file mode 100644 index 0000000..99ecc85 --- /dev/null +++ b/charts/plumber-platform/tests/redis_test.yaml @@ -0,0 +1,56 @@ +suite: bundled redis +templates: + - deployment-redis.yaml + - service-redis.yaml +values: + - ./values/base.yaml +release: + name: plumber +tests: + - it: is absent by default + asserts: + - hasDocuments: + count: 0 + - it: renders the deployment with requirepass from the chart secret + template: deployment-redis.yaml + set: + redis.deploy: true + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: plumber-redis-master + - equal: + path: spec.template.spec.containers[0].image + value: redis:8.4 + - equal: + path: spec.template.spec.containers[0].command + value: ["redis-server", "--requirepass", "$(REDIS_PASSWORD)"] + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: plumber-plumber-platform + key: redisPassword + - it: runs without a password when none is configured + template: deployment-redis.yaml + set: + redis.deploy: true + redis.auth.password: "" + asserts: + - notExists: + path: spec.template.spec.containers[0].command + - it: renders the service on 6379 + template: service-redis.yaml + set: + redis.deploy: true + asserts: + - equal: + path: metadata.name + value: plumber-redis-master + - equal: + path: spec.ports[0].port + value: 6379 diff --git a/charts/plumber-platform/tests/secret_test.yaml b/charts/plumber-platform/tests/secret_test.yaml new file mode 100644 index 0000000..0ef5bbd --- /dev/null +++ b/charts/plumber-platform/tests/secret_test.yaml @@ -0,0 +1,49 @@ +suite: chart-managed secret +templates: + - secret.yaml +values: + - ./values/base.yaml +release: + name: plumber +tests: + - it: holds the key and both passwords by default + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Secret + - equal: + path: metadata.name + value: plumber-plumber-platform + - equal: + path: stringData.tokenEncryptionKey + value: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + - equal: + path: stringData.dbPassword + value: pgpass + - equal: + path: stringData.redisPassword + value: redispass + - it: is not rendered when every secret is external + set: + platform.existingSecret: a + platform.tokenEncryptionKey: "" + postgresql.global.postgresql.auth.existingSecret: b + postgresql.global.postgresql.auth.password: "" + redis.auth.existingSecret: c + redis.auth.password: "" + asserts: + - hasDocuments: + count: 0 + - it: rejects a key that is not 64 hex characters + set: + platform.tokenEncryptionKey: "0123456789abcdef0123456789abcdef" + asserts: + - failedTemplate: + errorPattern: 64 hex + - it: requires a key when no existing secret is given + set: + platform.tokenEncryptionKey: "" + asserts: + - failedTemplate: + errorPattern: platform.tokenEncryptionKey diff --git a/charts/plumber-platform/tests/values/base.yaml b/charts/plumber-platform/tests/values/base.yaml new file mode 100644 index 0000000..8a15950 --- /dev/null +++ b/charts/plumber-platform/tests/values/base.yaml @@ -0,0 +1,12 @@ +platform: + baseUrl: https://plumber.example.com + gitlabUrl: https://gitlab.example.com + tokenEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +postgresql: + global: + postgresql: + auth: + password: pgpass +redis: + auth: + password: redispass diff --git a/charts/plumber-platform/values.yaml b/charts/plumber-platform/values.yaml new file mode 100644 index 0000000..080cc57 --- /dev/null +++ b/charts/plumber-platform/values.yaml @@ -0,0 +1,252 @@ +# Plumber Platform v2 Helm chart values. +# One origin is mandatory: the browser calls the API same-origin under /api, so +# ingress.host serves BOTH apps (/api -> backend, everything else -> frontend). + +# -- Additional annotations applied to all resources. +additionalAnnotations: {} +# -- Additional labels applied to all resources. +additionalLabels: {} +# -- Global image registry override. When set, app images are pulled as /:. +imageRegistry: "" +# -- Global image pull secrets. +imagePullSecrets: [] + # - name: my-registry-secret + +platform: + # -- Public origin of this install, no trailing slash (e.g. https://plumber.example.com). Required. + baseUrl: "" + # -- GitLab instance URL: frontend deep links and the default allowed OIDC issuer. + gitlabUrl: "https://gitlab.com" + oidc: + # -- The `aud` claim CI OIDC tokens must carry. Defaults to platform.baseUrl. + audience: "" + # -- GitLab base URLs allowed to push analysis results. Defaults to [platform.gitlabUrl]. + allowedIssuers: [] + # -- AES-256-GCM key sealing every stored GitLab secret: exactly 64 hex chars (openssl rand -hex 32). + # Inline value (dev only) OR an existing Secret below. Cannot be rotated in place: back it up. + tokenEncryptionKey: "" + # -- Existing Secret holding the key (preferred in production). + existingSecret: "" + existingSecretKey: "tokenEncryptionKey" + # -- Update check manifest URL. Leave empty for the default; set to "off" on air-gapped installs. + updateCheckUrl: "" + config: + logLevel: "info" # debug|info|warn|error + logFormat: "json" # json|text + sessionTTL: "168h" + retentionDays: 30 # floor 2 + # -- Trust X-Forwarded-For from the ingress for per-IP rate limits. Keep true behind an ingress. + trustForwardedFor: true + # -- Extra trusted proxy CIDRs (only when the ingress hop is outside private ranges). + trustedProxyCidrs: [] + # -- Mark the session cookie Secure. Keep true: the ingress terminates TLS. + cookieSecure: true + +front: + enabled: true + name: plumber-front + # -- Full image path (used when imageRegistry is empty). + image: docker.io/getplumber/platform-frontend + # -- Image name without registry prefix (used when imageRegistry is set). + imageName: getplumber/platform-frontend + imagePullPolicy: IfNotPresent + tag: v2.0.0 + replicaCount: 1 + revisionHistoryLimit: 5 + port: 3000 + extraEnv: [] + resources: {} + # -- The frontend image runs as nextjs (uid:gid 1001:1001). + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + privileged: false + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + seccompProfile: + type: RuntimeDefault + podSecurityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + automountServiceAccountToken: false + serviceAccountName: "" + nodeSelector: {} + tolerations: [] + +backend: + enabled: true + name: plumber-backend + image: docker.io/getplumber/platform-backend + imageName: getplumber/platform-backend + imagePullPolicy: IfNotPresent + tag: v2.0.0 + # -- One process runs the API, the background workers and the schedulers, and + # migrates the database on boot. Keep 1 replica and the Recreate strategy: two + # versions must never run at once (an old replica would claim and fail new job kinds). + replicaCount: 1 + revisionHistoryLimit: 5 + port: 8080 + # -- In-flight background work can take many minutes to drain on large estates. + terminationGracePeriodSeconds: 1200 + # -- First boot runs migrations before /readyz answers; startupProbe waits up to periodSeconds*failureThreshold. + startupProbe: + periodSeconds: 5 + failureThreshold: 60 + extraEnv: [] + resources: {} + # -- Distroless image, rootless user nonroot (uid:gid 65532:65532), writes nothing to disk. + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + privileged: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + automountServiceAccountToken: false + serviceAccountName: "" + nodeSelector: {} + tolerations: [] + +# -- First-run GitLab connection setup (plumber-bootstrap), as a post-install hook Job. +# Off by default: the manual `kubectl exec` command is printed in NOTES.txt. +bootstrap: + enabled: false + # -- GitLab instance base URL (e.g. https://gitlab.example.com). Required when enabled. + gitlabUrl: "" + # -- OAuth application id (redirect URI: /api/v1/auth/callback, scope api). Required when enabled. + clientId: "" + # -- instance | group + scope: "instance" + # -- Root group path, required with scope: group. + rootGroup: "" + # -- Existing Secret with the OAuth application secret (key clientSecret) and, optionally, an access token (key token). + existingSecret: "" + existingSecretClientSecretKey: "clientSecret" + existingSecretTokenKey: "token" + # -- Image used to wait for the backend's /readyz before bootstrapping. + waitImage: curlimages/curl:8.11.1 + +ingress: + enabled: false + className: "" + # -- ONE host for both apps. Required when enabled. + host: "" + annotations: {} + # cert-manager.io/cluster-issuer: letsencrypt-prod + tls: + enabled: false + # -- Defaults to -plumber-platform-tls. + secretName: "" + +# -- Private CA trust for GitLab (and SMTP). Same three modes as the v1 chart. +customCertificateAuthority: + existingSecret: "" + configMapName: "" + certificates: [] + # - name: rootCA.crt # Must have the .crt or .pem extension + # value: | + # -----BEGIN CERTIFICATE----- + # (SNIPPED FOR BREVITY) + # -----END CERTIFICATE----- + # -- Init image that concatenates the certificates into the one bundle file the backend reads. + bundlerImage: busybox:1.37 + +postgresql: + # -- Deploy a PostgreSQL StatefulSet as part of this chart. When false, point custom.host at yours. + deploy: false + image: + registry: "" + repository: postgres + tag: "18" + digest: "" + pullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + privileged: false + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + seccompProfile: + type: RuntimeDefault + podSecurityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + seccompProfile: + type: RuntimeDefault + persistence: + size: "10Gi" + storageClass: "" + accessMode: ReadWriteOnce + resources: {} + automountServiceAccountToken: false + # -- Connection details (used whether deployed by this chart or external). + custom: + # -- Defaults to -postgresql when empty. + host: "" + dbName: "plumber" + sslmode: "disable" + port: 5432 + global: + postgresql: + auth: + username: plumber + # -- Inline password (dev only) OR existingSecret. + password: "" + existingSecret: "" + secretKeys: + userPasswordKey: "password" + +redis: + # -- Deploy a Redis Deployment as part of this chart. When false, point custom.host at yours. + deploy: false + image: + registry: "" + repository: redis + tag: "8.4" + digest: "" + pullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + privileged: false + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + seccompProfile: + type: RuntimeDefault + podSecurityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + seccompProfile: + type: RuntimeDefault + resources: {} + custom: + # -- Defaults to -redis-master when empty. + host: "" + port: 6379 + databaseIndex: 0 + auth: + # -- Inline password (dev only) OR existingSecret. URL-safe characters only (it is embedded in redis://). + password: "" + existingSecret: "" + existingSecretPasswordKey: "password" diff --git a/compose.local.yml b/compose.local.yml index 3efc28c..1041cef 100644 --- a/compose.local.yml +++ b/compose.local.yml @@ -1,91 +1,102 @@ +# Plumber Platform v2, local stack (no TLS). Traefik on plain HTTP publishes +# ONE origin, http://localhost:3000, and routes /api to the backend and the +# rest to the frontend, because the browser calls the API same-origin. +# Use ./install.sh and pick "Local", or copy .env.example to .env yourself. name: "plumber-local" -x-backend-common: &backend-common - image: docker.io/getplumber/backend:${BACKEND_IMAGE_TAG} - env_file: .env - environment: - - JOBS_LISTEN_ADDR=0.0.0.0 - - JOBS_LISTEN_PORT=3000 - - JOBS_CORS_ORIGIN=http://localhost:3000 - - JOBS_FRONTEND_URL=http://localhost:3000 - - JOBS_SESSION_TTL=168h - - JOBS_DB_HOST=${JOBS_DB_HOST:-postgres} - - JOBS_DB_PORT=${JOBS_DB_PORT:-5432} - - JOBS_DB_USER=${JOBS_DB_USER:-jobs} - - JOBS_DB_NAME=${JOBS_DB_NAME:-jobs} - - JOBS_DB_SSLMODE=${JOBS_DB_SSLMODE:-disable} - - JOBS_DB_TIMEZONE=${JOBS_DB_TIMEZONE:-Europe/Paris} - - JOBS_API_DOMAIN=http://localhost:3001/api - - LOG_LEVEL=info - - LOG_FORMATTER=text - - JOBS_REDIS_HOST=redis - - JOBS_REDIS_PORT=6379 - - JOBS_REDIS_DB=0 - - JOBS_REDIS_USER=default - - JOBS_REDIS_SET_NAMESPACES_TTL=30s - - GITLEAKS_PATH=/opt/gitleaks - - FRONTEND_DOMAIN=localhost:3000 - - API_DOMAIN=http://localhost:3001 - - API_PATH=/api +services: + + traefik: + image: traefik:3.6 + pull_policy: always restart: unless-stopped depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy + - frontend + - backend + ports: + - "3000:80" + command: + - --log.level=INFO + - --entrypoints.web.address=:80 + - --providers.docker=true + - --providers.docker.exposedbydefault=false volumes: - - ./.docker/ca-certificates:/usr/local/share/ca-certificates/ + - /var/run/docker.sock:/var/run/docker.sock:ro networks: - intranet -services: - - ############ - # Plumber # - ############ - frontend: - image: docker.io/getplumber/frontend:${FRONTEND_IMAGE_TAG} - env_file: .env - environment: - - FRONTEND_DOMAIN=localhost:3000 - - API_DOMAIN=http://localhost:3001 - - API_PATH=/api + image: docker.io/getplumber/platform-frontend:${PLATFORM_VERSION} restart: unless-stopped + environment: + - API_INTERNAL_URL=http://backend:8080/api + - GITLAB_URL=${GITLAB_URL} volumes: - - ./.docker/ca-certificates:/usr/local/share/ca-certificates/ - ports: - - "3000:3000" + - ./.docker/ca-certificates:/usr/local/share/ca-certificates:ro + expose: + - "3000" + depends_on: + - backend + labels: + - "traefik.enable=true" + - "traefik.http.routers.front.rule=PathPrefix(`/`)" + - "traefik.http.routers.front.entrypoints=web" + - "traefik.http.services.front.loadbalancer.server.port=3000" networks: - intranet backend: - <<: *backend-common - ports: - - "3001:3000" - - worker: - <<: *backend-common - command: ["--worker"] - deploy: - mode: replicated - replicas: 5 - - - ##################### - # External services # - ##################### + image: docker.io/getplumber/platform-backend:${PLATFORM_VERSION} + restart: unless-stopped + environment: + - PLUMBER_LISTEN_ADDR=0.0.0.0 + - PLUMBER_LISTEN_PORT=8080 + - PLUMBER_BASE_URL=http://localhost:3000 + - PLUMBER_OIDC_AUDIENCE=http://localhost:3000 + - PLUMBER_OIDC_ALLOWED_ISSUERS=${PLUMBER_OIDC_ALLOWED_ISSUERS:-${GITLAB_URL}} + - PLUMBER_COOKIE_SECURE=false + - PLUMBER_TRUST_FORWARDED_FOR=true + - PLUMBER_TOKEN_ENCRYPTION_KEY=${PLUMBER_TOKEN_ENCRYPTION_KEY} + - PLUMBER_DB_HOST=${PLUMBER_DB_HOST:-postgres} + - PLUMBER_DB_PORT=${PLUMBER_DB_PORT:-5432} + - PLUMBER_DB_USER=${PLUMBER_DB_USER:-plumber} + - PLUMBER_DB_PASSWORD=${PLUMBER_DB_PASSWORD} + - PLUMBER_DB_NAME=${PLUMBER_DB_NAME:-plumber} + - PLUMBER_DB_SSLMODE=${PLUMBER_DB_SSLMODE:-disable} + - PLUMBER_DB_TIMEZONE=${PLUMBER_DB_TIMEZONE:-UTC} + - PLUMBER_REDIS_URL=redis://default:${PLUMBER_REDIS_PASSWORD}@redis:6379/0 + - PLUMBER_LOG_LEVEL=${PLUMBER_LOG_LEVEL:-info} + - PLUMBER_LOG_FORMAT=${PLUMBER_LOG_FORMAT:-text} + - PLUMBER_UPDATE_CHECK_URL=${PLUMBER_UPDATE_CHECK_URL:-} + - PLUMBER_PROVIDER_CA_BUNDLE=${PLUMBER_PROVIDER_CA_BUNDLE:-} + volumes: + - ./.docker/ca-certificates:/usr/local/share/ca-certificates:ro + expose: + - "8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + labels: + - "traefik.enable=true" + - "traefik.http.routers.api.rule=PathPrefix(`/api`)" + - "traefik.http.routers.api.entrypoints=web" + - "traefik.http.services.api.loadbalancer.server.port=8080" + networks: + - intranet redis: image: redis:8.4 pull_policy: always restart: unless-stopped - env_file: .env command: - redis-server - - --requirepass ${JOBS_REDIS_PASSWORD} + - --requirepass ${PLUMBER_REDIS_PASSWORD} + environment: + - REDIS_PASSWORD=${PLUMBER_REDIS_PASSWORD} healthcheck: - test: ["CMD-SHELL", "redis-cli -a \"$$JOBS_REDIS_PASSWORD\" ping"] + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping"] interval: 5s timeout: 3s retries: 20 @@ -101,11 +112,10 @@ services: restart: unless-stopped volumes: - postgres-data:/var/lib/postgresql - env_file: .env environment: - - POSTGRES_USER=${JOBS_DB_USER:-jobs} - - POSTGRES_PASSWORD=${JOBS_DB_PASSWORD} - - POSTGRES_DB=${JOBS_DB_NAME:-jobs} + - POSTGRES_USER=${PLUMBER_DB_USER:-plumber} + - POSTGRES_PASSWORD=${PLUMBER_DB_PASSWORD} + - POSTGRES_DB=${PLUMBER_DB_NAME:-plumber} - PGDATA=/var/lib/postgresql/18/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h 127.0.0.1 -p 5432"] diff --git a/compose.yml b/compose.yml index d2fe909..4eda074 100644 --- a/compose.yml +++ b/compose.yml @@ -1,97 +1,91 @@ +# Plumber Platform v2, production stack (single node, Docker Compose). +# +# Same shape as the v1 stack: Traefik terminates TLS on one origin and routes +# /api to the backend and everything else to the frontend (the browser calls +# the API same-origin, so one hostname is mandatory), Redis and an optional +# bundled Postgres. Configure through .env (see .env.example / install.sh). +# v1 lives unchanged in legacy-v1/. The two lines are not compatible. name: "plumber" -x-backend-common: &backend-common - image: docker.io/getplumber/backend:${BACKEND_IMAGE_TAG} - env_file: .env - # The backend runs as the rootless user uid:gid 65532:65532 (distroless nonroot) - # and therefore listens on a non-privileged port (>=1024). - environment: - - JOBS_LISTEN_ADDR=0.0.0.0 - - JOBS_LISTEN_PORT=3000 - - JOBS_CORS_ORIGIN=https://${DOMAIN_NAME} - - JOBS_FRONTEND_URL=https://${DOMAIN_NAME} - - JOBS_SESSION_TTL=168h - - JOBS_DB_HOST=${JOBS_DB_HOST:-postgres} - - JOBS_DB_PORT=${JOBS_DB_PORT:-5432} - - JOBS_DB_USER=${JOBS_DB_USER:-jobs} - - JOBS_DB_NAME=${JOBS_DB_NAME:-jobs} - - JOBS_DB_SSLMODE=${JOBS_DB_SSLMODE:-disable} - - JOBS_DB_TIMEZONE=${JOBS_DB_TIMEZONE:-Europe/Paris} - - JOBS_API_DOMAIN=https://${DOMAIN_NAME}/api - - LOG_LEVEL=info - - LOG_FORMATTER=text - - JOBS_REDIS_HOST=redis - - JOBS_REDIS_PORT=6379 - - JOBS_REDIS_DB=0 - - JOBS_REDIS_USER=default - - JOBS_REDIS_SET_NAMESPACES_TTL=30s - - GITLEAKS_PATH=/opt/gitleaks - - FRONTEND_DOMAIN=${DOMAIN_NAME} - - API_DOMAIN=${DOMAIN_NAME} - - API_PATH=/api - - API_URL=${DOMAIN_NAME}/api - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - required: false - redis: - condition: service_healthy - volumes: - - ./.docker/ca-certificates:/usr/local/share/ca-certificates/ - networks: - - intranet - services: ############ - # Plumber # + # Plumber # ############ frontend: - image: docker.io/getplumber/frontend:${FRONTEND_IMAGE_TAG} - env_file: .env - environment: - - FRONTEND_DOMAIN=${DOMAIN_NAME} - - API_DOMAIN=${DOMAIN_NAME} - - API_PATH=/api - - API_URL=${DOMAIN_NAME}/api + image: docker.io/getplumber/platform-frontend:${PLATFORM_VERSION} restart: unless-stopped + environment: + # Server-side rendering calls the backend over the compose network. + - API_INTERNAL_URL=http://backend:8080/api + - GITLAB_URL=${GITLAB_URL} volumes: - - ./.docker/ca-certificates:/usr/local/share/ca-certificates/ + # Private CA support: every .crt/.pem here is added to Node's trust store + # by the image entrypoint (NODE_EXTRA_CA_CERTS). + - ./.docker/ca-certificates:/usr/local/share/ca-certificates:ro expose: - "3000" + depends_on: + - backend labels: - "traefik.http.routers.front.rule=Host(`${DOMAIN_NAME}`)" - "traefik.http.routers.front.entrypoints=websecure" - "traefik.http.routers.front.tls=true" - "traefik.http.routers.front.tls.certresolver=${CERT_RESOLVER:-}" + - "traefik.http.services.front.loadbalancer.server.port=3000" networks: - intranet backend: - <<: *backend-common + # One process: API, background workers, schedulers and database migrations + # (run on boot). Distroless image, non-root uid 65532, no shell, so there is + # no in-container healthcheck: probe /healthz and /readyz from outside. + image: docker.io/getplumber/platform-backend:${PLATFORM_VERSION} + restart: unless-stopped + environment: + - PLUMBER_LISTEN_ADDR=0.0.0.0 + - PLUMBER_LISTEN_PORT=8080 + - PLUMBER_BASE_URL=https://${DOMAIN_NAME} + - PLUMBER_OIDC_AUDIENCE=https://${DOMAIN_NAME} + - PLUMBER_OIDC_ALLOWED_ISSUERS=${PLUMBER_OIDC_ALLOWED_ISSUERS:-${GITLAB_URL}} + - PLUMBER_COOKIE_SECURE=true + - PLUMBER_TRUST_FORWARDED_FOR=true + - PLUMBER_TRUSTED_PROXY_CIDRS=${PLUMBER_TRUSTED_PROXY_CIDRS:-} + - PLUMBER_TOKEN_ENCRYPTION_KEY=${PLUMBER_TOKEN_ENCRYPTION_KEY} + - PLUMBER_DB_HOST=${PLUMBER_DB_HOST:-postgres} + - PLUMBER_DB_PORT=${PLUMBER_DB_PORT:-5432} + - PLUMBER_DB_USER=${PLUMBER_DB_USER:-plumber} + - PLUMBER_DB_PASSWORD=${PLUMBER_DB_PASSWORD} + - PLUMBER_DB_NAME=${PLUMBER_DB_NAME:-plumber} + - PLUMBER_DB_SSLMODE=${PLUMBER_DB_SSLMODE:-disable} + - PLUMBER_DB_SSLROOTCERT=${PLUMBER_DB_SSLROOTCERT:-} + - PLUMBER_DB_TIMEZONE=${PLUMBER_DB_TIMEZONE:-UTC} + - PLUMBER_REDIS_URL=redis://default:${PLUMBER_REDIS_PASSWORD}@redis:6379/0 + - PLUMBER_SESSION_TTL=${PLUMBER_SESSION_TTL:-168h} + - PLUMBER_LOG_LEVEL=${PLUMBER_LOG_LEVEL:-info} + - PLUMBER_LOG_FORMAT=${PLUMBER_LOG_FORMAT:-json} + - PLUMBER_RETENTION_DAYS=${PLUMBER_RETENTION_DAYS:-30} + - PLUMBER_UPDATE_CHECK_URL=${PLUMBER_UPDATE_CHECK_URL:-} + - PLUMBER_PROVIDER_CA_BUNDLE=${PLUMBER_PROVIDER_CA_BUNDLE:-} + volumes: + - ./.docker/ca-certificates:/usr/local/share/ca-certificates:ro expose: - - "3000" - - "9090" + - "8080" + depends_on: + postgres: + condition: service_healthy + required: false + redis: + condition: service_healthy labels: - "traefik.http.routers.api.rule=Host(`${DOMAIN_NAME}`)&&PathPrefix(`/api`)" - "traefik.http.routers.api.entrypoints=websecure" - "traefik.http.routers.api.tls=true" - "traefik.http.routers.api.tls.certresolver=${CERT_RESOLVER:-}" - - "traefik.http.services.api.loadbalancer.server.port=3000" - - worker: - <<: *backend-common - command: ["--worker"] - deploy: - mode: replicated - replicas: 5 - expose: - - "9090" - labels: - - "traefik.enable=false" - + - "traefik.http.services.api.loadbalancer.server.port=8080" + networks: + - intranet ##################### # External services # @@ -101,17 +95,17 @@ services: image: redis:8.4 pull_policy: always restart: unless-stopped - env_file: - - .env command: - redis-server - - --requirepass ${JOBS_REDIS_PASSWORD} + - --requirepass ${PLUMBER_REDIS_PASSWORD} healthcheck: - test: ["CMD-SHELL", "redis-cli -a \"$$JOBS_REDIS_PASSWORD\" ping"] + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping"] interval: 5s timeout: 3s retries: 20 start_period: 5s + environment: + - REDIS_PASSWORD=${PLUMBER_REDIS_PASSWORD} expose: - "6379" labels: @@ -125,12 +119,13 @@ services: pull_policy: always restart: unless-stopped volumes: + # postgres 18 images refuse a mount at /var/lib/postgresql/data; mount one + # level up and point PGDATA below it (same layout as v1). - postgres-data:/var/lib/postgresql - env_file: .env environment: - - POSTGRES_USER=${JOBS_DB_USER:-jobs} - - POSTGRES_PASSWORD=${JOBS_DB_PASSWORD} - - POSTGRES_DB=${JOBS_DB_NAME:-jobs} + - POSTGRES_USER=${PLUMBER_DB_USER:-plumber} + - POSTGRES_PASSWORD=${PLUMBER_DB_PASSWORD} + - POSTGRES_DB=${PLUMBER_DB_NAME:-plumber} - PGDATA=/var/lib/postgresql/18/data labels: - "traefik.enable=false" @@ -161,7 +156,7 @@ services: - "80:80" - "443:443" command: - - --log.level=DEBUG + - --log.level=INFO - --accesslog=true - --entrypoints.web.address=:80 - --entrypoints.web.http.redirections.entryPoint.to=websecure @@ -175,7 +170,7 @@ services: labels: - "traefik.enable=false" volumes: - - /var/run/docker.sock:/var/run/docker.sock + - /var/run/docker.sock:/var/run/docker.sock:ro - traefik-acme:/acme networks: - intranet @@ -192,7 +187,7 @@ services: - "80:80" - "443:443" command: - - --log.level=DEBUG + - --log.level=INFO - --accesslog=true - --entrypoints.web.address=:80 - --entrypoints.web.http.redirections.entryPoint.to=websecure @@ -204,9 +199,9 @@ services: labels: - "traefik.enable=false" volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./.docker/traefik/certs:/certs - - ./.docker/traefik/certs.yml:/etc/traefik/certs.yml + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./.docker/traefik/certs:/certs:ro + - ./.docker/traefik/certs.yml:/etc/traefik/certs.yml:ro networks: - intranet diff --git a/install.sh b/install.sh index fffaa7a..1a52403 100755 --- a/install.sh +++ b/install.sh @@ -86,6 +86,32 @@ prompt_secret() { done } +prompt_secret_optional() { + local VARNAME="$1" + local MESSAGE="$2" + local VALUE="" + local CHAR="" + + echo -ne "${BOLD}${MESSAGE}${NC} ${DIM}(leave empty to skip)${NC}: " + stty -echo < /dev/tty 2>/dev/null || true + while IFS= read -r -n1 CHAR < /dev/tty; do + if [[ -z "$CHAR" ]]; then + break + elif [[ "$CHAR" == $'\x7f' ]] || [[ "$CHAR" == $'\b' ]]; then + if [ -n "$VALUE" ]; then + VALUE="${VALUE%?}" + echo -ne "\b \b" + fi + else + VALUE+="$CHAR" + echo -ne "*" + fi + done + stty echo < /dev/tty 2>/dev/null || true + echo "" + printf -v "$VARNAME" '%s' "$VALUE" +} + prompt_optional() { local VARNAME="$1" local MESSAGE="$2" @@ -187,6 +213,13 @@ if [ ! -f compose.yml ] || [ ! -f versions.env ]; then echo "" fi +if [ -f .env ]; then + echo -e "${RED}Error:${NC} .env already exists in $(pwd)." + echo " It holds PLUMBER_TOKEN_ENCRYPTION_KEY, which seals every stored GitLab secret and cannot be regenerated" + echo " without losing them. To upgrade an existing install run ./scripts/update.sh; to start over, move .env away first." + exit 1 +fi + # ============================================================================= # Step 2: Choose deployment type # ============================================================================= @@ -248,19 +281,27 @@ if [ "$DEPLOY_TYPE" = "1" ]; then fi # GitLab URL -prompt JOBS_GITLAB_URL "GitLab instance URL (e.g. https://gitlab.example.com)" -# Strip trailing slash -JOBS_GITLAB_URL="${JOBS_GITLAB_URL%/}" +prompt GITLAB_URL "GitLab instance URL (e.g. https://gitlab.example.com)" +GITLAB_URL="${GITLAB_URL%/}" +if [[ ! "$GITLAB_URL" =~ ^https?:// ]]; then + GITLAB_URL="https://${GITLAB_URL}" +fi -# Organization +# Connection scope echo "" -echo -e "${DIM}Leave empty to connect Plumber to the entire GitLab instance${NC}" -echo -e "${DIM}(requires GitLab instance Admin to run onboarding).${NC}" -echo -e "${DIM}Or enter a group path to limit Plumber to that group${NC}" -echo -e "${DIM}(requires at least Maintainer role in the group).${NC}" -echo -e "${DIM}On GitLab.com or non-self-hosted instances, use the exact group path as defined in GitLab (the 'full_path'),${NC}" -echo -e "${DIM}which may differ from the display name and can include a suffix added by GitLab for uniqueness.${NC}" -prompt_optional ORGANIZATION "GitLab group path" +prompt_choice SCOPE_CHOICE "Connection scope:" \ + "Whole GitLab instance (OAuth application created by an instance Admin)" \ + "One root group (OAuth application created in that group)" +if [ "$SCOPE_CHOICE" = "2" ]; then + PLUMBER_SCOPE="group" + echo -e "${DIM}Use the exact group path as GitLab shows it (the 'full_path', e.g. my-company).${NC}" + prompt ROOT_GROUP "Root group path" + ROOT_GROUP="${ROOT_GROUP#/}" + ROOT_GROUP="${ROOT_GROUP%/}" +else + PLUMBER_SCOPE="instance" + ROOT_GROUP="" +fi # GitLab OIDC echo "" @@ -268,29 +309,23 @@ echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ echo -e "${BOLD}GitLab OIDC Application${NC}" echo "" -# Ensure GitLab URL has https:// scheme for clickable terminal links -GITLAB_BASE_URL="${JOBS_GITLAB_URL}" -if [[ ! "$GITLAB_BASE_URL" =~ ^https?:// ]]; then - GITLAB_BASE_URL="https://${GITLAB_BASE_URL}" +if [ "$PLUMBER_SCOPE" = "group" ]; then + GITLAB_APP_URL="${GITLAB_URL}/groups/${ROOT_GROUP}/-/settings/applications" +else + GITLAB_APP_URL="${GITLAB_URL}/admin/applications" fi -# Build direct link to the GitLab application creation page -if [ -n "${ORGANIZATION}" ]; then - GITLAB_APP_URL="${GITLAB_BASE_URL}/groups/${ORGANIZATION}/-/settings/applications" +if [ "$DEPLOY_TYPE" = "1" ]; then + PLUMBER_URL="https://${DOMAIN_NAME}" else - GITLAB_APP_URL="${GITLAB_BASE_URL}/admin/applications" + PLUMBER_URL="http://localhost:3000" fi +REDIRECT_URI="${PLUMBER_URL}/api/v1/auth/callback" echo " 1. Open this link to create a new application:" echo "" echo -e " ${BOLD}${GITLAB_APP_URL}${NC}" echo "" -if [ "$DEPLOY_TYPE" = "1" ]; then - REDIRECT_URI="https://${DOMAIN_NAME}/api/auth/gitlab/callback" -else - REDIRECT_URI="http://localhost:3001/api/auth/gitlab/callback" -fi - echo " 2. Fill in the following:" echo -e " - Name: ${BOLD}Plumber${NC}" echo -e " - Redirect URI: ${BOLD}${REDIRECT_URI}${NC}" @@ -303,6 +338,19 @@ echo "" prompt GITLAB_OAUTH2_CLIENT_ID "Application ID" prompt_secret GITLAB_OAUTH2_CLIENT_SECRET "Secret" +# Access token (optional): lets Plumber sync projects right away. Without it +# the connection is stored and an Admin adds the token later in Settings. +echo "" +echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" +echo -e "${BOLD}GitLab access token${NC} ${DIM}(optional)${NC}" +echo "" +echo -e "${DIM}A group or personal access token with the 'api' scope, used by Plumber to${NC}" +echo -e "${DIM}read projects and pipelines. It is stored encrypted, never in .env.${NC}" +if [ "$PLUMBER_SCOPE" = "group" ]; then + echo -e "${DIM}With a group scope, the token also resolves the root group immediately.${NC}" +fi +prompt_secret_optional GITLAB_ORG_TOKEN "Access token" + # Certificate method & Database (production only) if [ "$DEPLOY_TYPE" = "1" ]; then @@ -328,31 +376,33 @@ if [ "$DEPLOY_TYPE" = "1" ]; then else echo -e " ${YELLOW}!${NC} Certificate files not found yet (add them before starting)" fi + fi - # Custom CA + # Custom CA + USE_CUSTOM_CA=false + echo "" + echo -e "${BOLD}Custom Certificate Authority${NC}" + echo "" + echo -e "${DIM}If your GitLab instance or your Plumber certificates are signed by${NC}" + echo -e "${DIM}a custom Certificate Authority (private CA), Plumber needs the root${NC}" + echo -e "${DIM}CA certificate to trust those connections.${NC}" + echo "" + + if prompt_confirm "Are you using a custom CA?" "N"; then + USE_CUSTOM_CA=true echo "" - echo -e "${BOLD}Custom Certificate Authority${NC}" + echo " Add your root CA certificate file (.pem or .crt) to:" echo "" - echo -e "${DIM}If your GitLab instance or your Plumber certificates are signed by${NC}" - echo -e "${DIM}a custom Certificate Authority (private CA), Plumber needs the root${NC}" - echo -e "${DIM}CA certificate to trust those connections.${NC}" + echo -e " ${BOLD}.docker/ca-certificates/${NC}" echo "" - if prompt_confirm "Are you using a custom CA?" "N"; then - echo "" - echo " Add your root CA certificate file (.pem or .crt) to:" - echo "" - echo -e " ${BOLD}.docker/ca-certificates/${NC}" - echo "" - - mkdir -p .docker/ca-certificates + mkdir -p .docker/ca-certificates - CA_FILES=$(find .docker/ca-certificates -maxdepth 1 -type f \( -name "*.pem" -o -name "*.crt" \) 2>/dev/null | wc -l | tr -d ' ') - if [ "$CA_FILES" -gt 0 ]; then - echo -e " ${GREEN}โœ“${NC} Found ${CA_FILES} CA certificate(s) in .docker/ca-certificates/" - else - echo -e " ${YELLOW}!${NC} No CA certificates found yet (add them before starting)" - fi + CA_FILES=$(find .docker/ca-certificates -maxdepth 1 -type f \( -name "*.pem" -o -name "*.crt" \) 2>/dev/null | wc -l | tr -d ' ') + if [ "$CA_FILES" -gt 0 ]; then + echo -e " ${GREEN}โœ“${NC} Found ${CA_FILES} CA certificate(s) in .docker/ca-certificates/" + else + echo -e " ${YELLOW}!${NC} No CA certificates found yet (add them before starting)" fi fi @@ -368,18 +418,16 @@ if [ "$DEPLOY_TYPE" = "1" ]; then else DB_PROFILE="" echo "" - prompt JOBS_DB_HOST "Database host" - prompt_optional JOBS_DB_PORT "Database port" "5432" - prompt JOBS_DB_USER "Database user" - prompt_optional JOBS_DB_NAME "Database name" "plumber" - prompt_secret JOBS_DB_PASSWORD_EXT "Database password" + prompt PLUMBER_DB_HOST "Database host" + prompt_optional PLUMBER_DB_PORT "Database port" "5432" + prompt PLUMBER_DB_USER "Database user" + prompt_optional PLUMBER_DB_NAME "Database name" "plumber" + prompt_secret PLUMBER_DB_PASSWORD_EXT "Database password" echo "" - echo -e "${DIM}SSL mode options: disable, require, verify-ca${NC}" - prompt_optional JOBS_DB_SSLMODE "SSL mode" "disable" + echo -e "${DIM}SSL mode options: disable, require, verify-ca, verify-full${NC}" + prompt_optional PLUMBER_DB_SSLMODE "SSL mode" "disable" - # Detect server timezone - SERVER_TZ=$(timedatectl show --property=Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo "UTC") - prompt_optional JOBS_DB_TIMEZONE "Timezone" "${SERVER_TZ}" + prompt_optional PLUMBER_DB_TIMEZONE "Timezone" "UTC" fi @@ -394,35 +442,48 @@ echo "" echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" echo "Generating secrets..." -SECRET_KEY=$(openssl rand -hex 32) -if [ -z "${JOBS_DB_PASSWORD_EXT:-}" ]; then - JOBS_DB_PASSWORD=$(openssl rand -hex 16) +PLUMBER_TOKEN_ENCRYPTION_KEY=$(openssl rand -hex 32) +if [ -z "${PLUMBER_DB_PASSWORD_EXT:-}" ]; then + PLUMBER_DB_PASSWORD=$(openssl rand -hex 16) else - JOBS_DB_PASSWORD="${JOBS_DB_PASSWORD_EXT}" + PLUMBER_DB_PASSWORD="${PLUMBER_DB_PASSWORD_EXT}" fi -JOBS_REDIS_PASSWORD=$(openssl rand -hex 16) +PLUMBER_REDIS_PASSWORD=$(openssl rand -hex 16) echo -e "${GREEN}โœ“${NC} Secrets generated" +echo -e "${DIM} PLUMBER_TOKEN_ENCRYPTION_KEY seals every stored GitLab secret. Back up .env: the key cannot be rotated.${NC}" # ============================================================================= # Step 6: Read image tags from versions.env # ============================================================================= +# shellcheck disable=SC1091 source versions.env - -if [ -z "${FRONTEND_IMAGE_TAG:-}" ] || [ -z "${BACKEND_IMAGE_TAG:-}" ]; then - echo -e "${RED}Error:${NC} versions.env is missing image tags." +if [ -z "${PLATFORM_VERSION:-}" ]; then + echo -e "${RED}Error:${NC} versions.env is missing PLATFORM_VERSION." exit 1 fi - -echo -e "${GREEN}โœ“${NC} Image versions: frontend=${FRONTEND_IMAGE_TAG}, backend=${BACKEND_IMAGE_TAG}" +echo -e "${GREEN}โœ“${NC} Platform version: ${PLATFORM_VERSION}" # ============================================================================= # Step 7: Write .env # ============================================================================= +write_common_env() { + echo "" + echo "# GitLab" + env_line GITLAB_URL "$GITLAB_URL" + echo "" + echo "# Secrets (PLUMBER_TOKEN_ENCRYPTION_KEY cannot be rotated: back this file up)" + env_line PLUMBER_TOKEN_ENCRYPTION_KEY "$PLUMBER_TOKEN_ENCRYPTION_KEY" + env_line PLUMBER_DB_PASSWORD "$PLUMBER_DB_PASSWORD" + env_line PLUMBER_REDIS_PASSWORD "$PLUMBER_REDIS_PASSWORD" + echo "" + echo "# Image version (managed by versions.env, synced by scripts/update.sh)" + env_line PLATFORM_VERSION "$PLATFORM_VERSION" +} + if [ "$DEPLOY_TYPE" = "2" ]; then - # Local development .env (simpler, no domain/profiles/cert-resolver) { cat <<'HEADER' ############################################################################### @@ -430,26 +491,9 @@ if [ "$DEPLOY_TYPE" = "2" ]; then # Documentation: https://getplumber.io/docs/installation/local-docker-compose # ############################################################################### HEADER - echo "" - echo "# Main" - env_line JOBS_GITLAB_URL "$JOBS_GITLAB_URL" - env_line ORGANIZATION "$ORGANIZATION" - echo "" - echo "# GitLab OIDC" - env_line GITLAB_OAUTH2_CLIENT_ID "$GITLAB_OAUTH2_CLIENT_ID" - env_line GITLAB_OAUTH2_CLIENT_SECRET "$GITLAB_OAUTH2_CLIENT_SECRET" - echo "" - echo "# Secrets" - env_line SECRET_KEY "$SECRET_KEY" - env_line JOBS_DB_PASSWORD "$JOBS_DB_PASSWORD" - env_line JOBS_REDIS_PASSWORD "$JOBS_REDIS_PASSWORD" - echo "" - echo "# Image versions (managed by versions.env)" - env_line FRONTEND_IMAGE_TAG "$FRONTEND_IMAGE_TAG" - env_line BACKEND_IMAGE_TAG "$BACKEND_IMAGE_TAG" + write_common_env } > .env else - # Production .env { cat <<'HEADER' ########################################################################## @@ -460,40 +504,30 @@ HEADER echo "" echo "# Main configuration" env_line DOMAIN_NAME "$DOMAIN_NAME" - env_line JOBS_GITLAB_URL "$JOBS_GITLAB_URL" - env_line ORGANIZATION "$ORGANIZATION" - echo "" - echo "# GitLab OIDC" - env_line GITLAB_OAUTH2_CLIENT_ID "$GITLAB_OAUTH2_CLIENT_ID" - env_line GITLAB_OAUTH2_CLIENT_SECRET "$GITLAB_OAUTH2_CLIENT_SECRET" - echo "" - echo "# Secrets" - env_line SECRET_KEY "$SECRET_KEY" - env_line JOBS_DB_PASSWORD "$JOBS_DB_PASSWORD" - env_line JOBS_REDIS_PASSWORD "$JOBS_REDIS_PASSWORD" + write_common_env echo "" echo "# Deployment profile" env_line COMPOSE_PROFILES "$COMPOSE_PROFILES" env_line CERT_RESOLVER "$CERT_RESOLVER" - echo "" - echo "# Image versions (managed by versions.env)" - env_line FRONTEND_IMAGE_TAG "$FRONTEND_IMAGE_TAG" - env_line BACKEND_IMAGE_TAG "$BACKEND_IMAGE_TAG" if [ "${DB_CHOICE:-}" = "2" ]; then echo "" echo "# External database configuration" - env_line JOBS_DB_HOST "$JOBS_DB_HOST" - env_line JOBS_DB_PORT "$JOBS_DB_PORT" - env_line JOBS_DB_USER "$JOBS_DB_USER" - env_line JOBS_DB_NAME "$JOBS_DB_NAME" - env_line JOBS_DB_SSLMODE "$JOBS_DB_SSLMODE" - env_line JOBS_DB_TIMEZONE "$JOBS_DB_TIMEZONE" + env_line PLUMBER_DB_HOST "$PLUMBER_DB_HOST" + env_line PLUMBER_DB_PORT "$PLUMBER_DB_PORT" + env_line PLUMBER_DB_USER "$PLUMBER_DB_USER" + env_line PLUMBER_DB_NAME "$PLUMBER_DB_NAME" + env_line PLUMBER_DB_SSLMODE "$PLUMBER_DB_SSLMODE" + env_line PLUMBER_DB_TIMEZONE "$PLUMBER_DB_TIMEZONE" fi } > .env fi echo -e "${GREEN}โœ“${NC} Configuration written to .env" +if [ "${USE_CUSTOM_CA:-false}" = true ]; then + bash scripts/ca-bundle.sh +fi + # ============================================================================= # Step 8: Run post-config checks # ============================================================================= @@ -516,27 +550,82 @@ echo "" if [ "$DEPLOY_TYPE" = "2" ]; then COMPOSE_CMD="docker compose -f compose.local.yml" - PLUMBER_URL="http://localhost:3000" + NETWORK_NAME="plumber-local_intranet" else COMPOSE_CMD="docker compose" - PLUMBER_URL="https://${DOMAIN_NAME}" + NETWORK_NAME="plumber_intranet" fi +print_bootstrap_hint() { + echo "" + echo " To configure the GitLab connection later, run from this directory:" + echo "" + echo " read -rs -p \"OAuth application secret: \" PLUMBER_BOOTSTRAP_CLIENT_SECRET; echo; export PLUMBER_BOOTSTRAP_CLIENT_SECRET" + echo -e " ${BOLD}${COMPOSE_CMD} exec -T -e PLUMBER_BOOTSTRAP_CLIENT_SECRET \\" + echo -e " backend plumber-bootstrap -base-url ${GITLAB_URL} -client-id ${GITLAB_OAUTH2_CLIENT_ID} -scope ${PLUMBER_SCOPE}${ROOT_GROUP:+ -root-group ${ROOT_GROUP}}${NC}" + echo "" + echo " Export PLUMBER_BOOTSTRAP_TOKEN the same way and add -e PLUMBER_BOOTSTRAP_TOKEN to store the access token in the same run." +} + +run_bootstrap() { + echo "" + echo "Waiting for the backend (first boot runs the database migrations)..." + local READY=false + for _ in $(seq 1 60); do + if docker run --rm --network "$NETWORK_NAME" curlimages/curl:8.11.1 -sf http://backend:8080/readyz > /dev/null 2>&1; then + READY=true + break + fi + sleep 3 + done + if [ "$READY" != true ]; then + echo -e "${RED}!${NC} The backend did not become ready in 3 minutes. Check: ${COMPOSE_CMD} logs backend" + print_bootstrap_hint + return 1 + fi + echo -e "${GREEN}โœ“${NC} Backend ready" + + echo "Configuring the GitLab connection..." + export PLUMBER_BOOTSTRAP_CLIENT_SECRET="$GITLAB_OAUTH2_CLIENT_SECRET" + local -a EXEC_ENV=(-e PLUMBER_BOOTSTRAP_CLIENT_SECRET) + local -a ARGS=(-base-url "$GITLAB_URL" -client-id "$GITLAB_OAUTH2_CLIENT_ID" -scope "$PLUMBER_SCOPE") + if [ "$PLUMBER_SCOPE" = "group" ]; then + ARGS+=(-root-group "$ROOT_GROUP") + fi + if [ -n "${GITLAB_ORG_TOKEN:-}" ]; then + export PLUMBER_BOOTSTRAP_TOKEN="$GITLAB_ORG_TOKEN" + EXEC_ENV+=(-e PLUMBER_BOOTSTRAP_TOKEN) + fi + # shellcheck disable=SC2086 + if $COMPOSE_CMD exec -T "${EXEC_ENV[@]}" backend plumber-bootstrap "${ARGS[@]}"; then + echo -e "${GREEN}โœ“${NC} GitLab connection configured" + unset PLUMBER_BOOTSTRAP_CLIENT_SECRET PLUMBER_BOOTSTRAP_TOKEN + else + echo -e "${RED}!${NC} Bootstrap failed (see the message above)." + unset PLUMBER_BOOTSTRAP_CLIENT_SECRET PLUMBER_BOOTSTRAP_TOKEN + print_bootstrap_hint + return 1 + fi +} + if prompt_confirm "Start Plumber now?"; then echo "" echo "Starting Plumber..." $COMPOSE_CMD up -d + run_bootstrap || true echo "" echo -e "${GREEN}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}" echo -e "${GREEN}โ•‘ Plumber is starting! โ•‘${NC}" echo -e "${GREEN}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" echo "" echo -e " Visit: ${BOLD}${PLUMBER_URL}${NC}" + echo -e " ${DIM}The first GitLab account that signs in becomes this organisation's Admin.${NC}" echo "" echo " Useful commands:" echo " ${COMPOSE_CMD} ps # Check service status" echo " ${COMPOSE_CMD} logs -f # View logs" echo " ./scripts/update.sh # Update to latest version" + echo " ./scripts/backup.sh 18 # Back up the database and .env" echo "" else echo "" @@ -546,5 +635,6 @@ else echo -e " ${BOLD}${COMPOSE_CMD} up -d${NC}" echo "" echo -e " Then visit: ${BOLD}${PLUMBER_URL}${NC}" + print_bootstrap_hint echo "" fi diff --git a/legacy-v1/.docker/ca-certificates/.gitkeep b/legacy-v1/.docker/ca-certificates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/legacy-v1/.docker/traefik/certs.yml b/legacy-v1/.docker/traefik/certs.yml new file mode 100644 index 0000000..47b8cde --- /dev/null +++ b/legacy-v1/.docker/traefik/certs.yml @@ -0,0 +1,4 @@ +tls: + certificates: + - certFile: /certs/plumber_fullchain.pem + keyFile: /certs/plumber_privkey.pem diff --git a/legacy-v1/.docker/traefik/certs/.gitkeep b/legacy-v1/.docker/traefik/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/legacy-v1/.env.example b/legacy-v1/.env.example new file mode 100644 index 0000000..ba9bc13 --- /dev/null +++ b/legacy-v1/.env.example @@ -0,0 +1,29 @@ +########################################################################## +# Plumber configuration file # +# Documentation: https://getplumber.io/docs/installation/docker-compose/ # +########################################################################## + +# Required configuration +DOMAIN_NAME="" +JOBS_GITLAB_URL="" +ORGANIZATION="" +GITLAB_OAUTH2_CLIENT_ID="" +GITLAB_OAUTH2_CLIENT_SECRET="" + +# Auto-generated secrets (populated by install script) +SECRET_KEY="" +JOBS_DB_PASSWORD="" +JOBS_REDIS_PASSWORD="" + +# Deployment profile (set by install script) +# Options: letsencrypt or custom-certs, combined with internal-db or omit for external +COMPOSE_PROFILES="letsencrypt,internal-db" +CERT_RESOLVER="le" + +# Optional: Override database defaults (only needed when NOT using internal-db profile) +# JOBS_DB_HOST="your-db-host" +# JOBS_DB_PORT="5432" +# JOBS_DB_USER="jobs" +# JOBS_DB_NAME="jobs" +# JOBS_DB_SSLMODE="disable" +# JOBS_DB_TIMEZONE="Europe/Paris" diff --git a/.env.local.example b/legacy-v1/.env.local.example similarity index 100% rename from .env.local.example rename to legacy-v1/.env.local.example diff --git a/legacy-v1/README.md b/legacy-v1/README.md new file mode 100644 index 0000000..f80f781 --- /dev/null +++ b/legacy-v1/README.md @@ -0,0 +1,47 @@ +# Plumber v1 (legacy) + +The v1 self-managed line lives here, **unchanged**. Everything under this directory (`compose.yml`, +`compose.local.yml`, `.env.example`, `.env.local.example`, `configmap.yml.example`, +`configmap.local.yml.example`, `podman.yml.example`, `podman.local.yml.example`, `.docker/`, +`charts/`, `install.sh`, `scripts/`, `versions.env`) is exactly what used to live at the repository +root, moved here as a unit on 2026-08-25 when v2 took over the root layout (compose-first, see the +root `README.md`). Nothing inside this directory was edited as part of that move: every script's +own relative references (`compose.yml`, `versions.env`, `scripts/*.sh`) still resolve, because the +whole set moved together. + +## Why v1 still lives here + +v1 (the Helm/Compose/podman install described here) is still supported for existing installs. It is +not being deprecated by this move, only relocated so the repository root can carry the new v2 line +(a different product generation, incompatible configuration, `docker.io/getplumber/platform-*` +images instead of v1's own). Run v1 exactly as documented below, from inside this directory. + +## Installing v1 + +- **Docker Compose**: + + ```bash + curl -fsSL https://raw.githubusercontent.com/getplumber/platform/main/legacy-v1/install.sh | bash + ``` + +- **Kubernetes with Helm**: see `charts/plumber/README.md` in this directory. + +- **Podman**: see `podman.yml.example` / `podman.local.yml.example` in this directory, and + `scripts/backup_podman.sh` / `scripts/restore_podman.sh`. + +## A note on documentation links + +The public docs site (getplumber.io/docs/installation/...) linked v1's Compose/Helm instructions +at their old repository-root paths. **Those links now need the `legacy-v1/` path prefix** (e.g. a +raw-file link to `install.sh` becomes `.../platform/main/legacy-v1/install.sh`, a link into +`charts/plumber` becomes `.../platform/main/legacy-v1/charts/plumber`). This directory does not fix +the site itself; whoever owns getplumber.io's docs content needs to update those links separately. + +## Upgrading within v1 + +Nothing about v1's own upgrade story changed: `scripts/update.sh` still reads `versions.env` from +this directory and syncs image tags into your `.env`, exactly as before the move. + +## v2 + +For the new install line, see the repository root `README.md`. diff --git a/charts/plumber/CONTIBUTING.md b/legacy-v1/charts/plumber/CONTIBUTING.md similarity index 100% rename from charts/plumber/CONTIBUTING.md rename to legacy-v1/charts/plumber/CONTIBUTING.md diff --git a/charts/plumber/Chart.yaml b/legacy-v1/charts/plumber/Chart.yaml similarity index 100% rename from charts/plumber/Chart.yaml rename to legacy-v1/charts/plumber/Chart.yaml diff --git a/charts/plumber/README.md b/legacy-v1/charts/plumber/README.md similarity index 100% rename from charts/plumber/README.md rename to legacy-v1/charts/plumber/README.md diff --git a/charts/plumber/templates/NOTES.txt b/legacy-v1/charts/plumber/templates/NOTES.txt similarity index 100% rename from charts/plumber/templates/NOTES.txt rename to legacy-v1/charts/plumber/templates/NOTES.txt diff --git a/charts/plumber/templates/_helpers.tpl b/legacy-v1/charts/plumber/templates/_helpers.tpl similarity index 100% rename from charts/plumber/templates/_helpers.tpl rename to legacy-v1/charts/plumber/templates/_helpers.tpl diff --git a/charts/plumber/templates/configmap.yaml b/legacy-v1/charts/plumber/templates/configmap.yaml similarity index 100% rename from charts/plumber/templates/configmap.yaml rename to legacy-v1/charts/plumber/templates/configmap.yaml diff --git a/charts/plumber/templates/deployment.yaml b/legacy-v1/charts/plumber/templates/deployment.yaml similarity index 100% rename from charts/plumber/templates/deployment.yaml rename to legacy-v1/charts/plumber/templates/deployment.yaml diff --git a/charts/plumber/templates/ingress.yaml b/legacy-v1/charts/plumber/templates/ingress.yaml similarity index 100% rename from charts/plumber/templates/ingress.yaml rename to legacy-v1/charts/plumber/templates/ingress.yaml diff --git a/charts/plumber/templates/redis-deployment.yaml b/legacy-v1/charts/plumber/templates/redis-deployment.yaml similarity index 100% rename from charts/plumber/templates/redis-deployment.yaml rename to legacy-v1/charts/plumber/templates/redis-deployment.yaml diff --git a/charts/plumber/templates/redis-service.yaml b/legacy-v1/charts/plumber/templates/redis-service.yaml similarity index 100% rename from charts/plumber/templates/redis-service.yaml rename to legacy-v1/charts/plumber/templates/redis-service.yaml diff --git a/charts/plumber/templates/service.yaml b/legacy-v1/charts/plumber/templates/service.yaml similarity index 100% rename from charts/plumber/templates/service.yaml rename to legacy-v1/charts/plumber/templates/service.yaml diff --git a/charts/plumber/templates/statefulset-psql.yaml b/legacy-v1/charts/plumber/templates/statefulset-psql.yaml similarity index 100% rename from charts/plumber/templates/statefulset-psql.yaml rename to legacy-v1/charts/plumber/templates/statefulset-psql.yaml diff --git a/charts/plumber/tests/configmap_test.yaml b/legacy-v1/charts/plumber/tests/configmap_test.yaml similarity index 100% rename from charts/plumber/tests/configmap_test.yaml rename to legacy-v1/charts/plumber/tests/configmap_test.yaml diff --git a/charts/plumber/tests/deployment_test.yaml b/legacy-v1/charts/plumber/tests/deployment_test.yaml similarity index 100% rename from charts/plumber/tests/deployment_test.yaml rename to legacy-v1/charts/plumber/tests/deployment_test.yaml diff --git a/charts/plumber/values.yaml b/legacy-v1/charts/plumber/values.yaml similarity index 100% rename from charts/plumber/values.yaml rename to legacy-v1/charts/plumber/values.yaml diff --git a/legacy-v1/compose.local.yml b/legacy-v1/compose.local.yml new file mode 100644 index 0000000..3efc28c --- /dev/null +++ b/legacy-v1/compose.local.yml @@ -0,0 +1,125 @@ +name: "plumber-local" + +x-backend-common: &backend-common + image: docker.io/getplumber/backend:${BACKEND_IMAGE_TAG} + env_file: .env + environment: + - JOBS_LISTEN_ADDR=0.0.0.0 + - JOBS_LISTEN_PORT=3000 + - JOBS_CORS_ORIGIN=http://localhost:3000 + - JOBS_FRONTEND_URL=http://localhost:3000 + - JOBS_SESSION_TTL=168h + - JOBS_DB_HOST=${JOBS_DB_HOST:-postgres} + - JOBS_DB_PORT=${JOBS_DB_PORT:-5432} + - JOBS_DB_USER=${JOBS_DB_USER:-jobs} + - JOBS_DB_NAME=${JOBS_DB_NAME:-jobs} + - JOBS_DB_SSLMODE=${JOBS_DB_SSLMODE:-disable} + - JOBS_DB_TIMEZONE=${JOBS_DB_TIMEZONE:-Europe/Paris} + - JOBS_API_DOMAIN=http://localhost:3001/api + - LOG_LEVEL=info + - LOG_FORMATTER=text + - JOBS_REDIS_HOST=redis + - JOBS_REDIS_PORT=6379 + - JOBS_REDIS_DB=0 + - JOBS_REDIS_USER=default + - JOBS_REDIS_SET_NAMESPACES_TTL=30s + - GITLEAKS_PATH=/opt/gitleaks + - FRONTEND_DOMAIN=localhost:3000 + - API_DOMAIN=http://localhost:3001 + - API_PATH=/api + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./.docker/ca-certificates:/usr/local/share/ca-certificates/ + networks: + - intranet + +services: + + ############ + # Plumber # + ############ + + frontend: + image: docker.io/getplumber/frontend:${FRONTEND_IMAGE_TAG} + env_file: .env + environment: + - FRONTEND_DOMAIN=localhost:3000 + - API_DOMAIN=http://localhost:3001 + - API_PATH=/api + restart: unless-stopped + volumes: + - ./.docker/ca-certificates:/usr/local/share/ca-certificates/ + ports: + - "3000:3000" + networks: + - intranet + + backend: + <<: *backend-common + ports: + - "3001:3000" + + worker: + <<: *backend-common + command: ["--worker"] + deploy: + mode: replicated + replicas: 5 + + + ##################### + # External services # + ##################### + + redis: + image: redis:8.4 + pull_policy: always + restart: unless-stopped + env_file: .env + command: + - redis-server + - --requirepass ${JOBS_REDIS_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$$JOBS_REDIS_PASSWORD\" ping"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 5s + expose: + - "6379" + networks: + - intranet + + postgres: + image: postgres:18 + pull_policy: always + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql + env_file: .env + environment: + - POSTGRES_USER=${JOBS_DB_USER:-jobs} + - POSTGRES_PASSWORD=${JOBS_DB_PASSWORD} + - POSTGRES_DB=${JOBS_DB_NAME:-jobs} + - PGDATA=/var/lib/postgresql/18/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h 127.0.0.1 -p 5432"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + expose: + - "5432" + networks: + - intranet + +networks: + intranet: + +volumes: + postgres-data: diff --git a/legacy-v1/compose.yml b/legacy-v1/compose.yml new file mode 100644 index 0000000..d2fe909 --- /dev/null +++ b/legacy-v1/compose.yml @@ -0,0 +1,218 @@ +name: "plumber" + +x-backend-common: &backend-common + image: docker.io/getplumber/backend:${BACKEND_IMAGE_TAG} + env_file: .env + # The backend runs as the rootless user uid:gid 65532:65532 (distroless nonroot) + # and therefore listens on a non-privileged port (>=1024). + environment: + - JOBS_LISTEN_ADDR=0.0.0.0 + - JOBS_LISTEN_PORT=3000 + - JOBS_CORS_ORIGIN=https://${DOMAIN_NAME} + - JOBS_FRONTEND_URL=https://${DOMAIN_NAME} + - JOBS_SESSION_TTL=168h + - JOBS_DB_HOST=${JOBS_DB_HOST:-postgres} + - JOBS_DB_PORT=${JOBS_DB_PORT:-5432} + - JOBS_DB_USER=${JOBS_DB_USER:-jobs} + - JOBS_DB_NAME=${JOBS_DB_NAME:-jobs} + - JOBS_DB_SSLMODE=${JOBS_DB_SSLMODE:-disable} + - JOBS_DB_TIMEZONE=${JOBS_DB_TIMEZONE:-Europe/Paris} + - JOBS_API_DOMAIN=https://${DOMAIN_NAME}/api + - LOG_LEVEL=info + - LOG_FORMATTER=text + - JOBS_REDIS_HOST=redis + - JOBS_REDIS_PORT=6379 + - JOBS_REDIS_DB=0 + - JOBS_REDIS_USER=default + - JOBS_REDIS_SET_NAMESPACES_TTL=30s + - GITLEAKS_PATH=/opt/gitleaks + - FRONTEND_DOMAIN=${DOMAIN_NAME} + - API_DOMAIN=${DOMAIN_NAME} + - API_PATH=/api + - API_URL=${DOMAIN_NAME}/api + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + required: false + redis: + condition: service_healthy + volumes: + - ./.docker/ca-certificates:/usr/local/share/ca-certificates/ + networks: + - intranet + +services: + + ############ + # Plumber # + ############ + + frontend: + image: docker.io/getplumber/frontend:${FRONTEND_IMAGE_TAG} + env_file: .env + environment: + - FRONTEND_DOMAIN=${DOMAIN_NAME} + - API_DOMAIN=${DOMAIN_NAME} + - API_PATH=/api + - API_URL=${DOMAIN_NAME}/api + restart: unless-stopped + volumes: + - ./.docker/ca-certificates:/usr/local/share/ca-certificates/ + expose: + - "3000" + labels: + - "traefik.http.routers.front.rule=Host(`${DOMAIN_NAME}`)" + - "traefik.http.routers.front.entrypoints=websecure" + - "traefik.http.routers.front.tls=true" + - "traefik.http.routers.front.tls.certresolver=${CERT_RESOLVER:-}" + networks: + - intranet + + backend: + <<: *backend-common + expose: + - "3000" + - "9090" + labels: + - "traefik.http.routers.api.rule=Host(`${DOMAIN_NAME}`)&&PathPrefix(`/api`)" + - "traefik.http.routers.api.entrypoints=websecure" + - "traefik.http.routers.api.tls=true" + - "traefik.http.routers.api.tls.certresolver=${CERT_RESOLVER:-}" + - "traefik.http.services.api.loadbalancer.server.port=3000" + + worker: + <<: *backend-common + command: ["--worker"] + deploy: + mode: replicated + replicas: 5 + expose: + - "9090" + labels: + - "traefik.enable=false" + + + ##################### + # External services # + ##################### + + redis: + image: redis:8.4 + pull_policy: always + restart: unless-stopped + env_file: + - .env + command: + - redis-server + - --requirepass ${JOBS_REDIS_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$$JOBS_REDIS_PASSWORD\" ping"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 5s + expose: + - "6379" + labels: + - "traefik.enable=false" + networks: + - intranet + + postgres: + profiles: ["internal-db"] + image: postgres:18 + pull_policy: always + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql + env_file: .env + environment: + - POSTGRES_USER=${JOBS_DB_USER:-jobs} + - POSTGRES_PASSWORD=${JOBS_DB_PASSWORD} + - POSTGRES_DB=${JOBS_DB_NAME:-jobs} + - PGDATA=/var/lib/postgresql/18/data + labels: + - "traefik.enable=false" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h 127.0.0.1 -p 5432"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + expose: + - "5432" + networks: + - intranet + + ################# + # Reverse proxy # + ################# + + traefik-le: + profiles: ["letsencrypt"] + image: traefik:3.6 + pull_policy: always + restart: unless-stopped + depends_on: + - frontend + - backend + ports: + - "80:80" + - "443:443" + command: + - --log.level=DEBUG + - --accesslog=true + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entryPoint.to=websecure + - --entrypoints.web.http.redirections.entryPoint.scheme=https + - --entrypoints.web.http.redirections.entrypoint.permanent=true + - --entrypoints.websecure.address=:443 + - --providers.docker=true + - --certificatesresolvers.le.acme.caserver=https://acme-v02.api.letsencrypt.org/directory + - --certificatesresolvers.le.acme.storage=/acme/acme.json + - --certificatesresolvers.le.acme.tlschallenge=true + labels: + - "traefik.enable=false" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - traefik-acme:/acme + networks: + - intranet + + traefik-custom-certs: + profiles: ["custom-certs"] + image: traefik:3.6 + pull_policy: always + restart: unless-stopped + depends_on: + - frontend + - backend + ports: + - "80:80" + - "443:443" + command: + - --log.level=DEBUG + - --accesslog=true + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entryPoint.to=websecure + - --entrypoints.web.http.redirections.entryPoint.scheme=https + - --entrypoints.web.http.redirections.entrypoint.permanent=true + - --entrypoints.websecure.address=:443 + - --providers.docker=true + - --providers.file.filename=/etc/traefik/certs.yml + labels: + - "traefik.enable=false" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./.docker/traefik/certs:/certs + - ./.docker/traefik/certs.yml:/etc/traefik/certs.yml + networks: + - intranet + +networks: + intranet: + +volumes: + postgres-data: + traefik-acme: diff --git a/configmap.local.yml.example b/legacy-v1/configmap.local.yml.example similarity index 100% rename from configmap.local.yml.example rename to legacy-v1/configmap.local.yml.example diff --git a/configmap.yml.example b/legacy-v1/configmap.yml.example similarity index 100% rename from configmap.yml.example rename to legacy-v1/configmap.yml.example diff --git a/legacy-v1/install.sh b/legacy-v1/install.sh new file mode 100755 index 0000000..fffaa7a --- /dev/null +++ b/legacy-v1/install.sh @@ -0,0 +1,550 @@ +#!/bin/bash + +# Plumber Installer +# Interactive setup wizard for self-managed Plumber instances. +# +# Usage: +# # Option A: One-liner (clones the repo automatically) +# curl -fsSL https://raw.githubusercontent.com/getplumber/platform/main/install.sh | bash +# +# # Option B: From a cloned repository +# git clone https://github.com/getplumber/platform.git plumber-platform +# cd plumber-platform +# ./install.sh + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +REPO_URL="https://github.com/getplumber/platform.git" +REPO_DIR="plumber-platform" + +# ============================================================================= +# Helpers +# ============================================================================= + +prompt() { + local VARNAME="$1" + local MESSAGE="$2" + local DEFAULT="${3:-}" + local VALUE="" + + while true; do + if [ -n "$DEFAULT" ]; then + echo -ne "${BOLD}${MESSAGE}${NC} ${DIM}(${DEFAULT})${NC}: " + else + echo -ne "${BOLD}${MESSAGE}${NC}: " + fi + read -r VALUE < /dev/tty + VALUE="${VALUE:-$DEFAULT}" + + if [ -n "$VALUE" ]; then + printf -v "$VARNAME" '%s' "$VALUE" + return + fi + echo -e "${RED} This field is required.${NC}" + done +} + +prompt_secret() { + local VARNAME="$1" + local MESSAGE="$2" + local VALUE="" + local CHAR="" + + while true; do + VALUE="" + echo -ne "${BOLD}${MESSAGE}${NC}: " + stty -echo < /dev/tty 2>/dev/null || true + while IFS= read -r -n1 CHAR < /dev/tty; do + if [[ -z "$CHAR" ]]; then + break + elif [[ "$CHAR" == $'\x7f' ]] || [[ "$CHAR" == $'\b' ]]; then + if [ -n "$VALUE" ]; then + VALUE="${VALUE%?}" + echo -ne "\b \b" + fi + else + VALUE+="$CHAR" + echo -ne "*" + fi + done + stty echo < /dev/tty 2>/dev/null || true + echo "" + + if [ -n "$VALUE" ]; then + printf -v "$VARNAME" '%s' "$VALUE" + return + fi + echo -e "${RED} This field is required.${NC}" + done +} + +prompt_optional() { + local VARNAME="$1" + local MESSAGE="$2" + local DEFAULT="${3:-}" + + if [ -n "$DEFAULT" ]; then + echo -ne "${BOLD}${MESSAGE}${NC} ${DIM}(${DEFAULT})${NC}: " + else + echo -ne "${BOLD}${MESSAGE}${NC} ${DIM}(leave empty to skip)${NC}: " + fi + read -r VALUE < /dev/tty + VALUE="${VALUE:-$DEFAULT}" + printf -v "$VARNAME" '%s' "$VALUE" +} + +env_line() { + printf "%s='%s'\n" "$1" "$2" +} + +prompt_choice() { + local VARNAME="$1" + local MESSAGE="$2" + shift 2 + local OPTIONS=("$@") + local NUM_OPTIONS=${#OPTIONS[@]} + + echo -e "${BOLD}${MESSAGE}${NC}" + for i in "${!OPTIONS[@]}"; do + echo " $((i + 1)). ${OPTIONS[$i]}" + done + + while true; do + echo -ne "${BOLD}Choice${NC} ${DIM}(1-${NUM_OPTIONS})${NC}: " + read -r CHOICE < /dev/tty + if [[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le "$NUM_OPTIONS" ]; then + printf -v "$VARNAME" '%s' "$CHOICE" + return + fi + echo -e "${RED} Please enter a number between 1 and ${NUM_OPTIONS}.${NC}" + done +} + +prompt_confirm() { + local MESSAGE="$1" + local DEFAULT="${2:-Y}" + + if [ "$DEFAULT" = "Y" ]; then + echo -ne "${BOLD}${MESSAGE}${NC} ${DIM}(Y/n)${NC}: " + else + echo -ne "${BOLD}${MESSAGE}${NC} ${DIM}(y/N)${NC}: " + fi + read -r REPLY < /dev/tty + REPLY="${REPLY:-$DEFAULT}" + + case "$REPLY" in + [yY][eE][sS]|[yY]) return 0 ;; + *) return 1 ;; + esac +} + +# ============================================================================= +# Step 1: Detect context and clone if needed +# ============================================================================= + +echo "" +echo -e "${BOLD}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}" +echo -e "${BOLD}โ•‘ Plumber Installer โ•‘${NC}" +echo -e "${BOLD}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo "" + +if [ ! -f compose.yml ] || [ ! -f versions.env ]; then + echo "Plumber repository not detected. Cloning..." + echo "" + + # Check git is available + if ! command -v git &> /dev/null; then + echo -e "${RED}Error:${NC} Git is required but not installed." + echo " Install it: https://git-scm.com/downloads" + exit 1 + fi + + # Check docker is available + if ! command -v docker &> /dev/null; then + echo -e "${RED}Error:${NC} Docker is required but not installed." + echo " Install it: https://docs.docker.com/get-docker/" + exit 1 + fi + + if [ -d "$REPO_DIR" ]; then + echo "Directory ${REPO_DIR} already exists, updating..." + cd "$REPO_DIR" + git pull --ff-only || true + else + git clone "$REPO_URL" "$REPO_DIR" + cd "$REPO_DIR" + fi + echo "" + echo -e "${GREEN}โœ“${NC} Repository ready at $(pwd)" + echo "" +fi + +# ============================================================================= +# Step 2: Choose deployment type +# ============================================================================= + +prompt_choice DEPLOY_TYPE "Deployment type:" \ + "Production (domain, TLS, reverse proxy)" \ + "Local (localhost, no TLS)" +echo "" + +# ============================================================================= +# Step 3: Run pre-config checks +# ============================================================================= + +echo "Running pre-flight checks..." +if [ "$DEPLOY_TYPE" = "2" ]; then + PREFLIGHT_FLAGS="--pre --local" +else + PREFLIGHT_FLAGS="--pre" +fi +if ! bash scripts/preflight.sh $PREFLIGHT_FLAGS; then + echo "" + echo -e "${RED}Pre-flight checks failed. Please fix the issues above and try again.${NC}" + exit 1 +fi + +# ============================================================================= +# Step 4: Interactive configuration +# ============================================================================= + +echo "" +echo -e "${BOLD}Configuration${NC}" +echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" +echo "" + +# Domain name (production only) +if [ "$DEPLOY_TYPE" = "1" ]; then + prompt DOMAIN_NAME "Plumber domain name (e.g. plumber.example.com)" + + # Check DNS resolution + DNS_OK=false + if command -v dig &> /dev/null; then + if dig +short "$DOMAIN_NAME" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + DNS_OK=true + fi + elif command -v nslookup &> /dev/null; then + if nslookup "$DOMAIN_NAME" &> /dev/null; then + DNS_OK=true + fi + fi + + if [ "$DNS_OK" = true ]; then + echo -e "${GREEN}โœ“${NC} DNS resolves for ${DOMAIN_NAME}" + else + echo -e "${RED}!${NC} DNS does not resolve for ${DOMAIN_NAME}" + echo -e "${DIM} Create a DNS A record pointing ${DOMAIN_NAME} to your server's public IP.${NC}" + echo -e "${DIM} You can continue the setup and configure DNS before starting Plumber.${NC}" + fi + echo "" +fi + +# GitLab URL +prompt JOBS_GITLAB_URL "GitLab instance URL (e.g. https://gitlab.example.com)" +# Strip trailing slash +JOBS_GITLAB_URL="${JOBS_GITLAB_URL%/}" + +# Organization +echo "" +echo -e "${DIM}Leave empty to connect Plumber to the entire GitLab instance${NC}" +echo -e "${DIM}(requires GitLab instance Admin to run onboarding).${NC}" +echo -e "${DIM}Or enter a group path to limit Plumber to that group${NC}" +echo -e "${DIM}(requires at least Maintainer role in the group).${NC}" +echo -e "${DIM}On GitLab.com or non-self-hosted instances, use the exact group path as defined in GitLab (the 'full_path'),${NC}" +echo -e "${DIM}which may differ from the display name and can include a suffix added by GitLab for uniqueness.${NC}" +prompt_optional ORGANIZATION "GitLab group path" + +# GitLab OIDC +echo "" +echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" +echo -e "${BOLD}GitLab OIDC Application${NC}" +echo "" + +# Ensure GitLab URL has https:// scheme for clickable terminal links +GITLAB_BASE_URL="${JOBS_GITLAB_URL}" +if [[ ! "$GITLAB_BASE_URL" =~ ^https?:// ]]; then + GITLAB_BASE_URL="https://${GITLAB_BASE_URL}" +fi + +# Build direct link to the GitLab application creation page +if [ -n "${ORGANIZATION}" ]; then + GITLAB_APP_URL="${GITLAB_BASE_URL}/groups/${ORGANIZATION}/-/settings/applications" +else + GITLAB_APP_URL="${GITLAB_BASE_URL}/admin/applications" +fi + +echo " 1. Open this link to create a new application:" +echo "" +echo -e " ${BOLD}${GITLAB_APP_URL}${NC}" +echo "" +if [ "$DEPLOY_TYPE" = "1" ]; then + REDIRECT_URI="https://${DOMAIN_NAME}/api/auth/gitlab/callback" +else + REDIRECT_URI="http://localhost:3001/api/auth/gitlab/callback" +fi + +echo " 2. Fill in the following:" +echo -e " - Name: ${BOLD}Plumber${NC}" +echo -e " - Redirect URI: ${BOLD}${REDIRECT_URI}${NC}" +echo -e " - Confidential: ${BOLD}yes${NC} (keep the box checked)" +echo -e " - Scopes: ${BOLD}api${NC}" +echo "" +echo " 3. Click Save and copy the credentials below" +echo "" + +prompt GITLAB_OAUTH2_CLIENT_ID "Application ID" +prompt_secret GITLAB_OAUTH2_CLIENT_SECRET "Secret" + +# Certificate method & Database (production only) + +if [ "$DEPLOY_TYPE" = "1" ]; then + echo "" + echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" + prompt_choice CERT_CHOICE "TLS certificate method:" \ + "Let's Encrypt (automatic, server must be reachable from internet)" \ + "Custom certificates (provide your own .pem files)" + + if [ "$CERT_CHOICE" = "1" ]; then + CERT_PROFILE="letsencrypt" + CERT_RESOLVER="le" + else + CERT_PROFILE="custom-certs" + CERT_RESOLVER="" + echo "" + echo -e "${DIM}Place your certificate files at:${NC}" + echo " .docker/traefik/certs/plumber_fullchain.pem" + echo " .docker/traefik/certs/plumber_privkey.pem" + + if [ -f .docker/traefik/certs/plumber_fullchain.pem ] && [ -f .docker/traefik/certs/plumber_privkey.pem ]; then + echo -e " ${GREEN}โœ“${NC} Certificate files found" + else + echo -e " ${YELLOW}!${NC} Certificate files not found yet (add them before starting)" + fi + + # Custom CA + echo "" + echo -e "${BOLD}Custom Certificate Authority${NC}" + echo "" + echo -e "${DIM}If your GitLab instance or your Plumber certificates are signed by${NC}" + echo -e "${DIM}a custom Certificate Authority (private CA), Plumber needs the root${NC}" + echo -e "${DIM}CA certificate to trust those connections.${NC}" + echo "" + + if prompt_confirm "Are you using a custom CA?" "N"; then + echo "" + echo " Add your root CA certificate file (.pem or .crt) to:" + echo "" + echo -e " ${BOLD}.docker/ca-certificates/${NC}" + echo "" + + mkdir -p .docker/ca-certificates + + CA_FILES=$(find .docker/ca-certificates -maxdepth 1 -type f \( -name "*.pem" -o -name "*.crt" \) 2>/dev/null | wc -l | tr -d ' ') + if [ "$CA_FILES" -gt 0 ]; then + echo -e " ${GREEN}โœ“${NC} Found ${CA_FILES} CA certificate(s) in .docker/ca-certificates/" + else + echo -e " ${YELLOW}!${NC} No CA certificates found yet (add them before starting)" + fi + fi + fi + + # Database + echo "" + echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" + prompt_choice DB_CHOICE "Database:" \ + "Internal (managed PostgreSQL container)" \ + "External (connect to your own PostgreSQL)" + + if [ "$DB_CHOICE" = "1" ]; then + DB_PROFILE=",internal-db" + else + DB_PROFILE="" + echo "" + prompt JOBS_DB_HOST "Database host" + prompt_optional JOBS_DB_PORT "Database port" "5432" + prompt JOBS_DB_USER "Database user" + prompt_optional JOBS_DB_NAME "Database name" "plumber" + prompt_secret JOBS_DB_PASSWORD_EXT "Database password" + echo "" + echo -e "${DIM}SSL mode options: disable, require, verify-ca${NC}" + prompt_optional JOBS_DB_SSLMODE "SSL mode" "disable" + + # Detect server timezone + SERVER_TZ=$(timedatectl show --property=Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo "UTC") + prompt_optional JOBS_DB_TIMEZONE "Timezone" "${SERVER_TZ}" + + fi + + COMPOSE_PROFILES="${CERT_PROFILE}${DB_PROFILE}" +fi + +# ============================================================================= +# Step 5: Generate secrets +# ============================================================================= + +echo "" +echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" +echo "Generating secrets..." + +SECRET_KEY=$(openssl rand -hex 32) +if [ -z "${JOBS_DB_PASSWORD_EXT:-}" ]; then + JOBS_DB_PASSWORD=$(openssl rand -hex 16) +else + JOBS_DB_PASSWORD="${JOBS_DB_PASSWORD_EXT}" +fi +JOBS_REDIS_PASSWORD=$(openssl rand -hex 16) + +echo -e "${GREEN}โœ“${NC} Secrets generated" + +# ============================================================================= +# Step 6: Read image tags from versions.env +# ============================================================================= + +source versions.env + +if [ -z "${FRONTEND_IMAGE_TAG:-}" ] || [ -z "${BACKEND_IMAGE_TAG:-}" ]; then + echo -e "${RED}Error:${NC} versions.env is missing image tags." + exit 1 +fi + +echo -e "${GREEN}โœ“${NC} Image versions: frontend=${FRONTEND_IMAGE_TAG}, backend=${BACKEND_IMAGE_TAG}" + +# ============================================================================= +# Step 7: Write .env +# ============================================================================= + +if [ "$DEPLOY_TYPE" = "2" ]; then + # Local development .env (simpler, no domain/profiles/cert-resolver) + { + cat <<'HEADER' +############################################################################### +# Plumber local configuration file # +# Documentation: https://getplumber.io/docs/installation/local-docker-compose # +############################################################################### +HEADER + echo "" + echo "# Main" + env_line JOBS_GITLAB_URL "$JOBS_GITLAB_URL" + env_line ORGANIZATION "$ORGANIZATION" + echo "" + echo "# GitLab OIDC" + env_line GITLAB_OAUTH2_CLIENT_ID "$GITLAB_OAUTH2_CLIENT_ID" + env_line GITLAB_OAUTH2_CLIENT_SECRET "$GITLAB_OAUTH2_CLIENT_SECRET" + echo "" + echo "# Secrets" + env_line SECRET_KEY "$SECRET_KEY" + env_line JOBS_DB_PASSWORD "$JOBS_DB_PASSWORD" + env_line JOBS_REDIS_PASSWORD "$JOBS_REDIS_PASSWORD" + echo "" + echo "# Image versions (managed by versions.env)" + env_line FRONTEND_IMAGE_TAG "$FRONTEND_IMAGE_TAG" + env_line BACKEND_IMAGE_TAG "$BACKEND_IMAGE_TAG" + } > .env +else + # Production .env + { + cat <<'HEADER' +########################################################################## +# Plumber configuration file # +# Documentation: https://getplumber.io/docs/installation/docker-compose/ # +########################################################################## +HEADER + echo "" + echo "# Main configuration" + env_line DOMAIN_NAME "$DOMAIN_NAME" + env_line JOBS_GITLAB_URL "$JOBS_GITLAB_URL" + env_line ORGANIZATION "$ORGANIZATION" + echo "" + echo "# GitLab OIDC" + env_line GITLAB_OAUTH2_CLIENT_ID "$GITLAB_OAUTH2_CLIENT_ID" + env_line GITLAB_OAUTH2_CLIENT_SECRET "$GITLAB_OAUTH2_CLIENT_SECRET" + echo "" + echo "# Secrets" + env_line SECRET_KEY "$SECRET_KEY" + env_line JOBS_DB_PASSWORD "$JOBS_DB_PASSWORD" + env_line JOBS_REDIS_PASSWORD "$JOBS_REDIS_PASSWORD" + echo "" + echo "# Deployment profile" + env_line COMPOSE_PROFILES "$COMPOSE_PROFILES" + env_line CERT_RESOLVER "$CERT_RESOLVER" + echo "" + echo "# Image versions (managed by versions.env)" + env_line FRONTEND_IMAGE_TAG "$FRONTEND_IMAGE_TAG" + env_line BACKEND_IMAGE_TAG "$BACKEND_IMAGE_TAG" + if [ "${DB_CHOICE:-}" = "2" ]; then + echo "" + echo "# External database configuration" + env_line JOBS_DB_HOST "$JOBS_DB_HOST" + env_line JOBS_DB_PORT "$JOBS_DB_PORT" + env_line JOBS_DB_USER "$JOBS_DB_USER" + env_line JOBS_DB_NAME "$JOBS_DB_NAME" + env_line JOBS_DB_SSLMODE "$JOBS_DB_SSLMODE" + env_line JOBS_DB_TIMEZONE "$JOBS_DB_TIMEZONE" + fi + } > .env +fi + +echo -e "${GREEN}โœ“${NC} Configuration written to .env" + +# ============================================================================= +# Step 8: Run post-config checks +# ============================================================================= + +echo "" +echo "Running post-config validation..." +if [ "$DEPLOY_TYPE" = "2" ]; then + bash scripts/preflight.sh --post --local || true +else + bash scripts/preflight.sh --post || true +fi + +# ============================================================================= +# Step 9: Launch +# ============================================================================= + +echo "" +echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" +echo "" + +if [ "$DEPLOY_TYPE" = "2" ]; then + COMPOSE_CMD="docker compose -f compose.local.yml" + PLUMBER_URL="http://localhost:3000" +else + COMPOSE_CMD="docker compose" + PLUMBER_URL="https://${DOMAIN_NAME}" +fi + +if prompt_confirm "Start Plumber now?"; then + echo "" + echo "Starting Plumber..." + $COMPOSE_CMD up -d + echo "" + echo -e "${GREEN}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}" + echo -e "${GREEN}โ•‘ Plumber is starting! โ•‘${NC}" + echo -e "${GREEN}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" + echo "" + echo -e " Visit: ${BOLD}${PLUMBER_URL}${NC}" + echo "" + echo " Useful commands:" + echo " ${COMPOSE_CMD} ps # Check service status" + echo " ${COMPOSE_CMD} logs -f # View logs" + echo " ./scripts/update.sh # Update to latest version" + echo "" +else + echo "" + echo -e "${GREEN}Configuration complete!${NC}" + echo "" + echo " To start Plumber, run:" + echo -e " ${BOLD}${COMPOSE_CMD} up -d${NC}" + echo "" + echo -e " Then visit: ${BOLD}${PLUMBER_URL}${NC}" + echo "" +fi diff --git a/podman.local.yml.example b/legacy-v1/podman.local.yml.example similarity index 100% rename from podman.local.yml.example rename to legacy-v1/podman.local.yml.example diff --git a/podman.yml.example b/legacy-v1/podman.yml.example similarity index 100% rename from podman.yml.example rename to legacy-v1/podman.yml.example diff --git a/scripts/add_helm_repo.sh b/legacy-v1/scripts/add_helm_repo.sh similarity index 100% rename from scripts/add_helm_repo.sh rename to legacy-v1/scripts/add_helm_repo.sh diff --git a/legacy-v1/scripts/backup.sh b/legacy-v1/scripts/backup.sh new file mode 100755 index 0000000..cf5f7e1 --- /dev/null +++ b/legacy-v1/scripts/backup.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +# This script creates a backup of your data inside an archive. +# Launch it in the docker compose folder + +# Usage function +usage() { + echo "Usage: $0 pg-version [s3-destination] [s3-endpoint] [s3-access-key] [s3-secret-key] [s3-region]" + echo "Backup Plumber. You must run this CLI from the root of your Plumber local git repository" + echo + echo "Options:" + echo " pg-version Version of PostgreSQL you are using" + echo " s3-destination Name of the S3 bucket and path to upload the backup (s3://bucket-name/folder)" + echo " s3-endpoint S3 endpoint (https://s3-endpoint-url)" + echo " s3-region S3 bucket region" + echo " s3-access-key S3 Access Key ID" + echo " s3-secret-key S3 Secret Access Key" + echo " -h, --help Display this help message" +} + +# Source Plumber configuration +source .env + +# Define some consts +BACKUPS_DIR=backups +BACKUP_NAME=backup_plumber-$(date +%Y-%m-%d_%H-%M-%S) +BACKUP_PATH=$BACKUPS_DIR/$BACKUP_NAME +PROJECT_NAME=plumber + +# Check if --help or -h is provided as the first argument +if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then + usage + exit 0 +fi + +# Get arguments +PG_VERSION=$1 +S3_DESTINATION=$2 +S3_ENDPOINT=$3 +S3_REGION=$4 +S3_ACCESS_KEY=$5 +S3_SECRET_KEY=$6 + +# Create the backup directory +mkdir -p $BACKUP_PATH +echo "๐Ÿ“ฆ Start backing up data..." + +# Backup the database +echo "Saving the database..." +if docker run --rm --network=${PROJECT_NAME}_intranet -v $PWD/$BACKUP_PATH:/backup -e PGPASSWORD=$JOBS_DB_PASSWORD postgres:$PG_VERSION /bin/bash -c "pg_dump -U $JOBS_DB_USER -h $JOBS_DB_HOST -Ft -f /db_backup.tar && mv db_backup.tar /backup"; then + echo "โœ… Database has been saved" +else + echo "โŒ Error while saving database" + exit 1 +fi + +# Backup the `.env` file: +echo "Saving the .env file..." +if cp .env $BACKUP_PATH; then + echo "โœ… The .env file has been saved" +else + echo "โŒ Error while saving the .env file" + exit 1 +fi + +# Backup the `config.json` file: +echo "Saving the config.json file..." +if cp .docker/plumber/config.json $BACKUP_PATH; then + echo "โœ… The config.json file has been saved" +else + echo "โŒ Error while saving the config.json file" + exit 1 +fi + +# Create a tar.gz archive of the backup +echo "Archiving the backup..." +if tar -czf $BACKUP_PATH.tar.gz -C $BACKUP_PATH .; then + echo "โœ… Backup has been archived" +else + echo "โŒ Error while archiving backup" + exit 1 +fi + +# Remove the backup folder after compression +echo "Cleaning up temporary backup folder..." +if rm -rf $BACKUP_PATH; then + echo "โœ… Temporary backup folder has been removed" +else + echo "โš ๏ธ Warning: Could not remove temporary backup folder" +fi + +# Optional: Upload the backup to S3 if the bucket name is provided +if [ -n "$S3_DESTINATION" ]; then + echo "Uploading the backup to S3..." + + # Configure AWS credentials temporarily + export AWS_ACCESS_KEY_ID=$S3_ACCESS_KEY + export AWS_SECRET_ACCESS_KEY=$S3_SECRET_KEY + + # Remove the "s3://" prefix from S3_DESTINATION if it exists + S3_DESTINATION_CLEANED=$(echo $S3_DESTINATION | sed 's/^s3:\/\///') + + # Construct the S3 URI + S3_URI="s3://$S3_DESTINATION_CLEANED/$BACKUP_NAME.tar.gz" + + # Prepare the AWS CLI command + S3_CLI_CMD="aws s3 cp $BACKUP_PATH.tar.gz $S3_URI --region $S3_REGION --endpoint-url $S3_ENDPOINT" + + # Execute the AWS CLI command + if eval $S3_CLI_CMD; then + echo "โœ… Backup has been uploaded to S3 bucket $S3_DESTINATION at $S3_URI" + # Remove local archive after successful upload + if rm -f $BACKUP_PATH.tar.gz; then + echo "โœ… Local archive has been removed" + else + echo "โš ๏ธ Warning: Could not remove local archive" + fi + else + echo "โŒ Error while uploading backup to S3" + fi + + # Unset AWS credentials after use + unset AWS_ACCESS_KEY_ID + unset AWS_SECRET_ACCESS_KEY +else + echo "โญ๏ธ Skipping S3 upload as AWS credentials or bucket name were not provided." +fi + diff --git a/scripts/backup_podman.sh b/legacy-v1/scripts/backup_podman.sh similarity index 100% rename from scripts/backup_podman.sh rename to legacy-v1/scripts/backup_podman.sh diff --git a/legacy-v1/scripts/preflight.sh b/legacy-v1/scripts/preflight.sh new file mode 100755 index 0000000..afed831 --- /dev/null +++ b/legacy-v1/scripts/preflight.sh @@ -0,0 +1,297 @@ +#!/bin/bash + +# Plumber Pre-flight Checks +# Validates system requirements and configuration before launching Plumber. +# +# Usage: +# ./scripts/preflight.sh # Run all checks (pre-config + post-config) +# ./scripts/preflight.sh --pre # Run only pre-config checks (no .env needed) +# ./scripts/preflight.sh --post # Run only post-config checks (.env required) +# ./scripts/preflight.sh --pre --local # Pre-config for local dev (ports 3000/3001) +# ./scripts/preflight.sh --post --local # Post-config for local dev (skip domain/TLS) +# +# Exit codes: +# 0 = all checks passed +# 1 = fatal error (must fix before proceeding) + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +ERRORS=0 +LOCAL_MODE=false + +pass() { + echo -e " ${GREEN}โœ“${NC} $1" +} + +fail() { + echo -e " ${RED}โœ—${NC} $1" + ERRORS=$((ERRORS + 1)) +} + +warn() { + echo -e " ${YELLOW}!${NC} $1" +} + +# ============================================================================= +# Pre-config checks (no .env needed) +# ============================================================================= +run_pre_checks() { + echo "" + echo "Pre-config checks" + echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" + + # Check Docker is installed and running + if command -v docker &> /dev/null; then + if docker info &> /dev/null; then + pass "Docker is installed and running" + else + fail "Docker is installed but not running (start Docker daemon)" + fi + else + fail "Docker is not installed (https://docs.docker.com/get-docker/)" + fi + + # Check Docker Compose v2.20.2+ is available + if docker compose version &> /dev/null; then + COMPOSE_VERSION=$(docker compose version --short 2>/dev/null || echo "0.0.0") + MAJOR=$(echo "$COMPOSE_VERSION" | cut -d. -f1 | sed 's/v//') + MINOR=$(echo "$COMPOSE_VERSION" | cut -d. -f2) + PATCH=$(echo "$COMPOSE_VERSION" | cut -d. -f3) + + if [ "$MAJOR" -gt 2 ] || ([ "$MAJOR" -eq 2 ] && [ "$MINOR" -gt 20 ]) || ([ "$MAJOR" -eq 2 ] && [ "$MINOR" -eq 20 ] && [ "$PATCH" -ge 2 ]); then + pass "Docker Compose v${COMPOSE_VERSION} (>= 2.20.2 required)" + else + fail "Docker Compose v${COMPOSE_VERSION} is too old (>= 2.20.2 required)" + fi + else + fail "Docker Compose plugin is not installed (https://docs.docker.com/compose/install/)" + fi + + # Check git is available + if command -v git &> /dev/null; then + pass "Git is installed" + else + fail "Git is not installed" + fi + + # Check openssl is available (needed for secret generation) + if command -v openssl &> /dev/null; then + pass "OpenSSL is installed (for secret generation)" + else + fail "OpenSSL is not installed (needed to generate secrets)" + fi + + # Check required ports are available + if [ "$LOCAL_MODE" = true ]; then + CHECK_PORTS="3000 3001" + else + CHECK_PORTS="80 443" + fi + for PORT in $CHECK_PORTS; do + if command -v ss &> /dev/null; then + if ss -tlnp 2>/dev/null | grep -q ":${PORT} "; then + fail "Port ${PORT} is already in use" + else + pass "Port ${PORT} is available" + fi + elif command -v lsof &> /dev/null; then + if lsof -i ":${PORT}" -sTCP:LISTEN &> /dev/null; then + fail "Port ${PORT} is already in use" + else + pass "Port ${PORT} is available" + fi + else + warn "Port ${PORT}: cannot check (install lsof or ss)" + fi + done +} + +# ============================================================================= +# Post-config checks (.env required) +# ============================================================================= +run_post_checks() { + echo "" + echo "Post-config checks" + echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" + + # Check .env exists + if [ ! -f .env ]; then + fail ".env file does not exist (run ./install.sh first)" + return + fi + pass ".env file exists" + + # Source .env for validation + set -a + source .env + set +a + + # Check required variables are set and non-empty + if [ "$LOCAL_MODE" = true ]; then + REQUIRED_VARS="JOBS_GITLAB_URL GITLAB_OAUTH2_CLIENT_ID GITLAB_OAUTH2_CLIENT_SECRET SECRET_KEY JOBS_DB_PASSWORD JOBS_REDIS_PASSWORD" + else + REQUIRED_VARS="DOMAIN_NAME JOBS_GITLAB_URL GITLAB_OAUTH2_CLIENT_ID GITLAB_OAUTH2_CLIENT_SECRET SECRET_KEY JOBS_DB_PASSWORD JOBS_REDIS_PASSWORD COMPOSE_PROFILES" + fi + for VAR in $REQUIRED_VARS; do + VALUE="${!VAR:-}" + if [ -z "$VALUE" ]; then + fail "$VAR is not set" + elif echo "$VALUE" | grep -qi "REPLACE_ME"; then + fail "$VAR still contains a placeholder value" + else + pass "$VAR is set" + fi + done + + # Check image tags are present + for VAR in FRONTEND_IMAGE_TAG BACKEND_IMAGE_TAG; do + VALUE="${!VAR:-}" + if [ -z "$VALUE" ]; then + fail "$VAR is not set (run ./install.sh or ./scripts/update.sh)" + else + pass "$VAR=${VALUE}" + fi + done + + # Production-only checks + if [ "$LOCAL_MODE" = false ]; then + # Check COMPOSE_PROFILES is valid + PROFILES="${COMPOSE_PROFILES:-}" + if [ -n "$PROFILES" ]; then + HAS_TRAEFIK=false + if echo "$PROFILES" | grep -q "letsencrypt"; then + HAS_TRAEFIK=true + fi + if echo "$PROFILES" | grep -q "custom-certs"; then + HAS_TRAEFIK=true + fi + if [ "$HAS_TRAEFIK" = false ]; then + fail "COMPOSE_PROFILES must include 'letsencrypt' or 'custom-certs'" + else + pass "COMPOSE_PROFILES has a valid traefik profile" + fi + fi + + # Check DNS resolution for DOMAIN_NAME + DOMAIN="${DOMAIN_NAME:-}" + if [ -n "$DOMAIN" ]; then + if command -v dig &> /dev/null; then + if dig +short "$DOMAIN" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + pass "DNS resolves for ${DOMAIN}" + else + fail "DNS does not resolve for ${DOMAIN} (ensure your DNS record is configured)" + fi + elif command -v nslookup &> /dev/null; then + if nslookup "$DOMAIN" &> /dev/null; then + pass "DNS resolves for ${DOMAIN}" + else + fail "DNS does not resolve for ${DOMAIN} (ensure your DNS record is configured)" + fi + else + warn "Cannot check DNS (install dig or nslookup)" + fi + fi + + # Check custom cert files if custom-certs profile is active + if echo "${COMPOSE_PROFILES:-}" | grep -q "custom-certs"; then + CERT_DIR=".docker/traefik/certs" + FULLCHAIN="${CERT_DIR}/plumber_fullchain.pem" + PRIVKEY="${CERT_DIR}/plumber_privkey.pem" + + if [ -f "$FULLCHAIN" ] && [ -f "$PRIVKEY" ]; then + pass "Custom certificate files found" + + # Validate certificate is a valid PEM + if openssl x509 -in "$FULLCHAIN" -noout 2>/dev/null; then + pass "Certificate ${FULLCHAIN} is a valid PEM" + else + fail "Certificate ${FULLCHAIN} is not a valid PEM file" + fi + + # Validate private key is a valid PEM + if openssl rsa -in "$PRIVKEY" -check -noout >/dev/null 2>&1 || openssl ec -in "$PRIVKEY" -check -noout >/dev/null 2>&1; then + pass "Private key ${PRIVKEY} is valid" + else + fail "Private key ${PRIVKEY} is not a valid PEM key file" + fi + else + fail "Custom certificates profile is active but cert files are missing" + echo -e " Expected: ${FULLCHAIN}" + echo -e " Expected: ${PRIVKEY}" + fi + + # Check custom CA certificates (optional, only needed for private CAs) + CA_DIR=".docker/ca-certificates" + if [ -d "$CA_DIR" ]; then + CA_COUNT=$(find "$CA_DIR" -maxdepth 1 -type f \( -name "*.pem" -o -name "*.crt" \) 2>/dev/null | wc -l | tr -d ' ') + if [ "$CA_COUNT" -gt 0 ]; then + pass "Found ${CA_COUNT} custom CA certificate(s) in ${CA_DIR}/" + for CA_FILE in "$CA_DIR"/*.pem "$CA_DIR"/*.crt; do + [ -f "$CA_FILE" ] || continue + if openssl x509 -in "$CA_FILE" -noout 2>/dev/null; then + pass "CA certificate $(basename "$CA_FILE") is a valid PEM" + else + fail "CA certificate $(basename "$CA_FILE") is not a valid PEM file" + fi + done + else + warn "No CA certificates in ${CA_DIR}/ (add .pem or .crt files only if using a private CA)" + fi + else + pass "No custom CA directory (not needed for publicly-signed certificates)" + fi + fi + fi + + # Check GitLab URL is reachable + GITLAB_URL="${JOBS_GITLAB_URL:-}" + if [ -n "$GITLAB_URL" ]; then + if curl -sf --max-time 10 "${GITLAB_URL}" -o /dev/null 2>/dev/null; then + pass "GitLab instance is reachable at ${GITLAB_URL}" + else + fail "Cannot reach GitLab at ${GITLAB_URL} (check URL and network)" + fi + fi +} + +# ============================================================================= +# Main +# ============================================================================= + +# Parse arguments +MODE="all" +for ARG in "$@"; do + case "$ARG" in + --pre) MODE="pre" ;; + --post) MODE="post" ;; + --local) LOCAL_MODE=true ;; + esac +done + +case "$MODE" in + pre) + run_pre_checks + ;; + post) + run_post_checks + ;; + *) + run_pre_checks + run_post_checks + ;; +esac + +echo "" +if [ "$ERRORS" -gt 0 ]; then + echo -e "${RED}$ERRORS check(s) failed.${NC} Please fix the issues above before proceeding." + exit 1 +else + echo -e "${GREEN}All checks passed.${NC}" + exit 0 +fi diff --git a/legacy-v1/scripts/restore.sh b/legacy-v1/scripts/restore.sh new file mode 100755 index 0000000..6d29016 --- /dev/null +++ b/legacy-v1/scripts/restore.sh @@ -0,0 +1,131 @@ +#!/bin/bash + +# This script restores your data from a backup archive. +# Launch it in the docker compose folder + +# Usage function +usage() { + echo "Usage: $0 pg-version [s3-endpoint] [s3-access-key] [s3-secret-key] [s3-region]" + echo "Restore Plumber. You must run this CLI from the root of your Plumber local git repository" + echo + echo "Options:" + echo " pg-version Version of PostgreSQL you are using" + echo " backup_file_path_or_s3_url Path to the backup file to restore or S3 URL (s3://bucket-name/folder/backup-file.tar.gz)" + echo " s3-endpoint S3 endpoint (https://s3-endpoint-url)" + echo " s3-region S3 bucket region" + echo " s3-access-key S3 Access Key ID" + echo " s3-secret-key S3 Secret Access Key" + echo " -h, --help Display this help message" +} + +# Define some consts +PROJECT_NAME=plumber + +# Get arguments +PG_VERSION=$1 +BACKUP_SOURCE=$2 +S3_ENDPOINT=$3 +S3_REGION=$4 +S3_ACCESS_KEY=$5 +S3_SECRET_KEY=$6 + + +# Check if --help or -h is provided as the first argument +if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then + usage + exit 1 +fi + +# Ensure a backup file path or S3 URL is specified +if [ -z "$BACKUP_SOURCE" ]; then + printf "Error: backup file path or S3 URL is missing\n" + usage + exit 1 +fi + +# Temporary directory for downloaded backup if from S3 +TMP_DIR="" +LOCAL_BACKUP_FILE="" + +# Check if the backup source is an S3 URL +if [[ $BACKUP_SOURCE == s3://* ]]; then + echo "๐Ÿ”„ Downloading backup file from S3..." + + # Parse S3 URL to get bucket and key + S3_BUCKET=$(echo $BACKUP_SOURCE | sed -n 's|^s3://\([^/]*\).*|\1|p') + S3_KEY=$(echo $BACKUP_SOURCE | sed -n 's|^s3://[^/]*/\(.*\)|\1|p') + LOCAL_BACKUP_FILE=$(basename $S3_KEY) + TMP_DIR=$(mktemp -d) + + # Configure AWS credentials temporarily + export AWS_ACCESS_KEY_ID=$S3_ACCESS_KEY + export AWS_SECRET_ACCESS_KEY=$S3_SECRET_KEY + + # Prepare the AWS CLI command + S3_CLI_CMD="aws s3 cp $BACKUP_SOURCE $TMP_DIR/$LOCAL_BACKUP_FILE --region $S3_REGION --endpoint-url $S3_ENDPOINT" + + # Download the file from S3 + if eval $S3_CLI_CMD; then + echo "โœ… Backup file downloaded from S3 to $TMP_DIR/$LOCAL_BACKUP_FILE" + else + echo "โŒ Error while downloading backup file from S3" + exit 1 + fi + BACKUP_FILE="$TMP_DIR/$LOCAL_BACKUP_FILE" + + # Unset AWS credentials after use + unset AWS_ACCESS_KEY_ID + unset AWS_SECRET_ACCESS_KEY + +else + # Use the provided local backup file path + BACKUP_FILE=$(realpath $BACKUP_SOURCE) +fi + +# Ensure the backup file exists +if [ ! -f "$BACKUP_FILE" ]; then + printf "Error: backup file $BACKUP_FILE does not exist\n" + usage + exit 1 +fi + +echo "๐Ÿ›ณ๏ธ Start restoring data..." + +# Extract the backup +BACKUP_DIR=$(mktemp -d) +tar xzf "$BACKUP_FILE" -C "$BACKUP_DIR" + +echo "Restoring the .env file..." +if cp "$BACKUP_DIR/.env" ./.env; then + echo "โœ… The .env file has been restored" + source .env +else + echo "โŒ Error while restoring the .env file" + exit 1 +fi + +echo "Restoring the config.json file..." +if cp "$BACKUP_DIR/config.json" .docker/plumber/config.json; then + echo "โœ… The config.json file has been restored" +else + echo "โŒ Error while restoring the config.json file" + exit 1 +fi + +echo "Restoring the database..." +if docker run --rm --network=${PROJECT_NAME}_intranet -v "$BACKUP_DIR":/backup -e PGPASSWORD=$JOBS_DB_PASSWORD -it postgres:$PG_VERSION /bin/bash -c "pg_restore -U $JOBS_DB_USER -h $JOBS_DB_HOST --if-exists -c -e -Ft -d $JOBS_DB_NAME /backup/db_backup.tar"; then + + echo "โœ… Database has been restored" +else + echo "โŒ Error while restoring database" + exit 1 +fi + +# Cleanup +rm -rf "$BACKUP_DIR" +if [ -n "$TMP_DIR" ]; then + rm -rf "$TMP_DIR" +fi + +echo "๐Ÿงน Cleanup done" + diff --git a/scripts/restore_podman.sh b/legacy-v1/scripts/restore_podman.sh similarity index 100% rename from scripts/restore_podman.sh rename to legacy-v1/scripts/restore_podman.sh diff --git a/legacy-v1/scripts/update.sh b/legacy-v1/scripts/update.sh new file mode 100755 index 0000000..91e7b6c --- /dev/null +++ b/legacy-v1/scripts/update.sh @@ -0,0 +1,218 @@ +#!/bin/bash + +# Plumber Update Script +# Updates your self-managed Plumber instance to the latest version. +# +# Usage: +# ./scripts/update.sh +# +# This script: +# 1. Stops running containers (using current compose config) +# 2. Pulls the latest changes from the git repository +# 3. Loads image tags from versions.env and syncs them into .env +# 4. Migrates old .env format if needed (auto-detects COMPOSE_PROFILES) +# 5. Starts containers with the new images + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +# Ensure we're in the repository root +if [ ! -f compose.yml ] || [ ! -f versions.env ]; then + echo -e "${RED}Error:${NC} This script must be run from the Plumber platform repository root." + exit 1 +fi + +# Ensure .env exists +if [ ! -f .env ]; then + echo -e "${RED}Error:${NC} .env file not found. Run ./install.sh first." + exit 1 +fi + +# ============================================================================= +# Helper: update or append a variable in .env +# ============================================================================= +set_env_var() { + local KEY="$1" + local VALUE="$2" + if grep -q "^${KEY}=" .env 2>/dev/null; then + sed -i."" "s|^${KEY}=.*|${KEY}=${VALUE}|" .env + rm -f .env."" 2>/dev/null || true + else + echo "${KEY}=${VALUE}" >> .env + fi +} + +# ============================================================================= +# Detect deployment type (local vs production) +# ============================================================================= +if grep -q "^DOMAIN_NAME=" .env 2>/dev/null; then + DEPLOY_TYPE="production" + COMPOSE_CMD="docker compose" +else + DEPLOY_TYPE="local" + COMPOSE_CMD="docker compose -f compose.local.yml" +fi + +echo "" +echo -e "${BOLD}Updating Plumber (${DEPLOY_TYPE})...${NC}" +echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" + +# ============================================================================= +# Step 1: Stop containers (before pulling, so old compose config is used) +# ============================================================================= +echo "" +echo "Stopping containers..." +$COMPOSE_CMD down --remove-orphans +echo -e "${GREEN}โœ“${NC} Containers stopped" + +# ============================================================================= +# Step 2: Pull latest changes +# ============================================================================= +echo "" +echo "Pulling latest changes..." +git pull +echo -e "${GREEN}โœ“${NC} Repository updated" + +# ============================================================================= +# Step 3: Load image tags from versions.env +# ============================================================================= +echo "" +echo "Loading image versions..." +set -a +source versions.env +set +a + +if [ -z "${FRONTEND_IMAGE_TAG:-}" ] || [ -z "${BACKEND_IMAGE_TAG:-}" ]; then + echo -e "${RED}Error:${NC} versions.env is missing image tags." + exit 1 +fi + +# Sync image tags into .env (versions.env is the source of truth) +set_env_var "FRONTEND_IMAGE_TAG" "${FRONTEND_IMAGE_TAG}" +set_env_var "BACKEND_IMAGE_TAG" "${BACKEND_IMAGE_TAG}" + +echo -e "${GREEN}โœ“${NC} Frontend: ${FRONTEND_IMAGE_TAG}" +echo -e "${GREEN}โœ“${NC} Backend: ${BACKEND_IMAGE_TAG}" + +# ============================================================================= +# Step 4: Migrate COMPOSE_PROFILES if missing (old .env format, production only) +# ============================================================================= +if [ "$DEPLOY_TYPE" = "production" ] && ! grep -q "^COMPOSE_PROFILES=" .env 2>/dev/null; then + echo "" + echo -e "${YELLOW}Migration required:${NC} COMPOSE_PROFILES is not set in your .env file." + echo "" + echo -e "${DIM}Starting with this version, Plumber uses Docker Compose profiles instead of${NC}" + echo -e "${DIM}separate compose files. Your .env needs a COMPOSE_PROFILES variable.${NC}" + echo "" + + # Source .env to read current config for auto-detection + set -a + source .env + set +a + + # --- Auto-detect TLS method --- + if [ -f .docker/traefik/certs/plumber_fullchain.pem ] && [ -f .docker/traefik/certs/plumber_privkey.pem ]; then + DETECTED_CERT="custom-certs" + CERT_REASON="custom certificate files found in .docker/traefik/certs/" + else + DETECTED_CERT="letsencrypt" + CERT_REASON="no custom certificate files found (using Let's Encrypt)" + fi + + # --- Auto-detect database --- + CURRENT_DB_HOST="${JOBS_DB_HOST:-postgres}" + if [ "$CURRENT_DB_HOST" != "postgres" ]; then + DETECTED_DB="" + DB_REASON="JOBS_DB_HOST is set to '${CURRENT_DB_HOST}' (external database)" + else + DETECTED_DB=",internal-db" + DB_REASON="JOBS_DB_HOST is 'postgres' or unset (internal database)" + fi + + DETECTED_PROFILES="${DETECTED_CERT}${DETECTED_DB}" + + echo " Auto-detected configuration:" + echo -e " TLS: ${BOLD}${DETECTED_CERT}${NC} ${DIM}(${CERT_REASON})${NC}" + if [ -n "$DETECTED_DB" ]; then + echo -e " Database: ${BOLD}internal${NC} ${DIM}(${DB_REASON})${NC}" + else + echo -e " Database: ${BOLD}external${NC} ${DIM}(${DB_REASON})${NC}" + fi + echo "" + echo -e " COMPOSE_PROFILES=${BOLD}${DETECTED_PROFILES}${NC}" + echo "" + + while true; do + echo -ne "${BOLD}Is this correct?${NC} ${DIM}(Y/n)${NC}: " + read -r REPLY + REPLY="${REPLY:-Y}" + case "$REPLY" in + [yY][eE][sS]|[yY]) + set_env_var "COMPOSE_PROFILES" "${DETECTED_PROFILES}" + if [ "$DETECTED_CERT" = "letsencrypt" ]; then + set_env_var "CERT_RESOLVER" "le" + else + set_env_var "CERT_RESOLVER" "" + fi + echo -e "${GREEN}โœ“${NC} COMPOSE_PROFILES=${DETECTED_PROFILES} added to .env" + break + ;; + [nN][oO]|[nN]) + echo "" + echo " Available profiles:" + echo " 1. letsencrypt,internal-db (Let's Encrypt + managed PostgreSQL)" + echo " 2. custom-certs,internal-db (Custom certs + managed PostgreSQL)" + echo " 3. letsencrypt (Let's Encrypt + external PostgreSQL)" + echo " 4. custom-certs (Custom certs + external PostgreSQL)" + echo "" + while true; do + echo -ne "${BOLD}Choice${NC} ${DIM}(1-4)${NC}: " + read -r CHOICE + case "$CHOICE" in + 1) PROFILES="letsencrypt,internal-db"; RESOLVER="le"; break ;; + 2) PROFILES="custom-certs,internal-db"; RESOLVER=""; break ;; + 3) PROFILES="letsencrypt"; RESOLVER="le"; break ;; + 4) PROFILES="custom-certs"; RESOLVER=""; break ;; + *) echo -e "${RED} Please enter a number between 1 and 4.${NC}" ;; + esac + done + set_env_var "COMPOSE_PROFILES" "${PROFILES}" + set_env_var "CERT_RESOLVER" "${RESOLVER}" + echo -e "${GREEN}โœ“${NC} COMPOSE_PROFILES=${PROFILES} added to .env" + break + ;; + *) + echo -e "${RED} Please answer Y or N.${NC}" + ;; + esac + done +fi + +# Ensure CERT_RESOLVER is set (derive from COMPOSE_PROFILES if missing, production only) +if [ "$DEPLOY_TYPE" = "production" ] && ! grep -q "^CERT_RESOLVER=" .env 2>/dev/null; then + source .env + if echo "${COMPOSE_PROFILES:-}" | grep -q "letsencrypt"; then + set_env_var "CERT_RESOLVER" "le" + else + set_env_var "CERT_RESOLVER" "" + fi + echo -e "${GREEN}โœ“${NC} CERT_RESOLVER added to .env" +fi + +# ============================================================================= +# Step 5: Start containers with new images +# ============================================================================= +echo "" +echo "Starting containers..." +$COMPOSE_CMD up -d + +echo "" +echo -e "${GREEN}โœ“ Plumber has been updated successfully!${NC}" +echo "" diff --git a/legacy-v1/versions.env b/legacy-v1/versions.env new file mode 100644 index 0000000..575c2a5 --- /dev/null +++ b/legacy-v1/versions.env @@ -0,0 +1,2 @@ +FRONTEND_IMAGE_TAG=v2.37.3 +BACKEND_IMAGE_TAG=v2.44.0 diff --git a/releases/.gitkeep b/releases/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/backup.sh b/scripts/backup.sh index cf5f7e1..f43c589 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -19,12 +19,16 @@ usage() { } # Source Plumber configuration +# shellcheck disable=SC1091 source .env +PLUMBER_DB_HOST="${PLUMBER_DB_HOST:-postgres}" +PLUMBER_DB_USER="${PLUMBER_DB_USER:-plumber}" +PLUMBER_DB_NAME="${PLUMBER_DB_NAME:-plumber}" # Define some consts BACKUPS_DIR=backups BACKUP_NAME=backup_plumber-$(date +%Y-%m-%d_%H-%M-%S) -BACKUP_PATH=$BACKUPS_DIR/$BACKUP_NAME +BACKUP_PATH="$BACKUPS_DIR/$BACKUP_NAME" PROJECT_NAME=plumber # Check if --help or -h is provided as the first argument @@ -42,39 +46,45 @@ S3_ACCESS_KEY=$5 S3_SECRET_KEY=$6 # Create the backup directory -mkdir -p $BACKUP_PATH +mkdir -p "$BACKUP_PATH" echo "๐Ÿ“ฆ Start backing up data..." # Backup the database echo "Saving the database..." -if docker run --rm --network=${PROJECT_NAME}_intranet -v $PWD/$BACKUP_PATH:/backup -e PGPASSWORD=$JOBS_DB_PASSWORD postgres:$PG_VERSION /bin/bash -c "pg_dump -U $JOBS_DB_USER -h $JOBS_DB_HOST -Ft -f /db_backup.tar && mv db_backup.tar /backup"; then +export PGPASSWORD="$PLUMBER_DB_PASSWORD" +if docker run --rm --network="${PROJECT_NAME}_intranet" -v "$PWD/$BACKUP_PATH":/backup -e PGPASSWORD postgres:"$PG_VERSION" /bin/bash -c "pg_dump -U \"$PLUMBER_DB_USER\" -h \"$PLUMBER_DB_HOST\" -d \"$PLUMBER_DB_NAME\" -Ft -f /db_backup.tar && mv db_backup.tar /backup"; then echo "โœ… Database has been saved" else echo "โŒ Error while saving database" exit 1 fi +unset PGPASSWORD # Backup the `.env` file: echo "Saving the .env file..." -if cp .env $BACKUP_PATH; then +if cp .env "$BACKUP_PATH"; then echo "โœ… The .env file has been saved" + echo "โ„น๏ธ .env holds PLUMBER_TOKEN_ENCRYPTION_KEY: the database dump is unreadable without it. Store this archive safely." else echo "โŒ Error while saving the .env file" exit 1 fi -# Backup the `config.json` file: -echo "Saving the config.json file..." -if cp .docker/plumber/config.json $BACKUP_PATH; then - echo "โœ… The config.json file has been saved" -else - echo "โŒ Error while saving the config.json file" - exit 1 +# Backup the custom CA certificates (if any): +if find .docker/ca-certificates -maxdepth 1 -type f \( -name "*.pem" -o -name "*.crt" \) | grep -q .; then + echo "Saving the custom CA certificates..." + mkdir -p "$BACKUP_PATH/ca-certificates" + if cp .docker/ca-certificates/*.pem .docker/ca-certificates/*.crt "$BACKUP_PATH/ca-certificates/" 2>/dev/null; then + echo "โœ… Custom CA certificates have been saved" + else + echo "โŒ Error while saving the custom CA certificates" + exit 1 + fi fi # Create a tar.gz archive of the backup echo "Archiving the backup..." -if tar -czf $BACKUP_PATH.tar.gz -C $BACKUP_PATH .; then +if tar -czf "$BACKUP_PATH.tar.gz" -C "$BACKUP_PATH" .; then echo "โœ… Backup has been archived" else echo "โŒ Error while archiving backup" @@ -83,7 +93,7 @@ fi # Remove the backup folder after compression echo "Cleaning up temporary backup folder..." -if rm -rf $BACKUP_PATH; then +if rm -rf "$BACKUP_PATH"; then echo "โœ… Temporary backup folder has been removed" else echo "โš ๏ธ Warning: Could not remove temporary backup folder" @@ -98,19 +108,19 @@ if [ -n "$S3_DESTINATION" ]; then export AWS_SECRET_ACCESS_KEY=$S3_SECRET_KEY # Remove the "s3://" prefix from S3_DESTINATION if it exists - S3_DESTINATION_CLEANED=$(echo $S3_DESTINATION | sed 's/^s3:\/\///') + S3_DESTINATION_CLEANED=$(echo "$S3_DESTINATION" | sed 's/^s3:\/\///') # Construct the S3 URI S3_URI="s3://$S3_DESTINATION_CLEANED/$BACKUP_NAME.tar.gz" # Prepare the AWS CLI command - S3_CLI_CMD="aws s3 cp $BACKUP_PATH.tar.gz $S3_URI --region $S3_REGION --endpoint-url $S3_ENDPOINT" + S3_CLI_CMD="aws s3 cp \"$BACKUP_PATH.tar.gz\" \"$S3_URI\" --region \"$S3_REGION\" --endpoint-url \"$S3_ENDPOINT\"" # Execute the AWS CLI command - if eval $S3_CLI_CMD; then + if eval "$S3_CLI_CMD"; then echo "โœ… Backup has been uploaded to S3 bucket $S3_DESTINATION at $S3_URI" # Remove local archive after successful upload - if rm -f $BACKUP_PATH.tar.gz; then + if rm -f "$BACKUP_PATH.tar.gz"; then echo "โœ… Local archive has been removed" else echo "โš ๏ธ Warning: Could not remove local archive" @@ -125,4 +135,3 @@ if [ -n "$S3_DESTINATION" ]; then else echo "โญ๏ธ Skipping S3 upload as AWS credentials or bucket name were not provided." fi - diff --git a/scripts/ca-bundle.sh b/scripts/ca-bundle.sh new file mode 100755 index 0000000..f7f2d9b --- /dev/null +++ b/scripts/ca-bundle.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +# Plumber CA bundle builder +# Concatenates every .pem/.crt in .docker/ca-certificates/ into one bundle the +# backend can read (PLUMBER_PROVIDER_CA_BUNDLE takes a single PEM path) and +# keeps the matching line in .env in sync. The frontend needs no bundle: its +# image entrypoint reads the same directory file by file. +# +# Usage: ./scripts/ca-bundle.sh (run from the repository root) +# Called automatically by install.sh and scripts/update.sh. + +set -euo pipefail + +CA_DIR=".docker/ca-certificates" +BUNDLE_NAME="plumber-ca.bundle" +BUNDLE="$CA_DIR/$BUNDLE_NAME" +ENV_KEY="PLUMBER_PROVIDER_CA_BUNDLE" +ENV_VALUE="/usr/local/share/ca-certificates/$BUNDLE_NAME" + +if [ ! -f .env ]; then + echo "Error: .env not found (run ./install.sh first)." >&2 + exit 1 +fi +mkdir -p "$CA_DIR" + +CERTS=() +for f in "$CA_DIR"/*.pem "$CA_DIR"/*.crt; do + [ -f "$f" ] || continue + [ "$(basename "$f")" = "$BUNDLE_NAME" ] && continue + if ! openssl x509 -in "$f" -noout 2>/dev/null; then + echo "Error: $f is not a valid PEM certificate." >&2 + exit 1 + fi + CERTS+=("$f") +done + +remove_env_line() { + sed -i."" "/^${ENV_KEY}=/d" .env + rm -f .env."" 2>/dev/null || true +} + +if [ "${#CERTS[@]}" -eq 0 ]; then + rm -f "$BUNDLE" + remove_env_line + echo "No custom CA certificates in $CA_DIR/ (nothing to do)." + exit 0 +fi + +TMP="$BUNDLE.tmp" +: > "$TMP" +for f in "${CERTS[@]}"; do + cat "$f" >> "$TMP" + printf '\n' >> "$TMP" +done +mv "$TMP" "$BUNDLE" +chmod 644 "$BUNDLE" + +remove_env_line +printf "%s='%s'\n" "$ENV_KEY" "$ENV_VALUE" >> .env +echo "Built $BUNDLE from ${#CERTS[@]} certificate(s); $ENV_KEY set in .env." +echo "Restart the apps so they pick it up: docker compose up -d --force-recreate backend frontend (or the compose.local.yml equivalent)." diff --git a/scripts/ci/chart-check.sh b/scripts/ci/chart-check.sh new file mode 100755 index 0000000..f8b5dd4 --- /dev/null +++ b/scripts/ci/chart-check.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Lint, unit-test and schema-validate the v2 chart. Runs in CI and locally. +# Needs: helm, the helm unittest plugin (v1.0.3), kubeconform (path via KUBECONFORM, default on PATH). +set -euo pipefail +cd "$(dirname "$0")/../.." +CHART=charts/plumber-platform +KUBECONFORM="${KUBECONFORM:-kubeconform}" +BASE="$CHART/tests/values/base.yaml" + +helm lint "$CHART" -f "$BASE" --strict +helm unittest "$CHART" + +validate() { # validate