1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Copyright: (c) 2017 John Kwiatkoski (@JayKayy) <jkwiat40@gmail.com>
5# Copyright: (c) 2018 Alexander Bethke (@oolongbrothers) <oolongbrothers@gmx.net>
6# Copyright: (c) 2017 Ansible Project
7# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
8
9from __future__ import (absolute_import, division, print_function)
10__metaclass__ = type
11
12DOCUMENTATION = r'''
13---
14module: flatpak
15short_description: Manage flatpaks
16description:
17- Allows users to add or remove flatpaks.
18- See the M(community.general.flatpak_remote) module for managing flatpak remotes.
19author:
20- John Kwiatkoski (@JayKayy)
21- Alexander Bethke (@oolongbrothers)
22requirements:
23- flatpak
24options:
25  executable:
26    description:
27    - The path to the C(flatpak) executable to use.
28    - By default, this module looks for the C(flatpak) executable on the path.
29    type: path
30    default: flatpak
31  method:
32    description:
33    - The installation method to use.
34    - Defines if the I(flatpak) is supposed to be installed globally for the whole C(system)
35      or only for the current C(user).
36    type: str
37    choices: [ system, user ]
38    default: system
39  name:
40    description:
41    - The name of the flatpak to manage. To operate on several packages this
42      can accept a list of packages.
43    - When used with I(state=present), I(name) can be specified as a URL to a
44      C(flatpakref) file or the unique reverse DNS name that identifies a flatpak.
45    - Both C(https://) and C(http://) URLs are supported.
46    - When supplying a reverse DNS name, you can use the I(remote) option to specify on what remote
47      to look for the flatpak. An example for a reverse DNS name is C(org.gnome.gedit).
48    - When used with I(state=absent), it is recommended to specify the name in the reverse DNS
49      format.
50    - When supplying a URL with I(state=absent), the module will try to match the
51      installed flatpak based on the name of the flatpakref to remove it. However, there is no
52      guarantee that the names of the flatpakref file and the reverse DNS name of the installed
53      flatpak do match.
54    type: list
55    elements: str
56    required: true
57  no_dependencies:
58    description:
59    - If installing runtime dependencies should be omitted or not
60    - This parameter is primarily implemented for integration testing this module.
61      There might however be some use cases where you would want to have this, like when you are
62      packaging your own flatpaks.
63    type: bool
64    default: false
65    version_added: 3.2.0
66  remote:
67    description:
68    - The flatpak remote (repository) to install the flatpak from.
69    - By default, C(flathub) is assumed, but you do need to add the flathub flatpak_remote before
70      you can use this.
71    - See the M(community.general.flatpak_remote) module for managing flatpak remotes.
72    type: str
73    default: flathub
74  state:
75    description:
76    - Indicates the desired package state.
77    choices: [ absent, present ]
78    type: str
79    default: present
80'''
81
82EXAMPLES = r'''
83- name: Install the spotify flatpak
84  community.general.flatpak:
85    name:  https://s3.amazonaws.com/alexlarsson/spotify-repo/spotify.flatpakref
86    state: present
87
88- name: Install the gedit flatpak package without dependencies (not recommended)
89  community.general.flatpak:
90    name: https://git.gnome.org/browse/gnome-apps-nightly/plain/gedit.flatpakref
91    state: present
92    no_dependencies: true
93
94- name: Install the gedit package from flathub for current user
95  community.general.flatpak:
96    name: org.gnome.gedit
97    state: present
98    method: user
99
100- name: Install the Gnome Calendar flatpak from the gnome remote system-wide
101  community.general.flatpak:
102    name: org.gnome.Calendar
103    state: present
104    remote: gnome
105
106- name: Install multiple packages
107  community.general.flatpak:
108    name:
109      - org.gimp.GIMP
110      - org.inkscape.Inkscape
111      - org.mozilla.firefox
112
113- name: Remove the gedit flatpak
114  community.general.flatpak:
115    name: org.gnome.gedit
116    state: absent
117
118- name: Remove multiple packages
119  community.general.flatpak:
120    name:
121      - org.gimp.GIMP
122      - org.inkscape.Inkscape
123      - org.mozilla.firefox
124    state: absent
125'''
126
127RETURN = r'''
128command:
129  description: The exact flatpak command that was executed
130  returned: When a flatpak command has been executed
131  type: str
132  sample: "/usr/bin/flatpak install --user --nontinteractive flathub org.gnome.Calculator"
133msg:
134  description: Module error message
135  returned: failure
136  type: str
137  sample: "Executable '/usr/local/bin/flatpak' was not found on the system."
138rc:
139  description: Return code from flatpak binary
140  returned: When a flatpak command has been executed
141  type: int
142  sample: 0
143stderr:
144  description: Error output from flatpak binary
145  returned: When a flatpak command has been executed
146  type: str
147  sample: "error: Error searching remote flathub: Can't find ref org.gnome.KDE"
148stdout:
149  description: Output from flatpak binary
150  returned: When a flatpak command has been executed
151  type: str
152  sample: "org.gnome.Calendar/x86_64/stable\tcurrent\norg.gnome.gitg/x86_64/stable\tcurrent\n"
153'''
154
155from distutils.version import StrictVersion
156
157from ansible.module_utils.six.moves.urllib.parse import urlparse
158from ansible.module_utils.basic import AnsibleModule
159
160OUTDATED_FLATPAK_VERSION_ERROR_MESSAGE = "Unknown option --columns=application"
161
162
163def install_flat(module, binary, remote, names, method, no_dependencies):
164    """Add new flatpaks."""
165    global result
166    uri_names = []
167    id_names = []
168    for name in names:
169        if name.startswith('http://') or name.startswith('https://'):
170            uri_names.append(name)
171        else:
172            id_names.append(name)
173    base_command = [binary, "install", "--{0}".format(method)]
174    flatpak_version = _flatpak_version(module, binary)
175    if StrictVersion(flatpak_version) < StrictVersion('1.1.3'):
176        base_command += ["-y"]
177    else:
178        base_command += ["--noninteractive"]
179    if no_dependencies:
180        base_command += ["--no-deps"]
181    if uri_names:
182        command = base_command + uri_names
183        _flatpak_command(module, module.check_mode, command)
184    if id_names:
185        command = base_command + [remote] + id_names
186        _flatpak_command(module, module.check_mode, command)
187    result['changed'] = True
188
189
190def uninstall_flat(module, binary, names, method):
191    """Remove existing flatpaks."""
192    global result
193    installed_flat_names = [
194        _match_installed_flat_name(module, binary, name, method)
195        for name in names
196    ]
197    command = [binary, "uninstall"]
198    flatpak_version = _flatpak_version(module, binary)
199    if StrictVersion(flatpak_version) < StrictVersion('1.1.3'):
200        command += ["-y"]
201    else:
202        command += ["--noninteractive"]
203    command += ["--{0}".format(method)] + installed_flat_names
204    _flatpak_command(module, module.check_mode, command)
205    result['changed'] = True
206
207
208def flatpak_exists(module, binary, names, method):
209    """Check if the flatpaks are installed."""
210    command = [binary, "list", "--{0}".format(method), "--app"]
211    output = _flatpak_command(module, False, command)
212    installed = []
213    not_installed = []
214    for name in names:
215        parsed_name = _parse_flatpak_name(name).lower()
216        if parsed_name in output.lower():
217            installed.append(name)
218        else:
219            not_installed.append(name)
220    return installed, not_installed
221
222
223def _match_installed_flat_name(module, binary, name, method):
224    # This is a difficult function, since if the user supplies a flatpakref url,
225    # we have to rely on a naming convention:
226    # The flatpakref file name needs to match the flatpak name
227    global result
228    parsed_name = _parse_flatpak_name(name)
229    # Try running flatpak list with columns feature
230    command = [binary, "list", "--{0}".format(method), "--app", "--columns=application"]
231    _flatpak_command(module, False, command, ignore_failure=True)
232    if result['rc'] != 0 and OUTDATED_FLATPAK_VERSION_ERROR_MESSAGE in result['stderr']:
233        # Probably flatpak before 1.2
234        matched_flatpak_name = \
235            _match_flat_using_flatpak_column_feature(module, binary, parsed_name, method)
236    else:
237        # Probably flatpak >= 1.2
238        matched_flatpak_name = \
239            _match_flat_using_outdated_flatpak_format(module, binary, parsed_name, method)
240
241    if matched_flatpak_name:
242        return matched_flatpak_name
243    else:
244        result['msg'] = "Flatpak removal failed: Could not match any installed flatpaks to " +\
245            "the name `{0}`. ".format(_parse_flatpak_name(name)) +\
246            "If you used a URL, try using the reverse DNS name of the flatpak"
247        module.fail_json(**result)
248
249
250def _match_flat_using_outdated_flatpak_format(module, binary, parsed_name, method):
251    global result
252    command = [binary, "list", "--{0}".format(method), "--app", "--columns=application"]
253    output = _flatpak_command(module, False, command)
254    for row in output.split('\n'):
255        if parsed_name.lower() == row.lower():
256            return row
257
258
259def _match_flat_using_flatpak_column_feature(module, binary, parsed_name, method):
260    global result
261    command = [binary, "list", "--{0}".format(method), "--app"]
262    output = _flatpak_command(module, False, command)
263    for row in output.split('\n'):
264        if parsed_name.lower() in row.lower():
265            return row.split()[0]
266
267
268def _parse_flatpak_name(name):
269    if name.startswith('http://') or name.startswith('https://'):
270        file_name = urlparse(name).path.split('/')[-1]
271        file_name_without_extension = file_name.split('.')[0:-1]
272        common_name = ".".join(file_name_without_extension)
273    else:
274        common_name = name
275    return common_name
276
277
278def _flatpak_version(module, binary):
279    global result
280    command = [binary, "--version"]
281    output = _flatpak_command(module, False, command)
282    version_number = output.split()[1]
283    return version_number
284
285
286def _flatpak_command(module, noop, command, ignore_failure=False):
287    global result
288    result['command'] = ' '.join(command)
289    if noop:
290        result['rc'] = 0
291        return ""
292
293    result['rc'], result['stdout'], result['stderr'] = module.run_command(
294        command, check_rc=not ignore_failure
295    )
296    return result['stdout']
297
298
299def main():
300    # This module supports check mode
301    module = AnsibleModule(
302        argument_spec=dict(
303            name=dict(type='list', elements='str', required=True),
304            remote=dict(type='str', default='flathub'),
305            method=dict(type='str', default='system',
306                        choices=['user', 'system']),
307            state=dict(type='str', default='present',
308                       choices=['absent', 'present']),
309            no_dependencies=dict(type='bool', default=False),
310            executable=dict(type='path', default='flatpak')
311        ),
312        supports_check_mode=True,
313    )
314
315    name = module.params['name']
316    state = module.params['state']
317    remote = module.params['remote']
318    no_dependencies = module.params['no_dependencies']
319    method = module.params['method']
320    executable = module.params['executable']
321    binary = module.get_bin_path(executable, None)
322
323    global result
324    result = dict(
325        changed=False
326    )
327
328    # If the binary was not found, fail the operation
329    if not binary:
330        module.fail_json(msg="Executable '%s' was not found on the system." % executable, **result)
331
332    installed, not_installed = flatpak_exists(module, binary, name, method)
333    if state == 'present' and not_installed:
334        install_flat(module, binary, remote, not_installed, method, no_dependencies)
335    elif state == 'absent' and installed:
336        uninstall_flat(module, binary, installed, method)
337
338    module.exit_json(**result)
339
340
341if __name__ == '__main__':
342    main()
343