Files
community.general/plugins/modules/yarn.py
Alexei Znamensky 38d49d240e yarn: add Alpine Linux support in integration tests (#11943)
* test(yarn): add Alpine Linux support via apk

Install nodejs and yarn via apk on Alpine, sharing the functional
test block with the existing non-Alpine (pre-built binary) path.
Extracts the test block into tests.yml to avoid duplication.

Fixes #4270

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(yarn): skip Node.js runtime warnings in stderr processing

Node.js 24 emits DeprecationWarning lines to stderr (e.g. for url.parse())
that are not JSON, causing _process_yarn_error to fail with "Unexpected
stderr output from Yarn". Skip lines starting with "(node:" before
attempting JSON parsing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(yarn): add changelog fragment for #11943

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(yarn): only JSON-parse lines starting with '{' in stderr

Node.js 24 emits multi-line DeprecationWarnings to stderr (e.g. the hint
line "(Use `node --trace-deprecation ...`") that are not JSON and were
tripping the "Unexpected stderr output from Yarn" failure. Yarn's
structured output always starts with '{', so skip any line that doesn't.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(yarn): install sqlite on Alpine to fix nodejs 22 symbol error

On Alpine 3.21 nodejs 22 requires SQLite session extension symbols
(sqlite3session_*) that are not present in sqlite-libs; installing
the full sqlite package provides them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(yarn): refresh apk cache and upgrade sqlite-libs before installing nodejs

The CI Alpine container may have a stale sqlite-libs that lacks the
session extension symbols (sqlite3session_*) required by nodejs 22+.
Force a cache refresh and upgrade sqlite-libs to the latest revision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(yarn): warn on non-JSON stderr lines instead of silently skipping

Non-JSON lines in stderr (e.g. Node.js runtime DeprecationWarnings) are
surfaced to the user via module.warn() rather than being silently ignored,
since their content and meaning are not known in advance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* prefix yarn output line

* Update changelogs/fragments/11943-yarn-nodejs-runtime-warnings.yml

Co-authored-by: Felix Fontein <felix@fontein.de>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Felix Fontein <felix@fontein.de>
2026-05-06 19:25:10 +02:00

371 lines
12 KiB
Python

#!/usr/bin/python
# (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 annotations
DOCUMENTATION = r"""
module: yarn
short_description: Manage Node.js packages with Yarn
description:
- Manage Node.js packages with the Yarn package manager U(https://yarnpkg.com/).
- Note that at the moment, this module B(only works with Yarn Classic).
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 C(file:) to the path of the Node.js library.
path:
type: path
description:
- The base path where Node.js installs libraries.
- This is where the C(node_modules) folder lives.
version:
type: str
description:
- The version of the library to be installed.
- Must be in semver format. If "latest" is desired, use O(state) arg instead.
global:
description:
- Install the Node.js library globally.
default: false
type: bool
executable:
type: path
description:
- The executable location for yarn.
ignore_scripts:
description:
- Use the C(--ignore-scripts) flag when installing.
type: bool
default: false
production:
description:
- Install dependencies in production mode.
- C(yarn) ignores any dependencies under devDependencies in C(package.json).
type: bool
default: false
registry:
type: str
description:
- The registry to install modules from.
state:
type: str
description:
- Installation state of the named Node.js library.
- If V(absent) is selected, a O(name) option must be provided.
default: present
choices: ["present", "absent", "latest"]
requirements:
- Yarn Classic installed in bin path (typically C(/usr/local/bin))
"""
EXAMPLES = r"""
- 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 = r"""
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 json
import os
from ansible.module_utils.basic import AnsibleModule
class Yarn:
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 = f"{self.name}@{self.version!s}"
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=f"Path provided {self.path} is not a directory")
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.
# Non-JSON lines (e.g. Node.js runtime warnings) are surfaced via module.warn()
# rather than treated as errors, since their meaning is unknown.
for line in err.splitlines():
if not line.startswith("{"):
self.module.warn(f"yarn stderr: {line}")
continue
if json.loads(line)["type"] == "error":
self.module.fail_json(msg=err)
except Exception:
self.module.fail_json(msg=f"Unexpected stderr output from Yarn: {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(),
path=dict(type="path"),
version=dict(),
production=dict(default=False, type="bool"),
executable=dict(type="path"),
registry=dict(),
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)
module.run_command_environ_update = {"LANGUAGE": "C", "LC_ALL": "C"}
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()