Merge pull request #1581 from CarlosNihelton/ad-autoinstall-deeng-587

Active Directory - Partial autoinstall support #&1F979
This commit is contained in:
Carlos Nihelton 2023-03-07 12:40:46 -03:00 committed by GitHub
commit 65e100eed9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 188 additions and 38 deletions

View File

@ -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": {

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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="")

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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',

View File

@ -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)

View File

@ -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):

View File

@ -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')

View File

@ -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",

View File

@ -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