Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions build_manager/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def __init__(self,
source commit to build on.
git_hash (str): The git commit hash to build on.
board (str): Board to build for.
selected_features (set): Set of features selected for the build.
selected_features (set): Set of feature API labels/IDs for the build.
"""
self.vehicle_id = vehicle_id
self.version_id = version_id
Expand Down Expand Up @@ -200,13 +200,12 @@ def __generate_build_id(self, build_info: BuildInfo) -> str:
build_info (BuildInfo): The build information object.

Returns:
str: The generated build ID (64 characters).
str: The generated build ID (8 characters).
"""
h = hashlib.md5(
f"{build_info}-{time.time_ns()}".encode()
).hexdigest()
bid = f"{build_info.vehicle_id}-{build_info.board}-{h}"
return bid
).hexdigest()[:8]
return h

def submit_build(self,
build_info: BuildInfo) -> str:
Expand Down Expand Up @@ -460,19 +459,23 @@ def get_build_log_path(self, build_id: str) -> str:
'build.log'
)

def get_build_archive_path(self, build_id: str) -> str:
def get_build_archive_path(
self, build_id: str, vehicle_id: str, board: str
) -> str:
"""
Return the path to the build archive.

Parameters:
build_id (str): The ID of the build.
vehicle_id (str): The vehicle identifier.
board (str): The board identifier.

Returns:
str: The path to the build archive.
"""
return os.path.join(
self.get_build_artifacts_dir_path(build_id),
f"{build_id}.tar.gz"
f"{vehicle_id}-{board}-{build_id}.tar.gz"
)

@staticmethod
Expand Down
4 changes: 3 additions & 1 deletion build_manager/progress_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ def __refresh_running_build_state(self, build_id: str) -> BuildState:
# Builder ships the archive post completion
# This is irrespective of SUCCESS or FAILURE
if not os.path.exists(
bm.get_singleton().get_build_archive_path(build_id)
bm.get_singleton().get_build_archive_path(
build_id, build_info.vehicle_id, build_info.board
)
):
return BuildState.RUNNING

Expand Down
50 changes: 38 additions & 12 deletions builder/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@
CBS_BUILD_TIMEOUT_SEC = int(os.getenv('CBS_BUILD_TIMEOUT_SEC', 900))


def resolve_feature_defines(selected_labels, all_features):
"""
Map API feature labels to preprocessor defines for extra_hwdef.

Returns:
tuple: (enabled_defines, disabled_defines, all_defines, unknown_labels)
"""
label_to_define = {
feature.label: feature.define for feature in all_features
}
all_defines = set(label_to_define.values())
selected = set(selected_labels)
known = set(label_to_define)
unknown_labels = selected.difference(known)
enabled_defines = {
label_to_define[label] for label in known.intersection(selected)
}
disabled_defines = all_defines.difference(enabled_defines)
return enabled_defines, disabled_defines, all_defines, unknown_labels


class Builder:
"""
Processes build requests, perform builds and ship build artifacts
Expand Down Expand Up @@ -102,22 +123,24 @@ def __generate_extrahwdef(self, build_id: str) -> None:
)

build_info = bm.get_singleton().get_build_info(build_id)
selected_features = build_info.selected_features
selected_labels = build_info.selected_features
self.logger.debug(
f"Selected features for {build_id}: {selected_features}"
f"Selected feature labels for {build_id}: {selected_labels}"
)
all_features = apfetch.get_singleton().get_build_options_at_commit(
remote=build_info.remote_info.name,
commit_ref=build_info.git_hash,
)
all_defines = {
feature.define
for feature in all_features
}
enabled_defines = selected_features.intersection(all_defines)
disabled_defines = all_defines.difference(enabled_defines)
enabled_defines, disabled_defines, all_defines, unknown_labels = (
resolve_feature_defines(selected_labels, all_features)
)
if unknown_labels:
self.logger.warning(
f"Unknown feature labels not found in build options; "
f"skipping: {sorted(unknown_labels)}"
)
self.logger.info(f"Enabled defines for {build_id}: {enabled_defines}")
self.logger.info(f"Disabled defines for {build_id}: {enabled_defines}")
self.logger.info(f"Disabled defines for {build_id}: {disabled_defines}")

with open(self.__get_path_to_extra_hwdef(build_id), "w") as f:
# Undefine all defines at the beginning
Expand Down Expand Up @@ -226,7 +249,9 @@ def __generate_archive(self, build_id: str) -> None:
build_id (str): Unique identifier for the build.
"""
build_info = bm.get_singleton().get_build_info(build_id)
archive_path = bm.get_singleton().get_build_archive_path(build_id)
archive_path = bm.get_singleton().get_build_archive_path(
build_id, build_info.vehicle_id, build_info.board
)

