Skip to content

Commit 5d9cfff

Browse files
feat(cli): improve secrets fetch to handle disabled versions (#540)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Aaron <AJ> Steers <[email protected]>
1 parent bcfcf04 commit 5d9cfff

File tree

3 files changed

+343
-13
lines changed

3 files changed

+343
-13
lines changed

airbyte_cdk/cli/airbyte_cdk/_secrets.py

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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,46 @@ def fetch(
131132
)
132133
# Fetch and write secrets
133134
secret_count = 0
135+
exceptions = []
136+
134137
for secret in secrets:
135138
secret_file_path = _get_secret_filepath(
136139
secrets_dir=secrets_dir,
137140
secret=secret,
138141
)
139-
_write_secret_file(
140-
secret=secret,
141-
client=client,
142-
file_path=secret_file_path,
142+
try:
143+
_write_secret_file(
144+
secret=secret,
145+
client=client,
146+
file_path=secret_file_path,
147+
connector_name=connector_name,
148+
gcp_project_id=gcp_project_id,
149+
)
150+
click.echo(f"Secret written to: {secret_file_path.absolute()!s}", err=True)
151+
secret_count += 1
152+
except ConnectorSecretWithNoValidVersionsError as e:
153+
exceptions.append(e)
154+
click.echo(
155+
f"Failed to retrieve secret '{e.secret_name}': No enabled version found", err=True
156+
)
157+
158+
if secret_count == 0 and not exceptions:
159+
click.echo(
160+
f"No secrets found for connector: '{connector_name}'",
161+
err=True,
143162
)
144-
click.echo(f"Secret written to: {secret_file_path.absolute()!s}", err=True)
145-
secret_count += 1
146163

147-
if secret_count == 0:
164+
if exceptions:
165+
error_message = f"Failed to retrieve {len(exceptions)} secret(s)"
148166
click.echo(
149-
f"No secrets found for connector: '{connector_name}'",
167+
style(
168+
error_message,
169+
fg="red",
170+
),
150171
err=True,
151172
)
173+
if secret_count == 0:
174+
raise exceptions[0]
152175

153176
if not print_ci_secrets_masks:
154177
return
@@ -230,9 +253,8 @@ def list_(
230253
table.add_column("Created", justify="left", style="blue", overflow="fold")
231254
for secret in secrets:
232255
full_secret_name = secret.name
233-
secret_name = full_secret_name.split("/secrets/")[-1] # Removes project prefix
234-
# E.g. https://console.cloud.google.com/security/secret-manager/secret/SECRET_SOURCE-SHOPIFY__CREDS/versions?hl=en&project=<gcp_project_id>
235-
secret_url = f"https://console.cloud.google.com/security/secret-manager/secret/{secret_name}/versions?hl=en&project={gcp_project_id}"
256+
secret_name = _extract_secret_name(full_secret_name)
257+
secret_url = _get_secret_url(secret_name, gcp_project_id)
236258
table.add_row(
237259
f"[link={secret_url}]{secret_name}[/link]",
238260
"\n".join([f"{k}={v}" for k, v in secret.labels.items()]),
@@ -242,6 +264,43 @@ def list_(
242264
console.print(table)
243265

244266

267+
def _extract_secret_name(secret_name: str) -> str:
268+
"""Extract the secret name from a fully qualified secret path.
269+
270+
Handles different formats of secret names:
271+
- Full path: "projects/project-id/secrets/SECRET_NAME"
272+
- Already extracted: "SECRET_NAME"
273+
274+
Args:
275+
secret_name: The secret name or path
276+
277+
Returns:
278+
str: The extracted secret name without project prefix
279+
"""
280+
if "/secrets/" in secret_name:
281+
return secret_name.split("/secrets/")[-1]
282+
return secret_name
283+
284+
285+
def _get_secret_url(secret_name: str, gcp_project_id: str) -> str:
286+
"""Generate a URL for a secret in the GCP Secret Manager console.
287+
288+
Note: This URL itself does not contain secrets or sensitive information.
289+
The URL itself is only useful for valid logged-in users of the project, and it
290+
safe to print this URL in logs.
291+
292+
Args:
293+
secret_name: The name of the secret in GCP.
294+
gcp_project_id: The GCP project ID.
295+
296+
Returns:
297+
str: URL to the secret in the GCP console
298+
"""
299+
# Ensure we have just the secret name without the project prefix
300+
secret_name = _extract_secret_name(secret_name)
301+
return f"https://console.cloud.google.com/security/secret-manager/secret/{secret_name}/versions?hl=en&project={gcp_project_id}"
302+
303+
245304
def _fetch_secret_handles(
246305
connector_name: str,
247306
gcp_project_id: str = AIRBYTE_INTERNAL_GCP_PROJECT,
@@ -272,9 +331,44 @@ def _write_secret_file(
272331
secret: "Secret", # type: ignore
273332
client: "secretmanager.SecretManagerServiceClient", # type: ignore
274333
file_path: Path,
334+
connector_name: str,
335+
gcp_project_id: str,
275336
) -> None:
276-
version_name = f"{secret.name}/versions/latest"
277-
response = client.access_secret_version(name=version_name)
337+
"""Write the most recent enabled version of a secret to a file.
338+
339+
Lists all enabled versions of the secret and selects the most recent one.
340+
Raises ConnectorSecretWithNoValidVersionsError if no enabled versions are found.
341+
342+
Args:
343+
secret: The secret to write to a file
344+
client: The Secret Manager client
345+
file_path: The path to write the secret to
346+
connector_name: The name of the connector
347+
gcp_project_id: The GCP project ID
348+
349+
Raises:
350+
ConnectorSecretWithNoValidVersionsError: If no enabled version is found
351+
"""
352+
# List all enabled versions of the secret.
353+
response = client.list_secret_versions(
354+
request={"parent": secret.name, "filter": "state:ENABLED"}
355+
)
356+
357+
# The API returns versions pre-sorted in descending order, with the
358+
# 0th item being the latest version.
359+
versions = list(response)
360+
361+
if not versions:
362+
secret_name = _extract_secret_name(secret.name)
363+
raise ConnectorSecretWithNoValidVersionsError(
364+
connector_name=connector_name,
365+
secret_name=secret_name,
366+
gcp_project_id=gcp_project_id,
367+
)
368+
369+
enabled_version = versions[0]
370+
371+
response = client.access_secret_version(name=enabled_version.name)
278372
file_path.write_text(response.payload.data.decode("UTF-8"))
279373
file_path.chmod(0o600) # default to owner read/write only
280374

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2+
"""Exceptions for the Airbyte CDK CLI."""
3+
4+
from dataclasses import dataclass
5+
6+
7+
@dataclass(kw_only=True)
8+
class ConnectorSecretWithNoValidVersionsError(Exception):
9+
"""Error when a connector secret has no valid versions."""
10+
11+
connector_name: str
12+
secret_name: str
13+
gcp_project_id: str
14+
15+
def __str__(self) -> str:
16+
"""Return a string representation of the exception."""
17+
from airbyte_cdk.cli.airbyte_cdk._secrets import _get_secret_url
18+
19+
url = _get_secret_url(self.secret_name, self.gcp_project_id)
20+
return (
21+
f"No valid versions found for secret '{self.secret_name}' in connector '{self.connector_name}'. "
22+
f"Please check the following URL for more information:\n- {url}"
23+
)

0 commit comments

Comments
 (0)