Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,12 @@ jobs:
shard: [0, 1, 2, 3]
steps:
- uses: actions/checkout@v7
- name: Install Ansible
- name: Install Ansible and zsh
# zsh is one of the two shells the host tools role writes startup lines for, and its tests read those lines back with a real zsh.
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq ansible pkg-config
sudo apt-get install -y -qq ansible pkg-config zsh
- name: Unit-test the collection
# Ubuntu's ansible package puts ansible-core on the system Python the filter plugins import from. The runner image can carry another Ansible earlier on PATH, so the tests run with /usr/bin first and ANSIBLE_PLAYBOOK pointing at Ubuntu's, which makes every playbook a test runs use the same ansible-core as the filters it imports; both versions are printed for the record. A module is a shard's when its position in the list of modules, largest first, leaves this shard's index modulo the number of shards.
env:
Expand Down
96 changes: 80 additions & 16 deletions plugins/filter/host_tools.py
Original file line number Diff line number Diff line change
@@ -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 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."""
"""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, whether a managed block leads its startup file, mise's configuration rendered to TOML, the managed lines of an npmrc file and a YAML file merged with settings."""

from __future__ import annotations

Expand Down Expand Up @@ -52,24 +52,24 @@ def host_tools_homebrew_prefix(system: Any, machine: Any) -> str:
raise AnsibleFilterError(f"Homebrew supports Darwin and Linux, not {system!r}.")


def host_tools_shell_integrations(prefix: Any, shell: Any, homebrew: Any, mise: Any) -> list[dict[str, str]]:
def host_tools_shell_integrations(prefix: Any, shell: Any, homebrew: Any, mise: Any) -> list[dict[str, Any]]:
"""Return the startup lines that bring Homebrew and mise into a shell.

Each line is guarded by a test that the binary is executable, so a startup file that outlives the tool does no harm. ``pattern`` is an extended regular expression that matches a line of a startup file that already does the same, outside a comment, and ``name`` tells the integrations apart.
Each line is guarded by a test that the binary is executable, so a startup file that outlives the tool does no harm. ``pattern`` is an extended regular expression that matches a line of a startup file that already does the same, outside a comment, ``name`` tells the integrations apart, and ``non_interactive`` is false because Homebrew and mise are wanted in an interactive shell only (the line goes where the file's own interactive setup is, never ahead of an early return for non-interactive shells).

:param prefix: the Homebrew prefix, whose ``bin`` holds ``brew`` and ``mise``. :param shell: the shell's name, ``zsh`` or ``bash``, which mise's activation is generated for. :param homebrew: whether Homebrew is wanted in the shell. :param mise: whether mise is wanted in the shell. :returns: ``[{"name", "pattern", "line"}, ...]``, Homebrew first, with a dictionary for each tool that is wanted. :raises AnsibleFilterError: the prefix is not an absolute path or the shell is not one the role supports.
:param prefix: the Homebrew prefix, whose ``bin`` holds ``brew`` and ``mise``. :param shell: the shell's name, ``zsh`` or ``bash``, which mise's activation is generated for. :param homebrew: whether Homebrew is wanted in the shell. :param mise: whether mise is wanted in the shell. :returns: ``[{"name", "pattern", "line", "non_interactive"}, ...]``, Homebrew first, with a dictionary for each tool that is wanted. :raises AnsibleFilterError: the prefix is not an absolute path or the shell is not one the role supports.
"""
if not isinstance(prefix, str) or not prefix.startswith("/"):
raise AnsibleFilterError(f"The Homebrew prefix must be an absolute path, not {prefix!r}.")
if shell not in ("zsh", "bash"):
raise AnsibleFilterError(f"The shell must be zsh or bash, not {shell!r}.")
brew = shlex.quote(f"{prefix}/bin/brew")
mise_binary = shlex.quote(f"{prefix}/bin/mise")
integrations: list[dict[str, str]] = []
integrations: list[dict[str, Any]] = []
if homebrew:
integrations.append({"name": "homebrew", "pattern": "^[^#]*brew shellenv", "line": f'[ -x {brew} ] && eval "$({brew} shellenv)"'})
integrations.append({"name": "homebrew", "pattern": "^[^#]*brew shellenv", "line": f'[ -x {brew} ] && eval "$({brew} shellenv)"', "non_interactive": False})
if mise:
integrations.append({"name": "mise", "pattern": "^[^#]*mise activate", "line": f'[ -x {mise_binary} ] && eval "$({mise_binary} activate {shell})"'})
integrations.append({"name": "mise", "pattern": "^[^#]*mise activate", "line": f'[ -x {mise_binary} ] && eval "$({mise_binary} activate {shell})"', "non_interactive": False})
return integrations


Expand All @@ -90,24 +90,87 @@ def _env_value(name: str, value: Any) -> str:
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.
def _env_name(name: Any) -> str:
"""Return the name of an environment variable.

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.
:raises AnsibleFilterError: the name is not one the shell accepts in an assignment.
"""
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.")
return name


def _export_line(name: str, word: str) -> dict[str, Any]:
"""Return the startup line that exports a variable set to a shell word, with the pattern that finds an assignment of it already in a startup file."""
return {"name": f"env-{name}", "pattern": f"^[[:space:]]*(export[[:space:]]+)?{name}=", "line": f"export {name}={word}", "non_interactive": True}


