diff options
| author | Egor Tensin <egor@tensin.name> | 2025-11-24 00:47:29 +0100 |
|---|---|---|
| committer | Egor Tensin <egor@tensin.name> | 2025-11-27 01:31:43 +0100 |
| commit | c4b418a791acf23742195709b3be777128c55ce1 (patch) | |
| tree | e04967a81122048592be935647e63eeb89c0fb79 | |
| parent | fix some ansible-lint warnings (diff) | |
| download | infra-ansible-c4b418a791acf23742195709b3be777128c55ce1.tar.gz infra-ansible-c4b418a791acf23742195709b3be777128c55ce1.zip | |
plugins: add safe_git
| -rwxr-xr-x | plugins/action/etckeeper_commit.py | 22 | ||||
| -rwxr-xr-x | plugins/action/safe_git.py | 64 | ||||
| -rw-r--r-- | plugins/module_utils/file.py | 13 | ||||
| -rw-r--r-- | plugins/module_utils/git.py | 96 | ||||
| -rw-r--r-- | roles/my_dotfiles/tasks/main.yml | 3 | ||||
| -rw-r--r-- | roles/my_workspace/tasks/project.yml | 3 |
6 files changed, 177 insertions, 24 deletions
diff --git a/plugins/action/etckeeper_commit.py b/plugins/action/etckeeper_commit.py index 065b9eb..d16e66f 100755 --- a/plugins/action/etckeeper_commit.py +++ b/plugins/action/etckeeper_commit.py @@ -4,6 +4,7 @@ import re from ansible.errors import AnsibleActionFail from ansible.plugins.action import ActionBase +from ansible_collections.tensin.infra.plugins.module_utils import git class ActionModule(ActionBase): @@ -15,7 +16,7 @@ class ActionModule(ActionBase): allowlist = [re.compile(pattern) for pattern in allowlist] skip_no_changes = self._task.args.get('skip_no_changes', False) - dirty_files = self._get_dirty_file_list('/etc', task_vars) + dirty_files = git.get_dirty_file_list(self, '/etc', task_vars) if not dirty_files: if skip_no_changes: return result @@ -31,25 +32,6 @@ class ActionModule(ActionBase): return self._commit(msg, task_vars) - def _get_dirty_file_list(self, repo_dir, task_vars): - status = self._get_status(repo_dir, task_vars) - status = status.splitlines() - status = [line[3:] for line in status] - return status - - def _get_status(self, repo_dir, task_vars): - cmd_result = self._execute_module( - module_name='ansible.builtin.command', - module_args=dict( - argv=['git', 'status', '--porcelain'], - chdir=repo_dir, - ), - task_vars=task_vars, - ) - if cmd_result.get('failed'): - raise AnsibleActionFail(f"Couldn't get git status for {repo_dir}", result=cmd_result) - return cmd_result['stdout'] - def _commit(self, msg, task_vars): cmd_result = self._execute_module( module_name='ansible.builtin.command', diff --git a/plugins/action/safe_git.py b/plugins/action/safe_git.py new file mode 100755 index 0000000..7615236 --- /dev/null +++ b/plugins/action/safe_git.py @@ -0,0 +1,64 @@ +#!/usr/bin/python + +from ansible.errors import AnsibleActionFail +from ansible.plugins.action import ActionBase +from ansible_collections.tensin.infra.plugins.module_utils import file, git + + +class ActionModule(ActionBase): + def run(self, tmp=None, task_vars=None): + result = super().run(tmp, task_vars) + + repo = self._task.args['repo'] + dest = self._task.args['dest'] + accept_hostkey = self._task.args.get('accept_hostkey', False) + remote = self._task.args.get('remote', 'origin') + version = self._task.args['version'] + + builtin_args = { + 'repo': repo, + 'dest': dest, + 'accept_hostkey': accept_hostkey, + 'remote': remote, + 'version': version, + } + + if not file.does_dir_exist(self, dest, task_vars): + # If the destination directory doesn't exist, delegate to the + # builtin module. + return self._execute_module( + module_name='ansible.builtin.git', + module_args=builtin_args, + task_vars=task_vars, + ) + + # ansible.builtin.git would just remove everything, fuck that. + if not git.is_work_tree(self, dest, task_vars): + raise AnsibleActionFail(f'Destination {dest} exists but is not a repository') + + # Again, ansible.builtin.git would just remove everything, fuck that. + current_url = git.get_remote_url(self, dest, task_vars, remote=remote) + if current_url != repo: + raise AnsibleActionFail(f"Existing remote URL '{current_url}' doesn't match '{repo}' for {dest}") + + # ansible.builtin.git would abort if the `force` parameter is set to + # `false`, but I don't want to depend on default values. + dirty_files = git.get_dirty_file_list(self, dest, task_vars) + if dirty_files: + raise AnsibleActionFail(f'There are local changes in {dest}') + + # Fetch the remote. + git.update_remote(self, dest, remote, task_vars, accept_hostkey=accept_hostkey) + + # If the current HEAD is not an ancestor of the requested version, fail + # (again, unlike the builtin module). + is_ancestor = git.is_ancestor(self, dest, remote, version, task_vars) + if not is_ancestor: + raise AnsibleActionFail(f'HEAD in {dest} is not an ancestor of {remote}/{version}') + + # Finally, everything looks good, delegate to the builtin module. + return self._execute_module( + module_name='ansible.builtin.git', + module_args=builtin_args, + task_vars=task_vars, + ) diff --git a/plugins/module_utils/file.py b/plugins/module_utils/file.py index 86a4f27..4492f81 100644 --- a/plugins/module_utils/file.py +++ b/plugins/module_utils/file.py @@ -4,7 +4,7 @@ from ansible.errors import AnsibleActionFail from ansible.module_utils.common.text.converters import to_text -def does_file_exist(action, path, task_vars): +def _get_stat(action, path, task_vars): stat_result = action._execute_module( module_name='ansible.builtin.stat', module_args=dict( @@ -17,7 +17,16 @@ def does_file_exist(action, path, task_vars): ) if stat_result.get('failed'): raise AnsibleActionFail(f"Couldn't stat file {path}", result=stat_result) - return stat_result['stat']['exists'] + return stat_result['stat'] + + +def does_file_exist(action, path, task_vars): + return _get_stat(action, path, task_vars)['exists'] + + +def does_dir_exist(action, path, task_vars): + stat = _get_stat(action, path, task_vars) + return stat['exists'] and stat.get('isdir', False) def read_file(action, path, task_vars, default=False): diff --git a/plugins/module_utils/git.py b/plugins/module_utils/git.py new file mode 100644 index 0000000..dec54c5 --- /dev/null +++ b/plugins/module_utils/git.py @@ -0,0 +1,96 @@ +import os +import shlex + +from ansible.errors import AnsibleActionFail +from ansible.module_utils.common.text.converters import to_text + + +def is_work_tree(action, repo_dir, task_vars): + cmd_result = action._execute_module( + module_name='ansible.builtin.command', + module_args=dict( + argv=['git', 'rev-parse', '--is-inside-work-tree'], + chdir=repo_dir, + ), + task_vars=task_vars, + ) + return not cmd_result.get('failed') + + +def get_status(action, repo_dir, task_vars): + cmd_result = action._execute_module( + module_name='ansible.builtin.command', + module_args=dict( + argv=['git', 'status', '--porcelain'], + chdir=repo_dir, + ), + task_vars=task_vars, + ) + if cmd_result.get('failed'): + raise AnsibleActionFail(f"Couldn't get git status for {repo_dir}", result=cmd_result) + return to_text(cmd_result['stdout']) + + +def get_dirty_file_list(action, repo_dir, task_vars): + status = get_status(action, repo_dir, task_vars) + status = status.splitlines() + status = [line[3:] for line in status] + return status + + +def get_remote_url(action, repo_dir, task_vars, remote='origin'): + cmd_result = action._execute_module( + module_name='ansible.builtin.command', + module_args=dict( + argv=['git', 'config', '--local', f'remote.{remote}.url'], + chdir=repo_dir, + ), + task_vars=task_vars, + ) + if cmd_result.get('failed'): + raise AnsibleActionFail(f"Couldn't read the URL of remote '{remote}' in {repo_dir}", result=cmd_result) + return cmd_result['stdout'] + + +def update_remote(action, repo_dir, remote, task_vars, accept_hostkey=False): + ssh = os.environ.get('GIT_SSH', 'ssh') + ssh_opts = '' + if accept_hostkey: + ssh_opts += ' -o StrictHostKeyChecking=no' + ssh_command = shlex.quote(ssh) + ssh_opts + + cmd_result = action._execute_module( + module_name='ansible.builtin.command', + module_args=dict( + argv=['env', f'GIT_SSH_COMMAND={ssh_command}', 'git', 'remote', 'update', remote], + chdir=repo_dir, + ), + task_vars=task_vars, + ) + if cmd_result.get('failed'): + raise AnsibleActionFail(f"Couldn't update remote '{remote}' for {repo_dir}", result=cmd_result) + + +def is_ancestor(action, repo_dir, remote, version, task_vars): + looks_like_hash = all(((c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') for c in version)) + + remote_versions = [f'{remote}/{version}'] + if looks_like_hash: + remote_versions += [version] + + for _version in remote_versions: + cmd_result = action._execute_module( + module_name='ansible.builtin.command', + module_args=dict( + argv=['git', 'merge-base', '--is-ancestor', 'HEAD', _version], + chdir=repo_dir, + ), + task_vars=task_vars, + ) + if cmd_result.get('failed'): + if cmd_result.get('stderr'): + continue + else: + return True + + raise AnsibleActionFail(f"Couldn't deduce how the HEAD of {repo_dir} relates to remote version {remote}/{version}") diff --git a/roles/my_dotfiles/tasks/main.yml b/roles/my_dotfiles/tasks/main.yml index 83ec2a3..338260b 100644 --- a/roles/my_dotfiles/tasks/main.yml +++ b/roles/my_dotfiles/tasks/main.yml @@ -1,7 +1,8 @@ - name: Pull repository - ansible.builtin.git: # noqa: latest + tensin.infra.safe_git: # noqa: latest repo: '{{ my_dotfiles_repo }}' dest: '{{ my_dotfiles_dir }}' + version: master accept_hostkey: true register: my_dotfiles_repo diff --git a/roles/my_workspace/tasks/project.yml b/roles/my_workspace/tasks/project.yml index efce065..58ccfd1 100644 --- a/roles/my_workspace/tasks/project.yml +++ b/roles/my_workspace/tasks/project.yml @@ -7,9 +7,10 @@ compose_file: '{{ project_dir }}/docker-compose.yml' block: - name: Update repository - ansible.builtin.git: # noqa: latest + tensin.infra.safe_git: repo: '{{ project_url }}' dest: '{{ project_dir }}' + version: master accept_hostkey: true - name: Configure git repository |
