Skip to content

Commit 16ec8eb

Browse files
jx2leepankajastro
authored andcommitted
awscli to boto3
1 parent 83ad8b2 commit 16ec8eb

6 files changed

Lines changed: 260 additions & 69 deletions

File tree

cosmos/operators/kubernetes.py

Lines changed: 100 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from __future__ import annotations
22

3+
import json
4+
import shlex
5+
import textwrap
36
from abc import ABC, abstractmethod
47
from collections.abc import Callable, Sequence
58
from typing import TYPE_CHECKING, Any
@@ -28,11 +31,6 @@
2831
DbtTestMixin,
2932
)
3033

31-
try:
32-
from airflow.sdk.bases.hook import BaseHook
33-
except ImportError: # Since Airflow 3.1, BaseHook is in the airflow.sdk.bases.hook module
34-
from airflow.hooks.base import BaseHook
35-
3634

3735
class DbtKubernetesBaseOperator(AbstractDbtBase, KubernetesPodOperator): # type: ignore[misc]
3836
"""
@@ -218,14 +216,22 @@ def __init__(self, **kwargs: Any) -> None:
218216
@abstractmethod
219217
def build_upload_shell_command(self, docs_target: str) -> str:
220218
"""
221-
Build the shell command that will upload the generated docs from
222-
`docs_target` to cloud storage. Implemented by subclasses.
219+
Build the shell command that uploads generated docs from `docs_target`
220+
to cloud storage inside the Kubernetes Pod.
223221
"""
224222

225223
@abstractmethod
226224
def get_upload_env_vars(self) -> dict[str, str]:
227225
"""Return env vars required by the upload command."""
228226

227+
@staticmethod
228+
def _command_parts(command: Any) -> list[str]:
229+
if not command:
230+
return []
231+
if isinstance(command, (list, tuple)):
232+
return [str(part) for part in command]
233+
return [str(command)]
234+
229235
def build_and_run_cmd(
230236
self,
231237
context: Context,
@@ -244,25 +250,20 @@ def build_and_run_cmd(
244250
# the leading "dbt" is not dropped when folded into the bash -c string below (see PR #2488).
245251
cmds: Any = self.cmds # type: ignore[has-type]
246252
arguments: Any = self.arguments # type: ignore[has-type]
247-
cmd_parts = list(cmds) if isinstance(cmds, (list, tuple)) else [cmds]
248-
if isinstance(arguments, (list, tuple)):
249-
cmd_parts.extend(arguments)
250-
else:
251-
cmd_parts.append(arguments)
252-
253-
dbt_cmd_str = " ".join([str(part) for part in cmd_parts])
253+
cmd_parts = self._command_parts(cmds) + self._command_parts(arguments)
254+
dbt_cmd_str = shlex.join(cmd_parts)
254255
docs_target = f"{self.project_dir}/target"
255256

256257
upload_cmd = self.build_upload_shell_command(docs_target)
257258
shell_cmd = f"{dbt_cmd_str} && {upload_cmd}"
258259

259-
# Override container command and arguments
260260
self.cmds = ["/bin/bash", "-c"]
261261
self.arguments = [shell_cmd]
262262

263263
self.log.info("Running command in Kubernetes Pod: %s", self.arguments)
264264
result = KubernetesPodOperator.execute(self, context)
265265
self.log.info(result)
266+
266267
return result
267268

268269
def inject_upload_env_vars(self, env_vars: dict[str, str]) -> None:
@@ -286,7 +287,7 @@ def inject_upload_env_vars(self, env_vars: dict[str, str]) -> None:
286287
class DbtDocsS3KubernetesOperator(DbtDocsCloudKubernetesOperator):
287288
"""
288289
Executes `dbt docs generate` inside a Kubernetes Pod and uploads the generated
289-
documentation to S3 *also inside that Pod* using `aws s3 sync`.
290+
documentation to S3 also inside that Pod using ``boto3``.
290291
- The Kubernetes Pod receives AWS credentials resolved from the supplied
291292
Airflow `connection_id`.
292293
"""
@@ -306,45 +307,97 @@ def __init__(
306307
self.folder_dir = folder_dir
307308

308309
def build_upload_shell_command(self, docs_target: str) -> str:
309-
if self.folder_dir:
310-
s3_prefix = f"s3://{self.bucket_name}/{self.folder_dir}".rstrip("/")
311-
else:
312-
s3_prefix = f"s3://{self.bucket_name}"
313-
314-
return f"aws s3 sync {docs_target} {s3_prefix}"
310+
folder_dir = self.folder_dir.rstrip("/") if self.folder_dir else ""
311+
upload_script = textwrap.dedent(f"""
312+
import json
313+
import mimetypes
314+
import os
315+
from pathlib import Path
316+
317+
try:
318+
import boto3
319+
except ImportError as exc:
320+
raise SystemExit("boto3 is required in the Kubernetes image to upload dbt docs to S3.") from exc
321+
322+
target_dir = Path({json.dumps(docs_target)})
323+
bucket_name = {json.dumps(self.bucket_name)}
324+
folder_dir = {json.dumps(folder_dir)}
325+
326+
client_kwargs = dict()
327+
endpoint_url = os.environ.get("AWS_ENDPOINT_URL_S3")
328+
if endpoint_url:
329+
client_kwargs["endpoint_url"] = endpoint_url
330+
331+
client_config = json.loads(os.environ.get("COSMOS_AWS_CLIENT_CONFIG", "{{}}"))
332+
if "verify" in client_config:
333+
client_kwargs["verify"] = client_config["verify"]
334+
335+
config_kwargs = client_config.get("config_kwargs")
336+
if config_kwargs:
337+
from botocore.config import Config
338+
339+
client_kwargs["config"] = Config(**config_kwargs)
340+
341+
s3 = boto3.client("s3", **client_kwargs)
342+
for file_path in target_dir.rglob("*"):
343+
if not file_path.is_file():
344+
continue
345+
346+
relative_path = file_path.relative_to(target_dir).as_posix()
347+
key = f"{{folder_dir}}/{{relative_path}}" if folder_dir else relative_path
348+
content_type, _ = mimetypes.guess_type(str(file_path))
349+
extra_args = {{"ContentType": content_type}} if content_type else None
350+
print(f"Uploading {{file_path}} to s3://{{bucket_name}}/{{key}}")
351+
if extra_args:
352+
s3.upload_file(str(file_path), bucket_name, key, ExtraArgs=extra_args)
353+
else:
354+
s3.upload_file(str(file_path), bucket_name, key)
355+
""").strip()
356+
return f"$(command -v python3 || command -v python) - <<'PY'\n{upload_script}\nPY"
315357

316358
def get_upload_env_vars(self) -> dict[str, str]:
317359
return self.aws_env_vars_from_connection(self.connection_id)
318360

319361
def aws_env_vars_from_connection(self, connection_id: str) -> dict[str, str]:
320-
conn = BaseHook.get_connection(connection_id)
321-
conn_extra = conn.extra_dejson
322-
323-
access_key = conn.login or conn_extra.get("aws_access_key_id")
324-
secret_key = conn.password or conn_extra.get("aws_secret_access_key")
325-
session_token = conn_extra.get("aws_session_token") or conn_extra.get("session_token")
326-
327-
session_kwargs = conn_extra.get("session_kwargs", {})
328-
config_kwargs = conn_extra.get("config_kwargs", {})
329-
if not isinstance(session_kwargs, dict):
330-
session_kwargs = {}
331-
if not isinstance(config_kwargs, dict):
332-
config_kwargs = {}
333-
region_name = (
334-
conn_extra.get("region_name")
335-
or conn_extra.get("region")
336-
or session_kwargs.get("region_name")
337-
or config_kwargs.get("region_name")
338-
)
362+
try:
363+
from airflow.providers.amazon.aws.hooks.base_aws import AwsBaseHook
364+
except ImportError:
365+
from cosmos.operators.lazy_load import MissingPackage
366+
367+
AwsBaseHook = MissingPackage(
368+
"airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook",
369+
"amazon",
370+
)
371+
372+
hook = AwsBaseHook(aws_conn_id=connection_id, client_type="s3")
373+
conn_config = hook.conn_config
374+
conn_extra = conn_config.extra_config
375+
376+
config_kwargs = conn_extra.get("config_kwargs") or {}
377+
378+
region_name = hook.region_name or conn_extra.get("region") or config_kwargs.get("region_name")
379+
endpoint_url = conn_config.get_service_endpoint_url("s3")
380+
verify = hook.verify
339381

340382
env_vars = {}
341-
if access_key:
342-
env_vars["AWS_ACCESS_KEY_ID"] = access_key
343-
if secret_key:
344-
env_vars["AWS_SECRET_ACCESS_KEY"] = secret_key
345-
if session_token:
346-
env_vars["AWS_SESSION_TOKEN"] = session_token
383+
if conn_config.aws_access_key_id:
384+
env_vars["AWS_ACCESS_KEY_ID"] = conn_config.aws_access_key_id
385+
if conn_config.aws_secret_access_key:
386+
env_vars["AWS_SECRET_ACCESS_KEY"] = conn_config.aws_secret_access_key
387+
if conn_config.aws_session_token:
388+
env_vars["AWS_SESSION_TOKEN"] = conn_config.aws_session_token
389+
if endpoint_url:
390+
env_vars["AWS_ENDPOINT_URL_S3"] = endpoint_url
347391
if region_name:
348392
env_vars["AWS_DEFAULT_REGION"] = region_name
393+
env_vars["AWS_REGION"] = region_name
394+
395+
client_config = {}
396+
if verify is not None:
397+
client_config["verify"] = verify
398+
if config_kwargs:
399+
client_config["config_kwargs"] = config_kwargs
400+
if client_config:
401+
env_vars["COSMOS_AWS_CLIENT_CONFIG"] = json.dumps(client_config)
349402

350403
return env_vars

dev/Dockerfile.postgres_profile_docker_k8s

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ FROM python:3.12
22

33
RUN pip install --force-reinstall 'dbt-postgres>=1.8' 'dbt-core<2.0'
44
RUN pip install --force-reinstall dbt-adapters
5-
RUN pip install --force-reinstall awscli
5+
RUN pip install --force-reinstall boto3
66

77
ENV POSTGRES_DATABASE=postgres
88
ENV POSTGRES_DB=postgres

dev/Dockerfile.watcher_failing_tests_k8s

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ FROM python:3.12
22

33
RUN pip install --force-reinstall 'dbt-postgres>=1.8' 'dbt-core<2.0'
44
RUN pip install --force-reinstall dbt-adapters
5-
RUN pip install --force-reinstall awscli
5+
RUN pip install --force-reinstall boto3
66

77
ENV POSTGRES_DATABASE=postgres
88
ENV POSTGRES_DB=postgres

docs/guides/dbt_docs/generating-docs.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ Requirements specific to Kubernetes:
139139

140140
- The container image must include your dbt project files.
141141
- The container image or mounted files must include a ``profiles.yml`` file, because Kubernetes execution mode does not support :ref:`use-profile-mapping`.
142-
- The container image must have the AWS CLI available because Cosmos uploads the generated docs with ``aws s3 sync``.
142+
- The container image must have ``boto3`` available because Cosmos uploads the generated docs from inside the Pod with a small Python uploader.
143143
- The Pod still needs the database credentials and any other secrets required to run ``dbt docs generate``.
144144

145145
The following example extends the Kubernetes example DAG and uploads the generated docs to S3:
@@ -149,7 +149,7 @@ The following example extends the Kubernetes example DAG and uploads the generat
149149
:start-after: [START kubernetes_docs_to_s3_example]
150150
:end-before: [END kubernetes_docs_to_s3_example]
151151

152-
The ``connection_id`` is resolved from Airflow and translated into AWS environment variables that are injected into the Pod before ``aws s3 sync`` runs.
152+
The ``connection_id`` is resolved from Airflow with ``AwsBaseHook`` and translated into AWS environment variables that are injected into the Pod before the ``boto3`` uploader runs.
153153

154154
.. note::
155155
This Kubernetes integration currently supports S3 only. If you need another storage backend, use one of the local operators or extend Cosmos with another Kubernetes docs operator.

docs/guides/run_dbt/container/kubernetes.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ At the moment, the user is expected to add to the Docker image both:
3838
- The dbt Profile, which contains the information for dbt to access the database while parsing the project from Apache Airflow nodes
3939
- Handle secrets
4040

41-
If you plan to generate dbt docs and upload them to S3 from Kubernetes, the image also needs the AWS CLI because Cosmos performs the upload from inside the Pod.
41+
If you plan to generate dbt docs and upload them to S3 from Kubernetes, the Pod image must have ``boto3`` available, either installed directly or provided by packages such as ``apache-airflow-providers-amazon``.
4242

4343
Additional KubernetesPodOperator parameters can be added to the ``operator_args`` parameter of the ``DbtKubernetesOperator``.
4444

0 commit comments

Comments
 (0)