Merge pull request #873 from mwhudson/combine-install-and-application-state

smoosh InstallState into ApplicationState
This commit is contained in:
Michael Hudson-Doyle 2020-12-18 15:52:59 +13:00 committed by GitHub
commit 07a85949b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 128 additions and 178 deletions

View File

@ -168,8 +168,8 @@ The API takes a "long poll" approach to status updates. For example,
`wait=False` and if the result indicates that the check for updates is still in
progress it shows a screen indicating this and calls it again with `wait=True`,
which will not return until the check has completed (or failed). In a similar
vein, `install.status.GET()` takes an argument indicating what the client
thinks the install state currently is and will block until that changes.
vein, `meta.status.GET()` takes an argument indicating what the client
thinks the application state currently is and will block until that changes.
### Examples and common patterns
@ -332,24 +332,31 @@ controllers have methods that are called to load and apply the autoinstall data
for each controller. The only real difference to the client is that it behaves
totally differently if the install is to be totally automated: in this case it
does not start the urwid-based UI at all and mostly just "listens" to install
progress via journald and the `install.status.GET()` API call.
progress via journald and the `meta.status.GET()` API call.
### Starting and confirming the install
### The server state machine
The installation code proceeds in stages:
The server code proceeds in stages:
1. First it waits for all the model objects that feed into the curtin config
to be configured.
2. It waits for confirmation.
3. It runs "curtin install" and waits for that to finish.
4. It waits for the model objects that feed into the cloud-init config to be
1. It starts up, checks for an autoinstall config and runs any early
commands.
2. Then it waits for all the model objects that feed into the curtin
config to be configured.
3. It waits for confirmation.
4. It runs "curtin install" and waits for that to finish.
5. It waits for the model objects that feed into the cloud-init config to be
configured.
5. If there appears to be a working network connection, it downloads and
6. It creates the cloud-init config for the first boot of the
installed system.
7. If there appears to be a working network connection, it downloads and
installs security updates.
6. It waits for the user to click "reboot".
8. It runs any late commands.
9. It waits for the user to click "reboot".
Each of these states gets a different value of the `InstallState` enum, so the
client gets notified via long-polling `install.status.GET()` of progress.
Each of these states gets a different value of the `ApplicationState`
enum, so the client gets notified via long-polling `meta.status.GET()`
of progress. In addition, `ApplicationState.ERROR` indicates something
has gone wrong.
### Refreshing the snap

View File

@ -46,7 +46,6 @@ from subiquity.common.types import (
ApplicationState,
ErrorReportKind,
ErrorReportRef,
InstallState,
)
from subiquity.journald import journald_listen
from subiquity.ui.frame import SubiquityUI
@ -206,24 +205,24 @@ class SubiquityClient(TuiApplication):
answer = await run_in_thread(input)
await self.confirm_install()
async def noninteractive_watch_install_state(self):
install_state = None
async def noninteractive_watch_app_state(self, initial_status):
app_status = initial_status
confirm_task = None
while True:
try:
install_status = await self.client.install.status.GET(
cur=install_state)
install_state = install_status.state
except aiohttp.ClientError:
await asyncio.sleep(1)
continue
if install_state == InstallState.NEEDS_CONFIRMATION:
if confirm_task is not None:
app_state = app_status.state
if app_state == ApplicationState.NEEDS_CONFIRMATION:
if confirm_task is None:
confirm_task = self.aio_loop.create_task(
self.noninteractive_confirmation())
elif confirm_task is not None:
confirm_task.cancel()
confirm_task = None
try:
app_status = await self.client.meta.status.GET(
cur=app_state)
except aiohttp.ClientError:
await asyncio.sleep(1)
continue
def subiquity_event_noninteractive(self, event):
if event['SUBIQUITY_EVENT_TYPE'] == 'start':
@ -244,28 +243,20 @@ class SubiquityClient(TuiApplication):
print(".", end='', flush=True)
else:
break
print()
print("\nconnected")
journald_listen(
self.aio_loop,
[status.echo_syslog_id],
lambda e: print(e['MESSAGE']))
if status.state == ApplicationState.STARTING:
print("server is starting...", end='', flush=True)
while status.state == ApplicationState.STARTING:
await asyncio.sleep(1)
print(".", end='', flush=True)
status = await self.client.meta.status.GET()
print()
if status.state == ApplicationState.EARLY_COMMANDS:
print("running early commands...")
if status.state == ApplicationState.STARTING_UP:
status = await self.client.meta.status.GET(cur=status.state)
await asyncio.sleep(0.5)
return status
async def start(self):
status = await self.connect()
if status.state == ApplicationState.INTERACTIVE:
self.interactive = True
self.interactive = status.interactive
if self.interactive:
await super().start()
journald_listen(
self.aio_loop,
@ -283,7 +274,6 @@ class SubiquityClient(TuiApplication):
self.show_error_report(report.ref())
break
else:
self.interactive = False
if self.opts.run_on_serial:
# Thanks to the fact that we are launched with agetty's
# --skip-login option, on serial lines we can end up starting
@ -299,7 +289,7 @@ class SubiquityClient(TuiApplication):
self.subiquity_event_noninteractive,
seek=True)
self.aio_loop.create_task(
self.noninteractive_watch_install_state())
self.noninteractive_watch_app_state(status))
def _exception_handler(self, loop, context):
exc = context.get('exception')

View File

@ -21,7 +21,7 @@ import aiohttp
from subiquitycore.context import with_context
from subiquity.client.controller import SubiquityTuiController
from subiquity.common.types import InstallState
from subiquity.common.types import ApplicationState
from subiquity.ui.views.installprogress import (
InstallRunning,
ProgressView,
@ -33,12 +33,10 @@ log = logging.getLogger("subiquity.client.controllers.progress")
class ProgressController(SubiquityTuiController):
endpoint_name = 'install'
def __init__(self, app):
super().__init__(app)
self.progress_view = ProgressView(self)
self.install_state = None
self.app_state = None
self.crash_report_ref = None
self.answers = app.answers.get("InstallProgress", {})
@ -77,43 +75,43 @@ class ProgressController(SubiquityTuiController):
install_running = None
while True:
try:
install_status = await self.endpoint.status.GET(
cur=self.install_state)
app_status = await self.app.client.meta.status.GET(
cur=self.app_state)
except aiohttp.ClientError:
await asyncio.sleep(1)
continue
self.install_state = install_status.state
self.app_state = app_status.state
self.progress_view.update_for_state(self.install_state)
self.progress_view.update_for_state(self.app_state)
if self.ui.body is self.progress_view:
self.ui.set_header(self.progress_view.title)
if install_status.error is not None:
if app_status.error is not None:
if self.crash_report_ref is None:
self.crash_report_ref = install_status.error
self.crash_report_ref = app_status.error
self.ui.set_body(self.progress_view)
self.app.show_error_report(self.crash_report_ref)
if self.install_state == InstallState.NEEDS_CONFIRMATION:
if self.app_state == ApplicationState.NEEDS_CONFIRMATION:
if self.showing:
self.app.show_confirm_install()
if self.install_state == InstallState.RUNNING:
if install_status.confirming_tty != self.app.our_tty:
if self.app_state == ApplicationState.RUNNING:
if app_status.confirming_tty != self.app.our_tty:
install_running = InstallRunning(
self.app, install_status.confirming_tty)
self.app, app_status.confirming_tty)
self.app.add_global_overlay(install_running)
else:
if install_running is not None:
self.app.remove_global_overlay(install_running)
install_running = None
if self.install_state == InstallState.DONE:
if self.app_state == ApplicationState.DONE:
if self.answers.get('reboot', False):
self.click_reboot()
def make_ui(self):
if self.install_state == InstallState.NEEDS_CONFIRMATION:
if self.app_state == ApplicationState.NEEDS_CONFIRMATION:
self.app.show_confirm_install()
return self.progress_view

