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