airbyte-cdk 6.45.10__py3-none-any.whl → 6.46.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- airbyte_cdk/cli/__init__.py +9 -1
- airbyte_cdk/cli/airbyte_cdk/__init__.py +86 -0
- airbyte_cdk/cli/airbyte_cdk/_connector.py +179 -0
- airbyte_cdk/cli/airbyte_cdk/_image.py +95 -0
- airbyte_cdk/cli/airbyte_cdk/_manifest.py +24 -0
- airbyte_cdk/cli/airbyte_cdk/_secrets.py +150 -0
- airbyte_cdk/cli/airbyte_cdk/_util.py +43 -0
- airbyte_cdk/cli/airbyte_cdk/_version.py +13 -0
- airbyte_cdk/connector_builder/connector_builder_handler.py +10 -0
- airbyte_cdk/models/connector_metadata.py +97 -0
- airbyte_cdk/sources/declarative/declarative_component_schema.yaml +108 -79
- airbyte_cdk/sources/declarative/manifest_declarative_source.py +122 -45
- airbyte_cdk/sources/declarative/models/declarative_component_schema.py +87 -82
- airbyte_cdk/sources/declarative/parsers/custom_exceptions.py +9 -0
- airbyte_cdk/sources/declarative/parsers/manifest_component_transformer.py +2 -2
- airbyte_cdk/sources/declarative/parsers/manifest_normalizer.py +462 -0
- airbyte_cdk/sources/declarative/parsers/manifest_reference_resolver.py +2 -2
- airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +24 -24
- airbyte_cdk/sources/declarative/stream_slicers/declarative_partition_generator.py +17 -1
- airbyte_cdk/test/standard_tests/connector_base.py +51 -25
- airbyte_cdk/test/standard_tests/declarative_sources.py +3 -1
- airbyte_cdk/test/standard_tests/test_resources.py +69 -0
- airbyte_cdk/test/standard_tests/util.py +79 -0
- airbyte_cdk/utils/docker.py +337 -0
- airbyte_cdk/utils/docker_image_templates.py +101 -0
- {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/METADATA +6 -1
- {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/RECORD +31 -18
- {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/entry_points.txt +1 -0
- {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/LICENSE.txt +0 -0
- {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/LICENSE_SHORT +0 -0
- {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/WHEEL +0 -0
@@ -4,7 +4,9 @@
|
|
4
4
|
from __future__ import annotations
|
5
5
|
|
6
6
|
import abc
|
7
|
+
import importlib
|
7
8
|
import inspect
|
9
|
+
import os
|
8
10
|
import sys
|
9
11
|
from collections.abc import Callable
|
10
12
|
from pathlib import Path
|
@@ -22,17 +24,61 @@ from airbyte_cdk.test.standard_tests._job_runner import IConnector, run_test_job
|
|
22
24
|
from airbyte_cdk.test.standard_tests.models import (
|
23
25
|
ConnectorTestScenario,
|
24
26
|
)
|
25
|
-
|
26
|
-
ACCEPTANCE_TEST_CONFIG
|
27
|
-
|
27
|
+
from airbyte_cdk.test.standard_tests.test_resources import (
|
28
|
+
ACCEPTANCE_TEST_CONFIG,
|
29
|
+
find_connector_root,
|
30
|
+
)
|
28
31
|
|
29
32
|
|
30
33
|
class ConnectorTestSuiteBase(abc.ABC):
|
31
34
|
"""Base class for connector test suites."""
|
32
35
|
|
33
|
-
connector: type[IConnector] | Callable[[], IConnector] | None
|
36
|
+
connector: type[IConnector] | Callable[[], IConnector] | None # type: ignore [reportRedeclaration]
|
34
37
|
"""The connector class or a factory function that returns an scenario of IConnector."""
|
35
38
|
|
39
|
+
@classproperty # type: ignore [no-redef]
|
40
|
+
def connector(cls) -> type[IConnector] | Callable[[], IConnector] | None:
|
41
|
+
"""Get the connector class for the test suite.
|
42
|
+
|
43
|
+
This assumes a python connector and should be overridden by subclasses to provide the
|
44
|
+
specific connector class to be tested.
|
45
|
+
"""
|
46
|
+
connector_root = cls.get_connector_root_dir()
|
47
|
+
connector_name = connector_root.absolute().name
|
48
|
+
|
49
|
+
expected_module_name = connector_name.replace("-", "_").lower()
|
50
|
+
expected_class_name = connector_name.replace("-", "_").title().replace("_", "")
|
51
|
+
|
52
|
+
# dynamically import and get the connector class: <expected_module_name>.<expected_class_name>
|
53
|
+
|
54
|
+
cwd_snapshot = Path().absolute()
|
55
|
+
os.chdir(connector_root)
|
56
|
+
|
57
|
+
# Dynamically import the module
|
58
|
+
try:
|
59
|
+
module = importlib.import_module(expected_module_name)
|
60
|
+
except ModuleNotFoundError as e:
|
61
|
+
raise ImportError(f"Could not import module '{expected_module_name}'.") from e
|
62
|
+
finally:
|
63
|
+
# Change back to the original working directory
|
64
|
+
os.chdir(cwd_snapshot)
|
65
|
+
|
66
|
+
# Dynamically get the class from the module
|
67
|
+
try:
|
68
|
+
return cast(type[IConnector], getattr(module, expected_class_name))
|
69
|
+
except AttributeError as e:
|
70
|
+
# We did not find it based on our expectations, so let's check if we can find it
|
71
|
+
# with a case-insensitive match.
|
72
|
+
matching_class_name = next(
|
73
|
+
(name for name in dir(module) if name.lower() == expected_class_name.lower()),
|
74
|
+
None,
|
75
|
+
)
|
76
|
+
if not matching_class_name:
|
77
|
+
raise ImportError(
|
78
|
+
f"Module '{expected_module_name}' does not have a class named '{expected_class_name}'."
|
79
|
+
) from e
|
80
|
+
return cast(type[IConnector], getattr(module, matching_class_name))
|
81
|
+
|
36
82
|
@classmethod
|
37
83
|
def get_test_class_dir(cls) -> Path:
|
38
84
|
"""Get the file path that contains the class."""
|
@@ -81,27 +127,7 @@ class ConnectorTestSuiteBase(abc.ABC):
|
|
81
127
|
@classmethod
|
82
128
|
def get_connector_root_dir(cls) -> Path:
|
83
129
|
"""Get the root directory of the connector."""
|
84
|
-
|
85
|
-
if (parent / MANIFEST_YAML).exists():
|
86
|
-
return parent
|
87
|
-
if (parent / ACCEPTANCE_TEST_CONFIG).exists():
|
88
|
-
return parent
|
89
|
-
if parent.name == "airbyte_cdk":
|
90
|
-
break
|
91
|
-
# If we reach here, we didn't find the manifest file in any parent directory
|
92
|
-
# Check if the manifest file exists in the current directory
|
93
|
-
for parent in Path.cwd().parents:
|
94
|
-
if (parent / MANIFEST_YAML).exists():
|
95
|
-
return parent
|
96
|
-
if (parent / ACCEPTANCE_TEST_CONFIG).exists():
|
97
|
-
return parent
|
98
|
-
if parent.name == "airbyte_cdk":
|
99
|
-
break
|
100
|
-
|
101
|
-
raise FileNotFoundError(
|
102
|
-
"Could not find connector root directory relative to "
|
103
|
-
f"'{str(cls.get_test_class_dir())}' or '{str(Path.cwd())}'."
|
104
|
-
)
|
130
|
+
return find_connector_root([cls.get_test_class_dir(), Path.cwd()])
|
105
131
|
|
106
132
|
@classproperty
|
107
133
|
def acceptance_test_config_path(cls) -> Path:
|
@@ -10,9 +10,9 @@ from airbyte_cdk.sources.declarative.concurrent_declarative_source import (
|
|
10
10
|
ConcurrentDeclarativeSource,
|
11
11
|
)
|
12
12
|
from airbyte_cdk.test.standard_tests._job_runner import IConnector
|
13
|
-
from airbyte_cdk.test.standard_tests.connector_base import MANIFEST_YAML
|
14
13
|
from airbyte_cdk.test.standard_tests.models import ConnectorTestScenario
|
15
14
|
from airbyte_cdk.test.standard_tests.source_base import SourceTestSuiteBase
|
15
|
+
from airbyte_cdk.test.standard_tests.test_resources import MANIFEST_YAML
|
16
16
|
|
17
17
|
|
18
18
|
def md5_checksum(file_path: Path) -> str:
|
@@ -35,6 +35,8 @@ class DeclarativeSourceTestSuite(SourceTestSuiteBase):
|
|
35
35
|
`components.py` file (if it exists) in the connector's directory.
|
36
36
|
"""
|
37
37
|
|
38
|
+
connector: type[IConnector] | None = None
|
39
|
+
|
38
40
|
@classproperty
|
39
41
|
def manifest_yaml_path(cls) -> Path:
|
40
42
|
"""Get the path to the manifest.yaml file."""
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
2
|
+
"""Resources for Airbyte CDK tests."""
|
3
|
+
|
4
|
+
from contextlib import suppress
|
5
|
+
from pathlib import Path
|
6
|
+
|
7
|
+
ACCEPTANCE_TEST_CONFIG = "acceptance-test-config.yml"
|
8
|
+
MANIFEST_YAML = "manifest.yaml"
|
9
|
+
METADATA_YAML = "metadata.yaml"
|
10
|
+
|
11
|
+
|
12
|
+
def find_connector_root(from_paths: list[Path]) -> Path:
|
13
|
+
"""Find the root directory of the connector."""
|
14
|
+
for path in from_paths:
|
15
|
+
# If we reach here, we didn't find the manifest file in any parent directory
|
16
|
+
# Check if the manifest file exists in the current directory
|
17
|
+
for parent in [path, *path.parents]:
|
18
|
+
if (parent / METADATA_YAML).exists():
|
19
|
+
return parent
|
20
|
+
if (parent / MANIFEST_YAML).exists():
|
21
|
+
return parent
|
22
|
+
if (parent / ACCEPTANCE_TEST_CONFIG).exists():
|
23
|
+
return parent
|
24
|
+
if parent.name == "airbyte_cdk":
|
25
|
+
break
|
26
|
+
|
27
|
+
raise FileNotFoundError(
|
28
|
+
"Could not find connector root directory relative to the provided directories: "
|
29
|
+
f"'{str(from_paths)}'."
|
30
|
+
)
|
31
|
+
|
32
|
+
|
33
|
+
def find_connector_root_from_name(connector_name: str) -> Path:
|
34
|
+
"""Find the root directory of the connector from its name."""
|
35
|
+
with suppress(FileNotFoundError):
|
36
|
+
return find_connector_root([Path(connector_name)])
|
37
|
+
|
38
|
+
# If the connector name is not found, check if we are in the airbyte monorepo
|
39
|
+
# and try to find the connector root from the current directory.
|
40
|
+
|
41
|
+
cwd: Path = Path().absolute()
|
42
|
+
|
43
|
+
if "airbyte" not in cwd.parts:
|
44
|
+
raise FileNotFoundError(
|
45
|
+
"Could not find connector root directory relative and we are not in the airbyte repo. "
|
46
|
+
f"Current directory: {cwd} "
|
47
|
+
)
|
48
|
+
|
49
|
+
# Find the connector root from the current directory
|
50
|
+
|
51
|
+
airbyte_repo_root: Path
|
52
|
+
for parent in [cwd, *cwd.parents]:
|
53
|
+
if parent.name == "airbyte":
|
54
|
+
airbyte_repo_root = parent
|
55
|
+
break
|
56
|
+
else:
|
57
|
+
raise FileNotFoundError(
|
58
|
+
"Could not find connector root directory relative and we are not in the airbyte repo."
|
59
|
+
)
|
60
|
+
|
61
|
+
expected_connector_dir: Path = (
|
62
|
+
airbyte_repo_root / "airbyte-integrations" / "connectors" / connector_name
|
63
|
+
)
|
64
|
+
if not expected_connector_dir.exists():
|
65
|
+
raise FileNotFoundError(
|
66
|
+
f"Could not find connector directory '{expected_connector_dir}' relative to the airbyte repo."
|
67
|
+
)
|
68
|
+
|
69
|
+
return expected_connector_dir
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
2
|
+
"""Utility and factory functions for testing Airbyte connectors."""
|
3
|
+
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Any, Literal
|
6
|
+
|
7
|
+
import yaml
|
8
|
+
|
9
|
+
from airbyte_cdk.test.standard_tests.connector_base import ConnectorTestSuiteBase
|
10
|
+
from airbyte_cdk.test.standard_tests.declarative_sources import (
|
11
|
+
DeclarativeSourceTestSuite,
|
12
|
+
)
|
13
|
+
from airbyte_cdk.test.standard_tests.destination_base import DestinationTestSuiteBase
|
14
|
+
from airbyte_cdk.test.standard_tests.source_base import SourceTestSuiteBase
|
15
|
+
from airbyte_cdk.test.standard_tests.test_resources import (
|
16
|
+
METADATA_YAML,
|
17
|
+
find_connector_root_from_name,
|
18
|
+
)
|
19
|
+
|
20
|
+
TEST_CLASS_MAPPING: dict[
|
21
|
+
Literal["python", "manifest-only", "declarative"], type[ConnectorTestSuiteBase]
|
22
|
+
] = {
|
23
|
+
"python": SourceTestSuiteBase,
|
24
|
+
"manifest-only": DeclarativeSourceTestSuite,
|
25
|
+
# "declarative": DeclarativeSourceTestSuite,
|
26
|
+
}
|
27
|
+
|
28
|
+
|
29
|
+
def create_connector_test_suite(
|
30
|
+
*,
|
31
|
+
connector_name: str | None = None,
|
32
|
+
connector_directory: Path | None = None,
|
33
|
+
) -> type[ConnectorTestSuiteBase]:
|
34
|
+
"""Get the test class for the specified connector name or path."""
|
35
|
+
if connector_name and connector_directory:
|
36
|
+
raise ValueError("Specify either `connector_name` or `connector_directory`, not both.")
|
37
|
+
if not connector_name and not connector_directory:
|
38
|
+
raise ValueError("Specify either `connector_name` or `connector_directory`.")
|
39
|
+
|
40
|
+
if connector_name:
|
41
|
+
connector_directory = find_connector_root_from_name(
|
42
|
+
connector_name,
|
43
|
+
)
|
44
|
+
else:
|
45
|
+
# By here, we know that connector_directory is not None
|
46
|
+
# but connector_name is None. Set the connector_name.
|
47
|
+
assert connector_directory is not None, "connector_directory should not be None here."
|
48
|
+
connector_name = connector_directory.name
|
49
|
+
|
50
|
+
metadata_yaml_path = connector_directory / METADATA_YAML
|
51
|
+
if not metadata_yaml_path.exists():
|
52
|
+
raise FileNotFoundError(
|
53
|
+
f"Could not find metadata YAML file '{metadata_yaml_path}' relative to the connector directory."
|
54
|
+
)
|
55
|
+
metadata_dict: dict[str, Any] = yaml.safe_load(metadata_yaml_path.read_text())
|
56
|
+
metadata_tags = metadata_dict["data"].get("tags", [])
|
57
|
+
for language_option in TEST_CLASS_MAPPING:
|
58
|
+
if f"language:{language_option}" in metadata_tags:
|
59
|
+
language = language_option
|
60
|
+
test_suite_class = TEST_CLASS_MAPPING[language]
|
61
|
+
break
|
62
|
+
else:
|
63
|
+
raise ValueError(
|
64
|
+
f"Unsupported connector type. "
|
65
|
+
f"Supported language values are: {', '.join(TEST_CLASS_MAPPING.keys())}. "
|
66
|
+
f"Found tags: {', '.join(metadata_tags)}"
|
67
|
+
)
|
68
|
+
|
69
|
+
subclass_overrides: dict[str, Any] = {
|
70
|
+
"get_connector_root_dir": lambda: connector_directory,
|
71
|
+
}
|
72
|
+
|
73
|
+
TestSuiteAuto = type(
|
74
|
+
"TestSuiteAuto",
|
75
|
+
(test_suite_class,),
|
76
|
+
subclass_overrides,
|
77
|
+
)
|
78
|
+
|
79
|
+
return TestSuiteAuto
|
@@ -0,0 +1,337 @@
|
|
1
|
+
"""Docker build utilities for Airbyte CDK."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import json
|
6
|
+
import logging
|
7
|
+
import os
|
8
|
+
import subprocess
|
9
|
+
import sys
|
10
|
+
from dataclasses import dataclass
|
11
|
+
from enum import Enum
|
12
|
+
from pathlib import Path
|
13
|
+
|
14
|
+
import click
|
15
|
+
|
16
|
+
from airbyte_cdk.models.connector_metadata import ConnectorLanguage, MetadataFile
|
17
|
+
from airbyte_cdk.utils.docker_image_templates import (
|
18
|
+
DOCKERIGNORE_TEMPLATE,
|
19
|
+
MANIFEST_ONLY_DOCKERFILE_TEMPLATE,
|
20
|
+
PYTHON_CONNECTOR_DOCKERFILE_TEMPLATE,
|
21
|
+
)
|
22
|
+
|
23
|
+
|
24
|
+
@dataclass(kw_only=True)
|
25
|
+
class ConnectorImageBuildError(Exception):
|
26
|
+
"""Custom exception for Docker build errors."""
|
27
|
+
|
28
|
+
error_text: str
|
29
|
+
build_args: list[str]
|
30
|
+
|
31
|
+
def __str__(self) -> str:
|
32
|
+
return "\n".join(
|
33
|
+
[
|
34
|
+
f"ConnectorImageBuildError: Could not build image.",
|
35
|
+
f"Build args: {self.build_args}",
|
36
|
+
f"Error text: {self.error_text}",
|
37
|
+
]
|
38
|
+
)
|
39
|
+
|
40
|
+
|
41
|
+
logger = logging.getLogger(__name__)
|
42
|
+
|
43
|
+
|
44
|
+
class ArchEnum(str, Enum):
|
45
|
+
"""Enum for supported architectures."""
|
46
|
+
|
47
|
+
ARM64 = "arm64"
|
48
|
+
AMD64 = "amd64"
|
49
|
+
|
50
|
+
|
51
|
+
def _build_image(
|
52
|
+
context_dir: Path,
|
53
|
+
dockerfile: Path,
|
54
|
+
metadata: MetadataFile,
|
55
|
+
tag: str,
|
56
|
+
arch: ArchEnum,
|
57
|
+
build_args: dict[str, str | None] | None = None,
|
58
|
+
) -> str:
|
59
|
+
"""Build a Docker image for the specified architecture.
|
60
|
+
|
61
|
+
Returns the tag of the built image.
|
62
|
+
|
63
|
+
Raises: ConnectorImageBuildError if the build fails.
|
64
|
+
"""
|
65
|
+
docker_args: list[str] = [
|
66
|
+
"docker",
|
67
|
+
"build",
|
68
|
+
"--platform",
|
69
|
+
f"linux/{arch.value}",
|
70
|
+
"--file",
|
71
|
+
str(dockerfile),
|
72
|
+
"--label",
|
73
|
+
f"io.airbyte.version={metadata.data.dockerImageTag}",
|
74
|
+
"--label",
|
75
|
+
f"io.airbyte.name={metadata.data.dockerRepository}",
|
76
|
+
]
|
77
|
+
if build_args:
|
78
|
+
for key, value in build_args.items():
|
79
|
+
if value is not None:
|
80
|
+
docker_args.append(f"--build-arg={key}={value}")
|
81
|
+
else:
|
82
|
+
docker_args.append(f"--build-arg={key}")
|
83
|
+
docker_args.extend(
|
84
|
+
[
|
85
|
+
"-t",
|
86
|
+
tag,
|
87
|
+
str(context_dir),
|
88
|
+
]
|
89
|
+
)
|
90
|
+
|
91
|
+
print(f"Building image: {tag} ({arch})")
|
92
|
+
try:
|
93
|
+
run_docker_command(
|
94
|
+
docker_args,
|
95
|
+
check=True,
|
96
|
+
)
|
97
|
+
except subprocess.CalledProcessError as e:
|
98
|
+
raise ConnectorImageBuildError(
|
99
|
+
error_text=e.stderr,
|
100
|
+
build_args=docker_args,
|
101
|
+
) from e
|
102
|
+
|
103
|
+
return tag
|
104
|
+
|
105
|
+
|
106
|
+
def _tag_image(
|
107
|
+
tag: str,
|
108
|
+
new_tags: list[str] | str,
|
109
|
+
) -> None:
|
110
|
+
"""Build a Docker image for the specified architecture.
|
111
|
+
|
112
|
+
Returns the tag of the built image.
|
113
|
+
|
114
|
+
Raises:
|
115
|
+
ConnectorImageBuildError: If the docker tag command fails.
|
116
|
+
"""
|
117
|
+
if not isinstance(new_tags, list):
|
118
|
+
new_tags = [new_tags]
|
119
|
+
|
120
|
+
for new_tag in new_tags:
|
121
|
+
print(f"Tagging image '{tag}' as: {new_tag}")
|
122
|
+
docker_args = [
|
123
|
+
"docker",
|
124
|
+
"tag",
|
125
|
+
tag,
|
126
|
+
new_tag,
|
127
|
+
]
|
128
|
+
try:
|
129
|
+
run_docker_command(
|
130
|
+
docker_args,
|
131
|
+
check=True,
|
132
|
+
)
|
133
|
+
except subprocess.CalledProcessError as e:
|
134
|
+
raise ConnectorImageBuildError(
|
135
|
+
error_text=e.stderr,
|
136
|
+
build_args=docker_args,
|
137
|
+
) from e
|
138
|
+
|
139
|
+
|
140
|
+
def build_connector_image(
|
141
|
+
connector_name: str,
|
142
|
+
connector_directory: Path,
|
143
|
+
metadata: MetadataFile,
|
144
|
+
tag: str,
|
145
|
+
primary_arch: ArchEnum = ArchEnum.ARM64, # Assume MacBook M series by default
|
146
|
+
no_verify: bool = False,
|
147
|
+
) -> None:
|
148
|
+
"""Build a connector Docker image.
|
149
|
+
|
150
|
+
This command builds a Docker image for a connector, using either
|
151
|
+
the connector's Dockerfile or a base image specified in the metadata.
|
152
|
+
The image is built for both AMD64 and ARM64 architectures.
|
153
|
+
|
154
|
+
Args:
|
155
|
+
connector_name: The name of the connector.
|
156
|
+
connector_directory: The directory containing the connector code.
|
157
|
+
metadata: The metadata of the connector.
|
158
|
+
tag: The tag to apply to the built image.
|
159
|
+
primary_arch: The primary architecture for the build (default: arm64). This
|
160
|
+
architecture will be used for the same-named tag. Both AMD64 and ARM64
|
161
|
+
images will be built, with the suffixes '-amd64' and '-arm64'.
|
162
|
+
no_verify: If True, skip verification of the built image.
|
163
|
+
|
164
|
+
Raises:
|
165
|
+
ValueError: If the connector build options are not defined in metadata.yaml.
|
166
|
+
ConnectorImageBuildError: If the image build or tag operation fails.
|
167
|
+
"""
|
168
|
+
connector_kebab_name = connector_name
|
169
|
+
connector_snake_name = connector_kebab_name.replace("-", "_")
|
170
|
+
|
171
|
+
dockerfile_path = connector_directory / "build" / "docker" / "Dockerfile"
|
172
|
+
dockerignore_path = connector_directory / "build" / "docker" / "Dockerfile.dockerignore"
|
173
|
+
|
174
|
+
extra_build_script: str = ""
|
175
|
+
build_customization_path = connector_directory / "build_customization.py"
|
176
|
+
if build_customization_path.exists():
|
177
|
+
extra_build_script = str(build_customization_path)
|
178
|
+
|
179
|
+
dockerfile_path.parent.mkdir(parents=True, exist_ok=True)
|
180
|
+
if not metadata.data.connectorBuildOptions:
|
181
|
+
raise ValueError(
|
182
|
+
"Connector build options are not defined in metadata.yaml. "
|
183
|
+
"Please check the connector's metadata file."
|
184
|
+
)
|
185
|
+
|
186
|
+
base_image = metadata.data.connectorBuildOptions.baseImage
|
187
|
+
|
188
|
+
dockerfile_path.write_text(get_dockerfile_template(metadata))
|
189
|
+
dockerignore_path.write_text(DOCKERIGNORE_TEMPLATE)
|
190
|
+
|
191
|
+
build_args: dict[str, str | None] = {
|
192
|
+
"BASE_IMAGE": base_image,
|
193
|
+
"CONNECTOR_SNAKE_NAME": connector_snake_name,
|
194
|
+
"CONNECTOR_KEBAB_NAME": connector_kebab_name,
|
195
|
+
"EXTRA_BUILD_SCRIPT": extra_build_script,
|
196
|
+
}
|
197
|
+
|
198
|
+
base_tag = f"{metadata.data.dockerRepository}:{tag}"
|
199
|
+
arch_images: list[str] = []
|
200
|
+
for arch in [ArchEnum.AMD64, ArchEnum.ARM64]:
|
201
|
+
docker_tag = f"{base_tag}-{arch.value}"
|
202
|
+
docker_tag_parts = docker_tag.split("/")
|
203
|
+
if len(docker_tag_parts) > 2:
|
204
|
+
docker_tag = "/".join(docker_tag_parts[-1:])
|
205
|
+
arch_images.append(
|
206
|
+
_build_image(
|
207
|
+
context_dir=connector_directory,
|
208
|
+
dockerfile=dockerfile_path,
|
209
|
+
metadata=metadata,
|
210
|
+
tag=docker_tag,
|
211
|
+
arch=arch,
|
212
|
+
build_args=build_args,
|
213
|
+
)
|
214
|
+
)
|
215
|
+
|
216
|
+
_tag_image(
|
217
|
+
tag=f"{base_tag}-{primary_arch.value}",
|
218
|
+
new_tags=[base_tag],
|
219
|
+
)
|
220
|
+
if not no_verify:
|
221
|
+
if verify_connector_image(base_tag):
|
222
|
+
click.echo(f"Build completed successfully: {base_tag}")
|
223
|
+
sys.exit(0)
|
224
|
+
else:
|
225
|
+
click.echo(f"Built image failed verification: {base_tag}", err=True)
|
226
|
+
sys.exit(1)
|
227
|
+
else:
|
228
|
+
click.echo(f"Build completed successfully (without verification): {base_tag}")
|
229
|
+
sys.exit(0)
|
230
|
+
|
231
|
+
|
232
|
+
def get_dockerfile_template(
|
233
|
+
metadata: MetadataFile,
|
234
|
+
) -> str:
|
235
|
+
"""Get the Dockerfile template for the connector.
|
236
|
+
|
237
|
+
Args:
|
238
|
+
metadata: The metadata of the connector.
|
239
|
+
connector_name: The name of the connector.
|
240
|
+
|
241
|
+
Returns:
|
242
|
+
The Dockerfile template as a string.
|
243
|
+
"""
|
244
|
+
if metadata.data.language == ConnectorLanguage.PYTHON:
|
245
|
+
return PYTHON_CONNECTOR_DOCKERFILE_TEMPLATE
|
246
|
+
|
247
|
+
if metadata.data.language == ConnectorLanguage.MANIFEST_ONLY:
|
248
|
+
return MANIFEST_ONLY_DOCKERFILE_TEMPLATE
|
249
|
+
|
250
|
+
if metadata.data.language == ConnectorLanguage.JAVA:
|
251
|
+
raise ValueError(
|
252
|
+
f"Java and Kotlin connectors are not yet supported. "
|
253
|
+
"Please use airbyte-ci or gradle to build your image."
|
254
|
+
)
|
255
|
+
|
256
|
+
raise ValueError(
|
257
|
+
f"Unsupported connector language: {metadata.data.language}. "
|
258
|
+
"Please check the connector's metadata file."
|
259
|
+
)
|
260
|
+
|
261
|
+
|
262
|
+
def run_docker_command(
|
263
|
+
cmd: list[str],
|
264
|
+
*,
|
265
|
+
check: bool = True,
|
266
|
+
capture_output: bool = False,
|
267
|
+
) -> subprocess.CompletedProcess[str]:
|
268
|
+
"""Run a Docker command as a subprocess.
|
269
|
+
|
270
|
+
Args:
|
271
|
+
cmd: The command to run as a list of strings.
|
272
|
+
check: If True, raises an exception if the command fails. If False, the caller is
|
273
|
+
responsible for checking the return code.
|
274
|
+
capture_output: If True, captures stdout and stderr and returns to the caller.
|
275
|
+
If False, the output is printed to the console.
|
276
|
+
|
277
|
+
Raises:
|
278
|
+
subprocess.CalledProcessError: If the command fails and check is True.
|
279
|
+
"""
|
280
|
+
print(f"Running command: {' '.join(cmd)}")
|
281
|
+
|
282
|
+
process = subprocess.run(
|
283
|
+
cmd,
|
284
|
+
text=True,
|
285
|
+
check=check,
|
286
|
+
# If capture_output=True, stderr and stdout are captured and returned to caller:
|
287
|
+
capture_output=capture_output,
|
288
|
+
env={**os.environ, "DOCKER_BUILDKIT": "1"},
|
289
|
+
)
|
290
|
+
return process
|
291
|
+
|
292
|
+
|
293
|
+
def verify_docker_installation() -> bool:
|
294
|
+
"""Verify Docker is installed and running."""
|
295
|
+
try:
|
296
|
+
run_docker_command(["docker", "--version"])
|
297
|
+
return True
|
298
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
299
|
+
return False
|
300
|
+
|
301
|
+
|
302
|
+
def verify_connector_image(
|
303
|
+
image_name: str,
|
304
|
+
) -> bool:
|
305
|
+
"""Verify the built image by running the spec command.
|
306
|
+
|
307
|
+
Args:
|
308
|
+
image_name: The full image name with tag.
|
309
|
+
|
310
|
+
Returns:
|
311
|
+
True if the spec command succeeds, False otherwise.
|
312
|
+
"""
|
313
|
+
logger.info(f"Verifying image {image_name} with 'spec' command...")
|
314
|
+
|
315
|
+
cmd = ["docker", "run", "--rm", image_name, "spec"]
|
316
|
+
|
317
|
+
try:
|
318
|
+
result = run_docker_command(
|
319
|
+
cmd,
|
320
|
+
check=True,
|
321
|
+
capture_output=True,
|
322
|
+
)
|
323
|
+
# check that the output is valid JSON
|
324
|
+
if result.stdout:
|
325
|
+
try:
|
326
|
+
json.loads(result.stdout)
|
327
|
+
except json.JSONDecodeError:
|
328
|
+
logger.error("Invalid JSON output from spec command.")
|
329
|
+
return False
|
330
|
+
else:
|
331
|
+
logger.error("No output from spec command.")
|
332
|
+
return False
|
333
|
+
except subprocess.CalledProcessError as e:
|
334
|
+
logger.error(f"Image verification failed: {e.stderr}")
|
335
|
+
return False
|
336
|
+
|
337
|
+
return True
|