Skip to content

Commit d41496d

Browse files
authored
feat(storage): Simulate object custom contexts (#764)
b/457332685 This simulates the Json and gRPC responses for object contexts with the following operations: 1. Insert or Complete Update - Input: {contexts: {custom: {key: value}}} - Update_mask: contexts - Simulation: Add new entries to the object.contexts and furnish with create_time and update_time 2. Patch (Update an existing key) - Input: {contexts: {custom: {key: new-value}}} - Update_mask: contexts.custom.key - Simulation: Update the value of the key to "new-value" and furnish with update_time 3. Patch (Delete an existing key) - Input: {contexts: {custom: {key: null}}} - Update_mask: contexts.custom.key - Simulation: Delete that entry with that key from object.contexts 4. Patch (Delete all the contexts) - Input: {contexts: {custom: null}} - Update_mask: contexts:custom - Simulation: Delete the contexts field from object, making that object looks like its default
1 parent bca3419 commit d41496d

4 files changed

Lines changed: 472 additions & 3 deletions

File tree

gcs/object.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class Object:
6161
"event_based_hold",
6262
"customer_encryption",
6363
"custom_time",
64+
"contexts",
6465
]
6566

6667
def __init__(self, metadata, media, bucket, *, upload=None, upload_gen=0):
@@ -123,6 +124,11 @@ def init(
123124
metadata.checksums.crc32c = actual_crc32c
124125
metadata.create_time.FromDatetime(timestamp)
125126
metadata.update_time.FromDatetime(timestamp)
127+
if metadata.HasField("contexts"):
128+
for _, payload in metadata.contexts.custom.items():
129+
payload.create_time.FromDatetime(timestamp)
130+
payload.update_time.FromDatetime(timestamp)
131+
cls.__validate_object_contexts(metadata.contexts)
126132
upload_gen = 1
127133
if upload is None:
128134
upload_gen = 0
@@ -252,6 +258,29 @@ def __update_metadata(self, source, update_mask):
252258
self.metadata.etag = Object._metadata_etag(self.metadata)
253259
self.metadata.update_time.FromDatetime(datetime.datetime.now())
254260

261+
def __update_contexts_with_timestamps(
262+
self, new_metadata, original_metadata, isUpdate
263+
):
264+
if not new_metadata.HasField("contexts"):
265+
return
266+
if not new_metadata.contexts.custom:
267+
# Equivalent to {"contexts": {"custom": None}}
268+
new_metadata.ClearField("contexts")
269+
return
270+
timestamp = datetime.datetime.now(datetime.timezone.utc)
271+
for key, payload in new_metadata.contexts.custom.items():
272+
if isUpdate or key not in original_metadata.contexts.custom:
273+
# This is a brand new key, set create and update timestamps
274+
payload.create_time.FromDatetime(timestamp)
275+
payload.update_time.FromDatetime(timestamp)
276+
elif (
277+
key in original_metadata.contexts.custom
278+
and original_metadata.contexts.custom[key].value != payload.value
279+
):
280+
# This is an existing key with new value, set update timestamp
281+
payload.update_time.FromDatetime(timestamp)
282+
self.__validate_object_contexts(new_metadata.contexts)
283+
255284
def update(self, request, context):
256285
# Support for `Object: update` over gRPC is not needed (and not implemented).
257286
assert context is None
@@ -265,6 +294,7 @@ def update(self, request, context):
265294
testbench.acl.extract_predefined_acl(request, False, context),
266295
context,
267296
)
297+
self.__update_contexts_with_timestamps(metadata, self.metadata, True)
268298
self.__update_metadata(metadata, None)
269299

270300
def patch(self, request, context):
@@ -285,8 +315,48 @@ def patch(self, request, context):
285315
testbench.acl.extract_predefined_acl(request, False, context),
286316
context,
287317
)
318+
self.__update_contexts_with_timestamps(metadata, self.metadata, False)
288319
self.__update_metadata(metadata, None)
289320

321+
@staticmethod
322+
def __validate_object_contexts(contexts) -> bool:
323+
"""Validates an object context map against API layer rules."""
324+
assert contexts is not None
325+
custom_contexts = contexts.custom
326+
if len(custom_contexts) > 50:
327+
raise ValueError("The count of object context entries cannot exceed 50.")
328+
invalid_chars = {"'", '"', "\\", "/"}
329+
total_size_bytes = 0
330+
for key, payload in custom_contexts.items():
331+
val = payload.value
332+
if key.startswith("goog"):
333+
raise ValueError(
334+
f"Key '{key}' is invalid. Keys cannot begin with 'goog'."
335+
)
336+
for item, item_type in ((key, "Key"), (val, "Value")):
337+
if not item or not item[0].isalnum():
338+
raise ValueError(
339+
f"{item_type} '{item}' must begin with an alphanumeric character."
340+
)
341+
if any(char in invalid_chars for char in item):
342+
raise ValueError(
343+
f"{item_type} '{item}' contains restricted characters (', \", \\, /)."
344+
)
345+
encoded_item = item.encode("utf-8")
346+
item_length = len(encoded_item)
347+
if not (1 <= item_length <= 256):
348+
raise ValueError(
349+
f"{item_type} '{item}' must be between 1 and 256 UTF-8 code units."
350+
)
351+
total_size_bytes += item_length
352+
max_size_bytes = 25 * 1024
353+
if total_size_bytes > max_size_bytes:
354+
raise ValueError(
355+
f"Aggregate size of keys and values ({total_size_bytes} bytes) exceeds the 25 KiB limit."
356+
)
357+
358+
return True
359+
290360
# === ACL === #
291361

292362
def __search_acl(self, entity, must_exist, context):

testbench/grpc_server.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,56 @@ def decorated(self, request, context):
157157
return decorated
158158

159159

