diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 375baaab7..7c420b904 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -161,6 +161,7 @@ jobs: - name: pytest tests/unit_tests run: | poetry run pytest \ + -rsxfE \ --cov=hvac \ --cov-report=xml:reports/coverage_units_py${{ matrix.python-version }}.xml \ tests/unit_tests @@ -242,6 +243,7 @@ jobs: COVFILE: coverage_integration_py${{ matrix.python-version }}_${{ matrix.vault-version }}.xml run: | poetry run pytest \ + -rsxfE \ --cov=hvac \ --cov-report=xml:reports/${COVFILE//[^A-Za-z0-9\-_\.]/_} \ tests/integration_tests diff --git a/docs/overview.rst b/docs/overview.rst index ab95a864a..7a8a3150c 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -109,13 +109,13 @@ Read and write to secrets engines KV Secrets Engine - Version 2 """"""""""""""""""""""""""""" +.. testsetup:: kvv2 + + client = manager.client .. doctest:: kvv2 :skipif: client.sys.retrieve_mount_option('secret', 'version', '1') != '2' - >>> # Retrieve an authenticated hvac.Client() instance - >>> client = test_utils.create_client() - >>> >>> # Write a k/v pair under path: secret/foo >>> create_response = client.secrets.kv.v2.create_or_update_secret( ... path='foo', diff --git a/poetry.lock b/poetry.lock index 191e047c0..a428bd888 100644 --- a/poetry.lock +++ b/poetry.lock @@ -446,6 +446,20 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "execnet" +version = "2.0.2" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.7" +files = [ + {file = "execnet-2.0.2-py3-none-any.whl", hash = "sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41"}, + {file = "execnet-2.0.2.tar.gz", hash = "sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + [[package]] name = "flake8" version = "5.0.4" @@ -1016,6 +1030,26 @@ pytest = ">=5.0" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "pytest-xdist" +version = "3.3.1" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-xdist-3.3.1.tar.gz", hash = "sha256:d5ee0520eb1b7bcca50a60a518ab7a7707992812c578198f8b44fdfac78e8c93"}, + {file = "pytest_xdist-3.3.1-py3-none-any.whl", hash = "sha256:ff9daa7793569e6a68544850fd3927cd257cc03a7ef76c95e86915355e82b5f2"}, +] + +[package.dependencies] +execnet = ">=1.1" +pytest = ">=6.2.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + [[package]] name = "python-ldap-test" version = "0.3.1" @@ -1532,4 +1566,4 @@ parser = ["pyhcl"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "9e4929bbf116a4f7b50561a7356414449eee106963bfd968d0314198f84a2b9c" +content-hash = "f0da8d606a81fd30e6c06f5a0e9dda1b9764bd0495facfa4efb307196ea91919" diff --git a/pyproject.toml b/pyproject.toml index 4e8866d1f..b584a0b90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,10 @@ greenlet = "^3.0.0" jwcrypto = "^1.5.0" typos = "^1.16.11" pytest-mock = "^3.11.1" +pytest-xdist = "^3.3.1" + +[tool.pytest.ini_options] +addopts = "-n auto --dist worksteal" [tool.typos.default.extend-words] Hashi = "Hashi" diff --git a/tests/config_files/generated/.gitignore b/tests/config_files/generated/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/tests/config_files/generated/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/config_files/vault-doctest.hcl b/tests/config_files/vault-doctest.hcl index 5e98ec3e3..3f5a3d1ab 100644 --- a/tests/config_files/vault-doctest.hcl +++ b/tests/config_files/vault-doctest.hcl @@ -2,6 +2,7 @@ backend "inmem" { } listener "tcp" { + address = "127.0.0.1:8200" tls_cert_file = "../tests/config_files/server-cert.pem" tls_key_file = "../tests/config_files/server-key.pem" } diff --git a/tests/config_files/vault-ha-node1.hcl b/tests/config_files/vault-ha-node1.hcl index 01f483b9c..e5407d51d 100644 --- a/tests/config_files/vault-ha-node1.hcl +++ b/tests/config_files/vault-ha-node1.hcl @@ -1,5 +1,5 @@ listener "tcp" { - address = "127.0.0.1:8200" + // address = "127.0.0.1:8200" tls_cert_file = "tests/config_files/server-cert.pem" tls_key_file = "tests/config_files/server-key.pem" } @@ -11,5 +11,5 @@ max_lease_ttl = "768h" storage "consul" { address = "127.0.0.1:8500" - path = "vault" + path = "vault_123/" } diff --git a/tests/config_files/vault-ha-node2.hcl b/tests/config_files/vault-ha-node2.hcl index 9bf1bb022..c4d79494d 100644 --- a/tests/config_files/vault-ha-node2.hcl +++ b/tests/config_files/vault-ha-node2.hcl @@ -1,6 +1,6 @@ listener "tcp" { - address = "127.0.0.1:8199" - cluster_address = "127.0.0.1:8201" + // address = "127.0.0.1:8198" + # cluster_address = "127.0.0.1:8201" tls_cert_file = "tests/config_files/server-cert.pem" tls_key_file = "tests/config_files/server-key.pem" } @@ -12,5 +12,5 @@ max_lease_ttl = "768h" storage "consul" { address = "127.0.0.1:8500" - path = "vault" + path = "vault_123/" } diff --git a/tests/doctest/__init__.py b/tests/doctest/__init__.py index d5d582d27..a4afa4652 100644 --- a/tests/doctest/__init__.py +++ b/tests/doctest/__init__.py @@ -11,15 +11,16 @@ def doctest_global_setup(): - client = test_utils.create_client() manager = ServerManager( config_paths=[test_utils.get_config_file_path("vault-doctest.hcl")], - client=client, + patch_config=False, ) manager.start() manager.initialize() manager.unseal() + client = manager.client + mocker = Mocker(real_http=True) mocker.start() @@ -27,7 +28,7 @@ def doctest_global_setup(): f"ldap/login/{MockLdapServer.ldap_user_name}", ] for auth_method_path in auth_method_paths: - mock_url = f"https://127.0.0.1:8200/v1/auth/{auth_method_path}" + mock_url = f"{client.url}/v1/auth/{auth_method_path}" mock_response = { "auth": { "client_token": manager.root_token, diff --git a/tests/integration_tests/api/auth_methods/test_cert.py b/tests/integration_tests/api/auth_methods/test_cert.py index 0458297d2..ee5871a3a 100644 --- a/tests/integration_tests/api/auth_methods/test_cert.py +++ b/tests/integration_tests/api/auth_methods/test_cert.py @@ -10,7 +10,7 @@ class TestCert(HvacIntegrationTestCase, TestCase): TEST_MOUNT_POINT = "cert-test" TEST_ROLE_NAME = "testrole" TEST_CLIENT_CERTIFICATE_FILE = utils.get_config_file_path("client-cert.pem") - cert = utils.create_client()._adapter._kwargs.get("cert") + cert = utils.create_client(url="fake")._adapter._kwargs.get("cert") with open(TEST_CLIENT_CERTIFICATE_FILE, "r") as fp: TEST_CERTIFICATE = fp.read() diff --git a/tests/integration_tests/api/auth_methods/test_jwt.py b/tests/integration_tests/api/auth_methods/test_jwt.py index 9c7718978..41039c186 100644 --- a/tests/integration_tests/api/auth_methods/test_jwt.py +++ b/tests/integration_tests/api/auth_methods/test_jwt.py @@ -37,11 +37,11 @@ def tearDown(self): [ param( "configure using vault identity OIDC", - issuer="https://localhost:8200", ), ] ) - def test_configure(self, label, issuer): + def test_configure(self, label): + issuer = self.client.url oidc_discovery_url = f"{issuer}/v1/identity/oidc" self.client.secrets.identity.configure_tokens_backend( issuer=issuer, @@ -63,11 +63,11 @@ def test_configure(self, label, issuer): [ param( "configure using vault identity OIDC", - issuer="https://localhost:8200", ), ] ) - def test_read_config(self, label, issuer): + def test_read_config(self, label): + issuer = self.client.url oidc_discovery_url = f"{issuer}/v1/identity/oidc" self.client.secrets.identity.configure_tokens_backend( issuer=issuer, @@ -94,12 +94,15 @@ def test_read_config(self, label, issuer): param( "success", role_name="hvac", - allowed_redirect_uris=["https://localhost:8200/jwt-test/callback"], + allowed_redirect_uris=["{url}/jwt-test/callback"], user_claim="https://vault/user", ), ] ) def test_create_role(self, label, role_name, allowed_redirect_uris, user_claim): + allowed_redirect_uris = [ + uri.format(url=self.client.url) for uri in allowed_redirect_uris + ] response = self.client.auth.jwt.create_role( name=role_name, allowed_redirect_uris=allowed_redirect_uris, @@ -124,12 +127,15 @@ def test_create_role(self, label, role_name, allowed_redirect_uris, user_claim): param( "success", role_name="hvac", - allowed_redirect_uris=["https://localhost:8200/jwt-test/callback"], + allowed_redirect_uris=["{url}/jwt-test/callback"], user_claim="https://vault/user", ), ] ) def test_read_role(self, label, role_name, allowed_redirect_uris, user_claim): + allowed_redirect_uris = [ + uri.format(url=self.client.url) for uri in allowed_redirect_uris + ] create_role_response = self.client.auth.jwt.create_role( name=role_name, allowed_redirect_uris=allowed_redirect_uris, @@ -153,12 +159,15 @@ def test_read_role(self, label, role_name, allowed_redirect_uris, user_claim): param( "success", role_name="hvac", - allowed_redirect_uris=["https://localhost:8200/jwt-test/callback"], + allowed_redirect_uris=["{url}/jwt-test/callback"], user_claim="https://vault/user", ), ] ) def test_list_roles(self, label, role_name, allowed_redirect_uris, user_claim): + allowed_redirect_uris = [ + uri.format(url=self.client.url) for uri in allowed_redirect_uris + ] create_role_response = self.client.auth.jwt.create_role( name=role_name, allowed_redirect_uris=allowed_redirect_uris, @@ -181,12 +190,15 @@ def test_list_roles(self, label, role_name, allowed_redirect_uris, user_claim): param( "success", role_name="hvac", - allowed_redirect_uris=["https://localhost:8200/jwt-test/callback"], + allowed_redirect_uris=["{url}/jwt-test/callback"], user_claim="https://vault/user", ), ] ) def test_delete_role(self, label, role_name, allowed_redirect_uris, user_claim): + allowed_redirect_uris = [ + uri.format(url=self.client.url) for uri in allowed_redirect_uris + ] create_role_response = self.client.auth.jwt.create_role( name=role_name, allowed_redirect_uris=allowed_redirect_uris, @@ -209,16 +221,17 @@ def test_delete_role(self, label, role_name, allowed_redirect_uris, user_claim): [ param( "success", - issuer="https://localhost:8200", role_name="hvac-jwt", - allowed_redirect_uris=["https://localhost:8200/jwt-test/oidc/callback"], + allowed_redirect_uris=["{url}/jwt-test/oidc/callback"], user_claim="sub", ), ] ) - def test_jwt_login( - self, label, issuer, role_name, allowed_redirect_uris, user_claim - ): + def test_jwt_login(self, label, role_name, allowed_redirect_uris, user_claim): + issuer = self.client.url + allowed_redirect_uris = [ + uri.format(url=self.client.url) for uri in allowed_redirect_uris + ] if "%s/" % self.TEST_APPROLE_PATH not in self.client.sys.list_auth_methods(): self.client.sys.enable_auth_method( method_type="approle", @@ -253,10 +266,10 @@ def test_jwt_login( logging.debug("create_named_key response: %s" % create_named_key_response) self.client.secrets.identity.configure_tokens_backend( - issuer="https://localhost:8200", + issuer=issuer, ) response = self.client.auth.jwt.configure( - jwks_url="https://localhost:8200/v1/identity/oidc/.well-known/keys", + jwks_url=f"{issuer}/v1/identity/oidc/.well-known/keys", jwks_ca_pem="".join( open(utils.get_config_file_path("server-cert.pem")).readlines() ), diff --git a/tests/integration_tests/api/auth_methods/test_oidc.py b/tests/integration_tests/api/auth_methods/test_oidc.py index b02d355e7..59c141c59 100644 --- a/tests/integration_tests/api/auth_methods/test_oidc.py +++ b/tests/integration_tests/api/auth_methods/test_oidc.py @@ -41,11 +41,11 @@ def tearDown(self): [ param( "configure using vault identity OIDC", - issuer="https://localhost:8200", ), ] ) - def test_configure(self, label, issuer): + def test_configure(self, label): + issuer = self.client.url oidc_discovery_url = f"{issuer}/v1/identity/oidc" self.client.secrets.identity.configure_tokens_backend( issuer=issuer, @@ -67,11 +67,11 @@ def test_configure(self, label, issuer): [ param( "configure using vault identity OIDC", - issuer="https://localhost:8200", ), ] ) - def test_read_config(self, label, issuer): + def test_read_config(self, label): + issuer = self.client.url oidc_discovery_url = f"{issuer}/v1/identity/oidc" self.client.secrets.identity.configure_tokens_backend( issuer=issuer, @@ -98,12 +98,15 @@ def test_read_config(self, label, issuer): param( "success", role_name="hvac", - allowed_redirect_uris=["https://localhost:8200/oidc-test/callback"], + allowed_redirect_uris=["{url}/oidc-test/callback"], user_claim="https://vault/user", ), ] ) def test_read_role(self, label, role_name, allowed_redirect_uris, user_claim): + allowed_redirect_uris = [ + uri.format(url=self.client.url) for uri in allowed_redirect_uris + ] create_role_response = self.client.auth.oidc.create_role( name=role_name, allowed_redirect_uris=allowed_redirect_uris, @@ -126,12 +129,15 @@ def test_read_role(self, label, role_name, allowed_redirect_uris, user_claim): param( "success", role_name="hvac", - allowed_redirect_uris=["https://localhost:8200/oidc-test/callback"], + allowed_redirect_uris=["{url}/oidc-test/callback"], user_claim="https://vault/user", ), ] ) def test_list_roles(self, label, role_name, allowed_redirect_uris, user_claim): + allowed_redirect_uris = [ + uri.format(url=self.client.url) for uri in allowed_redirect_uris + ] create_role_response = self.client.auth.oidc.create_role( name=role_name, allowed_redirect_uris=allowed_redirect_uris, @@ -153,12 +159,15 @@ def test_list_roles(self, label, role_name, allowed_redirect_uris, user_claim): param( "success", role_name="hvac", - allowed_redirect_uris=["https://localhost:8200/oidc-test/callback"], + allowed_redirect_uris=["{url}/oidc-test/callback"], user_claim="https://vault/user", ), ] ) def test_delete_role(self, label, role_name, allowed_redirect_uris, user_claim): + allowed_redirect_uris = [ + uri.format(url=self.client.url) for uri in allowed_redirect_uris + ] create_role_response = self.client.auth.oidc.create_role( name=role_name, allowed_redirect_uris=allowed_redirect_uris, @@ -180,16 +189,19 @@ def test_delete_role(self, label, role_name, allowed_redirect_uris, user_claim): [ param( "success", - issuer="https://localhost:8200", role_name="hvac", - allowed_redirect_uris=["https://localhost:8200/oidc-test/callback"], + allowed_redirect_uris=["{url}/oidc-test/callback"], user_claim="https://vault/user", ), ] ) def test_oidc_authorization_url_request( - self, label, issuer, role_name, allowed_redirect_uris, user_claim + self, label, role_name, allowed_redirect_uris, user_claim ): + issuer = self.client.url + allowed_redirect_uris = [ + uri.format(url=self.client.url) for uri in allowed_redirect_uris + ] if "%s/" % self.TEST_APPROLE_PATH not in self.client.sys.list_auth_methods(): self.client.sys.enable_auth_method( method_type="approle", @@ -268,14 +280,15 @@ def test_oidc_authorization_url_request( param( "success", role_name="hvac-oidc", - allowed_redirect_uris=[ - "https://localhost:8200/v1/auth/oidc-test/oidc/callback" - ], + allowed_redirect_uris=["{url}/v1/auth/oidc-test/oidc/callback"], user_claim="sub", ), ] ) def test_oidc_callback(self, label, role_name, allowed_redirect_uris, user_claim): + allowed_redirect_uris = [ + uri.format(url=self.client.url) for uri in allowed_redirect_uris + ] self.oidc_server = MockOauthProviderServerThread() self.oidc_server.start() oidc_details = create_user_session_and_client( diff --git a/tests/integration_tests/api/secrets_engines/test_pki.py b/tests/integration_tests/api/secrets_engines/test_pki.py index 3d7858708..e94fb200e 100644 --- a/tests/integration_tests/api/secrets_engines/test_pki.py +++ b/tests/integration_tests/api/secrets_engines/test_pki.py @@ -774,9 +774,9 @@ def test_update_issuer_no_extra_param(self): mount_point=self.TEST_MOUNT_POINT, ) - self.assertEqual( - first=pki_update_response["data"]["usage"], - second=pki_read_response["data"]["usage"], + self.assertCountEqual( + first=pki_update_response["data"]["usage"].split(","), + second=pki_read_response["data"]["usage"].split(","), ) # Revoke issuer diff --git a/tests/integration_tests/api/system_backend/test_key.py b/tests/integration_tests/api/system_backend/test_key.py index 9f5846b41..7aadbac9b 100644 --- a/tests/integration_tests/api/system_backend/test_key.py +++ b/tests/integration_tests/api/system_backend/test_key.py @@ -31,6 +31,7 @@ def test_start_generate_root_with_completion(self): new_root_token = utils.decode_generated_root_token( encoded_token=last_generate_root_response["encoded_root_token"], otp=test_otp, + url=self.client.url, ) logging.debug("new_root_token: %s" % new_root_token) token_lookup_resp = self.client.lookup_token(token=new_root_token) diff --git a/tests/integration_tests/v1/test_integration.py b/tests/integration_tests/v1/test_integration.py index 605dc712d..ca5d00e2b 100644 --- a/tests/integration_tests/v1/test_integration.py +++ b/tests/integration_tests/v1/test_integration.py @@ -152,22 +152,22 @@ def test_write_data(self): self.client.delete("secret/foo") def test_missing_token(self): - client = utils.create_client() + client = utils.create_client(url=self.client.url) assert not client.is_authenticated() def test_invalid_token(self): - client = utils.create_client(token="not-a-real-token") + client = utils.create_client(url=self.client.url, token="not-a-real-token") assert not client.is_authenticated() def test_illegal_token(self): - client = utils.create_client(token="token-with-new-line\n") + client = utils.create_client(url=self.client.url, token="token-with-new-line\n") try: client.is_authenticated() except ValueError as e: assert "Invalid header value" in str(e) def test_broken_token(self): - client = utils.create_client(token="\x1b") + client = utils.create_client(url=self.client.url, token="\x1b") try: client.is_authenticated() except exceptions.InvalidRequest as e: diff --git a/tests/integration_tests/v1/test_system_backend.py b/tests/integration_tests/v1/test_system_backend.py index 358d6953e..bd1785bee 100644 --- a/tests/integration_tests/v1/test_system_backend.py +++ b/tests/integration_tests/v1/test_system_backend.py @@ -327,6 +327,7 @@ def test_start_generate_root_with_completion(self): new_root_token = utils.decode_generated_root_token( encoded_token=last_generate_root_response["encoded_root_token"], otp=test_otp, + url=self.client.url, ) logging.debug("new_root_token: %s" % new_root_token) token_lookup_resp = self.client.auth.token.lookup(token=new_root_token) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index c0612e47a..82cc2613b 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -7,6 +7,7 @@ import re import socket import subprocess +import typing as t from distutils.spawn import find_executable from unittest import SkipTest, mock @@ -79,7 +80,7 @@ def get_generate_root_otp(): return test_otp -def create_client(url="https://localhost:8200", use_env=False, **kwargs): +def create_client(url, use_env=False, **kwargs): """Small helper to instantiate a :py:class:`hvac.v1.Client` class with the appropriate parameters for the test env. :param url: Vault address to configure the client with. @@ -113,7 +114,70 @@ def create_client(url="https://localhost:8200", use_env=False, **kwargs): return client +class PortGetter: + _entered: bool = False + _sockets: t.List[socket.socket] = [] + + def __init__(self, default_address: str = "localhost"): + self._default_addr = default_address + + class PortGetterProtocol(t.Protocol): + def __call__( + self, + *, + address: t.Optional[str] = None, + port: t.Optional[int] = None, + proto: socket.SocketKind = socket.SOCK_STREAM, + ) -> t.Tuple[str, int]: + pass + + def get_port( + self, + *, + address: t.Optional[str] = None, + port: t.Optional[int] = None, + proto: socket.SocketKind = socket.SOCK_STREAM, + ) -> t.Tuple[str, int]: + if not self._entered: + raise RuntimeError("Enter the context manager before calling get_port.") + + if address is None: + address = self._default_addr + + s = socket.socket(socket.AF_INET, type=proto) + + if port is not None: + try: + s.bind((address, port)) + except OSError: + s.bind((address, 0)) + else: + s.bind((address, 0)) + + self._sockets.append(s) + return s.getsockname() + + def __enter__(self): + if self._entered: + raise RuntimeError( + "This context manager can only be entered once at a time. Exit first or use a new instance." + ) + self._entered = True + self._sockets.clear() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + for sock in self._sockets: + try: + sock.close() + except Exception: + pass + self._sockets.clear() + self._entered = False + + def get_free_port(): + # TODO: deprecated: remove in favor of port getter class once LDAP mock is refactored """Small helper method used to discover an open port to use by mock API HTTP servers. :return: An available port number. @@ -140,13 +204,13 @@ def load_config_file(filename): return test_data -def get_config_file_path(filename): +def get_config_file_path(*components): """Get the path to a config file under the "tests/config_files" directory. - I.e., the directory containing self-signed certificates, configuration files, etc. that are used for various tests. + I.e., the directory containing self-signed certificates, configuration files, etc. that are used for various tests. - :param filename: Name of the test data file. - :type filename: str | unicode + :param components: One or more path components, the last of which is usually the name of the test data file. + :type components: str | unicode :return: The absolute path to the test data directory. :rtype: str | unicode """ @@ -154,10 +218,10 @@ def get_config_file_path(filename): relative_path = os.path.join( os.path.dirname(os.path.realpath(__file__)), "..", "config_files" ) - return os.path.join(os.path.abspath(relative_path), filename) + return os.path.join(os.path.abspath(relative_path), *components) -def decode_generated_root_token(encoded_token, otp): +def decode_generated_root_token(encoded_token, otp, url): """Decode a newly generated root token via Vault CLI. :param encoded_token: The token to decode. @@ -177,7 +241,7 @@ def decode_generated_root_token(encoded_token, otp): [ "generate-root", "-address", - "https://127.0.0.1:8200", + url, "-tls-skip-verify", "-decode", encoded_token, diff --git a/tests/utils/hvac_integration_test_case.py b/tests/utils/hvac_integration_test_case.py index b7cb920e3..005249703 100644 --- a/tests/utils/hvac_integration_test_case.py +++ b/tests/utils/hvac_integration_test_case.py @@ -1,19 +1,23 @@ #!/usr/bin/env python import logging import re +import time from tests.utils import get_config_file_path, create_client, is_enterprise from tests.utils.server_manager import ServerManager import distutils.spawn +from hvac import Client class HvacIntegrationTestCase: """Base class intended to be used by all hvac integration test cases.""" - manager = None - client = None - enable_vault_ha = False - use_env = False + manager: ServerManager = None + client: Client = None + enable_vault_ha: bool = False + use_env: bool = False + server_retry_count: int = 2 # num retries not total tries + server_retry_delay_seconds: float = 0.1 @classmethod def setUpClass(cls): @@ -37,16 +41,25 @@ def setUpClass(cls): ] cls.manager = ServerManager( config_paths=config_paths, - client=create_client(), use_consul=cls.enable_vault_ha, ) - try: - cls.manager.start() - cls.manager.initialize() - cls.manager.unseal() - except Exception: - cls.manager.stop() - raise + while True: + try: + cls.manager.start() + cls.manager.initialize() + cls.manager.unseal() + except Exception as e: + cls.manager.stop() + logging.debug( + f"Failure in ServerManager (retries remaining: {cls.server_retry_count})\n{str(e)}" + ) + if cls.server_retry_count > 0: + cls.server_retry_count -= 1 + time.sleep(cls.server_retry_delay_seconds) + else: + raise + else: + break @classmethod def tearDownClass(cls): @@ -56,7 +69,7 @@ def tearDownClass(cls): def setUp(self): """Set the client attribute to an authenticated hvac Client instance.""" - self.client = create_client(token=self.manager.root_token, use_env=self.use_env) + self.client = self.manager.client def tearDown(self): """Ensure the hvac Client instance's root token is reset after any auth method tests that may have modified it. diff --git a/tests/utils/server_manager.py b/tests/utils/server_manager.py index 2cb08aa26..d402174ab 100644 --- a/tests/utils/server_manager.py +++ b/tests/utils/server_manager.py @@ -5,18 +5,55 @@ import time import requests import hcl +import typing as t import distutils.spawn from unittest import SkipTest -from tests.utils import get_config_file_path, load_config_file, create_client +from tests.utils import ( + get_config_file_path, + load_config_file, + create_client, + PortGetter, +) + +from hvac.v1 import Client logger = logging.getLogger(__name__) +class TestProcessInfo: + name: str + process: subprocess.Popen + extra: t.List[str] + + def __init__( + self, name: str, process: subprocess.Popen, *extra: t.List[str] + ) -> None: + self.name = name + self.process = process + self.extra = extra + + def log_name(self, index: int, *suffixes: t.List[str], ext: str = ".log"): + segmented = "_".join([self.name, str(index), *self.extra, *suffixes]) + return f"{segmented}{ext}" + + class ServerManager: """Runs vault process running with test configuration and associates a hvac Client instance with this process.""" - def __init__(self, config_paths, client, use_consul=False): + active_config_paths: t.Optional[t.List[str]] + config_paths: t.List[str] + client: t.Optional[Client] + use_consul: bool + patch_config: bool + + def __init__( + self, + config_paths: t.List[str], + client: Client = None, + use_consul: bool = False, + patch_config: bool = True, + ): """Set up class attributes for managing a vault server process. :param config_paths: Full path to the Vault config to use when launching `vault server`. @@ -24,40 +61,113 @@ def __init__(self, config_paths, client, use_consul=False): :param client: Hvac Client that is used to initialize the vault server process. :type client: hvac.v1.Client """ + + self.active_config_paths = None self.config_paths = config_paths self.client = client self.use_consul = use_consul + self.patch_config = patch_config self.keys = None self.root_token = None - self._processes = [] + self._processes: t.List[TestProcessInfo] = [] + + def patch_config_port( + self, + config_file: str, + *, + port_getter: PortGetter.PortGetterProtocol, + insert: bool = False, + address: str = None, + additional_sections: t.Optional[t.Dict[str, t.Any]] = None, + output_dir: str = "generated", + ): + worker = os.getenv("PYTEST_XDIST_WORKER", "solo") + config_parent = os.path.dirname(config_file) + if not os.path.isabs(output_dir): + output_dir = os.path.join(config_parent, output_dir) + output_file = os.path.join( + output_dir, os.path.basename(config_file).replace(".hcl", f"_{worker}.json") + ) + + with open(config_file, "r") as f: + config: dict = hcl.load(f) + + if "listener" in config: + listeners = config["listener"] + if not isinstance(listeners, list): + listeners = [listeners] + for linstances in listeners: + if "tcp" in linstances: + listener = linstances["tcp"] + if "address" in listener: + addr, port = listener["address"].split(":") + if address is not None: + addr = address + addr, port = port_getter(address=addr, port=int(port)) + listener["address"] = ":".join((addr, str(port))) + elif insert: + addr, port = port_getter(address=address) + listener["address"] = ":".join((addr, str(port))) + + if additional_sections is not None: + config.update(additional_sections) + + with open(output_file, "w") as f: + hcl.api.json.dump(config, f, indent=4) + + return output_file def start(self): - """Launch the vault server process and wait until its online and ready.""" + consul_config = None if self.use_consul: - self.start_consul() + consul_addr = self.start_consul() + consul_config = { + "storage": { + "consul": { + "address": consul_addr, + "path": "vault_whatever/", + } + } + } + self.start_vault(consul_config=consul_config) + def start_vault( + self, *, consul_config: dict = None, attempt=1, max_attempts=3, delay_s=1 + ): + """Launch the vault server process and wait until its online and ready.""" if distutils.spawn.find_executable("vault") is None: raise SkipTest("Vault executable not found") - # If a vault server is already running then we won't be able to start another one. - # If we can't start our vault server then we don't know what we're testing against. - try: - self.client.sys.is_initialized() - except Exception: - pass - else: - raise Exception("Vault server already running") + with PortGetter() as g: + self.active_config_paths = [ + self.patch_config_port( + config_path, + port_getter=g.get_port, + insert=True, + additional_sections=consul_config, + ) + if self.patch_config + else config_path + for config_path in self.config_paths + ] cluster_ready = False - for config_path in self.config_paths: + for config_path in self.active_config_paths: + this_addr = self.get_config_vault_address(config_path) + this_client = create_client(url=this_addr) + if self.client is None: + self.client = this_client + command = ["vault", "server", "-config=" + config_path] logger.debug(f"Starting vault server with command: {command}") process = subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) - self._processes.append(process) + self._processes.append( + TestProcessInfo("vault", process, os.path.basename(config_path)) + ) logger.debug(f"Spawned vault server with PID {process.pid}") attempts_left = 20 @@ -65,12 +175,26 @@ def start(self): while attempts_left > 0 and not cluster_ready: try: logger.debug("Checking if vault is ready...") - self.client.sys.is_initialized() + this_client.sys.is_initialized() cluster_ready = True break except Exception as ex: if process.poll() is not None: - raise Exception("Vault server terminated before becoming ready") + stdout, stderr = process.stdout, process.stderr + if attempt < max_attempts: + logger.debug( + f"Starting Vault failed (attempt {attempt} of {max_attempts}):\n{last_exception}\n{stdout.readlines()}\n{stderr.readlines()}" + ) + time.sleep(delay_s) + self.start_vault( + attempt=(attempt + 1), + max_attempts=max_attempts, + delay_s=delay_s, + ) + else: + raise Exception( + "Vault server terminated before becoming ready" + ) logger.debug("Waiting for Vault to start") time.sleep(0.5) attempts_left -= 1 @@ -79,55 +203,83 @@ def start(self): if process.poll() is None: process.kill() stdout, stderr = process.communicate() - raise Exception( - "Unable to start Vault in background:\n{err}\n{stdout}\n{stderr}".format( - err=last_exception, - stdout=stdout, - stderr=stderr, + if attempt < max_attempts: + logger.debug( + f"Vault never became ready (attempt {attempt} of {max_attempts}):\n{last_exception}\n{stdout}\n{stderr}" + ) + time.sleep(delay_s) + self.start_vault( + attempt=(attempt + 1), + max_attempts=max_attempts, + delay_s=delay_s, + ) + else: + raise Exception( + "Unable to start Vault in background:\n{err}\n{stdout}\n{stderr}".format( + err=last_exception, + stdout=stdout, + stderr=stderr, + ) ) - ) - def start_consul(self): + def start_consul( + self, + ) -> str: if distutils.spawn.find_executable("consul") is None: raise SkipTest("Consul executable not found") - try: - requests.get("http://127.0.0.1:8500/v1/catalog/nodes") - except Exception: - pass - else: - raise Exception("Consul service already running") + with PortGetter() as g: + http_addr, http_port = g.get_port() + _, server_port = g.get_port(address=http_addr) + _, serf_lan_port = g.get_port(address=http_addr) + _, serf_wan_port = g.get_port(address=http_addr) + consul_addr = f"{http_addr}:{http_port}" + command = [ + "consul", + "agent", + "-dev", + "-disable-host-node-id", + f"-serf-lan-port={serf_lan_port}", + f"-serf-wan-port={serf_wan_port}", + f"-server-port={server_port}", + "-grpc-port=-1", + "-grpc-tls-port=-1", + f"-bind={http_addr}", + f"-http-port={http_port}", + "-dns-port=-1", + ] - command = ["consul", "agent", "-dev"] logger.debug(f"Starting consul service with command: {command}") process = subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) - self._processes.append(process) + self._processes.append( + TestProcessInfo("consul", process, os.getenv("PYTEST_XDIST_WORKER", "solo")) + ) attempts_left = 20 last_exception = None while attempts_left > 0: try: catalog_nodes_response = requests.get( - "http://127.0.0.1:8500/v1/catalog/nodes" + f"http://{consul_addr}/v1/catalog/nodes" ) nodes_list = catalog_nodes_response.json() logger.debug( - "JSON response from request to consul/v1/catalog/noses: {resp}".format( + "JSON response from request to consul/v1/catalog/nodes: {resp}".format( resp=nodes_list ) ) node_name = nodes_list[0]["Node"] logger.debug(f"Current consul node name: {node_name}") node_health_response = requests.get( - f"http://127.0.0.1:8500/v1/health/node/{node_name}" + f"http://{consul_addr}/v1/health/node/{node_name}" ) node_health = node_health_response.json() logger.debug(f"Node health response: {node_health}") assert ( node_health[0]["Status"] == "passing" ), f'Node {node_name} status != "passing"' - return True + return consul_addr except Exception as error: if process.poll() is not None: raise Exception("Consul service terminated before becoming ready") @@ -144,28 +296,46 @@ def start_consul(self): def stop(self): """Stop the vault server process being managed by this class.""" - for process_num, process in enumerate(self._processes): - logger.debug(f"Terminating vault server with PID {process.pid}") - if process.poll() is None: - process.kill() + self.client = None + for process_num, pinfo in reversed(list(enumerate(self._processes))): + logger.debug( + f"Terminating {pinfo.name} server with PID {pinfo.process.pid}" + ) + if pinfo.process.poll() is None: + pinfo.process.kill() + if os.getenv("HVAC_OUTPUT_VAULT_STDERR", False): - stdout_lines, stderr_lines = process.communicate() - stderr_filename = f"vault{process_num}_stderr.log" - with open(get_config_file_path(stderr_filename), "w") as f: - logger.debug(stderr_lines.decode()) - f.writelines(stderr_lines.decode()) - stdout_filename = f"vault{process_num}_stdout.log" - with open(get_config_file_path(stdout_filename), "w") as f: - logger.debug(stdout_lines.decode()) - f.writelines(stdout_lines.decode()) + try: + stdout_lines, stderr_lines = pinfo.process.communicate() + except ValueError: + pass + else: + log_dir = get_config_file_path("generated", "logs") + try: + os.mkdir(log_dir) + except FileExistsError: + pass + stderr_filename = pinfo.log_name(process_num, "stderr") + stderr_path = get_config_file_path(log_dir, stderr_filename) + with open(stderr_path, "w") as f: + logger.debug(stderr_lines.decode()) + f.writelines(stderr_lines.decode()) + stdout_filename = pinfo.log_name(process_num, "stdout") + stdout_path = get_config_file_path(log_dir, stdout_filename) + with open(get_config_file_path(stdout_path), "w") as f: + logger.debug(stdout_lines.decode()) + f.writelines(stdout_lines.decode()) def initialize(self): """Perform initialization of the vault server process and record the provided unseal keys and root token.""" - assert not self.client.sys.is_initialized() + if self.client.sys.is_initialized(): + raise RuntimeError( + f"Vault is already initialized: {self.get_active_vault_addresses()}" + ) - result = self.client.sys.initialize() + result = self.client.sys.initialize(secret_shares=5, secret_threshold=3) - self.root_token = result["root_token"] + self.root_token = self.client.token = result["root_token"] self.keys = result["keys"] def restart_vault_cluster(self, perform_init=True): @@ -174,29 +344,38 @@ def restart_vault_cluster(self, perform_init=True): if perform_init: self.initialize() + def get_config_vault_address(self, config_path: str) -> str: + config_hcl = load_config_file(config_path) + config = hcl.loads(config_hcl) + try: + vault_address = "https://{addr}".format( + addr=config["listener"]["tcp"]["address"] + ) + except KeyError as error: + logger.debug( + "Unable to find explicit Vault address in config file {path}: {err}".format( + path=config_path, + err=error, + ) + ) + vault_address = "https://127.0.0.1:8200" + logger.debug(f"Using default address: {vault_address}") + return vault_address + def get_active_vault_addresses(self): vault_addresses = [] - for config_path in self.config_paths: - config_hcl = load_config_file(config_path) - config = hcl.loads(config_hcl) - try: - vault_address = "https://{addr}".format( - addr=config["listener"]["tcp"]["address"] - ) - except KeyError as error: - logger.debug( - "Unable to find explicit Vault address in config file {path}: {err}".format( - path=config_path, - err=error, - ) - ) - vault_address = "https://127.0.0.1:8200" - logger.debug(f"Using default address: {vault_address}") - vault_addresses.append(vault_address) + config_paths = ( + self.active_config_paths + if self.active_config_paths is not None + else self.config_paths + ) + for config_path in config_paths: + vault_addresses.append(self.get_config_vault_address(config_path)) return vault_addresses def unseal(self): """Unseal the vault server process.""" vault_addresses = self.get_active_vault_addresses() for vault_address in vault_addresses: - create_client(url=vault_address).sys.submit_unseal_keys(self.keys) + client = create_client(url=vault_address) + client.sys.submit_unseal_keys(self.keys)