Skip to content

Commit bfe520b

Browse files
authored
Merge branch 'main' into lazebnyi/fix-dynamic-stream-read-with-dynamic-schema
2 parents 7fdc9a4 + 5d9cfff commit bfe520b

26 files changed

+1251
-420
lines changed

airbyte_cdk/cli/airbyte_cdk/_connector.py

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,14 @@
4444

4545
import rich_click as click
4646

47-
# from airbyte_cdk.test.standard_tests import pytest_hooks
48-
from airbyte_cdk.cli.airbyte_cdk._util import resolve_connector_name_and_directory
49-
from airbyte_cdk.test.standard_tests.test_resources import find_connector_root_from_name
5047
from airbyte_cdk.test.standard_tests.util import create_connector_test_suite
5148

49+
# from airbyte_cdk.test.standard_tests import pytest_hooks
50+
from airbyte_cdk.utils.connector_paths import (
51+
find_connector_root_from_name,
52+
resolve_connector_name_and_directory,
53+
)
54+
5255
click.rich_click.TEXT_MARKUP = "markdown"
5356

5457
pytest: ModuleType | None
@@ -98,15 +101,11 @@ def connector_cli_group() -> None:
98101

99102

100103
@connector_cli_group.command()
101-
@click.option(
102-
"--connector-name",
104+
@click.argument(
105+
"connector",
106+
required=False,
103107
type=str,
104-
help="Name of the connector to test. Ignored if --connector-directory is provided.",
105-
)
106-
@click.option(
107-
"--connector-directory",
108-
type=click.Path(exists=True, file_okay=False, path_type=Path),
109-
help="Path to the connector directory.",
108+
metavar="[CONNECTOR]",
110109
)
111110
@click.option(
112111
"--collect-only",
@@ -115,15 +114,17 @@ def connector_cli_group() -> None:
115114
help="Only collect tests, do not run them.",
116115
)
117116
def test(
118-
connector_name: str | None = None,
119-
connector_directory: Path | None = None,
117+
connector: str | Path | None = None,
120118
*,
121119
collect_only: bool = False,
122120
) -> None:
123121
"""Run connector tests.
124122
125123
This command runs the standard connector tests for a specific connector.
126124
125+
[CONNECTOR] can be a connector name (e.g. 'source-pokeapi'), a path to a connector directory, or omitted to use the current working directory.
126+
If a string containing '/' is provided, it is treated as a path. Otherwise, it is treated as a connector name.
127+
127128
If no connector name or directory is provided, we will look within the current working
128129
directory. If the current working directory is not a connector directory (e.g. starting
129130
with 'source-') and no connector name or path is provided, the process will fail.
@@ -133,10 +134,7 @@ def test(
133134
"pytest is not installed. Please install pytest to run the connector tests."
134135
)
135136
click.echo("Connector test command executed.")
136-
connector_name, connector_directory = resolve_connector_name_and_directory(
137-
connector_name=connector_name,
138-
connector_directory=connector_directory,
139-
)
137+
connector_name, connector_directory = resolve_connector_name_and_directory(connector)
140138

141139
connector_test_suite = create_connector_test_suite(
142140
connector_name=connector_name if not connector_directory else None,

airbyte_cdk/cli/airbyte_cdk/_image.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010

1111
import rich_click as click
1212

13-
from airbyte_cdk.cli.airbyte_cdk._util import resolve_connector_name_and_directory
1413
from airbyte_cdk.models.connector_metadata import MetadataFile
14+
from airbyte_cdk.utils.connector_paths import resolve_connector_name_and_directory
1515
from airbyte_cdk.utils.docker import (
1616
ConnectorImageBuildError,
1717
build_connector_image,
@@ -28,41 +28,38 @@ def image_cli_group() -> None:
2828

2929

3030
@image_cli_group.command()
31-
@click.option(
32-
"--connector-name",
31+
@click.argument(
32+
"connector",
33+
required=False,
3334
type=str,
34-
help="Name of the connector to test. Ignored if --connector-directory is provided.",
35-
)
36-
@click.option(
37-
"--connector-directory",
38-
type=click.Path(exists=True, file_okay=False, path_type=Path),
39-
help="Path to the connector directory.",
35+
metavar="[CONNECTOR]",
4036
)
4137
@click.option("--tag", default="dev", help="Tag to apply to the built image (default: dev)")
4238
@click.option("--no-verify", is_flag=True, help="Skip verification of the built image")
39+
@click.option(
40+
"--dockerfile",
41+
type=click.Path(exists=True, file_okay=True, path_type=Path),
42+
help="Optional. Override the Dockerfile used for building the image.",
43+
)
4344
def build(
44-
connector_name: str | None = None,
45-
connector_directory: Path | None = None,
45+
connector: str | None = None,
4646
*,
4747
tag: str = "dev",
4848
no_verify: bool = False,
49+
dockerfile: Path | None = None,
4950
) -> None:
5051
"""Build a connector Docker image.
5152
52-
This command builds a Docker image for a connector, using either
53-
the connector's Dockerfile or a base image specified in the metadata.
54-
The image is built for both AMD64 and ARM64 architectures.
53+
[CONNECTOR] can be a connector name (e.g. 'source-pokeapi'), a path to a connector directory, or omitted to use the current working directory.
54+
If a string containing '/' is provided, it is treated as a path. Otherwise, it is treated as a connector name.
5555
"""
5656
if not verify_docker_installation():
5757
click.echo(
5858
"Docker is not installed or not running. Please install Docker and try again.", err=True
5959
)
6060
sys.exit(1)
6161

62-
connector_name, connector_directory = resolve_connector_name_and_directory(
63-
connector_name=connector_name,
64-
connector_directory=connector_directory,
65-
)
62+
connector_name, connector_directory = resolve_connector_name_and_directory(connector)
6663

6764
metadata_file_path: Path = connector_directory / "metadata.yaml"
6865
try:
@@ -81,6 +78,7 @@ def build(
8178
metadata=metadata,
8279
tag=tag,
8380
no_verify=no_verify,
81+
dockerfile_override=dockerfile or None,
8482
)
8583
except ConnectorImageBuildError as e:
8684
click.echo(

airbyte_cdk/cli/airbyte_cdk/_secrets.py

Lines changed: 121 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
from rich.console import Console
4444
from 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
)
9996
def 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+
250304
def _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

Comments
 (0)