Skip to content

Commit cf305ad

Browse files
make airbyte-ci pass a parameter to the java connectors to the location of the logs
1 parent d520990 commit cf305ad

File tree

6 files changed

+123
-92
lines changed

6 files changed

+123
-92
lines changed

airbyte-ci/connectors/pipelines/pipelines/airbyte_ci/connectors/reports.py

+48-4
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
#
44
from __future__ import annotations
55

6+
import asyncio
67
import json
8+
import os
79
import webbrowser
810
from dataclasses import dataclass
9-
from typing import TYPE_CHECKING
11+
from types import MappingProxyType
12+
from typing import TYPE_CHECKING, Optional
13+
from zipfile import ZIP_DEFLATED, ZipFile
1014

1115
from anyio import Path
1216
from connector_ops.utils import console # type: ignore
@@ -42,13 +46,19 @@ def report_output_prefix(self) -> str:
4246
def html_report_file_name(self) -> str:
4347
return self.filename + ".html"
4448

49+
def file_remote_storage_key(self, file_name: str) -> str:
50+
return f"{self.report_output_prefix}/{file_name}"
51+
4552
@property
4653
def html_report_remote_storage_key(self) -> str:
47-
return f"{self.report_output_prefix}/{self.html_report_file_name}"
54+
return self.file_remote_storage_key(self.html_report_file_name)
55+
56+
def file_url(self, file_name: str) -> str:
57+
return f"{GCS_PUBLIC_DOMAIN}/{self.pipeline_context.ci_report_bucket}/{self.file_remote_storage_key(file_name)}"
4858

4959
@property
5060
def html_report_url(self) -> str:
51-
return f"{GCS_PUBLIC_DOMAIN}/{self.pipeline_context.ci_report_bucket}/{self.html_report_remote_storage_key}"
61+
return self.file_url(self.html_report_file_name)
5262

