Merge pull request #614 from mwhudson/reporting

structured reporting
This commit is contained in:
Michael Hudson-Doyle 2019-12-20 10:57:54 +13:00 committed by GitHub
commit ae9dd41e44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 551 additions and 307 deletions

View File

@ -22,6 +22,7 @@ from .proxy import ProxyController
from .mirror import MirrorController from .mirror import MirrorController
from .network import NetworkController from .network import NetworkController
from .refresh import RefreshController from .refresh import RefreshController
from .reporting import ReportingController
from .snaplist import SnapListController from .snaplist import SnapListController
from .ssh import SSHController from .ssh import SSHController
from .welcome import WelcomeController from .welcome import WelcomeController
@ -36,6 +37,7 @@ __all__ = [
'MirrorController', 'MirrorController',
'NetworkController', 'NetworkController',
'RefreshController', 'RefreshController',
'ReportingController',
'SnapListController', 'SnapListController',
'SSHController', 'SSHController',
'WelcomeController', 'WelcomeController',

View File

@ -94,6 +94,7 @@ class ErrorReport(metaclass=urwid.MetaSignals):
pr = attr.ib() pr = attr.ib()
state = attr.ib() state = attr.ib()
_file = attr.ib() _file = attr.ib()
_context = attr.ib()
meta = attr.ib(default=attr.Factory(dict)) meta = attr.ib(default=attr.Factory(dict))
uploader = attr.ib(default=None) uploader = attr.ib(default=None)
@ -110,7 +111,8 @@ class ErrorReport(metaclass=urwid.MetaSignals):
r = cls( r = cls(
controller=controller, base=base, pr=pr, file=crash_file, controller=controller, base=base, pr=pr, file=crash_file,
state=ErrorReportState.INCOMPLETE) state=ErrorReportState.INCOMPLETE,
context=controller.context.child(base))
r.set_meta("kind", kind.name) r.set_meta("kind", kind.name)
return r return r
@ -119,7 +121,8 @@ class ErrorReport(metaclass=urwid.MetaSignals):
base = os.path.splitext(os.path.basename(fpath))[0] base = os.path.splitext(os.path.basename(fpath))[0]
report = cls( report = cls(
controller, base, pr=apport.Report(date='???'), controller, base, pr=apport.Report(date='???'),
state=ErrorReportState.LOADING, file=open(fpath, 'rb')) state=ErrorReportState.LOADING, file=open(fpath, 'rb'),
context=controller.context.child(base))
try: try:
fp = open(report.meta_path, 'r') fp = open(report.meta_path, 'r')
except FileNotFoundError: except FileNotFoundError:
@ -130,8 +133,6 @@ class ErrorReport(metaclass=urwid.MetaSignals):
return report return report
def add_info(self, _bg_attach_hook, wait=False): def add_info(self, _bg_attach_hook, wait=False):
log.debug("begin adding info for report %s", self.base)
def _bg_add_info(): def _bg_add_info():
_bg_attach_hook() _bg_attach_hook()
# Add basic info to report. # Add basic info to report.
@ -158,39 +159,38 @@ class ErrorReport(metaclass=urwid.MetaSignals):
self.pr.write(self._file) self.pr.write(self._file)
async def add_info(): async def add_info():
log.debug("adding info for report %s", self.base) with self._context.child("add_info"):
try: try:
await run_in_thread(_bg_add_info) await run_in_thread(_bg_add_info)
except Exception: except Exception:
self.state = ErrorReportState.ERROR_GENERATING self.state = ErrorReportState.ERROR_GENERATING
log.exception("adding info to problem report failed") log.exception("adding info to problem report failed")
else: else:
self.state = ErrorReportState.DONE self.state = ErrorReportState.DONE
self._file.close() self._file.close()
self._file = None self._file = None
urwid.emit_signal(self, "changed") urwid.emit_signal(self, "changed")
if wait: if wait:
_bg_add_info() with self._context.child("add_info"):
_bg_add_info()
else: else:
schedule_task(add_info()) schedule_task(add_info())
async def load(self): async def load(self):
log.debug("loading report %s", self.base) with self._context.child("load"):
# Load report from disk in background. # Load report from disk in background.
try: try:
await run_in_thread(self.pr.load, self._file) await run_in_thread(self.pr.load, self._file)
except Exception: except Exception:
log.exception("loading problem report failed") log.exception("loading problem report failed")
self.state = ErrorReportState.ERROR_LOADING self.state = ErrorReportState.ERROR_LOADING
else: else:
log.debug("done loading report %s", self.base) self.state = ErrorReportState.DONE
self.state = ErrorReportState.DONE
self._file.close() self._file.close()
self._file = None self._file = None
urwid.emit_signal(self, "changed") urwid.emit_signal(self, "changed")
def upload(self): def upload(self):
log.debug("starting upload for %s", self.base)
uploader = self.uploader = Upload( uploader = self.uploader = Upload(
controller=self.controller, bytes_to_send=1) controller=self.controller, bytes_to_send=1)
@ -239,16 +239,17 @@ class ErrorReport(metaclass=urwid.MetaSignals):
return response.text.split()[0] return response.text.split()[0]
async def upload(): async def upload():
try: with self._context.child("upload") as context:
oops_id = await run_in_thread(_bg_upload) try:
except requests.exceptions.RequestException: oops_id = await run_in_thread(_bg_upload)
log.exception("upload for %s failed", self.base) except requests.exceptions.RequestException:
else: log.exception("upload for %s failed", self.base)
log.debug("finished upload for %s, %r", self.base, oops_id) else:
self.set_meta("oops-id", oops_id) self.set_meta("oops-id", oops_id)
uploader.stop() context.description = oops_id
self.uploader = None uploader.stop()
urwid.emit_signal(self, 'changed') self.uploader = None
urwid.emit_signal(self, 'changed')
urwid.emit_signal(self, 'changed') urwid.emit_signal(self, 'changed')
uploader.start() uploader.start()

View File

@ -86,12 +86,8 @@ class FilesystemController(BaseController):
probe_types = None probe_types = None
fname = 'probe-data.json' fname = 'probe-data.json'
key = "ProbeData" key = "ProbeData"
block_discover_log.exception(
"probing restricted=%s", restricted)
storage = await run_in_thread( storage = await run_in_thread(
self.app.prober.get_storage, probe_types) self.app.prober.get_storage, probe_types)
block_discover_log.info(
"probing successful restricted=%s", restricted)
fpath = os.path.join(self.app.block_log_dir, fname) fpath = os.path.join(self.app.block_log_dir, fname)
with open(fpath, 'w') as fp: with open(fpath, 'w') as fp:
json.dump(storage, fp, indent=4) json.dump(storage, fp, indent=4)
@ -99,24 +95,28 @@ class FilesystemController(BaseController):
self.model.load_probe_data(storage) self.model.load_probe_data(storage)
async def _probe(self): async def _probe(self):
self._crash_reports = {} with self.context.child("_probe") as context:
if isinstance(self.ui.body, ProbingFailed): self._crash_reports = {}
self.ui.set_body(SlowProbing(self)) if isinstance(self.ui.body, ProbingFailed):
schedule_task(self._wait_for_probing()) self.ui.set_body(SlowProbing(self))
for (restricted, kind) in [ schedule_task(self._wait_for_probing())
(False, ErrorReportKind.BLOCK_PROBE_FAIL), for (restricted, kind) in [
(True, ErrorReportKind.DISK_PROBE_FAIL), (False, ErrorReportKind.BLOCK_PROBE_FAIL),
]: (True, ErrorReportKind.DISK_PROBE_FAIL),
try: ]:
await self._probe_once_task.start(restricted) try:
await asyncio.wait_for(self._probe_once_task.task, 5.0) desc = "restricted={}".format(restricted)
except Exception: with context.child("probe_once", desc):
block_discover_log.exception( await self._probe_once_task.start(restricted)
"block probing failed restricted=%s", restricted) await asyncio.wait_for(self._probe_once_task.task, 5.0)
self._crash_reports[restricted] = self.app.make_apport_report( except Exception:
kind, "block probing", interrupt=False) block_discover_log.exception(
continue "block probing failed restricted=%s", restricted)
break report = self.app.make_apport_report(
kind, "block probing", interrupt=False)
self._crash_reports[restricted] = report
continue
break
def start(self): def start(self):
self._start_task = schedule_task(self._start()) self._start_task = schedule_task(self._start())

View File

@ -14,6 +14,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio import asyncio
import contextlib
import datetime import datetime
import logging import logging
import os import os
@ -36,6 +37,7 @@ from subiquitycore.async_helpers import (
run_in_thread, run_in_thread,
schedule_task, schedule_task,
) )
from subiquitycore.context import Status
from subiquitycore.controller import BaseController from subiquitycore.controller import BaseController
from subiquitycore.utils import ( from subiquitycore.utils import (
arun_command, arun_command,
@ -76,14 +78,15 @@ class TracebackExtractor:
self.traceback.append(line) self.traceback.append(line)
def install_step(label): def install_step(label, level=None, childlevel=None):
def decorate(meth): def decorate(meth):
async def decorated(self): name = meth.__name__
self._install_event_start(label)
try: async def decorated(self, context):
await meth(self) manager = self.install_context(
finally: context, name, label, level, childlevel)
self._install_event_finish() with manager as subcontext:
await meth(self, subcontext)
return decorated return decorated
return decorate return decorate
@ -107,9 +110,10 @@ class InstallProgressController(BaseController):
self._event_syslog_identifier = 'curtin_event.%s' % (os.getpid(),) self._event_syslog_identifier = 'curtin_event.%s' % (os.getpid(),)
self._log_syslog_identifier = 'curtin_log.%s' % (os.getpid(),) self._log_syslog_identifier = 'curtin_log.%s' % (os.getpid(),)
self.tb_extractor = TracebackExtractor() self.tb_extractor = TracebackExtractor()
self.curtin_context = None
def start(self): def start(self):
self.install_task = schedule_task(self.install()) self.install_task = schedule_task(self.install(self.context))
def tpath(self, *path): def tpath(self, *path):
return os.path.join(self.model.target, *path) return os.path.join(self.model.target, *path)
@ -117,14 +121,15 @@ class InstallProgressController(BaseController):
def curtin_error(self): def curtin_error(self):
self.install_state = InstallState.ERROR self.install_state = InstallState.ERROR
kw = {} kw = {}
if sys.exc_info()[0] is not None:
log.exception("curtin_error")
self.progress_view.add_log_line(traceback.format_exc())
if self.tb_extractor.traceback: if self.tb_extractor.traceback:
kw["Traceback"] = "\n".join(self.tb_extractor.traceback) kw["Traceback"] = "\n".join(self.tb_extractor.traceback)
crash_report = self.app.make_apport_report( crash_report = self.app.make_apport_report(
ErrorReportKind.INSTALL_FAIL, "install failed", interrupt=False, ErrorReportKind.INSTALL_FAIL, "install failed", interrupt=False,
**kw) **kw)
self.progress_view.spinner.stop() self.progress_view.spinner.stop()
if sys.exc_info()[0] is not None:
self.progress_view.add_log_line(traceback.format_exc())
self.progress_view.set_status(('info_error', self.progress_view.set_status(('info_error',
_("An error has occurred"))) _("An error has occurred")))
self.start_ui() self.start_ui()
@ -140,30 +145,46 @@ class InstallProgressController(BaseController):
elif event['SYSLOG_IDENTIFIER'] == self._log_syslog_identifier: elif event['SYSLOG_IDENTIFIER'] == self._log_syslog_identifier:
self.curtin_log(event) self.curtin_log(event)
@contextlib.contextmanager
def install_context(self, context, name, description, level, childlevel):
self._install_event_start(description)
try:
subcontext = context.child(name, description, level, childlevel)
with subcontext:
yield subcontext
finally:
self._install_event_finish()
def _install_event_start(self, message): def _install_event_start(self, message):
log.debug("_install_event_start %s", message)
self.progress_view.add_event(self._event_indent + message) self.progress_view.add_event(self._event_indent + message)
self._event_indent += " " self._event_indent += " "
self.progress_view.spinner.start() self.progress_view.spinner.start()
def _install_event_finish(self): def _install_event_finish(self):
self._event_indent = self._event_indent[:-2] self._event_indent = self._event_indent[:-2]
log.debug("_install_event_finish %r", self._event_indent)
self.progress_view.spinner.stop() self.progress_view.spinner.stop()
def curtin_event(self, event): def curtin_event(self, event):
e = {} e = {
"EVENT_TYPE": "???",
"MESSAGE": "???",
"NAME": "???",
"RESULT": "???",
}
prefix = "CURTIN_"
for k, v in event.items(): for k, v in event.items():
if k.startswith("CURTIN_"): if k.startswith(prefix):
e[k] = v e[k[len(prefix):]] = v
log.debug("curtin_event received %r", e) event_type = e["EVENT_TYPE"]
event_type = event.get("CURTIN_EVENT_TYPE")
if event_type not in ['start', 'finish']:
return
if event_type == 'start': if event_type == 'start':
self._install_event_start(event.get("CURTIN_MESSAGE", "??")) self._install_event_start(e["MESSAGE"])
if self.curtin_context is not None:
self.curtin_context.child(e["NAME"], e["MESSAGE"]).enter()
if event_type == 'finish': if event_type == 'finish':
self._install_event_finish() self._install_event_finish()
status = getattr(Status, e["RESULT"], Status.WARN)
if self.curtin_context is not None:
self.curtin_context.child(e["NAME"], e["MESSAGE"]).exit(status)
def curtin_log(self, event): def curtin_log(self, event):
log_line = event['MESSAGE'] log_line = event['MESSAGE']
@ -222,9 +243,11 @@ class InstallProgressController(BaseController):
return curtin_cmd return curtin_cmd
async def curtin_install(self): @install_step("installing system", level="INFO", childlevel="DEBUG")
async def curtin_install(self, context):
log.debug('curtin_install') log.debug('curtin_install')
self.install_state = InstallState.RUNNING self.install_state = InstallState.RUNNING
self.curtin_context = context
self.journal_listener_handle = self.start_journald_listener( self.journal_listener_handle = self.start_journald_listener(
[self._event_syslog_identifier, self._log_syslog_identifier], [self._event_syslog_identifier, self._log_syslog_identifier],
@ -245,19 +268,19 @@ class InstallProgressController(BaseController):
def cancel(self): def cancel(self):
pass pass
async def install(self): async def install(self, context):
try: try:
await asyncio.wait( await asyncio.wait(
{e.wait() for e in self.model.install_events}) {e.wait() for e in self.model.install_events})
await self.curtin_install() await self.curtin_install(context)
await asyncio.wait( await asyncio.wait(
{e.wait() for e in self.model.postinstall_events}) {e.wait() for e in self.model.postinstall_events})
await self.drain_curtin_events() await self.drain_curtin_events(context)
await self.postinstall() await self.postinstall(context)
self.ui.set_header(_("Installation complete!")) self.ui.set_header(_("Installation complete!"))
self.progress_view.set_status(_("Finished install!")) self.progress_view.set_status(_("Finished install!"))
@ -265,10 +288,10 @@ class InstallProgressController(BaseController):
if self.model.network.has_network: if self.model.network.has_network:
self.progress_view.update_running() self.progress_view.update_running()
await self.run_unattended_upgrades() await self.run_unattended_upgrades(context)
self.progress_view.update_done() self.progress_view.update_done()
await self.copy_logs_to_target() await self.copy_logs_to_target(context)
except Exception: except Exception:
self.curtin_error() self.curtin_error()
@ -276,26 +299,28 @@ class InstallProgressController(BaseController):
self.reboot() self.reboot()
async def drain_curtin_events(self): async def drain_curtin_events(self, context):
waited = 0.0 waited = 0.0
while self._event_indent and waited < 5.0: while self._event_indent and waited < 5.0:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
waited += 0.1 waited += 0.1
log.debug("waited %s seconds for events to drain", waited) log.debug("waited %s seconds for events to drain", waited)
self.curtin_context = None
@install_step("final system configuration") @install_step(
async def postinstall(self): "final system configuration", level="INFO", childlevel="DEBUG")
await self.configure_cloud_init() async def postinstall(self, context):
await self.configure_cloud_init(context)
if self.model.ssh.install_server: if self.model.ssh.install_server:
await self.install_openssh() await self.install_openssh(context)
await self.restore_apt_config() await self.restore_apt_config(context)
@install_step("configuring cloud-init") @install_step("configuring cloud-init")
async def configure_cloud_init(self): async def configure_cloud_init(self, context):
await run_in_thread(self.model.configure_cloud_init) await run_in_thread(self.model.configure_cloud_init)
@install_step("installing openssh") @install_step("installing openssh")
async def install_openssh(self): async def install_openssh(self, context):
if self.opts.dry_run: if self.opts.dry_run:
cmd = ["sleep", str(2/self.app.scale_factor)] cmd = ["sleep", str(2/self.app.scale_factor)]
else: else:
@ -307,7 +332,7 @@ class InstallProgressController(BaseController):
await arun_command(self.logged_command(cmd), check=True) await arun_command(self.logged_command(cmd), check=True)
@install_step("restoring apt configuration") @install_step("restoring apt configuration")
async def restore_apt_config(self): async def restore_apt_config(self, context):
if self.opts.dry_run: if self.opts.dry_run:
cmds = [["sleep", str(1/self.app.scale_factor)]] cmds = [["sleep", str(1/self.app.scale_factor)]]
else: else:
@ -325,7 +350,7 @@ class InstallProgressController(BaseController):
await arun_command(self.logged_command(cmd), check=True) await arun_command(self.logged_command(cmd), check=True)
@install_step("downloading and installing security updates") @install_step("downloading and installing security updates")
async def run_unattended_upgrades(self): async def run_unattended_upgrades(self, context):
target_tmp = os.path.join(self.model.target, "tmp") target_tmp = os.path.join(self.model.target, "tmp")
os.makedirs(target_tmp, exist_ok=True) os.makedirs(target_tmp, exist_ok=True)
apt_conf = tempfile.NamedTemporaryFile( apt_conf = tempfile.NamedTemporaryFile(
@ -362,7 +387,7 @@ class InstallProgressController(BaseController):
], check=True)) ], check=True))
@install_step("copying logs to installed system") @install_step("copying logs to installed system")
async def copy_logs_to_target(self): async def copy_logs_to_target(self, context):
if self.opts.dry_run: if self.opts.dry_run:
if 'copy-logs-fail' in self.app.debug_flags: if 'copy-logs-fail' in self.app.debug_flags:
raise PermissionError() raise PermissionError()

