summaryrefslogtreecommitdiffstats
path: root/ansible_collections/community/general/plugins/modules/yarn.py
blob: c278951d5efafddb10cfc5b17342aec8fcabd242 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
#!/usr/bin/python
# -*- coding: utf-8 -*-

# (c) 2017 David Gunter <david.gunter@tivix.com>
# Copyright (c) 2017 Chris Hoffman <christopher.hoffman@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later


from __future__ import absolute_import, division, print_function
__metaclass__ = type


DOCUMENTATION = '''
---
module: yarn
short_description: Manage node.js packages with Yarn
description:
  - Manage node.js packages with the Yarn package manager (https://yarnpkg.com/)
author:
  - "David Gunter (@verkaufer)"
  - "Chris Hoffman (@chrishoffman), creator of NPM Ansible module)"
extends_documentation_fragment:
  - community.general.attributes
attributes:
  check_mode:
    support: full
  diff_mode:
    support: none
options:
  name:
    type: str
    description:
      - The name of a node.js library to install
      - If omitted all packages in package.json are installed.
      - To globally install from local node.js library. Prepend "file:" to the path of the node.js library.
    required: false
  path:
    type: path
    description:
      - The base path where Node.js libraries will be installed.
      - This is where the node_modules folder lives.
    required: false
  version:
    type: str
    description:
      - The version of the library to be installed.
      - Must be in semver format. If "latest" is desired, use "state" arg instead
    required: false
  global:
    description:
      - Install the node.js library globally
    required: false
    default: false
    type: bool
  executable:
    type: path
    description:
      - The executable location for yarn.
    required: false
  ignore_scripts:
    description:
      - Use the --ignore-scripts flag when installing.
    required: false
    type: bool
    default: false
  production:
    description:
      - Install dependencies in production mode.
      - Yarn will ignore any dependencies under devDependencies in package.json
    required: false
    type: bool
    default: false
  registry:
    type: str
    description:
      - The registry to install modules from.
    required: false
  state:
    type: str
    description:
      - Installation state of the named node.js library
      - If absent is selected, a name option must be provided
    required: false
    default: present
    choices: [ "present", "absent", "latest" ]
requirements:
    - Yarn installed in bin path (typically /usr/local/bin)
'''

EXAMPLES = '''
- name: Install "imagemin" node.js package.
  community.general.yarn:
    name: imagemin
    path: /app/location

- name: Install "imagemin" node.js package on version 5.3.1
  community.general.yarn:
    name: imagemin
    version: '5.3.1'
    path: /app/location

- name: Install "imagemin" node.js package globally.
  community.general.yarn:
    name: imagemin
    global: true

- name: Remove the globally-installed package "imagemin".
  community.general.yarn:
    name: imagemin
    global: true
    state: absent

- name: Install "imagemin" node.js package from custom registry.
  community.general.yarn:
    name: imagemin
    registry: 'http://registry.mysite.com'

- name: Install packages based on package.json.
  community.general.yarn:
    path: /app/location

- name: Update all packages in package.json to their latest version.
  community.general.yarn:
    path: /app/location
    state: latest
'''

RETURN = '''
changed:
    description: Whether Yarn changed any package data
    returned: always
    type: bool
    sample: true
msg:
    description: Provides an error message if Yarn syntax was incorrect
    returned: failure
    type: str
    sample: "Package must be explicitly named when uninstalling."
invocation:
    description: Parameters and values used during execution
    returned: success
    type: dict
    sample: {
            "module_args": {
                "executable": null,
                "globally": false,
                "ignore_scripts": false,
                "name": null,
                "path": "/some/path/folder",
                "production": false,
                "registry": null,
                "state": "present",
                "version": null
            }
        }
out:
    description: Output generated from Yarn.
    returned: always
    type: str
    sample: "yarn add v0.16.1[1/4] Resolving packages...[2/4] Fetching packages...[3/4] Linking dependencies...[4/4]
    Building fresh packages...success Saved lockfile.success Saved 1 new dependency..left-pad@1.1.3 Done in 0.59s."
'''

import os
import json

from ansible.module_utils.basic import AnsibleModule


