← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~lgp171188/launchpad:charmcraft-unified-format into launchpad:master

 

Guruprasad has proposed merging ~lgp171188/launchpad:charmcraft-unified-format into launchpad:master with ~jugmac00/launchpad:add-is_unified_format-for-charms as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~lgp171188/launchpad/+git/launchpad/+merge/467335
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~lgp171188/launchpad:charmcraft-unified-format into launchpad:master.
diff --git a/lib/lp/charms/adapters/buildarch.py b/lib/lp/charms/adapters/buildarch.py
index 2579ad8..9292bdb 100644
--- a/lib/lp/charms/adapters/buildarch.py
+++ b/lib/lp/charms/adapters/buildarch.py
@@ -6,6 +6,7 @@ __all__ = [
 ]
 
 import json
+import re
 from collections import Counter, OrderedDict
 
 from lp.services.helpers import english_list
@@ -18,10 +19,10 @@ class CharmBasesParserError(Exception):
 class MissingPropertyError(CharmBasesParserError):
     """Error for when an expected property is not present in the YAML."""
 
-    def __init__(self, prop):
-        super().__init__(
-            f"Base specification is missing the {prop!r} property"
-        )
+    def __init__(self, prop, msg=None):
+        if msg is None:
+            msg = f"Base specification is missing the {prop!r} property"
+        super().__init__(msg)
         self.property = prop
 
 
@@ -107,6 +108,14 @@ class CharmBaseConfiguration:
         # Expand short-form configuration into long-form.  Account for
         # common typos in case the user intends to use long-form but did so
         # incorrectly (for better error message handling).
+        #      build-on:
+        #          - name: ubuntu
+        #            channel: 20.04
+        #            architectures: [amd64]
+        #      run-on:
+        #          - name: ubuntu
+        #            channel: 20.04
+        #            architectures: [amd64]
         if not any(
             item in properties
             for item in ("run-on", "run_on", "build-on", "build_on")
@@ -125,6 +134,85 @@ 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"
+            )
+
+        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}'."
+                    )
+            return cls(build_on, build_for)
+
+
 def determine_instances_to_build(
     charmcraft_data, supported_arches, default_distro_series
 ):
