Repository: flyingcircusio/batou

Issue:
Debugging batou?
To debug issues in `batou` I am nowadays using [remote-pdb](https://pypi.org/project/remote-pdb/). It allows to start a debugger at any point in the code as it opens a remote port where the debugger can be accessed via `nc` or `telnet`.

To be able to use it in the tests I added `remote-pdb` locally to `tox.ini` as a dependency.

Would it be a good idea to add it (or another remote debugger) there permanently? (It does not hurt and eases debugging end to end tests.)
I can live with being forced to have to add the debugger to `requirements.txt` to debug actual `batou` runs.

Comments:

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 4967aa33..913923b6 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -18,6 +18,9 @@
 - Support `ls` syntax in mode attributes.
   ([#61](https://github.com/flyingcircusio/batou/issues/61))
 
+- Integrate `remote-pdb` to debug batou runs.
+  ([#199](https://github.com/flyingcircusio/batou/issues/199))
+
 - Fail if an attribute is set both in environment and via secrets.
   ([#28](https://github.com/flyingcircusio/batou/issues/28))
 
diff --git a/doc/source/user/advanced.txt b/doc/source/user/advanced.txt
index d8f9f392..4c0928dd 100644
--- a/doc/source/user/advanced.txt
+++ b/doc/source/user/advanced.txt
@@ -5,6 +5,61 @@ Advanced Usage
 Writing a custom component (TODO)
 ---------------------------------
 
+Debugging batou runs
+--------------------
+
+Using a debugger
+++++++++++++++++
+
+``batou`` comes with `remote-pdb <https://pypi.org/project/remote-pdb/>`_
+pre-installed. When running on Python 3.7+ [#]_ you can use ``breakpoint()`` to
+drop into the debugger. You need ``telnet`` or ``netcat`` to connect to the
+hostname and port displayed.
+
+If you are using the default configuration, call::
+
+  $ nc -C 127.0.0.1 4444
+  or
+  $ telnet 127.0.0.1 4444
+
+If you are debugging a remote deployment you should create a port forward
+beforehand like this::
+
+  $ ssh -L 4444:localhost:4444 my.remote.host.dev
+
+You are able to configure hostname and port. For details see the documentation
+of `remote-pdb <https://pypi.org/project/remote-pdb/>`_. This works both for
+local and remote deployments. The environment variables for host and port are
+propagated to the remote host.
+
+Example shell session for debugging using a custom port::
+
+  $ REMOTE_PDB_PORT=4445 ./batou deploy dev
+  batou/2.3b2.dev0 (cpython 3.6.15-final0, Darwin 20.6.0 x86_64)
+  ============================= Preparing ============================
+  main: Loading environment `dev`...
+  main: Verifying repository ...
+  main: Loading secrets ...
+  ============================= Connecting ... =======================
+  localhost: Connecting via local (1/1)
+  ============================= Configuring model ... ================
+  RemotePdb session open at 127.0.0.1:4445, waiting for connection ...
+
+Example for a second terminal where the opened port gets connected to::
+
+  $ nc -C 127.0.0.1 4445
+  > /.../components/supervisor/component.py(32)configure()
+  -> if self.host.platform in ('gocept.net', 'fcio.net'):
+  (Pdb)
+
+
+Using "print debugging"
++++++++++++++++++++++++
+
+* Inside a component you can use ``self.log("output")`` to print to the
+  console.
+* You can call ``batou`` with the ``-d`` flag to enable debugging output during
+  the deployment run.
 
 
 Using 3rd party libraries within batou
@@ -196,3 +251,5 @@ batou.c (TODO)
 --------------
 
 ordered alphabetically (significant for imports)
+
+.. [#] On Python 3.6 you have to use ``from remote_pdb import set_trace; set_trace()``.
diff --git a/setup.py b/setup.py
index b57f9cd0..79e45eb0 100644
--- a/setup.py
+++ b/setup.py
@@ -21,9 +21,10 @@ setup(
         # ConfigUpdater does not manage its minimum requirements correctly.
         "setuptools>=38.3",
         "execnet>=1.8.1",
-        "pyyaml",
         "importlib_metadata",
-        "py",],
+        "py",
+        "pyyaml",
+        'remote-pdb',],
     extras_require={
         "test": [
             "mock",
diff --git a/src/batou/__init__.py b/src/batou/__init__.py
index b1fb3171..b2eaf5bb 100644
--- a/src/batou/__init__.py
+++ b/src/batou/__init__.py
@@ -1,5 +1,6 @@
 # This code must not cause non-stdlib imports to support self-bootstrapping.
 import os.path
+import os
 import traceback
 
 from ._output import output
@@ -7,6 +8,13 @@ from ._output import output
 with open(os.path.dirname(__file__) + "/version.txt") as f:
     __version__ = f.read().strip()
 
+# Configure `remote-pdb` to be used with `breakpoint()` in Python 3.7+:
+os.environ['PYTHONBREAKPOINT'] = "remote_pdb.set_trace"
+if not os.environ.get('REMOTE_PDB_HOST', None):
+    os.environ['REMOTE_PDB_HOST'] = "127.0.0.1"
+if not os.environ.get('REMOTE_PDB_PORT', None):
+    os.environ['REMOTE_PDB_PORT'] = "4444"
+
 
 class ReportingException(Exception):
     """Exceptions that support user-readable reporting."""
diff --git a/src/batou/host.py b/src/batou/host.py
index ce232734..aa7c2e52 100644
--- a/src/batou/host.py
+++ b/src/batou/host.py
@@ -8,6 +8,12 @@ import yaml
 from batou import (DeploymentError, SilentConfigurationError, output,
                    remote_core)
 
+# Keys in os.environ which get propagated to the remote side:
+REMOTE_OS_ENV_KEYS = (
+    'REMOTE_PDB_HOST',
+    'REMOTE_PDB_PORT',
+)
+
 # Monkeypatch execnet to support 'vagrant ssh' and 'kitchen exec'.
 # 'vagrant' support has been added to 'execnet' release 1.4.
 
@@ -285,9 +291,19 @@ pre=\"\"; else pre=\"sudo -ni -u {user}\"; fi; $pre\
         # know about locally)
         self.rpc.setup_output()
 
-        self.rpc.setup_deployment(env.name, self.name, env.overrides,
-                                  env.secret_files, env.secret_data,
-                                  env._host_data(), env.timeout, env.platform)
+        self.rpc.setup_deployment(
+            env.name,
+            self.name,
+            env.overrides,
+            env.secret_files,
+            env.secret_data,
+            env._host_data(),
+            env.timeout,
+            env.platform,
+            {
+                key: os.environ.get(key)
+                for key in REMOTE_OS_ENV_KEYS if os.environ.get(key)},
+        )
 
     def disconnect(self):
         if self.gateway is not None:
diff --git a/src/batou/remote_core.py b/src/batou/remote_core.py
index 8b7856b4..1cb0555a 100644
--- a/src/batou/remote_core.py
+++ b/src/batou/remote_core.py
@@ -129,8 +129,16 @@ class Deployment(object):
 
     environment = None
 
-    def __init__(self, env_name, host_name, overrides, secret_files,
-                 secret_data, host_data, timeout, platform):
+    def __init__(self,
+                 env_name,
+                 host_name,
+                 overrides,
+                 secret_files,
+                 secret_data,
+                 host_data,
+                 timeout,
+                 platform,
+                 os_env=None):
         self.env_name = env_name
         self.host_name = host_name
         self.overrides = overrides
@@ -139,10 +147,13 @@ class Deployment(object):
         self.secret_data = secret_data
         self.timeout = timeout
         self.platform = platform
+        self.os_env = os_env
 
     def load(self):
         from batou.environment import Environment
 
+        if self.os_env:
+            os.environ.update(self.os_env)
         self.environment = Environment(self.env_name, self.timeout,
                                        self.platform)
         self.environment.deployment = self


Test patch:
diff --git a/src/batou/tests/test_config.py b/src/batou/tests/test_config.py
index 0be4d4b7..50738eb7 100644
--- a/src/batou/tests/test_config.py
+++ b/src/batou/tests/test_config.py
@@ -133,3 +133,10 @@ def test_config_exceptions_orderable(env):
     for x in exceptions:
         for y in exceptions:
             x.sort_key < y.sort_key
+
+
+def test_remote_pdb_config():
+    """There are some environment variables set with default values."""
+    assert os.environ["PYTHONBREAKPOINT"] == "remote_pdb.set_trace"
+    assert os.environ["REMOTE_PDB_HOST"] == "127.0.0.1"
+    assert os.environ["REMOTE_PDB_PORT"] == "4444"
diff --git a/src/batou/tests/test_remote_core.py b/src/batou/tests/test_remote_core.py
index 3ac23711..9d1709d5 100644
--- a/src/batou/tests/test_remote_core.py
+++ b/src/batou/tests/test_remote_core.py
@@ -1,6 +1,7 @@
 from batou import remote_core
 import inspect
 import mock
+import os
 import os.path
 import pytest
 
@@ -287,3 +288,28 @@ def test_git_remote_init_pull(tmpdir):
         remote_core.git_update_working_copy("master")
 
     assert "bar" == dest.join("foo.txt").read()
+
+
+def test_Deployment_sets_os_environ_on_load(monkeypatch):
+    """If ``os_env`` is given to ``Deployment`` it changes os.environ, ...or
+
+    when the ``load()`` method is called.
+    """
+    monkeypatch.chdir('examples/tutorial-helloworld')
+
+    assert 'MY_ENV_VAR' not in os.environ
+
+    dep = remote_core.Deployment(
+        env_name='tutorial',
+        host_name='localhost',
+        overrides={},
+        secret_files=None,
+        secret_data=None,
+        host_data={},
+        timeout=None,
+        platform=None,
+        os_env={'MY_ENV_VAR': 'MY-VALUE'},
+    )
+    dep.load()
+
+    assert os.environ['MY_ENV_VAR'] == 'MY-VALUE'