Skip to content

Commit 7fd9313

Browse files
committed
Address account info bw compatibility issue caused by multi-bucket keys
1 parent c884f6f commit 7fd9313

File tree

5 files changed

+140
-68
lines changed

5 files changed

+140
-68
lines changed

b2sdk/_internal/account_info/sqlite_account_info.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ def _create_tables(self, conn, last_upgrade_to_run):
280280
"""
281281
)
282282
# By default, we run all the upgrades
283-
last_upgrade_to_run = 5 if last_upgrade_to_run is None else last_upgrade_to_run
283+
last_upgrade_to_run = 6 if last_upgrade_to_run is None else last_upgrade_to_run
284284
# Add the 'allowed' column if it hasn't been yet.
285285
if 1 <= last_upgrade_to_run:
286286
self._ensure_update(1, ['ALTER TABLE account ADD COLUMN allowed TEXT;'])
@@ -385,13 +385,17 @@ def _create_tables(self, conn, last_upgrade_to_run):
385385
)
386386

387387
if 5 <= last_upgrade_to_run:
388-
self._migrate_allowed_to_multi_bucket()
388+
self._migrate_allowed_to_multi_bucket(version=5)
389389

390-
def _migrate_allowed_to_multi_bucket(self):
390+
# There were reported cases of users with db schema upgraded to version 5 and still containing old single-bucket style allowed dict. This was possible because users of sdk apiver <= v2 still received old-styled allowed dicts (from v3 remote API) and there was no proper transformation mechanism when setting the auth data. The bug has now been addressed, but we need an additional migration to address aforementioned cases.
391+
if 6 <= last_upgrade_to_run:
392+
self._migrate_allowed_to_multi_bucket(version=6)
393+
394+
def _migrate_allowed_to_multi_bucket(self, *, version: int):
391395
"""
392396
Migrate existing allowed json dict to a new multi-bucket keys format
393397
"""
394-
if self._get_update_count(5) > 0:
398+
if self._get_update_count(version) > 0:
395399
return
396400

397401
try:
@@ -400,11 +404,15 @@ def _migrate_allowed_to_multi_bucket(self):
400404
allowed_json = None
401405

402406
if allowed_json is None:
403-
self._perform_update(5, [])
407+
self._perform_update(version, [])
404408
return
405409

406410
allowed = json.loads(allowed_json)
407411

412+
if 'buckets' in allowed:
413+
self._perform_update(version, [])
414+
return
415+
408416
bucket_id = allowed.pop('bucketId')
409417
bucket_name = allowed.pop('bucketName')
410418

@@ -416,7 +424,7 @@ def _migrate_allowed_to_multi_bucket(self):
416424
allowed_text = json.dumps(allowed)
417425
stmt = f"UPDATE account SET allowed = ('{allowed_text}');"
418426

419-
self._perform_update(5, [stmt])
427+
self._perform_update(version, [stmt])
420428

421429
def _ensure_update(self, update_number, update_commands: list[str]):
422430
"""
@@ -469,7 +477,6 @@ def _set_auth_data(
469477
allowed,
470478
application_key_id,
471479
):
472-
assert self.allowed_is_valid(allowed)
473480
with self._get_connection() as conn:
474481
conn.execute('DELETE FROM account;')
475482
conn.execute('DELETE FROM bucket;')

b2sdk/v2/account_info.py

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
######################################################################
1010
from __future__ import annotations
1111

12-
import json
1312

13+
from copy import copy
1414
from b2sdk import v3
1515
from .exception import MissingAccountData
1616

@@ -33,49 +33,22 @@ def allowed_is_valid(cls, allowed):
3333
and ('namePrefix' in allowed)
3434
)
3535

36+
@classmethod
37+
def _convert_allowed_single_to_multi_bucket(cls, allowed):
38+
new = copy(allowed)
3639

37-
class AbstractAccountInfo(_OldAllowedMixin, v3.AbstractAccountInfo):
38-
def list_bucket_names_ids(self):
39-
return [] # Removed @abstractmethod decorator
40-
41-
42-
class UrlPoolAccountInfo(_OldAllowedMixin, v3.UrlPoolAccountInfo):
43-
pass
40+
bucket_id = new.pop('bucketId')
41+
bucket_name = new.pop('bucketName')
4442

43+
if bucket_id is not None:
44+
new['buckets'] = [{'id': bucket_id, 'name': bucket_name}]
45+
else:
46+
new['buckets'] = None
4547

46-
class InMemoryAccountInfo(_OldAllowedMixin, v3.InMemoryAccountInfo):
47-
pass
48+
return new
4849

