diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index b554961..ded9ba9 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -50,6 +50,7 @@ jobs: canonical/operator-workflows/.github/workflows/integration_test.yaml@main secrets: inherit with: + extra-arguments: '-x --log-format="%(asctime)s %(levelname)s %(message)s"' juju-channel: 3/stable modules: | [ @@ -60,9 +61,28 @@ jobs: self-hosted-runner-label: "self-hosted-linux-amd64-noble-large" trivy-fs-enabled: false with-uv: true + integration-tests-stack: + uses: + canonical/operator-workflows/.github/workflows/integration_test.yaml@main + secrets: inherit + with: + extra-arguments: '-x --log-format="%(asctime)s %(levelname)s %(message)s"' + test-tox-env: stack-integration + provider: lxd + trivy-fs-enabled: false + self-hosted-runner: true + self-hosted-runner-label: "self-hosted-linux-amd64-noble-large" + juju-channel: 3/stable + charmcraft-channel: latest/edge + modules: | + [ + "test_e2e.py" + ] + with-uv: true allure-report: if: ${{ !cancelled() && github.event_name == 'schedule' }} needs: - integration-tests-juju-3 - integration-tests-global + - integration-tests-stack uses: canonical/operator-workflows/.github/workflows/allure_report.yaml@main diff --git a/.github/workflows/promote_charm.yaml b/.github/workflows/promote_charm.yaml index 66649de..00e4756 100644 --- a/.github/workflows/promote_charm.yaml +++ b/.github/workflows/promote_charm.yaml @@ -3,24 +3,48 @@ name: Promote charm on: workflow_dispatch: inputs: - origin-channel: + charm: type: choice - description: 'Origin Channel' + description: 'Charm to promote' options: - - latest/edge - destination-channel: - type: choice - description: 'Destination Channel' - options: - - latest/stable + - dovecot-charm + - opendkim-operator + - postfix-relay-operator + - postfix-relay-configurator-operator secrets: CHARMHUB_TOKEN: required: true jobs: + resolve-channels: + runs-on: ubuntu-latest + outputs: + origin-channel: ${{ steps.set-channels.outputs.origin-channel }} + destination-channel: ${{ steps.set-channels.outputs.destination-channel }} + steps: + - name: Set channels + id: set-channels + run: | + case "${{ github.event.inputs.charm }}" in + dovecot-charm) + echo "origin-channel=2.3/edge" >> "$GITHUB_OUTPUT" + echo "destination-channel=2.3/stable" >> "$GITHUB_OUTPUT" + ;; + opendkim-operator) + echo "origin-channel=2/edge" >> "$GITHUB_OUTPUT" + echo "destination-channel=2/stable" >> "$GITHUB_OUTPUT" + ;; + postfix-relay-operator|postfix-relay-configurator-operator) + echo "origin-channel=3/edge" >> "$GITHUB_OUTPUT" + echo "destination-channel=3/stable" >> "$GITHUB_OUTPUT" + ;; + esac + promote-charm: + needs: resolve-channels uses: canonical/operator-workflows/.github/workflows/promote_charm.yaml@main with: - origin-channel: ${{ github.event.inputs.origin-channel }} - destination-channel: ${{ github.event.inputs.destination-channel }} + origin-channel: ${{ needs.resolve-channels.outputs.origin-channel }} + destination-channel: ${{ needs.resolve-channels.outputs.destination-channel }} + working-directory: ${{ github.event.inputs.charm }} secrets: inherit diff --git a/.github/workflows/publish_charm.yaml b/.github/workflows/publish_charm.yaml index c5c5a43..8c5c25a 100644 --- a/.github/workflows/publish_charm.yaml +++ b/.github/workflows/publish_charm.yaml @@ -10,7 +10,29 @@ jobs: publish-to-edge: strategy: matrix: - configuration: [{working-directory: "./dovecot-charm", channel: "2.3/edge", tag-prefix: "dovecot"}] + configuration: + [ + { + working-directory: "./dovecot-charm", + channel: "2.3/edge", + tag-prefix: "dovecot", + }, + { + working-directory: "./opendkim-operator", + channel: "2/edge", + tag-prefix: "opendkim-operator", + }, + { + working-directory: "./postfix-relay-operator", + channel: "3/edge", + tag-prefix: "postfix-relay", + }, + { + working-directory: "./postfix-relay-configurator-operator", + channel: "3/edge", + tag-prefix: "postfix-relay-configurator", + }, + ] permissions: actions: read contents: write diff --git a/.github/workflows/publish_snap.yaml b/.github/workflows/publish_snap.yaml new file mode 100644 index 0000000..0c2b381 --- /dev/null +++ b/.github/workflows/publish_snap.yaml @@ -0,0 +1,56 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +name: Release snap to edge and promote + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + workflow_dispatch: + inputs: + promote-to: + description: Channel to promote the snap to (e.g. latest/candidate) + required: false + type: string + push: + branches: + - main + paths: + - opendkim-snap/** + +jobs: + build: + name: Build snap + uses: canonical/data-platform-workflows/.github/workflows/build_snap.yaml@v49 + with: + path-to-snap-project-directory: ./opendkim-snap + + release-edge: + name: Release snap to edge + needs: build + uses: canonical/data-platform-workflows/.github/workflows/release_snap.yaml@v49 + with: + channel: latest/edge + artifact-prefix: ${{ needs.build.outputs.artifact-prefix }} + path-to-snap-project-directory: ./opendkim-snap + secrets: + snap-store-token: ${{ secrets.SNAP_STORE_TOKEN }} + permissions: + contents: write + + promote: + name: Promote snap + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.promote-to != '' }} + needs: [build, release-edge] + uses: canonical/data-platform-workflows/.github/workflows/release_snap.yaml@v49 + with: + channel: ${{ github.event.inputs.promote-to }} + artifact-prefix: ${{ needs.build.outputs.artifact-prefix }} + path-to-snap-project-directory: ./opendkim-snap + create-git-tags: false + secrets: + snap-store-token: ${{ secrets.SNAP_STORE_TOKEN }} + permissions: + contents: write \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2b26a60..e4045a6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,6 +5,17 @@ on: jobs: unit-tests: + strategy: + matrix: + charm: + - name: dovecot + working-directory: ./dovecot-charm + - name: opendkim-operator + working-directory: ./opendkim-operator + - name: postfix-relay-operator + working-directory: ./postfix-relay-operator + - name: postfix-relay-configurator-operator + working-directory: ./postfix-relay-configurator-operator uses: canonical/operator-workflows/.github/workflows/test.yaml@main secrets: inherit permissions: @@ -13,6 +24,20 @@ jobs: with: self-hosted-runner: false with-uv: true - working-directory: dovecot-charm + working-directory: ${{ matrix.charm.working-directory }} runs-on-base: ubuntu-24.04 python-version: 3.12 + + snap-tests: + name: Snap spread tests + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up LXD + uses: canonical/setup-lxd@main + - name: Install snapcraft + run: sudo snap install snapcraft --classic + - name: Run spread tests + run: CI=1 snapcraft test + working-directory: opendkim-snap diff --git a/.licenserc.yaml b/.licenserc.yaml index 6f6b8f3..baec2e8 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -33,6 +33,8 @@ header: - 'docs/release-notes/template/*.yaml' - 'uv.lock' - 'opendkim-snap/snap/local/opendkim.conf' + - 'opendkim-operator/templates/opendkim.conf.j2' + - 'opendkim-operator/tests/unit/files/**' - '**/templates/**' - '**/lib/**' comment: on-failure diff --git a/dovecot-charm/charmcraft.yaml b/dovecot-charm/charmcraft.yaml index 5b56859..82d01d7 100644 --- a/dovecot-charm/charmcraft.yaml +++ b/dovecot-charm/charmcraft.yaml @@ -99,6 +99,20 @@ config: type: string actions: + create-mail-user: + description: Create or update local mail users used for SMTP and mailbox login. + params: + username: + type: string + description: Primary system username to create/update. + password: + type: string + description: Plaintext password to set for created users. + mailbox-user: + type: string + description: Optional mailbox username (for example user@example.com). + default: "" + required: [username, password] clear-queue: description: Forcibly remove messages from the Postfix mail queue. params: diff --git a/dovecot-charm/tests/integration/helpers.py b/dovecot-charm/tests/integration/helpers.py index f89f41e..7c18db2 100644 --- a/dovecot-charm/tests/integration/helpers.py +++ b/dovecot-charm/tests/integration/helpers.py @@ -316,3 +316,77 @@ def wait_for_sync_trigger( "Timed out waiting for sync trigger on " f"{unit}; previous mtime={previous_mtime}, previous timer count={previous_timer_count}" ) + + +def get_last_sync_mtime(juju: jubilant.Juju, unit: str) -> int | None: + """Return /srv/mail/.last-dsync mtime epoch on unit, or None if missing.""" + output = juju.exec( + "stat -c %Y /srv/mail/.last-dsync 2>/dev/null || true", unit=unit + ).stdout.strip() + return int(output) if output.isdigit() else None + + +def get_sync_timer_run_count(juju: jubilant.Juju, unit: str) -> int: + """Return count of sync-to-secondary service invocations from the journal.""" + output = juju.exec( + "journalctl -u sync-to-secondary.service --no-pager -q 2>/dev/null | wc -l || true", + unit=unit, + ).stdout.strip() + return int(output) if output.isdigit() else 0 + + +def get_sync_log_content(juju: jubilant.Juju, unit: str, lines: int = 20) -> str: + """Return last N lines from the sync-to-secondary service journal for debugging.""" + output = juju.exec( + f"journalctl -u sync-to-secondary.service --no-pager -n {lines} 2>/dev/null || echo 'No journal entries for sync-to-secondary'", + unit=unit, + ).stdout + return output + + +def get_timer_status(juju: jubilant.Juju, unit: str) -> str | None: + """Return systemctl show output for the sync-to-secondary timer, or None if absent.""" + result = juju.exec( + "systemctl show sync-to-secondary.timer --property=ActiveState,LastTriggerUSec 2>/dev/null || true", + unit=unit, + ).stdout.strip() + return result if result else None + + +def wait_for_sync_trigger( + juju: jubilant.Juju, + unit: str, + previous_mtime: int | None, + previous_timer_count: int, + timeout: int = 4 * 60, + poll_interval: int = 5, +) -> int: + """Wait until /srv/mail/.last-dsync mtime advances, indicating a completed sync. + + The sync script touches .last-dsync only at the very end, so this is a + reliable end-of-sync marker. Journal timer count is checked only to log + that the timer appears to have fired while we continue waiting for + .last-dsync to be updated. + """ + deadline = time.time() + timeout + timer_fired = False + while time.time() < deadline: + current_mtime = get_last_sync_mtime(juju, unit) + if current_mtime is not None and ( + previous_mtime is None or current_mtime > previous_mtime + ): + return current_mtime + + current_timer_count = get_sync_timer_run_count(juju, unit) + if current_timer_count > previous_timer_count and not timer_fired: + logging.info( + "Timer fired (journal count increased); waiting for .last-dsync to update..." + ) + timer_fired = True + + time.sleep(poll_interval) + + raise AssertionError( + "Timed out waiting for sync trigger on " + f"{unit}; previous mtime={previous_mtime}, previous timer count={previous_timer_count}" + ) diff --git a/dovecot-charm/tests/integration/test_mail.py b/dovecot-charm/tests/integration/test_mail.py index 9220960..ef20eb0 100644 --- a/dovecot-charm/tests/integration/test_mail.py +++ b/dovecot-charm/tests/integration/test_mail.py @@ -1,7 +1,7 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Integration tests for end-to-end mail delivery via Postfix → LMTP → Dovecot.""" +"""Integration tests for end-to-end mail delivery via Postfix -> LMTP -> Dovecot.""" import logging from secrets import token_hex @@ -14,7 +14,7 @@ def test_mail_workflow(juju: jubilant.Juju, dovecot_charm: str): - """Test end-to-end mail delivery via Postfix LMTP → Dovecot and IMAP retrieval. + """Test end-to-end mail delivery via Postfix LMTP -> Dovecot and IMAP retrieval. Mail is submitted over SMTP on port 25. Postfix matches the recipient domain against virtual_mailbox_domains and forwards it to Dovecot via the LMTP Unix @@ -31,19 +31,13 @@ def test_mail_workflow(juju: jubilant.Juju, dovecot_charm: str): password = token_hex(8) logging.info("Configuring user 'ubuntu'...") setup_mail_user(juju, primary=unit_name, secondary=None, user="ubuntu", password=password) - result = juju.run( unit_name, "create-mail-user", params={"username": "ubuntu", "password": password} ) assert result.status == "completed" assert result.results["status"] == "success" - logging.info("Sending test email...") - subject = "Mail Verification" - cmd = f"echo 'This is the body' | mail -s '{subject}' ubuntu@localhost" - juju.exec(cmd, unit=unit_name) - - logging.info("Verifying via IMAP...") + # Resolve the unit IP before sending so we can reuse it for the IMAP check. status = juju.status() unit_ip = status.apps[dovecot_charm].units[unit_name].public_address diff --git a/dovecot-charm/tests/unit/test_charm.py b/dovecot-charm/tests/unit/test_charm.py index efd539c..7e978ab 100644 --- a/dovecot-charm/tests/unit/test_charm.py +++ b/dovecot-charm/tests/unit/test_charm.py @@ -181,6 +181,87 @@ def test_clear_queue_failure(ctx, base_state): assert "postsuper" in exc_info.value.message +def test_create_mail_user_action_creates_primary_and_mailbox_user(ctx, base_state): + """create-mail-user creates missing users, groups and passwords.""" + with ( + patch( + "charm.getpwnam", + side_effect=[KeyError("e2euser"), KeyError("e2euser@example.com")], + ), + patch("charm.subprocess.run", return_value=MagicMock(returncode=0)), + ): + ctx.run( + ctx.on.action( + "create-mail-user", + params={ + "username": "e2euser", + "password": secrets.token_hex(8), + "mailbox-user": "e2euser@example.com", + }, + ), + base_state, + ) + + assert ctx.action_results["status"] == "success" + assert ctx.action_results["created"] == "e2euser,e2euser@example.com" + assert ctx.action_results["updated"] == "" + + +def test_create_mail_user_action_updates_existing_user(ctx, base_state): + """create-mail-user updates password/group for existing users.""" + existing_user = object() + with ( + patch("charm.getpwnam", return_value=existing_user), + patch("charm.subprocess.run", return_value=MagicMock(returncode=0)), + ): + ctx.run( + ctx.on.action( + "create-mail-user", + params={ + "username": "e2euser", + "password": secrets.token_hex(8), + }, + ), + base_state, + ) + + assert ctx.action_results["status"] == "success" + assert ctx.action_results["created"] == "" + assert ctx.action_results["updated"] == "e2euser" + + +def test_create_mail_user_action_requires_username(ctx, base_state): + """create-mail-user fails fast when username is missing.""" + with pytest.raises(ops.testing.ActionFailed) as exc_info: + ctx.run( + ctx.on.action( + "create-mail-user", + params={ + "username": "", + "password": secrets.token_hex(8), + }, + ), + base_state, + ) + assert "username" in exc_info.value.message + + +def test_create_mail_user_action_requires_password(ctx, base_state): + """create-mail-user fails fast when password is missing.""" + with pytest.raises(ops.testing.ActionFailed) as exc_info: + ctx.run( + ctx.on.action( + "create-mail-user", + params={ + "username": "e2euser", + "password": "", # nosec B105 + }, + ), + base_state, + ) + assert "password" in exc_info.value.message + + def test_force_sync_success(ctx, base_state): """force-sync succeeds when this unit is primary and a secondary exists.""" mock_result = MagicMock(stdout="ok", stderr="") diff --git a/opendkim-operator/tests/unit/files/base_keytable b/opendkim-operator/tests/unit/files/base_keytable index 6327a7b..f7817bc 100644 --- a/opendkim-operator/tests/unit/files/base_keytable +++ b/opendkim-operator/tests/unit/files/base_keytable @@ -1,5 +1,2 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - selector._domainkey.example.com example.com:selector:/etc/dkimkeys/key1.private selector._domainkey.other.example.com other.example.com:selector:/etc/dkimkeys/key2.private \ No newline at end of file diff --git a/opendkim-operator/tests/unit/files/base_opendkim.conf b/opendkim-operator/tests/unit/files/base_opendkim.conf index c5bb264..6557dfd 100644 --- a/opendkim-operator/tests/unit/files/base_opendkim.conf +++ b/opendkim-operator/tests/unit/files/base_opendkim.conf @@ -1,6 +1,3 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - # This file is Juju managed - do not edit by hand # Socket inet:8892 diff --git a/opendkim-operator/tests/unit/files/base_signingtable b/opendkim-operator/tests/unit/files/base_signingtable index cfe7cde..f5b924d 100644 --- a/opendkim-operator/tests/unit/files/base_signingtable +++ b/opendkim-operator/tests/unit/files/base_signingtable @@ -1,5 +1,2 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - *@example.com selector._domainkey.example.com *@other.example.com selector._domainkey.other.example.com \ No newline at end of file diff --git a/opendkim-operator/tests/unit/files/logrotate b/opendkim-operator/tests/unit/files/logrotate index be92317..8e1c0c9 100644 --- a/opendkim-operator/tests/unit/files/logrotate +++ b/opendkim-operator/tests/unit/files/logrotate @@ -1,6 +1,3 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - var/log/syslog { rotate 7 diff --git a/opendkim-operator/tests/unit/files/logrotate_frequency b/opendkim-operator/tests/unit/files/logrotate_frequency index bf08389..1338147 100644 --- a/opendkim-operator/tests/unit/files/logrotate_frequency +++ b/opendkim-operator/tests/unit/files/logrotate_frequency @@ -1,6 +1,3 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - var/log/syslog { rotate 7 diff --git a/opendkim-operator/tests/unit/files/logrotate_retention b/opendkim-operator/tests/unit/files/logrotate_retention index 98208b9..58697be 100644 --- a/opendkim-operator/tests/unit/files/logrotate_retention +++ b/opendkim-operator/tests/unit/files/logrotate_retention @@ -1,6 +1,3 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - var/log/syslog { dateext diff --git a/opendkim-operator/tests/unit/files/logrotate_retention_no_dateext b/opendkim-operator/tests/unit/files/logrotate_retention_no_dateext index bac61bf..7545e2f 100644 --- a/opendkim-operator/tests/unit/files/logrotate_retention_no_dateext +++ b/opendkim-operator/tests/unit/files/logrotate_retention_no_dateext @@ -1,6 +1,3 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - var/log/syslog { rotate 30 diff --git a/opendkim-operator/tests/unit/files/sv_trusted_sources_opendkim.conf b/opendkim-operator/tests/unit/files/sv_trusted_sources_opendkim.conf index c623dfe..5730ed2 100644 --- a/opendkim-operator/tests/unit/files/sv_trusted_sources_opendkim.conf +++ b/opendkim-operator/tests/unit/files/sv_trusted_sources_opendkim.conf @@ -1,6 +1,3 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - # This file is Juju managed - do not edit by hand # Socket inet:8892 diff --git a/opendkim-operator/tests/unit/files/verify_trusted_sources_opendkim.conf b/opendkim-operator/tests/unit/files/verify_trusted_sources_opendkim.conf index 78e5195..2abcdbd 100644 --- a/opendkim-operator/tests/unit/files/verify_trusted_sources_opendkim.conf +++ b/opendkim-operator/tests/unit/files/verify_trusted_sources_opendkim.conf @@ -1,6 +1,3 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - # This file is Juju managed - do not edit by hand # Socket inet:8892 diff --git a/opendkim-operator/tests/unit/test_charm.py b/opendkim-operator/tests/unit/test_charm.py index 1605c5d..a016c4d 100644 --- a/opendkim-operator/tests/unit/test_charm.py +++ b/opendkim-operator/tests/unit/test_charm.py @@ -90,28 +90,28 @@ def test_install(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): pytest.param( "*wrongyaml", "*wrongyaml", - {}, + {"thekey": "PRIVATEKEY"}, ["wrong signingtable", "wrong keytable"], id="Wrong YAML formats", ), pytest.param( "signingtable", "keytable", - {}, + {"thekey": "PRIVATEKEY"}, ["wrong", " signingtable,keytable."], id="Wrong YAML config options", ), pytest.param( json.dumps([["valid", "valid"]]), "keytable", - {}, + {"thekey": "PRIVATEKEY"}, ["wrong", " keytable."], id="Wrong YAML config options", ), pytest.param( "signingtable", json.dumps([["*@example.com", "selector._domainkey.example.com"]]), - {}, + {"thekey": "PRIVATEKEY"}, ["wrong", " signingtable."], id="Wrong YAML config options", ), diff --git a/pyproject.toml b/pyproject.toml index b5a6cbd..1cf853c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,8 +47,11 @@ static = [ integration = [ "allure-pytest>=2.8.18", "allure-pytest-collection-report @ git+https://github.com/canonical/data-platform-workflows@v24.0.0#subdirectory=python/pytest_plugins/allure_pytest_collection_report", + "cryptography>=42.0", "jubilant==1.10.0", "pytest", + "pyyaml>=6.0", + "requests>=2.31", ] [tool.uv] @@ -168,6 +171,9 @@ pythonpath = [ "dovecot-charm/lib", "dovecot-charm/src", ] +markers = [ + "abort_on_fail: abort remaining tests if this test fails", +] [tool.ruff.mccabe] max-complexity = 10 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f82e6c5..acae88d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,58 +1,109 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Shared fixtures and configuration for integration tests.""" +"""Fixtures for the full-stack mailserver integration tests. +Topology +-------- + ┌─────────────────────┐ + test runner ──587──► │ postfix-relay │ ◄─milter─► opendkim + (smtplib) │ + configurator │ (DKIM sign) + └──────────┬──────────┘ + │ LMTP :24 + ▼ + dovecot (IMAP) + │ + ◄──993──── │ + test runner (imaplib) │ + verifies DKIM-Signature header │ + and subject in delivered mail ┘ + +TLS for postfix-relay is provided by self-signed-certificates (CharmHub). +""" + +import base64 +from collections.abc import Generator +import hashlib +import json import logging +import pathlib +from secrets import token_hex +import socket import typing -from collections.abc import Generator import jubilant import pytest import yaml +from helpers import integrate_once as _integrate_once, select_charm_file as _select_charm_file, sha512_dovecot_password as _sha512_dovecot_password +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa -from helpers import integrate_once, select_charm_file, sha512_dovecot_password logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Charm / app names +# --------------------------------------------------------------------------- +DOVECOT_APP = "dovecot" POSTFIX_RELAY_APP = "postfix-relay" +OPENDKIM_APP = "opendkim" CONFIGURATOR_APP = "postfix-relay-configurator" SELF_SIGNED_APP = "self-signed-certificates" +# Domain used throughout the test suite TEST_DOMAIN = "mailstack.internal" -SMTP_PORT = 587 +TEST_SMTP_USER = "e2euser" +TEST_SMTP_PASSWORD = token_hex(16) +# parents[0]=tests/integration, parents[1]=tests, parents[2]=mailserver-operators/ +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +OPENDKIM_SNAP_DIR = _REPO_ROOT / "opendkim-snap" +SMTP_PORT = 587 +AUTHORIZED_SENDER = f"authorized@{TEST_DOMAIN}" +# --------------------------------------------------------------------------- +# pytest CLI options +# --------------------------------------------------------------------------- def pytest_addoption(parser: pytest.Parser) -> None: - """Add integration test command-line options.""" + """Register extra CLI options consumed by the integration suite.""" parser.addoption( "--charm-file", action="append", default=[], - help="Path to charm file (can be used multiple times)", + help=("Path to a pre-built .charm file. Pass this option multiple times (one per charm)."), ) parser.addoption( - "--use-existing", + "--keep-models", action="store_true", default=False, - help="Use existing model instead of creating a temporary one", + help="Keep Juju models after tests complete (useful for debugging).", ) parser.addoption( "--model", + action="store", default=None, - help="Specific model name to use", + help="Use an existing Juju model by name instead of creating a temp model.", ) parser.addoption( - "--keep-models", + "--use-existing", action="store_true", default=False, - help="Keep temporary models after tests complete", + help="Attach to the current Juju model without deploying anything new.", ) -@pytest.fixture(scope="module", name="juju") -def juju_fixture(request: pytest.FixtureRequest) -> Generator[jubilant.Juju, None, None]: - """Module-scoped Juju client in a temporary model for configurator map tests.""" + + + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- +@pytest.fixture(scope="session", name="juju") +def juju_fixture(request: pytest.FixtureRequest) -> Generator[jubilant.Juju, None, None]: + """Session-scoped Juju client in a temporary model for integration tests.""" + logging.getLogger('jubilant.wait').setLevel(logging.WARNING) def _show_debug_log(juju: jubilant.Juju) -> None: if request.session.testsfailed: log = juju.debug_log(limit=2000) @@ -66,7 +117,7 @@ def _show_debug_log(juju: jubilant.Juju) -> None: _show_debug_log(juju) return - model = request.config.getoption("--model", default=None) + model = request.config.getoption("--model") if model: juju = jubilant.Juju(model=model) juju.model_config({"automatically-retry-hooks": True}) @@ -74,37 +125,309 @@ def _show_debug_log(juju: jubilant.Juju) -> None: _show_debug_log(juju) return - keep_models = typing.cast(bool, request.config.getoption("--keep-models", default=False)) + keep_models = typing.cast(bool, request.config.getoption("--keep-models")) with jubilant.temp_model(keep=keep_models) as juju: juju.wait_timeout = 15 * 60 juju.model_config({"automatically-retry-hooks": True}) yield juju _show_debug_log(juju) + return + + +# --------------------------------------------------------------------------- +# Machine IP (test runner) +# --------------------------------------------------------------------------- +@pytest.fixture(scope="session", name="machine_ip_address") +def machine_ip_address_fixture() -> str: + """IP address of the machine running the tests. + + Used to configure /etc/hosts on Juju units so TEST_DOMAIN resolves to + the test runner (where mailcatcher or similar sinks may be listening), + and also to tell postfix-relay where to forward mail. + """ + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + logger.info("Test runner IP: %s", ip) + return ip + + +# --------------------------------------------------------------------------- +# Charm-file fixtures +# --------------------------------------------------------------------------- +def _select_charm_file_for_app(pytestconfig: pytest.Config, app_name: str) -> str: + """Return a charm file path matching *app_name* from repeated --charm-file args.""" + charm_files = typing.cast(list[str], pytestconfig.getoption("--charm-file")) + use_existing = pytestconfig.getoption("--use-existing", default=False) + + # Match more specific names first to avoid postfix-relay matching configurator. + match_order = ( + (CONFIGURATOR_APP, "postfix-relay-configurator"), + (POSTFIX_RELAY_APP, "postfix-relay"), + (DOVECOT_APP, "dovecot"), + (OPENDKIM_APP, "opendkim"), + ) + + app_to_path: dict[str, str] = {} + for path in charm_files: + name = pathlib.Path(path).name.lower() + for app, marker in match_order: + if marker in name: + app_to_path[app] = path + break + + selected = app_to_path.get(app_name) + if selected: + return selected + + if use_existing: + # In --use-existing mode, deployment may be skipped if apps already exist. + return "" + + provided = ", ".join(charm_files) if charm_files else "" + raise AssertionError( + f"Missing --charm-file for {app_name}. Provided: {provided}. " + "Pass one --charm-file per charm artifact." + ) + + +@pytest.fixture(scope="session", name="dovecot_charm_file") +def dovecot_charm_file_fixture(pytestconfig: pytest.Config) -> str: + """Absolute path to the pre-built dovecot .charm file.""" + return _select_charm_file_for_app(pytestconfig, DOVECOT_APP) + + +@pytest.fixture(scope="session", name="postfix_relay_charm_file") +def postfix_relay_charm_file_fixture(pytestconfig: pytest.Config) -> str: + """Absolute path to the pre-built postfix-relay .charm file.""" + return _select_charm_file_for_app(pytestconfig, POSTFIX_RELAY_APP) + + +@pytest.fixture(scope="session", name="opendkim_charm_file") +def opendkim_charm_file_fixture(pytestconfig: pytest.Config) -> str: + """Absolute path to the pre-built opendkim .charm file.""" + return _select_charm_file_for_app(pytestconfig, OPENDKIM_APP) + + +@pytest.fixture(scope="session", name="configurator_charm_file") +def configurator_charm_file_fixture(pytestconfig: pytest.Config) -> str: + """Absolute path to the pre-built postfix-relay-configurator .charm file.""" + return _select_charm_file_for_app(pytestconfig, CONFIGURATOR_APP) + +# --------------------------------------------------------------------------- +# DKIM key generation helper +# --------------------------------------------------------------------------- +def generate_dkim_keypair(domain: str, selector: str) -> typing.Tuple[str, str]: + """Generate a DKIM keypair using the Python cryptography library. -@pytest.fixture(scope="module", name="postfix_stack") + Args: + domain: The signing domain (e.g. ``mailstack.internal``). + selector: The DKIM selector (e.g. ``default``). + + Returns: + A ``(txt_record, private_key_pem)`` tuple where ``txt_record`` is a + DNS TXT record string and ``private_key_pem`` is a PEM-encoded RSA + private key. + """ + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + + pub_der = private_key.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + pub_b64 = base64.b64encode(pub_der).decode() + txt_record = ( + f'{selector}._domainkey\tIN\tTXT\t( "v=DKIM1; h=sha256; k=rsa; "\n' + f'\t"p={pub_b64}" )\n' + f"; ----- DKIM key {selector} for {domain}\n" + ) + return txt_record, private_key_pem + + +# --------------------------------------------------------------------------- +# Deploy: self-signed-certificates (TLS provider for postfix-relay / dovecot) +# --------------------------------------------------------------------------- +@pytest.fixture(scope="session", name="self_signed_app") +def deploy_self_signed_certs_fixture(juju: jubilant.Juju) -> str: + """Deploy self-signed-certificates from CharmHub.""" + if not juju.status().apps.get(SELF_SIGNED_APP): + juju.deploy(SELF_SIGNED_APP, channel="latest/stable") + juju.wait( + lambda status: status.apps[SELF_SIGNED_APP].is_active, + error=jubilant.any_error, + timeout=10 * 60, + ) + logger.info("self-signed-certificates is active") + return SELF_SIGNED_APP + + +@pytest.fixture(scope="session", name="postfix_stack") def postfix_stack_fixture( juju: jubilant.Juju, - pytestconfig: pytest.Config, + postfix_relay_app: str, + postfix_relay_configurator_app: str, ) -> typing.Dict[str, str]: """Deploy postfix-relay + postfix-relay-configurator configured for sender_login enforcement. Returns a dict with ``postfix_relay_ip``. """ - if not juju.status().apps.get(SELF_SIGNED_APP): - juju.deploy(SELF_SIGNED_APP, channel="latest/stable") + _integrate_once( + juju, + f"{postfix_relay_app}:juju-info", + f"{postfix_relay_configurator_app}:juju-info", + ) + + # Wait for both to be active. + def _both_active(status: jubilant.Status) -> bool: + if not status.apps.get(POSTFIX_RELAY_APP): + return False + if not status.apps[POSTFIX_RELAY_APP].is_active: + return False + for unit in status.apps[POSTFIX_RELAY_APP].units.values(): + subs = unit.subordinates or {} + conf_subs = {k: v for k, v in subs.items() if CONFIGURATOR_APP in k} + if not conf_subs: + return False + for sub in conf_subs.values(): + if sub.workload_status.current != "active": + return False + return True + + juju.wait(_both_active, error=jubilant.any_error, timeout=15 * 60) + logger.info("postfix-relay + configurator active for maps tests") + + status = juju.status() + relay_ip = next(iter(status.apps[POSTFIX_RELAY_APP].units.values())).public_address + logger.info("postfix-relay IP: %s", relay_ip) + return {"postfix_relay_app": postfix_relay_app, "postfix_relay_ip": relay_ip} + + +# --------------------------------------------------------------------------- +# Deploy: opendkim +# --------------------------------------------------------------------------- +@pytest.fixture(scope="session", name="opendkim_app") +def deploy_opendkim_fixture( + opendkim_charm_file: str, + juju: jubilant.Juju, +) -> str: + """Deploy opendkim and optionally replace the store snap with a local build.""" + if not juju.status().apps.get(OPENDKIM_APP): + charm_path = ( + opendkim_charm_file + if opendkim_charm_file.startswith(("./", "/")) + else f"./{opendkim_charm_file}" + ) + juju.deploy(charm_path, OPENDKIM_APP) + # Charm starts blocked (not yet configured) or waiting for milter relation. + juju.wait( + lambda status: ( + status.apps[OPENDKIM_APP].is_blocked + or status.apps[OPENDKIM_APP].app_status.current == "waiting" + ), + timeout=10 * 60, + ) + + _replace_opendkim_snap(juju, OPENDKIM_APP) + return OPENDKIM_APP + + +def _replace_opendkim_snap(juju: jubilant.Juju, app_name: str) -> None: + """Replace the store-installed opendkim snap with a locally-built one if present.""" + snap_files = sorted(OPENDKIM_SNAP_DIR.glob("opendkim_*.snap")) + if not snap_files: + logger.warning( + "No locally-built opendkim snap found in %s — using store version", + OPENDKIM_SNAP_DIR, + ) + return + + snap_path = snap_files[-1] + snap_name = snap_path.name + logger.info("Replacing opendkim snap with local build: %s", snap_path) + + status = juju.status() + for unit_name in status.apps[app_name].units: + juju.scp(snap_path, f"{unit_name}:/tmp/{snap_name}") + juju.exec( + "sudo", + "snap", + "install", + "--dangerous", + f"/tmp/{snap_name}", # nosec B108 + unit=unit_name, + ) + logger.info("Installed local opendkim snap on %s", unit_name) + + +# --------------------------------------------------------------------------- +# Deploy: dovecot +# --------------------------------------------------------------------------- +@pytest.fixture(scope="session", name="dovecot_app") +def deploy_dovecot_fixture( + dovecot_charm_file: str, + self_signed_app: str, + juju: jubilant.Juju, +) -> str: + """Deploy dovecot and wire up TLS.""" + luks_key = token_hex(16) + + if not juju.status().apps.get(DOVECOT_APP): + charm_path = ( + dovecot_charm_file + if dovecot_charm_file.startswith(("./", "/")) + else f"./{dovecot_charm_file}" + ) + secret_id = juju.cli("add-secret", "dovecot-luks-key", f"key={luks_key}").strip() + juju.deploy( + charm_path, + app=DOVECOT_APP, + config={ + "mailname": TEST_DOMAIN, + "postmaster-address": f"postmaster@{TEST_DOMAIN}", + "primary-unit": f"{DOVECOT_APP}/0", + "luks-auto-provisioning": True, + "luks-key": secret_id, + }, + constraints={"virt-type": "virtual-machine"}, + trust=True, + ) + juju.cli("grant-secret", "dovecot-luks-key", DOVECOT_APP) + + # Relate to TLS provider if not already related. + _integrate_once(juju, f"{DOVECOT_APP}:certificates", f"{self_signed_app}:certificates") + juju.wait( - lambda status: status.apps[SELF_SIGNED_APP].is_active, + lambda status: status.apps[DOVECOT_APP].is_active, error=jubilant.any_error, - timeout=10 * 60, + timeout=15 * 60, ) + logger.info("dovecot is active") + return DOVECOT_APP - # --- postfix-relay --- - auth_password = "test-password" + +# --------------------------------------------------------------------------- +# Deploy: postfix-relay +# --------------------------------------------------------------------------- +@pytest.fixture(scope="session", name="postfix_relay_app") +def deploy_postfix_relay_fixture( + postfix_relay_charm_file: str, + self_signed_app: str, + juju: jubilant.Juju, +) -> str: + """Deploy postfix-relay and integrate with TLS provider.""" if not juju.status().apps.get(POSTFIX_RELAY_APP): - charm_path = select_charm_file(pytestconfig, "postfix-relay_") - if not charm_path.startswith(("./", "/")): - charm_path = f"./{charm_path}" + charm_path = ( + postfix_relay_charm_file + if postfix_relay_charm_file.startswith(("./", "/")) + else f"./{postfix_relay_charm_file}" + ) juju.deploy( charm_path, app=POSTFIX_RELAY_APP, @@ -112,43 +435,98 @@ def postfix_stack_fixture( "relay_domains": f"- {TEST_DOMAIN}", "enable_smtp_auth": "true", "smtp_auth_users": yaml.dump( - [f"testuser:{sha512_dovecot_password(auth_password)}"] + [f"{TEST_SMTP_USER}:{_sha512_dovecot_password(TEST_SMTP_PASSWORD)}"] ), "enable_reject_unknown_sender_domain": "false", }, ) - integrate_once( - juju, - f"{POSTFIX_RELAY_APP}:certificates", - f"{SELF_SIGNED_APP}:certificates", + + _integrate_once(juju, f"{POSTFIX_RELAY_APP}:certificates", f"{self_signed_app}:certificates") + + juju.wait( + lambda status: ( + status.apps[POSTFIX_RELAY_APP].is_active + ), + timeout=15 * 60, ) + logger.info("postfix-relay is active") + return POSTFIX_RELAY_APP + - # --- postfix-relay-configurator --- - authorized_sender = f"authorized@{TEST_DOMAIN}" +@pytest.fixture(scope="session", name="postfix_relay_configurator_app") +def deploy_postfix_relay_configurator_fixture( + configurator_charm_file: str, + juju: jubilant.Juju, +) -> str: + """Deploy postfix-relay-configurator.""" if not juju.status().apps.get(CONFIGURATOR_APP): - charm_path = select_charm_file(pytestconfig, "postfix-relay-configurator_") - if not charm_path.startswith(("./", "/")): - charm_path = f"./{charm_path}" + charm_path = ( + configurator_charm_file + if configurator_charm_file.startswith(("./", "/")) + else f"./{configurator_charm_file}" + ) juju.deploy( charm_path, app=CONFIGURATOR_APP, config={ - "sender_login_maps": yaml.dump({authorized_sender: "testuser"}), + "sender_login_maps": yaml.dump({AUTHORIZED_SENDER: TEST_SMTP_USER}), }, ) - integrate_once( - juju, - f"{POSTFIX_RELAY_APP}:juju-info", - f"{CONFIGURATOR_APP}:juju-info", - ) + else: + juju.config(CONFIGURATOR_APP, {"sender_login_maps": yaml.dump({AUTHORIZED_SENDER: TEST_SMTP_USER})}) + logger.info("postfix-relay-configurator deployed") + return CONFIGURATOR_APP + + +# --------------------------------------------------------------------------- +# Deploy: postfix-relay-configurator (subordinate) +# --------------------------------------------------------------------------- +@pytest.fixture(scope="session", name="configurator_app") +def deploy_configurator_fixture( + postfix_stack: typing.Dict[str, str], + opendkim_app: str, + dovecot_app: str, + juju: jubilant.Juju, +) -> str: + """Deploy the postfix-relay-configurator subordinate and configure SMTP routing. + + The configurator's ``transport_maps`` is set to route mail for TEST_DOMAIN + to dovecot's Postfix on port 25, which delivers locally to dovecot via LMTP + Unix socket. + """ + # Resolve dovecot's IP after it is active. + status = juju.status() + dovecot_unit = next(iter(status.apps[dovecot_app].units.values())) + dovecot_ip = dovecot_unit.public_address + logger.info("Routing %s → smtp:[%s]:25", TEST_DOMAIN, dovecot_ip) + _integrate_once(juju, f"{postfix_stack['postfix_relay_app']}:milter", f"{opendkim_app}:milter") + + configurator_config = { + "relay_access_sources": yaml.dump({"192.0.2.0/24": "OK"}), + "relay_recipient_maps": yaml.dump( + {f"noreply@{TEST_DOMAIN}": f"postmaster@{TEST_DOMAIN}", f"{TEST_SMTP_USER}@{TEST_DOMAIN}": "OK"} + ), + "restrict_recipients": yaml.dump({"blocked-recipient@example.invalid": "REJECT"}), + "restrict_senders": yaml.dump({"blocked-sender@example.invalid": "REJECT"}), + "sender_login_maps": yaml.dump( + { + AUTHORIZED_SENDER: TEST_SMTP_USER, + "auth-only@example.invalid": "nobody", + f"{TEST_SMTP_USER}@{TEST_DOMAIN}": f"{TEST_SMTP_USER}@{TEST_DOMAIN}", + } + ), + "transport_maps": yaml.dump({TEST_DOMAIN: f"smtp:[{dovecot_ip}]:25"}), + } + + juju.config(CONFIGURATOR_APP, configurator_config) # Wait for both to be active. def _both_active(status: jubilant.Status) -> bool: - if not status.apps.get(POSTFIX_RELAY_APP): + if not status.apps.get(postfix_stack["postfix_relay_app"]): return False - if not status.apps[POSTFIX_RELAY_APP].is_active: + if not status.apps[postfix_stack["postfix_relay_app"]].is_active: return False - for unit in status.apps[POSTFIX_RELAY_APP].units.values(): + for unit in status.apps[postfix_stack["postfix_relay_app"]].units.values(): subs = unit.subordinates or {} conf_subs = {k: v for k, v in subs.items() if CONFIGURATOR_APP in k} if not conf_subs: @@ -160,8 +538,110 @@ def _both_active(status: jubilant.Status) -> bool: juju.wait(_both_active, error=jubilant.any_error, timeout=15 * 60) logger.info("postfix-relay + configurator active for maps tests") + return CONFIGURATOR_APP + + +# --------------------------------------------------------------------------- +# Configure opendkim with DKIM keys for TEST_DOMAIN +# --------------------------------------------------------------------------- +@pytest.fixture(scope="session", name="opendkim_configured") +def configure_opendkim_fixture( + opendkim_app: str, + postfix_stack: typing.Dict[str, str], + configurator_app: str, + machine_ip_address: str, + juju: jubilant.Juju, +) -> str: + """Generate a DKIM keypair and configure opendkim for TEST_DOMAIN. + + Returns the opendkim app name once the app is active. + """ + + selector = "default" + keyname = f"{TEST_DOMAIN.replace('.', '-')}-{selector}" + _, private_key = generate_dkim_keypair(domain=TEST_DOMAIN, selector=selector) + + # Store private key as a Juju secret. + try: + secret_id = juju.add_secret("mailstack-dkim-secret", {keyname: private_key}) + except jubilant.CLIError as exc: + if "already exists" in exc.stderr: + secret_info = juju.show_secret("mailstack-dkim-secret") + secret_id = secret_info.uri + juju.update_secret(secret_id, {keyname: private_key}) + else: + logger.error("Failed to add secret: %s %s", exc.stderr, exc.stdout) + raise + juju.cli("grant-secret", secret_id, opendkim_app) + + keytable = [ + [ + f"{selector}._domainkey.{TEST_DOMAIN}", + f"{TEST_DOMAIN}:{selector}:/etc/dkimkeys/{keyname}.private", + ] + ] + signingtable = [[f"*@{TEST_DOMAIN}", f"{selector}._domainkey.{TEST_DOMAIN}"]] + juju.config( + opendkim_app, + { + "keytable": json.dumps(keytable), + "signingtable": json.dumps(signingtable), + "private-keys": secret_id, + "mode": "s", + }, + ) + + # Inject TEST_DOMAIN → test-runner IP in /etc/hosts on the postfix-relay unit + # so that Postfix can resolve the domain when it looks up the MX / transport. status = juju.status() - relay_ip = next(iter(status.apps[POSTFIX_RELAY_APP].units.values())).public_address - logger.info("postfix-relay IP: %s", relay_ip) - return {"postfix_relay_ip": relay_ip} + relay_unit = next(iter(status.apps[postfix_stack["postfix_relay_app"]].units.values())) + juju.exec( + machine=relay_unit.machine, + command=f"echo '{machine_ip_address} {TEST_DOMAIN}' | sudo tee -a /etc/hosts", + ) + + juju.wait( + lambda status: jubilant.all_active(status, opendkim_app, postfix_stack["postfix_relay_app"]), + timeout=5 * 60, + delay=5, + ) + logger.info("opendkim configured and active with DKIM keys for %s", TEST_DOMAIN) + return opendkim_app + + +# --------------------------------------------------------------------------- +# Full stack fixture — depends on everything +# --------------------------------------------------------------------------- +@pytest.fixture(scope="session", name="mail_stack") +def mail_stack_fixture( + juju: jubilant.Juju, + dovecot_app: str, + postfix_stack: typing.Dict[str, str], + opendkim_configured: str, + configurator_app: str, +) -> typing.Dict[str, str]: + """Ensure the complete mail stack is up and return a dict of app names and IPs. + + Returns a mapping with keys: + ``dovecot_app``, ``postfix_relay_app``, ``opendkim_app``, + ``configurator_app``, ``dovecot_ip``, ``postfix_relay_ip``. + """ + juju.wait( + lambda status: jubilant.all_active( + status, dovecot_app, postfix_stack["postfix_relay_app"], opendkim_configured, SELF_SIGNED_APP + ), + timeout=5 * 60, + ) + + status = juju.status() + dovecot_ip = next(iter(status.apps[dovecot_app].units.values())).public_address + + logger.info("Mail stack ready — dovecot: %s, postfix-relay: %s", dovecot_ip, postfix_stack["postfix_relay_ip"]) + return { + "dovecot_app": dovecot_app, + "opendkim_app": opendkim_configured, + "configurator_app": configurator_app, + "dovecot_ip": dovecot_ip, + **postfix_stack + } diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index e8c0937..2036994 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -20,7 +20,6 @@ TEST_DOMAIN = "mailstack.internal" SMTP_PORT = 587 - def sha512_dovecot_password(password: str) -> str: """Generate a SSHA512 password hash compatible with dovecot.""" salt = b"mailtest" diff --git a/tests/integration/test_configurator_maps.py b/tests/integration/test_configurator_maps.py index ff4681c..cb3b4b2 100644 --- a/tests/integration/test_configurator_maps.py +++ b/tests/integration/test_configurator_maps.py @@ -15,17 +15,10 @@ import pytest -logger = logging.getLogger(__name__) +from conftest import AUTHORIZED_SENDER, SMTP_PORT, TEST_DOMAIN, TEST_SMTP_PASSWORD, TEST_SMTP_USER -# --------------------------------------------------------------------------- -# Test-specific constants -# --------------------------------------------------------------------------- -TEST_DOMAIN = "mailstack.internal" -SMTP_PORT = 587 +logger = logging.getLogger(__name__) -AUTH_USER = "testuser" -AUTH_PASSWORD = "test-password" -AUTHORIZED_SENDER = f"authorized@{TEST_DOMAIN}" SPOOFED_SENDER = f"spoofed@{TEST_DOMAIN}" RECIPIENT = f"recipient@{TEST_DOMAIN}" @@ -49,7 +42,7 @@ def test_sender_login_map_enforcement(self, postfix_stack: typing.Dict[str, str] smtp.ehlo() smtp.starttls(context=ctx) smtp.ehlo() - smtp.login(AUTH_USER, AUTH_PASSWORD) + smtp.login(TEST_SMTP_USER, TEST_SMTP_PASSWORD) smtp.sendmail( from_addr=AUTHORIZED_SENDER, to_addrs=[RECIPIENT], @@ -76,7 +69,7 @@ def test_sender_login_map_enforcement_failure(self, postfix_stack: typing.Dict[s smtp.ehlo() smtp.starttls(context=ctx) smtp.ehlo() - smtp.login(AUTH_USER, AUTH_PASSWORD) + smtp.login(TEST_SMTP_USER, TEST_SMTP_PASSWORD) with pytest.raises(smtplib.SMTPRecipientsRefused) as exc_info: smtp.sendmail( from_addr=SPOOFED_SENDER, diff --git a/tests/integration/test_e2e.py b/tests/integration/test_e2e.py new file mode 100644 index 0000000..8857882 --- /dev/null +++ b/tests/integration/test_e2e.py @@ -0,0 +1,124 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Single end-to-end test for the full mail system.""" + +import base64 +import contextlib +import email +import hashlib +import imaplib +import os +import smtplib +import ssl +import time +from typing import Dict + +import jubilant +import pytest +import yaml + +from conftest import TEST_DOMAIN, TEST_SMTP_PASSWORD, TEST_SMTP_USER + +MAILBOX_USER = f"{TEST_SMTP_USER}@{TEST_DOMAIN}" +SMTP_SUBMISSION_PORT = 587 +IMAP_PORT = 993 + + +def test_e2e(juju: jubilant.Juju, mail_stack: Dict[str, str]) -> None: + """Verify SMTP AUTH -> DKIM signing -> LMTP delivery -> IMAP retrieval.""" + relay_ip = mail_stack["postfix_relay_ip"] + dovecot_ip = mail_stack["dovecot_ip"] + + dovecot_unit = f"{mail_stack['dovecot_app']}/0" + action_result = juju.run( + dovecot_unit, + "create-mail-user", + params={ + "username": TEST_SMTP_USER, + "password": TEST_SMTP_PASSWORD, + "mailbox-user": MAILBOX_USER, + }, + ) + assert action_result.status == "completed" + + smtp_auth_users = yaml.dump([f"{MAILBOX_USER}:{_sha512_dovecot(TEST_SMTP_PASSWORD)}"]) + juju.config( + "postfix-relay", + { + "enable_smtp_auth": "true", + "smtp_auth_users": smtp_auth_users, + }, + ) + juju.wait( + lambda status: status.apps["postfix-relay"].is_active, + error=jubilant.any_error, + timeout=5 * 60, + ) + + subject = f"Whole system e2e {int(time.time())}" + from_addr = MAILBOX_USER + to_addr = MAILBOX_USER + message = ( + f"Subject: {subject}\r\n" + f"From: {from_addr}\r\n" + f"To: {to_addr}\r\n" + "\r\n" + "full system integration test\r\n" + ) + + tls_ctx = ssl.create_default_context() + tls_ctx.check_hostname = False + tls_ctx.verify_mode = ssl.CERT_NONE + + with smtplib.SMTP(relay_ip, SMTP_SUBMISSION_PORT, timeout=30) as server: + server.ehlo() + server.starttls(context=tls_ctx) + server.ehlo() + server.login(MAILBOX_USER, TEST_SMTP_PASSWORD) + server.sendmail(from_addr, [to_addr], message) + + raw_message = _wait_for_subject(dovecot_ip, MAILBOX_USER, TEST_SMTP_PASSWORD, subject) + # Dovecot accepts login with the full mailbox address (e.g. user@domain) when + # auth_username_format is unset; the system user is looked up by local part. + parsed = email.message_from_bytes(raw_message) + + assert parsed["Subject"] == subject + assert "DKIM-Signature" in parsed + assert TEST_DOMAIN in parsed.get("DKIM-Signature", "") + + +def _sha512_dovecot(password: str, salt: bytes | None = None) -> str: + if salt is None: + salt = os.urandom(8) + digest = hashlib.sha512(password.encode() + salt).digest() + return "{SSHA512}" + base64.b64encode(digest + salt).decode() + + +def _wait_for_subject(host: str, username: str, password: str, subject: str) -> bytes: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + deadline = time.time() + 120 + while time.time() < deadline: + try: + conn = imaplib.IMAP4_SSL(host, port=IMAP_PORT, ssl_context=ctx) + try: + conn.login(username, password) + conn.select("inbox") + _, data = conn.search(None, f'(HEADER Subject "{subject}")') + if data and data[0]: + msg_id = data[0].split()[-1] + _, msg_data = conn.fetch(msg_id, "(RFC822)") + return msg_data[0][1] # type: ignore[index] + finally: + with contextlib.suppress(Exception): + conn.close() + with contextlib.suppress(Exception): + conn.logout() + except Exception: + pass + time.sleep(3) + + pytest.fail(f"Message with subject '{subject}' did not arrive in IMAP inbox") diff --git a/tox.toml b/tox.toml index 419aafb..5200a82 100644 --- a/tox.toml +++ b/tox.toml @@ -17,130 +17,87 @@ PYTHONBREAKPOINT = "ipdb.set_trace" PY_COLORS = "1" [env.fmt] -description = "Apply coding style standards to code" +description = "Apply coding style standards to all charms" +allowlist_externals = [ "tox" ] commands = [ - [ - "ruff", - "check", - "--fix", - "--select", - "I", - { replace = "ref", of = [ - "vars", - "all_path", - ], extend = true }, - ], - [ - "ruff", - "format", - { replace = "ref", of = [ - "vars", - "all_path", - ], extend = true }, - ], + [ "tox", "-c", "{toxinidir}/dovecot-charm/tox.toml", "-e", "fmt", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/opendkim-operator/tox.toml", "-e", "fmt", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-operator/tox.ini", "-e", "fmt", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-configurator-operator/tox.ini", "-e", "fmt", { replace = "posargs", extend = true } ], ] dependency_groups = [ "fmt" ] [env.lint] -description = "Check code against coding style standards" +description = "Run lint checks for all charms" +allowlist_externals = [ "tox" ] commands = [ - [ - "codespell", - "{toxinidir}", - ], - [ - "ruff", - "format", - "--check", - "--diff", - { replace = "ref", of = [ - "vars", - "all_path", - ], extend = true }, - ], - [ - "ruff", - "check", - { replace = "ref", of = [ - "vars", - "all_path", - ], extend = true }, - ], - [ - "mypy", - { replace = "ref", of = [ - "vars", - "all_path", - ], extend = true }, - ], + [ "tox", "-c", "{toxinidir}/dovecot-charm/tox.toml", "-e", "lint", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/opendkim-operator/tox.toml", "-e", "lint", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-operator/tox.ini", "-e", "lint", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-configurator-operator/tox.ini", "-e", "lint", { replace = "posargs", extend = true } ], ] dependency_groups = [ "lint" ] [env.unit] -description = "Run unit tests" +description = "Run all charm unit tests" +allowlist_externals = [ "tox" ] commands = [ - [ - "coverage", - "run", - "--source={[vars]src_path}", - "-m", - "pytest", - "--ignore={[vars]tst_path}integration", - "-v", - "--tb", - "native", - "-s", - { replace = "posargs", extend = "true" }, - ], - [ - "coverage", - "report", - ], + [ "tox", "-c", "{toxinidir}/dovecot-charm/tox.toml", "-e", "unit", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/opendkim-operator/tox.toml", "-e", "unit", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-operator/tox.ini", "-e", "unit", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-configurator-operator/tox.ini", "-e", "unit", { replace = "posargs", extend = true } ], ] dependency_groups = [ "unit" ] [env.coverage-report] -description = "Create test coverage report" -commands = [ [ "coverage", "report" ] ] +description = "Create coverage reports for all charms" +allowlist_externals = [ "tox" ] +commands = [ + [ "tox", "-c", "{toxinidir}/dovecot-charm/tox.toml", "-e", "coverage-report", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/opendkim-operator/tox.toml", "-e", "coverage-report", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-operator/tox.ini", "-e", "coverage-report", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-configurator-operator/tox.ini", "-e", "coverage-report", { replace = "posargs", extend = true } ], +] dependency_groups = [ "coverage-report" ] [env.static] -description = "Run static analysis tests" -commands = [ [ "bandit", "-c", "{toxinidir}/pyproject.toml", "-r", "{[vars]src_path}", "{[vars]tst_path}" ] ] +description = "Run static analysis tests for all charms" +allowlist_externals = [ "tox" ] +commands = [ + [ "tox", "-c", "{toxinidir}/dovecot-charm/tox.toml", "-e", "static", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/opendkim-operator/tox.toml", "-e", "static", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-operator/tox.ini", "-e", "static", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-configurator-operator/tox.ini", "-e", "static", { replace = "posargs", extend = true } ], +] dependency_groups = [ "static" ] [env.integration] -description = "Run cross-charm integration tests" +description = "Run integration tests for all charms" +allowlist_externals = [ "tox" ] +commands = [ + [ "tox", "-c", "{toxinidir}/dovecot-charm/tox.toml", "-e", "integration", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/opendkim-operator/tox.toml", "-e", "integration", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-operator/tox.ini", "-e", "integration", { replace = "posargs", extend = true } ], + [ "tox", "-c", "{toxinidir}/postfix-relay-configurator-operator/tox.ini", "-e", "integration", { replace = "posargs", extend = true } ], +] +dependency_groups = [ "integration" ] + +[env.stack-integration] +description = "Run full-stack mailserver integration tests (all charms)" commands = [ [ "pytest", "-v", "--tb", "native", - "tests/integration/", "--log-cli-level=INFO", "-s", - { replace = "posargs", extend = "true" }, + "{toxinidir}/tests/integration", + { replace = "posargs", extend = true }, ], ] dependency_groups = [ "integration" ] -[env.lint-fix] -description = "Apply coding style standards to code" -commands = [ - [ - "ruff", - "check", - "--fix", - "--fix-only", - { replace = "ref", of = [ - "vars", - "all_path", - ], extend = true }, - ], -] -dependency_groups = [ "lint" ] - [vars] src_path = "{toxinidir}/src/" tst_path = "{toxinidir}/tests/" diff --git a/uv.lock b/uv.lock index da34c2b..e997c2b 100644 --- a/uv.lock +++ b/uv.lock @@ -70,6 +70,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -219,6 +276,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478, upload-time = "2025-11-10T00:13:14.908Z" }, ] +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, +] + [[package]] name = "flake8" version = "7.3.0" @@ -294,8 +404,11 @@ fmt = [ integration = [ { name = "allure-pytest" }, { name = "allure-pytest-collection-report" }, + { name = "cryptography" }, { name = "jubilant" }, { name = "pytest" }, + { name = "pyyaml" }, + { name = "requests" }, ] lint = [ { name = "codespell" }, @@ -329,8 +442,11 @@ fmt = [{ name = "ruff" }] integration = [ { name = "allure-pytest", specifier = ">=2.8.18" }, { name = "allure-pytest-collection-report", git = "https://github.com/canonical/data-platform-workflows?subdirectory=python%2Fpytest_plugins%2Fallure_pytest_collection_report&rev=v24.0.0" }, + { name = "cryptography", specifier = ">=42.0" }, { name = "jubilant", specifier = "==1.10.0" }, { name = "pytest" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "requests", specifier = ">=2.31" }, ] lint = [ { name = "codespell" }, @@ -515,6 +631,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pyflakes" version = "3.4.0"