aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorEgor Tensin <egor@tensin.name>2025-11-24 00:47:29 +0100
committerEgor Tensin <egor@tensin.name>2025-11-27 01:31:43 +0100
commitc4b418a791acf23742195709b3be777128c55ce1 (patch)
treee04967a81122048592be935647e63eeb89c0fb79
parentfix some ansible-lint warnings (diff)
downloadinfra-ansible-c4b418a791acf23742195709b3be777128c55ce1.tar.gz
infra-ansible-c4b418a791acf23742195709b3be777128c55ce1.zip
plugins: add safe_git
-rwxr-xr-xplugins/action/etckeeper_commit.py22
-rwxr-xr-xplugins/action/safe_git.py64
-rw-r--r--plugins/module_utils/file.py13
-rw-r--r--plugins/module_utils/git.py96
-rw-r--r--roles/my_dotfiles/tasks/main.yml3
-rw-r--r--roles/my_workspace/tasks/project.yml3
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