View File

@ -56,32 +56,33 @@ class MirrorController(BaseController):
schedule_task(self.lookup()) schedule_task(self.lookup())
async def lookup(self): async def lookup(self):
try: with self.context.child("lookup"):
response = await run_in_thread( try:
requests.get, "https://geoip.ubuntu.com/lookup") response = await run_in_thread(
response.raise_for_status() requests.get, "https://geoip.ubuntu.com/lookup")
except requests.exceptions.RequestException: response.raise_for_status()
log.exception("geoip lookup failed") except requests.exceptions.RequestException:
self.check_state = CheckState.FAILED log.exception("geoip lookup failed")
return self.check_state = CheckState.FAILED
try: return
e = ElementTree.fromstring(response.text) try:
except ElementTree.ParseError: e = ElementTree.fromstring(response.text)
log.exception("parsing %r failed", response.text) except ElementTree.ParseError:
self.check_state = CheckState.FAILED log.exception("parsing %r failed", response.text)
return self.check_state = CheckState.FAILED
cc = e.find("CountryCode") return
if cc is None: cc = e.find("CountryCode")
log.debug("no CountryCode found in %r", response.text) if cc is None:
self.check_state = CheckState.FAILED log.debug("no CountryCode found in %r", response.text)
return self.check_state = CheckState.FAILED
cc = cc.text.lower() return
if len(cc) != 2: cc = cc.text.lower()
log.debug("bogus CountryCode found in %r", response.text) if len(cc) != 2:
self.check_state = CheckState.FAILED log.debug("bogus CountryCode found in %r", response.text)
return self.check_state = CheckState.FAILED
self.check_state = CheckState.DONE return
self.model.set_country(cc) self.check_state = CheckState.DONE
self.model.set_country(cc)
def start_ui(self): def start_ui(self):
self.check_state = CheckState.DONE self.check_state = CheckState.DONE

