Skip to content

Commit 6139b64

Browse files
committed
ref(backup): Filter options on comparison rather than export
1 parent 43e2b92 commit 6139b64

File tree

6 files changed

+198
-48
lines changed

6 files changed

+198
-48
lines changed

src/sentry/backup/comparators.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,42 @@ def compare(self, on: InstanceID, left: JSONData, right: JSONData) -> list[Compa
211211
return findings
212212

213213

214+
class KeyValueComparator(JSONScrubbingComparator):
215+
"""
216+
Models of the kind `sentry.*option` store key-value pairs of options. Some of these keys and/or
217+
values need special-handling to be compared properly. Because these key-value pairs have no
218+
schema, we more or less have to resort to listing keys we want to skip.
219+
"""
220+
221+
def __init__(self, *, skip_keys: list[str]):
222+
super().__init__("key", "value")
223+
self.skip_keys = skip_keys
224+
225+
def compare(self, on: InstanceID, left: JSONData, right: JSONData) -> list[ComparatorFinding]:
226+
# Keys must be equal, since we use them as part of the `InstanceID`.
227+
assert left["fields"]["key"] == right["fields"]["key"]
228+
229+
findings: list[ComparatorFinding] = []
230+
key = left["fields"]["key"]
231+
if key in self.skip_keys:
232+
return findings
233+
234+
left_value = left["fields"]["value"]
235+
right_value = right["fields"]["value"]
236+
if left_value != right_value:
237+
findings.append(
238+
ComparatorFinding(
239+
kind=self.get_kind(),
240+
on=on,
241+
left_pk=left["pk"],
242+
right_pk=right["pk"],
243+
reason=f"""the left value ({left_value}) of a key-value entry with key `{key}` was not equal to the right({right_value})""",
244+
)
245+
)
246+
247+
return findings
248+
249+
214250
class DateUpdatedComparator(JSONScrubbingComparator):
215251
"""
216252
Comparator that ensures that the specified fields' value on the right input is an ISO-8601
@@ -866,6 +902,16 @@ def get_default_comparators() -> dict[str, list[JSONScrubbingComparator]]:
866902
],
867903
"sentry.dashboardwidgetqueryondemand": [DateUpdatedComparator("date_modified")],
868904
"sentry.dashboardwidgetquery": [DateUpdatedComparator("date_modified")],
905+
"sentry.option": [
906+
KeyValueComparator(
907+
skip_keys=[
908+
"sentry:install-id", # Only used on self-hosted.
909+
"sentry:latest_version", # Should not necessarily invalidate comparison.
910+
"sentry:last_worker_ping", # Changes very frequently.
911+
"sentry:last_worker_version", # Changes very frequently.
912+
]
913+
),
914+
],
869915
"sentry.organization": [AutoSuffixComparator("slug")],
870916
"sentry.organizationintegration": [DateUpdatedComparator("date_updated")],
871917
"sentry.organizationmember": [

src/sentry/backup/findings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ class ComparatorFindingKind(FindingKind):
100100
# Failed to compare an ignored field.
101101
IgnoredComparator = auto()
102102

103+
# A key-value pair was unequal
104+
KeyValueComparator = auto()
105+
106+
# Failed to compare a key-value pair because one side of the comparison had either the `key` or
107+
# `value` field missing.
108+
KeyValueComparatorExistenceCheck = auto()
109+
103110
# Secret token fields did not match their regex specification.
104111
SecretHexComparator = auto()
105112

src/sentry/models/options/option.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from django.db import models
66
from django.utils import timezone
77

8-
from sentry.backup.dependencies import PrimaryKeyMap
98
from sentry.backup.mixins import OverwritableConfigMixin
109
from sentry.backup.scopes import RelocationScope
1110
from sentry.db.models import (
@@ -47,19 +46,6 @@ class Meta:
4746

4847
__repr__ = sane_repr("key", "value")
4948

50-
@classmethod
51-
def query_for_relocation_export(cls, q: models.Q, pk_map: PrimaryKeyMap) -> models.Q:
52-
# These ping options change too frequently, or necessarily with each install, to be useful
53-
# in exports. More broadly, we don't really care about comparing them for accuracy.
54-
return q & ~models.Q(
55-
key__in={
56-
"sentry:install-id", # Only used on self-hosted
57-
"sentry:latest_version", # Auto-generated periodically, which defeats comparison
58-
"sentry:last_worker_ping", # Changes very frequently
59-
"sentry:last_worker_version", # Changes very frequently
60-
}
61-
)
62-
6349

6450
@region_silo_only_model
6551
class Option(BaseOption):

tests/sentry/backup/snapshots/test_comparators/test_default_comparators.pysnap

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,13 @@ source: tests/sentry/backup/test_comparators.py
882882
- user
883883
model_name: sentry.notificationsettingprovider
884884
- comparators:
885+
- class: DateUpdatedComparator
886+
fields:
887+
- last_updated
888+
- class: KeyValueComparator
889+
fields:
890+
- key
891+
- value
885892
- class: ForeignKeyComparator
886893
fields: []
887894
model_name: sentry.option

tests/sentry/backup/test_comparators.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ForeignKeyComparator,
1212
HashObfuscatingComparator,
1313
IgnoredComparator,
14+
KeyValueComparator,
1415
ScrubbedData,
1516
SecretHexComparator,
1617
SubscriptionIDComparator,
@@ -2146,6 +2147,143 @@ def test_good_user_password_obfuscating_comparator_scrubbed_short():
21462147
assert right["scrubbed"]["UserPasswordObfuscatingComparator::password"] == ["..."]
21472148

21482149

2150+
def test_good_key_value_comparator():
2151+
cmp = KeyValueComparator(skip_keys=[])
2152+
id = InstanceID("sentry.test", 0)
2153+
model = {
2154+
"model": "test",
2155+
"ordinal": 1,
2156+
"pk": 1,
2157+
"fields": {
2158+
"key": "foo",
2159+
"value": 1,
2160+
},
2161+
}
2162+
assert not cmp.compare(id, model, model)
2163+
2164+
2165+
def test_good_key_value_skip_comparator():
2166+
cmp = KeyValueComparator(skip_keys=["foo"])
2167+
id = InstanceID("sentry.test", 0)
2168+
left: JSONData = {
2169+
"model": "test",
2170+
"ordinal": 1,
2171+
"pk": 1,
2172+
"fields": {
2173+
"key": "foo",
2174+
"value": 1,
2175+
},
2176+
}
2177+
right: JSONData = {
2178+
"model": "test",
2179+
"ordinal": 1,
2180+
"pk": 1,
2181+
"fields": {
2182+
"key": "foo",
2183+
"value": 2,
2184+
},
2185+
}
2186+
assert not cmp.compare(id, left, right)
2187+
2188+
2189+
def test_bad_key_value_comparator():
2190+
cmp = KeyValueComparator(skip_keys=["bar"])
2191+
id = InstanceID("sentry.test", 0)
2192+
left: JSONData = {
2193+
"model": "test",
2194+
"ordinal": 1,
2195+
"pk": 1,
2196+
"fields": {
2197+
"key": "foo",
2198+
"value": 1,
2199+
},
2200+
}
2201+
right: JSONData = {
2202+
"model": "test",
2203+
"ordinal": 1,
2204+
"pk": 1,
2205+
"fields": {
2206+
"key": "foo",
2207+
"value": 2,
2208+
},
2209+
}
2210+
res = cmp.compare(id, left, right)
2211+
assert res
2212+
assert len(res) == 1
2213+
2214+
assert res[0]
2215+
assert res[0].kind == ComparatorFindingKind.KeyValueComparator
2216+
assert res[0].on == id
2217+
assert res[0].left_pk == 1
2218+
assert res[0].right_pk == 1
2219+
assert "`foo`" in res[0].reason
2220+
assert "1" in res[0].reason
2221+
assert "2" in res[0].reason
2222+
2223+
2224+
def test_good_key_value_comparator_existence():
2225+
cmp = KeyValueComparator(skip_keys=["bar"])
2226+
id = InstanceID("sentry.test", 0)
2227+
present: JSONData = {
2228+
"model": "test",
2229+
"ordinal": 1,
2230+
"pk": 1,
2231+
"fields": {
2232+
"key": "foo",
2233+
"value": 1,
2234+
},
2235+
}
2236+
missing: JSONData = {
2237+
"model": "test",
2238+
"ordinal": 1,
2239+
"pk": 1,
2240+
"fields": {
2241+
"key": "foo",
2242+
},
2243+
}
2244+
res = cmp.existence(id, missing, present)
2245+
assert res
2246+
assert len(res) == 1
2247+
2248+
assert res[0]
2249+
assert res[0].on == id
2250+
assert res[0].kind == ComparatorFindingKind.KeyValueComparatorExistenceCheck
2251+
assert res[0].left_pk == 1
2252+
assert res[0].right_pk == 1
2253+
assert "left" in res[0].reason
2254+
assert "`value`" in res[0].reason
2255+
2256+
2257+
def test_good_key_value_comparator_scrubbed():
2258+
cmp = KeyValueComparator(skip_keys=["bar"])
2259+
left: JSONData = {
2260+
"model": "test",
2261+
"ordinal": 1,
2262+
"pk": 1,
2263+
"fields": {
2264+
"key": "foo",
2265+
"value": 1,
2266+
},
2267+
}
2268+
right: JSONData = {
2269+
"model": "test",
2270+
"ordinal": 1,
2271+
"pk": 1,
2272+
"fields": {
2273+
"key": "foo",
2274+
"value": 2,
2275+
},
2276+
}
2277+
cmp.scrub(left, right)
2278+
assert left["scrubbed"]
2279+
assert left["scrubbed"]["KeyValueComparator::key"] is ScrubbedData()
2280+
assert left["scrubbed"]["KeyValueComparator::value"] is ScrubbedData()
2281+
2282+
assert right["scrubbed"]
2283+
assert right["scrubbed"]["KeyValueComparator::key"] is ScrubbedData()
2284+
assert right["scrubbed"]["KeyValueComparator::value"] is ScrubbedData()
2285+
2286+
21492287
def test_default_comparators(insta_snapshot):
21502288
serialized = []
21512289
defs = get_default_comparators()

tests/sentry/backup/test_exports.py

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from sentry.backup.validate import validate
1212
from sentry.db import models
1313
from sentry.models.email import Email
14-
from sentry.models.options.option import Option
1514
from sentry.models.organization import Organization
1615
from sentry.models.orgauthtoken import OrgAuthToken
1716
from sentry.models.user import User
@@ -315,36 +314,3 @@ def test_export_keep_only_admin_users_in_config_scope(self):
315314
assert self.count(data, UserRole) == 1
316315
assert self.count(data, UserRoleUser) == 1
317316
assert self.count(data, UserPermission) == 1
318-
319-
320-
class QueryTests(ExportTestCase):
321-
"""
322-
Some models have custom export logic that requires bespoke testing.
323-
"""
324-
325-
def test_export_query_for_option_model(self):
326-
# There are a number of options we specifically exclude by name, for various reasons
327-
# enumerated in that model's definition file.
328-
Option.objects.create(key="sentry:install-id", value='"excluded"')
329-
Option.objects.create(key="sentry:latest_version", value='"excluded"')
330-
Option.objects.create(key="sentry:last_worker_ping", value='"excluded"')
331-
Option.objects.create(key="sentry:last_worker_version", value='"excluded"')
332-
333-
Option.objects.create(key="sentry:test-unfiltered", value="included")
334-
Option.objects.create(key="foo:bar", value='"included"')
335-
336-
with tempfile.TemporaryDirectory() as tmp_dir:
337-
data = self.export(
338-
tmp_dir,
339-
scope=ExportScope.Config,
340-
)
341-
342-
assert self.count(data, Option) == 2
343-
assert not self.exists(data, Option, "key", "sentry:install-id")
344-
assert not self.exists(data, Option, "key", "sentry:last_version")
345-
assert not self.exists(data, Option, "key", "sentry:last_worker_ping")
346-
assert not self.exists(data, Option, "key", "sentry:last_worker_version")
347-
assert not self.exists(data, Option, "value", '"excluded"')
348-
assert self.exists(data, Option, "key", "sentry:test-unfiltered")
349-
assert self.exists(data, Option, "key", "foo:bar")
350-
assert self.exists(data, Option, "value", '"included"')

0 commit comments

Comments
 (0)