Repository: canonical/charmcraft

Issue:
`charmcraft analyze` - check for invalid `config.yaml`
Follow on from [canonical/operator#618](https://github.com/canonical/operator/pull/618). 

At present, it is possible to successfully `charmcraft pack` on a charm with an empty `config.yaml`, i.e. one that does not contain an `options:` map. Given that Juju will refuse to deploy a charm with such a `config.yaml`, we should probably try to catch this using the `charmcraft analyze` machinery that is invoked when a charm is packed.

As I understand, you can completely omit `config.yaml`, but if it exists it must have a top level key named `options`.

Comments:

Patch:
diff --git a/charmcraft/linters.py b/charmcraft/linters.py
index d75624d..8d579b2 100644
--- a/charmcraft/linters.py
+++ b/charmcraft/linters.py
@@ -23,6 +23,8 @@ import shlex
 from collections import namedtuple
 from typing import List, Generator, Union
 
+import yaml
+
 from charmcraft import config
 from charmcraft.metadata import parse_metadata_yaml
 
@@ -233,10 +235,86 @@ class JujuMetadata:
         return result
 
 
+class JujuActions:
+    """Check that the actions.yaml file is valid YAML if it exists."""
+
+    check_type = CheckType.lint
+    name = "juju-actions"
+    url = "https://juju.is/docs/sdk/charmcraft-analyze#heading--juju-actions"
+    text = "The actions.yaml file is not a valid YAML file."
+
+    # different result constants
+    Result = namedtuple("Result", "ok errors")(ok=OK, errors=ERRORS)
+
+    def run(self, basedir: pathlib.Path) -> str:
+        """Run the proper verifications."""
+        filepath = basedir / "actions.yaml"
+        if not filepath.exists():
+            # it's optional
+            return self.Result.ok
+
+        try:
+            with filepath.open("rt", encoding="utf8") as fh:
+                yaml.safe_load(fh)
+        except Exception:
+            return self.Result.errors
+
+        return self.Result.ok
+
+
+class JujuConfig:
+    """Check that the config.yaml file (if it exists) is valid.
+
+    The file is considered valid if the following checks are true:
+
+    - has an 'options' key
+    - it is a dictionary
+    - each item inside has the mandatory 'type' key
+    """
+
+    check_type = CheckType.lint
+    name = "juju-config"
+    url = "https://juju.is/docs/sdk/charmcraft-analyze#heading--juju-config"
+
+    # different result constants
+    Result = namedtuple("Result", "ok errors")(ok=OK, errors=ERRORS)
+
+    def __init__(self):
+        self.text = None
+
+    def run(self, basedir: pathlib.Path) -> str:
+        """Run the proper verifications."""
+        filepath = basedir / "config.yaml"
+        if not filepath.exists():
+            # it's optional
+            return self.Result.ok
+
+        try:
+            with filepath.open("rt", encoding="utf8") as fh:
+                content = yaml.safe_load(fh)
+        except Exception:
+            self.text = "The config.yaml file is not a valid YAML file."
+            return self.Result.errors
+
+        options = content.get("options")
+        if not isinstance(options, dict):
+            self.text = "Error in config.yaml: must have an 'options' dictionary."
+            return self.Result.errors
+
+        for value in options.values():
+            if "type" not in value:
+                self.text = "Error in config.yaml: items under 'options' must have a 'type' key."
+                return self.Result.errors
+
+        return self.Result.ok
+
+
 # all checkers to run; the order here is important, as some checkers depend on the
 # results from others
 CHECKERS = [
     Language,
+    JujuActions,
+    JujuConfig,
     JujuMetadata,
     Framework,
 ]
@@ -263,7 +341,7 @@ def analyze(
                     name=cls.name,
                     result=IGNORED,
                     url=cls.url,
-                    text=cls.text,
+                    text="",
                 )
             )
             continue