View File

@ -72,30 +72,32 @@ class RefreshController(BaseController):
return task.result() return task.result()
async def configure_snapd(self): async def configure_snapd(self):
log.debug("configure_snapd") with self.context.child("configure_snapd") as context:
try: with context.child("get_details") as subcontext:
r = await self.app.snapd.get( try:
'v2/snaps/{snap_name}'.format(snap_name=self.snap_name)) r = await self.app.snapd.get(
except requests.exceptions.RequestException: 'v2/snaps/{snap_name}'.format(
log.exception("getting snap details") snap_name=self.snap_name))
return except requests.exceptions.RequestException:
self.current_snap_version = r['result']['version'] log.exception("getting snap details")
for k in 'channel', 'revision', 'version': return
self.app.note_data_for_apport( self.current_snap_version = r['result']['version']
"Snap" + k.title(), r['result'][k]) for k in 'channel', 'revision', 'version':
log.debug( self.app.note_data_for_apport(
"current version of snap is: %r", "Snap" + k.title(), r['result'][k])
self.current_snap_version) subcontext.description = "current version of snap is: %r" % (
channel = self.get_refresh_channel() self.current_snap_version)
log.debug("switching %s to %s", self.snap_name, channel) channel = self.get_refresh_channel()
try: desc = "switching {} to {}".format(self.snap_name, channel)
await self.app.snapd.post_and_wait( with context.child("switching", desc) as subcontext:
'v2/snaps/{}'.format(self.snap_name), try:
{'action': 'switch', 'channel': channel}) await self.app.snapd.post_and_wait(
except requests.exceptions.RequestException: 'v2/snaps/{}'.format(self.snap_name),
log.exception("switching channels") {'action': 'switch', 'channel': channel})
return except requests.exceptions.RequestException:
log.debug("snap switching completed") log.exception("switching channels")
return
subcontext.description = "switched to " + channel
def get_refresh_channel(self): def get_refresh_channel(self):
"""Return the channel we should refresh subiquity to.""" """Return the channel we should refresh subiquity to."""
@ -134,28 +136,34 @@ class RefreshController(BaseController):
async def check_for_update(self): async def check_for_update(self):
await asyncio.shield(self.configure_task) await asyncio.shield(self.configure_task)
# If we restarted into this version, don't check for a new version. with self.context.child("check_for_update") as context:
if self.app.updated: if self.app.updated:
context.description = (
"not offered update when already updated")
return CheckState.UNAVAILABLE
result = await self.app.snapd.get('v2/find', select='refresh')
log.debug("check_for_update received %s", result)
for snap in result["result"]:
if snap["name"] == self.snap_name:
self.new_snap_version = snap["version"]
context.description = (
"new version of snap available: %r"
% self.new_snap_version)
return CheckState.AVAILABLE
else:
context.description = (
"no new version of snap available")
return CheckState.UNAVAILABLE return CheckState.UNAVAILABLE
result = await self.app.snapd.get('v2/find', select='refresh')
log.debug("check_for_update received %s", result)
for snap in result["result"]:
if snap["name"] == self.snap_name:
self.new_snap_version = snap["version"]
log.debug(
"new version of snap available: %r",
self.new_snap_version)
return CheckState.AVAILABLE
return CheckState.UNAVAILABLE
async def start_update(self): async def start_update(self):
update_marker = os.path.join(self.app.state_dir, 'updating') update_marker = os.path.join(self.app.state_dir, 'updating')
open(update_marker, 'w').close() open(update_marker, 'w').close()
change = await self.app.snapd.post( with self.context.child("starting_update") as context:
'v2/snaps/{}'.format(self.snap_name), change = await self.app.snapd.post(
{'action': 'refresh'}) 'v2/snaps/{}'.format(self.snap_name),
log.debug("refresh requested: %s", change) {'action': 'refresh'})
return change context.description = "change id: {}".format(change)
return change
async def get_progress(self, change): async def get_progress(self, change):
result = await self.app.snapd.get('v2/changes/{}'.format(change)) result = await self.app.snapd.get('v2/changes/{}'.format(change))

