Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: when handling actions, print uncaught exceptions to stderr #1087

Merged
merged 9 commits into from
Dec 15, 2023
8 changes: 7 additions & 1 deletion ops/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ def emit(self, record: logging.LogRecord):
self.model_backend.juju_log(record.levelname, self.format(record))


def setup_root_logging(model_backend: _ModelBackend, debug: bool = False):
def setup_root_logging(model_backend: _ModelBackend, debug: bool = False,
exc_stderr: bool = False):
"""Setup python logging to forward messages to juju-log.

By default, logging is set to DEBUG level, and messages will be filtered by Juju.
Expand All @@ -49,6 +50,7 @@ def setup_root_logging(model_backend: _ModelBackend, debug: bool = False):
Args:
model_backend: a ModelBackend to use for juju-log
debug: if True, write logs to stderr as well as to juju-log.
exc_stderr: if True, write uncaught exceptions to stderr as well as to juju-log.
"""
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
Expand All @@ -65,5 +67,9 @@ def except_hook(etype: typing.Type[BaseException],
logger.error(
"Uncaught exception while in charm code:",
exc_info=(etype, value, tb))
if exc_stderr:
print(f"Uncaught {etype.__name__} in charm code: {value}",
file=sys.stderr)
print("Use `juju debug-log` to see the full traceback.", file=sys.stderr)

sys.excepthook = except_hook
6 changes: 5 additions & 1 deletion ops/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,11 @@ def main(charm_class: Type[ops.charm.CharmBase],

model_backend = ops.model._ModelBackend()
debug = ('JUJU_DEBUG' in os.environ)
setup_root_logging(model_backend, debug=debug)
# For actions, there is a communication channel with the user running the
# action, so we want to send exception details through stderr, rather than
# only to juju-log as normal.
handling_action = ('JUJU_ACTION_NAME' in os.environ)
setup_root_logging(model_backend, debug=debug, exc_stderr=handling_action)
logger.debug("ops %s up and running.", ops.__version__) # type:ignore

dispatcher = _Dispatcher(charm_dir)
Expand Down
2 changes: 2 additions & 0 deletions test/charms/test_main/actions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ get-model-name:
description: Return the name of the model
get-status:
description: Return the Status of the unit
keyerror:
description: Raise a KeyError (to see crash output)
log-critical:
description: log a message at Critical level
log-error:
Expand Down
8 changes: 7 additions & 1 deletion test/charms/test_main/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ def __init__(self, *args: typing.Any):
self.framework.observe(self.on.foo_bar_action, self._on_foo_bar_action)
self.framework.observe(self.on.get_model_name_action, self._on_get_model_name_action)
self.framework.observe(self.on.get_status_action, self._on_get_status_action)
self.framework.observe(self.on.keyerror_action, self._on_keyerror_action)

self.framework.observe(self.on.log_critical_action, self._on_log_critical_action)
self.framework.observe(self.on.log_error_action, self._on_log_error_action)
Expand Down Expand Up @@ -225,10 +226,15 @@ def _on_foo_bar_action(self, event: ops.ActionEvent):
self._stored.on_foo_bar_action.append(type(event).__name__)
self._stored.observed_event_types.append(type(event).__name__)

def _on_get_status_action(self, event: ops.CollectStatusEvent):
def _on_get_status_action(self, event: ops.ActionEvent):
self._stored.status_name = self.unit.status.name
self._stored.status_message = self.unit.status.message

def _on_keyerror_action(self, event: ops.ActionEvent):
# Deliberately raise an uncaught exception, so that we can observe the
# behaviour when an action crashes.
raise KeyError("'foo' not found in 'bar'")

def _on_collect_metrics(self, event: ops.CollectMetricsEvent):
self._stored.on_collect_metrics.append(type(event).__name__)
self._stored.observed_event_types.append(type(event).__name__)
Expand Down
18 changes: 15 additions & 3 deletions test/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ def _prepare_actions(self):
actions_dir_name = 'actions'
actions_dir = self.JUJU_CHARM_DIR / actions_dir_name
actions_dir.mkdir()
for action_name in ('start', 'foo-bar', 'get-model-name', 'get-status'):
for action_name in ('start', 'foo-bar', 'get-model-name', 'get-status', 'keyerror'):
self._setup_entry_point(actions_dir, action_name)

def _read_and_clear_state(self,
Expand Down Expand Up @@ -1128,10 +1128,22 @@ def _call_event(self, rel_path: Path, env: typing.Dict[str, str]):
)
subprocess.run(
[sys.executable, str(dispatch)],
# stdout=self.stdout,
# stderr=self.stderr,
stdout=self.stdout,
stderr=self.stderr,
check=True, env=env, cwd=str(self.JUJU_CHARM_DIR))

def test_crash_action(self):
self._prepare_actions()
self.stderr = tempfile.TemporaryFile()
self.addCleanup(self.stderr.close)
fake_script(typing.cast(unittest.TestCase, self), 'action-get', "echo '{}'")
with self.assertRaises(subprocess.CalledProcessError):
self._simulate_event(EventSpec(
ops.ActionEvent, 'keyerror_action',
env_var='JUJU_ACTION_NAME'))
self.stderr.seek(0)
self.assertIn(b'KeyError', self.stderr.read())
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved


class TestMainWithDispatchAsScript(_TestMainWithDispatch, unittest.TestCase):
"""Here dispatch is a script that execs the charm.py instead of a symlink."""
Expand Down
Loading