diff --git a/po/POTFILES.in b/po/POTFILES.in index f3bb5283..8928e048 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -3,6 +3,7 @@ subiquity/client/client.py subiquity/client/controller.py subiquity/client/controllers/__init__.py subiquity/client/controllers/keyboard.py +subiquity/client/controllers/network.py subiquity/client/controllers/progress.py subiquity/client/controllers/refresh.py subiquity/client/controllers/welcome.py @@ -35,7 +36,6 @@ subiquity/controllers/filesystem.py subiquity/controllers/identity.py subiquity/controllers/__init__.py subiquity/controllers/mirror.py -subiquity/controllers/network.py subiquity/controllers/proxy.py subiquity/controllers/reboot.py subiquity/controllers/snaplist.py @@ -122,6 +122,7 @@ subiquity/server/controllers/__init__.py subiquity/server/controllers/install.py subiquity/server/controllers/keyboard.py subiquity/server/controllers/locale.py +subiquity/server/controllers/network.py subiquity/server/controllers/package.py subiquity/server/controllers/refresh.py subiquity/server/controllers/reporting.py diff --git a/subiquity/client/client.py b/subiquity/client/client.py index 92057241..f0077e2a 100644 --- a/subiquity/client/client.py +++ b/subiquity/client/client.py @@ -94,6 +94,7 @@ class SubiquityClient(TuiApplication): "Refresh", "Keyboard", "Zdev", + "Network", "Progress", ] diff --git a/subiquity/client/controllers/__init__.py b/subiquity/client/controllers/__init__.py index 49a224cf..b735f127 100644 --- a/subiquity/client/controllers/__init__.py +++ b/subiquity/client/controllers/__init__.py @@ -14,6 +14,7 @@ # along with this program. If not, see . from .keyboard import KeyboardController +from .network import NetworkController from .progress import ProgressController from .refresh import RefreshController from .welcome import WelcomeController @@ -21,6 +22,7 @@ from .zdev import ZdevController __all__ = [ 'KeyboardController', + 'NetworkController', 'ProgressController', 'RefreshController', 'WelcomeController', diff --git a/subiquity/client/controllers/network.py b/subiquity/client/controllers/network.py new file mode 100644 index 00000000..b1ea396c --- /dev/null +++ b/subiquity/client/controllers/network.py @@ -0,0 +1,129 @@ +# Copyright 2020 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 . + +import logging +import os +import shutil +import tempfile +from typing import List, Optional + +from subiquitycore.controllers.network import NetworkAnswersMixin +from subiquitycore.models.network import ( + BondConfig, + NetDevInfo, + StaticConfig, + ) +from subiquitycore.ui.views.network import NetworkView + +from subiquity.client.controller import SubiquityTuiController +from subiquity.common.api.server import make_server_at_path +from subiquity.common.apidef import LinkAction, NetEventAPI + +log = logging.getLogger('subiquity.client.controllers.network') + + +class NetworkController(SubiquityTuiController, NetworkAnswersMixin): + + endpoint_name = 'network' + + def __init__(self, app): + super().__init__(app) + self.view = None + + async def update_link_POST(self, act: LinkAction, + info: NetDevInfo) -> None: + if self.view is None: + return + if act == LinkAction.NEW: + self.view.new_link(info) + if act == LinkAction.CHANGE: + self.view.update_link(info) + if act == LinkAction.DEL: + self.view.del_link(info) + + async def route_watch_POST(self, routes: List[int]) -> None: + if self.view is not None: + self.view.update_default_routes(routes) + + async def apply_starting_POST(self) -> None: + if self.view is not None: + self.view.show_apply_spinner() + + async def apply_stopping_POST(self) -> None: + if self.view is not None: + self.view.hide_apply_spinner() + + async def apply_error_POST(self, stage: str) -> None: + if self.view is not None: + self.view.show_network_error(stage) + + async def subscribe(self): + self.tdir = tempfile.mkdtemp() + self.sock_path = os.path.join(self.tdir, 'socket') + self.site = await make_server_at_path( + self.sock_path, NetEventAPI, self) + await self.endpoint.subscription.PUT(self.sock_path) + + async def unsubscribe(self): + await self.endpoint.subscription.DELETE(self.sock_path) + await self.site.stop() + shutil.rmtree(self.tdir) + + async def make_ui(self): + netdev_infos = await self.endpoint.GET() + self.view = NetworkView(self, netdev_infos) + await self.subscribe() + return self.view + + def end_ui(self): + if self.view is not None: + self.view = None + self.app.aio_loop.create_task(self.unsubscribe()) + + def cancel(self): + self.app.prev_screen() + + def done(self): + self.app.next_screen(self.endpoint.POST()) + + def set_static_config(self, dev_name: str, ip_version: int, + static_config: StaticConfig) -> None: + self.app.aio_loop.create_task( + self.endpoint.set_static_config.POST( + dev_name, ip_version, static_config)) + + def enable_dhcp(self, dev_name, ip_version: int) -> None: + self.app.aio_loop.create_task( + self.endpoint.enable_dhcp.POST(dev_name, ip_version)) + + def disable_network(self, dev_name: str, ip_version: int) -> None: + self.app.aio_loop.create_task( + self.endpoint.disable.POST(dev_name, ip_version)) + + def add_vlan(self, dev_name: str, vlan_id: int): + self.app.aio_loop.create_task( + self.endpoint.vlan.PUT(dev_name, vlan_id)) + + def delete_link(self, dev_name: str): + self.app.aio_loop.create_task(self.endpoint.delete.POST(dev_name)) + + def add_or_update_bond(self, existing_name: Optional[str], + new_name: str, new_info: BondConfig) -> None: + self.app.aio_loop.create_task( + self.endpoint.add_or_edit_bond.POST( + existing_name, new_name, new_info)) + + async def get_info_for_netdev(self, dev_name: str) -> str: + return await self.endpoint.info.GET(dev_name) diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index 6a4f6056..119a31c7 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -13,9 +13,16 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import enum from typing import List, Optional -from subiquity.common.api.defs import api, simple_endpoint +from subiquitycore.models.network import ( + BondConfig, + NetDevInfo, + StaticConfig, + ) + +from subiquity.common.api.defs import api, Payload, simple_endpoint from subiquity.common.types import ( ApplicationState, ApplicationStatus, @@ -80,6 +87,101 @@ class API: class chzdev: def POST(action: str, zdev: ZdevInfo) -> List[ZdevInfo]: ... + class network: + def GET() -> List[NetDevInfo]: ... + def POST() -> None: ... + + class global_addresses: + def GET() -> List[str]: + """Return the global IP addresses the system currently has.""" + + class subscription: + """Subscribe to networking updates. + + The socket must serve the NetEventAPI below. + """ + def PUT(socket_path: str) -> None: ... + def DELETE(socket_path: str) -> None: ... + + # These methods could definitely be more RESTish, like maybe a + # GET request to /network/interfaces/$name should return netplan + # config which could then be POSTed back the same path. But + # well, that's not implemented yet. + # + # (My idea is that the API definition would look something like + # + # class network: + # class interfaces: + # class dev_name: + # __subscript__ = True + # def GET() -> dict: ... + # def POST(config: Payload[dict]) -> None: ... + # + # The client would use subscripting to get a client for + # the nic, so something like + # + # dev_client = client.network[dev_name] + # config = await dev_client.GET() + # ... + # await dev_client.POST(config) + # + # The implementation would look like: + # + # class NetworkController: + # + # async def interfaces_devname_GET(dev_name: str) -> dict: ... + # async def interfaces_devname_POST(dev_name: str, config: dict) \ + # -> None: ... + # + # So methods on nics get an extra dev_name: str parameter) + + class set_static_config: + def POST(dev_name: str, ip_version: int, + static_config: Payload[StaticConfig]) -> None: ... + + class enable_dhcp: + def POST(dev_name: str, ip_version: int) -> None: ... + + class disable: + def POST(dev_name: str, ip_version: int) -> None: ... + + class vlan: + def PUT(dev_name: str, vlan_id: int) -> None: ... + + class add_or_edit_bond: + def POST(existing_name: Optional[str], new_name: str, + bond_config: Payload[BondConfig]) -> None: ... + + class delete: + def POST(dev_name: str) -> None: ... + + class info: + def GET(dev_name: str) -> str: ... + class install: class status: def GET(cur: Optional[InstallState] = None) -> InstallStatus: ... + + +class LinkAction(enum.Enum): + NEW = enum.auto() + CHANGE = enum.auto() + DEL = enum.auto() + + +@api +class NetEventAPI: + class update_link: + def POST(act: LinkAction, info: Payload[NetDevInfo]) -> None: ... + + class route_watch: + def POST(routes: List[int]) -> None: ... + + class apply_starting: + def POST() -> None: ... + + class apply_stopping: + def POST() -> None: ... + + class apply_error: + def POST(stage: str) -> None: ... diff --git a/subiquity/controllers/__init__.py b/subiquity/controllers/__init__.py index 06805c66..98ce9e2c 100644 --- a/subiquity/controllers/__init__.py +++ b/subiquity/controllers/__init__.py @@ -17,7 +17,6 @@ from ..controller import RepeatedController from .filesystem import FilesystemController from .identity import IdentityController from .mirror import MirrorController -from .network import NetworkController from .proxy import ProxyController from .reboot import RebootController from .snaplist import SnapListController @@ -27,7 +26,6 @@ __all__ = [ 'FilesystemController', 'IdentityController', 'MirrorController', - 'NetworkController', 'ProxyController', 'RebootController', 'RepeatedController', diff --git a/subiquity/server/controllers/__init__.py b/subiquity/server/controllers/__init__.py index a960c3c6..637e3ce0 100644 --- a/subiquity/server/controllers/__init__.py +++ b/subiquity/server/controllers/__init__.py @@ -18,6 +18,7 @@ from .debconf import DebconfController from .install import InstallController from .keyboard import KeyboardController from .locale import LocaleController +from .network import NetworkController from .package import PackageController from .refresh import RefreshController from .reporting import ReportingController @@ -32,6 +33,7 @@ __all__ = [ 'KeyboardController', 'LateController', 'LocaleController', + 'NetworkController', 'PackageController', 'RefreshController', 'ReportingController', diff --git a/subiquity/controllers/network.py b/subiquity/server/controllers/network.py similarity index 54% rename from subiquity/controllers/network.py rename to subiquity/server/controllers/network.py index cdc17eab..45c6a84e 100644 --- a/subiquity/controllers/network.py +++ b/subiquity/server/controllers/network.py @@ -15,16 +15,30 @@ import asyncio import logging +from typing import List, Optional + +import aiohttp from subiquitycore.async_helpers import schedule_task from subiquitycore.context import with_context -from subiquitycore.controllers.network import NetworkController +from subiquitycore.controllers.network import BaseNetworkController +from subiquitycore.models.network import ( + BondConfig, + NetDevInfo, + StaticConfig, + ) +from subiquity.common.api.client import make_client_for_conn +from subiquity.common.apidef import ( + API, + LinkAction, + NetEventAPI, + ) from subiquity.common.errorreport import ErrorReportKind -from subiquity.controller import SubiquityTuiController +from subiquity.server.controller import SubiquityController -log = logging.getLogger("subiquity.controllers.network") +log = logging.getLogger("subiquity.server.controllers.network") MATCH = { 'type': 'object', @@ -65,7 +79,9 @@ NETPLAN_SCHEMA = { } -class NetworkController(NetworkController, SubiquityTuiController): +class NetworkController(BaseNetworkController, SubiquityController): + + endpoint = API.network ai_data = None autoinstall_key = "network" @@ -85,6 +101,8 @@ class NetworkController(NetworkController, SubiquityTuiController): def __init__(self, app): super().__init__(app) app.note_file_for_apport("NetplanConfig", self.netplan_path) + self.view_shown = False + self.clients = {} def load_autoinstall_data(self, data): if data is not None: @@ -171,9 +189,119 @@ class NetworkController(NetworkController, SubiquityTuiController): if not self.interactive(): raise - def done(self): - self.configured() - super().done() - def make_autoinstall(self): return self.model.render_config()['network'] + + async def GET(self) -> List[NetDevInfo]: + if not self.view_shown: + self.apply_config(silent=True) + self.view_shown = True + return [ + netdev.netdev_info() for netdev in self.model.get_all_netdevs() + ] + + def configured(self): + self.model.has_network = bool( + self.network_event_receiver.default_routes) + super().configured() + + async def POST(self) -> None: + self.configured() + + async def global_addresses_GET(self) -> List[str]: + ips = [] + for dev in self.model.get_all_netdevs(): + ips.extend(map(str, dev.actual_global_ip_addresses)) + return ips + + async def subscription_PUT(self, socket_path: str) -> None: + log.debug('added subscription %s', socket_path) + conn = aiohttp.UnixConnector(socket_path) + client = make_client_for_conn(NetEventAPI, conn) + lock = asyncio.Lock() + self.clients[socket_path] = (client, conn, lock) + self.app.aio_loop.create_task( + self._call_client( + client, conn, lock, "route_watch", + self.network_event_receiver.default_routes)) + + async def subscription_DELETE(self, socket_path: str) -> None: + if socket_path not in self.clients: + return + log.debug('removed subscription %s', socket_path) + client, conn, lock = self.clients.pop(socket_path) + async with lock: + await conn.close() + + async def _call_client(self, client, conn, lock, meth_name, *args): + async with lock: + log.debug("_call_client %s %s", meth_name, conn.path) + if conn.closed: + log.debug('closed') + return + await getattr(client, meth_name).POST(*args) + + def _call_clients(self, meth_name, *args): + for client, conn, lock in self.clients.values(): + log.debug('creating _call_client task %s %s', conn.path, meth_name) + self.app.aio_loop.create_task( + self._call_client(client, conn, lock, meth_name, *args)) + + def apply_starting(self): + super().apply_starting() + self._call_clients("apply_starting") + + def apply_stopping(self): + super().apply_stopping() + self._call_clients("apply_stopping") + + def apply_error(self, stage): + super().apply_error() + self._call_clients("apply_error", stage) + + def update_default_routes(self, routes): + super().update_default_routes(routes) + self._call_clients("route_watch", routes) + + def _send_update(self, act, dev): + with self.context.child( + "_send_update", "{} {}".format(act.name, dev.name)): + log.debug("dev_info {} {}".format(dev.name, dev.config)) + dev_info = dev.netdev_info() + self._call_clients("update_link", act, dev_info) + + def new_link(self, dev): + super().new_link(dev) + self._send_update(LinkAction.NEW, dev) + + def update_link(self, dev): + super().update_link(dev) + self._send_update(LinkAction.CHANGE, dev) + + def del_link(self, dev): + super().del_link(dev) + self._send_update(LinkAction.DEL, dev) + + async def set_static_config_POST(self, dev_name: str, ip_version: int, + static_config: StaticConfig) -> None: + self.set_static_config(dev_name, ip_version, static_config) + + async def enable_dhcp_POST(self, dev_name: str, ip_version: int) -> None: + self.enable_dhcp(dev_name, ip_version) + + async def disable_POST(self, dev_name: str, ip_version: int) -> None: + self.disable_network(dev_name, ip_version) + + async def vlan_PUT(self, dev_name: str, vlan_id: int) -> None: + self.add_vlan(dev_name, vlan_id) + + async def add_or_edit_bond_POST(self, existing_name: Optional[str], + new_name: str, + bond_config: BondConfig) -> None: + self.add_or_update_bond(existing_name, new_name, bond_config) + + async def delete_POST(self, dev_name: str) -> None: + self.delete_link(dev_name) + + async def info_GET(self, dev_name: str) -> str: + return await self.get_info_for_netdev(dev_name) diff --git a/subiquity/server/server.py b/subiquity/server/server.py index c277a424..179ccc32 100644 --- a/subiquity/server/server.py +++ b/subiquity/server/server.py @@ -122,6 +122,7 @@ class SubiquityServer(Application): "Refresh", "Keyboard", "Zdev", + "Network", "Install", "Late", ]