View File

@ -0,0 +1,58 @@
# Copyright 2019 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 <http://www.gnu.org/licenses/>.
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.handlers import (
LogHandler,
)
from subiquitycore.controller import NoUIController
class LogHandler(LogHandler):
def publish_event(self, event):
level = getattr(logging, event.level)
logger = logging.getLogger('')
logger.log(level, event.as_string())
available_handlers.unregister_item('log')
available_handlers.register_item('log', LogHandler)
class ReportingController(NoUIController):
def __init__(self, app):
super().__init__(app)
def start(self):
update_configuration({'logging': {'type': 'log', 'level': 'INFO'}})
def report_start_event(self, name, description, level):
report_start_event(name, description, level=level)
def report_finish_event(self, name, description, result, level):
result = getattr(status, result.name, status.WARN)
report_finish_event(name, description, result, level=level)

View File

@ -33,9 +33,10 @@ log = logging.getLogger('subiquity.controllers.snaplist')
class SnapdSnapInfoLoader: class SnapdSnapInfoLoader:
def __init__(self, model, snapd, store_section): def __init__(self, model, snapd, store_section, context):
self.model = model self.model = model
self.store_section = store_section self.store_section = store_section
self.context = context
self.main_task = None self.main_task = None
self.snap_list_fetched = False self.snap_list_fetched = False
@ -50,41 +51,42 @@ class SnapdSnapInfoLoader:
self.main_task = schedule_task(self._start()) self.main_task = schedule_task(self._start())
async def _start(self): async def _start(self):
task = self.tasks[None] = schedule_task(self._load_list()) with self.context:
await task task = self.tasks[None] = schedule_task(self._load_list())
self.pending_snaps = self.model.get_snap_list()
log.debug("fetched list of %s snaps", len(self.pending_snaps))
while self.pending_snaps:
snap = self.pending_snaps.pop(0)
task = self.tasks[snap] = schedule_task(
self._fetch_info_for_snap(snap))
await task await task
self.pending_snaps = self.model.get_snap_list()
log.debug("fetched list of %s snaps", len(self.pending_snaps))
while self.pending_snaps:
snap = self.pending_snaps.pop(0)
task = self.tasks[snap] = schedule_task(
self._fetch_info_for_snap(snap))
await task
async def _load_list(self): async def _load_list(self):
try: with self.context.child("list"):
result = await self.snapd.get( try:
'v2/find', section=self.store_section) result = await self.snapd.get(
except requests.exceptions.RequestException: 'v2/find', section=self.store_section)
log.exception("loading list of snaps failed") except requests.exceptions.RequestException:
self.failed = True log.exception("loading list of snaps failed")
return self.failed = True
self.model.load_find_data(result) return
self.snap_list_fetched = True self.model.load_find_data(result)
self.snap_list_fetched = True
def stop(self): def stop(self):
if self.main_task is not None: if self.main_task is not None:
self.main_task.cancel() self.main_task.cancel()
async def _fetch_info_for_snap(self, snap): async def _fetch_info_for_snap(self, snap):
log.debug('starting fetch for %s', snap.name) with self.context.child("fetch").child(snap.name):
try: try:
data = await self.snapd.get('v2/find', name=snap.name) data = await self.snapd.get('v2/find', name=snap.name)
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
log.exception("loading snap info failed") log.exception("loading snap info failed")
# XXX something better here? # XXX something better here?
return return
log.debug('got data for %s', snap.name) self.model.load_info_data(data)
self.model.load_info_data(data)
def get_snap_list_task(self): def get_snap_list_task(self):
return self.tasks[None] return self.tasks[None]
@ -106,8 +108,8 @@ class SnapListController(BaseController):
def _make_loader(self): def _make_loader(self):
return SnapdSnapInfoLoader( return SnapdSnapInfoLoader(
self.model, self.app.snapd, self.model, self.app.snapd, self.opts.snap_section,
self.opts.snap_section) self.context.child("loader"))
def __init__(self, app): def __init__(self, app):
super().__init__(app) super().__init__(app)

