Skip to content
Open

Dev #30

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
58 changes: 58 additions & 0 deletions .github/workflows/beta.yml
Original file line number Diff line number Diff line change
@@ -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 }}
13 changes: 13 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}

Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}

Expand All @@ -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:
Expand All @@ -37,6 +51,7 @@ jobs:
uses: mikepenz/release-changelog-builder-action@c9dc8369bccbc41e0ac887f8fd674f5925d315f7 # v5
with:
mode: COMMIT
toTag: ${{ inputs.tag }}
configurationJson: |
{
"template": "#{{CHANGELOG}}",
Expand All @@ -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 }}
Expand Down
8 changes: 8 additions & 0 deletions .plumber.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion commands/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
44 changes: 44 additions & 0 deletions engines/mongodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
66 changes: 64 additions & 2 deletions tests/engines/mongodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() == []
Expand Down Expand Up @@ -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"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading