diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b5916c9..046be62 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -258,4 +258,8 @@ Releases are cut from GitHub Actions, never from a local machine. Stable versions must match `X.Y.Z` and can only be cut from `main`. -The workflow pushes with a token minted from the Portabase GitHub App (`APP_ID` repository variable, `APP_PRIVATE_KEY` secret), scoped to *Contents: write*. The app must be installed on this repository and allowed to push to `main`. A tag pushed with the default `GITHUB_TOKEN` would not trigger the release workflows. +### Beta releases + +Every push to `dev` publishes a beta automatically (**Beta release** workflow): no version commit, no Discord notification. The version is the next patch of `pyproject.toml` suffixed with the run number (`26.09.2` → `26.09.3b57`), baked into the binaries at build time and published as a GitHub prerelease. Install it with `portabase config channel beta`; stable users never receive it. + +The Bump version workflow pushes with a token minted from the Portabase GitHub App (`APP_ID` repository variable, `APP_PRIVATE_KEY` secret), scoped to *Contents: write*. The app must be installed on this repository and allowed to push to `main`. A tag pushed with the default `GITHUB_TOKEN` would not trigger the release workflows. diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml new file mode 100644 index 0000000..d1eb694 --- /dev/null +++ b/.github/workflows/beta.yml @@ -0,0 +1,58 @@ +name: Beta release + +on: + push: + branches: [dev] + +permissions: {} + +concurrency: + group: beta-${{ github.ref }} + cancel-in-progress: false + +jobs: + version: + name: version + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + version: ${{ steps.compute.outputs.version }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - id: compute + env: + RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + CURRENT=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) + if [[ ! "$CURRENT" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then + echo "::error::cannot parse version '$CURRENT' from pyproject.toml" + exit 1 + fi + MAJOR=${BASH_REMATCH[1]} MINOR=${BASH_REMATCH[2]} PATCH=${BASH_REMATCH[3]} + PATCH=$((10#$PATCH + 1)) + VERSION="$MAJOR.$MINOR.${PATCH}b$RUN_NUMBER" + echo "Beta version: $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + build: + needs: version + permissions: + contents: read + id-token: write + attestations: write + uses: ./.github/workflows/build.yml + with: + version: ${{ needs.version.outputs.version }} + + publish: + needs: [version, build] + permissions: + contents: write + uses: ./.github/workflows/publish.yml + with: + prerelease: true + tag: ${{ needs.version.outputs.version }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7942dbf..3ddbf7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,12 @@ name: Build binaries on: workflow_call: + inputs: + version: + description: "Version baked into the binary. Default: the one in pyproject.toml." + required: false + type: string + default: "" permissions: {} @@ -44,6 +50,13 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: ./.github/actions/setup + # perl rather than sed -i, which differs between GNU and BSD (macOS). + - name: Set version + if: inputs.version != '' + env: + VERSION: ${{ inputs.version }} + run: perl -pi -e 's/^version = ".*"/version = "$ENV{VERSION}"/' pyproject.toml + - name: Build id: build uses: ./.github/actions/build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 07edf57..10c9e05 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,11 @@ on: prerelease: required: true type: boolean + tag: + description: "Tag to create on the current commit. Default: the pushed tag." + required: false + type: string + default: "" permissions: {} @@ -21,6 +26,15 @@ jobs: with: fetch-depth: 0 + - name: Create tag + if: inputs.tag != '' + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + git tag "$TAG" "$GITHUB_SHA" + git push origin "refs/tags/$TAG" + - name: Download binaries uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -37,6 +51,7 @@ jobs: uses: mikepenz/release-changelog-builder-action@c9dc8369bccbc41e0ac887f8fd674f5925d315f7 # v5 with: mode: COMMIT + toTag: ${{ inputs.tag }} configurationJson: | { "template": "#{{CHANGELOG}}", @@ -59,6 +74,7 @@ jobs: - name: Create GitHub release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: + tag_name: ${{ inputs.tag }} files: dist/* generate_release_notes: false body: ${{ steps.changelog.outputs.changelog }} diff --git a/.plumber.yaml b/.plumber.yaml new file mode 100644 index 0000000..59d1534 --- /dev/null +++ b/.plumber.yaml @@ -0,0 +1,8 @@ +extends: plumber:default +version: "2.0" + +github: + controls: + workflowMustIncludeRequiredActions: + enabled: true + required: gitleaks/gitleaks-action AND aquasecurity/trivy-action AND getplumber/plumber diff --git a/commands/db.py b/commands/db.py index bbee8ab..2ff0646 100644 --- a/commands/db.py +++ b/commands/db.py @@ -82,7 +82,8 @@ def run( str | None, typer.Option("--host", help="Host of an existing database") ] = None, port: Annotated[ - int | None, typer.Option("--port", help="Port of an existing database") + int | None, + typer.Option("--port", help="Port of an existing database"), ] = None, database: Annotated[ str | None, typer.Option("--database", help="Database name") diff --git a/engines/mongodb.py b/engines/mongodb.py index 09232ba..83c5c0b 100644 --- a/engines/mongodb.py +++ b/engines/mongodb.py @@ -3,17 +3,46 @@ import secrets from typing import Any +from core.errors import ValidationError +from core.fields import Field from core.specs import DatabaseSpec from core.utils import generate_password from engines.base import DbEngine from services.ports import PortAllocator +def validate_port(value: int) -> int: + if not 0 <= value <= 65535: + raise ValidationError( + f"--port must be between 0 and 65535, got {value}", + hint="Use 0 for a mongodb+srv:// (Atlas) connection.", + ) + return value + + class MongoEngine(DbEngine): key, display, default_port = "mongodb", "MongoDB", 27017 template = "engines/mongodb.yml.j2" auth_variants = True + def fields_existing(self) -> list[Field]: + overrides = { + "port": Field( + "port", + "Port", + "int", + default=self.default_port, + help=( + "Set the port to 0 for an SRV connection (mongodb+srv://, " + "e.g. MongoDB Atlas); use the cluster hostname as host." + ), + validator=validate_port, + ), + "username": Field("username", "Username", "text", default=""), + "password": Field("password", "Password", "secret", default=""), + } + return [overrides.get(field.name, field) for field in super().fields_existing()] + def generate( self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any] ) -> DatabaseSpec: @@ -41,3 +70,18 @@ def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: out[f"{prefix}_USER"] = spec.username or "" out[f"{prefix}_PASS"] = spec.password or "" return out + + @staticmethod + def is_srv(spec: DatabaseSpec) -> bool: + return not spec.managed and not spec.port + + def agent_entry(self, spec: DatabaseSpec) -> dict[str, Any]: + entry = super().agent_entry(spec) + if self.is_srv(spec): + del entry["port"] + return entry + + def describe(self, spec: DatabaseSpec) -> str: + if self.is_srv(spec): + return f"mongodb+srv://{spec.host}" + return super().describe(spec) diff --git a/tests/engines/mongodb.py b/tests/engines/mongodb.py index dce63a4..36c1061 100644 --- a/tests/engines/mongodb.py +++ b/tests/engines/mongodb.py @@ -2,6 +2,7 @@ import pytest +from core.errors import ValidationError from core.specs import DatabaseSpec from engines import registry from engines.mongodb import MongoEngine @@ -75,8 +76,8 @@ def fields(): ("host", "text", "localhost"), ("port", "int", 27017), ("database", "text", None), - ("username", "text", None), - ("password", "secret", None), + ("username", "text", ""), + ("password", "secret", ""), ] assert MONGO.fields_new() == [] assert MONGO.option_fields() == [] @@ -130,3 +131,64 @@ def compose_service_inline(render_engine): f"MONGO_INITDB_ROOT_PASSWORD={rendered.spec.password}", f"MONGO_INITDB_DATABASE={rendered.spec.database}", ] + + +def port_field_mentions_srv(): + port = next(field for field in MONGO.fields_existing() if field.name == "port") + assert "mongodb+srv://" in (port.help or "") + + +@pytest.mark.parametrize("port", [0, 27017, 65535]) +def port_validator_accepts(port): + field = next(field for field in MONGO.fields_existing() if field.name == "port") + assert field.validator is not None + assert field.validator(port) == port + + +@pytest.mark.parametrize("port", [-1, 65536]) +def port_validator_rejects(port): + field = next(field for field in MONGO.fields_existing() if field.name == "port") + assert field.validator is not None + with pytest.raises(ValidationError): + field.validator(port) + + +def srv_existing(): + spec = MONGO.from_existing( + {**EXISTING_ANSWERS, "host": "cluster0.abcde.mongodb.net", "port": 0} + ) + assert spec.port == 0 + assert MONGO.is_srv(spec) + assert MONGO.describe(spec) == "mongodb+srv://cluster0.abcde.mongodb.net" + assert MONGO.agent_entry(spec) == { + "name": "External DB", + "database": "app", + "type": "mongodb", + "username": "u", + "password": "p", + "host": "cluster0.abcde.mongodb.net", + "generated_id": spec.id, + } + + +def srv_missing_port(): + spec = DatabaseSpec( + id="x", engine="mongodb", name="Atlas", host="c.mongodb.net", port=None + ) + assert MONGO.is_srv(spec) + assert "port" not in MONGO.agent_entry(spec) + assert MONGO.describe(spec) == "mongodb+srv://c.mongodb.net" + + +def srv_without_auth(): + answers = {"host": "c.mongodb.net", "port": 0, "database": "app"} + spec = MONGO.from_existing({**answers, "username": "", "password": ""}) + entry = MONGO.agent_entry(spec) + assert "port" not in entry + assert (entry["username"], entry["password"]) == ("", "") + + +def non_srv_existing(): + spec = MONGO.from_existing(EXISTING_ANSWERS) + assert not MONGO.is_srv(spec) + assert MONGO.describe(spec) == "db.example:1234" diff --git a/uv.lock b/uv.lock index 357a540..de95f97 100644 --- a/uv.lock +++ b/uv.lock @@ -521,7 +521,7 @@ wheels = [ [[package]] name = "portabase-cli" -version = "26.8.12" +version = "26.9.2" source = { virtual = "." } dependencies = [ { name = "jinja2" },