View File

@ -76,37 +76,35 @@ class SSHController(BaseController):
async def _fetch_ssh_keys(self, user_spec): async def _fetch_ssh_keys(self, user_spec):
ssh_import_id = "{ssh_import_id}:{import_username}".format(**user_spec) ssh_import_id = "{ssh_import_id}:{import_username}".format(**user_spec)
log.debug( with self.context.child("ssh_import_id", ssh_import_id):
"User input: %s, fetching ssh keys for %s", try:
user_spec, ssh_import_id) cp = await self.run_cmd_checked(
try: ['ssh-import-id', '-o-', ssh_import_id],
cp = await self.run_cmd_checked( failmsg=_("Importing keys failed:"))
['ssh-import-id', '-o-', ssh_import_id], except subprocess.CalledProcessError:
failmsg=_("Importing keys failed:")) return
except subprocess.CalledProcessError: key_material = cp.stdout.replace('\r', '').strip()
return
key_material = cp.stdout.replace('\r', '').strip()
try: try:
cp = await self.run_cmd_checked( cp = await self.run_cmd_checked(
['ssh-keygen', '-lf-'], ['ssh-keygen', '-lf-'],
failmsg=_( failmsg=_(
"ssh-keygen failed to show fingerprint of downloaded " "ssh-keygen failed to show fingerprint of downloaded "
"keys:"), "keys:"),
input=key_material) input=key_material)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return return
fingerprints = cp.stdout.replace( fingerprints = cp.stdout.replace(
"# ssh-import-id {}".format(ssh_import_id), "# ssh-import-id {}".format(ssh_import_id),
"").strip().splitlines() "").strip().splitlines()
if 'ssh-import-id' in self.app.answers.get("Identity", {}): if 'ssh-import-id' in self.app.answers.get("Identity", {}):
user_spec['authorized_keys'] = key_material.splitlines() user_spec['authorized_keys'] = key_material.splitlines()
self.done(user_spec) self.done(user_spec)
else: else:
self.ui.body.confirm_ssh_keys( self.ui.body.confirm_ssh_keys(
user_spec, ssh_import_id, key_material, fingerprints) user_spec, ssh_import_id, key_material, fingerprints)
def fetch_ssh_keys(self, user_spec): def fetch_ssh_keys(self, user_spec):
self._fetch_task = schedule_task(self._fetch_ssh_keys(user_spec)) self._fetch_task = schedule_task(self._fetch_ssh_keys(user_spec))