5363
def to_json(self) -> str:
5464
"""Create a JSON representation of the connector test report.
@@ -92,6 +102,11 @@ async def to_html(self) -> str:
92102
template.globals["StepStatus"] = StepStatus
93103
template.globals["format_duration"] = format_duration
94104
local_icon_path = await Path(f"{self.pipeline_context.connector.code_directory}/icon.svg").resolve()
105+
step_result_to_artifact_link = {}
106+
for step_result in self.steps_results:
107+
test_artifacts_link = await self.get_path_as_link(step_result.test_artifacts_path)
108+
if test_artifacts_link:
109+
step_result_to_artifact_link[step_result.step.title] = test_artifacts_link
95110
template_context = {
96111
"connector_name": self.pipeline_context.connector.technical_name,
97112
"step_results": self.steps_results,
@@ -104,6 +119,8 @@ async def to_html(self) -> str:
104119
"git_revision": self.pipeline_context.git_revision,
105120
"commit_url": None,
106121
"icon_url": local_icon_path.as_uri(),
122+
"report": self,
123+
"step_result_to_artifact_link": MappingProxyType(step_result_to_artifact_link),
107124
}
108125

109126
if self.pipeline_context.is_ci:
@@ -126,7 +143,6 @@ async def save(self) -> None:
126143
webbrowser.open(absolute_path.as_uri())
127144
if self.remote_storage_enabled:
128145
await self.save_remote(local_html_path, self.html_report_remote_storage_key, "text/html")
129-
self.pipeline_context.logger.info(f"HTML report uploaded to {self.html_report_url}")
130146
await super().save()
131147

132148
def print(self) -> None:
@@ -155,3 +171,31 @@ def print(self) -> None:
155171

156172
main_panel = Panel(Group(*to_render), title=main_panel_title, subtitle=duration_subtitle)
157173
console.print(main_panel)
174+
175+
async def get_path_as_link(self, path: Path) -> Optional[str]:
176+
if not path or not path.exists():
177+
return None
178+
if self.pipeline_context.is_local:
179+
return str(path.resolve())
180+
181+
zip_file_path = Path(str(path) + ".zip")
182+
with ZipFile(zip_file_path, mode="w") as zip_file:
183+
# lifted from https://github.com/python/cpython/blob/3.12/Lib/zipfile/__init__.py#L2277C9-L2295C44
184+
def add_to_zip(zf: ZipFile, path_to_zip: str, zippath: str) -> None:
185+
if os.path.isfile(path_to_zip):
186+
zf.write(path_to_zip, zippath, ZIP_DEFLATED)
187+
elif os.path.isdir(path_to_zip):
188+
if zippath:
189+
zf.write(path_to_zip, zippath)
190+
for nm in sorted(os.listdir(path_to_zip)):
191+
add_to_zip(zf, os.path.join(path_to_zip, nm), os.path.join(zippath, nm))
192+
193+
add_to_zip(zip_file, str(path), "")
194+
195+
if not self.remote_storage_enabled:
196+
self.pipeline_context.logger.info(f"remote storage is disable. zip file is at {zip_file_path.resolve()}")
197+
return str(zip_file_path.resolve())
198+
else:
199+
await self.save_remote(zip_file_path, self.file_remote_storage_key(zip_file_path.name), "application/zip")
200+
self.pipeline_context.logger.info(f"zip file uploaded to {self.file_url(str(zip_file_path))}")
201+
return self.file_url(zip_file_path.name)

airbyte-ci/connectors/pipelines/pipelines/airbyte_ci/connectors/test/steps/java_connectors.py

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class IntegrationTests(GradleTask):
3939
mount_connector_secrets = True
4040
bind_to_docker_host = True
4141
with_test_report = True
42+
with_logs = True
4243

4344
@property
4445
def default_params(self) -> STEP_PARAMS:
@@ -77,6 +78,7 @@ class UnitTests(GradleTask):
7778
gradle_task_name = "test"
7879
bind_to_docker_host = True
7980
with_test_report = True
81+
with_logs = True
8082

8183

8284
def _create_integration_step_args_factory(context: ConnectorContext) -> Callable:

airbyte-ci/connectors/pipelines/pipelines/airbyte_ci/connectors/test/steps/templates/test_report.html.j2

+18-7
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
transition: max-height .25s ease-in-out;
8383
}
8484
.toggle:checked + .lbl-toggle + .collapsible-content {
85-
max-height: 100vh;
85+
max-height: 70vh;
8686
}
8787
.toggle:checked + .lbl-toggle {
8888
border-bottom-right-radius: 0;
@@ -110,6 +110,18 @@
110110
}
111111
</style>
112112
</head>
113+
<script>
114+
function copyToClipBoard(htmlElement) {
115+
var copyText = htmlElement.parentNode.parentNode.getElementsByTagName('pre')[0].innerText;
116+
htmlElement.parentNode.parentNode.getElementsByTagName('pre')[0].setSelectionRange
117+
118+
navigator.clipboard.writeText(copyText).then(() => {
119+
alert("successfully copied");
120+
})
121+
.catch((e) => {
122+
alert("something went wrong : " + e);
123+
});
124+
}
113125
<body>
114126
<h1><img src="{{ icon_url }}" width="40" height="40"> {{ connector_name }} test report</h1>
115127
<ul>
@@ -159,19 +171,18 @@
159171
<label for="{{ step_result.step.title }}" class="lbl-toggle">{{ step_result.step.title }} | {{ format_duration(step_result.step.run_duration) }}</label>
160172
{% endif %}
161173
<div class="collapsible-content">
174+
{% if step_result_to_artifact_link[step_result.step.title] %}
175+
<div><a href="{{ step_result_to_artifact_link[step_result.step.title] }}">Test Artifacts</a></div>
176+
{% endif %}
162177
<div class="content-inner">
163178
{% if step_result.stdout %}
164-
<span class="std">Standard output:</span>
179+
<span class="std">Standard output(<button onclick="copyToClipBoard(this)">Copy to clipboard</button>):</span>
165180
<pre>{{ step_result.stdout }}</pre>
166181
{% endif %}
167182
{% if step_result.stderr %}
168-
<span class="std">Standard error:</span>
183+
<span class="std">Standard error(<button onclick="copyToClipBoard(this)">Copy to clipboard</button>):</span>
169184
<pre>{{ step_result.stderr }}</pre>
170185
{% endif %}
171-
{% if step_result.report %}
172-
<span class="std">Report:</span>
173-
<pre lang="xml">{{ step_result.report }}</pre>
174-
{% endif %}
175186
</div>
176187
</div>
177188
</div>

airbyte-ci/connectors/pipelines/pipelines/airbyte_ci/steps/gradle.py

+53-80
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
#
22
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
33
#
4-
import html
5-
import re
64
from abc import ABC
7-
from typing import Any, ClassVar, List, Optional, Tuple
5+
from datetime import datetime
6+
from pathlib import Path
7+
from typing import Any, ClassVar, List, Optional, Tuple, cast
88

99
import pipelines.dagger.actions.system.docker
10-
import xmltodict
1110
from dagger import CacheSharingMode, CacheVolume, Container, QueryError
1211
from pipelines.airbyte_ci.connectors.context import ConnectorContext
1312
from pipelines.consts import AMAZONCORRETTO_IMAGE
@@ -37,8 +36,18 @@ class GradleTask(Step, ABC):
3736
bind_to_docker_host: ClassVar[bool] = False
3837
mount_connector_secrets: ClassVar[bool] = False
3938
with_test_report: ClassVar[bool] = False
39+
with_logs: ClassVar[bool] = False
4040
accept_extra_params = True
4141

42+
@property
43+
def airbyte_logs_subdir(self) -> str:
44+
return datetime.fromtimestamp(cast(float, self.context.pipeline_start_timestamp)).isoformat() + "-" + self.gradle_task_name
45+
46+
@property
47+
def test_artifacts_path(self) -> Path:
48+
test_artifacts_path = f"{self.context.connector.code_directory}/build/test-artifacts/{self.airbyte_logs_subdir}"
49+
return Path(test_artifacts_path)
50+
4251
@property
4352
def gradle_task_options(self) -> Tuple[str, ...]:
4453
return self.STATIC_GRADLE_OPTIONS + (f"-Ds3BuildCachePrefix={self.context.connector.technical_name}",)
@@ -68,22 +77,22 @@ def _get_gradle_command(self, task: str, *args: Any, task_options: Optional[List
6877

6978
async def _run(self, *args: Any, **kwargs: Any) -> StepResult:
7079
include = [
71-
".root",
72-
".env",
73-
"build.gradle",
74-
"deps.toml",
75-
"gradle.properties",
76-
"gradle",
77-
"gradlew",
78-
"settings.gradle",
79-
"build.gradle",
80-
"tools/gradle",
81-
"spotbugs-exclude-filter-file.xml",
82-
"buildSrc",
83-
"tools/bin/build_image.sh",
84-
"tools/lib/lib.sh",
85-
"pyproject.toml",
86-
] + self.build_include
80+
".root",
81+
".env",
82+
"build.gradle",
83+
"deps.toml",
84+
"gradle.properties",
85+
"gradle",
86+
"gradlew",
87+
"settings.gradle",
88+
"build.gradle",
89+
"tools/gradle",
90+
"spotbugs-exclude-filter-file.xml",
91+
"buildSrc",
92+
"tools/bin/build_image.sh",
93+
"tools/lib/lib.sh",
94+
"pyproject.toml",
95+
] + self.build_include
8796

8897
yum_packages_to_install = [
8998
"docker", # required by :integrationTestJava.
@@ -102,7 +111,7 @@ async def _run(self, *args: Any, **kwargs: Any) -> StepResult:
102111
# Set GRADLE_HOME to the directory which will be rsync-ed with the gradle cache volume.
103112
.with_env_variable("GRADLE_HOME", self.GRADLE_HOME_PATH)
104113
# Same for GRADLE_USER_HOME.
105-
.with_env_variable("GRADLE_USER_HOME", self.GRADLE_HOME_PATH)
114+
.with_env_variable("GRADLE_USER_HOME", self.GRADLE_HOME_PATH).with_env_variable("AIRBYTE_LOG_SUBDIR", self.airbyte_logs_subdir)
106115
# Install a bunch of packages as early as possible.
107116
.with_exec(
108117
sh_dash_c(
@@ -193,6 +202,8 @@ async def _run(self, *args: Any, **kwargs: Any) -> StepResult:
193202
connector_gradle_task = f":airbyte-integrations:connectors:{self.context.connector.technical_name}:{self.gradle_task_name}"
194203
gradle_command = self._get_gradle_command(connector_gradle_task, task_options=self.params_as_cli_options)
195204
gradle_container = gradle_container.with_(never_fail_exec([gradle_command]))
205+
await self._collect_logs(gradle_container)
206+
await self._collect_test_report(gradle_container)
196207
return await self.get_step_result(gradle_container)
197208

198209
async def get_step_result(self, container: Container) -> StepResult:
@@ -203,76 +214,38 @@ async def get_step_result(self, container: Container) -> StepResult:
203214
status=step_result.status,
204215
stdout=step_result.stdout,
205216
stderr=step_result.stderr,
206-
report=await self._collect_test_report(container),
207217
output_artifact=step_result.output_artifact,
218+
test_artifacts_path=self.test_artifacts_path,
208219
)
209220

210-
async def _collect_test_report(self, gradle_container: Container) -> Optional[str]:
221+
async def _collect_logs(self, gradle_container: Container) -> None:
222+
if not self.with_logs:
223+
return None
224+
logs_dir_path = f"{self.context.connector.code_directory}/build/test-logs/{self.airbyte_logs_subdir}"
225+
try:
226+
container_logs_dir = await gradle_container.directory(logs_dir_path)
227+
# the gradle task didn't create any logs.
228+
if not container_logs_dir:
229+
return None
230+
231+
self.test_artifacts_path.mkdir(parents=True, exist_ok=True)
232+
if not await container_logs_dir.export(str(self.test_artifacts_path)):
233+
self.context.logger.error("Error when trying to export log files from container")
234+
except QueryError as e:
235+
self.context.logger.error(str(e))
236+
self.context.logger.warn(f"Failed to retrieve junit test results from {logs_dir_path} gradle container.")
237+
return None
238+
239+
async def _collect_test_report(self, gradle_container: Container) -> None:
211240
if not self.with_test_report:
212241
return None
213242

214243
junit_xml_path = f"{self.context.connector.code_directory}/build/test-results/{self.gradle_task_name}"
215-
testsuites = []
216244
try:
217245
junit_xml_dir = await gradle_container.directory(junit_xml_path)
218246
for file_name in await junit_xml_dir.entries():
219247
if file_name.endswith(".xml"):
220-
junit_xml = await junit_xml_dir.file(file_name).contents()
221-
# This will be embedded in the HTML report in a <pre lang="xml"> block.
222-
# The java logging backend will have already taken care of masking any secrets.
223-
# Nothing to do in that regard.
224-
try:
225-
if testsuite := xmltodict.parse(junit_xml):
226-
testsuites.append(testsuite)
227-
except Exception as e:
228-
self.context.logger.error(str(e))
229-
self.context.logger.warn(f"Failed to parse junit xml file {file_name}.")
248+
export_succeeded = await junit_xml_dir.file(file_name).export(str(self.test_artifacts_path), allow_parent_dir_path=True)
230249
except QueryError as e:
231250
self.context.logger.error(str(e))
232-
self.context.logger.warn(f"Failed to retrieve junit test results from {junit_xml_path} gradle container.")
233251
return None
234-
return render_junit_xml(testsuites)
235-
236-
237-
MAYBE_STARTS_WITH_XML_TAG = re.compile("^ *<")
238-
ESCAPED_ANSI_COLOR_PATTERN = re.compile(r"\?\[0?m|\?\[[34][0-9]m")
239-
240-
241-
def render_junit_xml(testsuites: List[Any]) -> str:
242-
"""Renders the JUnit XML report as something readable in the HTML test report."""
243-
# Transform the dict contents.
244-
indent = " "
245-
for testsuite in testsuites:
246-
testsuite = testsuite.get("testsuite")
247-
massage_system_out_and_err(testsuite, indent, 4)
248-
if testcases := testsuite.get("testcase"):
249-
if not isinstance(testcases, list):
250-
testcases = [testcases]
251-
for testcase in testcases:
252-
massage_system_out_and_err(testcase, indent, 5)
253-
# Transform back to XML string.
254-
# Try to respect the JUnit XML test result schema.
255-
root = {"testsuites": {"testsuite": testsuites}}
256-
xml = xmltodict.unparse(root, pretty=True, short_empty_elements=True, indent=indent)
257-
# Escape < and > and so forth to make them render properly, but not in the log messages.
258-
# These lines will already have been escaped by xmltodict.unparse.
259-
lines = xml.splitlines()
260-
for idx, line in enumerate(lines):
261-
if MAYBE_STARTS_WITH_XML_TAG.match(line):
262-
lines[idx] = html.escape(line)
263-
return "\n".join(lines)
264-
265-
266-
def massage_system_out_and_err(d: dict, indent: str, indent_levels: int) -> None:
267-
"""Makes the system-out and system-err text prettier."""
268-
if d:
269-
for key in ["system-out", "system-err"]:
270-
if s := d.get(key):
271-
lines = s.splitlines()
272-
s = ""
273-
for line in lines:
274-
stripped = line.strip()
275-
if stripped:
276-
s += "\n" + indent * indent_levels + ESCAPED_ANSI_COLOR_PATTERN.sub("", line.strip())
277-
s = s + "\n" + indent * (indent_levels - 1) if s else None
278-
d[key] = s

airbyte-ci/connectors/pipelines/pipelines/models/reports.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,9 @@ async def save_local(self, filename: str, content: str) -> Path:
9393

9494
async def save_remote(self, local_path: Path, remote_key: str, content_type: str) -> int:
9595
assert self.pipeline_context.ci_report_bucket is not None, "The ci_report_bucket must be set to save reports."
96-
9796
gcs_cp_flags = None if content_type is None else [f"--content-type={content_type}"]
9897
local_file = self.pipeline_context.dagger_client.host().directory(".", include=[str(local_path)]).file(str(local_path))
98+
9999
report_upload_exit_code, _, _ = await remote_storage.upload_to_gcs(
100100
dagger_client=self.pipeline_context.dagger_client,
101101
file_to_upload=local_file,

airbyte-ci/connectors/pipelines/pipelines/models/steps.py

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class Result:
7474
report: Optional[str] = None
7575
exc_info: Optional[Exception] = None
7676
output_artifact: Any = None
77+
test_artifacts_path: Optional[Path] = None
7778

7879
@property
7980
def success(self) -> bool:

0 commit comments

Comments
 (0)