Skip to content

Commit fcfb82a

Browse files
committed
feat(cli): add secrets list command
1 parent a1dd40b commit fcfb82a

File tree

2 files changed

+217
-50
lines changed

2 files changed

+217
-50
lines changed

airbyte_cdk/cli/airbyte_cdk/_secrets.py

Lines changed: 191 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,41 @@
1616
labels and writes them to the connector's `secrets` directory.
1717
"""
1818

19+
from __future__ import annotations
20+
1921
import json
2022
import os
2123
from pathlib import Path
24+
from types import ModuleType
2225

2326
import rich_click as click
27+
from click import style
28+
from rich.console import Console
29+
from rich.table import Table
2430

25-
from airbyte_cdk.cli.airbyte_cdk._util import resolve_connector_name_and_directory
31+
from airbyte_cdk.cli.airbyte_cdk._util import (
32+
resolve_connector_name,
33+
resolve_connector_name_and_directory,
34+
)
2635

2736
AIRBYTE_INTERNAL_GCP_PROJECT = "dataline-integration-testing"
2837
CONNECTOR_LABEL = "connector"
2938

3039

40+
secretmanager: ModuleType | None
41+
Secret: type | None
42+
try:
43+
from google.cloud import secretmanager_v1 as secretmanager
44+
from google.cloud.secretmanager_v1 import Secret
45+
except ImportError:
46+
# If the package is not installed, we will raise an error in the CLI command.
47+
secretmanager = None
48+
Secret = None
49+
50+
3151
@click.group(
3252
name="secrets",
33-
help=__doc__.replace("\n", "\n\n"), # Render docstring as help text (markdown)
53+
help=__doc__.replace("\n", "\n\n"), # Render docstring as help text (markdown) # type: ignore
3454
)
3555
def secrets_cli_group() -> None:
3656
"""Secret management commands."""
@@ -68,18 +88,151 @@ def fetch(
6888
directory. If the current working directory is not a connector directory (e.g. starting
6989
with 'source-') and no connector name or path is provided, the process will fail.
7090
"""
71-
try:
72-
from google.cloud import secretmanager_v1 as secretmanager
73-
except ImportError:
91+
click.echo("Fetching secrets...")
92+
93+
client = _get_gsm_secrets_client()
94+
connector_name, connector_directory = resolve_connector_name_and_directory(
95+
connector_name=connector_name,
96+
connector_directory=connector_directory,
97+
)
98+
secrets_dir = _get_secrets_dir(
99+
connector_directory=connector_directory,
100+
connector_name=connector_name,
101+
ensure_exists=True,
102+
)
103+
secrets = _fetch_secret_handles(
104+
connector_name=connector_name,
105+
gcp_project_id=gcp_project_id,
106+
)
107+
# Fetch and write secrets
108+
secret_count = 0
109+
for secret in secrets:
110+
secret_file_path = _get_secret_filepath(
111+
secrets_dir=secrets_dir,
112+
secret=secret,
113+
)
114+
secret_file_path.write_text(_get_secret_value(secret=secret, client=client))
115+
secret_file_path.chmod(0o600) # default to owner read/write only
116+
click.echo(f"Secret written to: {secret_file_path.absolute()!s}")
117+
secret_count += 1
118+
119+
if secret_count == 0:
120+
click.echo(
121+
f"No secrets found for connector: '{connector_name}'",
122+
err=True,
123+
)
124+
125+
126+
@secrets_cli_group.command("list")
127+
@click.option(
128+
"--connector-name",
129+
type=str,
130+
help="Name of the connector to fetch secrets for. Ignored if --connector-directory is provided.",
131+
)
132+
@click.option(
133+
"--connector-directory",
134+
type=click.Path(exists=True, file_okay=False, path_type=Path),
135+
help="Path to the connector directory.",
136+
)
137+
@click.option(
138+
"--gcp-project-id",
139+
type=str,
140+
default=AIRBYTE_INTERNAL_GCP_PROJECT,
141+
help=f"GCP project ID. Defaults to '{AIRBYTE_INTERNAL_GCP_PROJECT}'.",
142+
)
143+
def list_(
144+
connector_name: str | None = None,
145+
connector_directory: Path | None = None,
146+
gcp_project_id: str = AIRBYTE_INTERNAL_GCP_PROJECT,
147+
) -> None:
148+
"""List secrets for a connector from Google Secret Manager.
149+
150+
This command fetches secrets for a connector from Google Secret Manager and prints
151+
them as a table.
152+
153+
If no connector name or directory is provided, we will look within the current working
154+
directory. If the current working directory is not a connector directory (e.g. starting
155+
with 'source-') and no connector name or path is provided, the process will fail.
156+
"""
157+
click.echo("Fetching secrets...")
158+
159+
connector_name = connector_name or resolve_connector_name(
160+
connector_directory=connector_directory or Path().resolve().absolute(),
161+
)
162+
secrets = _fetch_secret_handles(
163+
connector_name=connector_name,
164+
gcp_project_id=gcp_project_id,
165+
)
166+
167+
if not secrets:
168+
click.echo(
169+
f"No secrets found for connector: '{connector_name}'",
170+
err=True,
171+
)
172+
return
173+
# print a rich table with the secrets
174+
click.echo(
175+
style(
176+
f"Secrets for connector '{connector_name}' in project '{gcp_project_id}':",
177+
fg="green",
178+
)
179+
)
180+
181+
console = Console()
182+
table = Table(title=f"'{connector_name}' Secrets")
183+
table.add_column("Name", justify="left", style="cyan", overflow="fold")
184+
table.add_column("Labels", justify="left", style="magenta", overflow="fold")
185+
table.add_column("Last Updated", justify="left", style="blue", overflow="fold")
186+
for secret in secrets:
187+
table.add_row(
188+
secret.name.split("/secrets/")[-1],
189+
str(secret.create_time),
190+
"\n".join([f"{k}={v}" for k, v in secret.labels.items()]),
191+
)
192+
193+
console.print(table)
194+
195+
196+
def _fetch_secret_handles(
197+
connector_name: str,
198+
gcp_project_id: str = AIRBYTE_INTERNAL_GCP_PROJECT,
199+
) -> list["Secret"]: # type: ignore
200+
"""Fetch secrets from Google Secret Manager."""
201+
if not secretmanager:
74202
raise ImportError(
75203
"google-cloud-secret-manager package is required for Secret Manager integration. "
76204
"Install it with 'pip install airbyte-cdk[dev]' "
77205
"or 'pip install google-cloud-secret-manager'."
78206
)
79207