Test patch:
diff --git a/tests/test_linters.py b/tests/test_linters.py
index c743c3e..4040d1b 100644
--- a/tests/test_linters.py
+++ b/tests/test_linters.py
@@ -28,6 +28,8 @@ from charmcraft.linters import (
     FATAL,
     Framework,
     IGNORED,
+    JujuActions,
+    JujuConfig,
     JujuMetadata,
     Language,
     UNKNOWN,
@@ -639,7 +641,7 @@ def test_analyze_ignore_attribute(config):
     assert res1.check_type == CheckType.attribute
     assert res1.name == "name1"
     assert res1.result == IGNORED
-    assert res1.text == "text1"
+    assert res1.text == ""
     assert res1.url == "url1"
     assert res2.check_type == CheckType.lint
     assert res2.name == "name2"
@@ -678,7 +680,7 @@ def test_analyze_ignore_linter(config):
     assert res2.check_type == CheckType.lint
     assert res2.name == "name2"
     assert res2.result == IGNORED
-    assert res2.text == "text2"
+    assert res2.text == ""
     assert res2.url == "url2"
 
 
@@ -755,3 +757,121 @@ def test_analyze_all_can_be_ignored(config):
     )
     result = analyze(config, "somepath")
     assert all(r.result == IGNORED for r in result)
+
+
+# --- tests for JujuActions checker
+
+
+def test_jujuactions_ok(tmp_path):
+    """The actions.yaml file is valid."""
+    actions_file = tmp_path / "actions.yaml"
+    actions_file.write_text("stuff: foobar")
+    result = JujuActions().run(tmp_path)
+    assert result == JujuActions.Result.ok
+
+
+def test_jujuactions_missing_file(tmp_path):
+    """No actions.yaml file at all."""
+    result = JujuActions().run(tmp_path)
+    assert result == JujuActions.Result.ok
+
+
+def test_jujuactions_file_corrupted(tmp_path):
+    """The actions.yaml file is not valid YAML."""
+    actions_file = tmp_path / "actions.yaml"
+    actions_file.write_text(" - \n-")
+    result = JujuActions().run(tmp_path)
+    assert result == JujuActions.Result.errors
+
+
+# --- tests for JujuConfig checker
+
+
+def test_jujuconfig_ok(tmp_path):
+    """The config.yaml file is valid."""
+    config_file = tmp_path / "config.yaml"
+    config_file.write_text(
+        """
+        options:
+            foo:
+                type: buzz
+    """
+    )
+    result = JujuConfig().run(tmp_path)
+    assert result == JujuConfig.Result.ok
+
+
+def test_jujuconfig_missing_file(tmp_path):
+    """No config.yaml file at all."""
+    result = JujuConfig().run(tmp_path)
+    assert result == JujuConfig.Result.ok
+
+
+def test_jujuconfig_file_corrupted(tmp_path):
+    """The config.yaml file is not valid YAML."""
+    config_file = tmp_path / "config.yaml"
+    config_file.write_text(" - \n-")
+    linter = JujuConfig()
+    result = linter.run(tmp_path)
+    assert result == JujuConfig.Result.errors
+    assert linter.text == "The config.yaml file is not a valid YAML file."
+
+
+def test_jujuconfig_no_options(tmp_path):
+    """The config.yaml file does not have an options key."""
+    config_file = tmp_path / "config.yaml"
+    config_file.write_text(
+        """
+        summary: Small text.
+    """
+    )
+    linter = JujuConfig()
+    result = linter.run(tmp_path)
+    assert result == JujuConfig.Result.errors
+    assert linter.text == "Error in config.yaml: must have an 'options' dictionary."
+
+
+def test_jujuconfig_empty_options(tmp_path):
+    """The config.yaml file has an empty options key."""
+    config_file = tmp_path / "config.yaml"
+    config_file.write_text(
+        """
+        options:
+    """
+    )
+    linter = JujuConfig()
+    result = linter.run(tmp_path)
+    assert result == JujuConfig.Result.errors
+    assert linter.text == "Error in config.yaml: must have an 'options' dictionary."
+
+
+def test_jujuconfig_options_not_dict(tmp_path):
+    """The config.yaml file has an options key that is not a dict."""
+    config_file = tmp_path / "config.yaml"
+    config_file.write_text(
+        """
+        options:
+          - foo
+          - bar
+    """
+    )
+    linter = JujuConfig()
+    result = linter.run(tmp_path)
+    assert result == JujuConfig.Result.errors
+    assert linter.text == "Error in config.yaml: must have an 'options' dictionary."
+
+
+def test_jujuconfig_no_type_in_options_items(tmp_path):
+    """The items under 'options' must have a 'type' key."""
+    config_file = tmp_path / "config.yaml"
+    config_file.write_text(
+        """
+        options:
+          foo:
+            description: something missing
+    """
+    )
+    linter = JujuConfig()
+    result = linter.run(tmp_path)
+    assert result == JujuConfig.Result.errors
+    assert linter.text == "Error in config.yaml: items under 'options' must have a 'type' key."