← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~alvarocs/launchpad:charms-craft-platforms into launchpad:master

 

Alvaro Crespo Serrano has proposed merging ~alvarocs/launchpad:charms-craft-platforms into launchpad:master.

Commit message:
Support craft-platforms build plan in charm builds

Updates Launchpad to support charm builds using 'craft-platforms' for the unified format. It also ensures the platform name ('recipe_platform_name') is passed through the build pipeline.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~alvarocs/launchpad/+git/launchpad/+merge/483337

Changes include:

Charms base configuration and build planning
- lp/charms/adapters/buildarch.py - replace custom unified base with craft-platforms API, reformat code with helper functions, add comments.
- lp/charms/adapters/tests/test_buildarch.py - improve and extend existing test coverage, add multi-base platforms tests handled by craft-platforms.

Charm recipe model
- lp/charms/interfaces/charmrecipebuild.py, model/charmrecipebuild.py - store recipe_platform_name on CharmRecipeBuild storm object.
- lp/charms/model/charmrecipe.py - pass platform name through 'requestBuild' and 'requestBuildsFromJob'.
- lp/charms/tests/test_charmrecipe.py - add tests to verify 'recipe_platform_name' is passed through.

Build behaviour
- lp/charms/model/charmrecipebuildbehaviour.py - pass 'recipe_platform_name' as extra argument to the builder.
- lp/charms/tests/test_charmrecipebuildbehaviour.py - test coverage for 'recipe_platform_name' argument.
- lp/buildmaster/interfaces/buildfarmjobbehaviour.py - extend BuildArgs interface class to include the new 'recipe_platform_name' field.

Testing
- lp/testing/factory.py - extend factory methods to support setting the new arg

launchpad-buildd MP: https://code.launchpad.net/~alvarocs/launchpad-buildd/+git/launchpad-buildd/+merge/483410
craft-platforms repo https://github.com/canonical/craft-platforms/tree
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~alvarocs/launchpad:charms-craft-platforms into launchpad:master.
diff --git a/lib/lp/buildmaster/interfaces/buildfarmjobbehaviour.py b/lib/lp/buildmaster/interfaces/buildfarmjobbehaviour.py
index 74da7e9..e721f7d 100644
--- a/lib/lp/buildmaster/interfaces/buildfarmjobbehaviour.py
+++ b/lib/lp/buildmaster/interfaces/buildfarmjobbehaviour.py
@@ -121,6 +121,8 @@ class BuildArgs(TypedDict, total=False):
     private: bool
     # The URL of the proxy for internet access [charm, ci, oci, snap].
     proxy_url: str
+    # The platform name to build for [charm, snap, sources, rocks].
+    recipe_platform_name: str
     # The text of the recipe to build [required for
     # sourcepackagerecipe].
     recipe_text: str
diff --git a/lib/lp/charms/adapters/buildarch.py b/lib/lp/charms/adapters/buildarch.py
index 66bc20e..7a63117 100644
--- a/lib/lp/charms/adapters/buildarch.py
+++ b/lib/lp/charms/adapters/buildarch.py
@@ -6,9 +6,10 @@ __all__ = [
 ]
 
 import json
-import re
 from collections import Counter, OrderedDict
 
+from craft_platforms import CraftPlatformsError, charm
+
 from lp.services.helpers import english_list
 
 
@@ -42,6 +43,17 @@ class DuplicateRunOnError(CharmBasesParserError):
         )
 
 
+class CraftPlatformsBuildPlanError(CharmBasesParserError):
+    """Error raised when craft-platforms fails while generating a build
+    plan.
+    """
+
+    def __init__(self, message, resolution=None):
+        if resolution:
+            message += f" Resolution: {resolution}"
+        super().__init__(message)
+
+
 class CharmBase:
     """A single base in charmcraft.yaml."""
 
@@ -126,91 +138,16 @@ class CharmBaseConfiguration:
         return cls(build_on, run_on=run_on)
 
 
