diff --git a/subiquity/client/client.py b/subiquity/client/client.py
index f177a986..a3f5a0a6 100644
--- a/subiquity/client/client.py
+++ b/subiquity/client/client.py
@@ -461,6 +461,8 @@ class SubiquityClient(TuiApplication):
"marking additional endpoints as configured: %s",
needed)
await self.client.meta.mark_configured.POST(list(needed))
+ # TODO: remove this when TUI gets an Active Directory screen:
+ await self.client.meta.mark_configured.POST(['active_directory'])
await self.client.meta.confirm.POST(self.our_tty)
def add_global_overlay(self, overlay):
diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py
index e75da3c4..f0f0d0ab 100644
--- a/subiquity/common/apidef.py
+++ b/subiquity/common/apidef.py
@@ -28,6 +28,7 @@ from subiquity.common.types import (
ADConnectionInfo,
AdAdminNameValidation,
AdDomainNameValidation,
+ AdJoinResult,
AdPasswordValidation,
AddPartitionV2,
AnyStep,
@@ -434,6 +435,9 @@ class API:
class check_password:
def POST(password: Payload[str]) -> AdPasswordValidation: ...
+ class join_result:
+ def GET(wait: bool = True) -> AdJoinResult: ...
+
class LinkAction(enum.Enum):
NEW = enum.auto()
diff --git a/subiquity/common/types.py b/subiquity/common/types.py
index 732408b1..4875f97b 100644
--- a/subiquity/common/types.py
+++ b/subiquity/common/types.py
@@ -801,3 +801,11 @@ class AdDomainNameValidation(enum.Enum):
class AdPasswordValidation(enum.Enum):
OK = 'OK'
EMPTY = 'Empty'
+
+
+class AdJoinResult(enum.Enum):
+ OK = 'OK'
+ JOIN_ERROR = 'Failed to join'
+ EMPTY_HOSTNAME = 'Target hostname cannot be empty'
+ PAM_ERROR = 'Failed to update pam-auth'
+ UNKNOWN = "Didn't attempt to join yet"
diff --git a/subiquity/models/ad.py b/subiquity/models/ad.py
index 9cf8c24b..1816b8a4 100644
--- a/subiquity/models/ad.py
+++ b/subiquity/models/ad.py
@@ -27,6 +27,10 @@ class ADModel:
self.do_join = False
self.conn_info: Optional[ADConnectionInfo] = None
+ def set(self, info: ADConnectionInfo):
+ self.conn_info = info
+ self.do_join = True
+
def set_domain(self, domain: str):
if not domain:
return
diff --git a/subiquity/server/ad_joiner.py b/subiquity/server/ad_joiner.py
new file mode 100644
index 00000000..22ad2ac7
--- /dev/null
+++ b/subiquity/server/ad_joiner.py
@@ -0,0 +1,150 @@
+# Copyright 2023 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 asyncio
+from contextlib import contextmanager
+import logging
+from socket import gethostname
+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,
+ AdJoinResult,
+)
+
+log = logging.getLogger('subiquity.server.ad_joiner')
+
+
+@contextmanager
+def hostname_context(hostname: str):
+ """ Temporarily adjusts the host name to [hostname] and restores it
+ back in the end of the caller scope. """
+ hostname_current = gethostname()
+ hostname_process = run_command(['hostname', hostname])
+ try:
+ yield hostname_process
+ finally:
+ # Restoring the live session hostname.
+ hostname_process = run_command(['hostname', hostname_current])
+ if hostname_process.returncode:
+ log.info("Failed to restore live session hostname")
+
+
+class AdJoinStrategy():
+ realm = "/usr/sbin/realm"
+ pam = "/usr/sbin/pam-auth-update"
+
+ def __init__(self, app):
+ self.app = app
+
+ 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. """
+ # Set hostname for AD to determine FQDN (no FQDN option in realm join,
+ # only adcli, which only understands the live system, but not chroot)
+ with hostname_context(hostname) as host_process:
+ if host_process.returncode:
+ log.info("Failed to set live session hostname for adcli")
+ return AdJoinResult.JOIN_ERROR
+
+ root_dir = self.app.root
+ cp = await arun_command([self.realm, "join", "--install", root_dir,
+ "--user", info.admin_name,
+ "--computer-name", hostname,
+ "--unattended", info.domain_name],
+ input=info.password)
+
+ if cp.returncode:
+ # Try again without the computer name. Lab tests shown more
+ # success in this setup, but I'm still not sure if we should
+ # drop the computer name attempt, since that's the way Ubiquity
+ # has been doing for ages.
+ log.debug("Joining operation failed:")
+ log.debug(cp.stderr)
+ log.debug(cp.stdout)
+ log.debug("Trying again without overriding the computer name:")
+ cp = await arun_command([self.realm, "join", "--install",
+ root_dir, "--user", info.admin_name,
+ "--unattended", info.domain_name],
+ input=info.password)
+
+ if cp.returncode:
+ log.debug("Joining operation failed:")
+ log.debug(cp.stderr)
+ log.debug(cp.stdout)
+ return AdJoinResult.JOIN_ERROR
+
+ # Enable pam_mkhomedir
+ try:
+ # The function raises if the process fail.
+ await run_curtin_command(self.app, context,
+ "in-target", "-t", root_dir,
+ "--", self.pam, "--package",
+ "--enable", "mkhomedir",
+ private_mounts=False)
+
+ return AdJoinResult.OK
+ except CalledProcessError:
+ # The app command runner doesn't give us output in case of
+ # failure in the wait() method, which is called by
+ # run_curtin_command
+ log.info("Failed to update pam-auth")
+ return AdJoinResult.PAM_ERROR
+
+ return AdJoinResult.JOIN_ERROR
+
+
+class StubStrategy(AdJoinStrategy):
+ 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:
+ - p or P: returns PAM_ERROR.
+ - j or J: returns JOIN_ERROR.
+ - returns OK otherwise. """
+ initial = info.domain_name[0]
+ if initial in ('j', 'J'):
+ return AdJoinResult.JOIN_ERROR
+
+ if initial in ('p', 'P'):
+ return AdJoinResult.PAM_ERROR
+
+ return AdJoinResult.OK
+
+
+class AdJoiner():
+ def __init__(self, app):
+ self._result = AdJoinResult.UNKNOWN
+ self._completion_event = asyncio.Event()
+ if app.opts.dry_run:
+ self.strategy = StubStrategy(app)
+ else:
+ self.strategy = AdJoinStrategy(app)
+
+ async def join_domain(self, info: ADConnectionInfo, hostname: str,
+ context) -> AdJoinResult:
+ if hostname:
+ self._result = await self.strategy.do_join(info, hostname, context)
+ else:
+ self._result = AdJoinResult.EMPTY_HOSTNAME
+
+ self._completion_event.set()
+ return self._result
+
+ async def join_result(self):
+ await self._completion_event.wait()
+ return self._result
diff --git a/subiquity/server/controllers/ad.py b/subiquity/server/controllers/ad.py
index 346f6210..605c825f 100644
--- a/subiquity/server/controllers/ad.py
+++ b/subiquity/server/controllers/ad.py
@@ -25,8 +25,10 @@ from subiquity.common.types import (
ADConnectionInfo,
AdAdminNameValidation,
AdDomainNameValidation,
+ AdJoinResult,
AdPasswordValidation
)
+from subiquity.server.ad_joiner import AdJoiner
from subiquity.server.controller import SubiquityController
log = logging.getLogger('subiquity.server.controllers.ad')
@@ -95,6 +97,8 @@ class ADController(SubiquityController):
def __init__(self, app):
super().__init__(app)
+ self.ad_joiner = AdJoiner(self.app)
+ self.join_result = AdJoinResult.UNKNOWN
if self.app.opts.dry_run:
self.ping_strgy = StubDcPingStrategy()
else:
@@ -116,7 +120,7 @@ class ADController(SubiquityController):
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.conn_info = data
+ self.model.set(data)
await self.configured()
async def check_admin_name_POST(self, admin_name: str) \
@@ -141,6 +145,22 @@ class ADController(SubiquityController):
to configure AD are present in the live system."""
return self.ping_strgy.has_support()
+ async def join_result_GET(self, wait: bool = True) -> AdJoinResult:
+ """ If [wait] is True and the model is set for joining, this method
+ blocks until an attempt to join a domain completes.
+ Otherwise returns the current known state.
+ Most likely it will be AdJoinResult.UNKNOWN. """
+ if wait and self.model.do_join:
+ self.join_result = await self.ad_joiner.join_result()
+
+ return self.join_result
+
+ async def join_domain(self, hostname: str, context) -> None:
+ """ To be called from the install controller if the user requested
+ joining an AD domain """
+ await self.ad_joiner.join_domain(self.model.conn_info, hostname,
+ context)
+
# Helper out-of-class functions grouped.
class AdValidators:
diff --git a/subiquity/server/controllers/install.py b/subiquity/server/controllers/install.py
index 8f99322f..bc37ca0a 100644
--- a/subiquity/server/controllers/install.py
+++ b/subiquity/server/controllers/install.py
@@ -406,6 +406,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:
+ 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)
@with_context(description="configuring cloud-init")
async def configure_cloud_init(self, context):
diff --git a/subiquity/server/controllers/tests/test_ad.py b/subiquity/server/controllers/tests/test_ad.py
index bd6c95e9..3c70c6f7 100644
--- a/subiquity/server/controllers/tests/test_ad.py
+++ b/subiquity/server/controllers/tests/test_ad.py
@@ -13,13 +13,23 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from unittest import TestCase
+from unittest import (
+ TestCase,
+ IsolatedAsyncioTestCase,
+)
from subiquity.common.types import (
AdAdminNameValidation,
+ ADConnectionInfo,
AdDomainNameValidation,
+ AdJoinResult,
AdPasswordValidation,
)
-from subiquity.server.controllers.ad import AdValidators
+from subiquity.server.controllers.ad import (
+ ADController,
+ AdValidators,
+)
+from subiquity.models.ad import ADModel
+from subiquitycore.tests.mocks import make_app
class TestADValidation(TestCase):
@@ -119,3 +129,51 @@ class TestADValidation(TestCase):
admin = r'$Ubuntu{}'
result = AdValidators.admin_user_name(admin)
self.assertEqual(AdAdminNameValidation.OK, result)
+
+
+class TestAdJoin(IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.app = make_app()
+ 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.
+ result = await self.controller.join_result_GET(wait=True)
+ 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',
+ admin_name='Helper',
+ password='1234'))
+
+ result = await self.controller.join_result_GET(wait=False)
+ self.assertEqual(result, AdJoinResult.UNKNOWN)
+
+ async def test_join_OK(self):
+ # The equivalent of a successful POST
+ self.controller.model.set(ADConnectionInfo(domain_name='ubuntu.com',
+ admin_name='Helper',
+ password='1234'))
+ # Mimics a client requesting the join result. Blocking by default.
+ result = self.controller.join_result_GET()
+ # Mimics a calling from the install controller.
+ await self.controller.join_domain('this', 'AD Join')
+ self.assertEqual(await result, AdJoinResult.OK)
+
+ async def test_join_Join_Error(self):
+ self.controller.model.set(ADConnectionInfo(domain_name='jubuntu.com',
+ admin_name='Helper',
+ password='1234'))
+ await self.controller.join_domain('this', 'AD Join')
+ result = await self.controller.join_result_GET(wait=True)
+ self.assertEqual(result, AdJoinResult.JOIN_ERROR)
+
+ async def test_join_Pam_Error(self):
+ self.controller.model.set(ADConnectionInfo(domain_name='pubuntu.com',
+ admin_name='Helper',
+ password='1234'))
+ await self.controller.join_domain('this', 'AD Join')
+ result = await self.controller.join_result_GET(wait=True)
+ self.assertEqual(result, AdJoinResult.PAM_ERROR)
diff --git a/subiquity/server/server.py b/subiquity/server/server.py
index 9dd2de00..07b8c545 100644
--- a/subiquity/server/server.py
+++ b/subiquity/server/server.py
@@ -215,7 +215,7 @@ POSTINSTALL_MODEL_NAMES = ModelNames({
"ubuntu_pro",
"userdata",
},
- desktop={"timezone", "codecs"})
+ desktop={"timezone", "codecs", "ad"})
class SubiquityServer(Application):
diff --git a/subiquity/tests/api/test_api.py b/subiquity/tests/api/test_api.py
index 77c2d4a0..294c3e48 100644
--- a/subiquity/tests/api/test_api.py
+++ b/subiquity/tests/api/test_api.py
@@ -1696,3 +1696,21 @@ class TestActiveDirectory(TestAPI):
result = await instance.post(endpoint + '/check_admin_name',
data='$Ubuntu')
self.assertEqual('OK', result)
+ # Attempts to join with the info supplied above.
+ ad_dict = {
+ 'admin_name': 'Ubuntu',
+ 'domain_name': 'jubuntu.com',
+ 'password': 'u',
+ }
+ result = await instance.post(endpoint, ad_dict)
+ self.assertIsNone(result)
+ join_result_ep = endpoint + '/join_result'
+ # Without wait this shouldn't block but the result is unknown until
+ # the install controller runs.
+ join_result = await instance.get(join_result_ep, wait=False)
+ self.assertEqual('UNKNOWN', join_result)
+ # And without the installer controller running, a blocking call
+ # should timeout since joining never happens.
+ with self.assertRaises(asyncio.exceptions.TimeoutError):
+ join_result = instance.get(join_result_ep, wait=True)
+ await asyncio.wait_for(join_result, timeout=1.5)