Skip to content

Commit cc7a2ed

Browse files
committed
Merge pull request #1264 from dhermes/bigtable-parse-operation
Adding helpers to parse Bigtable create cluster operation.
2 parents f575283 + 388bf37 commit cc7a2ed

File tree

2 files changed

+266
-5
lines changed

2 files changed

+266
-5
lines changed

gcloud/bigtable/cluster.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
"""User friendly container for Google Cloud Bigtable Cluster."""
1616

1717

18+
import datetime
1819
import re
1920

21+
from gcloud._helpers import _EPOCH
2022
from gcloud.bigtable._generated import bigtable_cluster_data_pb2 as data_pb2
2123
from gcloud.bigtable._generated import (
2224
bigtable_cluster_service_messages_pb2 as messages_pb2)
@@ -26,7 +28,16 @@
2628
_CLUSTER_NAME_RE = re.compile(r'^projects/(?P<project>[^/]+)/'
2729
r'zones/(?P<zone>[^/]+)/clusters/'
2830
r'(?P<cluster_id>[a-z][-a-z0-9]*)$')
31+
_OPERATION_NAME_RE = re.compile(r'^operations/projects/([^/]+)/zones/([^/]+)/'
32+
r'clusters/([a-z][-a-z0-9]*)/operations/'
33+
r'(?P<operation_id>\d+)$')
2934
_DEFAULT_SERVE_NODES = 3
35+
_TYPE_URL_BASE = 'type.googleapis.com/google.bigtable.'
36+
_ADMIN_TYPE_URL_BASE = _TYPE_URL_BASE + 'admin.cluster.v1.'
37+
_CLUSTER_CREATE_METADATA = _ADMIN_TYPE_URL_BASE + 'CreateClusterMetadata'
38+
_TYPE_URL_MAP = {
39+
_CLUSTER_CREATE_METADATA: messages_pb2.CreateClusterMetadata,
40+
}
3041

3142

3243
def _get_pb_property_value(message_pb, property_name):
@@ -74,6 +85,74 @@ def _prepare_create_request(cluster):
7485
)
7586

7687

88+
def _pb_timestamp_to_datetime(timestamp):
89+
"""Convert a Timestamp protobuf to a datetime object.
90+
91+
:type timestamp: :class:`._generated.timestamp_pb2.Timestamp`
92+
:param timestamp: A Google returned timestamp protobuf.
93+
94+
:rtype: :class:`datetime.datetime`
95+
:returns: A UTC datetime object converted from a protobuf timestamp.
96+
"""
97+
return (
98+
_EPOCH +
99+
datetime.timedelta(
100+
seconds=timestamp.seconds,
101+
microseconds=(timestamp.nanos / 1000.0),
102+
)
103+
)
104+
105+
106+
def _parse_pb_any_to_native(any_val, expected_type=None):
107+
"""Convert a serialized "google.protobuf.Any" value to actual type.
108+
109+
:type any_val: :class:`gcloud.bigtable._generated.any_pb2.Any`
110+
:param any_val: A serialized protobuf value container.
111+
112+
:type expected_type: str
113+
:param expected_type: (Optional) The type URL we expect ``any_val``
114+
to have.
115+
116+
:rtype: object
117+
:returns: The de-serialized object.
118+
:raises: :class:`ValueError <exceptions.ValueError>` if the
119+
``expected_type`` does not match the ``type_url`` on the input.
120+
"""
121+
if expected_type is not None and expected_type != any_val.type_url:
122+
raise ValueError('Expected type: %s, Received: %s' % (
123+
expected_type, any_val.type_url))
124+
container_class = _TYPE_URL_MAP[any_val.type_url]
125+
return container_class.FromString(any_val.value)
126+
127+
128+
def _process_operation(operation_pb):
129+
"""Processes a create protobuf response.
130+
131+
:type operation_pb: :class:`operations_pb2.Operation`
132+
:param operation_pb: The long-running operation response from a
133+
Create/Update/Undelete cluster request.
134+
135+
:rtype: tuple
136+
:returns: A pair of an integer and datetime stamp. The integer is the ID
137+
of the operation (``operation_id``) and the timestamp when
138+
the create operation began (``operation_begin``).
139+
:raises: :class:`ValueError <exceptions.ValueError>` if the operation name
140+
doesn't match the :data:`_OPERATION_NAME_RE` regex.
141+
"""
142+
match = _OPERATION_NAME_RE.match(operation_pb.name)
143+
if match is None:
144+
raise ValueError('Operation name was not in the expected '
145+
'format after a cluster modification.',
146+
operation_pb.name)
147+
operation_id = int(match.group('operation_id'))
148+
149+
request_metadata = _parse_pb_any_to_native(operation_pb.metadata)
150+
operation_begin = _pb_timestamp_to_datetime(
151+
request_metadata.request_time)
152+
153+
return operation_id, operation_begin
154+
155+
77156
class Cluster(object):
78157
"""Representation of a Google Cloud Bigtable Cluster.
79158
@@ -105,7 +184,9 @@ def __init__(self, zone, cluster_id, client,
105184
self.display_name = display_name or cluster_id
106185
self.serve_nodes = serve_nodes
107186
self._client = client
108-
self._operation = None
187+
self._operation_type = None
188+
self._operation_id = None
189+
self._operation_begin = None
109190

110191
def table(self, table_id):
111192
"""Factory to create a table associated with this cluster.
@@ -217,7 +298,9 @@ def create(self):
217298
cluster_pb = self._client._cluster_stub.CreateCluster(
218299
request_pb, self._client.timeout_seconds)
219300