-class UnifiedCharmBaseConfiguration:
-    """A unified base configuration in charmcraft.yaml"""
-
-    def __init__(self, build_on, run_on=None):
-        self.build_on = build_on
-        self.run_on = list(build_on) if run_on is None else run_on
-
-    @classmethod
-    def from_dict(cls, charmcraft_data, supported_arches):
-        base = charmcraft_data["base"]
-        if isinstance(base, str):
-            # Expected short-form value looks like 'ubuntu@24.04'
-            match = re.match(r"(.+)@(.+)", base)
-            if not match:
-                raise BadPropertyError(
-                    f"Invalid value for base '{base}'. Expected value should "
-                    "be like 'ubuntu@24.04'"
-                )
-            base_name, base_channel = match.groups()
-        else:
-            # Expected value looks like {"name": "ubuntu", "channel": "24.04"}
-            base_name = base["name"]
-            # If a value like 24.04 is unquoted in yaml, it will be
-            # interpreted as a float. So we convert it to a string.
-            base_channel = str(base["channel"])
-
-        # XXX lgp171188 2024-06-11: Find out if we need 'build-base' or not.
-        # There is no existing code that is using that.
-
-        platforms = charmcraft_data.get("platforms")
-        if not platforms:
-            raise MissingPropertyError(
-                "platforms", "The 'platforms' property is required"
-            )
-        configs = []
-        for platform, configuration in platforms.items():
-            # The 'platforms' property and its values look like
-            # platforms:
-            #   ubuntu-amd64:
-            #     build-on: [amd64]
-            #     build-for: [amd64]
-            # 'ubuntu-amd64' will be the value of 'platform' and its value dict
-            # containing the keys 'build-on', 'build-for' will be the value of
-            # 'configuration'.
-            name = base_name
-            channel = base_channel
-            if configuration:
-                build_on = configuration["build-on"]
-                if isinstance(build_on, str):
-                    build_on = [build_on]
-
-                build_on = [
-                    CharmBase(name, channel, architecture)
-                    for architecture in build_on
-                ]
-
-                build_for = configuration["build-for"]
-                if isinstance(build_for, str):
-                    build_for = [build_for]
-
-                build_for = [
-                    CharmBase(name, channel, architecture)
-                    for architecture in build_for
-                ]
-            else:
-                supported_arch_names = (
-                    das.architecturetag for das in supported_arches
-                )
-                if platform in supported_arch_names:
-                    build_on = [CharmBase(name, channel, platform)]
-                    build_for = [CharmBase(name, channel, platform)]
-                else:
-                    raise BadPropertyError(
-                        f"'{platform}' is not a supported architecture "
-                        f"for '{base_name}@{base_channel}'."
-                    )
-            configs.append(cls(build_on, build_for))
-        return configs
-
-
 def determine_instances_to_build(
     charmcraft_data, supported_arches, default_distro_series
 ):
     """Return a list of instances to build based on charmcraft.yaml.
 
+    Handles three cases:
+    1) 'bases' configuration
+    2) Unified format configuration
+    3) With no bases specified configuration
+
     :param charmcraft_data: A parsed charmcraft.yaml.
     :param supported_arches: An ordered list of all `DistroArchSeries` that
         we can create builds for.  Note that these may span multiple
@@ -219,18 +156,139 @@ def determine_instances_to_build(
         charmcraft.yaml does not explicitly declare any bases.
     :return: A list of `DistroArchSeries`.
     """
+
+    def _check_duplicate_run_on(configs):
+        """Ensure that multiple `run-on` items don't overlap;
+        this is ambiguous and forbidden by charmcraft.
+
+        :param configs: List of CharmBaseConfiguration objects
+        :raises DuplicateRunOnError if any architecture appears in
+        multiple run-on configurations
+        """
+
+        run_ons = Counter()
+        for config in configs:
+            run_ons.update(config.run_on)
+        duplicates = {config for config, count in run_ons.items() if count > 1}
+        if duplicates:
+            raise DuplicateRunOnError(duplicates)
+
+    def _process_configs_to_instances(configs, supported_arches):
+        """Convert base configurations to buildable instances.
+
+        Filters configurations to only include supported architectures and
+        distro series.
+
+        :param configs: List of CharmBaseConfiguration objects
+        :param supported_arches: List of supported DistroArchSeries
+        :return: OrderedDict of filtered DistroArchSeries instances
+        """
+
+        _check_duplicate_run_on(configs)
+        instances = OrderedDict()
+        for config in configs:
+            # Charms are allowed to declare that they build on architectures
+            # that Launchpad doesn't currently support (perhaps they're
+            # upcoming, or perhaps they used to be supported).  We just ignore
+            # those.
+            for build_on in config.build_on:
+                for das in supported_arches:
+                    if das.distroseries.distribution.name != build_on.name:
+                        continue
+                    if build_on.channel not in (
+                        das.distroseries.name,
+                        das.distroseries.version,
+                    ):
+                        continue
+                    if build_on.architectures is None:
+                        # Build on all supported architectures for the
+                        # requested series.
+                        instances[das] = None
+                    elif das.architecturetag in build_on.architectures:
+                        # Build on the first matching supported architecture
+                        # for the requested series.
+                        instances[das] = None
+                        break
+                else:
+                    continue
+                break
+        return instances
+
     from lp.charms.model.charmrecipe import is_unified_format
 