View File

@ -29,8 +29,6 @@ from subiquity.common.types import (
ErrorReportRef,
KeyboardSetting,
IdentityData,
InstallState,
InstallStatus,
RefreshStatus,
SnapInfo,
SnapListResponse,
@ -79,6 +77,7 @@ class API:
class crash:
def GET() -> None:
"""Requests to this method will fail with a HTTP 500."""
class refresh:
def GET(wait: bool = False) -> RefreshStatus:
"""Get information about the snap refresh status.
@ -182,10 +181,6 @@ class API:
class snap_info:
def GET(snap_name: str) -> SnapInfo: ...
class install:
class status:
def GET(cur: Optional[InstallState] = None) -> InstallStatus: ...
class reboot:
def POST(): ...

View File

@ -25,22 +25,6 @@ from typing import List, Optional
import attr
class ApplicationState(enum.Enum):
STARTING = enum.auto()
EARLY_COMMANDS = enum.auto()
INTERACTIVE = enum.auto()
NON_INTERACTIVE = enum.auto()
@attr.s(auto_attribs=True)
class ApplicationStatus:
state: ApplicationState
cloud_init_ok: bool
echo_syslog_id: str
log_syslog_id: str
event_syslog_id: str
class ErrorReportState(enum.Enum):
INCOMPLETE = enum.auto()
LOADING = enum.auto()
@ -68,6 +52,31 @@ class ErrorReportRef:
oops_id: Optional[str]
class ApplicationState(enum.Enum):
STARTING_UP = enum.auto()
WAITING = enum.auto()
NEEDS_CONFIRMATION = enum.auto()
RUNNING = enum.auto()
POST_WAIT = enum.auto()
POST_RUNNING = enum.auto()
UU_RUNNING = enum.auto()
UU_CANCELLING = enum.auto()
DONE = enum.auto()
ERROR = enum.auto()
@attr.s(auto_attribs=True)
class ApplicationStatus:
state: ApplicationState
confirming_tty: str
error: Optional[ErrorReportRef]
cloud_init_ok: bool
interactive: Optional[bool]
echo_syslog_id: str
log_syslog_id: str
event_syslog_id: str
class RefreshCheckState(enum.Enum):
UNKNOWN = enum.auto()
AVAILABLE = enum.auto()
@ -195,22 +204,3 @@ class SnapListResponse:
status: SnapCheckState
snaps: List[SnapInfo] = attr.Factory(list)
selections: List[SnapSelection] = attr.Factory(list)
class InstallState(enum.Enum):
NOT_STARTED = enum.auto()
NEEDS_CONFIRMATION = enum.auto()
RUNNING = enum.auto()
POST_WAIT = enum.auto()
POST_RUNNING = enum.auto()
UU_RUNNING = enum.auto()
UU_CANCELLING = enum.auto()
DONE = enum.auto()
ERROR = enum.auto()
@attr.s(auto_attribs=True)
class InstallStatus:
state: InstallState
confirming_tty: str = ''
error: Optional[ErrorReportRef] = None

View File

@ -21,7 +21,7 @@ from systemd import journal
from subiquitycore.context import with_context
from subiquitycore.utils import arun_command
from subiquity.common.types import InstallState
from subiquity.common.types import ApplicationState
from subiquity.server.controller import NonInteractiveController
@ -102,7 +102,7 @@ class LateController(CmdListController):
async def _run(self):
Install = self.app.controllers.Install
await Install.install_task
if Install.install_state == InstallState.DONE:
if self.app.state == ApplicationState.DONE:
await self.run()
@ -113,7 +113,7 @@ class ErrorController(CmdListController):
@with_context()
async def run(self, context):
if self.app.interactive():
if self.app.interactive:
self.syslog_id = self.app.log_syslog_id
else:
self.syslog_id = self.app.echo_syslog_id

View File

@ -21,7 +21,6 @@ import re
import shutil
import sys
import tempfile
from typing import Optional
from curtin.commands.install import (
ERROR_TARFILE,
@ -40,14 +39,12 @@ from subiquitycore.utils import (
astart_command,
)
from subiquity.common.apidef import API
from subiquity.common.errorreport import ErrorReportKind
from subiquity.server.controller import (
SubiquityController,
)
from subiquity.common.types import (
InstallState,
InstallStatus,
ApplicationState,
)
from subiquity.journald import journald_listen
@ -75,14 +72,9 @@ class TracebackExtractor:
class InstallController(SubiquityController):
endpoint = API.install
def __init__(self, app):
super().__init__(app)
self.model = app.base_model
self._install_state = InstallState.NOT_STARTED
self._install_state_event = asyncio.Event()
self.error_ref = None
self.unattended_upgrades_proc = None
self.unattended_upgrades_ctx = None
@ -90,48 +82,27 @@ class InstallController(SubiquityController):
self.tb_extractor = TracebackExtractor()
self.curtin_event_contexts = {}
def interactive(self):
return True
async def status_GET(
self, cur: Optional[InstallState] = None) -> InstallStatus:
if cur == self.install_state:
await self._install_state_event.wait()
return InstallStatus(
self.install_state,
self.app.confirming_tty,
self.error_ref)
def stop_uu(self):
if self.install_state == InstallState.UU_RUNNING:
self.update_state(InstallState.UU_CANCELLING)
if self.app.state == ApplicationState.UU_RUNNING:
self.app.update_state(ApplicationState.UU_CANCELLING)
self.app.aio_loop.create_task(self.stop_unattended_upgrades())
def start(self):
self.install_task = self.app.aio_loop.create_task(self.install())
@property
def install_state(self):
return self._install_state
def update_state(self, state):
self._install_state_event.set()
self._install_state_event.clear()
self._install_state = state
def tpath(self, *path):
return os.path.join(self.model.target, *path)
def curtin_error(self):
self.update_state(InstallState.ERROR)
kw = {}
if sys.exc_info()[0] is not None:
log.exception("curtin_error")
# send traceback.format_exc() to journal?
if self.tb_extractor.traceback:
kw["Traceback"] = "\n".join(self.tb_extractor.traceback)
self.error_ref = self.app.make_apport_report(
ErrorReportKind.INSTALL_FAIL, "install failed", **kw).ref()
self.app.fatal_error = self.app.make_apport_report(
ErrorReportKind.INSTALL_FAIL, "install failed", **kw)
self.app.update_state(ApplicationState.ERROR)
def logged_command(self, cmd):
return ['systemd-cat', '--level-prefix=false',
@ -254,15 +225,15 @@ class InstallController(SubiquityController):
try:
await asyncio.wait({e.wait() for e in self.model.install_events})
if not self.app.interactive():
if not self.app.interactive:
if 'autoinstall' in self.app.kernel_cmdline:
self.model.confirm()
self.update_state(InstallState.NEEDS_CONFIRMATION)
self.app.update_state(ApplicationState.NEEDS_CONFIRMATION)
await self.model.confirmation.wait()
self.update_state(InstallState.RUNNING)
self.app.update_state(ApplicationState.RUNNING)
if os.path.exists(self.model.target):
await self.unmount_target(
@ -270,22 +241,22 @@ class InstallController(SubiquityController):
await self.curtin_install(context=context)
self.update_state(InstallState.POST_WAIT)
self.app.update_state(ApplicationState.POST_WAIT)
await asyncio.wait(
{e.wait() for e in self.model.postinstall_events})
await self.drain_curtin_events(context=context)
self.update_state(InstallState.POST_RUNNING)
self.app.update_state(ApplicationState.POST_RUNNING)
await self.postinstall(context=context)
if self.model.network.has_network:
self.update_state(InstallState.UU_RUNNING)
self.app.update_state(ApplicationState.UU_RUNNING)
await self.run_unattended_upgrades(context=context)
self.update_state(InstallState.DONE)
self.app.update_state(ApplicationState.DONE)
except Exception:
self.curtin_error()

View File

@ -32,7 +32,7 @@ class LocaleController(SubiquityController):
autoinstall_default = 'en_US.UTF-8'
def interactive(self):
return self.app.interactive()
return self.app.interactive
def load_autoinstall_data(self, data):
os.environ["LANG"] = data

View File

@ -24,7 +24,7 @@ from subiquitycore.utils import arun_command, run_command
from subiquity.common.apidef import API
from subiquity.server.controller import SubiquityController
from subiquity.server.controllers.install import InstallState
from subiquity.server.controllers.install import ApplicationState
log = logging.getLogger("subiquity.controllers.restart")
@ -51,10 +51,10 @@ class RebootController(SubiquityController):
await Install.install_task
await self.app.controllers.Late.run_event.wait()
await self.copy_logs_to_target()
if self.app.interactive():
if self.app.interactive:
await self.user_reboot_event.wait()
self.reboot()
elif Install.install_state == InstallState.DONE:
elif self.app.state == ApplicationState.DONE:
self.reboot()
@with_context()

View File

@ -69,7 +69,7 @@ class ReportingController(NonInteractiveController):
app.add_event_listener(self)
def load_autoinstall_data(self, data):
if self.app.interactive():
if self.app.interactive:
return
self.config.update(copy.deepcopy(NON_INTERACTIVE_CONFIG))
if data is not None:

View File

@ -47,7 +47,6 @@ from subiquity.common.types import (
ApplicationState,
ApplicationStatus,
ErrorReportRef,
InstallState,
)
from subiquity.server.controller import SubiquityController
from subiquity.models.subiquity import SubiquityModel
@ -73,8 +72,11 @@ class MetaController:
if cur == self.app.state:
await self.app.state_event.wait()
return ApplicationStatus(
self.app.state,
state=self.app.state,
confirming_tty=self.app.confirming_tty,
error=self.app.fatal_error,
cloud_init_ok=self.app.cloud_init_ok,
interactive=self.app.interactive,
echo_syslog_id=self.app.echo_syslog_id,
event_syslog_id=self.app.event_syslog_id,
log_syslog_id=self.app.log_syslog_id)
@ -145,9 +147,11 @@ class SubiquityServer(Application):
super().__init__(opts)
self.block_log_dir = block_log_dir
self.cloud_init_ok = cloud_init_ok
self._state = ApplicationState.STARTING
self._state = ApplicationState.STARTING_UP
self.state_event = asyncio.Event()
self.interactive = None
self.confirming_tty = ''
self.fatal_error = None
self.echo_syslog_id = 'subiquity_echo.{}'.format(os.getpid())
self.event_syslog_id = 'subiquity_event.{}'.format(os.getpid())
@ -184,14 +188,14 @@ class SubiquityServer(Application):
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():
if not context.get('is-install-context') and self.interactive:
controller = context.get('controller')
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():
if context.get('is-install-context') and self.interactive:
indent -= 1
msg = context.description
else:
@ -241,20 +245,14 @@ class SubiquityServer(Application):
return self.error_reporter.make_apport_report(
kind, thing, wait=wait, **kw)
def interactive(self):
if not self.autoinstall_config:
return True
return bool(self.autoinstall_config.get('interactive-sections'))
@web.middleware
async def middleware(self, request, handler):
override_status = None
controller = await controller_for_request(request)
if isinstance(controller, SubiquityController):
install_state = self.controllers.Install.install_state
if not controller.interactive():
override_status = 'skip'
elif install_state == InstallState.NEEDS_CONFIRMATION:
elif self.state == ApplicationState.NEEDS_CONFIRMATION:
if self.base_model.needs_configuration(controller.model_name):
override_status = 'confirm'
if override_status is not None:
@ -326,18 +324,19 @@ class SubiquityServer(Application):
if self.autoinstall_config and self.controllers.Early.cmds:
stamp_file = self.state_path("early-commands")
if not os.path.exists(stamp_file):
self.update_state(ApplicationState.EARLY_COMMANDS)
await self.controllers.Early.run()
open(stamp_file, 'w').close()
await asyncio.sleep(1)
self.load_autoinstall_config(only_early=False)
if not self.interactive() and not self.opts.dry_run:
if self.autoinstall_config:
self.interactive = bool(
self.autoinstall_config.get('interactive-sections'))
else:
self.interactive = True
if not self.interactive and not self.opts.dry_run:
open('/run/casper-no-prompt', 'w').close()
self.load_serialized_state()
if self.interactive():
self.update_state(ApplicationState.INTERACTIVE)
else:
self.update_state(ApplicationState.NON_INTERACTIVE)
await asyncio.sleep(1)
self.update_state(ApplicationState.WAITING)
await super().start()
await self.apply_autoinstall_config()

View File

@ -33,7 +33,7 @@ from subiquitycore.ui.utils import button_pile, Padding, rewrap
from subiquitycore.ui.stretchy import Stretchy
from subiquitycore.ui.width import widget_width
from subiquity.common.types import InstallState
from subiquity.common.types import ApplicationState
log = logging.getLogger("subiquity.views.installprogress")
@ -138,22 +138,22 @@ class ProgressView(BaseView):
self._set_button_width()
def update_for_state(self, state):
if state == InstallState.NOT_STARTED:
if state == ApplicationState.WAITING:
self.title = _("Installing system")
btns = []
elif state == InstallState.NEEDS_CONFIRMATION:
elif state == ApplicationState.NEEDS_CONFIRMATION:
self.title = _("Installing system")
btns = []
elif state == InstallState.RUNNING:
elif state == ApplicationState.RUNNING:
self.title = _("Installing system")
btns = [self.view_log_btn]
elif state == InstallState.POST_WAIT:
elif state == ApplicationState.POST_WAIT:
self.title = _("Installing system")
btns = [self.view_log_btn]
elif state == InstallState.POST_RUNNING:
elif state == ApplicationState.POST_RUNNING:
self.title = _("Installing system")
btns = [self.view_log_btn]
elif state == InstallState.UU_RUNNING:
elif state == ApplicationState.UU_RUNNING:
self.title = _("Install complete!")
self.reboot_btn.base_widget.set_label(
_("Cancel update and reboot"))
@ -161,7 +161,7 @@ class ProgressView(BaseView):
self.view_log_btn,
self.reboot_btn,
]
elif state == InstallState.UU_CANCELLING:
elif state == ApplicationState.UU_CANCELLING:
self.title = _("Install complete!")
self.reboot_btn.base_widget.set_label(_("Rebooting..."))
self.reboot_btn.enabled = False
@ -169,14 +169,14 @@ class ProgressView(BaseView):
self.view_log_btn,
self.reboot_btn,
]
elif state == InstallState.DONE:
elif state == ApplicationState.DONE:
self.title = _("Install complete!")
self.reboot_btn.base_widget.set_label(_("Reboot Now"))
btns = [
self.view_log_btn,
self.reboot_btn,
]
elif state == InstallState.ERROR:
elif state == ApplicationState.ERROR:
self.title = _('An error occurred during installation')
self.reboot_btn.base_widget.set_label(_("Reboot Now"))
self.reboot_btn.enabled = True

View File

@ -4,7 +4,7 @@ from unittest import mock
from subiquitycore.testing import view_helpers
from subiquity.client.controllers.progress import ProgressController
from subiquity.common.types import InstallState
from subiquity.common.types import ApplicationState
from subiquity.ui.views.installprogress import ProgressView
@ -28,7 +28,7 @@ class IdentityViewTests(unittest.TestCase):
view = self.make_view()
btn = view_helpers.find_button_matching(view, "^Reboot Now$")
self.assertIs(btn, None)
view.update_for_state(InstallState.DONE)
view.update_for_state(ApplicationState.DONE)
btn = view_helpers.find_button_matching(view, "^Reboot Now$")
self.assertIsNot(btn, None)
view_helpers.click(btn)