80-
click.echo("Fetching secrets...")
208+
client = _get_gsm_secrets_client()
209+
210+
# List all secrets with the connector label
211+
parent = f"projects/{gcp_project_id}"
212+
filter_string = f"labels.{CONNECTOR_LABEL}={connector_name}"
213+
secrets = client.list_secrets(
214+
request=secretmanager.ListSecretsRequest(
215+
parent=parent,
216+
filter=filter_string,
217+
)
218+
)
219+
return [s for s in secrets]
220+
221+
222+
def _get_secret_value(
223+
secret: "Secret", # type: ignore
224+
client: "secretmanager.SecretManagerServiceClient", # type: ignore
225+
) -> str:
226+
version_name = f"{secret.name}/versions/latest"
227+
response = client.access_secret_version(name=version_name)
228+
return response.payload.data.decode("UTF-8")
229+
81230

82-
# Resolve connector name/directory
231+
def _get_secrets_dir(
232+
connector_directory: Path,
233+
connector_name: str,
234+
ensure_exists: bool = True,
235+
) -> Path:
83236
try:
84237
connector_name, connector_directory = resolve_connector_name_and_directory(
85238
connector_name=connector_name,
@@ -95,56 +248,44 @@ def fetch(
95248
except ValueError as e:
96249
raise ValueError(str(e))
97250

98-
# Create secrets directory if it doesn't exist
99251
secrets_dir = connector_directory / "secrets"
100-
secrets_dir.mkdir(parents=True, exist_ok=True)
252+
if ensure_exists:
253+
secrets_dir.mkdir(parents=True, exist_ok=True)
101254

102-
gitignore_path = secrets_dir / ".gitignore"
103-
gitignore_path.write_text("*")
255+
gitignore_path = secrets_dir / ".gitignore"
256+
if not gitignore_path.exists():
257+
gitignore_path.write_text("*")
104258

105-
# Get GSM client
106-
credentials_json = os.environ.get("GCP_GSM_CREDENTIALS")
107-
if not credentials_json:
108-
raise ValueError(
109-
"No Google Cloud credentials found. Please set the GCP_GSM_CREDENTIALS environment variable."
110-
)
259+
return secrets_dir
111260

112-
client = secretmanager.SecretManagerServiceClient.from_service_account_info(
113-
json.loads(credentials_json)
114-
)
115261

116-
# List all secrets with the connector label
117-
parent = f"projects/{gcp_project_id}"
118-
filter_string = f"labels.{CONNECTOR_LABEL}={connector_name}"
119-
secrets = client.list_secrets(
120-
request=secretmanager.ListSecretsRequest(
121-
parent=parent,
122-
filter=filter_string,
123-
)
124-
)
262+
def _get_secret_filepath(
263+
secrets_dir: Path,
264+
secret: Secret, # type: ignore
265+
) -> Path:
266+
"""Get the file path for a secret based on its labels."""
267+
if secret.labels and "filename" in secret.labels:
268+
return secrets_dir / f"{secret.labels['filename']}.json"
125269

126-
# Fetch and write secrets
127-
secret_count = 0
128-
for secret in secrets:
129-
secret_name = secret.name
130-
version_name = f"{secret_name}/versions/latest"
131-
response = client.access_secret_version(name=version_name)
132-
payload = response.payload.data.decode("UTF-8")
270+
return secrets_dir / "config.json" # Default filename
133271

134-
filename_base = "config" # Default filename
135-
if secret.labels and "filename" in secret.labels:
136-
filename_base = secret.labels["filename"]
137272

138-
secret_file_path = secrets_dir / f"{filename_base}.json"
139-
secret_file_path.write_text(payload)
140-
secret_file_path.chmod(0o600) # default to owner read/write only
141-
click.echo(f"Secret written to: {secret_file_path.absolute()!s}")
142-
secret_count += 1
143-
144-
if secret_count == 0:
145-
click.echo(f"No secrets found for connector: {connector_name}")
273+
def _get_gsm_secrets_client() -> "secretmanager.SecretManagerServiceClient": # type: ignore
274+
"""Get the Google Secret Manager client."""
275+
if not secretmanager:
276+
raise ImportError(
277+
"google-cloud-secret-manager package is required for Secret Manager integration. "
278+
"Install it with 'pip install airbyte-cdk[dev]' "
279+
"or 'pip install google-cloud-secret-manager'."
280+
)
146281

282+
credentials_json = os.environ.get("GCP_GSM_CREDENTIALS")
283+
if not credentials_json:
284+
raise ValueError(
285+
"No Google Cloud credentials found. "
286+
"Please set the `GCP_GSM_CREDENTIALS` environment variable."
287+
)
147288

148-
__all__ = [
149-
"secrets_cli_group",
150-
]
289+
return secretmanager.SecretManagerServiceClient.from_service_account_info(
290+
json.loads(credentials_json)
291+
)

airbyte_cdk/cli/airbyte_cdk/_util.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,29 @@ def resolve_connector_name_and_directory(
4141
raise ValueError("Either connector_name or connector_directory must be provided.")
4242

4343
return connector_name, connector_directory
44+
45+
46+
def resolve_connector_name(
47+
connector_directory: Path,
48+
) -> str:
49+
"""Resolve the connector name.
50+
51+
This function will resolve the connector name based on the provided connector directory.
52+
If the current working directory is not a connector directory
53+
(e.g. starting with 'source-'), the process will fail.
54+
55+
Raises:
56+
FileNotFoundError: If the connector directory does not exist or cannot be found.
57+
"""
58+
if not connector_directory:
59+
raise FileNotFoundError(
60+
"Connector directory does not exist or cannot be found. Please provide a valid "
61+
"connector directory."
62+
)
63+
connector_name = connector_directory.absolute().name
64+
if not connector_name.startswith("source-") and not connector_name.startswith("destination-"):
65+
raise ValueError(
66+
f"Connector directory '{connector_name}' does not look like a valid connector directory. "
67+
f"Full path: {connector_directory.absolute()}"
68+
)
69+
return connector_name

0 commit comments

Comments
 (0)