49-
50-
class SqliteAccountInfo(_OldAllowedMixin, v3.SqliteAccountInfo):
5150
def get_allowed(self):
52-
"""
53-
Return 'allowed' dictionary info.
54-
Example:
55-
56-
.. code-block:: python
57-
58-
{
59-
"bucketId": null,
60-
"bucketName": null,
61-
"capabilities": [
62-
"listKeys",
63-
"writeKeys"
64-
],
65-
"namePrefix": null
66-
}
67-
68-
The 'allowed' column was not in the original schema, so it may be NULL.
69-
70-
:rtype: dict
71-
"""
72-
allowed_json = self._get_account_info_or_raise('allowed')
73-
if allowed_json is None:
74-
return self.DEFAULT_ALLOWED
75-
76-
allowed = json.loads(allowed_json)
77-
78-
# convert a multi-bucket key to a single bucket
51+
allowed = super().get_allowed()
7952

8053
if 'buckets' in allowed:
8154
buckets = allowed.pop('buckets')
@@ -89,6 +62,53 @@ def get_allowed(self):
8962

9063
return allowed
9164

65+
def _set_auth_data(
66+
self,
67+
account_id,
68+
auth_token,
69+
api_url,
70+
download_url,
71+
recommended_part_size,
72+
absolute_minimum_part_size,
73+
application_key,
74+
realm,
75+
s3_api_url,
76+
allowed,
77+
application_key_id,
78+
):
79+
new_allowed = self._convert_allowed_single_to_multi_bucket(allowed)
80+
81+
super()._set_auth_data(
82+
account_id,
83+
auth_token,
84+
api_url,
85+
download_url,
86+
recommended_part_size,
87+
absolute_minimum_part_size,
88+
application_key,
89+
realm,
90+
s3_api_url,
91+
new_allowed,
92+
application_key_id,
93+
)
94+
95+
96+
class AbstractAccountInfo(_OldAllowedMixin, v3.AbstractAccountInfo):
97+
def list_bucket_names_ids(self):
98+
return [] # Removed @abstractmethod decorator
99+
100+
101+
class UrlPoolAccountInfo(_OldAllowedMixin, v3.UrlPoolAccountInfo):
102+
pass
103+
104+
105+
class InMemoryAccountInfo(_OldAllowedMixin, v3.InMemoryAccountInfo):
106+
pass
107+
108+
109+
class SqliteAccountInfo(_OldAllowedMixin, v3.SqliteAccountInfo):
110+
pass
111+
92112

93113
class StubAccountInfo(_OldAllowedMixin, v3.StubAccountInfo):
94114
pass
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Address backwards compatibility issue for sqlite account info caused by the migration of schema to a new multi-bucket format.

test/unit/account_info/fixtures.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,9 @@ def in_memory_account_info(in_memory_account_info_factory):
6969

7070
@pytest.fixture
7171
def sqlite_account_info_factory(tmpdir):
72-
def get_account_info(file_name=None, schema_0=False):
72+
def get_account_info(file_name=None, last_upgrade_to_run: int | None = None):
7373
if file_name is None:
7474
file_name = str(tmpdir.join('b2_account_info'))
75-
if schema_0:
76-
last_upgrade_to_run = 0
77-
else:
78-
last_upgrade_to_run = None
7975
return SqliteAccountInfo(file_name, last_upgrade_to_run)
8076

8177
return get_account_info

test/unit/account_info/test_sqlite_account_info.py

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
######################################################################
1010
from __future__ import annotations
1111

12+
import json
1213
import os
1314

1415
import pytest
@@ -32,30 +33,30 @@ def setup(self, sqlite_account_info_factory, account_info_default_data_schema_0)
3233

3334
def test_upgrade_1_default_allowed(self):
3435
"""The 'allowed' field should be the default for upgraded databases."""
35-
old_account_info = self.sqlite_account_info_factory(schema_0=True)
36+
old_account_info = self.sqlite_account_info_factory(last_upgrade_to_run=0)
3637
old_account_info.set_auth_data_with_schema_0_for_test(**self.account_info_default_data)
3738
new_account_info = self.sqlite_account_info_factory(file_name=old_account_info.filename)
3839

3940
assert AbstractAccountInfo.DEFAULT_ALLOWED == new_account_info.get_allowed()
4041

4142
def test_upgrade_2_default_app_key(self):
4243
"""The 'application_key_id' field should default to the account ID."""
43-
old_account_info = self.sqlite_account_info_factory(schema_0=True)
44+
old_account_info = self.sqlite_account_info_factory(last_upgrade_to_run=0)
4445
old_account_info.set_auth_data_with_schema_0_for_test(**self.account_info_default_data)
4546
new_account_info = self.sqlite_account_info_factory(file_name=old_account_info.filename)
4647

4748
assert 'account_id' == new_account_info.get_application_key_id()
4849

4950
def test_upgrade_3_default_s3_api_url(self):
5051
"""The 's3_api_url' field should be set."""
51-
old_account_info = self.sqlite_account_info_factory(schema_0=True)
52+
old_account_info = self.sqlite_account_info_factory(last_upgrade_to_run=0)
5253
old_account_info.set_auth_data_with_schema_0_for_test(**self.account_info_default_data)
5354
new_account_info = self.sqlite_account_info_factory(file_name=old_account_info.filename)
5455

