diff --git a/tests/integration-tests/tests/ad_integration/cluster_user.py b/tests/integration-tests/tests/ad_integration/cluster_user.py index 77089dece3..f827ce19eb 100644 --- a/tests/integration-tests/tests/ad_integration/cluster_user.py +++ b/tests/integration-tests/tests/ad_integration/cluster_user.py @@ -19,12 +19,15 @@ def __init__( default_user_remote_command_executor, password, scheduler_commands_factory, + alias=None, ): self._default_user_remote_command_executor = default_user_remote_command_executor self.cluster = cluster self.scheduler = scheduler self.user_num = user_num # TODO: don't need to keep this? - self.alias = f"PclusterUser{user_num}" + # Allow an explicit alias (e.g. AD names containing dots or longer than 8 chars). Fall + # back to the conventional PclusterUser alias when not provided. + self.alias = alias or f"PclusterUser{user_num}" self.home_dir = f"/home/{self.alias}" self.ssh_keypair_path_prefix = str(test_datadir / self.alias) self.ssh_private_key_path = self.ssh_keypair_path_prefix diff --git a/tests/integration-tests/tests/ad_integration/test_ad_integration.py b/tests/integration-tests/tests/ad_integration/test_ad_integration.py index e85114a27b..7a5361df2f 100644 --- a/tests/integration-tests/tests/ad_integration/test_ad_integration.py +++ b/tests/integration-tests/tests/ad_integration/test_ad_integration.py @@ -29,15 +29,33 @@ from remote_command_executor import RemoteCommandExecutor from retrying import retry from time_utils import seconds -from utils import find_stack_by_tag, generate_stack_name, is_directory_supported, random_alphanumeric +from utils import ( + find_stack_by_tag, + generate_stack_name, + get_cidr_from_ip, + get_local_ip, + is_directory_supported, + random_alphanumeric, +) from xdist import get_xdist_worker_id from tests.ad_integration.cluster_user import ClusterUser +from tests.common.dcv_common import check_dcv_session_authentication from tests.common.utils import run_system_analyzer NUM_USERS_TO_CREATE = 5 NUM_USERS_TO_TEST = 3 +# DCV authenticator validates session ownership by matching the process UID. These AD users +# specifically exercise usernames that contain a dot and that exceed 8 characters (which a +# `ps aux` based lookup would truncate). They must be lowercase to satisfy the authenticator's +# USER_REGEX and stay within the 20-character sAMAccountName limit. +DCV_AD_USERS = ["pcluster.user", "pcluster.long.user"] + +# DCV defaults to port 8443 and the external authenticator listens on the next port. +DCV_AUTHENTICATOR_PORT = 8444 +DCV_SHARED_DIR = "/shared" + def get_infra_stack_outputs(stack_name): cfn = boto3.client("cloudformation") @@ -162,6 +180,9 @@ def _get_stack_parameters(directory_type, vpc_stack, keypair): users = "" for i in range(NUM_USERS_TO_CREATE): users += f"PclusterUser{i}," + # Also provision the AD users used to validate DCV authentication with dotted/long names. + for dcv_user in DCV_AD_USERS: + users += f"{dcv_user}," stack_parameters = [ { @@ -603,6 +624,8 @@ def test_ad_integration( # noqa: C901 vpc = directory_stack_outputs.get("VpcId") config_params.update(get_vpc_public_subnet(vpc)) + local_ip = get_local_ip() + config_params.update({"dcv_access_from": get_cidr_from_ip(local_ip) if local_ip else "0.0.0.0/0"}) cluster_config = pcluster_config_reader(**config_params) cluster = clusters_factory(cluster_config) @@ -640,6 +663,12 @@ def test_ad_integration( # noqa: C901 ) shared_storage_mount_dirs = ["/shared"] _run_user_workloads(users, test_datadir, shared_storage_mount_dirs) + + # Validate DCV authentication for AD users whose names contain a dot and exceed 8 characters. + _check_dcv_authentication_for_ad_users( + cluster, scheduler, test_datadir, remote_command_executor, ad_user_password, scheduler_commands_factory + ) + logging.info("Testing pcluster update and generate ssh keys for user") _check_ssh_key_generation(users[0], remote_command_executor, scheduler_commands, False) @@ -673,6 +702,30 @@ def test_ad_integration( # noqa: C901 run_system_analyzer(cluster, scheduler_commands_factory, request) +def _check_dcv_authentication_for_ad_users( + cluster, scheduler, test_datadir, remote_command_executor, ad_user_password, scheduler_commands_factory +): + """Start a DCV session as each dotted/long AD user and verify the authenticator accepts it. + + This exercises the DCV authenticator's session-ownership check (which resolves the username + to a numeric UID) against usernames that contain a dot and that are longer than 8 characters. + """ + for username in DCV_AD_USERS: + dcv_user = ClusterUser( + None, + test_datadir, + cluster, + scheduler, + remote_command_executor, + ad_user_password, + scheduler_commands_factory, + alias=username, + ) + check_dcv_session_authentication( + dcv_user.remote_command_executor(), DCV_AUTHENTICATOR_PORT, DCV_SHARED_DIR, dcv_user.alias + ) + + def _check_ssh_auth(user, expect_success=True): try: user.ssh_connect() diff --git a/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.update.yaml b/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.update.yaml index 8aa014fc6c..183df536b0 100644 --- a/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.update.yaml +++ b/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.update.yaml @@ -6,6 +6,9 @@ HeadNode: SubnetId: {{ public_subnet_id }} Ssh: KeyName: {{ key_name }} + Dcv: + Enabled: true + AllowedIps: {{ dcv_access_from }} Imds: Secured: {{ imds_secured }} Scheduling: diff --git a/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.update2.yaml b/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.update2.yaml index 085b5b54cc..357d3ab227 100644 --- a/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.update2.yaml +++ b/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.update2.yaml @@ -6,6 +6,9 @@ HeadNode: SubnetId: {{ public_subnet_id }} Ssh: KeyName: {{ key_name }} + Dcv: + Enabled: true + AllowedIps: {{ dcv_access_from }} Imds: Secured: {{ imds_secured }} Scheduling: diff --git a/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.yaml b/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.yaml index 46e2daae90..611819e3b6 100644 --- a/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.yaml +++ b/tests/integration-tests/tests/ad_integration/test_ad_integration/test_ad_integration/pcluster.config.yaml @@ -6,6 +6,9 @@ HeadNode: SubnetId: {{ public_subnet_id }} Ssh: KeyName: {{ key_name }} + Dcv: + Enabled: true + AllowedIps: {{ dcv_access_from }} Imds: Secured: {{ imds_secured }} Scheduling: diff --git a/tests/integration-tests/tests/common/dcv_common.py b/tests/integration-tests/tests/common/dcv_common.py new file mode 100644 index 0000000000..e301aabf5f --- /dev/null +++ b/tests/integration-tests/tests/common/dcv_common.py @@ -0,0 +1,61 @@ +# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "LICENSE.txt" file accompanying this file. +# This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. +# See the License for the specific language governing permissions and limitations under the License. +"""Shared helpers to launch DCV sessions and validate the DCV external authenticator.""" + +import logging +import re + +from assertpy import assert_that + +DCV_CONNECT_SCRIPT = "/opt/parallelcluster/scripts/pcluster_dcv_connect.sh" +DCV_SERVER_URL = "https://localhost" + +# Output emitted by the DCV connect script, e.g.: +# PclusterDcvServerPort=8443 PclusterDcvSessionId=mysession PclusterDcvSessionToken= +_DCV_SESSION_REGEX = r"PclusterDcvServerPort=([\d]+) PclusterDcvSessionId=([\w]+) PclusterDcvSessionToken=([\w-]+)" + + +def start_dcv_session(remote_command_executor, shared_dir): + """Run the DCV connect script and return (server_port, session_id, session_token).""" + command_execution = remote_command_executor.run_remote_command(f"{DCV_CONNECT_SCRIPT} {shared_dir}") + dcv_parameters = re.search(_DCV_SESSION_REGEX, command_execution.stdout) + assert_that(dcv_parameters).described_as( + "Command '{0} {1}' failed, output: {2}, error: {3}".format( + DCV_CONNECT_SCRIPT, shared_dir, command_execution.stdout, command_execution.stderr + ) + ).is_not_none() + return dcv_parameters.group(1), dcv_parameters.group(2), dcv_parameters.group(3) + + +def assert_authenticator_accepts_session( + remote_command_executor, authenticator_port, session_id, session_token, username +): + """Assert the DCV external authenticator validates the given session for the given username.""" + response = remote_command_executor.run_remote_command( + f"curl -s -k {DCV_SERVER_URL}:{authenticator_port} " + f"-d sessionId={session_id} -d authenticationToken={session_token} -d clientAddr=someIp" + ).stdout + assert_that(response).is_equal_to('{0}'.format(username)) + + +def check_dcv_session_authentication(remote_command_executor, authenticator_port, shared_dir, username): + """Open a DCV session and verify the authenticator validates it for the given username. + + This drives the full DCV authenticator path, including its session-ownership check that + resolves the username to a numeric UID. + """ + logging.info("Starting DCV session for user %s", username) + _, session_id, session_token = start_dcv_session(remote_command_executor, shared_dir) + logging.info("Verifying DCV authenticator validates the session for user %s", username) + assert_authenticator_accepts_session( + remote_command_executor, authenticator_port, session_id, session_token, username + ) diff --git a/tests/integration-tests/tests/dcv/test_dcv.py b/tests/integration-tests/tests/dcv/test_dcv.py index e1ca601d22..ae15acb721 100644 --- a/tests/integration-tests/tests/dcv/test_dcv.py +++ b/tests/integration-tests/tests/dcv/test_dcv.py @@ -34,9 +34,9 @@ ) from tests.cloudwatch_logging.test_cloudwatch_logging import FeatureSpecificCloudWatchLoggingTestRunner +from tests.common.dcv_common import check_dcv_session_authentication SERVER_URL = "https://localhost" -DCV_CONNECT_SCRIPT = "/opt/parallelcluster/scripts/pcluster_dcv_connect.sh" # Crashes matching any of these patterns are never tolerated, regardless of TOLERATED_CRASH_PATTERNS. UNTOLERATED_CRASH_PATTERNS = [ @@ -138,11 +138,15 @@ def _test_dcv_configuration( ), ( "error cases (head node)", - lambda: _check_error_cases(head_node_remote_command_executor, dcv_authenticator_port), + lambda: _check_error_cases( + head_node_remote_command_executor, dcv_authenticator_port, get_username_for_os(os) + ), ), ( "error cases (login node)", - lambda: _check_error_cases(login_node_remote_command_executor, dcv_authenticator_port), + lambda: _check_error_cases( + login_node_remote_command_executor, dcv_authenticator_port, get_username_for_os(os) + ), ), ("shared dir (head node)", lambda: _check_shared_dir(head_node_remote_command_executor, shared_dir)), ("shared dir (login node)", lambda: _check_shared_dir(login_node_remote_command_executor, shared_dir)), @@ -191,16 +195,6 @@ def _check_shared_dir(remote_command_executor, shared_dir): ).is_greater_than(0) -def _check_auth_ok(remote_command_executor, external_authenticator_port, session_id, session_token, os): - username = get_username_for_os(os) - assert_that( - remote_command_executor.run_remote_command( - f"curl -s -k {SERVER_URL}:{external_authenticator_port} " - f"-d sessionId={session_id} -d authenticationToken={session_token} -d clientAddr=someIp" - ).stdout - ).is_equal_to('{0}'.format(username)) - - def _get_crash_report(remote_command_executor): """Check for crash files on the node and return a crash report dictionary. @@ -268,13 +262,19 @@ def _get_known_hosts_content(host_keys_file): return b"" -def _check_error_cases(remote_command_executor, dcv_authenticator_port): +def _check_error_cases(remote_command_executor, dcv_authenticator_port, username): """Check DCV errors for both head and login nodes.""" logging.info("Checking expected authentication failure on %s", remote_command_executor.target) _check_auth_ko( remote_command_executor, dcv_authenticator_port, "-d action=requestToken -d authUser=centos -d sessionID=invalidSessionId", + "The given user does not exist", + ) + _check_auth_ko( + remote_command_executor, + dcv_authenticator_port, + f"-d action=requestToken -d authUser={username} -d sessionID=invalidSessionId", "The given session does not exists", ) _check_auth_ko( @@ -350,18 +350,6 @@ def _test_show_url(cluster, region, dcv_port, access_from, use_login_node=False) def _test_authenticator(remote_command_executor, dcv_authenticator_port, shared_dir, os): """Launch a DCV session and verify authenticator.""" - command_execution = remote_command_executor.run_remote_command(f"{DCV_CONNECT_SCRIPT} {shared_dir}") - dcv_parameters = re.search( - r"PclusterDcvServerPort=([\d]+) PclusterDcvSessionId=([\w]+) PclusterDcvSessionToken=([\w-]+)", - command_execution.stdout, + check_dcv_session_authentication( + remote_command_executor, dcv_authenticator_port, shared_dir, get_username_for_os(os) ) - if dcv_parameters: - dcv_session_id = dcv_parameters.group(2) - dcv_session_token = dcv_parameters.group(3) - _check_auth_ok(remote_command_executor, dcv_authenticator_port, dcv_session_id, dcv_session_token, os) - else: - assert_that(dcv_parameters).described_as( - "Command '{0} {1}' fails, output: {2}, error: {3}".format( - DCV_CONNECT_SCRIPT, shared_dir, command_execution.stdout, command_execution.stderr - ) - ).is_not_none()