Skip to content

Commit e990ff7

Browse files
authored
feat: add support for CMEK (#105)
* feat: add support for creating databases with CMEK * refactor: use kwargs for EncryptionConfig conversion * feat: add support for creating backups with CMEK * feat: add support for restore a database with CMEK * style: fix lint * fix: verify that correct encryption type is used when using a key * test: use non-default encryption for backup tests to test CMEK support * test: fix encryption assertion * test: fix encryption type for assertion * docs: fix docstring types * docs: update docstring descriptions Co-authored-by: larkee <[email protected]>
1 parent 801ddc8 commit e990ff7

File tree

7 files changed

+449
-33
lines changed

7 files changed

+449
-33
lines changed

google/cloud/spanner_v1/backup.py

+50-3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from google.cloud.exceptions import NotFound
2020

2121
from google.cloud.spanner_admin_database_v1 import Backup as BackupPB
22+
from google.cloud.spanner_admin_database_v1 import CreateBackupEncryptionConfig
23+
from google.cloud.spanner_admin_database_v1 import CreateBackupRequest
2224
from google.cloud.spanner_v1._helpers import _metadata_with_prefix
2325

2426
_BACKUP_NAME_RE = re.compile(
@@ -57,10 +59,24 @@ class Backup(object):
5759
the externally consistent copy of the database. If
5860
not present, it is the same as the `create_time` of
5961
the backup.
62+
63+
:type encryption_config:
64+
:class:`~google.cloud.spanner_admin_database_v1.types.CreateBackupEncryptionConfig`
65+
or :class:`dict`
66+
:param encryption_config:
67+
(Optional) Encryption configuration for the backup.
68+
If a dict is provided, it must be of the same form as the protobuf
69+
message :class:`~google.cloud.spanner_admin_database_v1.types.CreateBackupEncryptionConfig`
6070
"""
6171

6272
def __init__(
63-
self, backup_id, instance, database="", expire_time=None, version_time=None
73+
self,
74+
backup_id,
75+
instance,
76+
database="",
77+
expire_time=None,
78+
version_time=None,
79+
encryption_config=None,
6480
):
6581
self.backup_id = backup_id
6682
self._instance = instance
@@ -71,6 +87,11 @@ def __init__(
7187
self._size_bytes = None
7288
self._state = None
7389
self._referencing_databases = None
90+
self._encryption_info = None
91+
if type(encryption_config) == dict:
92+
self._encryption_config = CreateBackupEncryptionConfig(**encryption_config)
93+
else:
94+
self._encryption_config = encryption_config
7495

7596
@property
7697
def name(self):
@@ -156,6 +177,22 @@ def referencing_databases(self):
156177
"""
157178
return self._referencing_databases
158179

180+
@property
181+
def encryption_info(self):
182+
"""Encryption info for this backup.
183+
:rtype: :class:`~google.clod.spanner_admin_database_v1.types.EncryptionInfo`
184+
:returns: a class representing the encryption info
185+
"""
186+
return self._encryption_info
187+
188+
@property
189+
def encryption_config(self):
190+
"""Encryption config for this database.
191+
:rtype: :class:`~google.cloud.spanner_admin_instance_v1.types.CreateBackupEncryptionConfig`
192+
:returns: an object representing the encryption config for this database
193+
"""
194+
return self._encryption_config
195+
159196
@classmethod
160197
def from_pb(cls, backup_pb, instance):
161198
"""Create an instance of this class from a protobuf message.
@@ -207,6 +244,13 @@ def create(self):
207244
raise ValueError("expire_time not set")
208245
if not self._database:
209246
raise ValueError("database not set")
247+
if (
248+
self.encryption_config
249+
and self.encryption_config.kms_key_name
250+
and self.encryption_config.encryption_type
251+
!= CreateBackupEncryptionConfig.EncryptionType.CUSTOMER_MANAGED_ENCRYPTION
252+
):
253+
raise ValueError("kms_key_name only used with CUSTOMER_MANAGED_ENCRYPTION")
210254
api = self._instance._client.database_admin_api
211255
metadata = _metadata_with_prefix(self.name)
212256
backup = BackupPB(
@@ -215,12 +259,14 @@ def create(self):
215259
version_time=self.version_time,
216260
)
217261

218-
future = api.create_backup(
262+
request = CreateBackupRequest(
219263
parent=self._instance.name,
220264
backup_id=self.backup_id,
221265
backup=backup,
222-
metadata=metadata,
266+
encryption_config=self._encryption_config,
223267
)
268+
269+
future = api.create_backup(request=request, metadata=metadata,)
224270
return future
225271

226272
def exists(self):
@@ -255,6 +301,7 @@ def reload(self):
255301
self._size_bytes = pb.size_bytes
256302
self._state = BackupPB.State(pb.state)
257303
self._referencing_databases = pb.referencing_databases
304+
self._encryption_info = pb.encryption_info
258305

259306
def update_expire_time(self, new_expire_time):
260307
"""Update the expire time of this backup.

google/cloud/spanner_v1/database.py

+48-5
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
SpannerGrpcTransport,
4848
)
4949
from google.cloud.spanner_admin_database_v1 import CreateDatabaseRequest
50+
from google.cloud.spanner_admin_database_v1 import EncryptionConfig
51+
from google.cloud.spanner_admin_database_v1 import RestoreDatabaseEncryptionConfig
52+
from google.cloud.spanner_admin_database_v1 import RestoreDatabaseRequest
5053
from google.cloud.spanner_admin_database_v1 import UpdateDatabaseDdlRequest
5154
from google.cloud.spanner_v1 import (
5255
ExecuteSqlRequest,
@@ -108,12 +111,27 @@ class Database(object):
108111
is `True` to log commit statistics. If not passed, a logger
109112
will be created when needed that will log the commit statistics
110113
to stdout.
114+
:type encryption_config:
115+
:class:`~google.cloud.spanner_admin_database_v1.types.EncryptionConfig`
116+
or :class:`~google.cloud.spanner_admin_database_v1.types.RestoreDatabaseEncryptionConfig`
117+
or :class:`dict`
118+
:param encryption_config:
119+
(Optional) Encryption configuration for the database.
120+
If a dict is provided, it must be of the same form as either of the protobuf
121+
messages :class:`~google.cloud.spanner_admin_database_v1.types.EncryptionConfig`
122+
or :class:`~google.cloud.spanner_admin_database_v1.types.RestoreDatabaseEncryptionConfig`
111123
"""
112124

113125
_spanner_api = None
114126

115127
def __init__(
116-
self, database_id, instance, ddl_statements=(), pool=None, logger=None
128+
self,
129+
database_id,
130+
instance,
131+
ddl_statements=(),
132+
pool=None,
133+
logger=None,
134+
encryption_config=None,
117135
):
118136
self.database_id = database_id
119137
self._instance = instance
@@ -126,6 +144,7 @@ def __init__(
126144
self._earliest_version_time = None
127145
self.log_commit_stats = False
128146
self._logger = logger
147+
self._encryption_config = encryption_config
129148

130149
if pool is None:
131150
pool = BurstyPool()
@@ -242,6 +261,14 @@ def earliest_version_time(self):
242261
"""
243262
return self._earliest_version_time
244263

264+
@property
265+
def encryption_config(self):
266+
"""Encryption config for this database.
267+
:rtype: :class:`~google.cloud.spanner_admin_instance_v1.types.EncryptionConfig`
268+
:returns: an object representing the encryption config for this database
269+
"""
270+
return self._encryption_config
271+
245272
@property
246273
def ddl_statements(self):
247274
"""DDL Statements used to define database schema.
@@ -325,11 +352,14 @@ def create(self):
325352
db_name = self.database_id
326353
if "-" in db_name:
327354
db_name = "`%s`" % (db_name,)
355+
if type(self._encryption_config) == dict:
356+
self._encryption_config = EncryptionConfig(**self._encryption_config)
328357

329358
request = CreateDatabaseRequest(
330359
parent=self._instance.name,
331360
create_statement="CREATE DATABASE %s" % (db_name,),
332361
extra_statements=list(self._ddl_statements),
362+
encryption_config=self._encryption_config,
333363
)
334364
future = api.create_database(request=request, metadata=metadata)
335365
return future
@@ -372,6 +402,7 @@ def reload(self):
372402
self._restore_info = response.restore_info
373403
self._version_retention_period = response.version_retention_period
374404
self._earliest_version_time = response.earliest_version_time
405+
self._encryption_config = response.encryption_config
375406

376407
def update_ddl(self, ddl_statements, operation_id=""):
377408
"""Update DDL for this database.
@@ -588,8 +619,8 @@ def run_in_transaction(self, func, *args, **kw):
588619
def restore(self, source):
589620
"""Restore from a backup to this database.
590621
591-
:type backup: :class:`~google.cloud.spanner_v1.backup.Backup`
592-
:param backup: the path of the backup being restored from.
622+
:type source: :class:`~google.cloud.spanner_v1.backup.Backup`
623+
:param source: the path of the source being restored from.
593624
594625
:rtype: :class:`~google.api_core.operation.Operation`
595626
:returns: a future used to poll the status of the create request
@@ -601,14 +632,26 @@ def restore(self, source):
601632
"""
602633
if source is None:
603634
raise ValueError("Restore source not specified")
635+
if type(self._encryption_config) == dict:
636+
self._encryption_config = RestoreDatabaseEncryptionConfig(
637+
**self._encryption_config
638+
)
639+
if (
640+
self.encryption_config
641+
and self.encryption_config.kms_key_name
642+
and self.encryption_config.encryption_type
643+
!= RestoreDatabaseEncryptionConfig.EncryptionType.CUSTOMER_MANAGED_ENCRYPTION
644+
):
645+
raise ValueError("kms_key_name only used with CUSTOMER_MANAGED_ENCRYPTION")
604646
api = self._instance._client.database_admin_api
605647
metadata = _metadata_with_prefix(self.name)
606-
future = api.restore_database(
648+
request = RestoreDatabaseRequest(
607649
parent=self._instance.name,
608650
database_id=self.database_id,
609651
backup=source.name,
610-
metadata=metadata,
652+
encryption_config=self._encryption_config,
611653
)
654+
future = api.restore_database(request=request, metadata=metadata,)
612655
return future
613656

614657
def is_ready(self):

google/cloud/spanner_v1/instance.py

+42-3
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,14 @@ def delete(self):
357357

358358
api.delete_instance(name=self.name, metadata=metadata)
359359

360-
def database(self, database_id, ddl_statements=(), pool=None, logger=None):
360+
def database(
361+
self,
362+
database_id,
363+
ddl_statements=(),
364+
pool=None,
365+
logger=None,
366+
encryption_config=None,
367+
):
361368
"""Factory to create a database within this instance.
362369
363370
:type database_id: str
@@ -377,11 +384,26 @@ def database(self, database_id, ddl_statements=(), pool=None, logger=None):
377384
will be created when needed that will log the commit statistics
378385
to stdout.
379386
387+
:type encryption_config:
388+
:class:`~google.cloud.spanner_admin_database_v1.types.EncryptionConfig`
389+
or :class:`~google.cloud.spanner_admin_database_v1.types.RestoreDatabaseEncryptionConfig`
390+
or :class:`dict`
391+
:param encryption_config:
392+
(Optional) Encryption configuration for the database.
393+
If a dict is provided, it must be of the same form as either of the protobuf
394+
messages :class:`~google.cloud.spanner_admin_database_v1.types.EncryptionConfig`
395+
or :class:`~google.cloud.spanner_admin_database_v1.types.RestoreDatabaseEncryptionConfig`
396+
380397
:rtype: :class:`~google.cloud.spanner_v1.database.Database`
381398
:returns: a database owned by this instance.
382399
"""
383400
return Database(
384-
database_id, self, ddl_statements=ddl_statements, pool=pool, logger=logger
401+
database_id,
402+
self,
403+
ddl_statements=ddl_statements,
404+
pool=pool,
405+
logger=logger,
406+
encryption_config=encryption_config,
385407
)
386408

387409
def list_databases(self, page_size=None):
@@ -408,7 +430,14 @@ def list_databases(self, page_size=None):
408430
)
409431
return page_iter
410432

411-
def backup(self, backup_id, database="", expire_time=None, version_time=None):
433+
def backup(
434+
self,
435+
backup_id,
436+
database="",
437+
expire_time=None,
438+
version_time=None,
439+
encryption_config=None,
440+
):
412441
"""Factory to create a backup within this instance.
413442
414443
:type backup_id: str
@@ -430,6 +459,14 @@ def backup(self, backup_id, database="", expire_time=None, version_time=None):
430459
consistent copy of the database. If not present, it is the same as
431460
the `create_time` of the backup.
432461
462+
:type encryption_config:
463+
:class:`~google.cloud.spanner_admin_database_v1.types.CreateBackupEncryptionConfig`
464+
or :class:`dict`
465+
:param encryption_config:
466+
(Optional) Encryption configuration for the backup.
467+
If a dict is provided, it must be of the same form as the protobuf
468+
message :class:`~google.cloud.spanner_admin_database_v1.types.CreateBackupEncryptionConfig`
469+
433470
:rtype: :class:`~google.cloud.spanner_v1.backup.Backup`
434471
:returns: a backup owned by this instance.
435472
"""
@@ -440,6 +477,7 @@ def backup(self, backup_id, database="", expire_time=None, version_time=None):
440477
database=database.name,
441478
expire_time=expire_time,
442479
version_time=version_time,
480+
encryption_config=encryption_config,
443481
)
444482
except AttributeError:
445483
return Backup(
@@ -448,6 +486,7 @@ def backup(self, backup_id, database="", expire_time=None, version_time=None):
448486
database=database,
449487
expire_time=expire_time,
450488
version_time=version_time,
489+
encryption_config=encryption_config,
451490
)
452491

453492
def list_backups(self, filter_="", page_size=None):

tests/system/test_system.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,11 @@ def test_create_invalid(self):
738738
op.result()
739739

740740
def test_backup_workflow(self):
741+
from google.cloud.spanner_admin_database_v1 import (
742+
CreateBackupEncryptionConfig,
743+
EncryptionConfig,
744+
RestoreDatabaseEncryptionConfig,
745+
)
741746
from datetime import datetime
742747
from datetime import timedelta
743748
from pytz import UTC
@@ -746,13 +751,17 @@ def test_backup_workflow(self):
746751
backup_id = "backup_id" + unique_resource_id("_")
747752
expire_time = datetime.utcnow() + timedelta(days=3)
748753
expire_time = expire_time.replace(tzinfo=UTC)
754+
encryption_config = CreateBackupEncryptionConfig(
755+
encryption_type=CreateBackupEncryptionConfig.EncryptionType.GOOGLE_DEFAULT_ENCRYPTION,
756+
)
749757

750758
# Create backup.
751759
backup = instance.backup(
752760
backup_id,
753761
database=self._db,
754762
expire_time=expire_time,
755763
version_time=self.database_version_time,
764+
encryption_config=encryption_config,
756765
)
757766
operation = backup.create()
758767
self.to_delete.append(backup)
@@ -771,6 +780,7 @@ def test_backup_workflow(self):
771780
self.assertEqual(self.database_version_time, backup.version_time)
772781
self.assertIsNotNone(backup.size_bytes)
773782
self.assertIsNotNone(backup.state)
783+
self.assertEqual(encryption_config, backup.encryption_config)
774784

775785
# Update with valid argument.
776786
valid_expire_time = datetime.utcnow() + timedelta(days=7)
@@ -780,7 +790,10 @@ def test_backup_workflow(self):
780790

781791
# Restore database to same instance.
782792
restored_id = "restored_db" + unique_resource_id("_")
783-
database = instance.database(restored_id)
793+
encryption_config = RestoreDatabaseEncryptionConfig(
794+
encryption_type=RestoreDatabaseEncryptionConfig.EncryptionType.GOOGLE_DEFAULT_ENCRYPTION,
795+
)
796+
database = instance.database(restored_id, encryption_config=encryption_config)
784797
self.to_drop.append(database)
785798
operation = database.restore(source=backup)
786799
restored_db = operation.result()
@@ -791,6 +804,9 @@ def test_backup_workflow(self):
791804

792805
metadata = operation.metadata
793806
self.assertEqual(self.database_version_time, metadata.backup_info.version_time)
807+
database.reload()
808+
expected_encryption_config = EncryptionConfig()
809+
self.assertEqual(expected_encryption_config, database.encryption_config)
794810

795811
database.drop()
796812
backup.delete()

0 commit comments

Comments
 (0)