@@ -138,12 +226,20 @@ def determine_instances_to_build(
         charmcraft.yaml does not explicitly declare any bases.
     :return: A list of `DistroArchSeries`.
     """
+    from lp.charms.model.charmrecipe import is_unified_format
+
     bases_list = charmcraft_data.get("bases")
 
     if bases_list:
         configs = [
             CharmBaseConfiguration.from_dict(item) for item in bases_list
         ]
+    elif is_unified_format(charmcraft_data):
+        configs = [
+            UnifiedCharmBaseConfiguration.from_dict(
+                charmcraft_data, supported_arches
+            )
+        ]
     else:
         # If no bases are specified, build one for each supported
         # architecture for the default series.
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index d6f5819..b149cbc 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -31,6 +31,7 @@ from testtools.matchers import (
     MatchesSetwise,
     MatchesStructure,
 )
+from testtools.testcase import ExpectedException
 from zope.component import getUtility
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
@@ -49,6 +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.interfaces.charmrecipe import (
     CHARM_RECIPE_ALLOW_CREATE,
     CHARM_RECIPE_BUILD_DISTRIBUTION,
@@ -755,6 +757,212 @@ class TestCharmRecipe(TestCaseWithFactory):
             builds, job, "20.04", ["mips64el", "riscv64"], job.channels
         )
 
+    def test_requestBuildsFromJob_unified_charmcraft_yaml_invalid_short_base(
+        self,
+    ):
+        self.useFixture(
+            GitHostingFixture(
+                blob=dedent(
+                    """\
+                    base: ubuntu-24.04
+                    platforms:
+                        ubuntu-amd64:
+                            build-on: [amd64]
+                            build-for: [amd64]
+                    """
+                )
+            )
+        )
+        job = self.makeRequestBuildsJob("24.04", ["amd64", "riscv64", "arm64"])
+
+        self.assertEqual(
+            get_transaction_timestamp(IStore(job.recipe)), job.date_created
+        )
+        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'",
+            ):
+                job.recipe.requestBuildsFromJob(
+                    job.build_request,
+                    channels=removeSecurityProxy(job.channels),
+                )
+
+    def test_requestBuildsFromJob_unified_charmcraft_yaml_platforms_missing(
+        self,
+    ):
+        self.useFixture(
+            GitHostingFixture(
+                blob=dedent(
+                    """\
+                    base: ubuntu@24.04
+                    """
+                )
+            )
+        )
+        job = self.makeRequestBuildsJob("24.04", ["amd64", "riscv64", "arm64"])
+
+        self.assertEqual(
+            get_transaction_timestamp(IStore(job.recipe)), job.date_created
+        )
+        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),
+                )
+
+    def test_requestBuildsFromJob_unified_charmcraft_yaml_fully_expanded(self):
+        self.useFixture(
+            GitHostingFixture(
+                blob=dedent(
+                    """\
+                    base:
+                        name: ubuntu
+                        channel: 24.04
+                    platforms:
+                        ubuntu-amd64:
+                            build-on: [amd64]
+                            build-for: [amd64]
+                    """
+                )
+            )
+        )
+        job = self.makeRequestBuildsJob("24.04", ["amd64", "riscv64", "arm64"])
+        self.assertEqual(
+            get_transaction_timestamp(IStore(job.recipe)), job.date_created
+        )
+        transaction.commit()
+        with person_logged_in(job.requester):
+            builds = job.recipe.requestBuildsFromJob(
+                job.build_request, channels=removeSecurityProxy(job.channels)
+            )
+        self.assertRequestedBuildsMatch(
+            builds, job, "24.04", ["amd64"], job.channels
+        )
+
+    def test_requestBuildsFromJob_unified_charmcraft_yaml_arch_as_str(self):
+        self.useFixture(
+            GitHostingFixture(
+                blob=dedent(
+                    """\
+                    base:
+                        name: ubuntu
+                        channel: 24.04
+                    platforms:
+                        ubuntu-amd64:
+                            build-on: amd64
+                            build-for: amd64
+                    """
+                )
+            )
+        )
+        job = self.makeRequestBuildsJob("24.04", ["amd64", "riscv64", "arm64"])
+        self.assertEqual(
+            get_transaction_timestamp(IStore(job.recipe)), job.date_created
+        )
+        transaction.commit()
+        with person_logged_in(job.requester):
+            builds = job.recipe.requestBuildsFromJob(
+                job.build_request, channels=removeSecurityProxy(job.channels)
+            )
+        self.assertRequestedBuildsMatch(
+            builds, job, "24.04", ["amd64"], job.channels
+        )
+
+    def test_requestBuildsFromJob_unified_charmcraft_yaml_base_short_form(
+        self,
+    ):
+        self.useFixture(
+            GitHostingFixture(
+                blob=dedent(
+                    """\
+                    base: ubuntu@24.04
+                    platforms:
+                        ubuntu-amd64:
+                            build-on: [amd64]
+                            build-for: [amd64]
+                    """
+                )
+            )
+        )
+        job = self.makeRequestBuildsJob("24.04", ["amd64", "riscv64", "arm64"])
+        self.assertEqual(
+            get_transaction_timestamp(IStore(job.recipe)), job.date_created
+        )
+        transaction.commit()
+        with person_logged_in(job.requester):
+            builds = job.recipe.requestBuildsFromJob(
+                job.build_request, channels=removeSecurityProxy(job.channels)
+            )
+        self.assertRequestedBuildsMatch(
+            builds, job, "24.04", ["amd64"], job.channels
+        )
+
+    def test_requestBuildsFromJob_unified_charmcraft_yaml_unknown_arch(self):
+        self.useFixture(
+            GitHostingFixture(
+                blob=dedent(
+                    """\
+                    base:
+                        name: ubuntu
+                        channel: 24.04
+                    platforms:
+                        foobar:
+                    """
+                )
+            )
+        )
+        job = self.makeRequestBuildsJob("24.04", ["amd64", "riscv64", "arm64"])
+        self.assertEqual(
+            get_transaction_timestamp(IStore(job.recipe)), job.date_created
+        )
+        transaction.commit()
+        with person_logged_in(job.requester):
+            with ExpectedException(
+                BadPropertyError,
+                "'foobar' is not a supported architecture for "
+                "'ubuntu@24.04'",
+            ):
+                job.recipe.requestBuildsFromJob(
+                    job.build_request,
+                    channels=removeSecurityProxy(job.channels),
+                )
+
+    def test_requestBuildsFromJob_unified_charmcraft_yaml_platforms_short_form(
+        self,
+    ):
+        self.useFixture(
+            GitHostingFixture(
+                blob=dedent(
+                    """\
+                    base:
+                        name: ubuntu
+                        channel: 24.04
+                    platforms:
+                        amd64:
+                    """
+                )
+            )
+        )
+        job = self.makeRequestBuildsJob("24.04", ["amd64", "riscv64", "arm64"])
+        self.assertEqual(
+            get_transaction_timestamp(IStore(job.recipe)), job.date_created
+        )
+        transaction.commit()
+        with person_logged_in(job.requester):
+            builds = job.recipe.requestBuildsFromJob(
+                job.build_request, channels=removeSecurityProxy(job.channels)
+            )
+        self.assertRequestedBuildsMatch(
+            builds, job, "24.04", ["amd64"], job.channels
+        )
+
     def test_requestBuildsFromJob_no_charmcraft_yaml(self):
         # If the recipe has no charmcraft.yaml file, requestBuildsFromJob
         # treats this as equivalent to a charmcraft.yaml file that doesn't