2
2
3
3
from __future__ import annotations
4
4
5
+ import json
5
6
import logging
7
+ import os
6
8
import subprocess
7
9
import sys
8
10
from pathlib import Path
9
11
10
12
import click
11
13
12
14
from airbyte_cdk .models .connector_metadata import MetadataFile
15
+ from airbyte_cdk .utils .docker_image_templates import (
16
+ DOCKERIGNORE_TEMPLATE ,
17
+ PYTHON_CONNECTOR_DOCKERFILE_TEMPLATE ,
18
+ )
13
19
14
20
logger = logging .getLogger (__name__ )
15
21
16
- # This template accepts the following variables:
17
- # - base_image: The base image to use for the build
18
- # - extra_build_steps: Additional build steps to include in the Dockerfile
19
- # - connector_snake_name: The snake_case name of the connector
20
- # - connector_kebab_name: The kebab-case name of the connector
21
- DOCKERFILE_TEMPLATE = """
22
- FROM {base_image} AS builder
23
-
24
- WORKDIR /airbyte/integration_code
25
-
26
- COPY . ./
27
- COPY {connector_snake_name} ./{connector_snake_name}
28
- {extra_build_steps}
29
-
30
- # TODO: Pre-install uv on the base image to speed up the build.
31
- # (uv is still faster even with the extra step.)
32
- RUN pip install --no-cache-dir uv
33
-
34
- RUN python -m uv pip install --no-cache-dir .
35
-
36
- FROM {base_image}
37
-
38
- WORKDIR /airbyte/integration_code
39
-
40
- COPY --from=builder /usr/local /usr/local
41
-
42
- COPY . .
43
-
44
- ENV AIRBYTE_ENTRYPOINT="{connector_kebab_name}"
45
- ENTRYPOINT ["{connector_kebab_name}"]
46
- """
47
-
48
22
49
23
def _build_image (
50
24
context_dir : Path ,
51
25
dockerfile : Path ,
52
26
metadata : MetadataFile ,
53
27
tag : str ,
54
28
arch : str ,
29
+ build_args : dict [str , str | None ] | None = None ,
55
30
) -> str :
56
31
"""Build a Docker image for the specified architecture.
57
32
@@ -72,10 +47,22 @@ def _build_image(
72
47
tag ,
73
48
str (context_dir ),
74
49
]
50
+ if build_args :
51
+ for key , value in build_args .items ():
52
+ if value is not None :
53
+ docker_args .append (f"--build-arg={ key } ={ value } " )
54
+ else :
55
+ docker_args .append (f"--build-arg={ key } " )
56
+
75
57
print (f"Building image: { tag } ({ arch } )" )
76
- run_docker_command (
77
- docker_args ,
78
- )
58
+ try :
59
+ run_docker_command (
60
+ docker_args ,
61
+ )
62
+ except subprocess .CalledProcessError as e :
63
+ print (f"ERROR: Failed to build image using Docker args: { docker_args } " )
64
+ exit (1 )
65
+ raise
79
66
return tag
80
67
81
68
@@ -119,15 +106,10 @@ def build_connector_image(
119
106
dockerfile_path = connector_directory / "build" / "docker" / "Dockerfile"
120
107
dockerignore_path = connector_directory / "build" / "docker" / "Dockerfile.dockerignore"
121
108
122
- extra_build_steps : str = ""
109
+ extra_build_script : str = ""
123
110
build_customization_path = connector_directory / "build_customization.py"
124
111
if build_customization_path .exists ():
125
- extra_build_steps = "\n " .join (
126
- [
127
- "COPY build_customization.py ./" ,
128
- "RUN python3 build_customization.py" ,
129
- ]
130
- )
112
+ extra_build_script = str (build_customization_path )
131
113
132
114
dockerfile_path .parent .mkdir (parents = True , exist_ok = True )
133
115
if not metadata .data .connectorBuildOptions :
@@ -138,34 +120,15 @@ def build_connector_image(
138
120
139
121
base_image = metadata .data .connectorBuildOptions .baseImage
140
122
141
- dockerfile_path .write_text (
142
- DOCKERFILE_TEMPLATE .format (
143
- base_image = base_image ,
144
- connector_snake_name = connector_snake_name ,
145
- connector_kebab_name = connector_kebab_name ,
146
- extra_build_steps = extra_build_steps ,
147
- )
148
- )
149
- dockerignore_path .write_text (
150
- "\n " .join (
151
- [
152
- "# This file is auto-generated. Do not edit." ,
153
- "build/" ,
154
- ".venv/" ,
155
- "secrets/" ,
156
- "!setup.py" ,
157
- "!pyproject.toml" ,
158
- "!poetry.lock" ,
159
- "!poetry.toml" ,
160
- "!components.py" ,
161
- "!requirements.txt" ,
162
- "!README.md" ,
163
- "!metadata.yaml" ,
164
- "!build_customization.py" ,
165
- # f"!{connector_snake_name}/",
166
- ]
167
- )
168
- )
123
+ dockerfile_path .write_text (PYTHON_CONNECTOR_DOCKERFILE_TEMPLATE )
124
+ dockerignore_path .write_text (DOCKERIGNORE_TEMPLATE )
125
+
126
+ build_args : dict [str , str | None ] = {
127
+ "BASE_IMAGE" : base_image ,
128
+ "CONNECTOR_SNAKE_NAME" : connector_snake_name ,
129
+ "CONNECTOR_KEBAB_NAME" : connector_kebab_name ,
130
+ "EXTRA_BUILD_SCRIPT" : extra_build_script ,
131
+ }
169
132
170
133
base_tag = f"{ metadata .data .dockerRepository } :{ tag } "
171
134
arch_images : list [str ] = []
@@ -182,6 +145,7 @@ def build_connector_image(
182
145
metadata = metadata ,
183
146
tag = docker_tag ,
184
147
arch = arch ,
148
+ build_args = build_args ,
185
149
)
186
150
)
187
151
@@ -190,7 +154,7 @@ def build_connector_image(
190
154
new_tags = [base_tag ],
191
155
)
192
156
if not no_verify :
193
- if verify_image (base_tag ):
157
+ if verify_connector_image (base_tag ):
194
158
click .echo (f"Build completed successfully: { base_tag } " )
195
159
sys .exit (0 )
196
160
else :
@@ -201,19 +165,35 @@ def build_connector_image(
201
165
sys .exit (0 )
202
166
203
167
204
- def run_docker_command (cmd : list [str ]) -> None :
168
+ def run_docker_command (
169
+ cmd : list [str ],
170
+ * ,
171
+ check : bool = True ,
172
+ capture_output : bool = False ,
173
+ ) -> subprocess .CompletedProcess :
205
174
"""Run a Docker command as a subprocess.
206
175
176
+ Args:
177
+ cmd: The command to run as a list of strings.
178
+ check: If True, raises an exception if the command fails. If False, the caller is
179
+ responsible for checking the return code.
180
+ capture_output: If True, captures stdout and stderr and returns to the caller.
181
+ If False, the output is printed to the console.
182
+
207
183
Raises:
208
184
subprocess.CalledProcessError: If the command fails and check is True.
209
185
"""
210
- logger . debug (f"Running command: { ' ' .join (cmd )} " )
186
+ print (f"Running command: { ' ' .join (cmd )} " )
211
187
212
188
process = subprocess .run (
213
189
cmd ,
214
190
text = True ,
215
191
check = True ,
192
+ # If capture_output=True, stderr and stdout are captured and returned to caller:
193
+ capture_output = capture_output ,
194
+ env = {** os .environ , "DOCKER_BUILDKIT" : "1" },
216
195
)
196
+ return process
217
197
218
198
219
199
def verify_docker_installation () -> bool :
@@ -225,7 +205,9 @@ def verify_docker_installation() -> bool:
225
205
return False
226
206
227
207
228
- def verify_image (image_name : str ) -> bool :
208
+ def verify_connector_image (
209
+ image_name : str ,
210
+ ) -> bool :
229
211
"""Verify the built image by running the spec command.
230
212
231
213
Args:
@@ -239,7 +221,21 @@ def verify_image(image_name: str) -> bool:
239
221
cmd = ["docker" , "run" , "--rm" , image_name , "spec" ]
240
222
241
223
try :
242
- run_docker_command (cmd )
224
+ result = run_docker_command (
225
+ cmd ,
226
+ check = True ,
227
+ capture_output = True ,
228
+ )
229
+ # check that the output is valid JSON
230
+ if result .stdout :
231
+ try :
232
+ json .loads (result .stdout )
233
+ except json .JSONDecodeError :
234
+ logger .error ("Invalid JSON output from spec command." )
235
+ return False
236
+ else :
237
+ logger .error ("No output from spec command." )
238
+ return False
243
239
except subprocess .CalledProcessError as e :
244
240
logger .error (f"Image verification failed: { e .stderr } " )
245
241
return False
0 commit comments