1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3 4# (c) 2013, bleader 5# Written by bleader <bleader@ratonland.org> 6# Based on pkgin module written by Shaun Zinck <shaun.zinck at gmail.com> 7# that was based on pacman module written by Afterburn <https://github.com/afterburn> 8# that was based on apt module written by Matthew Williams <matthew@flowroute.com> 9# 10# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 11 12from __future__ import absolute_import, division, print_function 13__metaclass__ = type 14 15 16DOCUMENTATION = ''' 17--- 18module: pkgng 19short_description: Package manager for FreeBSD >= 9.0 20description: 21 - Manage binary packages for FreeBSD using 'pkgng' which is available in versions after 9.0. 22options: 23 name: 24 description: 25 - Name or list of names of packages to install/remove. 26 - "With I(name=*), I(state: latest) will operate, but I(state: present) and I(state: absent) will be noops." 27 - > 28 Warning: In Ansible 2.9 and earlier this module had a misfeature 29 where I(name=*) with I(state: latest) or I(state: present) would 30 install every package from every package repository, filling up 31 the machines disk. Avoid using them unless you are certain that 32 your role will only be used with newer versions. 33 required: true 34 aliases: [pkg] 35 type: list 36 elements: str 37 state: 38 description: 39 - State of the package. 40 - 'Note: "latest" added in 2.7' 41 choices: [ 'present', 'latest', 'absent' ] 42 required: false 43 default: present 44 type: str 45 cached: 46 description: 47 - Use local package base instead of fetching an updated one. 48 type: bool 49 required: false 50 default: no 51 annotation: 52 description: 53 - A comma-separated list of keyvalue-pairs of the form 54 C(<+/-/:><key>[=<value>]). A C(+) denotes adding an annotation, a 55 C(-) denotes removing an annotation, and C(:) denotes modifying an 56 annotation. 57 If setting or modifying annotations, a value must be provided. 58 required: false 59 type: str 60 pkgsite: 61 description: 62 - For pkgng versions before 1.1.4, specify packagesite to use 63 for downloading packages. If not specified, use settings from 64 C(/usr/local/etc/pkg.conf). 65 - For newer pkgng versions, specify a the name of a repository 66 configured in C(/usr/local/etc/pkg/repos). 67 required: false 68 type: str 69 rootdir: 70 description: 71 - For pkgng versions 1.5 and later, pkg will install all packages 72 within the specified root directory. 73 - Can not be used together with I(chroot) or I(jail) options. 74 required: false 75 type: path 76 chroot: 77 description: 78 - Pkg will chroot in the specified environment. 79 - Can not be used together with I(rootdir) or I(jail) options. 80 required: false 81 type: path 82 jail: 83 description: 84 - Pkg will execute in the given jail name or id. 85 - Can not be used together with I(chroot) or I(rootdir) options. 86 type: str 87 autoremove: 88 description: 89 - Remove automatically installed packages which are no longer needed. 90 required: false 91 type: bool 92 default: no 93 ignore_osver: 94 description: 95 - Ignore FreeBSD OS version check, useful on -STABLE and -CURRENT branches. 96 - Defines the C(IGNORE_OSVERSION) environment variable. 97 required: false 98 type: bool 99 default: no 100 version_added: 1.3.0 101author: "bleader (@bleader)" 102notes: 103 - When using pkgsite, be careful that already in cache packages won't be downloaded again. 104 - When used with a `loop:` each package will be processed individually, 105 it is much more efficient to pass the list directly to the `name` option. 106''' 107 108EXAMPLES = ''' 109- name: Install package foo 110 community.general.pkgng: 111 name: foo 112 state: present 113 114- name: Annotate package foo and bar 115 community.general.pkgng: 116 name: foo,bar 117 annotation: '+test1=baz,-test2,:test3=foobar' 118 119- name: Remove packages foo and bar 120 community.general.pkgng: 121 name: foo,bar 122 state: absent 123 124# "latest" support added in 2.7 125- name: Upgrade package baz 126 community.general.pkgng: 127 name: baz 128 state: latest 129 130- name: Upgrade all installed packages (see warning for the name option first!) 131 community.general.pkgng: 132 name: "*" 133 state: latest 134''' 135 136 137from collections import defaultdict 138import re 139from ansible.module_utils.basic import AnsibleModule 140 141 142def query_package(module, pkgng_path, name, dir_arg): 143 144 rc, out, err = module.run_command("%s %s info -g -e %s" % (pkgng_path, dir_arg, name)) 145 146 if rc == 0: 147 return True 148 149 return False 150 151 152def query_update(module, pkgng_path, name, dir_arg, old_pkgng, pkgsite): 153 154 # Check to see if a package upgrade is available. 155 # rc = 0, no updates available or package not installed 156 # rc = 1, updates available 157 if old_pkgng: 158 rc, out, err = module.run_command("%s %s upgrade -g -n %s" % (pkgsite, pkgng_path, name)) 159 else: 160 rc, out, err = module.run_command("%s %s upgrade %s -g -n %s" % (pkgng_path, dir_arg, pkgsite, name)) 161 162 if rc == 1: 163 return True 164 165 return False 166 167 168def pkgng_older_than(module, pkgng_path, compare_version): 169 170 rc, out, err = module.run_command("%s -v" % pkgng_path) 171 version = [int(x) for x in re.split(r'[\._]', out)] 172 173 i = 0 174 new_pkgng = True 175 while compare_version[i] == version[i]: 176 i += 1 177 if i == min(len(compare_version), len(version)): 178 break 179 else: 180 if compare_version[i] > version[i]: 181 new_pkgng = False 182 return not new_pkgng 183 184 185def upgrade_packages(module, pkgng_path, dir_arg): 186 # Run a 'pkg upgrade', updating all packages. 187 upgraded_c = 0 188 189 cmd = "%s %s upgrade -y" % (pkgng_path, dir_arg) 190 if module.check_mode: 191 cmd += " -n" 192 rc, out, err = module.run_command(cmd) 193 194 match = re.search('^Number of packages to be upgraded: ([0-9]+)', out, re.MULTILINE) 195 if match: 196 upgraded_c = int(match.group(1)) 197 198 if upgraded_c > 0: 199 return (True, "updated %s package(s)" % upgraded_c, out, err) 200 return (False, "no packages need upgrades", out, err) 201 202 203def remove_packages(module, pkgng_path, packages, dir_arg): 204 remove_c = 0 205 stdout = "" 206 stderr = "" 207 # Using a for loop in case of error, we can report the package that failed 208 for package in packages: 209 # Query the package first, to see if we even need to remove 210 if not query_package(module, pkgng_path, package, dir_arg): 211 continue 212 213 if not module.check_mode: 214 rc, out, err = module.run_command("%s %s delete -y %s" % (pkgng_path, dir_arg, package)) 215 stdout += out 216 stderr += err 217 218 if not module.check_mode and query_package(module, pkgng_path, package, dir_arg): 219 module.fail_json(msg="failed to remove %s: %s" % (package, out), stdout=stdout, stderr=stderr) 220 221 remove_c += 1 222 223 if remove_c > 0: 224 return (True, "removed %s package(s)" % remove_c, stdout, stderr) 225 226 return (False, "package(s) already absent", stdout, stderr) 227 228 229def install_packages(module, pkgng_path, packages, cached, pkgsite, dir_arg, state, ignoreosver): 230 action_queue = defaultdict(list) 231 action_count = defaultdict(int) 232 stdout = "" 233 stderr = "" 234 235 # as of pkg-1.1.4, PACKAGESITE is deprecated in favor of repository definitions 236 # in /usr/local/etc/pkg/repos 237 old_pkgng = pkgng_older_than(module, pkgng_path, [1, 1, 4]) 238 if pkgsite != "": 239 if old_pkgng: 240 pkgsite = "PACKAGESITE=%s" % (pkgsite) 241 else: 242 pkgsite = "-r %s" % (pkgsite) 243 244 # This environment variable skips mid-install prompts, 245 # setting them to their default values. 246 batch_var = 'env BATCH=yes' 247 248 if ignoreosver: 249 # Ignore FreeBSD OS version check, 250 # useful on -STABLE and -CURRENT branches. 251 batch_var = batch_var + ' IGNORE_OSVERSION=yes' 252 253 if not module.check_mode and not cached: 254 if old_pkgng: 255 rc, out, err = module.run_command("%s %s update" % (pkgsite, pkgng_path)) 256 else: 257 rc, out, err = module.run_command("%s %s %s update" % (batch_var, pkgng_path, dir_arg)) 258 stdout += out 259 stderr += err 260 if rc != 0: 261 module.fail_json(msg="Could not update catalogue [%d]: %s %s" % (rc, out, err), stdout=stdout, stderr=stderr) 262 263 for package in packages: 264 already_installed = query_package(module, pkgng_path, package, dir_arg) 265 if already_installed and state == "present": 266 continue 267 268 if ( 269 already_installed and state == "latest" 270 and not query_update(module, pkgng_path, package, dir_arg, old_pkgng, pkgsite) 271 ): 272 continue 273 274 if already_installed: 275 action_queue["upgrade"].append(package) 276 else: 277 action_queue["install"].append(package) 278 279 if not module.check_mode: 280 # install/upgrade all named packages with one pkg command 281 for (action, package_list) in action_queue.items(): 282 packages = ' '.join(package_list) 283 if old_pkgng: 284 rc, out, err = module.run_command("%s %s %s %s -g -U -y %s" % (batch_var, pkgsite, pkgng_path, action, packages)) 285 else: 286 rc, out, err = module.run_command("%s %s %s %s %s -g -U -y %s" % (batch_var, pkgng_path, dir_arg, action, pkgsite, packages)) 287 stdout += out 288 stderr += err 289 290 # individually verify packages are in requested state 291 for package in package_list: 292 verified = False 293 if action == 'install': 294 verified = query_package(module, pkgng_path, package, dir_arg) 295 elif action == 'upgrade': 296 verified = not query_update(module, pkgng_path, package, dir_arg, old_pkgng, pkgsite) 297 298 if verified: 299 action_count[action] += 1 300 else: 301 module.fail_json(msg="failed to %s %s" % (action, package), stdout=stdout, stderr=stderr) 302 303 if sum(action_count.values()) > 0: 304 past_tense = {'install': 'installed', 'upgrade': 'upgraded'} 305 messages = [] 306 for (action, count) in action_count.items(): 307 messages.append("%s %s package%s" % (past_tense.get(action, action), count, "s" if count != 1 else "")) 308 309 return (True, '; '.join(messages), stdout, stderr) 310 311 return (False, "package(s) already %s" % (state), stdout, stderr) 312 313 314def annotation_query(module, pkgng_path, package, tag, dir_arg): 315 rc, out, err = module.run_command("%s %s info -g -A %s" % (pkgng_path, dir_arg, package)) 316 match = re.search(r'^\s*(?P<tag>%s)\s*:\s*(?P<value>\w+)' % tag, out, flags=re.MULTILINE) 317 if match: 318 return match.group('value') 319 return False 320 321 322def annotation_add(module, pkgng_path, package, tag, value, dir_arg): 323 _value = annotation_query(module, pkgng_path, package, tag, dir_arg) 324 if not _value: 325 # Annotation does not exist, add it. 326 rc, out, err = module.run_command('%s %s annotate -y -A %s %s "%s"' 327 % (pkgng_path, dir_arg, package, tag, value)) 328 if rc != 0: 329 module.fail_json(msg="could not annotate %s: %s" 330 % (package, out), stderr=err) 331 return True 332 elif _value != value: 333 # Annotation exists, but value differs 334 module.fail_json( 335 mgs="failed to annotate %s, because %s is already set to %s, but should be set to %s" 336 % (package, tag, _value, value)) 337 return False 338 else: 339 # Annotation exists, nothing to do 340 return False 341 342 343def annotation_delete(module, pkgng_path, package, tag, value, dir_arg): 344 _value = annotation_query(module, pkgng_path, package, tag, dir_arg) 345 if _value: 346 rc, out, err = module.run_command('%s %s annotate -y -D %s %s' 347 % (pkgng_path, dir_arg, package, tag)) 348 if rc != 0: 349 module.fail_json(msg="could not delete annotation to %s: %s" 350 % (package, out), stderr=err) 351 return True 352 return False 353 354 355def annotation_modify(module, pkgng_path, package, tag, value, dir_arg): 356 _value = annotation_query(module, pkgng_path, package, tag, dir_arg) 357 if not value: 358 # No such tag 359 module.fail_json(msg="could not change annotation to %s: tag %s does not exist" 360 % (package, tag)) 361 elif _value == value: 362 # No change in value 363 return False 364 else: 365 rc, out, err = module.run_command('%s %s annotate -y -M %s %s "%s"' 366 % (pkgng_path, dir_arg, package, tag, value)) 367 if rc != 0: 368 module.fail_json(msg="could not change annotation annotation to %s: %s" 369 % (package, out), stderr=err) 370 return True 371 372 373def annotate_packages(module, pkgng_path, packages, annotation, dir_arg): 374 annotate_c = 0 375 annotations = map(lambda _annotation: 376 re.match(r'(?P<operation>[\+-:])(?P<tag>\w+)(=(?P<value>\w+))?', 377 _annotation).groupdict(), 378 re.split(r',', annotation)) 379 380 operation = { 381 '+': annotation_add, 382 '-': annotation_delete, 383 ':': annotation_modify 384 } 385 386 for package in packages: 387 for _annotation in annotations: 388 if operation[_annotation['operation']](module, pkgng_path, package, _annotation['tag'], _annotation['value']): 389 annotate_c += 1 390 391 if annotate_c > 0: 392 return (True, "added %s annotations." % annotate_c) 393 return (False, "changed no annotations") 394 395 396def autoremove_packages(module, pkgng_path, dir_arg): 397 stdout = "" 398 stderr = "" 399 rc, out, err = module.run_command("%s %s autoremove -n" % (pkgng_path, dir_arg)) 400 401 autoremove_c = 0 402 403 match = re.search('^Deinstallation has been requested for the following ([0-9]+) packages', out, re.MULTILINE) 404 if match: 405 autoremove_c = int(match.group(1)) 406 407 if autoremove_c == 0: 408 return (False, "no package(s) to autoremove", stdout, stderr) 409 410 if not module.check_mode: 411 rc, out, err = module.run_command("%s %s autoremove -y" % (pkgng_path, dir_arg)) 412 stdout += out 413 stderr += err 414 415 return (True, "autoremoved %d package(s)" % (autoremove_c), stdout, stderr) 416 417 418def main(): 419 module = AnsibleModule( 420 argument_spec=dict( 421 state=dict(default="present", choices=["present", "latest", "absent"], required=False), 422 name=dict(aliases=["pkg"], required=True, type='list', elements='str'), 423 cached=dict(default=False, type='bool'), 424 ignore_osver=dict(default=False, required=False, type='bool'), 425 annotation=dict(default="", required=False), 426 pkgsite=dict(default="", required=False), 427 rootdir=dict(default="", required=False, type='path'), 428 chroot=dict(default="", required=False, type='path'), 429 jail=dict(default="", required=False, type='str'), 430 autoremove=dict(default=False, type='bool')), 431 supports_check_mode=True, 432 mutually_exclusive=[["rootdir", "chroot", "jail"]]) 433 434 pkgng_path = module.get_bin_path('pkg', True) 435 436 p = module.params 437 438 pkgs = p["name"] 439 440 changed = False 441 msgs = [] 442 stdout = "" 443 stderr = "" 444 dir_arg = "" 445 446 if p["rootdir"] != "": 447 old_pkgng = pkgng_older_than(module, pkgng_path, [1, 5, 0]) 448 if old_pkgng: 449 module.fail_json(msg="To use option 'rootdir' pkg version must be 1.5 or greater") 450 else: 451 dir_arg = "--rootdir %s" % (p["rootdir"]) 452 453 if p["ignore_osver"]: 454 old_pkgng = pkgng_older_than(module, pkgng_path, [1, 11, 0]) 455 if old_pkgng: 456 module.fail_json(msg="To use option 'ignore_osver' pkg version must be 1.11 or greater") 457 458 if p["chroot"] != "": 459 dir_arg = '--chroot %s' % (p["chroot"]) 460 461 if p["jail"] != "": 462 dir_arg = '--jail %s' % (p["jail"]) 463 464 if pkgs == ['*'] and p["state"] == 'latest': 465 # Operate on all installed packages. Only state: latest makes sense here. 466 _changed, _msg, _stdout, _stderr = upgrade_packages(module, pkgng_path, dir_arg) 467 changed = changed or _changed 468 stdout += _stdout 469 stderr += _stderr 470 msgs.append(_msg) 471 472 # Operate on named packages 473 named_packages = [pkg for pkg in pkgs if pkg != '*'] 474 if p["state"] in ("present", "latest") and named_packages: 475 _changed, _msg, _out, _err = install_packages(module, pkgng_path, named_packages, 476 p["cached"], p["pkgsite"], dir_arg, 477 p["state"], p["ignore_osver"]) 478 stdout += _out 479 stderr += _err 480 changed = changed or _changed 481 msgs.append(_msg) 482 483 elif p["state"] == "absent" and named_packages: 484 _changed, _msg, _out, _err = remove_packages(module, pkgng_path, named_packages, dir_arg) 485 stdout += _out 486 stderr += _err 487 changed = changed or _changed 488 msgs.append(_msg) 489 490 if p["autoremove"]: 491 _changed, _msg, _stdout, _stderr = autoremove_packages(module, pkgng_path, dir_arg) 492 changed = changed or _changed 493 stdout += _stdout 494 stderr += _stderr 495 msgs.append(_msg) 496 497 if p["annotation"]: 498 _changed, _msg = annotate_packages(module, pkgng_path, pkgs, p["annotation"], dir_arg) 499 changed = changed or _changed 500 msgs.append(_msg) 501 502 module.exit_json(changed=changed, msg=", ".join(msgs), stdout=stdout, stderr=stderr) 503 504 505if __name__ == '__main__': 506 main() 507