Skip to content
Open
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
23 changes: 23 additions & 0 deletions build_config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Shared CustomBuild YAML config generation and validation."""

from build_config.config import (
CONFIG_VERSION,
CONFIG_FILENAME,
build_config_dict,
config_dict_from_build_info,
dump_config_yaml,
schema_path_for_version,
validate_config_dict,
write_config_yaml,
)

__all__ = [
"CONFIG_VERSION",
"CONFIG_FILENAME",
"build_config_dict",
"config_dict_from_build_info",
"dump_config_yaml",
"schema_path_for_version",
"validate_config_dict",
"write_config_yaml",
]
88 changes: 88 additions & 0 deletions build_config/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Serialize and validate CustomBuild config YAML (schemas/config)."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Mapping, Sequence

import yaml
from jsonschema import Draft202012Validator

CONFIG_VERSION = "0.0.1"
CONFIG_FILENAME = "custombuild.yaml"

_SCHEMAS_DIR = Path(__file__).resolve().parent.parent / "schemas" / "config"


def schema_path_for_version(version: str = CONFIG_VERSION) -> Path:
path = _SCHEMAS_DIR / f"{version}.json"
if not path.is_file():
raise FileNotFoundError(f"No config schema for version '{version}' at {path}")
return path


def validate_config_dict(config: Mapping[str, Any]) -> None:
version = config.get("config_version")
if not isinstance(version, str):
raise ValueError("Invalid or missing config_version")
schema = json.loads(schema_path_for_version(version).read_text(encoding="utf-8"))
Draft202012Validator(schema).validate(dict(config))


def build_config_dict(
*,
vehicle_id: str,
vehicle_name: str,
version_id: str,
version_name: str,
version_type: str,
remote_name: str,
board_id: str,
board_name: str,
selected_features: Sequence[str],
config_version: str = CONFIG_VERSION,
) -> dict[str, Any]:
"""Build a schema-compliant config dict (selected_features are API labels)."""
return {
"config_version": config_version,
"vehicle": {"id": vehicle_id, "name": vehicle_name},
"version": {
"id": version_id,
"name": version_name,
"type": version_type,
"remote_name": remote_name,
},
"board": {"id": board_id, "name": board_name},
"selected_features": sorted(selected_features),
}


def dump_config_yaml(config: Mapping[str, Any]) -> str:
validate_config_dict(config)
return yaml.safe_dump(
dict(config),
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)


def write_config_yaml(path: Path | str, config: Mapping[str, Any]) -> None:
path = Path(path)
path.write_text(dump_config_yaml(config), encoding="utf-8")


def config_dict_from_build_info(build_info: Any) -> dict[str, Any]:
"""Build config from BuildInfo fields set at submit time."""
return build_config_dict(
vehicle_id=build_info.vehicle_id,
vehicle_name=build_info.vehicle_name,
version_id=build_info.version_id,
version_name=build_info.version_name,
version_type=build_info.version_type,
remote_name=build_info.remote_info.name,
board_id=build_info.board,
board_name=build_info.board_name,
selected_features=list(build_info.selected_features),
)
20 changes: 18 additions & 2 deletions build_manager/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ def __init__(self,
remote_info: RemoteInfo,
git_hash: str,
board: str,
selected_features: set) -> None:
selected_features: set,
vehicle_name: str,
board_name: str,
version_name: str,
version_type: str) -> None:
"""
Initialize build information object including vehicle,
remote, git hash, selected features, and progress of the build.
Expand All @@ -62,13 +66,21 @@ def __init__(self,
git_hash (str): The git commit hash to build on.
board (str): Board to build for.
selected_features (set): Set of feature API labels/IDs for the build.
vehicle_name (str): Display name for rebuild config YAML.
board_name (str): Display name for rebuild config YAML.
version_name (str): Display name for rebuild config YAML.
version_type (str): Release type for rebuild config YAML.
"""
self.vehicle_id = vehicle_id
self.version_id = version_id
self.remote_info = remote_info
self.git_hash = git_hash
self.board = board
self.selected_features = selected_features
self.vehicle_name = vehicle_name
self.board_name = board_name
self.version_name = version_name
self.version_type = version_type
self.progress = BuildProgress(
state=BuildState.PENDING,
percent=0
Expand All @@ -84,9 +96,13 @@ def to_dict(self) -> dict:
'git_hash': self.git_hash,
'board': self.board,
'selected_features': list(self.selected_features),
'vehicle_name': self.vehicle_name,
'board_name': self.board_name,
'version_name': self.version_name,
'version_type': self.version_type,
'progress': self.progress.to_dict(),
'time_created': self.time_created,
'time_started': getattr(self, 'time_started', None),
'time_started': self.time_started,
}


Expand Down
16 changes: 16 additions & 0 deletions builder/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
VehiclesManager as vehm
)
from pathlib import Path
from build_config import (
CONFIG_FILENAME,
config_dict_from_build_info,
write_config_yaml,
)

CBS_BUILD_TIMEOUT_SEC = int(os.getenv('CBS_BUILD_TIMEOUT_SEC', 900))

Expand Down Expand Up @@ -286,6 +291,17 @@ def __generate_archive(self, build_id: str) -> None:
)
files_to_include.append(extra_hwdef_path_abs)

# include rebuild config YAML (Builder is sole canonical author)
config_path = Path(
self.__get_path_to_build_dir(build_id)
) / CONFIG_FILENAME

try:
write_config_yaml(config_path, config_dict_from_build_info(build_info))
files_to_include.append(str(config_path.resolve()))
except Exception:
self.logger.exception(f"Could not write {CONFIG_FILENAME} for {build_id}")

# 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:
Expand Down
1 change: 1 addition & 0 deletions builder/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ redis==5.2.1
dill==0.3.8
requests==2.31.0
packaging==25.0
PyYAML==6.0.2
51 changes: 51 additions & 0 deletions schemas/config/0.0.1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "BuildConfig",
"description": "CustomBuild configuration file schema v0.0.1",
"type": "object",
"required": ["config_version", "vehicle", "version", "board", "selected_features"],
"additionalProperties": false,
"properties": {
"config_version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"description": "Semver config format version"
},
"vehicle": {
"type": "object",
"required": ["id", "name"],
"additionalProperties": false,
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
},
"version": {
"type": "object",
"required": ["id", "name", "type", "remote_name"],
"additionalProperties": false,
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"type": { "type": "string" },
"remote_name": { "type": "string" }
}
},
"board": {
"type": "object",
"required": ["id", "name"],
"additionalProperties": false,
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
},
"selected_features": {
"type": "array",
"items": { "type": "string" }
},
"use_default_features": {
"type": "boolean"
}
}
}
101 changes: 101 additions & 0 deletions tests/build_config/test_build_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Tests for shared build_config packaging helpers."""
import tarfile
from pathlib import Path

import pytest
import yaml

from build_config import (
CONFIG_FILENAME,
CONFIG_VERSION,
build_config_dict,
config_dict_from_build_info,
dump_config_yaml,
validate_config_dict,
write_config_yaml,
)
from build_manager import BuildInfo
from metadata_manager import RemoteInfo


def _valid_config(**overrides):
base = build_config_dict(
vehicle_id="copter",
vehicle_name="Copter",
version_id="ardupilot-Copter-4.5.0-abc",
version_name="4.5.0",
version_type="stable",
remote_name="ardupilot",
board_id="CubeOrange",
board_name="CubeOrange",
selected_features={"HAL_LOGGING_ENABLED"},
)
base.update(overrides)
return base


def test_validate_accepts_schema_compliant_config():
validate_config_dict(_valid_config())


def test_validate_rejects_missing_required_field():
bad = _valid_config()
del bad["board"]
with pytest.raises(Exception):
validate_config_dict(bad)


def test_dump_and_write_roundtrip(tmp_path: Path):
config = _valid_config()
text = dump_config_yaml(config)
parsed = yaml.safe_load(text)
assert parsed["config_version"] == CONFIG_VERSION
assert parsed["vehicle"]["id"] == "copter"

out = tmp_path / CONFIG_FILENAME
write_config_yaml(out, config)
assert out.is_file()
assert yaml.safe_load(out.read_text())["board"]["id"] == "CubeOrange"


def test_config_dict_from_build_info_uses_labels_and_names():
info = BuildInfo(
vehicle_id="copter",
version_id="ver-1",
remote_info=RemoteInfo(
name="ardupilot",
url="https://github.com/ArduPilot/ardupilot.git",
),
git_hash="abc123",
board="MatekH743",
selected_features={"HAL_LOGGING_ENABLED"},
vehicle_name="Copter",
board_name="MatekH743",
version_name="4.5.0",
version_type="stable",
)
config = config_dict_from_build_info(info)
validate_config_dict(config)
assert config["selected_features"] == ["HAL_LOGGING_ENABLED"]
assert config["version"]["name"] == "4.5.0"
assert config["version"]["remote_name"] == "ardupilot"


def test_packaged_yaml_inside_tar(tmp_path: Path):
"""Simulate Builder archive membership for custombuild.yaml."""
config = _valid_config()
config_path = tmp_path / CONFIG_FILENAME
write_config_yaml(config_path, config)

archive = tmp_path / "copter-MatekH743-build1.tar.gz"
folder_name = archive.name.removesuffix(".tar.gz")
with tarfile.open(archive, "w:gz") as tar:
tar.add(config_path, arcname=f"{folder_name}/{CONFIG_FILENAME}")

with tarfile.open(archive, "r:gz") as tar:
names = [m.name for m in tar.getmembers()]
assert f"{folder_name}/{CONFIG_FILENAME}" in names
extracted = tar.extractfile(f"{folder_name}/{CONFIG_FILENAME}")
assert extracted is not None
loaded = yaml.safe_load(extracted.read())
assert loaded["vehicle"]["id"] == "copter"
31 changes: 31 additions & 0 deletions tests/web/test_builds_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,34 @@ def test_get_artifact_method_not_allowed(self, client):
for method in [client.post, client.put, client.patch, client.delete]:
response = method("/api/v1/builds/build-abc123/artifact")
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED

def test_get_config_returns_200_when_available(self, client):
"""Returns YAML when the service provides packaged config."""
mock_service = Mock()
mock_service.get_build_config_yaml.return_value = (
"config_version: \"0.0.1\"\nvehicle:\n id: copter\n name: Copter\n",
"custombuild-copter-MatekH743-build-abc123.yaml",
)
with self.override_builds_service(client, mock_service):
response = client.get("/api/v1/builds/build-abc123/config")

assert response.status_code == status.HTTP_200_OK
assert "copter" in response.text
assert "attachment" in response.headers.get("content-disposition", "")

def test_get_config_returns_404_when_not_available(self, client):
mock_service = Mock()
mock_service.get_build_config_yaml.return_value = None
with self.override_builds_service(client, mock_service):
response = client.get("/api/v1/builds/some-build-id/config")

assert response.status_code == status.HTTP_404_NOT_FOUND
assert "some-build-id" in response.json()["detail"]

def test_get_config_service_called_with_correct_build_id(self, client):
mock_service = Mock()
mock_service.get_build_config_yaml.return_value = None
with self.override_builds_service(client, mock_service):
client.get("/api/v1/builds/target-build/config")

mock_service.get_build_config_yaml.assert_called_once_with("target-build")
Loading
Loading