Skip to content

Commit f9a1224

Browse files
Add regional support for google secret manager hook (#52124)
* Add regional support for google secret manager hook * Change property name from location_id to location * Remove backward compatibility comment. * Fix static check failing
1 parent 3391ba2 commit f9a1224

File tree

2 files changed

+166
-17
lines changed

2 files changed

+166
-17
lines changed

providers/google/src/airflow/providers/google/cloud/hooks/secret_manager.py

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,27 @@ class GoogleCloudSecretManagerHook(GoogleBaseHook):
4747
See https://cloud.google.com/secret-manager
4848
"""
4949

50+
def __init__(self, location: str | None = None, **kwargs) -> None:
51+
super().__init__(**kwargs)
52+
self.location = location
53+
5054
@cached_property
5155
def client(self):
5256
"""
5357
Create a Secret Manager Client.
5458
5559
:return: Secret Manager client.
5660
"""
57-
return SecretManagerServiceClient(credentials=self.get_credentials(), client_info=CLIENT_INFO)
61+
if self.location is not None:
62+
return SecretManagerServiceClient(
63+
credentials=self.get_credentials(),
64+
client_info=CLIENT_INFO,
65+
client_options={"api_endpoint": f"secretmanager.{self.location}.rep.googleapis.com"},
66+
)
67+
return SecretManagerServiceClient(
68+
credentials=self.get_credentials(),
69+
client_info=CLIENT_INFO,
70+
)
5871

5972
def get_conn(self) -> SecretManagerServiceClient:
6073
"""
@@ -64,6 +77,60 @@ def get_conn(self) -> SecretManagerServiceClient:
6477
"""
6578
return self.client
6679

80+
def _get_parent(self, project_id: str, location: str | None = None) -> str:
81+
"""
82+
Return parent path.
83+
84+
:param project_id: Required. ID of the GCP project that owns the job.
85+
:param location: Optional. Target location. If set to ``None`` or missing, the location provided for GoogleCloudSecretHook instantiation is used
86+
For more details : https://cloud.google.com/secret-manager/docs/locations
87+
88+
:return: Parent path.
89+
"""
90+
_location = location or self.location
91+
if _location is not None:
92+
return self.client.common_location_path(project_id, _location)
93+
return self.client.common_project_path(project_id)
94+
95+
def _get_secret_path(self, project_id: str, secret_id: str, location: str | None = None) -> str:
96+
"""
97+
Return secret path.
98+
99+
:param project_id: Required. ID of the GCP project that owns the job.
100+
:param secret_id: Required. Secret ID for which path is required.
101+
:param location: Optional. Target location. If set to ``None`` or missing, the location provided for GoogleCloudSecretHook instantiation is used
102+
For more details : https://cloud.google.com/secret-manager/docs/locations
103+
104+
:return: Parent path.
105+
"""
106+
_location = location or self.location
107+
if _location is not None:
108+
# Google's client library does not provide a method to construct regional secret paths, so constructing manually.
109+
return f"projects/{project_id}/locations/{_location}/secrets/{secret_id}"
110+
return self.client.secret_path(project_id, secret_id)
111+
112+
def _get_secret_version_path(
113+
self, project_id: str, secret_id: str, secret_version: str, location: str | None = None
114+
) -> str:
115+
"""
116+
Return secret version path.
117+
118+
:param project_id: Required. ID of the GCP project that owns the job.
119+
:param secret_id: Required. Secret ID for which path is required.
120+
:param secret_version: Required. Secret version for which path is required.
121+
:param location: Optional. Target location. If set to ``None`` or missing, the location provided for GoogleCloudSecretHook instantiation is used
122+
For more details : https://cloud.google.com/secret-manager/docs/locations
123+
124+
:return: Parent path.
125+
"""
126+
_location = location or self.location
127+
if _location is not None:
128+
# Google's client library does not provide a method to construct regional secret version paths, so constructing manually.
129+
return (
130+
f"projects/{project_id}/locations/{_location}/secrets/{secret_id}/versions/{secret_version}"
131+
)
132+
return self.client.secret_version_path(project_id, secret_id, secret_version)
133+
67134
@GoogleBaseHook.fallback_to_default_project_id
68135
def create_secret(
69136
self,
@@ -73,6 +140,7 @@ def create_secret(
73140
retry: Retry | _MethodDefault = DEFAULT,
74141
timeout: float | None = None,
75142
metadata: Sequence[tuple[str, str]] = (),
143+
location: str | None = None,
76144
) -> Secret:
77145
"""
78146
Create a secret.
@@ -88,12 +156,20 @@ def create_secret(
88156
:param retry: Optional. Designation of what errors, if any, should be retried.
89157
:param timeout: Optional. The timeout for this request.
90158
:param metadata: Optional. Strings which should be sent along with the request as metadata.
159+
:param location: Optional. Location where secret should be created. Used for creating regional secret. If set to ``None`` or missing, the location provided for GoogleCloudSecretHook instantiation is used
160+
For more details : https://cloud.google.com/secret-manager/docs/locations
91161
:return: Secret object.
92162
"""
93-
_secret = secret or {"replication": {"automatic": {}}}
163+
if not secret:
164+
_secret: dict | Secret = {}
165+
if (location or self.location) is None:
166+
_secret["replication"] = {"automatic": {}}
167+
else:
168+
_secret = secret
169+
94170
response = self.client.create_secret(
95171
request={
96-
"parent": f"projects/{project_id}",
172+
"parent": self._get_parent(project_id, location),
97173
"secret_id": secret_id,
98174
"secret": _secret,
99175
},
@@ -113,6 +189,7 @@ def add_secret_version(
113189
retry: Retry | _MethodDefault = DEFAULT,
114190
timeout: float | None = None,
115191
metadata: Sequence[tuple[str, str]] = (),
192+
location: str | None = None,
116193
) -> SecretVersion:
117194
"""
118195
Add a version to the secret.
@@ -128,11 +205,13 @@ def add_secret_version(
128205
:param retry: Optional. Designation of what errors, if any, should be retried.
129206
:param timeout: Optional. The timeout for this request.
130207
:param metadata: Optional. Strings which should be sent along with the request as metadata.
208+
:param location: Optional. Location where secret is located. Used for adding version to regional secret. If set to ``None`` or missing, the location provided for GoogleCloudSecretHook instantiation is used
209+
For more details : https://cloud.google.com/secret-manager/docs/locations
131210
:return: Secret version object.
132211
"""
133212
response = self.client.add_secret_version(
134213
request={
135-
"parent": f"projects/{project_id}/secrets/{secret_id}",
214+
"parent": self._get_secret_path(project_id, secret_id, location),
136215
"payload": secret_payload,
137216
},
138217
retry=retry,
@@ -152,6 +231,7 @@ def list_secrets(
152231
retry: Retry | _MethodDefault = DEFAULT,
153232
timeout: float | None = None,
154233
metadata: Sequence[tuple[str, str]] = (),
234+
location: str | None = None,
155235
) -> ListSecretsPager:
156236
"""
157237
List secrets.
@@ -168,11 +248,13 @@ def list_secrets(
168248
:param retry: Optional. Designation of what errors, if any, should be retried.
169249
:param timeout: Optional. The timeout for this request.
170250
:param metadata: Optional. Strings which should be sent along with the request as metadata.
251+
:param location: Optional. The regional secrets stored in the provided location will be listed. If set to ``None`` or missing, the location provided for GoogleCloudSecretHook instantiation is used
252+
For more details : https://cloud.google.com/secret-manager/docs/locations
171253
:return: Secret List object.
172254
"""
173255
response = self.client.list_secrets(
174256
request={
175-
"parent": f"projects/{project_id}",
257+
"parent": self._get_parent(project_id, location),
176258
"page_size": page_size,
177259
"page_token": page_token,
178260
"filter": secret_filter,
@@ -185,18 +267,22 @@ def list_secrets(
185267
return response
186268

187269
@GoogleBaseHook.fallback_to_default_project_id
188-
def secret_exists(self, project_id: str, secret_id: str) -> bool:
270+
def secret_exists(self, project_id: str, secret_id: str, location: str | None = None) -> bool:
189271
"""
190272
Check whether secret exists.
191273
192274
:param project_id: Required. ID of the GCP project that owns the job.
193275
If set to ``None`` or missing, the default project_id from the GCP connection is used.
194276
:param secret_id: Required. ID of the secret to find.
277+
:param location: Optional. Location where secret is expected to be stored regionally. If set to ``None`` or missing, the location provided for GoogleCloudSecretHook instantiation is used
278+
For more details : https://cloud.google.com/secret-manager/docs/locations
195279
:return: True if the secret exists, False otherwise.
196280
"""
197281
secret_filter = f"name:{secret_id}"
198-
secret_name = self.client.secret_path(project_id, secret_id)
199-
for secret in self.list_secrets(project_id=project_id, page_size=100, secret_filter=secret_filter):
282+
secret_name = self._get_secret_path(project_id, secret_id, location)
283+
for secret in self.list_secrets(
284+
project_id=project_id, page_size=100, secret_filter=secret_filter, location=location
285+
):
200286
if secret.name.split("/")[-1] == secret_id:
201287
self.log.info("Secret %s exists.", secret_name)
202288
return True
@@ -212,6 +298,7 @@ def access_secret(
212298
retry: Retry | _MethodDefault = DEFAULT,
213299
timeout: float | None = None,
214300
metadata: Sequence[tuple[str, str]] = (),
301+
location: str | None = None,
215302
) -> AccessSecretVersionResponse:
216303
"""
217304
Access a secret version.
@@ -227,11 +314,13 @@ def access_secret(
227314
:param retry: Optional. Designation of what errors, if any, should be retried.
228315
:param timeout: Optional. The timeout for this request.
229316
:param metadata: Optional. Strings which should be sent along with the request as metadata.
317+
:param location: Optional. Location where secret is stored regionally. If set to ``None`` or missing, the location provided for GoogleCloudSecretHook instantiation is used
318+
For more details : https://cloud.google.com/secret-manager/docs/locations
230319
:return: Access secret version response object.
231320
"""
232321
response = self.client.access_secret_version(
233322
request={
234-
"name": self.client.secret_version_path(project_id, secret_id, secret_version),
323+
"name": self._get_secret_version_path(project_id, secret_id, secret_version, location),
235324
},
236325
retry=retry,
237326
timeout=timeout,
@@ -248,6 +337,7 @@ def delete_secret(
248337
retry: Retry | _MethodDefault = DEFAULT,
249338
timeout: float | None = None,
250339
metadata: Sequence[tuple[str, str]] = (),
340+
location: str | None = None,
251341
) -> None:
252342
"""
253343
Delete a secret.
@@ -262,9 +352,11 @@ def delete_secret(
262352
:param retry: Optional. Designation of what errors, if any, should be retried.
263353
:param timeout: Optional. The timeout for this request.
264354
:param metadata: Optional. Strings which should be sent along with the request as metadata.
355+
:param location: Optional. Location where secret is stored regionally. If set to ``None`` or missing, the location provided for GoogleCloudSecretHook instantiation is used.
356+
For more details : https://cloud.google.com/secret-manager/docs/locations
265357
:return: Access secret version response object.
266358
"""
267-
name = self.client.secret_path(project_id, secret_id)
359+
name = self._get_secret_path(project_id, secret_id, location)
268360
self.client.delete_secret(
269361
request={"name": name},
270362
retry=retry,

providers/google/tests/unit/google/cloud/hooks/test_secret_manager.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,61 @@
2929
BASE_PACKAGE = "airflow.providers.google.common.hooks.base_google."
3030
SECRETS_HOOK_PACKAGE = "airflow.providers.google.cloud.hooks.secret_manager."
3131
SECRET_ID = "test-secret-id"
32+
REGIONAL_SECRET_LOCATION = "test-location"
33+
SECRET_VERSION = "test-secret-version"
3234

3335

3436
class TestGoogleCloudSecretManagerHook:
3537
def setup_method(self, method):
3638
with patch(f"{BASE_PACKAGE}GoogleBaseHook.get_connection", return_value=MagicMock()):
3739
self.hook = GoogleCloudSecretManagerHook()
3840

41+
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook.client", new_callable=PropertyMock)
42+
def test__get_parent(self, mock_client):
43+
expected_value = mock_client.return_value.common_project_path.return_value
44+
parent = self.hook._get_parent(GCP_PROJECT_ID_HOOK_UNIT_TEST)
45+
assert expected_value == parent
46+
mock_client.assert_called_once()
47+
mock_client.return_value.common_project_path.assert_called_once_with(GCP_PROJECT_ID_HOOK_UNIT_TEST)
48+
49+
expected_value = mock_client.return_value.common_location_path.return_value
50+
parent = self.hook._get_parent(GCP_PROJECT_ID_HOOK_UNIT_TEST, REGIONAL_SECRET_LOCATION)
51+
assert expected_value == parent
52+
# mock_client.assert_called_once() # will fail as already client has been triggered due to previous case.
53+
mock_client.return_value.common_location_path.assert_called_once_with(
54+
GCP_PROJECT_ID_HOOK_UNIT_TEST, REGIONAL_SECRET_LOCATION
55+
)
56+
57+
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook.client", new_callable=PropertyMock)
58+
def test__get_secret_path(self, mock_client):
59+
expected_value = mock_client.return_value.secret_path.return_value
60+
secret_path = self.hook._get_secret_path(GCP_PROJECT_ID_HOOK_UNIT_TEST, SECRET_ID)
61+
assert expected_value == secret_path
62+
mock_client.assert_called_once()
63+
mock_client.return_value.secret_path.assert_called_once_with(GCP_PROJECT_ID_HOOK_UNIT_TEST, SECRET_ID)
64+
65+
expected_value = f"projects/{GCP_PROJECT_ID_HOOK_UNIT_TEST}/locations/{REGIONAL_SECRET_LOCATION}/secrets/{SECRET_ID}"
66+
parent = self.hook._get_secret_path(
67+
GCP_PROJECT_ID_HOOK_UNIT_TEST, SECRET_ID, REGIONAL_SECRET_LOCATION
68+
)
69+
assert expected_value == parent
70+
71+
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook.client", new_callable=PropertyMock)
72+
def test__get_secret_version_path(self, mock_client):
73+
expected_value = mock_client.return_value.secret_version_path.return_value
74+
parent = self.hook._get_secret_version_path(GCP_PROJECT_ID_HOOK_UNIT_TEST, SECRET_ID, SECRET_VERSION)
75+
assert expected_value == parent
76+
mock_client.assert_called_once()
77+
mock_client.return_value.secret_version_path.assert_called_once_with(
78+
GCP_PROJECT_ID_HOOK_UNIT_TEST, SECRET_ID, SECRET_VERSION
79+
)
80+
81+
expected_value = f"projects/{GCP_PROJECT_ID_HOOK_UNIT_TEST}/locations/{REGIONAL_SECRET_LOCATION}/secrets/{SECRET_ID}/versions/{SECRET_VERSION}"
82+
parent = self.hook._get_secret_version_path(
83+
GCP_PROJECT_ID_HOOK_UNIT_TEST, SECRET_ID, SECRET_VERSION, REGIONAL_SECRET_LOCATION
84+
)
85+
assert expected_value == parent
86+
3987
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook.get_credentials")
4088
@patch(f"{SECRETS_HOOK_PACKAGE}SecretManagerServiceClient")
4189
def test_client(self, mock_client, mock_get_credentials):
@@ -66,9 +114,10 @@ def test_get_conn(self, mock_client):
66114
(mock_secret := MagicMock(), mock_secret), # type: ignore[name-defined]
67115
],
68116
)
117+
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook._get_parent")
69118
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook.client", new_callable=PropertyMock)
70-
def test_create_secret(self, mock_client, input_secret, expected_secret):
71-
expected_parent = f"projects/{GCP_PROJECT_ID_HOOK_UNIT_TEST}"
119+
def test_create_secret(self, mock_client, mock_get_parent, input_secret, expected_secret):
120+
expected_parent = mock_get_parent.return_value # f"projects/{GCP_PROJECT_ID_HOOK_UNIT_TEST}"
72121
expected_response = mock_client.return_value.create_secret.return_value
73122
mock_retry, mock_timeout, mock_metadata = MagicMock(), MagicMock(), MagicMock()
74123

@@ -83,6 +132,7 @@ def test_create_secret(self, mock_client, input_secret, expected_secret):
83132

84133
assert actual_response == expected_response
85134
mock_client.assert_called_once()
135+
mock_get_parent.assert_called_once_with(GCP_PROJECT_ID_HOOK_UNIT_TEST, None)
86136
mock_client.return_value.create_secret.assert_called_once_with(
87137
request={
88138
"parent": expected_parent,
@@ -94,9 +144,10 @@ def test_create_secret(self, mock_client, input_secret, expected_secret):
94144
metadata=mock_metadata,
95145
)
96146

147+
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook._get_secret_path")
97148
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook.client", new_callable=PropertyMock)
98-
def test_add_secret_version(self, mock_client):
99-
expected_parent = f"projects/{GCP_PROJECT_ID_HOOK_UNIT_TEST}/secrets/{SECRET_ID}"
149+
def test_add_secret_version(self, mock_client, mock_get_secret_path):
150+
expected_parent = mock_get_secret_path.return_value
100151
expected_response = mock_client.return_value.add_secret_version.return_value
101152
mock_payload, mock_retry, mock_timeout, mock_metadata = (MagicMock() for _ in range(4))
102153

@@ -111,6 +162,7 @@ def test_add_secret_version(self, mock_client):
111162

112163
assert actual_response == expected_response
113164
mock_client.assert_called_once()
165+
mock_get_secret_path.assert_called_once_with(GCP_PROJECT_ID_HOOK_UNIT_TEST, SECRET_ID, None)
114166
mock_client.return_value.add_secret_version.assert_called_once_with(
115167
request={
116168
"parent": expected_parent,
@@ -121,9 +173,10 @@ def test_add_secret_version(self, mock_client):
121173
metadata=mock_metadata,
122174
)
123175

176+
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook._get_parent")
124177
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook.client", new_callable=PropertyMock)
125-
def test_list_secrets(self, mock_client):
126-
expected_parent = f"projects/{GCP_PROJECT_ID_HOOK_UNIT_TEST}"
178+
def test_list_secrets(self, mock_client, mock_get_parent):
179+
expected_parent = mock_get_parent.return_value
127180
expected_response = mock_client.return_value.list_secrets.return_value
128181
mock_filter, mock_retry, mock_timeout, mock_metadata = (MagicMock() for _ in range(4))
129182
page_size, page_token = 20, "test-page-token"
@@ -140,6 +193,7 @@ def test_list_secrets(self, mock_client):
140193

141194
assert actual_response == expected_response
142195
mock_client.assert_called_once()
196+
mock_get_parent.assert_called_once_with(GCP_PROJECT_ID_HOOK_UNIT_TEST, None)
143197
mock_client.return_value.list_secrets.assert_called_once_with(
144198
request={
145199
"parent": expected_parent,
@@ -184,7 +238,10 @@ def test_secret_exists(
184238
assert secret_exists_actual == secret_exists_expected
185239
mock_client.return_value.secret_path.assert_called_once_with(GCP_PROJECT_ID_HOOK_UNIT_TEST, secret_id)
186240
mock_list_secrets.assert_called_once_with(
187-
project_id=GCP_PROJECT_ID_HOOK_UNIT_TEST, page_size=100, secret_filter=secret_filter
241+
project_id=GCP_PROJECT_ID_HOOK_UNIT_TEST,
242+
page_size=100,
243+
secret_filter=secret_filter,
244+
location=None,
188245
)
189246

190247
@patch(f"{SECRETS_HOOK_PACKAGE}GoogleCloudSecretManagerHook.client", new_callable=PropertyMock)

0 commit comments

Comments
 (0)