5556
assert '' == new_account_info.get_s3_api_url()
5657

5758
def test_migrate_to_4(self):
58-
old_account_info = self.sqlite_account_info_factory(schema_0=True)
59+
old_account_info = self.sqlite_account_info_factory(last_upgrade_to_run=0)
5960
old_account_info.set_auth_data_with_schema_0_for_test(**self.account_info_default_data)
6061
new_account_info = self.sqlite_account_info_factory(file_name=old_account_info.filename)
6162

@@ -82,19 +83,48 @@ def test_migrate_to_4(self):
8283
),
8384
],
8485
)
85-
@pytest.mark.apiver(2)
86-
def test_migrate_to_5_v2(
87-
self, v2_sqlite_account_info_factory, account_info_default_data, allowed
86+
@pytest.mark.apiver(to_ver=2)
87+
@pytest.mark.parametrize(
88+
('start_version', 'end_version'),
89+
[
90+
(4, 5),
91+
(5, 6),
92+
],
93+
)
94+
def test_migrate_to_5_prior_to_v3(
95+
self,
96+
sqlite_account_info_factory,
97+
account_info_default_data: dict,
98+
allowed: dict,
99+
start_version: int,
100+
end_version: int,
88101
):
89-
old_account_info = v2_sqlite_account_info_factory()
90-
account_info_default_data.update({'allowed': allowed})
102+
old_account_info = sqlite_account_info_factory(last_upgrade_to_run=start_version)
91103
old_account_info.set_auth_data(**account_info_default_data)
104+
105+
allowed_text = json.dumps(allowed)
106+
with old_account_info._get_connection() as conn:
107+
conn.execute(f"UPDATE account SET allowed = ('{allowed_text}');")
108+
109+
assert old_account_info._get_update_count(start_version) == 1
110+
assert old_account_info._get_update_count(end_version) == 0
111+
92112
assert old_account_info.get_allowed() == allowed
93113

94-
new_account_info = self.sqlite_account_info_factory(file_name=old_account_info.filename)
114+
new_account_info = self.sqlite_account_info_factory(
115+
file_name=old_account_info.filename, last_upgrade_to_run=end_version
116+
)
95117

118+
assert new_account_info._get_update_count(end_version) == 1
96119
assert new_account_info.get_allowed() == allowed
97120

121+
with new_account_info._get_connection() as conn:
122+
new_allowed = json.loads(conn.execute('SELECT allowed FROM account;').fetchone()[0])
123+
124+
assert 'buckets' in new_allowed
125+
assert 'bucketId' not in new_allowed
126+
assert 'bucketName' not in new_allowed
127+
98128
@pytest.mark.parametrize(
99129
('old_allowed', 'exp_allowed'),
100130
[
@@ -126,22 +156,40 @@ def test_migrate_to_5_v2(
126156
),
127157
],
128158
)
159+
@pytest.mark.parametrize(
160+
('start_version', 'end_version'),
161+
[
162+
(4, 5),
163+
(5, 6),
164+
],
165+
)
129166
@pytest.mark.apiver(from_ver=3)
130167
def test_migrate_to_5_v3(
131168
self,
132-
v2_sqlite_account_info_factory,
133-
account_info_default_data,
134-
old_allowed,
135-
exp_allowed,
169+
sqlite_account_info_factory,
170+
account_info_default_data: dict,
171+
old_allowed: dict,
172+
exp_allowed: dict,
173+
start_version: int,
174+
end_version: int,
136175
):
137-
old_account_info = v2_sqlite_account_info_factory()
138-
account_info_default_data.update({'allowed': old_allowed})
176+
old_account_info = sqlite_account_info_factory(last_upgrade_to_run=start_version)
139177
old_account_info.set_auth_data(**account_info_default_data)
178+
179+
allowed_text = json.dumps(old_allowed)
180+
with old_account_info._get_connection() as conn:
181+
conn.execute(f"UPDATE account SET allowed = ('{allowed_text}');")
182+
140183
assert old_account_info.get_allowed() == old_allowed
184+
assert old_account_info._get_update_count(start_version) == 1
185+
assert old_account_info._get_update_count(end_version) == 0
141186

142-
new_account_info = self.sqlite_account_info_factory(file_name=old_account_info.filename)
187+
new_account_info = self.sqlite_account_info_factory(
188+
file_name=old_account_info.filename, last_upgrade_to_run=end_version
189+
)
143190

144191
assert new_account_info.get_allowed() == exp_allowed
192+
assert new_account_info._get_update_count(end_version) == 1
145193

146194
@pytest.mark.apiver(2)
147195
def test_multi_bucket_key_error_apiver_v2(

0 commit comments

Comments
 (0)