View File

@ -15,6 +15,7 @@
import unittest import unittest
from subiquitycore.context import Context
from subiquity.controllers.filesystem import ( from subiquity.controllers.filesystem import (
FilesystemController, FilesystemController,
) )
@ -35,15 +36,19 @@ class Thing:
class MiniApplication: class MiniApplication:
ui = signal = loop = None ui = signal = loop = None
project = "mini"
answers = {} answers = {}
opts = Thing() opts = Thing()
opts.dry_run = True opts.dry_run = True
opts.bootloader = None opts.bootloader = None
def report_start_event(*args): pass
def report_finish_event(*args): pass
def make_controller(bootloader=None): def make_controller(bootloader=None):
app = MiniApplication() app = MiniApplication()
app.base_model = bm = Thing() app.base_model = bm = Thing()
app.context = Context.new(app)
bm.filesystem = make_model(bootloader) bm.filesystem = make_model(bootloader)
controller = FilesystemController(app) controller = FilesystemController(app)
return controller return controller

View File

@ -87,6 +87,7 @@ class Subiquity(Application):
"SnapList", "SnapList",
"InstallProgress", "InstallProgress",
"Error", # does not have a UI "Error", # does not have a UI
"Reporting", # does not have a UI
] ]
def __init__(self, opts, block_log_dir): def __init__(self, opts, block_log_dir):
@ -123,6 +124,14 @@ class Subiquity(Application):
print("report saved to {}".format(report.path)) print("report saved to {}".format(report.path))
raise raise
def report_start_event(self, name, description, level="INFO"):
self.controllers.Reporting.report_start_event(
name, description, level)
def report_finish_event(self, name, description, status, level="INFO"):
self.controllers.Reporting.report_finish_event(
name, description, status, level)
def select_initial_screen(self, index): def select_initial_screen(self, index):
super().select_initial_screen(index) super().select_initial_screen(index)
for report in self.controllers.Error.reports: for report in self.controllers.Error.reports:

100
subiquitycore/context.py Normal file
View File

@ -0,0 +1,100 @@
# Copyright 2019 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 <http://www.gnu.org/licenses/>.
import asyncio
import enum
class Status(enum.Enum):
SUCCESS = enum.auto()
FAIL = enum.auto()
WARN = enum.auto()
class Context:
"""Class to report when things start and finish.
The expected way to use this is something like:
with somecontext.child("operation"):
await long_running_operation()
but you can also call .enter() and .exit() if use as a context
manager isn't possible.
start and finish events are reported via the report_start_event and
report_finish_event methods on app.
You can override the message shown on exit by passing it to the .exit
method or by assigning to description:
with somecontext.child("operation") as context:
result = await long_running_operation()
context.description = "result was {}".format(result)
"""
def __init__(self, app, name, description, parent, level, childlevel=None):
self.app = app
self.name = name
self.description = description
self.parent = parent
self.level = level
if childlevel is None:
childlevel = level
self.childlevel = childlevel
@classmethod
def new(self, app):
return Context(app, app.project, "", None, "INFO")
def child(self, name, description="", level=None, childlevel=None):
if level is None:
level = self.childlevel
return Context(self.app, name, description, self, level, childlevel)
def _name(self):
c = self
names = []
while c is not None:
names.append(c.name)
c = c.parent
return '/'.join(reversed(names))
def enter(self, description=None):
if description is None:
description = self.description
self.app.report_start_event(self._name(), description, self.level)
def exit(self, description=None, result=Status.SUCCESS):
if description is None:
description = self.description
self.app.report_finish_event(
self._name(), description, result, self.level)
def __enter__(self):
self.enter()
return self
def __exit__(self, exc, value, tb):
if exc is not None:
result = Status.FAIL
if isinstance(value, asyncio.CancelledError):
description = "cancelled"
else:
description = str(value)
else:
result = Status.SUCCESS
description = None
self.exit(description, result)

