Skip to content

Commit 3d521a4

Browse files
alafanecherestephane-airbyte
authored andcommitted
airbyte-ci: upload test artifacts along with reports
1 parent 60e71fc commit 3d521a4

File tree

8 files changed

+223
-135
lines changed

8 files changed

+223
-135
lines changed

airbyte-cdk/java/airbyte-cdk/dependencies/src/main/resources/log4j2-test.xml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
<Property name="container-log-pattern">%d{yyyy-MM-dd'T'HH:mm:ss,SSS}{GMT+0}`%replace{%X{log_source}}{^ -}{} > %replace{%m}{$${env:LOG_SCRUB_PATTERN:-\*\*\*\*\*}}{*****}%n</Property>
88
<!-- Always log INFO by default. -->
99
<Property name="log-level">${sys:LOG_LEVEL:-${env:LOG_LEVEL:-INFO}}</Property>
10-
<Property name="logSubDir">${env:AIRBYTE_LOG_SUBDIR:-${date:yyyy-MM-dd'T'HH:mm:ss}}</Property>
11-
<Property name="logDir">build/test-logs/${logSubDir}</Property>
10+
<Property name="logDir">build/test-logs/${date:yyyy-MM-dd'T'HH:mm:ss}</Property>
1211
</Properties>
1312

1413
<Appenders>

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

Lines changed: 38 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,17 @@
44
from __future__ import annotations
55

66
import json
7-
import os
87
import webbrowser
98
from dataclasses import dataclass
109
from pathlib import Path
1110
from types import MappingProxyType
12-
from typing import TYPE_CHECKING, Optional
13-
from zipfile import ZIP_DEFLATED, ZipFile
11+
from typing import TYPE_CHECKING, Dict
1412

1513
from connector_ops.utils import console # type: ignore
1614
from jinja2 import Environment, PackageLoader, select_autoescape
1715
from pipelines.consts import GCS_PUBLIC_DOMAIN
1816
from pipelines.helpers.utils import format_duration
17+
from pipelines.models.artifacts import Artifact
1918
from pipelines.models.reports import Report
2019
from pipelines.models.steps import StepStatus
2120
from rich.console import Group
@@ -90,7 +89,7 @@ def to_json(self) -> str:
9089
}
9190
)
9291

93-
async def to_html(self) -> str:
92+
def to_html(self) -> str:
9493
env = Environment(
9594
loader=PackageLoader("pipelines.airbyte_ci.connectors.test.steps"),
9695
autoescape=select_autoescape(),
@@ -101,10 +100,17 @@ async def to_html(self) -> str:
101100
template.globals["StepStatus"] = StepStatus
102101
template.globals["format_duration"] = format_duration
103102
local_icon_path = Path(f"{self.pipeline_context.connector.code_directory}/icon.svg").resolve()
104-
step_result_to_artifact_link = {}
103+
step_result_to_artifact_links: Dict[str, List[Dict]] = {}
105104
for step_result in self.steps_results:
106-
if test_artifacts_link := await self.upload_path(step_result.test_artifacts_path):
107-
step_result_to_artifact_link[step_result.step.title] = test_artifacts_link
105+
for artifact in step_result.artifacts:
106+
if artifact.gcs_url:
107+
url = artifact.gcs_url
108+
elif artifact.local_path:
109+
url = artifact.local_path.resolve().as_uri()
110+
else:
111+
continue
112+
step_result_to_artifact_links.setdefault(step_result.step.title, []).append({"name": artifact.name, "url": url})
113+
108114
template_context = {
109115
"connector_name": self.pipeline_context.connector.technical_name,
110116
"step_results": self.steps_results,
@@ -118,7 +124,7 @@ async def to_html(self) -> str:
118124
"commit_url": None,
119125
"icon_url": local_icon_path.as_uri(),
120126
"report": self,
121-
"step_result_to_artifact_link": MappingProxyType(step_result_to_artifact_link),
127+
"step_result_to_artifact_links": MappingProxyType(step_result_to_artifact_links),
122128
}
123129

124130
if self.pipeline_context.is_ci:
@@ -131,17 +137,32 @@ async def to_html(self) -> str:
131137
] = f"https://raw.githubusercontent.com/airbytehq/airbyte/{self.pipeline_context.git_revision}/{self.pipeline_context.connector.code_directory}/icon.svg"
132138
return template.render(template_context)
133139

140+
async def save_html_report(self) -> None:
141+
"""Save the report as HTML, upload it to GCS if the pipeline is running in CI"""
142+
143+
html_report_path = self.report_dir_path / self.html_report_file_name
144+
report_dir = self.pipeline_context.dagger_client.host().directory(str(self.report_dir_path))
145+
local_html_report_file = report_dir.with_new_file(self.html_report_file_name, self.to_html()).file(self.html_report_file_name)
146+
html_report_artifact = Artifact(name="HTML Report", content_type="text/html", content=local_html_report_file)
147+
await html_report_artifact.save_to_local_path(html_report_path)
148+
absolute_path = html_report_path.absolute()
149+
self.pipeline_context.logger.info(f"Report saved locally at {absolute_path}")
150+
if self.remote_storage_enabled and self.pipeline_context.ci_gcs_credentials_secret and self.pipeline_context.ci_report_bucket:
151+
gcs_url = await html_report_artifact.upload_to_gcs(
152+
dagger_client=self.pipeline_context.dagger_client,
153+
bucket=self.pipeline_context.ci_report_bucket,
154+
key=self.html_report_remote_storage_key,
155+
gcs_credentials=self.pipeline_context.ci_gcs_credentials_secret,
156+
)
157+
self.pipeline_context.logger.info(f"HTML report uploaded to {gcs_url}")
158+
159+
elif self.pipeline_context.enable_report_auto_open:
160+
self.pipeline_context.logger.info("Opening HTML report in browser.")
161+
webbrowser.open(absolute_path.as_uri())
162+
134163
async def save(self) -> None:
135-
local_html_path = await self.save_local(self.html_report_file_name, await self.to_html())
136-
absolute_path = local_html_path.resolve()
137-
if self.pipeline_context.enable_report_auto_open:
138-
self.pipeline_context.logger.info(f"HTML report saved locally: {absolute_path}")
139-
if self.pipeline_context.enable_report_auto_open:
140-
self.pipeline_context.logger.info("Opening HTML report in browser.")
141-
webbrowser.open(absolute_path.as_uri())
142-
if self.remote_storage_enabled:
143-
await self.save_remote(local_html_path, self.html_report_remote_storage_key, "text/html")
144164
await super().save()
165+
await self.save_html_report()
145166

146167
def print(self) -> None:
147168
"""Print the test report to the console in a nice way."""
@@ -169,31 +190,3 @@ def print(self) -> None:
169190

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

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,13 @@ function copyToClipBoard(htmlElement) {
167167
<label for="{{ step_result.step.title }}" class="lbl-toggle">{{ step_result.step.title }} | {{ format_duration(step_result.step.run_duration) }}</label>
168168
{% endif %}
169169
<div class="collapsible-content">
170-
{% if step_result_to_artifact_link[step_result.step.title] %}
171-
<div><a href="{{ step_result_to_artifact_link[step_result.step.title] }}">Test Artifacts</a></div>
170+
{% if step_result_to_artifact_links[step_result.step.title] %}
171+
<h3>Artifacts</h3>
172+
<ul>
173+
{% for artifact in step_result_to_artifact_links[step_result.step.title] %}
174+
<li><a href="{{ artifact.url }}">{{ artifact.name }}</a></li>
175+
{% endfor %}
176+
</ul>
172177
{% endif %}
173178
<div class="content-inner">
174179
{% if step_result.stdout %}

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

Lines changed: 55 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@
22
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
33
#
44
from abc import ABC
5-
from datetime import datetime
6-
from pathlib import Path
7-
from typing import Any, ClassVar, List, Optional, Tuple, cast
5+
from typing import Any, ClassVar, List, Optional, Tuple
86

97
import pipelines.dagger.actions.system.docker
10-
from dagger import CacheSharingMode, CacheVolume, Container, QueryError
8+
from dagger import CacheSharingMode, CacheVolume, Container, ExecError
119
from pipelines.airbyte_ci.connectors.context import ConnectorContext
1210
from pipelines.consts import AMAZONCORRETTO_IMAGE
1311
from pipelines.dagger.actions import secrets
1412
from pipelines.hacks import never_fail_exec
15-
from pipelines.helpers.utils import sh_dash_c
13+
from pipelines.helpers.utils import dagger_directory_as_zip_file, sh_dash_c
14+
from pipelines.models.artifacts import Artifact
1615
from pipelines.models.steps import Step, StepResult
1716

1817

@@ -38,15 +37,6 @@ class GradleTask(Step, ABC):
3837
with_test_artifacts: ClassVar[bool] = False
3938
accept_extra_params = True
4039

41-
@property
42-
def airbyte_logs_subdir(self) -> str:
43-
return datetime.fromtimestamp(cast(float, self.context.pipeline_start_timestamp)).isoformat() + "-" + self.gradle_task_name
44-
45-
@property
46-
def test_artifacts_path(self) -> Path:
47-
test_artifacts_path = f"{self.context.connector.code_directory}/build/test-artifacts/{self.airbyte_logs_subdir}"
48-
return Path(test_artifacts_path)
49-
5040
@property
5141
def gradle_task_options(self) -> Tuple[str, ...]:
5242
return self.STATIC_GRADLE_OPTIONS + (f"-Ds3BuildCachePrefix={self.context.connector.technical_name}",)
@@ -111,8 +101,6 @@ async def _run(self, *args: Any, **kwargs: Any) -> StepResult:
111101
.with_env_variable("GRADLE_HOME", self.GRADLE_HOME_PATH)
112102
# Same for GRADLE_USER_HOME.
113103
.with_env_variable("GRADLE_USER_HOME", self.GRADLE_HOME_PATH)
114-
# Set the AIRBYTE_LOG_SUBDIR for log4j
115-
.with_env_variable("AIRBYTE_LOG_SUBDIR", self.airbyte_logs_subdir)
116104
# Install a bunch of packages as early as possible.
117105
.with_exec(
118106
sh_dash_c(
@@ -203,11 +191,18 @@ async def _run(self, *args: Any, **kwargs: Any) -> StepResult:
203191
connector_gradle_task = f":airbyte-integrations:connectors:{self.context.connector.technical_name}:{self.gradle_task_name}"
204192
gradle_command = self._get_gradle_command(connector_gradle_task, task_options=self.params_as_cli_options)
205193
gradle_container = gradle_container.with_(never_fail_exec([gradle_command]))
206-
await self._collect_logs(gradle_container)
207-
await self._collect_test_report(gradle_container)
208-
return await self.get_step_result(gradle_container)
209194

210-
async def get_step_result(self, container: Container) -> StepResult:
195+
# Collect the test artifacts, if applicable.
196+
artifacts = []
197+
if self.with_test_artifacts:
198+
if test_logs := await self._collect_test_logs(gradle_container):
199+
artifacts.append(test_logs)
200+
if test_results := await self._collect_test_results(gradle_container):
201+
artifacts.append(test_results)
202+
203+
return await self.get_step_result(gradle_container, artifacts)
204+
205+
async def get_step_result(self, container: Container, output_artifacts: List[Artifact]) -> StepResult:
211206
step_result = await super().get_step_result(container)
212207
# Decorate with test report, if applicable.
213208
return StepResult(
@@ -216,48 +211,62 @@ async def get_step_result(self, container: Container) -> StepResult:
216211
stdout=step_result.stdout,
217212
stderr=step_result.stderr,
218213
output_artifact=step_result.output_artifact,
219-
test_artifacts_path=self.test_artifacts_path,
214+
artifacts=output_artifacts,
220215
)
221216

222-
async def _collect_logs(self, gradle_container: Container) -> None:
217+
async def _collect_test_logs(self, gradle_container: Container) -> Optional[Artifact]:
223218
"""
224219
Exports the java docs from the container into the host filesystem.
225220
The docs in the container are expected to be in build/test-logs, and will end up test-artifact directory by default
226-
One can change the destination directory by setting the test_artifacts_path
221+
One can change the destination directory by setting the output_artifacts
227222
"""
228-
if not self.with_test_artifacts:
223+
test_logs_dir_name = "test-logs"
224+
if test_logs_dir_name not in await gradle_container.directory(f"{self.context.connector.code_directory}/build").entries():
225+
self.context.logger.warn(f"No {test_logs_dir_name} found directory in the build folder")
229226
return None
230-
logs_dir_path = f"{self.context.connector.code_directory}/build/test-logs/{self.airbyte_logs_subdir}"
231227
try:
232-
container_logs_dir = await gradle_container.directory(logs_dir_path)
233-
# the gradle task didn't create any logs.
234-
if not container_logs_dir:
235-
return None
236-
237-
self.test_artifacts_path.mkdir(parents=True, exist_ok=True)
238-
if not await container_logs_dir.export(str(self.test_artifacts_path)):
239-
self.context.logger.error("Error when trying to export log files from container")
240-
except QueryError as e:
228+
zip_file = await (
229+
dagger_directory_as_zip_file(
230+
self.dagger_client,
231+
await gradle_container.directory(f"{self.context.connector.code_directory}/build/{test_logs_dir_name}"),
232+
test_logs_dir_name,
233+
)
234+
)
235+
return Artifact(
236+
name="test-logs.zip",
237+
content=zip_file,
238+
content_type="application/zip",
239+
to_upload=True,
240+
)
241+
except ExecError as e:
241242
self.context.logger.error(str(e))
242-
self.context.logger.warn(f"Failed to retrieve junit test results from {logs_dir_path} gradle container.")
243-
return None
243+
return None
244244

245-
async def _collect_test_report(self, gradle_container: Container) -> None:
245+
async def _collect_test_results(self, gradle_container: Container) -> Optional[Artifact]:
246246
"""
247247
Exports the junit test reports from the container into the host filesystem.
248248
The docs in the container are expected to be in build/test-results, and will end up test-artifact directory by default
249249
Only the XML files generated by junit are downloaded into the host filesystem
250-
One can change the destination directory by setting the test_artifacts_path
250+
One can change the destination directory by setting the output_artifacts
251251
"""
252-
if not self.with_test_artifacts:
252+
test_results_dir_name = "test-results"
253+
if test_results_dir_name not in await gradle_container.directory(f"{self.context.connector.code_directory}/build").entries():
254+
self.context.logger.warn(f"No {test_results_dir_name} found directory in the build folder")
253255
return None
254-
255-
junit_xml_path = f"{self.context.connector.code_directory}/build/test-results/{self.gradle_task_name}"
256256
try:
257-
junit_xml_dir = await gradle_container.directory(junit_xml_path)
258-
for file_name in await junit_xml_dir.entries():
259-
if file_name.endswith(".xml"):
260-
await junit_xml_dir.file(file_name).export(str(self.test_artifacts_path), allow_parent_dir_path=True)
261-
except QueryError as e:
257+
zip_file = await (
258+
dagger_directory_as_zip_file(
259+
self.dagger_client,
260+
await gradle_container.directory(f"{self.context.connector.code_directory}/build/{test_results_dir_name}"),
261+
test_results_dir_name,
262+
)
263+
)
264+
return Artifact(
265+
name="test-results.zip",
266+
content=zip_file,
267+
content_type="application/zip",
268+
to_upload=True,
269+
)
270+
except ExecError as e:
262271
self.context.logger.error(str(e))
263272
return None

airbyte-ci/connectors/pipelines/pipelines/helpers/utils.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import anyio
2020
import asyncclick as click
2121
import asyncer
22-
from dagger import Client, Config, Container, ExecError, File, ImageLayerCompression, Platform, Secret
22+
from dagger import Client, Config, Container, Directory, ExecError, File, ImageLayerCompression, Platform, Secret
2323
from more_itertools import chunked
2424

2525
if TYPE_CHECKING:
@@ -353,3 +353,24 @@ def java_log_scrub_pattern(secrets_to_mask: List[str]) -> str:
353353
":": "&#58;",
354354
},
355355
)
356+
357+
358+
def dagger_directory_as_zip_file(dagger_client: Client, directory: Directory, directory_name: str) -> File:
359+
"""Compress a directory and return a File object representing the zip file.
360+
361+
Args:
362+
dagger_client (Client): The dagger client.
363+
directory (Path): The directory to compress.
364+
directory_name (str): The name of the directory.
365+
366+
Returns:
367+
File: The File object representing the zip file.
368+
"""
369+
return (
370+
dagger_client.container()
371+
.from_("alpine:3.19.1")
372+
.with_exec(sh_dash_c(["apk update", "apk add zip"]))
373+
.with_mounted_directory(f"/{directory_name}", directory)
374+
.with_exec(["zip", "-r", "/zipped.zip", f"/{directory_name}"])
375+
.file("/zipped.zip")
376+
)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
2+
3+
from dataclasses import dataclass
4+
from pathlib import Path
5+
from typing import Optional
6+
7+
import dagger
8+
from pipelines.consts import GCS_PUBLIC_DOMAIN
9+
from pipelines.dagger.actions import remote_storage
10+
11+
12+
@dataclass(kw_only=True)
13+
class Artifact:
14+
"""A dataclass to represent an artifact produced by a pipeline execution."""
15+
16+
name: str
17+
content_type: str
18+
content: dagger.File
19+
to_upload: bool = True
20+
local_path: Optional[Path] = None
21+
gcs_url: Optional[str] = None
22+
23+
async def save_to_local_path(self, path: Path) -> Path:
24+
exported = await self.content.export(str(path))
25+
if exported:
26+
self.local_path = path
27+
return path
28+
else:
29+
raise Exception(f"Failed to save artifact {self.name} to local path {path}")
30+
31+
async def upload_to_gcs(self, dagger_client: dagger.Client, bucket: str, key: str, gcs_credentials: dagger.Secret) -> str:
32+
gcs_cp_flags = None if self.content_type is None else [f"--content-type={self.content_type}"]
33+
34+
report_upload_exit_code, _, _ = await remote_storage.upload_to_gcs(
35+
dagger_client=dagger_client,
36+
file_to_upload=self.content,
37+
key=key,
38+
bucket=bucket,
39+
gcs_credentials=gcs_credentials,
40+
flags=gcs_cp_flags,
41+
)
42+
if report_upload_exit_code != 0:
43+
raise Exception(f"Failed to upload artifact {self.name} to GCS. Exit code: {report_upload_exit_code}.")
44+
self.gcs_url = f"{GCS_PUBLIC_DOMAIN}/{bucket}/{key}"
45+
return f"{GCS_PUBLIC_DOMAIN}/{bucket}/{key}"

0 commit comments

Comments
 (0)