airbyte-internal-ops 0.4.1__py3-none-any.whl → 0.5.0__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_internal_ops-0.4.1.dist-info → airbyte_internal_ops-0.5.0.dist-info}/METADATA +1 -1
- {airbyte_internal_ops-0.4.1.dist-info → airbyte_internal_ops-0.5.0.dist-info}/RECORD +13 -52
- airbyte_ops_mcp/cli/cloud.py +42 -3
- airbyte_ops_mcp/cloud_admin/api_client.py +473 -0
- airbyte_ops_mcp/cloud_admin/models.py +56 -0
- airbyte_ops_mcp/mcp/cloud_connector_versions.py +460 -0
- airbyte_ops_mcp/mcp/prerelease.py +6 -46
- airbyte_ops_mcp/regression_tests/ci_output.py +151 -71
- airbyte_ops_mcp/regression_tests/http_metrics.py +21 -2
- airbyte_ops_mcp/regression_tests/models.py +6 -0
- airbyte_ops_mcp/telemetry.py +162 -0
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/.gitignore +0 -1
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/README.md +0 -420
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/__init__.py +0 -2
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/__init__.py +0 -1
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/backends/__init__.py +0 -8
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/backends/base_backend.py +0 -16
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/backends/duckdb_backend.py +0 -87
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/backends/file_backend.py +0 -165
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/connection_objects_retrieval.py +0 -377
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/connector_runner.py +0 -247
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/errors.py +0 -7
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/evaluation_modes.py +0 -25
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/hacks.py +0 -23
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/json_schema_helper.py +0 -384
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/mitm_addons.py +0 -37
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/models.py +0 -595
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/proxy.py +0 -207
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/secret_access.py +0 -47
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/segment_tracking.py +0 -45
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/utils.py +0 -214
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/conftest.py.disabled +0 -751
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/consts.py +0 -4
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/poetry.lock +0 -4480
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/pytest.ini +0 -9
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/regression_tests/__init__.py +0 -1
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/regression_tests/test_check.py +0 -61
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/regression_tests/test_discover.py +0 -117
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/regression_tests/test_read.py +0 -627
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/regression_tests/test_spec.py +0 -43
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/report.py +0 -542
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/stash_keys.py +0 -38
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/templates/__init__.py +0 -0
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/templates/private_details.html.j2 +0 -305
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/templates/report.html.j2 +0 -515
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/utils.py +0 -187
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/validation_tests/__init__.py +0 -0
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/validation_tests/test_check.py +0 -61
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/validation_tests/test_discover.py +0 -217
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/validation_tests/test_read.py +0 -177
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/validation_tests/test_spec.py +0 -631
- {airbyte_internal_ops-0.4.1.dist-info → airbyte_internal_ops-0.5.0.dist-info}/WHEEL +0 -0
- {airbyte_internal_ops-0.4.1.dist-info → airbyte_internal_ops-0.5.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import logging
|
|
5
|
-
import uuid
|
|
6
|
-
|
|
7
|
-
import dagger
|
|
8
|
-
|
|
9
|
-
from . import mitm_addons
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class Proxy:
|
|
13
|
-
"""
|
|
14
|
-
This class is a wrapper around a mitmproxy container. It allows to declare a mitmproxy container,
|
|
15
|
-
bind it as a service to a different container and retrieve the mitmproxy stream file.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
MITMPROXY_IMAGE = "mitmproxy/mitmproxy:10.2.4"
|
|
19
|
-
MITM_STREAM_FILE = "stream.mitm"
|
|
20
|
-
PROXY_PORT = 8080
|
|
21
|
-
MITM_ADDONS_PATH = mitm_addons.__file__
|
|
22
|
-
|
|
23
|
-
def __init__(
|
|
24
|
-
self,
|
|
25
|
-
dagger_client: dagger.Client,
|
|
26
|
-
hostname: str,
|
|
27
|
-
session_id: str,
|
|
28
|
-
stream_for_server_replay: dagger.File | None = None,
|
|
29
|
-
) -> None:
|
|
30
|
-
self.dagger_client = dagger_client
|
|
31
|
-
self.hostname = hostname
|
|
32
|
-
self.session_id = session_id
|
|
33
|
-
self.stream_for_server_replay = stream_for_server_replay
|
|
34
|
-
|
|
35
|
-
@property
|
|
36
|
-
def dump_cache_volume(self) -> dagger.CacheVolume:
|
|
37
|
-
# We namespace the cache by:
|
|
38
|
-
# - Mitmproxy image name to make sure we're not re-using a cached artifact on a different and potentially incompatible mitmproxy version
|
|
39
|
-
# - Hostname to avoid sharing the same https dump between different tests
|
|
40
|
-
# - Session id to avoid sharing the same https dump between different runs of the same tests
|
|
41
|
-
# The session id is set to the Airbyte Connection ID to ensure that no cache is shared between connections
|
|
42
|
-
return self.dagger_client.cache_volume(
|
|
43
|
-
f"{self.MITMPROXY_IMAGE}{self.hostname}{self.session_id}"
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
@property
|
|
47
|
-
def mitmproxy_dir_cache(self) -> dagger.CacheVolume:
|
|
48
|
-
return self.dagger_client.cache_volume(self.MITMPROXY_IMAGE)
|
|
49
|
-
|
|
50
|
-
def generate_mitmconfig(self):
|
|
51
|
-
return (
|
|
52
|
-
self.dagger_client.container()
|
|
53
|
-
.from_(self.MITMPROXY_IMAGE)
|
|
54
|
-
# Mitmproxy generates its self signed certs at first run, we need to run it once to generate the certs
|
|
55
|
-
# They are stored in /root/.mitmproxy
|
|
56
|
-
.with_exec(["timeout", "--preserve-status", "1", "mitmdump"])
|
|
57
|
-
.directory("/root/.mitmproxy")
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
async def get_container(self, mitm_config: dagger.Directory) -> dagger.Container:
|
|
61
|
-
"""Get a container for the mitmproxy service.
|
|
62
|
-
If a stream for server replay is provided, it will be used to replay requests to the same URL.
|
|
63
|
-
|
|
64
|
-
Returns:
|
|
65
|
-
dagger.Container: The container for the mitmproxy service.
|
|
66
|
-
mitm_config (dagger.Directory): The directory containing the mitmproxy configuration.
|
|
67
|
-
"""
|
|
68
|
-
container_addons_path = "/addons.py"
|
|
69
|
-
proxy_container = (
|
|
70
|
-
self.dagger_client.container()
|
|
71
|
-
.from_(self.MITMPROXY_IMAGE)
|
|
72
|
-
.with_exec(["mkdir", "/dumps"])
|
|
73
|
-
# This is caching the mitmproxy stream files, which can contain sensitive information
|
|
74
|
-
# We want to nuke this cache after test suite execution.
|
|
75
|
-
.with_mounted_cache("/dumps", self.dump_cache_volume)
|
|
76
|
-
# This is caching the mitmproxy self-signed certificate, no sensitive information is stored in it
|
|
77
|
-
.with_file(
|
|
78
|
-
container_addons_path,
|
|
79
|
-
self.dagger_client.host().file(self.MITM_ADDONS_PATH),
|
|
80
|
-
)
|
|
81
|
-
.with_directory("/root/.mitmproxy", mitm_config)
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
# If the proxy was instantiated with a stream for server replay from a previous run, we want to use it.
|
|
85
|
-
# Requests to the same URL will be replayed from the stream instead of being sent to the server.
|
|
86
|
-
# This is useful to avoid rate limiting issues and limits responses drifts due to time based logics.
|
|
87
|
-
if (
|
|
88
|
-
self.stream_for_server_replay is not None
|
|
89
|
-
and await self.stream_for_server_replay.size() > 0
|
|
90
|
-
):
|
|
91
|
-
proxy_container = proxy_container.with_file(
|
|
92
|
-
"/cache.mitm", self.stream_for_server_replay
|
|
93
|
-
)
|
|
94
|
-
command = [
|
|
95
|
-
"mitmdump",
|
|
96
|
-
"-s",
|
|
97
|
-
container_addons_path,
|
|
98
|
-
"--flow-detail",
|
|
99
|
-
"2",
|
|
100
|
-
"--server-replay",
|
|
101
|
-
"/cache.mitm",
|
|
102
|
-
"--save-stream-file",
|
|
103
|
-
f"/dumps/{self.MITM_STREAM_FILE}",
|
|
104
|
-
]
|
|
105
|
-
else:
|
|
106
|
-
command = [
|
|
107
|
-
"mitmdump",
|
|
108
|
-
"-s",
|
|
109
|
-
container_addons_path,
|
|
110
|
-
"--flow-detail",
|
|
111
|
-
"2",
|
|
112
|
-
"--save-stream-file",
|
|
113
|
-
f"/dumps/{self.MITM_STREAM_FILE}",
|
|
114
|
-
]
|
|
115
|
-
|
|
116
|
-
return proxy_container.with_exec(command)
|
|
117
|
-
|
|
118
|
-
async def get_service(self, mitm_config: dagger.Directory) -> dagger.Service:
|
|
119
|
-
return (
|
|
120
|
-
(await self.get_container(mitm_config))
|
|
121
|
-
.with_exposed_port(self.PROXY_PORT)
|
|
122
|
-
.as_service()
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
async def bind_container(self, container: dagger.Container) -> dagger.Container:
|
|
126
|
-
"""Bind a container to the proxy service and set environment variables to use the proxy for HTTP(S) traffic.
|
|
127
|
-
|
|
128
|
-
Args:
|
|
129
|
-
container (dagger.Container): The container to bind to the proxy service.
|
|
130
|
-
|
|
131
|
-
Returns:
|
|
132
|
-
dagger.Container: The container with the proxy service bound and environment variables set.
|
|
133
|
-
"""
|
|
134
|
-
mitmconfig_dir = self.generate_mitmconfig()
|
|
135
|
-
pem = mitmconfig_dir.file("mitmproxy-ca.pem")
|
|
136
|
-
|
|
137
|
-
# Find the python version in the container to get the correct path for the requests cert file
|
|
138
|
-
# We will overwrite this file with the mitmproxy self-signed certificate
|
|
139
|
-
# I could not find a less brutal way to make Requests trust the mitmproxy self-signed certificate
|
|
140
|
-
# I tried running update-ca-certificates + setting REQUESTS_CA_BUNDLE in the container but it did not work
|
|
141
|
-
python_version_output = (
|
|
142
|
-
await container.with_exec(["python", "--version"]).stdout()
|
|
143
|
-
).strip()
|
|
144
|
-
python_version = python_version_output.split(" ")[-1]
|
|
145
|
-
python_version_minor_only = ".".join(python_version.split(".")[:-1])
|
|
146
|
-
requests_cert_path = f"/usr/local/lib/python{python_version_minor_only}/site-packages/certifi/cacert.pem"
|
|
147
|
-
current_user = (await container.with_exec(["whoami"]).stdout()).strip()
|
|
148
|
-
try:
|
|
149
|
-
return await (
|
|
150
|
-
container.with_user("root")
|
|
151
|
-
# Overwrite the requests cert file with the mitmproxy self-signed certificate
|
|
152
|
-
.with_file(requests_cert_path, pem, owner=current_user)
|
|
153
|
-
.with_env_variable("http_proxy", f"{self.hostname}:{self.PROXY_PORT}")
|
|
154
|
-
.with_env_variable("https_proxy", f"{self.hostname}:{self.PROXY_PORT}")
|
|
155
|
-
.with_user(current_user)
|
|
156
|
-
.with_service_binding(
|
|
157
|
-
self.hostname, await self.get_service(mitmconfig_dir)
|
|
158
|
-
)
|
|
159
|
-
)
|
|
160
|
-
except dagger.DaggerError as e:
|
|
161
|
-
# This is likely hapenning on Java connector images whose certificates location is different
|
|
162
|
-
# TODO handle this case
|
|
163
|
-
logging.warn(f"Failed to bind container to proxy: {e}")
|
|
164
|
-
return container
|
|
165
|
-
|
|
166
|
-
async def retrieve_http_dump(self) -> dagger.File | None:
|
|
167
|
-
"""We mount the cache volume, where the mitmproxy container saves the stream file, to a fresh container.
|
|
168
|
-
We then copy the stream file to a new directory and return it as a dagger.File.
|
|
169
|
-
The copy operation to /to_export is required as Dagger does not support direct access to files in cache volumes.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
Returns:
|
|
173
|
-
dagger.File | None: The mitmproxy stream file if it exists, None otherwise.
|
|
174
|
-
"""
|
|
175
|
-
container = (
|
|
176
|
-
self.dagger_client.container()
|
|
177
|
-
.from_("alpine:latest")
|
|
178
|
-
.with_env_variable("CACHEBUSTER", str(uuid.uuid4()))
|
|
179
|
-
.with_mounted_cache("/dumps", self.dump_cache_volume)
|
|
180
|
-
)
|
|
181
|
-
dump_files = (await container.with_exec(["ls", "/dumps"]).stdout()).splitlines()
|
|
182
|
-
if self.MITM_STREAM_FILE not in dump_files:
|
|
183
|
-
return None
|
|
184
|
-
return await (
|
|
185
|
-
container.with_exec(["mkdir", "/to_export"], use_entrypoint=True)
|
|
186
|
-
.with_exec(
|
|
187
|
-
[
|
|
188
|
-
"cp",
|
|
189
|
-
"-r",
|
|
190
|
-
f"/dumps/{self.MITM_STREAM_FILE}",
|
|
191
|
-
f"/to_export/{self.MITM_STREAM_FILE}",
|
|
192
|
-
],
|
|
193
|
-
use_entrypoint=True,
|
|
194
|
-
)
|
|
195
|
-
.file(f"/to_export/{self.MITM_STREAM_FILE}")
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
async def clear_cache_volume(self) -> None:
|
|
199
|
-
"""Delete all files in the cache volume. This is useful to avoid caching sensitive information between tests."""
|
|
200
|
-
await (
|
|
201
|
-
self.dagger_client.container()
|
|
202
|
-
.from_("alpine:latest")
|
|
203
|
-
.with_mounted_cache("/to_clear", self.dump_cache_volume)
|
|
204
|
-
.with_exec(["rm", "-rf", "/to_clear/*"], use_entrypoint=True)
|
|
205
|
-
.sync()
|
|
206
|
-
)
|
|
207
|
-
logging.info(f"Cache volume {self.dump_cache_volume} cleared")
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import logging
|
|
5
|
-
|
|
6
|
-
from google.api_core.exceptions import PermissionDenied
|
|
7
|
-
from google.cloud import secretmanager
|
|
8
|
-
|
|
9
|
-
LIVE_TESTS_AIRBYTE_API_KEY_SECRET_ID = (
|
|
10
|
-
"projects/587336813068/secrets/live_tests_airbyte_api_key"
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def get_secret_value(
|
|
15
|
-
secret_manager_client: secretmanager.SecretManagerServiceClient, secret_id: str
|
|
16
|
-
) -> str:
|
|
17
|
-
"""Get the value of the enabled version of a secret
|
|
18
|
-
|
|
19
|
-
Args:
|
|
20
|
-
secret_manager_client (secretmanager.SecretManagerServiceClient): The secret manager client
|
|
21
|
-
secret_id (str): The id of the secret
|
|
22
|
-
|
|
23
|
-
Returns:
|
|
24
|
-
str: The value of the enabled version of the secret
|
|
25
|
-
"""
|
|
26
|
-
try:
|
|
27
|
-
response = secret_manager_client.list_secret_versions(
|
|
28
|
-
request={"parent": secret_id, "filter": "state:ENABLED"}
|
|
29
|
-
)
|
|
30
|
-
if len(response.versions) == 0:
|
|
31
|
-
raise ValueError(f"No enabled version of secret {secret_id} found")
|
|
32
|
-
enabled_version = response.versions[0]
|
|
33
|
-
response = secret_manager_client.access_secret_version(
|
|
34
|
-
name=enabled_version.name
|
|
35
|
-
)
|
|
36
|
-
return response.payload.data.decode("UTF-8")
|
|
37
|
-
except PermissionDenied as e:
|
|
38
|
-
logging.exception(
|
|
39
|
-
f"Permission denied while trying to access secret {secret_id}. Please write to #dev-tooling in Airbyte Slack for help.",
|
|
40
|
-
exc_info=e,
|
|
41
|
-
)
|
|
42
|
-
raise e
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def get_airbyte_api_key() -> str:
|
|
46
|
-
secret_manager_client = secretmanager.SecretManagerServiceClient()
|
|
47
|
-
return get_secret_value(secret_manager_client, LIVE_TESTS_AIRBYTE_API_KEY_SECRET_ID)
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import logging
|
|
5
|
-
import os
|
|
6
|
-
from importlib.metadata import version
|
|
7
|
-
from typing import Any, Optional
|
|
8
|
-
|
|
9
|
-
from segment import analytics # type: ignore
|
|
10
|
-
|
|
11
|
-
ENABLE_TRACKING = os.getenv("REGRESSION_TEST_DISABLE_TRACKING") is None
|
|
12
|
-
DEBUG_SEGMENT = os.getenv("DEBUG_SEGMENT") is not None
|
|
13
|
-
EVENT_NAME = "regression_test_start"
|
|
14
|
-
CURRENT_VERSION = version(__name__.split(".")[0])
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def on_error(error: Exception, items: Any) -> None:
|
|
18
|
-
logging.warning("An error occurred in Segment Tracking", exc_info=error)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# This is not a secret key, it is a public key that is used to identify the Segment project
|
|
22
|
-
analytics.write_key = "hnWfMdEtXNKBjvmJ258F72wShsLmcsZ8"
|
|
23
|
-
analytics.send = ENABLE_TRACKING
|
|
24
|
-
analytics.debug = DEBUG_SEGMENT
|
|
25
|
-
analytics.on_error = on_error
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def track_usage(
|
|
29
|
-
user_id: Optional[str],
|
|
30
|
-
pytest_options: dict[str, Any],
|
|
31
|
-
) -> None:
|
|
32
|
-
if user_id:
|
|
33
|
-
analytics.identify(user_id)
|
|
34
|
-
else:
|
|
35
|
-
user_id = "airbyte-ci"
|
|
36
|
-
|
|
37
|
-
# It contains default pytest option and the custom one passed by the user
|
|
38
|
-
analytics.track(
|
|
39
|
-
user_id,
|
|
40
|
-
EVENT_NAME,
|
|
41
|
-
{
|
|
42
|
-
"pytest_options": pytest_options,
|
|
43
|
-
"package_version": CURRENT_VERSION,
|
|
44
|
-
},
|
|
45
|
-
)
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import logging
|
|
5
|
-
import os
|
|
6
|
-
import re
|
|
7
|
-
import shutil
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Optional
|
|
10
|
-
|
|
11
|
-
import dagger
|
|
12
|
-
import docker # type: ignore
|
|
13
|
-
import pytest
|
|
14
|
-
from mitmproxy import http, io # type: ignore
|
|
15
|
-
from mitmproxy.addons.savehar import SaveHar # type: ignore
|
|
16
|
-
from slugify import slugify
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
async def get_container_from_id(
|
|
20
|
-
dagger_client: dagger.Client, container_id: str
|
|
21
|
-
) -> dagger.Container:
|
|
22
|
-
"""Get a dagger container from its id.
|
|
23
|
-
Please remind that container id are not persistent and can change between Dagger sessions.
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
dagger_client (dagger.Client): The dagger client to use to import the connector image
|
|
27
|
-
"""
|
|
28
|
-
try:
|
|
29
|
-
return await dagger_client.load_container_from_id(
|
|
30
|
-
dagger.ContainerID(container_id)
|
|
31
|
-
)
|
|
32
|
-
except dagger.DaggerError as e:
|
|
33
|
-
pytest.exit(f"Failed to load connector container: {e}")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
async def get_container_from_tarball_path(
|
|
37
|
-
dagger_client: dagger.Client, tarball_path: Path
|
|
38
|
-
) -> dagger.Container:
|
|
39
|
-
if not tarball_path.exists():
|
|
40
|
-
pytest.exit(f"Connector image tarball {tarball_path} does not exist")
|
|
41
|
-
container_under_test_tar_file = (
|
|
42
|
-
dagger_client.host()
|
|
43
|
-
.directory(str(tarball_path.parent), include=[tarball_path.name])
|
|
44
|
-
.file(tarball_path.name)
|
|
45
|
-
)
|
|
46
|
-
try:
|
|
47
|
-
return await dagger_client.container().import_(container_under_test_tar_file)
|
|
48
|
-
except dagger.DaggerError as e:
|
|
49
|
-
pytest.exit(f"Failed to import connector image from tarball: {e}")
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
async def get_container_from_local_image(
|
|
53
|
-
dagger_client: dagger.Client, local_image_name: str
|
|
54
|
-
) -> Optional[dagger.Container]:
|
|
55
|
-
"""Get a dagger container from a local image.
|
|
56
|
-
It will use Docker python client to export the image to a tarball and then import it into dagger.
|
|
57
|
-
|
|
58
|
-
Args:
|
|
59
|
-
dagger_client (dagger.Client): The dagger client to use to import the connector image
|
|
60
|
-
local_image_name (str): The name of the local image to import
|
|
61
|
-
|
|
62
|
-
Returns:
|
|
63
|
-
Optional[dagger.Container]: The dagger container for the local image or None if the image does not exist
|
|
64
|
-
"""
|
|
65
|
-
docker_client = docker.from_env()
|
|
66
|
-
|
|
67
|
-
try:
|
|
68
|
-
image = docker_client.images.get(local_image_name)
|
|
69
|
-
except docker.errors.ImageNotFound:
|
|
70
|
-
return None
|
|
71
|
-
|
|
72
|
-
image_digest = image.id.replace("sha256:", "")
|
|
73
|
-
tarball_path = Path(f"/tmp/{image_digest}.tar")
|
|
74
|
-
if not tarball_path.exists():
|
|
75
|
-
logging.info(
|
|
76
|
-
f"Exporting local connector image {local_image_name} to tarball {tarball_path}"
|
|
77
|
-
)
|
|
78
|
-
with open(tarball_path, "wb") as f:
|
|
79
|
-
for chunk in image.save(named=True):
|
|
80
|
-
f.write(chunk)
|
|
81
|
-
return await get_container_from_tarball_path(dagger_client, tarball_path)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
async def get_container_from_dockerhub_image(
|
|
85
|
-
dagger_client: dagger.Client, dockerhub_image_name: str
|
|
86
|
-
) -> dagger.Container:
|
|
87
|
-
"""Get a dagger container from a dockerhub image.
|
|
88
|
-
|
|
89
|
-
Args:
|
|
90
|
-
dagger_client (dagger.Client): The dagger client to use to import the connector image
|
|
91
|
-
dockerhub_image_name (str): The name of the dockerhub image to import
|
|
92
|
-
|
|
93
|
-
Returns:
|
|
94
|
-
dagger.Container: The dagger container for the dockerhub image
|
|
95
|
-
"""
|
|
96
|
-
try:
|
|
97
|
-
return await dagger_client.container().from_(dockerhub_image_name)
|
|
98
|
-
except dagger.DaggerError as e:
|
|
99
|
-
pytest.exit(f"Failed to import connector image from DockerHub: {e}")
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
async def get_connector_container(
|
|
103
|
-
dagger_client: dagger.Client, image_name_with_tag: str
|
|
104
|
-
) -> dagger.Container:
|
|
105
|
-
"""Get a dagger container for the connector image to test.
|
|
106
|
-
|
|
107
|
-
Args:
|
|
108
|
-
dagger_client (dagger.Client): The dagger client to use to import the connector image
|
|
109
|
-
image_name_with_tag (str): The docker image name and tag of the connector image to test
|
|
110
|
-
|
|
111
|
-
Returns:
|
|
112
|
-
dagger.Container: The dagger container for the connector image to test
|
|
113
|
-
"""
|
|
114
|
-
# If a container_id.txt file is available, we'll use it to load the connector container
|
|
115
|
-
# We use a txt file as container ids can be too long to be passed as env vars
|
|
116
|
-
# It's used for dagger-in-dagger use case with airbyte-ci, when the connector container is built via an upstream dagger operation
|
|
117
|
-
container_id_path = Path(f"/tmp/{slugify(image_name_with_tag)}_container_id.txt")
|
|
118
|
-
if container_id_path.exists():
|
|
119
|
-
return await get_container_from_id(dagger_client, container_id_path.read_text())
|
|
120
|
-
|
|
121
|
-
# If the CONNECTOR_UNDER_TEST_IMAGE_TAR_PATH env var is set, we'll use it to import the connector image from the tarball
|
|
122
|
-
if connector_image_tarball_path := os.environ.get(
|
|
123
|
-
"CONNECTOR_UNDER_TEST_IMAGE_TAR_PATH"
|
|
124
|
-
):
|
|
125
|
-
tarball_path = Path(connector_image_tarball_path)
|
|
126
|
-
return await get_container_from_tarball_path(dagger_client, tarball_path)
|
|
127
|
-
|
|
128
|
-
# Let's try to load the connector container from a local image
|
|
129
|
-
if connector_container := await get_container_from_local_image(
|
|
130
|
-
dagger_client, image_name_with_tag
|
|
131
|
-
):
|
|
132
|
-
return connector_container
|
|
133
|
-
|
|
134
|
-
# If we get here, we'll try to pull the connector image from DockerHub
|
|
135
|
-
return await get_container_from_dockerhub_image(dagger_client, image_name_with_tag)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def sh_dash_c(lines: list[str]) -> list[str]:
|
|
139
|
-
"""Wrap sequence of commands in shell for safe usage of dagger Container's with_exec method."""
|
|
140
|
-
return ["sh", "-c", " && ".join(["set -o xtrace"] + lines)]
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def clean_up_artifacts(directory: Path, logger: logging.Logger) -> None:
|
|
144
|
-
if directory.exists():
|
|
145
|
-
shutil.rmtree(directory)
|
|
146
|
-
logger.info(f"🧹 Test artifacts cleaned up from {directory}")
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def get_http_flows_from_mitm_dump(mitm_dump_path: Path) -> list[http.HTTPFlow]:
|
|
150
|
-
"""Get http flows from a mitmproxy dump file.
|
|
151
|
-
|
|
152
|
-
Args:
|
|
153
|
-
mitm_dump_path (Path): Path to the mitmproxy dump file.
|
|
154
|
-
|
|
155
|
-
Returns:
|
|
156
|
-
List[http.HTTPFlow]: List of http flows.
|
|
157
|
-
"""
|
|
158
|
-
with open(mitm_dump_path, "rb") as dump_file:
|
|
159
|
-
return [
|
|
160
|
-
f for f in io.FlowReader(dump_file).stream() if isinstance(f, http.HTTPFlow)
|
|
161
|
-
]
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def mitm_http_stream_to_har(mitm_http_stream_path: Path, har_file_path: Path) -> Path:
|
|
165
|
-
"""Converts a mitmproxy http stream file to a har file.
|
|
166
|
-
|
|
167
|
-
Args:
|
|
168
|
-
mitm_http_stream_path (Path): Path to the mitmproxy http stream file.
|
|
169
|
-
har_file_path (Path): Path where the har file will be saved.
|
|
170
|
-
|
|
171
|
-
Returns:
|
|
172
|
-
Path: Path to the har file.
|
|
173
|
-
"""
|
|
174
|
-
flows = get_http_flows_from_mitm_dump(mitm_http_stream_path)
|
|
175
|
-
SaveHar().export_har(flows, str(har_file_path))
|
|
176
|
-
return har_file_path
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def extract_connection_id_from_url(url: str) -> str:
|
|
180
|
-
pattern = r"/connections/([a-f0-9\-]+)"
|
|
181
|
-
match = re.search(pattern, url)
|
|
182
|
-
if match:
|
|
183
|
-
return match.group(1)
|
|
184
|
-
else:
|
|
185
|
-
raise ValueError(f"Could not extract connection id from url {url}")
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def extract_workspace_id_from_url(url: str) -> str:
|
|
189
|
-
pattern = r"/workspaces/([a-f0-9\-]+)"
|
|
190
|
-
match = re.search(pattern, url)
|
|
191
|
-
if match:
|
|
192
|
-
return match.group(1)
|
|
193
|
-
else:
|
|
194
|
-
raise ValueError(f"Could not extract workspace id from url {url}")
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def build_connection_url(workspace_id: str | None, connection_id: str | None) -> str:
|
|
198
|
-
if not workspace_id or not connection_id:
|
|
199
|
-
raise ValueError("Both workspace_id and connection_id must be provided")
|
|
200
|
-
return f"https://cloud.airbyte.com/workspaces/{workspace_id}/connections/{connection_id}"
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def sort_dict_keys(d: dict) -> dict:
|
|
204
|
-
if isinstance(d, dict):
|
|
205
|
-
sorted_dict = {}
|
|
206
|
-
for key in sorted(d.keys()):
|
|
207
|
-
sorted_dict[key] = sort_dict_keys(d[key])
|
|
208
|
-
return sorted_dict
|
|
209
|
-
else:
|
|
210
|
-
return d
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def sanitize_stream_name(stream_name: str) -> str:
|
|
214
|
-
return stream_name.replace("/", "_").replace(" ", "_").lower()
|