Merge pull request #1581 from CarlosNihelton/ad-autoinstall-deeng-587
Active Directory - Partial autoinstall support #&1F979
This commit is contained in:
commit
65e100eed9
|
@ -461,6 +461,18 @@
|
|||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"active-directory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"admin-name": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain-name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"codecs": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -313,7 +313,7 @@ The default is to use the lvm layout.
|
|||
|
||||
#### action-based config
|
||||
|
||||
For full flexibility, the installer allows storage configuration to be done using a syntax which is a superset of that supported by curtin, described at https://curtin.readthedocs.io/en/latest/topics/storage.html.
|
||||
For full flexibility, the installer allows storage configuration to be done using a syntax which is a superset of that supported by curtin, described at https://curtin.readthedocs.io/en/latest/topics/storage.html.
|
||||
|
||||
If the "layout" feature is used to configure the disks, the "config" section will not be used.
|
||||
|
||||
|
@ -329,7 +329,7 @@ As well as putting the list of actions under the 'config' key, the [grub](https:
|
|||
- type: partition
|
||||
...
|
||||
|
||||
The extensions to the curtin syntax are around disk selection and partition/logical volume sizing.
|
||||
The extensions to the curtin syntax are around disk selection and partition/logical volume sizing.
|
||||
|
||||
##### Disk selection extensions
|
||||
|
||||
|
@ -413,6 +413,24 @@ The hostname for the system.
|
|||
|
||||
The password for the new user, crypted. This is required for use with sudo, even if SSH access is configured.
|
||||
|
||||
### active-directory
|
||||
|
||||
**type:** mapping, see below
|
||||
**default:** no default
|
||||
**can be interactive:** yes
|
||||
|
||||
Accepts data required to join the target system in an Active Directory domain.
|
||||
|
||||
A mapping that can contain keys, all of which take string values:
|
||||
|
||||
#### admin-name
|
||||
|
||||
A domain account name with privilege to perform the join operation. That account's password will be requested during runtime.
|
||||
|
||||
#### domain-name
|
||||
|
||||
The Active Directory domain to join.
|
||||
|
||||
### ubuntu-pro
|
||||
|
||||
**type:** mapping, see below
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
version: 1
|
||||
active-directory:
|
||||
admin-name: '$ubuntu'
|
||||
domain-name: 'ad.ubuntu.com'
|
||||
identity:
|
||||
realname: ''
|
||||
username: ubuntu
|
||||
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
|
||||
hostname: ubuntu
|
|
@ -25,7 +25,7 @@ from subiquitycore.models.network import (
|
|||
|
||||
from subiquity.common.api.defs import api, Payload, simple_endpoint
|
||||
from subiquity.common.types import (
|
||||
ADConnectionInfo,
|
||||
AdConnectionInfo,
|
||||
AdAdminNameValidation,
|
||||
AdDomainNameValidation,
|
||||
AdJoinResult,
|
||||
|
@ -415,9 +415,9 @@ class API:
|
|||
def GET() -> CasperMd5Results: ...
|
||||
|
||||
class active_directory:
|
||||
def GET() -> Optional[ADConnectionInfo]: ...
|
||||
def GET() -> Optional[AdConnectionInfo]: ...
|
||||
# POST expects input validated by the check methods below:
|
||||
def POST(data: Payload[ADConnectionInfo]) -> None: ...
|
||||
def POST(data: Payload[AdConnectionInfo]) -> None: ...
|
||||
|
||||
class has_support:
|
||||
""" Whether the live system supports Active Directory or not.
|
||||
|
|
|
@ -773,7 +773,7 @@ class MirrorSelectionFallback(enum.Enum):
|
|||
|
||||
|
||||
@attr.s(auto_attribs=True)
|
||||
class ADConnectionInfo:
|
||||
class AdConnectionInfo:
|
||||
admin_name: str = ""
|
||||
domain_name: str = ""
|
||||
password: str = attr.ib(repr=False, default="")
|
||||
|
|
|
@ -16,18 +16,18 @@
|
|||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from subiquity.common.types import ADConnectionInfo
|
||||
from subiquity.common.types import AdConnectionInfo
|
||||
|
||||
log = logging.getLogger('subiquity.models.ad')
|
||||
|
||||
|
||||
class ADModel:
|
||||
class AdModel:
|
||||
""" Models the Active Directory feature """
|
||||
def __init__(self) -> None:
|
||||
self.do_join = False
|
||||
self.conn_info: Optional[ADConnectionInfo] = None
|
||||
self.conn_info: Optional[AdConnectionInfo] = None
|
||||
|
||||
def set(self, info: ADConnectionInfo):
|
||||
def set(self, info: AdConnectionInfo):
|
||||
self.conn_info = info
|
||||
self.do_join = True
|
||||
|
||||
|
@ -39,7 +39,7 @@ class ADModel:
|
|||
self.conn_info.domain_name = domain
|
||||
|
||||
else:
|
||||
self.conn_info = ADConnectionInfo(domain_name=domain)
|
||||
self.conn_info = AdConnectionInfo(domain_name=domain)
|
||||
|
||||
async def target_packages(self):
|
||||
# NOTE Those packages must be present in the target system to allow
|
||||
|
|
|
@ -46,7 +46,7 @@ from subiquitycore.lsb_release import lsb_release
|
|||
from subiquity.common.resources import get_users_and_groups
|
||||
from subiquity.server.types import InstallerChannels
|
||||
|
||||
from .ad import ADModel
|
||||
from .ad import AdModel
|
||||
from .codecs import CodecsModel
|
||||
from .drivers import DriversModel
|
||||
from .filesystem import FilesystemModel
|
||||
|
@ -180,7 +180,7 @@ class SubiquityModel:
|
|||
self.target = root
|
||||
self.chroot_prefix = []
|
||||
|
||||
self.ad = ADModel()
|
||||
self.active_directory = AdModel()
|
||||
self.codecs = CodecsModel()
|
||||
self.debconf_selections = DebconfSelectionsModel()
|
||||
self.drivers = DriversModel()
|
||||
|
|
|
@ -21,7 +21,7 @@ from subprocess import CalledProcessError
|
|||
from subiquitycore.utils import arun_command, run_command
|
||||
from subiquity.server.curtin import run_curtin_command
|
||||
from subiquity.common.types import (
|
||||
ADConnectionInfo,
|
||||
AdConnectionInfo,
|
||||
AdJoinResult,
|
||||
)
|
||||
|
||||
|
@ -50,7 +50,7 @@ class AdJoinStrategy():
|
|||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def do_join(self, info: ADConnectionInfo, hostname: str, context) \
|
||||
async def do_join(self, info: AdConnectionInfo, hostname: str, context) \
|
||||
-> AdJoinResult:
|
||||
""" This method changes the hostname and perform a real AD join, thus
|
||||
should only run in a live session. """
|
||||
|
@ -109,7 +109,7 @@ class AdJoinStrategy():
|
|||
|
||||
|
||||
class StubStrategy(AdJoinStrategy):
|
||||
async def do_join(self, info: ADConnectionInfo, hostname: str, context) \
|
||||
async def do_join(self, info: AdConnectionInfo, hostname: str, context) \
|
||||
-> AdJoinResult:
|
||||
""" Enables testing without real join. The result depends on the
|
||||
domain name initial character, such that if it is:
|
||||
|
@ -135,7 +135,7 @@ class AdJoiner():
|
|||
else:
|
||||
self.strategy = AdJoinStrategy(app)
|
||||
|
||||
async def join_domain(self, info: ADConnectionInfo, hostname: str,
|
||||
async def join_domain(self, info: AdConnectionInfo, hostname: str,
|
||||
context) -> AdJoinResult:
|
||||
if hostname:
|
||||
self._result = await self.strategy.do_join(info, hostname, context)
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
# 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 .ad import ADController
|
||||
from .ad import AdController
|
||||
from .cmdlist import EarlyController, LateController, ErrorController
|
||||
from .codecs import CodecsController
|
||||
from .debconf import DebconfController
|
||||
|
@ -42,7 +42,7 @@ from .userdata import UserdataController
|
|||
from .zdev import ZdevController
|
||||
|
||||
__all__ = [
|
||||
'ADController',
|
||||
'AdController',
|
||||
'CodecsController',
|
||||
'DebconfController',
|
||||
'DriversController',
|
||||
|
|
|
@ -22,7 +22,7 @@ from subiquitycore.async_helpers import run_bg_task
|
|||
|
||||
from subiquity.common.apidef import API
|
||||
from subiquity.common.types import (
|
||||
ADConnectionInfo,
|
||||
AdConnectionInfo,
|
||||
AdAdminNameValidation,
|
||||
AdDomainNameValidation,
|
||||
AdJoinResult,
|
||||
|
@ -89,11 +89,46 @@ class StubDcPingStrategy(DcPingStrategy):
|
|||
return True
|
||||
|
||||
|
||||
class ADController(SubiquityController):
|
||||
class AdController(SubiquityController):
|
||||
""" Implements the server part of the Active Directory feature. """
|
||||
model_name = "ad"
|
||||
endpoint = API.active_directory
|
||||
# No auto install key and schema for now due password handling uncertainty.
|
||||
autoinstall_key = "active-directory"
|
||||
model_name = "active_directory"
|
||||
autoinstall_schema = {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'admin-name': {
|
||||
'type': 'string',
|
||||
},
|
||||
'domain-name': {
|
||||
'type': 'string',
|
||||
},
|
||||
},
|
||||
'additionalProperties': False,
|
||||
}
|
||||
autoinstall_default = {"admin-name": '', 'domain-name': ''}
|
||||
|
||||
def make_autoinstall(self):
|
||||
info = self.model.conn_info
|
||||
if info is None:
|
||||
return None
|
||||
|
||||
return {'admin-name': info.admin_name, 'domain-name': info.domain_name}
|
||||
|
||||
def load_autoinstall_data(self, data):
|
||||
if data is None:
|
||||
return
|
||||
if 'admin-name' in data and 'domain-name' in data:
|
||||
info = AdConnectionInfo(admin_name=data['admin-name'],
|
||||
domain_name=data['domain-name'])
|
||||
self.model.set(info)
|
||||
self.model.do_join = False
|
||||
|
||||
def interactive(self):
|
||||
# Since we don't accept the domain admin password in the autoinstall
|
||||
# file, this cannot be non-interactive.
|
||||
return True
|
||||
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
|
@ -113,11 +148,11 @@ class ADController(SubiquityController):
|
|||
if discovered_domain:
|
||||
self.model.set_domain(discovered_domain)
|
||||
|
||||
async def GET(self) -> Optional[ADConnectionInfo]:
|
||||
async def GET(self) -> Optional[AdConnectionInfo]:
|
||||
"""Returns the currently configured AD settings"""
|
||||
return self.model.conn_info
|
||||
|
||||
async def POST(self, data: ADConnectionInfo) -> None:
|
||||
async def POST(self, data: AdConnectionInfo) -> None:
|
||||
""" Configures this controller with the supplied info.
|
||||
Clients are required to validate the info before POST'ing """
|
||||
self.model.set(data)
|
||||
|
|
|
@ -407,13 +407,13 @@ class InstallController(SubiquityController):
|
|||
policy = self.model.updates.updates
|
||||
await self.run_unattended_upgrades(context=context, policy=policy)
|
||||
await self.restore_apt_config(context=context)
|
||||
if self.model.ad.do_join:
|
||||
if self.model.active_directory.do_join:
|
||||
hostname = self.model.identity.hostname
|
||||
if not hostname:
|
||||
with open(self.tpath('etc/hostname'), 'r') as f:
|
||||
hostname = f.read().strip()
|
||||
|
||||
await self.app.controllers.AD.join_domain(hostname, context)
|
||||
await self.app.controllers.Ad.join_domain(hostname, context)
|
||||
|
||||
@with_context(description="configuring cloud-init")
|
||||
async def configure_cloud_init(self, context):
|
||||
|
|
|
@ -19,16 +19,16 @@ from unittest import (
|
|||
)
|
||||
from subiquity.common.types import (
|
||||
AdAdminNameValidation,
|
||||
ADConnectionInfo,
|
||||
AdConnectionInfo,
|
||||
AdDomainNameValidation,
|
||||
AdJoinResult,
|
||||
AdPasswordValidation,
|
||||
)
|
||||
from subiquity.server.controllers.ad import (
|
||||
ADController,
|
||||
AdController,
|
||||
AdValidators,
|
||||
)
|
||||
from subiquity.models.ad import ADModel
|
||||
from subiquity.models.ad import AdModel
|
||||
from subiquitycore.tests.mocks import make_app
|
||||
|
||||
|
||||
|
@ -134,8 +134,8 @@ class TestADValidation(TestCase):
|
|||
class TestAdJoin(IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.app = make_app()
|
||||
self.controller = ADController(self.app)
|
||||
self.controller.model = ADModel()
|
||||
self.controller = AdController(self.app)
|
||||
self.controller.model = AdModel()
|
||||
|
||||
async def test_never_join(self):
|
||||
# Calling join_result_GET has no effect if the model is not set.
|
||||
|
@ -143,8 +143,8 @@ class TestAdJoin(IsolatedAsyncioTestCase):
|
|||
self.assertEqual(result, AdJoinResult.UNKNOWN)
|
||||
|
||||
async def test_join_Unknown(self):
|
||||
# Result remains UNKNOWN while ADController.join_domain is not called.
|
||||
self.controller.model.set(ADConnectionInfo(domain_name='ubuntu.com',
|
||||
# Result remains UNKNOWN while AdController.join_domain is not called.
|
||||
self.controller.model.set(AdConnectionInfo(domain_name='ubuntu.com',
|
||||
admin_name='Helper',
|
||||
password='1234'))
|
||||
|
||||
|
@ -153,7 +153,7 @@ class TestAdJoin(IsolatedAsyncioTestCase):
|
|||
|
||||
async def test_join_OK(self):
|
||||
# The equivalent of a successful POST
|
||||
self.controller.model.set(ADConnectionInfo(domain_name='ubuntu.com',
|
||||
self.controller.model.set(AdConnectionInfo(domain_name='ubuntu.com',
|
||||
admin_name='Helper',
|
||||
password='1234'))
|
||||
# Mimics a client requesting the join result. Blocking by default.
|
||||
|
@ -163,7 +163,7 @@ class TestAdJoin(IsolatedAsyncioTestCase):
|
|||
self.assertEqual(await result, AdJoinResult.OK)
|
||||
|
||||
async def test_join_Join_Error(self):
|
||||
self.controller.model.set(ADConnectionInfo(domain_name='jubuntu.com',
|
||||
self.controller.model.set(AdConnectionInfo(domain_name='jubuntu.com',
|
||||
admin_name='Helper',
|
||||
password='1234'))
|
||||
await self.controller.join_domain('this', 'AD Join')
|
||||
|
@ -171,7 +171,7 @@ class TestAdJoin(IsolatedAsyncioTestCase):
|
|||
self.assertEqual(result, AdJoinResult.JOIN_ERROR)
|
||||
|
||||
async def test_join_Pam_Error(self):
|
||||
self.controller.model.set(ADConnectionInfo(domain_name='pubuntu.com',
|
||||
self.controller.model.set(AdConnectionInfo(domain_name='pubuntu.com',
|
||||
admin_name='Helper',
|
||||
password='1234'))
|
||||
await self.controller.join_domain('this', 'AD Join')
|
||||
|
|
|
@ -215,7 +215,7 @@ POSTINSTALL_MODEL_NAMES = ModelNames({
|
|||
"ubuntu_pro",
|
||||
"userdata",
|
||||
},
|
||||
desktop={"timezone", "codecs", "ad"})
|
||||
desktop={"timezone", "codecs", "active_directory"})
|
||||
|
||||
|
||||
class SubiquityServer(Application):
|
||||
|
@ -259,7 +259,7 @@ class SubiquityServer(Application):
|
|||
"Identity",
|
||||
"SSH",
|
||||
"SnapList",
|
||||
"AD",
|
||||
"Ad",
|
||||
"Codecs",
|
||||
"Drivers",
|
||||
"TimeZone",
|
||||
|
|
|
@ -21,7 +21,9 @@ import contextlib
|
|||
from functools import wraps
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Dict, List, Optional
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
@ -150,6 +152,10 @@ class Server(Client):
|
|||
if extra_args is not None:
|
||||
cmd.extend(extra_args)
|
||||
self.proc = await astart_command(cmd, env=env)
|
||||
self._output_base = output_base
|
||||
|
||||
def output_base(self) -> Optional[str]:
|
||||
return self._output_base
|
||||
|
||||
async def close(self):
|
||||
try:
|
||||
|
@ -1715,3 +1721,73 @@ class TestActiveDirectory(TestAPI):
|
|||
with self.assertRaises(asyncio.exceptions.TimeoutError):
|
||||
join_result = instance.get(join_result_ep, wait=True)
|
||||
await asyncio.wait_for(join_result, timeout=1.5)
|
||||
|
||||
# Helper method
|
||||
@staticmethod
|
||||
async def target_packages() -> List[str]:
|
||||
""" Returns the list of packages the AD Model wants to install in the
|
||||
target system."""
|
||||
from subiquity.models.ad import AdModel
|
||||
model = AdModel()
|
||||
model.do_join = True
|
||||
return await model.target_packages()
|
||||
|
||||
async def packages_lookup(self, log_dir: str) -> Dict[str, bool]:
|
||||
""" Returns a dictionary mapping the additional packages expected
|
||||
to be installed in the target system and whether they were
|
||||
referred to or not in the server log. """
|
||||
expected_packages = await self.target_packages()
|
||||
packages_lookup = {p: False for p in expected_packages}
|
||||
log_path = os.path.join(log_dir, "subiquity-server-debug.log")
|
||||
find_start = \
|
||||
'finish: subiquity/Install/install/postinstall/install_{}:'
|
||||
log_status = ' SUCCESS: installing {}'
|
||||
|
||||
with open(log_path, encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
for line in lines:
|
||||
for pack in packages_lookup:
|
||||
find_line = find_start.format(pack) + \
|
||||
log_status.format(pack)
|
||||
pack_found = re.search(find_line, line) is not None
|
||||
if pack_found:
|
||||
packages_lookup[pack] = True
|
||||
|
||||
return packages_lookup
|
||||
|
||||
@timeout()
|
||||
async def test_ad_autoinstall(self):
|
||||
cfg = 'examples/simple.json'
|
||||
extra = [
|
||||
'--autoinstall', 'examples/autoinstall-ad.yaml',
|
||||
'--source-catalog', 'examples/mixed-sources.yaml',
|
||||
'--kernel-cmdline', 'autoinstall',
|
||||
]
|
||||
try:
|
||||
async with start_server(cfg, extra_args=extra) as inst:
|
||||
endpoint = '/active_directory'
|
||||
logdir = inst.output_base()
|
||||
self.assertIsNotNone(logdir)
|
||||
await inst.post('/meta/client_variant', variant='desktop')
|
||||
ad_info = await inst.get(endpoint)
|
||||
self.assertIsNotNone(ad_info['admin_name'])
|
||||
self.assertIsNotNone(ad_info['domain_name'])
|
||||
self.assertEqual('', ad_info['password'])
|
||||
ad_info['password'] = 'passw0rd'
|
||||
|
||||
# This should be enough to configure AD controller and cause it
|
||||
# to install packages into and try joining the target system.
|
||||
await inst.post(endpoint, ad_info)
|
||||
# Now this shouldn't hang or timeout
|
||||
join_result = await inst.get(endpoint + '/join_result',
|
||||
wait=True)
|
||||
self.assertEqual('OK', join_result)
|
||||
packages = await self.packages_lookup(logdir)
|
||||
for k, v in packages.items():
|
||||
print(f"Checking package {k}")
|
||||
self.assertTrue(v, f"package {k} not found in the target")
|
||||
|
||||
# By the time we reach here the server already exited and the context
|
||||
# manager will fail to POST /shutdown.
|
||||
except aiohttp.client_exceptions.ClientOSError:
|
||||
pass
|
||||
|
|
Loading…
Reference in New Issue