Repository: flyingcircusio/batou

Issue:
Better error handling on invalid environment configuration 
(2.3b3)

Let's having this component:

```python
class Settings(batou.component.Component):

    public_name = batou.component.Attribute(str)
```

and an configuration inside environment like

```
[component:settings]
public_name = {{host.aliases.www}}
```

(in this case wrongly copied over from devcontainer envrionment) 

giving a rather good understandable error:

```
ERROR: KeyError('www')
This /might/ be a batou bug. Please consider reporting it.

      Host: myhost00
 Component: settings

Traceback (simplified, most recent call last):
  File "/my/deployment//.appenv/f2def7b1/lib/python3.8/site-packages/batou/template.py", line 74, in expand
    return tmpl.render(**args)
  File "/my/deployment//.appenv/f2def7b1/lib/python3.8/site-packages/jinja2/environment.py", line 1291, in render
    self.environment.handle_exception()
  File "/my/deployment//.appenv/f2def7b1/lib/python3.8/site-packages/jinja2/environment.py", line 925, in handle_exception
    raise rewrite_traceback_stack(source=source)
  File "Settings", line 1, in top-level template code
  File "/my/deployment//.appenv/f2def7b1/lib/python3.8/site-packages/jinja2/environment.py", line 474, in getattr
    return getattr(obj, attribute)
  File "/my/deployment//.appenv/f2def7b1/lib/python3.8/site-packages/batou/utils.py", line 32, in __getattr__
    return self[key]
```


Discussion:

Patch:
diff --git a/examples/errors2/components/component1/component.py b/examples/errors2/components/component1/component.py
index cb37d5c4..a51a3180 100644
--- a/examples/errors2/components/component1/component.py
+++ b/examples/errors2/components/component1/component.py
@@ -24,6 +24,13 @@ class Component4(Component):
         self.require("application")
 
 
+class Component5(Component):
+    attribute_cannot_be_expanded = Attribute("literal", default=None)
+
+    def configure(self):
+        print(self.attribute_cannot_be_expanded)
+
+
 class SubComponent(Component):
 
     namevar = "aname"
diff --git a/examples/errors2/environments/errors/environment.cfg b/examples/errors2/environments/errors/environment.cfg
index 9af3da71..1edfc60e 100644
--- a/examples/errors2/environments/errors/environment.cfg
+++ b/examples/errors2/environments/errors/environment.cfg
@@ -2,7 +2,7 @@
 connect_method = local
 
 [hosts]
-localhost = component1, component2, component3, component4, cycle1, cycle2, dnsproblem, dnsproblem2, crontab, filemode
+localhost = component1, component2, component3, component4, component5, cycle1, cycle2, dnsproblem, dnsproblem2, crontab, filemode
 
 [component:component1]
 do_what_is_needed = false
@@ -10,5 +10,8 @@ do_what_is_needed = false
 [component:component2]
 this_does_not_exist = True
 
+[component:component5]
+attribute_cannot_be_expanded = {{host.aliases.doesnotexist}}
+
 [component:dnsproblem]
 attribute_with_problem = localhost
diff --git a/src/batou/__init__.py b/src/batou/__init__.py
index 262c9964..37382569 100644
--- a/src/batou/__init__.py
+++ b/src/batou/__init__.py
@@ -175,6 +175,45 @@ class ConfigurationError(ReportingException):
         output.error(message)
 
 
+class AttributeExpansionError(ConfigurationError):
+    """An override attribute could not be expanded properly."""
+
+    component_breadcrumbs: str
+    value_repr: str
+    error_str: str
+    key: str
+
+    @property
+    def sort_key(self):
+        return (
+            1,
+            self.affected_hostname,
+            self.component_breadcrumbs,
+            self.key,
+        )
+
+    @classmethod
+    def from_context(cls, component, key, value, error):
+        self = cls()
+        self.affected_hostname = component.root.host.name
+        self.component_breadcrumbs = component._breadcrumbs
+        self.value_repr = repr(value)
+        self.error_str = str(error)
+        self.key = key
+        return self
+
+    def __str__(self):
+        return "Error while expanding attribute: " + self.error_str
+
+    def report(self):
+        output.error("Error while expanding attribute: " + self.error_str)
+        output.tabular(
+            "Attribute",
+            "{}.{}".format(self.component_breadcrumbs, self.key),
+            red=True,
+        )
+
+
 class ConversionError(ConfigurationError):
     """An override attribute could not be converted properly."""
 
diff --git a/src/batou/component.py b/src/batou/component.py
index 21beb18e..95f845ca 100644
--- a/src/batou/component.py
+++ b/src/batou/component.py
@@ -1150,19 +1150,20 @@ class Attribute(object):
         #     value = attribute.from_config_string(self, value)
         # setattr(self, key, value)
         #
-        if self.expand:
-            value = obj.expand(value)
+        try:
+            if self.expand:
+                value = obj.expand(value)
+        except Exception as e:
+            name = self.names.get(obj.__class__, "<unknown>")
+            raise batou.AttributeExpansionError.from_context(
+                obj, name, value, repr(e)
+            ) from e
         if self.map:
             value = obj.map(value)
         try:
             value = self.conversion(value)
         except Exception as e:
-            # Try to detect our own name.
-            name = "<unknown>"
-            for k in dir(obj):
-                if getattr(obj.__class__, k, None) is self:
-                    name = k
-                    break
+            name = self.names.get(obj.__class__, "<unknown>")
             raise batou.ConversionError.from_context(
                 obj, name, value, self.conversion, e
             )


Test patch:
diff --git a/src/batou/tests/test_endtoend.py b/src/batou/tests/test_endtoend.py
index 4e8341c9..bb25057c 100644
--- a/src/batou/tests/test_endtoend.py
+++ b/src/batou/tests/test_endtoend.py
@@ -120,6 +120,10 @@ ERROR: malformed node or string: <...Name object at 0x...>
      Conversion: convert_literal('false')
  Affected hosts: localhost
 
+ERROR: Error while expanding attribute: KeyError('doesnotexist'...
+      Attribute: Component5.attribute_cannot_be_expanded
+ Affected hosts: localhost
+
 ERROR: Need port for service address.
       Attribute: DNSProblem.attribute_with_problem
      Conversion: Address('localhost')
@@ -149,8 +153,8 @@ cycle1 depends on
 cycle2 depends on
         cycle1
 
-ERROR: 9 remaining unconfigured component(s): component1, component2, component4, crontab, cycle1, cycle2, dnsproblem, dnsproblem2, filemode
-======================= 10 ERRORS - CONFIGURATION FAILED =======================
+ERROR: 10 remaining unconfigured component(s): component1, component2, component4, component5, crontab, cycle1, cycle2, dnsproblem, dnsproblem2, filemode
+======================= 11 ERRORS - CONFIGURATION FAILED =======================
 ====================== DEPLOYMENT FAILED (during connect) ======================
 """
     )  # noqa: E501 line too long