Skip to content

Commit 6fdf2c6

Browse files
feat: add secrets fetch command to CDK CLI (#494)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Aaron <AJ> Steers <[email protected]>
1 parent 5f025ba commit 6fdf2c6

File tree

5 files changed

+458
-3
lines changed

5 files changed

+458
-3
lines changed

airbyte_cdk/cli/airbyte_cdk/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from airbyte_cdk.cli.airbyte_cdk._connector import connector_cli_group
4545
from airbyte_cdk.cli.airbyte_cdk._image import image_cli_group
4646
from airbyte_cdk.cli.airbyte_cdk._manifest import manifest_cli_group
47+
from airbyte_cdk.cli.airbyte_cdk._secrets import secrets_cli_group
4748
from airbyte_cdk.cli.airbyte_cdk._version import print_version
4849

4950

@@ -78,6 +79,7 @@ def cli(
7879
cli.add_command(connector_cli_group)
7980
cli.add_command(manifest_cli_group)
8081
cli.add_command(image_cli_group)
82+
cli.add_command(secrets_cli_group)
8183

8284

8385
if __name__ == "__main__":
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2+
"""Secret management commands.
3+
4+
This module provides commands for managing secrets for Airbyte connectors.
5+
6+
Usage:
7+
airbyte-cdk secrets fetch --connector-name source-github
8+
airbyte-cdk secrets fetch --connector-directory /path/to/connector
9+
airbyte-cdk secrets fetch # Run from within a connector directory
10+
11+
Usage without pre-installing (stateless):
12+
pipx run airbyte-cdk secrets fetch ...
13+
uvx airbyte-cdk secrets fetch ...
14+
15+
The 'fetch' command retrieves secrets from Google Secret Manager based on connector
16+
labels and writes them to the connector's `secrets` directory.
17+
"""
18+
19+
import json
20+
import os
21+
from pathlib import Path
22+
23+
import rich_click as click
24+
25+
from airbyte_cdk.cli.airbyte_cdk._util import resolve_connector_name_and_directory
26+
27+
AIRBYTE_INTERNAL_GCP_PROJECT = "dataline-integration-testing"
28+
CONNECTOR_LABEL = "connector"
29+
30+
31+
@click.group(
32+
name="secrets",
33+
help=__doc__.replace("\n", "\n\n"),
34+
)
35+
def secrets_cli_group() -> None:
36+
"""Secret management commands."""
37+
pass
38+
39+
40+
@secrets_cli_group.command()
41+
@click.option(
42+
"--connector-name",
43+
type=str,
44+
help="Name of the connector to fetch secrets for. Ignored if --connector-directory is provided.",
45+
)
46+
@click.option(
47+
"--connector-directory",
48+
type=click.Path(exists=True, file_okay=False, path_type=Path),
49+
help="Path to the connector directory.",
50+
)
51+
@click.option(
52+
"--gcp-project-id",
53+
type=str,
54+
default=AIRBYTE_INTERNAL_GCP_PROJECT,
55+
help=f"GCP project ID. Defaults to '{AIRBYTE_INTERNAL_GCP_PROJECT}'.",
56+
)
57+
def fetch(
58+
connector_name: str | None = None,
59+
connector_directory: Path | None = None,
60+
gcp_project_id: str = AIRBYTE_INTERNAL_GCP_PROJECT,
61+
) -> None:
62+
"""Fetch secrets for a connector from Google Secret Manager.
63+
64+
This command fetches secrets for a connector from Google Secret Manager and writes them
65+
to the connector's secrets directory.
66+
67+
If no connector name or directory is provided, we will look within the current working
68+
directory. If the current working directory is not a connector directory (e.g. starting
69+
with 'source-') and no connector name or path is provided, the process will fail.
70+
"""
71+
try:
72+
from google.cloud import secretmanager_v1 as secretmanager
73+
except ImportError:
74+
raise ImportError(
75+
"google-cloud-secret-manager package is required for Secret Manager integration. "
76+
"Install it with 'pip install airbyte-cdk[dev]' "
77+
"or 'pip install google-cloud-secret-manager'."
78+
)
79+
80+
click.echo("Fetching secrets...")
81+
82+
# Resolve connector name/directory
83+
try:
84+
connector_name, connector_directory = resolve_connector_name_and_directory(
85+
connector_name=connector_name,
86+
connector_directory=connector_directory,
87+
)
88+
except FileNotFoundError as e:
89+
raise FileNotFoundError(
90+
f"Could not find connector directory for '{connector_name}'. "
91+
"Please provide the --connector-directory option with the path to the connector. "
92+
"Note: This command requires either running from within a connector directory, "
93+
"being in the airbyte monorepo, or explicitly providing the connector directory path."
94+
) from e
95+
except ValueError as e:
96+
raise ValueError(str(e))
97+
98+
# Create secrets directory if it doesn't exist
99+
secrets_dir = connector_directory / "secrets"
100+
secrets_dir.mkdir(parents=True, exist_ok=True)
101+
102+
gitignore_path = secrets_dir / ".gitignore"
103+
gitignore_path.write_text("*")
104+
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+
)
111+
112+
client = secretmanager.SecretManagerServiceClient.from_service_account_info(
113+
json.loads(credentials_json)
114+
)
115+
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+
)
125+
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")
133+
134+
filename_base = "config" # Default filename
135+
if secret.labels and "filename" in secret.labels:
136+
filename_base = secret.labels["filename"]
137+
138+
secret_file_path = secrets_dir / f"{filename_base}.json"
139+
secret_file_path.write_text(payload)
140+
click.echo(f"Secret written to: {secret_file_path}")
141+
secret_count += 1
142+
143+
if secret_count == 0:
144+
click.echo(f"No secrets found for connector: {connector_name}")
145+
146+
147+
__all__ = [
148+
"secrets_cli_group",
149+
]

docs/CONTRIBUTING.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,32 @@ To see all available `ruff` options, run `poetry run ruff`.
4949

5050
The Ruff configuration is stored in `ruff.toml` at the root of the repository. This file contains settings for line length, target Python version, and linting rules.
5151

52+
## Handling Dependency Analysis with Deptry
53+
54+
The CDK uses [Deptry](https://deptry.com/) for dependency analysis to ensure all dependencies are properly declared and used. Sometimes Deptry may not correctly detect certain package usage patterns, especially for packages with complex import structures.
55+
56+
To ignore specific Deptry errors:
57+
58+
1. Identify the rule you need to ignore (DEP001, DEP002, DEP003, or DEP004).
59+
2. Add the package name to the appropriate rule list in the `[tool.deptry.per_rule_ignores]` section of `pyproject.toml`.
60+
3. Include an inline comment explaining why the ignore is needed.
61+
62+
Example:
63+
64+
```toml
65+
[tool.deptry.per_rule_ignores]
66+
# DEP002: Project should not contain unused dependencies.
67+
DEP002 = [
68+
"google-cloud-secret-manager", # Deptry can't detect that `google.cloud.secretmanager_v1` uses this package
69+
]
70+
```
71+
72+
Common scenarios requiring ignores:
73+
74+
- Packages imported using a different name than their PyPI package name.
75+
- Packages that are imported dynamically or through submodules.
76+
- Transitive dependencies that are used directly in the code.
77+
5278
## Auto-Generating the Declarative Schema File
5379

5480
Low-code CDK models are generated from `sources/declarative/declarative_component_schema.yaml`. If the iteration you are working on includes changes to the models or the connector generator, you may need to regenerate them. In order to do that, you can run:

0 commit comments

Comments
 (0)