Change subuiquity to run as firstboot for snappy.
Signed-off-by: Ryan Harper <ryan.harper@canonical.com>
This commit is contained in:
parent
2007d6401b
commit
346d4fa37d
|
@ -0,0 +1,63 @@
|
|||
Firstboot
|
||||
---------
|
||||
|
||||
Firstboot is a tui that runs on the device's getty interfaces when a
|
||||
system has not yet been configured. It displays the current network
|
||||
configuration and allows user to modify that. It also collects
|
||||
user information used to create a local user and import ssh public keys
|
||||
|
||||
|
||||
Getting Started
|
||||
---------------
|
||||
|
||||
Install pre-reqs:
|
||||
|
||||
% sudo apt-get update && sudo apt-get install qemu-system-x86 cloud-image-utils
|
||||
|
||||
Download the firstboot image and startup script
|
||||
|
||||
% wget http://people.canonical.com/~rharper/firstboot/firstboot.sh
|
||||
% chmod +x ./firstboot.sh
|
||||
% wget http://people.canonical.com/~rharper/firstboot/firstboot.raw.xz
|
||||
% unxz firstboot.raw.xz
|
||||
% ./firstboot.sh
|
||||
|
||||
This will launch the firstboot image under KVM using userspace networking
|
||||
The main console will open in a new window, the serial console is available via
|
||||
telnet session (telnet localhost 2447).
|
||||
|
||||
|
||||
When firstboot displays the ssh URL, in the demo, since we're using qemu user
|
||||
networking, we can't ssh directly to the VM, instead we redirect the guest's ssh
|
||||
port 22 to host port 2222; this is a limitation of the demo. When ssh'ing to
|
||||
the guest, use:
|
||||
|
||||
ssh -p 2222 <user>@localhost
|
||||
|
||||
|
||||
How it works
|
||||
------------
|
||||
|
||||
The firstboot program is launched after the getty service is available, and
|
||||
disables getty on any tty and instead spawns the firstboot program. It will
|
||||
remain available until one of the firstboot instances successfully completes.
|
||||
After completion, firstboot will disable itself and re-enable getty services.
|
||||
|
||||
firstboot is based on subiquity, just pulling out a few of the panels and
|
||||
reusing certain parts. The networking information is probed from the host
|
||||
and allows user configuration. After completion of configuration, firstboot
|
||||
uses the ``ip`` command to apply the new network config to the network devices
|
||||
present. Long term, we'll supply network-config yaml to snappy or whatever
|
||||
network configuration tool will be present and be responsible for bringing
|
||||
networking up to the desired state.
|
||||
|
||||
For identity, we collect realname, username, password (and crypt it), and a
|
||||
"ssh_import_id" URL. The ``ssh-import-id`` binary already supports both
|
||||
launchpad (lp:) and github (gh:). In the demo, I added mock SSO support (sso:)
|
||||
and this would trigger a call out to snappy login or what ever the right tool
|
||||
to initiate a connection to the SSO for authentication and retrieval of the
|
||||
user's ssh keys.
|
||||
|
||||
After collecting the input, we run ``ip``, ``useradd`` and ``ssh-import-id``
|
||||
and display the current config, including ssh url. After selecting "Finish"
|
||||
We restore the normal getty prompt from which the newly created user can login.
|
|
@ -0,0 +1,23 @@
|
|||
#!/bin/bash
|
||||
BOOT=firstboot.raw
|
||||
SEED=seed.img
|
||||
|
||||
[ ! -e ${SEED} ] && {
|
||||
cat > user-data <<EOF
|
||||
#cloud-config
|
||||
password: passw0rd
|
||||
chpasswd: { expire: False }
|
||||
ssh_pwauth: True
|
||||
EOF
|
||||
echo "instance-id: $(uuidgen || echo i-abcdefg)" > meta-data
|
||||
cloud-localds ${SEED} user-data meta-data
|
||||
}
|
||||
|
||||
qemu-system-x86_64 -m 1024 --enable-kvm \
|
||||
-snapshot \
|
||||
-drive file=${BOOT},format=raw,if=virtio \
|
||||
-net user -net nic,model=virtio \
|
||||
-redir tcp:2222::22 \
|
||||
-cdrom $SEED \
|
||||
-monitor stdio \
|
||||
-serial telnet:localhost:2447,nowait,server
|
|
@ -0,0 +1,39 @@
|
|||
[Unit]
|
||||
Description=Ubuntu Snappy Firstboot Configuration Getty %I
|
||||
After=systemd-user-sessions.service plymouth-quit-wait.service
|
||||
ExecPreStart=systemctl stop getty@%I
|
||||
After=rc-local.service
|
||||
ExecStop=systemctl start getty@%I
|
||||
Before=getty.target
|
||||
IgnoreOnIsolate=yes
|
||||
ConditionPathExists=/dev/tty0
|
||||
ConditionPathExists=!/var/lib/firstboot/firstboot-complete
|
||||
|
||||
[Service]
|
||||
Environment=PYTHONPATH=/home/ubuntu/firstboot
|
||||
ExecStartPre=/bin/systemctl stop getty@%I
|
||||
ExecStart=-/sbin/agetty -n --noclear -l /home/ubuntu/firstboot/bin/subiquity-tui %I $TERM
|
||||
ExecStop=/bin/systemctl start getty@%I
|
||||
#ExecStopPost=/bin/echo "Post stop, starting getty@%I"
|
||||
#ExecStopPost=/bin/systemctl start getty@%I
|
||||
Type=idle
|
||||
Restart=always
|
||||
RestartSec=0
|
||||
UtmpIdentifier=%I
|
||||
TTYPath=/dev/%I
|
||||
TTYReset=yes
|
||||
TTYVHangup=yes
|
||||
TTYVTDisallocate=yes
|
||||
KillMode=process
|
||||
IgnoreSIGPIPE=no
|
||||
SendsSIGHUP=yes
|
||||
|
||||
#KillMode=process
|
||||
#Restart=always
|
||||
#StandardInput=tty-force
|
||||
#StandardOutput=tty
|
||||
#StandardError=tty
|
||||
|
||||
[Install]
|
||||
WantedBy=getty.target
|
||||
DefaultInstance=tty1
|
|
@ -0,0 +1,32 @@
|
|||
[Unit]
|
||||
Description=Ubuntu Snappy Firstboot Configuration Getty %I
|
||||
BindsTo=dev-%i.device
|
||||
#After=getty@tty.service
|
||||
After=dev-%i.device systemd-user-sessions.service plymouth-quit-wait.service
|
||||
After=rc-local.service
|
||||
ConditionPathExists=!/var/lib/firstboot/firstboot-complete
|
||||
|
||||
[Service]
|
||||
Environment=PYTHONPATH=/home/ubuntu/firstboot
|
||||
ExecStartPre=/sbin/systemctl stop serial-getty@%I
|
||||
ExecStart=-/sbin/agetty -n --noclear -l /home/ubuntu/firstboot/bin/subiquity-tui %I $TERM
|
||||
ExecStop=/sbin/systemctl start serial-getty@%I
|
||||
Type=idle
|
||||
Restart=always
|
||||
UtmpIdentifier=%I
|
||||
TTYPath=/dev/%I
|
||||
TTYReset=yes
|
||||
TTYVHangup=yes
|
||||
KillMode=process
|
||||
IgnoreSIGPIPE=no
|
||||
SendsSIGHUP=yes
|
||||
|
||||
#TTYVTDisallocate=yes
|
||||
#KillMode=process
|
||||
#Restart=always
|
||||
#StandardInput=tty-force
|
||||
#StandardOutput=tty
|
||||
#StandardError=tty
|
||||
|
||||
[Install]
|
||||
WantedBy=getty.target
|
|
@ -34,6 +34,7 @@ class ControllerPolicy:
|
|||
self.opts = common['opts']
|
||||
self.loop = common['loop']
|
||||
self.prober = common['prober']
|
||||
self.controllers = common['controllers']
|
||||
|
||||
def register_signals(self):
|
||||
""" Defines signals associated with controller from model """
|
||||
|
|
|
@ -19,3 +19,4 @@ from .network import NetworkController # NOQA
|
|||
from .filesystem import FilesystemController # NOQA
|
||||
from .installprogress import InstallProgressController # NOQA
|
||||
from .identity import IdentityController # NOQA
|
||||
from .login import LoginController # NOQA
|
||||
|
|
|
@ -13,15 +13,17 @@
|
|||
# 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 subiquity.controller import ControllerPolicy
|
||||
from subiquity.models import IdentityModel
|
||||
from subiquity.ui.views import IdentityView
|
||||
from subiquity.ui.views import IdentityView, LoginView
|
||||
|
||||
log = logging.getLogger('subiquity.controllers.identity')
|
||||
|
||||
class IdentityController(ControllerPolicy):
|
||||
def __init__(self, common):
|
||||
super().__init__(common)
|
||||
self.model = IdentityModel()
|
||||
self.model = IdentityModel(self.opts)
|
||||
|
||||
def identity(self):
|
||||
title = "Profile setup"
|
||||
|
@ -29,4 +31,23 @@ class IdentityController(ControllerPolicy):
|
|||
footer = ""
|
||||
self.ui.set_header(title, excerpt)
|
||||
self.ui.set_footer(footer, 40)
|
||||
self.ui.set_body(IdentityView(self.model, self.signal))
|
||||
self.ui.set_body(IdentityView(self.model, self.signal, self.opts))
|
||||
|
||||
|
||||
def login(self):
|
||||
log.debug("Identity login view")
|
||||
title = ("Snappy Ubuntu Core Pre-ownership Configuration Complete")
|
||||
footer = ("View configured user and device access methods")
|
||||
self.ui.set_header(title)
|
||||
self.ui.set_footer(footer)
|
||||
|
||||
net_model = self.controllers['Network'].model
|
||||
configured_ifaces = net_model.get_configured_interfaces()
|
||||
login_view = LoginView(self.model,
|
||||
self.signal,
|
||||
self.model.user,
|
||||
configured_ifaces)
|
||||
|
||||
self.ui.set_body(login_view)
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
# Copyright 2015 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/>.
|
||||
|
||||
|
||||
from subiquity.ui.views import LoginView
|
||||
from subiquity.models import LoginModel
|
||||
from subiquity.controller import ControllerPolicy
|
||||
|
||||
|
||||
class LoginController(ControllerPolicy):
|
||||
def __init__(self, common):
|
||||
super().__init__(common)
|
||||
self.model = LoginModel()
|
||||
|
||||
def login(self):
|
||||
title = "Snappy Ubuntu Core Pre-ownership Configuration Complete"
|
||||
excerpt = "Your device is now configured. Login details below."
|
||||
self.ui.set_header(title, excerpt)
|
||||
view = LoginView(self.model, self.signal, self.model.user)
|
||||
self.ui.set_body(view)
|
|
@ -24,6 +24,7 @@ from subiquity.ui.views import (NetworkView,
|
|||
from subiquity.ui.dummy import DummyView
|
||||
|
||||
from subiquity.curtin import curtin_write_network_actions
|
||||
from subiquity.curtin import curtin_apply_networking
|
||||
|
||||
log = logging.getLogger("subiquity.controller.network")
|
||||
|
||||
|
@ -52,7 +53,11 @@ class NetworkController(ControllerPolicy):
|
|||
'curtin_write_network_actions')
|
||||
return None
|
||||
|
||||
self.signal.emit_signal('menu:filesystem:main')
|
||||
curtin_apply_networking(actions, dryrun=self.opts.dry_run)
|
||||
|
||||
#self.signal.emit_signal('menu:filesystem:main')
|
||||
# switch to identity view
|
||||
self.signal.emit_signal('menu:identity:main')
|
||||
|
||||
def set_default_route(self):
|
||||
self.ui.set_header("Default route")
|
||||
|
|
|
@ -25,10 +25,10 @@ class WelcomeController(ControllerPolicy):
|
|||
self.model = WelcomeModel()
|
||||
|
||||
def welcome(self):
|
||||
title = "Wilkommen! Bienvenue! Welcome! Zdrastvutie! Welkom!"
|
||||
excerpt = "Please choose your preferred language"
|
||||
title = "Ubuntu Core - Firstboot Configuration"
|
||||
excerpt = "Welcome to snappy Ubuntu Core, a transactionally updated Ubuntu. It's a brave new world here in snappy Ubuntu Core! This machine has not been configured. Please continue to configure this device for use."
|
||||
footer = ("Use UP, DOWN arrow keys, and ENTER, to "
|
||||
"select your language.")
|
||||
"configure your device.")
|
||||
self.ui.set_header(title, excerpt)
|
||||
self.ui.set_footer(footer)
|
||||
view = WelcomeView(self.model, self.signal)
|
||||
|
|
|
@ -52,7 +52,9 @@ class Controller:
|
|||
"Filesystem": None,
|
||||
"Identity": None,
|
||||
"InstallProgress": None,
|
||||
"Login": None,
|
||||
}
|
||||
self.common['controllers'] = self.controllers
|
||||
|
||||
def _connect_base_signals(self):
|
||||
""" Connect signals used in the core controller
|
||||
|
|
|
@ -17,7 +17,9 @@ import datetime
|
|||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import yaml
|
||||
import subiquity.utils as utils
|
||||
|
||||
|
||||
log = logging.getLogger("subiquity.curtin")
|
||||
|
@ -88,6 +90,46 @@ POST_INSTALL_LIST = [
|
|||
POST_INSTALL = '\n' + "\n".join(POST_INSTALL_LIST) + '\n'
|
||||
|
||||
|
||||
def curtin_configure_user(userinfo, dryrun=False):
|
||||
usercmds = []
|
||||
usercmds += ["useradd -m -p {confirm_password} {username}".format(**userinfo)]
|
||||
if 'ssh_import_id' in userinfo:
|
||||
target = "/home/{username}/.ssh/authorized_keys".format(**userinfo)
|
||||
userinfo.update({'target': target})
|
||||
ssh_id = userinfo.get('ssh_import_id')
|
||||
if ssh_id.startswith('sso'):
|
||||
log.info('call out to snappyd login')
|
||||
else:
|
||||
ssh_import_id = "ssh-import-id -o "
|
||||
ssh_import_id += "{target} {ssh_import_id}".format(**userinfo)
|
||||
usercmds += [ssh_import_id]
|
||||
|
||||
if not dryrun:
|
||||
for cmd in usercmds:
|
||||
utils.run_command(cmd.split(), shell=False)
|
||||
|
||||
# always run chown last
|
||||
homedir = '/home/{username}'.format(**userinfo)
|
||||
retries = 10
|
||||
while not os.path.exists(homedir) and retries > 0:
|
||||
log.debug('waiting on homedir')
|
||||
retries -= 1
|
||||
time.sleep(0.2)
|
||||
|
||||
if retries <= 0:
|
||||
raise ValueError('Failed to create homedir')
|
||||
|
||||
chown = "chown {username}.{username} -R /home/{username}".format(**userinfo)
|
||||
utils.run_command(chown.split(), shell=False)
|
||||
|
||||
# add sudo rule
|
||||
with open('/etc/sudoers.d/firstboot-user', 'w') as fh:
|
||||
fh.write('# firstboot config added user\n\n')
|
||||
fh.write('{username} ALL=(ALL) NOPASSWD:ALL\n'.format(**userinfo))
|
||||
else:
|
||||
log.info('dry-run, skiping user configuration')
|
||||
|
||||
|
||||
def curtin_userinfo_to_config(userinfo):
|
||||
user_template = ' - default\\n' + \
|
||||
' - name: {username}\\n' + \
|
||||
|
@ -108,9 +150,10 @@ def curtin_hostinfo_to_config(hostinfo):
|
|||
|
||||
|
||||
def curtin_write_postinst_config(userinfo):
|
||||
# firstboot doesn't get hostinfo; but it's still present in the template
|
||||
config = {
|
||||
'users': curtin_userinfo_to_config(userinfo),
|
||||
'hostinfo': curtin_hostinfo_to_config(userinfo),
|
||||
'hostinfo': '',
|
||||
}
|
||||
|
||||
with open(POST_INSTALL_CONFIG_FILE, 'w') as conf:
|
||||
|
@ -151,6 +194,25 @@ def curtin_write_network_actions(actions):
|
|||
conf.close()
|
||||
|
||||
|
||||
def curtin_apply_networking(actions, dryrun=True):
|
||||
log.info('Applying network actions:\n%s', actions)
|
||||
network_commands = []
|
||||
for entry in actions:
|
||||
if entry['type'] == 'physical':
|
||||
for subnet in entry.get('subnets', []):
|
||||
if subnet['type'] == 'static':
|
||||
cmd = "ifconfig %s %s" % (entry['name'], subnet['address'])
|
||||
if 'netmask' in subnet:
|
||||
cmd += " netmask %s" % subnet['netmask']
|
||||
cmd += " up"
|
||||
network_commands += [cmd]
|
||||
|
||||
for cmd in network_commands:
|
||||
log.info('Running command: [%s]', cmd)
|
||||
if not dryrun:
|
||||
utils.run_command(cmd.split(), shell=False)
|
||||
|
||||
|
||||
def curtin_write_preserved_actions(actions):
|
||||
''' caller must use models.actions.preserve_action on
|
||||
all elements of the actions'''
|
||||
|
|
|
@ -20,5 +20,6 @@ from .welcome import WelcomeModel # NOQA
|
|||
from .identity import IdentityModel # NOQA
|
||||
from .installprogress import InstallProgressModel # NOQA
|
||||
from .iscsi_disk import IscsiDiskModel # NOQA
|
||||
from .login import LoginModel # NOQA
|
||||
from .raid import RaidModel # NOQA
|
||||
from .ceph_disk import CephDiskModel # NOQA
|
||||
|
|
|
@ -20,6 +20,39 @@ from subiquity.utils import crypt_password
|
|||
|
||||
log = logging.getLogger('subiquity.models.identity')
|
||||
|
||||
class LocalUser(object):
|
||||
def __init__(self, result):
|
||||
self._realname = result.get('realname')
|
||||
self._username = result.get('username')
|
||||
self._password = result.get('password')
|
||||
self._cpassword = result.get('confirm_password')
|
||||
self._ssh_import_id = None
|
||||
if 'ssh_import_id' in result:
|
||||
self._ssh_import_id = result.get('ssh_import_id')
|
||||
|
||||
@property
|
||||
def realname(self):
|
||||
return self._realname
|
||||
|
||||
@property
|
||||
def username(self):
|
||||
return self._username
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
return self._password
|
||||
|
||||
@property
|
||||
def cpassword(self):
|
||||
return self._cpassword
|
||||
|
||||
@property
|
||||
def ssh_import_id(self):
|
||||
return self._ssh_import_id
|
||||
|
||||
def __repr__(self):
|
||||
return "%s <%s>" % (self._realname, self._username)
|
||||
|
||||
|
||||
class IdentityModel(ModelPolicy):
|
||||
""" Model representing user identity
|
||||
|
@ -27,7 +60,10 @@ class IdentityModel(ModelPolicy):
|
|||
signals = [
|
||||
("Identity view",
|
||||
'menu:identity:main',
|
||||
'identity')
|
||||
'identity'),
|
||||
("Login view",
|
||||
'menu:identity:login:main',
|
||||
'login')
|
||||
]
|
||||
|
||||
identity_menu = [
|
||||
|
@ -42,6 +78,9 @@ class IdentityModel(ModelPolicy):
|
|||
"validate_confirm_password")
|
||||
]
|
||||
|
||||
def __init__(self, opts):
|
||||
self.opts = opts
|
||||
|
||||
def get_signals(self):
|
||||
return self.signals
|
||||
|
||||
|
@ -53,8 +92,18 @@ class IdentityModel(ModelPolicy):
|
|||
if x == selection:
|
||||
return y
|
||||
|
||||
def add_user(self, result):
|
||||
if result:
|
||||
self._user = LocalUser(result)
|
||||
else:
|
||||
self._user = None
|
||||
|
||||
@property
|
||||
def user(self):
|
||||
return self._user
|
||||
|
||||
def encrypt_password(self, passinput):
|
||||
return crypt_password(passinput)
|
||||
|
||||
def __repr__(self):
|
||||
return "<Username: {}>".format(self.username)
|
||||
return "<LocalUser: {}>".format(self.user)
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
# Copyright 2015 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 subiquity.model import ModelPolicy
|
||||
|
||||
|
||||
log = logging.getLogger('subiquity.login')
|
||||
|
||||
|
||||
class LoginModel(ModelPolicy):
|
||||
""" Model representing Final login screen
|
||||
"""
|
||||
prev_signal = 'menu:identity:main'
|
||||
|
||||
signals = [
|
||||
("Login view",
|
||||
'menu:login:main',
|
||||
'login')
|
||||
]
|
||||
|
||||
configured_logins = [
|
||||
'local',
|
||||
'ssh'
|
||||
]
|
||||
|
||||
def get_signals(self):
|
||||
return self.signals
|
||||
|
||||
def get_menu(self):
|
||||
return self.configured_logins
|
||||
|
||||
def get_signal_by_name(self, selection):
|
||||
for x, y, z in self.get_menu():
|
||||
if x == selection:
|
||||
return y
|
||||
|
||||
def __repr__(self):
|
||||
return "<Configured: {}>".format(self.configured_logins)
|
|
@ -68,6 +68,9 @@ class Networkdev():
|
|||
|
||||
log.debug('Post config action: {}'.format(self.action.get()))
|
||||
|
||||
def __repr__(self):
|
||||
return "%s: %s" % (self.ifname, self.ip)
|
||||
|
||||
@property
|
||||
def is_configured(self):
|
||||
return (self.action is not None and
|
||||
|
|
|
@ -30,5 +30,8 @@ class MenuSelectButton(Button):
|
|||
confirm_btn = partial(PlainButton, label="Confirm", on_press=None)
|
||||
cancel_btn = partial(PlainButton, label="Cancel", on_press=None)
|
||||
done_btn = partial(PlainButton, label="Done", on_press=None)
|
||||
finish_btn = partial(PlainButton, label="Finish", on_press=None)
|
||||
ok_btn = partial(PlainButton, label="OK", on_press=None)
|
||||
continue_btn = partial(PlainButton, label="Continue", on_press=None)
|
||||
reset_btn = partial(PlainButton, label="Reset", on_press=None)
|
||||
menu_btn = partial(MenuSelectButton, on_press=None)
|
||||
|
|
|
@ -32,3 +32,4 @@ from .installpath import InstallpathView # NOQA
|
|||
from .installprogress import ProgressView # NOQA
|
||||
from .welcome import WelcomeView # NOQA
|
||||
from .identity import IdentityView # NOQA
|
||||
from .login import LoginView # NOQA
|
||||
|
|
|
@ -27,7 +27,8 @@ from subiquity.ui.interactive import (PasswordEditor,
|
|||
UsernameEditor)
|
||||
from subiquity.ui.utils import Padding, Color
|
||||
from subiquity.view import ViewPolicy
|
||||
from subiquity.curtin import curtin_write_postinst_config
|
||||
from subiquity.curtin import (curtin_write_postinst_config,
|
||||
curtin_configure_user)
|
||||
|
||||
|
||||
log = logging.getLogger("subiquity.views.identity")
|
||||
|
@ -39,9 +40,10 @@ USERNAME_MAXLEN = 32
|
|||
|
||||
|
||||
class IdentityView(ViewPolicy):
|
||||
def __init__(self, model, signal):
|
||||
def __init__(self, model, signal, opts):
|
||||
self.model = model
|
||||
self.signal = signal
|
||||
self.opts = opts
|
||||
self.items = []
|
||||
self.realname = RealnameEditor(caption="")
|
||||
self.hostname = UsernameEditor(caption="")
|
||||
|
@ -82,25 +84,6 @@ class IdentityView(ViewPolicy):
|
|||
],
|
||||
dividechars=4
|
||||
),
|
||||
Columns(
|
||||
[
|
||||
("weight", 0.2, Text("Your server's name:",
|
||||
align="right")),
|
||||
("weight", 0.3,
|
||||
Color.string_input(self.hostname,
|
||||
focus_map="string_input focus"))
|
||||
],
|
||||
dividechars=4
|
||||
),
|
||||
Columns(
|
||||
[
|
||||
("weight", 0.2, Text("", align="right")),
|
||||
("weight", 0.3, Color.info_minor(
|
||||
Text("The name it uses when it talks to "
|
||||
"other computers", align="left"))),
|
||||
],
|
||||
dividechars=4
|
||||
),
|
||||
Columns(
|
||||
[
|
||||
("weight", 0.2, Text("Pick a username:", align="right")),
|
||||
|
@ -145,6 +128,7 @@ class IdentityView(ViewPolicy):
|
|||
("weight", 0.2, Text("", align="right")),
|
||||
("weight", 0.3, Color.info_minor(
|
||||
Text("Input your SSH user id from "
|
||||
"Ubuntu SSO (sso:email), "
|
||||
"Launchpad (lp:username) or "
|
||||
"Github (gh:username).",
|
||||
align="left"))),
|
||||
|
@ -168,17 +152,6 @@ class IdentityView(ViewPolicy):
|
|||
self.realname.value = ""
|
||||
return
|
||||
|
||||
if len(self.hostname.value) < 1:
|
||||
self.error.set_text("Server name missing.")
|
||||
self.hostname.value = ""
|
||||
return
|
||||
|
||||
if len(self.hostname.value) > HOSTNAME_MAXLEN:
|
||||
self.error.set_text("Server name too long, must be < " +
|
||||
str(HOSTNAME_MAXLEN))
|
||||
self.hostname.value = ""
|
||||
return
|
||||
|
||||
if len(self.username.value) < 1:
|
||||
self.error.set_text("Username missing.")
|
||||
self.username.value = ""
|
||||
|
@ -213,7 +186,6 @@ class IdentityView(ViewPolicy):
|
|||
log.debug("*crypted* User input: {} {} {}".format(
|
||||
self.username.value, cpassword, cpassword))
|
||||
result = {
|
||||
"hostname": self.hostname.value,
|
||||
"realname": self.realname.value,
|
||||
"username": self.username.value,
|
||||
"password": cpassword,
|
||||
|
@ -230,17 +202,20 @@ class IdentityView(ViewPolicy):
|
|||
return
|
||||
|
||||
log.debug("User input: {}".format(result))
|
||||
self.model.add_user(result)
|
||||
|
||||
try:
|
||||
curtin_write_postinst_config(result)
|
||||
curtin_configure_user(result, dryrun=self.opts.dry_run)
|
||||
except PermissionError:
|
||||
log.exception('Failed to write curtin post-install config')
|
||||
self.signal.emit_signal('filesystem:error',
|
||||
'curtin_write_postinst_config')
|
||||
'curtin_write_postinst_config', result)
|
||||
return None
|
||||
|
||||
self.signal.emit_signal('installprogress:wrote-postinstall')
|
||||
# show progress view
|
||||
self.signal.emit_signal('menu:installprogress:main')
|
||||
# show login view
|
||||
self.signal.emit_signal('menu:identity:login:main')
|
||||
|
||||
def cancel(self, button):
|
||||
self.signal.prev_signal()
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
# Copyright 2015 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/>.
|
||||
|
||||
""" Login
|
||||
|
||||
Login provides user with language selection
|
||||
|
||||
"""
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
from urwid import (ListBox, Pile, BoxAdapter, Text)
|
||||
from subiquity.ui.lists import SimpleList
|
||||
from subiquity.ui.buttons import finish_btn
|
||||
from subiquity.ui.utils import Padding, Color
|
||||
from subiquity.view import ViewPolicy
|
||||
from subiquity import utils
|
||||
|
||||
log = logging.getLogger("subiquity.views.login")
|
||||
|
||||
|
||||
class LoginView(ViewPolicy):
|
||||
def __init__(self, model, signal, user, ifaces):
|
||||
self.model = model
|
||||
self.signal = signal
|
||||
self.user = user
|
||||
self.ifaces = ifaces
|
||||
self.items = []
|
||||
self.body = [
|
||||
Padding.line_break(""),
|
||||
Padding.line_break(""),
|
||||
Padding.line_break(""),
|
||||
Padding.center_79(self._build_model_inputs()),
|
||||
Padding.line_break(""),
|
||||
Padding.fixed_10(self._build_buttons())
|
||||
]
|
||||
super().__init__(ListBox(self.body))
|
||||
|
||||
def _build_buttons(self):
|
||||
self.buttons = [
|
||||
Color.button(finish_btn(on_press=self.done),
|
||||
focus_map='button focus'),
|
||||
]
|
||||
return Pile(self.buttons)
|
||||
|
||||
def auth_name(self, idstr):
|
||||
# lp:<id>
|
||||
# gh:<id>
|
||||
# sso:<id>
|
||||
auth_type = idstr.split(":")[0]
|
||||
auth_to_name = {
|
||||
'lp': 'Launchpad',
|
||||
'gh': 'Github',
|
||||
'sso': 'Ubuntu SSO'
|
||||
}
|
||||
return auth_to_name.get(auth_type, 'Unknown Authenication')
|
||||
|
||||
def _build_model_inputs(self):
|
||||
"""
|
||||
This device is registered to Ryan Harper. Ryan Harper added
|
||||
a user, raharper, to the device for local access on the console.
|
||||
|
||||
Remote access was enabled via authentication with Launchpad as
|
||||
lp:raharper and public ssh keys were added to the system for
|
||||
remote access.
|
||||
|
||||
Ryan Harper can remotely connect to this system via SSH:
|
||||
|
||||
ssh rharper@192.168.11.58
|
||||
ssh rharper@192.168.11.44
|
||||
"""
|
||||
|
||||
local_tpl = (
|
||||
"This device is registered to {realname}. {realname} added a"
|
||||
" user, <{username}> to the device for access.")
|
||||
|
||||
remote_tpl = (
|
||||
"\n\nRemote access was enabled via authentication with {auth} user"
|
||||
" <{ssh_import_id}>.\nPublic SSH keys were added to the device "
|
||||
"for remote access.\n\n{realname} can connect remotely to this "
|
||||
"device via SSH:")
|
||||
|
||||
sl = []
|
||||
ssh = []
|
||||
user = self.model.user
|
||||
login_info = {
|
||||
'realname': user.realname,
|
||||
'username': user.username,
|
||||
}
|
||||
login_info.update({'auth': self.auth_name(user.ssh_import_id),
|
||||
'ssh_import_id': user.ssh_import_id.split(":")[-1]})
|
||||
print(login_info)
|
||||
login_text = local_tpl.format(**login_info)
|
||||
if user.ssh_import_id:
|
||||
login_text += remote_tpl.format(**login_info)
|
||||
for iface in self.ifaces:
|
||||
ip = str(iface.ip).split("/")[0]
|
||||
ssh_iface = " ssh %s@%s" % (user.username, ip)
|
||||
ssh += [Padding.center_50(Text(ssh_iface))]
|
||||
|
||||
sl += [Text(login_text),
|
||||
Padding.line_break("")] + ssh
|
||||
|
||||
return Pile(sl)
|
||||
|
||||
|
||||
def confirm(self, result):
|
||||
self.done()
|
||||
|
||||
def done(self, button):
|
||||
# mark ourselves complete
|
||||
utils.mark_firstboot_complete()
|
||||
|
||||
# disable the UI service restoring getty service
|
||||
utils.disable_first_boot_service()
|
||||
|
||||
self.signal.emit_signal('quit')
|
|
@ -46,23 +46,26 @@ class NetworkView(ViewPolicy):
|
|||
Padding.fixed_10(self._build_buttons()),
|
||||
]
|
||||
# FIXME determine which UX widget should have focus
|
||||
super().__init__(ListBox(self.body))
|
||||
self.lb = ListBox(self.body)
|
||||
self.lb.set_focus(4) # _build_buttons
|
||||
super().__init__(self.lb)
|
||||
|
||||
|
||||
def _build_buttons(self):
|
||||
cancel = cancel_btn(on_press=self.cancel)
|
||||
done = done_btn(on_press=self.done)
|
||||
cancel = Color.button(cancel_btn(on_press=self.cancel),
|
||||
focus_map='button focus')
|
||||
done = Color.button(done_btn(on_press=self.done),
|
||||
focus_map='button focus')
|
||||
self.default_focus = done
|
||||
|
||||
buttons = [
|
||||
Color.button(done, focus_map='button focus'),
|
||||
Color.button(cancel, focus_map='button focus')
|
||||
]
|
||||
return Pile(buttons)
|
||||
buttons = [done, cancel]
|
||||
return Pile(buttons, focus_item=done)
|
||||
|
||||
def _build_model_inputs(self):
|
||||
log.info("probing for network devices")
|
||||
self.model.probe_network()
|
||||
ifaces = self.model.get_all_interface_names()
|
||||
ifname_width = 4 # default padding
|
||||
ifname_width = 8 # default padding
|
||||
|
||||
col_1 = []
|
||||
for iface in ifaces:
|
||||
|
@ -70,7 +73,7 @@ class NetworkView(ViewPolicy):
|
|||
Color.menu_button(
|
||||
menu_btn(label=iface,
|
||||
on_press=self.on_net_dev_press),
|
||||
focus_map='menu_button focus'))
|
||||
focus_map='button focus'))
|
||||
col_1.append(Text("")) # vertical holder for ipv6 status
|
||||
col_1.append(Text("")) # vertical holder for ipv4 status
|
||||
col_1 = BoxAdapter(SimpleList(col_1),
|
||||
|
@ -114,8 +117,8 @@ class NetworkView(ViewPolicy):
|
|||
col_2 = BoxAdapter(SimpleList(col_2, is_selectable=False),
|
||||
height=len(col_2))
|
||||
ifname_width += len(max(ifaces, key=len))
|
||||
if ifname_width > 14:
|
||||
ifname_width = 14
|
||||
if ifname_width > 20:
|
||||
ifname_width = 20
|
||||
else:
|
||||
col_2 = Pile([Text("No network interfaces detected.")])
|
||||
|
||||
|
@ -152,7 +155,7 @@ class NetworkView(ViewPolicy):
|
|||
Color.menu_button(
|
||||
menu_btn(label=opt,
|
||||
on_press=self.additional_menu_select),
|
||||
focus_map='menu_button focus'))
|
||||
focus_map='button focus'))
|
||||
return Pile(opts)
|
||||
|
||||
def additional_menu_select(self, result):
|
||||
|
|
|
@ -21,7 +21,7 @@ Welcome provides user with language selection
|
|||
import logging
|
||||
from urwid import (ListBox, Pile, BoxAdapter)
|
||||
from subiquity.ui.lists import SimpleList
|
||||
from subiquity.ui.buttons import menu_btn, cancel_btn
|
||||
from subiquity.ui.buttons import menu_btn, ok_btn, cancel_btn
|
||||
from subiquity.ui.utils import Padding, Color
|
||||
from subiquity.view import ViewPolicy
|
||||
|
||||
|
@ -33,8 +33,8 @@ class WelcomeView(ViewPolicy):
|
|||
self.model = model
|
||||
self.signal = signal
|
||||
self.items = []
|
||||
#Padding.center_50(self._build_model_inputs()),
|
||||
self.body = [
|
||||
Padding.center_50(self._build_model_inputs()),
|
||||
Padding.line_break(""),
|
||||
Padding.fixed_10(self._build_buttons())
|
||||
]
|
||||
|
@ -42,7 +42,7 @@ class WelcomeView(ViewPolicy):
|
|||
|
||||
def _build_buttons(self):
|
||||
self.buttons = [
|
||||
Color.button(cancel_btn(on_press=self.cancel),
|
||||
Color.button(ok_btn(on_press=self.confirm),
|
||||
focus_map='button focus'),
|
||||
]
|
||||
return Pile(self.buttons)
|
||||
|
@ -59,8 +59,8 @@ class WelcomeView(ViewPolicy):
|
|||
|
||||
def confirm(self, result):
|
||||
self.model.selected_language = result.label
|
||||
log.debug('calling installpath')
|
||||
self.signal.emit_signal('menu:installpath:main')
|
||||
log.debug('calling network')
|
||||
self.signal.emit_signal('menu:network:main')
|
||||
|
||||
def cancel(self, button):
|
||||
raise SystemExit("No language selected, exiting as there are no "
|
||||
|
|
|
@ -18,8 +18,9 @@ import errno
|
|||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import yaml
|
||||
from subprocess import Popen, PIPE
|
||||
from subprocess import Popen, PIPE, call
|
||||
from subiquity.async import Async
|
||||
|
||||
log = logging.getLogger("subiquity.utils")
|
||||
|
@ -107,7 +108,7 @@ def run_command_async(cmd, timeout=None):
|
|||
return Async.pool.submit(run_command, cmd, timeout)
|
||||
|
||||
|
||||
def run_command(command, timeout=None):
|
||||
def run_command(command, timeout=None, shell=False):
|
||||
""" Execute command through system shell
|
||||
:param command: command to run
|
||||
:param timeout: (optional) use 'timeout' to limit time. default 300
|
||||
|
@ -131,7 +132,7 @@ def run_command(command, timeout=None):
|
|||
# http://stackoverflow.com/ +
|
||||
# questions/27022810/urwid-watch-file-blocks-keypress
|
||||
r, w = os.pipe()
|
||||
p = Popen(command, shell=True,
|
||||
p = Popen(command, shell=shell,
|
||||
stdin=r, stdout=PIPE, stderr=PIPE,
|
||||
bufsize=-1, env=cmd_env, close_fds=True)
|
||||
os.close(w)
|
||||
|
@ -213,3 +214,35 @@ def sudo_user():
|
|||
"""
|
||||
sudo_user = os.getenv('SUDO_USER', None)
|
||||
return sudo_user
|
||||
|
||||
|
||||
def mark_firstboot_complete():
|
||||
""" Touch our firstboot-complete eyecatcher """
|
||||
log.info('marking firstboot service complete')
|
||||
firstboot = '/var/lib/firstboot/firstboot-complete'
|
||||
if not os.path.exists(os.path.dirname(firstboot)):
|
||||
os.makedirs(os.path.dirname(firstboot))
|
||||
with open(firstboot, 'w') as fp:
|
||||
os.utime(fp.name, None)
|
||||
fp.close()
|
||||
|
||||
|
||||
def disable_first_boot_service():
|
||||
""" Stop firstboot service; which also restores getty service """
|
||||
log.info('disabling first boot service')
|
||||
tty = os.ttyname(sys.stdout.fileno()).split("/")[-1]
|
||||
if not tty.startswith('tty'):
|
||||
log.debug('tty is not tty: %s , skipping service shutdown', tty)
|
||||
return
|
||||
|
||||
cmd = "systemctl stop firstboot@%s" % tty
|
||||
log.info("disabling firstboot service with %s", cmd)
|
||||
fid = os.fork()
|
||||
if fid == 0:
|
||||
try:
|
||||
subprocess.call([cmd])
|
||||
os._exit(0)
|
||||
except:
|
||||
log.warn("%s returned non-zero" % cmd)
|
||||
os._exit(1)
|
||||
return
|
||||
|
|
Loading…
Reference in New Issue