+    # 1) Charm with 'bases' format
     bases_list = charmcraft_data.get("bases")
-
     if bases_list:
         configs = [
             CharmBaseConfiguration.from_dict(item) for item in bases_list
         ]
+        instances = _process_configs_to_instances(configs, supported_arches)
+        instances_to_build = [(None, das) for das in instances.keys()]
+        return instances_to_build
+
+    # 2) Charm with unified format
     elif is_unified_format(charmcraft_data):
-        configs = UnifiedCharmBaseConfiguration.from_dict(
-            charmcraft_data, supported_arches
-        )
+
+        base = charmcraft_data["base"]
+        build_base = charmcraft_data.get("build-base", None)
+        platforms = charmcraft_data.get("platforms", None)
+        # Reformat base dict style
+        if isinstance(base, dict):
+            base = f"{base['name']}@{base['channel']}"
+
+        # Generate exhaustive build plan
+        try:
+            exhaustive_build_plan = charm.get_platforms_charm_build_plan(
+                base=base,
+                platforms=platforms,
+                build_base=build_base,
+            )
+
+        except (CraftPlatformsError, ValueError) as e:
+            message = getattr(e, "message", str(e))
+            resolution = getattr(e, "resolution", None)
+            raise CraftPlatformsBuildPlanError(
+                f"Failed to compute the build plan for base={base}, "
+                f"build base={build_base}, platforms={platforms}: {message}",
+                resolution=resolution,
+            )
+        # Filter exhaustive build plan
+        filtered_plan = []
+        for info in exhaustive_build_plan:
+            for das in supported_arches:
+                # Compare DAS-BuildInfo and append if match
+                if (
+                    das.distroseries.distribution.name
+                    == info.build_base.distribution
+                    and info.build_base.series
+                    in (das.distroseries.name, das.distroseries.version)
+                    and das.architecturetag == info.build_on.value
+                ):
+                    filtered_plan.append((info, das))
+                    break
+        # Group by platform
+        platform_plans = {}
+        for info, das in filtered_plan:
+            platform_plans.setdefault(info.platform, []).append((info, das))
+        # Pick one BuildInfo per platform
+        instances_to_build = []
+        for _platform, pairs in platform_plans.items():
+            # One way of building for that platform, i.e one (info, das)
+            if len(pairs) == 1:
+                instances_to_build.append(pairs[0])
+                continue
+            # More than one way of building for that platform
+            for info, das in pairs:
+                # Pick the native build
+                if info.build_on == info.build_for:
+                    instances_to_build.append((info, das))
+                    break
+            # Pick first one if none are native
+            else:
+                instances_to_build.append(pairs[0])
+        return instances_to_build
+
+    # 3) Charms with no bases specified
     else:
         # If no bases are specified, build one for each supported
         # architecture for the default series.
@@ -247,41 +305,6 @@ def determine_instances_to_build(
             for das in supported_arches
             if das.distroseries == default_distro_series
         ]
