Fix network settings loading from config file (#1921)

* Fix network config error and simplify code

* Update schema and exmaple

---------

Co-authored-by: Daniel Girtler <girtler.daniel@gmail.com>
This commit is contained in:
Daniel Girtler 2023-07-17 17:27:21 +10:00 committed by GitHub
parent c67bb0b549
commit 2f273868d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 187 additions and 234 deletions

View File

@ -225,10 +225,9 @@ def load_config():
if arguments.get('servers', None) is not None:
storage['_selected_servers'] = arguments.get('servers', None)
if arguments.get('nic', None) is not None:
handler = models.NetworkConfigurationHandler()
handler.parse_arguments(arguments.get('nic'))
arguments['nic'] = handler.configuration
if arguments.get('network_config', None) is not None:
config = NetworkConfiguration.parse_arg(arguments.get('network_config'))
arguments['network_config'] = config
if arguments.get('!users', None) is not None or arguments.get('!superusers', None) is not None:
users = arguments.get('!users', None)

View File

@ -7,7 +7,7 @@ from .general import secret
from .locale.locale_menu import LocaleConfiguration, LocaleMenu
from .menu import Selector, AbstractMenu
from .mirrors import MirrorConfiguration, MirrorMenu
from .models import NetworkConfiguration
from .models import NetworkConfiguration, NicType
from .models.bootloader import Bootloader
from .models.users import User
from .output import FormattedOutput
@ -142,7 +142,7 @@ class GlobalMenu(AbstractMenu):
lambda preset: select_additional_repositories(preset),
display_func=lambda x: ', '.join(x) if x else None,
default=[])
self._menu_options['nic'] = \
self._menu_options['network_config'] = \
Selector(
_('Network configuration'),
lambda preset: ask_to_configure_network(preset),
@ -221,14 +221,11 @@ class GlobalMenu(AbstractMenu):
return _('Install ({} config(s) missing)').format(missing)
return _('Install')
def _display_network_conf(self, cur_value: Union[NetworkConfiguration, List[NetworkConfiguration]]) -> str:
if not cur_value:
return _('Not configured, unavailable unless setup manually')
else:
if isinstance(cur_value, list):
return str(_('Configured {} interfaces')).format(len(cur_value))
else:
return str(cur_value)
def _display_network_conf(self, config: Optional[NetworkConfiguration]) -> str:
if not config:
return str(_('Not configured, unavailable unless setup manually'))
return config.type.display_msg()
def _disk_encryption(self, preset: Optional[disk.DiskEncryption]) -> Optional[disk.DiskEncryption]:
mods: Optional[List[disk.DeviceModification]] = self._menu_options['disk_config'].current_selection
@ -257,11 +254,11 @@ class GlobalMenu(AbstractMenu):
return None
def _prev_network_config(self) -> Optional[str]:
selector = self._menu_options['nic']
if selector.has_selection():
ifaces = selector.current_selection
if isinstance(ifaces, list):
return FormattedOutput.as_table(ifaces)
selector: Optional[NetworkConfiguration] = self._menu_options['network_config'].current_selection
if selector:
if selector.type == NicType.MANUAL:
output = FormattedOutput.as_table(selector.nics)
return output
return None
def _prev_additional_pkgs(self):

View File

@ -19,7 +19,7 @@ from .locale import verify_keyboard_layout, verify_x11_keyboard_layout
from .luks import Luks2
from .mirrors import use_mirrors, MirrorConfiguration, add_custom_mirrors
from .models.bootloader import Bootloader
from .models.network_configuration import NetworkConfiguration
from .models.network_configuration import Nic
from .models.users import User
from .output import log, error, info, warn, debug
from . import pacman
@ -458,20 +458,20 @@ class Installer:
def drop_to_shell(self) -> None:
subprocess.check_call(f"/usr/bin/arch-chroot {self.target}", shell=True)
def configure_nic(self, network_config: NetworkConfiguration) -> None:
conf = network_config.as_systemd_config()
def configure_nic(self, nic: Nic):
conf = nic.as_systemd_config()
for plugin in plugins.values():
if hasattr(plugin, 'on_configure_nic'):
conf = plugin.on_configure_nic(
network_config.iface,
network_config.dhcp,
network_config.ip,
network_config.gateway,
network_config.dns
nic.iface,
nic.dhcp,
nic.ip,
nic.gateway,
nic.dns
) or conf
with open(f"{self.target}/etc/systemd/network/10-{network_config.iface}.network", "a") as netconf:
with open(f"{self.target}/etc/systemd/network/10-{nic.iface}.network", "a") as netconf:
netconf.write(str(conf))
def copy_iso_network_config(self, enable_services :bool = False) -> bool:

View File

@ -1,5 +1,5 @@
from .manage_users_conf import UserList, ask_for_additional_users
from .network_conf import ManualNetworkConfig, ask_to_configure_network
from .network_menu import ManualNetworkConfig, ask_to_configure_network
from .utils import get_password
from .disk_conf import (

View File

@ -1,10 +1,10 @@
from __future__ import annotations
import ipaddress
from typing import Any, Optional, TYPE_CHECKING, List, Union, Dict
from typing import Any, Optional, TYPE_CHECKING, List, Dict
from ..menu import MenuSelectionType, TextInput
from ..models.network_configuration import NetworkConfiguration, NicType
from ..models.network_configuration import NetworkConfiguration, NicType, Nic
from ..networking import list_interfaces
from ..output import FormattedOutput, warn
@ -19,23 +19,22 @@ class ManualNetworkConfig(ListManager):
subclass of ListManager for the managing of network configurations
"""
def __init__(self, prompt: str, ifaces: List[NetworkConfiguration]):
def __init__(self, prompt: str, preset: List[Nic]):
self._actions = [
str(_('Add interface')),
str(_('Edit interface')),
str(_('Delete interface'))
]
super().__init__(prompt, preset, [self._actions[0]], self._actions[1:])
super().__init__(prompt, ifaces, [self._actions[0]], self._actions[1:])
def reformat(self, data: List[NetworkConfiguration]) -> Dict[str, Optional[NetworkConfiguration]]:
def reformat(self, data: List[Nic]) -> Dict[str, Optional[Nic]]:
table = FormattedOutput.as_table(data)
rows = table.split('\n')
# these are the header rows of the table and do not map to any User obviously
# we're adding 2 spaces as prefix because the menu selector '> ' will be put before
# the selectable rows so the header has to be aligned
display_data: Dict[str, Optional[NetworkConfiguration]] = {f' {rows[0]}': None, f' {rows[1]}': None}
display_data: Dict[str, Optional[Nic]] = {f' {rows[0]}': None, f' {rows[1]}': None}
for row, iface in zip(rows[2:], data):
row = row.replace('|', '\\|')
@ -43,16 +42,16 @@ class ManualNetworkConfig(ListManager):
return display_data
def selected_action_display(self, iface: NetworkConfiguration) -> str:
return iface.iface if iface.iface else ''
def selected_action_display(self, nic: Nic) -> str:
return nic.iface if nic.iface else ''
def handle_action(self, action: str, entry: Optional[NetworkConfiguration], data: List[NetworkConfiguration]):
def handle_action(self, action: str, entry: Optional[Nic], data: List[Nic]):
if action == self._actions[0]: # add
iface_name = self._select_iface(data)
if iface_name:
iface = NetworkConfiguration(NicType.MANUAL, iface=iface_name)
iface = self._edit_iface(iface)
data += [iface]
iface = self._select_iface(data)
if iface:
nic = Nic(iface=iface)
nic = self._edit_iface(nic)
data += [nic]
elif entry:
if action == self._actions[1]: # edit interface
data = [d for d in data if d.iface != entry.iface]
@ -62,7 +61,7 @@ class ManualNetworkConfig(ListManager):
return data
def _select_iface(self, data: List[NetworkConfiguration]) -> Optional[Any]:
def _select_iface(self, data: List[Nic]) -> Optional[str]:
all_ifaces = list_interfaces().values()
existing_ifaces = [d.iface for d in data]
available = set(all_ifaces) - set(existing_ifaces)
@ -71,10 +70,10 @@ class ManualNetworkConfig(ListManager):
if choice.type_ == MenuSelectionType.Skip:
return None
return choice.value
return choice.single_value
def _edit_iface(self, edit_iface: NetworkConfiguration):
iface_name = edit_iface.iface
def _edit_iface(self, edit_nic: Nic) -> Nic:
iface_name = edit_nic.iface
modes = ['DHCP (auto detect)', 'IP (static)']
default_mode = 'DHCP (auto detect)'
@ -84,7 +83,7 @@ class ManualNetworkConfig(ListManager):
if mode.value == 'IP (static)':
while 1:
prompt = _('Enter the IP and subnet for {} (example: 192.168.0.5/24): ').format(iface_name)
ip = TextInput(prompt, edit_iface.ip).run().strip()
ip = TextInput(prompt, edit_nic.ip).run().strip()
# Implemented new check for correct IP/subnet input
try:
ipaddress.ip_interface(ip)
@ -98,7 +97,7 @@ class ManualNetworkConfig(ListManager):
while 1:
gateway = TextInput(
_('Enter your gateway (router) IP address or leave blank for none: '),
edit_iface.gateway
edit_nic.gateway
).run().strip()
try:
if len(gateway) > 0:
@ -107,8 +106,8 @@ class ManualNetworkConfig(ListManager):
except ValueError:
warn("You need to enter a valid gateway (router) IP address")
if edit_iface.dns:
display_dns = ' '.join(edit_iface.dns)
if edit_nic.dns:
display_dns = ' '.join(edit_nic.dns)
else:
display_dns = None
dns_input = TextInput(_('Enter your DNS servers (space separated, blank for none): '), display_dns).run().strip()
@ -117,39 +116,24 @@ class ManualNetworkConfig(ListManager):
if len(dns_input):
dns = dns_input.split(' ')
return NetworkConfiguration(NicType.MANUAL, iface=iface_name, ip=ip, gateway=gateway, dns=dns, dhcp=False)
return Nic(iface=iface_name, ip=ip, gateway=gateway, dns=dns, dhcp=False)
else:
# this will contain network iface names
return NetworkConfiguration(NicType.MANUAL, iface=iface_name)
return Nic(iface=iface_name)
def ask_to_configure_network(
preset: Union[NetworkConfiguration, List[NetworkConfiguration]]
) -> Optional[NetworkConfiguration | List[NetworkConfiguration]]:
def ask_to_configure_network(preset: Optional[NetworkConfiguration]) -> Optional[NetworkConfiguration]:
"""
Configure the network on the newly installed system
"""
network_options = {
'none': str(_('No network configuration')),
'iso_config': str(_('Copy ISO network configuration to installation')),
'network_manager': str(_('Use NetworkManager (necessary to configure internet graphically in GNOME and KDE)')),
'manual': str(_('Manual configuration'))
}
# for this routine it's easier to set the cursor position rather than a preset value
cursor_idx = None
if preset and not isinstance(preset, list):
if preset.type == 'iso_config':
cursor_idx = 0
elif preset.type == 'network_manager':
cursor_idx = 1
options = {n.display_msg(): n for n in NicType}
preset_val = preset.type.display_msg() if preset else None
warning = str(_('Are you sure you want to reset this setting?'))
choice = Menu(
_('Select one network interface to configure'),
list(network_options.values()),
cursor_index=cursor_idx,
list(options.keys()),
preset_values=preset_val,
sort=False,
allow_reset=True,
allow_reset_warning_msg=warning
@ -158,15 +142,18 @@ def ask_to_configure_network(
match choice.type_:
case MenuSelectionType.Skip: return preset
case MenuSelectionType.Reset: return None
case MenuSelectionType.Selection:
nic_type = options[choice.single_value]
if choice.value == network_options['none']:
return None
elif choice.value == network_options['iso_config']:
return NetworkConfiguration(NicType.ISO)
elif choice.value == network_options['network_manager']:
return NetworkConfiguration(NicType.NM)
elif choice.value == network_options['manual']:
preset_ifaces = preset if isinstance(preset, list) else []
return ManualNetworkConfig('Configure interfaces', preset_ifaces).run()
match nic_type:
case NicType.ISO:
return NetworkConfiguration(NicType.ISO)
case NicType.NM:
return NetworkConfiguration(NicType.NM)
case NicType.MANUAL:
preset_nics = preset.nics if preset else []
nics = ManualNetworkConfig('Configure interfaces', preset_nics).run()
if nics:
return NetworkConfiguration(NicType.MANUAL, nics)
return preset

View File

@ -1,4 +1,8 @@
from .network_configuration import NetworkConfiguration, NicType, NetworkConfigurationHandler
from .network_configuration import (
NetworkConfiguration,
NicType,
Nic
)
from .bootloader import Bootloader
from .gen import VersionDef, PackageSearchResult, PackageSearch, LocalPackage
from .users import PasswordStrength, User

View File

@ -2,56 +2,64 @@ from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional, Dict, Union, Any, TYPE_CHECKING, Tuple
from typing import List, Optional, Dict, Any, TYPE_CHECKING, Tuple
from ..output import debug
from ..profile import ProfileConfiguration
if TYPE_CHECKING:
_: Any
class NicType(str, Enum):
class NicType(Enum):
ISO = "iso"
NM = "nm"
MANUAL = "manual"
def display_msg(self) -> str:
match self:
case NicType.ISO:
return str(_('Copy ISO network configuration to installation'))
case NicType.NM:
return str(_('Use NetworkManager (necessary to configure internet graphically in GNOME and KDE)'))
case NicType.MANUAL:
return str(_('Manual configuration'))
@dataclass
class NetworkConfiguration:
type: NicType
class Nic:
iface: Optional[str] = None
ip: Optional[str] = None
dhcp: bool = True
gateway: Optional[str] = None
dns: List[str] = field(default_factory=list)
def __str__(self):
if self.is_iso():
return "Copy ISO configuration"
elif self.is_network_manager():
return "Use NetworkManager"
elif self.is_manual():
if self.dhcp:
return f'iface={self.iface}, dhcp=auto'
else:
return f'iface={self.iface}, ip={self.ip}, dhcp=staticIp, gateway={self.gateway}, dns={self.dns}'
else:
return 'Unknown type'
def table_data(self) -> Dict[str, Any]:
exclude_fields = ['type']
data = {}
for k, v in self.__dict__.items():
if k not in exclude_fields:
if isinstance(v, list) and len(v) == 0:
v = ''
elif v is None:
v = ''
return {
'iface': self.iface if self.iface else '',
'ip': self.ip if self.ip else '',
'dhcp': self.dhcp,
'gateway': self.gateway if self.gateway else '',
'dns': self.dns
}
data[k] = v
def __dump__(self) -> Dict[str, Any]:
return {
'iface': self.iface,
'ip': self.ip,
'dhcp': self.dhcp,
'gateway': self.gateway,
'dns': self.dns
}
return data
@staticmethod
def parse_arg(arg: Dict[str, Any]) -> Nic:
return Nic(
iface=arg.get('iface', None),
ip=arg.get('ip', None),
dhcp=arg.get('dhcp', True),
gateway=arg.get('gateway', None),
dns=arg.get('dns', []),
)
def as_systemd_config(self) -> str:
match: List[Tuple[str, str]] = []
@ -80,107 +88,57 @@ class NetworkConfiguration:
return config_str
def json(self) -> Dict:
# for json serialization when calling json.dumps(...) on this class
return self.__dict__
def is_iso(self) -> bool:
return self.type == NicType.ISO
@dataclass
class NetworkConfiguration:
type: NicType
nics: List[Nic] = field(default_factory=list)
def is_network_manager(self) -> bool:
return self.type == NicType.NM
def __dump__(self) -> Dict[str, Any]:
config: Dict[str, Any] = {'type': self.type.value}
if self.nics:
config['nics'] = [n.__dump__() for n in self.nics]
def is_manual(self) -> bool:
return self.type == NicType.MANUAL
return config
@staticmethod
def parse_arg(config: Dict[str, Any]) -> Optional[NetworkConfiguration]:
nic_type = config.get('type', None)
if not nic_type:
return None
class NetworkConfigurationHandler:
def __init__(self, config: Union[None, NetworkConfiguration, List[NetworkConfiguration]] = None):
self._configuration = config
match NicType(nic_type):
case NicType.ISO:
return NetworkConfiguration(NicType.ISO)
case NicType.NM:
return NetworkConfiguration(NicType.NM)
case NicType.MANUAL:
nics_arg = config.get('nics', [])
if nics_arg:
nics = [Nic.parse_arg(n) for n in nics_arg]
return NetworkConfiguration(NicType.MANUAL, nics)
@property
def configuration(self):
return self._configuration
return None
def config_installer(
def install_network_config(
self,
installation: Any,
profile_config: Optional[ProfileConfiguration] = None
):
if self._configuration is None:
return
if isinstance(self._configuration, list):
for config in self._configuration:
installation.configure_nic(config)
installation.enable_service('systemd-networkd')
installation.enable_service('systemd-resolved')
else:
# If user selected to copy the current ISO network configuration
# Perform a copy of the config
if self._configuration.is_iso():
match self.type:
case NicType.ISO:
installation.copy_iso_network_config(
enable_services=True # Sources the ISO network configuration to the install medium.
enable_services=True # Sources the ISO network configuration to the install medium.
)
elif self._configuration.is_network_manager():
case NicType.NM:
installation.add_additional_packages(["networkmanager"])
if profile_config and profile_config.profile:
if profile_config.profile.is_desktop_type_profile():
installation.add_additional_packages(["network-manager-applet"])
installation.enable_service('NetworkManager.service')
case NicType.MANUAL:
for nic in self.nics:
installation.configure_nic(nic)
def _parse_manual_config(self, configs: List[Dict[str, Any]]) -> Optional[List[NetworkConfiguration]]:
configurations = []
for manual_config in configs:
iface = manual_config.get('iface', None)
if iface is None:
raise ValueError('No iface specified for manual configuration')
if manual_config.get('dhcp', False) or not any([manual_config.get(v, '') for v in ['ip', 'gateway', 'dns']]):
configurations.append(
NetworkConfiguration(NicType.MANUAL, iface=iface)
)
else:
ip = manual_config.get('ip', '')
if not ip:
raise ValueError('Manual nic configuration with no auto DHCP requires an IP address')
dns = manual_config.get('dns', [])
if not isinstance(dns, list):
dns = [dns]
configurations.append(
NetworkConfiguration(
NicType.MANUAL,
iface=iface,
ip=ip,
gateway=manual_config.get('gateway', ''),
dns=dns,
dhcp=False
)
)
return configurations
def _parse_nic_type(self, nic_type: str) -> NicType:
try:
return NicType(nic_type)
except ValueError:
options = [e.value for e in NicType]
raise ValueError(f'Unknown nic type: {nic_type}. Possible values are {options}')
def parse_arguments(self, config: Any):
if isinstance(config, list): # new data format
self._configuration = self._parse_manual_config(config)
elif nic_type := config.get('type', None): # new data format
type_ = self._parse_nic_type(nic_type)
if type_ != NicType.MANUAL:
self._configuration = NetworkConfiguration(type_)
else: # manual configuration settings
self._configuration = self._parse_manual_config([config])
else:
debug(f'Unable to parse network configuration: {config}')
installation.enable_service('systemd-networkd')
installation.enable_service('systemd-resolved')

View File

@ -1,6 +1,6 @@
import os
from pathlib import Path
from typing import Any, TYPE_CHECKING
from typing import Any, TYPE_CHECKING, Optional
import archinstall
from archinstall import info, debug
@ -14,7 +14,7 @@ from archinstall.lib.installer import Installer
from archinstall.lib.menu import Menu
from archinstall.lib.mirrors import use_mirrors, add_custom_mirrors
from archinstall.lib.models.bootloader import Bootloader
from archinstall.lib.models.network_configuration import NetworkConfigurationHandler
from archinstall.lib.models.network_configuration import NetworkConfiguration
from archinstall.lib.networking import check_mirror_reachable
from archinstall.lib.profile.profiles_handler import profile_handler
@ -82,7 +82,7 @@ def ask_user_questions():
global_menu.enable('parallel downloads')
# Ask or Call the helper function that asks the user to optionally configure a network.
global_menu.enable('nic')
global_menu.enable('network_config')
global_menu.enable('timezone')
@ -158,11 +158,10 @@ def perform_installation(mountpoint: Path):
# If user selected to copy the current ISO network configuration
# Perform a copy of the config
network_config = archinstall.arguments.get('nic', None)
network_config: Optional[NetworkConfiguration] = archinstall.arguments.get('network_config', None)
if network_config:
handler = NetworkConfigurationHandler(network_config)
handler.config_installer(
network_config.install_network_config(
installation,
archinstall.arguments.get('profile_config', None)
)

View File

@ -95,7 +95,7 @@ class SwissMainMenu(GlobalMenu):
options_list = [
'mirror_config', 'disk_config',
'disk_encryption', 'swap', 'bootloader', 'hostname', '!root-password',
'!users', 'profile_config', 'audio', 'kernels', 'packages', 'additional-repositories', 'nic',
'!users', 'profile_config', 'audio', 'kernels', 'packages', 'additional-repositories', 'network_config',
'timezone', 'ntp'
]
@ -110,7 +110,7 @@ class SwissMainMenu(GlobalMenu):
options_list = [
'mirror_config','bootloader', 'hostname',
'!root-password', '!users', 'profile_config', 'audio', 'kernels',
'packages', 'additional-repositories', 'nic', 'timezone', 'ntp'
'packages', 'additional-repositories', 'network_config', 'timezone', 'ntp'
]
mandatory_list = ['hostname']
@ -222,11 +222,10 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode):
# If user selected to copy the current ISO network configuration
# Perform a copy of the config
network_config = archinstall.arguments.get('nic', None)
network_config = archinstall.arguments.get('network_config', None)
if network_config:
handler = models.NetworkConfigurationHandler(network_config)
handler.config_installer(
network_config.install_network_config(
installation,
archinstall.arguments.get('profile_config', None)
)

View File

@ -29,7 +29,7 @@ To start the installer, run the following in the latest Arch Linux ISO:
.. code-block:: sh
archinstall --script guided
| The ``--script guided`` argument is optional as it's the default behavior.
| But this will use our most guided installation and if you skip all the option steps it will install a minimal Arch Linux experience.
@ -49,7 +49,7 @@ There are three different configuration files, all of which are optional.
.. note::
You can always get the latest options with ``archinstall --dry-run``, but edit the following json according to your needs.
Save the configuration as a ``.json`` file. Archinstall can source it via a local or remote path (URL)
.. code-block:: json
{
@ -72,8 +72,8 @@ There are three different configuration files, all of which are optional.
],
"keyboard-language": "us",
"mirror-region": "Worldwide",
"nic": {
"type": "NM"
"network_config": {
"type": "nm"
},
"ntp": true,
"packages": ["docker", "git", "wget", "zsh"],

View File

@ -99,13 +99,19 @@
"http://archlinux.mirror.digitalpacific.com.au/$repo/os/$arch": true,
}
},
"nic": {
"dhcp": true,
"dns": null,
"gateway": null,
"iface": null,
"ip": null,
"type": "nm"
"network_config": {
"nics": [
{
"dhcp": false,
"dns": [
"3.3.3.3"
],
"gateway": "2.2.2.2",
"iface": "enp0s31f6",
"ip": "1.1.1.1"
}
],
"type": "manual"
},
"no_pkg_lookups": false,
"ntp": true,

View File

@ -12,8 +12,8 @@
],
"keyboard-layout": "us",
"mirror-region": "Worldwide",
"nic": {
"type": "NM"
"network_config": {
"type": "nm"
},
"ntp": true,
"packages": ["docker", "git", "wget", "zsh"],

View File

@ -61,7 +61,7 @@ def ask_user_questions():
global_menu.enable('parallel downloads')
# Ask or Call the helper function that asks the user to optionally configure a network.
global_menu.enable('nic')
global_menu.enable('network_config')
global_menu.enable('timezone')
@ -137,11 +137,10 @@ def perform_installation(mountpoint: Path):
# If user selected to copy the current ISO network configuration
# Perform a copy of the config
network_config = archinstall.arguments.get('nic', None)
network_config = archinstall.arguments.get('network_config', None)
if network_config:
handler = models.NetworkConfigurationHandler(network_config)
handler.config_installer(
network_config.install_network_config(
installation,
archinstall.arguments.get('profile_config', None)
)

View File

@ -69,21 +69,26 @@
"description": "By default, it will autodetect the best region. Enter a region or a dictionary of regions and mirrors to use specific ones",
"type": "object"
},
"nic": {
"network_config": {
"description": "Choose between NetworkManager, manual configuration, use systemd-networkd from the ISO or no configuration",
"type": "object",
"properties": {
"type": "string",
"iface": "string",
"dhcp": "boolean",
"ip": "string",
"gateway": "string",
"dns": {
"description": "List of DNS servers",
"nics": [
"type": "array",
"items": {
"type": "string"
}
"iface": "string",
"dhcp": "boolean",
"ip": "string",
"gateway": "string",
"dns": {
"description": "List of DNS servers",
"type": "array",
"items": {
"type": "string"
}
}
]
}
}
},