diff --git a/README.md b/README.md index 8648521d..f363c1dc 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ ansible-playbook -i inventory.yml exadev.github_runner.site --limit node1 --chec ansible-playbook -i inventory.yml exadev.github_runner.site --limit node1 # real run ``` -The playbook runs up to seven roles, each with its own README: `github_runner_host_tools` installs Homebrew and mise on every macOS and Debian-family Linux host first, keeps both at their latest release and puts them in the connecting user's shell (it uses `become` and Homebrew's installer, so run the play with passwordless sudo or `--ask-become-pass`, and `bootstrap.sh` turns it off), `github_runner_macos_host` sets up a Mac's host baseline next (AC power settings and `disablesleep` that stop it sleeping, lid closed included, and its own Tailscale client as the `tailscaled` system daemon; it uses `become`, as do the Linux host role and a native node, so run the play with passwordless sudo or `--ask-become-pass`, and `bootstrap.sh` turns it off), `github_runner_linux_host` sets up a Linux host's baseline (keeping it from suspending and its Wi-Fi from power saving; both off unless a host's variables turn them on), `github_runner_cluster` brings the host up as a k3s node, in a container under Docker Compose or, with `github_runner_cluster_node_mode: native`, directly under systemd on a Linux host, `github_runner_arc` installs ARC and the fleet-health platform, `github_runner_k8s_client` (included by the other two) gives a host the kubernetes Python client, and `github_runner_secrets_onepassword` reads the secrets from 1Password when `github_runner_secrets_source` is `onepassword`. The cluster and ARC roles read every secret (`K3S_TOKEN`, the mesh keys, GHCR credentials, the heartbeat PAT, each org's App private key) as a plain variable, so any source Ansible reads works: ansible-vault, SOPS, an env or file lookup, HashiCorp Vault or AWS SSM (see `examples/ansible-vault/`, `examples/env/` and `examples/onepassword/`). With `github_runner_secrets_source: onepassword`, every secret is read live from one 1Password item at task time; `roles/github_runner_secrets_onepassword/README.md` lists the item's fields and the `op://` references. `bootstrap.sh` sets the same variables directly from `.env.bootstrap`. The cluster role ships the node's `docker-compose.yml` and k3s image build context in `roles/github_runner_cluster/files/`, copies them on every run into each host's `github_runner_cluster_dir` (`~/.github-runner` by default), and templates the `.env` for their `k3s`/`k3s-agent` services there, so a host needs no checkout of this repository. The Compose project name is pinned to `github-runner` (`github_runner_cluster_compose_project`) rather than taken from the directory's name, because the named volumes holding the k3s datastore and each node's mesh identity are prefixed with it; the directory can move without losing them. +The playbook runs up to seven roles, each with its own README: `github_runner_host_tools` installs Homebrew and mise on every macOS and Debian-family Linux host first, keeps both at their latest release and puts them in the connecting user's shell, and writes npm and pnpm package-install security settings and shell environment variables from the variables a host gives (it uses `become` and Homebrew's installer, so run the play with passwordless sudo or `--ask-become-pass`, and `bootstrap.sh` turns it off), `github_runner_macos_host` sets up a Mac's host baseline next (AC power settings and `disablesleep` that stop it sleeping, lid closed included, and its own Tailscale client as the `tailscaled` system daemon; it uses `become`, as do the Linux host role and a native node, so run the play with passwordless sudo or `--ask-become-pass`, and `bootstrap.sh` turns it off), `github_runner_linux_host` sets up a Linux host's baseline (keeping it from suspending and its Wi-Fi from power saving; both off unless a host's variables turn them on), `github_runner_cluster` brings the host up as a k3s node, in a container under Docker Compose or, with `github_runner_cluster_node_mode: native`, directly under systemd on a Linux host, `github_runner_arc` installs ARC and the fleet-health platform, `github_runner_k8s_client` (included by the other two) gives a host the kubernetes Python client, and `github_runner_secrets_onepassword` reads the secrets from 1Password when `github_runner_secrets_source` is `onepassword`. The cluster and ARC roles read every secret (`K3S_TOKEN`, the mesh keys, GHCR credentials, the heartbeat PAT, each org's App private key) as a plain variable, so any source Ansible reads works: ansible-vault, SOPS, an env or file lookup, HashiCorp Vault or AWS SSM (see `examples/ansible-vault/`, `examples/env/` and `examples/onepassword/`). With `github_runner_secrets_source: onepassword`, every secret is read live from one 1Password item at task time; `roles/github_runner_secrets_onepassword/README.md` lists the item's fields and the `op://` references. `bootstrap.sh` sets the same variables directly from `.env.bootstrap`. The cluster role ships the node's `docker-compose.yml` and k3s image build context in `roles/github_runner_cluster/files/`, copies them on every run into each host's `github_runner_cluster_dir` (`~/.github-runner` by default), and templates the `.env` for their `k3s`/`k3s-agent` services there, so a host needs no checkout of this repository. The Compose project name is pinned to `github-runner` (`github_runner_cluster_compose_project`) rather than taken from the directory's name, because the named volumes holding the k3s datastore and each node's mesh identity are prefixed with it; the directory can move without losing them. Which hosts are k3s servers is worked out from the inventory, not set per host. The `github_runner_cluster` inventory group lists the cluster's hosts in order: the first N become servers (control-plane/etcd members running `k3s server`, genuine voting members of the raft quorum), where N is the largest odd number no greater than both the number of hosts and `github_runner_cluster_max_servers` (5 by default), so 1 or 2 hosts give 1 server, 3 or 4 give 3, and 5 or more give 5. The first server bootstraps the cluster (`--cluster-init`, embedded etcd) and every other host joins it, the rest as agents (`k3s agent`, schedulable capacity with no control-plane role). Join URLs and every server's `--tls-san` list are derived from each server's node name and `github_runner_cluster_tailnet_domain`. A host can still set `github_runner_cluster_node_role`, `github_runner_cluster_bootstrap`, `github_runner_cluster_server_url` or `github_runner_cluster_tls_sans` to override its part, and the run fails with an explanation if the result has an even number of servers, none at all, or more than one bootstrap host; `roles/github_runner_cluster/defaults/main.yml` describes each. Installing the ARC controller, each org's runner scale set, and the fleet-health platform is not tied to a node role at all: each is driven purely by whether the installing host's own `github_runner_arc_orgs`/`github_runner_arc_heartbeat_gist_id` is set, so any server host can be the one Ansible installs against, and moving which host does the installing is a config change (which host's `host_vars` sets it), not a role change. See the [Architecture](#architecture) section for why 3 servers is the minimum sane count for genuine HA (2 is worse than 1 for quorum math, not better). To add a server or agent, add the host to the `github_runner_cluster` group in the fleet's inventory and give it its own `host_vars/.yml`; keep the bootstrap host first. diff --git a/plugins/filter/host_tools.py b/plugins/filter/host_tools.py index 6c6c1c73..e0121484 100644 --- a/plugins/filter/host_tools.py +++ b/plugins/filter/host_tools.py @@ -1,4 +1,4 @@ -"""Filters the github_runner_host_tools role uses: the Homebrew prefix a host has, the shell startup lines that bring Homebrew and mise into a shell, and mise's configuration rendered to TOML.""" +"""Filters the github_runner_host_tools role uses: the Homebrew prefix a host has, the shell startup lines that bring Homebrew and mise into a shell or set environment variables in it, mise's configuration rendered to TOML, the managed lines of an npmrc file and a YAML file merged with settings.""" from __future__ import annotations @@ -7,6 +7,7 @@ import shlex from typing import Any +import yaml from ansible.errors import AnsibleFilterError # The prefixes Homebrew supports, which are where its bottles are built to be installed: a different prefix means compiling from source and is unsupported. macOS has one for each architecture; Linux has one whatever the architecture. @@ -23,6 +24,20 @@ STRING_ESCAPES = {"\\": "\\\\", '"': '\\"', "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r"} +# The name of an environment variable the shell accepts in an assignment. +ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +# The npmrc key whose values are a list, written as one key[]=value line for each. It has a variable of its own, so the scalar settings refuse it. +NPM_LIST_KEY = "min-release-age-exclude" + +# An npmrc key, and a package name or pattern in an exclusion list. +NPM_KEY = re.compile(r"^[A-Za-z0-9_@:./-]+$") +NPM_PACKAGE = re.compile(r"^[A-Za-z0-9_@~./*-]+$") + +# An npmrc value written bare. The ini format npm reads treats ; and # as the start of a comment and a leading quote as quoting, and the value is trimmed, so a string with any of them would not read back as given. +NPM_VALUE = re.compile(r"^[^\x00-\x1f#;\"'\\]*$") + + def host_tools_homebrew_prefix(system: Any, machine: Any) -> str: """Return the Homebrew prefix of a host. @@ -58,6 +73,41 @@ def host_tools_shell_integrations(prefix: Any, shell: Any, homebrew: Any, mise: return integrations +def _env_value(name: str, value: Any) -> str: + """Return the text of an environment variable's value: a string as it is, a boolean as true or false, a number as its digits. + + :raises AnsibleFilterError: the value is none of those, or is a string of more than one line or with a NUL, which a startup file line cannot hold. + """ + # bool is a subclass of int, so it has to be tested first. + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + if any(char in value for char in "\n\r\x00"): + raise AnsibleFilterError(f"The value of {name} must be one line without a NUL.") + return str(value) + raise AnsibleFilterError(f"The value of {name} must be a string, a number or a boolean, not {type(value).__name__}.") + + +def host_tools_shell_env(env: Any) -> list[dict[str, str]]: + """Return the startup lines that set environment variables in a shell, in the shape host_tools_shell_integrations returns. + + Each value is quoted for the shell with ``shlex.quote``, so it reaches the variable exactly as given whatever it holds (spaces, quotes, ``$``, backticks), in zsh and in bash alike. ``pattern`` is an extended regular expression that matches a line of a startup file that already assigns the variable, outside a comment, whatever its value, so a value the user chose is never overridden, and ``name`` tells the lines apart. + + :param env: a mapping of variable names to string, number or boolean values. :returns: ``[{"name", "pattern", "line"}, ...]`` in the mapping's order, with a dictionary for each variable. :raises AnsibleFilterError: ``env`` is not a mapping, a name is not one the shell accepts, or a value is not a single-line string, a number or a boolean. + """ + if not isinstance(env, dict): + raise AnsibleFilterError(f"The shell environment must be a mapping, not {type(env).__name__}.") + lines: list[dict[str, str]] = [] + for name, value in env.items(): + if not isinstance(name, str) or not ENV_NAME.match(name): + raise AnsibleFilterError(f"{name!r} is not a name a shell accepts for an environment variable.") + text = _env_value(name, value) + lines.append({"name": f"env-{name}", "pattern": f"^[[:space:]]*(export[[:space:]]+)?{name}=", "line": f"export {name}={shlex.quote(text)}"}) + return lines + + def _key(name: Any) -> str: """Return a TOML key: bare when it can be, a quoted string when it has characters a bare key cannot.""" if not isinstance(name, str) or not name: @@ -127,6 +177,176 @@ def host_tools_mise_toml(config: Any) -> str: return "\n\n".join("\n".join(lines) for lines in blocks) + "\n" +def _npm_value(key: str, value: Any) -> str: + """Return the text of an npmrc value: a string as it is, a boolean as true or false, a number as its digits. + + :raises AnsibleFilterError: the value is none of those, or is a string that would not read back as given (a comment character, a quote, a backslash, a control character or surrounding space). + """ + # bool is a subclass of int, so it has to be tested first. + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if not math.isfinite(value): + raise AnsibleFilterError(f"{key} must be a finite number, not {value!r}.") + return repr(value) + if isinstance(value, str): + if not NPM_VALUE.match(value) or value != value.strip(): + raise AnsibleFilterError(f"{key} must not contain a quote, a backslash, a control character, # or ;, or start or end with a space, since npm would not read it back as given.") + return str(value) + raise AnsibleFilterError(f"{key} must be a string, a number or a boolean, not {type(value).__name__}.") + + +def _outside_block(existing: str, marker: str) -> list[str]: + """Return the lines of a text that are not between the BEGIN and END marker lines of the role's block, which are dropped with it. A BEGIN with no END after it drops nothing, since there is no block to speak of.""" + lines = existing.splitlines() + begin, end = marker.replace("{mark}", "BEGIN"), marker.replace("{mark}", "END") + stripped = [line.rstrip() for line in lines] + if begin not in stripped or end not in stripped[stripped.index(begin) :]: + return lines + first = stripped.index(begin) + last = first + stripped[first:].index(end) + return lines[:first] + lines[last + 1 :] + + +def host_tools_npmrc(settings: Any, excludes: Any, existing: Any, marker: Any) -> dict[str, list[str]]: + """Return the lines the role's block of an npmrc file holds, and the settings it leaves out because the file already sets them. + + A key the file sets outside the block is the user's own: it is left out of the lines and reported in ``shadowed``, so the role never overrides it (appended after it, a line of the block would win, because npm reads the last of two assignments), and an exclusion the file lists outside the block is left out silently, since listing it twice changes nothing. The lines are only key names and the values the caller gave: nothing of the file's other lines is returned or put in an error. + + :param settings: a mapping of npmrc keys to string, number or boolean values. :param excludes: a list of package names or patterns, each written as ``min-release-age-exclude[]=NAME``. :param existing: the text of the file, empty when it does not exist yet. :param marker: the block's marker line with ``{mark}`` where BEGIN and END go, which tells the role's block apart from the user's lines. :returns: ``{"lines": [...], "shadowed": [...]}``, the lines in the mapping's order and then the exclusions, without repeats. :raises AnsibleFilterError: ``settings`` is not a mapping or ``excludes`` not a list, a key or package name has a character an npmrc cannot hold, a key is the exclusion list's own, or a value is not a string, number or boolean that reads back as given. + """ + if not isinstance(settings, dict): + raise AnsibleFilterError(f"The npm settings must be a mapping, not {type(settings).__name__}.") + if not isinstance(excludes, list): + raise AnsibleFilterError(f"The npm exclusions must be a list, not {type(excludes).__name__}.") + if not isinstance(existing, str) or not isinstance(marker, str): + raise AnsibleFilterError("The npmrc text and the block's marker must be strings.") + outside = _outside_block(existing, marker) + lines: list[str] = [] + shadowed: list[str] = [] + for key, value in settings.items(): + if not isinstance(key, str) or not NPM_KEY.match(key): + raise AnsibleFilterError(f"{key!r} is not a key an npmrc file can hold.") + if key == NPM_LIST_KEY: + raise AnsibleFilterError(f"{key} is a list, so it has a variable of its own and is not one of the npm settings.") + text = _npm_value(key, value) + if any(re.match(rf"^\s*{re.escape(key)}\s*(=|$)", line) for line in outside): + shadowed.append(key) + else: + lines.append(f"{key}={text}") + listed = {match.group(1).strip().strip("\"'") for line in outside if (match := re.match(rf"^\s*{re.escape(NPM_LIST_KEY)}\[\]\s*=\s*(.*)$", line))} + for name in excludes: + if not isinstance(name, str) or not NPM_PACKAGE.match(name): + raise AnsibleFilterError(f"{name!r} is not a package name or pattern an npmrc exclusion can hold.") + line = f"{NPM_LIST_KEY}[]={name}" + if name not in listed and line not in lines: + lines.append(line) + return {"lines": lines, "shadowed": shadowed} + + +class _IndentedDumper(yaml.SafeDumper): + """A YAML dumper that indents a list under its key, as hand-written configuration files do.""" + + def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: + super().increase_indent(flow, False) + + +def _plain(value: Any, where: str) -> Any: + """Return a copy of a YAML value made of the plain types a dump writes: strings, booleans, numbers, lists and mappings with string keys. + + :raises AnsibleFilterError: the value holds anything else, a null included, or a number that is not finite. + """ + # bool is a subclass of int, so it has to be tested first. + if isinstance(value, bool): + return bool(value) + if isinstance(value, int): + return int(value) + if isinstance(value, float): + if not math.isfinite(value): + raise AnsibleFilterError(f"{where} must be a finite number, not {value!r}.") + return float(value) + if isinstance(value, str): + return str(value) + if isinstance(value, (list, tuple)): + return [_plain(item, f"An item of {where}") for item in value] + if isinstance(value, dict): + if not all(isinstance(key, str) for key in value): + raise AnsibleFilterError(f"{where} must have string keys.") + return {str(key): _plain(item, f"{where}.{key}") for key, item in value.items()} + raise AnsibleFilterError(f"{where} must be a string, a number, a boolean, a list or a mapping, not {type(value).__name__}.") + + +def _same(left: Any, right: Any) -> bool: + """Return whether two YAML values are equal and of the same types all the way down, which ``==`` is not (``True == 1`` and ``1 == 1.0``).""" + if type(left) is not type(right): + return False + if isinstance(left, dict): + return left.keys() == right.keys() and all(_same(left[key], right[key]) for key in left) + if isinstance(left, list): + return len(left) == len(right) and all(_same(a, b) for a, b in zip(left, right)) + return bool(left == right) + + +def _block_end(lines: list[str], start: int) -> int: + """Return the index after the last line of the top-level entry that starts at ``start``: the lines indented under it and the list items written at its own indent, without the blank and comment lines after them, which belong to what follows.""" + end = start + 1 + for index in range(start + 1, len(lines)): + line = lines[index] + if not line.strip() or line.startswith("#"): + continue + if line[0] in " \t" or line.startswith("- ") or line.rstrip() == "-": + end = index + 1 + continue + break + return end + + +def host_tools_yaml_merge(existing: Any, settings: Any) -> str: + """Return a YAML file's text with top-level keys set to the given values, and everything else in the file as it was. + + Only the entries of the keys whose value differs are rewritten, in place, or appended when the file has no such key; every other line, comments and blank lines included, is kept byte for byte, and a key that already holds the value is not touched, so the result equals the input when there is nothing to change. The result is parsed and compared with what the merge should hold, and a file whose layout the merge cannot edit safely (a flow-style mapping at the top, several documents) is refused rather than written wrong. A comment on a rewritten key's own line goes with its old value. + + :param existing: the file's text, empty when it does not exist yet. :param settings: a mapping of top-level keys to string, number, boolean, list or mapping values. :returns: the new text. :raises AnsibleFilterError: ``existing`` is not YAML or not a mapping at the top, ``settings`` holds a value that is none of the plain types, or the merge could not be made without changing something else. + """ + if not isinstance(existing, str): + raise AnsibleFilterError(f"The existing YAML must be text, not {type(existing).__name__}.") + if not isinstance(settings, dict): + raise AnsibleFilterError(f"The YAML settings must be a mapping, not {type(settings).__name__}.") + wanted = _plain(settings, "The YAML settings") + try: + current = yaml.safe_load(existing) + except yaml.YAMLError as error: + raise AnsibleFilterError(f"The existing file is not valid YAML, so nothing was merged into it ({getattr(error, 'problem', type(error).__name__)}).") from error + if current is None: + current = {} + if not isinstance(current, dict): + raise AnsibleFilterError("The existing file is not a mapping at the top, so nothing was merged into it.") + lines = existing.splitlines(keepends=True) + expected = dict(current) + for key, value in wanted.items(): + if key in current and _same(current[key], value): + continue + rendered = yaml.dump({key: value}, Dumper=_IndentedDumper, default_flow_style=False, sort_keys=False, allow_unicode=True, width=10**9).splitlines(keepends=True) + starts = [index for index, line in enumerate(lines) if re.match(rf"""^(?:{re.escape(key)}|"{re.escape(key)}"|'{re.escape(key)}')[ \t]*:(?=[ \t\r\n]|$)""", line)] + if starts: + lines[starts[-1] : _block_end(lines, starts[-1])] = rendered + else: + if lines and not lines[-1].endswith(("\n", "\r")): + lines[-1] += "\n" + lines.extend(rendered) + expected[key] = value + merged = "".join(lines) + try: + reread = yaml.safe_load(merged) + except yaml.YAMLError: + reread = None + if not _same(reread if reread is not None else {}, expected): + raise AnsibleFilterError("The file's layout is one the merge cannot edit safely, so nothing was merged into it.") + return merged + + class FilterModule: """Registers the host tools filters with Ansible.""" @@ -135,5 +355,8 @@ def filters(self) -> dict[str, Any]: return { "host_tools_homebrew_prefix": host_tools_homebrew_prefix, "host_tools_shell_integrations": host_tools_shell_integrations, + "host_tools_shell_env": host_tools_shell_env, "host_tools_mise_toml": host_tools_mise_toml, + "host_tools_npmrc": host_tools_npmrc, + "host_tools_yaml_merge": host_tools_yaml_merge, } diff --git a/roles/github_runner_host_tools/README.md b/roles/github_runner_host_tools/README.md index f78c048a..b8bb2733 100644 --- a/roles/github_runner_host_tools/README.md +++ b/roles/github_runner_host_tools/README.md @@ -1,6 +1,6 @@ # github_runner_host_tools -Homebrew and [mise](https://mise.jdx.dev) on every host of the fleet, Mac or Linux: installed when missing, kept at their latest release and made available to the connecting user's shell. It runs on macOS (Apple silicon and Intel) and on Debian-family Linux (Debian and Ubuntu) and skips any other host with a message, so it can be applied to a mixed inventory. `playbooks/site.yml` runs it for every cluster host, before the two host baseline plays and the fleet play. +Homebrew and [mise](https://mise.jdx.dev) on every host of the fleet, Mac or Linux: installed when missing, kept at their latest release and made available to the connecting user's shell, and the package-install security settings of npm and pnpm and the shell environment variables a host asks for, written from variables. It runs on macOS (Apple silicon and Intel) and on Debian-family Linux (Debian and Ubuntu) and skips any other host with a message, so it can be applied to a mixed inventory. `playbooks/site.yml` runs it for every cluster host, before the two host baseline plays and the fleet play. ## Privileges @@ -71,6 +71,70 @@ experimental = true Any other table, or a value of a kind its table does not take, fails the run with a message rather than being written in a form mise might read differently. +## Package-install security settings + +Hosts that install packages can refuse packages younger than a minimum age and skip install scripts. The role writes these settings from variables, and all four are empty by default: empty, the role does not read or write `~/.npmrc` or pnpm's config file, so a host that sets none of them sees no change. They do not need Homebrew or mise, and they apply on every host the role supports. Node and pnpm themselves are not installed here; put them in `github_runner_host_tools_mise_config`. + +### npm + +`github_runner_host_tools_npm_settings` is a mapping of npmrc keys to string, number or boolean values, and `github_runner_host_tools_npm_min_release_age_exclude` is a list of package names or patterns. Both are written to the connecting user's `~/.npmrc` in one block between `ANSIBLE MANAGED BLOCK` marker lines, and nothing outside the block is touched: + +```yaml +github_runner_host_tools_npm_settings: + min-release-age: 7 + ignore-scripts: true +github_runner_host_tools_npm_min_release_age_exclude: + - example-package + - "@example/*" +``` + +renders to + +```ini +# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools npm +min-release-age=7 +ignore-scripts=true +min-release-age-exclude[]=example-package +min-release-age-exclude[]=@example/* +# END ANSIBLE MANAGED BLOCK github_runner_host_tools npm +``` + +`min-release-age` is in days ([npm config](https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age); [Wayback Machine snapshot](https://web.archive.org/web/20261022065815/https://docs.npmjs.com/cli/v11/using-npm/config/)). A second run changes nothing, dropping an entry removes its line, and dropping them all removes the block. + +An `.npmrc` can hold credentials, so the role reads it only to tell which keys the user already sets, with `no_log`, and prints key names and nothing else. It never changes the mode of an existing file, and creates a missing one with mode `0600`. A file that is a symlink stays one and its target is edited. + +A key the file already sets outside the block is the user's own, and the role keeps it: the role's value for that key is not written, and the run prints a message naming the key (`~/.npmrc already sets min-release-age outside the block the role manages, so the host's own value is kept and the role's is not written.`). Writing it would not be neutral, because npm reads the last of two assignments of a key, so a line in a block at the end of the file would silently replace the user's. The run does not fail, since a host that chose its own value on purpose is not a fault. A key set only in a comment does not count, and an exclusion the file lists already is not listed again. A key the user adds later leaves the block on the next run. + +The ini format npm reads treats `;` and `#` as comments and a leading quote as quoting, so a value with any of those, a backslash, a control character or surrounding space is refused with a message rather than written in a form npm might read differently. A key that is the exclusion list's own (`min-release-age-exclude`) has the variable above and is refused in the mapping. + +### pnpm + +`github_runner_host_tools_pnpm_settings` is a mapping of top-level keys of pnpm's global `config.yaml` to values, and `github_runner_host_tools_pnpm_minimum_release_age_exclude` is a list written under `minimumReleaseAgeExclude`: + +```yaml +github_runner_host_tools_pnpm_settings: + minimumReleaseAge: 10080 + saveExact: true +github_runner_host_tools_pnpm_minimum_release_age_exclude: + - example-package +``` + +`minimumReleaseAge` is in minutes ([pnpm, Supply chain security](https://pnpm.io/supply-chain-security); [Wayback Machine snapshot](https://web.archive.org/web/20261022233946/https://pnpm.io/supply-chain-security)). The file is at `~/Library/Preferences/pnpm/config.yaml` on macOS and `~/.config/pnpm/config.yaml` on Linux, the two paths [pnpm config](https://pnpm.io/cli/config) documents for the global configuration file ([Wayback Machine snapshot](https://web.archive.org/web/20260915213602/https://pnpm.io/cli/config)). pnpm reads `$XDG_CONFIG_HOME/pnpm/config.yaml` instead when `XDG_CONFIG_HOME` is set, which a play over a non-interactive SSH session cannot know, so a host that sets it has the file elsewhere and the role still writes the platform's own location. + +The role sets each key it is given and edits nothing else: every other key, comment and blank line stays byte for byte, a key that already holds the value is not touched (so a second run changes nothing and a file that is right is not rewritten), and a list is written indented under its key. A comment on the same line as a rewritten key goes with its old value. The result is parsed and compared with what the merge should hold before it is written, and a file whose layout the merge cannot edit safely (not YAML, not a mapping at the top, a flow-style mapping, several documents) fails the run with a message and is left as it is. Setting `minimumReleaseAgeExclude` in both variables fails the run before anything is written. The role never changes the mode of an existing file, a new one gets the user's umask, and a symlink stays one with its target edited. A key dropped from the variable is left in the file, since the role cannot tell which keys in a YAML file it wrote. + +## Shell environment + +`github_runner_host_tools_shell_env` is a mapping of environment variable names to string, number or boolean values, for example `UV_PYTHON_DOWNLOADS: never`. Each variable gets its own managed block with one `export` line in the shell's startup file (`.zshenv` for zsh, `.bashrc` for bash, as under Shell integration), so a command run over SSH sees it: + +```sh +export UV_PYTHON_DOWNLOADS=never +``` + +The same discipline as the Homebrew and mise lines applies: a variable is added only when none of that shell's startup files assigns it already (`NAME=` or `export NAME=` outside a comment, whatever the value), so a value the user chose is never overridden, and a block the role wrote earlier is kept current when the value changes. The value is quoted for the shell (`shlex.quote`: single quotes, with an embedded single quote written as `'"'"'`), so spaces, quotes, `$`, backticks and `;` reach the variable exactly as given in zsh and bash alike. A name the shell does not accept, and a value of more than one line, fail the run with a message. A boolean is written as `true` or `false`. + +The variables are written whether or not `github_runner_host_tools_shell_integration` is on, and without Homebrew: that variable covers only the Homebrew and mise lines, and a mapping the host gives is the host asking for the variables. A user whose login shell is neither zsh nor bash is left alone with a message. A variable dropped from the mapping keeps its line. + ## Variables - `github_runner_host_tools_homebrew` (default true): install Homebrew when it is missing and run `brew update` on every run. @@ -78,6 +142,11 @@ Any other table, or a value of a kind its table does not take, fails the run wit - `github_runner_host_tools_shell_integration` (default true): add the Homebrew and mise lines to the connecting user's shell startup file where none has them. - `github_runner_host_tools_homebrew_install_commit`, `github_runner_host_tools_homebrew_install_sha256`: the commit of Homebrew/install whose `install.sh` is downloaded, and that file's SHA-256 checksum. Both are checked to be full lower-case hexadecimal hashes (40 and 64 characters) before anything runs; `defaults/main.yml` holds the values. - `github_runner_host_tools_mise_config` (default an empty mapping): see Global mise defaults above. +- `github_runner_host_tools_npm_settings` (default an empty mapping): npmrc keys and values written to `~/.npmrc`; see Package-install security settings above. +- `github_runner_host_tools_npm_min_release_age_exclude` (default an empty list): package names or patterns written as `min-release-age-exclude[]=NAME` lines in the same block. +- `github_runner_host_tools_pnpm_settings` (default an empty mapping): top-level keys and values merged into pnpm's global `config.yaml`. +- `github_runner_host_tools_pnpm_minimum_release_age_exclude` (default an empty list): package names or patterns written under `minimumReleaseAgeExclude` in that file. +- `github_runner_host_tools_shell_env` (default an empty mapping): environment variables written as `export` lines in the shell's startup file; see Shell environment above. - `github_runner_host_tools_homebrew_prefix` (default empty): the Homebrew prefix. Empty takes the prefix described under Homebrew. Not a setting; a variable only so the tests can point it at a stand-in. - `github_runner_host_tools_homebrew_install_base_url`: where the installer is downloaded from, as `//install.sh`. Not a setting; a variable only so the tests can point it at a stand-in. @@ -100,6 +169,17 @@ github_runner_host_tools_mise_config: python: "3.13" ``` +```yaml +# host_vars/example.yml: refuse packages younger than a week and skip install scripts, and keep uv from downloading Pythons +github_runner_host_tools_npm_settings: + min-release-age: 7 + ignore-scripts: true +github_runner_host_tools_pnpm_settings: + minimumReleaseAge: 10080 +github_runner_host_tools_shell_env: + UV_PYTHON_DOWNLOADS: never +``` + ## Testing -`tests/unit/test_host_tools.py` covers the filters in `plugins/filter/host_tools.py` (the prefix of each system and architecture, the shell startup lines, and the TOML rendering against golden strings), the defaults against the argument specs and this file, the shape of the pinned commit and checksum, that the only formula the role ever names to Homebrew is mise, the wiring in `playbooks/site.yml`, and the role's tasks (`tests/host_tools/run.yml`) against stand-ins for `brew`, `sudo`, `apt-get`, `dpkg-query`, `getent`, `dscl` and Homebrew's installer on `PATH`, with the installer served from a local directory and every shell startup file in a temporary home. They check that the installer runs non-interactively as the user and only when its checksum matches, that a second run and `--check` change nothing, that root is refused, that a startup file keeps its mode, its content and its symlink, and that `config.toml` is never written. Nothing is downloaded and no real package manager, privilege or startup file is touched. +`tests/unit/test_host_tools.py` covers the filters in `plugins/filter/host_tools.py` (the prefix of each system and architecture, the shell startup lines, and the TOML rendering against golden strings), the defaults against the argument specs and this file, the shape of the pinned commit and checksum, that the only formula the role ever names to Homebrew is mise, the wiring in `playbooks/site.yml`, and the role's tasks (`tests/host_tools/run.yml`) against stand-ins for `brew`, `sudo`, `apt-get`, `dpkg-query`, `getent`, `dscl` and Homebrew's installer on `PATH`, with the installer served from a local directory and every shell startup file in a temporary home. They check that the installer runs non-interactively as the user and only when its checksum matches, that a second run and `--check` change nothing, that root is refused, that a startup file keeps its mode, its content and its symlink, and that `config.toml` is never written. They also check the package-install settings and the shell variables: that nothing is read or written when the variables are empty, that the `.npmrc` block holds only the role's lines, keeps the user's lines and credentials (made-up values) out of the output, is idempotent and shrinks as entries are dropped, that a key the user sets outside the block is kept and reported, that a new `.npmrc` is `0600` and no existing file or symlink changes mode, that the pnpm merge keeps unrelated keys and comments, and that a shell variable's value, quotes and dollar signs included, round-trips through `sh` and `bash` reading the written file. Nothing is downloaded and no real package manager, privilege or startup file is touched. diff --git a/roles/github_runner_host_tools/defaults/main.yml b/roles/github_runner_host_tools/defaults/main.yml index 455cb4a7..2aa67b8e 100644 --- a/roles/github_runner_host_tools/defaults/main.yml +++ b/roles/github_runner_host_tools/defaults/main.yml @@ -15,6 +15,19 @@ github_runner_host_tools_homebrew_install_sha256: 5f333bbe53bc490e51e7ccb1df8779 # Global mise defaults, as a mapping of mise's own configuration tables. Empty (the default) writes nothing and leaves any existing file alone. Otherwise the role renders it to ~/.config/mise/conf.d/00-fleet.toml, never to config.toml, which is the host's own and takes precedence over conf.d. Two tables are understood: tools (a tool name, quoted in the file if it needs it, to a version string or a list of them) and settings (a setting name to a string, number, boolean or a list of those). For example {tools: {node: "22", python: ["3.13", "3.12"]}, settings: {experimental: true}}. github_runner_host_tools_mise_config: {} +# Package-install security settings for npm and pnpm. All empty by default, and empty they write nothing, so a host that sets none of them sees no change. +# npm settings, as a mapping of npmrc keys to string, number or boolean values, written to the connecting user's ~/.npmrc inside one block between ANSIBLE MANAGED BLOCK marker lines, for example {min-release-age: 7, ignore-scripts: true} (min-release-age is in days). A key the file already sets outside the block is the user's own: it is kept, the role's value is not written, and the run says so. Dropping a key here, or all of them, removes its line, and the block with the last one. The file can hold credentials, so the role never prints it, never changes the mode of an existing one, and creates a missing one with mode 0600. +github_runner_host_tools_npm_settings: {} +# Package names or patterns exempt from min-release-age, each written to the same block as a min-release-age-exclude[]=NAME line. A name the file already lists outside the block is not written again. +github_runner_host_tools_npm_min_release_age_exclude: [] +# pnpm settings, as a mapping of top-level keys of pnpm's global config.yaml to values, merged into that file (~/Library/Preferences/pnpm/config.yaml on macOS, ~/.config/pnpm/config.yaml on Linux), for example {minimumReleaseAge: 10080, saveExact: true} (minimumReleaseAge is in minutes). Each key named here is set to the value given; every other key and comment in the file stays as it is, and a file that holds the values already is not rewritten. A key dropped from the mapping is left in the file. +github_runner_host_tools_pnpm_settings: {} +# Package names or patterns exempt from minimumReleaseAge. A list for minimumReleaseAgeExclude, which replaces whatever list the file holds under that key when this is not empty; do not set the key in github_runner_host_tools_pnpm_settings as well. +github_runner_host_tools_pnpm_minimum_release_age_exclude: [] + +# Environment variables for the connecting user's shell, as a mapping of names to string, number or boolean values, for example {UV_PYTHON_DOWNLOADS: never}. Each becomes an export line in a managed block in the shell's startup file (.zshenv for zsh, .bashrc for bash), quoted so the value reaches the variable as given, and only when none of that shell's startup files assigns the variable already. Written whether or not github_runner_host_tools_shell_integration is on, since that variable covers only the Homebrew and mise lines. A variable dropped from the mapping keeps its line. +github_runner_host_tools_shell_env: {} + # Not a setting: the Homebrew prefix. Empty takes /opt/homebrew on Apple silicon, /usr/local on Intel and /home/linuxbrew/.linuxbrew on Linux, which are the only places Homebrew supports. It is a variable only so the tests can point it at a stand-in. github_runner_host_tools_homebrew_prefix: "" # Not a setting: where the commit's install.sh is downloaded from, as //install.sh. It is a variable only so the tests can point it at a stand-in. diff --git a/roles/github_runner_host_tools/meta/argument_specs.yml b/roles/github_runner_host_tools/meta/argument_specs.yml index 7ef19f0e..7eb63e59 100644 --- a/roles/github_runner_host_tools/meta/argument_specs.yml +++ b/roles/github_runner_host_tools/meta/argument_specs.yml @@ -2,7 +2,7 @@ # The role's variables, checked when the role runs. defaults/main.yml holds each default and the reasoning behind it. argument_specs: main: - short_description: Installs Homebrew and mise, keeps them at their latest release and makes them available to the connecting user's shell. + short_description: Installs Homebrew and mise, keeps them at their latest release and makes them available to the connecting user's shell, and sets npm and pnpm package-install security settings and shell environment variables when the variables for them are given. description: - Runs on macOS and Debian-family Linux hosts and skips any other with a message. Homebrew refuses to run as root, so the connecting user must not be root; the tasks that need privilege (the Linux prerequisites packages and the installer's own sudo) use become or sudo, so run the play with passwordless sudo for the connecting user or with --ask-become-pass. options: @@ -30,6 +30,28 @@ argument_specs: type: dict default: {} description: Global mise defaults, as a mapping with the tables tools and settings. Empty writes nothing; otherwise it is rendered to ~/.config/mise/conf.d/00-fleet.toml. + github_runner_host_tools_npm_settings: + type: dict + default: {} + description: npm settings as a mapping of npmrc keys to string, number or boolean values, written to ~/.npmrc inside one managed block (min-release-age is in days). Empty writes nothing. A key the file already sets outside the block is kept and reported. + github_runner_host_tools_npm_min_release_age_exclude: + type: list + elements: str + default: [] + description: Package names or patterns exempt from min-release-age, written to the same block as min-release-age-exclude[]=NAME lines. + github_runner_host_tools_pnpm_settings: + type: dict + default: {} + description: pnpm settings as a mapping of top-level keys to values, merged into pnpm's global config.yaml (minimumReleaseAge is in minutes). Empty writes nothing. Other keys and comments in the file are kept. + github_runner_host_tools_pnpm_minimum_release_age_exclude: + type: list + elements: str + default: [] + description: Package names or patterns exempt from minimumReleaseAge, written as the list under minimumReleaseAgeExclude in pnpm's global config.yaml. + github_runner_host_tools_shell_env: + type: dict + default: {} + description: Environment variables for the connecting user's shell, as a mapping of names to string, number or boolean values, each written as a quoted export line in a managed block of the shell's startup file unless a startup file of the shell assigns it already. github_runner_host_tools_homebrew_prefix: type: str default: "" diff --git a/roles/github_runner_host_tools/tasks/main.yml b/roles/github_runner_host_tools/tasks/main.yml index ff9e1d03..e1357e44 100644 --- a/roles/github_runner_host_tools/tasks/main.yml +++ b/roles/github_runner_host_tools/tasks/main.yml @@ -42,3 +42,20 @@ when: - github_runner_host_tools_supported | bool - github_runner_host_tools_homebrew | bool + +- name: Set up the connecting user's shell + # Homebrew's and mise's lines when the integration is on, and the environment variables of github_runner_host_tools_shell_env, which are written whether or not it is: the mapping is the consumer asking for them. Nothing is read or written when there is neither. + ansible.builtin.include_tasks: shell_integration.yml + when: + - github_runner_host_tools_supported | bool + - (github_runner_host_tools_shell_integration | bool and github_runner_host_tools_homebrew | bool) or github_runner_host_tools_shell_env | length > 0 + +- name: Write the npm package-install security settings + ansible.builtin.include_tasks: npm_settings.yml + when: github_runner_host_tools_supported | bool + +- name: Write the pnpm package-install security settings + ansible.builtin.include_tasks: pnpm_settings.yml + when: + - github_runner_host_tools_supported | bool + - github_runner_host_tools_pnpm_settings | length > 0 or github_runner_host_tools_pnpm_minimum_release_age_exclude | length > 0 diff --git a/roles/github_runner_host_tools/tasks/npm_settings.yml b/roles/github_runner_host_tools/tasks/npm_settings.yml new file mode 100644 index 00000000..43895a26 --- /dev/null +++ b/roles/github_runner_host_tools/tasks/npm_settings.yml @@ -0,0 +1,59 @@ +--- +# Writes the npm package-install security settings (github_runner_host_tools_npm_settings and github_runner_host_tools_npm_min_release_age_exclude) to the connecting user's ~/.npmrc as one managed block, and takes the block out again when the variables no longer name anything. The file can hold credentials: it is never printed, logged or given a mode it did not have, and no line outside the block is touched. A key the file already sets outside the block is the user's own, so it is left as it is and reported. + +- name: Look for ~/.npmrc + ansible.builtin.stat: + path: "{{ ansible_facts['user_dir'] }}/.npmrc" + get_checksum: false + register: github_runner_host_tools_npmrc + +- name: Look for the role's own block in ~/.npmrc + # grep prints nothing with -q, so the file is not read into the output. It exits 0 for a match, 1 for none and 2 when it could not read the file, which a file that does not exist is. Read-only, so it runs under --check too. + ansible.builtin.command: + argv: [grep, -F, -q, -s, -e, "{{ github_runner_host_tools_npmrc_marker | replace('{mark}', 'BEGIN') }}", --, "{{ ansible_facts['user_dir'] }}/.npmrc"] + register: github_runner_host_tools_npmrc_own_block + changed_when: false + failed_when: github_runner_host_tools_npmrc_own_block.rc not in [0, 1, 2] + check_mode: false + +- name: Write or remove the managed block + # Only when there is something to write, or a block the role wrote earlier to take out: with both variables empty and no block, ~/.npmrc is not even read. + when: github_runner_host_tools_npm_settings | length > 0 or github_runner_host_tools_npm_min_release_age_exclude | length > 0 or github_runner_host_tools_npmrc_own_block.rc == 0 + block: + - name: Read ~/.npmrc + # Only to tell which keys the user already sets outside the block. no_log keeps the file, credentials included, out of the output and the log, and the filter below returns key names only. + ansible.builtin.slurp: + src: "{{ ansible_facts['user_dir'] }}/.npmrc" + register: github_runner_host_tools_npmrc_content + no_log: true + check_mode: false + when: github_runner_host_tools_npmrc.stat.exists + + - name: Work out the lines of the block + # The file's text is empty when it does not exist yet (a --check run before the file is created). + ansible.builtin.set_fact: + github_runner_host_tools_npmrc_plan: "{{ github_runner_host_tools_npm_settings | exadev.github_runner.host_tools_npmrc(github_runner_host_tools_npm_min_release_age_exclude, github_runner_host_tools_npmrc_content.content | default('') | b64decode, github_runner_host_tools_npmrc_marker) }}" + + - name: Report the settings ~/.npmrc already sets itself + ansible.builtin.debug: + msg: "{{ ansible_facts['user_dir'] }}/.npmrc already sets {{ github_runner_host_tools_npmrc_plan['shadowed'] | join(', ') }} outside the block the role manages, so the host's own value is kept and the role's is not written." + when: github_runner_host_tools_npmrc_plan['shadowed'] | length > 0 + + - name: Create ~/.npmrc when it does not exist + # After the lines are worked out, so a value the filter refuses leaves no file behind. 0600 because an npmrc can hold credentials. Only when the file is missing, so the role never asks copy to set a mode on a file the user has, whatever copy would do with its content. + ansible.builtin.copy: + content: "" + dest: "{{ ansible_facts['user_dir'] }}/.npmrc" + mode: "0600" + force: false + when: + - not github_runner_host_tools_npmrc.stat.exists + - github_runner_host_tools_npm_settings | length > 0 or github_runner_host_tools_npm_min_release_age_exclude | length > 0 + + - name: Write the block # noqa: risky-file-permissions + # No mode is passed on purpose, so an existing file keeps the mode it has (blockinfile always edits the target of a symlink, so an npmrc that is a link stays one). With no lines the block is empty, and an empty block removes the block and its markers (the module documents it). + ansible.builtin.blockinfile: + path: "{{ ansible_facts['user_dir'] }}/.npmrc" + marker: "{{ github_runner_host_tools_npmrc_marker }}" + block: "{{ github_runner_host_tools_npmrc_plan['lines'] | join('\n') }}" + create: true diff --git a/roles/github_runner_host_tools/tasks/pnpm_settings.yml b/roles/github_runner_host_tools/tasks/pnpm_settings.yml new file mode 100644 index 00000000..d1fb2cf0 --- /dev/null +++ b/roles/github_runner_host_tools/tasks/pnpm_settings.yml @@ -0,0 +1,60 @@ +--- +# Merges github_runner_host_tools_pnpm_settings and github_runner_host_tools_pnpm_minimum_release_age_exclude into pnpm's global config.yaml, at the path the host's operating system has. Only reached when there is something to write. The keys the role names are set; every other key and comment in the file stays as it is, and a file that already holds the values is not rewritten. + +- name: Fail if the exclusion list is given twice + # minimumReleaseAgeExclude can be set by its own variable or inside the settings mapping, and a host that did both would have one silently win. + ansible.builtin.assert: + that: "'minimumReleaseAgeExclude' not in github_runner_host_tools_pnpm_settings or github_runner_host_tools_pnpm_minimum_release_age_exclude | length == 0" + fail_msg: minimumReleaseAgeExclude is in github_runner_host_tools_pnpm_settings and github_runner_host_tools_pnpm_minimum_release_age_exclude is set too. Use one of them. + quiet: true + +- name: Work out the pnpm settings and the file they go to + ansible.builtin.set_fact: + github_runner_host_tools_pnpm_wanted: "{{ github_runner_host_tools_pnpm_settings | combine({'minimumReleaseAgeExclude': github_runner_host_tools_pnpm_minimum_release_age_exclude} if github_runner_host_tools_pnpm_minimum_release_age_exclude | length > 0 else {}) }}" + github_runner_host_tools_pnpm_config: "{{ ansible_facts['user_dir'] }}/{{ github_runner_host_tools_pnpm_config_files[ansible_facts['system']] }}" + +- name: Look for the config file + ansible.builtin.stat: + path: "{{ github_runner_host_tools_pnpm_config }}" + get_checksum: false + register: github_runner_host_tools_pnpm_file + +- name: Look for the config directory + ansible.builtin.stat: + path: "{{ github_runner_host_tools_pnpm_config | dirname }}" + get_checksum: false + register: github_runner_host_tools_pnpm_dir + +- name: Make the config directory # noqa: risky-file-permissions + # No mode is passed on purpose, so an existing directory keeps the mode it has; a new one gets the user's umask. + ansible.builtin.file: + path: "{{ github_runner_host_tools_pnpm_config | dirname }}" + state: directory + +- name: Read the config file + ansible.builtin.slurp: + src: "{{ github_runner_host_tools_pnpm_config }}" + register: github_runner_host_tools_pnpm_content + no_log: true + check_mode: false + when: github_runner_host_tools_pnpm_file.stat.exists + +- name: Merge the settings into the file's text + ansible.builtin.set_fact: + github_runner_host_tools_pnpm_merged: "{{ github_runner_host_tools_pnpm_content.content | default('') | b64decode | exadev.github_runner.host_tools_yaml_merge(github_runner_host_tools_pnpm_wanted) }}" + +- name: Write the config file # noqa: risky-file-permissions + # No mode is passed on purpose, so an existing file keeps the mode it has and a new one gets the user's umask; follow keeps a file that is a symlink one and edits its target. Under --check the directory above was not made if it was missing, and copy refuses a destination whose directory is not there, so that case is reported below. + ansible.builtin.copy: + content: "{{ github_runner_host_tools_pnpm_merged }}" + dest: "{{ github_runner_host_tools_pnpm_config }}" + follow: true + when: github_runner_host_tools_pnpm_dir.stat.exists or not ansible_check_mode + +- name: Report the settings a real run would write + ansible.builtin.debug: + msg: "{{ github_runner_host_tools_pnpm_config }} would be written" + changed_when: true + when: + - ansible_check_mode + - not github_runner_host_tools_pnpm_dir.stat.exists diff --git a/roles/github_runner_host_tools/tasks/shell_integration.yml b/roles/github_runner_host_tools/tasks/shell_integration.yml index 235574d8..303dc30c 100644 --- a/roles/github_runner_host_tools/tasks/shell_integration.yml +++ b/roles/github_runner_host_tools/tasks/shell_integration.yml @@ -1,5 +1,5 @@ --- -# Adds Homebrew's shellenv and the mise activation to the connecting user's shell, and only what is missing: a line goes into a startup file only when none of the shell's startup files has an equivalent already, so a host that was set up by hand, or by a dotfiles manager, is left exactly as it is. The user's login shell is read from the account database, never assumed. +# Adds Homebrew's shellenv, the mise activation and the environment variables of github_runner_host_tools_shell_env to the connecting user's shell, and only what is missing: a line goes into a startup file only when none of the shell's startup files has an equivalent already, so a host that was set up by hand, or by a dotfiles manager, is left exactly as it is. The user's login shell is read from the account database, never assumed. - name: Read the connecting user's account entry on Linux ansible.builtin.command: @@ -28,8 +28,8 @@ when: github_runner_host_tools_shell not in github_runner_host_tools_shell_files - name: Add what each shell startup file is missing - # The list is empty for a shell the role does not set up, because a loop is evaluated before its when and the filter refuses a shell it does not know. + # The list is empty for a shell the role does not set up, because a loop is evaluated before its when and the filter refuses a shell it does not know. Homebrew's and mise's lines come first, only when the integration is on and Homebrew is managed (the prefix is worked out only then), and the environment variables follow. ansible.builtin.include_tasks: shell_integration_line.yml - loop: "{{ (github_runner_host_tools_effective_prefix | exadev.github_runner.host_tools_shell_integrations(github_runner_host_tools_shell, github_runner_host_tools_homebrew | bool, github_runner_host_tools_mise | bool)) if github_runner_host_tools_shell in github_runner_host_tools_shell_files else [] }}" + loop: "{{ (((github_runner_host_tools_effective_prefix | exadev.github_runner.host_tools_shell_integrations(github_runner_host_tools_shell, github_runner_host_tools_homebrew | bool, github_runner_host_tools_mise | bool)) if github_runner_host_tools_shell_integration | bool and github_runner_host_tools_homebrew | bool else []) + (github_runner_host_tools_shell_env | exadev.github_runner.host_tools_shell_env)) if github_runner_host_tools_shell in github_runner_host_tools_shell_files else [] }}" loop_control: loop_var: github_runner_host_tools_integration diff --git a/roles/github_runner_host_tools/tasks/tools.yml b/roles/github_runner_host_tools/tasks/tools.yml index 3d2dfd32..c5c1ae16 100644 --- a/roles/github_runner_host_tools/tasks/tools.yml +++ b/roles/github_runner_host_tools/tasks/tools.yml @@ -33,10 +33,6 @@ - name: Keep Homebrew and mise at their latest release ansible.builtin.include_tasks: packages.yml -- name: Make Homebrew and mise available to the connecting user's shell - ansible.builtin.include_tasks: shell_integration.yml - when: github_runner_host_tools_shell_integration | bool - - name: Write the global mise defaults ansible.builtin.include_tasks: mise_config.yml when: diff --git a/roles/github_runner_host_tools/vars/main.yml b/roles/github_runner_host_tools/vars/main.yml index 5aa349bd..18901937 100644 --- a/roles/github_runner_host_tools/vars/main.yml +++ b/roles/github_runner_host_tools/vars/main.yml @@ -15,3 +15,11 @@ github_runner_host_tools_apt_prerequisites: - curl - file - git + +# Where pnpm reads its global config.yaml on each operating system, relative to the user's home (https://pnpm.io/cli/config). pnpm looks in $XDG_CONFIG_HOME/pnpm first when that is set, which a role that runs over a non-interactive SSH session cannot know, so the role writes the platform's own location. +github_runner_host_tools_pnpm_config_files: + Darwin: Library/Preferences/pnpm/config.yaml + Linux: .config/pnpm/config.yaml + +# The marker lines of the block the role manages in ~/.npmrc, with {mark} where BEGIN and END go. A block with these markers is the role's own, and everything outside it is the user's. +github_runner_host_tools_npmrc_marker: "# {mark} ANSIBLE MANAGED BLOCK github_runner_host_tools npm" diff --git a/tests/unit/test_host_tools.py b/tests/unit/test_host_tools.py index a2b75e23..aa8b9718 100644 --- a/tests/unit/test_host_tools.py +++ b/tests/unit/test_host_tools.py @@ -1,4 +1,4 @@ -"""Tests the github_runner_host_tools role: the filters that pick the Homebrew prefix, write the shell startup lines and render mise's configuration, the defaults against the argument specs and README, how site.yml wires the role, and the role's tasks (tests/host_tools/run.yml) against stand-ins for brew, sudo, apt-get, dpkg-query, getent, dscl and Homebrew's installer on PATH, so no package, shell startup file outside a temporary home or privilege is ever touched and nothing is downloaded.""" +"""Tests the github_runner_host_tools role: the filters that pick the Homebrew prefix, write the shell startup lines and render mise's configuration, the package-install security settings for npm and pnpm and the shell environment variables the role writes, the defaults against the argument specs and README, how site.yml wires the role, and the role's tasks (tests/host_tools/run.yml) against stand-ins for brew, sudo, apt-get, dpkg-query, getent, dscl and Homebrew's installer on PATH, so no package, shell startup file outside a temporary home or privilege is ever touched and nothing is downloaded.""" from __future__ import annotations @@ -133,6 +133,21 @@ def need_root(): READ_ONLY = re.compile(r"(brew root=0 (info .*|update)|dpkg-query .*|getent .*|dscl .*|sudo .*)$") +# A made-up credential, so the tests can check that no run prints the file it sits in. +NPMRC_TOKEN = "made-up-token-0000" +NPMRC_USER = f"registry=https://registry.example.test/\n//registry.example.test/:_authToken={NPMRC_TOKEN}\n" +# The test inventory is written with sorted keys, so the block lists these in that order. +NPM_SETTINGS = {"ignore-scripts": True, "min-release-age": 7} +NPM_MARKS = ("# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools npm\n", "# END ANSIBLE MANAGED BLOCK github_runner_host_tools npm\n") +NPM_BLOCK_SETTINGS = NPM_MARKS[0] + "ignore-scripts=true\nmin-release-age=7\n" + NPM_MARKS[1] +NPM_BLOCK = NPM_MARKS[0] + "ignore-scripts=true\nmin-release-age=7\nmin-release-age-exclude[]=example-a\nmin-release-age-exclude[]=@example/*\n" + NPM_MARKS[1] +PNPM_CONFIG = {"Darwin": "Library/Preferences/pnpm/config.yaml", "Linux": ".config/pnpm/config.yaml"} +PNPM_SETTINGS = {"minimumReleaseAge": 10080, "saveExact": True} +PNPM_USER = "# Mine.\nstoreDir: /somewhere/store # the store\n\n# The age, in minutes.\nminimumReleaseAge: 1440\n\n# Exclusions.\nminimumReleaseAgeExclude:\n- old-one\n\nregistry: https://registry.example.test/\n" +PNPM_MERGED = "# Mine.\nstoreDir: /somewhere/store # the store\n\n# The age, in minutes.\nminimumReleaseAge: 10080\n\n# Exclusions.\nminimumReleaseAgeExclude:\n - example-a\n\nregistry: https://registry.example.test/\nsaveExact: true\n" +ENV_BLOCK_UV = "# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools env-UV_PYTHON_DOWNLOADS\nexport UV_PYTHON_DOWNLOADS=never\n# END ANSIBLE MANAGED BLOCK github_runner_host_tools env-UV_PYTHON_DOWNLOADS\n" + + class QuietHandler(http.server.SimpleHTTPRequestHandler): """Serves a directory without writing a line to the test output for every request.""" @@ -150,7 +165,7 @@ def mise_block(prefix: Path, shell: str) -> str: class FilterRegistrationTest(unittest.TestCase): def test_the_filters_are_registered_under_the_names_the_role_uses(self) -> None: - self.assertEqual(set(host_tools.FilterModule().filters()), {"host_tools_homebrew_prefix", "host_tools_shell_integrations", "host_tools_mise_toml"}) + self.assertEqual(set(host_tools.FilterModule().filters()), {"host_tools_homebrew_prefix", "host_tools_shell_integrations", "host_tools_shell_env", "host_tools_mise_toml", "host_tools_npmrc", "host_tools_yaml_merge"}) used = set(re.findall(r"exadev\.github_runner\.(host_tools_\w+)", "".join(path.read_text() for path in (ROLE / "tasks").glob("*.yml")))) self.assertEqual(used, set(host_tools.FilterModule().filters())) @@ -250,10 +265,35 @@ def test_the_installer_is_never_run_as_root(self) -> None: self.assertNotIn("become", task) def test_the_shell_integration_never_sets_a_mode_on_a_startup_file(self) -> None: - [block] = [task["ansible.builtin.blockinfile"] for name, task in self.tasks() if "ansible.builtin.blockinfile" in task] + [block] = [task["ansible.builtin.blockinfile"] for name, task in self.tasks() if "ansible.builtin.blockinfile" in task and name == "shell_integration_line.yml"] self.assertNotIn("mode", block) self.assertIs(block["create"], True) + def test_the_npm_settings_block_never_sets_a_mode_and_a_missing_npmrc_is_created_private(self) -> None: + [block] = [task["ansible.builtin.blockinfile"] for name, task in self.tasks() if "ansible.builtin.blockinfile" in task and name == "npm_settings.yml"] + self.assertNotIn("mode", block) + [created] = [task for name, task in self.tasks() if name == "npm_settings.yml" and task.get("name") == "Create ~/.npmrc when it does not exist"] + self.assertEqual(created["ansible.builtin.copy"]["mode"], "0600") + self.assertIs(created["ansible.builtin.copy"]["force"], False) + self.assertIn("not github_runner_host_tools_npmrc.stat.exists", created["when"]) + + def test_the_npmrc_is_never_shown_or_logged(self) -> None: + [read] = [task for name, task in self.tasks() if name == "npm_settings.yml" and "ansible.builtin.slurp" in task] + self.assertIs(read["no_log"], True) + + def test_the_pnpm_config_is_written_without_a_mode_and_through_a_symlink(self) -> None: + [write] = [task["ansible.builtin.copy"] for name, task in self.tasks() if name == "pnpm_settings.yml" and "ansible.builtin.copy" in task] + self.assertNotIn("mode", write) + self.assertIs(write["follow"], True) + + def test_the_pnpm_config_paths_are_the_ones_pnpm_documents(self) -> None: + self.assertEqual(VARS["github_runner_host_tools_pnpm_config_files"], {"Darwin": "Library/Preferences/pnpm/config.yaml", "Linux": ".config/pnpm/config.yaml"}) + + def test_the_package_install_settings_and_the_shell_environment_write_nothing_by_default(self) -> None: + for name in ("npm_settings", "npm_min_release_age_exclude", "pnpm_settings", "pnpm_minimum_release_age_exclude", "shell_env"): + with self.subTest(name=name): + self.assertEqual(DEFAULTS[f"github_runner_host_tools_{name}"], {} if name.endswith(("_settings", "_env")) else []) + def test_the_mise_configuration_goes_to_conf_d_and_never_to_config_toml(self) -> None: paths = [str(module.get("dest") or module.get("path")) for _, task in self.tasks() for key, module in task.items() if key.startswith("ansible.builtin.") and isinstance(module, dict) and (module.get("dest") or module.get("path"))] mise_paths = [path for path in paths if "/.config/mise" in path] @@ -683,5 +723,328 @@ def test_a_mise_configuration_the_filter_cannot_render_fails_the_run(self) -> No self.assertFalse(self.fleet_file().exists()) + # Package-install security settings and the shell environment. The tests turn Homebrew and mise off, so they need none of the stand-ins and a host with no tool installed is enough. + + def run_settings(self, *extra: str, **variables: Any) -> subprocess.CompletedProcess[str]: + return self.run_role(*extra, github_runner_host_tools_homebrew=False, github_runner_host_tools_mise=False, **variables) + + def npmrc(self) -> Path: + return self.home / ".npmrc" + + def pnpm_config(self, system: str = "Darwin") -> Path: + return self.home / PNPM_CONFIG[system] + + def mode(self, path: Path) -> int: + return stat.S_IMODE(path.stat().st_mode) + + def test_no_package_install_variable_set_leaves_every_file_alone_and_unread(self) -> None: + self.npmrc().write_text(NPMRC_USER) + self.npmrc().chmod(0o640) + self.pnpm_config().parent.mkdir(parents=True) + self.pnpm_config().write_text(PNPM_USER) + before = (self.npmrc().stat().st_mtime_ns, self.pnpm_config().stat().st_mtime_ns) + result = self.run_settings() + self.ok(result) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + self.assertEqual(self.npmrc().read_text(), NPMRC_USER) + self.assertEqual(self.mode(self.npmrc()), 0o640) + self.assertEqual(self.pnpm_config().read_text(), PNPM_USER) + self.assertEqual((self.npmrc().stat().st_mtime_ns, self.pnpm_config().stat().st_mtime_ns), before) + self.assertNotIn(NPMRC_TOKEN, self.squashed(result)) + + def test_no_package_install_variable_set_creates_no_file_on_a_clean_home(self) -> None: + self.ok(self.run_settings()) + self.assertEqual(list(self.home.iterdir()), []) + + def test_npm_settings_go_into_one_block_after_the_users_own_lines_and_a_second_run_changes_nothing(self) -> None: + self.npmrc().write_text(NPMRC_USER) + variables = {"github_runner_host_tools_npm_settings": NPM_SETTINGS, "github_runner_host_tools_npm_min_release_age_exclude": ["example-a", "@example/*"]} + result = self.run_settings(**variables) + self.ok(result) + self.assertEqual(self.npmrc().read_text(), NPMRC_USER + NPM_BLOCK) + self.assertNotIn(NPMRC_TOKEN, self.squashed(result)) + result = self.run_settings(**variables) + self.ok(result) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + self.assertEqual(self.npmrc().read_text(), NPMRC_USER + NPM_BLOCK) + + def test_a_new_npmrc_is_private_and_an_existing_ones_mode_is_kept(self) -> None: + self.ok(self.run_settings(github_runner_host_tools_npm_settings=NPM_SETTINGS)) + self.assertEqual(self.mode(self.npmrc()), 0o600) + self.assertEqual(self.npmrc().read_text(), "# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools npm\nignore-scripts=true\nmin-release-age=7\n# END ANSIBLE MANAGED BLOCK github_runner_host_tools npm\n") + for mode in (0o644, 0o640, 0o400): + with self.subTest(mode=oct(mode)): + self.npmrc().chmod(0o600) + self.npmrc().write_text(NPMRC_USER) + self.npmrc().chmod(mode) + self.ok(self.run_settings(github_runner_host_tools_npm_settings=NPM_SETTINGS)) + self.assertEqual(self.mode(self.npmrc()), mode) + self.assertEqual(self.npmrc().read_text(), NPMRC_USER + NPM_BLOCK_SETTINGS) + + def test_an_existing_empty_npmrc_keeps_its_mode_too(self) -> None: + # An empty file is the case where copy sees the content as unchanged and goes on to look at attributes, which is why the role never asks it to set a mode on an existing file. + self.npmrc().write_text("") + self.npmrc().chmod(0o644) + self.ok(self.run_settings(github_runner_host_tools_npm_settings=NPM_SETTINGS)) + self.assertEqual(self.mode(self.npmrc()), 0o644) + self.assertEqual(self.npmrc().read_text(), NPM_BLOCK_SETTINGS) + + def test_dropping_an_entry_removes_its_line_and_dropping_all_removes_the_block(self) -> None: + self.npmrc().write_text(NPMRC_USER) + self.ok(self.run_settings(github_runner_host_tools_npm_settings=NPM_SETTINGS, github_runner_host_tools_npm_min_release_age_exclude=["example-a", "example-b"])) + self.assertIn("min-release-age-exclude[]=example-b\n", self.npmrc().read_text()) + self.ok(self.run_settings(github_runner_host_tools_npm_settings={"min-release-age": 7}, github_runner_host_tools_npm_min_release_age_exclude=["example-a"])) + self.assertEqual(self.npmrc().read_text(), NPMRC_USER + "# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools npm\nmin-release-age=7\nmin-release-age-exclude[]=example-a\n# END ANSIBLE MANAGED BLOCK github_runner_host_tools npm\n") + result = self.run_settings() + self.ok(result) + self.assertEqual(self.npmrc().read_text(), NPMRC_USER) + self.assertEqual(self.recap(result)["changed"], 1, result.stdout) + result = self.run_settings() + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_a_changed_value_is_rewritten_in_the_block(self) -> None: + self.ok(self.run_settings(github_runner_host_tools_npm_settings={"min-release-age": 3})) + self.ok(self.run_settings(github_runner_host_tools_npm_settings={"min-release-age": 7})) + self.assertEqual(self.npmrc().read_text(), NPM_MARKS[0] + "min-release-age=7\n" + NPM_MARKS[1]) + + def test_a_key_the_user_sets_outside_the_block_is_kept_and_reported_and_the_role_does_not_write_it(self) -> None: + self.npmrc().write_text(NPMRC_USER + "min-release-age=30\n# ignore-scripts=false\n") + variables = {"github_runner_host_tools_npm_settings": NPM_SETTINGS} + result = self.run_settings(**variables) + self.ok(result) + self.assertEqual(self.npmrc().read_text(), NPMRC_USER + "min-release-age=30\n# ignore-scripts=false\n# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools npm\nignore-scripts=true\n# END ANSIBLE MANAGED BLOCK github_runner_host_tools npm\n") + self.assertIn("already sets min-release-age outside the block", self.squashed(result)) + self.assertNotIn(NPMRC_TOKEN, self.squashed(result)) + result = self.run_settings(**variables) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_a_key_the_user_adds_later_takes_it_out_of_the_block(self) -> None: + self.ok(self.run_settings(github_runner_host_tools_npm_settings=NPM_SETTINGS)) + self.npmrc().write_text("min-release-age=30\n" + self.npmrc().read_text()) + self.ok(self.run_settings(github_runner_host_tools_npm_settings=NPM_SETTINGS)) + self.assertEqual(self.npmrc().read_text().count("min-release-age"), 1) + self.assertEqual(self.npmrc().read_text().splitlines()[0], "min-release-age=30") + + def test_every_setting_the_user_already_has_leaves_no_block_and_no_change(self) -> None: + self.npmrc().write_text("min-release-age=30\nignore-scripts=false\n") + result = self.run_settings(github_runner_host_tools_npm_settings=NPM_SETTINGS) + self.ok(result) + self.assertEqual(self.npmrc().read_text(), "min-release-age=30\nignore-scripts=false\n") + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_an_exclusion_the_user_lists_already_is_not_added_again(self) -> None: + self.npmrc().write_text("min-release-age-exclude[]=example-a\n") + self.ok(self.run_settings(github_runner_host_tools_npm_min_release_age_exclude=["example-a", "example-b"])) + self.assertEqual(self.npmrc().read_text(), "min-release-age-exclude[]=example-a\n# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools npm\nmin-release-age-exclude[]=example-b\n# END ANSIBLE MANAGED BLOCK github_runner_host_tools npm\n") + + def test_a_file_with_no_final_newline_keeps_its_last_line(self) -> None: + self.npmrc().write_text("registry=https://registry.example.test/") + self.ok(self.run_settings(github_runner_host_tools_npm_settings={"fund": False})) + self.assertEqual(self.npmrc().read_text().splitlines()[0], "registry=https://registry.example.test/") + self.assertEqual(self.npmrc().read_text().count("fund=false"), 1) + + def test_a_symlinked_npmrc_stays_a_link_and_its_target_is_edited(self) -> None: + target = self.work / "dotfiles-npmrc" + target.write_text(NPMRC_USER) + target.chmod(0o640) + self.npmrc().symlink_to(target) + self.ok(self.run_settings(github_runner_host_tools_npm_settings=NPM_SETTINGS)) + self.assertTrue(self.npmrc().is_symlink()) + self.assertEqual(target.read_text(), NPMRC_USER + NPM_BLOCK_SETTINGS) + self.assertEqual(self.mode(target), 0o640) + + def test_check_mode_reports_the_npm_block_and_writes_nothing(self) -> None: + result = self.run_settings("--check", github_runner_host_tools_npm_settings=NPM_SETTINGS) + self.ok(result) + self.assertFalse(self.npmrc().exists()) + self.assertGreaterEqual(self.recap(result)["changed"], 1, result.stdout) + self.npmrc().write_text(NPMRC_USER) + result = self.run_settings("--check", github_runner_host_tools_npm_settings=NPM_SETTINGS) + self.ok(result) + self.assertEqual(self.npmrc().read_text(), NPMRC_USER) + self.assertGreaterEqual(self.recap(result)["changed"], 1, result.stdout) + + def test_a_value_npm_would_misread_is_refused_and_leaves_no_file(self) -> None: + result = self.run_settings(github_runner_host_tools_npm_settings={"fund": "a;b"}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("would not read it back", self.squashed(result)) + self.assertFalse(self.npmrc().exists()) + + def test_a_value_npm_would_misread_is_refused_and_the_existing_file_is_unchanged(self) -> None: + self.npmrc().write_text(NPMRC_USER) + result = self.run_settings(github_runner_host_tools_npm_settings={"fund": "a;b"}) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.npmrc().read_text(), NPMRC_USER) + self.assertNotIn(NPMRC_TOKEN, self.squashed(result)) + + def test_a_host_the_role_does_not_support_gets_no_package_install_settings(self) -> None: + result = self.run_settings(system="FreeBSD", os_family="FreeBSD", github_runner_host_tools_npm_settings=NPM_SETTINGS, github_runner_host_tools_pnpm_settings={"saveExact": True}, github_runner_host_tools_shell_env={"A": "b"}) + self.ok(result) + self.assertEqual(list(self.home.iterdir()), []) + + def test_pnpm_settings_make_a_new_config_file_at_the_macos_path(self) -> None: + self.ok(self.run_settings(github_runner_host_tools_pnpm_settings=PNPM_SETTINGS, github_runner_host_tools_pnpm_minimum_release_age_exclude=["example-a", "@example/*"])) + self.assertEqual(self.pnpm_config().read_text(), "minimumReleaseAge: 10080\nsaveExact: true\nminimumReleaseAgeExclude:\n - example-a\n - '@example/*'\n") + self.assertEqual(sorted(path.name for path in self.home.iterdir()), ["Library"]) + + def test_pnpm_settings_make_a_new_config_file_at_the_linux_path(self) -> None: + self.ok(self.run_settings(system="Linux", os_family="Debian", machine="x86_64", github_runner_host_tools_pnpm_settings=PNPM_SETTINGS)) + self.assertEqual(self.pnpm_config("Linux").read_text(), "minimumReleaseAge: 10080\nsaveExact: true\n") + self.assertEqual(sorted(path.name for path in self.home.iterdir()), [".config"]) + + def test_pnpm_settings_are_merged_keeping_the_other_keys_and_comments_and_the_mode_and_a_second_run_changes_nothing(self) -> None: + self.pnpm_config().parent.mkdir(parents=True) + self.pnpm_config().write_text(PNPM_USER) + self.pnpm_config().chmod(0o640) + variables = {"github_runner_host_tools_pnpm_settings": PNPM_SETTINGS, "github_runner_host_tools_pnpm_minimum_release_age_exclude": ["example-a"]} + self.ok(self.run_settings(**variables)) + self.assertEqual(self.pnpm_config().read_text(), PNPM_MERGED) + self.assertEqual(self.mode(self.pnpm_config()), 0o640) + result = self.run_settings(**variables) + self.ok(result) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + self.assertEqual(self.pnpm_config().read_text(), PNPM_MERGED) + + def test_a_pnpm_config_that_holds_the_values_already_is_not_rewritten(self) -> None: + self.pnpm_config().parent.mkdir(parents=True) + self.pnpm_config().write_text("# mine\nminimumReleaseAge: 10080\nsaveExact: yes\n") + before = self.pnpm_config().stat().st_mtime_ns + result = self.run_settings(github_runner_host_tools_pnpm_settings={"minimumReleaseAge": 10080}) + self.ok(result) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + self.assertEqual(self.pnpm_config().stat().st_mtime_ns, before) + + def test_a_symlinked_pnpm_config_stays_a_link_and_its_target_is_edited(self) -> None: + target = self.work / "dotfiles-pnpm.yaml" + target.write_text(PNPM_USER) + self.pnpm_config().parent.mkdir(parents=True) + self.pnpm_config().symlink_to(target) + self.ok(self.run_settings(github_runner_host_tools_pnpm_settings=PNPM_SETTINGS)) + self.assertTrue(self.pnpm_config().is_symlink()) + self.assertIn("minimumReleaseAge: 10080\n", target.read_text()) + self.assertIn("# Mine.\n", target.read_text()) + + def test_the_pnpm_exclusion_list_given_twice_is_refused_before_the_file_is_changed(self) -> None: + result = self.run_settings(github_runner_host_tools_pnpm_settings={"minimumReleaseAgeExclude": ["a"]}, github_runner_host_tools_pnpm_minimum_release_age_exclude=["b"]) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Use one of them", self.squashed(result)) + self.assertEqual(list(self.home.iterdir()), []) + + def test_a_pnpm_config_that_is_not_yaml_is_refused_and_left_as_it_is(self) -> None: + self.pnpm_config().parent.mkdir(parents=True) + self.pnpm_config().write_text("a: [1\n") + result = self.run_settings(github_runner_host_tools_pnpm_settings=PNPM_SETTINGS) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not valid YAML", self.squashed(result)) + self.assertEqual(self.pnpm_config().read_text(), "a: [1\n") + + def test_check_mode_reports_the_pnpm_settings_and_writes_nothing(self) -> None: + result = self.run_settings("--check", "--diff", github_runner_host_tools_pnpm_settings=PNPM_SETTINGS) + self.ok(result) + self.assertEqual(list(self.home.iterdir()), []) + self.assertIn("config.yaml would be written", self.squashed(result)) + self.assertGreaterEqual(self.recap(result)["changed"], 1, result.stdout) + self.pnpm_config().parent.mkdir(parents=True) + self.pnpm_config().write_text(PNPM_USER) + result = self.run_settings("--check", github_runner_host_tools_pnpm_settings=PNPM_SETTINGS) + self.ok(result) + self.assertEqual(self.pnpm_config().read_text(), PNPM_USER) + self.assertGreaterEqual(self.recap(result)["changed"], 1, result.stdout) + + def test_the_npm_and_pnpm_settings_do_not_need_homebrew(self) -> None: + result = self.run_settings(github_runner_host_tools_npm_settings=NPM_SETTINGS, github_runner_host_tools_pnpm_settings=PNPM_SETTINGS) + self.ok(result) + self.assertEqual(self.logged(), []) + self.assertTrue(self.npmrc().exists()) + self.assertTrue(self.pnpm_config().exists()) + + # Shell environment variables. + + def test_a_shell_variable_goes_into_zshenv_as_a_managed_block_and_a_second_run_changes_nothing(self) -> None: + variables = {"github_runner_host_tools_shell_env": {"UV_PYTHON_DOWNLOADS": "never"}} + self.ok(self.run_settings(**variables)) + self.assertEqual((self.home / ".zshenv").read_text(), ENV_BLOCK_UV) + result = self.run_settings(**variables) + self.ok(result) + self.assertEqual((self.home / ".zshenv").read_text(), ENV_BLOCK_UV) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + + def test_bash_gets_the_variable_in_bashrc(self) -> None: + self.ok(self.run_settings(system="Linux", os_family="Debian", machine="x86_64", github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"})) + self.assertEqual((self.home / ".bashrc").read_text(), ENV_BLOCK_UV) + self.assertFalse((self.home / ".zshenv").exists()) + + def test_a_value_with_quotes_and_dollar_signs_reaches_the_variable_exactly_in_the_shell_that_reads_the_file(self) -> None: + value = "it's \"$HOME\" `id` $(id) ${HOME} \\n; echo injected" + self.ok(self.run_settings(system="Linux", os_family="Debian", machine="x86_64", github_runner_host_tools_shell_env={"EXAMPLE_VALUE": value, "EXAMPLE_FLAG": True, "EXAMPLE_COUNT": 3})) + for shell in ("sh", "bash"): + with self.subTest(shell=shell): + result = subprocess.run([shell, "-c", '. "$1"\nprintf "%s|%s|%s" "$EXAMPLE_VALUE" "$EXAMPLE_FLAG" "$EXAMPLE_COUNT"', shell, str(self.home / ".bashrc")], capture_output=True, text=True, check=True, env={"PATH": "/usr/bin:/bin", "HOME": "/nonexistent-home"}) + self.assertEqual(result.stdout, f"{value}|true|3") + + def test_a_variable_a_startup_file_assigns_already_is_not_added_in_any_file_of_that_shell(self) -> None: + for name in (".zshenv", ".zshrc", ".zprofile"): + with self.subTest(file=name): + (self.home / ".zshrc").unlink(missing_ok=True) + (self.home / ".zshenv").unlink(missing_ok=True) + (self.home / ".zprofile").unlink(missing_ok=True) + (self.home / name).write_text("export UV_PYTHON_DOWNLOADS=automatic\n") + self.ok(self.run_settings(github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never", "EXAMPLE_OTHER": "x"})) + self.assertEqual((self.home / name).read_text().count("UV_PYTHON_DOWNLOADS"), 1) + self.assertIn("automatic", (self.home / name).read_text()) + self.assertEqual("".join(path.read_text() for path in self.home.iterdir()).count("UV_PYTHON_DOWNLOADS"), 1) + self.assertIn("EXAMPLE_OTHER", "".join(path.read_text() for path in self.home.iterdir())) + + def test_a_commented_out_assignment_does_not_count_as_set(self) -> None: + (self.home / ".zshrc").write_text("# export UV_PYTHON_DOWNLOADS=automatic\n") + self.ok(self.run_settings(github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"})) + self.assertEqual((self.home / ".zshenv").read_text(), ENV_BLOCK_UV) + + def test_a_changed_value_updates_the_roles_own_block_in_place(self) -> None: + self.ok(self.run_settings(github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "automatic"})) + self.ok(self.run_settings(github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"})) + self.assertEqual((self.home / ".zshenv").read_text(), ENV_BLOCK_UV) + + def test_the_variables_are_written_whether_or_not_the_shell_integration_is_on(self) -> None: + for integration in (True, False): + with self.subTest(integration=integration): + (self.home / ".zshenv").unlink(missing_ok=True) + self.ok(self.run_settings(github_runner_host_tools_shell_integration=integration, github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"})) + self.assertEqual((self.home / ".zshenv").read_text(), ENV_BLOCK_UV) + + def test_the_variables_come_with_homebrew_and_mise_lines_in_the_same_file_when_both_are_wanted(self) -> None: + self.have_homebrew() + (self.stub / "mise.state").write_text("current") + self.ok(self.run_role(github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"})) + self.assertEqual((self.home / ".zshenv").read_text(), homebrew_block(self.prefix) + mise_block(self.prefix, "zsh") + ENV_BLOCK_UV) + + def test_a_login_shell_the_role_does_not_set_up_gets_no_variables(self) -> None: + (self.stub / "shell").write_text("/usr/bin/fish") + result = self.run_settings(github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"}) + self.ok(result) + self.assertEqual(list(self.home.iterdir()), []) + self.assertIn("so no startup file was changed", self.squashed(result)) + + def test_an_existing_startup_file_keeps_its_mode_and_its_other_lines(self) -> None: + startup = self.home / ".zshenv" + startup.write_text("export EXAMPLE=1\n") + startup.chmod(0o600) + self.ok(self.run_settings(github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"})) + self.assertEqual(self.mode(startup), 0o600) + self.assertEqual(startup.read_text(), "export EXAMPLE=1\n" + ENV_BLOCK_UV) + + def test_a_name_a_shell_would_not_accept_is_refused_before_any_file_is_changed(self) -> None: + result = self.run_settings(github_runner_host_tools_shell_env={"NOT-A-NAME": "x"}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not a name a shell accepts", self.squashed(result)) + self.assertEqual(list(self.home.iterdir()), []) + + def test_check_mode_reports_a_variable_and_writes_nothing(self) -> None: + result = self.run_settings("--check", github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"}) + self.ok(result) + self.assertEqual(list(self.home.iterdir()), []) + self.assertGreaterEqual(self.recap(result)["changed"], 1, result.stdout) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_host_tools_filters.py b/tests/unit/test_host_tools_filters.py index e731637e..2f58de7f 100644 --- a/tests/unit/test_host_tools_filters.py +++ b/tests/unit/test_host_tools_filters.py @@ -1,12 +1,15 @@ -"""Tests the filters of the github_runner_host_tools role in plugins/filter/host_tools.py: the Homebrew prefix a host has, the shell startup lines that bring Homebrew and mise into a shell, and mise's configuration rendered to TOML against golden strings.""" +"""Tests the filters of the github_runner_host_tools role in plugins/filter/host_tools.py: the Homebrew prefix a host has, the shell startup lines that bring Homebrew and mise into a shell or set environment variables in it, mise's configuration rendered to TOML, the lines of the managed block of an npmrc file and a YAML file merged with settings, against golden strings.""" from __future__ import annotations import importlib.util import re +import subprocess import unittest from pathlib import Path +import yaml + ROOT = Path(__file__).resolve().parents[2] PLUGIN = ROOT / "plugins" / "filter" / "host_tools.py" @@ -114,5 +117,247 @@ def test_what_it_cannot_render_is_refused(self) -> None: host_tools.host_tools_mise_toml(config) +def grep_finds(pattern: str, text: str) -> bool: + """Whether grep -E, which the role runs the pattern with, finds the pattern in a line.""" + return subprocess.run(["grep", "-E", "-q", "-e", pattern], input=text + "\n", capture_output=True, text=True, check=False).returncode == 0 + + +class ShellEnvFilterTest(unittest.TestCase): + def test_a_variable_becomes_a_quoted_export_line_and_a_pattern_that_finds_an_assignment_of_it(self) -> None: + [item] = host_tools.host_tools_shell_env({"UV_PYTHON_DOWNLOADS": "never"}) + self.assertEqual(item, {"name": "env-UV_PYTHON_DOWNLOADS", "pattern": "^[[:space:]]*(export[[:space:]]+)?UV_PYTHON_DOWNLOADS=", "line": "export UV_PYTHON_DOWNLOADS=never"}) + + def test_the_variables_come_out_in_the_order_of_the_mapping(self) -> None: + self.assertEqual([item["name"] for item in host_tools.host_tools_shell_env({"B": "1", "A": "2"})], ["env-B", "env-A"]) + + def test_an_empty_mapping_has_no_lines(self) -> None: + self.assertEqual(host_tools.host_tools_shell_env({}), []) + + def test_a_value_reaches_the_variable_exactly_as_given_whatever_it_holds(self) -> None: + values = ["it's", 'say "hi"', "$HOME", "${HOME}", "`id`", "$(id)", "a b c", "back\\slash", "tab\there", "*", "; echo injected", "", "-n", "'", "''", "\"'\"", "é"] + for value in values: + with self.subTest(value=value): + [item] = host_tools.host_tools_shell_env({"EXAMPLE": value}) + for shell in ("sh", "bash"): + result = subprocess.run([shell, "-c", item["line"] + '\nprintf %s "$EXAMPLE"'], capture_output=True, text=True, check=True, env={"PATH": "/usr/bin:/bin", "HOME": "/nonexistent-home"}) + self.assertEqual(result.stdout, value) + self.assertEqual(result.stderr, "") + + def test_a_dollar_sign_and_a_single_quote_are_quoted_and_not_expanded(self) -> None: + self.assertEqual(host_tools.host_tools_shell_env({"A": "$HOME"})[0]["line"], "export A='$HOME'") + self.assertEqual(host_tools.host_tools_shell_env({"A": "it's"})[0]["line"], "export A='it'\"'\"'s'") + + def test_a_boolean_is_true_or_false_and_a_number_is_its_digits(self) -> None: + lines = {item["name"]: item["line"] for item in host_tools.host_tools_shell_env({"A": True, "B": False, "C": 3, "D": 0.5})} + self.assertEqual(lines, {"env-A": "export A=true", "env-B": "export B=false", "env-C": "export C=3", "env-D": "export D=0.5"}) + + def test_the_pattern_finds_an_assignment_with_or_without_export_and_not_a_comment_or_another_variable(self) -> None: + [item] = host_tools.host_tools_shell_env({"UV_PYTHON_DOWNLOADS": "never"}) + for text in ("UV_PYTHON_DOWNLOADS=automatic", "export UV_PYTHON_DOWNLOADS=automatic", " export UV_PYTHON_DOWNLOADS='x'", "\texport UV_PYTHON_DOWNLOADS="): + with self.subTest(text=text): + self.assertTrue(grep_finds(item["pattern"], text)) + for text in ("# export UV_PYTHON_DOWNLOADS=never", "export OTHER_UV_PYTHON_DOWNLOADS=never", "echo UV_PYTHON_DOWNLOADS=never", "UV_PYTHON_DOWNLOADS_MORE=never"): + with self.subTest(text=text): + self.assertFalse(grep_finds(item["pattern"], text)) + + def test_what_a_shell_would_not_take_is_refused(self) -> None: + cases = { + "not a mapping": (["A"], "must be a mapping"), + "name with a hyphen": ({"A-B": "1"}, "not a name a shell accepts"), + "name starting with a digit": ({"1A": "1"}, "not a name a shell accepts"), + "empty name": ({"": "1"}, "not a name a shell accepts"), + "name with a space": ({"A B": "1"}, "not a name a shell accepts"), + "name that is not a string": ({1: "1"}, "not a name a shell accepts"), + "value with a newline": ({"A": "a\nb"}, "one line"), + "value with a NUL": ({"A": "a\x00b"}, "one line"), + "list value": ({"A": ["a"]}, "must be a string, a number or a boolean"), + "null value": ({"A": None}, "must be a string, a number or a boolean"), + } + for label, (env, message) in cases.items(): + with self.subTest(label), self.assertRaisesRegex(Exception, message): + host_tools.host_tools_shell_env(env) + + +MARKER = "# {mark} ANSIBLE MANAGED BLOCK example" + + +class NpmrcFilterTest(unittest.TestCase): + def test_settings_then_exclusions_come_out_as_lines_in_the_order_given(self) -> None: + result = host_tools.host_tools_npmrc({"min-release-age": 7, "ignore-scripts": True, "fund": False}, ["example-a", "@example/*"], "", MARKER) + self.assertEqual(result, {"lines": ["min-release-age=7", "ignore-scripts=true", "fund=false", "min-release-age-exclude[]=example-a", "min-release-age-exclude[]=@example/*"], "shadowed": []}) + + def test_a_string_a_float_and_a_value_with_a_variable_reference_are_written_as_given(self) -> None: + result = host_tools.host_tools_npmrc({"registry": "https://registry.example.test/", "ratio": 0.5, "cache": "${HOME}/.npm-cache"}, [], "", MARKER) + self.assertEqual(result["lines"], ["registry=https://registry.example.test/", "ratio=0.5", "cache=${HOME}/.npm-cache"]) + + def test_nothing_set_has_no_lines(self) -> None: + self.assertEqual(host_tools.host_tools_npmrc({}, [], "registry=x\n", MARKER), {"lines": [], "shadowed": []}) + + def test_a_key_the_file_sets_outside_the_block_is_left_out_and_named(self) -> None: + existing = "registry=https://registry.example.test/\nmin-release-age=30\n" + result = host_tools.host_tools_npmrc({"min-release-age": 7, "ignore-scripts": True}, [], existing, MARKER) + self.assertEqual(result, {"lines": ["ignore-scripts=true"], "shadowed": ["min-release-age"]}) + + def test_the_users_key_is_found_with_spaces_around_the_equals_sign_indented_and_with_no_value(self) -> None: + for line in ("min-release-age = 30", " min-release-age=30", "min-release-age", "min-release-age ="): + with self.subTest(line=line): + self.assertEqual(host_tools.host_tools_npmrc({"min-release-age": 7}, [], line + "\n", MARKER)["shadowed"], ["min-release-age"]) + + def test_a_comment_or_another_key_with_the_same_beginning_is_not_the_users_key(self) -> None: + existing = "# min-release-age=30\n; min-release-age=30\nmin-release-age-exclude[]=a\nmin-release-agent=1\nfoo=min-release-age=3\n" + result = host_tools.host_tools_npmrc({"min-release-age": 7}, [], existing, MARKER) + self.assertEqual(result, {"lines": ["min-release-age=7"], "shadowed": []}) + + def test_the_roles_own_block_is_not_the_users_key_so_a_changed_value_is_rewritten(self) -> None: + existing = "registry=x\n# BEGIN ANSIBLE MANAGED BLOCK example\nmin-release-age=3\nmin-release-age-exclude[]=a\n# END ANSIBLE MANAGED BLOCK example\n" + result = host_tools.host_tools_npmrc({"min-release-age": 7}, ["a"], existing, MARKER) + self.assertEqual(result, {"lines": ["min-release-age=7", "min-release-age-exclude[]=a"], "shadowed": []}) + + def test_a_key_outside_the_block_is_found_after_it_as_well(self) -> None: + existing = "# BEGIN ANSIBLE MANAGED BLOCK example\nfund=false\n# END ANSIBLE MANAGED BLOCK example\nfund=true\n" + self.assertEqual(host_tools.host_tools_npmrc({"fund": False}, [], existing, MARKER)["shadowed"], ["fund"]) + + def test_a_begin_marker_with_no_end_is_not_a_block(self) -> None: + existing = "# BEGIN ANSIBLE MANAGED BLOCK example\nfund=false\n" + self.assertEqual(host_tools.host_tools_npmrc({"fund": False}, [], existing, MARKER)["shadowed"], ["fund"]) + + def test_an_exclusion_the_file_lists_outside_the_block_is_not_written_again(self) -> None: + existing = "min-release-age-exclude[]=example-a\nmin-release-age-exclude[] = \"@example/b\"\n" + result = host_tools.host_tools_npmrc({}, ["example-a", "@example/b", "example-c"], existing, MARKER) + self.assertEqual(result, {"lines": ["min-release-age-exclude[]=example-c"], "shadowed": []}) + + def test_an_exclusion_given_twice_is_written_once(self) -> None: + self.assertEqual(host_tools.host_tools_npmrc({}, ["a", "a"], "", MARKER)["lines"], ["min-release-age-exclude[]=a"]) + + def test_other_lines_of_the_file_never_reach_the_result_or_an_error(self) -> None: + secret = "//registry.example.test/:_authToken=made-up-token-0000" + result = host_tools.host_tools_npmrc({"fund": False}, ["a"], secret + "\n", MARKER) + self.assertNotIn("made-up-token-0000", repr(result)) + with self.assertRaises(Exception) as caught: + host_tools.host_tools_npmrc({"fund": "a;b"}, [], secret + "\n", MARKER) + self.assertNotIn("made-up-token-0000", str(caught.exception)) + + def test_what_an_npmrc_would_not_read_back_as_given_is_refused(self) -> None: + cases = { + "settings not a mapping": ([], [], "must be a mapping"), + "exclusions not a list": ({}, {"a": 1}, "must be a list"), + "key with a space": ({"a b": 1}, [], "not a key"), + "empty key": ({"": 1}, [], "not a key"), + "key that is a list": ({"min-release-age-exclude": "a"}, [], "variable of its own"), + "key with brackets": ({"a[]": 1}, [], "not a key"), + "value with a comment character": ({"a": "x # y"}, [], "would not read it back"), + "value with a semicolon": ({"a": "x;y"}, [], "would not read it back"), + "value with a quote": ({"a": "'x'"}, [], "would not read it back"), + "value with a backslash": ({"a": "x\\y"}, [], "would not read it back"), + "value with a newline": ({"a": "x\ny"}, [], "would not read it back"), + "value with surrounding space": ({"a": " x"}, [], "would not read it back"), + "list value": ({"a": ["x"]}, [], "must be a string, a number or a boolean"), + "null value": ({"a": None}, [], "must be a string, a number or a boolean"), + "infinite value": ({"a": float("inf")}, [], "finite"), + "exclusion with a space": ({}, ["a b"], "not a package name"), + "exclusion that is not a string": ({}, [1], "not a package name"), + "exclusion that would end the line early": ({}, ["a\nb"], "not a package name"), + } + for label, (settings, excludes, message) in cases.items(): + with self.subTest(label), self.assertRaisesRegex(Exception, message): + host_tools.host_tools_npmrc(settings, excludes, "", MARKER) + + +class YamlMergeFilterTest(unittest.TestCase): + def merge(self, existing: str, settings: dict) -> str: + return host_tools.host_tools_yaml_merge(existing, settings) + + def test_a_new_file_is_the_settings_alone_with_lists_indented(self) -> None: + self.assertEqual(self.merge("", {"minimumReleaseAge": 10080, "saveExact": True, "minimumReleaseAgeExclude": ["a", "@b/c"]}), "minimumReleaseAge: 10080\nsaveExact: true\nminimumReleaseAgeExclude:\n - a\n - '@b/c'\n") + + def test_a_nested_mapping_is_rendered_and_read_back(self) -> None: + result = self.merge("", {"catalog": {"react": "^19.0.0"}}) + self.assertEqual(result, "catalog:\n react: ^19.0.0\n") + self.assertEqual(yaml.safe_load(result), {"catalog": {"react": "^19.0.0"}}) + + def test_a_string_that_looks_like_another_type_stays_a_string(self) -> None: + result = self.merge("", {"a": "7", "b": "true", "c": "null"}) + self.assertEqual(yaml.safe_load(result), {"a": "7", "b": "true", "c": "null"}) + + def test_unrelated_keys_comments_and_blank_lines_are_kept_byte_for_byte(self) -> None: + existing = "# pnpm settings\nstoreDir: /somewhere/store # the store\n\n# the age\nminimumReleaseAge: 1440\n\n# registries\nregistry: https://registry.example.test/\n" + self.assertEqual(self.merge(existing, {"minimumReleaseAge": 10080}), existing.replace("minimumReleaseAge: 1440", "minimumReleaseAge: 10080")) + + def test_a_key_that_holds_the_value_is_not_touched_and_the_text_comes_back_unchanged(self) -> None: + existing = "minimumReleaseAge: 10080 # a week\nsaveExact: yes\nlist: [a, b]\n" + self.assertEqual(self.merge(existing, {"minimumReleaseAge": 10080}), existing) + self.assertEqual(self.merge(existing, {"list": ["a", "b"]}), existing) + + def test_a_value_of_another_type_counts_as_different(self) -> None: + self.assertEqual(self.merge("a: 1\n", {"a": True}), "a: true\n") + self.assertEqual(self.merge("a: 1\n", {"a": 1.0}), "a: 1.0\n") + self.assertEqual(self.merge("a: '1'\n", {"a": 1}), "a: 1\n") + + def test_a_list_is_replaced_with_its_items_whatever_layout_it_had(self) -> None: + for before in ("a:\n- x\n- y\n", "a:\n - x\n - y\n", "a: [x, y]\n", "a:\n - x\n # note\n - y\n"): + with self.subTest(before=before): + self.assertEqual(self.merge(before + "z: 1\n", {"a": ["p"]}), "a:\n - p\nz: 1\n") + + def test_comments_before_the_next_key_stay_with_it(self) -> None: + self.assertEqual(self.merge("a:\n- x\n\n# about b\nb: 1\n", {"a": ["p"]}), "a:\n - p\n\n# about b\nb: 1\n") + + def test_a_key_that_is_missing_is_appended_after_the_last_line_even_with_no_final_newline(self) -> None: + self.assertEqual(self.merge("a: 1", {"b": 2}), "a: 1\nb: 2\n") + self.assertEqual(self.merge("a: 1\n# end\n", {"b": 2}), "a: 1\n# end\nb: 2\n") + + def test_a_quoted_key_is_found(self) -> None: + self.assertEqual(self.merge('"a": 1\n\'b\': 2\n', {"a": 3, "b": 4}), "a: 3\nb: 4\n") + + def test_a_key_that_only_begins_like_another_is_not_it(self) -> None: + self.assertEqual(self.merge("ab: 1\nabc: 2\n", {"a": 3}), "ab: 1\nabc: 2\na: 3\n") + + def test_a_key_that_is_the_start_of_a_nested_one_is_not_a_top_level_key(self) -> None: + self.assertEqual(self.merge("outer:\n a: 1\n", {"a": 3}), "outer:\n a: 1\na: 3\n") + + def test_windows_line_endings_elsewhere_in_the_file_are_kept(self) -> None: + self.assertEqual(self.merge("a: 1\r\nb: 2\r\n", {"a": 3}), "a: 3\nb: 2\r\n") + + def test_a_document_start_marker_is_kept(self) -> None: + self.assertEqual(self.merge("---\na: 1\n", {"b": 2}), "---\na: 1\nb: 2\n") + + def test_an_empty_or_comment_only_file_takes_the_settings(self) -> None: + self.assertEqual(self.merge("# nothing yet\n", {"a": 1}), "# nothing yet\na: 1\n") + self.assertEqual(self.merge("{}\n", {}), "{}\n") + + def test_no_settings_return_the_text_unchanged(self) -> None: + self.assertEqual(self.merge("a: 1\n", {}), "a: 1\n") + + def test_a_merge_twice_is_the_merge_once(self) -> None: + once = self.merge("# keep\nb: 1\n", {"a": ["x", "y"], "c": {"d": 1}}) + self.assertEqual(self.merge(once, {"a": ["x", "y"], "c": {"d": 1}}), once) + + def test_ansible_style_subclasses_of_the_plain_types_are_accepted(self) -> None: + class Text(str): + pass + + class Mapping(dict): # type: ignore[type-arg] + pass + + self.assertEqual(self.merge("", Mapping({Text("a"): [Text("x")], "b": Mapping({"c": Text("d")})})), "a:\n - x\nb:\n c: d\n") + + def test_what_cannot_be_merged_is_refused_and_nothing_is_returned(self) -> None: + cases = { + "existing not text": (None, {"a": 1}, "must be text"), + "settings not a mapping": ("", [1], "must be a mapping"), + "not YAML": ("a: [1\n", {"b": 1}, "not valid YAML"), + "a list at the top": ("- a\n- b\n", {"b": 1}, "not a mapping at the top"), + "a scalar at the top": ("just text\n", {"b": 1}, "not a mapping at the top"), + "a flow mapping at the top": ("{a: 1}\n", {"b": 2}, "cannot edit safely"), + "several documents": ("a: 1\n---\nb: 2\n", {"c": 1}, "not valid YAML"), + "a null value": ("", {"a": None}, "must be a string, a number"), + "a non-string key": ("", {"a": {1: 2}}, "string keys"), + "an infinite value": ("", {"a": float("inf")}, "finite"), + "an object": ("", {"a": object()}, "must be a string, a number"), + } + for label, (existing, settings, message) in cases.items(): + with self.subTest(label), self.assertRaisesRegex(Exception, message): + host_tools.host_tools_yaml_merge(existing, settings) + + if __name__ == "__main__": unittest.main()