2021-07-02 08:26:21 +00:00
|
|
|
# Copyright 2021 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/>.
|
|
|
|
|
2021-08-19 15:42:06 +00:00
|
|
|
import asyncio
|
|
|
|
import logging
|
2021-07-02 08:26:21 +00:00
|
|
|
import os
|
2021-08-19 15:42:06 +00:00
|
|
|
import shlex
|
|
|
|
import sys
|
|
|
|
import time
|
|
|
|
|
|
|
|
import jsonschema
|
|
|
|
import yaml
|
|
|
|
from aiohttp import web
|
|
|
|
from cloudinit import atomic_helper, safeyaml, stages
|
|
|
|
from cloudinit.config.cc_set_passwords import rand_user_password
|
|
|
|
from cloudinit.distros import ug_util
|
|
|
|
from subiquity.common.api.server import bind, controller_for_request
|
|
|
|
from subiquity.common.apidef import API
|
|
|
|
from subiquity.common.errorreport import ErrorReporter
|
|
|
|
from subiquity.common.serialize import to_json
|
|
|
|
from subiquity.common.types import (ApplicationState, ErrorReportKind,
|
|
|
|
ErrorReportRef, PasswordKind)
|
|
|
|
from subiquity.server.controller import SubiquityController
|
|
|
|
from subiquity.server.errors import ErrorController
|
|
|
|
from subiquity.server.server import (MetaController,
|
|
|
|
get_installer_password_from_cloudinit_log)
|
|
|
|
from subiquitycore.async_helpers import run_in_thread
|
|
|
|
from subiquitycore.context import with_context
|
|
|
|
from subiquitycore.core import Application
|
|
|
|
from subiquitycore.ssh import user_key_fingerprints
|
|
|
|
from subiquitycore.utils import arun_command, run_command
|
|
|
|
from system_setup.models.system_server import SystemSetupModel
|
|
|
|
|
2021-08-25 02:56:07 +00:00
|
|
|
log = logging.getLogger('system_setup.server.server')
|
2021-08-19 15:42:06 +00:00
|
|
|
|
|
|
|
NOPROBERARG = "NOPROBER"
|
|
|
|
|
2021-07-02 08:26:21 +00:00
|
|
|
|
2021-08-19 15:42:06 +00:00
|
|
|
class SystemSetupServer(Application):
|
|
|
|
'''
|
|
|
|
Server for the System Setup.
|
2021-07-02 08:26:21 +00:00
|
|
|
|
2021-08-19 15:42:06 +00:00
|
|
|
No longer using old method because it keep inheriting incompatible
|
|
|
|
classes subiquity.server, which is not what we want for System Setup.
|
|
|
|
'''
|
2021-07-02 08:26:21 +00:00
|
|
|
|
2021-08-19 15:42:06 +00:00
|
|
|
snapd_socket_path = '/run/snapd.socket'
|
|
|
|
|
|
|
|
base_schema = {
|
|
|
|
'type': 'object',
|
|
|
|
'properties': {
|
|
|
|
'version': {
|
|
|
|
'type': 'integer',
|
|
|
|
'minimum': 1,
|
|
|
|
'maximum': 1,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
'required': ['version'],
|
|
|
|
'additionalProperties': True,
|
|
|
|
}
|
|
|
|
|
|
|
|
project = "subiquity"
|
2021-07-02 08:26:21 +00:00
|
|
|
from system_setup.server import controllers as controllers_mod
|
|
|
|
controllers = [
|
|
|
|
"Reporting",
|
|
|
|
"Error",
|
|
|
|
"Userdata",
|
|
|
|
"Locale",
|
|
|
|
"Identity",
|
2021-07-13 06:15:09 +00:00
|
|
|
"WSLConfiguration1",
|
2021-08-10 07:26:07 +00:00
|
|
|
"WSLConfiguration2",
|
2021-07-22 16:49:12 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
def __init__(self, opts, block_log_dir):
|
2021-08-19 15:42:06 +00:00
|
|
|
super().__init__(opts)
|
|
|
|
self.block_log_dir = block_log_dir
|
|
|
|
self.cloud = None
|
|
|
|
self.cloud_init_ok = None
|
|
|
|
self._state = ApplicationState.STARTING_UP
|
|
|
|
self.state_event = asyncio.Event()
|
|
|
|
self.interactive = None
|
|
|
|
self.confirming_tty = ''
|
|
|
|
self.fatal_error = None
|
|
|
|
self.running_error_commands = False
|
|
|
|
self.installer_user_name = None
|
|
|
|
self.installer_user_passwd_kind = PasswordKind.NONE
|
|
|
|
self.installer_user_passwd = None
|
|
|
|
|
|
|
|
self.echo_syslog_id = 'subiquity_echo.{}'.format(os.getpid())
|
|
|
|
self.event_syslog_id = 'subiquity_event.{}'.format(os.getpid())
|
|
|
|
self.log_syslog_id = 'subiquity_log.{}'.format(os.getpid())
|
|
|
|
|
|
|
|
self.error_reporter = ErrorReporter(
|
|
|
|
self.context.child("ErrorReporter"), self.opts.dry_run, self.root)
|
|
|
|
self.prober = None
|
|
|
|
self.kernel_cmdline = shlex.split(opts.kernel_cmdline)
|
|
|
|
self.snapd = None
|
|
|
|
self.note_data_for_apport("SnapUpdated", str(self.updated))
|
|
|
|
self.event_listeners = []
|
|
|
|
self.autoinstall_config = None
|
|
|
|
self.hub.subscribe('network-up', self._network_change)
|
|
|
|
self.hub.subscribe('network-proxy-set', self._proxy_set)
|
|
|
|
self.geoip = None
|
2021-08-06 17:14:26 +00:00
|
|
|
self.is_reconfig = opts.reconfigure
|
2021-08-10 07:26:07 +00:00
|
|
|
if self.is_reconfig and not opts.dry_run:
|
2021-07-22 16:49:12 +00:00
|
|
|
self.controllers = [
|
|
|
|
"Reporting",
|
|
|
|
"Error",
|
|
|
|
"Locale",
|
|
|
|
"WSLConfiguration2",
|
|
|
|
]
|
2021-07-02 08:26:21 +00:00
|
|
|
|
|
|
|
def make_model(self):
|
|
|
|
root = '/'
|
|
|
|
if self.opts.dry_run:
|
|
|
|
root = os.path.abspath('.subiquity')
|
2021-08-06 17:14:26 +00:00
|
|
|
return SystemSetupModel(root, self.is_reconfig)
|
2021-08-19 15:42:06 +00:00
|
|
|
|
|
|
|
def load_serialized_state(self):
|
|
|
|
for controller in self.controllers.instances:
|
|
|
|
controller.load_state()
|
|
|
|
|
|
|
|
def add_event_listener(self, listener):
|
|
|
|
self.event_listeners.append(listener)
|
|
|
|
|
|
|
|
def _maybe_push_to_journal(self, event_type, context, description):
|
|
|
|
pass
|
|
|
|
|
|
|
|
def report_start_event(self, context, description):
|
|
|
|
for listener in self.event_listeners:
|
|
|
|
listener.report_start_event(context, description)
|
|
|
|
self._maybe_push_to_journal('start', context, description)
|
|
|
|
|
|
|
|
def report_finish_event(self, context, description, status):
|
|
|
|
for listener in self.event_listeners:
|
|
|
|
listener.report_finish_event(context, description, status)
|
|
|
|
self._maybe_push_to_journal('finish', context, description)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def state(self):
|
|
|
|
return self._state
|
|
|
|
|
|
|
|
def update_state(self, state):
|
|
|
|
self._state = state
|
|
|
|
self.state_event.set()
|
|
|
|
self.state_event.clear()
|
|
|
|
|
|
|
|
def note_file_for_apport(self, key, path):
|
|
|
|
self.error_reporter.note_file_for_apport(key, path)
|
|
|
|
|
|
|
|
def note_data_for_apport(self, key, value):
|
|
|
|
self.error_reporter.note_data_for_apport(key, value)
|
|
|
|
|
|
|
|
def make_apport_report(self, kind, thing, *, wait=False, **kw):
|
|
|
|
return self.error_reporter.make_apport_report(
|
|
|
|
kind, thing, wait=wait, **kw)
|
|
|
|
|
|
|
|
async def _run_error_cmds(self, report):
|
|
|
|
await report._info_task
|
|
|
|
Error = getattr(self.controllers, "Error", None)
|
|
|
|
if Error is not None and Error.cmds:
|
|
|
|
try:
|
|
|
|
await Error.run()
|
|
|
|
except Exception:
|
|
|
|
log.exception("running error-commands failed")
|
|
|
|
if not self.interactive:
|
|
|
|
self.update_state(ApplicationState.ERROR)
|
|
|
|
|
|
|
|
def _exception_handler(self, loop, context):
|
|
|
|
exc = context.get('exception')
|
|
|
|
if exc is None:
|
|
|
|
super()._exception_handler(loop, context)
|
|
|
|
return
|
|
|
|
report = self.error_reporter.report_for_exc(exc)
|
|
|
|
log.error("top level error", exc_info=exc)
|
|
|
|
if not report:
|
|
|
|
report = self.make_apport_report(
|
|
|
|
ErrorReportKind.UNKNOWN, "unknown error",
|
|
|
|
exc=exc)
|
|
|
|
self.fatal_error = report
|
|
|
|
if self.interactive:
|
|
|
|
self.update_state(ApplicationState.ERROR)
|
|
|
|
if not self.running_error_commands:
|
|
|
|
self.running_error_commands = True
|
|
|
|
self.aio_loop.create_task(self._run_error_cmds(report))
|
|
|
|
|
|
|
|
@web.middleware
|
|
|
|
async def middleware(self, request, handler):
|
|
|
|
override_status = None
|
|
|
|
controller = await controller_for_request(request)
|
|
|
|
if isinstance(controller, SubiquityController):
|
|
|
|
if not controller.interactive():
|
|
|
|
override_status = 'skip'
|
|
|
|
elif self.state == ApplicationState.NEEDS_CONFIRMATION:
|
|
|
|
if self.base_model.is_postinstall_only(controller.model_name):
|
|
|
|
override_status = 'confirm'
|
|
|
|
if override_status is not None:
|
|
|
|
resp = web.Response(headers={'x-status': override_status})
|
|
|
|
else:
|
|
|
|
resp = await handler(request)
|
|
|
|
if self.updated:
|
|
|
|
resp.headers['x-updated'] = 'yes'
|
|
|
|
else:
|
|
|
|
resp.headers['x-updated'] = 'no'
|
|
|
|
if resp.get('exception'):
|
|
|
|
exc = resp['exception']
|
|
|
|
log.debug(
|
|
|
|
'request to {} crashed'.format(request.raw_path), exc_info=exc)
|
|
|
|
report = self.make_apport_report(
|
|
|
|
ErrorReportKind.SERVER_REQUEST_FAIL,
|
|
|
|
"request to {}".format(request.raw_path),
|
|
|
|
exc=exc)
|
|
|
|
resp.headers['x-error-report'] = to_json(
|
|
|
|
ErrorReportRef, report.ref())
|
|
|
|
return resp
|
|
|
|
|
|
|
|
@with_context()
|
|
|
|
async def apply_autoinstall_config(self, context):
|
|
|
|
for controller in self.controllers.instances:
|
|
|
|
if controller.interactive():
|
|
|
|
log.debug(
|
|
|
|
"apply_autoinstall_config: skipping %s as interactive",
|
|
|
|
controller.name)
|
|
|
|
continue
|
|
|
|
await controller.apply_autoinstall_config()
|
|
|
|
controller.configured()
|
|
|
|
|
|
|
|
def load_autoinstall_config(self, *, only_early):
|
|
|
|
log.debug("load_autoinstall_config only_early %s", only_early)
|
|
|
|
if self.opts.autoinstall is None:
|
|
|
|
return
|
|
|
|
with open(self.opts.autoinstall) as fp:
|
|
|
|
self.autoinstall_config = yaml.safe_load(fp)
|
|
|
|
if only_early:
|
|
|
|
self.controllers.Reporting.setup_autoinstall()
|
|
|
|
self.controllers.Reporting.start()
|
|
|
|
self.controllers.Error.setup_autoinstall()
|
|
|
|
with self.context.child("core_validation", level="INFO"):
|
|
|
|
jsonschema.validate(self.autoinstall_config, self.base_schema)
|
|
|
|
self.controllers.Early.setup_autoinstall()
|
|
|
|
else:
|
|
|
|
for controller in self.controllers.instances:
|
|
|
|
controller.setup_autoinstall()
|
|
|
|
|
|
|
|
async def start_api_server(self):
|
|
|
|
app = web.Application(middlewares=[self.middleware])
|
|
|
|
bind(app.router, API.meta, MetaController(self))
|
|
|
|
bind(app.router, API.errors, ErrorController(self))
|
|
|
|
if self.opts.dry_run:
|
|
|
|
from subiquity.server.dryrun import DryRunController
|
|
|
|
bind(app.router, API.dry_run, DryRunController(self))
|
|
|
|
for controller in self.controllers.instances:
|
|
|
|
controller.add_routes(app)
|
|
|
|
runner = web.AppRunner(app, keepalive_timeout=0xffffffff)
|
|
|
|
await runner.setup()
|
|
|
|
site = web.UnixSite(runner, self.opts.socket)
|
|
|
|
await site.start()
|
|
|
|
# It is intended that a non-root client can connect.
|
|
|
|
os.chmod(self.opts.socket, 0o666)
|
|
|
|
|
|
|
|
async def wait_for_cloudinit(self):
|
|
|
|
if self.opts.dry_run:
|
|
|
|
self.cloud_init_ok = True
|
|
|
|
return
|
|
|
|
ci_start = time.time()
|
|
|
|
status_coro = arun_command(["cloud-init", "status", "--wait"])
|
|
|
|
try:
|
|
|
|
status_cp = await asyncio.wait_for(status_coro, 600)
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
status_txt = '<timeout>'
|
|
|
|
self.cloud_init_ok = False
|
|
|
|
else:
|
|
|
|
status_txt = status_cp.stdout
|
|
|
|
self.cloud_init_ok = True
|
|
|
|
log.debug("waited %ss for cloud-init", time.time() - ci_start)
|
|
|
|
if "status: done" in status_txt:
|
|
|
|
log.debug("loading cloud config")
|
|
|
|
init = stages.Init()
|
|
|
|
init.read_cfg()
|
|
|
|
init.fetch(existing="trust")
|
|
|
|
self.cloud = init.cloudify()
|
|
|
|
autoinstall_path = '/autoinstall.yaml'
|
|
|
|
if 'autoinstall' in self.cloud.cfg:
|
|
|
|
if not os.path.exists(autoinstall_path):
|
|
|
|
atomic_helper.write_file(
|
|
|
|
autoinstall_path,
|
|
|
|
safeyaml.dumps(
|
|
|
|
self.cloud.cfg['autoinstall']).encode('utf-8'),
|
|
|
|
mode=0o600)
|
|
|
|
if os.path.exists(autoinstall_path):
|
|
|
|
self.opts.autoinstall = autoinstall_path
|
|
|
|
else:
|
|
|
|
log.debug(
|
|
|
|
"cloud-init status: %r, assumed disabled",
|
|
|
|
status_txt)
|
|
|
|
|
|
|
|
def _user_has_password(self, username):
|
|
|
|
with open('/etc/shadow') as fp:
|
|
|
|
for line in fp:
|
|
|
|
if line.startswith(username + ":$"):
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
|
|
def set_installer_password(self):
|
|
|
|
if self.cloud is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
passfile = self.state_path("installer-user-passwd")
|
|
|
|
|
|
|
|
if os.path.exists(passfile):
|
|
|
|
with open(passfile) as fp:
|
|
|
|
contents = fp.read()
|
|
|
|
self.installer_user_passwd_kind = PasswordKind.KNOWN
|
|
|
|
self.installer_user_name, self.installer_user_passwd = \
|
|
|
|
contents.split(':', 1)
|
|
|
|
return
|
|
|
|
|
|
|
|
def use_passwd(passwd):
|
|
|
|
self.installer_user_passwd = passwd
|
|
|
|
self.installer_user_passwd_kind = PasswordKind.KNOWN
|
|
|
|
with open(passfile, 'w') as fp:
|
|
|
|
fp.write(self.installer_user_name + ':' + passwd)
|
|
|
|
|
|
|
|
if self.opts.dry_run:
|
|
|
|
self.installer_user_name = os.environ['USER']
|
|
|
|
use_passwd(rand_user_password())
|
|
|
|
return
|
|
|
|
|
|
|
|
(users, _groups) = ug_util.normalize_users_groups(
|
|
|
|
self.cloud.cfg, self.cloud.distro)
|
|
|
|
(username, _user_config) = ug_util.extract_default(users)
|
|
|
|
|
|
|
|
self.installer_user_name = username
|
|
|
|
|
|
|
|
if self._user_has_password(username):
|
|
|
|
# Was the password set to a random password by a version of
|
|
|
|
# cloud-init that records the username in the log? (This is the
|
|
|
|
# case we hit on upgrading the subiquity snap)
|
|
|
|
passwd = get_installer_password_from_cloudinit_log()
|
|
|
|
if passwd:
|
|
|
|
use_passwd(passwd)
|
|
|
|
else:
|
|
|
|
self.installer_user_passwd_kind = PasswordKind.UNKNOWN
|
|
|
|
elif not user_key_fingerprints(username):
|
|
|
|
passwd = rand_user_password()
|
|
|
|
cp = run_command('chpasswd', input=username + ':'+passwd+'\n')
|
|
|
|
if cp.returncode == 0:
|
|
|
|
use_passwd(passwd)
|
|
|
|
else:
|
|
|
|
log.info("setting installer password failed %s", cp)
|
|
|
|
self.installer_user_passwd_kind = PasswordKind.NONE
|
|
|
|
else:
|
|
|
|
self.installer_user_passwd_kind = PasswordKind.NONE
|
|
|
|
|
|
|
|
async def start(self):
|
|
|
|
self.controllers.load_all()
|
|
|
|
await self.start_api_server()
|
|
|
|
self.update_state(ApplicationState.CLOUD_INIT_WAIT)
|
|
|
|
await self.wait_for_cloudinit()
|
|
|
|
self.set_installer_password()
|
|
|
|
self.load_autoinstall_config(only_early=True)
|
|
|
|
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)
|
|
|
|
# Just wait a second for any clients to get ready to print
|
|
|
|
# output.
|
|
|
|
await asyncio.sleep(1)
|
|
|
|
await self.controllers.Early.run()
|
|
|
|
open(stamp_file, 'w').close()
|
|
|
|
await asyncio.sleep(1)
|
|
|
|
self.load_autoinstall_config(only_early=False)
|
|
|
|
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()
|
|
|
|
self.update_state(ApplicationState.WAITING)
|
|
|
|
await super().start()
|
|
|
|
await self.apply_autoinstall_config()
|
|
|
|
|
|
|
|
def _network_change(self):
|
|
|
|
if not self.snapd:
|
|
|
|
return
|
|
|
|
self.hub.broadcast('snapd-network-change')
|
|
|
|
|
|
|
|
async def _proxy_set(self):
|
|
|
|
if not self.snapd:
|
|
|
|
return
|
|
|
|
await run_in_thread(
|
|
|
|
self.snapd.connection.configure_proxy, self.base_model.proxy)
|
|
|
|
self.hub.broadcast('snapd-network-change')
|
|
|
|
|
|
|
|
def restart(self):
|
|
|
|
if not self.snapd:
|
|
|
|
return
|
|
|
|
cmdline = ['snap', 'run', 'subiquity.subiquity-server']
|
|
|
|
if self.opts.dry_run:
|
|
|
|
cmdline = [
|
|
|
|
sys.executable, '-m', 'subiquity.cmd.server',
|
|
|
|
] + sys.argv[1:]
|
|
|
|
os.execvp(cmdline[0], cmdline)
|
|
|
|
|
|
|
|
def make_autoinstall(self):
|
|
|
|
config = {'version': 1}
|
|
|
|
for controller in self.controllers.instances:
|
|
|
|
controller_conf = controller.make_autoinstall()
|
|
|
|
if controller_conf:
|
|
|
|
config[controller.autoinstall_key] = controller_conf
|
|
|
|
return config
|