Skip to content

Commit 8ef5e66

Browse files
authored
Minimal implementation for continuous backup (#7735)
1 parent 145291f commit 8ef5e66

File tree

3 files changed

+149
-14
lines changed

3 files changed

+149
-14
lines changed

localstack/services/dynamodb/provider.py

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,15 @@
2929
BatchWriteItemInput,
3030
BatchWriteItemOutput,
3131
BillingMode,
32+
ContinuousBackupsDescription,
33+
ContinuousBackupsStatus,
3234
CreateGlobalTableOutput,
3335
CreateTableInput,
3436
CreateTableOutput,
3537
DeleteItemInput,
3638
DeleteItemOutput,
3739
DeleteTableOutput,
40+
DescribeContinuousBackupsOutput,
3841
DescribeGlobalTableOutput,
3942
DescribeKinesisStreamingDestinationOutput,
4043
DescribeTableOutput,
@@ -55,6 +58,9 @@
5558
ListTagsOfResourceOutput,
5659
NextTokenString,
5760
PartiQLBatchRequest,
61+
PointInTimeRecoveryDescription,
62+
PointInTimeRecoverySpecification,
63+
PointInTimeRecoveryStatus,
5864
PositiveIntegerObject,
5965
ProvisionedThroughputExceededException,
6066
PutItemInput,
@@ -73,6 +79,7 @@
7379
ScanInput,
7480
ScanOutput,
7581
StreamArn,
82+
TableDescription,
7683
TableName,
7784
TagKeyList,
7885
TagList,
@@ -81,6 +88,7 @@
8188
TransactGetItemsOutput,
8289
TransactWriteItemsInput,
8390
TransactWriteItemsOutput,
91+
UpdateContinuousBackupsOutput,
8492
UpdateGlobalTableOutput,
8593
UpdateItemInput,
8694
UpdateItemOutput,
@@ -109,7 +117,7 @@
109117
from localstack.utils.aws import arns, aws_stack
110118
from localstack.utils.aws.arns import extract_account_id_from_arn, extract_region_from_arn
111119
from localstack.utils.aws.aws_stack import get_valid_regions_for_service
112-
from localstack.utils.collections import select_attributes
120+
from localstack.utils.collections import select_attributes, select_from_typed_dict
113121
from localstack.utils.common import short_uid, to_bytes
114122
from localstack.utils.json import BytesEncoder, canonical_json
115123
from localstack.utils.strings import long_uid, to_str
@@ -506,11 +514,13 @@ def describe_table(self, context: RequestContext, table_name: TableName) -> Desc
506514
global_table_region = self.get_global_table_region(context, table_name)
507515

508516
result = self._forward_request(context=context, region=global_table_region)
517+
table_description: TableDescription = result["Table"]
509518

510519
# Update table properties from LocalStack stores
511-
table_props = get_store(context.account_id, context.region).table_properties.get(table_name)
512-
if table_props:
513-
result.get("Table", {}).update(table_props)
520+
if table_props := get_store(context.account_id, context.region).table_properties.get(
521+
table_name
522+
):
523+
table_description.update(table_props)
514524

515525
store = get_store(context.account_id, context.region)
516526

@@ -530,22 +540,21 @@ def describe_table(self, context: RequestContext, table_name: TableName) -> Desc
530540
RegionName=replicated_region, ReplicaStatus=ReplicaStatus.ACTIVE
531541
)
532542
)
533-
result.get("Table", {}).update({"Replicas": replica_description_list})
543+
table_description.update({"Replicas": replica_description_list})
534544

