Skip to content
Open
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
38 changes: 38 additions & 0 deletions .github/workflows/_build-wheels.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Build Operator Wheels

on:
workflow_call:
outputs:
artifact-name:
description: "Name of the artifact containing the wheels"
value: ${{ jobs.build-wheels.outputs.artifact-name }}

permissions: {}

jobs:
build-wheels:
runs-on: ubuntu-latest
outputs:
artifact-name: ${{ steps.artifact-name.outputs.name }}
steps:
- name: Checkout the operator repository
uses: actions/checkout@v6.0.2
with:
persist-credentials: false

- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

- name: Build wheels
run: uv build --all

- name: Set artifact name
id: artifact-name
run: echo "name=operator-wheels-${{ github.sha }}" >> "$GITHUB_OUTPUT"

- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: ${{ steps.artifact-name.outputs.name }}
path: dist/*.whl
retention-days: 1
39 changes: 30 additions & 9 deletions .github/workflows/db-charm-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,49 +6,70 @@ on:
- main
pull_request:
workflow_call:
workflow_dispatch:

permissions: {}

jobs:
build-wheels:
uses: ./.github/workflows/_build-wheels.yaml

db-charm-tests:
runs-on: ubuntu-latest
needs: build-wheels
strategy:
fail-fast: false
matrix:
include:
- charm-repo: canonical/postgresql-operator
commit: 33ca7f308fa4289cdf45ceefcb335f373713431d # 2025-06-21T22:22:22Z
- charm-repo: canonical/postgresql-k8s-operator
commit: a6753b27440a17ed5f37d4c2c5c6f53b1d3a1f7f # 2025-06-22T11:27:17Z
commit: c434df80953409f290e7ea6e76f78a3ec46cecc2 # 2026-05-30T03:35:40Z
- charm-repo: canonical/mysql-operator
commit: 411fb45ecfe200d22b46af9cbacaf72e8eb09da7 # 2025-06-23T14:51:47Z
- charm-repo: canonical/mysql-k8s-operator
commit: 7b5775c9d07fd32c4453d93dfc51cf1b4d6c8024 # rev257 rev256 2025-06-23T15:19:19Z
steps:
- name: Checkout the ${{ matrix.charm-repo }} repository
uses: actions/checkout@v4
uses: actions/checkout@v6.0.2
with:
repository: ${{ matrix.charm-repo }}
persist-credentials: false
ref: ${{ matrix.commit }}

- name: Checkout the operator repository
uses: actions/checkout@v4
- name: Download operator wheels
uses: actions/download-artifact@v8
with:
path: myops
persist-credentials: false
name: ${{ needs.build-wheels.outputs.artifact-name }}
path: operator-wheels

- name: Install patch dependencies
run: pip install poetry~=2.0

- name: Update 'ops' dependency in test charm to latest
run: |
ops_wheel="$(ls operator-wheels/ops-[0-9]*.whl)"
ops_tracing_wheel="$(ls operator-wheels/ops_tracing-*.whl)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder why the pattern for ops_tracing wheel is more relaxed compared to ops_wheel here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The [0-9] in ops-[0-9]*.whl is defensive: without it, the glob would also match anything starting with ops- (for example a future ops-tools-*.whl). Wheel filenames normalise - to _ in the package portion, so ops_tracing-*.whl already can't be matched by ops-* — there's no similarly-named package to disambiguate against, so the looser pattern is fine.

if [ -e "requirements.txt" ]; then
sed -i -e "/^ops[ ><=]/d" -e "/canonical\/operator/d" -e "/#egg=ops/d" requirements.txt
echo -e "\ngit+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA#egg=ops" >> requirements.txt
echo -e "\n${ops_wheel}" >> requirements.txt
else
sed -i -e "s/^ops[ ><=].*/ops = {path = \"myops\"}/" pyproject.toml
poetry lock
# The charm may declare ops with the 'tracing' extra, which pulls in
# ops-tracing from PyPI. Replacing ops with a local wheel drops the
# extra, so install the matching ops-tracing wheel explicitly when
# the charm asked for it. Check before `poetry remove`, which
# strips the ops line from pyproject.toml.
if grep -qE 'ops[^a-z]+=.*tracing|ops\[[^]]*tracing' pyproject.toml; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to double check my understanding, the first sub pattern matches something like

ops = {extras = ["tracing"], version = ">=2.0"}

while the second pattern matches

ops[testing,tracing]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's right. The first alternative (ops[^a-z]+=.*tracing) catches the Poetry table form like ops = {extras = ["tracing"], ...}, and the second (ops\[[^]]*tracing) catches the PEP 508 extras form like ops[testing,tracing] or ops[tracing].

(I wish all the poetry usage would go away, but some people really like it.)

wants_tracing=1
else
wants_tracing=0
fi
poetry remove ops --lock
if [ "${wants_tracing}" = "1" ]; then
poetry add "${ops_wheel}" "${ops_tracing_wheel}" --lock
else
poetry add "${ops_wheel}" --lock
fi
fi

- name: Install dependencies
Expand Down
11 changes: 10 additions & 1 deletion ops/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ class _Manager:
- graceful teardown of the storage
"""

_saved_breakpointhook: Any = None

def __init__(
self,
charm_class: type[_charm.CharmBase],
Expand Down Expand Up @@ -402,7 +404,12 @@ def _make_framework(self, dispatcher: _Dispatcher):
event_name=dispatcher.event_name,
juju_debug_at=self._juju_context.debug_at,
)
framework.set_breakpointhook()
# set_breakpointhook() returns the old sys.breakpointhook; capture it
# so destroy() can put the global state back rather than leaving the
# framework's bound method dangling. In production this is moot (the
# process is exiting anyway), but tests and other long-running
# ops.main() callers inherit the polluted hook otherwise.
self._saved_breakpointhook = framework.set_breakpointhook()
return framework

def _emit(self):
Expand Down Expand Up @@ -476,6 +483,8 @@ def destroy(self):
"""Finalise the manager."""
from . import tracing # break circular import

if self._saved_breakpointhook is not None:
sys.breakpointhook = self._saved_breakpointhook
self._tracing_context.__exit__(*sys.exc_info())
if tracing:
tracing._shutdown()
Expand Down
47 changes: 42 additions & 5 deletions ops/_private/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ def cleanup(self) -> None:
Always call ``self.addCleanup(harness.cleanup)`` after creating a :class:`Harness`.
"""
self._backend._cleanup()
self._framework.close()

def _create_meta(
self,
Expand Down Expand Up @@ -2439,19 +2440,41 @@ def relation_ids(self, relation_name: str) -> list[int]:
no_ids: list[int] = []
return no_ids

def relation_list(self, relation_id: int):
def _check_relation_name(self, relation_id: int, relation_name: str | None) -> None:
"""Mimic Juju's ``endpoint:id`` validation by erroring on a mismatch."""
if relation_name is None:
return
actual = self._relation_names.get(relation_id)
if actual is None or actual != relation_name:
raise model.RelationNotFoundError()

def relation_list(self, relation_id: int, *, relation_name: str | None = None):
self._check_relation_name(relation_id, relation_name)
try:
return self._relation_list_map[relation_id]
except KeyError:
raise model.RelationNotFoundError from None

def relation_remote_app_name(self, relation_id: int) -> str | None:
def relation_remote_app_name(
self, relation_id: int, *, relation_name: str | None = None
) -> str | None:
if relation_name is not None and self._relation_names.get(relation_id) != relation_name:
# Mismatched endpoint:id -- treat as if the relation does not exist.
return None
if relation_id not in self._relation_app_and_units:
# Non-existent or dead relation
return None
return self._relation_app_and_units[relation_id]['app']

def relation_get(self, relation_id: int, member_name: str, is_app: bool):
def relation_get(
self,
relation_id: int,
member_name: str,
is_app: bool,
*,
relation_name: str | None = None,
):
self._check_relation_name(relation_id, relation_name)
if is_app and '/' in member_name:
member_name = member_name.split('/')[0]
if relation_id not in self._relation_data_raw:
Expand All @@ -2463,19 +2486,30 @@ def update_relation_data(
relation_id: int,
entity: model.Unit | model.Application,
data: Mapping[str, str],
*,
relation_name: str | None = None,
):
# this is where the 'real' backend would call relation-set.
self._check_relation_name(relation_id, relation_name)
raw_data = self._relation_data_raw[relation_id][entity.name]
for key, value in data.items():
if value == '':
raw_data.pop(key, None)
else:
raw_data[key] = value

def relation_set(self, relation_id: int, data: Mapping[str, str], is_app: bool) -> None:
def relation_set(
self,
relation_id: int,
data: Mapping[str, str],
is_app: bool,
*,
relation_name: str | None = None,
) -> None:
if not isinstance(is_app, bool):
raise TypeError('is_app parameter to relation_set must be a boolean')

self._check_relation_name(relation_id, relation_name)
if relation_id not in self._relation_data_raw:
raise RelationNotFoundError(relation_id)

Expand All @@ -2490,8 +2524,11 @@ def relation_set(self, relation_id: int, data: Mapping[str, str], is_app: bool)
else:
bucket[key] = value

def relation_model_get(self, relation_id: int) -> dict[str, Any]:
def relation_model_get(
self, relation_id: int, *, relation_name: str | None = None
) -> dict[str, Any]:
# For Harness, ignore relation_id and assume relation is never cross-model.
self._check_relation_name(relation_id, relation_name)
return {'uuid': self.model_uuid}

def config_get(self) -> _TestingConfig:
Expand Down
Loading
Loading