diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77ba04e..72ffa85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,19 @@ jobs: set -euo pipefail sudo apt-get update -qq sudo apt-get install -y -qq ansible pkg-config zsh + - name: Install mise + # The host tools tests read the mise configuration the role renders back with a real mise, to check that every value reaches the variable as given. A release more than a week old, pinned to its published checksum so the download is the file that was tested; the version is the one the tests were written against, raised by hand with its checksum from the release's SHASUMS256.txt. + env: + MISE_VERSION: v2026.10.20 + MISE_SHA256: 5a9057180c3a9a795076f84be4f5f372e6e37179e1aeb49732928d6b395ce829 + run: | + set -euo pipefail + directory="${RUNNER_TEMP}/mise" + mkdir -p "${directory}" + curl -fsSL -o "${directory}/mise.tar.gz" "https://github.com/jdx/mise/releases/download/${MISE_VERSION}/mise-${MISE_VERSION}-linux-x64.tar.gz" + echo "${MISE_SHA256} ${directory}/mise.tar.gz" | sha256sum --check --strict + tar -xzf "${directory}/mise.tar.gz" -C "${directory}" + echo "${directory}/mise/bin" >> "$GITHUB_PATH" - 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: diff --git a/plugins/filter/host_tools.py b/plugins/filter/host_tools.py index a3de4ec..227a4eb 100644 --- a/plugins/filter/host_tools.py +++ b/plugins/filter/host_tools.py @@ -15,7 +15,7 @@ LINUX_PREFIX = "/home/linuxbrew/.linuxbrew" # The tables of a mise configuration file the role renders, in the order they are written. -MISE_TABLES = ("tools", "settings") +MISE_TABLES = ("tools", "settings", "env") # A TOML bare key, which needs no quotes. BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$") @@ -105,12 +105,10 @@ def _export_line(name: str, word: str) -> dict[str, Any]: 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/"`` 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.) +def _home_relative_path(name: str, value: Any) -> str: + """Return a path that is relative to the user's home directory and stays inside it, as given. - :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. + :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 or a single-line value cannot hold. """ if not isinstance(value, str): raise AnsibleFilterError(f"The home-relative path of {name} must be a string, not {type(value).__name__}.") @@ -119,10 +117,30 @@ def _home_relative_value(name: str, value: Any) -> str: 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.") + raise AnsibleFilterError(f"The path of {name} must be relative to the user's home directory, not absolute ({value!r}); a fixed absolute path is given as a plain value instead.") 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) + '"' + return value + + +def _reject_names_in_both(env: dict[Any, Any], home_relative: dict[Any, Any]) -> None: + """Raise unless every variable is given once, as a plain value or as a path below the home directory. + + :raises AnsibleFilterError: a name is in both mappings. + """ + both = [name for name in env if name in home_relative] + if both: + raise AnsibleFilterError(f"{', '.join(map(str, both))} is set both as a plain value and as a home-relative path; give each variable once.") + + +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/"`` 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: as ``_home_relative_path``. + """ + return '"$HOME/' + re.sub(r'([\\$`"])', r"\\\1", _home_relative_path(name, value)) + '"' def host_tools_shell_env(env: Any, home_relative: Any) -> list[dict[str, Any]]: @@ -130,15 +148,13 @@ def host_tools_shell_env(env: Any, home_relative: Any) -> list[dict[str, Any]]: 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/"``, 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. + :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 name in both is refused by the same check as in ``host_tools_mise_toml``), 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__}.") 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.") + _reject_names_in_both(env, home_relative) lines: list[dict[str, Any]] = [] for name, value in env.items(): lines.append(_export_line(name, shlex.quote(_env_value(_env_name(name), value)))) @@ -224,17 +240,44 @@ def _table(name: str, entries: Any) -> list[str]: return [f"[{name}]", *lines] if lines else [] -def host_tools_mise_toml(config: Any) -> str: +def _mise_literal(text: str) -> str: + """Return the text of a mise ``[env]`` value that mise reads back as exactly ``text``. + + mise renders a value as a Tera template and then expands ``$VAR`` and ``${VAR}`` in the result, with ``$$`` as a literal dollar sign. So a ``$`` is doubled, and a brace that starts a Tera tag, expression or comment (``{{``, ``{%`` and ``{#``) is written as the Tera expression ``{{ '{' }}``, which renders as that brace. Nothing else in a value is special to either. + """ + return re.sub(r"\{(?=[{%#])", "{{ '{' }}", text.replace("$", "$$")) + + +def _env_table(env: Any, home_relative: Any) -> list[str]: + """Return the lines of mise's ``[env]`` table, empty when neither mapping has an entry. + + A plain value is read back as given. A home-relative path is written as ``{{ env.HOME }}/``, so mise works the home directory out when it reads the file, which the role cannot know when it runs without gathering facts. + + :raises AnsibleFilterError: a name is not one a shell accepts (a mise variable is exported to commands run by shells), is in both mappings, a plain value is not a single-line string, a number or a boolean, or a path is not a non-empty relative path without a ``..`` component. + """ + if not isinstance(env, dict): + raise AnsibleFilterError(f"env must be a mapping, not {type(env).__name__}.") + if not isinstance(home_relative, dict): + raise AnsibleFilterError(f"The home-relative env must be a mapping, not {type(home_relative).__name__}.") + _reject_names_in_both(env, home_relative) + lines = [f"{_env_name(name)} = {_string(_mise_literal(_env_value(name, value)))}" for name, value in env.items()] + lines += [f"{_env_name(name)} = {_string('{{ env.HOME }}/' + _mise_literal(_home_relative_path(name, value)))}" for name, value in home_relative.items()] + return ["[env]", *lines] if lines else [] + + +def host_tools_mise_toml(config: Any, env_home_relative: Any) -> str: """Return a mise configuration file for a mapping of its tables. - :param config: a mapping with the tables ``tools`` (a tool name to a version string or a list of them) and ``settings`` (a setting name to a string, number or boolean, or a list of those). A table that is empty is left out. :returns: the TOML text, each table after a blank line and the whole ending in a newline, in the order tools then settings whatever the order of the mapping. :raises AnsibleFilterError: ``config`` is not a mapping, has a table the role does not render, a value of a kind its table does not take, or renders to nothing. + :param config: a mapping with the tables ``tools`` (a tool name to a version string or a list of them), ``settings`` (a setting name to a string, number or boolean, or a list of those) and ``env`` (a variable name to a string, number or boolean, read back by mise as given). A table that is empty is left out. :param env_home_relative: a mapping of variable names to paths relative to the user's home directory, written into the ``env`` table as mise's ``{{ env.HOME }}`` template followed by the path. :returns: the TOML text, each table after a blank line and the whole ending in a newline, in the order tools, settings and env whatever the order of the mapping. :raises AnsibleFilterError: an argument is not a mapping, ``config`` has a table the role does not render or a value of a kind its table does not take, a variable is in both ``config['env']`` and ``env_home_relative``, a path is absolute, empty or has a ``..`` component, or there is nothing to render. """ if not isinstance(config, dict): raise AnsibleFilterError(f"The mise configuration must be a mapping, not {type(config).__name__}.") unknown = [name for name in config if name not in MISE_TABLES] if unknown: - raise AnsibleFilterError(f"The mise configuration has the tables {', '.join(map(repr, unknown))}; the role renders only {' and '.join(MISE_TABLES)}.") - blocks = [lines for name in MISE_TABLES if name in config and (lines := _table(name, config[name]))] + raise AnsibleFilterError(f"The mise configuration has the tables {', '.join(map(repr, unknown))}; the role renders only {', '.join(MISE_TABLES)}.") + tables = {name: _table(name, config[name]) for name in MISE_TABLES if name in config and name != "env"} + tables["env"] = _env_table(config.get("env", {}), env_home_relative) + blocks = [tables[name] for name in MISE_TABLES if tables.get(name)] if not blocks: raise AnsibleFilterError("The mise configuration has no entry in any table, so there is nothing to render.") return "\n\n".join("\n".join(lines) for lines in blocks) + "\n" diff --git a/roles/github_runner_host_tools/README.md b/roles/github_runner_host_tools/README.md index eacf600..c712583 100644 --- a/roles/github_runner_host_tools/README.md +++ b/roles/github_runner_host_tools/README.md @@ -44,10 +44,11 @@ A host whose startup files already do this, set up by hand or by a dotfiles mana ## Global mise defaults -`github_runner_host_tools_mise_config` is a mapping that the role renders to `~/.config/mise/conf.d/00-fleet.toml`. It is empty by default, and empty it writes nothing and leaves an existing `00-fleet.toml` alone. It is never written to `~/.config/mise/config.toml`: that file is the host's own and takes precedence over `conf.d`, so the role neither writes nor deletes it, and a host's own choice of a tool version wins over the fleet's default. Two tables are understood: +`github_runner_host_tools_mise_config` is a mapping that the role renders to `~/.config/mise/conf.d/00-fleet.toml`. It is empty by default, and empty it writes nothing and leaves an existing `00-fleet.toml` alone. It is never written to `~/.config/mise/config.toml`: that file is the host's own and takes precedence over `conf.d`, so the role neither writes nor deletes it, and a host's own choice of a tool version wins over the fleet's default. Three tables are understood: - `tools`: a tool name to a version string or a list of them. A name that is not a bare TOML key, such as `npm:typescript`, is quoted in the file. - `settings`: a setting name to a string, a number, a boolean or a list of those. +- `env`: a variable name to a string, a number or a boolean, which mise exports with exactly that value. ```yaml github_runner_host_tools_mise_config: @@ -56,6 +57,8 @@ github_runner_host_tools_mise_config: python: ["3.13", "3.12"] settings: experimental: true + env: + RUST_BACKTRACE: 1 ``` renders to @@ -67,8 +70,31 @@ python = ["3.13", "3.12"] [settings] experimental = true + +[env] +RUST_BACKTRACE = "1" +``` + +mise renders each `env` value as a template and then expands `$VAR` and `${VAR}` in the result, with `$$` as a literal dollar sign ([mise, Environments](https://mise.jdx.dev/environments/); the `$$` escape is tested in [mise's `e2e/env/test_env_shell_expand`](https://github.com/jdx/mise/blob/main/e2e/env/test_env_shell_expand)). So the role writes each value so that it reaches the variable exactly as given: a `$` is doubled, and a brace that would start a template tag, expression or comment (`{{`, `{%`, `{#`) is written as the template expression that renders as that brace. A number or a boolean is exported as its text (`true`, `false`, `3`) and written as a string. A variable name must be one a shell accepts, and a value must be one line. A value with braces in it that comes from an inventory is templated by Ansible before the role sees it, so write it with the `!unsafe` tag there (`EXAMPLE: !unsafe "{{ literal }}"`). + +### Paths below the home directory + +`github_runner_host_tools_mise_env_home_relative` is a mapping of variable names to paths relative to the connecting user's home directory, written into the same `env` table. The play runs with `gather_facts: false`, so a fleet cannot name the home directory, and a plain `env` value cannot hold one: + +```yaml +github_runner_host_tools_mise_env_home_relative: + TURBO_CACHE_DIR: .cache/turbo +``` + +renders to + +```toml +[env] +TURBO_CACHE_DIR = "{{ env.HOME }}/.cache/turbo" ``` +so mise works the home directory out when it reads the file, whichever user and home the host has. The path reaches the variable exactly as given (the same escaping as for a plain value, applied to the path). The rules are those of `github_runner_host_tools_shell_env_home_relative`, whose validation the role shares: a path that is empty, absolute, has a `..` component, is not a string or holds a newline or a NUL fails the run before anything is written, and a variable that is both a plain `env` value and here fails it too. With `github_runner_host_tools_mise_config` empty, this mapping alone makes the role write the file. A fleet that wants one value in the shell's startup file and in mise gives it to both `github_runner_host_tools_shell_env_home_relative` and this mapping: the shell file serves commands run by a shell, and mise's table serves commands run by `mise exec`, `mise run` and shims. + 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 @@ -163,6 +189,7 @@ The variables are written whether or not `github_runner_host_tools_shell_integra - `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_mise_env_home_relative` (default an empty mapping): variables for mise's `env` table whose value is a path below the user's home directory; see Paths below the home directory 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`. @@ -202,8 +229,10 @@ github_runner_host_tools_shell_env: UV_PYTHON_DOWNLOADS: never github_runner_host_tools_shell_env_home_relative: TURBO_CACHE_DIR: .cache/turbo +github_runner_host_tools_mise_env_home_relative: + TURBO_CACHE_DIR: .cache/turbo ``` ## 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. 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. The bash cases run against `tests/host_tools/ubuntu-26.04-skel.bashrc`, a byte-for-byte copy of Ubuntu's default `/etc/skel/.bashrc` (its checksum is asserted), and read the result with a real non-interactive `bash`, which sees a variable only when the block is above the interactive guard; they cover a block an earlier version appended below the guard being moved to the top without a duplicate, a second run and `--check` changing nothing, and a variable the file assigns already being kept. The home-relative variables are read back through real `sh`, `bash` and `zsh` with a home directory whose name has a space, a quote and a dollar sign, and a path with every character special in double quotes, to show that the home directory is expanded when the file is read and nothing else is. 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. The mise `env` table is read back with a real `mise env`, which has to be on `PATH` (the CI job installs it), for values with `$`, template braces, quotes and a home directory whose name has a space, a quote and a dollar sign. 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. The bash cases run against `tests/host_tools/ubuntu-26.04-skel.bashrc`, a byte-for-byte copy of Ubuntu's default `/etc/skel/.bashrc` (its checksum is asserted), and read the result with a real non-interactive `bash`, which sees a variable only when the block is above the interactive guard; they cover a block an earlier version appended below the guard being moved to the top without a duplicate, a second run and `--check` changing nothing, and a variable the file assigns already being kept. The home-relative variables are read back through real `sh`, `bash` and `zsh` with a home directory whose name has a space, a quote and a dollar sign, and a path with every character special in double quotes, to show that the home directory is expanded when the file is read and nothing else is. 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 381c946..f91955c 100644 --- a/roles/github_runner_host_tools/defaults/main.yml +++ b/roles/github_runner_host_tools/defaults/main.yml @@ -12,8 +12,10 @@ github_runner_host_tools_shell_integration: true github_runner_host_tools_homebrew_install_commit: 8c82c081753d4263da0cfe615ef8d9433425f22b github_runner_host_tools_homebrew_install_sha256: 5f333bbe53bc490e51e7ccb1df8779b3dd6ee73a1a7379efda216edb08ccb148 -# 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}}. +# 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. Three tables are understood: tools (a tool name, quoted in the file if it needs it, to a version string or a list of them), settings (a setting name to a string, number, boolean or a list of those) and env (a variable name to a string, number or boolean, which mise exports with exactly that value: a dollar sign, which mise would otherwise expand, and a brace that would start one of its templates are escaped). For example {tools: {node: "22", python: ["3.13", "3.12"]}, settings: {experimental: true}, env: {RUST_BACKTRACE: 1}}. github_runner_host_tools_mise_config: {} +# Variables for mise's env table whose value is a path below the connecting user's home directory, as a mapping of names to paths relative to it, for example {TURBO_CACHE_DIR: .cache/turbo}. The play runs without gathering facts, so a fleet cannot name the home directory itself; each is written as mise's template for the home directory followed by the path, so mise works it out when it reads the file. The same rules as github_runner_host_tools_shell_env_home_relative: a path that is absolute, empty or has a .. component fails the run, and a name may not also be in the env table of github_runner_host_tools_mise_config. Empty writes nothing by itself. +github_runner_host_tools_mise_env_home_relative: {} # 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. diff --git a/roles/github_runner_host_tools/meta/argument_specs.yml b/roles/github_runner_host_tools/meta/argument_specs.yml index cea146e..61fe655 100644 --- a/roles/github_runner_host_tools/meta/argument_specs.yml +++ b/roles/github_runner_host_tools/meta/argument_specs.yml @@ -29,7 +29,11 @@ argument_specs: github_runner_host_tools_mise_config: 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. + description: Global mise defaults, as a mapping with the tables tools, settings and env. Empty writes nothing; otherwise it is rendered to ~/.config/mise/conf.d/00-fleet.toml. Values of the env table reach mise's exported variables exactly as given. + github_runner_host_tools_mise_env_home_relative: + type: dict + default: {} + description: Variables for mise's env table whose value is a path below the connecting user's home directory, as a mapping of names to paths relative to it, written as mise's template for the home directory followed by the path. An absolute path, an empty one and one with a .. component are refused, and a name may not also be in the env table of github_runner_host_tools_mise_config. github_runner_host_tools_npm_settings: type: dict default: {} diff --git a/roles/github_runner_host_tools/tasks/mise_config.yml b/roles/github_runner_host_tools/tasks/mise_config.yml index a745f8e..f4d91bb 100644 --- a/roles/github_runner_host_tools/tasks/mise_config.yml +++ b/roles/github_runner_host_tools/tasks/mise_config.yml @@ -1,5 +1,5 @@ --- -# Writes the global mise defaults to conf.d, never to config.toml: that file is the host's own and takes precedence over conf.d, so the role neither writes nor deletes it. Only reached when there is something to write; an empty github_runner_host_tools_mise_config leaves any existing file alone. +# Writes the global mise defaults to conf.d, never to config.toml: that file is the host's own and takes precedence over conf.d, so the role neither writes nor deletes it. Only reached when there is something to write; with github_runner_host_tools_mise_config and github_runner_host_tools_mise_env_home_relative both empty, any existing file is left alone. The content is a filter's output, which Ansible does not template again, so the braces of mise's own templates in it reach the file as they are. - name: Look for the conf.d directory ansible.builtin.stat: @@ -16,7 +16,7 @@ - name: Write the defaults # 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: "# Managed by Ansible (exadev.github_runner.github_runner_host_tools). Edits to this file are overwritten.\n{{ github_runner_host_tools_mise_config | exadev.github_runner.host_tools_mise_toml }}" + content: "# Managed by Ansible (exadev.github_runner.github_runner_host_tools). Edits to this file are overwritten.\n{{ github_runner_host_tools_mise_config | exadev.github_runner.host_tools_mise_toml(github_runner_host_tools_mise_env_home_relative) }}" dest: "{{ ansible_facts['user_dir'] }}/.config/mise/conf.d/00-fleet.toml" mode: "0644" when: github_runner_host_tools_conf_dir.stat.exists or not ansible_check_mode diff --git a/roles/github_runner_host_tools/tasks/tools.yml b/roles/github_runner_host_tools/tasks/tools.yml index c5c1ae1..3b1d92c 100644 --- a/roles/github_runner_host_tools/tasks/tools.yml +++ b/roles/github_runner_host_tools/tasks/tools.yml @@ -37,4 +37,4 @@ ansible.builtin.include_tasks: mise_config.yml when: - github_runner_host_tools_mise | bool - - github_runner_host_tools_mise_config | length > 0 + - github_runner_host_tools_mise_config | length > 0 or github_runner_host_tools_mise_env_home_relative | length > 0 diff --git a/tests/unit/mise_reader.py b/tests/unit/mise_reader.py new file mode 100644 index 0000000..0f466bd --- /dev/null +++ b/tests/unit/mise_reader.py @@ -0,0 +1,41 @@ +"""Reads a mise global configuration back with a real mise, for the tests of the github_runner_host_tools role (test_host_tools.py and test_host_tools_filters.py): what mise exports for the files in a home directory's conf.d, with everything mise reads kept inside that directory.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +MISE = shutil.which("mise") + + +def mise_reads(home: Path) -> tuple[dict[str, str], str]: + """Return the variables a real mise exports for the configuration files under ``home``/.config/mise, and what it printed to stderr. + + The home directory is the one a template such as ``{{ env.HOME }}`` works out, and the data, cache and state directories are inside it, so the result depends on nothing of the machine running the test. + + :raises AssertionError: mise is not on PATH, which a test that reads a file back with it needs. + """ + assert MISE is not None, "mise is needed to read the rendered file back; install it (mise, Homebrew or the release tarball) and put it on PATH" + env = { + "PATH": f"{Path(MISE).parent}{os.pathsep}/usr/bin:/bin", + "HOME": str(home), + "MISE_TRUSTED_CONFIG_PATHS": str(home), + "MISE_DATA_DIR": str(home / "data"), + "MISE_CACHE_DIR": str(home / "cache"), + "MISE_STATE_DIR": str(home / "state"), + } + result = subprocess.run([MISE, "env", "--json"], capture_output=True, text=True, check=True, cwd=home, env=env) + exported = json.loads(result.stdout) + assert isinstance(exported, dict) + return exported, result.stderr + + +def mise_env(toml: str, home: Path) -> tuple[dict[str, str], str]: + """Write ``toml`` as ``home``/.config/mise/conf.d/00-fleet.toml and return what ``mise_reads`` returns for ``home``.""" + conf = home / ".config" / "mise" / "conf.d" + conf.mkdir(parents=True) + (conf / "00-fleet.toml").write_text(toml) + return mise_reads(home) diff --git a/tests/unit/test_host_tools.py b/tests/unit/test_host_tools.py index eb3f474..4e9f950 100644 --- a/tests/unit/test_host_tools.py +++ b/tests/unit/test_host_tools.py @@ -19,6 +19,7 @@ from typing import Any import yaml +from mise_reader import mise_reads ROOT = Path(__file__).resolve().parents[2] ROLE = ROOT / "roles" / "github_runner_host_tools" @@ -152,6 +153,17 @@ def need_root(): TURBO_BLOCK = "# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools env-TURBO_CACHE_DIR\nexport TURBO_CACHE_DIR=\"$HOME/.cache/turbo\"\n# END ANSIBLE MANAGED BLOCK github_runner_host_tools env-TURBO_CACHE_DIR\n" +class Unsafe(str): + """A string written to the test inventory with the ``!unsafe`` tag, so that Ansible does not template the braces in it before the role sees it, as a fleet's inventory has to mark such a value.""" + + +class InventoryDumper(yaml.SafeDumper): + """Writes an Unsafe string as ``!unsafe``.""" + + +InventoryDumper.add_representer(Unsafe, lambda dumper, value: dumper.represent_scalar("!unsafe", str(value))) + + class QuietHandler(http.server.SimpleHTTPRequestHandler): """Serves a directory without writing a line to the test output for every request.""" @@ -193,6 +205,7 @@ def test_the_tools_and_the_shell_integration_are_on_and_the_mise_defaults_are_em for name in ("homebrew", "mise", "shell_integration"): self.assertIs(DEFAULTS[f"github_runner_host_tools_{name}"], True) self.assertEqual(DEFAULTS["github_runner_host_tools_mise_config"], {}) + self.assertEqual(DEFAULTS["github_runner_host_tools_mise_env_home_relative"], {}) self.assertEqual(DEFAULTS["github_runner_host_tools_homebrew_prefix"], "") def test_the_installer_is_pinned_to_a_commit_and_a_checksum_of_the_right_shape(self) -> None: @@ -398,7 +411,7 @@ def run_role(self, *extra: str, system: str = "Darwin", machine: str = "arm64", **variables, } inventory = self.work / "inventory.yml" - inventory.write_text(yaml.safe_dump({"all": {"hosts": {"node1": host}}})) + inventory.write_text(yaml.dump({"all": {"hosts": {"node1": host}}}, Dumper=InventoryDumper)) env = { **os.environ, "ANSIBLE_COLLECTIONS_PATH": str(ROOT / "playbooks" / "collections"), @@ -729,9 +742,84 @@ def test_check_mode_reports_the_mise_configuration_without_a_directory_to_write_ def test_a_mise_configuration_the_filter_cannot_render_fails_the_run(self) -> None: self.have_homebrew() - result = self.run_role(github_runner_host_tools_shell_integration=False, github_runner_host_tools_mise_config={"env": {"A": "b"}}) + result = self.run_role(github_runner_host_tools_shell_integration=False, github_runner_host_tools_mise_config={"plugins": {"A": "b"}}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("renders only tools, settings, env", self.squashed(result)) + self.assertFalse(self.fleet_file().exists()) + + # The test inventory is written with sorted keys, so the paths come out in that order. + def test_the_env_table_and_home_relative_paths_are_written_untouched_by_ansibles_templating_and_a_second_run_changes_nothing(self) -> None: + self.have_homebrew() + (self.stub / "mise.state").write_text("current") + variables = { + "github_runner_host_tools_shell_integration": False, + "github_runner_host_tools_mise_config": {"env": {"EXAMPLE_FLAG": True, "EXAMPLE_TEXT": Unsafe("a $HOME {{ env.HOME }} {% raw %}")}}, + "github_runner_host_tools_mise_env_home_relative": {"TURBO_CACHE_DIR": ".cache/turbo", "EXAMPLE_TEMPLATED": Unsafe("{{ x }}/$HOME")}, + } + self.ok(self.run_role(**variables)) + written = self.fleet_file().read_text() + self.assertEqual( + written, + '# Managed by Ansible (exadev.github_runner.github_runner_host_tools). Edits to this file are overwritten.\n[env]\nEXAMPLE_FLAG = "true"\nEXAMPLE_TEXT = "a $$HOME {{ \'{\' }}{ env.HOME }} {{ \'{\' }}% raw %}"\nEXAMPLE_TEMPLATED = "{{ env.HOME }}/{{ \'{\' }}{ x }}/$$HOME"\nTURBO_CACHE_DIR = "{{ env.HOME }}/.cache/turbo"\n', + ) + result = self.run_role(**variables) + self.ok(result) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + self.assertEqual(self.fleet_file().read_text(), written) + + def test_mise_reads_the_file_the_role_wrote_back_with_the_home_directory_it_runs_in(self) -> None: + self.have_homebrew() + (self.stub / "mise.state").write_text("current") + self.ok(self.run_role(github_runner_host_tools_shell_integration=False, github_runner_host_tools_mise_config={"env": {"EXAMPLE_TEXT": Unsafe("a $HOME {{ env.HOME }}")}}, github_runner_host_tools_mise_env_home_relative={"TURBO_CACHE_DIR": ".cache/turbo"})) + elsewhere = self.work / "another user's home" + (elsewhere / ".config" / "mise").mkdir(parents=True) + shutil.copytree(self.fleet_file().parent, elsewhere / ".config" / "mise" / "conf.d") + exported, _ = mise_reads(elsewhere) + self.assertEqual(exported["TURBO_CACHE_DIR"], f"{elsewhere}/.cache/turbo") + self.assertEqual(exported["EXAMPLE_TEXT"], "a $HOME {{ env.HOME }}") + + def test_home_relative_paths_alone_are_enough_to_write_the_file(self) -> None: + self.have_homebrew() + (self.stub / "mise.state").write_text("current") + self.ok(self.run_role(github_runner_host_tools_shell_integration=False, github_runner_host_tools_mise_env_home_relative={"TURBO_CACHE_DIR": ".cache/turbo"})) + self.assertEqual(self.fleet_file().read_text().splitlines()[1:], ["[env]", 'TURBO_CACHE_DIR = "{{ env.HOME }}/.cache/turbo"']) + + def test_a_changed_home_relative_path_rewrites_the_file(self) -> None: + self.have_homebrew() + (self.stub / "mise.state").write_text("current") + self.ok(self.run_role(github_runner_host_tools_shell_integration=False, github_runner_host_tools_mise_env_home_relative={"TURBO_CACHE_DIR": ".cache/old"})) + result = self.run_role(github_runner_host_tools_shell_integration=False, github_runner_host_tools_mise_env_home_relative={"TURBO_CACHE_DIR": ".cache/turbo"}) + self.ok(result) + self.assertGreaterEqual(self.recap(result)["changed"], 1, result.stdout) + self.assertIn(".cache/turbo", self.fleet_file().read_text()) + + def test_check_mode_reports_a_home_relative_path_without_a_directory_to_write_it_in(self) -> None: + self.have_homebrew() + result = self.run_role("--check", github_runner_host_tools_shell_integration=False, github_runner_host_tools_mise_env_home_relative={"TURBO_CACHE_DIR": ".cache/turbo"}) + self.ok(result) + self.assertFalse((self.home / ".config").exists()) + self.assertIn("00-fleet.toml would be written", result.stdout) + + def test_a_home_relative_path_that_is_not_below_the_home_directory_fails_the_run_and_writes_nothing(self) -> None: + self.have_homebrew() + cases = {"absolute": ("/var/cache/turbo", "relative to the user's home"), "parent": ("../turbo", "'..' component"), "empty": ("", "must not be empty")} + for label, (path, message) in cases.items(): + with self.subTest(label): + result = self.run_role(github_runner_host_tools_shell_integration=False, github_runner_host_tools_mise_env_home_relative={"TURBO_CACHE_DIR": path}) + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, self.squashed(result)) + self.assertFalse(self.fleet_file().exists()) + + def test_a_variable_in_both_the_env_table_and_the_home_relative_mapping_fails_the_run_and_writes_nothing(self) -> None: + self.have_homebrew() + result = self.run_role(github_runner_host_tools_shell_integration=False, github_runner_host_tools_mise_config={"env": {"TURBO_CACHE_DIR": "/x"}}, github_runner_host_tools_mise_env_home_relative={"TURBO_CACHE_DIR": ".cache/turbo"}) self.assertNotEqual(result.returncode, 0) - self.assertIn("renders only tools and settings", self.squashed(result)) + self.assertIn("TURBO_CACHE_DIR is set both", self.squashed(result)) + self.assertFalse(self.fleet_file().exists()) + + def test_the_home_relative_paths_are_not_written_when_mise_is_left_out(self) -> None: + self.have_homebrew() + self.ok(self.run_role(github_runner_host_tools_mise=False, github_runner_host_tools_shell_integration=False, github_runner_host_tools_mise_env_home_relative={"TURBO_CACHE_DIR": ".cache/turbo"})) self.assertFalse(self.fleet_file().exists()) @@ -1302,7 +1390,7 @@ def test_a_path_that_is_not_below_the_home_directory_is_refused_before_any_file_ def test_a_variable_in_both_mappings_is_refused_before_any_file_is_changed(self) -> None: result = self.run_settings(github_runner_host_tools_shell_env={"TURBO_CACHE_DIR": "/x"}, github_runner_host_tools_shell_env_home_relative={"TURBO_CACHE_DIR": ".cache/turbo"}) self.assertNotEqual(result.returncode, 0) - self.assertIn("set in both", self.squashed(result)) + self.assertIn("set both as a plain value", self.squashed(result)) self.assertEqual(list(self.home.iterdir()), []) def test_a_home_relative_mapping_of_the_wrong_type_is_refused_by_the_argument_specs(self) -> None: diff --git a/tests/unit/test_host_tools_filters.py b/tests/unit/test_host_tools_filters.py index 673a43b..ba19c3d 100644 --- a/tests/unit/test_host_tools_filters.py +++ b/tests/unit/test_host_tools_filters.py @@ -4,11 +4,14 @@ import importlib.util import re +import shutil import subprocess +import tempfile import unittest from pathlib import Path import yaml +from mise_reader import mise_env ROOT = Path(__file__).resolve().parents[2] PLUGIN = ROOT / "plugins" / "filter" / "host_tools.py" @@ -78,31 +81,31 @@ class MiseTomlFilterTest(unittest.TestCase): def test_tools_and_settings_render_as_two_tables(self) -> None: config = {"tools": {"node": "22", "python": ["3.13", "3.12"]}, "settings": {"experimental": True, "jobs": 4, "ratio": 0.5, "http_timeout": "30s"}} self.assertEqual( - host_tools.host_tools_mise_toml(config), + host_tools.host_tools_mise_toml(config, {}), '[tools]\nnode = "22"\npython = ["3.13", "3.12"]\n\n[settings]\nexperimental = true\njobs = 4\nratio = 0.5\nhttp_timeout = "30s"\n', ) def test_the_tables_come_out_in_one_order_whatever_the_mapping_has(self) -> None: - self.assertEqual(host_tools.host_tools_mise_toml({"settings": {"experimental": False}, "tools": {"node": "lts"}}), '[tools]\nnode = "lts"\n\n[settings]\nexperimental = false\n') + self.assertEqual(host_tools.host_tools_mise_toml({"settings": {"experimental": False}, "tools": {"node": "lts"}}, {}), '[tools]\nnode = "lts"\n\n[settings]\nexperimental = false\n') def test_a_key_that_is_not_bare_is_quoted(self) -> None: - self.assertEqual(host_tools.host_tools_mise_toml({"tools": {"npm:typescript": "latest", "cargo:ripgrep": ["14"]}}), '[tools]\n"npm:typescript" = "latest"\n"cargo:ripgrep" = ["14"]\n') + self.assertEqual(host_tools.host_tools_mise_toml({"tools": {"npm:typescript": "latest", "cargo:ripgrep": ["14"]}}, {}), '[tools]\n"npm:typescript" = "latest"\n"cargo:ripgrep" = ["14"]\n') def test_strings_are_escaped(self) -> None: - self.assertEqual(host_tools.host_tools_mise_toml({"settings": {"note": 'say "hi"\\\n\t\x01'}}), '[settings]\nnote = "say \\"hi\\"\\\\\\n\\t\\u0001"\n') + self.assertEqual(host_tools.host_tools_mise_toml({"settings": {"note": 'say "hi"\\\n\t\x01'}}, {}), '[settings]\nnote = "say \\"hi\\"\\\\\\n\\t\\u0001"\n') def test_a_setting_can_be_a_list_of_scalars(self) -> None: - self.assertEqual(host_tools.host_tools_mise_toml({"settings": {"disable_tools": ["node", "python"], "levels": [1, 2]}}), '[settings]\ndisable_tools = ["node", "python"]\nlevels = [1, 2]\n') + self.assertEqual(host_tools.host_tools_mise_toml({"settings": {"disable_tools": ["node", "python"], "levels": [1, 2]}}, {}), '[settings]\ndisable_tools = ["node", "python"]\nlevels = [1, 2]\n') def test_an_empty_table_is_left_out(self) -> None: - self.assertEqual(host_tools.host_tools_mise_toml({"tools": {}, "settings": {"experimental": True}}), "[settings]\nexperimental = true\n") + self.assertEqual(host_tools.host_tools_mise_toml({"tools": {}, "settings": {"experimental": True}}, {}), "[settings]\nexperimental = true\n") def test_a_boolean_is_not_written_as_a_number(self) -> None: - self.assertEqual(host_tools.host_tools_mise_toml({"settings": {"on": True, "one": 1}}), "[settings]\non = true\none = 1\n") + self.assertEqual(host_tools.host_tools_mise_toml({"settings": {"on": True, "one": 1}}, {}), "[settings]\non = true\none = 1\n") def test_what_it_cannot_render_is_refused(self) -> None: cases = { - "a table the role does not render": ({"env": {"A": "b"}}, "env"), + "a table the role does not render": ({"plugins": {"A": "b"}}, "renders only tools, settings, env"), "nothing to render": ({"tools": {}}, "nothing to render"), "not a mapping": (["node"], "must be a mapping"), "a tool given a number": ({"tools": {"node": 22}}, "tools.node"), @@ -114,7 +117,84 @@ def test_what_it_cannot_render_is_refused(self) -> None: } for label, (config, message) in cases.items(): with self.subTest(label), self.assertRaisesRegex(Exception, message): - host_tools.host_tools_mise_toml(config) + host_tools.host_tools_mise_toml(config, {}) + + +class MiseEnvFilterTest(unittest.TestCase): + def read_back(self, env: dict[str, object], home_relative: dict[str, str], home: str = "home") -> tuple[dict[str, str], str]: + root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, root) + return mise_env(host_tools.host_tools_mise_toml({"env": env}, home_relative), root / home) + + def test_the_env_table_is_written_last_with_plain_values_then_home_relative_paths(self) -> None: + self.assertEqual( + host_tools.host_tools_mise_toml({"env": {"B": "x", "A": True, "N": 3, "F": 0.5}, "tools": {"node": "22"}}, {"TURBO_CACHE_DIR": ".cache/turbo"}), + '[tools]\nnode = "22"\n\n[env]\nB = "x"\nA = "true"\nN = "3"\nF = "0.5"\nTURBO_CACHE_DIR = "{{ env.HOME }}/.cache/turbo"\n', + ) + + def test_home_relative_paths_alone_are_enough_to_render(self) -> None: + self.assertEqual(host_tools.host_tools_mise_toml({}, {"TURBO_CACHE_DIR": ".cache/turbo"}), '[env]\nTURBO_CACHE_DIR = "{{ env.HOME }}/.cache/turbo"\n') + + def test_an_empty_env_table_is_left_out(self) -> None: + self.assertEqual(host_tools.host_tools_mise_toml({"env": {}, "tools": {"node": "22"}}, {}), '[tools]\nnode = "22"\n') + + def test_a_dollar_sign_and_a_template_brace_are_escaped_for_mise_and_the_string_for_toml(self) -> None: + self.assertEqual(host_tools.host_tools_mise_toml({"env": {"A": 'a$b "c" {{d}} {%e%} {#f#} {g}'}}, {}), "[env]\nA = \"a$$b \\\"c\\\" {{ '{' }}{d}} {{ '{' }}%e%} {{ '{' }}#f#} {g}\"\n") + + def test_a_home_relative_variable_is_expanded_by_mise_to_the_home_directory_it_reads_the_file_in(self) -> None: + for home in ("home", "a home", "it's", 'say "hi"', "$d"): + with self.subTest(home=home): + exported, _ = self.read_back({}, {"TURBO_CACHE_DIR": ".cache/turbo"}, home) + self.assertTrue(exported["TURBO_CACHE_DIR"].endswith(f"/{home}/.cache/turbo"), exported) + + def test_a_value_reaches_the_variable_exactly_as_given_whatever_it_holds(self) -> None: + values = ["$HOME", "${HOME}", "$$", "a$", "$1", "${X:-y}", "$(id)", "`id`", "~", "~/x", "{{ env.HOME }}", "{{", "{%", "{#", "{", "}}", "{{x}}", "{% raw %}", "{% endraw %}", "{{ '{' }}", "a{b}c", "{ {", "$ {", "${{x}}", "it's", 'say "hi"', "back\\slash", "tab\there", "a b c", "é", "", "-n", "true", "3", "*"] + exported, stderr = self.read_back({f"V{index}": value for index, value in enumerate(values)}, {}) + for index, value in enumerate(values): + with self.subTest(value=value): + self.assertEqual(exported[f"V{index}"], value) + self.assertNotIn("not defined", stderr) + + def test_a_number_and_a_boolean_are_exported_as_their_text(self) -> None: + exported, _ = self.read_back({"A": True, "B": False, "C": 3, "D": 0.5}, {}) + self.assertEqual({key: exported[key] for key in "ABCD"}, {"A": "true", "B": "false", "C": "3", "D": "0.5"}) + + def test_a_path_reaches_the_variable_exactly_as_given_after_the_home_directory(self) -> None: + paths = [".cache/turbo", "a b c", "it's", 'say "hi"', "$HOME", "${HOME}", "`id`", "$(id)", "back\\slash", "\\$", "tab\there", "*", "; echo injected", "-n", "~", "~/x", "a..b", "..x", "x..", "dir/", "./x", "é", "{{ env.HOME }}", "{{x}}", "{%x%}", "{#x#}", "{", "{{", "}}", "{% endraw %}", "$$", "a$"] + exported, stderr = self.read_back({}, {f"P{index}": path for index, path in enumerate(paths)}, "the home") + for index, path in enumerate(paths): + with self.subTest(path=path): + self.assertTrue(exported[f"P{index}"].endswith(f"/the home/{path}"), exported[f"P{index}"]) + self.assertEqual(exported[f"P{index}"].count("/the home/"), 1) + self.assertNotIn("not defined", stderr) + + def test_what_mise_would_not_take_as_a_variable_is_refused(self) -> None: + cases = { + "a name with a hyphen": ({"env": {"A-B": "x"}}, {}, "not a name a shell accepts"), + "a name starting with a digit": ({"env": {"1A": "x"}}, {}, "not a name a shell accepts"), + "a name that is not a string": ({"env": {1: "x"}}, {}, "not a name a shell accepts"), + "a home-relative name with a hyphen": ({}, {"A-B": "x"}, "not a name a shell accepts"), + "a list value": ({"env": {"A": ["a"]}}, {}, "must be a string, a number or a boolean"), + "a null value": ({"env": {"A": None}}, {}, "must be a string, a number or a boolean"), + "a newline": ({"env": {"A": "a\nb"}}, {}, "one line"), + "a NUL": ({"env": {"A": "a\x00b"}}, {}, "one line"), + "an env table that is not a mapping": ({"env": ["A"]}, {}, "env must be a mapping"), + "a home-relative mapping that is not a mapping": ({}, [".cache"], "home-relative env must be a mapping"), + "an absolute path": ({}, {"A": "/var/cache"}, "relative to the user's home"), + "an empty path": ({}, {"A": ""}, "must not be empty"), + "a parent component": ({}, {"A": "a/../b"}, "'..' component"), + "only a parent component": ({}, {"A": ".."}, "'..' component"), + "a number as a path": ({}, {"A": 3}, "must be a string"), + "a newline in a path": ({}, {"A": "a\nb"}, "one line"), + "a name in both": ({"env": {"A": "x"}}, {"A": ".cache"}, "A is set both"), + } + for label, (config, home_relative, message) in cases.items(): + with self.subTest(label), self.assertRaisesRegex(Exception, message): + host_tools.host_tools_mise_toml(config, home_relative) + + def test_nothing_in_either_mapping_is_nothing_to_render(self) -> None: + with self.assertRaisesRegex(Exception, "nothing to render"): + host_tools.host_tools_mise_toml({"env": {}}, {}) def grep_finds(pattern: str, text: str) -> bool: @@ -245,7 +325,7 @@ def test_a_mapping_that_is_not_one_is_refused(self) -> None: host_tools.host_tools_shell_env({}, [".cache"]) def test_a_variable_in_both_mappings_is_refused_and_named(self) -> None: - with self.assertRaisesRegex(Exception, "A is set in both"): + with self.assertRaisesRegex(Exception, "A is set both"): host_tools.host_tools_shell_env({"A": "x"}, {"A": "y"})