├── LICENSE.md ├── README.md └── yay /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Michael Nussbaum 4 | Copyright (c) 2014 Austin Hyde 5 | 6 | > Permission is hereby granted, free of charge, to any person obtaining a copy 7 | > of this software and associated documentation files (the "Software"), to deal 8 | > in the Software without restriction, including without limitation the rights 9 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | > copies of the Software, and to permit persons to whom the Software is 11 | > furnished to do so, subject to the following conditions: 12 | > 13 | > The above copyright notice and this permission notice shall be included in 14 | > all copies or substantial portions of the Software. 15 | > 16 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | > THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ansible-yay 2 | 3 | An Ansible module for installing [AUR](https://aur.archlinux.org/) packages via 4 | the [yay][yay] AUR helper. 5 | 6 | This assumes your target node already has yay and its dependecies installed. 7 | 8 | ## Dependencies (Managed Node) 9 | 10 | * [Arch Linux](https://www.archlinux.org/) (Obviously) 11 | * [yay][yay] 12 | 13 | ## Installation 14 | 15 | 1. Clone this repo 16 | 2. Copy or link the `yay` file into your global Ansible library (usually 17 | `/usr/share/ansible`) or into the `./library` folder alongside your 18 | top-level playbook 19 | 20 | ## Usage 21 | 22 | Pretty much identical to the [pacman module][pacman-mod]. Note that package 23 | status, removal, the corresponding `pacman` commands are used (`-Q`, `-R`, 24 | respectively). 25 | 26 | ### Options 27 | 28 | | parameter | required | default | choices | description | 29 | |--------------|-----------|---------|-----------------------|-------------------------------------| 30 | | name | no | | | Name of the AUR package to install. | 31 | | recurse | no | no | yes/no | Whether to recursively remove packages. See [pacman module docs][pacman-mod]. | 32 | | state | no | no | absent/present/latest | Whether the package needs to be installed or updated. | 33 | | update_cache | no | no | yes/no | Whether or not to refresh the master package lists. This can be run as part of a package installation or as a separate step. | 34 | | upgrade | no | no | yes/no | Whether or not to upgrade the whole systemd. | 35 | 36 | ### Examples 37 | 38 | ```yaml 39 | # Install package foo 40 | - yay: name=foo state=present 41 | 42 | # Ensure package fuzz is installed and up-to-date 43 | - yay: name=fuzz state=latest 44 | 45 | # Remove packages foo and bar 46 | - yay: name=foo,bar state=absent 47 | 48 | # Recursively remove package baz 49 | - yay: name=baz state=absent recurse=yes 50 | 51 | # Effectively run yay -Syu 52 | - yay: update_cache=yes upgrade=yes 53 | ``` 54 | 55 | [yay]: https://github.com/Jguer/yay 56 | [pacman-mod]: http://docs.ansible.com/pacman_module.html 57 | -------------------------------------------------------------------------------- /yay: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/python 2 | 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2014 Austin Hyde 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | 25 | def yay_in_path(module): 26 | rc, _, _ = module.run_command('which yay', check_rc=False) 27 | return rc == 0 28 | 29 | 30 | def pacman_in_path(module): 31 | rc, _, _ = module.run_command('which pacman', check_rc=False) 32 | return rc == 0 33 | 34 | def get_version(yay_output): 35 | '''Take yay -Qi or yay -Si output and get the Version''' 36 | lines = yay_output.split('\n') 37 | for line in lines: 38 | if 'Version' in line: 39 | return line.split(':')[1].strip() 40 | return None 41 | 42 | def query_package(module, pkg, state): 43 | ''' 44 | Query the package status in both the local system and the repository. 45 | Returns three booleans to indicate: 46 | * If the package is installed 47 | * If the package is up-to-date 48 | * Whether online information was available 49 | ''' 50 | local_check_cmd = 'yay -Qi %s' % pkg 51 | local_check_rc, local_check_stdout, _ = module.run_command(local_check_cmd, check_rc=False) 52 | if local_check_rc != 0: 53 | return False, False, False 54 | 55 | # No need to check for the repo version in some situations 56 | # Indicate the package is out-of-date, because we chose not to check 57 | if state == 'present' or state == 'absent': 58 | return True, False, False 59 | 60 | local_version = get_version(local_check_stdout) 61 | 62 | repo_check_cmd = 'yay -Si %s' % pkg 63 | repo_check_rc, repo_check_stdout, repo_check_stderr = module.run_command(repo_check_cmd, check_rc=False) 64 | repo_version = get_version(repo_check_stdout) 65 | 66 | if repo_check_rc == 0 and repo_check_stderr == '': 67 | return True, (local_version == repo_version), False 68 | else: 69 | # Indicate package is up-to-date, but just because we hit an error contacting the repo 70 | return True, True, True 71 | 72 | def update_package_db(module): 73 | rc, _, stderr = module.run_command('yay -Sy', check_rc=False) 74 | 75 | if rc == 0 and stderr == '': 76 | return False, 'Package DB up-to-date' 77 | elif rc == 1 and stderr == '': 78 | return True, 'Updated the package DB' 79 | else: 80 | module.fail_json(msg='could not update package db: %s' % stderr) 81 | 82 | def upgrade(module): 83 | check_rc, check_stdout, check_stderr = module.run_command('yay -Qqu', check_rc=False) 84 | 85 | if check_rc == 0 and check_stderr == '' and module.check_mode: 86 | return True, '%s package(s) would be upgraded' % (len(check_stdout.split('\n')) - 1) 87 | elif check_rc == 0 and check_stderr == '' and not module.check_mode: 88 | upgrade_rc, _, upgrade_stderr = module.run_command( 89 | 'yay -Su --noconfirm', 90 | check_rc=False, 91 | ) 92 | 93 | if upgrade_rc == 0: 94 | return True, 'System upgraded' 95 | else: 96 | module.fail_json(msg='unable to upgrade: %s' % upgrade_stderr) 97 | elif check_rc == 1 and check_stderr == '': 98 | return False, 'Nothing to upgrade' 99 | else: 100 | module.fail_json(msg='unable to check for upgrade: %s' % check_stderr) 101 | 102 | def get_sudo_user(module): 103 | # ansible sets the SUDO_USER environment variable. Default to using this, 104 | # checking USER and then `logname` as backups. 105 | user = os.environ.get('SUDO_USER') 106 | 107 | # If ansible is run as root with become_user set, use the specified user 108 | # instead of root. 109 | if not user or user == 'root': 110 | user = os.environ.get('USER') 111 | 112 | if not user: 113 | rc, stdout, _ = module.run_command('logname', check_rc=True) 114 | user = stdout 115 | 116 | return user 117 | 118 | def check_packages(module, pkgs, state): 119 | would_be_changed = [] 120 | 121 | for pkg in pkgs: 122 | installed, updated, _ = query_package(module, pkg, state) 123 | if ((state in ['present', 'latest'] and not installed) or 124 | (state == 'latest' and not updated) or 125 | (state == 'absent' and installed)): 126 | would_be_changed.append(pkg) 127 | 128 | word = 'installed' 129 | if state == 'absent': 130 | word = 'removed' 131 | 132 | if would_be_changed: 133 | return True, '%s package(s) would be %s' % (len(would_be_changed), word) 134 | else: 135 | return False, 'All packages are already %s' % word 136 | 137 | def install_packages(module, pkgs, state): 138 | num_installed = 0 139 | package_err = [] 140 | message = '' 141 | 142 | sudo_user = get_sudo_user(module) 143 | cmd = 'sudo -u %s yay --noconfirm -S %s' 144 | 145 | for pkg in pkgs: 146 | installed, updated, latest_error = query_package(module, pkg, state) 147 | if latest_error and state == 'latest': 148 | package_err.append(pkg) 149 | 150 | if installed and (state == 'present' or (state == 'latest' and updated)): 151 | continue 152 | 153 | rc, _, stderr = module.run_command(cmd % (sudo_user, pkg), check_rc=False) 154 | 155 | if rc != 0: 156 | module.fail_json(msg='Failed to install package %s, because: %s' % (pkg, stderr)) 157 | 158 | num_installed += 1 159 | 160 | if state == 'latest' and len(package_err) > 0: 161 | message = 'But could not ensure "latest" state for %s package(s) as remote version could not be fetched.' % package_err 162 | 163 | if num_installed > 0: 164 | return True, 'Installed %s package(s). %s' % (num_installed, message) 165 | else: 166 | return False, 'All packages were already installed. %s' % message 167 | 168 | def remove_packages(module, pkgs, recurse, state): 169 | num_removed = 0 170 | 171 | arg = 'R' 172 | word = 'remove' 173 | if recurse: 174 | arg = 'Rs' 175 | word = 'recursively remove' 176 | 177 | cmd = 'pacman -%s --noconfirm %s' 178 | 179 | for pkg in pkgs: 180 | installed, _, _ = query_package(module, pkg, state) 181 | if not installed: 182 | continue 183 | 184 | rc, _, stderr = module.run_command(cmd % (arg, pkg), check_rc=False) 185 | 186 | if rc != 0: 187 | module.fail_json(msg='failed to %s package %s because: %s' % (word, pkg, stderr)) 188 | 189 | num_removed += 1 190 | 191 | if num_removed > 0: 192 | return True, 'Removed %s package(s)' % num_removed 193 | else: 194 | return False, 'All packages were already removed' 195 | 196 | 197 | def main(): 198 | module = AnsibleModule( 199 | argument_spec = dict( 200 | name = dict(type='list'), 201 | state = dict( 202 | default='present', 203 | choices=['absent', 'present', 'latest'], 204 | ), 205 | recurse = dict(default='no', type='bool'), 206 | upgrade = dict(default='no', type='bool'), 207 | update_cache = dict( 208 | default='no', 209 | aliases=['update-cache'], 210 | type='bool', 211 | ), 212 | ), 213 | required_one_of = [['name', 'update_cache', 'upgrade']], 214 | supports_check_mode = True 215 | ) 216 | 217 | if not yay_in_path(module): 218 | module.fail_json(msg="could not locate yay executable") 219 | 220 | if not pacman_in_path(module): 221 | module.fail_json(msg="could not locate pacman executable") 222 | 223 | p = module.params 224 | 225 | changed = False 226 | messages = [] 227 | if p["update_cache"] and not module.check_mode: 228 | updated, update_message = update_package_db(module) 229 | changed = changed or updated 230 | messages.append(update_message) 231 | 232 | if p['update_cache'] and module.check_mode: 233 | changed = True 234 | messages.append('Would have updated the package cache') 235 | 236 | if p['upgrade']: 237 | upgraded, upgrade_message = upgrade(module) 238 | changed = changed or upgraded 239 | messages.append(upgrade_message) 240 | 241 | if p['name'] and module.check_mode: 242 | packages_would_change, check_message = check_packages( 243 | module, 244 | p['name'], 245 | p['state'], 246 | ) 247 | changed = changed or packages_would_change 248 | messages.append(check_message) 249 | elif p['name'] and not module.check_mode: 250 | if p['name']: 251 | if p['state'] in ['present', 'latest']: 252 | packages_changed, package_message = install_packages( 253 | module, 254 | p['name'], 255 | p['state'], 256 | ) 257 | elif p['state'] == 'absent': 258 | packages_changed, package_message = remove_packages( 259 | module, 260 | p['name'], 261 | p['recurse'], 262 | p['state'], 263 | ) 264 | 265 | changed = changed or packages_changed 266 | messages.append(package_message) 267 | 268 | module.exit_json(changed=changed, msg='. '.join(messages)) 269 | 270 | 271 | from ansible.module_utils.basic import * 272 | main() 273 | --------------------------------------------------------------------------------