Repository: canonical/snapcraft

Issue:
Parse and validate component keywords in `snapcraft.yaml`
### What needs to get done

The Project model needs to parse and validate a top-level `components` keyword containing a list of `Component` pydantic models.

The schema is:
```yaml
components:
  <name>:
    type: "test"
    summary: ...
    description: ...
    version: ...
```

Rules: 
- component names are unique
- component names adhere to the naming convention for namespaced partitions (lowercase alphabetical characters and hyphens, cannot start or end with a hyphen)
- component type is `test`
- summary is a string with the same naming convention as the top-level `summary` keyword
- description is a string (same as the top-level `description`)
- version is an optional field with the same naming convention as the top-level `version` keyword


Snapcraft also needs a new function in the Project module called `get_component_names() -> List[str]`.  It should return a list of component names.  Later, this will be used to create a list of partitions to pass to the `LifecycleManager`.



### Why it needs to get done

To support components in snapcraft


Discussion:

Resolving patch:
diff --git a/snapcraft/projects.py b/snapcraft/projects.py
--- a/snapcraft/projects.py
+++ b/snapcraft/projects.py
@@ -187,6 +187,26 @@ def _validate_architectures_all_keyword(architectures):
             )
 
 
+def _validate_version_name(version: str, model_name: str) -> None:
+    """Validate a version complies to the naming convention.
+
+    :param version: version string to validate
+    :param model_name: name of the model that contains the version
+
+    :raises ValueError: if the version contains invalid characters
+    """
+    if version and not re.match(
+        r"^[a-zA-Z0-9](?:[a-zA-Z0-9:.+~-]*[a-zA-Z0-9+~])?$", version
+    ):
+        raise ValueError(
+            f"Invalid version '{version}': {model_name.title()} versions consist of "
+            "upper- and lower-case alphanumeric characters, as well as periods, colons, "
+            "plus signs, tildes, and hyphens. They cannot begin with a period, colon, "
+            "plus sign, tilde, or hyphen. They cannot end with a period, colon, or "
+            "hyphen"
+        )
+
+
 class Socket(ProjectModel):
     """Snapcraft app socket definition."""
 
@@ -415,6 +435,26 @@ def _validate_default_provider(cls, default_provider):
         return default_provider
 
 
+class Component(ProjectModel):
+    """Snapcraft component definition."""
+
+    summary: ProjectSummary
+    description: str
+    type: Literal["test"]
+    version: Optional[ProjectVersion]
+
+    @pydantic.validator("version")
+    @classmethod
+    def _validate_version(cls, version):
+        if version == "":
+            raise ValueError("Component version cannot be an empty string.")
+
+        if version:
+            _validate_version_name(version, "Component")
+
+        return version
+
+
 MANDATORY_ADOPTABLE_FIELDS = ("version", "summary", "description")
 
 
@@ -466,6 +506,7 @@ class Project(ProjectModel):
     build_snaps: Optional[GrammarStrList]
     ua_services: Optional[UniqueStrList]
     provenance: Optional[str]
+    components: Optional[Dict[ProjectName, Component]]
 
     @pydantic.validator("plugs")
     @classmethod
@@ -556,21 +597,40 @@ def _validate_name(cls, name):
 
         return name
 
+    @pydantic.validator("components")
+    @classmethod
+    def _validate_components(cls, components):
+        for component_name in components.keys():
+            cls._validate_component_name(component_name)
+
+        return components
+
+    @classmethod
+    def _validate_component_name(cls, name):
+        """Validate component names."""
+        if not re.fullmatch(r"[a-z-]*[a-z][a-z-]*", name):
+            raise ValueError(
+                "Component names can only use ASCII lowercase letters and hyphens"
+            )
+
+        if name.startswith("-"):
+            raise ValueError("Component names cannot start with a hyphen")
+
+        if name.endswith("-"):
+            raise ValueError("Component names cannot end with a hyphen")
+
+        if "--" in name:
+            raise ValueError("Component names cannot have two hyphens in a row")
+
+        return name
+
     @pydantic.validator("version")
     @classmethod
     def _validate_version(cls, version, values):
         if not version and "adopt_info" not in values:
             raise ValueError("Version must be declared if not adopting metadata")
 