View File

@ -36,6 +36,7 @@ class BaseController(ABC):
self.opts = app.opts self.opts = app.opts
self.loop = app.loop self.loop = app.loop
self.app = app self.app = app
self.context = self.app.context.child(self.name, childlevel="DEBUG")
self.answers = app.answers.get(self.name, {}) self.answers = app.answers.get(self.name, {})
if self.model_name is not None: if self.model_name is not None:
self.model = getattr(self.app.base_model, self.model_name) self.model = getattr(self.app.base_model, self.model_name)
@ -143,6 +144,7 @@ class RepeatedController(BaseController):
self.name = "{}-{}".format(orig.name, index) self.name = "{}-{}".format(orig.name, index)
self.orig = orig self.orig = orig
self.index = index self.index = index
self.context = orig.context
def register_signals(self): def register_signals(self):
pass pass

View File

@ -25,7 +25,11 @@ from probert.network import IFF_UP, NetworkEventReceiver
from subiquitycore.async_helpers import SingleInstanceTask from subiquitycore.async_helpers import SingleInstanceTask
from subiquitycore.controller import BaseController from subiquitycore.controller import BaseController
from subiquitycore.file_util import write_file from subiquitycore.file_util import write_file
from subiquitycore.models.network import BondParameters, sanitize_config from subiquitycore.models.network import (
BondParameters,
NetDevAction,
sanitize_config,
)
from subiquitycore import netplan from subiquitycore import netplan
from subiquitycore.ui.views.network import ( from subiquitycore.ui.views.network import (
NetworkView, NetworkView,
@ -231,7 +235,8 @@ class NetworkController(BaseController):
meth = getattr( meth = getattr(
self.ui.body, self.ui.body,
"_action_{}".format(action['action'])) "_action_{}".format(action['action']))
meth(obj) action_obj = getattr(NetDevAction, action['action'])
self.ui.body._action(None, (action_obj, meth), obj)
yield yield
body = self.ui.body._w body = self.ui.body._w
if not isinstance(body, StretchyOverlay): if not isinstance(body, StretchyOverlay):
@ -343,92 +348,94 @@ class NetworkController(BaseController):
self.model.parse_netplan_configs(self.root) self.model.parse_netplan_configs(self.root)
async def _apply_config(self, silent): async def _apply_config(self, silent):
log.debug("apply_config silent=%s", silent) with self.context.child(
"apply_config", "silent={}".format(silent), level="INFO"):
devs_to_delete = []
devs_to_down = []
dhcp_device_versions = []
dhcp_events = set()
for dev in self.model.get_all_netdevs(include_deleted=True):
dev.dhcp_events = {}
for v in 4, 6:
if dev.dhcp_enabled(v):
if not silent:
dev.set_dhcp_state(v, "PENDING")
self.network_event_receiver.update_link(
dev.ifindex)
else:
dev.set_dhcp_state(v, "RECONFIGURE")
dev.dhcp_events[v] = e = asyncio.Event()
dhcp_events.add(e)
if dev.info is None:
continue
if dev.is_virtual:
devs_to_delete.append(dev)
continue
if dev.config != self.model.config.config_for_device(dev.info):
devs_to_down.append(dev)
devs_to_delete = [] self._write_config()
devs_to_down = []
dhcp_device_versions = []
dhcp_events = set()
for dev in self.model.get_all_netdevs(include_deleted=True):
dev.dhcp_events = {}
for v in 4, 6:
if dev.dhcp_enabled(v):
if not silent:
dev.set_dhcp_state(v, "PENDING")
self.network_event_receiver.update_link(dev.ifindex)
else:
dev.set_dhcp_state(v, "RECONFIGURE")
dev.dhcp_events[v] = e = asyncio.Event()
dhcp_events.add(e)
if dev.info is None:
continue
if dev.is_virtual:
devs_to_delete.append(dev)
continue
if dev.config != self.model.config.config_for_device(dev.info):
devs_to_down.append(dev)
self._write_config()
if not silent and self.view:
self.view.show_apply_spinner()
def error(stage):
if not silent and self.view: if not silent and self.view:
self.view.show_network_error(stage) self.view.show_apply_spinner()
if self.opts.dry_run: def error(stage):
delay = 1/self.app.scale_factor if not silent and self.view:
await arun_command(['sleep', str(delay)]) self.view.show_network_error(stage)
if os.path.exists('/lib/netplan/generate'):
# If netplan appears to be installed, run generate to at if self.opts.dry_run:
# least test that what we wrote is acceptable to netplan. delay = 1/self.app.scale_factor
await arun_command( await arun_command(['sleep', str(delay)])
['netplan', 'generate', '--root', self.root], check=True) if os.path.exists('/lib/netplan/generate'):
else: # If netplan appears to be installed, run generate to at
if devs_to_down or devs_to_delete: # least test that what we wrote is acceptable to netplan.
try:
await arun_command( await arun_command(
['systemctl', 'stop', 'systemd-networkd.service'], ['netplan', 'generate', '--root', self.root],
check=True) check=True)
else:
if devs_to_down or devs_to_delete:
try:
await arun_command(
['systemctl', 'stop', 'systemd-networkd.service'],
check=True)
except subprocess.CalledProcessError:
error("stop-networkd")
raise
if devs_to_down:
await self._down_devs(devs_to_down)
if devs_to_delete:
await self._delete_devs(devs_to_delete)
try:
await arun_command(['netplan', 'apply'], check=True)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
error("stop-networkd") error("apply")
raise raise
if devs_to_down:
await self._down_devs(devs_to_down) if not silent and self.view:
if devs_to_delete: self.view.hide_apply_spinner()
await self._delete_devs(devs_to_delete)
if self.answers.get('accept-default', False):
self.done()
elif self.answers.get('actions', False):
actions = self.answers['actions']
self.answers.clear()
self._run_iterator(self._run_actions(actions))
if not dhcp_events:
return
try: try:
await arun_command(['netplan', 'apply'], check=True) await asyncio.wait_for(
except subprocess.CalledProcessError: asyncio.wait({e.wait() for e in dhcp_events}),
error("apply") 10)
raise except asyncio.TimeoutError:
pass
if not silent and self.view: for dev, v in dhcp_device_versions:
self.view.hide_apply_spinner() dev.dhcp_events = {}
if not dev.dhcp_addresses()[v]:
if self.answers.get('accept-default', False): dev.set_dhcp_state(v, "TIMEDOUT")
self.done() self.network_event_receiver.update_link(dev.ifindex)
elif self.answers.get('actions', False):
actions = self.answers['actions']
self.answers.clear()
self._run_iterator(self._run_actions(actions))
if not dhcp_events:
return
try:
await asyncio.wait_for(
asyncio.wait({e.wait() for e in dhcp_events}),
10)
except asyncio.TimeoutError:
pass
for dev, v in dhcp_device_versions:
dev.dhcp_events = {}
if not dev.dhcp_addresses()[v]:
dev.set_dhcp_state(v, "TIMEDOUT")
self.network_event_receiver.update_link(dev.ifindex)
def add_vlan(self, device, vlan): def add_vlan(self, device, vlan):
return self.model.new_vlan(device, vlan) return self.model.new_vlan(device, vlan)

View File

@ -25,6 +25,9 @@ import urwid
import yaml import yaml
from subiquitycore.async_helpers import schedule_task from subiquitycore.async_helpers import schedule_task
from subiquitycore.context import (
Context,
)
from subiquitycore.controller import ( from subiquitycore.controller import (
RepeatedController, RepeatedController,
Skip, Skip,
@ -385,6 +388,7 @@ class Application:
self.prober = prober self.prober = prober
self.loop = None self.loop = None
self.controllers = ControllerSet(self, self.controllers) self.controllers = ControllerSet(self, self.controllers)
self.context = Context.new(self)
def run_command_in_foreground(self, cmd, before_hook=None, after_hook=None, def run_command_in_foreground(self, cmd, before_hook=None, after_hook=None,
**kw): **kw):
@ -440,10 +444,14 @@ class Application:
json.dump(cur.serialize(), fp) json.dump(cur.serialize(), fp)
def select_screen(self, new): def select_screen(self, new):
log.info("moving to screen %s", new.name) new.context.enter("starting UI")
if self.opts.screens and new.name not in self.opts.screens: if self.opts.screens and new.name not in self.opts.screens:
raise Skip raise Skip
new.start_ui() try:
new.start_ui()
except Skip:
new.context.exit("(skipped)")
raise
state_path = os.path.join(self.state_dir, 'last-screen') state_path = os.path.join(self.state_dir, 'last-screen')
with open(state_path, 'w') as fp: with open(state_path, 'w') as fp:
fp.write(new.name) fp.write(new.name)
@ -452,6 +460,7 @@ class Application:
self.save_state() self.save_state()
old = self.controllers.cur old = self.controllers.cur
if old is not None: if old is not None:
old.context.exit("completed")
old.end_ui() old.end_ui()
while True: while True:
self.controllers.index += increment self.controllers.index += increment
@ -476,6 +485,17 @@ class Application:
self.controllers.index = controller_index - 1 self.controllers.index = controller_index - 1
self.next_screen() self.next_screen()
def report_start_event(self, name, description, level):
# See context.py for what calls these.
log = logging.getLogger(name)
level = getattr(logging, level)
log.log(level, "start: %s", description)
def report_finish_event(self, name, description, status, level):
log = logging.getLogger(name)
level = getattr(logging, level)
log.log(level, "finish: %s %s", description, status.name)
# EventLoop ------------------------------------------------------------------- # EventLoop -------------------------------------------------------------------
def exit(self): def exit(self):

View File

@ -78,6 +78,10 @@ class Stretchy(metaclass=urwid.MetaSignals):
def stretchy_w(self): def stretchy_w(self):
return self.widgets[self.stretchy_index] return self.widgets[self.stretchy_index]
def attach_context(self, context):
urwid.connect_signal(self, 'opened', lambda: context.enter("opened"))
urwid.connect_signal(self, 'closed', lambda: context.exit("closed"))
class StretchyOverlay(urwid.Widget): class StretchyOverlay(urwid.Widget):
_selectable = True _selectable = True

View File

@ -64,8 +64,10 @@ log = logging.getLogger('subiquitycore.views.network')
def _stretchy_shower(cls, *args): def _stretchy_shower(cls, *args):
def impl(self, device): def impl(self, name, device):
self.show_stretchy_overlay(cls(self, device, *args)) stretchy = cls(self, device, *args)
stretchy.attach_context(self.controller.context.child(name))
self.show_stretchy_overlay(stretchy)
impl.opens_dialog = True impl.opens_dialog = True
return impl return impl
@ -140,8 +142,7 @@ class NetworkView(BaseView):
def _action(self, sender, action, device): def _action(self, sender, action, device):
action, meth = action action, meth = action
log.debug("_action %s %s", action.name, device.name) meth("{}/{}".format(device.name, action.name), device)
meth(device)
def _route_watcher(self, routes): def _route_watcher(self, routes):
log.debug('view route_watcher %s', routes) log.debug('view route_watcher %s', routes)
@ -377,8 +378,9 @@ class NetworkView(BaseView):
return rows return rows
def _create_bond(self, sender=None): def _create_bond(self, sender=None):
log.debug("_create_bond") stretchy = BondStretchy(self)
self.show_stretchy_overlay(BondStretchy(self)) stretchy.attach_context(self.controller.context.child("add_bond"))
self.show_stretchy_overlay(stretchy)
def show_network_error(self, action, info=None): def show_network_error(self, action, info=None):
self.error_showing = True self.error_showing = True