160+
def _validate_object_contexts(contexts, grpc_context):
161+
"""
162+
Validates a storage_pb2.Object.contexts message against API layer rules.
163+
If validation fails, it aborts the gRPC request using testbench.error.invalid.
164+
"""
165+
if not contexts or not contexts.custom:
166+
return
167+
168+
custom_contexts = contexts.custom
169+
if len(custom_contexts) > 50:
170+
return testbench.error.invalid(
171+
"The count of object context entries cannot exceed 50.", grpc_context
172+
)
173+
174+
invalid_chars = {"'", '"', "\\", "/"}
175+
total_size_bytes = 0
176+
177+
for key, payload in custom_contexts.items():
178+
val = payload.value
179+
if key.startswith("goog"):
180+
return testbench.error.invalid(
181+
f"Key '{key}' is invalid. Keys cannot begin with 'goog'.", grpc_context
182+
)
183+
for item, item_type in ((key, "Key"), (val, "Value")):
184+
if not item or not item[0].isalnum():
185+
return testbench.error.invalid(
186+
f"{item_type} '{item}' must begin with an alphanumeric character.",
187+
grpc_context,
188+
)
189+
if any(char in invalid_chars for char in item):
190+
return testbench.error.invalid(
191+
f"{item_type} '{item}' contains restricted characters (', \", \\, /).",
192+
grpc_context,
193+
)
194+
encoded_item = item.encode("utf-8")
195+
item_length = len(encoded_item)
196+
if not (1 <= item_length <= 256):
197+
return testbench.error.invalid(
198+
f"{item_type} '{item}' must be between 1 and 256 UTF-8 code units.",
199+
grpc_context,
200+
)
201+
total_size_bytes += item_length
202+
max_size_bytes = 25 * 1024
203+
if total_size_bytes > max_size_bytes:
204+
return testbench.error.invalid(
205+
f"Aggregate size of keys and values ({total_size_bytes} bytes) exceeds the 25 KiB limit.",
206+
grpc_context,
207+
)
208+
209+
160210
def retry_test(method):
161211
"""
162212
Decorate a routing function to handle the Retry Test API instructions,
@@ -812,6 +862,10 @@ def UpdateObject(self, request, context):
812862
updated_metadata = dict()
813863
removed_metadata_keys = set()
814864
replace_metadata = False
865+
updated_contexts = dict()
866+
removed_contexts_keys = set()
867+
replace_contexts = False
868+
reset_contexts = False
815869
for path in request.update_mask.paths:
816870
if path == "metadata":
817871
replace_metadata = True
@@ -822,6 +876,17 @@ def UpdateObject(self, request, context):
822876
removed_metadata_keys.add(key)
823877
else:
824878
updated_metadata[key] = value
879+
elif path == "contexts":
880+
replace_contexts = True
881+
elif path == "contexts.custom":
882+
reset_contexts = True
883+
elif path.startswith("contexts.custom."):
884+
key = path[len("contexts.custom.") :]
885+
value = request.object.contexts.custom.get(key, None)
886+
if value is None:
887+
removed_contexts_keys.add(key)
888+
else:
889+
updated_contexts[key] = value
825890
elif path == "acl":
826891
pass
827892
else:
@@ -873,8 +938,39 @@ def update_impl(blob, live_generation) -> storage_pb2.Object:
873938
object.metadata.update(updated_metadata)
874939
for k in removed_metadata_keys:
875940
object.metadata.pop(k, None)
941+
# Manually handle custom contexts due to its complexity.
942+
curr_time = datetime.datetime.now()
943+
if reset_contexts:
944+
object.ClearField("contexts")
945+
elif replace_contexts:
946+
object.contexts.custom.clear()
947+
for k, v in request.object.contexts.custom.items():
948+
dest_payload = object.contexts.custom[k]
949+
dest_payload.CopyFrom(v)
950+
dest_payload.create_time.FromDatetime(curr_time)
951+
dest_payload.update_time.FromDatetime(curr_time)
952+
else:
953+
for k, v in updated_contexts.items():
954+
if k not in object.contexts.custom:
955+
# This is a brand new key, add create and update time.
956+
updated_contexts[k].create_time.FromDatetime(curr_time)
957+
updated_contexts[k].update_time.FromDatetime(curr_time)
958+
elif v.value != object.contexts.custom[k].value:
959+
# This is an existing key, copy original key's create
960+
# time and add new update time.
961+
updated_contexts[k].create_time.CopyFrom(
962+
object.contexts.custom[k].create_time
963+
)
964+
updated_contexts[k].update_time.FromDatetime(curr_time)
965+
object.contexts.custom[k].CopyFrom(v)
966+
for k in removed_contexts_keys:
967+
object.contexts.custom.pop(k, None)
968+
969+
if object.HasField("contexts"):
970+
_validate_object_contexts(object.contexts, context)
971+
876972
object.metageneration += 1
877-
object.update_time.FromDatetime(datetime.datetime.now())
973+
object.update_time.FromDatetime(curr_time)
878974
return object
879975

880976
self.db.insert_test_bucket()

tests/test_grpc_server.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,6 +1142,118 @@ def test_update_object_metadata(self):
11421142
)
11431143
self.assertEqual(response.metadata, {**response.metadata, **expected})
11441144

1145+
def test_update_object_contexts(self):
1146+
media = b"How vexingly quick daft zebras jump!"
1147+
request = testbench.common.FakeRequest(
1148+
args={"name": "object-name"},
1149+
data=media,
1150+
headers={},
1151+
environ={},
1152+
)
1153+
blob, _ = gcs.object.Object.init_media(request, self.bucket.metadata)
1154+
self.db.insert_object("bucket-name", blob, None)
1155+
1156+
def assert_context_valid(contexts, key, expected_value):
1157+
payload = contexts.custom.get(key)
1158+
self.assertIsNotNone(payload, f"Key '{key}' missing from custom contexts")
1159+
self.assertEqual(payload.value, expected_value)
1160+
# Verify timestamps are set (non-default)
1161+
self.assertGreater(
1162+
payload.create_time.seconds, 0, f"create_time not set for {key}"
1163+
)
1164+
self.assertGreater(
1165+
payload.update_time.seconds, 0, f"update_time not set for {key}"
1166+
)
1167+
1168+
request = storage_pb2.UpdateObjectRequest(
1169+
object=storage_pb2.Object(
1170+
bucket="projects/_/buckets/bucket-name",
1171+
name="object-name",
1172+
contexts=storage_pb2.ObjectContexts(
1173+
custom={
1174+
"location": storage_pb2.ObjectCustomContextPayload(
1175+
value="Canada"
1176+
),
1177+
"year": storage_pb2.ObjectCustomContextPayload(value="2026"),
1178+
}
1179+
),
1180+
),
1181+
update_mask=field_mask_pb2.FieldMask(
1182+
paths=["contexts.custom.location", "contexts.custom.year"]
1183+
),
1184+
)
1185+
context = unittest.mock.Mock()
1186+
response = self.grpc.UpdateObject(request, context)
1187+
assert_context_valid(response.contexts, "location", "Canada")
1188+
assert_context_valid(response.contexts, "year", "2026")
1189+
1190+
# Finally verify the changes are persisted
1191+
context = unittest.mock.Mock()
1192+
response = self.grpc.GetObject(
1193+
storage_pb2.GetObjectRequest(
1194+
bucket="projects/_/buckets/bucket-name", object="object-name"
1195+
),
1196+
context,
1197+
)
1198+
assert_context_valid(response.contexts, "location", "Canada")
1199+
assert_context_valid(response.contexts, "year", "2026")
1200+
1201+
def test_update_object_contexts_invalid(self):
1202+
media = b"How vexingly quick daft zebras jump!"
1203+
request = testbench.common.FakeRequest(
1204+
args={"name": "object-name"},
1205+
data=media,
1206+
headers={},
1207+
environ={},
1208+
)
1209+
blob, _ = gcs.object.Object.init_media(request, self.bucket.metadata)
1210+
self.db.insert_object("bucket-name", blob, None)
1211+
1212+
def assert_invalid_context(custom_dict):
1213+
context = unittest.mock.Mock()
1214+
request = storage_pb2.UpdateObjectRequest(
1215+
object=storage_pb2.Object(
1216+
bucket="projects/_/buckets/bucket-name",
1217+
name="object-name",
1218+
contexts=storage_pb2.ObjectContexts(
1219+
custom={
1220+
k: storage_pb2.ObjectCustomContextPayload(value=v)
1221+
for k, v in custom_dict.items()
1222+
}
1223+
),
1224+
),
1225+
# Using the broader 'contexts' mask to trigger the replacement logic
1226+
# and evaluate the entire custom dictionary we just built
1227+
update_mask=field_mask_pb2.FieldMask(paths=["contexts"]),
1228+
)
1229+
_ = self.grpc.UpdateObject(request, context)
1230+
context.abort.assert_called_once_with(
1231+
grpc.StatusCode.INVALID_ARGUMENT, unittest.mock.ANY
1232+
)
1233+
1234+
# --- Keys cannot begin with reserved 'goog' ---
1235+
assert_invalid_context({"googlekey": "value"})
1236+
1237+
# --- Must begin with an alphanumeric character ---
1238+
assert_invalid_context({"-badkey": "value"}) # Bad Key
1239+
assert_invalid_context({"validKey": ".badvalue"}) # Bad Value
1240+
1241+
# --- Cannot contain ', \", \\, or / ---
1242+
assert_invalid_context({"bad'key": "value"})
1243+
assert_invalid_context({"validKey": 'bad"value'})
1244+
assert_invalid_context({"validKey": "bad/value"})
1245+
assert_invalid_context({"validKey": "bad\\value"})
1246+
1247+
# --- Must be between 1 and 256 UTF-8 code units ---
1248+
assert_invalid_context({"": "value"}) # 0 length key
1249+
assert_invalid_context({"key": ""}) # 0 length value
1250+
assert_invalid_context({"a" * 257: "value"}) # Key too long
1251+
assert_invalid_context({"key": "v" * 257}) # Value too long
1252+
1253+
# --- Limit to 50 entries per object ---
1254+
too_many_contexts = {f"key{i}": "val" for i in range(51)}
1255+
assert_invalid_context(too_many_contexts)
1256+
11451257
def test_update_object_acl(self):
11461258
media = b"How vexingly quick daft zebras jump!"
11471259
request = testbench.common.FakeRequest(

0 commit comments

Comments
 (0)