-        if version and not re.match(
-            r"^[a-zA-Z0-9](?:[a-zA-Z0-9:.+~-]*[a-zA-Z0-9+~])?$", version
-        ):
-            raise ValueError(
-                f"Invalid version '{version}': Snap versions consist of upper- and lower-case "
-                "alphanumeric characters, as well as periods, colons, plus signs, tildes, "
-                "and hyphens. They cannot begin with a period, colon, plus sign, tilde, or "
-                "hyphen. They cannot end with a period, colon, or hyphen"
-            )
+        _validate_version_name(version, "Snap")
 
         return version
 
@@ -747,6 +807,13 @@ def get_build_for_arch_triplet(self) -> Optional[str]:
 
         return None
 
+    def get_component_names(self) -> Optional[List[str]]:
+        """Get the component names.
+
+        :returns: A list of component names for the project.
+        """
+        return list(self.components.keys()) if self.components else None
+
 
 class _GrammarAwareModel(pydantic.BaseModel):
     class Config:


Test patch:
diff --git a/tests/spread/core24/components/snap/Makefile b/tests/spread/core24/components/snap/Makefile
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/components/snap/Makefile
@@ -0,0 +1,11 @@
+# -*- Mode: Makefile; indent-tabs-mode:t; tab-width: 4 -*-
+.PHONY: all
+
+all: hello
+
+install: hello
+	install -d $(DESTDIR)/bin/
+	install -D $^ $(DESTDIR)/bin/
+
+hello: hello.c
+	$(CC) hello.c -o hello -lcurl
diff --git a/tests/spread/core24/components/snap/hello.c b/tests/spread/core24/components/snap/hello.c
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/components/snap/hello.c
@@ -0,0 +1,6 @@
+#include <stdio.h>
+#include <curl/curl.h>
+
+int main() {
+    curl_global_init(CURL_GLOBAL_DEFAULT);
+}
diff --git a/tests/spread/core24/components/snap/snapcraft.yaml b/tests/spread/core24/components/snap/snapcraft.yaml
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/components/snap/snapcraft.yaml
@@ -0,0 +1,36 @@
+name: snap-with-components
+version: "1.0"
+summary: Build a snap with components
+description: |
+  Build a snap with components to test components.
+
+base: core24
+build-base: devel
+
+grade: devel
+confinement: strict
+
+apps:
+  hello:
+    command: bin/hello
+
+components:
+  foo:
+    type: test
+    summary: Hello World
+    description: Hello World
+    version: "1.0"
+  bar:
+    type: test
+    summary: Hello World
+    description: Hello World
+
+parts:
+  hello:
+    source: .
+    plugin: make
+    build-packages:
+      - gcc
+      - libc-dev
+      - libcurl4-openssl-dev
+    stage-packages: [libcurl4]
diff --git a/tests/spread/core24/components/task.yaml b/tests/spread/core24/components/task.yaml
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/components/task.yaml
@@ -0,0 +1,11 @@
+summary: Build a snap with components
+
+restore: |
+  snapcraft clean
+  rm -f ./*.snap
+
+execute: |
+  cd "./snap"
+
+  # ensure snapcraft can parse the components model in the snapcraft.yaml
+  snapcraft pack --destructive-mode
diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py
--- a/tests/unit/test_projects.py
+++ b/tests/unit/test_projects.py
@@ -106,6 +106,7 @@ def test_project_defaults(self, project_yaml_data):
         assert project.ua_services is None
         assert project.system_usernames is None
         assert project.provenance is None
+        assert project.components is None
 
     def test_app_defaults(self, project_yaml_data):
         data = project_yaml_data(apps={"app1": {"command": "/bin/true"}})
@@ -247,7 +248,7 @@ def test_project_name_invalid(self, name, error, project_yaml_data):
             "git",
             "1~",
             "1+",
-            "12345678901234567890123456789012",
+            "x" * 32,
         ],
     )
     def test_project_version_valid(self, version, project_yaml_data):
@@ -257,51 +258,61 @@ def test_project_version_valid(self, version, project_yaml_data):
     @pytest.mark.parametrize(
         "version,error",
         [
-            (
+            pytest.param(
                 "1_0",
                 "Invalid version '1_0': Snap versions consist of",
-            ),  # _ is an invalid character
-            (
+                id="'_' in version",
+            ),
+            pytest.param(
                 "1=1",
                 "Invalid version '1=1': Snap versions consist of",
-            ),  # = is an invalid character
-            (
+                id="'=' in version",
+            ),
+            pytest.param(
                 ".1",
                 "Invalid version '.1': Snap versions consist of",
-            ),  # cannot start with period
-            (
+                id="cannot start with '.'",
+            ),
+            pytest.param(
                 ":1",
                 "Invalid version ':1': Snap versions consist of",
-            ),  # cannot start with colon
-            (
+                id="cannot start with ':'",
+            ),
+            pytest.param(
                 "+1",
                 r"Invalid version '\+1': Snap versions consist of",
-            ),  # cannot start with plus sign
-            # escaping + from the regex string to match.
-            (
+                id="cannot start with '+'",
+            ),
+            pytest.param(
                 "~1",
                 "Invalid version '~1': Snap versions consist of",
-            ),  # cannot start with tilde
-            (
+                id="cannot start with '~'",
+            ),
+            pytest.param(
                 "-1",
                 "Invalid version '-1': Snap versions consist of",
-            ),  # cannot start with hyphen
-            (
+                id="cannot start with '-'",
+            ),
+            pytest.param(
                 "1.",
                 "Invalid version '1.': Snap versions consist of",
-            ),  # cannot end with period
-            (
+                id="cannot end with '.'",
+            ),
+            pytest.param(
                 "1:",
                 "Invalid version '1:': Snap versions consist of",
-            ),  # cannot end with colon
-            (
+                id="cannot end with ':'",
+            ),
+            pytest.param(
                 "1-",
                 "Invalid version '1-': Snap versions consist of",
-            ),  # cannot end with hyphen
-            (
-                "123456789012345678901234567890123",
+                id="cannot end with '-'",
+            ),
+            pytest.param(
+                "1" * 33,
                 "ensure this value has at most 32 characters",
-            ),  # too large
+                id="too large",
+            ),
         ],
     )
     def test_project_version_invalid(self, version, error, project_yaml_data):
@@ -1773,3 +1784,206 @@ def test_project_get_build_for_arch_triplet_all(self, project_yaml_data):
         arch_triplet = project.get_build_for_arch_triplet()
 
         assert not arch_triplet
+
+
+class TestComponents:
+    """Validate components."""
+
+    @pytest.fixture
+    def stub_component_data(self):
+        return {
+            "type": "test",
+            "summary": "test summary",
+            "description": "test description",
+            "version": "1.0",
+        }
+
+    def test_components_valid(self, project_yaml_data, stub_component_data):
+        components = {"foo": stub_component_data, "bar": stub_component_data}
+
+        project = Project.unmarshal(project_yaml_data(components=components))
+
+        assert project.components == components
+
+    def test_component_type_valid(self, project_yaml_data, stub_component_data):
+        component = {"foo": stub_component_data}
+        component["foo"]["type"] = "test"
+
+        project = Project.unmarshal(project_yaml_data(components=component))
+
+        assert project.components
+        assert project.components["foo"].type == "test"
+
+    def test_component_type_invalid(self, project_yaml_data, stub_component_data):
+        component = {"foo": stub_component_data}
+        component["foo"]["type"] = "invalid"
+        error = ".*unexpected value; permitted: 'test'"
+
+        with pytest.raises(errors.ProjectValidationError, match=error):
+            Project.unmarshal(project_yaml_data(components=component))
+
+    @pytest.mark.parametrize("name", ["name", "name-with-dashes", "x" * 40])
+    def test_component_name_valid(self, name, project_yaml_data, stub_component_data):
+        component = {name: stub_component_data}
+
+        project = Project.unmarshal(project_yaml_data(components=component))
+
+        assert project.components
+        assert list(project.components.keys()) == [name]
+
+    @pytest.mark.parametrize(
+        "name,error",
+        [
+            ("123456", "Component names can only use"),
+            ("name-ends-with-digits-0123", "Component names can only use"),
+            ("456-name-starts-with-digits", "Component names can only use"),
+            ("name-789-contains-digits", "Component names can only use"),
+            ("name_with_underscores", "Component names can only use"),
+            ("name-with-UPPERCASE", "Component names can only use"),
+            ("name with spaces", "Component names can only use"),
+            ("-name-starts-with-hyphen", "Component names cannot start with a hyphen"),
+            ("name-ends-with-hyphen-", "Component names cannot end with a hyphen"),
+            (
+                "name-has--two-hyphens",
+                "Component names cannot have two hyphens in a row",
+            ),
+            ("x" * 41, "ensure this value has at most 40 characters"),
+        ],
+    )
+    def test_component_name_invalid(
+        self, name, error, project_yaml_data, stub_component_data
+    ):
+        component = {name: stub_component_data}
+
+        with pytest.raises(errors.ProjectValidationError, match=error):
+            Project.unmarshal(project_yaml_data(components=component))
+
+    def test_component_summary_valid(self, project_yaml_data, stub_component_data):
+        component = {"foo": stub_component_data}
+        summary = "x" * 78
+        component["foo"]["summary"] = summary
+
+        project = Project.unmarshal(project_yaml_data(components=component))
+
+        assert project.components
+        assert project.components["foo"].summary == summary
+
+    def test_component_summary_invalid(self, project_yaml_data, stub_component_data):
+        component = {"foo": stub_component_data}
+        component["foo"]["summary"] = "x" * 79
+
+        error = "ensure this value has at most 78 characters"
+        with pytest.raises(errors.ProjectValidationError, match=error):
+            Project.unmarshal(project_yaml_data(components=component))
+
+    @pytest.mark.parametrize(
+        "version",
+        [
+            "1",
+            "1.0",
+            "1.0.1-5.2~build0.20.04:1+1A",
+            "git",
+            "1~",
+            "1+",
+            "x" * 32,
+        ],
+    )
+    def test_component_version_valid(
+        self, version, project_yaml_data, stub_component_data
+    ):
+        component = {"foo": stub_component_data}
+        component["foo"]["version"] = version
+
+        project = Project.unmarshal(project_yaml_data(components=component))
+
+        assert project.components
+        assert project.components["foo"].version == version
+
+    @pytest.mark.parametrize(
+        "version,error",
+        [
+            pytest.param(
+                "1_0",
+                "Invalid version '1_0': Component versions consist of",
+                id="'_' in version",
+            ),
+            pytest.param(
+                "1=1",
+                "Invalid version '1=1': Component versions consist of",
+                id="'=' in version",
+            ),
+            pytest.param(
+                ".1",
+                "Invalid version '.1': Component versions consist of",
+                id="cannot start with '.'",
+            ),
+            pytest.param(
+                ":1",
+                "Invalid version ':1': Component versions consist of",
+                id="cannot start with ':'",
+            ),
+            pytest.param(
+                "+1",
+                r"Invalid version '\+1': Component versions consist of",
+                id="cannot start with '+'",
+            ),
+            pytest.param(
+                "~1",
+                "Invalid version '~1': Component versions consist of",
+                id="cannot start with '~'",
+            ),
+            pytest.param(
+                "-1",
+                "Invalid version '-1': Component versions consist of",
+                id="cannot start with '-'",
+            ),
+            pytest.param(
+                "1.",
+                "Invalid version '1.': Component versions consist of",
+                id="cannot end with '.'",
+            ),
+            pytest.param(
+                "1:",
+                "Invalid version '1:': Component versions consist of",
+                id="cannot end with ':'",
+            ),
+            pytest.param(
+                "1-",
+                "Invalid version '1-': Component versions consist of",
+                id="cannot end with '-'",
+            ),
+            pytest.param(
+                "x" * 33,
+                "ensure this value has at most 32 characters",
+                id="too large",
+            ),
+            pytest.param(
+                "",
+                "Component version cannot be an empty string",
+                id="empty string",
+            ),
+        ],
+    )
+    def test_project_version_invalid(
+        self, version, error, project_yaml_data, stub_component_data
+    ):
+        component = {"foo": stub_component_data}
+        component["foo"]["version"] = version
+
+        with pytest.raises(errors.ProjectValidationError, match=error):
+            Project.unmarshal(project_yaml_data(components=component))
+
+    def test_get_component_names(self, project_yaml_data, stub_component_data):
+        components = {"foo": stub_component_data, "bar": stub_component_data}
+        project = Project.unmarshal(project_yaml_data(components=components))
+
+        component_names = project.get_component_names()
+
+        assert component_names == ["foo", "bar"]
+
+    def test_get_components_names_none(self, project_yaml_data):
+        project = Project.unmarshal(project_yaml_data())
+
+        component_names = project.get_component_names()
+
+        assert component_names is None