diff --git a/subiquity/client/client.py b/subiquity/client/client.py
index 88b7fde3d..d16080bec 100644
--- a/subiquity/client/client.py
+++ b/subiquity/client/client.py
@@ -265,10 +265,9 @@ async def noninteractive_watch_app_state(self, initial_status):
app_status = await self._status_get(app_state)
def subiquity_event_noninteractive(self, event):
- if event["SUBIQUITY_EVENT_TYPE"] == "start":
- print("start: " + event["MESSAGE"])
- elif event["SUBIQUITY_EVENT_TYPE"] == "finish":
- print("finish: " + event["MESSAGE"])
+ event_type = event["SUBIQUITY_EVENT_TYPE"]
+ message = event["MESSAGE"]
+ print(f"{event_type}: {message}")
async def connect(self):
def p(s):
@@ -379,7 +378,9 @@ def header_func():
# prompting for confirmation will be confusing.
os.system("stty sane")
journald_listen(
- [status.event_syslog_id], self.subiquity_event_noninteractive, seek=True
+ [status.event_syslog_id],
+ self.subiquity_event_noninteractive,
+ seek=False,
)
run_bg_task(self.noninteractive_watch_app_state(status))
diff --git a/subiquity/server/controllers/reporting.py b/subiquity/server/controllers/reporting.py
index 90ad1ee70..b962b628f 100644
--- a/subiquity/server/controllers/reporting.py
+++ b/subiquity/server/controllers/reporting.py
@@ -17,10 +17,18 @@
import logging
from curtin.reporter import available_handlers, update_configuration
-from curtin.reporter.events import report_finish_event, report_start_event, status
+from curtin.reporter.events import (
+ ReportingEvent,
+ report_event,
+ report_finish_event,
+ report_start_event,
+ status,
+)
from curtin.reporter.handlers import LogHandler as CurtinLogHandler
from subiquity.server.controller import NonInteractiveController
+from subiquity.server.event_listener import EventListener
+from subiquitycore.context import Context
class LogHandler(CurtinLogHandler):
@@ -39,7 +47,7 @@ def publish_event(self, event):
NON_INTERACTIVE_CONFIG = {"builtin": {"type": "print"}}
-class ReportingController(NonInteractiveController):
+class ReportingController(EventListener, NonInteractiveController):
autoinstall_key = "reporting"
autoinstall_schema = {
"type": "object",
@@ -76,3 +84,18 @@ def report_finish_event(self, context, description, result):
report_finish_event(
context.full_name(), description, result, level=context.level
)
+
+ def report_info_event(self, context: Context, message: str):
+ """Report an "info" event."""
+ event = ReportingEvent("info", context.full_name(), message, level="INFO")
+ report_event(event)
+
+ def report_warning_event(self, context: Context, message: str):
+ """Report a "warning" event."""
+ event = ReportingEvent("warning", context.full_name(), message, level="WARNING")
+ report_event(event)
+
+ def report_error_event(self, context: Context, message: str):
+ """Report an "error" event."""
+ event = ReportingEvent("error", context.full_name(), message, level="ERROR")
+ report_event(event)
diff --git a/subiquity/server/controllers/tests/test_reporting.py b/subiquity/server/controllers/tests/test_reporting.py
index 56ae4c196..aa692aa7f 100644
--- a/subiquity/server/controllers/tests/test_reporting.py
+++ b/subiquity/server/controllers/tests/test_reporting.py
@@ -13,11 +13,17 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+from unittest.mock import Mock, patch
+
import jsonschema
+from curtin.reporter.events import status as CurtinStatus
from jsonschema.validators import validator_for
from subiquity.server.controllers.reporting import ReportingController
+from subiquitycore.context import Context
+from subiquitycore.context import Status as ContextStatus
from subiquitycore.tests import SubiTestCase
+from subiquitycore.tests.mocks import MockedApplication, make_app
class TestReportingController(SubiTestCase):
@@ -29,3 +35,85 @@ def test_valid_schema(self):
)
JsonValidator.check_schema(ReportingController.autoinstall_schema)
+
+
+@patch("subiquity.server.controllers.reporting.report_event")
+class TestReportingCurtinCalls(SubiTestCase):
+ def setUp(self):
+ app: MockedApplication = make_app()
+ self.controller: ReportingController = ReportingController(app)
+ self.context: Context = app.context
+
+ @patch("subiquity.server.controllers.reporting.report_start_event")
+ def test_start_event(self, report_start_event, report_event):
+ self.controller.report_start_event(self.context, "description")
+
+ # Calls specific start event method
+ report_start_event.assert_called_with(
+ self.context.full_name(), "description", level=self.context.level
+ )
+
+ # Not the generic one
+ report_event.assert_not_called()
+
+ @patch("subiquity.server.controllers.reporting.report_finish_event")
+ def test_finish_event(self, report_finish_event, report_event):
+ self.controller.report_finish_event(
+ self.context, "description", ContextStatus.FAIL
+ )
+
+ # Calls specific finish event method
+ report_finish_event.assert_called_with(
+ self.context.full_name(),
+ "description",
+ CurtinStatus.FAIL,
+ level=self.context.level,
+ )
+
+ # Not the generic one
+ report_event.assert_not_called()
+
+ # Test default WARN
+ status = Mock()
+ status.name = "NEW LEVEL"
+ self.controller.report_finish_event(self.context, "description", status)
+
+ report_finish_event.assert_called_with(
+ self.context.full_name(),
+ "description",
+ CurtinStatus.WARN,
+ level=self.context.level,
+ )
+
+ @patch("subiquity.server.controllers.reporting.ReportingEvent")
+ def test_info_event(self, mock_class, report_event):
+ self.controller.report_info_event(self.context, "description")
+
+ mock_class.assert_called_with(
+ "info",
+ self.context.full_name(),
+ "description",
+ level="INFO",
+ )
+
+ @patch("subiquity.server.controllers.reporting.ReportingEvent")
+ def test_warning_event(self, mock_class, report_event):
+ self.controller.report_warning_event(self.context, "description")
+
+ mock_class.assert_called_with(
+ "warning",
+ self.context.full_name(),
+ "description",
+ level="WARNING",
+ )
+
+ @patch("subiquity.server.controllers.reporting.ReportingEvent")
+ def test_error_event(self, mock_class, report_event):
+ self.controller.report_error_event(self.context, "description")
+
+ mock_class.assert_called_with(
+ "error",
+ self.context.full_name(),
+ "description",
+ level="ERROR",
+ )
diff --git a/subiquity/server/event_listener.py b/subiquity/server/event_listener.py
new file mode 100644
index 000000000..ac6af0860
--- /dev/null
+++ b/subiquity/server/event_listener.py
@@ -0,0 +1,44 @@
+# Copyright 2024 Canonical, Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+from abc import ABC, abstractmethod
+from typing import Any
+
+from subiquitycore.context import Context
+
+
+class EventListener(ABC):
+ """Interface for SubiquitySever event listeners"""
+
+ @abstractmethod
+ def report_start_event(self, context: Context, description: str) -> None:
+ """Report a "start" event."""
+
+ @abstractmethod
+ def report_finish_event(
+ self, context: Context, description: str, result: Any
+ ) -> None:
+ """Report a "finish" event."""
+
+ @abstractmethod
+ def report_info_event(self, context: Context, message: str) -> None:
+ """Report an "info" event."""
+
+ @abstractmethod
+ def report_warning_event(self, context: Context, message: str) -> None:
+ """Report a "warning" event."""
+
+ @abstractmethod
+ def report_error_event(self, context: Context, message: str) -> None:
+ """Report an "error" event."""
diff --git a/subiquity/server/server.py b/subiquity/server/server.py
index e0e5ac17b..638378646 100644
--- a/subiquity/server/server.py
+++ b/subiquity/server/server.py
@@ -46,6 +46,7 @@
from subiquity.server.controller import SubiquityController
from subiquity.server.dryrun import DRConfig
from subiquity.server.errors import ErrorController
+from subiquity.server.event_listener import EventListener
from subiquity.server.geoip import DryRunGeoIPStrategy, GeoIP, HTTPGeoIPStrategy
from subiquity.server.nonreportable import NonReportableException
from subiquity.server.pkghelper import get_package_installer
@@ -53,7 +54,7 @@
from subiquity.server.snapdapi import make_api_client
from subiquity.server.types import InstallerChannels
from subiquitycore.async_helpers import run_bg_task, run_in_thread
-from subiquitycore.context import with_context
+from subiquitycore.context import Context, with_context
from subiquitycore.core import Application
from subiquitycore.file_util import copy_file_if_exists, write_file
from subiquitycore.prober import Prober
@@ -326,7 +327,7 @@ def __init__(self, opts, block_log_dir):
log.info("no snapd socket found. Snap support is disabled")
self.snapd = None
self.note_data_for_apport("SnapUpdated", str(self.updated))
- self.event_listeners = []
+ self.event_listeners: list[EventListener] = []
self.autoinstall_config = None
self.hub.subscribe(InstallerChannels.NETWORK_UP, self._network_change)
self.hub.subscribe(InstallerChannels.NETWORK_PROXY_SET, self._proxy_set)
@@ -344,31 +345,77 @@ def load_serialized_state(self):
for controller in self.controllers.instances:
controller.load_state()
- def add_event_listener(self, listener):
+ def add_event_listener(self, listener: EventListener):
self.event_listeners.append(listener)
- def _maybe_push_to_journal(self, event_type, context, description):
- if not context.get("is-install-context") and self.interactive in [True, None]:
- controller = context.get("controller")
+ def _maybe_push_to_journal(
+ self,
+ event_type: str,
+ context: Context,
+ description: Optional[str],
+ ):
+ # No reporting for request handlers
+ if context.get("request", default=None) is not None:
+ return
+
+ install_context: bool = context.get("is-install-context", default=False)
+ msg: str = ""
+ parent_id: str = ""
+ indent: int = context.full_name().count("/") - 2
+
+ # We do filtering on which types of events get reported.
+ # For interactive installs, we only want to report the event
+ # if it's coming from a non-interactive context. The user is aware
+ # of the changes being made in interactive sections so lets skip
+ # reporting those events.
+ #
+ # The exceptions to this are:
+ # - special sections of the install, which set "is-install-context"
+ # where we want to report the event anyways
+ #
+ # - special event types:
+ # - warn
+ # - error
+ #
+ # For non-interactive installs (i.e., full autoinstall) we report
+ # everything.
+
+ force_reporting: bool = install_context or event_type in ["warning", "error"]
+
+ # self.interactive=None could be an interactive install, we just
+ # haven't found out yet
+ if self.interactive in [True, None] and not force_reporting:
+ # If the event came from a controller and it's interactive,
+ # or there's no associated controller so we can't be sure,
+ # skip reporting.
+ controller = context.get("controller", default=None)
if controller is None or controller.interactive():
return
- if context.get("request"):
- return
- indent = context.full_name().count("/") - 2
- if context.get("is-install-context") and self.interactive:
+
+ # Create the message out of the name of the reporter and optionally
+ # the description
+ name: str = context.full_name()
+ if description is not None:
+ msg = f"{name}: {description}"
+ else:
+ msg = name
+
+ # Special case: events from special install contexts which are also
+ # interactive get special formatting
+ if self.interactive and install_context:
indent -= 1
msg = context.description
- else:
- msg = context.full_name()
- if description:
- msg += ": " + description
- msg = " " * indent + msg
- if context.parent:
+
+ indent_prefix: str = " " * indent
+ formatted_message: str = f"{indent_prefix}{msg}"
+
+ if context.parent is not None:
parent_id = str(context.parent.id)
else:
parent_id = ""
+
journal.send(
- msg,
+ formatted_message,
PRIORITY=context.level,
SYSLOG_IDENTIFIER=self.event_syslog_id,
SUBIQUITY_CONTEXT_NAME=context.full_name(),
@@ -387,6 +434,21 @@ def report_finish_event(self, context, description, status):
listener.report_finish_event(context, description, status)
self._maybe_push_to_journal("finish", context, description)
+ def report_info_event(self, context: Context, message: str) -> None:
+ for listener in self.event_listeners:
+ listener.report_info_event(context, message)
+ self._maybe_push_to_journal("info", context, message)
+
+ def report_warning_event(self, context: Context, message: str) -> None:
+ for listener in self.event_listeners:
+ listener.report_warning_event(context, message)
+ self._maybe_push_to_journal("warning", context, message)
+
+ def report_error_event(self, context: Context, message: str) -> None:
+ for listener in self.event_listeners:
+ listener.report_error_event(context, message)
+ self._maybe_push_to_journal("error", context, message)
+
@property
def state(self):
return self._state
diff --git a/subiquity/server/tests/test_server.py b/subiquity/server/tests/test_server.py
index 4fd562afc..292980f57 100644
--- a/subiquity/server/tests/test_server.py
+++ b/subiquity/server/tests/test_server.py
@@ -24,14 +24,17 @@
from subiquity.server.autoinstall import AutoinstallValidationError
from subiquity.server.nonreportable import NonReportableException
from subiquity.server.server import (
+ NOPROBERARG,
MetaController,
SubiquityServer,
cloud_autoinstall_path,
iso_autoinstall_path,
root_autoinstall_path,
)
+from subiquitycore.context import Context
from subiquitycore.tests import SubiTestCase
from subiquitycore.tests.mocks import make_app
+from subiquitycore.tests.parameterized import parameterized
from subiquitycore.utils import run_command
@@ -313,3 +316,143 @@ async def test_not_suppressed_apport_reporting(self):
self.server.make_apport_report.assert_called()
self.assertIsNotNone(self.server.fatal_error)
self.assertIsNone(self.server.nonreportable_error)
+
+
+class TestEventReporting(SubiTestCase):
+ async def asyncSetUp(self):
+ opts = Mock()
+ opts.dry_run = True
+ opts.output_base = self.tmp_dir()
+ opts.machine_config = NOPROBERARG
+ self.server = SubiquityServer(opts, None)
+
+ @parameterized.expand(
+ (
+ # A very tedious to read truth table for testing
+ # behavior. A value of None should mean another
+ # option is shadowing the importance of that value
+ # ex: in the is-install-context it doesn't matter
+ # if it came from a controller. Except interactive=None
+ # is a valid value.
+ #
+ #
+ # -> Special "is-install-context" to force logging
+ # | -> Install is interactive
+ # | | -> Comes from a controller
+ # | | | -> That controller is interactive
+ # | | | | -> Expected to send
+ # | | | | |
+ (True, True, None, None, True),
+ (True, False, None, None, True),
+ (True, None, None, None, True),
+ (False, True, None, None, False),
+ (False, True, True, True, False),
+ (False, True, True, False, True),
+ (False, False, False, None, True),
+ )
+ )
+ async def test_maybe_push_to_journal(
+ self,
+ is_install_context,
+ interactive,
+ from_controller,
+ controller_is_interactive,
+ expected_to_send,
+ ):
+ context: Context = Context(
+ self.server, "MockContext", "description", None, "INFO"
+ )
+
+ context.set("is-install-context", is_install_context)
+ self.server.interactive = interactive
+ if from_controller:
+ controller = Mock()
+ controller.interactive = lambda: controller_is_interactive
+ context.set("controller", controller)
+
+ with patch("subiquity.server.server.journal.send") as journal_send_mock:
+ self.server._maybe_push_to_journal(
+ "event_type", context, context.description
+ )
+ if expected_to_send:
+ journal_send_mock.assert_called_once()
+ else:
+ journal_send_mock.assert_not_called()
+
+ @parameterized.expand(
+ (
+ # interactive, pushed to journal
+ (True, False),
+ (None, False),
+ (False, True),
+ )
+ )
+ def test_push_info_events(self, interactive, expect_pushed):
+ """Test info event publication"""
+
+ context: Context = Context(
+ self.server, "MockContext", "description", None, "INFO"
+ )
+ self.server.interactive = interactive
+
+ with patch("subiquity.server.server.journal.send") as journal_send_mock:
+ self.server.report_info_event(context, "message")
+
+ if not expect_pushed:
+ journal_send_mock.assert_not_called()
+ else:
+ journal_send_mock.assert_called_once()
+ # message is the only positional argument
+ (message,) = journal_send_mock.call_args.args
+ self.assertIn("message", message)
+ self.assertNotIn("description", message)
+
+ @parameterized.expand(
+ (
+ # interactive
+ (True,),
+ (None,),
+ (False,),
+ )
+ )
+ def test_push_warning_events(self, interactive):
+ """Test warning event publication"""
+
+ context: Context = Context(
+ self.server, "MockContext", "description", None, "INFO"
+ )
+ self.server.interactive = interactive
+
+ with patch("subiquity.server.server.journal.send") as journal_send_mock:
+ self.server.report_warning_event(context, "message")
+
+ journal_send_mock.assert_called_once()
+ # message is the only positional argument
+ (message,) = journal_send_mock.call_args.args
+ self.assertIn("message", message)
+ self.assertNotIn("description", message)
+
+ @parameterized.expand(
+ (
+ # interactive
+ (True,),
+ (None,),
+ (False,),
+ )
+ )
+ def test_push_error_events(self, interactive):
+ """Test error event publication"""
+
+ context: Context = Context(
+ self.server, "MockContext", "description", None, "INFO"
+ )
+ self.server.interactive = interactive
+
+ with patch("subiquity.server.server.journal.send") as journal_send_mock:
+ self.server.report_error_event(context, "message")
+
+ journal_send_mock.assert_called_once()
+ # message is the only positional argument
+ (message,) = journal_send_mock.call_args.args
+ self.assertIn("message", message)
+ self.assertNotIn("description", message)
diff --git a/subiquitycore/context.py b/subiquitycore/context.py
index 07c94d71c..bd1ca641a 100644
--- a/subiquitycore/context.py
+++ b/subiquitycore/context.py
@@ -118,6 +118,15 @@ def get(self, key, default=None):
c = c.parent
return default
+ def info(self, message: str) -> None:
+ self.app.report_info_event(self, message)
+
+ def warning(self, message: str) -> None:
+ self.app.report_warning_event(self, message)
+
+ def error(self, message: str) -> None:
+ self.app.report_error_event(self, message)
+
def with_context(name=None, description="", **context_kw):
def decorate(meth):
diff --git a/subiquitycore/tests/mocks.py b/subiquitycore/tests/mocks.py
index 748b97b55..a936b15f9 100644
--- a/subiquitycore/tests/mocks.py
+++ b/subiquitycore/tests/mocks.py
@@ -36,6 +36,7 @@ def make_app(model=None):
app.base_model = model
else:
app.base_model = mock.Mock()
+ app.add_event_listener = mock.Mock()
app.controllers = mock.Mock()
app.context = Context.new(app)
app.exit = mock.Mock()