-
-    # Ensure that multiple `run-on` items don't overlap; this is ambiguous
-    # and forbidden by charmcraft.
-    run_ons = Counter()
-    for config in configs:
-        run_ons.update(config.run_on)
-    duplicates = {config for config, count in run_ons.items() if count > 1}
-    if duplicates:
-        raise DuplicateRunOnError(duplicates)
-
-    instances = OrderedDict()
-    for config in configs:
-        # Charms are allowed to declare that they build on architectures
-        # that Launchpad doesn't currently support (perhaps they're
-        # upcoming, or perhaps they used to be supported).  We just ignore
-        # those.
-        for build_on in config.build_on:
-            for das in supported_arches:
-                if das.distroseries.distribution.name != build_on.name:
-                    continue
-                if build_on.channel not in (
-                    das.distroseries.name,
-                    das.distroseries.version,
-                ):
-                    continue
-                if build_on.architectures is None:
-                    # Build on all supported architectures for the requested
-                    # series.
-                    instances[das] = None
-                elif das.architecturetag in build_on.architectures:
-                    # Build on the first matching supported architecture for
-                    # the requested series.
-                    instances[das] = None
-                    break
-            else:
-                continue
-            break
-    return list(instances)
+        instances = _process_configs_to_instances(configs, supported_arches)
+        instances_to_build = [(None, das) for das in instances.keys()]
+        return instances_to_build
diff --git a/lib/lp/charms/adapters/tests/test_buildarch.py b/lib/lp/charms/adapters/tests/test_buildarch.py
index a87528d..d14ca60 100644
--- a/lib/lp/charms/adapters/tests/test_buildarch.py
+++ b/lib/lp/charms/adapters/tests/test_buildarch.py
@@ -21,6 +21,7 @@ from lp.buildmaster.interfaces.processor import (
 from lp.charms.adapters.buildarch import (
     CharmBase,
     CharmBaseConfiguration,
+    CraftPlatformsBuildPlanError,
     DuplicateRunOnError,
     determine_instances_to_build,
 )
@@ -122,6 +123,7 @@ class TestDetermineInstancesToBuild(WithScenarios, TestCaseWithFactory):
     # Scenarios taken from the charmcraft build providers specification:
     # https://discourse.charmhub.io/t/charmcraft-bases-provider-support/4713
     scenarios = [
+        # 'Bases' format scenarios
         (
             "single entry, single arch",
             {
@@ -373,17 +375,6 @@ class TestDetermineInstancesToBuild(WithScenarios, TestCaseWithFactory):
             },
         ),
         (
-            "no bases specified",
-            {
-                "bases": None,
-                "expected": [
-                    ("20.04", "amd64"),
-                    ("20.04", "arm64"),
-                    ("20.04", "riscv64"),
-                ],
-            },
-        ),
-        (
             "abbreviated, no architectures specified",
             {
                 "bases": [
@@ -399,6 +390,178 @@ class TestDetermineInstancesToBuild(WithScenarios, TestCaseWithFactory):
                 ],
             },
         ),
+        # Unified format scenarios
+        (
+            "unified single platform",
+            {
+                "bases": None,
+                "base": "ubuntu@20.04",
+                "platforms": {
+                    "ubuntu-amd64": {
+                        "build-on": ["amd64"],
+                        "build-for": ["amd64"],
+                    }
+                },
+                "expected": [("20.04", "amd64")],
+            },
+        ),
+        (
+            "unified multi-platforms",
+            {
+                "bases": None,
+                "base": {"name": "ubuntu", "channel": "20.04"},
+                "platforms": {
+                    "ubuntu-amd64": {
+                        "build-on": ["amd64"],
+                        "build-for": ["amd64"],
+                    },
+                    "ubuntu-arm64": {
+                        "build-on": ["arm64"],
+                        "build-for": ["arm64"],
+                    },
+                },
+                "expected": [("20.04", "amd64"), ("20.04", "arm64")],
+            },
+        ),
+        (
+            "unified mismatched platform base",
+            {
+                "bases": None,
+                "base": "ubuntu@20.04",
+                "platforms": {
+                    "ubuntu:18.04": {
+                        "build-on": ["amd64"],
+                        "build-for": ["amd64"],
+                    },
+                },
+                "expected_exception": MatchesException(
+                    CraftPlatformsBuildPlanError,
+                    r"Failed to compute the build plan for base=.+",
+                ),
+            },
+        ),
+        (
+            "unified build-for all single platform",
+            {
+                "bases": None,
+                "base": "ubuntu@20.04",
+                "platforms": {
+                    "ubuntu-amd64": {
+                        "build-on": ["amd64"],
+                        "build-for": ["all"],
+                    },
+                },
+                "expected": [("20.04", "amd64")],
+            },
+        ),
+        (
+            "unified multiple build-for all platforms (invalid)",
+            {
+                "bases": None,
+                "base": "ubuntu@20.04",
+                "platforms": {
+                    "ubuntu-amd64": {
+                        "build-on": ["amd64"],
+                        "build-for": ["all"],
+                    },
+                    "ubuntu-arm64": {
+                        "build-on": ["arm64"],
+                        "build-for": ["all"],
+                    },
+                },
+                "expected": [("20.04", "amd64"), ("20.04", "arm64")],
+            },
+        ),
+        (
+            "unified without platforms, builds for all allowed archs",
+            {
+                "bases": None,
+                "base": "ubuntu@20.04",
+                "platforms": None,
+                "expected": [
+                    ("20.04", "amd64"),
+                    ("20.04", "arm64"),
+                    ("20.04", "riscv64"),
+                ],
+            },
+        ),
+        (
+            "unified without supported series",
+            {
+                "bases": None,
+                "base": "ubuntu@22.04",
+                "platforms": {
+                    "ubuntu-amd64": {
+                        "build-on": ["amd64"],
+                        "build-for": ["amd64"],
+                    },
+                },
+                "expected": [],
+            },
+        ),
+        (
+            "unified invalid platform name",
+            {
+                "bases": None,
+                "base": "ubuntu@20.04",
+                "platforms": {"not-a-valid-arch": None},
+                "expected_exception": MatchesException(
+                    CraftPlatformsBuildPlanError,
+                    r"Failed to compute the build plan for base=.+",
+                ),
+            },
+        ),
+        (
+            "unified missing build-on",
+            {
+                "bases": None,
+                "base": "ubuntu@20.04",
+                "platforms": {
+                    "ubuntu-amd64": {
+                        "build-on": [],
+                        "build-for": ["amd64"],
+                    },
+                },
+                "expected": [],
+            },
+        ),
+        (
+            "unified cross-compiling combinations, taking the first one",
+            {
+                "bases": None,
+                "base": "ubuntu@20.04",
+                "platforms": {
+                    "ubuntu-cross": {
+                        "build-on": ["amd64", "arm64"],
+                        "build-for": ["riscv64"],
+                    },
+                },
+                "expected": [("20.04", "amd64")],
+            },
+        ),
+        (
+            "unified base only with unsupported series",
+            {
+                "bases": None,
+                "base": "ubuntu@99.99",
+                "platforms": None,
+                "expected": [],
+            },
+        ),
+        # No bases specified scenario
+        (
+            "no bases specified",
+            {
+                "bases": None,
+                "base": None,
+                "platforms": None,
+                "expected": [
+                    ("20.04", "amd64"),
+                    ("20.04", "arm64"),
+                    ("20.04", "riscv64"),
+                ],
+            },
+        ),
     ]
 
     def test_parser(self):
@@ -409,7 +572,7 @@ class TestDetermineInstancesToBuild(WithScenarios, TestCaseWithFactory):
             )
             for version in ("20.04", "18.04")
         ]
