diff --git a/plugins/modules/git_commit.py b/plugins/modules/git_commit.py new file mode 120000 index 00000000000..dd471af70e3 --- /dev/null +++ b/plugins/modules/git_commit.py @@ -0,0 +1 @@ +./source_control/git/git_commit.py \ No newline at end of file diff --git a/plugins/modules/git_push.py b/plugins/modules/git_push.py new file mode 120000 index 00000000000..3bc1361a778 --- /dev/null +++ b/plugins/modules/git_push.py @@ -0,0 +1 @@ +./source_control/git/git_push.py \ No newline at end of file diff --git a/plugins/modules/source_control/git/__init__.py b/plugins/modules/source_control/git/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plugins/modules/source_control/git/git_commit.py b/plugins/modules/source_control/git/git_commit.py new file mode 100644 index 00000000000..fae67069f69 --- /dev/null +++ b/plugins/modules/source_control/git/git_commit.py @@ -0,0 +1,194 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Federico Olivieri (lvrfrc87@gmail.com) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: git_commit +author: + - "Federico Olivieri (@Federico87)" +version_added: "2.10" +short_description: Perform git add and git commit operations. +description: + - Manage C(git add) and C(git commit) on a local git. +options: + path: + description: + - Folder path where C(.git/) is located. + required: true + type: path + comment: + description: + - Git commit comment. Same as C(git commit -m). + type: str + required: true + add: + description: + - List of files under C(path) to be staged. Same as C(git add .). + File globs not accepted, such as C(./*) or C(*). + type: list + elements: str + required: true + default: ["."] + empty_commit: + descripion: + - Drive module behaviour in case nothing to commit. + If C(allow) empty commit is allowed, same as C(--allow-empty). + Do not commit if C(skip). Fail job if C(fail). In order to C(allow) + to work, C(add) argument must not provided in module. + type: str + default: 'fail' +requirements: + - git>=2.10.0 (the command line tool) +''' + +EXAMPLES = ''' +- name: Add and commit two files. + community.general.git_commit: + path: /Users/federicoolivieri/git/git_test_module + comment: My amazing backup + add: ['test.txt', 'txt.test'] + empty_commit: fail + +- name: Empty commit. + community.general.git_commit: + path: /Users/federicoolivieri/git/git_test_module + comment: My amazing empty commit + empty_commit: allow + add: ['.'] + +- name: Skip if nothing to commit. + community.general.git_commit: + path: /Users/federicoolivieri/git/git_test_module + comment: Skip my amazing empty commit + empty_commit: skyp + add: ['.'] +''' + +RETURN = ''' +output: + description: list of git cli command stdout + type: list + returned: always + sample: [ + "[master 99830f4] Remove [ test.txt, tax.txt ]\n 4 files changed, 26 insertions(+)..." + ] +''' + +import os +from ansible.module_utils.basic import AnsibleModule + + +def git_add(module): + + add = module.params.get('add') + + if add: + add_cmds = [ + 'git', + 'add', + ] + for item in add: + add_cmds.insert(len(add_cmds), item) + + return add_cmds + + +def git_commit(module): + + empty_commit = module.params.get('empty_commit') + comment = module.params.get('comment') + + if comment and empty_commit == 'allow': + commit_cmds = [ + 'git', + 'commit', + '--allow-empty', + '-m', + '"{0}"'.format(comment), + '--porcelain' + ] + + if comment and empty_commit != 'allow': + commit_cmds = [ + 'git', + 'commit', + '-m', + '"{0}"'.format(comment), + '--porcelain' + ] + + if commit_cmds: + return commit_cmds + + +def main(): + + argument_spec = dict( + path=dict(required=True, type="path"), + comment=dict(required=True), + add=dict(type='list', elements='str', default=["."]), + empty_commit=dict(choices=[ "allow", "fail", "skip" ], default='fail') + ) + + module = AnsibleModule( + argument_spec=argument_spec, + ) + + path = module.params.get('path') + empty_commit = module.params.get('empty_commit') + + result = dict(changed=False) + + rc, output, error = module.run_command( + git_add(module), + cwd=path, + check_rc=False, + ) + + # "git add do_not_exist" -> rc: 128 + # "git add i_exist" -> rc: 0 + # "git add ." -> rc: 0 + if rc != 0 and empty_commit != 'skip': + module.fail_json( + msg=error, + ) + elif rc != 0 and empty_commit == 'skip': + rc = 0 + result.update(changed=False, output=output, rc=rc) + else: + porcelain_list = ('M', 'A', 'D', 'R', 'C', 'U') + # A test.txt -> rc: 1 + # "" (nothing to commit) -> rc: 1 + rc, output, error = module.run_command( + git_commit(module), + cwd=path, + check_rc=False, + ) + + if rc == 1: + + if empty_commit == 'allow' and not output: + rc = 0 + result.update(changed=True, output=output, rc=rc) + + if empty_commit == 'fail' and output: + for porc in output.splitlines(): + if porc.startswith(porcelain_list): + rc = 0 + result.update(changed=True, output=output, rc=rc) + elif empty_commit == 'fail' and not output: + module.fail_json(msg='Empty commit not allowed with empty_commit=fail',rc=rc) + + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/source_control/git/git_push.py b/plugins/modules/source_control/git/git_push.py new file mode 100644 index 00000000000..25e0fc6a8cf --- /dev/null +++ b/plugins/modules/source_control/git/git_push.py @@ -0,0 +1,251 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Federico Olivieri (lvrfrc87@gmail.com) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: git_push +author: + - "Federico Olivieri (@Federico87)" +version_added: "2.10" +short_description: Perform git push operations. +description: + - Manage C(git push) on local or remote git repository. +options: + path: + description: + - Folder path where C(.git/) is located. + required: true + type: path + user: + description: + - Git username for https operations. + type: str + token: + description: + - Git API token for https operations. + type: str + branch: + description: + - Git branch where perform git push. + required: True + type: str + push_option: + description: + - Git push options. Same as C(git --push-option=option). + type: str + mode: + description: + - Git operations are performend eithr over ssh, https or local. + Same as C(git@git...) or C(https://user:token@git...). + choices: ['ssh', 'https', 'local'] + default: ssh + type: str + url: + description: + - Git repo URL. + required: True + type: str +requirements: + - git>=2.10.0 (the command line tool) +''' + +EXAMPLES = ''' + +- name: Push changes via HTTPs. + community.general.git_push: + path: /Users/federicoolivieri/git/git_test_module + user: Federico87 + token: m1Ap!T0k3n!!! + branch: master + mode: https + url: https://gitlab.com/networkAutomation/git_test_module + +- name: Push changes via SSH. + community.general.git_push: + path: /Users/federicoolivieri/git/git_test_module + branch: master + mode: ssh + url: https://gitlab.com/networkAutomation/git_test_module + +- name: Push changes on local repo. + community.general.git_push: + path: /Users/federicoolivieri/git/git_test_module + comment: My amazing backup + branch: master + url: /Users/federicoolivieri/git/local_repo +''' + +RETURN = ''' +output: + description: list of git cli commands stdout + type: list + returned: always + sample: [ + "To https://gitlab.com/networkAutomation/git_test_module.git\n 372db19..99830f4 master -> master\n" + ] +''' + +import os +from ansible.module_utils.basic import AnsibleModule + + +def git_push(module): + + commands = list() + + path = module.params.get('path') + url = module.params.get('url') + user = module.params.get('user') + token = module.params.get('token') + branch = module.params.get('branch') + push_option = module.params.get('push_option') + mode = module.params.get('mode') + + def https(path, user, token, url, branch, push_option): + if url.startswith('https://'): + remote_add = [ + 'git', + '-C', + path, + 'remote', + 'set-url', + 'origin', + 'https://{user}:{token}@{url}'.format( + url=url[8:], + user=user, + token=token, + ), + ] + + cmd = [ + 'git', + '-C', + path, + 'push', + 'origin', + branch, + '--porcelain', + ] + + if push_option: + return [remote_add, cmd.insert(5, '--push-option={0} '.format(push_option))] + + if not push_option: + return [remote_add, cmd] + + if mode == 'local': + if 'https' in url or 'ssh' in url: + module.fail_json(msg='SSH or HTTPS mode selected but repo is LOCAL') + + cmd = [ + 'git', + '-C', + path, + 'push', + 'origin', + branch, + ] + + if push_option: + module.fail_json(msg='"--push-option" not supported with mode "local"') + + if not push_option: + return [remote_add, cmd] + + if mode == 'https': + for cmd in https(path, user, token, url, branch, push_option): + commands.append(cmd) + + if mode == 'ssh': + if 'https' in url: + module.fail_json(msg='SSH mode selected but HTTPS URL provided') + + remote_add = [ + 'git', + '-C', + path, + 'remote', + 'set-url', + 'origin', + url + ] + + cmd = 'git -C {path} push origin {branch}'.format( + path=path, + branch=branch + ) + commands.append(remote_add) + + if push_option: + return [remote_add, cmd.insert(5, '--push-option={0} '.format(push_option))] + + if not push_option: + return [remote_add, cmd] + + return commands + + +def main(): + + argument_spec = dict( + path=dict(required=True, type="path"), + user=dict(), + token=dict(), + branch=dict(required=True), + push_option=dict(), + mode=dict(choices=["ssh", "https", "local"], default='ssh'), + url=dict(required=True), + ) + + required_if = [ + ("mode", "https", ["user", "token"]), + ] + + module = AnsibleModule( + argument_spec=argument_spec, + required_if=required_if, + ) + + result = dict(changed=False) + + result_output = list() + + for cmd in git_push(module): + _rc, output, error = module.run_command(cmd, check_rc=False) + + if output: + if 'no changes added to commit' in output: + module.fail_json(msg=output) + elif 'nothing to commit, working tree clean' in output: + module.fail_json(msg=output) + else: + result_output.append(output) + result.update(changed=True) + + if error: + if 'error:' in error: + module.fail_json(msg=error) + elif 'fatal:' in error: + module.fail_json(msg=error) + elif 'Everything up-to-date' in error: + result_output.append(error) + result.update(changed=True) + else: + result_output.append(error) + result.update(changed=True) + + if result_output: + result.update(output=result_output) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/git_commit/aliases b/tests/integration/targets/git_commit/aliases new file mode 100644 index 00000000000..f71c8117c74 --- /dev/null +++ b/tests/integration/targets/git_commit/aliases @@ -0,0 +1,2 @@ +shippable/posix/group4 +skip/aix diff --git a/tests/integration/targets/git_commit/meta/main.yml b/tests/integration/targets/git_commit/meta/main.yml new file mode 100644 index 00000000000..44bdb9b8a7d --- /dev/null +++ b/tests/integration/targets/git_commit/meta/main.yml @@ -0,0 +1,3 @@ +dependencies: + - prepare_tests + - setup_pkg_mgr diff --git a/tests/integration/targets/git_commit/tasks/local.yml b/tests/integration/targets/git_commit/tasks/local.yml new file mode 100644 index 00000000000..90eb681b0ce --- /dev/null +++ b/tests/integration/targets/git_commit/tasks/local.yml @@ -0,0 +1,83 @@ +- name: FILE | set first random file name. + set_fact: + file1: "{{ lookup('pipe','date +%s%N') }}.txt" + +- name: FILE | set second random file name. + set_fact: + file2: "{{ lookup('pipe','date +%s%N') }}.txt" + +- name: LOCAL | Empty commit -> empty_commi=allow + register: result + git_commit: + path: "{{ playbook_dir }}/test_directory/repo" + comment: I am an empty commit. + add: ['.'] + empty_commit: allow +- debug: var=result +- assert: { that: "result.changed == true" } + +- name: LOCAL | Empty commit -> empty_commi=fail + register: result + git_commit: + path: "{{ playbook_dir }}/test_directory/repo" + comment: I am failing because I got nothing to add and commit. + add: ['.'] + empty_commit: fail + ignore_errors: yes +- debug: var=result +- assert: { that: "result.changed == false" } + +- name: LOCAL | Empty commit -> empty_commi=skip + register: result + git_commit: + path: "{{ playbook_dir }}/test_directory/repo" + comment: I do not care. + add: ['.'] + empty_commit: skip +- debug: var=result +- assert: { that: "result.changed == false" } + +- name: FILE | touch file1 and file2 + file: + path: "{{ playbook_dir }}/test_directory/repo/{{ item }}" + state: touch + loop: + - "{{ file1 }}" + - "{{ file2 }}" + +- name: LOCAL | "git add {{ file1 }} {{ file2 }}" rc == 0 + register: result + git_commit: + path: "{{ playbook_dir }}/test_directory/repo" + comment: Add file1 and file2 + add: [ "{{ file1 }}", "{{ file2 }}" ] + empty_commit: fail +- debug: var=result +- assert: { that: "result.changed == true" } + +- name: FILE | rm file1 and file2 + file: + path: "{{ playbook_dir }}/test_directory/repo/{{ item }}" + state: absent + loop: + - "{{ file1 }}" + - "{{ file2 }}" + +- name: LOCAL | "git add . " -> rc == 0 + git_commit: + path: "{{ playbook_dir }}/test_directory/repo" + comment: Add file1 and file2 + empty_commit: skip +- debug: var=result +- assert: { that: "result.changed == true" } + +- name: LOCAL | git add not existing file -> rc == 128 + register: result + git_commit: + path: "{{ playbook_dir }}/test_directory/repo" + comment: Add file1 and file2 + add: [ 'i_do_not_exist.txt' ] + empty_commit: fail + ignore_errors: yes +- debug: var=result +- assert: { that: "result.changed == false" } \ No newline at end of file diff --git a/tests/integration/targets/git_commit/tasks/main.yml b/tests/integration/targets/git_commit/tasks/main.yml new file mode 100644 index 00000000000..16db69a7043 --- /dev/null +++ b/tests/integration/targets/git_commit/tasks/main.yml @@ -0,0 +1,2 @@ +- include_tasks: setup.yml +- include_tasks: local.yml diff --git a/tests/integration/targets/git_commit/tasks/setup.yml b/tests/integration/targets/git_commit/tasks/setup.yml new file mode 100644 index 00000000000..e2b88877d65 --- /dev/null +++ b/tests/integration/targets/git_commit/tasks/setup.yml @@ -0,0 +1,34 @@ +- name: SETUP | update git to the latest. + package: + name: git + state: latest + when: ansible_distribution != "MacOSX" + +- name: SETUP | check git version. + shell: git --version | grep 'git version' | sed 's/git version //' + register: git_version + +- name: SETUP | edit git version variable. + set_fact: + git_int: "{{ git_version | replace('.', '') }}" + +- name: SETUP | end play if git is not >=2.19. + meta: end_play + when: git_int.stdout | int < 2100 + +- name: SETUP | create test directory. + file: + path: "{{ playbook_dir }}/test_directory" + state: directory + +- name: SETUP | set git local. + shell: "{{ item }}" + loop: + - "git -C {{ playbook_dir }}/test_directory init --bare repo.git" + - "git -C {{ playbook_dir }}/test_directory clone repo.git -l" + +- name: SETUP | set git global user.email if not already set. + shell: git -C {{ playbook_dir }}/test_directory/repo config --global user.email "noreply@example.com" + +- name: SETUP | set git global user.name if not already set. + shell: git -C {{ playbook_dir }}/test_directory/repo config --global user.name "Ansible Test Runner" \ No newline at end of file diff --git a/tests/integration/targets/git_push/aliases b/tests/integration/targets/git_push/aliases new file mode 100644 index 00000000000..f71c8117c74 --- /dev/null +++ b/tests/integration/targets/git_push/aliases @@ -0,0 +1,2 @@ +shippable/posix/group4 +skip/aix diff --git a/tests/integration/targets/git_push/meta/main.yml b/tests/integration/targets/git_push/meta/main.yml new file mode 100644 index 00000000000..44bdb9b8a7d --- /dev/null +++ b/tests/integration/targets/git_push/meta/main.yml @@ -0,0 +1,3 @@ +dependencies: + - prepare_tests + - setup_pkg_mgr diff --git a/tests/integration/targets/git_push/tasks/local.yml b/tests/integration/targets/git_push/tasks/local.yml new file mode 100644 index 00000000000..8548b645674 --- /dev/null +++ b/tests/integration/targets/git_push/tasks/local.yml @@ -0,0 +1,60 @@ +- name: FILE | set first random file name. + set_fact: + file1: "{{ lookup('pipe','date +%s%N') }}.txt" + +- name: FILE | set second random file name. + set_fact: + file2: "{{ lookup('pipe','date +%s%N') }}.txt" + +- name: FILE | touch file1 and file2 + file: + path: "{{ playbook_dir }}/test_directory/repo/{{ item }}" + state: touch + loop: + - "{{ file1 }}" + - "{{ file2 }}" + +- name: LOCAL +add +mode + register: result + git_commit: + path: "{{ playbook_dir }}/test_directory/repo" + comment: Add file1 and file2 + add: [ "{{ file1 }}", "{{ file2 }}" ] + +- assert: { that: "result.changed == true" } + +- name: LOCAL push on master + register: result + git_push: + path: "{{ playbook_dir }}/test_directory/repo" + branch: master + mode: local + url: "{{ playbook_dir }}/test_directory/repo.git" + +- assert: { that: "result.changed == true" } + +- name: FILE | rm file1 and file2 + file: + path: "{{ playbook_dir }}/test_directory/repo/{{ item }}" + state: absent + loop: + - "{{ file1 }}" + - "{{ file2 }}" + +- name: LOCAL -add +mode + register: result + git_commit: + path: "{{ playbook_dir }}/test_directory/repo" + comment: Add . + +- assert: { that: "result.changed == true" } + +- name: LOCAL push on master + register: result + git_push: + path: "{{ playbook_dir }}/test_directory/repo" + branch: master + mode: local + url: "{{ playbook_dir }}/test_directory/repo.git" + +- assert: { that: "result.changed == true" } diff --git a/tests/integration/targets/git_push/tasks/main.yml b/tests/integration/targets/git_push/tasks/main.yml new file mode 100644 index 00000000000..16db69a7043 --- /dev/null +++ b/tests/integration/targets/git_push/tasks/main.yml @@ -0,0 +1,2 @@ +- include_tasks: setup.yml +- include_tasks: local.yml diff --git a/tests/integration/targets/git_push/tasks/setup.yml b/tests/integration/targets/git_push/tasks/setup.yml new file mode 100644 index 00000000000..e2b88877d65 --- /dev/null +++ b/tests/integration/targets/git_push/tasks/setup.yml @@ -0,0 +1,34 @@ +- name: SETUP | update git to the latest. + package: + name: git + state: latest + when: ansible_distribution != "MacOSX" + +- name: SETUP | check git version. + shell: git --version | grep 'git version' | sed 's/git version //' + register: git_version + +- name: SETUP | edit git version variable. + set_fact: + git_int: "{{ git_version | replace('.', '') }}" + +- name: SETUP | end play if git is not >=2.19. + meta: end_play + when: git_int.stdout | int < 2100 + +- name: SETUP | create test directory. + file: + path: "{{ playbook_dir }}/test_directory" + state: directory + +- name: SETUP | set git local. + shell: "{{ item }}" + loop: + - "git -C {{ playbook_dir }}/test_directory init --bare repo.git" + - "git -C {{ playbook_dir }}/test_directory clone repo.git -l" + +- name: SETUP | set git global user.email if not already set. + shell: git -C {{ playbook_dir }}/test_directory/repo config --global user.email "noreply@example.com" + +- name: SETUP | set git global user.name if not already set. + shell: git -C {{ playbook_dir }}/test_directory/repo config --global user.name "Ansible Test Runner" \ No newline at end of file