Add initial integration of Ubuntu Advantage in subiquity

Introduce a new view, model and controllers pair to enable Ubuntu
Advantage from Subiquity. For now, we expect the user to input an
alphanumeric token and rely on cloud-init to enable the features
associated with the subscription.

This implementation comes with a number of limitations:
 * inability to validate the token before first-boot
 * inability to check what features are associated with the Ubuntu
   Advantage subscription, before first-boot
 * the user must input the token directly: there is no mechanism to
   deduce the token based on email address and password.

The new screen is placed after the identity scree. The installation
should already be running when the UA screen will be shown to the user.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
This commit is contained in:
Olivier Gayot 2021-12-03 10:42:49 +01:00
parent 55b2d4cf86
commit 1c9c04d55d
25 changed files with 361 additions and 0 deletions

View File

@ -45,6 +45,8 @@ Identity:
hostname: ubuntu-server
# ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
UbuntuAdvantage:
token: "a1b2c3d4"
SSH:
install_server: false
SnapList:

View File

@ -20,6 +20,8 @@ Identity:
hostname: ubuntu-server
# ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
UbuntuAdvantage:
token: "a1b2c3d4"
SSH:
install_server: false
SnapList:

View File

@ -20,6 +20,8 @@ Identity:
hostname: ubuntu-server
# ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
UbuntuAdvantage:
token: "a1b2c3d4"
SSH:
install_server: true
pwauth: false

View File

@ -64,6 +64,8 @@ Identity:
hostname: ubuntu-server
# ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
UbuntuAdvantage:
token: "a1b2c3d4"
SSH:
install_server: true
pwauth: false

View File

@ -45,6 +45,8 @@ Filesystem:
fstype: ext4
mount: /
- action: done
UbuntuAdvantage:
token: "a1b2c3d4"
Identity:
realname: Ubuntu
username: ubuntu

View File

@ -25,6 +25,8 @@ Identity:
hostname: ubuntu-server
# ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
UbuntuAdvantage:
token: "a1b2c3d4"
SSH:
install_server: true
pwauth: false

View File

@ -96,6 +96,8 @@ Identity:
hostname: ubuntu-server
# ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
UbuntuAdvantage:
token: "a1b2c3d4"
SSH:
install_server: false
SnapList:

View File

@ -55,6 +55,8 @@ Identity:
hostname: ubuntu-server
# ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
UbuntuAdvantage:
token: "a1b2c3d4"
SSH:
install_server: false
SnapList:

View File

@ -24,6 +24,8 @@ Identity:
hostname: ubuntu-server
# ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
UbuntuAdvantage:
token: "a1b2c3d4"
SSH:
install_server: true
pwauth: false

View File

@ -28,6 +28,8 @@ Identity:
hostname: ubuntu-server
# ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
UbuntuAdvantage:
token: "a1b2c3d4"
SSH:
install_server: false
SnapList:

View File

@ -22,6 +22,8 @@ Identity:
# ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
ssh-import-id: gh:mwhudson
UbuntuAdvantage:
token: "a1b2c3d4"
SnapList:
snaps:
hello:

View File

@ -110,6 +110,7 @@ class SubiquityClient(TuiApplication):
"Refresh",
"Filesystem",
"Identity",
"UbuntuAdvantage",
"SSH",
"SnapList",
"Progress",

View File

@ -26,6 +26,7 @@ from .serial import SerialController
from .snaplist import SnapListController
from .source import SourceController
from .ssh import SSHController
from .ubuntu_advantage import UbuntuAdvantageController
from .welcome import WelcomeController
from .zdev import ZdevController
@ -44,6 +45,7 @@ __all__ = [
'SnapListController',
'SourceController',
'SSHController',
'UbuntuAdvantageController',
'WelcomeController',
'ZdevController',
]

View File

@ -0,0 +1,48 @@
# 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/>.
""" Module that defines the client-side controller class for Ubuntu Advantage.
"""
import logging
from subiquity.client.controller import SubiquityTuiController
from subiquity.common.types import UbuntuAdvantageInfo
from subiquity.ui.views.ubuntu_advantage import UbuntuAdvantageView
log = logging.getLogger("subiquity.client.controllers.ubuntu_advantage")
class UbuntuAdvantageController(SubiquityTuiController):
""" Client-side controller for Ubuntu Advantage configuration. """
endpoint_name = "ubuntu_advantage"
async def make_ui(self) -> UbuntuAdvantageView:
""" Generate the UI, based on the data provided by the model. """
ubuntu_advantage_info = await self.endpoint.GET()
return UbuntuAdvantageView(self, ubuntu_advantage_info.token)
def run_answers(self) -> None:
if "token" in self.answers:
self.done(self.answers["token"])
def cancel(self) -> None:
self.app.prev_screen()
def done(self, token: str) -> None:
log.debug("UbuntuAdvantageController.done token=%s", token)
self.app.next_screen(
self.endpoint.POST(UbuntuAdvantageInfo(token=token))
)