def _home_relative_value(name: str, value: Any) -> str:
"""Return the shell word that expands to the user's home directory followed by a relative path: ``"$HOME/<path>"`` with the path escaped for double quotes.

Inside double quotes the shell expands ``$`` and the backtick and gives the backslash and the double quote a meaning, and nothing else, in sh, bash and zsh alike, so a backslash before each of those four makes the path reach the variable exactly as given. (``!`` is special only to an interactive shell reading a typed line, never to one reading a startup file.)

:raises AnsibleFilterError: the value is not a string, is empty, is absolute, has a ``..`` component, or has a newline or a NUL, which a startup file line cannot hold.
"""
if not isinstance(value, str):
raise AnsibleFilterError(f"The home-relative path of {name} must be a string, not {type(value).__name__}.")
if any(char in value for char in "\n\r\x00"):
raise AnsibleFilterError(f"The home-relative path of {name} must be one line without a NUL.")
if not value:
raise AnsibleFilterError(f"The home-relative path of {name} must not be empty.")
if value.startswith("/"):
raise AnsibleFilterError(f"The path of {name} must be relative to the user's home directory, not absolute ({value!r}); a fixed absolute path belongs in github_runner_host_tools_shell_env.")
if ".." in value.split("/"):
raise AnsibleFilterError(f"The path of {name} must stay inside the user's home directory, so it must not have a '..' component ({value!r}).")
return '"$HOME/' + re.sub(r'([\\$`"])', r"\\\1", value) + '"'

: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.

def host_tools_shell_env(env: Any, home_relative: Any) -> list[dict[str, Any]]:
"""Return the startup lines that set environment variables in a shell, in the shape host_tools_shell_integrations returns, with ``non_interactive`` true: a variable is for every shell the user's commands run in, and a non-interactive bash returns from ``~/.bashrc`` at the file's interactive guard, so the caller writes these lines ahead of everything else in the file.

A value in ``env`` 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. A value in ``home_relative`` is a path below the user's home directory and is written as ``"$HOME/<path>"``, so the shell works out the home directory when the file is read, which the role cannot know when it runs without gathering facts. ``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. :param home_relative: a mapping of variable names to paths relative to the user's home directory. :returns: ``[{"name", "pattern", "line", "non_interactive"}, ...]``, the variables of ``env`` in its order and then those of ``home_relative`` in its. :raises AnsibleFilterError: an argument is not a mapping, a name is not one the shell accepts or is in both mappings, a value of ``env`` is not a single-line string, a number or a boolean, or a path of ``home_relative`` is not a non-empty relative path without a ``..`` component.
"""
if not isinstance(env, dict):
raise AnsibleFilterError(f"The shell environment must be a mapping, not {type(env).__name__}.")
lines: list[dict[str, str]] = []
if not isinstance(home_relative, dict):
raise AnsibleFilterError(f"The home-relative shell environment must be a mapping, not {type(home_relative).__name__}.")
both = [name for name in env if name in home_relative]
if both:
raise AnsibleFilterError(f"{', '.join(map(str, both))} is set in both the shell environment and the home-relative one; give each variable once.")
lines: list[dict[str, Any]] = []
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)}"})
lines.append(_export_line(name, shlex.quote(_env_value(_env_name(name), value))))
for name, value in home_relative.items():
lines.append(_export_line(name, _home_relative_value(_env_name(name), value)))
return lines


def host_tools_block_leads(existing: Any, marker: Any, stem: Any) -> bool:
"""Return whether a managed block comes before everything in a startup file that could return early.

The role writes the blocks a non-interactive shell must read at the top of the file, because a non-interactive bash reads ``~/.bashrc`` only as far as the interactive guard (``case $- in *i*) ;; *) return;; esac``). A block leads the file when every line above its BEGIN marker is blank, a comment, or inside a block of the role's own, which never returns. The test is the file's own order, not a guess at what the guard looks like, so a guard written any other way is handled alike: any code above the block counts as the guard.

:param existing: the text of the startup file. :param marker: the block's marker line with ``{mark}`` where BEGIN and END go. :param stem: the start of the marker line of every block the role writes in this file, with ``{mark}`` where BEGIN and END go (the marker without the block's name). :returns: whether nothing but blank lines, comments and the role's blocks come above the block. :raises AnsibleFilterError: an argument is not a string, or the file has no block with the marker.
"""
if not (isinstance(existing, str) and isinstance(marker, str) and isinstance(stem, str)):
raise AnsibleFilterError("The startup file's text, the block's marker and the marker stem must be strings.")
begin, own_begin, own_end = marker.replace("{mark}", "BEGIN"), stem.replace("{mark}", "BEGIN"), stem.replace("{mark}", "END")
inside = False
for line in existing.splitlines():
text = line.rstrip()
if text == begin:
return True
if inside:
inside = not text.startswith(own_end)
elif text.startswith(own_begin):
inside = True
elif text.strip() and not text.lstrip().startswith("#"):
return False
raise AnsibleFilterError(f"The startup file has no block that starts with {begin!r}.")


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:
Expand Down Expand Up @@ -356,6 +419,7 @@ def filters(self) -> dict[str, Any]:
"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_block_leads": host_tools_block_leads,
"host_tools_mise_toml": host_tools_mise_toml,
"host_tools_npmrc": host_tools_npmrc,
"host_tools_yaml_merge": host_tools_yaml_merge,
Expand Down
Loading
Loading