files_to_include = []

Expand Down Expand Up @@ -261,10 +286,11 @@ def __generate_archive(self, build_id: str) -> None:
)
files_to_include.append(extra_hwdef_path_abs)

# create archive
# create archive (inner folder matches download basename)
folder_name = Path(archive_path).name.removesuffix(".tar.gz")
with tarfile.open(archive_path, "w:gz") as tar:
for file in files_to_include:
arcname = f"{build_id}/{os.path.basename(file)}"
arcname = f"{folder_name}/{os.path.basename(file)}"
self.logger.debug(f"Added {file} as {arcname}")
tar.add(file, arcname=arcname)
self.logger.info(f"Generated {archive_path}.")
Expand Down
56 changes: 56 additions & 0 deletions tests/builder/test_resolve_feature_defines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Tests for builder label to define resolution."""
from unittest.mock import Mock

from builder.builder import resolve_feature_defines


def _option(label, define):
opt = Mock()
opt.label = label
opt.define = define
return opt


def test_resolve_feature_defines_maps_labels():
features = [
_option("HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED_DEFINE"),
_option("HAL_WITH_EKF3", "HAL_WITH_EKF3_DEFINE"),
]

enabled, disabled, all_defines, unknown = resolve_feature_defines(
["HAL_LOGGING_ENABLED"],
features,
)

assert enabled == {"HAL_LOGGING_ENABLED_DEFINE"}
assert disabled == {"HAL_WITH_EKF3_DEFINE"}
assert all_defines == {
"HAL_LOGGING_ENABLED_DEFINE",
"HAL_WITH_EKF3_DEFINE",
}
assert unknown == set()


def test_resolve_feature_defines_returns_unknown_labels():
features = [_option("HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED_DEFINE")]

enabled, disabled, all_defines, unknown = resolve_feature_defines(
["COMPLETELY_UNKNOWN_FEATURE"],
features,
)

assert enabled == set()
assert disabled == all_defines == {"HAL_LOGGING_ENABLED_DEFINE"}
assert unknown == {"COMPLETELY_UNKNOWN_FEATURE"}


def test_resolve_feature_defines_empty_selection_disables_all():
features = [_option("HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED_DEFINE")]

enabled, disabled, all_defines, unknown = resolve_feature_defines(
[], features
)

assert enabled == set()
assert disabled == all_defines == {"HAL_LOGGING_ENABLED_DEFINE"}
assert unknown == set()
72 changes: 12 additions & 60 deletions tests/web/test_builds_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def make_build_info(
remote_info=ManagerRemoteInfo(name=remote_name, url=remote_url),
git_hash=git_hash,
board=board,
selected_features=selected_features or set(),
selected_features=set(selected_features) if selected_features is not None else set(),
)
info.progress = bm.BuildProgress(state=state, percent=percent)
return info
Expand Down Expand Up @@ -242,17 +242,12 @@ def test_create_build_raises_value_error_when_board_not_in_version(
with pytest.raises(ValueError, match="Invalid board for this version"):
service.create_build(request)

def test_create_build_maps_feature_labels_to_defines(
def test_create_build_stores_feature_labels(
self,
service,
mock_ap_src_metadata_fetcher,
mock_build_manager,
):
"""Selected feature labels are translated to defines before build submission."""
opt = Mock()
opt.label = "HAL_LOGGING_ENABLED"
opt.define = "HAL_LOGGING_ENABLED_DEFINE"
mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt]
"""Selected feature labels are stored on BuildInfo as-is."""
request = BuildRequest(
vehicle_id="copter",
board_id="MatekH743",
Expand All @@ -263,37 +258,14 @@ def test_create_build_maps_feature_labels_to_defines(
service.create_build(request)

submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"]
assert "HAL_LOGGING_ENABLED_DEFINE" in submitted.selected_features

def test_create_build_ignores_unknown_feature_labels(
self,
service,
mock_ap_src_metadata_fetcher,
mock_build_manager,
):
"""Unknown feature labels are silently skipped (not added to defines set)."""
opt = Mock()
opt.label = "HAL_LOGGING_ENABLED"
opt.define = "HAL_LOGGING_ENABLED_DEFINE"
mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt]
request = BuildRequest(
vehicle_id="copter",
board_id="MatekH743",
version_id="copter-4.5.0-stable",
selected_features=["COMPLETELY_UNKNOWN_FEATURE"],
)

service.create_build(request)

submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"]
assert len(submitted.selected_features) == 0
assert submitted.selected_features == {"HAL_LOGGING_ENABLED"}

def test_create_build_no_features_submits_empty_set(
def test_create_build_no_features_submits_empty_list(
self,
service,
mock_build_manager,
):
"""When selected_features is empty, build is submitted with an empty set."""
"""When selected_features is empty, build is submitted with an empty list."""
request = BuildRequest(
vehicle_id="copter",
board_id="MatekH743",
Expand All @@ -304,7 +276,7 @@ def test_create_build_no_features_submits_empty_set(
service.create_build(request)

submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"]
assert len(submitted.selected_features) == 0
assert submitted.selected_features == set()

# Tests for list_builds

Expand Down Expand Up @@ -546,40 +518,20 @@ def test_get_build_output_has_correct_vehicle_and_board(
assert result.vehicle.id == "plane"
assert result.board.id == "CubeOrange"

def test_get_build_maps_feature_defines_to_labels(
def test_get_build_uses_stored_feature_labels(
self,
service,
mock_build_manager,
mock_ap_src_metadata_fetcher,
):
"""Feature defines in BuildInfo are mapped back to labels in the output."""
"""API output uses feature labels stored on BuildInfo at submit time."""
mock_build_manager.build_exists.return_value = True
mock_build_manager.get_build_info.return_value = make_build_info(
selected_features={"HAL_LOGGING_ENABLED_DEFINE"}
)
opt = Mock()
opt.define = "HAL_LOGGING_ENABLED_DEFINE"
opt.label = "HAL_LOGGING_ENABLED"
mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt]

result = service.get_build("build-abc123")

assert "HAL_LOGGING_ENABLED" in result.selected_features

def test_get_build_falls_back_to_define_when_label_not_found(
self,
service,
mock_build_manager,
):
"""When a define has no matching label, the define itself is used as fallback."""
mock_build_manager.build_exists.return_value = True
mock_build_manager.get_build_info.return_value = make_build_info(
selected_features={"ORPHANED_DEFINE"}
selected_features=["HAL_LOGGING_ENABLED"],
)

result = service.get_build("build-abc123")

assert "ORPHANED_DEFINE" in result.selected_features
assert result.selected_features == ["HAL_LOGGING_ENABLED"]

def test_get_build_no_selected_features_returns_empty_list(
self,
Expand All @@ -589,7 +541,7 @@ def test_get_build_no_selected_features_returns_empty_list(
"""When a build has no selected features, the output list is empty."""
mock_build_manager.build_exists.return_value = True
mock_build_manager.get_build_info.return_value = make_build_info(
selected_features=set()
selected_features=[],
)

result = service.get_build("build-abc123")
Expand Down
3 changes: 2 additions & 1 deletion web/api/v1/builds.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from typing import List, Optional
from fastapi import (
APIRouter,
Expand Down Expand Up @@ -219,5 +220,5 @@ async def download_artifact(
return FileResponse(
path=artifact_path,
media_type='application/gzip',
filename=f"{build_id}.tar.gz"
filename=os.path.basename(artifact_path)
)
Loading
Loading