Repository: flyingcircusio/batou

Issue:
batou.utils.Address: Report FQDN an error occured with
When using  batou.utils.Address() it's sometimes hard to determine the root cause of an error like:

```
ERROR: gaierror(-2, 'Name or service not known')
This /might/ be a batou bug. Please consider reporting it.

      Host: myhost0815
 Component: mycomponent

Traceback (simplified, most recent call last):
  File "/path/to/user/deployment/components/mycomponent/component.py", line 89, in configure
    self.address = batou.utils.Address(self.homeserver_alias,
  File "/path/to/user/deployment/.appenv/33db79a7/lib/python3.8/site-packages/batou/utils.py", line 198, in __init__
    address = resolve(connect, port)
  File "/path/to/user/deployment/.appenv/33db79a7/lib/python3.8/site-packages/batou/utils.py", line 126, in resolve
    responses = socket.getaddrinfo(host, int(port), socket.AF_INET)
  File "/run/current-system/sw/lib/python3.8/socket.py", line 918, in getaddrinfo
    for res in _socket.getaddrinfo(host, port, family, type, proto, flags):
```

It would be great if the error message would contain the FQDN that failed to resolve. 

Discussion:

Patch:
diff --git a/src/batou/__init__.py b/src/batou/__init__.py
index 262c9964..f8f1b3e8 100644
--- a/src/batou/__init__.py
+++ b/src/batou/__init__.py
@@ -1,6 +1,7 @@
 # This code must not cause non-stdlib imports to support self-bootstrapping.
 import os
 import os.path
+import socket
 import traceback
 from typing import List, Optional
 
@@ -138,6 +139,28 @@ class AgeCallError(ReportingException):
         output.tabular("message", self.output, separator=":\n")
 
 
+class GetAddressInfoError(ReportingException, socket.gaierror):
+    """There was an error calling getaddrinfo."""
+
+    hostname: str
+    error: str
+
+    @classmethod
+    def from_context(cls, hostname, error):
+        self = cls()
+        self.hostname = hostname
+        self.error = error
+        return self
+
+    def __str__(self):
+        return f"Error while resolving {self.hostname}: {self.error}"
+
+    def report(self):
+        output.error("Error while resolving hostname")
+        output.tabular("hostname", self.hostname, red=True)
+        output.tabular("message", self.error, separator=":\n")
+
+
 class UpdateNeeded(AssertionError):
     """A component requires an update."""
 
@@ -234,7 +257,6 @@ class SilentConfigurationError(Exception):
 
 
 class MissingOverrideAttributes(ConfigurationError):
-
     component_breadcrumbs: str
     attributes: List[str]
 
@@ -732,7 +754,6 @@ class DuplicateHostError(ConfigurationError):
 
 
 class InvalidIPAddressError(ConfigurationError):
-
     sort_key = (0,)
 
     @classmethod
diff --git a/src/batou/utils.py b/src/batou/utils.py
index 1401b31c..3f4fe833 100644
--- a/src/batou/utils.py
+++ b/src/batou/utils.py
@@ -17,7 +17,12 @@ from typing import Optional
 
 import pkg_resources
 
-from batou import DeploymentError, IPAddressConfigurationError, output
+from batou import (
+    DeploymentError,
+    GetAddressInfoError,
+    IPAddressConfigurationError,
+    output,
+)
 
 
 class BagOfAttributes(dict):
@@ -259,11 +264,16 @@ class Address(object):
             try:
                 address = resolve(self.connect.host, self.connect.port)
                 self.listen = NetLoc(address, self.connect.port)
-            except Exception:
+            except Exception as e:
                 if self.require_v4 == "optional":
                     self.listen = None
                 else:
-                    raise
+                    if isinstance(e, OSError):
+                        raise GetAddressInfoError.from_context(
+                            f"{self.connect.host}:{self.connect.port}", str(e)
+                        )
+                    else:
+                        raise
 
         return self._listen_v4
 
@@ -286,11 +296,16 @@ class Address(object):
             try:
                 address = resolve_v6(self.connect.host, self.connect.port)
                 self.listen_v6 = NetLoc(address, self.connect.port)
-            except Exception:
+            except Exception as e:
                 if self.require_v6 == "optional":
                     self.listen_v6 = None
                 else:
-                    raise
+                    if isinstance(e, OSError):
+                        raise GetAddressInfoError.from_context(
+                            f"{self.connect.host}:{self.connect.port}", str(e)
+                        )
+                    else:
+                        raise
 
         return self._listen_v6
 


Test patch:
diff --git a/src/batou/tests/test_utils.py b/src/batou/tests/test_utils.py
index 0813114a..4a7005bc 100644
--- a/src/batou/tests/test_utils.py
+++ b/src/batou/tests/test_utils.py
@@ -172,11 +172,11 @@ def test_address_v6_only(monkeypatch):
 
     monkeypatch.setitem(resolve_v6_override, hostname, "::346")
 
-    with pytest.raises(socket.gaierror) as f:
+    with pytest.raises(batou.GetAddressInfoError) as f:
         a = Address(hostname, 1234)
         a.listen
 
-    assert str(f.value) in RESOLVER_ERRORS
+    assert any(x in str(f.value) for x in RESOLVER_ERRORS)
 
     address = Address(hostname, 1234, require_v4=False, require_v6=True)
     assert address.listen_v6.host == "::346"
@@ -184,29 +184,29 @@ def test_address_v6_only(monkeypatch):
 
 
 def test_address_fails_when_name_cannot_be_looked_up_at_all():
-    with pytest.raises(socket.gaierror) as f:
+    with pytest.raises(batou.GetAddressInfoError) as f:
         a = Address("does-not-exist.example.com:1234")
         a.listen
-    assert str(f.value) in RESOLVER_ERRORS
+    assert any(x in str(f.value) for x in RESOLVER_ERRORS)
 
-    with pytest.raises(socket.gaierror) as f:
+    with pytest.raises(batou.GetAddressInfoError) as f:
         a = Address(
             "does-not-exist.example.com:1234", require_v4=False, require_v6=True
         )
         a.listen_v6
-    assert str(f.value) in RESOLVER_ERRORS
+    assert any(x in str(f.value) for x in RESOLVER_ERRORS)
 
-    with pytest.raises(socket.gaierror) as f:
+    with pytest.raises(batou.GetAddressInfoError) as f:
         a = Address(
             "does-not-exist.example.com:1234", require_v4=True, require_v6=True
         )
         a.listen
-    with pytest.raises(socket.gaierror) as f:
+    with pytest.raises(batou.GetAddressInfoError) as f:
         a = Address(
             "does-not-exist.example.com:1234", require_v4=True, require_v6=True
         )
         a.listen_v6
-    assert str(f.value) in RESOLVER_ERRORS
+    assert any(x in str(f.value) for x in RESOLVER_ERRORS)
 
 
 def test_address_optional_when_name_cannot_be_looked_up_at_all():
@@ -341,7 +341,6 @@ class LockfileContextManagerTests(unittest.TestCase):
 
 
 class NotifyTests(unittest.TestCase):
-
     # XXX: What this actually should test is:
     # if notify-send is there, use it. It does not need to exit on all
     # non-darwin machines.
@@ -394,7 +393,6 @@ def test_graph_remove_leafs():
 
 
 class Checksum(unittest.TestCase):
-
     fixture = os.path.join(
         os.path.dirname(__file__), "fixture", "component", "haproxy.cfg"
     )