diff --git a/alts/shared/models.py b/alts/shared/models.py index 01eccae9..20ea7e42 100644 --- a/alts/shared/models.py +++ b/alts/shared/models.py @@ -238,6 +238,11 @@ class PulpLogsConfig(BaseLogsConfig): pulp_password: Optional[str] = None +class ThirdPartyRepoSshHost(BaseModel): + host: str + user: Optional[str] = None + + class CeleryConfig(BaseModel): def __init__(self, **data): super().__init__(**data) @@ -342,6 +347,12 @@ def __init__(self, **data): extra_alpine_repo_baseurl: str = '' epel_mirror_replacement: str = '' git_reference_directory: Optional[str] = None + # SSH client config seeded into test VMs so third-party test repos + # (gerrit, gitlab) can be cloned. Each entry maps to a `Host` block + # in `/root/.ssh/config` on the VM; missing `user` leaves the + # default (current SSH user). Configured per-deployment; empty by + # default so no `~/.ssh/config` is written unless explicitly set. + third_party_repo_ssh_hosts: List[ThirdPartyRepoSshHost] = [] tests_base_dir: str = '/tests' package_proxy: str = '' disabled_al_repos: List[str] = [] diff --git a/alts/shared/utils/git_utils.py b/alts/shared/utils/git_utils.py index bc70fc35..9cb23e62 100644 --- a/alts/shared/utils/git_utils.py +++ b/alts/shared/utils/git_utils.py @@ -43,7 +43,7 @@ def repo_reference_subpath(repo_url: str) -> str: host = scp_match.group('host') path = scp_match.group('path') if not host or not path: - # Unparseable URL — fall back to basename so behaviour stays + # Unparsable URL — fall back to basename so behaviour stays # predictable; collisions across hosts remain the caller's problem # in that degenerate case. basename = os.path.basename(repo_url) or 'repo' @@ -87,19 +87,20 @@ def __clone_git_repo( reference_directory: Optional[str] = None, cmd_timeout: int = 300, ): - git_repo_path = Path( - work_dir, - Path(repo_url).name.replace('.git', ''), - ) + subpath = repo_reference_subpath(repo_url) + if subpath.endswith('.git'): + subpath = subpath[:-4] + git_repo_path = Path(work_dir, subpath) if git_repo_path.exists(): return git_repo_path + git_repo_path.parent.mkdir(parents=True, exist_ok=True) logger.debug('Cloning the git repo: %s', repo_url) - args = ['clone', repo_url, '--depth', '1'] + args = ['clone', repo_url, str(git_repo_path), '--depth', '1'] if reference_directory: args.extend( ['--reference-if-able', get_abspath(reference_directory)] ) - exit_code, stdout, stderr = local['git'].with_cwd(work_dir).run( + exit_code, stdout, stderr = local['git'].run( args, retcode=None, timeout=cmd_timeout, diff --git a/alts/worker/runners/base.py b/alts/worker/runners/base.py index e45ddeda..2fdefa58 100644 --- a/alts/worker/runners/base.py +++ b/alts/worker/runners/base.py @@ -747,6 +747,10 @@ def initial_provision(self, verbose=False): 'pytest_is_needed': self.pytest_is_needed, 'development_mode': CONFIG.development_mode, 'package_proxy': CONFIG.package_proxy, + 'third_party_repo_ssh_hosts': [ + entry.model_dump(exclude_none=True) + for entry in CONFIG.third_party_repo_ssh_hosts + ], } dist_major_version = self.dist_version[0] if self.dist_name in CONFIG.rhel_flavors and dist_major_version in ('6', '7'): @@ -1425,9 +1429,12 @@ def find_and_run_init_tests(): if not test_repo_path: errors.append(f'Cannot clone test repository {repo_url}') continue + remote_subpath = repo_reference_subpath(repo_url) + if remote_subpath.endswith('.git'): + remote_subpath = remote_subpath[:-4] remote_workdir = os.path.join( CONFIG.tests_base_dir, - test_repo_path.name, + remote_subpath, test_dir, ) local_workdir = self._work_dir @@ -1482,9 +1489,12 @@ def find_and_run_init_tests(): if not test_repo_path: errors.append(f'Cannot clone test repository {repo_url}') continue + remote_subpath = repo_reference_subpath(repo_url) + if remote_subpath.endswith('.git'): + remote_subpath = remote_subpath[:-4] remote_workdir = os.path.join( CONFIG.tests_base_dir, - test_repo_path.name, + remote_subpath, test_dir, ) local_workdir = self._work_dir @@ -1883,15 +1893,16 @@ def clone_third_party_repo( if not git_repo_path: return if self._ssh_client: - repo_path = Path( - self._tests_dir, - Path(repo_url).name.replace('.git', ''), - ) + subpath = repo_reference_subpath(repo_url) + if subpath.endswith('.git'): + subpath = subpath[:-4] + repo_path = Path(self._tests_dir, subpath) result = None for attempt in range(1, 6): cmd = (f'if [ -e {repo_path} ]; then cd {repo_path} && ' f'git reset --hard origin/master && git checkout master && git pull; ' - f'else cd {self._tests_dir} && git clone {repo_url}; fi') + f'else mkdir -p {repo_path.parent} && ' + f'git clone {repo_url} {repo_path}; fi') result = self._ssh_client.sync_run_command(cmd) if result.is_successful(): break @@ -1903,10 +1914,6 @@ def clone_third_party_repo( if not result or (result and not result.is_successful()): return - repo_path = Path( - self._tests_dir, - Path(repo_url).name.replace('.git', ''), - ) command = f'git fetch origin && git checkout {git_ref}' if 'gerrit' in repo_url: command = prepare_gerrit_command(git_ref) diff --git a/alts/worker/runners/docker.py b/alts/worker/runners/docker.py index 9c9a0447..7c44d1ee 100644 --- a/alts/worker/runners/docker.py +++ b/alts/worker/runners/docker.py @@ -28,6 +28,7 @@ from alts.worker.executors.bats import BatsExecutor from alts.worker.executors.command import CommandExecutor from alts.worker.executors.shell import ShellExecutor +from alts.shared.utils.git_utils import repo_reference_subpath from alts.worker.runners.base import ( TESTS_SECTION_NAME, BaseRunner, @@ -376,10 +377,15 @@ def clone_third_party_repo( return self._logger.info('Copying tests to container') self._logger.debug('Repo path: %s', test_repo_path) - self.exec_command('mkdir', '-p', CONFIG.tests_base_dir) + remote_subpath = repo_reference_subpath(repo_url) + if remote_subpath.endswith('.git'): + remote_subpath = remote_subpath[:-4] + remote_repo_path = f'{CONFIG.tests_base_dir}/{remote_subpath}' + remote_parent = os.path.dirname(remote_repo_path) + self.exec_command('mkdir', '-p', remote_parent) self.copy([ str(test_repo_path), - f'{self.env_name}:{CONFIG.tests_base_dir}/{test_repo_path.name}', + f'{self.env_name}:{remote_repo_path}', ]) return test_repo_path diff --git a/resources/roles/preparation/defaults/main.yml b/resources/roles/preparation/defaults/main.yml index 9d92dba5..b3ae8f4d 100644 --- a/resources/roles/preparation/defaults/main.yml +++ b/resources/roles/preparation/defaults/main.yml @@ -1,3 +1,8 @@ --- procps_pkg: "procps" centos_repo_baseurl: "https://vault.centos.org" +# Source of truth lives in the alts config +# (`CelerConfig.third_party_repo_ssh_hosts`) and is passed in as an +# extra-var by the runner. Empty list here is a no-op fallback so the +# role still works if invoked standalone (e.g. ansible-lint). +third_party_repo_ssh_hosts: [] diff --git a/resources/roles/preparation/tasks/main.yml b/resources/roles/preparation/tasks/main.yml index fbe87f28..da73b4be 100644 --- a/resources/roles/preparation/tasks/main.yml +++ b/resources/roles/preparation/tasks/main.yml @@ -21,6 +21,47 @@ tags: - initial_provision +# SSH client config is only needed where the third-party repo is cloned +# *inside* the environment (VMs cloned over SSH). Docker envs receive the +# repo via `docker cp` from the worker, so they never clone and need no +# SSH config. +- name: Ensure root ~/.ssh directory exists + file: + path: /root/.ssh + state: directory + owner: root + group: root + mode: '0700' + when: + - connection_type != 'docker' + - third_party_repo_ssh_hosts | length > 0 + tags: + - initial_provision + +- name: Configure SSH client for third-party repo hosts + copy: + dest: /root/.ssh/config + owner: root + group: root + mode: '0600' + content: | + {% for entry in third_party_repo_ssh_hosts %} + Host {{ entry.host }} + {% if entry.user is defined %} + User {{ entry.user }} + {% endif %} + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + {% if not loop.last %} + + {% endif %} + {% endfor %} + when: + - connection_type != 'docker' + - third_party_repo_ssh_hosts | length > 0 + tags: + - initial_provision + - name: Copy tests to test environment copy: src: "{{ integrity_tests_dir }}" diff --git a/tests/runners/test_clone_paths_docker.py b/tests/runners/test_clone_paths_docker.py new file mode 100644 index 00000000..476e534a --- /dev/null +++ b/tests/runners/test_clone_paths_docker.py @@ -0,0 +1,85 @@ +"""Container-side path-collision regression tests. + +DockerRunner copies the locally-cloned QA tree into the container. +With the host-aware layout, two QA upstreams must be copied to two +distinct paths inside the container, matching the layout the test +runner expects when running tests by ``remote_workdir``. +""" +import logging +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from alts.worker import CONFIG +from alts.worker.runners.docker import DockerRunner + + +GERRIT_QA = 'ssh://gerrit.cloudlinux.com:29418/QA' +GITLAB_QA = 'git@gitlab.corp.cloudlinux.com:clos/ci-tools/QA.git' + + +def _make_runner(monkeypatch, env_name='env-x'): + runner = DockerRunner.__new__(DockerRunner) + runner._logger = logging.getLogger('test') + runner._env_name = env_name # env_name is a read-only property + runner.exec_command = MagicMock(return_value=(0, '', '')) + runner.copy = MagicMock(return_value=(0, '', '')) + + fake_local_clone = Path('/tmp/fake-local-clone') + monkeypatch.setattr( + 'alts.worker.runners.base.BaseRunner.clone_third_party_repo', + lambda self, repo_url, git_ref: fake_local_clone, + ) + return runner + + +class TestDockerCopyDestinationIsHostAware: + def test_gerrit_qa_copy_target(self, monkeypatch): + runner = _make_runner(monkeypatch) + runner.clone_third_party_repo(GERRIT_QA, 'master') + + expected_dest = ( + f'env-x:{CONFIG.tests_base_dir}/gerrit.cloudlinux.com/QA' + ) + runner.copy.assert_called_once() + copy_args = runner.copy.call_args.args[0] + assert copy_args[1] == expected_dest + + def test_gitlab_qa_copy_target(self, monkeypatch): + runner = _make_runner(monkeypatch) + runner.clone_third_party_repo(GITLAB_QA, 'master') + + expected_dest = ( + f'env-x:{CONFIG.tests_base_dir}' + '/gitlab.corp.cloudlinux.com/clos/ci-tools/QA' + ) + runner.copy.assert_called_once() + assert runner.copy.call_args.args[0][1] == expected_dest + + def test_parent_directory_is_created_in_container(self, monkeypatch): + runner = _make_runner(monkeypatch) + runner.clone_third_party_repo(GITLAB_QA, 'master') + + expected_parent = ( + f'{CONFIG.tests_base_dir}' + '/gitlab.corp.cloudlinux.com/clos/ci-tools' + ) + runner.exec_command.assert_called_once_with( + 'mkdir', '-p', expected_parent, + ) + + def test_two_qa_upstreams_get_distinct_container_paths(self, monkeypatch): + runner = _make_runner(monkeypatch) + runner.clone_third_party_repo(GERRIT_QA, 'master') + gerrit_dest = runner.copy.call_args_list[0].args[0][1] + runner.copy.reset_mock() + runner.exec_command.reset_mock() + runner.clone_third_party_repo(GITLAB_QA, 'master') + gitlab_dest = runner.copy.call_args_list[0].args[0][1] + + assert gerrit_dest != gitlab_dest + # Neither destination is the bare basename path. + bad = f'env-x:{CONFIG.tests_base_dir}/QA' + assert gerrit_dest != bad + assert gitlab_dest != bad diff --git a/tests/runners/test_clone_paths_vm.py b/tests/runners/test_clone_paths_vm.py new file mode 100644 index 00000000..480e1cfd --- /dev/null +++ b/tests/runners/test_clone_paths_vm.py @@ -0,0 +1,129 @@ +"""VM-side path-collision regression tests. + +The SSH-runner override of ``clone_third_party_repo`` decides where the +QA repo lives inside the test VM. With the old basename-only path, two +upstreams (gerrit ``QA`` and gitlab ``QA.git``) collided at ``/opt/QA``. +These tests pin the host-aware layout so a single-VM test session that +talks to both upstreams keeps them in separate checkouts. +""" +import logging +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from alts.worker.runners.base import GenericVMRunner + + +GERRIT_QA = 'ssh://gerrit.cloudlinux.com:29418/QA' +GITLAB_QA = 'git@gitlab.corp.cloudlinux.com:clos/ci-tools/QA.git' + + +def _make_runner(monkeypatch): + """Return a GenericVMRunner instance with just enough state for + ``clone_third_party_repo`` to run, without exercising the real + constructor (which boots terraform/uploaders/etc.). + """ + runner = GenericVMRunner.__new__(GenericVMRunner) + runner._tests_dir = '/opt' + runner._logger = logging.getLogger('test') + runner._ssh_client = MagicMock() + # ``is_successful`` is consulted on the return of each SSH call. + successful = SimpleNamespace( + is_successful=lambda: True, + exit_code=0, + stdout='', + stderr='', + ) + runner._ssh_client.sync_run_command.return_value = successful + + # Stub out the parent's clone_third_party_repo so the test doesn't + # try to do a real local clone — we only care about the VM-side + # command construction here. + monkeypatch.setattr( + 'alts.worker.runners.base.BaseRunner.clone_third_party_repo', + lambda self, repo_url, git_ref: Path('/tmp/fake-local-clone'), + ) + return runner + + +class TestVMRepoPathIsHostAware: + def test_gerrit_qa_uses_gerrit_host_subdir_on_vm(self, monkeypatch): + runner = _make_runner(monkeypatch) + runner.clone_third_party_repo(GERRIT_QA, 'master') + + # Two SSH commands fired: (1) the if/else clone, (2) fetch+checkout. + cmds = [ + call.args[0] + for call in runner._ssh_client.sync_run_command.call_args_list + ] + assert len(cmds) >= 1 + expected_repo_path = '/opt/gerrit.cloudlinux.com/QA' + expected_parent = '/opt/gerrit.cloudlinux.com' + assert f'if [ -e {expected_repo_path} ]' in cmds[0] + assert f'mkdir -p {expected_parent}' in cmds[0] + assert f'git clone {GERRIT_QA} {expected_repo_path}' in cmds[0] + + def test_gitlab_qa_uses_gitlab_path_subdir_on_vm(self, monkeypatch): + runner = _make_runner(monkeypatch) + runner.clone_third_party_repo(GITLAB_QA, 'master') + + cmds = [ + call.args[0] + for call in runner._ssh_client.sync_run_command.call_args_list + ] + expected_repo_path = ( + '/opt/gitlab.corp.cloudlinux.com/clos/ci-tools/QA' + ) + expected_parent = '/opt/gitlab.corp.cloudlinux.com/clos/ci-tools' + assert f'if [ -e {expected_repo_path} ]' in cmds[0] + assert f'mkdir -p {expected_parent}' in cmds[0] + assert f'git clone {GITLAB_QA} {expected_repo_path}' in cmds[0] + # Regression guard: the bare basename path must not appear at all. + assert '/opt/QA' not in cmds[0] + + def test_two_qa_upstreams_do_not_share_a_directory(self, monkeypatch): + """A single VM session that clones both upstreams ends up with + two distinct checkouts. This is the exact failure mode the patch + fixes: previously both landed at ``/opt/QA`` and the second + clone's branch wouldn't be visible from the first clone's remote. + """ + runner = _make_runner(monkeypatch) + runner.clone_third_party_repo(GERRIT_QA, 'master') + gerrit_cmd = ( + runner._ssh_client.sync_run_command.call_args_list[0].args[0] + ) + runner._ssh_client.sync_run_command.reset_mock() + runner.clone_third_party_repo(GITLAB_QA, 'master') + gitlab_cmd = ( + runner._ssh_client.sync_run_command.call_args_list[0].args[0] + ) + + # Different repo_path tokens appear in each command's if-guard. + assert ( + 'if [ -e /opt/gerrit.cloudlinux.com/QA ]' in gerrit_cmd + ) + assert ( + 'if [ -e /opt/gitlab.corp.cloudlinux.com/clos/ci-tools/QA ]' + in gitlab_cmd + ) + + def test_second_command_targets_host_aware_path(self, monkeypatch): + """After clone, the runner runs ``cd && git fetch + origin && git checkout ``. That path must match the new + host-aware one, otherwise the wrong checkout would be operated + on. + """ + runner = _make_runner(monkeypatch) + runner.clone_third_party_repo(GITLAB_QA, 'some-branch') + cmds = [ + call.args[0] + for call in runner._ssh_client.sync_run_command.call_args_list + ] + # Second command is the fetch+checkout. + assert ( + 'cd /opt/gitlab.corp.cloudlinux.com/clos/ci-tools/QA' + in cmds[1] + ) + assert 'git fetch origin && git checkout some-branch' in cmds[1] diff --git a/tests/runners/test_provision_ssh_hosts.py b/tests/runners/test_provision_ssh_hosts.py new file mode 100644 index 00000000..877e3064 --- /dev/null +++ b/tests/runners/test_provision_ssh_hosts.py @@ -0,0 +1,99 @@ +"""`initial_provision` passes the configured third-party SSH hosts to +Ansible as an extra-var, so the preparation role can render the VM's +`/root/.ssh/config`. + +The value comes from `CONFIG.third_party_repo_ssh_hosts` and is +serialized with `exclude_none=True` (so host-only entries don't carry +an empty `user`). +""" +import ast +import logging +import re + +import pytest + +from alts.worker import runners +from alts.worker.runners.base import GenericVMRunner +from alts.shared.models import ThirdPartyRepoSshHost + + +def _make_runner(tmp_path): + runner = GenericVMRunner.__new__(GenericVMRunner) + runner._work_dir = str(tmp_path) + runner._artifacts = {} + runner._stats = {} + runner.already_aborted = True # skip the abort check in the decorator + runner._logger = logging.getLogger('test') + runner._repositories = [] + runner._integrity_tests_dir = str(tmp_path / 'tests') + runner._ansible_connection_type = 'ssh' + runner._test_env = {'pytest_is_needed': False} + runner._dist_name = 'almalinux' + runner._dist_version = '9' + runner._env_name = 'env-test' + return runner + + +def _extract_extra_vars(cmd_args): + """Pull the `-e ` payload out of the ansible args and + parse it back into a Python dict. + """ + idx = cmd_args.index('-e') + return ast.literal_eval(cmd_args[idx + 1]) + + +class TestInitialProvisionSshHosts: + def test_extra_vars_include_configured_hosts( + self, tmp_path, monkeypatch, + ): + monkeypatch.setattr( + runners.base.CONFIG, + 'third_party_repo_ssh_hosts', + [ + ThirdPartyRepoSshHost( + host='gerrit.cloudlinux.com', + user='alternatives', + ), + ThirdPartyRepoSshHost(host='gitlab.corp.cloudlinux.com'), + ], + raising=False, + ) + captured = {} + + def fake_run(cmd_args, timeout=None): + captured['args'] = cmd_args + return 0, '', '' + + runner = _make_runner(tmp_path) + runner.run_ansible_command = fake_run + + runner.initial_provision() + + extra_vars = _extract_extra_vars(captured['args']) + assert extra_vars['third_party_repo_ssh_hosts'] == [ + {'host': 'gerrit.cloudlinux.com', 'user': 'alternatives'}, + {'host': 'gitlab.corp.cloudlinux.com'}, # no empty `user` + ] + + def test_extra_vars_empty_when_unconfigured( + self, tmp_path, monkeypatch, + ): + monkeypatch.setattr( + runners.base.CONFIG, + 'third_party_repo_ssh_hosts', + [], + raising=False, + ) + captured = {} + + def fake_run(cmd_args, timeout=None): + captured['args'] = cmd_args + return 0, '', '' + + runner = _make_runner(tmp_path) + runner.run_ansible_command = fake_run + + runner.initial_provision() + + extra_vars = _extract_extra_vars(captured['args']) + assert extra_vars['third_party_repo_ssh_hosts'] == [] diff --git a/tests/shared/test_clone_paths.py b/tests/shared/test_clone_paths.py new file mode 100644 index 00000000..4cfcc51c --- /dev/null +++ b/tests/shared/test_clone_paths.py @@ -0,0 +1,140 @@ +"""Path-collision regression tests for QA-repo cloning. + +When two repositories share a basename (e.g. gerrit's `QA` and gitlab's +`QA.git`), the old code would land both at `/QA`, so a checkout of +one upstream would silently satisfy the reuse guard for the other and a +branch from the second upstream would fail to check out. + +These tests pin the host-aware layout so the collision can't come back. +""" +import logging +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from alts.shared.utils import git_utils +from alts.shared.utils.git_utils import ( + clone_git_repo, + repo_reference_subpath, +) + + +GERRIT_QA = 'ssh://gerrit.cloudlinux.com:29418/QA' +GITLAB_QA = 'git@gitlab.corp.cloudlinux.com:clos/ci-tools/QA.git' + + +@pytest.fixture +def fake_git(monkeypatch): + """Replace ``plumbum.local['git']`` with a recording stub. + + Returns the stub so tests can inspect the args the clone was invoked + with. The stub also marks the cloned directory as existing so the + subsequent ``checkout`` call doesn't blow up on a missing path. + """ + # `clone_args` captures the `git clone ...` invocation specifically; + # `__clone_git_repo` uses `local['git']` for the clone and `checkout` + # later uses `local['bash']`, and the fake serves the same runner for + # both, so we must not just keep "the last call". + recorded = {'clone_args': None} + git_runner = MagicMock() + + def run(args, retcode=None, timeout=None): + # `args[2]` is the destination path with the new layout + # (`['clone', url, dest, '--depth', '1', ...]`). + if len(args) >= 3 and args[0] == 'clone': + recorded['clone_args'] = list(args) + Path(args[2]).mkdir(parents=True, exist_ok=True) + return 0, '', '' + + git_runner.run.side_effect = run + git_runner.with_cwd.return_value = git_runner + + fake_local = MagicMock() + fake_local.__getitem__.return_value = git_runner + + monkeypatch.setattr(git_utils, 'local', fake_local) + # `checkout` shells out via the same `local`; the bash stub just no-ops. + return recorded + + +class TestCloneDestinationIsHostAware: + def test_gerrit_qa_lands_under_gerrit_host_dir( + self, tmp_path, fake_git, + ): + result = clone_git_repo( + GERRIT_QA, 'master', tmp_path, logging.getLogger('test'), + ) + # `repo_reference_subpath` strips trailing `.git` for the on-disk path. + expected = tmp_path / 'gerrit.cloudlinux.com' / 'QA' + assert result == expected + # `git clone --depth 1` — third positional arg is dest. + assert fake_git['clone_args'][0:3] == ['clone', GERRIT_QA, str(expected)] + assert expected.exists() + + def test_gitlab_qa_lands_under_gitlab_path_dir( + self, tmp_path, fake_git, + ): + result = clone_git_repo( + GITLAB_QA, 'master', tmp_path, logging.getLogger('test'), + ) + expected = ( + tmp_path + / 'gitlab.corp.cloudlinux.com' + / 'clos' + / 'ci-tools' + / 'QA' + ) + assert result == expected + assert fake_git['clone_args'][0:3] == ['clone', GITLAB_QA, str(expected)] + assert expected.exists() + + def test_gerrit_and_gitlab_qa_do_not_collide( + self, tmp_path, fake_git, + ): + gerrit_path = clone_git_repo( + GERRIT_QA, 'master', tmp_path, logging.getLogger('test'), + ) + gitlab_path = clone_git_repo( + GITLAB_QA, 'master', tmp_path, logging.getLogger('test'), + ) + assert gerrit_path != gitlab_path + # Neither path should be a prefix of the other. + assert not str(gitlab_path).startswith(f'{gerrit_path}/') + assert not str(gerrit_path).startswith(f'{gitlab_path}/') + + def test_parent_directories_are_created(self, tmp_path, fake_git): + # Cleanly missing intermediate dirs (`gitlab.corp.../clos/ci-tools`) + # must be created before `git clone` runs, otherwise the subprocess + # would error out. + clone_git_repo( + GITLAB_QA, 'master', tmp_path, logging.getLogger('test'), + ) + assert ( + tmp_path + / 'gitlab.corp.cloudlinux.com' + / 'clos' + / 'ci-tools' + ).is_dir() + + +class TestRepoReferenceSubpathMatchesCloneLayout: + """Guards the contract the runners rely on: the on-disk layout + used by ``__clone_git_repo`` and the layout used to derive + ``remote_workdir`` / VM repo paths are the *same* subpath, modulo + the trailing ``.git`` which is stripped for the working tree. + """ + + @pytest.mark.parametrize( + 'url', + [GERRIT_QA, GITLAB_QA], + ) + def test_subpath_minus_git_suffix_is_used_for_checkout_dir(self, url): + subpath = repo_reference_subpath(url) + assert subpath.endswith('.git') + working_tree_subpath = subpath[:-4] + # The host segment is always present so two different upstreams + # with the same basename always diverge at this segment. + assert '/' in working_tree_subpath + host, _, _ = working_tree_subpath.partition('/') + assert host # non-empty host segment diff --git a/tests/shared/test_third_party_ssh_hosts.py b/tests/shared/test_third_party_ssh_hosts.py new file mode 100644 index 00000000..839b8457 --- /dev/null +++ b/tests/shared/test_third_party_ssh_hosts.py @@ -0,0 +1,46 @@ +"""Tests for the third-party-repo SSH host config model. + +`ThirdPartyRepoSshHost` entries are seeded into a test VM's +`/root/.ssh/config` so QA repos on gerrit/gitlab can be cloned. The +`user` field is optional and must be omitted from the serialized form +when unset (the Ansible template keys off `user is defined`). +""" +import pytest + +from alts.shared.models import CeleryConfig, ThirdPartyRepoSshHost + + +class TestThirdPartyRepoSshHost: + def test_host_only_entry_omits_user_when_serialized(self): + entry = ThirdPartyRepoSshHost(host='gitlab.corp.cloudlinux.com') + dumped = entry.model_dump(exclude_none=True) + assert dumped == {'host': 'gitlab.corp.cloudlinux.com'} + # `user` must NOT be present, otherwise the Jinja `is defined` + # guard would emit an empty `User` line. + assert 'user' not in dumped + + def test_host_with_user_keeps_user_when_serialized(self): + entry = ThirdPartyRepoSshHost( + host='gerrit.cloudlinux.com', + user='alternatives', + ) + dumped = entry.model_dump(exclude_none=True) + assert dumped == { + 'host': 'gerrit.cloudlinux.com', + 'user': 'alternatives', + } + + def test_host_is_required(self): + with pytest.raises(Exception): + ThirdPartyRepoSshHost() + + +class TestCeleryConfigDefault: + def test_third_party_repo_ssh_hosts_defaults_to_empty(self): + # No built-in hosts: an unconfigured deployment writes no + # ~/.ssh/config (the role task is gated on a non-empty list). + config = CeleryConfig.__new__(CeleryConfig) + field = CeleryConfig.model_fields['third_party_repo_ssh_hosts'] + # Pydantic stores the default factory / default value. + default = field.get_default(call_default_factory=True) + assert default == []