diff --git a/autoinstall-schema.json b/autoinstall-schema.json index 05b211201..f21b3be82 100644 --- a/autoinstall-schema.json +++ b/autoinstall-schema.json @@ -454,7 +454,8 @@ "type": "string", "enum": [ "reboot", - "poweroff" + "poweroff", + "wait" ] } }, diff --git a/documentation/autoinstall-reference.md b/documentation/autoinstall-reference.md index 453cb5017..cb6ac87ef 100644 --- a/documentation/autoinstall-reference.md +++ b/documentation/autoinstall-reference.md @@ -507,11 +507,13 @@ Supported values are: **default:** `reboot` **can be interactive:** no -Request the system to shutdown or reboot automatically after the installation has finished. +Request the system to shutdown or reboot automatically after the installation has finished. Wait means that after install is complete, no powerstate change occurs and instead it waits indefinitely. + Supported values are: * `reboot` * `shutdown` + * `wait` diff --git a/subiquity/client/client.py b/subiquity/client/client.py index 784d80fd4..721d0770b 100644 --- a/subiquity/client/client.py +++ b/subiquity/client/client.py @@ -49,6 +49,7 @@ ApplicationState, ErrorReportKind, ErrorReportRef, + ShutdownMode, ) from subiquity.journald import journald_listen from subiquity.ui.frame import SubiquityUI @@ -219,6 +220,28 @@ def resp_hook(self, response): raise Abort(report.ref()) return response + async def noninteractive_confirmation_shutdown(self): + await asyncio.sleep(1) + reboot = _('reboot') + poweroff = _('poweroff') + answer = None + print(_("The install is complete.")) + print() + prompt = "\n\n{} ({}|{})".format( + _("Shutdown the system?"), reboot, poweroff) + while answer not in (reboot, poweroff): + print(prompt) + answer = await run_in_thread(input) + if answer == reboot: + mode = ShutdownMode('reboot') + elif answer == poweroff: + mode = ShutdownMode('poweroff') + try: + await self.client.shutdown.POST(mode=mode, immediate=True) + except aiohttp.client_exceptions.ServerDisconnectedError: + # In this case, this is a good thing + pass + async def noninteractive_confirmation(self): await asyncio.sleep(1) yes = _('yes') @@ -255,6 +278,10 @@ async def noninteractive_watch_app_state(self, initial_status): confirm_task = None while True: app_state = app_status.state + if app_state == ApplicationState.DONE: + mode = await self.client.shutdown.GET() + if ShutdownMode(mode) == ShutdownMode.WAIT: + await self.noninteractive_confirmation_shutdown() if app_state == ApplicationState.NEEDS_CONFIRMATION: if confirm_task is None: confirm_task = self.aio_loop.create_task( diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index 0ce0808d7..6ac4939a9 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -332,6 +332,7 @@ def GET() -> TimeZoneInfo: ... def POST(tz: str): ... class shutdown: + def GET() -> ShutdownMode: ... def POST(mode: ShutdownMode, immediate: bool = False): ... class mirror: diff --git a/subiquity/common/types.py b/subiquity/common/types.py index 4e89dae30..5b78a1add 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -595,8 +595,9 @@ class UbuntuProCheckTokenAnswer: class ShutdownMode(enum.Enum): - REBOOT = enum.auto() - POWEROFF = enum.auto() + REBOOT = 'reboot' + POWEROFF = 'poweroff' + WAIT = 'wait' @attr.s(auto_attribs=True) diff --git a/subiquity/server/controllers/shutdown.py b/subiquity/server/controllers/shutdown.py index b203a08a4..d7857213f 100644 --- a/subiquity/server/controllers/shutdown.py +++ b/subiquity/server/controllers/shutdown.py @@ -40,7 +40,7 @@ class ShutdownController(SubiquityController): autoinstall_key = 'shutdown' autoinstall_schema = { 'type': 'string', - 'enum': ['reboot', 'poweroff'] + 'enum': [m.value for m in ShutdownMode], } def __init__(self, app): @@ -56,10 +56,11 @@ def __init__(self, app): self.mode = ShutdownMode.REBOOT def load_autoinstall_data(self, data): - if data == 'reboot': - self.mode = ShutdownMode.REBOOT - elif data == 'poweroff': - self.mode = ShutdownMode.POWEROFF + if data is not None: + self.mode = ShutdownMode(data) + + async def GET(self) -> ShutdownMode: + return self.mode async def POST(self, mode: ShutdownMode, immediate: bool = False): self.mode = mode @@ -70,7 +71,9 @@ async def POST(self, mode: ShutdownMode, immediate: bool = False): await self.shuttingdown_event.wait() def interactive(self): - return self.app.interactive + # Under normal conditions there is no matching client controller. + # When we're DONE, the client may be inquiring about shutdown state. + return True def start(self): self.app.aio_loop.create_task(self._wait_install()) @@ -115,6 +118,11 @@ async def copy_logs_to_target(self, context): @with_context(description='mode={self.mode.name}') def shutdown(self, context): + if self.mode == ShutdownMode.WAIT: + log.debug('Not shutting down yet due to shutdown mode "wait"') + self.server_reboot_event.clear() + self.app.aio_loop.create_task(self._run()) + return self.shuttingdown_event.set() if self.opts.dry_run: self.app.exit() diff --git a/subiquity/server/controllers/tests/test_shutdown.py b/subiquity/server/controllers/tests/test_shutdown.py new file mode 100644 index 000000000..2f2b132b6 --- /dev/null +++ b/subiquity/server/controllers/tests/test_shutdown.py @@ -0,0 +1,44 @@ +# Copyright 2022 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 parameterized import parameterized + +from subiquitycore.tests import SubiTestCase +from subiquitycore.tests.mocks import make_app + +from subiquity.server.controllers.shutdown import ShutdownController +from subiquity.common.types import ShutdownMode + + +class TestSubiquityModel(SubiTestCase): + def setUp(self): + self.app = make_app() + self.controller = ShutdownController(self.app) + + @parameterized.expand([ + [{'shutdown': 'reboot'}], + [{'shutdown': 'poweroff'}], + [{'shutdown': 'wait'}], + ]) + async def test_load_ai(self, ai_data): + expected = ai_data.get('shutdown') + self.controller.load_autoinstall_data(expected) + self.assertEqual(self.controller.mode.value, expected) + self.assertEqual(self.controller.mode, ShutdownMode(expected)) + + async def test_supported_values(self): + actual = ShutdownController.autoinstall_schema['enum'] + expected = ['reboot', 'poweroff', 'wait'] + self.assertEqual(expected, actual) diff --git a/system_setup/server/controllers/shutdown.py b/system_setup/server/controllers/shutdown.py index c72dbaddd..b8626011f 100644 --- a/system_setup/server/controllers/shutdown.py +++ b/system_setup/server/controllers/shutdown.py @@ -30,6 +30,11 @@ class WSLShutdownMode(enum.Enum): class SetupShutdownController(ShutdownController): + autoinstall_schema = { + 'type': 'string', + 'enum': ['reboot', 'poweroff'], + } + def __init__(self, app): # This isn't the most beautiful way, but the shutdown controller # depends on Install, override with our configure one. @@ -60,6 +65,8 @@ def shutdown(self, context): elif self.mode == ShutdownMode.POWEROFF: log.debug("Setting launcher for shut down") launcher_status += ["action=shutdown"] + else: + raise Exception(f"Unknown Shutdown Mode {self.mode}") default_uid = self.app.controllers.Install.get_default_uid() if default_uid is not None and default_uid != 0: