diff --git a/.github/actions/make-integ-config/action.yml b/.github/actions/make-integ-config/action.yml index 59d126def..211045398 100644 --- a/.github/actions/make-integ-config/action.yml +++ b/.github/actions/make-integ-config/action.yml @@ -19,6 +19,9 @@ inputs: oidc_client_secret: description: 'oidc_client_secret' required: true + oidc_users_0_password: + description: 'oidc_users_0_password' + required: true runs: using: "composite" steps: @@ -35,6 +38,8 @@ runs: smb_share: /cidata oidc_client_id: d2298ec0-0596-49d2-9554-840a2fe20603 oidc_client_secret: ${{ inputs.oidc_client_secret }} + oidc_users_0_username: xlabciuser@scalemsdnscalecomputing.onmicrosoft.com + oidc_users_0_password: ${{ inputs.oidc_users_0_password }} EOF cat integ_config_vars.yml | jinja2 --strict tests/integration/integration_config.yml.j2 > tests/integration/integration_config.yml echo "sc_host: ${{ inputs.sc_host }}" >> tests/integration/integration_config.yml diff --git a/.github/workflows/integ-test.yml b/.github/workflows/integ-test.yml index 41ccd7324..289d11494 100644 --- a/.github/workflows/integ-test.yml +++ b/.github/workflows/integ-test.yml @@ -14,6 +14,7 @@ on: # dns_config|time_server - NTP cannot be reconfigured if DNS is invalid # git_issues - slow, do not run on each push. TODO - run them only once a day # oidc_config - during reconfiguration API returns 500/502 errors for other requests + # utils_login - it uses OIDC user to login # smtp - email_alert test requires a configured SMTP # role_cluster_config - role cluster_config reconfigures DNS, SMTP, OIDC. And it is slow. # version_update_single_node - role would change version, VSNS system cannot be updated. @@ -23,7 +24,7 @@ on: List integration tests to exclude. Use "*" to exclude all tests. Use regex like 'node|^git_issue|^dns_config$' to exclude only a subset. - default: "^dns_config$|^cluster_shutdown$|^version_update$|^oidc_config$|^smtp$|^role_cluster_config$|^role_version_update_single_node$" + default: "^dns_config$|^cluster_shutdown$|^version_update$|^oidc_config$|^smtp$|^role_cluster_config$|^role_version_update_single_node$|^utils_login$" examples_tests_include: type: string description: |- @@ -32,7 +33,7 @@ on: default: "iso_info" env: INTEG_TESTS_INCLUDE_SCHEDULE: "*" - INTEG_TESTS_EXCLUDE_SCHEDULE: "^dns_config$|^cluster_shutdown$|^version_update$|^oidc_config$|^smtp$|^role_cluster_config$|^role_version_update_single_node$" + INTEG_TESTS_EXCLUDE_SCHEDULE: "^dns_config$|^cluster_shutdown$|^version_update$|^oidc_config$|^smtp$|^role_cluster_config$|^role_version_update_single_node$|^utils_login$" EXAMPLES_TESTS_INCLUDE_SCHEDULE: "*" # ansible-test needs special directory structure. # WORKDIR is a subdir of GITHUB_WORKSPACE @@ -73,6 +74,7 @@ jobs: sc_password_50: ${{ secrets.CI_CONFIG_HC_IP50_SC_PASSWORD }} smb_password: ${{ secrets.CI_CONFIG_HC_IP50_SMB_PASSWORD }} oidc_client_secret: ${{ secrets.OIDC_CLIENT_SECRET }} + oidc_users_0_password: ${{ secrets.OIDC_USERS_0_PASSWORD }} working_directory: ${{ env.WORKDIR }} - run: ansible-playbook tests/integration/prepare/prepare_iso.yml - run: ansible-playbook tests/integration/prepare/prepare_vm.yml @@ -177,6 +179,7 @@ jobs: sc_password_50: ${{ secrets.CI_CONFIG_HC_IP50_SC_PASSWORD }} smb_password: ${{ secrets.CI_CONFIG_HC_IP50_SMB_PASSWORD }} oidc_client_secret: ${{ secrets.OIDC_CLIENT_SECRET }} + oidc_users_0_password: ${{ secrets.OIDC_USERS_0_PASSWORD }} working_directory: ${{ env.WORKDIR }} - run: | pwd @@ -220,6 +223,12 @@ jobs: test_name: support_tunnel - sc_host: https://10.5.11.50 test_name: vm_clone__replicated + # test oidc login + - sc_host: https://10.5.11.50 + test_name: utils_login + # test inventory plugin with oidc login + - sc_host: https://10.5.11.50 + test_name: inventory exclude: # The VSNS were not configured with remote replication cluster. - sc_host: https://10.5.11.200 @@ -255,6 +264,7 @@ jobs: sc_password_50: ${{ secrets.CI_CONFIG_HC_IP50_SC_PASSWORD }} smb_password: ${{ secrets.CI_CONFIG_HC_IP50_SMB_PASSWORD }} oidc_client_secret: ${{ secrets.OIDC_CLIENT_SECRET }} + oidc_users_0_password: ${{ secrets.OIDC_USERS_0_PASSWORD }} working_directory: ${{ env.WORKDIR }} - run: ansible-test integration --local ${{ matrix.test_name }} @@ -282,6 +292,7 @@ jobs: sc_password_50: ${{ secrets.CI_CONFIG_HC_IP50_SC_PASSWORD }} smb_password: ${{ secrets.CI_CONFIG_HC_IP50_SMB_PASSWORD }} oidc_client_secret: ${{ secrets.OIDC_CLIENT_SECRET }} + oidc_users_0_password: ${{ secrets.OIDC_USERS_0_PASSWORD }} working_directory: ${{ env.WORKDIR }} - run: ansible-galaxy collection install community.general - run: ansible-playbook tests/integration/cleanup/ci_replica_cleanup.yml @@ -306,6 +317,7 @@ jobs: sc_password_50: ${{ secrets.CI_CONFIG_HC_IP50_SC_PASSWORD }} smb_password: ${{ secrets.CI_CONFIG_HC_IP50_SMB_PASSWORD }} oidc_client_secret: ${{ secrets.OIDC_CLIENT_SECRET }} + oidc_users_0_password: ${{ secrets.OIDC_USERS_0_PASSWORD }} working_directory: ${{ env.WORKDIR }} - run: | cd tests/integration/cleanup && ./smb_cleanup.sh \ diff --git a/plugins/doc_fragments/cluster_instance.py b/plugins/doc_fragments/cluster_instance.py index 66fc175e2..b0f86c0ef 100644 --- a/plugins/doc_fragments/cluster_instance.py +++ b/plugins/doc_fragments/cluster_instance.py @@ -47,4 +47,14 @@ class ModuleDocFragment(object): variable will be used. required: false type: float + auth_method: + description: + - Select login method. + If not set, the value of the C(SC_AUTH_METHOD) environment + variable will be used. + - Value I(local) - username/password is verified by the HyperCore server (the local users). + - Value I(oidc) - username/password is verified by the configured OIDC provider. + default: local + choices: [local, oidc] + type: str """ diff --git a/plugins/inventory/hypercore.py b/plugins/inventory/hypercore.py index f6d6426f3..88db7d76f 100644 --- a/plugins/inventory/hypercore.py +++ b/plugins/inventory/hypercore.py @@ -223,6 +223,7 @@ def parse(self, inventory, loader, path, cache=False): username = os.getenv("SC_USERNAME") password = os.getenv("SC_PASSWORD") timeout = os.getenv("SC_TIMEOUT") + auth_method = os.getenv("SC_AUTH_METHOD") if timeout: try: timeout = float(timeout) @@ -234,7 +235,7 @@ def parse(self, inventory, loader, path, cache=False): raise errors.ScaleComputingError( "Missing one or more parameters: sc_host, sc_username, sc_password." ) - client = Client(host, username, password, timeout) + client = Client(host, username, password, timeout, auth_method) rest_client = RestClient(client) vms = rest_client.list_records("/rest/v1/VirDomain") diff --git a/plugins/module_utils/arguments.py b/plugins/module_utils/arguments.py index 76f894bb9..40095bb9f 100644 --- a/plugins/module_utils/arguments.py +++ b/plugins/module_utils/arguments.py @@ -10,6 +10,7 @@ from ansible.module_utils.basic import env_fallback from typing import Any +from ..module_utils.client import AuthMethod # TODO - env from /etc/environment is loaded # But when env is set in bash session, env seems to be lost on ssh connection to localhost. @@ -42,6 +43,12 @@ required=False, fallback=(env_fallback, ["SC_TIMEOUT"]), ), + auth_method=dict( + type="str", + default=AuthMethod.local, + choices=[am.value for am in AuthMethod], + fallback=(env_fallback, ["SC_AUTH_METHOD"]), + ), ), required_together=[("username", "password")], ), diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py index f4812c391..fa3294b2e 100644 --- a/plugins/module_utils/client.py +++ b/plugins/module_utils/client.py @@ -12,8 +12,9 @@ import ssl from typing import Any, Optional, Union from io import BufferedReader +import enum -from ansible.module_utils.urls import Request, basic_auth_header +from ansible.module_utils.urls import Request from .errors import ( AuthError, @@ -29,6 +30,11 @@ DEFAULT_HEADERS = dict(Accept="application/json") +class AuthMethod(str, enum.Enum): + local = "local" + oidc = "oidc" + + class Response: # I have felling (but I'm not sure) we will always use # "Response(raw_resp.status, raw_resp.read(), raw_resp.headers)" @@ -64,6 +70,7 @@ def __init__( username: str, password: str, timeout: float, + auth_method: str, ): if not (host or "").startswith(("https://", "http://")): raise ScaleComputingError( @@ -75,8 +82,9 @@ def __init__( self.username = username self.password = password self.timeout = timeout + self.auth_method = auth_method - self._auth_header: Optional[dict[str, bytes]] = None + self._auth_header: Optional[dict[str, str]] = None self._client = Request() @classmethod @@ -86,19 +94,38 @@ def get_client(cls, cluster_instance: TypedClusterInstance) -> Client: cluster_instance["username"], cluster_instance["password"], cluster_instance["timeout"], + cluster_instance["auth_method"], ) @property - def auth_header(self) -> dict[str, bytes]: + def auth_header(self) -> dict[str, str]: if not self._auth_header: self._auth_header = self._login() return self._auth_header - def _login(self) -> dict[str, bytes]: + def _login(self) -> dict[str, str]: return self._login_username_password() - def _login_username_password(self) -> dict[str, bytes]: - return dict(Authorization=basic_auth_header(self.username, self.password)) + def _login_username_password(self) -> dict[str, str]: + headers = { + "Accept": "application/json", + "Content-type": "application/json", + } + use_oidc = self.auth_method == AuthMethod.oidc.value + resp = self._request( + "POST", + f"{self.host}/rest/v1/login", + data=json.dumps( + dict( + username=self.username, + password=self.password, + useOIDC=use_oidc, + ) + ), + headers=headers, + timeout=self.timeout, + ) + return dict(Cookie=f"sessionID={resp.json['sessionID']}") def _request( self, @@ -108,9 +135,8 @@ def _request( headers: Optional[dict[Any, Any]] = None, timeout: Optional[float] = None, ) -> Response: - if ( - timeout is None - ): # If timeout from request is not specifically provided, take it from the Client. + if timeout is None: + # If timeout from request is not specifically provided, take it from the Client. timeout = self.timeout try: raw_resp = self._client.open( diff --git a/plugins/module_utils/typed_classes.py b/plugins/module_utils/typed_classes.py index fa7a9dd1d..7da5d5c86 100644 --- a/plugins/module_utils/typed_classes.py +++ b/plugins/module_utils/typed_classes.py @@ -21,6 +21,7 @@ class TypedClusterInstance(TypedDict): username: str password: str timeout: float + auth_method: str # Registration to ansible return dict. diff --git a/tests/integration/integration_config.yml.j2 b/tests/integration/integration_config.yml.j2 index e688e13a9..8f810e448 100644 --- a/tests/integration/integration_config.yml.j2 +++ b/tests/integration/integration_config.yml.j2 @@ -55,6 +55,10 @@ sc_config: shared_secret_default: "{{ oidc_client_secret }}" config_url_default: https://login.microsoftonline.com/76d4c62a-a9ca-4dc2-9187-e2cc4d9abe7f/v2.0/.well-known/openid-configuration scopes: "openid+profile" + # Users that can login when config_url_default is configured as OIDC provider + users: + - username: "{{ oidc_users_0_username }}" + password: "{{ oidc_users_0_password }}" client_id_test: ci-client-id shared_secret_test: ci-client-secret config_url_test: https://login.microsoftonline.com/76d4c62a-a9ca-4dc2-9187-e2cc4d9abe7f/v2.0/.well-known/openid-configuration diff --git a/tests/integration/targets/inventory/runme.sh b/tests/integration/targets/inventory/runme.sh index dc05ad020..2d04dd1e0 100755 --- a/tests/integration/targets/inventory/runme.sh +++ b/tests/integration/targets/inventory/runme.sh @@ -12,6 +12,7 @@ print("export SC_HOST='{}'".format(sc_host)) print("export SC_TIMEOUT='{}'".format(sc_timeout)) print("export SC_USERNAME='{}'".format(data["sc_config"][sc_host]["sc_username"])) print("export SC_PASSWORD='{}'".format(data["sc_config"][sc_host]["sc_password"])) +# SC_AUTH_METHOD==local by default, leave it unset EOF )" @@ -45,8 +46,34 @@ ansible-playbook -i localhost, -i hypercore_inventory_ansible_enable.yml run_ans ansible-playbook -i localhost, -i hypercore_inventory_ansible_disable.yml run_ansible_disable_tests.yml ansible-playbook -i localhost, -i hypercore_inventory_ansible_both_true.yml run_ansible_both_true_tests.yml -unset SC_TIMEOUT # do one test without SC_TIMEOUT - +# do one test without SC_TIMEOUT +unset SC_TIMEOUT +ansible-playbook -i localhost, -i hypercore_inventory_ansible_both_false.yml run_ansible_both_false_tests.yml +# test with SC_AUTH_METHOD being set to "local" +export SC_AUTH_METHOD=local ansible-playbook -i localhost, -i hypercore_inventory_ansible_both_false.yml run_ansible_both_false_tests.yml +# test with OIDC user +if [[ "$SC_HOST" == "https://10.5.11.50" ]] +then + # We can do this only if OIDC login is configured. + echo "Testing inventory plugin with OIDC user." + eval "$(cat <