4343from rich .console import Console
4444from rich .table import Table
4545
46- from airbyte_cdk .cli .airbyte_cdk ._util import (
46+ from airbyte_cdk .cli .airbyte_cdk .exceptions import ConnectorSecretWithNoValidVersionsError
47+ from airbyte_cdk .utils .connector_paths import (
4748 resolve_connector_name ,
4849 resolve_connector_name_and_directory ,
4950)
@@ -73,15 +74,11 @@ def secrets_cli_group() -> None:
7374
7475
7576@secrets_cli_group .command ()
76- @click .option (
77- "--connector-name" ,
77+ @click .argument (
78+ "connector" ,
79+ required = False ,
7880 type = str ,
79- help = "Name of the connector to fetch secrets for. Ignored if --connector-directory is provided." ,
80- )
81- @click .option (
82- "--connector-directory" ,
83- type = click .Path (exists = True , file_okay = False , path_type = Path ),
84- help = "Path to the connector directory." ,
81+ metavar = "[CONNECTOR]" ,
8582)
8683@click .option (
8784 "--gcp-project-id" ,
@@ -97,8 +94,7 @@ def secrets_cli_group() -> None:
9794 default = False ,
9895)
9996def fetch (
100- connector_name : str | None = None ,
101- connector_directory : Path | None = None ,
97+ connector : str | Path | None = None ,
10298 gcp_project_id : str = AIRBYTE_INTERNAL_GCP_PROJECT ,
10399 print_ci_secrets_masks : bool = False ,
104100) -> None :
@@ -107,24 +103,24 @@ def fetch(
107103 This command fetches secrets for a connector from Google Secret Manager and writes them
108104 to the connector's secrets directory.
109105
106+ [CONNECTOR] can be a connector name (e.g. 'source-pokeapi'), a path to a connector directory, or omitted to use the current working directory.
107+ If a string containing '/' is provided, it is treated as a path. Otherwise, it is treated as a connector name.
108+
110109 If no connector name or directory is provided, we will look within the current working
111110 directory. If the current working directory is not a connector directory (e.g. starting
112111 with 'source-') and no connector name or path is provided, the process will fail.
113112
114113 The `--print-ci-secrets-masks` option will print the GitHub CI mask for the secrets.
115114 This is useful for masking secrets in CI logs.
116115
117- WARNING: This action causes the secrets to be printed in clear text to `STDOUT`. For security
118- reasons, this function will only execute if the `CI` environment variable is set. Otherwise,
119- masks will not be printed .
116+ WARNING: The `--print-ci-secrets-masks` option causes the secrets to be printed in clear text to
117+ `STDOUT`. For security reasons, this argument will be ignored if the `CI` environment
118+ variable is not set .
120119 """
121120 click .echo ("Fetching secrets..." , err = True )
122121
123122 client = _get_gsm_secrets_client ()
124- connector_name , connector_directory = resolve_connector_name_and_directory (
125- connector_name = connector_name ,
126- connector_directory = connector_directory ,
127- )
123+ connector_name , connector_directory = resolve_connector_name_and_directory (connector )
128124 secrets_dir = _get_secrets_dir (
129125 connector_directory = connector_directory ,
130126 connector_name = connector_name ,
@@ -136,24 +132,46 @@ def fetch(
136132 )
137133 # Fetch and write secrets
138134 secret_count = 0
135+ exceptions = []
136+
139137 for secret in secrets :
140138 secret_file_path = _get_secret_filepath (
141139 secrets_dir = secrets_dir ,
142140 secret = secret ,
143141 )
144- _write_secret_file (
145- secret = secret ,
146- client = client ,
147- 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 ,
148162 )
149- click .echo (f"Secret written to: { secret_file_path .absolute ()!s} " , err = True )
150- secret_count += 1
151163
152- if secret_count == 0 :
164+ if exceptions :
165+ error_message = f"Failed to retrieve { len (exceptions )} secret(s)"
153166 click .echo (
154- f"No secrets found for connector: '{ connector_name } '" ,
167+ style (
168+ error_message ,
169+ fg = "red" ,
170+ ),
155171 err = True ,
156172 )
173+ if secret_count == 0 :
174+ raise exceptions [0 ]
157175
158176 if not print_ci_secrets_masks :
159177 return
@@ -235,9 +253,8 @@ def list_(
235253 table .add_column ("Created" , justify = "left" , style = "blue" , overflow = "fold" )
236254 for secret in secrets :
237255 full_secret_name = secret .name
238- secret_name = full_secret_name .split ("/secrets/" )[- 1 ] # Removes project prefix
239- # E.g. https://console.cloud.google.com/security/secret-manager/secret/SECRET_SOURCE-SHOPIFY__CREDS/versions?hl=en&project=<gcp_project_id>
240- 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 )
241258 table .add_row (
242259 f"[link={ secret_url } ]{ secret_name } [/link]" ,
243260 "\n " .join ([f"{ k } ={ v } " for k , v in secret .labels .items ()]),
@@ -247,6 +264,43 @@ def list_(
247264 console .print (table )
248265
249266
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+
250304def _fetch_secret_handles (
251305 connector_name : str ,
252306 gcp_project_id : str = AIRBYTE_INTERNAL_GCP_PROJECT ,
@@ -277,9 +331,44 @@ def _write_secret_file(
277331 secret : "Secret" , # type: ignore
278332 client : "secretmanager.SecretManagerServiceClient" , # type: ignore
279333 file_path : Path ,
334+ connector_name : str ,
335+ gcp_project_id : str ,
280336) -> None :
281- version_name = f"{ secret .name } /versions/latest"
282- 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 )
283372 file_path .write_text (response .payload .data .decode ("UTF-8" ))
284373 file_path .chmod (0o600 ) # default to owner read/write only
285374
@@ -289,21 +378,7 @@ def _get_secrets_dir(
289378 connector_name : str ,
290379 ensure_exists : bool = True ,
291380) -> Path :
292- try :
293- connector_name , connector_directory = resolve_connector_name_and_directory (
294- connector_name = connector_name ,
295- connector_directory = connector_directory ,
296- )
297- except FileNotFoundError as e :
298- raise FileNotFoundError (
299- f"Could not find connector directory for '{ connector_name } '. "
300- "Please provide the --connector-directory option with the path to the connector. "
301- "Note: This command requires either running from within a connector directory, "
302- "being in the airbyte monorepo, or explicitly providing the connector directory path."
303- ) from e
304- except ValueError as e :
305- raise ValueError (str (e ))
306-
381+ _ = connector_name # Unused, but it may be used in the future for logging
307382 secrets_dir = connector_directory / "secrets"
308383 if ensure_exists :
309384 secrets_dir .mkdir (parents = True , exist_ok = True )
0 commit comments