535545
# update only TableId and SSEDescription if present
536-
table_definitions = get_store(context.account_id, context.region).table_definitions.get(
537-
table_name
538-
)
539-
if table_definitions:
546+
if table_definitions := store.table_definitions.get(table_name):
540547
for key in ["TableId", "SSEDescription"]:
541548
if table_definitions.get(key):
542-
result.get("Table", {})[key] = table_definitions[key]
549+
table_description[key] = table_definitions[key]
543550
if "TableClass" in table_definitions:
544-
result.get("Table", {})["TableClassSummary"] = {
551+
table_description["TableClassSummary"] = {
545552
"TableClass": table_definitions["TableClass"]
546553
}
547554

548-
return result
555+
return DescribeTableOutput(
556+
Table=select_from_typed_dict(TableDescription, table_description)
557+
)
549558

550559
@handler("UpdateTable", expand=False)
551560
def update_table(
@@ -1197,6 +1206,55 @@ def describe_kinesis_streaming_destination(
11971206
KinesisDataStreamDestinations=stream_destinations, TableName=table_name
11981207
)
11991208

1209+
#
1210+
# Continuous Backups
1211+
#
1212+
1213+
def describe_continuous_backups(
1214+
self, context: RequestContext, table_name: TableName
1215+
) -> DescribeContinuousBackupsOutput:
1216+
self.get_global_table_region(context, table_name)
1217+
store = get_store(context.account_id, context.region)
1218+
continuous_backup_description = (
1219+
store.table_properties.get(table_name, {}).get("ContinuousBackupsDescription")
1220+
) or ContinuousBackupsDescription(
1221+
ContinuousBackupsStatus=ContinuousBackupsStatus.ENABLED,
1222+
PointInTimeRecoveryDescription=PointInTimeRecoveryDescription(
1223+
PointInTimeRecoveryStatus=PointInTimeRecoveryStatus.DISABLED
1224+
),
1225+
)
1226+
1227+
return DescribeContinuousBackupsOutput(
1228+
ContinuousBackupsDescription=continuous_backup_description
1229+
)
1230+
1231+
def update_continuous_backups(
1232+
self,
1233+
context: RequestContext,
1234+
table_name: TableName,
1235+
point_in_time_recovery_specification: PointInTimeRecoverySpecification,
1236+
) -> UpdateContinuousBackupsOutput:
1237+
self.get_global_table_region(context, table_name)
1238+
1239+
store = get_store(context.account_id, context.region)
1240+
pit_recovery_status = (
1241+
PointInTimeRecoveryStatus.ENABLED
1242+
if point_in_time_recovery_specification["PointInTimeRecoveryEnabled"]
1243+
else PointInTimeRecoveryStatus.DISABLED
1244+
)
1245+
continuous_backup_description = ContinuousBackupsDescription(
1246+
ContinuousBackupsStatus=ContinuousBackupsStatus.ENABLED,
1247+
PointInTimeRecoveryDescription=PointInTimeRecoveryDescription(
1248+
PointInTimeRecoveryStatus=pit_recovery_status
1249+
),
1250+
)
1251+
table_props = store.table_properties.setdefault(table_name, {})
1252+
table_props["ContinuousBackupsDescription"] = continuous_backup_description
1253+
1254+
return UpdateContinuousBackupsOutput(
1255+
ContinuousBackupsDescription=continuous_backup_description
1256+
)
1257+
12001258
#
12011259
# Helpers
12021260
#
@@ -1271,7 +1329,7 @@ def prepare_request_headers(headers: Dict, account_id: str, region_name: str):
12711329

12721330
# DynamoDBLocal namespaces based on the value of Credentials
12731331
# Since we want to namespace by both account ID and region, use an aggregate key
1274-
# We also replace the region to keep compatibilty with NoSQL Workbench
1332+
# We also replace the region to keep compatibility with NoSQL Workbench
12751333
headers["Authorization"] = re.sub(
12761334
AUTH_CREDENTIAL_REGEX,
12771335
rf"Credential={key}/\2/{region_name}/\4/",

tests/integration/test_dynamodb.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@
88
from boto3.dynamodb.conditions import Key
99
from boto3.dynamodb.types import STRING
1010

11+
from localstack.aws.api.dynamodb import (
12+
ContinuousBackupsUnavailableException,
13+
PointInTimeRecoverySpecification,
14+
)
1115
from localstack.constants import TEST_AWS_SECRET_ACCESS_KEY
1216
from localstack.services.dynamodbstreams.dynamodbstreams_api import get_kinesis_stream_name
1317
from localstack.testing.snapshots.transformer import SortingTransformer
1418
from localstack.utils import testutil
1519
from localstack.utils.aws import arns, aws_stack, queries, resources
1620
from localstack.utils.common import json_safe, long_uid, retry, short_uid
21+
from localstack.utils.sync import poll_condition
1722

1823
from .test_kinesis import get_shard_iterator
1924

@@ -1610,6 +1615,45 @@ def test_data_encoding_consistency(
16101615
]
16111616
snapshot.match("GetRecordsAfterUpdate", records[1]["dynamodb"]["NewImage"])
16121617

1618+
@pytest.mark.skip_snapshot_verify(
1619+
paths=[
1620+
"$..PointInTimeRecoveryDescription..EarliestRestorableDateTime",
1621+
"$..PointInTimeRecoveryDescription..LatestRestorableDateTime",
1622+
]
1623+
)
1624+
def test_continuous_backup_update(self, dynamodb_create_table, dynamodb_client, snapshot):
1625+
table_name = f"table-{short_uid()}"
1626+
dynamodb_create_table(
1627+
table_name=table_name,
1628+
partition_key=PARTITION_KEY,
1629+
)
1630+
1631+
def wait_for_continuous_backend():
1632+
try:
1633+
dynamodb_client.update_continuous_backups(
1634+
TableName=table_name,
1635+
PointInTimeRecoverySpecification=PointInTimeRecoverySpecification(
1636+
PointInTimeRecoveryEnabled=True
1637+
),
1638+
)
1639+
return True
1640+
except ContinuousBackupsUnavailableException:
1641+
return False
1642+
1643+
assert poll_condition(wait_for_continuous_backend, timeout=10)
1644+
1645+
response = dynamodb_client.update_continuous_backups(
1646+
TableName=table_name,
1647+
PointInTimeRecoverySpecification=PointInTimeRecoverySpecification(
1648+
PointInTimeRecoveryEnabled=True
1649+
),
1650+
)
1651+
1652+
snapshot.match("update-continuous-backup", response)
1653+
1654+
response = dynamodb_client.describe_continuous_backups(TableName=table_name)
1655+
snapshot.match("describe-continuous-backup", response)
1656+
16131657

16141658
def delete_table(name):
16151659
dynamodb_client = aws_stack.create_external_boto_client("dynamodb")

tests/integration/test_dynamodb.snapshot.json

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,5 +376,38 @@
376376
}
377377
}
378378
}
379+
},
380+
"tests/integration/test_dynamodb.py::TestDynamoDB::test_continuous_backup_update": {
381+
"recorded-date": "22-02-2023, 21:13:32",
382+
"recorded-content": {
383+
"update-continuous-backup": {
384+
"ContinuousBackupsDescription": {
385+
"ContinuousBackupsStatus": "ENABLED",
386+
"PointInTimeRecoveryDescription": {
387+
"EarliestRestorableDateTime": "datetime",
388+
"LatestRestorableDateTime": "datetime",
389+
"PointInTimeRecoveryStatus": "ENABLED"
390+
}
391+
},
392+
"ResponseMetadata": {
393+
"HTTPHeaders": {},
394+
"HTTPStatusCode": 200
395+
}
396+
},
397+
"describe-continuous-backup": {
398+
"ContinuousBackupsDescription": {
399+
"ContinuousBackupsStatus": "ENABLED",
400+
"PointInTimeRecoveryDescription": {
401+
"EarliestRestorableDateTime": "datetime",
402+
"LatestRestorableDateTime": "datetime",
403+
"PointInTimeRecoveryStatus": "ENABLED"
404+
}
405+
},
406+
"ResponseMetadata": {
407+
"HTTPHeaders": {},
408+
"HTTPStatusCode": 200
409+
}
410+
}
411+
}
379412
}
380-
}
413+
}

0 commit comments

Comments
 (0)