220-
self._operation = cluster_pb.current_operation
301+
self._operation_type = 'create'
302+
self._operation_id, self._operation_begin = _process_operation(
303+
cluster_pb.current_operation)
221304

222305
def delete(self):
223306
"""Delete this cluster.

gcloud/bigtable/test_cluster.py

Lines changed: 181 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ def test_create(self):
233233

234234
# Create response_pb
235235
op_id = 5678
236+
op_begin = object()
236237
op_name = ('operations/projects/%s/zones/%s/clusters/%s/'
237238
'operations/%d' % (project, zone, cluster_id, op_id))
238239
current_op = operations_pb2.Operation(name=op_name)
@@ -244,14 +245,22 @@ def test_create(self):
244245
# Create expected_result.
245246
expected_result = None # create() has no return value.
246247

247-
# Perform the method and check the result.
248+
# Create the mocks.
248249
prep_create_called = []
249250

250251
def mock_prep_create_req(cluster):
251252
prep_create_called.append(cluster)
252253
return request_pb
253254

254-
with _Monkey(MUT, _prepare_create_request=mock_prep_create_req):
255+
process_operation_called = []
256+
257+
def mock_process_operation(operation_pb):
258+
process_operation_called.append(operation_pb)
259+
return (op_id, op_begin)
260+
261+
# Perform the method and check the result.
262+
with _Monkey(MUT, _prepare_create_request=mock_prep_create_req,
263+
_process_operation=mock_process_operation):
255264
result = cluster.create()
256265

257266
self.assertEqual(result, expected_result)
@@ -260,8 +269,11 @@ def mock_prep_create_req(cluster):
260269
(request_pb, timeout_seconds),
261270
{},
262271
)])
263-
self.assertEqual(cluster._operation, current_op)
272+
self.assertEqual(cluster._operation_type, 'create')
273+
self.assertEqual(cluster._operation_id, op_id)
274+
self.assertTrue(cluster._operation_begin is op_begin)
264275
self.assertEqual(prep_create_called, [cluster])
276+
self.assertEqual(process_operation_called, [current_op])
265277

266278
def test_delete(self):
267279
from gcloud.bigtable._generated import (
@@ -357,6 +369,172 @@ def test_it(self):
357369
self.assertEqual(request_pb.cluster.serve_nodes, serve_nodes)
358370

359371

372+
class Test__pb_timestamp_to_datetime(unittest2.TestCase):
373+
374+
def _callFUT(self, timestamp):
375+
from gcloud.bigtable.cluster import _pb_timestamp_to_datetime
376+
return _pb_timestamp_to_datetime(timestamp)
377+
378+
def test_it(self):
379+
import datetime
380+
from gcloud._helpers import UTC
381+
from gcloud.bigtable._generated.timestamp_pb2 import Timestamp
382+
383+
# Epoch is midnight on January 1, 1970 ...
384+
dt_stamp = datetime.datetime(1970, month=1, day=1, hour=0,
385+
minute=1, second=1, microsecond=1234,
386+
tzinfo=UTC)
387+
# ... so 1 minute and 1 second after is 61 seconds and 1234
388+
# microseconds is 1234000 nanoseconds.
389+
timestamp = Timestamp(seconds=61, nanos=1234000)
390+
self.assertEqual(self._callFUT(timestamp), dt_stamp)
391+
392+
393+
class Test__parse_pb_any_to_native(unittest2.TestCase):
394+
395+
def _callFUT(self, any_val, expected_type=None):
396+
from gcloud.bigtable.cluster import _parse_pb_any_to_native
397+
return _parse_pb_any_to_native(any_val, expected_type=expected_type)
398+
399+
def test_with_known_type_url(self):
400+
from gcloud._testing import _Monkey
401+
from gcloud.bigtable._generated import any_pb2
402+
from gcloud.bigtable._generated import bigtable_data_pb2 as data_pb2
403+
from gcloud.bigtable import cluster as MUT
404+
405+
type_url = 'type.googleapis.com/' + data_pb2._CELL.full_name
406+
fake_type_url_map = {type_url: data_pb2.Cell}
407+
408+
cell = data_pb2.Cell(
409+
timestamp_micros=0,
410+
value=b'foobar',
411+
)
412+
any_val = any_pb2.Any(
413+
type_url=type_url,
414+
value=cell.SerializeToString(),
415+
)
416+
with _Monkey(MUT, _TYPE_URL_MAP=fake_type_url_map):
417+
result = self._callFUT(any_val)
418+
419+
self.assertEqual(result, cell)
420+
421+
def test_with_create_cluster_metadata(self):
422+
from gcloud.bigtable._generated import any_pb2
423+
from gcloud.bigtable._generated import (
424+
bigtable_cluster_data_pb2 as data_pb2)
425+
from gcloud.bigtable._generated import (
426+
bigtable_cluster_service_messages_pb2 as messages_pb2)
427+
from gcloud.bigtable._generated.timestamp_pb2 import Timestamp
428+
429+
type_url = ('type.googleapis.com/' +
430+
messages_pb2._CREATECLUSTERMETADATA.full_name)
431+
metadata = messages_pb2.CreateClusterMetadata(
432+
request_time=Timestamp(seconds=1, nanos=1234),
433+
finish_time=Timestamp(seconds=10, nanos=891011),
434+
original_request=messages_pb2.CreateClusterRequest(
435+
name='foo',
436+
cluster_id='bar',
437+
cluster=data_pb2.Cluster(
438+
display_name='quux',
439+
serve_nodes=1337,
440+
),
441+
),
442+
)
443+
444+
any_val = any_pb2.Any(
445+
type_url=type_url,
446+
value=metadata.SerializeToString(),
447+
)
448+
result = self._callFUT(any_val)
449+
self.assertEqual(result, metadata)
450+
451+
def test_unknown_type_url(self):
452+
from gcloud._testing import _Monkey
453+
from gcloud.bigtable._generated import any_pb2
454+
from gcloud.bigtable import cluster as MUT
455+
456+
fake_type_url_map = {}
457+
any_val = any_pb2.Any()
458+
with _Monkey(MUT, _TYPE_URL_MAP=fake_type_url_map):
459+
with self.assertRaises(KeyError):
460+
self._callFUT(any_val)
461+
462+
def test_disagreeing_type_url(self):
463+
from gcloud._testing import _Monkey
464+
from gcloud.bigtable._generated import any_pb2
465+
from gcloud.bigtable import cluster as MUT
466+
467+
type_url1 = 'foo'
468+
type_url2 = 'bar'
469+
fake_type_url_map = {type_url1: None}
470+
any_val = any_pb2.Any(type_url=type_url2)
471+
with _Monkey(MUT, _TYPE_URL_MAP=fake_type_url_map):
472+
with self.assertRaises(ValueError):
473+
self._callFUT(any_val, expected_type=type_url1)
474+
475+
476+
class Test__process_operation(unittest2.TestCase):
477+
478+
def _callFUT(self, operation_pb):
479+
from gcloud.bigtable.cluster import _process_operation
480+
return _process_operation(operation_pb)
481+
482+
def test_it(self):
483+
from gcloud._testing import _Monkey
484+
from gcloud.bigtable._generated import (
485+
bigtable_cluster_service_messages_pb2 as messages_pb2)
486+
from gcloud.bigtable._generated import operations_pb2
487+
from gcloud.bigtable import cluster as MUT
488+
489+
project = 'PROJECT'
490+
zone = 'zone'
491+
cluster_id = 'cluster-id'
492+
expected_operation_id = 234
493+
operation_name = ('operations/projects/%s/zones/%s/clusters/%s/'
494+
'operations/%d' % (project, zone, cluster_id,
495+
expected_operation_id))
496+
497+
current_op = operations_pb2.Operation(name=operation_name)
498+
499+
# Create mocks.
500+
request_metadata = messages_pb2.CreateClusterMetadata()
501+
parse_pb_any_called = []
502+
503+
def mock_parse_pb_any_to_native(any_val, expected_type=None):
504+
parse_pb_any_called.append((any_val, expected_type))
505+
return request_metadata
506+
507+
expected_operation_begin = object()
508+
ts_to_dt_called = []
509+
510+
def mock_pb_timestamp_to_datetime(timestamp):
511+
ts_to_dt_called.append(timestamp)
512+
return expected_operation_begin
513+
514+
# Exectute method with mocks in place.
515+
with _Monkey(MUT, _parse_pb_any_to_native=mock_parse_pb_any_to_native,
516+
_pb_timestamp_to_datetime=mock_pb_timestamp_to_datetime):
517+
operation_id, operation_begin = self._callFUT(current_op)
518+
519+
# Check outputs.
520+
self.assertEqual(operation_id, expected_operation_id)
521+
self.assertTrue(operation_begin is expected_operation_begin)
522+
523+
# Check mocks were used correctly.
524+
self.assertEqual(parse_pb_any_called, [(current_op.metadata, None)])
525+
self.assertEqual(ts_to_dt_called, [request_metadata.request_time])
526+
527+
def test_op_name_parsing_failure(self):
528+
from gcloud.bigtable._generated import (
529+
bigtable_cluster_data_pb2 as data_pb2)
530+
from gcloud.bigtable._generated import operations_pb2
531+
532+
current_op = operations_pb2.Operation(name='invalid')
533+
cluster = data_pb2.Cluster(current_operation=current_op)
534+
with self.assertRaises(ValueError):
535+
self._callFUT(cluster)
536+
537+
360538
class _Client(object):
361539

362540
def __init__(self, project, timeout_seconds=None):

0 commit comments

Comments
 (0)