-        dases = []
+        supported_dases = []
         for arch_tag in ("amd64", "arm64", "riscv64"):
             try:
                 processor = getUtility(IProcessorSet).getByName(arch_tag)
@@ -418,7 +581,7 @@ class TestDetermineInstancesToBuild(WithScenarios, TestCaseWithFactory):
                     name=arch_tag, supports_virtualized=True
                 )
             for distro_series in distro_serieses:
-                dases.append(
+                supported_dases.append(
                     self.factory.makeDistroArchSeries(
                         distroseries=distro_series,
                         architecturetag=arch_tag,
@@ -426,12 +589,16 @@ class TestDetermineInstancesToBuild(WithScenarios, TestCaseWithFactory):
                     )
                 )
         charmcraft_data = {}
-        if self.bases is not None:
+        if getattr(self, "bases", None) is not None:
             charmcraft_data["bases"] = self.bases
+        if getattr(self, "base", None) is not None:
+            charmcraft_data["base"] = self.base
+        if getattr(self, "platforms", None) is not None:
+            charmcraft_data["platforms"] = self.platforms
         build_instances_factory = partial(
             determine_instances_to_build,
             charmcraft_data,
-            dases,
+            supported_dases,
             distro_serieses[0],
         )
         if hasattr(self, "expected_exception"):
@@ -439,8 +606,11 @@ class TestDetermineInstancesToBuild(WithScenarios, TestCaseWithFactory):
                 build_instances_factory, Raises(self.expected_exception)
             )
         else:
+            result = build_instances_factory()
+            # Only asserting DistroArchSeries
+            result = [das for (info, das) in result]
             self.assertThat(
-                build_instances_factory(),
+                result,
                 MatchesListwise(
                     [
                         MatchesStructure(
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 7ad2b5a..4037681 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -371,7 +371,11 @@ class ICharmRecipeView(Interface):
         """Can the specified user see this charm recipe?"""
 
     def requestBuild(
-        build_request, distro_arch_series, charm_base=None, channels=None
+        build_request,
+        distro_arch_series,
+        charm_base=None,
+        channels=None,
+        recipe_platform_name=None,
     ):
         """Request a single build of this charm recipe.
 
@@ -384,6 +388,7 @@ class ICharmRecipeView(Interface):
         :param charm_base: The `ICharmBase` to use for this build.
         :param channels: A dictionary mapping snap names to channels to use
             for this build.
+        :param recipe_platform_name: The platform name to build for.
         :return: `ICharmRecipeBuild`.
         """
 
diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
index 78cec6b..56e4801 100644
--- a/lib/lp/charms/interfaces/charmrecipebuild.py
+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
@@ -154,6 +154,14 @@ class ICharmRecipeBuildView(IPackageBuildView):
         )
     )
 
+    recipe_platform_name = exported(
+        TextLine(
+            title=_("Recipe platform name"),
+            required=False,
+            readonly=True,
+        )
+    )
+
     virtualized = Bool(
         title=_("If True, this build is virtualized."), readonly=True
     )
@@ -318,6 +326,7 @@ class ICharmRecipeBuildSet(ISpecificBuildFarmJobSource):
         recipe,
         distro_arch_series,
         channels=None,
+        recipe_platform_name=None,
         store_upload_metadata=None,
         date_created=DEFAULT,
     ):
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 8d64194..9637dbe 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -540,7 +540,12 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
             )
 
     def requestBuild(
-        self, build_request, distro_arch_series, charm_base=None, channels=None
+        self,
+        build_request,
+        distro_arch_series,
+        charm_base=None,
+        channels=None,
+        recipe_platform_name=None,
     ):
         """Request a single build of this charm recipe.
 
@@ -552,6 +557,7 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
         :param distro_arch_series: The architecture to build for.
         :param channels: A dictionary mapping snap names to channels to use
             for this build.
+        :param recipe_platform_name: The platform name to build for.
         :return: `ICharmRecipeBuild`.
         """
         self._checkRequestBuild(build_request.requester)
@@ -572,13 +578,18 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
             CharmRecipeBuild.recipe == self,
             CharmRecipeBuild.distro_arch_series == distro_arch_series,
             channels_clause,
+            CharmRecipeBuild.recipe_platform_name == recipe_platform_name,
             CharmRecipeBuild.status == BuildStatus.NEEDSBUILD,
         )
         if pending.any() is not None:
             raise CharmRecipeBuildAlreadyPending
 
         build = getUtility(ICharmRecipeBuildSet).new(
-            build_request, self, distro_arch_series, channels=channels
+            build_request,
+            self,
+            distro_arch_series,
+            channels=channels,
+            recipe_platform_name=recipe_platform_name,
         )
         build.queueBuild()
         notify(ObjectCreatedEvent(build, user=build_request.requester))
@@ -645,7 +656,7 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
                 )
 
         builds = []