class Yarn(object):

    def __init__(self, module, **kwargs):
        self.module = module
        self.globally = kwargs['globally']
        self.name = kwargs['name']
        self.version = kwargs['version']
        self.path = kwargs['path']
        self.registry = kwargs['registry']
        self.production = kwargs['production']
        self.ignore_scripts = kwargs['ignore_scripts']
        self.executable = kwargs['executable']

        # Specify a version of package if version arg passed in
        self.name_version = None

        if kwargs['version'] and self.name is not None:
            self.name_version = self.name + '@' + str(self.version)
        elif self.name is not None:
            self.name_version = self.name

    def _exec(self, args, run_in_check_mode=False, check_rc=True, unsupported_with_global=False):
        if not self.module.check_mode or (self.module.check_mode and run_in_check_mode):

            with_global_arg = self.globally and not unsupported_with_global

            if with_global_arg:
                # Yarn global arg is inserted before the command (e.g. `yarn global {some-command}`)
                args.insert(0, 'global')

            cmd = self.executable + args

            if self.production:
                cmd.append('--production')
            if self.ignore_scripts:
                cmd.append('--ignore-scripts')
            if self.registry:
                cmd.append('--registry')
                cmd.append(self.registry)

            # If path is specified, cd into that path and run the command.
            cwd = None
            if self.path and not with_global_arg:
                if not os.path.exists(self.path):
                    # Module will make directory if not exists.
                    os.makedirs(self.path)
                if not os.path.isdir(self.path):
                    self.module.fail_json(msg="Path provided %s is not a directory" % self.path)
                cwd = self.path

                if not os.path.isfile(os.path.join(self.path, 'package.json')):
                    self.module.fail_json(msg="Package.json does not exist in provided path.")

            rc, out, err = self.module.run_command(cmd, check_rc=check_rc, cwd=cwd)
            return out, err

        return None, None

    def _process_yarn_error(self, err):
        try:
            # We need to filter for errors, since Yarn warnings are included in stderr
            for line in err.splitlines():
                if json.loads(line)['type'] == 'error':
                    self.module.fail_json(msg=err)
        except Exception:
            self.module.fail_json(msg="Unexpected stderr output from Yarn: %s" % err, stderr=err)

    def list(self):
        cmd = ['list', '--depth=0', '--json']

        installed = list()
        missing = list()

        if not os.path.isfile(os.path.join(self.path, 'yarn.lock')):
            missing.append(self.name)
            return installed, missing

        # `yarn global list` should be treated as "unsupported with global" even though it exists,
        # because it only only lists binaries, but `yarn global add` can install libraries too.
        result, error = self._exec(cmd, run_in_check_mode=True, check_rc=False, unsupported_with_global=True)

        self._process_yarn_error(error)

        for json_line in result.strip().split('\n'):
            data = json.loads(json_line)
            if data['type'] == 'tree':
                dependencies = data['data']['trees']

                for dep in dependencies:
                    name, version = dep['name'].rsplit('@', 1)
                    installed.append(name)

        if self.name not in installed:
            missing.append(self.name)

        return installed, missing

    def install(self):
        if self.name_version:
            # Yarn has a separate command for installing packages by name...
            return self._exec(['add', self.name_version])
        # And one for installing all packages in package.json
        return self._exec(['install', '--non-interactive'])

    def update(self):
        return self._exec(['upgrade', '--latest'])

    def uninstall(self):
        return self._exec(['remove', self.name])

    def list_outdated(self):
        outdated = list()

        if not os.path.isfile(os.path.join(self.path, 'yarn.lock')):
            return outdated

        cmd_result, err = self._exec(['outdated', '--json'], True, False, unsupported_with_global=True)

        # the package.json in the global dir is missing a license field, so warnings are expected on stderr
        self._process_yarn_error(err)

        if not cmd_result:
            return outdated

        outdated_packages_data = cmd_result.splitlines()[1]

        data = json.loads(outdated_packages_data)

        try:
            outdated_dependencies = data['data']['body']
        except KeyError:
            return outdated

        for dep in outdated_dependencies:
            # Outdated dependencies returned as a list of lists, where
            # item at index 0 is the name of the dependency
            outdated.append(dep[0])
        return outdated


def main():
    arg_spec = dict(
        name=dict(default=None),
        path=dict(default=None, type='path'),
        version=dict(default=None),
        production=dict(default=False, type='bool'),
        executable=dict(default=None, type='path'),
        registry=dict(default=None),
        state=dict(default='present', choices=['present', 'absent', 'latest']),
        ignore_scripts=dict(default=False, type='bool'),
    )
    arg_spec['global'] = dict(default=False, type='bool')
    module = AnsibleModule(
        argument_spec=arg_spec,
        supports_check_mode=True
    )

    name = module.params['name']
    path = module.params['path']
    version = module.params['version']
    globally = module.params['global']
    production = module.params['production']
    registry = module.params['registry']
    state = module.params['state']
    ignore_scripts = module.params['ignore_scripts']

    # When installing globally, users should not be able to define a path for installation.
    # Require a path if global is False, though!
    if path is None and globally is False:
        module.fail_json(msg='Path must be specified when not using global arg')
    elif path and globally is True:
        module.fail_json(msg='Cannot specify path if doing global installation')

    if state == 'absent' and not name:
        module.fail_json(msg='Package must be explicitly named when uninstalling.')
    if state == 'latest':
        version = 'latest'

    if module.params['executable']:
        executable = module.params['executable'].split(' ')
    else:
        executable = [module.get_bin_path('yarn', True)]

    # When installing globally, use the defined path for global node_modules
    if globally:
        _rc, out, _err = module.run_command(executable + ['global', 'dir'], check_rc=True)
        path = out.strip()

    yarn = Yarn(module,
                name=name,
                path=path,
                version=version,
                globally=globally,
                production=production,
                executable=executable,
                registry=registry,
                ignore_scripts=ignore_scripts)

    changed = False
    out = ''
    err = ''
    if state == 'present':

        if not name:
            changed = True
            out, err = yarn.install()
        else:
            installed, missing = yarn.list()
            if len(missing):
                changed = True
                out, err = yarn.install()

    elif state == 'latest':

        if not name:
            changed = True
            out, err = yarn.install()
        else:
            installed, missing = yarn.list()
            outdated = yarn.list_outdated()
            if len(missing):
                changed = True
                out, err = yarn.install()
            if len(outdated):
                changed = True
                out, err = yarn.update()
    else:
        # state == absent
        installed, missing = yarn.list()
        if name in installed:
            changed = True
            out, err = yarn.uninstall()

    module.exit_json(changed=changed, out=out, err=err)


if __name__ == '__main__':
    main()