Skip to content

Commit c11bc6d

Browse files
authored
Improve make_group/make_acc_group fixture consistency (#50)
## Changes This PR implements some fixture improvements that were originally implemented downstream: - When creating an account or workspace group, we now require the group to be visible via two subsequent `.get()` and `.list()` calls. This double-check approach mitigates (but doesn't completely eliminate) problems with consistency of the APIs for working with groups. - The `wait_for_provisioning` argument to the `make_group` and `make_acc_group` has now been removed: we always wait. (For compatibility the argument is still accepted but will trigger a deprecation warning.) An incidental change is the internal unit-test plumbing can now be provided with mock fixtures specific to the test; this is needed to ensure the double-check implementation can be tested from the unit tests. ### Linked issues Supersedes databrickslabs/ucx#2634. ### Tests - added/updated unit tests - existing integration tests
1 parent 4099364 commit c11bc6d

File tree

4 files changed

+132
-29
lines changed

4 files changed

+132
-29
lines changed

README.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -542,16 +542,15 @@ See also [`ws`](#ws-fixture), [`make_random`](#make_random-fixture), [`watchdog_
542542
[[back to top](#python-testing-for-databricks)]
543543

544544
### `make_group` fixture
545-
This fixture provides a function to manage Databricks workspace groups. Groups can be created with
546-
specified members and roles, and they will be deleted after the test is complete. Deals with eventual
547-
consistency issues by retrying the creation process for 30 seconds and allowing up to two minutes
548-
for group to be provisioned. Returns an instance of [`Group`](https://databricks-sdk-py.readthedocs.io/en/latest/dbdataclasses/iam.html#databricks.sdk.service.iam.Group).
545+
This fixture provides a function to manage Databricks workspace groups. Groups can be created with specified
546+
members and roles, and they will be deleted after the test is complete. Deals with eventual consistency issues by
547+
retrying the creation process for 30 seconds and then waiting for up to 3 minutes for the group to be provisioned.
548+
Returns an instance of [`Group`](https://databricks-sdk-py.readthedocs.io/en/latest/dbdataclasses/iam.html#databricks.sdk.service.iam.Group).
549549

550550
Keyword arguments:
551551
* `members` (list of strings): A list of user IDs to add to the group.
552552
* `roles` (list of strings): A list of roles to assign to the group.
553553
* `display_name` (str): The display name of the group.
554-
* `wait_for_provisioning` (bool): If `True`, the function will wait for the group to be provisioned.
555554
* `entitlements` (list of strings): A list of entitlements to assign to the group.
556555

557556
The following example creates a group with a single member and independently verifies that the group was created:

src/databricks/labs/pytester/fixtures/iam.py

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import logging
2+
import warnings
23
from collections.abc import Generator
34
from datetime import timedelta
45

56
from pytest import fixture
7+
from databricks.sdk import AccountGroupsAPI, GroupsAPI, WorkspaceClient
68
from databricks.sdk.config import Config
79
from databricks.sdk.errors import ResourceConflict, NotFound
810
from databricks.sdk.retries import retried
9-
from databricks.sdk.service.iam import User, Group
10-
from databricks.sdk import WorkspaceClient
1111
from databricks.sdk.service import iam
12+
from databricks.sdk.service.iam import User, Group
1213

1314
from databricks.labs.pytester.fixtures.baseline import factory
1415

@@ -44,16 +45,15 @@ def create(**kwargs) -> User:
4445
@fixture
4546
def make_group(ws: WorkspaceClient, make_random, watchdog_purge_suffix):
4647
"""
47-
This fixture provides a function to manage Databricks workspace groups. Groups can be created with
48-
specified members and roles, and they will be deleted after the test is complete. Deals with eventual
49-
consistency issues by retrying the creation process for 30 seconds and allowing up to two minutes
50-
for group to be provisioned. Returns an instance of `databricks.sdk.service.iam.Group`.
48+
This fixture provides a function to manage Databricks workspace groups. Groups can be created with specified
49+
members and roles, and they will be deleted after the test is complete. Deals with eventual consistency issues by
50+
retrying the creation process for 30 seconds and then waiting for up to 3 minutes for the group to be provisioned.
51+
Returns an instance of `databricks.sdk.service.iam.Group`.
5152
5253
Keyword arguments:
5354
* `members` (list of strings): A list of user IDs to add to the group.
5455
* `roles` (list of strings): A list of roles to assign to the group.
5556
* `display_name` (str): The display name of the group.
56-
* `wait_for_provisioning` (bool): If `True`, the function will wait for the group to be provisioned.
5757
* `entitlements` (list of strings): A list of entitlements to assign to the group.
5858
5959
The following example creates a group with a single member and independently verifies that the group was created:
@@ -94,15 +94,63 @@ def _scim_values(ids: list[str]) -> list[iam.ComplexValue]:
9494
return [iam.ComplexValue(value=x) for x in ids]
9595

9696

97+
def _wait_group_provisioned(interface: AccountGroupsAPI | GroupsAPI, group: Group) -> None:
98+
"""Wait for a group to be visible via the supplied group interface.
99+
100+
Due to consistency issues in the group-management APIs, new groups are not always visible in a consistent manner
101+
after being created or modified. This method can be used to mitigate against this by checking that a group:
102+
103+
- Is visible via the `.get()` interface;
104+
- Is visible via the `.list()` interface that enumerates groups.
105+
106+
Visibility is assumed when 2 calls in a row return the expected results.
107+
108+
Args:
109+
interface: the group-management interface to use for checking whether the groups are visible.
110+
group: the group whose visibility should be verified.
111+
Raises:
112+
NotFound: this is thrown if it takes longer than 90 seconds for the group to become visible via the
113+
management interface.
114+
"""
115+
# Use double-checking to try and compensate for the lack of monotonic consistency with the group-management
116+
# interfaces: two subsequent calls need to succeed for us to proceed. (This is probabilistic, and not a guarantee.)
117+
# The REST API internals cache things for up to 60s, and we see times close to this during tests. The retry timeout
118+
# reflects this: if it's taking much longer then something else is wrong.
119+
group_id = group.id
120+
assert group_id is not None
121+
122+
@retried(on=[NotFound], timeout=timedelta(seconds=90))
123+
def _double_get_group() -> None:
124+
interface.get(group_id)
125+
interface.get(group_id)
126+
127+
def _check_group_in_listing() -> None:
128+
found_groups = interface.list(attributes="id", filter=f'id eq "{group_id}"')
129+
found_ids = {found_group.id for found_group in found_groups}
130+
if group_id not in found_ids:
131+
msg = f"Group id not (yet) found in group listing: {group_id}"
132+
raise NotFound(msg)
133+
134+
@retried(on=[NotFound], timeout=timedelta(seconds=90))
135+
def _double_check_group_in_listing() -> None:
136+
_check_group_in_listing()
137+
_check_group_in_listing()
138+
139+
_double_get_group()
140+
_double_check_group_in_listing()
141+
142+
97143
def _make_group(name: str, cfg: Config, interface, make_random, watchdog_purge_suffix) -> Generator[Group, None, None]:
144+
_not_specified = object()
145+
98146
@retried(on=[ResourceConflict], timeout=timedelta(seconds=30))
99147
def create(
100148
*,
101149
members: list[str] | None = None,
102150
roles: list[str] | None = None,
103151
entitlements: list[str] | None = None,
104152
display_name: str | None = None,
105-
wait_for_provisioning: bool = False,
153+
wait_for_provisioning: bool | object = _not_specified,
106154
**kwargs,
107155
):
108156
kwargs["display_name"] = (
@@ -114,19 +162,21 @@ def create(
114162
kwargs["roles"] = _scim_values(roles)
115163
if entitlements is not None:
116164
kwargs["entitlements"] = _scim_values(entitlements)
165+
if wait_for_provisioning is not _not_specified:
166+
warnings.warn(
167+
"Specifying wait_for_provisioning when making a group is deprecated; we always wait.",
168+
DeprecationWarning,
169+
# Call stack is: create()[iam.py], wrapper()[retries.py], inner()[baseline.py], client_code
170+
stacklevel=4,
171+
)
117172
# TODO: REQUEST_LIMIT_EXCEEDED: GetUserPermissionsRequest RPC token bucket limit has been exceeded.
118173
group = interface.create(**kwargs)
119174
if cfg.is_account_client:
120175
logger.info(f"Account group {group.display_name}: {cfg.host}/users/groups/{group.id}/members")
121176
else:
122177
logger.info(f"Workspace group {group.display_name}: {cfg.host}#setting/accounts/groups/{group.id}")
123178

124-
@retried(on=[NotFound], timeout=timedelta(minutes=2))
125-
def _wait_for_provisioning() -> None:
126-
interface.get(group.id)
127-
128-
if wait_for_provisioning:
129-
_wait_for_provisioning()
179+
_wait_group_provisioned(interface, group)
130180

131181
return group
132182

src/databricks/labs/pytester/fixtures/unwrap.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,14 @@ def __str__(self):
6565
_GENERATORS = set[str]()
6666

6767

68-
def call_stateful(some: Callable[..., Generator[Callable[..., T]]], **kwargs) -> tuple[CallContext, T]:
68+
def call_stateful(
69+
some: Callable[..., Generator[Callable[..., T]]],
70+
call_context_setup: Callable[[CallContext], CallContext] = lambda x: x,
71+
**kwargs,
72+
) -> tuple[CallContext, T]:
6973
# pylint: disable=too-complex
7074
ctx = CallContext()
75+
ctx = call_context_setup(ctx)
7176
drains = []
7277

7378
if len(_GENERATORS) == 0:

tests/unit/fixtures/test_iam.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,75 @@
1-
from databricks.labs.pytester.fixtures.iam import make_user, make_group, make_acc_group
2-
from databricks.labs.pytester.fixtures.unwrap import call_stateful
1+
import sys
2+
import warnings
3+
from functools import partial
4+
from unittest.mock import call
35

6+
import pytest
47

5-
def test_make_user_no_args():
8+
from databricks.labs.pytester.fixtures.iam import make_acc_group, make_group, make_user, Group
9+
from databricks.labs.pytester.fixtures.unwrap import call_stateful, CallContext
10+
11+
12+
def test_make_user_no_args() -> None:
613
ctx, user = call_stateful(make_user)
714
assert ctx is not None
815
assert user is not None
916
ctx['ws'].users.create.assert_called_once()
1017
ctx['ws'].users.delete.assert_called_once()
1118

1219

13-
def test_make_group_no_args():
14-
ctx, group = call_stateful(make_group)
15-
assert ctx is not None
20+
def _setup_groups_api(call_context: CallContext, *, client_fixture_name: str) -> CallContext:
21+
"""Minimum mocking of the specific client so that when a group is created it is also visible via the list() method.
22+
This is required because the make_group and make_acc_group fixtures double-check after creating a group to ensure
23+
the group is visible."""
24+
mock_group = Group(id="an_id")
25+
call_context[client_fixture_name].groups.create.return_value = mock_group
26+
call_context[client_fixture_name].groups.list.return_value = [mock_group]
27+
return call_context
28+
29+
30+
def test_make_group_no_args() -> None:
31+
ctx, group = call_stateful(make_group, call_context_setup=partial(_setup_groups_api, client_fixture_name="ws"))
32+
1633
assert group is not None
1734
ctx['ws'].groups.create.assert_called_once()
35+
assert ctx['ws'].groups.get.call_args_list == [call("an_id"), call("an_id")]
36+
assert ctx['ws'].groups.list.call_args_list == [
37+
call(attributes="id", filter='id eq "an_id"'),
38+
call(attributes="id", filter='id eq "an_id"'),
39+
]
1840
ctx['ws'].groups.delete.assert_called_once()
1941

2042

21-
def test_make_acc_group_no_args():
22-
ctx, group = call_stateful(make_acc_group)
23-
assert ctx is not None
43+
def test_make_acc_group_no_args() -> None:
44+
ctx, group = call_stateful(make_acc_group, call_context_setup=partial(_setup_groups_api, client_fixture_name="acc"))
45+
2446
assert group is not None
2547
ctx['acc'].groups.create.assert_called_once()
48+
assert ctx['acc'].groups.get.call_args_list == [call("an_id"), call("an_id")]
49+
assert ctx['acc'].groups.list.call_args_list == [
50+
call(attributes="id", filter='id eq "an_id"'),
51+
call(attributes="id", filter='id eq "an_id"'),
52+
]
2653
ctx['acc'].groups.delete.assert_called_once()
54+
55+
56+
@pytest.mark.parametrize(
57+
"make_group_fixture, client_fixture_name",
58+
[(make_group, "ws"), (make_acc_group, "acc")],
59+
)
60+
def test_make_group_deprecated_arg(make_group_fixture, client_fixture_name) -> None:
61+
with warnings.catch_warnings(record=True) as w:
62+
warnings.simplefilter("always")
63+
64+
call_stateful(
65+
make_group_fixture,
66+
wait_for_provisioning=True,
67+
call_context_setup=partial(_setup_groups_api, client_fixture_name=client_fixture_name),
68+
)
69+
70+
# Check that the expected warning was emitted and attributed to the caller.
71+
(the_warning, *other_warnings) = w
72+
assert not other_warnings
73+
assert issubclass(the_warning.category, DeprecationWarning)
74+
assert "wait_for_provisioning when making a group is deprecated" in str(the_warning.message)
75+
assert the_warning.filename == sys.modules[call_stateful.__module__].__file__

0 commit comments

Comments
 (0)