View File

@ -48,6 +48,7 @@ from subiquity.common.types import (
StorageResponse,
StorageResponseV2,
TimeZoneInfo,
UbuntuAdvantageInfo,
WLANSupportInstallState,
ZdevInfo,
WSLConfigurationBase,
@ -62,6 +63,7 @@ class API:
locale = simple_endpoint(str)
proxy = simple_endpoint(str)
ssh = simple_endpoint(SSHData)
ubuntu_advantage = simple_endpoint(UbuntuAdvantageInfo)
updates = simple_endpoint(str)
wslconfbase = simple_endpoint(WSLConfigurationBase)
wslconfadvanced = simple_endpoint(WSLConfigurationAdvanced)

View File

@ -389,6 +389,11 @@ class TimeZoneInfo:
from_geoip: bool
@attr.s(auto_attribs=True)
class UbuntuAdvantageInfo:
token: str
class ShutdownMode(enum.Enum):
REBOOT = enum.auto()
POWEROFF = enum.auto()

View File

@ -41,6 +41,7 @@ from .snaplist import SnapListModel
from .source import SourceModel
from .ssh import SSHModel
from .timezone import TimeZoneModel
from .ubuntu_advantage import UbuntuAdvantageModel
from .updates import UpdatesModel
@ -143,6 +144,7 @@ class SubiquityModel:
self.ssh = SSHModel()
self.source = SourceModel()
self.timezone = TimeZoneModel()
self.ubuntu_advantage = UbuntuAdvantageModel()
self.updates = UpdatesModel()
self.userdata = {}

View File

@ -0,0 +1,38 @@
# 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/>.
import unittest
from subiquity.models.ubuntu_advantage import UbuntuAdvantageModel
class TestUbuntuAdvantageModel(unittest.TestCase):
def test_make_cloudconfig_(self):
model = UbuntuAdvantageModel()
# Test with a token
model.token = "0a1b2c3d4e5f6"
expected = {
"ubuntu_advantage": {
"token": "0a1b2c3d4e5f6",
}
}
self.assertEqual(model.make_cloudconfig(), expected)
# Test without token
model.token = ""
self.assertEqual(model.make_cloudconfig(), {})
model.token = None
self.assertEqual(model.make_cloudconfig(), {})

View File

@ -0,0 +1,47 @@
# 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/>.
""" Module that defines the model for Ubuntu Advantage configuration. """
import logging
log = logging.getLogger("subiquitycore.models.ubuntu_advantage")
class UbuntuAdvantageModel:
"""
Model that represents the Ubuntu Advantage configuration.
Currently, we rely only on cloud-init so we have no means to validate that
the provided token is correct ; nor to retrieve information about the
subscription.
"""
def __init__(self):
""" Initialize the model. """
self.token: str = ""
def make_cloudconfig(self) -> dict:
"""
Return a dictionary that will be included in cloud-init config.
Having the token set to the empty-string disables the configuration.
"""
if not self.token:
return {}
# Both "ubuntu_advantage" and "ubuntu-advantage" keys are accepted, but
# "ubuntu-advantage" is deprecated despite not being mentioned in the
# documentation.
return {
"ubuntu_advantage": {
"token": self.token,
},
}

View File

@ -32,6 +32,7 @@ from .snaplist import SnapListController
from .source import SourceController
from .ssh import SSHController
from .timezone import TimeZoneController
from .ubuntu_advantage import UbuntuAdvantageController
from .updates import UpdatesController
from .userdata import UserdataController
from .zdev import ZdevController
@ -58,6 +59,7 @@ __all__ = [
'SourceController',
'SSHController',
'TimeZoneController',
'UbuntuAdvantageController',
'UpdatesController',
'UserdataController',
'ZdevController',

View File

@ -0,0 +1,34 @@
# 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/>.
import unittest
from subiquity.server.controllers.ubuntu_advantage import (
UbuntuAdvantageController,
)
from subiquitycore.tests.mocks import make_app
class TestUbuntuAdvantageController(unittest.TestCase):
def setUp(self):
self.controller = UbuntuAdvantageController(make_app())
def test_serialize(self):
self.controller.model.token = "1a2b3C"
self.assertEqual(self.controller.serialize(), "1a2b3C")
def test_deserialize(self):
self.controller.deserialize("1A2B3C4D")
self.assertEqual(self.controller.model.token, "1A2B3C4D")

View File

@ -0,0 +1,53 @@
# 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/>.
""" Module defining the server-side controller class for Ubuntu Advantage. """
import logging
from subiquity.common.apidef import API
from subiquity.common.types import UbuntuAdvantageInfo
from subiquity.server.controller import SubiquityController
log = logging.getLogger("subiquity.server.controllers.ubuntu_advantage")
class UbuntuAdvantageController(SubiquityController):
""" Represent the server-side Ubuntu Advantage controller. """
endpoint = API.ubuntu_advantage
model_name = "ubuntu_advantage"
def serialize(self) -> str:
""" Save the current state of the model so it can be loaded later.
Currently this function is called automatically by .configured().
"""
return self.model.token
def deserialize(self, token: str) -> None:
""" Loads the last-known state of the model. """
self.model.token = token
async def GET(self) -> UbuntuAdvantageInfo:
""" Handle a GET request coming from the client-side controller. """
return UbuntuAdvantageInfo(token=self.model.token)
async def POST(self, data: UbuntuAdvantageInfo):
""" Handle a POST request coming from the client-side controller and
then call .configured().
"""
log.debug("Received POST: %s", data)
self.model.token = data.token
await self.configured()

View File

@ -204,6 +204,7 @@ POSTINSTALL_MODEL_NAMES = ModelNames({
"packages",
"snaplist",
"ssh",
"ubuntu_advantage",
"userdata",
},
desktop={"timezone"})
@ -242,6 +243,7 @@ class SubiquityServer(Application):
"Zdev",
"Source",
"Network",
"UbuntuAdvantage",
"Proxy",
"Mirror",
"Filesystem",

View File

@ -215,6 +215,10 @@ class TestFlow(TestAPI):
}
await inst.post('/ssh', ssh)
await inst.post('/snaplist', [])
ua_params = {
"token": "a1b2c3d4e6f7g8h9I0K1",
}
await inst.post('/ubuntu_advantage', ua_params)
for state in 'RUNNING', 'POST_WAIT', 'POST_RUNNING', 'UU_RUNNING':
await inst.get('/meta/status', cur=state)

View File

@ -0,0 +1,99 @@
# 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/>.
""" Module that defines the view class for Ubuntu Advantage configuration. """
import logging
import re
from urwid import connect_signal
from subiquitycore.view import BaseView
from subiquitycore.ui.form import (
Form,
simple_field,
WantsToKnowFormField,
)
from subiquitycore.ui.interactive import StringEditor
log = logging.getLogger('subiquity.ui.views.ubuntu_advantage')
ua_help = _("If you want to enroll this system using your Ubuntu Advantage "
"subscription, enter your Ubuntu Advantage token here. "
"Otherwise, leave this blank.")
class UATokenEditor(StringEditor, WantsToKnowFormField):
""" Represent a text-box editor for the Ubuntu Advantage Token. """
def __init__(self):
""" Initialize the text-field editor for UA token. """
self.valid_char_pat = r"[a-zA-Z0-9]"
self.error_invalid_char = _("The only characters permitted in this "
"field are alphanumeric characters.")
super().__init__()
def valid_char(self, ch: str) -> bool:
""" Tells whether the character passed is within the range of allowed
characters
"""
if len(ch) == 1 and not re.match(self.valid_char_pat, ch):
self.bff.in_error = True
self.bff.show_extra(("info_error", self.error_invalid_char))
return False
return super().valid_char(ch)
class UbuntuAdvantageForm(Form):
"""
Represents a form requesting Ubuntu Advantage information
"""
cancel_label = _("Back")
UATokenField = simple_field(UATokenEditor)
token = UATokenField(_("Ubuntu Advantage token:"), help=ua_help)
class UbuntuAdvantageView(BaseView):
""" Represent the view of the Ubuntu Advantage configuration. """
title = _("Enable Ubuntu Advantage")
excerpt = _("Enter your Ubuntu Advantage token if you want to enroll "
"this system.")
def __init__(self, controller, token: str):
""" Initialize the view with the default value for the token. """
self.controller = controller
self.form = UbuntuAdvantageForm(initial={"token": token})
def on_cancel(_: UbuntuAdvantageForm):
self.cancel()
connect_signal(self.form, 'submit', self.done)
connect_signal(self.form, 'cancel', on_cancel)
super().__init__(self.form.as_screen(excerpt=_(self.excerpt)))
def done(self, form: UbuntuAdvantageForm) -> None:
""" Called when the user presses the Done button. """
log.debug("User input: %r", form.as_data())
self.controller.done(form.token.value)
def cancel(self) -> None:
""" Called when the user presses the Back button. """
self.controller.cancel()