-        for das in instances_to_build:
+        for info, das in instances_to_build:
             try:
                 charm_base = getUtility(ICharmBaseSet).getByDistroSeries(
                     das.distroseries
@@ -662,18 +673,23 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
             else:
                 arch_channels = channels
             try:
+                platform_name = info.platform if info is not None else None
                 build = self.requestBuild(
-                    build_request, das, channels=arch_channels
+                    build_request,
+                    das,
+                    channels=arch_channels,
+                    recipe_platform_name=platform_name,
                 )
                 if logger is not None:
                     logger.debug(
-                        " - %s/%s/%s %s/%s/%s: Build requested.",
+                        " - %s/%s/%s %s/%s/%s/%s: Build requested.",
                         self.owner.name,
                         self.project.name,
                         self.name,
                         das.distroseries.distribution.name,
                         das.distroseries.name,
                         das.architecturetag,
+                        platform_name,
                     )
                 builds.append(build)
             except CharmRecipeBuildAlreadyPending:
@@ -683,13 +699,14 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
                     raise
                 elif logger is not None:
                     logger.exception(
-                        " - %s/%s/%s %s/%s/%s: %s",
+                        " - %s/%s/%s %s/%s/%s/%s: %s",
                         self.owner.name,
                         self.project.name,
                         self.name,
                         das.distroseries.distribution.name,
                         das.distroseries.name,
                         das.architecturetag,
+                        platform_name,
                         e,
                     )
         return builds
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
index e63743e..15da7f2 100644
--- a/lib/lp/charms/model/charmrecipebuild.py
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -93,6 +93,10 @@ class CharmRecipeBuild(PackageBuildMixin, StormBase):
     recipe_id = Int(name="recipe", allow_none=False)
     recipe = Reference(recipe_id, "CharmRecipe.id")
 
+    recipe_platform_name = Unicode(
+        name="recipe_platform_name", allow_none=True
+    )
+
     distro_arch_series_id = Int(name="distro_arch_series", allow_none=False)
     distro_arch_series = Reference(
         distro_arch_series_id, "DistroArchSeries.id"
@@ -149,6 +153,7 @@ class CharmRecipeBuild(PackageBuildMixin, StormBase):
         processor,
         virtualized,
         channels=None,
+        recipe_platform_name=None,
         store_upload_metadata=None,
         date_created=DEFAULT,
     ):
@@ -163,6 +168,7 @@ class CharmRecipeBuild(PackageBuildMixin, StormBase):
         self.processor = processor
         self.virtualized = virtualized
         self.channels = channels
+        self.recipe_platform_name = recipe_platform_name
         self.store_upload_metadata = store_upload_metadata
         self.date_created = date_created
         self.status = BuildStatus.NEEDSBUILD
@@ -475,6 +481,7 @@ class CharmRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
         recipe,
         distro_arch_series,
         channels=None,
+        recipe_platform_name=None,
         store_upload_metadata=None,
         date_created=DEFAULT,
     ):
@@ -495,6 +502,7 @@ class CharmRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
             distro_arch_series.processor,
             virtualized,
             channels=channels,
+            recipe_platform_name=recipe_platform_name,
             store_upload_metadata=store_upload_metadata,
             date_created=date_created,
         )
diff --git a/lib/lp/charms/model/charmrecipebuildbehaviour.py b/lib/lp/charms/model/charmrecipebuildbehaviour.py
index f1ee46c..3e961ca 100644
--- a/lib/lp/charms/model/charmrecipebuildbehaviour.py
+++ b/lib/lp/charms/model/charmrecipebuildbehaviour.py
@@ -110,6 +110,8 @@ class CharmRecipeBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase):
                 )
             )
         args["private"] = build.is_private
+        if build.recipe_platform_name:
+            args["recipe_platform_name"] = build.recipe_platform_name
         return args
 
     def verifySuccessfulBuild(self):
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 2cb3f41..8a32bc6 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -50,7 +50,7 @@ from lp.buildmaster.interfaces.processor import (
 )
 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.buildqueue import BuildQueue
-from lp.charms.adapters.buildarch import BadPropertyError, MissingPropertyError
+from lp.charms.adapters.buildarch import CraftPlatformsBuildPlanError
 from lp.charms.interfaces.charmrecipe import (
     CHARM_RECIPE_ALLOW_CREATE,
     CHARM_RECIPE_BUILD_DISTRIBUTION,
@@ -546,6 +546,15 @@ class TestCharmRecipe(TestCaseWithFactory):
                     ),
                 )
 
+    def test_requestBuild_with_recipe_platform_name(self):
+        recipe = self.factory.makeCharmRecipe()
+        das = self.makeBuildableDistroArchSeries()
+        build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe)
+        build = recipe.requestBuild(
+            build_request, das, recipe_platform_name="ubuntu-amd64"
+        )
+        self.assertEqual("ubuntu-amd64", build.recipe_platform_name)
+
     def test_requestBuilds(self):
         # requestBuilds schedules a job and returns a corresponding
         # CharmRecipeBuildRequest.
@@ -780,11 +789,7 @@ class TestCharmRecipe(TestCaseWithFactory):
         )
         transaction.commit()
         with person_logged_in(job.requester):
