Skip to content

Commit aeae3af

Browse files
feat(cli): improve secrets fetch to handle disabled versions
Co-Authored-By: Aaron <AJ> Steers <[email protected]>
1 parent bcfcf04 commit aeae3af

File tree

3 files changed

+271
-9
lines changed

3 files changed

+271
-9
lines changed

airbyte_cdk/cli/airbyte_cdk/_secrets.py

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
import os
3535
from functools import lru_cache
3636
from pathlib import Path
37-
from typing import Any, cast
37+
from typing import Any, List, Optional, Tuple, cast
3838

3939
import requests
4040
import rich_click as click
@@ -43,6 +43,7 @@
4343
from rich.console import Console
4444
from rich.table import Table
4545

46+
from airbyte_cdk.cli.airbyte_cdk.exceptions import ConnectorSecretWithNoValidVersionsError
4647
from airbyte_cdk.utils.connector_paths import (
4748
resolve_connector_name,
4849
resolve_connector_name_and_directory,
@@ -131,24 +132,51 @@ def fetch(
131132
)
132133
# Fetch and write secrets
133134
secret_count = 0
135+
failed_secrets = []
136+
failed_secret_urls = []
137+
134138
for secret in secrets:
135139
secret_file_path = _get_secret_filepath(
136140
secrets_dir=secrets_dir,
137141
secret=secret,
138142
)
139-
_write_secret_file(
143+
error = _write_secret_file(
140144
secret=secret,
141145
client=client,
142146
file_path=secret_file_path,
143147
)
144-
click.echo(f"Secret written to: {secret_file_path.absolute()!s}", err=True)
145-
secret_count += 1
146-
147-
if secret_count == 0:
148+
149+
if error:
150+
secret_name = secret.name.split("/secrets/")[-1] # Removes project prefix
151+
failed_secrets.append(secret_name)
152+
secret_url = f"https://console.cloud.google.com/security/secret-manager/secret/{secret_name}/versions?hl=en&project={gcp_project_id}"
153+
failed_secret_urls.append(secret_url)
154+
click.echo(f"Failed to retrieve secret '{secret_name}': {error}", err=True)
155+
else:
156+
click.echo(f"Secret written to: {secret_file_path.absolute()!s}", err=True)
157+
secret_count += 1
158+
159+
if secret_count == 0 and not failed_secrets:
148160
click.echo(
149161
f"No secrets found for connector: '{connector_name}'",
150162
err=True,
151163
)
164+
165+
if failed_secrets:
166+
error_message = f"Failed to retrieve {len(failed_secrets)} secret(s)"
167+
click.echo(
168+
style(
169+
error_message,
170+
fg="red",
171+
),
172+
err=True,
173+
)
174+
if secret_count == 0:
175+
raise ConnectorSecretWithNoValidVersionsError(
176+
connector_name=connector_name,
177+
secret_names=failed_secrets,
178+
connector_secret_urls=failed_secret_urls,
179+
)
152180

153181
if not print_ci_secrets_masks:
154182
return
@@ -272,11 +300,37 @@ def _write_secret_file(
272300
secret: "Secret", # type: ignore
273301
client: "secretmanager.SecretManagerServiceClient", # type: ignore
274302
file_path: Path,
275-
) -> None:
276-
version_name = f"{secret.name}/versions/latest"
277-
response = client.access_secret_version(name=version_name)
303+
) -> Optional[str]:
304+
"""Write the most recent enabled version of a secret to a file.
305+
306+
Lists all enabled versions of the secret and selects the most recent one.
307+
Returns an error message if no enabled versions are found.
308+
309+
Args:
310+
secret: The secret to write to a file
311+
client: The Secret Manager client
312+
file_path: The path to write the secret to
313+
314+
Returns:
315+
Optional[str]: Error message if no enabled version is found, None otherwise
316+
"""
317+
# List all enabled versions of the secret
318+
response = client.list_secret_versions(
319+
request={"parent": secret.name, "filter": "state:ENABLED"}
320+
)
321+
322+
versions = list(response)
323+
324+
if not versions:
325+
secret_name = secret.name.split("/secrets/")[-1] # Removes project prefix
326+
return f"No enabled version found for secret: {secret_name}"
327+
328+
enabled_version = versions[0]
329+
330+
response = client.access_secret_version(name=enabled_version.name)
278331
file_path.write_text(response.payload.data.decode("UTF-8"))
279332
file_path.chmod(0o600) # default to owner read/write only
333+
return None
280334

281335

282336
def _get_secrets_dir(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2+
"""Exceptions for the Airbyte CDK CLI."""
3+
4+
from dataclasses import dataclass
5+
from typing import List
6+
7+
from airbyte_cdk.sql.exceptions import AirbyteConnectorError
8+
9+
10+
@dataclass
11+
class ConnectorSecretWithNoValidVersionsError(AirbyteConnectorError):
12+
"""Error when a connector secret has no valid versions."""
13+
14+
connector_name: str
15+
secret_names: List[str] = None # type: ignore
16+
connector_secret_urls: List[str] = None # type: ignore
17+
18+
def __post_init__(self):
19+
"""Initialize default values for lists."""
20+
super().__post_init__()
21+
if self.secret_names is None:
22+
self.secret_names = []
23+
if self.connector_secret_urls is None:
24+
self.connector_secret_urls = []
25+
26+
def __str__(self) -> str:
27+
"""Return a string representation of the exception."""
28+
urls_str = "\n".join([f"- {url}" for url in self.connector_secret_urls])
29+
secrets_str = ", ".join(self.secret_names)
30+
return (
31+
f"No valid versions found for the following secrets in connector '{self.connector_name}': {secrets_str}. "
32+
f"Please check the following URLs for more information:\n{urls_str}"
33+
)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
from click.testing import CliRunner
8+
9+
from airbyte_cdk.cli.airbyte_cdk._secrets import (
10+
_write_secret_file,
11+
fetch,
12+
secretmanager,
13+
)
14+
from airbyte_cdk.cli.airbyte_cdk.exceptions import ConnectorSecretWithNoValidVersionsError
15+
16+
17+
class TestWriteSecretFile:
18+
@pytest.fixture
19+
def mock_client(self):
20+
return MagicMock()
21+
22+
@pytest.fixture
23+
def mock_secret(self):
24+
secret = MagicMock()
25+
secret.name = "projects/test-project/secrets/test-secret"
26+
return secret
27+
28+
@pytest.fixture
29+
def mock_file_path(self, tmp_path):
30+
return tmp_path / "test_secret.json"
31+
32+
def test_write_secret_file_with_enabled_version(self, mock_client, mock_secret, mock_file_path):
33+
# Mock list_secret_versions to return an enabled version
34+
mock_version = MagicMock()
35+
mock_version.name = f"{mock_secret.name}/versions/1"
36+
mock_client.list_secret_versions.return_value = [mock_version]
37+
38+
# Mock access_secret_version to return a payload
39+
mock_response = MagicMock()
40+
mock_response.payload.data.decode.return_value = '{"key": "value"}'
41+
mock_client.access_secret_version.return_value = mock_response
42+
43+
# Call the function
44+
result = _write_secret_file(mock_secret, mock_client, mock_file_path)
45+
46+
# Verify that list_secret_versions was called with the correct parameters
47+
mock_client.list_secret_versions.assert_called_once()
48+
assert "state:ENABLED" in str(mock_client.list_secret_versions.call_args)
49+
50+
# Verify that access_secret_version was called with the correct version
51+
mock_client.access_secret_version.assert_called_once_with(name=mock_version.name)
52+
53+
# Verify that the file was created with the correct content
54+
assert mock_file_path.read_text() == '{"key": "value"}'
55+
56+
# Verify that no error was returned
57+
assert result is None
58+
59+
def test_write_secret_file_with_no_enabled_versions(self, mock_client, mock_secret, mock_file_path):
60+
# Mock list_secret_versions to return an empty list (no enabled versions)
61+
mock_client.list_secret_versions.return_value = []
62+
63+
# Call the function
64+
result = _write_secret_file(mock_secret, mock_client, mock_file_path)
65+
66+
# Verify that list_secret_versions was called with the correct parameters
67+
mock_client.list_secret_versions.assert_called_once()
68+
assert "state:ENABLED" in str(mock_client.list_secret_versions.call_args)
69+
70+
# Verify that access_secret_version was not called
71+
mock_client.access_secret_version.assert_not_called()
72+
73+
# Verify that the file was not created
74+
assert not mock_file_path.exists()
75+
76+
# Verify that an error was returned
77+
assert result is not None
78+
assert "No enabled version found for secret" in result
79+
assert "test-secret" in result
80+
81+
82+
@patch("airbyte_cdk.cli.airbyte_cdk._secrets._get_gsm_secrets_client")
83+
@patch("airbyte_cdk.cli.airbyte_cdk._secrets.resolve_connector_name_and_directory")
84+
@patch("airbyte_cdk.cli.airbyte_cdk._secrets._get_secrets_dir")
85+
@patch("airbyte_cdk.cli.airbyte_cdk._secrets._fetch_secret_handles")
86+
class TestFetch:
87+
def test_fetch_with_some_failed_secrets(
88+
self, mock_fetch_secret_handles, mock_get_secrets_dir, mock_resolve, mock_get_client, tmp_path
89+
):
90+
# Setup mocks
91+
mock_client = MagicMock()
92+
mock_get_client.return_value = mock_client
93+
94+
mock_resolve.return_value = ("test-connector", tmp_path)
95+
96+
secrets_dir = tmp_path / "secrets"
97+
mock_get_secrets_dir.return_value = secrets_dir
98+
99+
# Create two secrets, one that will succeed and one that will fail
100+
secret1 = MagicMock()
101+
secret1.name = "projects/test-project/secrets/test-secret-1"
102+
secret1.labels = {}
103+
104+
secret2 = MagicMock()
105+
secret2.name = "projects/test-project/secrets/test-secret-2"
106+
secret2.labels = {}
107+
108+
mock_fetch_secret_handles.return_value = [secret1, secret2]
109+
110+
# Mock _write_secret_file to succeed for secret1 and fail for secret2
111+
with patch("airbyte_cdk.cli.airbyte_cdk._secrets._write_secret_file") as mock_write_secret_file:
112+
mock_write_secret_file.side_effect = [
113+
None, # Success for secret1
114+
"No enabled version found for secret: test-secret-2", # Failure for secret2
115+
]
116+
117+
# Call the function
118+
runner = CliRunner()
119+
result = runner.invoke(fetch)
120+
121+
# Verify that _write_secret_file was called twice
122+
assert mock_write_secret_file.call_count == 2
123+
124+
# Verify that the error message was printed
125+
assert "Failed to retrieve secret 'test-secret-2'" in result.output
126+
assert "Failed to retrieve 1 secret(s)" in result.output
127+
128+
# Verify that the function did not raise an exception
129+
assert result.exit_code == 0
130+
131+
def test_fetch_with_all_failed_secrets(
132+
self, mock_fetch_secret_handles, mock_get_secrets_dir, mock_resolve, mock_get_client, tmp_path
133+
):
134+
# Setup mocks
135+
mock_client = MagicMock()
136+
mock_get_client.return_value = mock_client
137+
138+
mock_resolve.return_value = ("test-connector", tmp_path)
139+
140+
secrets_dir = tmp_path / "secrets"
141+
mock_get_secrets_dir.return_value = secrets_dir
142+
143+
# Create two secrets that will both fail
144+
secret1 = MagicMock()
145+
secret1.name = "projects/test-project/secrets/test-secret-1"
146+
secret1.labels = {}
147+
148+
secret2 = MagicMock()
149+
secret2.name = "projects/test-project/secrets/test-secret-2"
150+
secret2.labels = {}
151+
152+
mock_fetch_secret_handles.return_value = [secret1, secret2]
153+
154+
# Mock _write_secret_file to fail for both secrets
155+
with patch("airbyte_cdk.cli.airbyte_cdk._secrets._write_secret_file") as mock_write_secret_file:
156+
mock_write_secret_file.side_effect = [
157+
"No enabled version found for secret: test-secret-1", # Failure for secret1
158+
"No enabled version found for secret: test-secret-2", # Failure for secret2
159+
]
160+
161+
# Call the function
162+
runner = CliRunner()
163+
result = runner.invoke(fetch)
164+
165+
# Verify that _write_secret_file was called twice
166+
assert mock_write_secret_file.call_count == 2
167+
168+
# Verify that the error message was printed
169+
assert "Failed to retrieve secret 'test-secret-1'" in result.output
170+
assert "Failed to retrieve secret 'test-secret-2'" in result.output
171+
assert "Failed to retrieve 2 secret(s)" in result.output
172+
173+
# Verify that the function raised an exception
174+
assert result.exit_code != 0
175+

0 commit comments

Comments
 (0)