-            with ExpectedException(
-                BadPropertyError,
-                "Invalid value for base 'ubuntu-24.04'. "
-                "Expected value should be like 'ubuntu@24.04'",
-            ):
+            with ExpectedException(CraftPlatformsBuildPlanError):
                 job.recipe.requestBuildsFromJob(
                     job.build_request,
                     channels=removeSecurityProxy(job.channels),
@@ -793,6 +798,8 @@ class TestCharmRecipe(TestCaseWithFactory):
     def test_requestBuildsFromJob_unified_charmcraft_yaml_platforms_missing(
         self,
     ):
+        # If the recipe has no platforms specified, craft-platforms builds
+        # for all default architectures without an option of cross-compiling.
         self.useFixture(
             GitHostingFixture(
                 blob=dedent(
@@ -809,13 +816,13 @@ class TestCharmRecipe(TestCaseWithFactory):
         )
         transaction.commit()
         with person_logged_in(job.requester):
-            with ExpectedException(
-                MissingPropertyError, "The 'platforms' property is required"
-            ):
-                job.recipe.requestBuildsFromJob(
-                    job.build_request,
-                    channels=removeSecurityProxy(job.channels),
-                )
+            builds = job.recipe.requestBuildsFromJob(
+                job.build_request,
+                channels=removeSecurityProxy(job.channels),
+            )
+        self.assertRequestedBuildsMatch(
+            builds, job, "24.04", ["amd64", "riscv64", "arm64"], job.channels
+        )
 
     def test_requestBuildsFromJob_unified_charmcraft_yaml_fully_expanded(self):
         self.useFixture(
@@ -890,8 +897,8 @@ class TestCharmRecipe(TestCaseWithFactory):
                         channel: 24.04
                     platforms:
                         ubuntu-amd64:
-                            build-on: amd64
-                            build-for: amd64
+                            build-on: [amd64]
+                            build-for: [amd64]
                     """
                 )
             )
@@ -959,9 +966,7 @@ class TestCharmRecipe(TestCaseWithFactory):
         transaction.commit()
         with person_logged_in(job.requester):
             with ExpectedException(
-                BadPropertyError,
-                "'foobar' is not a supported architecture for "
-                "'ubuntu@24.04'",
+                CraftPlatformsBuildPlanError,
             ):
                 job.recipe.requestBuildsFromJob(
                     job.build_request,
diff --git a/lib/lp/charms/tests/test_charmrecipebuildbehaviour.py b/lib/lp/charms/tests/test_charmrecipebuildbehaviour.py
index 29fefa6..27512bc 100644
--- a/lib/lp/charms/tests/test_charmrecipebuildbehaviour.py
+++ b/lib/lp/charms/tests/test_charmrecipebuildbehaviour.py
@@ -420,6 +420,22 @@ class TestAsyncCharmRecipeBuildBehaviour(
         self.assertEqual({"charmcraft": "edge"}, args["channels"])
 
     @defer.inlineCallbacks
+    def test_extraBuildArgs_recipe_platform_name(self):
+        # If the build is for a particular platform name, extraBuildArgs
+        # sends it.
+        job = self.makeJob(recipe_platform_name="ubuntu-amd64")
+        (
+            expected_archives,
+            expected_trusted_keys,
+        ) = yield get_sources_list_for_building(
+            job, job.build.distro_arch_series, None
+        )
+        with dbuser(config.builddmaster.dbuser):
+            args = yield job.extraBuildArgs()
+        self.assertFalse(isProxy(args["recipe_platform_name"]))
+        self.assertEqual("ubuntu-amd64", args["recipe_platform_name"])
+
+    @defer.inlineCallbacks
     def test_extraBuildArgs_archives_primary(self):
         # The build uses the release, security, and updates pockets from the
         # primary archive.
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 427e323..b956c6d 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -6826,6 +6826,7 @@ class LaunchpadObjectFactory(ObjectFactory):
         requester=None,
         distro_arch_series=None,
         channels=None,
+        recipe_platform_name=None,
         store_upload_metadata=None,
         date_created=DEFAULT,
         status=BuildStatus.NEEDSBUILD,
@@ -6851,6 +6852,7 @@ class LaunchpadObjectFactory(ObjectFactory):
             recipe,
             distro_arch_series,
             channels=channels,
+            recipe_platform_name=recipe_platform_name,
             store_upload_metadata=store_upload_metadata,
             date_created=date_created,
         )

Follow ups